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