From bcf644952930c80c54b8be309189d096bae04cd2 Mon Sep 17 00:00:00 2001 From: maxcleme <7669401+maxcleme@users.noreply.github.com> Date: Fri, 18 Sep 2026 09:48:05 +0200 Subject: [PATCH 1/3] docs(image): document optional credentials fn and anonymous fallback in Pull Signed-off-by: maxcleme <7669401+maxcleme@users.noreply.github.com> --- image/README.md | 3 +++ image/options.go | 4 +++- image/pull.go | 3 +++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/image/README.md b/image/README.md index 91290bf3..9dbad1b5 100644 --- a/image/README.md +++ b/image/README.md @@ -27,6 +27,9 @@ The Pull operation can be customized using functional options. The following opt - `WithPullOptions(options apiimage.PullOptions) image.PullOption`: The options to use to pull the image. The type of the options is "github.com/moby/moby/api/types/image". - `WithPullHandler(pullHandler func(r io.ReadCloser) error) image.PullOption`: The handler to use to pull the image, which acts as a callback to the pull operation. +> [!NOTE] +> Credentials default to the Docker CLI config (`WithCredentialsFn` is optional). If no credentials can be resolved, the pull falls back to anonymous with a logged warning, so no credentials fn is needed for public registries. + First, you need to import the following packages: ```go diff --git a/image/options.go b/image/options.go index 27bf8015..7b625e2a 100644 --- a/image/options.go +++ b/image/options.go @@ -47,7 +47,9 @@ type pullOptions struct { credentialsFn func(string) (string, string, error) } -// WithCredentialsFn sets the function to retrieve credentials for an image to be pulled +// WithCredentialsFn sets the function to retrieve credentials for an image to be pulled. +// It is optional and only needed for custom credential sources: by default credentials are +// resolved from the Docker CLI config, and public images work without it. func WithCredentialsFn(credentialsFn func(string) (string, string, error)) PullOption { return func(opts *pullOptions) error { opts.credentialsFn = credentialsFn diff --git a/image/pull.go b/image/pull.go index 10243a1d..3fa9131f 100644 --- a/image/pull.go +++ b/image/pull.go @@ -47,6 +47,9 @@ var defaultPullHandler = DisplayProgress(os.Stdout) // Pull pulls an image from a remote registry, retrying on non-permanent errors. // See [client.IsPermanentClientError] for the list of non-permanent errors. // It first extracts the registry credentials from the image name, and sets them in the pull options. +// When no credentials fn is set with [WithCredentialsFn], credentials are resolved from the +// Docker CLI config; if that resolution fails, the pull proceeds anonymously and a warning is +// logged. Errors from an explicitly provided credentials fn still fail the pull. // It needs to be called with a valid image name, and optional pull options, see [PullOption]. // It's possible to override the default pull handler function by using the [WithPullHandler] option. func Pull(ctx context.Context, imageName string, opts ...PullOption) error { From b0d91195e400a1ba5b08c445ff109c6534021485 Mon Sep 17 00:00:00 2001 From: maxcleme <7669401+maxcleme@users.noreply.github.com> Date: Fri, 18 Sep 2026 09:48:09 +0200 Subject: [PATCH 2/3] chore(image): add unit tests for default credential resolution in Pull Signed-off-by: maxcleme <7669401+maxcleme@users.noreply.github.com> --- image/pull_unit_test.go | 91 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/image/pull_unit_test.go b/image/pull_unit_test.go index 6e391ef5..75930e15 100644 --- a/image/pull_unit_test.go +++ b/image/pull_unit_test.go @@ -4,7 +4,10 @@ import ( "bytes" "context" "errors" + "fmt" "log/slog" + "os" + "path/filepath" "testing" "time" @@ -145,3 +148,91 @@ func TestPull(t *testing.T) { require.Contains(t, out, "failed to pull image, will retry") }) } + +func TestPullDefaultCredentials(t *testing.T) { + newSDK := func(t *testing.T, mockCli *errMockCli, buf *bytes.Buffer) sdkclient.SDKClient { + t.Helper() + sdk, err := sdkclient.New(context.TODO(), + sdkclient.WithDockerAPI(mockCli), + sdkclient.WithLogger(slog.New(slog.NewTextHandler(buf, nil)))) + require.NoError(t, err) + return sdk + } + + t.Run("broken-config/anonymous-fallback", func(t *testing.T) { + tmpDir := t.TempDir() + // unique helper name to avoid the config module's content-hash cache + helper := fmt.Sprintf("broken-helper-%d", time.Now().UnixNano()) + binDir := t.TempDir() + helperPath := filepath.Join(binDir, "docker-credential-"+helper) + require.NoError(t, os.WriteFile(helperPath, []byte("#!/bin/sh\nexit 1\n"), 0o755)) + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + cfg := fmt.Sprintf(`{"credHelpers":{"myregistry.example.com":%q}}`, helper) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "config.json"), []byte(cfg), 0o600)) + t.Setenv("DOCKER_CONFIG", tmpDir) + t.Setenv("DOCKER_AUTH_CONFIG", "") + + mockCli := &errMockCli{} + buf := &bytes.Buffer{} + sdk := newSDK(t, mockCli, buf) + + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + err := Pull(ctx, "myregistry.example.com/myimage:tag", + WithPullOptions(dockerclient.ImagePullOptions{}), + WithPullClient(sdk), + ) + require.NoError(t, err) + require.Contains(t, buf.String(), "failed to retrieve registry credentials, pulling without authentication") + require.Positive(t, mockCli.imagePullCount) + + decoded, err := authconfig.Decode(mockCli.lastPullOptions.RegistryAuth) + require.NoError(t, err) + require.Empty(t, decoded.Username) + require.Empty(t, decoded.Password) + require.Empty(t, decoded.IdentityToken) + }) + + t.Run("no-config/anonymous", func(t *testing.T) { + t.Setenv("DOCKER_CONFIG", t.TempDir()) + t.Setenv("DOCKER_AUTH_CONFIG", "") + + mockCli := &errMockCli{} + buf := &bytes.Buffer{} + sdk := newSDK(t, mockCli, buf) + + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + err := Pull(ctx, "myregistry.example.com/myimage:tag", + WithPullOptions(dockerclient.ImagePullOptions{}), + WithPullClient(sdk), + ) + require.NoError(t, err) + require.Positive(t, mockCli.imagePullCount) + + decoded, err := authconfig.Decode(mockCli.lastPullOptions.RegistryAuth) + require.NoError(t, err) + require.Empty(t, decoded.Username) + require.Empty(t, decoded.Password) + require.Empty(t, decoded.IdentityToken) + }) + + t.Run("explicit-credentials-fn/error-fails", func(t *testing.T) { + mockCli := &errMockCli{} + buf := &bytes.Buffer{} + sdk := newSDK(t, mockCli, buf) + + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + err := Pull(ctx, "myregistry.example.com/myimage:tag", + WithPullOptions(dockerclient.ImagePullOptions{}), + WithCredentialsFn(func(string) (string, string, error) { + return "", "", errors.New("boom") + }), + WithPullClient(sdk), + ) + require.Error(t, err) + require.Contains(t, err.Error(), "failed to retrieve registry credentials") + require.Zero(t, mockCli.imagePullCount) + }) +} From 5bd56c88ebd36283b7423141c0f632cc56eb6fcc Mon Sep 17 00:00:00 2001 From: maxcleme <7669401+maxcleme@users.noreply.github.com> Date: Fri, 18 Sep 2026 09:48:13 +0200 Subject: [PATCH 3/3] fix(image): fall back to anonymous pull when default credential resolution fails Signed-off-by: maxcleme <7669401+maxcleme@users.noreply.github.com> --- image/pull.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/image/pull.go b/image/pull.go index 3fa9131f..c19a2658 100644 --- a/image/pull.go +++ b/image/pull.go @@ -70,7 +70,8 @@ func Pull(ctx context.Context, imageName string, opts ...PullOption) error { pullOpts.client = sdk } - if pullOpts.credentialsFn == nil { + defaultedCredentials := pullOpts.credentialsFn == nil + if defaultedCredentials { if err := WithCredentialsFromConfig(pullOpts); err != nil { return fmt.Errorf("set credentials for pull option: %w", err) } @@ -82,7 +83,11 @@ func Pull(ctx context.Context, imageName string, opts ...PullOption) error { username, password, err := pullOpts.credentialsFn(imageName) if err != nil { - return fmt.Errorf("failed to retrieve registry credentials for %s: %w", imageName, err) + if !defaultedCredentials { + return fmt.Errorf("failed to retrieve registry credentials for %s: %w", imageName, err) + } + pullOpts.client.Logger().Warn("failed to retrieve registry credentials, pulling without authentication", "image", imageName, "error", err) + username, password = "", "" } imgRef, err := configauth.ParseImageRef(imageName)