// SPDX-FileCopyrightText: 2022 Free Mobile // SPDX-License-Identifier: AGPL-3.0-only package cmd_test import ( "bytes" "fmt" "log" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" "github.com/gin-gonic/gin" "akvorado/common/helpers/yaml" "akvorado/cmd" "akvorado/common/helpers" ) type dummyConfiguration struct { Module1 dummyModule1Configuration Module2 dummyModule2Configuration } type dummyModule1Configuration struct { Listen string `validate:"listen"` Topic string `validate:"gte=3"` Workers int `validate:"gte=1"` } type dummyModule2Configuration struct { Details dummyModule2DetailsConfiguration Elements []dummyModule2ElementsConfiguration MoreDetails `mapstructure:",squash" yaml:",inline"` } type MoreDetails struct { Stuff string } type dummyModule2ElementsConfiguration struct { Name string Gauge int } type dummyModule2DetailsConfiguration struct { Workers int IntervalValue time.Duration } func (c *dummyConfiguration) Reset() { *c = dummyConfiguration{ Module1: dummyModule1Configuration{ Listen: "127.0.0.1:8080", Topic: "nothingness", Workers: 100, }, Module2: dummyModule2Configuration{ MoreDetails: MoreDetails{ Stuff: "hello", }, Details: dummyModule2DetailsConfiguration{ Workers: 1, IntervalValue: time.Minute, }, Elements: []dummyModule2ElementsConfiguration{ { Name: "el1", Gauge: 10, }, { Name: "el2", Gauge: 11, }, }, }, } } func TestValidation(t *testing.T) { config := `--- module1: topic: fl workers: -5 ` configFile := filepath.Join(t.TempDir(), "config.yaml") os.WriteFile(configFile, []byte(config), 0o644) c := cmd.ConfigRelatedOptions{ Path: configFile, } parsed := dummyConfiguration{} out := bytes.NewBuffer([]byte{}) if _, err := c.Parse(out, "dummy", &parsed); err == nil { t.Fatal("Parse() didn't error") } else if diff := helpers.Diff(err.Error(), `invalid configuration: Key: 'dummyConfiguration.Module1.Topic' Error:Field validation for 'Topic' failed on the 'gte' tag Key: 'dummyConfiguration.Module1.Workers' Error:Field validation for 'Workers' failed on the 'gte' tag`); diff != "" { t.Fatalf("Parse() (-got, +want):\n%s", diff) } } func TestDump(t *testing.T) { // Configuration file config := `--- module1: topic: flows module2: details: workers: 5 interval-value: 20m stuff: bye elements: - name: first gauge: 67 - name: second ` configFile := filepath.Join(t.TempDir(), "config.yaml") os.WriteFile(configFile, []byte(config), 0o644) c := cmd.ConfigRelatedOptions{ Path: configFile, Dump: true, } parsed := dummyConfiguration{} out := bytes.NewBuffer([]byte{}) if _, err := c.Parse(out, "dummy", &parsed); err != nil { t.Fatalf("Parse() error:\n%+v", err) } // Expected configuration expected := dummyConfiguration{ Module1: dummyModule1Configuration{ Listen: "127.0.0.1:8080", Topic: "flows", Workers: 100, }, Module2: dummyModule2Configuration{ MoreDetails: MoreDetails{ Stuff: "bye", }, Details: dummyModule2DetailsConfiguration{ Workers: 5, IntervalValue: 20 * time.Minute, }, Elements: []dummyModule2ElementsConfiguration{ {"first", 67}, {"second", 0}, }, }, } if diff := helpers.Diff(parsed, expected); diff != "" { t.Errorf("Parse() (-got, +want):\n%s", diff) } var gotRaw map[string]gin.H if err := yaml.Unmarshal(out.Bytes(), &gotRaw); err != nil { t.Fatalf("Unmarshal() error:\n%+v", err) } expectedRaw := map[string]gin.H{ "module1": { "listen": "127.0.0.1:8080", "topic": "flows", "workers": 100, }, "module2": { "stuff": "bye", "details": gin.H{ "workers": 5, "intervalvalue": "20m0s", }, "elements": []any{ gin.H{ "name": "first", "gauge": 67, }, gin.H{ "name": "second", "gauge": 0, }, }, }, } if diff := helpers.Diff(gotRaw, expectedRaw); diff != "" { t.Errorf("Parse() (-got, +want):\n%s", diff) } } func TestEnvOverride(t *testing.T) { // Configuration file config := `--- module1: topic: flows module2: details: workers: 5 interval-value: 20m ` configFile := filepath.Join(t.TempDir(), "config.yaml") os.WriteFile(configFile, []byte(config), 0o644) // Environment clean := func() { for _, env := range os.Environ() { if strings.HasPrefix(env, "AKVORADO_CFG_DUMMY_") { os.Unsetenv(strings.Split(env, "=")[0]) } } } clean() defer clean() os.Setenv("AKVORADO_CFG_DUMMY_MODULE1_LISTEN", "127.0.0.1:9000") os.Setenv("AKVORADO_CFG_DUMMY_MODULE1_TOPIC", "something") os.Setenv("AKVORADO_CFG_DUMMY_MODULE2_DETAILS_INTERVALVALUE", "10m") os.Setenv("AKVORADO_CFG_DUMMY_MODULE2_STUFF", "bye") os.Setenv("AKVORADO_CFG_DUMMY_MODULE2_ELEMENTS_0_NAME", "something") os.Setenv("AKVORADO_CFG_DUMMY_MODULE2_ELEMENTS_0_GAUGE", "18") os.Setenv("AKVORADO_CFG_DUMMY_MODULE2_ELEMENTS_1_NAME", "something else") os.Setenv("AKVORADO_CFG_DUMMY_MODULE2_ELEMENTS_1_GAUGE", "7") c := cmd.ConfigRelatedOptions{ Path: configFile, Dump: true, } parsed := dummyConfiguration{} out := bytes.NewBuffer([]byte{}) if _, err := c.Parse(out, "dummy", &parsed); err != nil { t.Fatalf("Parse() error:\n%+v", err) } // Expected configuration expected := dummyConfiguration{ Module1: dummyModule1Configuration{ Listen: "127.0.0.1:9000", Topic: "something", Workers: 100, }, Module2: dummyModule2Configuration{ MoreDetails: MoreDetails{ Stuff: "bye", }, Details: dummyModule2DetailsConfiguration{ Workers: 5, IntervalValue: 10 * time.Minute, }, Elements: []dummyModule2ElementsConfiguration{ {"something", 18}, {"something else", 7}, }, }, } if diff := helpers.Diff(parsed, expected); diff != "" { t.Errorf("Parse() (-got, +want):\n%s", diff) } } func TestHTTPConfiguration(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/yaml; charset=utf-8") fmt.Fprint(w, `--- module1: topic: flows module2: details: workers: 5 interval-value: 20m stuff: bye elements: - {"name": "first", "gauge": 67} - {"name": "second"} `) })) defer ts.Close() c := cmd.ConfigRelatedOptions{ Path: ts.URL, Dump: true, } parsed := dummyConfiguration{} out := bytes.NewBuffer([]byte{}) if _, err := c.Parse(out, "dummy", &parsed); err != nil { t.Fatalf("Parse() error:\n%+v", err) } // Expected configuration expected := dummyConfiguration{ Module1: dummyModule1Configuration{ Listen: "127.0.0.1:8080", Topic: "flows", Workers: 100, }, Module2: dummyModule2Configuration{ MoreDetails: MoreDetails{ Stuff: "bye", }, Details: dummyModule2DetailsConfiguration{ Workers: 5, IntervalValue: 20 * time.Minute, }, Elements: []dummyModule2ElementsConfiguration{ {"first", 67}, {"second", 0}, }, }, } if diff := helpers.Diff(parsed, expected); diff != "" { t.Errorf("Parse() (-got, +want):\n%s", diff) } } func TestUnused(t *testing.T) { t.Run("ignored fields", func(t *testing.T) { config := `--- .unused: should be ignored module1: .too: nope topic: flow workers: 10 ` configFile := filepath.Join(t.TempDir(), "config.yaml") os.WriteFile(configFile, []byte(config), 0o644) c := cmd.ConfigRelatedOptions{Path: configFile} parsed := dummyConfiguration{} out := bytes.NewBuffer([]byte{}) if _, err := c.Parse(out, "dummy", &parsed); err != nil { t.Fatalf("Parse() error:\n%+v", err) } }) t.Run("unused fields", func(t *testing.T) { config := `--- unused: should not be ignored module1: extra: 111 topic: flow workers: 10 ` configFile := filepath.Join(t.TempDir(), "config.yaml") os.WriteFile(configFile, []byte(config), 0o644) c := cmd.ConfigRelatedOptions{Path: configFile} parsed := dummyConfiguration{} out := bytes.NewBuffer([]byte{}) if _, err := c.Parse(out, "dummy", &parsed); err == nil { t.Fatal("Parse() didn't error") } else if diff := helpers.Diff(err.Error(), `invalid configuration: invalid key "Module1.extra" invalid key "unused"`); diff != "" { t.Fatalf("Parse() (-got, +want):\n%s", diff) } }) } func TestDefaultInSlice(t *testing.T) { try := func(t *testing.T, parse func(cmd.ConfigRelatedOptions, *bytes.Buffer) any, expected any) { // Configuration file config := `--- modules: - module1: topic: flows1 - module1: topic: flows2 ` configFile := filepath.Join(t.TempDir(), "config.yaml") os.WriteFile(configFile, []byte(config), 0o644) c := cmd.ConfigRelatedOptions{ Path: configFile, Dump: true, } out := bytes.NewBuffer([]byte{}) parsed := parse(c, out) if diff := helpers.Diff(parsed, expected); diff != "" { t.Errorf("Parse() (-got, +want):\n%s", diff) } } t.Run("without pointer", func(t *testing.T) { try(t, func(c cmd.ConfigRelatedOptions, out *bytes.Buffer) any { parsed := struct { Modules []dummyConfiguration }{} if _, err := c.Parse(out, "dummy", &parsed); err != nil { t.Fatalf("Parse() error:\n%+v", err) } return parsed }, // Expected configuration struct { Modules []dummyConfiguration }{ Modules: []dummyConfiguration{ { Module1: dummyModule1Configuration{ Listen: "127.0.0.1:8080", Topic: "flows1", Workers: 100, }, Module2: dummyModule2Configuration{ MoreDetails: MoreDetails{ Stuff: "hello", }, Details: dummyModule2DetailsConfiguration{ Workers: 1, IntervalValue: time.Minute, }, Elements: []dummyModule2ElementsConfiguration{ { Name: "el1", Gauge: 10, }, { Name: "el2", Gauge: 11, }, }, }, }, { Module1: dummyModule1Configuration{ Listen: "127.0.0.1:8080", Topic: "flows2", Workers: 100, }, Module2: dummyModule2Configuration{ MoreDetails: MoreDetails{ Stuff: "hello", }, Details: dummyModule2DetailsConfiguration{ Workers: 1, IntervalValue: time.Minute, }, Elements: []dummyModule2ElementsConfiguration{ { Name: "el1", Gauge: 10, }, { Name: "el2", Gauge: 11, }, }, }, }, }, }) }) t.Run("with pointer", func(t *testing.T) { try(t, func(c cmd.ConfigRelatedOptions, out *bytes.Buffer) any { parsed := struct { Modules []*dummyConfiguration }{} if _, err := c.Parse(out, "dummy", &parsed); err != nil { t.Fatalf("Parse() error:\n%+v", err) } return parsed }, struct { Modules []*dummyConfiguration }{ Modules: []*dummyConfiguration{ { Module1: dummyModule1Configuration{ Listen: "127.0.0.1:8080", Topic: "flows1", Workers: 100, }, Module2: dummyModule2Configuration{ MoreDetails: MoreDetails{ Stuff: "hello", }, Details: dummyModule2DetailsConfiguration{ Workers: 1, IntervalValue: time.Minute, }, Elements: []dummyModule2ElementsConfiguration{ { Name: "el1", Gauge: 10, }, { Name: "el2", Gauge: 11, }, }, }, }, { Module1: dummyModule1Configuration{ Listen: "127.0.0.1:8080", Topic: "flows2", Workers: 100, }, Module2: dummyModule2Configuration{ MoreDetails: MoreDetails{ Stuff: "hello", }, Details: dummyModule2DetailsConfiguration{ Workers: 1, IntervalValue: time.Minute, }, Elements: []dummyModule2ElementsConfiguration{ { Name: "el1", Gauge: 10, }, { Name: "el2", Gauge: 11, }, }, }, }, }, }) }) } func TestDevNullDefault(t *testing.T) { c := cmd.ConfigRelatedOptions{ Path: "/dev/null", Dump: true, } var parsed dummyConfiguration out := bytes.NewBuffer([]byte{}) if _, err := c.Parse(out, "dummy", &parsed); err != nil { t.Fatalf("Parse() error:\n%+v", err) } // Expected configuration expected := dummyConfiguration{ Module1: dummyModule1Configuration{ Listen: "127.0.0.1:8080", Topic: "nothingness", Workers: 100, }, Module2: dummyModule2Configuration{ MoreDetails: MoreDetails{ Stuff: "hello", }, Details: dummyModule2DetailsConfiguration{ Workers: 1, IntervalValue: time.Minute, }, Elements: []dummyModule2ElementsConfiguration{ { Name: "el1", Gauge: 10, }, { Name: "el2", Gauge: 11, }, }, }, } if diff := helpers.Diff(parsed, expected); diff != "" { t.Errorf("Parse() (-got, +want):\n%s", diff) } } func TestZeroCanOverrideDefault(t *testing.T) { config := `--- module1: topic: flows workers: 10 module2: details: workers: 0 stuff: "" ` configFile := filepath.Join(t.TempDir(), "config.yaml") os.WriteFile(configFile, []byte(config), 0o644) c := cmd.ConfigRelatedOptions{ Path: configFile, Dump: true, } parsed := dummyConfiguration{} expected := dummyConfiguration{ Module1: dummyModule1Configuration{ Listen: "127.0.0.1:8080", Topic: "flows", Workers: 10, }, Module2: dummyModule2Configuration{ Details: dummyModule2DetailsConfiguration{ Workers: 0, IntervalValue: time.Minute, }, MoreDetails: MoreDetails{ Stuff: "", }, Elements: []dummyModule2ElementsConfiguration{ { Name: "el1", Gauge: 10, }, { Name: "el2", Gauge: 11, }, }, }, } out := bytes.NewBuffer([]byte{}) if _, err := c.Parse(out, "dummy", &parsed); err != nil { t.Fatalf("Parse() error:\n%+v", err) } if diff := helpers.Diff(parsed, expected); diff != "" { t.Fatalf("Parse() (-got, +want):\n%s", diff) } log.Printf("output:\n%s", out.Bytes()) if !bytes.Contains(out.Bytes(), []byte(`stuff: ""`)) { t.Errorf("Dumped configuration should contain `stuff: \"\"`") } if !bytes.Contains(out.Bytes(), []byte(`workers: 0`)) { t.Errorf("Dumped configuration should contain `workers: 0`") } }