daemon: Enable/disable extensions

Add configuration options for disabling and enabling built-in
extensions.

This allow us to add built-ins that can be then chosen by the user.
For example, we can include multiple name generators and let the user
decide.

Signed-off-by: Paweł Gronowski <pawel.gronowski@docker.com>
This commit is contained in:
Paweł Gronowski committed 2026-10-30 19:02:58 +01:00
1 parent 5e1a5a88a5
commit df2f89eb5a
8 files changed
+207 -10

No files matched your search

+2
View File
@@ -24,6 +24,8 @@ 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("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")
+23 -1
View File
@@ -78,10 +78,13 @@ func TestLoadDaemonCliConfigWithExtensions(t *testing.T) {
{
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"}}},
},
@@ -89,12 +92,31 @@ func TestLoadDaemonCliConfigWithExtensions(t *testing.T) {
{
name: "merge different settings",
config: `{"extensions": {"configs": {"org.example.test.v1": {"workers": 4}}}}`,
flags: []string{"--extension-dir", "/opt/extensions"},
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",
+6 -2
View File
@@ -561,9 +561,9 @@ func getConflictFreeConfiguration(configFile string, flags *pflag.FlagSet) (*Con
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.
// CLI spellings and internal dotted extension keys are not JSON fields.
for key := range jsonConfig {
if strings.HasPrefix(key, "extensions.") {
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)
}
}
@@ -747,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
}
+12 -3
View File
@@ -70,7 +70,7 @@ func TestDaemonConfigurationExtensionConfig(t *testing.T) {
})
}
func TestDaemonConfigurationRejectsDottedExtensionKeys(t *testing.T) {
func TestDaemonConfigurationRejectsTopLevelExtensionKeys(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
@@ -82,6 +82,14 @@ func TestDaemonConfigurationRejectsDottedExtensionKeys(t *testing.T) {
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}}}`,
@@ -100,9 +108,10 @@ func TestDaemonConfigurationRejectsDottedExtensionKeys(t *testing.T) {
configFile := makeConfigFile(t, tc.config)
cfg := &Config{}
var disabled []string
var enabled, disabled []string
flags := pflag.NewFlagSet("test", pflag.ContinueOnError)
flags.Var(opts.NewNamedListOptsRef("extensions.disable", &disabled, nil), "disable-extension", "")
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)
+24 -2
View File
@@ -5,20 +5,42 @@ import (
"encoding/json"
"errors"
"fmt"
"slices"
"github.com/moby/extensions"
)
// ExtensionsConfig holds extension startup settings.
// ExtensionsConfig controls extension activation and startup configuration.
// Changes take effect on the next daemon start.
type ExtensionsConfig struct {
Dirs []string `json:"dirs,omitempty"`
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
+7 -2
View File
@@ -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 {
+43
View File
@@ -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.
//
+90
View File
@@ -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())
}
})
}
}