diff --git a/cmd/goss/goss.go b/cmd/goss/goss.go index 0c6a061a..1064e575 100644 --- a/cmd/goss/goss.go +++ b/cmd/goss/goss.go @@ -42,10 +42,9 @@ func newRuntimeConfigFromCLI(c *cli.Context) *util.Config { Spec: c.GlobalString("gossfile"), Timeout: c.Duration("timeout"), Username: c.String("username"), - Vars: c.GlobalString("vars"), + VarsFiles: c.GlobalStringSlice("vars"), VarsInline: c.GlobalString("vars-inline"), } - if c.Bool("no-color") { util.WithNoColor()(cfg) } @@ -83,9 +82,9 @@ func main() { Usage: "Goss file to read from / write to", EnvVar: "GOSS_FILE", }, - cli.StringFlag{ + cli.StringSliceFlag{ Name: "vars", - Usage: "json/yaml file containing variables for template", + Usage: "json/yaml file containing variables for template. Can be specified multiple times. When specified multiple times it will load variables from all files. Non-empty map keys that overlap will be overriden by subsequent file that defines them.", EnvVar: "GOSS_VARS", }, cli.StringFlag{ diff --git a/docs/cli.md b/docs/cli.md index f72b0bfb..a8ab5f45 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -22,7 +22,7 @@ COMMANDS: GLOBAL OPTIONS: --gossfile value, -g value Goss file to read from / write to (default: "./goss.yaml") [$GOSS_FILE] - --vars value json/yaml file containing variables for template [$GOSS_VARS] + --vars value json/yaml file containing variables for template. Can be specified multiple times. When specified multiple times it will load variables from all files. Non-empty map keys that overlap will be overriden by subsequent file that defines them. [$GOSS_VARS] --vars-inline value json/yaml string containing variables for template (overwrites vars) [$GOSS_VARS_INLINE] --package value Package type to use [rpm, deb, apk, pacman] --help, -h show help @@ -42,7 +42,7 @@ GLOBAL OPTIONS: * `json` `--vars ` -: The file to read variables from when rendering gossfile [templates](gossfile.md#templates). +: Files to read variables from when rendering gossfile [templates](gossfile.md#templates). Can be specified multiple times. When specified multiple times it will load variables from all files. Non-empty map keys that overlap will be overriden by subsequent file that defines them. Valid formats: diff --git a/serve.go b/serve.go index 858973c7..cac35c4b 100644 --- a/serve.go +++ b/serve.go @@ -38,7 +38,7 @@ func newHealthHandler(c *util.Config) (*healthHandler, error) { color.NoColor = true cache := cache.New(c.Cache, 30*time.Second) - cfg, err := getGossConfig(c.Vars, c.VarsInline, c.Spec) + cfg, err := getGossConfig(c.VarsFiles, c.VarsInline, c.Spec) if err != nil { return nil, err } diff --git a/store.go b/store.go index d3d3af0e..2620c023 100644 --- a/store.go +++ b/store.go @@ -10,6 +10,7 @@ import ( "sort" "strings" + "dario.cat/mergo" "gopkg.in/yaml.v3" "github.com/goss-org/goss/resource" @@ -73,10 +74,17 @@ func (t *TmplVars) Env() map[string]string { return env } -func loadVars(varsFile string, varsInline string) (map[string]any, error) { - vars, err := varsFromFile(varsFile) - if err != nil { - return nil, fmt.Errorf("loading vars file '%s'\n%w", varsFile, err) +func loadVars(varsFiles []string, varsInline string) (map[string]any, error) { + mergedVars := map[string]any{} + + // Later defined vars file overwrites values of the previous files + // in places where non-empty keys overlap. + for _, varsFile := range varsFiles { + vars, err := varsFromFile(varsFile) + if err != nil { + return nil, fmt.Errorf("loading vars file '%s'\n%w", varsFile, err) + } + mergo.Merge(&mergedVars, vars, mergo.WithOverride) } varsExtra, err := varsFromString(varsInline) @@ -84,11 +92,12 @@ func loadVars(varsFile string, varsInline string) (map[string]any, error) { return nil, fmt.Errorf("loading inline vars\n%w", err) } + // Note: This algorithm replaces value under key even if it's nested map for k, v := range varsExtra { - vars[k] = v + mergedVars[k] = v } - return vars, nil + return mergedVars, nil } func varsFromFile(varsFile string) (map[string]any, error) { @@ -162,7 +171,7 @@ func ReadJSONData(data []byte, detectFormat bool) (GossConfig, error) { func RenderJSON(c *util.Config) (string, error) { var err error debug = c.Debug - currentTemplateFilter, err = NewTemplateFilter(c.Vars, c.VarsInline) + currentTemplateFilter, err = NewTemplateFilter(c.VarsFiles, c.VarsInline) if err != nil { return "", err } diff --git a/store_test.go b/store_test.go index 3e5a4e35..57586f5c 100644 --- a/store_test.go +++ b/store_test.go @@ -129,11 +129,22 @@ func Test_loadVars(t *testing.T) { fileNil, fileNilClose := fileMaker(``) defer fileNilClose() - fileSimple, fileSimpleClose := fileMaker(`{a: a}`) - defer fileSimpleClose() + fileSimple1, fileSimpleClose1 := fileMaker(`{a: a}`) + defer fileSimpleClose1() + fileSimple2, fileSimpleClose2 := fileMaker(`{b: b}`) + defer fileSimpleClose2() + fileSimple3, fileSimpleClose3 := fileMaker(`{a: overriden, c: c}`) + defer fileSimpleClose3() + + fileComplex1, fileComplexClose1 := fileMaker(`{vars: {a: a}}`) + defer fileComplexClose1() + fileComplex2, fileComplexClose2 := fileMaker(`{vars: {b: b}}`) + defer fileComplexClose2() + fileComplex3, fileComplexClose3 := fileMaker(`{vars: {a: overriden}}`) + defer fileComplexClose3() type args struct { - varsFile string + varsFiles []string varsInline string } tests := []struct { @@ -145,7 +156,7 @@ func Test_loadVars(t *testing.T) { { name: "both_empty", args: args{ - varsFile: fileEmpty, + varsFiles: []string{fileEmpty}, varsInline: `{}`, }, want: map[string]any{}, @@ -154,7 +165,7 @@ func Test_loadVars(t *testing.T) { { name: "both_nil", args: args{ - varsFile: fileNil, + varsFiles: []string{fileNil}, varsInline: `{}`, }, want: map[string]any{}, @@ -163,7 +174,7 @@ func Test_loadVars(t *testing.T) { { name: "file_empty", args: args{ - varsFile: fileEmpty, + varsFiles: []string{fileEmpty}, varsInline: `{b: b}`, }, want: map[string]any{ @@ -174,7 +185,7 @@ func Test_loadVars(t *testing.T) { { name: "inline_empty", args: args{ - varsFile: fileSimple, + varsFiles: []string{fileSimple1}, varsInline: `{}`, }, want: map[string]any{ @@ -185,7 +196,7 @@ func Test_loadVars(t *testing.T) { { name: "no_overwrite", args: args{ - varsFile: fileSimple, + varsFiles: []string{fileSimple1}, varsInline: `{b: b}`, }, want: map[string]any{ @@ -197,7 +208,7 @@ func Test_loadVars(t *testing.T) { { name: "overwrite", args: args{ - varsFile: fileSimple, + varsFiles: []string{fileSimple1}, varsInline: `{a: c, b: b}`, }, want: map[string]any{ @@ -206,10 +217,75 @@ func Test_loadVars(t *testing.T) { }, wantErr: false, }, + { + name: "multiple files, non-overlapped keys, no inline vars", + args: args{ + varsFiles: []string{fileSimple1, fileSimple2}, + varsInline: `{}`, + }, + want: map[string]any{ + "a": "a", + "b": "b", + }, + wantErr: false, + }, + { + name: "multiple files, overlapped keys, no inline vars", + args: args{ + varsFiles: []string{fileSimple1, fileSimple2, fileSimple3}, + varsInline: `{}`, + }, + want: map[string]any{ + "a": "overriden", + "b": "b", + "c": "c", + }, + wantErr: false, + }, + { + name: "multiple files, overlapped keys, inline vars", + args: args{ + varsFiles: []string{fileSimple1, fileSimple2, fileSimple3}, + varsInline: `{c: overriden, b: b}`, + }, + want: map[string]any{ + "a": "overriden", + "b": "b", + "c": "overriden", + }, + wantErr: false, + }, + { + name: "multiple nested files", + args: args{ + varsFiles: []string{fileComplex1, fileComplex2, fileComplex3}, + varsInline: `{}`, + }, + want: map[string]any{ + "vars": map[string]any{ + "a": "overriden", + "b": "b", + }, + }, + wantErr: false, + }, + { + name: "multiple nested files and inline variables", + args: args{ + varsFiles: []string{fileComplex1, fileComplex2, fileComplex3}, + varsInline: `{vars: { c: c }}`, + }, + want: map[string]any{ + "vars": map[string]any{ + "c": "c", + }, + }, + wantErr: false, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := loadVars(tt.args.varsFile, tt.args.varsInline) + got, err := loadVars(tt.args.varsFiles, tt.args.varsInline) assert.Equal(t, tt.want, got, "map contents") assert.Equal(t, tt.wantErr, err != nil, "has error") diff --git a/template.go b/template.go index 8621ce9d..9ae97b12 100644 --- a/template.go +++ b/template.go @@ -16,10 +16,10 @@ import ( type TemplateFilter func([]byte) ([]byte, error) // NewTemplateFilter creates a new Template Filter based in the file and inline variables. -func NewTemplateFilter(varsFile string, varsInline string) (func([]byte) ([]byte, error), error) { - vars, err := loadVars(varsFile, varsInline) +func NewTemplateFilter(varsFiles []string, varsInline string) (func([]byte) ([]byte, error), error) { + vars, err := loadVars(varsFiles, varsInline) if err != nil { - return nil, fmt.Errorf("failed while loading vars file %q: %v", varsFile, err) + return nil, fmt.Errorf("failed while loading vars file %q: %v", varsFiles, err) } tVars := &TmplVars{Vars: vars} diff --git a/util/config.go b/util/config.go index 3be5fa84..71b88592 100644 --- a/util/config.go +++ b/util/config.go @@ -52,7 +52,7 @@ type Config struct { CAFile string CertFile string KeyFile string - Vars string + VarsFiles []string VarsInline string DisabledResourceTypes []string } @@ -90,7 +90,7 @@ func NewConfig(opts ...ConfigOption) (rc *Config, err error) { Spec: "", Timeout: 0, Username: "", - Vars: "", + VarsFiles: []string{}, VarsInline: "", } @@ -206,10 +206,10 @@ func WithDebug() ConfigOption { } } -// WithVarsFile is a json or yaml file containing variables to pass to the validator -func WithVarsFile(file string) ConfigOption { +// WithVarsFiles are json or yaml files containing variables to pass to the validator +func WithVarsFiles(files []string) ConfigOption { return func(c *Config) error { - c.Vars = file + c.VarsFiles = files return nil } } diff --git a/util/config_test.go b/util/config_test.go index 91471874..1d437901 100644 --- a/util/config_test.go +++ b/util/config_test.go @@ -1,6 +1,7 @@ package util import ( + "reflect" "testing" ) @@ -28,14 +29,25 @@ func TestWithVarsString(t *testing.T) { } } -func TestWithVarsFile(t *testing.T) { - c, err := NewConfig(WithVarsFile("/nonexisting")) +func TestWithVarsFiles(t *testing.T) { + files := []string{"/nonexisting"} + c, err := NewConfig(WithVarsFiles(files)) if err != nil { t.Fatal(err.Error()) } - if c.Vars != "/nonexisting" { - t.Fatalf("expected '/nonexisting' got %q", c.Vars) + if !reflect.DeepEqual(c.VarsFiles, files) { + t.Fatalf("expected %s got %q", files, c.VarsFiles) + } + + files = []string{"/nonexisting", "/second", "third"} + c, err = NewConfig(WithVarsFiles(files)) + if err != nil { + t.Fatal(err.Error()) + } + + if !reflect.DeepEqual(c.VarsFiles, files) { + t.Fatalf("expected %s got %q", files, c.VarsFiles) } } diff --git a/validate.go b/validate.go index 9e4c394c..222db044 100644 --- a/validate.go +++ b/validate.go @@ -18,13 +18,13 @@ import ( "github.com/goss-org/goss/util" ) -func getGossConfig(vars string, varsInline string, specFile string) (cfg *GossConfig, err error) { +func getGossConfig(varsFiles []string, varsInline string, specFile string) (cfg *GossConfig, err error) { // handle stdin var fh *os.File var path, source string var gossConfig GossConfig - currentTemplateFilter, err = NewTemplateFilter(vars, varsInline) + currentTemplateFilter, err = NewTemplateFilter(varsFiles, varsInline) if err != nil { return nil, err } @@ -85,7 +85,7 @@ func getOutputer(c *bool, format string) (outputs.Outputer, error) { // ValidateResults performs validation and provides programmatic access to validation results // no retries or outputs are supported func ValidateResults(c *util.Config) (results <-chan []resource.TestResult, err error) { - gossConfig, err := getGossConfig(c.Vars, c.VarsInline, c.Spec) + gossConfig, err := getGossConfig(c.VarsFiles, c.VarsInline, c.Spec) if err != nil { return nil, err } @@ -104,7 +104,7 @@ func Validate(c *util.Config) (code int, err error) { if err != nil { return 1, err } - gossConfig, err := getGossConfig(c.Vars, c.VarsInline, c.Spec) + gossConfig, err := getGossConfig(c.VarsFiles, c.VarsInline, c.Spec) if err != nil { return 78, err }