daemon/config: Group extension settings under extensions

Move extension directories and per-extension configuration into the
extensions object in daemon.json.

Signed-off-by: Paweł Gronowski <pawel.gronowski@docker.com>
This commit is contained in:
Paweł Gronowski committed 2026-10-29 14:20:59 +01:00
1 parent 9f07d9222f
commit 5e1a5a88a5
6 files changed
+170 -49

No files matched your search

+1 -1
View File
@@ -23,7 +23,7 @@ func installCommonConfigFlags(conf *config.Config, flags *pflag.FlagSet) {
flags.Var(opts.NewNamedListOptsRef("storage-opts", &conf.GraphOptions, nil), "storage-opt", "Storage driver options")
flags.Var(opts.NewNamedListOptsRef("authorization-plugins", &conf.AuthorizationPlugins, nil), "authorization-plugin", "Authorization plugins to load")
flags.Var(opts.NewNamedListOptsRef("extension-dirs", &conf.ExtensionDirs, nil), "extension-dir", "Directories to load extension binaries from")
flags.Var(opts.NewNamedListOptsRef("extensions.dirs", &conf.Extensions.Dirs, nil), "extension-dir", "Directories to load extension binaries from")
flags.Var(opts.NewNamedListOptsRef("exec-opts", &conf.ExecOptions, nil), "exec-opt", "Runtime execution options")
flags.StringVarP(&conf.Pidfile, "pidfile", "p", conf.Pidfile, "Path to use for daemon PID file")
flags.StringVar(&conf.Root, "data-root", conf.Root, "Root directory of persistent Docker state")
+51
View File
@@ -67,6 +67,57 @@ func TestLoadDaemonCliConfigWithConflicts(t *testing.T) {
assert.Check(t, is.ErrorContains(err, "as a flag and in the configuration file: labels"))
}
func TestLoadDaemonCliConfigWithExtensions(t *testing.T) {
for _, tc := range []struct {
name string
config string
flags []string
want config.ExtensionsConfig
wantErr string
}{
{
name: "nested settings",
config: `{"extensions": {
"dirs": ["/opt/extensions"],
"configs": {"org.example.test.v1": {"dirs": "opaque"}}
}}`,
want: config.ExtensionsConfig{
Dirs: []string{"/opt/extensions"},
Configs: config.ExtensionConfigs{{ID: "org.example.test.v1", Config: map[string]any{"dirs": "opaque"}}},
},
},
{
name: "merge different settings",
config: `{"extensions": {"configs": {"org.example.test.v1": {"workers": 4}}}}`,
flags: []string{"--extension-dir", "/opt/extensions"},
want: config.ExtensionsConfig{
Dirs: []string{"/opt/extensions"},
Configs: config.ExtensionConfigs{{ID: "org.example.test.v1", Config: map[string]any{"workers": float64(4)}}},
},
},
{
name: "directory flag conflict", config: `{"extensions": {"dirs": []}}`,
flags: []string{"--extension-dir", "/opt/extensions"}, wantErr: "as a flag and in the configuration file: extensions.dirs",
},
{
name: "unknown nested key", config: `{"extensions": {"directories": []}}`, wantErr: "don't match any configuration option: extensions.directories",
},
} {
t.Run(tc.name, func(t *testing.T) {
tempFile := fs.NewFile(t, "config", fs.WithContent(tc.config))
opts := defaultOptions(t, tempFile.Path())
assert.NilError(t, opts.flags.Parse(tc.flags))
cfg, err := loadDaemonCliConfig(opts)
if tc.wantErr != "" {
assert.ErrorContains(t, err, tc.wantErr)
return
}
assert.NilError(t, err)
assert.DeepEqual(t, cfg.Extensions, tc.want)
})
}
}
func TestLoadDaemonCliWithConflictingNodeGenericResources(t *testing.T) {
tempFile := fs.NewFile(t, "config", fs.WithContent(`{"node-generic-resources": ["foo=bar", "bar=baz"]}`))
configFile := tempFile.Path()
+30 -21
View File
@@ -92,7 +92,6 @@ var flatOptions = map[string]bool{
"features": true,
"builder": true,
"nri-opts": true,
"extension-config": true,
}
// skipValidateOptions contains configuration keys
@@ -103,8 +102,8 @@ var skipValidateOptions = map[string]bool{
"builder": true,
// Only available in daemon.json, no flags
"min-api-version": true,
"extension-config": true,
"min-api-version": true,
"extensions.configs": true,
// Deprecated options that are safe to ignore if present.
"deprecated-key-path": true,
@@ -207,19 +206,15 @@ type CommonConfig struct {
DisableBridge bool `json:"-"`
ExecOptions []string `json:"exec-opts,omitempty"`
// TODO: Should be renamed to StorageDriver
GraphDriver string `json:"storage-driver,omitempty"`
GraphOptions []string `json:"storage-opts,omitempty"`
Labels []string `json:"labels,omitempty"`
NetworkDiagnosticPort int `json:"network-diagnostic-port,omitempty"`
Pidfile string `json:"pidfile,omitempty"`
Root string `json:"data-root,omitempty"`
ExecRoot string `json:"exec-root,omitempty"`
ExtensionDirs []string `json:"extension-dirs,omitempty"`
// ExtensionConfig holds per-extension configuration keyed by extension ID.
// The entry reaches in-process Init directly or a launched binary through the
// startup handshake.
ExtensionConfig ExtensionConfigs `json:"extension-config,omitempty"`
SocketGroup string `json:"group,omitempty"`
GraphDriver string `json:"storage-driver,omitempty"`
GraphOptions []string `json:"storage-opts,omitempty"`
Labels []string `json:"labels,omitempty"`
NetworkDiagnosticPort int `json:"network-diagnostic-port,omitempty"`
Pidfile string `json:"pidfile,omitempty"`
Root string `json:"data-root,omitempty"`
ExecRoot string `json:"exec-root,omitempty"`
Extensions ExtensionsConfig `json:"extensions,omitempty"`
SocketGroup string `json:"group,omitempty"`
// Proxies holds the proxies that are configured for the daemon.
Proxies `json:"proxies"`
@@ -562,12 +557,18 @@ func getConflictFreeConfiguration(configFile string, flags *pflag.FlagSet) (*Con
return &config, nil // early return on empty config
}
if flags != nil {
var jsonConfig map[string]any
if err := json.Unmarshal(b, &jsonConfig); err != nil {
return nil, fmt.Errorf("invalid JSON: %w", err)
var jsonConfig map[string]any
if err := json.Unmarshal(b, &jsonConfig); err != nil {
return nil, fmt.Errorf("invalid JSON: %w", err)
}
// Dotted extension names are internal conflict-checking keys, not JSON fields.
for key := range jsonConfig {
if strings.HasPrefix(key, "extensions.") {
return nil, fmt.Errorf("invalid configuration directive %q: use the nested \"extensions\" object", key)
}
}
if flags != nil {
configSet := configValuesSet(jsonConfig)
if err := findConfigurationConflicts(configSet, flags); err != nil {
@@ -622,6 +623,14 @@ func getConflictFreeConfiguration(configFile string, flags *pflag.FlagSet) (*Con
func configValuesSet(config map[string]any) map[string]any {
flatten := make(map[string]any)
for k, v := range config {
if k == "extensions" {
if m, ok := v.(map[string]any); ok {
for name, value := range m {
flatten["extensions."+name] = value
}
continue
}
}
if m, isMap := v.(map[string]any); isMap && !flatOptions[k] {
maps.Copy(flatten, m)
continue
@@ -884,6 +893,6 @@ func Sanitize(cfg Config) Config {
HTTPSProxy: MaskCredentials(cfg.HTTPSProxy),
NoProxy: MaskCredentials(cfg.NoProxy),
}
cfg.CommonConfig.ExtensionConfig = nil
cfg.Extensions.Configs = nil
return cfg
}
+68 -17
View File
@@ -49,15 +49,17 @@ func TestDaemonConfigurationMalformedJSON(t *testing.T) {
func TestDaemonConfigurationExtensionConfig(t *testing.T) {
configFile := makeConfigFile(t, `{
"extension-config": {
"org.example.foo.v1": {"plugin_path": "/opt/foo", "enabled": true},
"com.docker.compose.v1": {"workers": 4}
"extensions": {
"configs": {
"org.example.foo.v1": {"plugin_path": "/opt/foo", "enabled": true},
"com.docker.compose.v1": {"workers": 4}
}
}
}`)
flags := pflag.NewFlagSet("test", pflag.ContinueOnError)
cfg, err := MergeDaemonConfigurations(&Config{}, flags, configFile)
assert.NilError(t, err)
assert.DeepEqual(t, cfg.ExtensionConfig, ExtensionConfigs{
assert.DeepEqual(t, cfg.Extensions.Configs, ExtensionConfigs{
{ID: "org.example.foo.v1", Config: map[string]any{
"plugin_path": "/opt/foo",
"enabled": true,
@@ -68,6 +70,49 @@ func TestDaemonConfigurationExtensionConfig(t *testing.T) {
})
}
func TestDaemonConfigurationRejectsDottedExtensionKeys(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
config string
key string
}{
{
name: "activation", key: "extensions.disable",
config: `{"extensions.disable": ["org.mobyproject.namesgenerator.legacy.v1"]}`,
},
{
name: "configuration", key: "extensions.configs",
config: `{"extensions.configs": {"org.example.test.v1": {"workers": 4}}}`,
},
{
name: "unknown empty object", key: "extensions.unknown",
config: `{"extensions.unknown": {}}`,
},
{
name: "mixed nested and dotted settings", key: "extensions.disable",
config: `{"extensions": {"disable": []}, "extensions.disable": ["org.mobyproject.namesgenerator.legacy.v1"]}`,
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
configFile := makeConfigFile(t, tc.config)
cfg := &Config{}
var disabled []string
flags := pflag.NewFlagSet("test", pflag.ContinueOnError)
flags.Var(opts.NewNamedListOptsRef("extensions.disable", &disabled, nil), "disable-extension", "")
for name, flagSet := range map[string]*pflag.FlagSet{"with flags": flags, "without flags": nil} {
t.Run(name, func(t *testing.T) {
_, err := MergeDaemonConfigurations(cfg, flagSet, configFile)
assert.ErrorContains(t, err, `invalid configuration directive "`+tc.key+`": use the nested "extensions" object`)
})
}
})
}
}
func TestExtensionConfigsMarshalOrder(t *testing.T) {
configs := ExtensionConfigs{
{ID: "org.example.first.v1", Config: map[string]any{"name": "first"}},
@@ -100,23 +145,27 @@ func TestExtensionConfigsNullAndEmpty(t *testing.T) {
func TestDaemonConfigurationInvalidExtensionConfigID(t *testing.T) {
configFile := makeConfigFile(t, `{
"extension-config": {
"org.example.foo": {"enabled": true}
"extensions": {
"configs": {
"org.example.foo": {"enabled": true}
}
}
}`)
_, err := MergeDaemonConfigurations(&Config{}, nil, configFile)
assert.ErrorContains(t, err, `invalid extension-config key "org.example.foo"`)
assert.ErrorContains(t, err, `invalid extensions.configs key "org.example.foo"`)
}
func TestDaemonConfigurationExtensionDirs(t *testing.T) {
configFile := makeConfigFile(t, `{
"extension-dirs": ["/opt/extensions/one", "/opt/extensions/two"]
"extensions": {
"dirs": ["/opt/extensions/one", "/opt/extensions/two"]
}
}`)
cfg, err := MergeDaemonConfigurations(&Config{}, nil, configFile)
assert.NilError(t, err)
assert.DeepEqual(t, cfg.ExtensionDirs, []string{"/opt/extensions/one", "/opt/extensions/two"})
assert.DeepEqual(t, cfg.Extensions.Dirs, []string{"/opt/extensions/one", "/opt/extensions/two"})
}
// TestDaemonConfigurationUnicodeVariations feeds various variations of Unicode into the JSON parser, ensuring that we
@@ -989,12 +1038,14 @@ func TestSanitize(t *testing.T) {
HTTPSProxy: proxyRawURL,
NoProxy: proxyRawURL,
},
ExtensionConfig: ExtensionConfigs{{
ID: extensionID,
Config: map[string]any{
"secret": extensionSecret,
},
}},
Extensions: ExtensionsConfig{
Configs: ExtensionConfigs{{
ID: extensionID,
Config: map[string]any{
"secret": extensionSecret,
},
}},
},
},
}
sanitizedCfg := Sanitize(cfg)
@@ -1008,8 +1059,8 @@ func TestSanitize(t *testing.T) {
sanitizedJSON, err := json.Marshal(sanitizedCfg)
assert.NilError(t, err)
assert.Assert(t, !strings.Contains(string(sanitizedJSON), extensionSecret))
assert.Assert(t, !strings.Contains(string(sanitizedJSON), `"extension-config"`))
assert.Check(t, is.DeepEqual(cfg.ExtensionConfig[0].Config, map[string]any{
assert.Assert(t, !strings.Contains(string(sanitizedJSON), `"configs"`))
assert.Check(t, is.DeepEqual(cfg.Extensions.Configs[0].Config, map[string]any{
"secret": extensionSecret,
}))
}
+14 -4
View File
@@ -9,6 +9,16 @@ import (
"github.com/moby/extensions"
)
// ExtensionsConfig holds extension startup settings.
// Changes take effect on the next daemon start.
type ExtensionsConfig struct {
Dirs []string `json:"dirs,omitempty"`
// Configs holds opaque configuration keyed by extension ID.
// The entry reaches in-process Init directly or a launched binary through
// the startup handshake.
Configs ExtensionConfigs `json:"configs,omitempty"`
}
// ExtensionConfig holds the opaque configuration for one extension.
type ExtensionConfig struct {
ID extensions.ExtensionID
@@ -32,7 +42,7 @@ func (c *ExtensionConfigs) UnmarshalJSON(data []byte) error {
delim, ok := token.(json.Delim)
if !ok || delim != '{' {
return errors.New("extension-config must be a JSON object")
return errors.New("extensions.configs must be a JSON object")
}
configs := make(ExtensionConfigs, 0)
@@ -43,11 +53,11 @@ func (c *ExtensionConfigs) UnmarshalJSON(data []byte) error {
}
id, ok := token.(string)
if !ok {
return errors.New("extension-config key must be a string")
return errors.New("extensions.configs key must be a string")
}
extensionID := extensions.ExtensionID(id)
if err := extensions.ValidateExtensionID(extensionID); err != nil {
return fmt.Errorf("invalid extension-config key %q: %w", id, err)
return fmt.Errorf("invalid extensions.configs key %q: %w", id, err)
}
var extensionConfig map[string]any
@@ -78,7 +88,7 @@ func (c ExtensionConfigs) MarshalJSON() ([]byte, error) {
}
if err := extensions.ValidateExtensionID(extensionConfig.ID); err != nil {
return nil, fmt.Errorf("invalid extension-config key %q: %w", extensionConfig.ID, err)
return nil, fmt.Errorf("invalid extensions.configs key %q: %w", extensionConfig.ID, err)
}
id, err := json.Marshal(extensionConfig.ID)
if err != nil {
+6 -6
View File
@@ -41,13 +41,13 @@ func (daemon *Daemon) newExtensionHost(ctx context.Context) (*host.Host, error)
)
}
// extensionConfig converts daemon.json's extension-config entries to host form.
// extensionConfig converts daemon.json's extensions.configs entries to host form.
func extensionConfig(cfg *config.Config) map[extensions.ExtensionID]extensions.Config {
if len(cfg.ExtensionConfig) == 0 {
if len(cfg.Extensions.Configs) == 0 {
return nil
}
out := make(map[extensions.ExtensionID]extensions.Config, len(cfg.ExtensionConfig))
for _, extensionConfig := range cfg.ExtensionConfig {
out := make(map[extensions.ExtensionID]extensions.Config, len(cfg.Extensions.Configs))
for _, extensionConfig := range cfg.Extensions.Configs {
out[extensionConfig.ID] = extensionConfig.Config
}
return out
@@ -57,8 +57,8 @@ func extensionConfig(cfg *config.Config) map[extensions.ExtensionID]extensions.C
// binaries: the ones configured with --extension-dir, or the default location
// when none are configured.
func extensionDirs(cfg *config.Config) []string {
if len(cfg.ExtensionDirs) > 0 {
return cfg.ExtensionDirs
if len(cfg.Extensions.Dirs) > 0 {
return cfg.Extensions.Dirs
}
dir, err := defaultExtensionDir()
if err != nil {