diff --git a/config.go b/config.go new file mode 100644 index 0000000..9367f24 --- /dev/null +++ b/config.go @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: Copyright The Moby Authors +// SPDX-License-Identifier: Apache-2.0 + +package extensions + +import "fmt" + +// Config is parsed per-extension configuration delivered by id to Init or the +// out-of-process startup handshake. +type Config = map[string]any + +// ValidateExtensionConfigKey reports whether key can key extension +// configuration. +// A key is either a full extension id such as org.example.myext.v1, or an +// extension name without a version, such as org.example.myext, which applies +// to every version of that extension. +func ValidateExtensionConfigKey(key ExtensionID) error { + if ValidateExtensionID(key) != nil && !validExtensionName(string(key)) { + return fmt.Errorf("invalid extension config key %q: want an extension id like org.example.myext.v1, or a name like org.example.myext for any version", key) + } + + return nil +} + +// LookupConfig returns the configuration for extension id from configs. +// An entry keyed by id takes precedence over one keyed by the extension name, +// which is id without its version. +func LookupConfig(configs map[ExtensionID]Config, id ExtensionID) Config { + if config, ok := configs[id]; ok { + return config + } + + name, ok := cutExtensionVersion(id) + if !ok { + return nil + } + + return configs[ExtensionID(name)] +} diff --git a/config_test.go b/config_test.go new file mode 100644 index 0000000..45ac273 --- /dev/null +++ b/config_test.go @@ -0,0 +1,66 @@ +// SPDX-FileCopyrightText: Copyright The Moby Authors +// SPDX-License-Identifier: Apache-2.0 + +package extensions + +import ( + "testing" + + "gotest.tools/v3/assert" +) + +func TestValidateExtensionConfigKey(t *testing.T) { + valid := []ExtensionID{ + "org.example.myext.v1", + "org.example.myext", + "org.example.s3-volume", + "org.example.v1", + } + for _, key := range valid { + if err := ValidateExtensionConfigKey(key); err != nil { + t.Errorf("ValidateExtensionConfigKey(%q) = %v, want nil", key, err) + } + } + + invalid := []ExtensionID{ + "", + "foo", // only one name segment + "org.example.myext.v*", // wildcard + "org.example.myext.*", // wildcard + "org.example.myext.v1.v2", // name ends in a version + "Org.Example.myext", // uppercase + "org.example/evil", // path separator + } + for _, key := range invalid { + if err := ValidateExtensionConfigKey(key); err == nil { + t.Errorf("ValidateExtensionConfigKey(%q) = nil, want error", key) + } + } +} + +func TestLookupConfig(t *testing.T) { + exact := Config{"source": "exact"} + anyVersion := Config{"source": "any version"} + configs := map[ExtensionID]Config{ + "org.example.foo.v1": exact, + "org.example.foo": anyVersion, + "org.example.bar.v1": {"source": "bar"}, + } + + for _, tc := range []struct { + name string + id ExtensionID + want Config + }{ + {name: "exact id takes precedence", id: "org.example.foo.v1", want: exact}, + {name: "name key covers other versions", id: "org.example.foo.v2", want: anyVersion}, + {name: "other extension without a key", id: "org.example.baz.v1", want: nil}, + {name: "exact key does not cover other versions", id: "org.example.bar.v2", want: nil}, + {name: "name prefix does not match", id: "org.example.foobar.v1", want: nil}, + {name: "name key does not cover nested names", id: "org.example.foo.runtime.v1", want: nil}, + } { + t.Run(tc.name, func(t *testing.T) { + assert.DeepEqual(t, LookupConfig(configs, tc.id), tc.want) + }) + } +} diff --git a/core.go b/core.go index 39af0b0..32c71ac 100644 --- a/core.go +++ b/core.go @@ -112,11 +112,59 @@ func ValidatePointID(id PointID) error { return nil } -// extensionIDPattern requires a lowercase reverse-DNS name with a mandatory vN -// version. The version is a namespace element, not a semantic version: changing -// com.foo.v1 to com.foo.v2 creates a new extension, binary, and configuration. +// cutExtensionVersion splits id into its name and its mandatory ".vN" version. +// The version is a namespace element, not a semantic version: changing +// com.foo.v1 to com.foo.v2 creates a new extension and binary. +func cutExtensionVersion(id ExtensionID) (name string, ok bool) { + i := strings.LastIndexByte(string(id), '.') + if i < 0 || !isExtensionVersion(string(id[i+1:])) { + return "", false + } + + return string(id[:i]), true +} + +// isExtensionVersion reports whether segment is a version such as v1. +func isExtensionVersion(segment string) bool { + digits, ok := strings.CutPrefix(segment, "v") + if !ok || digits == "" { + return false + } + + for _, r := range digits { + if r < '0' || r > '9' { + return false + } + } + + return true +} + +// validExtensionName reports whether name is a lowercase reverse-DNS extension +// name: an extension id without its version. // The restricted characters also make the id safe as a config key and filename. -var extensionIDPattern = lazyRegexp(`^[a-z0-9]+(-[a-z0-9]+)*(\.[a-z0-9]+(-[a-z0-9]+)*)+\.v[0-9]+$`) +// A name must not end in a version itself, so a configuration key is either an +// extension id or an extension name, never both. +func validExtensionName(name string) bool { + segments := strings.Split(name, ".") + if len(segments) < 2 || isExtensionVersion(segments[len(segments)-1]) { + return false + } + + for _, segment := range segments { + if segment == "" || segment[0] == '-' || segment[len(segment)-1] == '-' || strings.Contains(segment, "--") { + return false + } + + for _, r := range segment { + if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '-' { + return false + } + } + } + + return true +} // ValidateExtensionID reports whether id is well-formed before it is used as a // configuration key or binary name. @@ -124,8 +172,10 @@ func ValidateExtensionID(id ExtensionID) error { if id == "" { return errors.New("extension id is required") } - if !extensionIDPattern().MatchString(string(id)) { - return fmt.Errorf("invalid extension id %q: want a versioned reverse-DNS name like org.example.myext.v1, lowercase, no path-hostile characters", id) + + name, ok := cutExtensionVersion(id) + if !ok || !validExtensionName(name) { + return fmt.Errorf("invalid extension id %q: want a versioned reverse-DNS name like org.example.myext.v1, lowercase, no path-hostile characters, with a name that does not end in a version", id) } return nil } @@ -306,10 +356,6 @@ func RegisterAll(registrar Registrar, exts ...Extension) error { return nil } -// Config is parsed per-extension configuration delivered by id to Init or the -// out-of-process startup handshake. -type Config = map[string]any - // Extension is a host-managed extension declaration. type Extension interface { // Declaration returns the extension's id, providers, dependencies, and diff --git a/core_test.go b/core_test.go index cf9c238..d39558c 100644 --- a/core_test.go +++ b/core_test.go @@ -104,6 +104,7 @@ func TestValidateExtensionID(t *testing.T) { "org.example.a b.v1", // whitespace "org..example.v1", // empty segment "org.example.under_score.v1", // underscore not allowed in extension ids + "org.example.v1.v2", // name ends in a version, so the key org.example.v1 would be ambiguous } for _, id := range invalid { if err := ValidateExtensionID(id); err == nil { diff --git a/docs/AUTHORING.md b/docs/AUTHORING.md index 0f8085a..3fc7e0a 100644 --- a/docs/AUTHORING.md +++ b/docs/AUTHORING.md @@ -331,7 +331,7 @@ func (b *Bridge) Declaration() extensions.Declaration { ``` `Init` receives the extension config and a resolver. -The application supplies config keyed by id through `host.WithExtensionConfig`; declare dependencies and conflicts in the declaration. +The application supplies config keyed by id, or by the id without its version for every version, through `host.WithExtensionConfig`; declare dependencies and conflicts in the declaration. The broker initializes dependencies before dependents. ### Retain dependencies for later calls @@ -414,11 +414,17 @@ Configure extensions when constructing the Host: ```go host.WithExtensionConfig(map[extensions.ExtensionID]extensions.Config{ "com.example.myext.v1": {"some_key": "value"}, + "com.example.other": {"some_key": "value"}, }), ``` The same entry reaches an in-process `Init` or a separate binary through the startup handshake. +A key without the version, such as `com.example.other`, applies to every version of that extension. +It matches only that name: `com.example.other` does not reach `com.example.other.runtime.v1`. +A key with the full id, such as `com.example.other.v2`, takes precedence over it. +Because each version is a separate extension, a versionless entry must suit the configuration format of every version it reaches. The application chooses how to read and validate this configuration; the framework does not read `daemon.json`. +The Host does not validate keys and silently ignores one that matches no extension; check their format with `extensions.ValidateExtensionConfigKey`. ### Run in-process @@ -509,7 +515,7 @@ See the [lifecycle limitations](./DESIGN.md#resolution-ordering-and-lifecycle). - Discover binaries with `host.WithDirs` and provide socket storage with `host.WithRuntimeDir`. - Wire internal calls to binaries with `host.WithClientProviders`. - Wire calls from binaries to Host providers with `host.WithDependencyProviders` and SDK `Server.Depends`. -- Supply configuration by extension id with `host.WithExtensionConfig`. +- Supply configuration by extension id, or by the id without its version for every version, with `host.WithExtensionConfig`. - Resolve Points through the Host and call their contract helpers. - Restrict internal providers with `host.WithProviderPolicy` using ordinary Point IDs. - Offer implemented Points with `servicev0.Offer`, authorize publication through policy on `servicev0.Point.ID()`, and supply `host.WithPointServers` for in-process offers. diff --git a/docs/DESIGN.md b/docs/DESIGN.md index d339720..3782dec 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -36,7 +36,9 @@ These names are distinct: The id is the binary name, configuration key, dependency name, and named-lookup selector, not an interface name. Registration rejects invalid ids. Its version is a namespace element, not a semantic version, and is independent of point versions. - Thus `com.foo.v1` and `com.foo.v2` are different extensions, binaries, and configurations that can coexist. + Thus `com.foo.v1` and `com.foo.v2` are different extensions and binaries that can coexist. + Their configuration is separate unless the application supplies a `com.foo` key, which applies to every version without an entry keyed by its full id. + The name before the version must not end in a version itself, so `com.foo.v1.v2` is invalid and a key is never both an id and a name. - **Extension identity** is the logical extension id plus the host-attested origin `builtin` or `executable`. Registration rejects an empty or unknown origin, an invalid identity id, or an identity id that differs from the declaration id. Providers and Host policy receive this validated identity. @@ -169,7 +171,7 @@ The binary name is its extension id (`.exe` on Windows), and the daemon laun Startup proceeds as follows: 1. The daemon writes JSON to stdin with `endpoint`, `protocolVersion`, `config`, and, when dependencies are offered, `callbackEndpoint`. - The current protocol version is `1`; `config` is the entry supplied through `host.WithExtensionConfig` for the id. + The current protocol version is `1`; `config` is the entry that `extensions.LookupConfig` selects for the id from `host.WithExtensionConfig`. 2. The SDK listens on the supplied Unix socket, then writes exactly `ready\n` to stdout. Stdout is reserved for this line: any earlier output corrupts the handshake and fails launch. The daemon also drains later stdout so it cannot block the process. diff --git a/host/host.go b/host/host.go index 84766b9..b439a5b 100644 --- a/host/host.go +++ b/host/host.go @@ -189,6 +189,9 @@ func WithReservedServices(services ...string) Option { } // WithExtensionConfig sets configuration keyed by extension id. +// A key without the version, such as org.example.myext, applies to every +// version of that extension; a key with the full id takes precedence. +// See [extensions.LookupConfig]. // The outer map is copied when this option is created, while configuration // values are shared. func WithExtensionConfig(config map[extensions.ExtensionID]extensions.Config) Option { diff --git a/internal/broker/broker.go b/internal/broker/broker.go index 1e55954..df67c6b 100644 --- a/internal/broker/broker.go +++ b/internal/broker/broker.go @@ -108,7 +108,8 @@ func (b *Broker) checkConflicts(ext extensions.Declaration) error { } // Init resolves dependencies and initializes all registered extensions in -// order, delivering each its configuration from configs (keyed by extension id). +// order, delivering each its configuration from configs, as selected by +// [extensions.LookupConfig]. func (b *Broker) Init(ctx context.Context, configs map[extensions.ExtensionID]extensions.Config) error { b.mu.Lock() resolved, err := b.resolveOrder() @@ -131,7 +132,7 @@ func (b *Broker) Init(ctx context.Context, configs map[extensions.ExtensionID]ex } // Init may resolve providers, so do not hold the lock across the callback. if initFn != nil { - if err := initFn(ctx, configs[id], b); err != nil { + if err := initFn(ctx, extensions.LookupConfig(configs, id), b); err != nil { return fmt.Errorf("initialize extension %q: %w", id, err) } } diff --git a/internal/launcher/launcher.go b/internal/launcher/launcher.go index a67ec27..1179691 100644 --- a/internal/launcher/launcher.go +++ b/internal/launcher/launcher.go @@ -32,8 +32,9 @@ type Launcher struct { RuntimeDir string ReadyTimeout time.Duration ShutdownTimeout time.Duration - // ExtensionConfig holds configuration keyed by extension id. The selected - // entry is delivered over the startup handshake. + // ExtensionConfig holds configuration keyed by extension id, or by an id + // without its version. The entry selected by [extensions.LookupConfig] is + // delivered over the startup handshake. ExtensionConfig map[extensions.ExtensionID]extensions.Config // CallbackEndpoint is the socket where the host serves dependencies. CallbackEndpoint string @@ -129,7 +130,7 @@ func (l Launcher) Launch(ctx context.Context, bin string) (*Launched, error) { startup := sdk.StartupConfig{ Endpoint: endpoint, ProtocolVersion: sdk.ProtocolVersion, - Config: l.ExtensionConfig[extensions.ExtensionID(name)], + Config: extensions.LookupConfig(l.ExtensionConfig, extensions.ExtensionID(name)), CallbackEndpoint: l.CallbackEndpoint, } launchCtx, cancel := context.WithTimeout(ctx, readyTimeout)