diff --git a/daemon/command/config.go b/daemon/command/config.go index a6e0a0015e..31797c413c 100644 --- a/daemon/command/config.go +++ b/daemon/command/config.go @@ -23,7 +23,9 @@ 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("extensions.enable", &conf.Extensions.Enable, nil), "extension-enable", "Built-in extensions to enable, by extension ID") + flags.Var(opts.NewNamedListOptsRef("extensions.disable", &conf.Extensions.Disable, nil), "extension-disable", "Built-in extensions to disable, by extension ID") 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") diff --git a/daemon/command/daemon_test.go b/daemon/command/daemon_test.go index dcabd2c71f..5629946cc4 100644 --- a/daemon/command/daemon_test.go +++ b/daemon/command/daemon_test.go @@ -67,6 +67,79 @@ 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": { + "enable": ["org.example.optional.v1"], + "disable": ["org.example.default.v1"], + "dirs": ["/opt/extensions"], + "configs": {"org.example.test.v1": {"dirs": "opaque"}} + }}`, + want: config.ExtensionsConfig{ + Enable: []string{"org.example.optional.v1"}, Disable: []string{"org.example.default.v1"}, + 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", "--extension-enable", "org.example.optional.v1", "--extension-disable", "org.example.default.v1"}, + want: config.ExtensionsConfig{ + Enable: []string{"org.example.optional.v1"}, Disable: []string{"org.example.default.v1"}, + Dirs: []string{"/opt/extensions"}, + Configs: config.ExtensionConfigs{{ID: "org.example.test.v1", Config: map[string]any{"workers": float64(4)}}}, + }, + }, + { + name: "enable flag conflict", config: `{"extensions": {"enable": []}}`, + flags: []string{"--extension-enable", "org.example.optional.v1"}, wantErr: "as a flag and in the configuration file: extensions.enable", + }, + { + name: "disable flag conflict", config: `{"extensions": {"disable": []}}`, + flags: []string{"--extension-disable", "org.example.default.v1"}, wantErr: "as a flag and in the configuration file: extensions.disable", + }, + { + name: "conflicting activation", config: `{"extensions": {"disable": ["org.example.optional.v1"]}}`, + flags: []string{"--extension-enable", "org.example.optional.v1"}, wantErr: "listed in both extensions.enable and extensions.disable", + }, + { + name: "invalid enable ID", config: `{"extensions": {"enable": ["invalid"]}}`, wantErr: "extensions.enable: invalid extension id", + }, + { + name: "invalid disable ID", config: `{"extensions": {"disable": ["invalid"]}}`, wantErr: "extensions.disable: invalid extension id", + }, + { + 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() diff --git a/daemon/config/config.go b/daemon/config/config.go index a8c06697c8..fc71b1cb38 100644 --- a/daemon/config/config.go +++ b/daemon/config/config.go @@ -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) + } + // CLI spellings and internal dotted extension keys are not JSON fields. + for key := range jsonConfig { + if strings.HasPrefix(key, "extensions.") || key == "extension-enable" || key == "extension-disable" { + 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 @@ -738,6 +747,10 @@ func ValidateMinAPIVersion(ver string) error { // such as config.DNS, config.Labels, config.DNSSearch, // as well as config.MaxConcurrentDownloads, config.MaxConcurrentUploads and config.MaxDownloadAttempts. func Validate(config *Config) error { + if err := config.Extensions.Validate(); err != nil { + return err + } + if err := validateDaemonLogConfig(config.DaemonLogConfig); err != nil { return err } @@ -884,6 +897,6 @@ func Sanitize(cfg Config) Config { HTTPSProxy: MaskCredentials(cfg.HTTPSProxy), NoProxy: MaskCredentials(cfg.NoProxy), } - cfg.CommonConfig.ExtensionConfig = nil + cfg.Extensions.Configs = nil return cfg } diff --git a/daemon/config/config_test.go b/daemon/config/config_test.go index 5f738febbf..6eb3ff0001 100644 --- a/daemon/config/config_test.go +++ b/daemon/config/config_test.go @@ -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,58 @@ func TestDaemonConfigurationExtensionConfig(t *testing.T) { }) } +func TestDaemonConfigurationRejectsTopLevelExtensionKeys(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: "enable flag spelling", key: "extension-enable", + config: `{"extension-enable": ["org.example.optional.v1"]}`, + }, + { + name: "disable flag spelling", key: "extension-disable", + config: `{"extension-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 enabled, disabled []string + flags := pflag.NewFlagSet("test", pflag.ContinueOnError) + flags.Var(opts.NewNamedListOptsRef("extensions.enable", &enabled, nil), "extension-enable", "") + flags.Var(opts.NewNamedListOptsRef("extensions.disable", &disabled, nil), "extension-disable", "") + 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 +154,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 +1047,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 +1068,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, })) } diff --git a/daemon/config/extensions.go b/daemon/config/extensions.go index 94d69bcc57..42d62efa7b 100644 --- a/daemon/config/extensions.go +++ b/daemon/config/extensions.go @@ -5,10 +5,42 @@ import ( "encoding/json" "errors" "fmt" + "slices" "github.com/moby/extensions" ) +// ExtensionsConfig controls extension activation and startup configuration. +// Changes take effect on the next daemon start. +type ExtensionsConfig struct { + Enable []string `json:"enable,omitempty"` + Disable []string `json:"disable,omitempty"` + Dirs []string `json:"dirs,omitempty"` + // Configs holds opaque configuration keyed by extension ID. + // Configuration alone does not enable an extension. + // The entry reaches in-process Init directly or a launched binary through + // the startup handshake. + Configs ExtensionConfigs `json:"configs,omitempty"` +} + +// Validate checks extension IDs and conflicting activation settings. +func (c ExtensionsConfig) Validate() error { + for _, id := range c.Enable { + if err := extensions.ValidateExtensionID(extensions.ExtensionID(id)); err != nil { + return fmt.Errorf("extensions.enable: %w", err) + } + if slices.Contains(c.Disable, id) { + return fmt.Errorf("extension %q is listed in both extensions.enable and extensions.disable", id) + } + } + for _, id := range c.Disable { + if err := extensions.ValidateExtensionID(extensions.ExtensionID(id)); err != nil { + return fmt.Errorf("extensions.disable: %w", err) + } + } + return nil +} + // ExtensionConfig holds the opaque configuration for one extension. type ExtensionConfig struct { ID extensions.ExtensionID @@ -32,7 +64,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 +75,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 +110,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 { diff --git a/daemon/extensions.go b/daemon/extensions.go index 9363abd5d0..32f0cdba06 100644 --- a/daemon/extensions.go +++ b/daemon/extensions.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "slices" "github.com/containerd/log" "github.com/moby/extensions" @@ -27,10 +28,14 @@ func (daemon *Daemon) newExtensionHost(ctx context.Context) (*host.Host, error) if err := os.Chmod(runtimeDir, 0o700); err != nil { return nil, fmt.Errorf("set permissions on extension runtime directory: %w", err) } + defaults := slices.Concat(builtinExtensions, daemon.daemonExtensions()) + exts, err := selectBuiltinExtensions(cfg.Extensions, defaults, daemon.optionalExtensions()) + if err != nil { + return nil, err + } return host.New(ctx, host.WithRuntimeDir(runtimeDir), - host.WithExtensions(builtinExtensions...), - host.WithExtensions(daemon.daemonExtensions()...), + host.WithExtensions(exts...), host.WithDirs(extensionDirs(&cfg)...), host.WithClientProviders(clientProviders()...), host.WithProviderPolicy(host.PointPolicyFunc(func(extensions.ExtensionIdentity, extensions.PointID) host.PointPolicyResult { @@ -41,13 +46,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 +62,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 { diff --git a/daemon/extensions_default.go b/daemon/extensions_default.go index b56ab58bd6..17e8c2f4d1 100644 --- a/daemon/extensions_default.go +++ b/daemon/extensions_default.go @@ -1,9 +1,15 @@ package daemon import ( + "fmt" + "maps" + "slices" + "strings" + "github.com/moby/extensions" "github.com/moby/extensions/clientpoint" "github.com/moby/extensions/serverpoint" + "github.com/moby/moby/v2/daemon/config" "github.com/moby/moby/v2/daemon/internal/extensionconfig" "github.com/moby/moby/v2/daemon/internal/extensionstorage/bbolt" @@ -21,6 +27,12 @@ var builtinExtensions = []extensions.Extension{ bbolt.Extension, } +// optionalExtensions lists built-ins that remain inactive until explicitly +// enabled through extensions.enable. +func (daemon *Daemon) optionalExtensions() []extensions.Extension { + return []extensions.Extension{} +} + // daemonExtensions declares extensions that need daemon-owned state. // Ordinary builtin definitions obtain their bootstrap settings through points. func (daemon *Daemon) daemonExtensions() []extensions.Extension { @@ -30,6 +42,37 @@ func (daemon *Daemon) daemonExtensions() []extensions.Extension { } } +// selectBuiltinExtensions applies activation settings to whole extensions. +// Provider conflicts and required dependencies are validated by the host. +func selectBuiltinExtensions(cfg config.ExtensionsConfig, defaults, optional []extensions.Extension) ([]extensions.Extension, error) { + if err := cfg.Validate(); err != nil { + return nil, err + } + + all := slices.Concat(defaults, optional) + enabled := make(map[string]bool, len(all)) + for _, ext := range optional { + enabled[string(ext.Declaration().ID)] = false + } + for _, ext := range defaults { + enabled[string(ext.Declaration().ID)] = true + } + for _, id := range slices.Concat(cfg.Enable, cfg.Disable) { + if _, known := enabled[id]; !known { + return nil, fmt.Errorf("extensions: unknown built-in extension %q (available: %s)", id, strings.Join(slices.Sorted(maps.Keys(enabled)), ", ")) + } + } + for _, id := range cfg.Enable { + enabled[id] = true + } + for _, id := range cfg.Disable { + enabled[id] = false + } + return slices.DeleteFunc(all, func(ext extensions.Extension) bool { + return !enabled[string(ext.Declaration().ID)] + }), nil +} + // clientProviders lists generated client wiring for points that launched // extensions may provide. // diff --git a/daemon/extensions_default_test.go b/daemon/extensions_default_test.go new file mode 100644 index 0000000000..c54db31ccc --- /dev/null +++ b/daemon/extensions_default_test.go @@ -0,0 +1,90 @@ +package daemon + +import ( + "testing" + + "github.com/moby/extensions" + "github.com/moby/moby/v2/daemon/config" + "gotest.tools/v3/assert" +) + +func TestSelectBuiltinExtensions(t *testing.T) { + t.Parallel() + + defaultExtension := extensions.New(extensions.Declaration{ + ID: "org.example.default.v1", + Providers: []extensions.Provider{ + {Point: "org.example.first.v0", Impl: "default-first"}, + {Point: "org.example.second.v0", Impl: "default-second"}, + }, + }) + optionalExtension := extensions.New(extensions.Declaration{ + ID: "org.example.optional.v1", + Providers: []extensions.Provider{ + {Point: "org.example.first.v0", Impl: "optional-first"}, + {Point: "org.example.second.v0", Impl: "optional-second"}, + }, + }) + defaults := []extensions.Extension{defaultExtension} + optional := []extensions.Extension{optionalExtension} + + for _, tc := range []struct { + name string + config config.ExtensionsConfig + want []extensions.Extension + wantErr string + }{ + { + name: "defaults", want: defaults, + }, + { + name: "configuration does not enable an optional extension", + config: config.ExtensionsConfig{Configs: config.ExtensionConfigs{{ID: "org.example.optional.v1", Config: map[string]any{"enabled": true}}}}, + want: defaults, + }, + { + name: "enable does not implicitly replace defaults", + config: config.ExtensionsConfig{Enable: []string{"org.example.optional.v1", "org.example.optional.v1", "org.example.default.v1"}}, + want: []extensions.Extension{defaultExtension, optionalExtension}, + }, + { + name: "replace whole extension", + config: config.ExtensionsConfig{Enable: []string{"org.example.optional.v1"}, Disable: []string{"org.example.default.v1"}}, + want: optional, + }, + { + name: "disable all", + config: config.ExtensionsConfig{Disable: []string{"org.example.default.v1", "org.example.optional.v1"}}, + }, + { + name: "conflicting activation", + config: config.ExtensionsConfig{Enable: []string{"org.example.optional.v1"}, Disable: []string{"org.example.optional.v1"}}, + wantErr: "listed in both extensions.enable and extensions.disable", + }, + { + name: "unknown enable ID", + config: config.ExtensionsConfig{Enable: []string{"org.example.unknown.v1"}}, + wantErr: `unknown built-in extension "org.example.unknown.v1" (available: org.example.default.v1, org.example.optional.v1)`, + }, + { + name: "unknown disable ID", + config: config.ExtensionsConfig{Disable: []string{"org.example.unknown.v1"}}, + wantErr: `unknown built-in extension "org.example.unknown.v1" (available: org.example.default.v1, org.example.optional.v1)`, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := selectBuiltinExtensions(tc.config, defaults, optional) + if tc.wantErr != "" { + assert.ErrorContains(t, err, tc.wantErr) + return + } + assert.NilError(t, err) + assert.Equal(t, len(got), len(tc.want)) + for i, ext := range got { + assert.DeepEqual(t, ext.Declaration(), tc.want[i].Declaration()) + } + }) + } +}