diff --git a/cmd/config/configcobra/cmds.go b/cmd/config/configcobra/cmds.go index b21c9ea50..878c7d87c 100644 --- a/cmd/config/configcobra/cmds.go +++ b/cmd/config/configcobra/cmds.go @@ -97,6 +97,9 @@ func NewConfigCommand(name string) *cobra.Command { root.AddCommand(commands.Merge3Command(name)) root.AddCommand(commands.CountCommand(name)) root.AddCommand(commands.RunFnCommand(name)) + root.AddCommand(commands.XArgsCommand()) + root.AddCommand(commands.WrapCommand()) + root.AddCommand(commands.SetCommand(name)) root.AddCommand(commands.ListSettersCommand(name)) root.AddCommand(commands.CreateSetterCommand(name)) diff --git a/cmd/config/internal/commands/run-fns.go b/cmd/config/internal/commands/run-fns.go index e5b588fed..290ea7d91 100644 --- a/cmd/config/internal/commands/run-fns.go +++ b/cmd/config/internal/commands/run-fns.go @@ -4,22 +4,27 @@ package commands import ( + "fmt" + "io" + "strings" + "github.com/spf13/cobra" "sigs.k8s.io/kustomize/cmd/config/internal/generateddocs/commands" + "sigs.k8s.io/kustomize/kyaml/errors" "sigs.k8s.io/kustomize/kyaml/runfn" + "sigs.k8s.io/kustomize/kyaml/yaml" ) // GetCatRunner returns a RunFnRunner. func GetRunFnRunner(name string) *RunFnRunner { r := &RunFnRunner{} c := &cobra.Command{ - Use: "run DIR", - Aliases: []string{"run-fns"}, + Use: "run [DIR]", Short: commands.RunFnsShort, Long: commands.RunFnsLong, Example: commands.RunFnsExamples, RunE: r.runE, - Args: cobra.ExactArgs(1), + PreRunE: r.preRunE, } fixDocs(name, c) c.Flags().BoolVar(&r.IncludeSubpackages, "include-subpackages", true, @@ -29,11 +34,12 @@ func GetRunFnRunner(name string) *RunFnRunner { &r.DryRun, "dry-run", false, "print results to stdout") r.Command.Flags().BoolVar( &r.GlobalScope, "global-scope", false, "set global scope for functions.") - r.Command.Flags().StringSliceVar( - &r.FnPaths, "fn-path", []string{}, - "directories containing functions without configuration") - r.Command.AddCommand(XArgsCommand()) - r.Command.AddCommand(WrapCommand()) + r.Command.Flags().StringSliceVarP( + &r.FnPaths, "fn-path", "p", []string{}, + "read functions from these directories instead of the configuration directory.") + r.Command.Flags().StringVarP( + &r.Image, "image", "i", "", + "run this image as a function instead of discovering them.") return r } @@ -48,12 +54,142 @@ type RunFnRunner struct { DryRun bool GlobalScope bool FnPaths []string + Image string + RunFns runfn.RunFns } func (r *RunFnRunner) runE(c *cobra.Command, args []string) error { - rec := runfn.RunFns{Path: args[0], FunctionPaths: r.FnPaths, GlobalScope: r.GlobalScope} - if r.DryRun { - rec.Output = c.OutOrStdout() - } - return handleError(c, rec.Execute()) + return handleError(c, r.RunFns.Execute()) +} + +// getFunctions parses the commandline flags and arguments into explicit +// Functions to run. +func (r *RunFnRunner) getFunctions(c *cobra.Command, args, dataItems []string) ( + []*yaml.RNode, error) { + // if image isn't specified, then Functions is empty + if r.Image == "" { + return nil, nil + } + + // create the function spec to set as an annotation + fn, err := yaml.Parse(`container: {}`) + if err != nil { + return nil, err + } + // TODO: add support network, volumes, etc based on flag values + err = fn.PipeE( + yaml.Lookup("container"), + yaml.SetField("image", yaml.NewScalarRNode(r.Image))) + if err != nil { + return nil, err + } + + // create the function config + rc, err := yaml.Parse(` +metadata: + name: function-input +data: {} +`) + if err != nil { + return nil, err + } + + // set the function annotation on the function config so it + // is parsed by RunFns + value, err := fn.String() + if err != nil { + return nil, err + } + err = rc.PipeE( + yaml.LookupCreate(yaml.MappingNode, "metadata", "annotations"), + yaml.SetField("config.kubernetes.io/function", yaml.NewScalarRNode(value))) + if err != nil { + return nil, err + } + + // default the function config kind to ConfigMap, this may be overridden + var kind = "ConfigMap" + var version = "v1" + + // populate the function config with data. this is a convention for functions + // to be more commandline friendly + if len(dataItems) > 0 { + dataField, err := rc.Pipe(yaml.Lookup("data")) + if err != nil { + return nil, err + } + for i, s := range dataItems { + kv := strings.SplitN(s, "=", 2) + if i == 0 && len(kv) == 1 { + // first argument may be the kind + kind = s + continue + } + if len(kv) != 2 { + return nil, fmt.Errorf("args must have keys and values separated by =") + } + err := dataField.PipeE(yaml.SetField(kv[0], yaml.NewScalarRNode(kv[1]))) + if err != nil { + return nil, err + } + } + } + err = rc.PipeE(yaml.SetField("kind", yaml.NewScalarRNode(kind))) + if err != nil { + return nil, err + } + err = rc.PipeE(yaml.SetField("apiVersion", yaml.NewScalarRNode(version))) + if err != nil { + return nil, err + } + return []*yaml.RNode{rc}, nil +} + +func (r *RunFnRunner) preRunE(c *cobra.Command, args []string) error { + if c.ArgsLenAtDash() >= 0 && r.Image == "" { + return errors.Errorf("must specify --image") + } + + var dataItems []string + if c.ArgsLenAtDash() >= 0 { + dataItems = args[c.ArgsLenAtDash():] + args = args[:c.ArgsLenAtDash()] + } + if len(args) > 1 { + return errors.Errorf("0 or 1 arguments supported, function arguments go after '--'") + } + + fns, err := r.getFunctions(c, args, dataItems) + if err != nil { + return err + } + + // set the output to stdout if in dry-run mode or no arguments are specified + var output io.Writer + var input io.Reader + if len(args) == 0 { + output = c.OutOrStdout() + input = c.InOrStdin() + } else if r.DryRun { + output = c.OutOrStdout() + } + + // set the path if specified as an argument + var path string + if len(args) == 1 { + // argument is the directory + path = args[0] + } + + r.RunFns = runfn.RunFns{ + FunctionPaths: r.FnPaths, + GlobalScope: r.GlobalScope, + Functions: fns, + Output: output, + Input: input, + Path: path, + } + + // don't consider args for the function + return nil } diff --git a/cmd/config/internal/commands/run_test.go b/cmd/config/internal/commands/run_test.go new file mode 100644 index 000000000..56f2d916b --- /dev/null +++ b/cmd/config/internal/commands/run_test.go @@ -0,0 +1,232 @@ +// Copyright 2019 The Kubernetes Authors. +// SPDX-License-Identifier: Apache-2.0 + +package commands + +import ( + "io" + "os" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" +) + +// TestRunFnCommand_preRunE verifies that preRunE correctly parses the commandline +// flags and arguments into the RunFns structure to be executed. +func TestRunFnCommand_preRunE(t *testing.T) { + tests := []struct { + name string + args []string + expected string + err string + path string + input io.Reader + output io.Writer + functionPaths []string + }{ + { + name: "config map", + args: []string{"run", "dir", "--image", "foo:bar", "--", "a=b", "c=d", "e=f"}, + path: "dir", + expected: ` +metadata: + name: function-input + annotations: + config.kubernetes.io/function: | + container: {image: 'foo:bar'} +data: {a: b, c: d, e: f} +kind: ConfigMap +apiVersion: v1 +`, + }, + { + name: "config map stdin / stdout", + args: []string{"run", "--image", "foo:bar", "--", "a=b", "c=d", "e=f"}, + input: os.Stdin, + output: os.Stdout, + expected: ` +metadata: + name: function-input + annotations: + config.kubernetes.io/function: | + container: {image: 'foo:bar'} +data: {a: b, c: d, e: f} +kind: ConfigMap +apiVersion: v1 +`, + }, + { + name: "config map dry-run", + args: []string{"run", "dir", "--image", "foo:bar", "--dry-run", "--", "a=b", "c=d", "e=f"}, + output: os.Stdout, + path: "dir", + expected: ` +metadata: + name: function-input + annotations: + config.kubernetes.io/function: | + container: {image: 'foo:bar'} +data: {a: b, c: d, e: f} +kind: ConfigMap +apiVersion: v1 +`, + }, + { + name: "config map no args", + args: []string{"run", "dir", "--image", "foo:bar"}, + path: "dir", + expected: ` +metadata: + name: function-input + annotations: + config.kubernetes.io/function: | + container: {image: 'foo:bar'} +data: {} +kind: ConfigMap +apiVersion: v1 +`, + }, + { + name: "custom kind", + args: []string{"run", "dir", "-i", "foo:bar", "--", "Foo", "g=h"}, + path: "dir", + expected: ` +metadata: + name: function-input + annotations: + config.kubernetes.io/function: | + container: {image: 'foo:bar'} +data: {g: h} +kind: Foo +apiVersion: v1 +`, + }, + { + name: "custom kind '=' in data", + args: []string{"run", "dir", "-i", "foo:bar", "--", "Foo", "g=h", "i=j=k"}, + path: "dir", + expected: ` +metadata: + name: function-input + annotations: + config.kubernetes.io/function: | + container: {image: 'foo:bar'} +data: {g: h, i: j=k} +kind: Foo +apiVersion: v1 +`, + }, + { + name: "function paths", + args: []string{"run", "dir", "-p", "path1", "--fn-path", "path2"}, + path: "dir", + functionPaths: []string{"path1", "path2"}, + }, + { + name: "custom kind with function paths", + args: []string{ + "run", "dir", "-p", "path", "-i", "foo:bar", "--", "Foo", "g=h", "i=j=k"}, + path: "dir", + functionPaths: []string{"path"}, + expected: ` +metadata: + name: function-input + annotations: + config.kubernetes.io/function: | + container: {image: 'foo:bar'} +data: {g: h, i: j=k} +kind: Foo +apiVersion: v1 +`, + }, + { + name: "config map multi args", + args: []string{"run", "dir", "dir2", "--image", "foo:bar", "--", "a=b", "c=d", "e=f"}, + err: "0 or 1 arguments supported", + }, + { + name: "config map not image", + args: []string{"run", "dir", "--", "a=b", "c=d", "e=f"}, + err: "must specify --image", + }, + { + name: "config map bad data", + args: []string{"run", "dir", "--image", "foo:bar", "--", "a=b", "c", "e=f"}, + err: "must have keys and values separated by", + }, + } + + for i := range tests { + tt := tests[i] + t.Run(tt.name, func(t *testing.T) { + r := GetRunFnRunner("kustomize") + // Don't run the actual command + r.Command.Run = nil + r.Command.RunE = func(cmd *cobra.Command, args []string) error { return nil } + r.Command.SilenceErrors = true + r.Command.SilenceUsage = true + + // hack due to https://github.com/spf13/cobra/issues/42 + root := &cobra.Command{Use: "root"} + root.AddCommand(r.Command) + root.SetArgs(tt.args) + + // error case + err := r.Command.Execute() + if tt.err != "" { + if !assert.Error(t, err) { + t.FailNow() + } + if !assert.Contains(t, err.Error(), tt.err) { + t.FailNow() + } + // don't check anything else in error case + return + } + + // non-error case + if !assert.NoError(t, err) { + t.FailNow() + } + + // check if Input was set + if !assert.Equal(t, tt.input, r.RunFns.Input) { + t.FailNow() + } + + // check if Output was set + if !assert.Equal(t, tt.output, r.RunFns.Output) { + t.FailNow() + } + + // check if Path was set + if !assert.Equal(t, tt.path, r.RunFns.Path) { + t.FailNow() + } + + // check if FunctionPaths were set + if tt.functionPaths == nil { + // make Equal work against flag default + tt.functionPaths = []string{} + } + if !assert.Equal(t, tt.functionPaths, r.RunFns.FunctionPaths) { + t.FailNow() + } + + // check if Functions were set + if tt.expected != "" { + if !assert.Len(t, r.RunFns.Functions, 1) { + t.FailNow() + } + actual := strings.TrimSpace(r.RunFns.Functions[0].MustString()) + if !assert.Equal(t, strings.TrimSpace(tt.expected), actual) { + t.FailNow() + } + } + + }) + } + +}