diff --git a/docs/cmd_reference.md b/docs/cmd_reference.md index 6660398c..e12a7e09 100644 --- a/docs/cmd_reference.md +++ b/docs/cmd_reference.md @@ -77,6 +77,9 @@ This lists available CMD options in Helmsman: `--no-ssm-subst` turn off SSM parameter substitution globally. + `-spec string` + specification file name, contains locations of desired state files to be merged + `--subst-ssm-values` turn on SSM parameter substitution in values files. diff --git a/examples/example-spec.yaml b/examples/example-spec.yaml new file mode 100644 index 00000000..14757ed1 --- /dev/null +++ b/examples/example-spec.yaml @@ -0,0 +1,5 @@ +--- +stateFiles: + - path: examples/example.yaml + - path: examples/minimal-example.yaml + - path: examples/minimal-example.toml diff --git a/internal/app/cli.go b/internal/app/cli.go index eb7cff84..99a3f09c 100644 --- a/internal/app/cli.go +++ b/internal/app/cli.go @@ -4,6 +4,7 @@ import ( "flag" "fmt" "os" + "sort" "strings" "github.com/imdario/mergo" @@ -24,6 +25,37 @@ const ( // Allow parsing of multiple string command line options into an array of strings type stringArray []string +type fileOptionArray []fileOption + +type fileOption struct { + name string + priority int +} + +func (f *fileOptionArray) String() string { + var a []string + for _, v := range *f { + a = append(a, v.name) + } + return strings.Join(a, " ") +} + +func (f *fileOptionArray) Set(value string) error { + var fo fileOption + + fo.name = value + *f = append(*f, fo) + return nil +} + +func (f fileOptionArray) sort() { + log.Verbose("Sorting files listed in the -spec file based on their priorities... ") + + sort.SliceStable(f, func(i, j int) bool { + return (f)[i].priority < (f)[j].priority + }) +} + func (i *stringArray) String() string { return strings.Join(*i, " ") } @@ -35,7 +67,8 @@ func (i *stringArray) Set(value string) error { type cli struct { debug bool - files stringArray + files fileOptionArray + spec string envFiles stringArray target stringArray group stringArray @@ -88,6 +121,7 @@ func (c *cli) parse() { flag.Var(&c.group, "group", "limit execution to specific group of apps.") flag.IntVar(&c.diffContext, "diff-context", -1, "number of lines of context to show around changes in helm diff output") flag.IntVar(&c.parallel, "p", 1, "max number of concurrent helm releases to run") + flag.StringVar(&c.spec, "spec", "", "specification file name, contains locations of desired state files to be merged") flag.StringVar(&c.kubeconfig, "kubeconfig", "", "path to the kubeconfig file to use for CLI requests") flag.StringVar(&c.nsOverride, "ns-override", "", "override defined namespaces with this one") flag.StringVar(&c.contextOverride, "context-override", "", "override releases context defined in release state with this one") @@ -147,6 +181,10 @@ func (c *cli) parse() { log.Fatal("--target and --group can't be used together.") } + if len(flags.files) > 0 && len(flags.spec) > 0 { + log.Fatal("-f and -spec can't be used together.") + } + if c.parallel < 1 { c.parallel = 1 } @@ -159,7 +197,7 @@ func (c *cli) parse() { kubectlVersion := getKubectlVersion() log.Verbose("kubectl client version: " + kubectlVersion) - if len(c.files) == 0 { + if len(c.files) == 0 && len(c.spec) == 0 { log.Info("No desired state files provided.") os.Exit(0) } @@ -218,32 +256,49 @@ func (c *cli) readState(s *state) error { os.RemoveAll(tempFilesDir) _ = os.MkdirAll(tempFilesDir, 0o755) + if len(c.spec) > 0 { + + sp := new(StateFiles) + sp.specFromYAML(c.spec) + + for _, val := range sp.StateFiles { + fo := fileOption{} + fo.name = val.Path + if err := isValidFile(fo.name, validManifestFiles); err != nil { + return fmt.Errorf("invalid -spec file: %w", err) + } + c.files = append(c.files, fo) + } + c.files.sort() + } + // read the TOML/YAML desired state file for _, f := range c.files { var fileState state - if err := fileState.fromFile(f); err != nil { + if err := fileState.fromFile(f.name); err != nil { return err } - log.Infof("Parsed [[ %s ]] successfully and found [ %d ] apps", f, len(fileState.Apps)) + + log.Infof("Parsed [[ %s ]] successfully and found [ %d ] apps", f.name, len(fileState.Apps)) // Merge Apps that already existed in the state for appName, app := range fileState.Apps { if _, ok := s.Apps[appName]; ok { if err := mergo.Merge(s.Apps[appName], app, mergo.WithAppendSlice, mergo.WithOverride); err != nil { - return fmt.Errorf("failed to merge %s from desired state file %s: %w", appName, f, err) + return fmt.Errorf("failed to merge %s from desired state file %s: %w", appName, f.name, err) } } } // Merge the remaining Apps if err := mergo.Merge(&s.Apps, &fileState.Apps); err != nil { - return fmt.Errorf("failed to merge desired state file %s: %w", f, err) + return fmt.Errorf("failed to merge desired state file %s: %w", f.name, err) } // All the apps are already merged, make fileState.Apps empty to avoid conflicts in the final merge fileState.Apps = make(map[string]*release) if err := mergo.Merge(s, &fileState, mergo.WithAppendSlice, mergo.WithOverride); err != nil { - return fmt.Errorf("failed to merge desired state file %s: %w", f, err) + return fmt.Errorf("failed to merge desired state file %s: %w", f.name, err) } } diff --git a/internal/app/cli_test.go b/internal/app/cli_test.go index 79680b1f..9b34399f 100644 --- a/internal/app/cli_test.go +++ b/internal/app/cli_test.go @@ -22,7 +22,7 @@ func Test_readState(t *testing.T) { { name: "yaml minimal example; no validation", flags: cli{ - files: stringArray([]string{"../../examples/minimal-example.yaml"}), + files: fileOptionArray([]fileOption{fileOption{"../../examples/minimal-example.yaml", 0}}), skipValidation: true, }, want: result{ @@ -35,7 +35,7 @@ func Test_readState(t *testing.T) { { name: "toml minimal example; no validation", flags: cli{ - files: stringArray([]string{"../../examples/minimal-example.toml"}), + files: fileOptionArray([]fileOption{fileOption{"../../examples/minimal-example.toml", 0}}), skipValidation: true, }, want: result{ @@ -49,7 +49,7 @@ func Test_readState(t *testing.T) { name: "yaml minimal example; no validation with bad target", flags: cli{ target: stringArray([]string{"foo"}), - files: stringArray([]string{"../../examples/minimal-example.yaml"}), + files: fileOptionArray([]fileOption{{"../../examples/minimal-example.yaml", 0}}), skipValidation: true, }, want: result{ @@ -63,7 +63,7 @@ func Test_readState(t *testing.T) { name: "yaml minimal example; no validation; target jenkins", flags: cli{ target: stringArray([]string{"jenkins"}), - files: stringArray([]string{"../../examples/minimal-example.yaml"}), + files: fileOptionArray([]fileOption{{"../../examples/minimal-example.yaml", 0}}), skipValidation: true, }, want: result{ @@ -76,7 +76,7 @@ func Test_readState(t *testing.T) { { name: "yaml and toml minimal examples merged; no validation", flags: cli{ - files: stringArray([]string{"../../examples/minimal-example.yaml", "../../examples/minimal-example.toml"}), + files: fileOptionArray([]fileOption{{"../../examples/minimal-example.yaml", 0}, {"../../examples/minimal-example.toml", 0}}), skipValidation: true, }, want: result{ diff --git a/internal/app/spec_state.go b/internal/app/spec_state.go new file mode 100644 index 00000000..3a7b5144 --- /dev/null +++ b/internal/app/spec_state.go @@ -0,0 +1,33 @@ +package app + +import ( + "io/ioutil" + + "gopkg.in/yaml.v2" +) + +type StatePath struct { + Path string `yaml:"path"` +} + +type StateFiles struct { + StateFiles []StatePath `yaml:"stateFiles"` +} + +// fromYAML reads a yaml file and decodes it to a state type. +// parser which throws an error if the YAML file is not valid. +func (pc *StateFiles) specFromYAML(file string) error { + rawYamlFile, err := ioutil.ReadFile(file) + if err != nil { + log.Errorf("specFromYaml %v %v", file, err) + return err + } + + yamlFile := string(rawYamlFile) + + if err = yaml.UnmarshalStrict([]byte(yamlFile), pc); err != nil { + return err + } + + return nil +} diff --git a/internal/app/spec_state_test.go b/internal/app/spec_state_test.go new file mode 100644 index 00000000..dc209414 --- /dev/null +++ b/internal/app/spec_state_test.go @@ -0,0 +1,90 @@ +package app + +import ( + "testing" +) + +func Test_specFromYAML(t *testing.T) { + type args struct { + file string + s *StateFiles + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "test case 1 -- Valid YAML", + args: args{ + file: "../../examples/example-spec.yaml", + s: new(StateFiles), + }, + want: true, + }, { + name: "test case 2 -- Invalid Yaml", + args: args{ + file: "../../tests/Invalid_example_spec.yaml", + s: new(StateFiles), + }, + want: false, + }, + } + + teardownTestCase := setupStateFileTestCase(t) + defer teardownTestCase(t) + for _, tt := range tests { + // os.Args = append(os.Args, "-f ../../examples/example.yaml") + t.Run(tt.name, func(t *testing.T) { + err := tt.args.s.specFromYAML(tt.args.file) + if err != nil { + t.Log(err) + } + + got := err == nil + if got != tt.want { + t.Errorf("specFromYaml() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_specFileSort(t *testing.T) { + type args struct { + files fileOptionArray + } + tests := []struct { + name string + args args + want [3]int + }{ + { + name: "test case 1 -- Files sorted by priority", + args: args{ + files: fileOptionArray( + []fileOption{ + fileOption{"third.yaml", 0}, + fileOption{"first.yaml", -20}, + fileOption{"second.yaml", -10}, + }), + }, + want: [3]int{-20, -10, 0}, + }, + } + + teardownTestCase := setupStateFileTestCase(t) + defer teardownTestCase(t) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.args.files.sort() + + got := [3]int{} + for i, f := range tt.args.files { + got[i] = f.priority + } + if got != tt.want { + t.Errorf("files from spec file are not sorted by priority = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/tests/Invalid_example_spec.yaml b/tests/Invalid_example_spec.yaml new file mode 100644 index 00000000..19ccd2c1 --- /dev/null +++ b/tests/Invalid_example_spec.yaml @@ -0,0 +1,4 @@ +--- +stateFiles: + name1: invalid/example.yaml + name2: invalid/minimal-example.yaml