From b485de295b81395ec2e168b51b7bad18c6901115 Mon Sep 17 00:00:00 2001 From: Oscar Zhou <100548325+oscarzhou-portainer@users.noreply.github.com> Date: Wed, 15 Jul 2026 18:48:30 +1200 Subject: [PATCH] fix(swarm): no image error for Docker private registry [BE-13217] (#3190) --- pkg/libstack/swarm/swarm.go | 21 +++++++++- pkg/libstack/swarm/swarm_unit_test.go | 57 +++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/pkg/libstack/swarm/swarm.go b/pkg/libstack/swarm/swarm.go index b49b0b646b..649bc3112d 100644 --- a/pkg/libstack/swarm/swarm.go +++ b/pkg/libstack/swarm/swarm.go @@ -239,10 +239,12 @@ func deployStack(ctx context.Context, dockerCLI *command.DockerCli, filePaths [] return err } + registries := normalizeRegistryServerAddresses(options.Registries) + return deployServices( ctx, dockerCLI.Client(), - options.Registries, + registries, services, namespace, options.PullImage, @@ -396,6 +398,23 @@ func createConfigs(ctx context.Context, apiClient client.APIClient, configs []sw return nil } +// normalizeRegistryServerAddresses returns a copy of registries with each ServerAddress +// rewritten so it matches the domain encodeRegistryAuth resolves for docker.io images +// (dockerregistry.IndexServer). +func normalizeRegistryServerAddresses(registries []configtypes.AuthConfig) []configtypes.AuthConfig { + normalized := make([]configtypes.AuthConfig, len(registries)) + + for i, r := range registries { + if r.ServerAddress == "" || r.ServerAddress == dockerregistry.DefaultNamespace { + r.ServerAddress = dockerregistry.IndexServer + } + + normalized[i] = r + } + + return normalized +} + // encodeRegistryAuth finds the registry credentials for the given image and returns // the base64-encoded auth string expected by the Docker service API. // Returns an empty string (no error) when no matching credentials are found. diff --git a/pkg/libstack/swarm/swarm_unit_test.go b/pkg/libstack/swarm/swarm_unit_test.go index 61096b2c5c..340e3e313f 100644 --- a/pkg/libstack/swarm/swarm_unit_test.go +++ b/pkg/libstack/swarm/swarm_unit_test.go @@ -256,6 +256,63 @@ func Test_encodeRegistryAuth(t *testing.T) { } } +func Test_normalizeRegistryServerAddresses(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + registries []configtypes.AuthConfig + expectedReturned []configtypes.AuthConfig + }{ + { + name: "empty ServerAddress is normalized to IndexServer", + registries: []configtypes.AuthConfig{{ServerAddress: "", Username: "user"}}, + expectedReturned: []configtypes.AuthConfig{{ServerAddress: dockerregistry.IndexServer, Username: "user"}}, + }, + { + name: "docker.io default namespace is normalized to IndexServer", + registries: []configtypes.AuthConfig{{ServerAddress: dockerregistry.DefaultNamespace, Username: "user"}}, + expectedReturned: []configtypes.AuthConfig{{ServerAddress: dockerregistry.IndexServer, Username: "user"}}, + }, + { + name: "custom registry address is left untouched", + registries: []configtypes.AuthConfig{{ServerAddress: "registry.example.com", Username: "user"}}, + expectedReturned: []configtypes.AuthConfig{{ServerAddress: "registry.example.com", Username: "user"}}, + }, + { + name: "IndexServer address is left untouched", + registries: []configtypes.AuthConfig{{ServerAddress: dockerregistry.IndexServer, Username: "user"}}, + expectedReturned: []configtypes.AuthConfig{{ServerAddress: dockerregistry.IndexServer, Username: "user"}}, + }, + { + name: "mix of registries normalizes only the matching ones", + registries: []configtypes.AuthConfig{ + {ServerAddress: "", Username: "empty"}, + {ServerAddress: dockerregistry.DefaultNamespace, Username: "default-namespace"}, + {ServerAddress: "registry.example.com", Username: "custom"}, + }, + expectedReturned: []configtypes.AuthConfig{ + {ServerAddress: dockerregistry.IndexServer, Username: "empty"}, + {ServerAddress: dockerregistry.IndexServer, Username: "default-namespace"}, + {ServerAddress: "registry.example.com", Username: "custom"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + original := slices.Clone(tt.registries) + + got := normalizeRegistryServerAddresses(tt.registries) + + require.Equal(t, tt.expectedReturned, got) + require.Equal(t, original, tt.registries, "input slice must not be mutated") + }) + } +} + func Test_getConfig(t *testing.T) { dir := t.TempDir()