Skip to content
Open
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
9 changes: 9 additions & 0 deletions cli/command/image/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
108 changes: 108 additions & 0 deletions cli/command/image/pull.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,24 @@ 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"
"github.com/docker/cli/cli/command"
"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"
Expand All @@ -25,6 +31,8 @@ type pullOptions struct {
all bool
platform string
quiet bool
dryRun bool
format string
}

// newPullCommand creates a new `docker pull` command
Expand All @@ -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{
Expand All @@ -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)")
Expand Down Expand Up @@ -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,
Expand All @@ -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)
}
94 changes: 94 additions & 0 deletions cli/command/image/pull_test.go
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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) {
Expand Down Expand Up @@ -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))
})
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"Reference":"docker.io/library/image:tag","Digest":"sha256:c0537ff6a5218ef531ece93d4984efc99bbf3f7497c0a7726c88e2bb7584dc96","Status":"up-to-date"}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
newer-available
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Digest: sha256:c0537ff6a5218ef531ece93d4984efc99bbf3f7497c0a7726c88e2bb7584dc96
Status: A newer image is available for docker.io/library/image:tag
docker.io/library/image:tag
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
docker.io/library/image:tag
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Digest: sha256:c0537ff6a5218ef531ece93d4984efc99bbf3f7497c0a7726c88e2bb7584dc96
Status: Image is up to date for docker.io/library/image:tag
docker.io/library/image:tag
4 changes: 2 additions & 2 deletions contrib/completion/bash/docker
Original file line number Diff line number Diff line change
Expand Up @@ -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" ) )
;;
*)
Expand Down
2 changes: 2 additions & 0 deletions contrib/completion/fish/docker.fish
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions contrib/completion/zsh/_docker
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
12 changes: 7 additions & 5 deletions docs/reference/commandline/image_pull.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |


<!---MARKER_GEN_END-->
Expand Down
12 changes: 7 additions & 5 deletions docs/reference/commandline/pull.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |


<!---MARKER_GEN_END-->
Expand Down
Loading