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:
Paweł Gronowski committed 2026-10-26 21:35:07 +01:00
1 parent 4237bc6cb7
commit 88c9d21f4a
9 files changed
+357 -395

No files matched your search

+2 -10
View File
@@ -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)
}
+144
View File
@@ -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)
}
+211
View File
@@ -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")
}