daemon: Move name-generator provider resolution out of extpoints
The extpoints packages define the extension contract. Provider selection, timeouts, name validation, and the built-in fallback are daemon policy, and the daemon is the only caller. The fallback helper is can be moved to github.com/moby/extensions once another point needs it. Signed-off-by: Paweł Gronowski <pawel.gronowski@docker.com>
This commit is contained in:
9 files changed
+357
-395
No files matched your search
+2
-10
@@ -97,14 +97,10 @@ func (daemon *Daemon) GenerateServiceName(ctx context.Context, retry int, image
|
||||
return "", errors.New("name generator extension host is not configured")
|
||||
}
|
||||
|
||||
reply, err := servicenamegeneratorv0.GenerateServiceName(ctx, daemon.extensionHost, &servicenamegeneratorv0.GenerateServiceNameRequest{
|
||||
name, err := resolveServiceName(ctx, daemon.extensionHost, &servicenamegeneratorv0.GenerateServiceNameRequest{
|
||||
Retry: int64(retry),
|
||||
Image: image,
|
||||
})
|
||||
var name string
|
||||
if reply != nil {
|
||||
name = reply.Name
|
||||
}
|
||||
return generatedName(ctx, name, err)
|
||||
}
|
||||
|
||||
@@ -113,11 +109,7 @@ func (daemon *Daemon) generateContainerName(ctx context.Context, req *containern
|
||||
return "", errors.New("name generator extension host is not configured")
|
||||
}
|
||||
|
||||
reply, err := containernamegeneratorv0.GenerateContainerName(ctx, daemon.extensionHost, req)
|
||||
var name string
|
||||
if reply != nil {
|
||||
name = reply.Name
|
||||
}
|
||||
name, err := resolveContainerName(ctx, daemon.extensionHost, req)
|
||||
return generatedName(ctx, name, err)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/moby/extensions"
|
||||
containernamegeneratorv0 "github.com/moby/moby/v2/extpoints/containernamegenerator/v0"
|
||||
servicenamegeneratorv0 "github.com/moby/moby/v2/extpoints/servicenamegenerator/v0"
|
||||
)
|
||||
|
||||
const nameProviderTimeout = 5 * time.Second
|
||||
|
||||
// resolveContainerName calls the effective container name provider.
|
||||
func resolveContainerName(ctx context.Context, resolver extensions.Resolver, req *containernamegeneratorv0.GenerateContainerNameRequest) (string, error) {
|
||||
return callSingleWithFallback(ctx, containernamegeneratorv0.Point, resolver, nameProviderTimeout, func(ctx context.Context, generator containernamegeneratorv0.ContainerNameGenerator) (string, error) {
|
||||
reply, err := generator.GenerateContainerName(ctx, req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if reply == nil {
|
||||
return "", errors.New("provider returned a nil reply")
|
||||
}
|
||||
return reply.Name, validateGeneratedName(reply.Name)
|
||||
})
|
||||
}
|
||||
|
||||
// resolveServiceName calls the effective service name provider.
|
||||
func resolveServiceName(ctx context.Context, resolver extensions.Resolver, req *servicenamegeneratorv0.GenerateServiceNameRequest) (string, error) {
|
||||
return callSingleWithFallback(ctx, servicenamegeneratorv0.Point, resolver, nameProviderTimeout, func(ctx context.Context, generator servicenamegeneratorv0.ServiceNameGenerator) (string, error) {
|
||||
reply, err := generator.GenerateServiceName(ctx, req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if reply == nil {
|
||||
return "", errors.New("provider returned a nil reply")
|
||||
}
|
||||
return reply.Name, validateGeneratedName(reply.Name)
|
||||
})
|
||||
}
|
||||
|
||||
func validateGeneratedName(name string) error {
|
||||
if name == "" {
|
||||
return errors.New("provider returned an empty name")
|
||||
}
|
||||
if !isValidGeneratedName(name) {
|
||||
return errors.New("provider returned an invalid name: expected 2-63 ASCII letters, digits, hyphens, or underscores, starting and ending with a letter or digit")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isValidGeneratedName(name string) bool {
|
||||
if len(name) < 2 || len(name) > 63 {
|
||||
return false
|
||||
}
|
||||
for i := range len(name) {
|
||||
ch := name[i]
|
||||
if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') {
|
||||
continue
|
||||
}
|
||||
if i > 0 && i < len(name)-1 && (ch == '-' || ch == '_') {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// callSingleWithFallback calls the effective provider of a single point.
|
||||
// An executable provider hides the built-in during normal resolution, but the
|
||||
// full provider list retains it as a fallback.
|
||||
// If the executable provider fails and the built-in succeeds, it returns the
|
||||
// built-in result together with the executable provider's error so callers can
|
||||
// report the extension failure without blocking the operation.
|
||||
// If the built-in also fails, the returned error contains both failures.
|
||||
// Each provider call gets its own timeout.
|
||||
//
|
||||
// It is the single-point counterpart of [extensions.Each] and
|
||||
// [extensions.Fold]; move it to github.com/moby/extensions once points outside
|
||||
// name generation need it.
|
||||
func callSingleWithFallback[T, R any](ctx context.Context, point extensions.Point[T], resolver extensions.Resolver, timeout time.Duration, fn func(context.Context, T) (R, error)) (R, error) {
|
||||
providers := resolver.Providers(point.ID())
|
||||
primary, err := effectiveSingleProvider(point.ID(), providers)
|
||||
if err != nil {
|
||||
var zero R
|
||||
return zero, err
|
||||
}
|
||||
|
||||
result, primaryErr := callTypedProvider(ctx, point, primary, timeout, fn)
|
||||
if primaryErr == nil || primary.Identity.Origin.Kind == extensions.ExtensionOriginBuiltin {
|
||||
return result, primaryErr
|
||||
}
|
||||
|
||||
fallback, ok := findBuiltinFallback(providers, primary)
|
||||
if !ok {
|
||||
return result, primaryErr
|
||||
}
|
||||
|
||||
result, fallbackErr := callTypedProvider(ctx, point, fallback, timeout, fn)
|
||||
return result, errors.Join(primaryErr, fallbackErr)
|
||||
}
|
||||
|
||||
func effectiveSingleProvider(point extensions.PointID, providers []extensions.ResolvedProvider) (extensions.ResolvedProvider, error) {
|
||||
effective := extensions.EffectiveProviders(providers)
|
||||
switch len(effective) {
|
||||
case 0:
|
||||
return extensions.ResolvedProvider{}, fmt.Errorf("point %q has no providers", point)
|
||||
case 1:
|
||||
return effective[0], nil
|
||||
default:
|
||||
return extensions.ResolvedProvider{}, fmt.Errorf("point %q has multiple providers", point)
|
||||
}
|
||||
}
|
||||
|
||||
func findBuiltinFallback(providers []extensions.ResolvedProvider, primary extensions.ResolvedProvider) (extensions.ResolvedProvider, bool) {
|
||||
for _, provider := range providers {
|
||||
if provider.Identity.Origin.Kind == extensions.ExtensionOriginBuiltin && provider.Identity.ID != primary.Identity.ID {
|
||||
return provider, true
|
||||
}
|
||||
}
|
||||
return extensions.ResolvedProvider{}, false
|
||||
}
|
||||
|
||||
func callTypedProvider[T, R any](ctx context.Context, point extensions.Point[T], provider extensions.ResolvedProvider, timeout time.Duration, fn func(context.Context, T) (R, error)) (R, error) {
|
||||
var zero R
|
||||
impl, ok := provider.Impl.(T)
|
||||
if !ok {
|
||||
return zero, providerError(point.ID(), provider.Identity.ID, fmt.Errorf("provider has type %T", provider.Impl))
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
result, err := fn(ctx, impl)
|
||||
if err != nil {
|
||||
return zero, providerError(point.ID(), provider.Identity.ID, err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func providerError(point extensions.PointID, extension extensions.ExtensionID, err error) error {
|
||||
return fmt.Errorf("%s provider %q: %w", point.Name(), extension, err)
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/moby/extensions"
|
||||
containernamegeneratorv0 "github.com/moby/moby/v2/extpoints/containernamegenerator/v0"
|
||||
servicenamegeneratorv0 "github.com/moby/moby/v2/extpoints/servicenamegenerator/v0"
|
||||
"github.com/moby/moby/v2/internal/testutil/staticresolver"
|
||||
"gotest.tools/v3/assert"
|
||||
)
|
||||
|
||||
var testSinglePoint = extensions.DefineSinglePoint[any]("org.example.single.v0")
|
||||
|
||||
func resolvedTestProvider(id extensions.ExtensionID, impl any, builtin bool) extensions.ResolvedProvider {
|
||||
origin := extensions.ExtensionOrigin{
|
||||
Kind: extensions.ExtensionOriginExecutable,
|
||||
Executable: &extensions.ExecutableOrigin{Path: string(id)},
|
||||
}
|
||||
if builtin {
|
||||
origin = extensions.ExtensionOrigin{Kind: extensions.ExtensionOriginBuiltin}
|
||||
}
|
||||
return extensions.ResolvedProvider{
|
||||
Identity: extensions.ExtensionIdentity{ID: id, Origin: origin},
|
||||
Impl: impl,
|
||||
}
|
||||
}
|
||||
|
||||
type containerNameGeneratorFunc func(context.Context, *containernamegeneratorv0.GenerateContainerNameRequest) (*containernamegeneratorv0.GenerateContainerNameReply, error)
|
||||
|
||||
func (f containerNameGeneratorFunc) GenerateContainerName(ctx context.Context, req *containernamegeneratorv0.GenerateContainerNameRequest) (*containernamegeneratorv0.GenerateContainerNameReply, error) {
|
||||
return f(ctx, req)
|
||||
}
|
||||
|
||||
func TestResolveContainerName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
provider := containerNameGeneratorFunc(func(_ context.Context, req *containernamegeneratorv0.GenerateContainerNameRequest) (*containernamegeneratorv0.GenerateContainerNameReply, error) {
|
||||
assert.Equal(t, req.Retry, int64(3))
|
||||
assert.Equal(t, req.ContainerID, "container-id")
|
||||
assert.Equal(t, req.Image, "image:latest")
|
||||
return &containernamegeneratorv0.GenerateContainerNameReply{Name: "container-name"}, nil
|
||||
})
|
||||
resolver := staticresolver.New(resolvedTestProvider("org.example.containernamegenerator.v1", provider, true))
|
||||
|
||||
name, err := resolveContainerName(t.Context(), resolver, &containernamegeneratorv0.GenerateContainerNameRequest{
|
||||
Retry: 3,
|
||||
ContainerID: "container-id",
|
||||
Image: "image:latest",
|
||||
})
|
||||
assert.NilError(t, err)
|
||||
assert.Equal(t, name, "container-name")
|
||||
}
|
||||
|
||||
func TestResolveContainerNameFallsBackToBuiltin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
replacementName string
|
||||
replacementErr error
|
||||
wantErr string
|
||||
}{
|
||||
{name: "error", replacementErr: errors.New("replacement failed"), wantErr: "replacement failed"},
|
||||
{name: "empty name", wantErr: "provider returned an empty name"},
|
||||
{name: "invalid character", replacementName: "invalid.name", wantErr: "provider returned an invalid name"},
|
||||
{name: "too short", replacementName: "x", wantErr: "provider returned an invalid name"},
|
||||
{name: "too long", replacementName: strings.Repeat("x", 64), wantErr: "provider returned an invalid name"},
|
||||
{name: "ending with separator", replacementName: "invalid_name_", wantErr: "provider returned an invalid name"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
builtinCalls := 0
|
||||
replacement := containerNameGeneratorFunc(func(context.Context, *containernamegeneratorv0.GenerateContainerNameRequest) (*containernamegeneratorv0.GenerateContainerNameReply, error) {
|
||||
if tc.replacementErr != nil {
|
||||
return nil, tc.replacementErr
|
||||
}
|
||||
return &containernamegeneratorv0.GenerateContainerNameReply{Name: tc.replacementName}, nil
|
||||
})
|
||||
builtin := containerNameGeneratorFunc(func(ctx context.Context, _ *containernamegeneratorv0.GenerateContainerNameRequest) (*containernamegeneratorv0.GenerateContainerNameReply, error) {
|
||||
_, ok := ctx.Deadline()
|
||||
assert.Assert(t, ok, "the fallback attempt must have its own deadline")
|
||||
builtinCalls++
|
||||
return &containernamegeneratorv0.GenerateContainerNameReply{Name: "builtin-name"}, nil
|
||||
})
|
||||
resolver := staticresolver.New(
|
||||
resolvedTestProvider("org.example.replacement.v1", replacement, false),
|
||||
resolvedTestProvider("org.example.builtin.v1", builtin, true),
|
||||
)
|
||||
|
||||
name, err := resolveContainerName(t.Context(), resolver, &containernamegeneratorv0.GenerateContainerNameRequest{})
|
||||
assert.ErrorContains(t, err, `containernamegenerator provider "org.example.replacement.v1"`)
|
||||
assert.ErrorContains(t, err, tc.wantErr)
|
||||
assert.Equal(t, name, "builtin-name")
|
||||
assert.Equal(t, builtinCalls, 1)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type serviceNameGeneratorFunc func(context.Context, *servicenamegeneratorv0.GenerateServiceNameRequest) (*servicenamegeneratorv0.GenerateServiceNameReply, error)
|
||||
|
||||
func (f serviceNameGeneratorFunc) GenerateServiceName(ctx context.Context, req *servicenamegeneratorv0.GenerateServiceNameRequest) (*servicenamegeneratorv0.GenerateServiceNameReply, error) {
|
||||
return f(ctx, req)
|
||||
}
|
||||
|
||||
func TestResolveServiceName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
provider := serviceNameGeneratorFunc(func(_ context.Context, req *servicenamegeneratorv0.GenerateServiceNameRequest) (*servicenamegeneratorv0.GenerateServiceNameReply, error) {
|
||||
assert.Equal(t, req.Retry, int64(3))
|
||||
assert.Equal(t, req.Image, "image:latest")
|
||||
return &servicenamegeneratorv0.GenerateServiceNameReply{Name: "service-name"}, nil
|
||||
})
|
||||
resolver := staticresolver.New(resolvedTestProvider("org.example.servicenamegenerator.v1", provider, true))
|
||||
|
||||
name, err := resolveServiceName(t.Context(), resolver, &servicenamegeneratorv0.GenerateServiceNameRequest{
|
||||
Retry: 3,
|
||||
Image: "image:latest",
|
||||
})
|
||||
assert.NilError(t, err)
|
||||
assert.Equal(t, name, "service-name")
|
||||
}
|
||||
|
||||
func TestCallSingleWithFallbackUsesReplacementProvider(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
builtinCalled := false
|
||||
resolver := staticresolver.New(
|
||||
resolvedTestProvider("org.example.replacement.v1", "replacement", false),
|
||||
resolvedTestProvider("org.example.builtin.v1", "builtin", true),
|
||||
)
|
||||
result, err := callSingleWithFallback(t.Context(), testSinglePoint, resolver, time.Second, func(ctx context.Context, impl any) (string, error) {
|
||||
deadline, ok := ctx.Deadline()
|
||||
assert.Assert(t, ok)
|
||||
assert.Assert(t, time.Until(deadline) <= time.Second)
|
||||
if impl == "builtin" {
|
||||
builtinCalled = true
|
||||
return "builtin-result", nil
|
||||
}
|
||||
return "replacement-result", nil
|
||||
})
|
||||
assert.NilError(t, err)
|
||||
assert.Equal(t, result, "replacement-result")
|
||||
assert.Assert(t, !builtinCalled)
|
||||
}
|
||||
|
||||
func TestCallSingleWithFallbackDoesNotRetryFailingBuiltin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
calls := 0
|
||||
result, err := callSingleWithFallback(t.Context(), testSinglePoint, staticresolver.New(
|
||||
resolvedTestProvider("org.example.builtin.v1", "builtin", true),
|
||||
), time.Second, func(context.Context, any) (string, error) {
|
||||
calls++
|
||||
return "partial", errors.New("builtin failed")
|
||||
})
|
||||
assert.Equal(t, result, "")
|
||||
assert.ErrorContains(t, err, `single provider "org.example.builtin.v1": builtin failed`)
|
||||
assert.Equal(t, calls, 1)
|
||||
}
|
||||
|
||||
func TestCallSingleWithFallbackJoinsReplacementAndBuiltinErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
replacementErr := errors.New("replacement failed")
|
||||
builtinErr := errors.New("builtin failed")
|
||||
result, err := callSingleWithFallback(t.Context(), testSinglePoint, staticresolver.New(
|
||||
resolvedTestProvider("org.example.replacement.v1", replacementErr, false),
|
||||
resolvedTestProvider("org.example.builtin.v1", builtinErr, true),
|
||||
), time.Second, func(_ context.Context, impl any) (string, error) {
|
||||
return "", impl.(error)
|
||||
})
|
||||
assert.Equal(t, result, "")
|
||||
assert.Assert(t, errors.Is(err, replacementErr))
|
||||
assert.Assert(t, errors.Is(err, builtinErr))
|
||||
}
|
||||
|
||||
func TestCallSingleWithFallbackRequiresExactlyOneEffectiveProvider(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fn := func(context.Context, any) (string, error) { return "result", nil }
|
||||
_, err := callSingleWithFallback(t.Context(), testSinglePoint, staticresolver.New(), time.Second, fn)
|
||||
assert.ErrorContains(t, err, "has no providers")
|
||||
|
||||
_, err = callSingleWithFallback(t.Context(), testSinglePoint, staticresolver.New(
|
||||
resolvedTestProvider("org.example.one.v1", "one", false),
|
||||
resolvedTestProvider("org.example.two.v1", "two", false),
|
||||
), time.Second, fn)
|
||||
assert.ErrorContains(t, err, "has multiple providers")
|
||||
}
|
||||
|
||||
func TestCallSingleWithFallbackPropagatesContextTimeout(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), time.Millisecond)
|
||||
defer cancel()
|
||||
_, err := callSingleWithFallback(ctx, testSinglePoint, staticresolver.New(
|
||||
resolvedTestProvider("org.example.slow.v1", "slow", false),
|
||||
), time.Second, func(ctx context.Context, _ any) (string, error) {
|
||||
<-ctx.Done()
|
||||
return "", ctx.Err()
|
||||
})
|
||||
assert.Assert(t, errors.Is(err, context.DeadlineExceeded))
|
||||
}
|
||||
@@ -6,11 +6,8 @@ package containernamegeneratorv0
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/moby/extensions"
|
||||
"github.com/moby/moby/v2/extpoints/internal/namesgenerator"
|
||||
)
|
||||
|
||||
// ContainerNameGenerator generates a default container name.
|
||||
@@ -37,25 +34,3 @@ type GenerateContainerNameReply struct {
|
||||
|
||||
// Point is the single-provider container name-generator point.
|
||||
var Point = extensions.DefineSinglePoint[ContainerNameGenerator]("org.mobyproject.extension.containernamegenerator.v0")
|
||||
|
||||
// GenerateContainerName calls the effective container name provider.
|
||||
func GenerateContainerName(ctx context.Context, resolver extensions.Resolver, req *GenerateContainerNameRequest) (*GenerateContainerNameReply, error) {
|
||||
name, err := namesgenerator.Generate(ctx, resolver, Point.ID(), func(ctx context.Context, impl any) (string, error) {
|
||||
generator, ok := impl.(ContainerNameGenerator)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("provider has type %T, not ContainerNameGenerator", impl)
|
||||
}
|
||||
reply, err := generator.GenerateContainerName(ctx, req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if reply == nil {
|
||||
return "", errors.New("provider returned a nil reply")
|
||||
}
|
||||
return reply.Name, nil
|
||||
})
|
||||
if name == "" {
|
||||
return nil, err
|
||||
}
|
||||
return &GenerateContainerNameReply{Name: name}, err
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package containernamegeneratorv0
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/moby/extensions"
|
||||
"github.com/moby/moby/v2/internal/testutil/staticresolver"
|
||||
"gotest.tools/v3/assert"
|
||||
)
|
||||
|
||||
type generatorFunc func(context.Context, *GenerateContainerNameRequest) (*GenerateContainerNameReply, error)
|
||||
|
||||
func (f generatorFunc) GenerateContainerName(ctx context.Context, req *GenerateContainerNameRequest) (*GenerateContainerNameReply, error) {
|
||||
return f(ctx, req)
|
||||
}
|
||||
|
||||
func TestGenerateContainerName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
provider := generatorFunc(func(_ context.Context, req *GenerateContainerNameRequest) (*GenerateContainerNameReply, error) {
|
||||
assert.Equal(t, req.Retry, int64(3))
|
||||
assert.Equal(t, req.ContainerID, "container-id")
|
||||
assert.Equal(t, req.Image, "image:latest")
|
||||
return &GenerateContainerNameReply{Name: "container-name"}, nil
|
||||
})
|
||||
resolver := staticresolver.New(extensions.ResolvedProvider{
|
||||
Identity: extensions.ExtensionIdentity{
|
||||
ID: "org.example.containernamegenerator.v1",
|
||||
Origin: extensions.ExtensionOrigin{Kind: extensions.ExtensionOriginBuiltin},
|
||||
},
|
||||
Impl: provider,
|
||||
})
|
||||
|
||||
reply, err := GenerateContainerName(t.Context(), resolver, &GenerateContainerNameRequest{
|
||||
Retry: 3,
|
||||
ContainerID: "container-id",
|
||||
Image: "image:latest",
|
||||
})
|
||||
assert.NilError(t, err)
|
||||
assert.Equal(t, reply.Name, "container-name")
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
// Package namesgenerator implements behavior shared by name-generator points.
|
||||
package namesgenerator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/moby/extensions"
|
||||
)
|
||||
|
||||
// Generate calls the effective provider for a name-generator point.
|
||||
// A replacement hides the built-in during normal resolution, but the full
|
||||
// provider list retains it as a fallback.
|
||||
// If the replacement fails and the fallback succeeds, Generate returns the
|
||||
// fallback name together with the replacement error so callers can report the
|
||||
// extension failure without blocking object creation.
|
||||
// If the fallback also fails, the returned error contains both failures.
|
||||
func Generate(ctx context.Context, resolver extensions.Resolver, point extensions.PointID, invoke func(context.Context, any) (string, error)) (string, error) {
|
||||
providers := resolver.Providers(point)
|
||||
primary, err := effectiveProvider(point, providers)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
name, primaryErr := generate(ctx, point, primary, invoke)
|
||||
if primaryErr == nil || primary.Identity.Origin.Kind == extensions.ExtensionOriginBuiltin {
|
||||
return name, primaryErr
|
||||
}
|
||||
|
||||
fallback, ok := findBuiltinFallback(providers, primary)
|
||||
if !ok {
|
||||
return "", primaryErr
|
||||
}
|
||||
|
||||
name, fallbackErr := generate(ctx, point, fallback, invoke)
|
||||
return name, errors.Join(primaryErr, fallbackErr)
|
||||
}
|
||||
|
||||
func effectiveProvider(point extensions.PointID, providers []extensions.ResolvedProvider) (extensions.ResolvedProvider, error) {
|
||||
effective := extensions.EffectiveProviders(providers)
|
||||
switch len(effective) {
|
||||
case 0:
|
||||
return extensions.ResolvedProvider{}, fmt.Errorf("point %q has no providers", point)
|
||||
case 1:
|
||||
return effective[0], nil
|
||||
default:
|
||||
return extensions.ResolvedProvider{}, fmt.Errorf("point %q has multiple providers", point)
|
||||
}
|
||||
}
|
||||
|
||||
func findBuiltinFallback(providers []extensions.ResolvedProvider, primary extensions.ResolvedProvider) (extensions.ResolvedProvider, bool) {
|
||||
for _, provider := range providers {
|
||||
if provider.Identity.Origin.Kind == extensions.ExtensionOriginBuiltin && provider.Identity.ID != primary.Identity.ID {
|
||||
return provider, true
|
||||
}
|
||||
}
|
||||
return extensions.ResolvedProvider{}, false
|
||||
}
|
||||
|
||||
func generate(ctx context.Context, point extensions.PointID, provider extensions.ResolvedProvider, invoke func(context.Context, any) (string, error)) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
name, err := invoke(ctx, provider.Impl)
|
||||
if err != nil {
|
||||
return "", providerError(point, provider.Identity.ID, err)
|
||||
}
|
||||
if name == "" {
|
||||
return "", providerError(point, provider.Identity.ID, errors.New("provider returned an empty name"))
|
||||
}
|
||||
if !isValidGeneratedName(name) {
|
||||
return "", providerError(point, provider.Identity.ID, errors.New(
|
||||
"provider returned an invalid name: expected 2-63 ASCII letters, digits, hyphens, or underscores, starting and ending with a letter or digit",
|
||||
))
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
func isValidGeneratedName(name string) bool {
|
||||
if len(name) < 2 || len(name) > 63 {
|
||||
return false
|
||||
}
|
||||
for i := range len(name) {
|
||||
ch := name[i]
|
||||
if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') {
|
||||
continue
|
||||
}
|
||||
if i > 0 && i < len(name)-1 && (ch == '-' || ch == '_') {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func providerError(point extensions.PointID, extension extensions.ExtensionID, err error) error {
|
||||
return fmt.Errorf("%s provider %q: %w", point.Name(), extension, err)
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
package namesgenerator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/moby/extensions"
|
||||
"github.com/moby/moby/v2/internal/testutil/staticresolver"
|
||||
"gotest.tools/v3/assert"
|
||||
)
|
||||
|
||||
var testPoint = extensions.DefineSinglePoint[any]("org.example.namesgenerator.v0")
|
||||
|
||||
func resolvedProvider(id extensions.ExtensionID, impl any, builtin bool) extensions.ResolvedProvider {
|
||||
origin := extensions.ExtensionOrigin{
|
||||
Kind: extensions.ExtensionOriginExecutable,
|
||||
Executable: &extensions.ExecutableOrigin{Path: string(id)},
|
||||
}
|
||||
if builtin {
|
||||
origin = extensions.ExtensionOrigin{Kind: extensions.ExtensionOriginBuiltin}
|
||||
}
|
||||
return extensions.ResolvedProvider{
|
||||
Identity: extensions.ExtensionIdentity{ID: id, Origin: origin},
|
||||
Impl: impl,
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateUsesReplacementProvider(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
builtinCalled := false
|
||||
resolver := staticresolver.New(
|
||||
resolvedProvider("org.example.replacement.v1", "replacement", false),
|
||||
resolvedProvider("org.example.builtin.v1", "builtin", true),
|
||||
)
|
||||
name, err := Generate(t.Context(), resolver, testPoint.ID(), func(ctx context.Context, impl any) (string, error) {
|
||||
deadline, ok := ctx.Deadline()
|
||||
assert.Assert(t, ok)
|
||||
assert.Assert(t, time.Until(deadline) <= 5*time.Second)
|
||||
if impl == "builtin" {
|
||||
builtinCalled = true
|
||||
return "builtin-name", nil
|
||||
}
|
||||
return "replacement-name", nil
|
||||
})
|
||||
assert.NilError(t, err)
|
||||
assert.Equal(t, name, "replacement-name")
|
||||
assert.Assert(t, !builtinCalled)
|
||||
}
|
||||
|
||||
func TestGenerateFallsBackToBuiltin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
replacementName string
|
||||
replacementErr error
|
||||
wantErr string
|
||||
}{
|
||||
{name: "error", replacementErr: errors.New("replacement failed"), wantErr: "replacement failed"},
|
||||
{name: "empty name", wantErr: "provider returned an empty name"},
|
||||
{name: "invalid character", replacementName: "invalid.name", wantErr: "provider returned an invalid name"},
|
||||
{name: "too short", replacementName: "x", wantErr: "provider returned an invalid name"},
|
||||
{name: "too long", replacementName: strings.Repeat("x", 64), wantErr: "provider returned an invalid name"},
|
||||
{name: "ending with separator", replacementName: "invalid_name_", wantErr: "provider returned an invalid name"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
builtinCalls := 0
|
||||
resolver := staticresolver.New(
|
||||
resolvedProvider("org.example.replacement.v1", "replacement", false),
|
||||
resolvedProvider("org.example.builtin.v1", "builtin", true),
|
||||
)
|
||||
name, err := Generate(t.Context(), resolver, testPoint.ID(), func(ctx context.Context, impl any) (string, error) {
|
||||
if impl == "builtin" {
|
||||
_, ok := ctx.Deadline()
|
||||
assert.Assert(t, ok, "the fallback attempt must have its own deadline")
|
||||
builtinCalls++
|
||||
return "builtin-name", nil
|
||||
}
|
||||
return tc.replacementName, tc.replacementErr
|
||||
})
|
||||
assert.ErrorContains(t, err, `namesgenerator provider "org.example.replacement.v1"`)
|
||||
assert.ErrorContains(t, err, tc.wantErr)
|
||||
assert.Equal(t, name, "builtin-name")
|
||||
assert.Equal(t, builtinCalls, 1)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDoesNotRetryFailingBuiltin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
calls := 0
|
||||
name, err := Generate(t.Context(), staticresolver.New(
|
||||
resolvedProvider("org.example.builtin.v1", "builtin", true),
|
||||
), testPoint.ID(), func(context.Context, any) (string, error) {
|
||||
calls++
|
||||
return "", errors.New("builtin failed")
|
||||
})
|
||||
assert.Equal(t, name, "")
|
||||
assert.ErrorContains(t, err, `namesgenerator provider "org.example.builtin.v1": builtin failed`)
|
||||
assert.Equal(t, calls, 1)
|
||||
}
|
||||
|
||||
func TestGenerateJoinsReplacementAndBuiltinErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
replacementErr := errors.New("replacement failed")
|
||||
builtinErr := errors.New("builtin failed")
|
||||
name, err := Generate(t.Context(), staticresolver.New(
|
||||
resolvedProvider("org.example.replacement.v1", replacementErr, false),
|
||||
resolvedProvider("org.example.builtin.v1", builtinErr, true),
|
||||
), testPoint.ID(), func(_ context.Context, impl any) (string, error) {
|
||||
return "", impl.(error)
|
||||
})
|
||||
assert.Equal(t, name, "")
|
||||
assert.Assert(t, errors.Is(err, replacementErr))
|
||||
assert.Assert(t, errors.Is(err, builtinErr))
|
||||
}
|
||||
|
||||
func TestGenerateRequiresExactlyOneEffectiveProvider(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
invoke := func(context.Context, any) (string, error) { return "name", nil }
|
||||
_, err := Generate(t.Context(), staticresolver.New(), testPoint.ID(), invoke)
|
||||
assert.ErrorContains(t, err, "has no providers")
|
||||
|
||||
_, err = Generate(t.Context(), staticresolver.New(
|
||||
resolvedProvider("org.example.one.v1", "one", false),
|
||||
resolvedProvider("org.example.two.v1", "two", false),
|
||||
), testPoint.ID(), invoke)
|
||||
assert.ErrorContains(t, err, "has multiple providers")
|
||||
}
|
||||
|
||||
func TestGeneratePropagatesContextTimeout(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), time.Millisecond)
|
||||
defer cancel()
|
||||
_, err := Generate(ctx, staticresolver.New(
|
||||
resolvedProvider("org.example.slow.v1", "slow", false),
|
||||
), testPoint.ID(), func(ctx context.Context, _ any) (string, error) {
|
||||
<-ctx.Done()
|
||||
return "", ctx.Err()
|
||||
})
|
||||
assert.Assert(t, errors.Is(err, context.DeadlineExceeded))
|
||||
}
|
||||
@@ -6,11 +6,8 @@ package servicenamegeneratorv0
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/moby/extensions"
|
||||
"github.com/moby/moby/v2/extpoints/internal/namesgenerator"
|
||||
)
|
||||
|
||||
// ServiceNameGenerator generates a default Swarm service name.
|
||||
@@ -36,25 +33,3 @@ type GenerateServiceNameReply struct {
|
||||
|
||||
// Point is the single-provider service name-generator point.
|
||||
var Point = extensions.DefineSinglePoint[ServiceNameGenerator]("org.mobyproject.extension.servicenamegenerator.v0")
|
||||
|
||||
// GenerateServiceName calls the effective service name provider.
|
||||
func GenerateServiceName(ctx context.Context, resolver extensions.Resolver, req *GenerateServiceNameRequest) (*GenerateServiceNameReply, error) {
|
||||
name, err := namesgenerator.Generate(ctx, resolver, Point.ID(), func(ctx context.Context, impl any) (string, error) {
|
||||
generator, ok := impl.(ServiceNameGenerator)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("provider has type %T, not ServiceNameGenerator", impl)
|
||||
}
|
||||
reply, err := generator.GenerateServiceName(ctx, req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if reply == nil {
|
||||
return "", errors.New("provider returned a nil reply")
|
||||
}
|
||||
return reply.Name, nil
|
||||
})
|
||||
if name == "" {
|
||||
return nil, err
|
||||
}
|
||||
return &GenerateServiceNameReply{Name: name}, err
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package servicenamegeneratorv0
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/moby/extensions"
|
||||
"github.com/moby/moby/v2/internal/testutil/staticresolver"
|
||||
"gotest.tools/v3/assert"
|
||||
)
|
||||
|
||||
type generatorFunc func(context.Context, *GenerateServiceNameRequest) (*GenerateServiceNameReply, error)
|
||||
|
||||
func (f generatorFunc) GenerateServiceName(ctx context.Context, req *GenerateServiceNameRequest) (*GenerateServiceNameReply, error) {
|
||||
return f(ctx, req)
|
||||
}
|
||||
|
||||
func TestGenerateServiceName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
provider := generatorFunc(func(_ context.Context, req *GenerateServiceNameRequest) (*GenerateServiceNameReply, error) {
|
||||
assert.Equal(t, req.Retry, int64(3))
|
||||
assert.Equal(t, req.Image, "image:latest")
|
||||
return &GenerateServiceNameReply{Name: "service-name"}, nil
|
||||
})
|
||||
resolver := staticresolver.New(extensions.ResolvedProvider{
|
||||
Identity: extensions.ExtensionIdentity{
|
||||
ID: "org.example.servicenamegenerator.v1",
|
||||
Origin: extensions.ExtensionOrigin{Kind: extensions.ExtensionOriginBuiltin},
|
||||
},
|
||||
Impl: provider,
|
||||
})
|
||||
|
||||
reply, err := GenerateServiceName(t.Context(), resolver, &GenerateServiceNameRequest{
|
||||
Retry: 3,
|
||||
Image: "image:latest",
|
||||
})
|
||||
assert.NilError(t, err)
|
||||
assert.Equal(t, reply.Name, "service-name")
|
||||
}
|
||||
Reference in new issue
Block a user