package config // import "github.com/docker/docker/daemon/config" import ( "encoding/json" "os" "path/filepath" "reflect" "strings" "testing" "dario.cat/mergo" "github.com/docker/docker/api" "github.com/docker/docker/libnetwork/ipamutils" "github.com/docker/docker/opts" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/spf13/pflag" "golang.org/x/text/encoding" "golang.org/x/text/encoding/unicode" "gotest.tools/v3/assert" is "gotest.tools/v3/assert/cmp" "gotest.tools/v3/skip" ) func makeConfigFile(t *testing.T, content string) string { t.Helper() name := filepath.Join(t.TempDir(), "daemon.json") err := os.WriteFile(name, []byte(content), 0o666) assert.NilError(t, err) return name } func TestDaemonConfigurationNotFound(t *testing.T) { _, err := MergeDaemonConfigurations(&Config{}, nil, "/tmp/foo-bar-baz-docker") assert.Check(t, os.IsNotExist(err), "got: %[1]T: %[1]v", err) } func TestDaemonBrokenConfiguration(t *testing.T) { configFile := makeConfigFile(t, `{"Debug": tru`) _, err := MergeDaemonConfigurations(&Config{}, nil, configFile) assert.ErrorContains(t, err, `invalid character ' ' in literal true`) } // TestDaemonConfigurationUnicodeVariations feeds various variations of Unicode into the JSON parser, ensuring that we // respect a BOM and otherwise default to UTF-8. func TestDaemonConfigurationUnicodeVariations(t *testing.T) { jsonData := `{"debug": true}` testCases := []struct { name string encoding encoding.Encoding }{ { name: "UTF-8", encoding: unicode.UTF8, }, { name: "UTF-8 (with BOM)", encoding: unicode.UTF8BOM, }, { name: "UTF-16 (BE with BOM)", encoding: unicode.UTF16(unicode.BigEndian, unicode.UseBOM), }, { name: "UTF-16 (LE with BOM)", encoding: unicode.UTF16(unicode.LittleEndian, unicode.UseBOM), }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { encodedJson, err := tc.encoding.NewEncoder().String(jsonData) assert.NilError(t, err) configFile := makeConfigFile(t, encodedJson) _, err = MergeDaemonConfigurations(&Config{}, nil, configFile) assert.NilError(t, err) }) } } // TestDaemonConfigurationInvalidUnicode ensures that the JSON parser returns a useful error message if malformed UTF-8 // is provided. func TestDaemonConfigurationInvalidUnicode(t *testing.T) { configFileBOM := makeConfigFile(t, "\xef\xbb\xbf{\"debug\": true}\xff") _, err := MergeDaemonConfigurations(&Config{}, nil, configFileBOM) assert.ErrorIs(t, err, encoding.ErrInvalidUTF8) configFileNoBOM := makeConfigFile(t, "{\"debug\": true}\xff") _, err = MergeDaemonConfigurations(&Config{}, nil, configFileNoBOM) assert.ErrorIs(t, err, encoding.ErrInvalidUTF8) } func TestFindConfigurationConflicts(t *testing.T) { config := map[string]interface{}{"authorization-plugins": "foobar"} flags := pflag.NewFlagSet("test", pflag.ContinueOnError) flags.String("authorization-plugins", "", "") assert.Check(t, flags.Set("authorization-plugins", "asdf")) assert.Check(t, is.ErrorContains(findConfigurationConflicts(config, flags), "authorization-plugins: (from flag: asdf, from file: foobar)")) } func TestFindConfigurationConflictsWithNamedOptions(t *testing.T) { config := map[string]interface{}{"hosts": []string{"qwer"}} flags := pflag.NewFlagSet("test", pflag.ContinueOnError) var hosts []string flags.VarP(opts.NewNamedListOptsRef("hosts", &hosts, opts.ValidateHost), "host", "H", "Daemon socket(s) to connect to") assert.Check(t, flags.Set("host", "tcp://127.0.0.1:4444")) assert.Check(t, flags.Set("host", "unix:///var/run/docker.sock")) assert.Check(t, is.ErrorContains(findConfigurationConflicts(config, flags), "hosts")) } func TestDaemonConfigurationMergeConflicts(t *testing.T) { configFile := makeConfigFile(t, `{"debug": true}`) flags := pflag.NewFlagSet("test", pflag.ContinueOnError) flags.Bool("debug", false, "") assert.Check(t, flags.Set("debug", "false")) _, err := MergeDaemonConfigurations(&Config{}, flags, configFile) if err == nil { t.Fatal("expected error, got nil") } if !strings.Contains(err.Error(), "debug") { t.Fatalf("expected debug conflict, got %v", err) } } func TestDaemonConfigurationMergeConcurrent(t *testing.T) { configFile := makeConfigFile(t, `{"max-concurrent-downloads": 1}`) _, err := MergeDaemonConfigurations(&Config{}, nil, configFile) assert.NilError(t, err) } func TestDaemonConfigurationMergeConcurrentError(t *testing.T) { configFile := makeConfigFile(t, `{"max-concurrent-downloads": -1}`) _, err := MergeDaemonConfigurations(&Config{}, nil, configFile) assert.ErrorContains(t, err, `invalid max concurrent downloads: -1`) } func TestDaemonConfigurationMergeConflictsWithInnerStructs(t *testing.T) { configFile := makeConfigFile(t, `{"tlscacert": "/etc/certificates/ca.pem"}`) flags := pflag.NewFlagSet("test", pflag.ContinueOnError) flags.String("tlscacert", "", "") assert.Check(t, flags.Set("tlscacert", "~/.docker/ca.pem")) _, err := MergeDaemonConfigurations(&Config{}, flags, configFile) assert.ErrorContains(t, err, `the following directives are specified both as a flag and in the configuration file: tlscacert`) } // TestDaemonConfigurationMergeDefaultAddressPools is a regression test for #40711. func TestDaemonConfigurationMergeDefaultAddressPools(t *testing.T) { emptyConfigFile := makeConfigFile(t, `{}`) configFile := makeConfigFile(t, `{"default-address-pools":[{"base": "10.123.0.0/16", "size": 24 }]}`) expected := []*ipamutils.NetworkToSplit{{Base: "10.123.0.0/16", Size: 24}} t.Run("empty config file", func(t *testing.T) { conf := Config{} flags := pflag.NewFlagSet("test", pflag.ContinueOnError) flags.Var(&conf.NetworkConfig.DefaultAddressPools, "default-address-pool", "") assert.Check(t, flags.Set("default-address-pool", "base=10.123.0.0/16,size=24")) config, err := MergeDaemonConfigurations(&conf, flags, emptyConfigFile) assert.NilError(t, err) assert.DeepEqual(t, config.DefaultAddressPools.Value(), expected) }) t.Run("config file", func(t *testing.T) { conf := Config{} flags := pflag.NewFlagSet("test", pflag.ContinueOnError) flags.Var(&conf.NetworkConfig.DefaultAddressPools, "default-address-pool", "") config, err := MergeDaemonConfigurations(&conf, flags, configFile) assert.NilError(t, err) assert.DeepEqual(t, config.DefaultAddressPools.Value(), expected) }) t.Run("with conflicting options", func(t *testing.T) { conf := Config{} flags := pflag.NewFlagSet("test", pflag.ContinueOnError) flags.Var(&conf.NetworkConfig.DefaultAddressPools, "default-address-pool", "") assert.Check(t, flags.Set("default-address-pool", "base=10.123.0.0/16,size=24")) _, err := MergeDaemonConfigurations(&conf, flags, configFile) assert.ErrorContains(t, err, "the following directives are specified both as a flag and in the configuration file") assert.ErrorContains(t, err, "default-address-pools") }) } func TestFindConfigurationConflictsWithUnknownKeys(t *testing.T) { config := map[string]interface{}{"tls-verify": "true"} flags := pflag.NewFlagSet("test", pflag.ContinueOnError) flags.Bool("tlsverify", false, "") err := findConfigurationConflicts(config, flags) assert.ErrorContains(t, err, "the following directives don't match any configuration option: tls-verify") } func TestFindConfigurationConflictsWithMergedValues(t *testing.T) { var hosts []string config := map[string]interface{}{"hosts": "tcp://127.0.0.1:2345"} flags := pflag.NewFlagSet("base", pflag.ContinueOnError) flags.VarP(opts.NewNamedListOptsRef("hosts", &hosts, nil), "host", "H", "") err := findConfigurationConflicts(config, flags) assert.NilError(t, err) assert.Check(t, flags.Set("host", "unix:///var/run/docker.sock")) err = findConfigurationConflicts(config, flags) assert.ErrorContains(t, err, "hosts: (from flag: [unix:///var/run/docker.sock], from file: tcp://127.0.0.1:2345)") } func TestValidateConfigurationErrors(t *testing.T) { testCases := []struct { name string field string config *Config expectedErr string }{ { name: "single label without value", config: &Config{ CommonConfig: CommonConfig{ Labels: []string{"one"}, }, }, expectedErr: "bad attribute format: one", }, { name: "multiple label without value", config: &Config{ CommonConfig: CommonConfig{ Labels: []string{"foo=bar", "one"}, }, }, expectedErr: "bad attribute format: one", }, { name: "single DNSSearch", config: &Config{ CommonConfig: CommonConfig{ DNSConfig: DNSConfig{ DNSSearch: []string{"123456"}, }, }, }, expectedErr: "123456 is not a valid domain", }, { name: "multiple DNSSearch", config: &Config{ CommonConfig: CommonConfig{ DNSConfig: DNSConfig{ DNSSearch: []string{"a.b.c", "123456"}, }, }, }, expectedErr: "123456 is not a valid domain", }, { name: "negative MTU", config: &Config{ CommonConfig: CommonConfig{ BridgeConfig: BridgeConfig{ DefaultBridgeConfig: DefaultBridgeConfig{ MTU: -10, }, }, }, }, expectedErr: "invalid default MTU: -10", }, { name: "negative max-concurrent-downloads", config: &Config{ CommonConfig: CommonConfig{ MaxConcurrentDownloads: -10, }, }, expectedErr: "invalid max concurrent downloads: -10", }, { name: "negative max-concurrent-uploads", config: &Config{ CommonConfig: CommonConfig{ MaxConcurrentUploads: -10, }, }, expectedErr: "invalid max concurrent uploads: -10", }, { name: "negative max-download-attempts", config: &Config{ CommonConfig: CommonConfig{ MaxDownloadAttempts: -10, }, }, expectedErr: "invalid max download attempts: -10", }, // TODO(thaJeztah) temporarily excluding this test as it assumes defaults are set before validating and applying updated configs /* { name: "zero max-download-attempts", field: "MaxDownloadAttempts", config: &Config{ CommonConfig: CommonConfig{ MaxDownloadAttempts: 0, }, }, expectedErr: "invalid max download attempts: 0", }, */ { name: "generic resource without =", config: &Config{ CommonConfig: CommonConfig{ NodeGenericResources: []string{"foo"}, }, }, expectedErr: "could not parse GenericResource: incorrect term foo, missing '=' or malformed expression", }, { name: "generic resource mixed named and discrete", config: &Config{ CommonConfig: CommonConfig{ NodeGenericResources: []string{"foo=bar", "foo=1"}, }, }, expectedErr: "could not parse GenericResource: mixed discrete and named resources in expression 'foo=[bar 1]'", }, { name: "with invalid hosts", config: &Config{ CommonConfig: CommonConfig{ Hosts: []string{"127.0.0.1:2375/path"}, }, }, expectedErr: "invalid bind address (127.0.0.1:2375/path): should not contain a path element", }, { name: "with invalid log-level", config: &Config{ CommonConfig: CommonConfig{ LogLevel: "foobar", }, }, expectedErr: "invalid logging level: foobar", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { cfg, err := New() assert.NilError(t, err) if tc.field != "" { assert.Check(t, mergo.Merge(cfg, tc.config, mergo.WithOverride, withForceOverwrite(tc.field))) } else { assert.Check(t, mergo.Merge(cfg, tc.config, mergo.WithOverride)) } err = Validate(cfg) assert.Error(t, err, tc.expectedErr) }) } } func withForceOverwrite(fieldName string) func(config *mergo.Config) { return mergo.WithTransformers(overwriteTransformer{fieldName: fieldName}) } type overwriteTransformer struct { fieldName string } func (tf overwriteTransformer) Transformer(typ reflect.Type) func(dst, src reflect.Value) error { if typ == reflect.TypeOf(CommonConfig{}) { return func(dst, src reflect.Value) error { dst.FieldByName(tf.fieldName).Set(src.FieldByName(tf.fieldName)) return nil } } return nil } func TestValidateConfiguration(t *testing.T) { testCases := []struct { name string field string config *Config }{ { name: "with label", field: "Labels", config: &Config{ CommonConfig: CommonConfig{ Labels: []string{"one=two"}, }, }, }, { name: "with dns-search", field: "DNSConfig", config: &Config{ CommonConfig: CommonConfig{ DNSConfig: DNSConfig{ DNSSearch: []string{"a.b.c"}, }, }, }, }, { name: "with mtu", field: "MTU", config: &Config{ CommonConfig: CommonConfig{ BridgeConfig: BridgeConfig{ DefaultBridgeConfig: DefaultBridgeConfig{ MTU: 1234, }, }, }, }, }, { name: "with max-concurrent-downloads", field: "MaxConcurrentDownloads", config: &Config{ CommonConfig: CommonConfig{ MaxConcurrentDownloads: 4, }, }, }, { name: "with max-concurrent-uploads", field: "MaxConcurrentUploads", config: &Config{ CommonConfig: CommonConfig{ MaxConcurrentUploads: 4, }, }, }, { name: "with max-download-attempts", field: "MaxDownloadAttempts", config: &Config{ CommonConfig: CommonConfig{ MaxDownloadAttempts: 4, }, }, }, { name: "with multiple node generic resources", field: "NodeGenericResources", config: &Config{ CommonConfig: CommonConfig{ NodeGenericResources: []string{"foo=bar", "foo=baz"}, }, }, }, { name: "with node generic resources", field: "NodeGenericResources", config: &Config{ CommonConfig: CommonConfig{ NodeGenericResources: []string{"foo=1"}, }, }, }, { name: "with hosts", field: "Hosts", config: &Config{ CommonConfig: CommonConfig{ Hosts: []string{"tcp://127.0.0.1:2375"}, }, }, }, { name: "with log-level warn", field: "LogLevel", config: &Config{ CommonConfig: CommonConfig{ LogLevel: "warn", }, }, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { // Start with a config with all defaults set, so that we only cfg, err := New() assert.NilError(t, err) assert.Check(t, mergo.Merge(cfg, tc.config, mergo.WithOverride)) // Check that the override happened :) assert.Check(t, is.DeepEqual(cfg, tc.config, field(tc.field))) err = Validate(cfg) assert.NilError(t, err) }) } } func TestValidateMinAPIVersion(t *testing.T) { t.Parallel() tests := []struct { doc string input string expectedErr string }{ { doc: "empty", expectedErr: "value is empty", }, { doc: "with prefix", input: "v1.43", expectedErr: `API version must be provided without "v" prefix`, }, { doc: "major only", input: "1", expectedErr: `minimum supported API version is`, }, { doc: "too low", input: "1.0", expectedErr: `minimum supported API version is`, }, { doc: "minor too high", input: "1.99", expectedErr: `maximum supported API version is`, }, { doc: "major too high", input: "9.0", expectedErr: `maximum supported API version is`, }, { doc: "current version", input: api.DefaultVersion, }, } for _, tc := range tests { tc := tc t.Run(tc.doc, func(t *testing.T) { err := ValidateMinAPIVersion(tc.input) if tc.expectedErr != "" { assert.Check(t, is.ErrorContains(err, tc.expectedErr)) } else { assert.Check(t, err) } }) } } func TestConfigInvalidDNS(t *testing.T) { tests := []struct { doc string input string expectedErr string }{ { doc: "single DNS, invalid IP-address", input: `{"dns": ["1.1.1.1o"]}`, expectedErr: `invalid IP address: 1.1.1.1o`, }, { doc: "multiple DNS, invalid IP-address", input: `{"dns": ["2.2.2.2", "1.1.1.1o"]}`, expectedErr: `invalid IP address: 1.1.1.1o`, }, } for _, tc := range tests { tc := tc t.Run(tc.doc, func(t *testing.T) { var cfg Config err := json.Unmarshal([]byte(tc.input), &cfg) assert.Check(t, is.Error(err, tc.expectedErr)) }) } } func field(field string) cmp.Option { tmp := reflect.TypeOf(Config{}) ignoreFields := make([]string, 0, tmp.NumField()) for i := 0; i < tmp.NumField(); i++ { if tmp.Field(i).Name != field { ignoreFields = append(ignoreFields, tmp.Field(i).Name) } } return cmpopts.IgnoreFields(Config{}, ignoreFields...) } // TestReloadSetConfigFileNotExist tests that when `--config-file` is set, and it doesn't exist the `Reload` function // returns an error. func TestReloadSetConfigFileNotExist(t *testing.T) { configFile := "/tmp/blabla/not/exists/config.json" flags := pflag.NewFlagSet("test", pflag.ContinueOnError) flags.String("config-file", "", "") assert.Check(t, flags.Set("config-file", configFile)) err := Reload(configFile, flags, func(c *Config) {}) assert.Check(t, is.ErrorContains(err, "unable to configure the Docker daemon with file")) } // TestReloadDefaultConfigNotExist tests that if the default configuration file doesn't exist the daemon still will // still be reloaded. func TestReloadDefaultConfigNotExist(t *testing.T) { skip.If(t, os.Getuid() != 0, "skipping test that requires root") defaultConfigFile := "/tmp/blabla/not/exists/daemon.json" flags := pflag.NewFlagSet("test", pflag.ContinueOnError) flags.String("config-file", defaultConfigFile, "") reloaded := false err := Reload(defaultConfigFile, flags, func(c *Config) { reloaded = true }) assert.Check(t, err) assert.Check(t, reloaded) } // TestReloadBadDefaultConfig tests that when `--config-file` is not set and the default configuration file exists and // is bad, an error is returned. func TestReloadBadDefaultConfig(t *testing.T) { configFile := makeConfigFile(t, `{wrong: "configuration"}`) flags := pflag.NewFlagSet("test", pflag.ContinueOnError) flags.String("config-file", configFile, "") reloaded := false err := Reload(configFile, flags, func(c *Config) { reloaded = true }) assert.Check(t, is.ErrorContains(err, "unable to configure the Docker daemon with file")) assert.Check(t, reloaded == false) } func TestReloadWithConflictingLabels(t *testing.T) { configFile := makeConfigFile(t, `{"labels": ["foo=bar", "foo=baz"]}`) var lbls []string flags := pflag.NewFlagSet("test", pflag.ContinueOnError) flags.String("config-file", configFile, "") flags.StringSlice("labels", lbls, "") reloaded := false err := Reload(configFile, flags, func(c *Config) { reloaded = true }) assert.Check(t, is.ErrorContains(err, "conflict labels for foo=baz and foo=bar")) assert.Check(t, reloaded == false) } func TestReloadWithDuplicateLabels(t *testing.T) { configFile := makeConfigFile(t, `{"labels": ["foo=the-same", "foo=the-same"]}`) var lbls []string flags := pflag.NewFlagSet("test", pflag.ContinueOnError) flags.String("config-file", configFile, "") flags.StringSlice("labels", lbls, "") reloaded := false err := Reload(configFile, flags, func(c *Config) { reloaded = true assert.Check(t, is.DeepEqual(c.Labels, []string{"foo=the-same"})) }) assert.Check(t, err) assert.Check(t, reloaded) } func TestMaskURLCredentials(t *testing.T) { tests := []struct { rawURL string maskedURL string }{ { rawURL: "", maskedURL: "", }, { rawURL: "invalidURL", maskedURL: "invalidURL", }, { rawURL: "http://proxy.example.com:80/", maskedURL: "http://proxy.example.com:80/", }, { rawURL: "http://USER:PASSWORD@proxy.example.com:80/", maskedURL: "http://xxxxx:xxxxx@proxy.example.com:80/", }, { rawURL: "http://PASSWORD:PASSWORD@proxy.example.com:80/", maskedURL: "http://xxxxx:xxxxx@proxy.example.com:80/", }, { rawURL: "http://USER:@proxy.example.com:80/", maskedURL: "http://xxxxx:xxxxx@proxy.example.com:80/", }, { rawURL: "http://:PASSWORD@proxy.example.com:80/", maskedURL: "http://xxxxx:xxxxx@proxy.example.com:80/", }, { rawURL: "http://USER@docker:password@proxy.example.com:80/", maskedURL: "http://xxxxx:xxxxx@proxy.example.com:80/", }, { rawURL: "http://USER%40docker:password@proxy.example.com:80/", maskedURL: "http://xxxxx:xxxxx@proxy.example.com:80/", }, { rawURL: "http://USER%40docker:pa%3Fsword@proxy.example.com:80/", maskedURL: "http://xxxxx:xxxxx@proxy.example.com:80/", }, { rawURL: "http://USER%40docker:pa%3Fsword@proxy.example.com:80/hello%20world", maskedURL: "http://xxxxx:xxxxx@proxy.example.com:80/hello%20world", }, } for _, test := range tests { maskedURL := MaskCredentials(test.rawURL) assert.Equal(t, maskedURL, test.maskedURL) } }