Skip to content
Closed
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
3 changes: 3 additions & 0 deletions image/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion image/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 10 additions & 2 deletions image/pull.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -67,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)
}
Expand All @@ -79,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)
Expand Down
91 changes: 91 additions & 0 deletions image/pull_unit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import (
"bytes"
"context"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"testing"
"time"

Expand Down Expand Up @@ -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)
})
}
Loading