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 }