Merge pull request #54059 from vvoland/ext-config
daemon: Enable/disable extensions
This commit is contained in:
8 files changed
+369
-51
No files matched your search
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
+34
-21
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
+13
-8
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
//
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user