github.com/wmuizelaar/kpt@v0.0.0-20221018115725-bd564717b2ed/internal/fnruntime/runner.go (about)

     1  // Copyright 2021 Google LLC
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package fnruntime
    16  
    17  import (
    18  	"context"
    19  	goerrors "errors"
    20  	"fmt"
    21  	"io"
    22  	"os"
    23  	"path"
    24  	"path/filepath"
    25  	"strings"
    26  	"time"
    27  
    28  	"github.com/GoogleContainerTools/kpt/internal/builtins"
    29  	"github.com/GoogleContainerTools/kpt/internal/errors"
    30  	"github.com/GoogleContainerTools/kpt/internal/pkg"
    31  	"github.com/GoogleContainerTools/kpt/internal/printer"
    32  	"github.com/GoogleContainerTools/kpt/internal/types"
    33  	fnresult "github.com/GoogleContainerTools/kpt/pkg/api/fnresult/v1"
    34  	kptfilev1 "github.com/GoogleContainerTools/kpt/pkg/api/kptfile/v1"
    35  	"github.com/GoogleContainerTools/kpt/pkg/fn"
    36  	"github.com/google/shlex"
    37  	"sigs.k8s.io/kustomize/kyaml/filesys"
    38  	"sigs.k8s.io/kustomize/kyaml/fn/framework"
    39  	"sigs.k8s.io/kustomize/kyaml/fn/runtime/runtimeutil"
    40  	"sigs.k8s.io/kustomize/kyaml/kio/kioutil"
    41  	"sigs.k8s.io/kustomize/kyaml/yaml"
    42  )
    43  
    44  const (
    45  	FuncGenPkgContext = "builtins/gen-pkg-context"
    46  )
    47  
    48  type RunnerOptions struct {
    49  	// ImagePullPolicy controls the image pulling behavior before running the container.
    50  	ImagePullPolicy ImagePullPolicy
    51  
    52  	// when set to true, function runner will set the package path annotation
    53  	// on resources that do not have it set. The resources generated by
    54  	// functions do not have this annotation set.
    55  	SetPkgPathAnnotation bool
    56  
    57  	DisplayResourceCount bool
    58  
    59  	// allowExec determines if function binary executable are allowed
    60  	// to be run during pipeline execution. Running function binaries is a
    61  	// privileged operation, so explicit permission is required.
    62  	AllowExec bool
    63  
    64  	// allowWasm determines if function wasm are allowed to be run during pipeline
    65  	// execution. Running wasm function is an alpha feature, so it needs to be
    66  	// enabled explicitly.
    67  	AllowWasm bool
    68  
    69  	// ResolveToImage will resolve a partial image to a fully-qualified one
    70  	ResolveToImage ImageResolveFunc
    71  }
    72  
    73  // ImageResolveFunc is the type for a function that can resolve a partial image to a (more) fully-qualified name
    74  type ImageResolveFunc func(ctx context.Context, image string) (string, error)
    75  
    76  func (o *RunnerOptions) InitDefaults() {
    77  	o.ImagePullPolicy = IfNotPresentPull
    78  	o.ResolveToImage = ResolveToImageForCLI
    79  }
    80  
    81  // NewRunner returns a FunctionRunner given a specification of a function
    82  // and it's config.
    83  func NewRunner(
    84  	ctx context.Context,
    85  	fsys filesys.FileSystem,
    86  	f *kptfilev1.Function,
    87  	pkgPath types.UniquePath,
    88  	fnResults *fnresult.ResultList,
    89  	opts RunnerOptions,
    90  	runtime fn.FunctionRuntime,
    91  ) (*FunctionRunner, error) {
    92  	config, err := newFnConfig(fsys, f, pkgPath)
    93  	if err != nil {
    94  		return nil, err
    95  	}
    96  	if f.Image != "" {
    97  		img, err := opts.ResolveToImage(ctx, f.Image)
    98  		if err != nil {
    99  			return nil, err
   100  		}
   101  		f.Image = img
   102  	}
   103  
   104  	fnResult := &fnresult.Result{
   105  		Image:    f.Image,
   106  		ExecPath: f.Exec,
   107  		// TODO(droot): This is required for making structured results subpackage aware.
   108  		// Enable this once test harness supports filepath based assertions.
   109  		// Pkg: string(pkgPath),
   110  	}
   111  
   112  	fltr := &runtimeutil.FunctionFilter{
   113  		FunctionConfig: config,
   114  		// by default, the inner most runtimeutil.FunctionFilter scopes resources to the
   115  		// directory specified by the functionConfig, kpt v1+ doesn't scope resources
   116  		// during function execution, so marking the scope to global.
   117  		// See https://github.com/GoogleContainerTools/kpt/issues/3230 for more details.
   118  		GlobalScope: true,
   119  	}
   120  
   121  	if runtime != nil {
   122  		if runner, err := runtime.GetRunner(ctx, f); err != nil {
   123  			return nil, fmt.Errorf("function runtime failed to evaluate function %q: %w", f.Image, err)
   124  		} else if runner != nil {
   125  			fltr.Run = runner.Run
   126  		}
   127  	}
   128  	if fltr.Run == nil {
   129  		if f.Image == FuncGenPkgContext {
   130  			pkgCtxGenerator := &builtins.PackageContextGenerator{}
   131  			fltr.Run = pkgCtxGenerator.Run
   132  		} else {
   133  			switch {
   134  			case f.Image != "":
   135  				// If allowWasm is true, we will use wasm runtime for image field.
   136  				if opts.AllowWasm {
   137  					wFn, err := NewWasmFn(NewOciLoader(filepath.Join(os.TempDir(), "kpt-fn-wasm"), f.Image))
   138  					if err != nil {
   139  						return nil, err
   140  					}
   141  					fltr.Run = wFn.Run
   142  				} else {
   143  					cfn := &ContainerFn{
   144  						Image:           f.Image,
   145  						ImagePullPolicy: opts.ImagePullPolicy,
   146  						Ctx:             ctx,
   147  						FnResult:        fnResult,
   148  					}
   149  					fltr.Run = cfn.Run
   150  				}
   151  			case f.Exec != "":
   152  				// If AllowWasm is true, we will use wasm runtime for exec field.
   153  				if opts.AllowWasm {
   154  					wFn, err := NewWasmFn(&FsLoader{Filename: f.Exec})
   155  					if err != nil {
   156  						return nil, err
   157  					}
   158  					fltr.Run = wFn.Run
   159  				} else {
   160  					var execArgs []string
   161  					// assuming exec here
   162  					s, err := shlex.Split(f.Exec)
   163  					if err != nil {
   164  						return nil, fmt.Errorf("exec command %q must be valid: %w", f.Exec, err)
   165  					}
   166  					execPath := f.Exec
   167  					if len(s) > 0 {
   168  						execPath = s[0]
   169  					}
   170  					if len(s) > 1 {
   171  						execArgs = s[1:]
   172  					}
   173  					eFn := &ExecFn{
   174  						Path:     execPath,
   175  						Args:     execArgs,
   176  						FnResult: fnResult,
   177  					}
   178  					fltr.Run = eFn.Run
   179  				}
   180  			default:
   181  				return nil, fmt.Errorf("must specify `exec` or `image` to execute a function")
   182  			}
   183  		}
   184  	}
   185  	return NewFunctionRunner(ctx, fltr, pkgPath, fnResult, fnResults, opts)
   186  }
   187  
   188  // NewFunctionRunner returns a FunctionRunner given a specification of a function
   189  // and it's config.
   190  func NewFunctionRunner(ctx context.Context,
   191  	fltr *runtimeutil.FunctionFilter,
   192  	pkgPath types.UniquePath,
   193  	fnResult *fnresult.Result,
   194  	fnResults *fnresult.ResultList,
   195  	opts RunnerOptions) (*FunctionRunner, error) {
   196  	name := fnResult.Image
   197  	if name == "" {
   198  		name = fnResult.ExecPath
   199  	}
   200  	// by default, the inner most runtimeutil.FunctionFilter scopes resources to the
   201  	// directory specified by the functionConfig, kpt v1+ doesn't scope resources
   202  	// during function execution, so marking the scope to global.
   203  	// See https://github.com/GoogleContainerTools/kpt/issues/3230 for more details.
   204  	fltr.GlobalScope = true
   205  	return &FunctionRunner{
   206  		ctx:       ctx,
   207  		name:      name,
   208  		pkgPath:   pkgPath,
   209  		filter:    fltr,
   210  		fnResult:  fnResult,
   211  		fnResults: fnResults,
   212  		opts:      opts,
   213  	}, nil
   214  }
   215  
   216  // FunctionRunner wraps FunctionFilter and implements kio.Filter interface.
   217  type FunctionRunner struct {
   218  	ctx              context.Context
   219  	name             string
   220  	pkgPath          types.UniquePath
   221  	disableCLIOutput bool
   222  	filter           *runtimeutil.FunctionFilter
   223  	fnResult         *fnresult.Result
   224  	fnResults        *fnresult.ResultList
   225  	opts             RunnerOptions
   226  }
   227  
   228  func (fr *FunctionRunner) Filter(input []*yaml.RNode) (output []*yaml.RNode, err error) {
   229  	pr := printer.FromContextOrDie(fr.ctx)
   230  	if !fr.disableCLIOutput {
   231  		if fr.opts.AllowWasm {
   232  			pr.Printf("[RUNNING] WASM %q", fr.name)
   233  		} else {
   234  			pr.Printf("[RUNNING] %q", fr.name)
   235  		}
   236  		if fr.opts.DisplayResourceCount {
   237  			pr.Printf(" on %d resource(s)", len(input))
   238  		}
   239  		pr.Printf("\n")
   240  	}
   241  	t0 := time.Now()
   242  	output, err = fr.do(input)
   243  	if err != nil {
   244  		printOpt := printer.NewOpt()
   245  		pr.OptPrintf(printOpt, "[FAIL] %q in %v\n", fr.name, time.Since(t0).Truncate(time.Millisecond*100))
   246  		printFnResult(fr.ctx, fr.fnResult, printOpt)
   247  		var fnErr *ExecError
   248  		if goerrors.As(err, &fnErr) {
   249  			printFnExecErr(fr.ctx, fnErr)
   250  			return nil, errors.ErrAlreadyHandled
   251  		}
   252  		return nil, err
   253  	}
   254  	if !fr.disableCLIOutput {
   255  		pr.Printf("[PASS] %q in %v\n", fr.name, time.Since(t0).Truncate(time.Millisecond*100))
   256  		printFnResult(fr.ctx, fr.fnResult, printer.NewOpt())
   257  		printFnStderr(fr.ctx, fr.fnResult.Stderr)
   258  	}
   259  	return output, err
   260  }
   261  
   262  // SetFnConfig updates the functionConfig for the FunctionRunner instance.
   263  func (fr *FunctionRunner) SetFnConfig(conf *yaml.RNode) {
   264  	fr.filter.FunctionConfig = conf
   265  }
   266  
   267  // do executes the kpt function and returns the modified resources.
   268  // fnResult is updated with the function results returned by the kpt function.
   269  func (fr *FunctionRunner) do(input []*yaml.RNode) (output []*yaml.RNode, err error) {
   270  	if krmErr := kptfilev1.AreKRM(input); krmErr != nil {
   271  		return output, fmt.Errorf("input resource list must contain only KRM resources: %s", krmErr.Error())
   272  	}
   273  
   274  	fnResult := fr.fnResult
   275  	output, err = fr.filter.Filter(input)
   276  
   277  	if fr.opts.SetPkgPathAnnotation {
   278  		if pkgPathErr := setPkgPathAnnotationIfNotExist(output, fr.pkgPath); pkgPathErr != nil {
   279  			return output, pkgPathErr
   280  		}
   281  	}
   282  	if pathErr := enforcePathInvariants(output); pathErr != nil {
   283  		return output, pathErr
   284  	}
   285  	if krmErr := kptfilev1.AreKRM(output); krmErr != nil {
   286  		return output, fmt.Errorf("output resource list must contain only KRM resources: %s", krmErr.Error())
   287  	}
   288  
   289  	// parse the results irrespective of the success/failure of fn exec
   290  	resultErr := parseStructuredResult(fr.filter.Results, fnResult)
   291  	if resultErr != nil {
   292  		// Not sure if it's a good idea. This may mask the original
   293  		// function exec error. Revisit this if this turns out to be true.
   294  		return output, resultErr
   295  	}
   296  	if err != nil {
   297  		var execErr *ExecError
   298  		if goerrors.As(err, &execErr) {
   299  			fnResult.ExitCode = execErr.ExitCode
   300  			fnResult.Stderr = execErr.Stderr
   301  			fr.fnResults.ExitCode = 1
   302  		}
   303  		// accumulate the results
   304  		fr.fnResults.Items = append(fr.fnResults.Items, *fnResult)
   305  		return output, err
   306  	}
   307  	fnResult.ExitCode = 0
   308  	fr.fnResults.Items = append(fr.fnResults.Items, *fnResult)
   309  	return output, nil
   310  }
   311  
   312  func setPkgPathAnnotationIfNotExist(resources []*yaml.RNode, pkgPath types.UniquePath) error {
   313  	for _, r := range resources {
   314  		currPkgPath, err := pkg.GetPkgPathAnnotation(r)
   315  		if err != nil {
   316  			return err
   317  		}
   318  		if currPkgPath == "" {
   319  			if err = pkg.SetPkgPathAnnotation(r, pkgPath); err != nil {
   320  				return err
   321  			}
   322  		}
   323  	}
   324  	return nil
   325  }
   326  
   327  func parseStructuredResult(yml *yaml.RNode, fnResult *fnresult.Result) error {
   328  	if yml.IsNilOrEmpty() {
   329  		return nil
   330  	}
   331  	// Note: TS SDK and Go SDK implements two different formats for the
   332  	// result. Go SDK wraps result items while TS SDK doesn't. So examine
   333  	// if items are wrapped or not to support both the formats for now.
   334  	// Refer to https://github.com/GoogleContainerTools/kpt/pull/1923#discussion_r628604165
   335  	// for some more details.
   336  	if yml.YNode().Kind == yaml.MappingNode {
   337  		// check if legacy structured result wraps ResultItems
   338  		itemsNode, err := yml.Pipe(yaml.Lookup("items"))
   339  		if err != nil {
   340  			return err
   341  		}
   342  		if !itemsNode.IsNilOrEmpty() {
   343  			// if legacy structured result, uplift the items
   344  			yml = itemsNode
   345  		}
   346  	}
   347  	err := yaml.Unmarshal([]byte(yml.MustString()), &fnResult.Results)
   348  	if err != nil {
   349  		return err
   350  	}
   351  
   352  	return migrateLegacyResult(yml, fnResult)
   353  }
   354  
   355  // migrateLegacyResult populates name and namespace in fnResult.Result if a
   356  // function (e.g. using kyaml Go SDKs) gives results in a schema
   357  // that puts a resourceRef's name and namespace under a metadata field
   358  // TODO: fix upstream (https://github.com/GoogleContainerTools/kpt/issues/2091)
   359  func migrateLegacyResult(yml *yaml.RNode, fnResult *fnresult.Result) error {
   360  	items, err := yml.Elements()
   361  	if err != nil {
   362  		return err
   363  	}
   364  
   365  	for i := range items {
   366  		if err = populateResourceRef(items[i], fnResult.Results[i]); err != nil {
   367  			return err
   368  		}
   369  		if err = populateProposedValue(items[i], fnResult.Results[i]); err != nil {
   370  			return err
   371  		}
   372  	}
   373  
   374  	return nil
   375  }
   376  
   377  func populateProposedValue(item *yaml.RNode, resultItem *framework.Result) error {
   378  	sv, err := item.Pipe(yaml.Lookup("field", "suggestedValue"))
   379  	if err != nil {
   380  		return err
   381  	}
   382  	if sv == nil {
   383  		return nil
   384  	}
   385  	if resultItem.Field == nil {
   386  		resultItem.Field = &framework.Field{}
   387  	}
   388  	resultItem.Field.ProposedValue = sv
   389  	return nil
   390  }
   391  
   392  func populateResourceRef(item *yaml.RNode, resultItem *framework.Result) error {
   393  	r, err := item.Pipe(yaml.Lookup("resourceRef", "metadata"))
   394  	if err != nil {
   395  		return err
   396  	}
   397  	if r == nil {
   398  		return nil
   399  	}
   400  	nameNode, err := r.Pipe(yaml.Lookup("name"))
   401  	if err != nil {
   402  		return err
   403  	}
   404  	namespaceNode, err := r.Pipe(yaml.Lookup("namespace"))
   405  	if err != nil {
   406  		return err
   407  	}
   408  	if nameNode != nil {
   409  		resultItem.ResourceRef.Name = strings.TrimSpace(nameNode.MustString())
   410  	}
   411  	if namespaceNode != nil {
   412  		namespace := strings.TrimSpace(namespaceNode.MustString())
   413  		if namespace != "" && namespace != "''" {
   414  			resultItem.ResourceRef.Namespace = strings.TrimSpace(namespace)
   415  		}
   416  	}
   417  	return nil
   418  }
   419  
   420  // printFnResult prints given function result in a user friendly
   421  // format on kpt CLI.
   422  func printFnResult(ctx context.Context, fnResult *fnresult.Result, opt *printer.Options) {
   423  	pr := printer.FromContextOrDie(ctx)
   424  	if len(fnResult.Results) > 0 {
   425  		// function returned structured results
   426  		var lines []string
   427  		for _, item := range fnResult.Results {
   428  			lines = append(lines, item.String())
   429  		}
   430  		ri := &multiLineFormatter{
   431  			Title:          "Results",
   432  			Lines:          lines,
   433  			TruncateOutput: printer.TruncateOutput,
   434  		}
   435  		pr.OptPrintf(opt, "%s", ri.String())
   436  	}
   437  }
   438  
   439  // printFnExecErr prints given ExecError in a user friendly format
   440  // on kpt CLI.
   441  func printFnExecErr(ctx context.Context, fnErr *ExecError) {
   442  	pr := printer.FromContextOrDie(ctx)
   443  	printFnStderr(ctx, fnErr.Stderr)
   444  	pr.Printf("  Exit code: %d\n\n", fnErr.ExitCode)
   445  }
   446  
   447  // printFnStderr prints given stdErr in a user friendly format on kpt CLI.
   448  func printFnStderr(ctx context.Context, stdErr string) {
   449  	pr := printer.FromContextOrDie(ctx)
   450  	if len(stdErr) > 0 {
   451  		errLines := &multiLineFormatter{
   452  			Title:          "Stderr",
   453  			Lines:          strings.Split(stdErr, "\n"),
   454  			UseQuote:       true,
   455  			TruncateOutput: printer.TruncateOutput,
   456  		}
   457  		pr.Printf("%s", errLines.String())
   458  	}
   459  }
   460  
   461  // path (location) of a KRM resources is tracked in a special key in
   462  // metadata.annotation field. enforcePathInvariants throws an error if there is a path
   463  // to a file outside the package, or if the same index/path is on multiple resources
   464  func enforcePathInvariants(nodes []*yaml.RNode) error {
   465  	// map has structure pkgPath-->path -> index -> bool
   466  	// to keep track of paths and indexes found
   467  	pkgPaths := make(map[string]map[string]map[string]bool)
   468  	for _, node := range nodes {
   469  		pkgPath, err := pkg.GetPkgPathAnnotation(node)
   470  		if err != nil {
   471  			return err
   472  		}
   473  		if pkgPaths[pkgPath] == nil {
   474  			pkgPaths[pkgPath] = make(map[string]map[string]bool)
   475  		}
   476  		currPath, index, err := kioutil.GetFileAnnotations(node)
   477  		if err != nil {
   478  			return err
   479  		}
   480  		fp := path.Clean(currPath)
   481  		if strings.HasPrefix(fp, "../") {
   482  			return fmt.Errorf("function must not modify resources outside of package: resource has path %s", currPath)
   483  		}
   484  		if pkgPaths[pkgPath][fp] == nil {
   485  			pkgPaths[pkgPath][fp] = make(map[string]bool)
   486  		}
   487  		if _, ok := pkgPaths[pkgPath][fp][index]; ok {
   488  			return fmt.Errorf("resource at path %q and index %q already exists", fp, index)
   489  		}
   490  		pkgPaths[pkgPath][fp][index] = true
   491  	}
   492  	return nil
   493  }
   494  
   495  // multiLineFormatter knows how to format multiple lines in pretty format
   496  // that can be displayed to an end user.
   497  type multiLineFormatter struct {
   498  	// Title under which lines need to be printed
   499  	Title string
   500  
   501  	// Lines to be printed on the CLI.
   502  	Lines []string
   503  
   504  	// TruncateOuput determines if output needs to be truncated or not.
   505  	TruncateOutput bool
   506  
   507  	// MaxLines to be printed if truncation is enabled.
   508  	MaxLines int
   509  
   510  	// UseQuote determines if line needs to be quoted or not
   511  	UseQuote bool
   512  }
   513  
   514  // String returns multiline string.
   515  func (ri *multiLineFormatter) String() string {
   516  	if ri.MaxLines == 0 {
   517  		ri.MaxLines = FnExecErrorTruncateLines
   518  	}
   519  	strInterpolator := "%s"
   520  	if ri.UseQuote {
   521  		strInterpolator = "%q"
   522  	}
   523  
   524  	var b strings.Builder
   525  
   526  	b.WriteString(fmt.Sprintf("  %s:\n", ri.Title))
   527  	lineIndent := strings.Repeat(" ", FnExecErrorIndentation+2)
   528  	if !ri.TruncateOutput {
   529  		// stderr string should have indentations
   530  		for _, s := range ri.Lines {
   531  			// suppress newlines to avoid poor formatting
   532  			s = strings.ReplaceAll(s, "\n", " ")
   533  			b.WriteString(fmt.Sprintf(lineIndent+strInterpolator+"\n", s))
   534  		}
   535  		return b.String()
   536  	}
   537  	printedLines := 0
   538  	for i, s := range ri.Lines {
   539  		if i >= ri.MaxLines {
   540  			break
   541  		}
   542  		// suppress newlines to avoid poor formatting
   543  		s = strings.ReplaceAll(s, "\n", " ")
   544  		b.WriteString(fmt.Sprintf(lineIndent+strInterpolator+"\n", s))
   545  		printedLines++
   546  	}
   547  	truncatedLines := len(ri.Lines) - printedLines
   548  	if truncatedLines > 0 {
   549  		b.WriteString(fmt.Sprintf(lineIndent+"...(%d line(s) truncated, use '--truncate-output=false' to disable)\n", truncatedLines))
   550  	}
   551  	return b.String()
   552  }
   553  
   554  func newFnConfig(fsys filesys.FileSystem, f *kptfilev1.Function, pkgPath types.UniquePath) (*yaml.RNode, error) {
   555  	const op errors.Op = "fn.readConfig"
   556  	fn := errors.Fn(f.Image)
   557  
   558  	var node *yaml.RNode
   559  	switch {
   560  	case f.ConfigPath != "":
   561  		path := filepath.Join(string(pkgPath), f.ConfigPath)
   562  		file, err := fsys.Open(path)
   563  		if err != nil {
   564  			return nil, errors.E(op, fn,
   565  				fmt.Errorf("missing function config %q", f.ConfigPath))
   566  		}
   567  		defer file.Close()
   568  		b, err := io.ReadAll(file)
   569  		if err != nil {
   570  			return nil, errors.E(op, fn, err)
   571  		}
   572  		node, err = yaml.Parse(string(b))
   573  		if err != nil {
   574  			return nil, errors.E(op, fn, fmt.Errorf("invalid function config %q %w", f.ConfigPath, err))
   575  		}
   576  		// directly use the config from file
   577  		return node, nil
   578  	case len(f.ConfigMap) != 0:
   579  		configNode, err := NewConfigMap(f.ConfigMap)
   580  		if err != nil {
   581  			return nil, errors.E(op, fn, err)
   582  		}
   583  		return configNode, nil
   584  	}
   585  	// no need to return ConfigMap if no config given
   586  	return nil, nil
   587  }