From bb492b8dd0f3e4564ecd9fd2aa03166c621807e6 Mon Sep 17 00:00:00 2001 From: Raphael Date: Tue, 13 Oct 2026 16:26:31 +0100 Subject: [PATCH] Add --dry-run flag to docker pull Add a --dry-run option to "docker pull" that reports whether pulling an image would download a newer version, without pulling anything. The CLI asks the daemon to resolve the reference to a digest in the registry (DistributionInspect) and compares it against the digests of the local image, printing the same "Digest:" / "Status:" lines as a regular pull: $ docker pull --dry-run debian Digest: sha256:3f1d6c... Status: Image is up to date for docker.io/library/debian:latest docker.io/library/debian:latest This makes it possible to detect available updates in automation without downloading the image. The --dry-run option cannot be combined with --all-tags. fixes https://github.com/docker/cli/issues/6059 Signed-off-by: Raphael --- cli/command/image/client_test.go | 9 ++ cli/command/image/pull.go | 108 ++++++++++++++++++ cli/command/image/pull_test.go | 94 +++++++++++++++ .../pull-command-dry-run.format-json.golden | 1 + ...ull-command-dry-run.format-template.golden | 1 + ...ull-command-dry-run.newer-available.golden | 3 + ...command-dry-run.not-present-locally.golden | 3 + .../pull-command-dry-run.quiet.golden | 1 + .../pull-command-dry-run.up-to-date.golden | 3 + contrib/completion/bash/docker | 4 +- contrib/completion/fish/docker.fish | 2 + contrib/completion/zsh/_docker | 2 + docs/reference/commandline/image_pull.md | 12 +- docs/reference/commandline/pull.md | 12 +- man/src/image/pull.md | 44 +++++++ 15 files changed, 287 insertions(+), 12 deletions(-) create mode 100644 cli/command/image/testdata/pull-command-dry-run.format-json.golden create mode 100644 cli/command/image/testdata/pull-command-dry-run.format-template.golden create mode 100644 cli/command/image/testdata/pull-command-dry-run.newer-available.golden create mode 100644 cli/command/image/testdata/pull-command-dry-run.not-present-locally.golden create mode 100644 cli/command/image/testdata/pull-command-dry-run.quiet.golden create mode 100644 cli/command/image/testdata/pull-command-dry-run.up-to-date.golden diff --git a/cli/command/image/client_test.go b/cli/command/image/client_test.go index fd2ab4a1cb2a..12fe0c30d117 100644 --- a/cli/command/image/client_test.go +++ b/cli/command/image/client_test.go @@ -26,6 +26,8 @@ type fakeClient struct { imageImportFunc func(source client.ImageImportSource, ref string, options client.ImageImportOptions) (client.ImageImportResult, error) imageHistoryFunc func(img string, options ...client.ImageHistoryOption) (client.ImageHistoryResult, error) imageBuildFunc func(context.Context, io.Reader, client.ImageBuildOptions) (client.ImageBuildResult, error) + + distributionInspectFunc func(image string, options client.DistributionInspectOptions) (client.DistributionInspectResult, error) } type fakeStreamResult struct { @@ -130,3 +132,10 @@ func (cli *fakeClient) ImageBuild(ctx context.Context, buildContext io.Reader, o } return client.ImageBuildResult{Body: io.NopCloser(strings.NewReader(""))}, nil } + +func (cli *fakeClient) DistributionInspect(_ context.Context, imageRef string, options client.DistributionInspectOptions) (client.DistributionInspectResult, error) { + if cli.distributionInspectFunc != nil { + return cli.distributionInspectFunc(imageRef, options) + } + return client.DistributionInspectResult{}, nil +} diff --git a/cli/command/image/pull.go b/cli/command/image/pull.go index 7485d21a6880..b247ff67efee 100644 --- a/cli/command/image/pull.go +++ b/cli/command/image/pull.go @@ -2,11 +2,16 @@ package image import ( "context" + "encoding/json" "errors" "fmt" "io" "os" + "slices" + "strings" + "text/template" + "github.com/containerd/errdefs" "github.com/containerd/platforms" "github.com/distribution/reference" "github.com/docker/cli/cli" @@ -14,6 +19,7 @@ import ( "github.com/docker/cli/cli/command/completion" "github.com/docker/cli/cli/streams" "github.com/docker/cli/internal/jsonstream" + "github.com/docker/cli/templates" "github.com/moby/moby/client" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "github.com/spf13/cobra" @@ -25,6 +31,8 @@ type pullOptions struct { all bool platform string quiet bool + dryRun bool + format string } // newPullCommand creates a new `docker pull` command @@ -37,6 +45,9 @@ func newPullCommand(dockerCLI command.Cli) *cobra.Command { Args: cli.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { opts.remote = args[0] + if opts.format != "" && !opts.dryRun { + return errors.New("--format can only be used with --dry-run") + } return runPull(cmd.Context(), dockerCLI, opts) }, Annotations: map[string]string{ @@ -52,7 +63,10 @@ func newPullCommand(dockerCLI command.Cli) *cobra.Command { flags := cmd.Flags() flags.BoolVarP(&opts.all, "all-tags", "a", false, "Download all tagged images in the repository") + flags.BoolVar(&opts.dryRun, "dry-run", false, "Check if a newer image is available without pulling") + flags.StringVar(&opts.format, "format", "", "Format output of --dry-run using a custom template: 'json' or 'TEMPLATE'") flags.BoolVarP(&opts.quiet, "quiet", "q", false, "Suppress verbose output") + cmd.MarkFlagsMutuallyExclusive("all-tags", "dry-run") // TODO(thaJeztah): DEPRECATED: remove in v29.1 or v30 flags.Bool("disable-content-trust", true, "Skip image verification (deprecated)") @@ -95,6 +109,10 @@ func runPull(ctx context.Context, dockerCLI command.Cli, opts pullOptions) error return err } + if opts.dryRun { + return runPullDryRun(ctx, dockerCLI, distributionRef, encodedAuth, opts) + } + responseBody, err := dockerCLI.Client().ImagePull(ctx, reference.FamiliarString(distributionRef), client.ImagePullOptions{ RegistryAuth: encodedAuth, PrivilegeFunc: nil, @@ -116,3 +134,93 @@ func runPull(ctx context.Context, dockerCLI command.Cli, opts pullOptions) error _, _ = fmt.Fprintln(dockerCLI.Out(), distributionRef.String()) return nil } + +// Status values reported by "docker pull --dry-run". +const ( + dryRunUpToDate = "up-to-date" + dryRunNewerAvailable = "newer-available" + dryRunNotPresent = "not-present" +) + +// dryRunResult is the result of "docker pull --dry-run". Its fields can be +// used in the --format template. +type dryRunResult struct { + Reference string + Digest string + Status string +} + +// runPullDryRun checks whether pulling the reference would download a newer +// image, without pulling it. It asks the daemon to resolve the reference to +// a digest in the registry, and compares that digest against the digests of +// the local image. +func runPullDryRun(ctx context.Context, dockerCLI command.Cli, distributionRef reference.Named, encodedAuth string, opts pullOptions) error { + // Validate the format before contacting the registry. + var tmpl *template.Template + if opts.format != "" && opts.format != "json" { + var err error + if tmpl, err = templates.Parse(opts.format); err != nil { + return fmt.Errorf("template parsing error: %w", err) + } + } + + remote, err := dockerCLI.Client().DistributionInspect(ctx, reference.FamiliarString(distributionRef), client.DistributionInspectOptions{ + EncodedRegistryAuth: encodedAuth, + }) + if err != nil { + return err + } + remoteDigest := remote.Descriptor.Digest + if remoteDigest == "" { + return fmt.Errorf("registry did not return a digest for %s", distributionRef) + } + + result := dryRunResult{Reference: distributionRef.String(), Digest: remoteDigest.String()} + local, err := dockerCLI.Client().ImageInspect(ctx, reference.FamiliarString(distributionRef)) + switch { + case errdefs.IsNotFound(err): + result.Status = dryRunNotPresent + case err != nil: + return err + case slices.ContainsFunc(local.RepoDigests, func(repoDigest string) bool { + return strings.HasSuffix(repoDigest, "@"+result.Digest) + }): + result.Status = dryRunUpToDate + default: + result.Status = dryRunNewerAvailable + } + + switch { + case opts.format == "json": + b, err := json.Marshal(result) + if err != nil { + return err + } + _, _ = fmt.Fprintln(dockerCLI.Out(), string(b)) + case tmpl != nil: + if err := tmpl.Execute(dockerCLI.Out(), result); err != nil { + return fmt.Errorf("template parsing error: %w", err) + } + _, _ = fmt.Fprintln(dockerCLI.Out()) + default: + printDryRunText(dockerCLI, result, opts.quiet) + } + return nil +} + +func printDryRunText(dockerCLI command.Cli, result dryRunResult, quiet bool) { + out := dockerCLI.Out() + if quiet { + out = streams.NewOut(io.Discard) + } + _, _ = fmt.Fprintln(out, "Digest:", result.Digest) + switch result.Status { + case dryRunNotPresent: + _, _ = fmt.Fprintf(out, "Status: Image %s is not available locally and would be pulled\n", result.Reference) + case dryRunUpToDate: + _, _ = fmt.Fprintf(out, "Status: Image is up to date for %s\n", result.Reference) + default: + _, _ = fmt.Fprintf(out, "Status: A newer image is available for %s\n", result.Reference) + } + _, _ = fmt.Fprintln(dockerCLI.Out(), result.Reference) +} diff --git a/cli/command/image/pull_test.go b/cli/command/image/pull_test.go index eb8edfbca699..6ce80d504d05 100644 --- a/cli/command/image/pull_test.go +++ b/cli/command/image/pull_test.go @@ -1,13 +1,18 @@ package image import ( + "errors" "fmt" "io" "net/http" "testing" + "github.com/containerd/errdefs" "github.com/docker/cli/internal/test" + "github.com/moby/moby/api/types/image" + "github.com/moby/moby/api/types/registry" "github.com/moby/moby/client" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" "gotest.tools/v3/assert" is "gotest.tools/v3/assert/cmp" "gotest.tools/v3/golden" @@ -34,6 +39,21 @@ func TestNewPullCommandErrors(t *testing.T) { expectedError: "tag can't be used with --all-tags/-a", args: []string{"--all-tags", "image:tag"}, }, + { + name: "all-tags-with-dry-run", + expectedError: "if any flags in the group [all-tags dry-run] are set none of the others can be", + args: []string{"--all-tags", "--dry-run", "image"}, + }, + { + name: "format-without-dry-run", + expectedError: "--format can only be used with --dry-run", + args: []string{"--format", "json", "image"}, + }, + { + name: "dry-run-invalid-template", + expectedError: "template parsing error", + args: []string{"--dry-run", "--format", "{{.Status", "image"}, + }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { @@ -88,3 +108,77 @@ func TestNewPullCommandSuccess(t *testing.T) { }) } } + +func TestNewPullCommandDryRun(t *testing.T) { + const remoteDigest = "sha256:c0537ff6a5218ef531ece93d4984efc99bbf3f7497c0a7726c88e2bb7584dc96" + + testCases := []struct { + name string + args []string + repoDigests []string + localError error + }{ + { + name: "up-to-date", + args: []string{"--dry-run", "image:tag"}, + repoDigests: []string{"docker.io/library/image@" + remoteDigest}, + }, + { + name: "newer-available", + args: []string{"--dry-run", "image:tag"}, + repoDigests: []string{"docker.io/library/image@sha256:1b25a9b06ee76ff3d21a34b33bd8a1a9e4ef4e2b2a3f4f43d4db1f81a1e4e00a"}, + }, + { + name: "not-present-locally", + args: []string{"--dry-run", "image:tag"}, + localError: errdefs.ErrNotFound.WithMessage("No such image: image:tag"), + }, + { + name: "format-json", + args: []string{"--dry-run", "--format", "json", "image:tag"}, + repoDigests: []string{"docker.io/library/image@" + remoteDigest}, + }, + { + name: "format-template", + args: []string{"--dry-run", "--format", "{{.Status}}", "image:tag"}, + repoDigests: []string{"docker.io/library/image@sha256:1b25a9b06ee76ff3d21a34b33bd8a1a9e4ef4e2b2a3f4f43d4db1f81a1e4e00a"}, + }, + { + name: "quiet", + args: []string{"--dry-run", "--quiet", "image:tag"}, + repoDigests: []string{"docker.io/library/image@" + remoteDigest}, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + cli := test.NewFakeCli(&fakeClient{ + imagePullFunc: func(ref string, options client.ImagePullOptions) (client.ImagePullResponse, error) { + return fakeStreamResult{}, errors.New("ImagePull should not be called with --dry-run") + }, + distributionInspectFunc: func(image string, options client.DistributionInspectOptions) (client.DistributionInspectResult, error) { + assert.Check(t, is.Equal("image:tag", image)) + return client.DistributionInspectResult{ + DistributionInspect: registry.DistributionInspect{ + Descriptor: ocispec.Descriptor{Digest: remoteDigest}, + }, + }, nil + }, + imageInspectFunc: func(img string) (client.ImageInspectResult, error) { + if tc.localError != nil { + return client.ImageInspectResult{}, tc.localError + } + return client.ImageInspectResult{ + InspectResponse: image.InspectResponse{RepoDigests: tc.repoDigests}, + }, nil + }, + }) + cmd := newPullCommand(cli) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetArgs(tc.args) + err := cmd.Execute() + assert.NilError(t, err) + golden.Assert(t, cli.OutBuffer().String(), fmt.Sprintf("pull-command-dry-run.%s.golden", tc.name)) + }) + } +} diff --git a/cli/command/image/testdata/pull-command-dry-run.format-json.golden b/cli/command/image/testdata/pull-command-dry-run.format-json.golden new file mode 100644 index 000000000000..a59e2509ede9 --- /dev/null +++ b/cli/command/image/testdata/pull-command-dry-run.format-json.golden @@ -0,0 +1 @@ +{"Reference":"docker.io/library/image:tag","Digest":"sha256:c0537ff6a5218ef531ece93d4984efc99bbf3f7497c0a7726c88e2bb7584dc96","Status":"up-to-date"} diff --git a/cli/command/image/testdata/pull-command-dry-run.format-template.golden b/cli/command/image/testdata/pull-command-dry-run.format-template.golden new file mode 100644 index 000000000000..92047f731e96 --- /dev/null +++ b/cli/command/image/testdata/pull-command-dry-run.format-template.golden @@ -0,0 +1 @@ +newer-available diff --git a/cli/command/image/testdata/pull-command-dry-run.newer-available.golden b/cli/command/image/testdata/pull-command-dry-run.newer-available.golden new file mode 100644 index 000000000000..f9c9cb304729 --- /dev/null +++ b/cli/command/image/testdata/pull-command-dry-run.newer-available.golden @@ -0,0 +1,3 @@ +Digest: sha256:c0537ff6a5218ef531ece93d4984efc99bbf3f7497c0a7726c88e2bb7584dc96 +Status: A newer image is available for docker.io/library/image:tag +docker.io/library/image:tag diff --git a/cli/command/image/testdata/pull-command-dry-run.not-present-locally.golden b/cli/command/image/testdata/pull-command-dry-run.not-present-locally.golden new file mode 100644 index 000000000000..4a7a342ae799 --- /dev/null +++ b/cli/command/image/testdata/pull-command-dry-run.not-present-locally.golden @@ -0,0 +1,3 @@ +Digest: sha256:c0537ff6a5218ef531ece93d4984efc99bbf3f7497c0a7726c88e2bb7584dc96 +Status: Image docker.io/library/image:tag is not available locally and would be pulled +docker.io/library/image:tag diff --git a/cli/command/image/testdata/pull-command-dry-run.quiet.golden b/cli/command/image/testdata/pull-command-dry-run.quiet.golden new file mode 100644 index 000000000000..42b61f09344d --- /dev/null +++ b/cli/command/image/testdata/pull-command-dry-run.quiet.golden @@ -0,0 +1 @@ +docker.io/library/image:tag diff --git a/cli/command/image/testdata/pull-command-dry-run.up-to-date.golden b/cli/command/image/testdata/pull-command-dry-run.up-to-date.golden new file mode 100644 index 000000000000..73b1fa7d42ae --- /dev/null +++ b/cli/command/image/testdata/pull-command-dry-run.up-to-date.golden @@ -0,0 +1,3 @@ +Digest: sha256:c0537ff6a5218ef531ece93d4984efc99bbf3f7497c0a7726c88e2bb7584dc96 +Status: Image is up to date for docker.io/library/image:tag +docker.io/library/image:tag diff --git a/contrib/completion/bash/docker b/contrib/completion/bash/docker index a78b08a6e473..66582b8d5f8c 100644 --- a/contrib/completion/bash/docker +++ b/contrib/completion/bash/docker @@ -3049,14 +3049,14 @@ _docker_image_prune() { _docker_image_pull() { case "$prev" in - --platform) + --format|--platform) return ;; esac case "$cur" in -*) - local options="--all-tags -a --disable-content-trust=false --help --platform --quiet -q" + local options="--all-tags -a --disable-content-trust=false --dry-run --format --help --platform --quiet -q" COMPREPLY=( $( compgen -W "$options" -- "$cur" ) ) ;; *) diff --git a/contrib/completion/fish/docker.fish b/contrib/completion/fish/docker.fish index bf1dd38b3b0c..c2738f2a9ace 100644 --- a/contrib/completion/fish/docker.fish +++ b/contrib/completion/fish/docker.fish @@ -414,6 +414,8 @@ complete -c docker -A -f -n '__fish_seen_subcommand_from ps' -l since -d 'Show o # pull complete -c docker -f -n '__fish_docker_no_subcommand' -a pull -d 'Download an image from a registry' complete -c docker -A -f -n '__fish_seen_subcommand_from pull' -s a -l all-tags -d 'Download all tagged images in the repository' +complete -c docker -A -f -n '__fish_seen_subcommand_from pull' -l dry-run -d 'Check if a newer image is available without pulling' +complete -c docker -A -f -n '__fish_seen_subcommand_from pull' -l format -d 'Format the --dry-run output using a custom template' complete -c docker -A -f -n '__fish_seen_subcommand_from pull' -l help -d 'Print usage' complete -c docker -A -f -n '__fish_seen_subcommand_from pull' -a '(__fish_print_docker_images)' -d "Image" complete -c docker -A -f -n '__fish_seen_subcommand_from pull' -a '(__fish_print_docker_repositories)' -d "Repository" diff --git a/contrib/completion/zsh/_docker b/contrib/completion/zsh/_docker index 1efb30f86a8a..b5bc3f66f78d 100644 --- a/contrib/completion/zsh/_docker +++ b/contrib/completion/zsh/_docker @@ -1070,6 +1070,8 @@ __docker_image_subcommand() { $opts_help \ "($help -a --all-tags)"{-a,--all-tags}"[Download all tagged images]" \ "($help)--disable-content-trust[Skip image verification]" \ + "($help)--dry-run[Check if a newer image is available without pulling]" \ + "($help)--format=[Format the --dry-run output using a custom template]:template: " \ "($help -):name:__docker_search" && ret=0 ;; (push) diff --git a/docs/reference/commandline/image_pull.md b/docs/reference/commandline/image_pull.md index beb270660aa6..d5d345e585e7 100644 --- a/docs/reference/commandline/image_pull.md +++ b/docs/reference/commandline/image_pull.md @@ -9,11 +9,13 @@ Download an image from a registry ### Options -| Name | Type | Default | Description | -|:---------------------------------------------|:---------|:--------|:-------------------------------------------------| -| [`-a`](#all-tags), [`--all-tags`](#all-tags) | `bool` | | Download all tagged images in the repository | -| `--platform` | `string` | | Set platform if server is multi-platform capable | -| `-q`, `--quiet` | `bool` | | Suppress verbose output | +| Name | Type | Default | Description | +|:---------------------------------------------|:---------|:--------|:-------------------------------------------------------------------------| +| [`-a`](#all-tags), [`--all-tags`](#all-tags) | `bool` | | Download all tagged images in the repository | +| `--dry-run` | `bool` | | Check if a newer image is available without pulling | +| `--format` | `string` | | Format output of --dry-run using a custom template: 'json' or 'TEMPLATE' | +| `--platform` | `string` | | Set platform if server is multi-platform capable | +| `-q`, `--quiet` | `bool` | | Suppress verbose output | diff --git a/docs/reference/commandline/pull.md b/docs/reference/commandline/pull.md index 3cc2b0f94f93..5072f8aeb14f 100644 --- a/docs/reference/commandline/pull.md +++ b/docs/reference/commandline/pull.md @@ -9,11 +9,13 @@ Download an image from a registry ### Options -| Name | Type | Default | Description | -|:-------------------|:---------|:--------|:-------------------------------------------------| -| `-a`, `--all-tags` | `bool` | | Download all tagged images in the repository | -| `--platform` | `string` | | Set platform if server is multi-platform capable | -| `-q`, `--quiet` | `bool` | | Suppress verbose output | +| Name | Type | Default | Description | +|:-------------------|:---------|:--------|:-------------------------------------------------------------------------| +| `-a`, `--all-tags` | `bool` | | Download all tagged images in the repository | +| `--dry-run` | `bool` | | Check if a newer image is available without pulling | +| `--format` | `string` | | Format output of --dry-run using a custom template: 'json' or 'TEMPLATE' | +| `--platform` | `string` | | Set platform if server is multi-platform capable | +| `-q`, `--quiet` | `bool` | | Suppress verbose output | diff --git a/man/src/image/pull.md b/man/src/image/pull.md index caba00938c71..080a3e8347db 100644 --- a/man/src/image/pull.md +++ b/man/src/image/pull.md @@ -165,6 +165,50 @@ images that are present locally: ubuntu latest 35a88802559d 6 weeks ago 78.1MB ubuntu noble 35a88802559d 6 weeks ago 78.1MB +## Check if an image is up to date (dry run) + +Use the `--dry-run` option to check whether pulling an image would download a +newer version, without pulling it. The CLI asks the daemon to resolve the +image reference to a digest in the registry, and compares it with the digest +of the local image. No image content is downloaded. + + $ docker image pull --dry-run debian + + Digest: sha256:3f1d6c17773a45c97bd8f158d665c9709d7b29ed7917ac934086ad96f92e4510 + Status: Image is up to date for docker.io/library/debian:latest + docker.io/library/debian:latest + +If a newer image is available in the registry, the status indicates so: + + $ docker image pull --dry-run debian + + Digest: sha256:3f1d6c17773a45c97bd8f158d665c9709d7b29ed7917ac934086ad96f92e4510 + Status: A newer image is available for docker.io/library/debian:latest + docker.io/library/debian:latest + +If the image is not present locally at all, the status reports that the image +would be pulled: + + $ docker image pull --dry-run debian:bookworm + + Digest: sha256:3f1d6c17773a45c97bd8f158d665c9709d7b29ed7917ac934086ad96f92e4510 + Status: Image docker.io/library/debian:bookworm is not available locally and would be pulled + docker.io/library/debian:bookworm + +For use in scripts, use the `--format` option to get machine-readable output. +The `Status` field is one of `up-to-date`, `newer-available`, or `not-present`: + + $ docker image pull --dry-run --format json debian + + {"Reference":"docker.io/library/debian:latest","Digest":"sha256:3f1d6c17773a45c97bd8f158d665c9709d7b29ed7917ac934086ad96f92e4510","Status":"up-to-date"} + + $ docker image pull --dry-run --format '{{.Status}}' debian + + up-to-date + +The `--dry-run` option cannot be combined with `--all-tags`, and `--format` +can only be used together with `--dry-run`. + ## Cancel a pull Killing the `docker image pull` process, for example by pressing `CTRL-c` while it is