Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions config.go
Original file line number Diff line number Diff line change
@@ -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)]
}
66 changes: 66 additions & 0 deletions config_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
}
66 changes: 56 additions & 10 deletions core.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,20 +112,70 @@ 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.
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
}
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions core_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
10 changes: 8 additions & 2 deletions docs/AUTHORING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Comment on lines +422 to +425

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, having (e.g.) both com.example.other.runtime.v1 and com.example.other.runtime makes one config apply to v1, and the other for com.example.other.runtime.v0, com.example.other.runtime.v2, com.example.other.runtime.v3 (any other version), right?

Probably works, yes; alternative could be to not allow a mix of versioned and non-versioned for the same match. (Not sure if that's better/clearer though).

Mostly thinking there if there's situations where I would have multiple versions of the same extension installed (could we come up with reasons for that? do we / do we want to allow that?)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think there's a reason to allow multiple versions of the same extension running at the same time.

For mixing.. I'm thinking if it would make sense to merge them - so the "versionless" kinda acts as a base, and then the per version can adjust it. But I don't think we should do it now.

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

Expand Down Expand Up @@ -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.
Expand Down
6 changes: 4 additions & 2 deletions docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -169,7 +171,7 @@ The binary name is its extension id (`<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.
Expand Down
3 changes: 3 additions & 0 deletions host/host.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
5 changes: 3 additions & 2 deletions internal/broker/broker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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)
}
}
Expand Down
7 changes: 4 additions & 3 deletions internal/launcher/launcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading