mirror of https://github.com/docker/cli.git
Compare commits
207 Commits
master
...
v27.3.0-rc
Author | SHA1 | Date |
---|---|---|
|
48a2cdff97 | |
|
b48720e65e | |
|
c85c3df6b5 | |
|
74bebe2719 | |
|
9748bef1c3 | |
|
2fc18b9874 | |
|
1a199b63da | |
|
968341cc7d | |
|
fbb0cfd86a | |
|
49e33a03df | |
|
295b75e5ff | |
|
090d1ff555 | |
|
5dde3d8570 | |
|
d64740d347 | |
|
f68936ef2e | |
|
4607c883c5 | |
|
7ff3daa446 | |
|
1fe06dd0e7 | |
|
b381ab1d8d | |
|
05fbfc6995 | |
|
fba240c5b4 | |
|
965699ba0f | |
|
c65ac2d90d | |
|
05fb576772 | |
|
4be6b1f3d7 | |
|
65decb5731 | |
|
90559a6143 | |
|
dbde5b3681 | |
|
b1e50eea92 | |
|
9e34c9bb39 | |
|
324cdbca40 | |
|
b5290d4e0b | |
|
3db9538748 | |
|
1ab89e71fa | |
|
667d9fd4df | |
|
41e61c45d9 | |
|
869df10064 | |
|
6feee4ab35 | |
|
d0c1a80617 | |
|
383c428451 | |
|
5f8416e541 | |
|
5bf5cb9ff6 | |
|
3a15d5a640 | |
|
1dfd11acc0 | |
|
de2b49b074 | |
|
c5d846735c | |
|
6274754e66 | |
|
7a50cd0f01 | |
|
074dfc0f88 | |
|
92423287cc | |
|
1a0b6a7a44 | |
|
8fcfc0b803 | |
|
28d2fed463 | |
|
83072c0232 | |
|
40109aa45f | |
|
32aadc9902 | |
|
3ab4256958 | |
|
88a49df297 | |
|
5d17c29eb2 | |
|
64b9e4cd16 | |
|
4b71d0d1af | |
|
002cfcde85 | |
|
d8af7812b5 | |
|
f042ddb5c9 | |
|
8e94ed15e6 | |
|
7a82aeeeba | |
|
24837f9260 | |
|
5805df0205 | |
|
fb20f009f7 | |
|
6ceb0aba82 | |
|
2d7b8998c4 | |
|
cabd410a1a | |
|
a58af379e1 | |
|
1b3fa65759 | |
|
cf01923519 | |
|
a0d7f0dbd3 | |
|
0c4e7478e2 | |
|
60ce3fbc96 | |
|
7902b52714 | |
|
7196200fc2 | |
|
f42fa0b8e1 | |
|
b719b10257 | |
|
ab55d75cf5 | |
|
324cc5d30f | |
|
44a9ffa0ad | |
|
ba43ae0bd2 | |
|
99b647cfca | |
|
f90dc28f1e | |
|
26536d1145 | |
|
c5e733becc | |
|
7227402d94 | |
|
83f6ca4a73 | |
|
ad7912a846 | |
|
afb5e143b1 | |
|
b8a38fd22d | |
|
0c29d6bac1 | |
|
3eaf30278f | |
|
d01f264bcc | |
|
65dec14ac0 | |
|
1f80c54b51 | |
|
33573e20bc | |
|
73452e316f | |
|
bcd90be73a | |
|
f62c68eedd | |
|
946d1097b8 | |
|
096e42b366 | |
|
984ef9072c | |
|
30c7951192 | |
|
54135b0724 | |
|
40707e17b8 | |
|
edd71d77c7 | |
|
9593373f9e | |
|
5761e662f1 | |
|
c7f3031f74 | |
|
53cb00a818 | |
|
a35c363ffc | |
|
1cf3637198 | |
|
fd3157bf35 | |
|
dfb8f2155a | |
|
2e506cbb10 | |
|
7f02bc9704 | |
|
8a6f7d849d | |
|
1b2782ef64 | |
|
a74040315e | |
|
b889b2562c | |
|
b24c7417e4 | |
|
63125853e3 | |
|
c599566439 | |
|
fb19def3ce | |
|
bccd4786f7 | |
|
8992378c87 | |
|
f90273c340 | |
|
ca9636a1c3 | |
|
ad47d2a2c1 | |
|
a2a0fb73ea | |
|
16d6c90a94 | |
|
f7be714467 | |
|
33bf62c4cb | |
|
7e972d2f5c | |
|
a0791d0e8a | |
|
259d5e0f57 | |
|
c365c3cc8a | |
|
54c8eb7941 | |
|
5ab44a4690 | |
|
33e5c87957 | |
|
674f8d2979 | |
|
2794ebd599 | |
|
76863b46b7 | |
|
e7bcae9053 | |
|
3577a17ce1 | |
|
a8e2130643 | |
|
4f1a67e06b | |
|
dfe3c0c074 | |
|
66ec7142ae | |
|
ab8a2c0716 | |
|
d11e73d6e6 | |
|
36802176a7 | |
|
3926ed6b24 | |
|
787caf2fe4 | |
|
4473f48847 | |
|
7a4062a4a9 | |
|
d914a3f97e | |
|
68fe829c96 | |
|
d60252954b | |
|
6cd1f6f26d | |
|
338e7a604c | |
|
5dea9d881c | |
|
4d67ef09c8 | |
|
69a2c9fb6d | |
|
333103d93f | |
|
d36bdb0d84 | |
|
5777558c77 | |
|
52848fb798 | |
|
bd43ca786b | |
|
330a8e4e23 | |
|
e0b44d6d3f | |
|
d41cb083c3 | |
|
0a8bb6e5b4 | |
|
c777dd16df | |
|
eea26c50dd | |
|
1cf2c4efb3 | |
|
c2b9c1474a | |
|
598442d37d | |
|
c964b80e53 | |
|
0ca7be015e | |
|
59fb099da2 | |
|
27bf78d335 | |
|
71b756be32 | |
|
63a27bc9b6 | |
|
5ed8f858cf | |
|
6b99e0370c | |
|
7d4bcd863a | |
|
3134d55821 | |
|
912c1ddf8a | |
|
c97e8091a6 | |
|
82bd8158f7 | |
|
8945848025 | |
|
b54897bcb8 | |
|
cd560916f3 | |
|
9a101a955b | |
|
50fae20748 | |
|
37533c2f55 | |
|
217971d481 | |
|
fce24d5f8d | |
|
0e4f16f3bf | |
|
6e35a78fd9 | |
|
bf1a701820 |
|
@ -1,5 +1,14 @@
|
|||
name: build
|
||||
|
||||
# Default to 'contents: read', which grants actions to read commits.
|
||||
#
|
||||
# If any permission is set, any permission not included in the list is
|
||||
# implicitly set to "none".
|
||||
#
|
||||
# see https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#permissions
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
|
|
@ -1,5 +1,14 @@
|
|||
name: codeql
|
||||
|
||||
# Default to 'contents: read', which grants actions to read commits.
|
||||
#
|
||||
# If any permission is set, any permission not included in the list is
|
||||
# implicitly set to "none".
|
||||
#
|
||||
# see https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#permissions
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
|
|
|
@ -1,5 +1,14 @@
|
|||
name: e2e
|
||||
|
||||
# Default to 'contents: read', which grants actions to read commits.
|
||||
#
|
||||
# If any permission is set, any permission not included in the list is
|
||||
# implicitly set to "none".
|
||||
#
|
||||
# see https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#permissions
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
@ -28,9 +37,8 @@ jobs:
|
|||
- alpine
|
||||
- debian
|
||||
engine-version:
|
||||
- 27-rc # testing
|
||||
- 26.1 # latest
|
||||
- 25.0 # latest - 1
|
||||
- 27.0 # latest
|
||||
- 26.1 # latest - 1
|
||||
- 23.0 # mirantis lts
|
||||
# TODO(krissetto) 19.03 needs a look, doesn't work ubuntu 22.04 (cgroup errors).
|
||||
# we could have a separate job that tests it against ubuntu 20.04
|
||||
|
|
|
@ -1,5 +1,14 @@
|
|||
name: test
|
||||
|
||||
# Default to 'contents: read', which grants actions to read commits.
|
||||
#
|
||||
# If any permission is set, any permission not included in the list is
|
||||
# implicitly set to "none".
|
||||
#
|
||||
# see https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#permissions
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
@ -46,7 +55,8 @@ jobs:
|
|||
fail-fast: false
|
||||
matrix:
|
||||
os:
|
||||
- macos-12
|
||||
- macos-13 # macOS 13 on Intel
|
||||
- macos-14 # macOS 14 on arm64 (Apple Silicon M1)
|
||||
# - windows-2022 # FIXME: some tests are failing on the Windows runner, as well as on Appveyor since June 24, 2018: https://ci.appveyor.com/project/docker/cli/history
|
||||
steps:
|
||||
-
|
||||
|
@ -64,7 +74,7 @@ jobs:
|
|||
name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 1.21.11
|
||||
go-version: 1.22.7
|
||||
-
|
||||
name: Test
|
||||
run: |
|
||||
|
|
|
@ -1,5 +1,14 @@
|
|||
name: validate-pr
|
||||
|
||||
# Default to 'contents: read', which grants actions to read commits.
|
||||
#
|
||||
# If any permission is set, any permission not included in the list is
|
||||
# implicitly set to "none".
|
||||
#
|
||||
# see https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#permissions
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, edited, labeled, unlabeled]
|
||||
|
@ -53,10 +62,16 @@ jobs:
|
|||
# Backports or PR that target a release branch directly should mention the target branch in the title, for example:
|
||||
# [X.Y backport] Some change that needs backporting to X.Y
|
||||
# [X.Y] Change directly targeting the X.Y branch
|
||||
- name: Get branch from PR title
|
||||
id: title_branch
|
||||
run: echo "$PR_TITLE" | sed -n 's/^\[\([0-9]*\.[0-9]*\)[^]]*\].*/branch=\1/p' >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Check release branch
|
||||
if: github.event.pull_request.base.ref != steps.title_branch.outputs.branch && !(github.event.pull_request.base.ref == 'master' && steps.title_branch.outputs.branch == '')
|
||||
run: echo "::error::PR title suggests targetting the ${{ steps.title_branch.outputs.branch }} branch, but is opened against ${{ github.event.pull_request.base.ref }}" && exit 1
|
||||
id: title_branch
|
||||
run: |
|
||||
# get the intended major version prefix ("[27.1 backport]" -> "27.") from the PR title.
|
||||
[[ "$PR_TITLE" =~ ^\[([0-9]*\.)[^]]*\] ]] && branch="${BASH_REMATCH[1]}"
|
||||
|
||||
# get major version prefix from the release branch ("27.x -> "27.")
|
||||
[[ "$GITHUB_BASE_REF" =~ ^([0-9]*\.) ]] && target_branch="${BASH_REMATCH[1]}" || target_branch="$GITHUB_BASE_REF"
|
||||
|
||||
if [[ "$target_branch" != "$branch" ]] && ! [[ "$GITHUB_BASE_REF" == "master" && "$branch" == "" ]]; then
|
||||
echo "::error::PR is opened against the $GITHUB_BASE_REF branch, but its title suggests otherwise."
|
||||
exit 1
|
||||
fi
|
||||
|
|
|
@ -1,5 +1,14 @@
|
|||
name: validate
|
||||
|
||||
# Default to 'contents: read', which grants actions to read commits.
|
||||
#
|
||||
# If any permission is set, any permission not included in the list is
|
||||
# implicitly set to "none".
|
||||
#
|
||||
# see https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#permissions
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
|
|
@ -4,12 +4,12 @@ ARG BASE_VARIANT=alpine
|
|||
ARG ALPINE_VERSION=3.20
|
||||
ARG BASE_DEBIAN_DISTRO=bookworm
|
||||
|
||||
ARG GO_VERSION=1.21.11
|
||||
ARG XX_VERSION=1.4.0
|
||||
ARG GO_VERSION=1.22.7
|
||||
ARG XX_VERSION=1.5.0
|
||||
ARG GOVERSIONINFO_VERSION=v1.3.0
|
||||
ARG GOTESTSUM_VERSION=v1.10.0
|
||||
ARG BUILDX_VERSION=0.15.1
|
||||
ARG COMPOSE_VERSION=v2.28.0
|
||||
ARG BUILDX_VERSION=0.16.1
|
||||
ARG COMPOSE_VERSION=v2.29.0
|
||||
|
||||
FROM --platform=$BUILDPLATFORM tonistiigi/xx:${XX_VERSION} AS xx
|
||||
|
||||
|
|
10
Makefile
10
Makefile
|
@ -86,6 +86,16 @@ mod-outdated: ## check outdated dependencies
|
|||
authors: ## generate AUTHORS file from git history
|
||||
scripts/docs/generate-authors.sh
|
||||
|
||||
.PHONY: completion
|
||||
completion: binary
|
||||
completion: /etc/bash_completion.d/docker
|
||||
completion: ## generate and install the completion scripts
|
||||
|
||||
.PHONY: /etc/bash_completion.d/docker
|
||||
/etc/bash_completion.d/docker: ## generate and install the bash-completion script
|
||||
mkdir -p /etc/bash_completion.d
|
||||
docker completion bash > /etc/bash_completion.d/docker
|
||||
|
||||
.PHONY: manpages
|
||||
manpages: ## generate man pages from go source and markdown
|
||||
scripts/docs/generate-man.sh
|
||||
|
|
|
@ -95,6 +95,9 @@ func (pl *PluginServer) Addr() net.Addr {
|
|||
//
|
||||
// The error value is that of the underlying [net.Listner.Close] call.
|
||||
func (pl *PluginServer) Close() error {
|
||||
if pl == nil {
|
||||
return nil
|
||||
}
|
||||
logrus.Trace("Closing plugin server")
|
||||
// Close connections first to ensure the connections get io.EOF instead
|
||||
// of a connection reset.
|
||||
|
|
|
@ -117,6 +117,18 @@ func TestPluginServer(t *testing.T) {
|
|||
assert.NilError(t, err, "failed to dial returned server")
|
||||
checkDirNoNewPluginServer(t)
|
||||
})
|
||||
|
||||
t.Run("does not panic on Close if server is nil", func(t *testing.T) {
|
||||
var srv *PluginServer
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("panicked on Close")
|
||||
}
|
||||
}()
|
||||
|
||||
err := srv.Close()
|
||||
assert.NilError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func checkDirNoNewPluginServer(t *testing.T) {
|
||||
|
|
|
@ -3,6 +3,7 @@ package builder
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/docker/cli/internal/test"
|
||||
|
@ -19,5 +20,7 @@ func TestBuilderPromptTermination(t *testing.T) {
|
|||
},
|
||||
})
|
||||
cmd := NewPruneCommand(cli)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
test.TerminatePrompt(ctx, t, cmd, cli)
|
||||
}
|
||||
|
|
|
@ -42,6 +42,7 @@ func TestCheckpointCreateErrors(t *testing.T) {
|
|||
cmd := newCreateCommand(cli)
|
||||
cmd.SetArgs(tc.args)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -42,6 +42,7 @@ func TestCheckpointListErrors(t *testing.T) {
|
|||
cmd := newListCommand(cli)
|
||||
cmd.SetArgs(tc.args)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -41,6 +41,7 @@ func TestCheckpointRemoveErrors(t *testing.T) {
|
|||
cmd := newRemoveCommand(cli)
|
||||
cmd.SetArgs(tc.args)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -324,7 +324,7 @@ func newAPIClientFromEndpoint(ep docker.Endpoint, configFile *configfile.ConfigF
|
|||
if len(configFile.HTTPHeaders) > 0 {
|
||||
opts = append(opts, client.WithHTTPHeaders(configFile.HTTPHeaders))
|
||||
}
|
||||
opts = append(opts, client.WithUserAgent(UserAgent()))
|
||||
opts = append(opts, withCustomHeadersFromEnv(), client.WithUserAgent(UserAgent()))
|
||||
return client.NewClientWithOpts(opts...)
|
||||
}
|
||||
|
||||
|
|
|
@ -2,13 +2,18 @@ package command
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/docker/cli/cli/streams"
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/docker/docker/errdefs"
|
||||
"github.com/moby/term"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// CLIOption is a functional argument to apply options to a [DockerCli]. These
|
||||
|
@ -108,3 +113,107 @@ func WithAPIClient(c client.APIClient) CLIOption {
|
|||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// envOverrideHTTPHeaders is the name of the environment-variable that can be
|
||||
// used to set custom HTTP headers to be sent by the client. This environment
|
||||
// variable is the equivalent to the HttpHeaders field in the configuration
|
||||
// file.
|
||||
//
|
||||
// WARNING: If both config and environment-variable are set, the environment
|
||||
// variable currently overrides all headers set in the configuration file.
|
||||
// This behavior may change in a future update, as we are considering the
|
||||
// environment variable to be appending to existing headers (and to only
|
||||
// override headers with the same name).
|
||||
//
|
||||
// While this env-var allows for custom headers to be set, it does not allow
|
||||
// for built-in headers (such as "User-Agent", if set) to be overridden.
|
||||
// Also see [client.WithHTTPHeaders] and [client.WithUserAgent].
|
||||
//
|
||||
// This environment variable can be used in situations where headers must be
|
||||
// set for a specific invocation of the CLI, but should not be set by default,
|
||||
// and therefore cannot be set in the config-file.
|
||||
//
|
||||
// envOverrideHTTPHeaders accepts a comma-separated (CSV) list of key=value pairs,
|
||||
// where key must be a non-empty, valid MIME header format. Whitespaces surrounding
|
||||
// the key are trimmed, and the key is normalised. Whitespaces in values are
|
||||
// preserved, but "key=value" pairs with an empty value (e.g. "key=") are ignored.
|
||||
// Tuples without a "=" produce an error.
|
||||
//
|
||||
// It follows CSV rules for escaping, allowing "key=value" pairs to be quoted
|
||||
// if they must contain commas, which allows for multiple values for a single
|
||||
// header to be set. If a key is repeated in the list, later values override
|
||||
// prior values.
|
||||
//
|
||||
// For example, the following value:
|
||||
//
|
||||
// one=one-value,"two=two,value","three= a value with whitespace ",four=,five=five=one,five=five-two
|
||||
//
|
||||
// Produces four headers (four is omitted as it has an empty value set):
|
||||
//
|
||||
// - one (value is "one-value")
|
||||
// - two (value is "two,value")
|
||||
// - three (value is " a value with whitespace ")
|
||||
// - five (value is "five-two", the later value has overridden the prior value)
|
||||
const envOverrideHTTPHeaders = "DOCKER_CUSTOM_HEADERS"
|
||||
|
||||
// withCustomHeadersFromEnv overriding custom HTTP headers to be sent by the
|
||||
// client through the [envOverrideHTTPHeaders] environment-variable. This
|
||||
// environment variable is the equivalent to the HttpHeaders field in the
|
||||
// configuration file.
|
||||
//
|
||||
// WARNING: If both config and environment-variable are set, the environment-
|
||||
// variable currently overrides all headers set in the configuration file.
|
||||
// This behavior may change in a future update, as we are considering the
|
||||
// environment-variable to be appending to existing headers (and to only
|
||||
// override headers with the same name).
|
||||
//
|
||||
// TODO(thaJeztah): this is a client Option, and should be moved to the client. It is non-exported for that reason.
|
||||
func withCustomHeadersFromEnv() client.Opt {
|
||||
return func(apiClient *client.Client) error {
|
||||
value := os.Getenv(envOverrideHTTPHeaders)
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
csvReader := csv.NewReader(strings.NewReader(value))
|
||||
fields, err := csvReader.Read()
|
||||
if err != nil {
|
||||
return errdefs.InvalidParameter(errors.Errorf("failed to parse custom headers from %s environment variable: value must be formatted as comma-separated key=value pairs", envOverrideHTTPHeaders))
|
||||
}
|
||||
if len(fields) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
env := map[string]string{}
|
||||
for _, kv := range fields {
|
||||
k, v, hasValue := strings.Cut(kv, "=")
|
||||
|
||||
// Only strip whitespace in keys; preserve whitespace in values.
|
||||
k = strings.TrimSpace(k)
|
||||
|
||||
if k == "" {
|
||||
return errdefs.InvalidParameter(errors.Errorf(`failed to set custom headers from %s environment variable: value contains a key=value pair with an empty key: '%s'`, envOverrideHTTPHeaders, kv))
|
||||
}
|
||||
|
||||
// We don't currently allow empty key=value pairs, and produce an error.
|
||||
// This is something we could allow in future (e.g. to read value
|
||||
// from an environment variable with the same name). In the meantime,
|
||||
// produce an error to prevent users from depending on this.
|
||||
if !hasValue {
|
||||
return errdefs.InvalidParameter(errors.Errorf(`failed to set custom headers from %s environment variable: missing "=" in key=value pair: '%s'`, envOverrideHTTPHeaders, kv))
|
||||
}
|
||||
|
||||
env[http.CanonicalHeaderKey(k)] = v
|
||||
}
|
||||
|
||||
if len(env) == 0 {
|
||||
// We should probably not hit this case, as we don't skip values
|
||||
// (only return errors), but we don't want to discard existing
|
||||
// headers with an empty set.
|
||||
return nil
|
||||
}
|
||||
|
||||
// TODO(thaJeztah): add a client.WithExtraHTTPHeaders() function to allow these headers to be _added_ to existing ones, instead of _replacing_
|
||||
// see https://github.com/docker/cli/pull/5098#issuecomment-2147403871 (when updating, also update the WARNING in the function and env-var GoDoc)
|
||||
return client.WithHTTPHeaders(env)(apiClient)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -87,6 +87,41 @@ func TestNewAPIClientFromFlagsWithCustomHeaders(t *testing.T) {
|
|||
assert.DeepEqual(t, received, expectedHeaders)
|
||||
}
|
||||
|
||||
func TestNewAPIClientFromFlagsWithCustomHeadersFromEnv(t *testing.T) {
|
||||
var received http.Header
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
received = r.Header.Clone()
|
||||
_, _ = w.Write([]byte("OK"))
|
||||
}))
|
||||
defer ts.Close()
|
||||
host := strings.Replace(ts.URL, "http://", "tcp://", 1)
|
||||
opts := &flags.ClientOptions{Hosts: []string{host}}
|
||||
configFile := &configfile.ConfigFile{
|
||||
HTTPHeaders: map[string]string{
|
||||
"My-Header": "Custom-Value from config-file",
|
||||
},
|
||||
}
|
||||
|
||||
// envOverrideHTTPHeaders should override the HTTPHeaders from the config-file,
|
||||
// so "My-Header" should not be present.
|
||||
t.Setenv(envOverrideHTTPHeaders, `one=one-value,"two=two,value",three=,four=four-value,four=four-value-override`)
|
||||
apiClient, err := NewAPIClientFromFlags(opts, configFile)
|
||||
assert.NilError(t, err)
|
||||
assert.Equal(t, apiClient.DaemonHost(), host)
|
||||
assert.Equal(t, apiClient.ClientVersion(), api.DefaultVersion)
|
||||
|
||||
expectedHeaders := http.Header{
|
||||
"One": []string{"one-value"},
|
||||
"Two": []string{"two,value"},
|
||||
"Three": []string{""},
|
||||
"Four": []string{"four-value-override"},
|
||||
"User-Agent": []string{UserAgent()},
|
||||
}
|
||||
_, err = apiClient.Ping(context.Background())
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, received, expectedHeaders)
|
||||
}
|
||||
|
||||
func TestNewAPIClientFromFlagsWithAPIVersionFromEnv(t *testing.T) {
|
||||
customVersion := "v3.3.3"
|
||||
t.Setenv("DOCKER_API_VERSION", customVersion)
|
||||
|
|
|
@ -2,6 +2,7 @@ package completion
|
|||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/docker/cli/cli/command/formatter"
|
||||
"github.com/docker/docker/api/types"
|
||||
|
@ -106,6 +107,41 @@ func NetworkNames(dockerCLI APIClientProvider) ValidArgsFn {
|
|||
}
|
||||
}
|
||||
|
||||
// EnvVarNames offers completion for environment-variable names. This
|
||||
// completion can be used for "--env" and "--build-arg" flags, which
|
||||
// allow obtaining the value of the given environment-variable if present
|
||||
// in the local environment, so we only should complete the names of the
|
||||
// environment variables, and not their value. This also prevents the
|
||||
// completion script from printing values of environment variables
|
||||
// containing sensitive values.
|
||||
//
|
||||
// For example;
|
||||
//
|
||||
// export MY_VAR=hello
|
||||
// docker run --rm --env MY_VAR alpine printenv MY_VAR
|
||||
// hello
|
||||
func EnvVarNames(_ *cobra.Command, _ []string, _ string) (names []string, _ cobra.ShellCompDirective) {
|
||||
envs := os.Environ()
|
||||
names = make([]string, 0, len(envs))
|
||||
for _, env := range envs {
|
||||
name, _, _ := strings.Cut(env, "=")
|
||||
names = append(names, name)
|
||||
}
|
||||
return names, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
|
||||
// FromList offers completion for the given list of options.
|
||||
func FromList(options ...string) ValidArgsFn {
|
||||
return cobra.FixedCompletions(options, cobra.ShellCompDirectiveNoFileComp)
|
||||
}
|
||||
|
||||
// FileNames is a convenience function to use [cobra.ShellCompDirectiveDefault],
|
||||
// which indicates to let the shell perform its default behavior after
|
||||
// completions have been provided.
|
||||
func FileNames(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return nil, cobra.ShellCompDirectiveDefault
|
||||
}
|
||||
|
||||
// NoComplete is used for commands where there's no relevant completion
|
||||
func NoComplete(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
|
|
|
@ -43,14 +43,18 @@ func TestConfigCreateErrors(t *testing.T) {
|
|||
},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
cmd := newConfigCreateCommand(
|
||||
test.NewFakeCli(&fakeClient{
|
||||
configCreateFunc: tc.configCreateFunc,
|
||||
}),
|
||||
)
|
||||
cmd.SetArgs(tc.args)
|
||||
cmd.SetOut(io.Discard)
|
||||
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
|
||||
tc := tc
|
||||
t.Run(tc.expectedError, func(t *testing.T) {
|
||||
cmd := newConfigCreateCommand(
|
||||
test.NewFakeCli(&fakeClient{
|
||||
configCreateFunc: tc.configCreateFunc,
|
||||
}),
|
||||
)
|
||||
cmd.SetArgs(tc.args)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -61,6 +61,7 @@ func TestConfigInspectErrors(t *testing.T) {
|
|||
assert.Check(t, cmd.Flags().Set(key, value))
|
||||
}
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -42,6 +42,7 @@ func TestConfigListErrors(t *testing.T) {
|
|||
)
|
||||
cmd.SetArgs(tc.args)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -37,6 +37,7 @@ func TestConfigRemoveErrors(t *testing.T) {
|
|||
)
|
||||
cmd.SetArgs(tc.args)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
|
||||
}
|
||||
}
|
||||
|
@ -74,6 +75,7 @@ func TestConfigRemoveContinueAfterError(t *testing.T) {
|
|||
cmd := newConfigRemoveCommand(cli)
|
||||
cmd.SetArgs(names)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
assert.Error(t, cmd.Execute(), "error removing config: foo")
|
||||
assert.Check(t, is.DeepEqual(names, removedConfigs))
|
||||
}
|
||||
|
|
|
@ -73,7 +73,8 @@ func RunAttach(ctx context.Context, dockerCLI command.Cli, containerID string, o
|
|||
apiClient := dockerCLI.Client()
|
||||
|
||||
// request channel to wait for client
|
||||
resultC, errC := apiClient.ContainerWait(ctx, containerID, "")
|
||||
waitCtx := context.WithoutCancel(ctx)
|
||||
resultC, errC := apiClient.ContainerWait(waitCtx, containerID, "")
|
||||
|
||||
c, err := inspectContainerAndCheckState(ctx, apiClient, containerID)
|
||||
if err != nil {
|
||||
|
@ -146,7 +147,8 @@ func RunAttach(ctx context.Context, dockerCLI command.Cli, containerID string, o
|
|||
detachKeys: options.DetachKeys,
|
||||
}
|
||||
|
||||
if err := streamer.stream(ctx); err != nil {
|
||||
// if the context was canceled, this was likely intentional and we shouldn't return an error
|
||||
if err := streamer.stream(ctx); err != nil && !errors.Is(err, context.Canceled) {
|
||||
return err
|
||||
}
|
||||
|
||||
|
@ -163,6 +165,9 @@ func getExitStatus(errC <-chan error, resultC <-chan container.WaitResponse) err
|
|||
return cli.StatusError{StatusCode: int(result.StatusCode)}
|
||||
}
|
||||
case err := <-errC:
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
|
@ -69,19 +70,19 @@ func TestNewAttachCommandErrors(t *testing.T) {
|
|||
},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
cmd := NewAttachCommand(test.NewFakeCli(&fakeClient{inspectFunc: tc.containerInspectFunc}))
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetArgs(tc.args)
|
||||
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmd := NewAttachCommand(test.NewFakeCli(&fakeClient{inspectFunc: tc.containerInspectFunc}))
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs(tc.args)
|
||||
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetExitStatus(t *testing.T) {
|
||||
var (
|
||||
expectedErr = errors.New("unexpected error")
|
||||
errC = make(chan error, 1)
|
||||
resultC = make(chan container.WaitResponse, 1)
|
||||
)
|
||||
expectedErr := errors.New("unexpected error")
|
||||
|
||||
testcases := []struct {
|
||||
result *container.WaitResponse
|
||||
|
@ -109,16 +110,24 @@ func TestGetExitStatus(t *testing.T) {
|
|||
},
|
||||
expectedError: cli.StatusError{StatusCode: 15},
|
||||
},
|
||||
{
|
||||
err: context.Canceled,
|
||||
expectedError: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testcase := range testcases {
|
||||
errC := make(chan error, 1)
|
||||
resultC := make(chan container.WaitResponse, 1)
|
||||
if testcase.err != nil {
|
||||
errC <- testcase.err
|
||||
}
|
||||
if testcase.result != nil {
|
||||
resultC <- *testcase.result
|
||||
}
|
||||
|
||||
err := getExitStatus(errC, resultC)
|
||||
|
||||
if testcase.expectedError == nil {
|
||||
assert.NilError(t, err)
|
||||
} else {
|
||||
|
|
|
@ -0,0 +1,94 @@
|
|||
package container
|
||||
|
||||
import (
|
||||
"github.com/docker/cli/cli/command/completion"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/moby/sys/signal"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// allLinuxCapabilities is a list of all known Linux capabilities.
|
||||
//
|
||||
// This list was based on the containerd pkg/cap package;
|
||||
// https://github.com/containerd/containerd/blob/v1.7.19/pkg/cap/cap_linux.go#L133-L181
|
||||
//
|
||||
// TODO(thaJeztah): add descriptions, and enable descriptions for our completion scripts (cobra.CompletionOptions.DisableDescriptions is currently set to "true")
|
||||
var allLinuxCapabilities = []string{
|
||||
"ALL", // magic value for "all capabilities"
|
||||
|
||||
// caps35 is the caps of kernel 3.5 (37 entries)
|
||||
"CAP_CHOWN", // 2.2
|
||||
"CAP_DAC_OVERRIDE", // 2.2
|
||||
"CAP_DAC_READ_SEARCH", // 2.2
|
||||
"CAP_FOWNER", // 2.2
|
||||
"CAP_FSETID", // 2.2
|
||||
"CAP_KILL", // 2.2
|
||||
"CAP_SETGID", // 2.2
|
||||
"CAP_SETUID", // 2.2
|
||||
"CAP_SETPCAP", // 2.2
|
||||
"CAP_LINUX_IMMUTABLE", // 2.2
|
||||
"CAP_NET_BIND_SERVICE", // 2.2
|
||||
"CAP_NET_BROADCAST", // 2.2
|
||||
"CAP_NET_ADMIN", // 2.2
|
||||
"CAP_NET_RAW", // 2.2
|
||||
"CAP_IPC_LOCK", // 2.2
|
||||
"CAP_IPC_OWNER", // 2.2
|
||||
"CAP_SYS_MODULE", // 2.2
|
||||
"CAP_SYS_RAWIO", // 2.2
|
||||
"CAP_SYS_CHROOT", // 2.2
|
||||
"CAP_SYS_PTRACE", // 2.2
|
||||
"CAP_SYS_PACCT", // 2.2
|
||||
"CAP_SYS_ADMIN", // 2.2
|
||||
"CAP_SYS_BOOT", // 2.2
|
||||
"CAP_SYS_NICE", // 2.2
|
||||
"CAP_SYS_RESOURCE", // 2.2
|
||||
"CAP_SYS_TIME", // 2.2
|
||||
"CAP_SYS_TTY_CONFIG", // 2.2
|
||||
"CAP_MKNOD", // 2.4
|
||||
"CAP_LEASE", // 2.4
|
||||
"CAP_AUDIT_WRITE", // 2.6.11
|
||||
"CAP_AUDIT_CONTROL", // 2.6.11
|
||||
"CAP_SETFCAP", // 2.6.24
|
||||
"CAP_MAC_OVERRIDE", // 2.6.25
|
||||
"CAP_MAC_ADMIN", // 2.6.25
|
||||
"CAP_SYSLOG", // 2.6.37
|
||||
"CAP_WAKE_ALARM", // 3.0
|
||||
"CAP_BLOCK_SUSPEND", // 3.5
|
||||
|
||||
// caps316 is the caps of kernel 3.16 (38 entries)
|
||||
"CAP_AUDIT_READ",
|
||||
|
||||
// caps58 is the caps of kernel 5.8 (40 entries)
|
||||
"CAP_PERFMON",
|
||||
"CAP_BPF",
|
||||
|
||||
// caps59 is the caps of kernel 5.9 (41 entries)
|
||||
"CAP_CHECKPOINT_RESTORE",
|
||||
}
|
||||
|
||||
// restartPolicies is a list of all valid restart-policies..
|
||||
//
|
||||
// TODO(thaJeztah): add descriptions, and enable descriptions for our completion scripts (cobra.CompletionOptions.DisableDescriptions is currently set to "true")
|
||||
var restartPolicies = []string{
|
||||
string(container.RestartPolicyDisabled),
|
||||
string(container.RestartPolicyAlways),
|
||||
string(container.RestartPolicyOnFailure),
|
||||
string(container.RestartPolicyUnlessStopped),
|
||||
}
|
||||
|
||||
func completeLinuxCapabilityNames(cmd *cobra.Command, args []string, toComplete string) (names []string, _ cobra.ShellCompDirective) {
|
||||
return completion.FromList(allLinuxCapabilities...)(cmd, args, toComplete)
|
||||
}
|
||||
|
||||
func completeRestartPolicies(cmd *cobra.Command, args []string, toComplete string) (names []string, _ cobra.ShellCompDirective) {
|
||||
return completion.FromList(restartPolicies...)(cmd, args, toComplete)
|
||||
}
|
||||
|
||||
func completeSignals(cmd *cobra.Command, args []string, toComplete string) (names []string, _ cobra.ShellCompDirective) {
|
||||
// TODO(thaJeztah): do we want to provide the full list here, or a subset?
|
||||
signalNames := make([]string, 0, len(signal.SignalMap))
|
||||
for k := range signal.SignalMap {
|
||||
signalNames = append(signalNames, k)
|
||||
}
|
||||
return completion.FromList(signalNames...)(cmd, args, toComplete)
|
||||
}
|
|
@ -178,13 +178,14 @@ func TestSplitCpArg(t *testing.T) {
|
|||
expectedContainer: "container",
|
||||
},
|
||||
}
|
||||
for _, testcase := range testcases {
|
||||
t.Run(testcase.doc, func(t *testing.T) {
|
||||
skip.If(t, testcase.os != "" && testcase.os != runtime.GOOS)
|
||||
for _, tc := range testcases {
|
||||
tc := tc
|
||||
t.Run(tc.doc, func(t *testing.T) {
|
||||
skip.If(t, tc.os == "windows" && runtime.GOOS != "windows" || tc.os == "linux" && runtime.GOOS == "windows")
|
||||
|
||||
ctr, path := splitCpArg(testcase.path)
|
||||
assert.Check(t, is.Equal(testcase.expectedContainer, ctr))
|
||||
assert.Check(t, is.Equal(testcase.expectedPath, path))
|
||||
ctr, path := splitCpArg(tc.path)
|
||||
assert.Check(t, is.Equal(tc.expectedContainer, ctr))
|
||||
assert.Check(t, is.Equal(tc.expectedPath, path))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -77,6 +77,16 @@ func NewCreateCommand(dockerCli command.Cli) *cobra.Command {
|
|||
command.AddPlatformFlag(flags, &options.platform)
|
||||
command.AddTrustVerificationFlags(flags, &options.untrusted, dockerCli.ContentTrustEnabled())
|
||||
copts = addFlags(flags)
|
||||
|
||||
_ = cmd.RegisterFlagCompletionFunc("cap-add", completeLinuxCapabilityNames)
|
||||
_ = cmd.RegisterFlagCompletionFunc("cap-drop", completeLinuxCapabilityNames)
|
||||
_ = cmd.RegisterFlagCompletionFunc("env", completion.EnvVarNames)
|
||||
_ = cmd.RegisterFlagCompletionFunc("env-file", completion.FileNames)
|
||||
_ = cmd.RegisterFlagCompletionFunc("network", completion.NetworkNames(dockerCli))
|
||||
_ = cmd.RegisterFlagCompletionFunc("pull", completion.FromList(PullImageAlways, PullImageMissing, PullImageNever))
|
||||
_ = cmd.RegisterFlagCompletionFunc("restart", completeRestartPolicies)
|
||||
_ = cmd.RegisterFlagCompletionFunc("stop-signal", completeSignals)
|
||||
_ = cmd.RegisterFlagCompletionFunc("volumes-from", completion.ContainerNames(dockerCli, true))
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
|
|
@ -236,6 +236,7 @@ func TestNewCreateCommandWithContentTrustErrors(t *testing.T) {
|
|||
fakeCLI.SetNotaryClient(tc.notaryFunc)
|
||||
cmd := NewCreateCommand(fakeCLI)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs(tc.args)
|
||||
err := cmd.Execute()
|
||||
assert.ErrorContains(t, err, tc.expectedError)
|
||||
|
|
|
@ -4,7 +4,6 @@ import (
|
|||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/docker/cli/cli"
|
||||
"github.com/docker/cli/cli/command"
|
||||
|
@ -79,12 +78,8 @@ func NewExecCommand(dockerCli command.Cli) *cobra.Command {
|
|||
flags.StringVarP(&options.Workdir, "workdir", "w", "", "Working directory inside the container")
|
||||
flags.SetAnnotation("workdir", "version", []string{"1.35"})
|
||||
|
||||
_ = cmd.RegisterFlagCompletionFunc("env", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
|
||||
return os.Environ(), cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
_ = cmd.RegisterFlagCompletionFunc("env-file", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
|
||||
return nil, cobra.ShellCompDirectiveDefault // _filedir
|
||||
})
|
||||
_ = cmd.RegisterFlagCompletionFunc("env", completion.EnvVarNames)
|
||||
_ = cmd.RegisterFlagCompletionFunc("env-file", completion.FileNames)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
|
|
@ -39,6 +39,7 @@ func TestContainerExportOutputToIrregularFile(t *testing.T) {
|
|||
})
|
||||
cmd := NewExportCommand(cli)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs([]string{"-o", "/dev/random", "container"})
|
||||
|
||||
err := cmd.Execute()
|
||||
|
|
|
@ -38,6 +38,9 @@ func NewKillCommand(dockerCli command.Cli) *cobra.Command {
|
|||
|
||||
flags := cmd.Flags()
|
||||
flags.StringVarP(&opts.signal, "signal", "s", "", "Signal to send to the container")
|
||||
|
||||
_ = cmd.RegisterFlagCompletionFunc("signal", completeSignals)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
@ -50,7 +53,7 @@ func runKill(ctx context.Context, dockerCli command.Cli, opts *killOptions) erro
|
|||
if err := <-errChan; err != nil {
|
||||
errs = append(errs, err.Error())
|
||||
} else {
|
||||
fmt.Fprintln(dockerCli.Out(), name)
|
||||
_, _ = fmt.Fprintln(dockerCli.Out(), name)
|
||||
}
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
|
|
|
@ -163,6 +163,7 @@ func TestContainerListErrors(t *testing.T) {
|
|||
assert.Check(t, cmd.Flags().Set(key, value))
|
||||
}
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ package container
|
|||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/docker/cli/internal/test"
|
||||
|
@ -20,5 +21,7 @@ func TestContainerPrunePromptTermination(t *testing.T) {
|
|||
},
|
||||
})
|
||||
cmd := NewPruneCommand(cli)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
test.TerminatePrompt(ctx, t, cmd, cli)
|
||||
}
|
||||
|
|
|
@ -43,6 +43,9 @@ func NewRestartCommand(dockerCli command.Cli) *cobra.Command {
|
|||
flags := cmd.Flags()
|
||||
flags.StringVarP(&opts.signal, "signal", "s", "", "Signal to send to the container")
|
||||
flags.IntVarP(&opts.timeout, "time", "t", 0, "Seconds to wait before killing the container")
|
||||
|
||||
_ = cmd.RegisterFlagCompletionFunc("signal", completeSignals)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
|
|
@ -45,6 +45,7 @@ func TestRemoveForce(t *testing.T) {
|
|||
})
|
||||
cmd := NewRmCommand(cli)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs(tc.args)
|
||||
|
||||
err := cmd.Execute()
|
||||
|
|
|
@ -4,7 +4,6 @@ import (
|
|||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
|
@ -70,22 +69,15 @@ func NewRunCommand(dockerCli command.Cli) *cobra.Command {
|
|||
command.AddTrustVerificationFlags(flags, &options.untrusted, dockerCli.ContentTrustEnabled())
|
||||
copts = addFlags(flags)
|
||||
|
||||
cmd.RegisterFlagCompletionFunc(
|
||||
"env",
|
||||
func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return os.Environ(), cobra.ShellCompDirectiveNoFileComp
|
||||
},
|
||||
)
|
||||
cmd.RegisterFlagCompletionFunc(
|
||||
"env-file",
|
||||
func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return nil, cobra.ShellCompDirectiveDefault
|
||||
},
|
||||
)
|
||||
cmd.RegisterFlagCompletionFunc(
|
||||
"network",
|
||||
completion.NetworkNames(dockerCli),
|
||||
)
|
||||
_ = cmd.RegisterFlagCompletionFunc("cap-add", completeLinuxCapabilityNames)
|
||||
_ = cmd.RegisterFlagCompletionFunc("cap-drop", completeLinuxCapabilityNames)
|
||||
_ = cmd.RegisterFlagCompletionFunc("env", completion.EnvVarNames)
|
||||
_ = cmd.RegisterFlagCompletionFunc("env-file", completion.FileNames)
|
||||
_ = cmd.RegisterFlagCompletionFunc("network", completion.NetworkNames(dockerCli))
|
||||
_ = cmd.RegisterFlagCompletionFunc("pull", completion.FromList(PullImageAlways, PullImageMissing, PullImageNever))
|
||||
_ = cmd.RegisterFlagCompletionFunc("restart", completeRestartPolicies)
|
||||
_ = cmd.RegisterFlagCompletionFunc("stop-signal", completeSignals)
|
||||
_ = cmd.RegisterFlagCompletionFunc("volumes-from", completion.ContainerNames(dockerCli, true))
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
@ -119,6 +111,8 @@ func runRun(ctx context.Context, dockerCli command.Cli, flags *pflag.FlagSet, ro
|
|||
|
||||
//nolint:gocyclo
|
||||
func runContainer(ctx context.Context, dockerCli command.Cli, runOpts *runOptions, copts *containerOptions, containerCfg *containerConfig) error {
|
||||
ctx = context.WithoutCancel(ctx)
|
||||
|
||||
config := containerCfg.Config
|
||||
stdout, stderr := dockerCli.Out(), dockerCli.Err()
|
||||
apiClient := dockerCli.Client()
|
||||
|
@ -178,6 +172,9 @@ func runContainer(ctx context.Context, dockerCli command.Cli, runOpts *runOption
|
|||
detachKeys = runOpts.detachKeys
|
||||
}
|
||||
|
||||
// ctx should not be cancellable here, as this would kill the stream to the container
|
||||
// and we want to keep the stream open until the process in the container exits or until
|
||||
// the user forcefully terminates the CLI.
|
||||
closeFn, err := attachContainer(ctx, dockerCli, containerID, &errCh, config, container.AttachOptions{
|
||||
Stream: true,
|
||||
Stdin: config.AttachStdin,
|
||||
|
|
|
@ -5,7 +5,6 @@ import (
|
|||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
@ -38,15 +37,85 @@ func TestRunLabel(t *testing.T) {
|
|||
assert.NilError(t, cmd.Execute())
|
||||
}
|
||||
|
||||
func TestRunAttachTermination(t *testing.T) {
|
||||
func TestRunAttach(t *testing.T) {
|
||||
p, tty, err := pty.Open()
|
||||
assert.NilError(t, err)
|
||||
|
||||
defer func() {
|
||||
_ = tty.Close()
|
||||
_ = p.Close()
|
||||
}()
|
||||
|
||||
var conn net.Conn
|
||||
attachCh := make(chan struct{})
|
||||
fakeCLI := test.NewFakeCli(&fakeClient{
|
||||
createContainerFunc: func(_ *container.Config, _ *container.HostConfig, _ *network.NetworkingConfig, _ *specs.Platform, _ string) (container.CreateResponse, error) {
|
||||
return container.CreateResponse{
|
||||
ID: "id",
|
||||
}, nil
|
||||
},
|
||||
containerAttachFunc: func(ctx context.Context, containerID string, options container.AttachOptions) (types.HijackedResponse, error) {
|
||||
server, client := net.Pipe()
|
||||
conn = server
|
||||
t.Cleanup(func() {
|
||||
_ = server.Close()
|
||||
})
|
||||
attachCh <- struct{}{}
|
||||
return types.NewHijackedResponse(client, types.MediaTypeRawStream), nil
|
||||
},
|
||||
waitFunc: func(_ string) (<-chan container.WaitResponse, <-chan error) {
|
||||
responseChan := make(chan container.WaitResponse, 1)
|
||||
errChan := make(chan error)
|
||||
|
||||
responseChan <- container.WaitResponse{
|
||||
StatusCode: 33,
|
||||
}
|
||||
return responseChan, errChan
|
||||
},
|
||||
// use new (non-legacy) wait API
|
||||
// see: 38591f20d07795aaef45d400df89ca12f29c603b
|
||||
Version: "1.30",
|
||||
}, func(fc *test.FakeCli) {
|
||||
fc.SetOut(streams.NewOut(tty))
|
||||
fc.SetIn(streams.NewIn(tty))
|
||||
})
|
||||
|
||||
cmd := NewRunCommand(fakeCLI)
|
||||
cmd.SetArgs([]string{"-it", "busybox"})
|
||||
cmd.SilenceUsage = true
|
||||
cmdErrC := make(chan error, 1)
|
||||
go func() {
|
||||
cmdErrC <- cmd.Execute()
|
||||
}()
|
||||
|
||||
// run command should attempt to attach to the container
|
||||
select {
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("containerAttachFunc was not called before the 5 second timeout")
|
||||
case <-attachCh:
|
||||
}
|
||||
|
||||
// end stream from "container" so that we'll detach
|
||||
conn.Close()
|
||||
|
||||
select {
|
||||
case cmdErr := <-cmdErrC:
|
||||
assert.Equal(t, cmdErr, cli.StatusError{
|
||||
StatusCode: 33,
|
||||
})
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("cmd did not return within timeout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAttachTermination(t *testing.T) {
|
||||
p, tty, err := pty.Open()
|
||||
assert.NilError(t, err)
|
||||
defer func() {
|
||||
_ = tty.Close()
|
||||
_ = p.Close()
|
||||
}()
|
||||
|
||||
var conn net.Conn
|
||||
killCh := make(chan struct{})
|
||||
attachCh := make(chan struct{})
|
||||
fakeCLI := test.NewFakeCli(&fakeClient{
|
||||
|
@ -61,42 +130,62 @@ func TestRunAttachTermination(t *testing.T) {
|
|||
},
|
||||
containerAttachFunc: func(ctx context.Context, containerID string, options container.AttachOptions) (types.HijackedResponse, error) {
|
||||
server, client := net.Pipe()
|
||||
conn = server
|
||||
t.Cleanup(func() {
|
||||
_ = server.Close()
|
||||
})
|
||||
attachCh <- struct{}{}
|
||||
return types.NewHijackedResponse(client, types.MediaTypeRawStream), nil
|
||||
},
|
||||
Version: "1.36",
|
||||
waitFunc: func(_ string) (<-chan container.WaitResponse, <-chan error) {
|
||||
responseChan := make(chan container.WaitResponse, 1)
|
||||
errChan := make(chan error)
|
||||
|
||||
responseChan <- container.WaitResponse{
|
||||
StatusCode: 130,
|
||||
}
|
||||
return responseChan, errChan
|
||||
},
|
||||
// use new (non-legacy) wait API
|
||||
// see: 38591f20d07795aaef45d400df89ca12f29c603b
|
||||
Version: "1.30",
|
||||
}, func(fc *test.FakeCli) {
|
||||
fc.SetOut(streams.NewOut(tty))
|
||||
fc.SetIn(streams.NewIn(tty))
|
||||
})
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM)
|
||||
defer cancel()
|
||||
|
||||
assert.Equal(t, fakeCLI.In().IsTerminal(), true)
|
||||
assert.Equal(t, fakeCLI.Out().IsTerminal(), true)
|
||||
|
||||
cmd := NewRunCommand(fakeCLI)
|
||||
cmd.SetArgs([]string{"-it", "busybox"})
|
||||
cmd.SilenceUsage = true
|
||||
cmdErrC := make(chan error, 1)
|
||||
go func() {
|
||||
assert.ErrorIs(t, cmd.ExecuteContext(ctx), context.Canceled)
|
||||
cmdErrC <- cmd.Execute()
|
||||
}()
|
||||
|
||||
// run command should attempt to attach to the container
|
||||
select {
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("containerAttachFunc was not called before the 5 second timeout")
|
||||
t.Fatal("containerAttachFunc was not called before the timeout")
|
||||
case <-attachCh:
|
||||
}
|
||||
|
||||
assert.NilError(t, syscall.Kill(syscall.Getpid(), syscall.SIGTERM))
|
||||
assert.NilError(t, syscall.Kill(syscall.Getpid(), syscall.SIGINT))
|
||||
// end stream from "container" so that we'll detach
|
||||
conn.Close()
|
||||
|
||||
select {
|
||||
case <-time.After(5 * time.Second):
|
||||
cancel()
|
||||
t.Fatal("containerKillFunc was not called before the 5 second timeout")
|
||||
case <-killCh:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("containerKillFunc was not called before the timeout")
|
||||
}
|
||||
|
||||
select {
|
||||
case cmdErr := <-cmdErrC:
|
||||
assert.Equal(t, cmdErr, cli.StatusError{
|
||||
StatusCode: 130,
|
||||
})
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("cmd did not return before the timeout")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -127,23 +216,27 @@ func TestRunCommandWithContentTrustErrors(t *testing.T) {
|
|||
},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
fakeCLI := test.NewFakeCli(&fakeClient{
|
||||
createContainerFunc: func(config *container.Config,
|
||||
hostConfig *container.HostConfig,
|
||||
networkingConfig *network.NetworkingConfig,
|
||||
platform *specs.Platform,
|
||||
containerName string,
|
||||
) (container.CreateResponse, error) {
|
||||
return container.CreateResponse{}, errors.New("shouldn't try to pull image")
|
||||
},
|
||||
}, test.EnableContentTrust)
|
||||
fakeCLI.SetNotaryClient(tc.notaryFunc)
|
||||
cmd := NewRunCommand(fakeCLI)
|
||||
cmd.SetArgs(tc.args)
|
||||
cmd.SetOut(io.Discard)
|
||||
err := cmd.Execute()
|
||||
assert.Assert(t, err != nil)
|
||||
assert.Assert(t, is.Contains(fakeCLI.ErrBuffer().String(), tc.expectedError))
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
fakeCLI := test.NewFakeCli(&fakeClient{
|
||||
createContainerFunc: func(config *container.Config,
|
||||
hostConfig *container.HostConfig,
|
||||
networkingConfig *network.NetworkingConfig,
|
||||
platform *specs.Platform,
|
||||
containerName string,
|
||||
) (container.CreateResponse, error) {
|
||||
return container.CreateResponse{}, errors.New("shouldn't try to pull image")
|
||||
},
|
||||
}, test.EnableContentTrust)
|
||||
fakeCLI.SetNotaryClient(tc.notaryFunc)
|
||||
cmd := NewRunCommand(fakeCLI)
|
||||
cmd.SetArgs(tc.args)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
err := cmd.Execute()
|
||||
assert.Assert(t, err != nil)
|
||||
assert.Assert(t, is.Contains(fakeCLI.ErrBuffer().String(), tc.expectedError))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -43,6 +43,9 @@ func NewStopCommand(dockerCli command.Cli) *cobra.Command {
|
|||
flags := cmd.Flags()
|
||||
flags.StringVarP(&opts.signal, "signal", "s", "", "Signal to send to the container")
|
||||
flags.IntVarP(&opts.timeout, "time", "t", 0, "Seconds to wait before killing the container")
|
||||
|
||||
_ = cmd.RegisterFlagCompletionFunc("signal", completeSignals)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
|
|
@ -83,6 +83,8 @@ func NewUpdateCommand(dockerCli command.Cli) *cobra.Command {
|
|||
flags.Var(&options.cpus, "cpus", "Number of CPUs")
|
||||
flags.SetAnnotation("cpus", "version", []string{"1.29"})
|
||||
|
||||
_ = cmd.RegisterFlagCompletionFunc("restart", completeRestartPolicies)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
|
|
@ -5,6 +5,7 @@ package formatter
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
@ -331,7 +332,8 @@ func DisplayablePorts(ports []types.Port) string {
|
|||
portKey := port.Type
|
||||
if port.IP != "" {
|
||||
if port.PublicPort != current {
|
||||
hostMappings = append(hostMappings, fmt.Sprintf("%s:%d->%d/%s", port.IP, port.PublicPort, port.PrivatePort, port.Type))
|
||||
hAddrPort := net.JoinHostPort(port.IP, strconv.Itoa(int(port.PublicPort)))
|
||||
hostMappings = append(hostMappings, fmt.Sprintf("%s->%d/%s", hAddrPort, port.PrivatePort, port.Type))
|
||||
continue
|
||||
}
|
||||
portKey = port.IP + "/" + port.Type
|
||||
|
|
|
@ -471,6 +471,16 @@ func TestDisplayablePorts(t *testing.T) {
|
|||
},
|
||||
"0.0.0.0:0->9988/tcp",
|
||||
},
|
||||
{
|
||||
[]types.Port{
|
||||
{
|
||||
IP: "::",
|
||||
PrivatePort: 9988,
|
||||
Type: "tcp",
|
||||
},
|
||||
},
|
||||
"[::]:0->9988/tcp",
|
||||
},
|
||||
{
|
||||
[]types.Port{
|
||||
{
|
||||
|
|
|
@ -15,6 +15,7 @@ import (
|
|||
"strings"
|
||||
|
||||
"github.com/distribution/reference"
|
||||
"github.com/docker/cli-docs-tool/annotation"
|
||||
"github.com/docker/cli/cli"
|
||||
"github.com/docker/cli/cli/command"
|
||||
"github.com/docker/cli/cli/command/image/build"
|
||||
|
@ -104,7 +105,7 @@ func NewBuildCommand(dockerCli command.Cli) *cobra.Command {
|
|||
},
|
||||
Annotations: map[string]string{
|
||||
"category-top": "4",
|
||||
"aliases": "docker image build, docker build, docker buildx build, docker builder build",
|
||||
"aliases": "docker image build, docker build, docker builder build",
|
||||
},
|
||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return nil, cobra.ShellCompDirectiveFilterDirs
|
||||
|
@ -114,9 +115,12 @@ func NewBuildCommand(dockerCli command.Cli) *cobra.Command {
|
|||
flags := cmd.Flags()
|
||||
|
||||
flags.VarP(&options.tags, "tag", "t", `Name and optionally a tag in the "name:tag" format`)
|
||||
flags.SetAnnotation("tag", annotation.ExternalURL, []string{"https://docs.docker.com/reference/cli/docker/buildx/build/#tag"})
|
||||
flags.Var(&options.buildArgs, "build-arg", "Set build-time variables")
|
||||
flags.SetAnnotation("build-arg", annotation.ExternalURL, []string{"https://docs.docker.com/reference/cli/docker/buildx/build/#build-arg"})
|
||||
flags.Var(options.ulimits, "ulimit", "Ulimit options")
|
||||
flags.StringVarP(&options.dockerfileName, "file", "f", "", `Name of the Dockerfile (Default is "PATH/Dockerfile")`)
|
||||
flags.SetAnnotation("file", annotation.ExternalURL, []string{"https://docs.docker.com/reference/cli/docker/buildx/build/#file"})
|
||||
flags.VarP(&options.memory, "memory", "m", "Memory limit")
|
||||
flags.Var(&options.memorySwap, "memory-swap", `Swap limit equal to memory plus swap: -1 to enable unlimited swap`)
|
||||
flags.Var(&options.shmSize, "shm-size", `Size of "/dev/shm"`)
|
||||
|
@ -126,6 +130,7 @@ func NewBuildCommand(dockerCli command.Cli) *cobra.Command {
|
|||
flags.StringVar(&options.cpuSetCpus, "cpuset-cpus", "", "CPUs in which to allow execution (0-3, 0,1)")
|
||||
flags.StringVar(&options.cpuSetMems, "cpuset-mems", "", "MEMs in which to allow execution (0-3, 0,1)")
|
||||
flags.StringVar(&options.cgroupParent, "cgroup-parent", "", `Set the parent cgroup for the "RUN" instructions during build`)
|
||||
flags.SetAnnotation("cgroup-parent", annotation.ExternalURL, []string{"https://docs.docker.com/reference/cli/docker/buildx/build/#cgroup-parent"})
|
||||
flags.StringVar(&options.isolation, "isolation", "", "Container isolation technology")
|
||||
flags.Var(&options.labels, "label", "Set metadata for an image")
|
||||
flags.BoolVar(&options.noCache, "no-cache", false, "Do not use cache when building the image")
|
||||
|
@ -138,8 +143,11 @@ func NewBuildCommand(dockerCli command.Cli) *cobra.Command {
|
|||
flags.StringSliceVar(&options.securityOpt, "security-opt", []string{}, "Security options")
|
||||
flags.StringVar(&options.networkMode, "network", "default", "Set the networking mode for the RUN instructions during build")
|
||||
flags.SetAnnotation("network", "version", []string{"1.25"})
|
||||
flags.SetAnnotation("network", annotation.ExternalURL, []string{"https://docs.docker.com/reference/cli/docker/buildx/build/#network"})
|
||||
flags.Var(&options.extraHosts, "add-host", `Add a custom host-to-IP mapping ("host:ip")`)
|
||||
flags.SetAnnotation("add-host", annotation.ExternalURL, []string{"https://docs.docker.com/reference/cli/docker/buildx/build/#add-host"})
|
||||
flags.StringVar(&options.target, "target", "", "Set the target build stage to build.")
|
||||
flags.SetAnnotation("target", annotation.ExternalURL, []string{"https://docs.docker.com/reference/cli/docker/buildx/build/#target"})
|
||||
flags.StringVar(&options.imageIDFile, "iidfile", "", "Write the image ID to the file")
|
||||
|
||||
command.AddTrustVerificationFlags(flags, &options.untrusted, dockerCli.ContentTrustEnabled())
|
||||
|
|
|
@ -127,6 +127,7 @@ func TestRunBuildFromGitHubSpecialCase(t *testing.T) {
|
|||
// Clone a small repo that exists so git doesn't prompt for credentials
|
||||
cmd.SetArgs([]string{"github.com/docker/for-win"})
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
err := cmd.Execute()
|
||||
assert.ErrorContains(t, err, "unable to prepare context")
|
||||
assert.ErrorContains(t, err, "docker-build-git")
|
||||
|
|
|
@ -5,6 +5,7 @@ import (
|
|||
|
||||
"github.com/docker/cli/cli"
|
||||
"github.com/docker/cli/cli/command"
|
||||
"github.com/docker/cli/cli/command/completion"
|
||||
"github.com/docker/cli/cli/command/formatter"
|
||||
flagsHelper "github.com/docker/cli/cli/flags"
|
||||
"github.com/spf13/cobra"
|
||||
|
@ -31,6 +32,7 @@ func NewHistoryCommand(dockerCli command.Cli) *cobra.Command {
|
|||
opts.image = args[0]
|
||||
return runHistory(cmd.Context(), dockerCli, opts)
|
||||
},
|
||||
ValidArgsFunction: completion.ImageNames(dockerCli),
|
||||
Annotations: map[string]string{
|
||||
"aliases": "docker image history, docker history",
|
||||
},
|
||||
|
|
|
@ -35,10 +35,14 @@ func TestNewHistoryCommandErrors(t *testing.T) {
|
|||
},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
cmd := NewHistoryCommand(test.NewFakeCli(&fakeClient{imageHistoryFunc: tc.imageHistoryFunc}))
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetArgs(tc.args)
|
||||
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmd := NewHistoryCommand(test.NewFakeCli(&fakeClient{imageHistoryFunc: tc.imageHistoryFunc}))
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs(tc.args)
|
||||
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -36,6 +36,7 @@ func TestNewImportCommandErrors(t *testing.T) {
|
|||
for _, tc := range testCases {
|
||||
cmd := NewImportCommand(test.NewFakeCli(&fakeClient{imageImportFunc: tc.imageImportFunc}))
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs(tc.args)
|
||||
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
|
||||
}
|
||||
|
@ -44,6 +45,7 @@ func TestNewImportCommandErrors(t *testing.T) {
|
|||
func TestNewImportCommandInvalidFile(t *testing.T) {
|
||||
cmd := NewImportCommand(test.NewFakeCli(&fakeClient{}))
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs([]string{"testdata/import-command-success.unexistent-file"})
|
||||
assert.ErrorContains(t, cmd.Execute(), "testdata/import-command-success.unexistent-file")
|
||||
}
|
||||
|
@ -96,9 +98,13 @@ func TestNewImportCommandSuccess(t *testing.T) {
|
|||
},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
cmd := NewImportCommand(test.NewFakeCli(&fakeClient{imageImportFunc: tc.imageImportFunc}))
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetArgs(tc.args)
|
||||
assert.NilError(t, cmd.Execute())
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmd := NewImportCommand(test.NewFakeCli(&fakeClient{imageImportFunc: tc.imageImportFunc}))
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs(tc.args)
|
||||
assert.NilError(t, cmd.Execute())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ import (
|
|||
|
||||
"github.com/docker/cli/cli"
|
||||
"github.com/docker/cli/cli/command"
|
||||
"github.com/docker/cli/cli/command/completion"
|
||||
"github.com/docker/cli/cli/command/inspect"
|
||||
flagsHelper "github.com/docker/cli/cli/flags"
|
||||
"github.com/spf13/cobra"
|
||||
|
@ -30,6 +31,7 @@ func newInspectCommand(dockerCli command.Cli) *cobra.Command {
|
|||
opts.refs = args
|
||||
return runInspect(cmd.Context(), dockerCli, opts)
|
||||
},
|
||||
ValidArgsFunction: completion.ImageNames(dockerCli),
|
||||
}
|
||||
|
||||
flags := cmd.Flags()
|
||||
|
|
|
@ -25,10 +25,14 @@ func TestNewInspectCommandErrors(t *testing.T) {
|
|||
},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
cmd := newInspectCommand(test.NewFakeCli(&fakeClient{}))
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetArgs(tc.args)
|
||||
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmd := newInspectCommand(test.NewFakeCli(&fakeClient{}))
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs(tc.args)
|
||||
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@ package image
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
|
@ -24,6 +25,7 @@ type imagesOptions struct {
|
|||
format string
|
||||
filter opts.FilterOpt
|
||||
calledAs string
|
||||
tree bool
|
||||
}
|
||||
|
||||
// NewImagesCommand creates a new `docker images` command
|
||||
|
@ -59,6 +61,10 @@ func NewImagesCommand(dockerCLI command.Cli) *cobra.Command {
|
|||
flags.StringVar(&options.format, "format", "", flagsHelper.FormatHelp)
|
||||
flags.VarP(&options.filter, "filter", "f", "Filter output based on conditions provided")
|
||||
|
||||
flags.BoolVar(&options.tree, "tree", false, "List multi-platform images as a tree (EXPERIMENTAL)")
|
||||
flags.SetAnnotation("tree", "version", []string{"1.47"})
|
||||
flags.SetAnnotation("tree", "experimentalCLI", nil)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
@ -75,6 +81,26 @@ func runImages(ctx context.Context, dockerCLI command.Cli, options imagesOptions
|
|||
filters.Add("reference", options.matchName)
|
||||
}
|
||||
|
||||
if options.tree {
|
||||
if options.quiet {
|
||||
return errors.New("--quiet is not yet supported with --tree")
|
||||
}
|
||||
if options.noTrunc {
|
||||
return errors.New("--no-trunc is not yet supported with --tree")
|
||||
}
|
||||
if options.showDigests {
|
||||
return errors.New("--show-digest is not yet supported with --tree")
|
||||
}
|
||||
if options.format != "" {
|
||||
return errors.New("--format is not yet supported with --tree")
|
||||
}
|
||||
|
||||
return runTree(ctx, dockerCLI, treeOptions{
|
||||
all: options.all,
|
||||
filters: filters,
|
||||
})
|
||||
}
|
||||
|
||||
images, err := dockerCLI.Client().ImageList(ctx, image.ListOptions{
|
||||
All: options.all,
|
||||
Filters: filters,
|
||||
|
|
|
@ -35,10 +35,14 @@ func TestNewImagesCommandErrors(t *testing.T) {
|
|||
},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
cmd := NewImagesCommand(test.NewFakeCli(&fakeClient{imageListFunc: tc.imageListFunc}))
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetArgs(tc.args)
|
||||
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmd := NewImagesCommand(test.NewFakeCli(&fakeClient{imageListFunc: tc.imageListFunc}))
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs(tc.args)
|
||||
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -79,14 +83,18 @@ func TestNewImagesCommandSuccess(t *testing.T) {
|
|||
},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
cli := test.NewFakeCli(&fakeClient{imageListFunc: tc.imageListFunc})
|
||||
cli.SetConfigFile(&configfile.ConfigFile{ImagesFormat: tc.imageFormat})
|
||||
cmd := NewImagesCommand(cli)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetArgs(tc.args)
|
||||
err := cmd.Execute()
|
||||
assert.NilError(t, err)
|
||||
golden.Assert(t, cli.OutBuffer().String(), fmt.Sprintf("list-command-success.%s.golden", tc.name))
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cli := test.NewFakeCli(&fakeClient{imageListFunc: tc.imageListFunc})
|
||||
cli.SetConfigFile(&configfile.ConfigFile{ImagesFormat: tc.imageFormat})
|
||||
cmd := NewImagesCommand(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("list-command-success.%s.golden", tc.name))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -40,12 +40,16 @@ func TestNewLoadCommandErrors(t *testing.T) {
|
|||
},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
cli := test.NewFakeCli(&fakeClient{imageLoadFunc: tc.imageLoadFunc})
|
||||
cli.In().SetIsTerminal(tc.isTerminalIn)
|
||||
cmd := NewLoadCommand(cli)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetArgs(tc.args)
|
||||
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cli := test.NewFakeCli(&fakeClient{imageLoadFunc: tc.imageLoadFunc})
|
||||
cli.In().SetIsTerminal(tc.isTerminalIn)
|
||||
cmd := NewLoadCommand(cli)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs(tc.args)
|
||||
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -53,6 +57,7 @@ func TestNewLoadCommandInvalidInput(t *testing.T) {
|
|||
expectedError := "open *"
|
||||
cmd := NewLoadCommand(test.NewFakeCli(&fakeClient{}))
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs([]string{"--input", "*"})
|
||||
err := cmd.Execute()
|
||||
assert.ErrorContains(t, err, expectedError)
|
||||
|
@ -89,12 +94,15 @@ func TestNewLoadCommandSuccess(t *testing.T) {
|
|||
},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
cli := test.NewFakeCli(&fakeClient{imageLoadFunc: tc.imageLoadFunc})
|
||||
cmd := NewLoadCommand(cli)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetArgs(tc.args)
|
||||
err := cmd.Execute()
|
||||
assert.NilError(t, err)
|
||||
golden.Assert(t, cli.OutBuffer().String(), fmt.Sprintf("load-command-success.%s.golden", tc.name))
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cli := test.NewFakeCli(&fakeClient{imageLoadFunc: tc.imageLoadFunc})
|
||||
cmd := NewLoadCommand(cli)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetArgs(tc.args)
|
||||
err := cmd.Execute()
|
||||
assert.NilError(t, err)
|
||||
golden.Assert(t, cli.OutBuffer().String(), fmt.Sprintf("load-command-success.%s.golden", tc.name))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -39,12 +39,16 @@ func TestNewPruneCommandErrors(t *testing.T) {
|
|||
},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
cmd := NewPruneCommand(test.NewFakeCli(&fakeClient{
|
||||
imagesPruneFunc: tc.imagesPruneFunc,
|
||||
}))
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetArgs(tc.args)
|
||||
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmd := NewPruneCommand(test.NewFakeCli(&fakeClient{
|
||||
imagesPruneFunc: tc.imagesPruneFunc,
|
||||
}))
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs(tc.args)
|
||||
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -94,6 +98,7 @@ func TestNewPruneCommandSuccess(t *testing.T) {
|
|||
},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cli := test.NewFakeCli(&fakeClient{imagesPruneFunc: tc.imagesPruneFunc})
|
||||
// when prompted, answer "Y" to confirm the prune.
|
||||
|
@ -119,5 +124,8 @@ func TestPrunePromptTermination(t *testing.T) {
|
|||
},
|
||||
})
|
||||
cmd := NewPruneCommand(cli)
|
||||
cmd.SetArgs([]string{})
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
test.TerminatePrompt(ctx, t, cmd, cli)
|
||||
}
|
||||
|
|
|
@ -38,11 +38,15 @@ func TestNewPullCommandErrors(t *testing.T) {
|
|||
},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
cli := test.NewFakeCli(&fakeClient{})
|
||||
cmd := NewPullCommand(cli)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetArgs(tc.args)
|
||||
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cli := test.NewFakeCli(&fakeClient{})
|
||||
cmd := NewPullCommand(cli)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs(tc.args)
|
||||
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -69,18 +73,22 @@ func TestNewPullCommandSuccess(t *testing.T) {
|
|||
},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
cli := test.NewFakeCli(&fakeClient{
|
||||
imagePullFunc: func(ref string, options image.PullOptions) (io.ReadCloser, error) {
|
||||
assert.Check(t, is.Equal(tc.expectedTag, ref), tc.name)
|
||||
return io.NopCloser(strings.NewReader("")), nil
|
||||
},
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cli := test.NewFakeCli(&fakeClient{
|
||||
imagePullFunc: func(ref string, options image.PullOptions) (io.ReadCloser, error) {
|
||||
assert.Check(t, is.Equal(tc.expectedTag, ref), tc.name)
|
||||
return io.NopCloser(strings.NewReader("")), 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-success.%s.golden", tc.name))
|
||||
})
|
||||
cmd := NewPullCommand(cli)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetArgs(tc.args)
|
||||
err := cmd.Execute()
|
||||
assert.NilError(t, err)
|
||||
golden.Assert(t, cli.OutBuffer().String(), fmt.Sprintf("pull-command-success.%s.golden", tc.name))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -111,16 +119,20 @@ func TestNewPullCommandWithContentTrustErrors(t *testing.T) {
|
|||
},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
cli := test.NewFakeCli(&fakeClient{
|
||||
imagePullFunc: func(ref string, options image.PullOptions) (io.ReadCloser, error) {
|
||||
return io.NopCloser(strings.NewReader("")), errors.New("shouldn't try to pull image")
|
||||
},
|
||||
}, test.EnableContentTrust)
|
||||
cli.SetNotaryClient(tc.notaryFunc)
|
||||
cmd := NewPullCommand(cli)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetArgs(tc.args)
|
||||
err := cmd.Execute()
|
||||
assert.ErrorContains(t, err, tc.expectedError)
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cli := test.NewFakeCli(&fakeClient{
|
||||
imagePullFunc: func(ref string, options image.PullOptions) (io.ReadCloser, error) {
|
||||
return io.NopCloser(strings.NewReader("")), errors.New("shouldn't try to pull image")
|
||||
},
|
||||
}, test.EnableContentTrust)
|
||||
cli.SetNotaryClient(tc.notaryFunc)
|
||||
cmd := NewPullCommand(cli)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs(tc.args)
|
||||
err := cmd.Execute()
|
||||
assert.ErrorContains(t, err, tc.expectedError)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@ import (
|
|||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/containerd/platforms"
|
||||
"github.com/distribution/reference"
|
||||
|
@ -58,8 +58,13 @@ func NewPushCommand(dockerCli command.Cli) *cobra.Command {
|
|||
flags.BoolVarP(&opts.all, "all-tags", "a", false, "Push all tags of an image to the repository")
|
||||
flags.BoolVarP(&opts.quiet, "quiet", "q", false, "Suppress verbose output")
|
||||
command.AddTrustSigningFlags(flags, &opts.untrusted, dockerCli.ContentTrustEnabled())
|
||||
flags.StringVar(&opts.platform, "platform", os.Getenv("DOCKER_DEFAULT_PLATFORM"),
|
||||
|
||||
// Don't default to DOCKER_DEFAULT_PLATFORM env variable, always default to
|
||||
// pushing the image as-is. This also avoids forcing the platform selection
|
||||
// on older APIs which don't support it.
|
||||
flags.StringVar(&opts.platform, "platform", "",
|
||||
`Push a platform-specific manifest as a single-platform image to the registry.
|
||||
Image index won't be pushed, meaning that other manifests, including attestations won't be preserved.
|
||||
'os[/arch[/variant]]': Explicit platform (eg. linux/amd64)`)
|
||||
flags.SetAnnotation("platform", "version", []string{"1.46"})
|
||||
|
||||
|
@ -79,9 +84,9 @@ func RunPush(ctx context.Context, dockerCli command.Cli, opts pushOptions) error
|
|||
}
|
||||
platform = &p
|
||||
|
||||
printNote(dockerCli, `Selecting a single platform will only push one matching image manifest from a multi-platform image index.
|
||||
This means that any other components attached to the multi-platform image index (like Buildkit attestations) won't be pushed.
|
||||
If you want to only push a single platform image while preserving the attestations, please use 'docker convert\n'
|
||||
printNote(dockerCli, `Using --platform pushes only the specified platform manifest of a multi-platform image index.
|
||||
Other components, like attestations, will not be included.
|
||||
To push the complete multi-platform image, remove the --platform flag.
|
||||
`)
|
||||
}
|
||||
|
||||
|
@ -179,9 +184,22 @@ func handleAux(dockerCli command.Cli) func(jm jsonmessage.JSONMessage) {
|
|||
|
||||
func printNote(dockerCli command.Cli, format string, args ...any) {
|
||||
if dockerCli.Err().IsTerminal() {
|
||||
_, _ = fmt.Fprint(dockerCli.Err(), aec.WhiteF.Apply(aec.CyanB.Apply("[ NOTE ]"))+" ")
|
||||
} else {
|
||||
_, _ = fmt.Fprint(dockerCli.Err(), "[ NOTE ] ")
|
||||
format = strings.ReplaceAll(format, "--platform", aec.Bold.Apply("--platform"))
|
||||
}
|
||||
|
||||
header := " Info -> "
|
||||
padding := len(header)
|
||||
if dockerCli.Err().IsTerminal() {
|
||||
padding = len("i Info > ")
|
||||
header = aec.Bold.Apply(aec.LightCyanB.Apply(aec.BlackF.Apply("i")) + " " + aec.LightCyanF.Apply("Info → "))
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprint(dockerCli.Err(), header)
|
||||
s := fmt.Sprintf(format, args...)
|
||||
for idx, line := range strings.Split(s, "\n") {
|
||||
if idx > 0 {
|
||||
_, _ = fmt.Fprint(dockerCli.Err(), strings.Repeat(" ", padding))
|
||||
}
|
||||
_, _ = fmt.Fprintln(dockerCli.Err(), aec.Italic.Apply(line))
|
||||
}
|
||||
_, _ = fmt.Fprintf(dockerCli.Err(), aec.Bold.Apply(format)+"\n", args...)
|
||||
}
|
||||
|
|
|
@ -38,11 +38,15 @@ func TestNewPushCommandErrors(t *testing.T) {
|
|||
},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
cli := test.NewFakeCli(&fakeClient{imagePushFunc: tc.imagePushFunc})
|
||||
cmd := NewPushCommand(cli)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetArgs(tc.args)
|
||||
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cli := test.NewFakeCli(&fakeClient{imagePushFunc: tc.imagePushFunc})
|
||||
cmd := NewPushCommand(cli)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs(tc.args)
|
||||
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -73,6 +77,7 @@ func TestNewPushCommandSuccess(t *testing.T) {
|
|||
})
|
||||
cmd := NewPushCommand(cli)
|
||||
cmd.SetOut(cli.OutBuffer())
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs(tc.args)
|
||||
assert.NilError(t, cmd.Execute())
|
||||
if tc.output != "" {
|
||||
|
|
|
@ -7,6 +7,7 @@ import (
|
|||
|
||||
"github.com/docker/cli/cli"
|
||||
"github.com/docker/cli/cli/command"
|
||||
"github.com/docker/cli/cli/command/completion"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
"github.com/docker/docker/errdefs"
|
||||
"github.com/pkg/errors"
|
||||
|
@ -29,6 +30,7 @@ func NewRemoveCommand(dockerCli command.Cli) *cobra.Command {
|
|||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runRemove(cmd.Context(), dockerCli, opts, args)
|
||||
},
|
||||
ValidArgsFunction: completion.ImageNames(dockerCli),
|
||||
Annotations: map[string]string{
|
||||
"aliases": "docker image rm, docker image remove, docker rmi",
|
||||
},
|
||||
|
|
|
@ -62,11 +62,13 @@ func TestNewRemoveCommandErrors(t *testing.T) {
|
|||
},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmd := NewRemoveCommand(test.NewFakeCli(&fakeClient{
|
||||
imageRemoveFunc: tc.imageRemoveFunc,
|
||||
}))
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs(tc.args)
|
||||
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
|
||||
})
|
||||
|
@ -119,10 +121,12 @@ func TestNewRemoveCommandSuccess(t *testing.T) {
|
|||
},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cli := test.NewFakeCli(&fakeClient{imageRemoveFunc: tc.imageRemoveFunc})
|
||||
cmd := NewRemoveCommand(cli)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs(tc.args)
|
||||
assert.NilError(t, cmd.Execute())
|
||||
assert.Check(t, is.Equal(tc.expectedStderr, cli.ErrBuffer().String()))
|
||||
|
|
|
@ -52,12 +52,16 @@ func TestNewSaveCommandErrors(t *testing.T) {
|
|||
},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
cli := test.NewFakeCli(&fakeClient{imageSaveFunc: tc.imageSaveFunc})
|
||||
cli.Out().SetIsTerminal(tc.isTerminal)
|
||||
cmd := NewSaveCommand(cli)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetArgs(tc.args)
|
||||
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cli := test.NewFakeCli(&fakeClient{imageSaveFunc: tc.imageSaveFunc})
|
||||
cli.Out().SetIsTerminal(tc.isTerminal)
|
||||
cmd := NewSaveCommand(cli)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs(tc.args)
|
||||
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -77,7 +81,7 @@ func TestNewSaveCommandSuccess(t *testing.T) {
|
|||
return io.NopCloser(strings.NewReader("")), nil
|
||||
},
|
||||
deferredFunc: func() {
|
||||
os.Remove("save_tmp_file")
|
||||
_ = os.Remove("save_tmp_file")
|
||||
},
|
||||
},
|
||||
{
|
||||
|
@ -92,16 +96,20 @@ func TestNewSaveCommandSuccess(t *testing.T) {
|
|||
},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
cmd := NewSaveCommand(test.NewFakeCli(&fakeClient{
|
||||
imageSaveFunc: func(images []string) (io.ReadCloser, error) {
|
||||
return io.NopCloser(strings.NewReader("")), nil
|
||||
},
|
||||
}))
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetArgs(tc.args)
|
||||
assert.NilError(t, cmd.Execute())
|
||||
if tc.deferredFunc != nil {
|
||||
tc.deferredFunc()
|
||||
}
|
||||
tc := tc
|
||||
t.Run(strings.Join(tc.args, " "), func(t *testing.T) {
|
||||
cmd := NewSaveCommand(test.NewFakeCli(&fakeClient{
|
||||
imageSaveFunc: func(images []string) (io.ReadCloser, error) {
|
||||
return io.NopCloser(strings.NewReader("")), nil
|
||||
},
|
||||
}))
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs(tc.args)
|
||||
assert.NilError(t, cmd.Execute())
|
||||
if tc.deferredFunc != nil {
|
||||
tc.deferredFunc()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,6 +20,7 @@ func TestCliNewTagCommandErrors(t *testing.T) {
|
|||
cmd := NewTagCommand(test.NewFakeCli(&fakeClient{}))
|
||||
cmd.SetArgs(args)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
assert.ErrorContains(t, cmd.Execute(), expectedError)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,393 @@
|
|||
package image
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/containerd/platforms"
|
||||
"github.com/docker/cli/cli/command"
|
||||
"github.com/docker/cli/cli/streams"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
imagetypes "github.com/docker/docker/api/types/image"
|
||||
"github.com/docker/docker/pkg/stringid"
|
||||
"github.com/docker/go-units"
|
||||
"github.com/morikuni/aec"
|
||||
)
|
||||
|
||||
type treeOptions struct {
|
||||
all bool
|
||||
filters filters.Args
|
||||
}
|
||||
|
||||
type treeView struct {
|
||||
images []topImage
|
||||
|
||||
// imageSpacing indicates whether there should be extra spacing between images.
|
||||
imageSpacing bool
|
||||
}
|
||||
|
||||
func runTree(ctx context.Context, dockerCLI command.Cli, opts treeOptions) error {
|
||||
images, err := dockerCLI.Client().ImageList(ctx, imagetypes.ListOptions{
|
||||
All: opts.all,
|
||||
Filters: opts.filters,
|
||||
Manifests: true,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
view := treeView{
|
||||
images: make([]topImage, 0, len(images)),
|
||||
}
|
||||
for _, img := range images {
|
||||
details := imageDetails{
|
||||
ID: img.ID,
|
||||
DiskUsage: units.HumanSizeWithPrecision(float64(img.Size), 3),
|
||||
Used: img.Containers > 0,
|
||||
}
|
||||
|
||||
var totalContent int64
|
||||
children := make([]subImage, 0, len(img.Manifests))
|
||||
for _, im := range img.Manifests {
|
||||
if im.Kind != imagetypes.ManifestKindImage {
|
||||
continue
|
||||
}
|
||||
|
||||
im := im
|
||||
sub := subImage{
|
||||
Platform: platforms.Format(im.ImageData.Platform),
|
||||
Available: im.Available,
|
||||
Details: imageDetails{
|
||||
ID: im.ID,
|
||||
DiskUsage: units.HumanSizeWithPrecision(float64(im.Size.Total), 3),
|
||||
Used: len(im.ImageData.Containers) > 0,
|
||||
ContentSize: units.HumanSizeWithPrecision(float64(im.Size.Content), 3),
|
||||
},
|
||||
}
|
||||
|
||||
if sub.Details.Used {
|
||||
// Mark top-level parent image as used if any of its subimages are used.
|
||||
details.Used = true
|
||||
}
|
||||
|
||||
totalContent += im.Size.Content
|
||||
children = append(children, sub)
|
||||
|
||||
// Add extra spacing between images if there's at least one entry with children.
|
||||
view.imageSpacing = true
|
||||
}
|
||||
|
||||
details.ContentSize = units.HumanSizeWithPrecision(float64(totalContent), 3)
|
||||
|
||||
view.images = append(view.images, topImage{
|
||||
Names: img.RepoTags,
|
||||
Details: details,
|
||||
Children: children,
|
||||
created: img.Created,
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(view.images, func(i, j int) bool {
|
||||
return view.images[i].created > view.images[j].created
|
||||
})
|
||||
|
||||
return printImageTree(dockerCLI, view)
|
||||
}
|
||||
|
||||
type imageDetails struct {
|
||||
ID string
|
||||
DiskUsage string
|
||||
Used bool
|
||||
ContentSize string
|
||||
}
|
||||
|
||||
type topImage struct {
|
||||
Names []string
|
||||
Details imageDetails
|
||||
Children []subImage
|
||||
|
||||
created int64
|
||||
}
|
||||
|
||||
type subImage struct {
|
||||
Platform string
|
||||
Available bool
|
||||
Details imageDetails
|
||||
}
|
||||
|
||||
const columnSpacing = 3
|
||||
|
||||
func printImageTree(dockerCLI command.Cli, view treeView) error {
|
||||
out := dockerCLI.Out()
|
||||
_, width := out.GetTtySize()
|
||||
if width == 0 {
|
||||
width = 80
|
||||
}
|
||||
if width < 20 {
|
||||
width = 20
|
||||
}
|
||||
|
||||
warningColor := aec.LightYellowF
|
||||
headerColor := aec.NewBuilder(aec.DefaultF, aec.Bold).ANSI
|
||||
topNameColor := aec.NewBuilder(aec.BlueF, aec.Underline, aec.Bold).ANSI
|
||||
normalColor := aec.NewBuilder(aec.DefaultF).ANSI
|
||||
greenColor := aec.NewBuilder(aec.GreenF).ANSI
|
||||
untaggedColor := aec.NewBuilder(aec.Faint).ANSI
|
||||
if !out.IsTerminal() {
|
||||
headerColor = noColor{}
|
||||
topNameColor = noColor{}
|
||||
normalColor = noColor{}
|
||||
greenColor = noColor{}
|
||||
warningColor = noColor{}
|
||||
untaggedColor = noColor{}
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(out, warningColor.Apply("WARNING: This is an experimental feature. The output may change and shouldn't be depended on."))
|
||||
_, _ = fmt.Fprintln(out, "")
|
||||
|
||||
columns := []imgColumn{
|
||||
{
|
||||
Title: "Image",
|
||||
Align: alignLeft,
|
||||
Width: 0,
|
||||
},
|
||||
{
|
||||
Title: "ID",
|
||||
Align: alignLeft,
|
||||
Width: 12,
|
||||
DetailsValue: func(d *imageDetails) string {
|
||||
return stringid.TruncateID(d.ID)
|
||||
},
|
||||
},
|
||||
{
|
||||
Title: "Disk usage",
|
||||
Align: alignRight,
|
||||
Width: 10,
|
||||
DetailsValue: func(d *imageDetails) string {
|
||||
return d.DiskUsage
|
||||
},
|
||||
},
|
||||
{
|
||||
Title: "Content size",
|
||||
Align: alignRight,
|
||||
Width: 12,
|
||||
DetailsValue: func(d *imageDetails) string {
|
||||
return d.ContentSize
|
||||
},
|
||||
},
|
||||
{
|
||||
Title: "Used",
|
||||
Align: alignCenter,
|
||||
Width: 4,
|
||||
Color: &greenColor,
|
||||
DetailsValue: func(d *imageDetails) string {
|
||||
if d.Used {
|
||||
return "✔"
|
||||
}
|
||||
return " "
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
nameWidth := int(width)
|
||||
for idx, h := range columns {
|
||||
if h.Width == 0 {
|
||||
continue
|
||||
}
|
||||
d := h.Width
|
||||
if idx > 0 {
|
||||
d += columnSpacing
|
||||
}
|
||||
// If the first column gets too short, remove remaining columns
|
||||
if nameWidth-d < 12 {
|
||||
columns = columns[:idx]
|
||||
break
|
||||
}
|
||||
nameWidth -= d
|
||||
}
|
||||
|
||||
images := view.images
|
||||
// Try to make the first column as narrow as possible
|
||||
widest := widestFirstColumnValue(columns, images)
|
||||
if nameWidth > widest {
|
||||
nameWidth = widest
|
||||
}
|
||||
columns[0].Width = nameWidth
|
||||
|
||||
// Print columns
|
||||
for i, h := range columns {
|
||||
if i > 0 {
|
||||
_, _ = fmt.Fprint(out, strings.Repeat(" ", columnSpacing))
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprint(out, h.Print(headerColor, strings.ToUpper(h.Title)))
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(out)
|
||||
|
||||
// Print images
|
||||
for _, img := range images {
|
||||
printNames(out, columns, img, topNameColor, untaggedColor)
|
||||
printDetails(out, columns, normalColor, img.Details)
|
||||
|
||||
if len(img.Children) > 0 || view.imageSpacing {
|
||||
_, _ = fmt.Fprintln(out)
|
||||
}
|
||||
printChildren(out, columns, img, normalColor)
|
||||
_, _ = fmt.Fprintln(out)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func printDetails(out *streams.Out, headers []imgColumn, defaultColor aec.ANSI, details imageDetails) {
|
||||
for _, h := range headers {
|
||||
if h.DetailsValue == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprint(out, strings.Repeat(" ", columnSpacing))
|
||||
clr := defaultColor
|
||||
if h.Color != nil {
|
||||
clr = *h.Color
|
||||
}
|
||||
val := h.DetailsValue(&details)
|
||||
_, _ = fmt.Fprint(out, h.Print(clr, val))
|
||||
}
|
||||
}
|
||||
|
||||
func printChildren(out *streams.Out, headers []imgColumn, img topImage, normalColor aec.ANSI) {
|
||||
for idx, sub := range img.Children {
|
||||
clr := normalColor
|
||||
if !sub.Available {
|
||||
clr = normalColor.With(aec.Faint)
|
||||
}
|
||||
|
||||
if idx != len(img.Children)-1 {
|
||||
_, _ = fmt.Fprint(out, headers[0].Print(clr, "├─ "+sub.Platform))
|
||||
} else {
|
||||
_, _ = fmt.Fprint(out, headers[0].Print(clr, "└─ "+sub.Platform))
|
||||
}
|
||||
|
||||
printDetails(out, headers, clr, sub.Details)
|
||||
_, _ = fmt.Fprintln(out, "")
|
||||
}
|
||||
}
|
||||
|
||||
func printNames(out *streams.Out, headers []imgColumn, img topImage, color, untaggedColor aec.ANSI) {
|
||||
if len(img.Names) == 0 {
|
||||
_, _ = fmt.Fprint(out, headers[0].Print(untaggedColor, "<untagged>"))
|
||||
}
|
||||
|
||||
for nameIdx, name := range img.Names {
|
||||
if nameIdx != 0 {
|
||||
_, _ = fmt.Fprintln(out, "")
|
||||
}
|
||||
_, _ = fmt.Fprint(out, headers[0].Print(color, name))
|
||||
}
|
||||
}
|
||||
|
||||
type alignment int
|
||||
|
||||
const (
|
||||
alignLeft alignment = iota
|
||||
alignCenter
|
||||
alignRight
|
||||
)
|
||||
|
||||
type imgColumn struct {
|
||||
Title string
|
||||
Width int
|
||||
Align alignment
|
||||
|
||||
DetailsValue func(*imageDetails) string
|
||||
Color *aec.ANSI
|
||||
}
|
||||
|
||||
func truncateRunes(s string, length int) string {
|
||||
runes := []rune(s)
|
||||
if len(runes) > length {
|
||||
return string(runes[:length-3]) + "..."
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (h imgColumn) Print(clr aec.ANSI, s string) string {
|
||||
switch h.Align {
|
||||
case alignCenter:
|
||||
return h.PrintC(clr, s)
|
||||
case alignRight:
|
||||
return h.PrintR(clr, s)
|
||||
case alignLeft:
|
||||
}
|
||||
return h.PrintL(clr, s)
|
||||
}
|
||||
|
||||
func (h imgColumn) PrintC(clr aec.ANSI, s string) string {
|
||||
ln := utf8.RuneCountInString(s)
|
||||
|
||||
if ln > h.Width {
|
||||
return clr.Apply(truncateRunes(s, h.Width))
|
||||
}
|
||||
|
||||
fill := h.Width - ln
|
||||
|
||||
l := fill / 2
|
||||
r := fill - l
|
||||
|
||||
return strings.Repeat(" ", l) + clr.Apply(s) + strings.Repeat(" ", r)
|
||||
}
|
||||
|
||||
func (h imgColumn) PrintL(clr aec.ANSI, s string) string {
|
||||
ln := utf8.RuneCountInString(s)
|
||||
if ln > h.Width {
|
||||
return clr.Apply(truncateRunes(s, h.Width))
|
||||
}
|
||||
|
||||
return clr.Apply(s) + strings.Repeat(" ", h.Width-ln)
|
||||
}
|
||||
|
||||
func (h imgColumn) PrintR(clr aec.ANSI, s string) string {
|
||||
ln := utf8.RuneCountInString(s)
|
||||
if ln > h.Width {
|
||||
return clr.Apply(truncateRunes(s, h.Width))
|
||||
}
|
||||
|
||||
return strings.Repeat(" ", h.Width-ln) + clr.Apply(s)
|
||||
}
|
||||
|
||||
type noColor struct{}
|
||||
|
||||
func (a noColor) With(_ ...aec.ANSI) aec.ANSI {
|
||||
return a
|
||||
}
|
||||
|
||||
func (a noColor) Apply(s string) string {
|
||||
return s
|
||||
}
|
||||
|
||||
func (a noColor) String() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// widestFirstColumnValue calculates the width needed to fully display the image names and platforms.
|
||||
func widestFirstColumnValue(headers []imgColumn, images []topImage) int {
|
||||
width := len(headers[0].Title)
|
||||
for _, img := range images {
|
||||
for _, name := range img.Names {
|
||||
if len(name) > width {
|
||||
width = len(name)
|
||||
}
|
||||
}
|
||||
for _, sub := range img.Children {
|
||||
pl := len(sub.Platform) + len("└─ ")
|
||||
if pl > width {
|
||||
width = pl
|
||||
}
|
||||
}
|
||||
}
|
||||
return width
|
||||
}
|
|
@ -35,6 +35,7 @@ func TestManifestAnnotateError(t *testing.T) {
|
|||
cmd := newAnnotateCommand(cli)
|
||||
cmd.SetArgs(tc.args)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
|
||||
}
|
||||
}
|
||||
|
@ -52,6 +53,7 @@ func TestManifestAnnotate(t *testing.T) {
|
|||
cmd := newAnnotateCommand(cli)
|
||||
cmd.SetArgs([]string{"example.com/list:v1", "example.com/fake:0.0"})
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
expectedError := "manifest for image example.com/fake:0.0 does not exist"
|
||||
assert.ErrorContains(t, cmd.Execute(), expectedError)
|
||||
|
||||
|
@ -71,6 +73,7 @@ func TestManifestAnnotate(t *testing.T) {
|
|||
err = cmd.Flags().Set("verbose", "true")
|
||||
assert.NilError(t, err)
|
||||
cmd.SetArgs([]string{"example.com/list:v1", "example.com/alpine:3.0"})
|
||||
cmd.SetErr(io.Discard)
|
||||
assert.NilError(t, cmd.Execute())
|
||||
actual := cli.OutBuffer()
|
||||
expected := golden.Get(t, "inspect-annotate.golden")
|
||||
|
|
|
@ -18,7 +18,7 @@ func NewManifestCommand(dockerCli command.Cli) *cobra.Command {
|
|||
Long: manifestDescription,
|
||||
Args: cli.NoArgs,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
fmt.Fprintf(dockerCli.Err(), "\n"+cmd.UsageString())
|
||||
_, _ = fmt.Fprint(dockerCli.Err(), "\n"+cmd.UsageString())
|
||||
},
|
||||
Annotations: map[string]string{"experimentalCLI": ""},
|
||||
}
|
||||
|
|
|
@ -31,11 +31,15 @@ func TestManifestCreateErrors(t *testing.T) {
|
|||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
cli := test.NewFakeCli(nil)
|
||||
cmd := newCreateListCommand(cli)
|
||||
cmd.SetArgs(tc.args)
|
||||
cmd.SetOut(io.Discard)
|
||||
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
|
||||
tc := tc
|
||||
t.Run(tc.expectedError, func(t *testing.T) {
|
||||
cli := test.NewFakeCli(nil)
|
||||
cmd := newCreateListCommand(cli)
|
||||
cmd.SetArgs(tc.args)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -87,6 +91,7 @@ func TestManifestCreateRefuseAmend(t *testing.T) {
|
|||
cmd := newCreateListCommand(cli)
|
||||
cmd.SetArgs([]string{"example.com/list:v1", "example.com/alpine:3.0"})
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
err = cmd.Execute()
|
||||
assert.Error(t, err, "refusing to amend an existing manifest list with no --amend flag")
|
||||
}
|
||||
|
@ -109,6 +114,7 @@ func TestManifestCreateNoManifest(t *testing.T) {
|
|||
cmd := newCreateListCommand(cli)
|
||||
cmd.SetArgs([]string{"example.com/list:v1", "example.com/alpine:3.0"})
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
err := cmd.Execute()
|
||||
assert.Error(t, err, "No such image: example.com/alpine:3.0")
|
||||
}
|
||||
|
|
|
@ -70,6 +70,7 @@ func TestInspectCommandLocalManifestNotFound(t *testing.T) {
|
|||
|
||||
cmd := newInspectCommand(cli)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs([]string{"example.com/list:v1", "example.com/alpine:3.0"})
|
||||
err := cmd.Execute()
|
||||
assert.Error(t, err, "No such manifest: example.com/alpine:3.0")
|
||||
|
@ -91,6 +92,7 @@ func TestInspectCommandNotFound(t *testing.T) {
|
|||
|
||||
cmd := newInspectCommand(cli)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs([]string{"example.com/alpine:3.0"})
|
||||
err := cmd.Execute()
|
||||
assert.Error(t, err, "No such manifest: example.com/alpine:3.0")
|
||||
|
|
|
@ -44,6 +44,7 @@ func TestManifestPushErrors(t *testing.T) {
|
|||
cmd := newPushListCommand(cli)
|
||||
cmd.SetArgs(tc.args)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -56,6 +56,7 @@ func TestRmManifestNotCreated(t *testing.T) {
|
|||
cmd := newRmManifestListCommand(cli)
|
||||
cmd.SetArgs([]string{"example.com/first:1", "example.com/second:2"})
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
err = cmd.Execute()
|
||||
assert.Error(t, err, "No such manifest: example.com/first:1")
|
||||
|
||||
|
|
|
@ -38,6 +38,7 @@ func TestNetworkConnectErrors(t *testing.T) {
|
|||
)
|
||||
cmd.SetArgs(tc.args)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -137,6 +137,7 @@ func TestNetworkCreateErrors(t *testing.T) {
|
|||
assert.NilError(t, cmd.Flags().Set(key, value))
|
||||
}
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -36,6 +36,7 @@ func TestNetworkDisconnectErrors(t *testing.T) {
|
|||
)
|
||||
cmd.SetArgs(tc.args)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -36,6 +36,7 @@ func TestNetworkListErrors(t *testing.T) {
|
|||
}),
|
||||
)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
|
||||
}
|
||||
}
|
||||
|
@ -82,6 +83,7 @@ func TestNetworkList(t *testing.T) {
|
|||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
tc := tc
|
||||
t.Run(tc.doc, func(t *testing.T) {
|
||||
cli := test.NewFakeCli(&fakeClient{networkListFunc: tc.networkListFunc})
|
||||
cmd := newListCommand(cli)
|
||||
|
|
|
@ -2,6 +2,7 @@ package network
|
|||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/docker/cli/internal/test"
|
||||
|
@ -20,5 +21,8 @@ func TestNetworkPrunePromptTermination(t *testing.T) {
|
|||
},
|
||||
})
|
||||
cmd := NewPruneCommand(cli)
|
||||
cmd.SetArgs([]string{})
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
test.TerminatePrompt(ctx, t, cmd, cli)
|
||||
}
|
||||
|
|
|
@ -114,5 +114,7 @@ func TestNetworkRemovePromptTermination(t *testing.T) {
|
|||
})
|
||||
cmd := newRemoveCommand(cli)
|
||||
cmd.SetArgs([]string{"existing-network"})
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
test.TerminatePrompt(ctx, t, cmd, cli)
|
||||
}
|
||||
|
|
|
@ -44,6 +44,7 @@ func TestNodeDemoteErrors(t *testing.T) {
|
|||
}))
|
||||
cmd.SetArgs(tc.args)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -74,6 +74,7 @@ func TestNodeInspectErrors(t *testing.T) {
|
|||
assert.Check(t, cmd.Flags().Set(key, value))
|
||||
}
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
|
||||
}
|
||||
}
|
||||
|
@ -105,13 +106,16 @@ func TestNodeInspectPretty(t *testing.T) {
|
|||
},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
cli := test.NewFakeCli(&fakeClient{
|
||||
nodeInspectFunc: tc.nodeInspectFunc,
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cli := test.NewFakeCli(&fakeClient{
|
||||
nodeInspectFunc: tc.nodeInspectFunc,
|
||||
})
|
||||
cmd := newInspectCommand(cli)
|
||||
cmd.SetArgs([]string{"nodeID"})
|
||||
assert.Check(t, cmd.Flags().Set("pretty", "true"))
|
||||
assert.NilError(t, cmd.Execute())
|
||||
golden.Assert(t, cli.OutBuffer().String(), fmt.Sprintf("node-inspect-pretty.%s.golden", tc.name))
|
||||
})
|
||||
cmd := newInspectCommand(cli)
|
||||
cmd.SetArgs([]string{"nodeID"})
|
||||
assert.Check(t, cmd.Flags().Set("pretty", "true"))
|
||||
assert.NilError(t, cmd.Execute())
|
||||
golden.Assert(t, cli.OutBuffer().String(), fmt.Sprintf("node-inspect-pretty.%s.golden", tc.name))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -48,6 +48,7 @@ func TestNodeListErrorOnAPIFailure(t *testing.T) {
|
|||
})
|
||||
cmd := newListCommand(cli)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
assert.Error(t, cmd.Execute(), tc.expectedError)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -44,6 +44,7 @@ func TestNodePromoteErrors(t *testing.T) {
|
|||
}))
|
||||
cmd.SetArgs(tc.args)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -61,6 +61,7 @@ func TestNodePsErrors(t *testing.T) {
|
|||
assert.Check(t, cmd.Flags().Set(key, value))
|
||||
}
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
assert.Error(t, cmd.Execute(), tc.expectedError)
|
||||
}
|
||||
}
|
||||
|
@ -133,19 +134,22 @@ func TestNodePs(t *testing.T) {
|
|||
},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
cli := test.NewFakeCli(&fakeClient{
|
||||
infoFunc: tc.infoFunc,
|
||||
nodeInspectFunc: tc.nodeInspectFunc,
|
||||
taskInspectFunc: tc.taskInspectFunc,
|
||||
taskListFunc: tc.taskListFunc,
|
||||
serviceInspectFunc: tc.serviceInspectFunc,
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cli := test.NewFakeCli(&fakeClient{
|
||||
infoFunc: tc.infoFunc,
|
||||
nodeInspectFunc: tc.nodeInspectFunc,
|
||||
taskInspectFunc: tc.taskInspectFunc,
|
||||
taskListFunc: tc.taskListFunc,
|
||||
serviceInspectFunc: tc.serviceInspectFunc,
|
||||
})
|
||||
cmd := newPsCommand(cli)
|
||||
cmd.SetArgs(tc.args)
|
||||
for key, value := range tc.flags {
|
||||
assert.Check(t, cmd.Flags().Set(key, value))
|
||||
}
|
||||
assert.NilError(t, cmd.Execute())
|
||||
golden.Assert(t, cli.OutBuffer().String(), fmt.Sprintf("node-ps.%s.golden", tc.name))
|
||||
})
|
||||
cmd := newPsCommand(cli)
|
||||
cmd.SetArgs(tc.args)
|
||||
for key, value := range tc.flags {
|
||||
assert.Check(t, cmd.Flags().Set(key, value))
|
||||
}
|
||||
assert.NilError(t, cmd.Execute())
|
||||
golden.Assert(t, cli.OutBuffer().String(), fmt.Sprintf("node-ps.%s.golden", tc.name))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -33,6 +33,7 @@ func TestNodeRemoveErrors(t *testing.T) {
|
|||
}))
|
||||
cmd.SetArgs(tc.args)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -64,6 +64,7 @@ func TestNodeUpdateErrors(t *testing.T) {
|
|||
assert.Check(t, cmd.Flags().Set(key, value))
|
||||
}
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -41,6 +41,7 @@ func TestCreateErrors(t *testing.T) {
|
|||
cmd := newCreateCommand(cli)
|
||||
cmd.SetArgs(tc.args)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
|
||||
}
|
||||
}
|
||||
|
@ -53,6 +54,7 @@ func TestCreateErrorOnFileAsContextDir(t *testing.T) {
|
|||
cmd := newCreateCommand(cli)
|
||||
cmd.SetArgs([]string{"plugin-foo", tmpFile.Path()})
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
assert.ErrorContains(t, cmd.Execute(), "context must be a directory")
|
||||
}
|
||||
|
||||
|
@ -64,6 +66,7 @@ func TestCreateErrorOnContextDirWithoutConfig(t *testing.T) {
|
|||
cmd := newCreateCommand(cli)
|
||||
cmd.SetArgs([]string{"plugin-foo", tmpDir.Path()})
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
|
||||
expectedErr := "config.json: no such file or directory"
|
||||
if runtime.GOOS == "windows" {
|
||||
|
@ -82,6 +85,7 @@ func TestCreateErrorOnInvalidConfig(t *testing.T) {
|
|||
cmd := newCreateCommand(cli)
|
||||
cmd.SetArgs([]string{"plugin-foo", tmpDir.Path()})
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
assert.ErrorContains(t, cmd.Execute(), "invalid")
|
||||
}
|
||||
|
||||
|
@ -98,6 +102,7 @@ func TestCreateErrorFromDaemon(t *testing.T) {
|
|||
}))
|
||||
cmd.SetArgs([]string{"plugin-foo", tmpDir.Path()})
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
assert.ErrorContains(t, cmd.Execute(), "error creating plugin")
|
||||
}
|
||||
|
||||
|
|
|
@ -41,6 +41,7 @@ func TestPluginDisableErrors(t *testing.T) {
|
|||
}))
|
||||
cmd.SetArgs(tc.args)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -51,6 +51,7 @@ func TestPluginEnableErrors(t *testing.T) {
|
|||
cmd.Flags().Set(key, value)
|
||||
}
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -66,6 +66,7 @@ func TestInspectErrors(t *testing.T) {
|
|||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
tc := tc
|
||||
t.Run(tc.description, func(t *testing.T) {
|
||||
cli := test.NewFakeCli(&fakeClient{pluginInspectFunc: tc.inspectFunc})
|
||||
cmd := newInspectCommand(cli)
|
||||
|
@ -74,6 +75,7 @@ func TestInspectErrors(t *testing.T) {
|
|||
cmd.Flags().Set(key, value)
|
||||
}
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
|
||||
})
|
||||
}
|
||||
|
@ -136,6 +138,7 @@ func TestInspect(t *testing.T) {
|
|||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
tc := tc
|
||||
t.Run(tc.description, func(t *testing.T) {
|
||||
cli := test.NewFakeCli(&fakeClient{pluginInspectFunc: tc.inspectFunc})
|
||||
cmd := newInspectCommand(cli)
|
||||
|
|
|
@ -54,11 +54,15 @@ func TestInstallErrors(t *testing.T) {
|
|||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
cli := test.NewFakeCli(&fakeClient{pluginInstallFunc: tc.installFunc})
|
||||
cmd := newInstallCommand(cli)
|
||||
cmd.SetArgs(tc.args)
|
||||
cmd.SetOut(io.Discard)
|
||||
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
|
||||
tc := tc
|
||||
t.Run(tc.description, func(t *testing.T) {
|
||||
cli := test.NewFakeCli(&fakeClient{pluginInstallFunc: tc.installFunc})
|
||||
cmd := newInstallCommand(cli)
|
||||
cmd.SetArgs(tc.args)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -90,16 +94,20 @@ func TestInstallContentTrustErrors(t *testing.T) {
|
|||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
cli := test.NewFakeCli(&fakeClient{
|
||||
pluginInstallFunc: func(name string, options types.PluginInstallOptions) (io.ReadCloser, error) {
|
||||
return nil, errors.New("should not try to install plugin")
|
||||
},
|
||||
}, test.EnableContentTrust)
|
||||
cli.SetNotaryClient(tc.notaryFunc)
|
||||
cmd := newInstallCommand(cli)
|
||||
cmd.SetArgs(tc.args)
|
||||
cmd.SetOut(io.Discard)
|
||||
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
|
||||
tc := tc
|
||||
t.Run(tc.description, func(t *testing.T) {
|
||||
cli := test.NewFakeCli(&fakeClient{
|
||||
pluginInstallFunc: func(name string, options types.PluginInstallOptions) (io.ReadCloser, error) {
|
||||
return nil, errors.New("should not try to install plugin")
|
||||
},
|
||||
}, test.EnableContentTrust)
|
||||
cli.SetNotaryClient(tc.notaryFunc)
|
||||
cmd := newInstallCommand(cli)
|
||||
cmd.SetArgs(tc.args)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -130,10 +138,13 @@ func TestInstall(t *testing.T) {
|
|||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
cli := test.NewFakeCli(&fakeClient{pluginInstallFunc: tc.installFunc})
|
||||
cmd := newInstallCommand(cli)
|
||||
cmd.SetArgs(tc.args)
|
||||
assert.NilError(t, cmd.Execute())
|
||||
assert.Check(t, strings.Contains(cli.OutBuffer().String(), tc.expectedOutput))
|
||||
tc := tc
|
||||
t.Run(tc.description, func(t *testing.T) {
|
||||
cli := test.NewFakeCli(&fakeClient{pluginInstallFunc: tc.installFunc})
|
||||
cmd := newInstallCommand(cli)
|
||||
cmd.SetArgs(tc.args)
|
||||
assert.NilError(t, cmd.Execute())
|
||||
assert.Check(t, strings.Contains(cli.OutBuffer().String(), tc.expectedOutput))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -46,14 +46,18 @@ func TestListErrors(t *testing.T) {
|
|||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
cli := test.NewFakeCli(&fakeClient{pluginListFunc: tc.listFunc})
|
||||
cmd := newListCommand(cli)
|
||||
cmd.SetArgs(tc.args)
|
||||
for key, value := range tc.flags {
|
||||
cmd.Flags().Set(key, value)
|
||||
}
|
||||
cmd.SetOut(io.Discard)
|
||||
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
|
||||
tc := tc
|
||||
t.Run(tc.description, func(t *testing.T) {
|
||||
cli := test.NewFakeCli(&fakeClient{pluginListFunc: tc.listFunc})
|
||||
cmd := newListCommand(cli)
|
||||
cmd.SetArgs(tc.args)
|
||||
for key, value := range tc.flags {
|
||||
cmd.Flags().Set(key, value)
|
||||
}
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -162,13 +166,16 @@ func TestList(t *testing.T) {
|
|||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
cli := test.NewFakeCli(&fakeClient{pluginListFunc: tc.listFunc})
|
||||
cmd := newListCommand(cli)
|
||||
cmd.SetArgs(tc.args)
|
||||
for key, value := range tc.flags {
|
||||
cmd.Flags().Set(key, value)
|
||||
}
|
||||
assert.NilError(t, cmd.Execute())
|
||||
golden.Assert(t, cli.OutBuffer().String(), tc.golden)
|
||||
tc := tc
|
||||
t.Run(tc.description, func(t *testing.T) {
|
||||
cli := test.NewFakeCli(&fakeClient{pluginListFunc: tc.listFunc})
|
||||
cmd := newListCommand(cli)
|
||||
cmd.SetArgs(tc.args)
|
||||
for key, value := range tc.flags {
|
||||
cmd.Flags().Set(key, value)
|
||||
}
|
||||
assert.NilError(t, cmd.Execute())
|
||||
golden.Assert(t, cli.OutBuffer().String(), tc.golden)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -37,6 +37,7 @@ func TestRemoveErrors(t *testing.T) {
|
|||
cmd := newRemoveCommand(cli)
|
||||
cmd.SetArgs(tc.args)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -32,6 +32,8 @@ func TestUpgradePromptTermination(t *testing.T) {
|
|||
// need to set a remote address that does not match the plugin
|
||||
// reference sent by the `pluginInspectFunc`
|
||||
cmd.SetArgs([]string{"foo/bar", "localhost:5000/foo/bar:v1.0.0"})
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
test.TerminatePrompt(ctx, t, cmd, cli)
|
||||
golden.Assert(t, cli.OutBuffer().String(), "plugin-upgrade-terminate.golden")
|
||||
}
|
||||
|
|
|
@ -1,10 +1,8 @@
|
|||
package command
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
@ -18,7 +16,6 @@ import (
|
|||
"github.com/docker/docker/api/types"
|
||||
registrytypes "github.com/docker/docker/api/types/registry"
|
||||
"github.com/docker/docker/registry"
|
||||
"github.com/moby/term"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
|
@ -44,7 +41,7 @@ func RegistryAuthenticationPrivilegedFunc(cli Cli, index *registrytypes.IndexInf
|
|||
default:
|
||||
}
|
||||
|
||||
err = ConfigureAuth(cli, "", "", &authConfig, isDefaultRegistry)
|
||||
authConfig, err = PromptUserForCredentials(ctx, cli, "", "", authConfig.Username, indexServer)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
@ -89,8 +86,32 @@ func GetDefaultAuthConfig(cfg *configfile.ConfigFile, checkCredStore bool, serve
|
|||
return registrytypes.AuthConfig(authconfig), nil
|
||||
}
|
||||
|
||||
// ConfigureAuth handles prompting of user's username and password if needed
|
||||
func ConfigureAuth(cli Cli, flUser, flPassword string, authconfig *registrytypes.AuthConfig, isDefaultRegistry bool) error {
|
||||
// ConfigureAuth handles prompting of user's username and password if needed.
|
||||
// Deprecated: use PromptUserForCredentials instead.
|
||||
func ConfigureAuth(ctx context.Context, cli Cli, flUser, flPassword string, authConfig *registrytypes.AuthConfig, _ bool) error {
|
||||
defaultUsername := authConfig.Username
|
||||
serverAddress := authConfig.ServerAddress
|
||||
|
||||
newAuthConfig, err := PromptUserForCredentials(ctx, cli, flUser, flPassword, defaultUsername, serverAddress)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
authConfig.Username = newAuthConfig.Username
|
||||
authConfig.Password = newAuthConfig.Password
|
||||
return nil
|
||||
}
|
||||
|
||||
// PromptUserForCredentials handles the CLI prompt for the user to input
|
||||
// credentials.
|
||||
// If argUser is not empty, then the user is only prompted for their password.
|
||||
// If argPassword is not empty, then the user is only prompted for their username
|
||||
// If neither argUser nor argPassword are empty, then the user is not prompted and
|
||||
// an AuthConfig is returned with those values.
|
||||
// If defaultUsername is not empty, the username prompt includes that username
|
||||
// and the user can hit enter without inputting a username to use that default
|
||||
// username.
|
||||
func PromptUserForCredentials(ctx context.Context, cli Cli, argUser, argPassword, defaultUsername, serverAddress string) (authConfig registrytypes.AuthConfig, err error) {
|
||||
// On Windows, force the use of the regular OS stdin stream.
|
||||
//
|
||||
// See:
|
||||
|
@ -103,20 +124,10 @@ func ConfigureAuth(cli Cli, flUser, flPassword string, authconfig *registrytypes
|
|||
cli.SetIn(streams.NewIn(os.Stdin))
|
||||
}
|
||||
|
||||
// Some links documenting this:
|
||||
// - https://code.google.com/archive/p/mintty/issues/56
|
||||
// - https://github.com/docker/docker/issues/15272
|
||||
// - https://mintty.github.io/ (compatibility)
|
||||
// Linux will hit this if you attempt `cat | docker login`, and Windows
|
||||
// will hit this if you attempt docker login from mintty where stdin
|
||||
// is a pipe, not a character based console.
|
||||
if flPassword == "" && !cli.In().IsTerminal() {
|
||||
return errors.Errorf("Error: Cannot perform an interactive login from a non TTY device")
|
||||
}
|
||||
isDefaultRegistry := serverAddress == registry.IndexServer
|
||||
defaultUsername = strings.TrimSpace(defaultUsername)
|
||||
|
||||
authconfig.Username = strings.TrimSpace(authconfig.Username)
|
||||
|
||||
if flUser = strings.TrimSpace(flUser); flUser == "" {
|
||||
if argUser = strings.TrimSpace(argUser); argUser == "" {
|
||||
if isDefaultRegistry {
|
||||
// if this is a default registry (docker hub), then display the following message.
|
||||
fmt.Fprintln(cli.Out(), "Log in with your Docker ID or email address to push and pull images from Docker Hub. If you don't have a Docker ID, head over to https://hub.docker.com/ to create one.")
|
||||
|
@ -125,62 +136,45 @@ func ConfigureAuth(cli Cli, flUser, flPassword string, authconfig *registrytypes
|
|||
fmt.Fprintln(cli.Out())
|
||||
}
|
||||
}
|
||||
promptWithDefault(cli.Out(), "Username", authconfig.Username)
|
||||
var err error
|
||||
flUser, err = readInput(cli.In())
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
var prompt string
|
||||
if defaultUsername == "" {
|
||||
prompt = "Username: "
|
||||
} else {
|
||||
prompt = fmt.Sprintf("Username (%s): ", defaultUsername)
|
||||
}
|
||||
if flUser == "" {
|
||||
flUser = authconfig.Username
|
||||
argUser, err = PromptForInput(ctx, cli.In(), cli.Out(), prompt)
|
||||
if err != nil {
|
||||
return authConfig, err
|
||||
}
|
||||
if argUser == "" {
|
||||
argUser = defaultUsername
|
||||
}
|
||||
}
|
||||
if flUser == "" {
|
||||
return errors.Errorf("Error: Non-null Username Required")
|
||||
if argUser == "" {
|
||||
return authConfig, errors.Errorf("Error: Non-null Username Required")
|
||||
}
|
||||
if flPassword == "" {
|
||||
oldState, err := term.SaveState(cli.In().FD())
|
||||
if argPassword == "" {
|
||||
restoreInput, err := DisableInputEcho(cli.In())
|
||||
if err != nil {
|
||||
return err
|
||||
return authConfig, err
|
||||
}
|
||||
fmt.Fprintf(cli.Out(), "Password: ")
|
||||
_ = term.DisableEcho(cli.In().FD(), oldState)
|
||||
defer func() {
|
||||
_ = term.RestoreTerminal(cli.In().FD(), oldState)
|
||||
}()
|
||||
flPassword, err = readInput(cli.In())
|
||||
defer restoreInput()
|
||||
|
||||
argPassword, err = PromptForInput(ctx, cli.In(), cli.Out(), "Password: ")
|
||||
if err != nil {
|
||||
return err
|
||||
return authConfig, err
|
||||
}
|
||||
fmt.Fprint(cli.Out(), "\n")
|
||||
if flPassword == "" {
|
||||
return errors.Errorf("Error: Password Required")
|
||||
if argPassword == "" {
|
||||
return authConfig, errors.Errorf("Error: Password Required")
|
||||
}
|
||||
}
|
||||
|
||||
authconfig.Username = flUser
|
||||
authconfig.Password = flPassword
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// readInput reads, and returns user input from in. It tries to return a
|
||||
// single line, not including the end-of-line bytes, and trims leading
|
||||
// and trailing whitespace.
|
||||
func readInput(in io.Reader) (string, error) {
|
||||
line, _, err := bufio.NewReader(in).ReadLine()
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "error while reading input")
|
||||
}
|
||||
return strings.TrimSpace(string(line)), nil
|
||||
}
|
||||
|
||||
func promptWithDefault(out io.Writer, prompt string, configDefault string) {
|
||||
if configDefault == "" {
|
||||
fmt.Fprintf(out, "%s: ", prompt)
|
||||
} else {
|
||||
fmt.Fprintf(out, "%s (%s): ", prompt, configDefault)
|
||||
}
|
||||
authConfig.Username = argUser
|
||||
authConfig.Password = argPassword
|
||||
authConfig.ServerAddress = serverAddress
|
||||
return authConfig, nil
|
||||
}
|
||||
|
||||
// RetrieveAuthTokenFromImage retrieves an encoded auth token given a complete
|
||||
|
|
|
@ -4,12 +4,15 @@ import (
|
|||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/docker/cli/cli"
|
||||
"github.com/docker/cli/cli/command"
|
||||
"github.com/docker/cli/cli/command/completion"
|
||||
configtypes "github.com/docker/cli/cli/config/types"
|
||||
"github.com/docker/cli/cli/internal/oauth/manager"
|
||||
registrytypes "github.com/docker/docker/api/types/registry"
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/docker/docker/errdefs"
|
||||
|
@ -36,8 +39,8 @@ func NewLoginCommand(dockerCli command.Cli) *cobra.Command {
|
|||
|
||||
cmd := &cobra.Command{
|
||||
Use: "login [OPTIONS] [SERVER]",
|
||||
Short: "Log in to a registry",
|
||||
Long: "Log in to a registry.\nIf no server is specified, the default is defined by the daemon.",
|
||||
Short: "Authenticate to a registry",
|
||||
Long: "Authenticate to a registry.\nDefaults to Docker Hub if no server is specified.",
|
||||
Args: cli.RequiresMaxArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) > 0 {
|
||||
|
@ -100,80 +103,176 @@ func verifyloginOptions(dockerCli command.Cli, opts *loginOptions) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func runLogin(ctx context.Context, dockerCli command.Cli, opts loginOptions) error { //nolint:gocyclo
|
||||
clnt := dockerCli.Client()
|
||||
func runLogin(ctx context.Context, dockerCli command.Cli, opts loginOptions) error {
|
||||
if err := verifyloginOptions(dockerCli, &opts); err != nil {
|
||||
return err
|
||||
}
|
||||
var (
|
||||
serverAddress string
|
||||
response registrytypes.AuthenticateOKBody
|
||||
response *registrytypes.AuthenticateOKBody
|
||||
)
|
||||
if opts.serverAddress != "" && opts.serverAddress != registry.DefaultNamespace {
|
||||
serverAddress = opts.serverAddress
|
||||
} else {
|
||||
serverAddress = registry.IndexServer
|
||||
}
|
||||
|
||||
isDefaultRegistry := serverAddress == registry.IndexServer
|
||||
|
||||
// attempt login with current (stored) credentials
|
||||
authConfig, err := command.GetDefaultAuthConfig(dockerCli.ConfigFile(), opts.user == "" && opts.password == "", serverAddress, isDefaultRegistry)
|
||||
if err == nil && authConfig.Username != "" && authConfig.Password != "" {
|
||||
response, err = loginWithCredStoreCreds(ctx, dockerCli, &authConfig)
|
||||
response, err = loginWithStoredCredentials(ctx, dockerCli, authConfig)
|
||||
}
|
||||
if err != nil || authConfig.Username == "" || authConfig.Password == "" {
|
||||
err = command.ConfigureAuth(dockerCli, opts.user, opts.password, &authConfig, isDefaultRegistry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
response, err = clnt.RegistryLogin(ctx, authConfig)
|
||||
if err != nil && client.IsErrConnectionFailed(err) {
|
||||
// If the server isn't responding (yet) attempt to login purely client side
|
||||
response, err = loginClientSide(ctx, authConfig)
|
||||
}
|
||||
// If we (still) have an error, give up
|
||||
// if we failed to authenticate with stored credentials (or didn't have stored credentials),
|
||||
// prompt the user for new credentials
|
||||
if err != nil || authConfig.Username == "" || authConfig.Password == "" {
|
||||
response, err = loginUser(ctx, dockerCli, opts, authConfig.Username, authConfig.ServerAddress)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if response != nil && response.Status != "" {
|
||||
_, _ = fmt.Fprintln(dockerCli.Out(), response.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loginWithStoredCredentials(ctx context.Context, dockerCli command.Cli, authConfig registrytypes.AuthConfig) (*registrytypes.AuthenticateOKBody, error) {
|
||||
_, _ = fmt.Fprintf(dockerCli.Out(), "Authenticating with existing credentials...\n")
|
||||
response, err := dockerCli.Client().RegistryLogin(ctx, authConfig)
|
||||
if err != nil {
|
||||
if errdefs.IsUnauthorized(err) {
|
||||
_, _ = fmt.Fprintf(dockerCli.Err(), "Stored credentials invalid or expired\n")
|
||||
} else {
|
||||
_, _ = fmt.Fprintf(dockerCli.Err(), "Login did not succeed, error: %s\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
if response.IdentityToken != "" {
|
||||
authConfig.Password = ""
|
||||
authConfig.IdentityToken = response.IdentityToken
|
||||
}
|
||||
|
||||
creds := dockerCli.ConfigFile().GetCredentialsStore(serverAddress)
|
||||
if err := storeCredentials(dockerCli, authConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &response, err
|
||||
}
|
||||
|
||||
const OauthLoginEscapeHatchEnvVar = "DOCKER_CLI_DISABLE_OAUTH_LOGIN"
|
||||
|
||||
func isOauthLoginDisabled() bool {
|
||||
if v := os.Getenv(OauthLoginEscapeHatchEnvVar); v != "" {
|
||||
enabled, err := strconv.ParseBool(v)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return enabled
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func loginUser(ctx context.Context, dockerCli command.Cli, opts loginOptions, defaultUsername, serverAddress string) (*registrytypes.AuthenticateOKBody, error) {
|
||||
// Some links documenting this:
|
||||
// - https://code.google.com/archive/p/mintty/issues/56
|
||||
// - https://github.com/docker/docker/issues/15272
|
||||
// - https://mintty.github.io/ (compatibility)
|
||||
// Linux will hit this if you attempt `cat | docker login`, and Windows
|
||||
// will hit this if you attempt docker login from mintty where stdin
|
||||
// is a pipe, not a character based console.
|
||||
if (opts.user == "" || opts.password == "") && !dockerCli.In().IsTerminal() {
|
||||
return nil, errors.Errorf("Error: Cannot perform an interactive login from a non TTY device")
|
||||
}
|
||||
|
||||
// If we're logging into the index server and the user didn't provide a username or password, use the device flow
|
||||
if serverAddress == registry.IndexServer && opts.user == "" && opts.password == "" && !isOauthLoginDisabled() {
|
||||
response, err := loginWithDeviceCodeFlow(ctx, dockerCli)
|
||||
// if the error represents a failure to initiate the device-code flow,
|
||||
// then we fallback to regular cli credentials login
|
||||
if !errors.Is(err, manager.ErrDeviceLoginStartFail) {
|
||||
return response, err
|
||||
}
|
||||
fmt.Fprint(dockerCli.Err(), "Failed to start web-based login - falling back to command line login...\n\n")
|
||||
}
|
||||
|
||||
return loginWithUsernameAndPassword(ctx, dockerCli, opts, defaultUsername, serverAddress)
|
||||
}
|
||||
|
||||
func loginWithUsernameAndPassword(ctx context.Context, dockerCli command.Cli, opts loginOptions, defaultUsername, serverAddress string) (*registrytypes.AuthenticateOKBody, error) {
|
||||
// Prompt user for credentials
|
||||
authConfig, err := command.PromptUserForCredentials(ctx, dockerCli, opts.user, opts.password, defaultUsername, serverAddress)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response, err := loginWithRegistry(ctx, dockerCli, authConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if response.IdentityToken != "" {
|
||||
authConfig.Password = ""
|
||||
authConfig.IdentityToken = response.IdentityToken
|
||||
}
|
||||
if err = storeCredentials(dockerCli, authConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
func loginWithDeviceCodeFlow(ctx context.Context, dockerCli command.Cli) (*registrytypes.AuthenticateOKBody, error) {
|
||||
store := dockerCli.ConfigFile().GetCredentialsStore(registry.IndexServer)
|
||||
authConfig, err := manager.NewManager(store).LoginDevice(ctx, dockerCli.Err())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response, err := loginWithRegistry(ctx, dockerCli, registrytypes.AuthConfig(*authConfig))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err = storeCredentials(dockerCli, registrytypes.AuthConfig(*authConfig)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
func storeCredentials(dockerCli command.Cli, authConfig registrytypes.AuthConfig) error {
|
||||
creds := dockerCli.ConfigFile().GetCredentialsStore(authConfig.ServerAddress)
|
||||
|
||||
store, isDefault := creds.(isFileStore)
|
||||
// Display a warning if we're storing the users password (not a token)
|
||||
if isDefault && authConfig.Password != "" {
|
||||
err = displayUnencryptedWarning(dockerCli, store.GetFilename())
|
||||
err := displayUnencryptedWarning(dockerCli, store.GetFilename())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := creds.Store(configtypes.AuthConfig(authConfig)); err != nil {
|
||||
return errors.Errorf("Error saving credentials: %v", err)
|
||||
}
|
||||
|
||||
if response.Status != "" {
|
||||
fmt.Fprintln(dockerCli.Out(), response.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loginWithCredStoreCreds(ctx context.Context, dockerCli command.Cli, authConfig *registrytypes.AuthConfig) (registrytypes.AuthenticateOKBody, error) {
|
||||
fmt.Fprintf(dockerCli.Out(), "Authenticating with existing credentials...\n")
|
||||
cliClient := dockerCli.Client()
|
||||
response, err := cliClient.RegistryLogin(ctx, *authConfig)
|
||||
if err != nil {
|
||||
if errdefs.IsUnauthorized(err) {
|
||||
fmt.Fprintf(dockerCli.Err(), "Stored credentials invalid or expired\n")
|
||||
} else {
|
||||
fmt.Fprintf(dockerCli.Err(), "Login did not succeed, error: %s\n", err)
|
||||
}
|
||||
func loginWithRegistry(ctx context.Context, dockerCli command.Cli, authConfig registrytypes.AuthConfig) (registrytypes.AuthenticateOKBody, error) {
|
||||
response, err := dockerCli.Client().RegistryLogin(ctx, authConfig)
|
||||
if err != nil && client.IsErrConnectionFailed(err) {
|
||||
// If the server isn't responding (yet) attempt to login purely client side
|
||||
response, err = loginClientSide(ctx, authConfig)
|
||||
}
|
||||
return response, err
|
||||
// If we (still) have an error, give up
|
||||
if err != nil {
|
||||
return registrytypes.AuthenticateOKBody{}, err
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func loginClientSide(ctx context.Context, auth registrytypes.AuthConfig) (registrytypes.AuthenticateOKBody, error) {
|
||||
|
|
|
@ -6,13 +6,17 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/creack/pty"
|
||||
"github.com/docker/cli/cli/command"
|
||||
configtypes "github.com/docker/cli/cli/config/types"
|
||||
"github.com/docker/cli/cli/streams"
|
||||
"github.com/docker/cli/internal/test"
|
||||
registrytypes "github.com/docker/docker/api/types/registry"
|
||||
"github.com/docker/docker/api/types/system"
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/docker/docker/registry"
|
||||
"gotest.tools/v3/assert"
|
||||
is "gotest.tools/v3/assert/cmp"
|
||||
"gotest.tools/v3/fs"
|
||||
|
@ -71,7 +75,7 @@ func TestLoginWithCredStoreCreds(t *testing.T) {
|
|||
cli := test.NewFakeCli(&fakeClient{})
|
||||
errBuf := new(bytes.Buffer)
|
||||
cli.SetErr(streams.NewOut(errBuf))
|
||||
loginWithCredStoreCreds(ctx, cli, &tc.inputAuthConfig)
|
||||
loginWithStoredCredentials(ctx, cli, tc.inputAuthConfig)
|
||||
outputString := cli.OutBuffer().String()
|
||||
assert.Check(t, is.Equal(tc.expectedMsg, outputString))
|
||||
errorString := errBuf.String()
|
||||
|
@ -80,88 +84,207 @@ func TestLoginWithCredStoreCreds(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestRunLogin(t *testing.T) {
|
||||
const (
|
||||
storedServerAddress = "reg1"
|
||||
validUsername = "u1"
|
||||
validPassword = "p1"
|
||||
validPassword2 = "p2"
|
||||
)
|
||||
|
||||
validAuthConfig := configtypes.AuthConfig{
|
||||
ServerAddress: storedServerAddress,
|
||||
Username: validUsername,
|
||||
Password: validPassword,
|
||||
}
|
||||
expiredAuthConfig := configtypes.AuthConfig{
|
||||
ServerAddress: storedServerAddress,
|
||||
Username: validUsername,
|
||||
Password: expiredPassword,
|
||||
}
|
||||
validIdentityToken := configtypes.AuthConfig{
|
||||
ServerAddress: storedServerAddress,
|
||||
Username: validUsername,
|
||||
IdentityToken: useToken,
|
||||
}
|
||||
testCases := []struct {
|
||||
doc string
|
||||
inputLoginOption loginOptions
|
||||
inputStoredCred *configtypes.AuthConfig
|
||||
expectedErr string
|
||||
expectedSavedCred configtypes.AuthConfig
|
||||
doc string
|
||||
priorCredentials map[string]configtypes.AuthConfig
|
||||
input loginOptions
|
||||
expectedCredentials map[string]configtypes.AuthConfig
|
||||
expectedErr string
|
||||
}{
|
||||
{
|
||||
doc: "valid auth from store",
|
||||
inputLoginOption: loginOptions{
|
||||
serverAddress: storedServerAddress,
|
||||
priorCredentials: map[string]configtypes.AuthConfig{
|
||||
"reg1": {
|
||||
Username: "my-username",
|
||||
Password: "a-password",
|
||||
ServerAddress: "reg1",
|
||||
},
|
||||
},
|
||||
inputStoredCred: &validAuthConfig,
|
||||
expectedSavedCred: validAuthConfig,
|
||||
},
|
||||
{
|
||||
doc: "expired auth",
|
||||
inputLoginOption: loginOptions{
|
||||
serverAddress: storedServerAddress,
|
||||
input: loginOptions{
|
||||
serverAddress: "reg1",
|
||||
},
|
||||
inputStoredCred: &expiredAuthConfig,
|
||||
expectedErr: "Error: Cannot perform an interactive login from a non TTY device",
|
||||
},
|
||||
{
|
||||
doc: "valid username and password",
|
||||
inputLoginOption: loginOptions{
|
||||
serverAddress: storedServerAddress,
|
||||
user: validUsername,
|
||||
password: validPassword2,
|
||||
},
|
||||
inputStoredCred: &validAuthConfig,
|
||||
expectedSavedCred: configtypes.AuthConfig{
|
||||
ServerAddress: storedServerAddress,
|
||||
Username: validUsername,
|
||||
Password: validPassword2,
|
||||
expectedCredentials: map[string]configtypes.AuthConfig{
|
||||
"reg1": {
|
||||
Username: "my-username",
|
||||
Password: "a-password",
|
||||
ServerAddress: "reg1",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
doc: "unknown user",
|
||||
inputLoginOption: loginOptions{
|
||||
serverAddress: storedServerAddress,
|
||||
doc: "expired auth from store",
|
||||
priorCredentials: map[string]configtypes.AuthConfig{
|
||||
"reg1": {
|
||||
Username: "my-username",
|
||||
Password: expiredPassword,
|
||||
ServerAddress: "reg1",
|
||||
},
|
||||
},
|
||||
input: loginOptions{
|
||||
serverAddress: "reg1",
|
||||
},
|
||||
expectedErr: "Error: Cannot perform an interactive login from a non TTY device",
|
||||
},
|
||||
{
|
||||
doc: "store valid username and password",
|
||||
priorCredentials: map[string]configtypes.AuthConfig{},
|
||||
input: loginOptions{
|
||||
serverAddress: "reg1",
|
||||
user: "my-username",
|
||||
password: "p2",
|
||||
},
|
||||
expectedCredentials: map[string]configtypes.AuthConfig{
|
||||
"reg1": {
|
||||
Username: "my-username",
|
||||
Password: "p2",
|
||||
ServerAddress: "reg1",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
doc: "unknown user w/ prior credentials",
|
||||
priorCredentials: map[string]configtypes.AuthConfig{
|
||||
"reg1": {
|
||||
Username: "my-username",
|
||||
Password: "a-password",
|
||||
ServerAddress: "reg1",
|
||||
},
|
||||
},
|
||||
input: loginOptions{
|
||||
serverAddress: "reg1",
|
||||
user: unknownUser,
|
||||
password: validPassword,
|
||||
password: "a-password",
|
||||
},
|
||||
expectedErr: errUnknownUser,
|
||||
expectedCredentials: map[string]configtypes.AuthConfig{
|
||||
"reg1": {
|
||||
Username: "a-password",
|
||||
Password: "a-password",
|
||||
ServerAddress: "reg1",
|
||||
},
|
||||
},
|
||||
inputStoredCred: &validAuthConfig,
|
||||
expectedErr: errUnknownUser,
|
||||
},
|
||||
{
|
||||
doc: "valid token",
|
||||
inputLoginOption: loginOptions{
|
||||
serverAddress: storedServerAddress,
|
||||
user: validUsername,
|
||||
doc: "unknown user w/o prior credentials",
|
||||
priorCredentials: map[string]configtypes.AuthConfig{},
|
||||
input: loginOptions{
|
||||
serverAddress: "reg1",
|
||||
user: unknownUser,
|
||||
password: "a-password",
|
||||
},
|
||||
expectedErr: errUnknownUser,
|
||||
expectedCredentials: map[string]configtypes.AuthConfig{},
|
||||
},
|
||||
{
|
||||
doc: "store valid token",
|
||||
priorCredentials: map[string]configtypes.AuthConfig{},
|
||||
input: loginOptions{
|
||||
serverAddress: "reg1",
|
||||
user: "my-username",
|
||||
password: useToken,
|
||||
},
|
||||
inputStoredCred: &validIdentityToken,
|
||||
expectedSavedCred: validIdentityToken,
|
||||
expectedCredentials: map[string]configtypes.AuthConfig{
|
||||
"reg1": {
|
||||
Username: "my-username",
|
||||
IdentityToken: useToken,
|
||||
ServerAddress: "reg1",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
doc: "valid token from store",
|
||||
priorCredentials: map[string]configtypes.AuthConfig{
|
||||
"reg1": {
|
||||
Username: "my-username",
|
||||
Password: useToken,
|
||||
ServerAddress: "reg1",
|
||||
},
|
||||
},
|
||||
input: loginOptions{
|
||||
serverAddress: "reg1",
|
||||
},
|
||||
expectedCredentials: map[string]configtypes.AuthConfig{
|
||||
"reg1": {
|
||||
Username: "my-username",
|
||||
IdentityToken: useToken,
|
||||
ServerAddress: "reg1",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
doc: "no registry specified defaults to index server",
|
||||
priorCredentials: map[string]configtypes.AuthConfig{},
|
||||
input: loginOptions{
|
||||
user: "my-username",
|
||||
password: "my-password",
|
||||
},
|
||||
expectedCredentials: map[string]configtypes.AuthConfig{
|
||||
registry.IndexServer: {
|
||||
Username: "my-username",
|
||||
Password: "my-password",
|
||||
ServerAddress: registry.IndexServer,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
doc: "registry-1.docker.io",
|
||||
priorCredentials: map[string]configtypes.AuthConfig{},
|
||||
input: loginOptions{
|
||||
serverAddress: "registry-1.docker.io",
|
||||
user: "my-username",
|
||||
password: "my-password",
|
||||
},
|
||||
expectedCredentials: map[string]configtypes.AuthConfig{
|
||||
"registry-1.docker.io": {
|
||||
Username: "my-username",
|
||||
Password: "my-password",
|
||||
ServerAddress: "registry-1.docker.io",
|
||||
},
|
||||
},
|
||||
},
|
||||
// Regression test for https://github.com/docker/cli/issues/5382
|
||||
{
|
||||
doc: "sanitizes server address to remove repo",
|
||||
priorCredentials: map[string]configtypes.AuthConfig{},
|
||||
input: loginOptions{
|
||||
serverAddress: "registry-1.docker.io/bork/test",
|
||||
user: "my-username",
|
||||
password: "a-password",
|
||||
},
|
||||
expectedCredentials: map[string]configtypes.AuthConfig{
|
||||
"registry-1.docker.io": {
|
||||
Username: "my-username",
|
||||
Password: "a-password",
|
||||
ServerAddress: "registry-1.docker.io",
|
||||
},
|
||||
},
|
||||
},
|
||||
// Regression test for https://github.com/docker/cli/issues/5382
|
||||
{
|
||||
doc: "updates credential if server address includes repo",
|
||||
priorCredentials: map[string]configtypes.AuthConfig{
|
||||
"registry-1.docker.io": {
|
||||
Username: "my-username",
|
||||
Password: "a-password",
|
||||
ServerAddress: "registry-1.docker.io",
|
||||
},
|
||||
},
|
||||
input: loginOptions{
|
||||
serverAddress: "registry-1.docker.io/bork/test",
|
||||
user: "my-username",
|
||||
password: "new-password",
|
||||
},
|
||||
expectedCredentials: map[string]configtypes.AuthConfig{
|
||||
"registry-1.docker.io": {
|
||||
Username: "my-username",
|
||||
Password: "new-password",
|
||||
ServerAddress: "registry-1.docker.io",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
tc := tc
|
||||
t.Run(tc.doc, func(t *testing.T) {
|
||||
tmpFile := fs.NewFile(t, "test-run-login")
|
||||
defer tmpFile.Remove()
|
||||
|
@ -169,19 +292,246 @@ func TestRunLogin(t *testing.T) {
|
|||
configfile := cli.ConfigFile()
|
||||
configfile.Filename = tmpFile.Path()
|
||||
|
||||
if tc.inputStoredCred != nil {
|
||||
cred := *tc.inputStoredCred
|
||||
assert.NilError(t, configfile.GetCredentialsStore(cred.ServerAddress).Store(cred))
|
||||
for _, priorCred := range tc.priorCredentials {
|
||||
assert.NilError(t, configfile.GetCredentialsStore(priorCred.ServerAddress).Store(priorCred))
|
||||
}
|
||||
loginErr := runLogin(context.Background(), cli, tc.inputLoginOption)
|
||||
storedCreds, err := configfile.GetAllCredentials()
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, storedCreds, tc.priorCredentials)
|
||||
|
||||
loginErr := runLogin(context.Background(), cli, tc.input)
|
||||
if tc.expectedErr != "" {
|
||||
assert.Error(t, loginErr, tc.expectedErr)
|
||||
return
|
||||
}
|
||||
assert.NilError(t, loginErr)
|
||||
savedCred, credStoreErr := configfile.GetCredentialsStore(tc.inputStoredCred.ServerAddress).Get(tc.inputStoredCred.ServerAddress)
|
||||
assert.Check(t, credStoreErr)
|
||||
assert.DeepEqual(t, tc.expectedSavedCred, savedCred)
|
||||
|
||||
outputCreds, err := configfile.GetAllCredentials()
|
||||
assert.Check(t, err)
|
||||
assert.DeepEqual(t, outputCreds, tc.expectedCredentials)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginNonInteractive(t *testing.T) {
|
||||
t.Run("no prior credentials", func(t *testing.T) {
|
||||
testCases := []struct {
|
||||
doc string
|
||||
username bool
|
||||
password bool
|
||||
expectedErr string
|
||||
}{
|
||||
{
|
||||
doc: "success - w/ user w/ password",
|
||||
username: true,
|
||||
password: true,
|
||||
},
|
||||
{
|
||||
doc: "error - w/o user w/o pass ",
|
||||
username: false,
|
||||
password: false,
|
||||
expectedErr: "Error: Cannot perform an interactive login from a non TTY device",
|
||||
},
|
||||
{
|
||||
doc: "error - w/ user w/o pass",
|
||||
username: true,
|
||||
password: false,
|
||||
expectedErr: "Error: Cannot perform an interactive login from a non TTY device",
|
||||
},
|
||||
{
|
||||
doc: "error - w/o user w/ pass",
|
||||
username: false,
|
||||
password: true,
|
||||
expectedErr: "Error: Cannot perform an interactive login from a non TTY device",
|
||||
},
|
||||
}
|
||||
|
||||
// "" meaning default registry
|
||||
registries := []string{"", "my-registry.com"}
|
||||
|
||||
for _, registry := range registries {
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.doc, func(t *testing.T) {
|
||||
tmpFile := fs.NewFile(t, "test-run-login")
|
||||
defer tmpFile.Remove()
|
||||
cli := test.NewFakeCli(&fakeClient{})
|
||||
configfile := cli.ConfigFile()
|
||||
configfile.Filename = tmpFile.Path()
|
||||
options := loginOptions{
|
||||
serverAddress: registry,
|
||||
}
|
||||
if tc.username {
|
||||
options.user = "my-username"
|
||||
}
|
||||
if tc.password {
|
||||
options.password = "my-password"
|
||||
}
|
||||
|
||||
loginErr := runLogin(context.Background(), cli, options)
|
||||
if tc.expectedErr != "" {
|
||||
assert.Error(t, loginErr, tc.expectedErr)
|
||||
return
|
||||
}
|
||||
assert.NilError(t, loginErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("w/ prior credentials", func(t *testing.T) {
|
||||
testCases := []struct {
|
||||
doc string
|
||||
username bool
|
||||
password bool
|
||||
expectedErr string
|
||||
}{
|
||||
{
|
||||
doc: "success - w/ user w/ password",
|
||||
username: true,
|
||||
password: true,
|
||||
},
|
||||
{
|
||||
doc: "success - w/o user w/o pass ",
|
||||
username: false,
|
||||
password: false,
|
||||
},
|
||||
{
|
||||
doc: "error - w/ user w/o pass",
|
||||
username: true,
|
||||
password: false,
|
||||
expectedErr: "Error: Cannot perform an interactive login from a non TTY device",
|
||||
},
|
||||
{
|
||||
doc: "error - w/o user w/ pass",
|
||||
username: false,
|
||||
password: true,
|
||||
expectedErr: "Error: Cannot perform an interactive login from a non TTY device",
|
||||
},
|
||||
}
|
||||
|
||||
// "" meaning default registry
|
||||
registries := []string{"", "my-registry.com"}
|
||||
|
||||
for _, registry := range registries {
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.doc, func(t *testing.T) {
|
||||
tmpFile := fs.NewFile(t, "test-run-login")
|
||||
defer tmpFile.Remove()
|
||||
cli := test.NewFakeCli(&fakeClient{})
|
||||
configfile := cli.ConfigFile()
|
||||
configfile.Filename = tmpFile.Path()
|
||||
serverAddress := registry
|
||||
if serverAddress == "" {
|
||||
serverAddress = "https://index.docker.io/v1/"
|
||||
}
|
||||
assert.NilError(t, configfile.GetCredentialsStore(serverAddress).Store(configtypes.AuthConfig{
|
||||
Username: "my-username",
|
||||
Password: "my-password",
|
||||
ServerAddress: serverAddress,
|
||||
}))
|
||||
|
||||
options := loginOptions{
|
||||
serverAddress: registry,
|
||||
}
|
||||
if tc.username {
|
||||
options.user = "my-username"
|
||||
}
|
||||
if tc.password {
|
||||
options.password = "my-password"
|
||||
}
|
||||
|
||||
loginErr := runLogin(context.Background(), cli, options)
|
||||
if tc.expectedErr != "" {
|
||||
assert.Error(t, loginErr, tc.expectedErr)
|
||||
return
|
||||
}
|
||||
assert.NilError(t, loginErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestLoginTermination(t *testing.T) {
|
||||
p, tty, err := pty.Open()
|
||||
assert.NilError(t, err)
|
||||
|
||||
t.Cleanup(func() {
|
||||
_ = tty.Close()
|
||||
_ = p.Close()
|
||||
})
|
||||
|
||||
cli := test.NewFakeCli(&fakeClient{}, func(fc *test.FakeCli) {
|
||||
fc.SetOut(streams.NewOut(tty))
|
||||
fc.SetIn(streams.NewIn(tty))
|
||||
})
|
||||
tmpFile := fs.NewFile(t, "test-login-termination")
|
||||
defer tmpFile.Remove()
|
||||
|
||||
configFile := cli.ConfigFile()
|
||||
configFile.Filename = tmpFile.Path()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
|
||||
runErr := make(chan error)
|
||||
go func() {
|
||||
runErr <- runLogin(ctx, cli, loginOptions{
|
||||
user: "test-user",
|
||||
})
|
||||
}()
|
||||
|
||||
// Let the prompt get canceled by the context
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case <-time.After(1 * time.Second):
|
||||
t.Fatal("timed out after 1 second. `runLogin` did not return")
|
||||
case err := <-runErr:
|
||||
assert.ErrorIs(t, err, command.ErrPromptTerminated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsOauthLoginDisabled(t *testing.T) {
|
||||
testCases := []struct {
|
||||
envVar string
|
||||
disabled bool
|
||||
}{
|
||||
{
|
||||
envVar: "",
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
envVar: "bork",
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
envVar: "0",
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
envVar: "false",
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
envVar: "true",
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
envVar: "TRUE",
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
envVar: "1",
|
||||
disabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Setenv(OauthLoginEscapeHatchEnvVar, tc.envVar)
|
||||
|
||||
disabled := isOauthLoginDisabled()
|
||||
|
||||
assert.Equal(t, disabled, tc.disabled)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ import (
|
|||
"github.com/docker/cli/cli"
|
||||
"github.com/docker/cli/cli/command"
|
||||
"github.com/docker/cli/cli/config/credentials"
|
||||
"github.com/docker/cli/cli/internal/oauth/manager"
|
||||
"github.com/docker/docker/registry"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
@ -34,7 +35,7 @@ func NewLogoutCommand(dockerCli command.Cli) *cobra.Command {
|
|||
return cmd
|
||||
}
|
||||
|
||||
func runLogout(_ context.Context, dockerCli command.Cli, serverAddress string) error {
|
||||
func runLogout(ctx context.Context, dockerCli command.Cli, serverAddress string) error {
|
||||
var isDefaultRegistry bool
|
||||
|
||||
if serverAddress == "" {
|
||||
|
@ -53,6 +54,13 @@ func runLogout(_ context.Context, dockerCli command.Cli, serverAddress string) e
|
|||
regsToLogout = append(regsToLogout, hostnameAddress, "http://"+hostnameAddress, "https://"+hostnameAddress)
|
||||
}
|
||||
|
||||
if isDefaultRegistry {
|
||||
store := dockerCli.ConfigFile().GetCredentialsStore(registry.IndexServer)
|
||||
if err := manager.NewManager(store).Logout(ctx); err != nil {
|
||||
fmt.Fprintf(dockerCli.Err(), "WARNING: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(dockerCli.Out(), "Removing login credentials for %s\n", hostnameAddress)
|
||||
errs := make(map[string]error)
|
||||
for _, r := range regsToLogout {
|
||||
|
|
|
@ -49,6 +49,7 @@ func TestSecretCreateErrors(t *testing.T) {
|
|||
)
|
||||
cmd.SetArgs(tc.args)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -61,6 +61,7 @@ func TestSecretInspectErrors(t *testing.T) {
|
|||
assert.Check(t, cmd.Flags().Set(key, value))
|
||||
}
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
|
||||
}
|
||||
}
|
||||
|
@ -92,13 +93,16 @@ func TestSecretInspectWithoutFormat(t *testing.T) {
|
|||
},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
cli := test.NewFakeCli(&fakeClient{
|
||||
secretInspectFunc: tc.secretInspectFunc,
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cli := test.NewFakeCli(&fakeClient{
|
||||
secretInspectFunc: tc.secretInspectFunc,
|
||||
})
|
||||
cmd := newSecretInspectCommand(cli)
|
||||
cmd.SetArgs(tc.args)
|
||||
assert.NilError(t, cmd.Execute())
|
||||
golden.Assert(t, cli.OutBuffer().String(), fmt.Sprintf("secret-inspect-without-format.%s.golden", tc.name))
|
||||
})
|
||||
cmd := newSecretInspectCommand(cli)
|
||||
cmd.SetArgs(tc.args)
|
||||
assert.NilError(t, cmd.Execute())
|
||||
golden.Assert(t, cli.OutBuffer().String(), fmt.Sprintf("secret-inspect-without-format.%s.golden", tc.name))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -128,14 +132,17 @@ func TestSecretInspectWithFormat(t *testing.T) {
|
|||
},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
cli := test.NewFakeCli(&fakeClient{
|
||||
secretInspectFunc: tc.secretInspectFunc,
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cli := test.NewFakeCli(&fakeClient{
|
||||
secretInspectFunc: tc.secretInspectFunc,
|
||||
})
|
||||
cmd := newSecretInspectCommand(cli)
|
||||
cmd.SetArgs(tc.args)
|
||||
assert.Check(t, cmd.Flags().Set("format", tc.format))
|
||||
assert.NilError(t, cmd.Execute())
|
||||
golden.Assert(t, cli.OutBuffer().String(), fmt.Sprintf("secret-inspect-with-format.%s.golden", tc.name))
|
||||
})
|
||||
cmd := newSecretInspectCommand(cli)
|
||||
cmd.SetArgs(tc.args)
|
||||
assert.Check(t, cmd.Flags().Set("format", tc.format))
|
||||
assert.NilError(t, cmd.Execute())
|
||||
golden.Assert(t, cli.OutBuffer().String(), fmt.Sprintf("secret-inspect-with-format.%s.golden", tc.name))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -42,6 +42,7 @@ func TestSecretListErrors(t *testing.T) {
|
|||
)
|
||||
cmd.SetArgs(tc.args)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -38,6 +38,7 @@ func TestSecretRemoveErrors(t *testing.T) {
|
|||
)
|
||||
cmd.SetArgs(tc.args)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
|
||||
}
|
||||
}
|
||||
|
@ -74,6 +75,7 @@ func TestSecretRemoveContinueAfterError(t *testing.T) {
|
|||
|
||||
cmd := newSecretRemoveCommand(cli)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs(names)
|
||||
assert.Error(t, cmd.Execute(), "error removing secret: foo")
|
||||
assert.Check(t, is.DeepEqual(names, removedSecrets))
|
||||
|
|
|
@ -68,6 +68,8 @@ func newCreateCommand(dockerCli command.Cli) *cobra.Command {
|
|||
flags.SetAnnotation(flagSysCtl, "version", []string{"1.40"})
|
||||
flags.Var(&opts.ulimits, flagUlimit, "Ulimit options")
|
||||
flags.SetAnnotation(flagUlimit, "version", []string{"1.41"})
|
||||
flags.Int64Var(&opts.oomScoreAdj, flagOomScoreAdj, 0, "Tune host's OOM preferences (-1000 to 1000) ")
|
||||
flags.SetAnnotation(flagOomScoreAdj, "version", []string{"1.46"})
|
||||
|
||||
flags.Var(cliopts.NewListOptsRef(&opts.resources.resGenericResources, ValidateSingleGenericResource), "generic-resource", "User defined resources")
|
||||
flags.SetAnnotation(flagHostAdd, "version", []string{"1.32"})
|
||||
|
|
|
@ -529,6 +529,7 @@ type serviceOptions struct {
|
|||
capAdd opts.ListOpts
|
||||
capDrop opts.ListOpts
|
||||
ulimits opts.UlimitOpt
|
||||
oomScoreAdj int64
|
||||
|
||||
resources resourceOptions
|
||||
stopGrace opts.DurationOpt
|
||||
|
@ -747,6 +748,7 @@ func (options *serviceOptions) ToService(ctx context.Context, apiClient client.N
|
|||
CapabilityAdd: capAdd,
|
||||
CapabilityDrop: capDrop,
|
||||
Ulimits: options.ulimits.GetList(),
|
||||
OomScoreAdj: options.oomScoreAdj,
|
||||
},
|
||||
Networks: networks,
|
||||
Resources: resources,
|
||||
|
@ -1043,6 +1045,7 @@ const (
|
|||
flagUlimit = "ulimit"
|
||||
flagUlimitAdd = "ulimit-add"
|
||||
flagUlimitRemove = "ulimit-rm"
|
||||
flagOomScoreAdj = "oom-score-adj"
|
||||
)
|
||||
|
||||
func validateAPIVersion(c swarm.ServiceSpec, serverAPIVersion string) error {
|
||||
|
|
|
@ -39,10 +39,10 @@ func runRemove(ctx context.Context, dockerCli command.Cli, sids []string) error
|
|||
errs = append(errs, err.Error())
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(dockerCli.Out(), "%s\n", sid)
|
||||
_, _ = fmt.Fprintf(dockerCli.Out(), "%s\n", sid)
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return errors.Errorf(strings.Join(errs, "\n"))
|
||||
return errors.New(strings.Join(errs, "\n"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -91,14 +91,18 @@ func TestRollbackWithErrors(t *testing.T) {
|
|||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
cmd := newRollbackCommand(
|
||||
test.NewFakeCli(&fakeClient{
|
||||
serviceInspectWithRawFunc: tc.serviceInspectWithRawFunc,
|
||||
serviceUpdateFunc: tc.serviceUpdateFunc,
|
||||
}))
|
||||
cmd.SetArgs(tc.args)
|
||||
cmd.Flags().Set("quiet", "true")
|
||||
cmd.SetOut(io.Discard)
|
||||
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmd := newRollbackCommand(
|
||||
test.NewFakeCli(&fakeClient{
|
||||
serviceInspectWithRawFunc: tc.serviceInspectWithRawFunc,
|
||||
serviceUpdateFunc: tc.serviceUpdateFunc,
|
||||
}))
|
||||
cmd.SetArgs(tc.args)
|
||||
cmd.Flags().Set("quiet", "true")
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -90,7 +90,7 @@ func runScale(ctx context.Context, dockerCli command.Cli, options *scaleOptions,
|
|||
if len(errs) == 0 {
|
||||
return nil
|
||||
}
|
||||
return errors.Errorf(strings.Join(errs, "\n"))
|
||||
return errors.New(strings.Join(errs, "\n"))
|
||||
}
|
||||
|
||||
func runServiceScale(ctx context.Context, dockerCli command.Cli, serviceID string, scale uint64) error {
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue