Compare commits
70 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
9
.github/workflows/build.yml
vendored
9
.github/workflows/build.yml
vendored
@ -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
|
||||
|
||||
9
.github/workflows/codeql.yml
vendored
9
.github/workflows/codeql.yml
vendored
@ -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:
|
||||
|
||||
9
.github/workflows/e2e.yml
vendored
9
.github/workflows/e2e.yml
vendored
@ -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
|
||||
|
||||
11
.github/workflows/test.yml
vendored
11
.github/workflows/test.yml
vendored
@ -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
|
||||
@ -65,7 +74,7 @@ jobs:
|
||||
name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 1.21.12
|
||||
go-version: 1.21.13
|
||||
-
|
||||
name: Test
|
||||
run: |
|
||||
|
||||
9
.github/workflows/validate-pr.yml
vendored
9
.github/workflows/validate-pr.yml
vendored
@ -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]
|
||||
|
||||
9
.github/workflows/validate.yml
vendored
9
.github/workflows/validate.yml
vendored
@ -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,7 +4,7 @@ ARG BASE_VARIANT=alpine
|
||||
ARG ALPINE_VERSION=3.20
|
||||
ARG BASE_DEBIAN_DISTRO=bookworm
|
||||
|
||||
ARG GO_VERSION=1.21.12
|
||||
ARG GO_VERSION=1.21.13
|
||||
ARG XX_VERSION=1.4.0
|
||||
ARG GOVERSIONINFO_VERSION=v1.3.0
|
||||
ARG GOTESTSUM_VERSION=v1.10.0
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -81,11 +82,7 @@ func TestNewAttachCommandErrors(t *testing.T) {
|
||||
}
|
||||
|
||||
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
|
||||
@ -113,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 {
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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{
|
||||
{
|
||||
|
||||
@ -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,
|
||||
|
||||
393
cli/command/image/tree.go
Normal file
393
cli/command/image/tree.go
Normal file
@ -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
|
||||
}
|
||||
@ -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": ""},
|
||||
}
|
||||
|
||||
@ -41,7 +41,7 @@ func RegistryAuthenticationPrivilegedFunc(cli Cli, index *registrytypes.IndexInf
|
||||
default:
|
||||
}
|
||||
|
||||
err = ConfigureAuth(ctx, cli, "", "", &authConfig, isDefaultRegistry)
|
||||
authConfig, err = PromptUserForCredentials(ctx, cli, "", "", authConfig.Username, indexServer)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@ -86,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(ctx context.Context, 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:
|
||||
@ -107,13 +131,14 @@ func ConfigureAuth(ctx context.Context, cli Cli, flUser, flPassword string, auth
|
||||
// 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")
|
||||
if argPassword == "" && !cli.In().IsTerminal() {
|
||||
return authConfig, errors.Errorf("Error: Cannot perform an interactive login from a non TTY device")
|
||||
}
|
||||
|
||||
authconfig.Username = strings.TrimSpace(authconfig.Username)
|
||||
isDefaultRegistry := serverAddress == registry.IndexServer
|
||||
defaultUsername = strings.TrimSpace(defaultUsername)
|
||||
|
||||
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.")
|
||||
@ -124,44 +149,43 @@ func ConfigureAuth(ctx context.Context, cli Cli, flUser, flPassword string, auth
|
||||
}
|
||||
|
||||
var prompt string
|
||||
if authconfig.Username == "" {
|
||||
if defaultUsername == "" {
|
||||
prompt = "Username: "
|
||||
} else {
|
||||
prompt = fmt.Sprintf("Username (%s): ", authconfig.Username)
|
||||
prompt = fmt.Sprintf("Username (%s): ", defaultUsername)
|
||||
}
|
||||
var err error
|
||||
flUser, err = PromptForInput(ctx, cli.In(), cli.Out(), prompt)
|
||||
argUser, err = PromptForInput(ctx, cli.In(), cli.Out(), prompt)
|
||||
if err != nil {
|
||||
return err
|
||||
return authConfig, err
|
||||
}
|
||||
if flUser == "" {
|
||||
flUser = authconfig.Username
|
||||
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 == "" {
|
||||
if argPassword == "" {
|
||||
restoreInput, err := DisableInputEcho(cli.In())
|
||||
if err != nil {
|
||||
return err
|
||||
return authConfig, err
|
||||
}
|
||||
defer restoreInput()
|
||||
|
||||
flPassword, err = PromptForInput(ctx, cli.In(), cli.Out(), "Password: ")
|
||||
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
|
||||
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"
|
||||
@ -100,80 +103,167 @@ 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 {
|
||||
if opts.serverAddress != "" &&
|
||||
opts.serverAddress != registry.DefaultNamespace &&
|
||||
opts.serverAddress != registry.DefaultRegistryHost {
|
||||
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(ctx, 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, 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) {
|
||||
// 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) {
|
||||
|
||||
@ -74,7 +74,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()
|
||||
@ -213,7 +213,9 @@ func TestLoginTermination(t *testing.T) {
|
||||
|
||||
runErr := make(chan error)
|
||||
go func() {
|
||||
runErr <- runLogin(ctx, cli, loginOptions{})
|
||||
runErr <- runLogin(ctx, cli, loginOptions{
|
||||
user: "test-user",
|
||||
})
|
||||
}()
|
||||
|
||||
// Let the prompt get canceled by the context
|
||||
@ -226,3 +228,47 @@ func TestLoginTermination(t *testing.T) {
|
||||
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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -48,7 +48,7 @@ func RunRemove(ctx context.Context, dockerCli command.Cli, opts options.Remove)
|
||||
}
|
||||
|
||||
if len(services)+len(networks)+len(secrets)+len(configs) == 0 {
|
||||
fmt.Fprintf(dockerCli.Err(), "Nothing found in stack: %s\n", namespace)
|
||||
_, _ = fmt.Fprintf(dockerCli.Err(), "Nothing found in stack: %s\n", namespace)
|
||||
continue
|
||||
}
|
||||
|
||||
@ -71,7 +71,7 @@ func RunRemove(ctx context.Context, dockerCli command.Cli, opts options.Remove)
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
return errors.Errorf(strings.Join(errs, "\n"))
|
||||
return errors.New(strings.Join(errs, "\n"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -372,7 +372,7 @@ func prettyPrintServerInfo(streams command.Streams, info *dockerInfo) []error {
|
||||
fprintln(output, " Product License:", info.ProductLicense)
|
||||
}
|
||||
|
||||
if info.DefaultAddressPools != nil && len(info.DefaultAddressPools) > 0 {
|
||||
if len(info.DefaultAddressPools) > 0 {
|
||||
fprintln(output, " Default Address Pools:")
|
||||
for _, pool := range info.DefaultAddressPools {
|
||||
fprintf(output, " Base: %s, Size: %d\n", pool.Base, pool.Size)
|
||||
|
||||
@ -222,7 +222,7 @@ func ValidateOutputPath(path string) error {
|
||||
}
|
||||
|
||||
if err := ValidateOutputPathFileMode(fileInfo.Mode()); err != nil {
|
||||
return errors.Wrapf(err, fmt.Sprintf("invalid output path: %q must be a directory or a regular file", path))
|
||||
return errors.Wrapf(err, "invalid output path: %q must be a directory or a regular file", path)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@ -48,7 +48,7 @@ func TestCloseRunningCommand(t *testing.T) {
|
||||
defer close(done)
|
||||
|
||||
go func() {
|
||||
c, err := New(ctx, "sh", "-c", "while true; sleep 1; done")
|
||||
c, err := New(ctx, "sh", "-c", "while true; do sleep 1; done")
|
||||
assert.NilError(t, err)
|
||||
cmdConn := c.(*commandConn)
|
||||
assert.Check(t, process.Alive(cmdConn.cmd.Process.Pid))
|
||||
@ -145,7 +145,7 @@ func (mockStdoutEOF) Close() error {
|
||||
|
||||
func TestCloseWhileWriting(t *testing.T) {
|
||||
ctx := context.TODO()
|
||||
c, err := New(ctx, "sh", "-c", "while true; sleep 1; done")
|
||||
c, err := New(ctx, "sh", "-c", "while true; do sleep 1; done")
|
||||
assert.NilError(t, err)
|
||||
cmdConn := c.(*commandConn)
|
||||
assert.Check(t, process.Alive(cmdConn.cmd.Process.Pid))
|
||||
@ -173,7 +173,7 @@ func TestCloseWhileWriting(t *testing.T) {
|
||||
|
||||
func TestCloseWhileReading(t *testing.T) {
|
||||
ctx := context.TODO()
|
||||
c, err := New(ctx, "sh", "-c", "while true; sleep 1; done")
|
||||
c, err := New(ctx, "sh", "-c", "while true; do sleep 1; done")
|
||||
assert.NilError(t, err)
|
||||
cmdConn := c.(*commandConn)
|
||||
assert.Check(t, process.Alive(cmdConn.cmd.Process.Pid))
|
||||
|
||||
@ -52,6 +52,7 @@ func getConnectionHelper(daemonURL string, sshFlags []string) (*ConnectionHelper
|
||||
args = append(args, "--host", "unix://"+sp.Path)
|
||||
}
|
||||
sshFlags = addSSHTimeout(sshFlags)
|
||||
sshFlags = disablePseudoTerminalAllocation(sshFlags)
|
||||
args = append(args, "system", "dial-stdio")
|
||||
return commandconn.New(ctx, "ssh", append(sshFlags, sp.Args(args...)...)...)
|
||||
},
|
||||
@ -79,3 +80,14 @@ func addSSHTimeout(sshFlags []string) []string {
|
||||
}
|
||||
return sshFlags
|
||||
}
|
||||
|
||||
// disablePseudoTerminalAllocation disables pseudo-terminal allocation to
|
||||
// prevent SSH from executing as a login shell
|
||||
func disablePseudoTerminalAllocation(sshFlags []string) []string {
|
||||
for _, flag := range sshFlags {
|
||||
if flag == "-T" {
|
||||
return sshFlags
|
||||
}
|
||||
}
|
||||
return append(sshFlags, "-T")
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package connhelper
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"gotest.tools/v3/assert"
|
||||
@ -29,3 +30,36 @@ func TestSSHFlags(t *testing.T) {
|
||||
assert.DeepEqual(t, addSSHTimeout(tc.in), tc.out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisablePseudoTerminalAllocation(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
sshFlags []string
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "No -T flag present",
|
||||
sshFlags: []string{"-v", "-oStrictHostKeyChecking=no"},
|
||||
expected: []string{"-v", "-oStrictHostKeyChecking=no", "-T"},
|
||||
},
|
||||
{
|
||||
name: "Already contains -T flag",
|
||||
sshFlags: []string{"-v", "-T", "-oStrictHostKeyChecking=no"},
|
||||
expected: []string{"-v", "-T", "-oStrictHostKeyChecking=no"},
|
||||
},
|
||||
{
|
||||
name: "Empty sshFlags",
|
||||
sshFlags: []string{},
|
||||
expected: []string{"-T"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := disablePseudoTerminalAllocation(tc.sshFlags)
|
||||
if !reflect.DeepEqual(result, tc.expected) {
|
||||
t.Errorf("expected %v, got %v", tc.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
228
cli/internal/oauth/api/api.go
Normal file
228
cli/internal/oauth/api/api.go
Normal file
@ -0,0 +1,228 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.21
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/docker/cli/cli/version"
|
||||
)
|
||||
|
||||
type OAuthAPI interface {
|
||||
GetDeviceCode(ctx context.Context, audience string) (State, error)
|
||||
WaitForDeviceToken(ctx context.Context, state State) (TokenResponse, error)
|
||||
RevokeToken(ctx context.Context, refreshToken string) error
|
||||
GetAutoPAT(ctx context.Context, audience string, res TokenResponse) (string, error)
|
||||
}
|
||||
|
||||
// API represents API interactions with Auth0.
|
||||
type API struct {
|
||||
// TenantURL is the base used for each request to Auth0.
|
||||
TenantURL string
|
||||
// ClientID is the client ID for the application to auth with the tenant.
|
||||
ClientID string
|
||||
// Scopes are the scopes that are requested during the device auth flow.
|
||||
Scopes []string
|
||||
}
|
||||
|
||||
// TokenResponse represents the response of the /oauth/token route.
|
||||
type TokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
IDToken string `json:"id_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
Scope string `json:"scope"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
TokenType string `json:"token_type"`
|
||||
Error *string `json:"error,omitempty"`
|
||||
ErrorDescription string `json:"error_description,omitempty"`
|
||||
}
|
||||
|
||||
var ErrTimeout = errors.New("timed out waiting for device token")
|
||||
|
||||
// GetDeviceCode initiates the device-code auth flow with the tenant.
|
||||
// The state returned contains the device code that the user must use to
|
||||
// authenticate, as well as the URL to visit, etc.
|
||||
func (a API) GetDeviceCode(ctx context.Context, audience string) (State, error) {
|
||||
data := url.Values{
|
||||
"client_id": {a.ClientID},
|
||||
"audience": {audience},
|
||||
"scope": {strings.Join(a.Scopes, " ")},
|
||||
}
|
||||
|
||||
deviceCodeURL := a.TenantURL + "/oauth/device/code"
|
||||
resp, err := postForm(ctx, deviceCodeURL, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return State{}, err
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return State{}, tryDecodeOAuthError(resp)
|
||||
}
|
||||
|
||||
var state State
|
||||
err = json.NewDecoder(resp.Body).Decode(&state)
|
||||
if err != nil {
|
||||
return state, fmt.Errorf("failed to get device code: %w", err)
|
||||
}
|
||||
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func tryDecodeOAuthError(resp *http.Response) error {
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err == nil {
|
||||
if errorDescription, ok := body["error_description"].(string); ok {
|
||||
return errors.New(errorDescription)
|
||||
}
|
||||
}
|
||||
return errors.New("unexpected response from tenant: " + resp.Status)
|
||||
}
|
||||
|
||||
// WaitForDeviceToken polls the tenant to get access/refresh tokens for the user.
|
||||
// This should be called after GetDeviceCode, and will block until the user has
|
||||
// authenticated or we have reached the time limit for authenticating (based on
|
||||
// the response from GetDeviceCode).
|
||||
func (a API) WaitForDeviceToken(ctx context.Context, state State) (TokenResponse, error) {
|
||||
ticker := time.NewTicker(state.IntervalDuration())
|
||||
defer ticker.Stop()
|
||||
timeout := time.After(state.ExpiryDuration())
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return TokenResponse{}, ctx.Err()
|
||||
case <-ticker.C:
|
||||
res, err := a.getDeviceToken(ctx, state)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
|
||||
if res.Error != nil {
|
||||
if *res.Error == "authorization_pending" {
|
||||
continue
|
||||
}
|
||||
|
||||
return res, errors.New(res.ErrorDescription)
|
||||
}
|
||||
|
||||
return res, nil
|
||||
case <-timeout:
|
||||
return TokenResponse{}, ErrTimeout
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// getToken calls the token endpoint of Auth0 and returns the response.
|
||||
func (a API) getDeviceToken(ctx context.Context, state State) (TokenResponse, error) {
|
||||
data := url.Values{
|
||||
"client_id": {a.ClientID},
|
||||
"grant_type": {"urn:ietf:params:oauth:grant-type:device_code"},
|
||||
"device_code": {state.DeviceCode},
|
||||
}
|
||||
oauthTokenURL := a.TenantURL + "/oauth/token"
|
||||
|
||||
resp, err := postForm(ctx, oauthTokenURL, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return TokenResponse{}, fmt.Errorf("failed to get tokens: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
// this endpoint returns a 403 with an `authorization_pending` error until the
|
||||
// user has authenticated, so we don't check the status code here and instead
|
||||
// decode the response and check for the error.
|
||||
var res TokenResponse
|
||||
err = json.NewDecoder(resp.Body).Decode(&res)
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// RevokeToken revokes a refresh token with the tenant so that it can no longer
|
||||
// be used to get new tokens.
|
||||
func (a API) RevokeToken(ctx context.Context, refreshToken string) error {
|
||||
data := url.Values{
|
||||
"client_id": {a.ClientID},
|
||||
"token": {refreshToken},
|
||||
}
|
||||
|
||||
revokeURL := a.TenantURL + "/oauth/revoke"
|
||||
resp, err := postForm(ctx, revokeURL, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return tryDecodeOAuthError(resp)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func postForm(ctx context.Context, reqURL string, data io.Reader) (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
cliVersion := strings.ReplaceAll(version.Version, ".", "_")
|
||||
req.Header.Set("User-Agent", fmt.Sprintf("docker-cli:%s:%s-%s", cliVersion, runtime.GOOS, runtime.GOARCH))
|
||||
|
||||
return http.DefaultClient.Do(req)
|
||||
}
|
||||
|
||||
func (a API) GetAutoPAT(ctx context.Context, audience string, res TokenResponse) (string, error) {
|
||||
patURL := audience + "/v2/access-tokens/desktop-generate"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, patURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+res.AccessToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
return "", fmt.Errorf("unexpected response from Hub: %s", resp.Status)
|
||||
}
|
||||
|
||||
var response patGenerateResponse
|
||||
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return response.Data.Token, nil
|
||||
}
|
||||
|
||||
type patGenerateResponse struct {
|
||||
Data struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
}
|
||||
428
cli/internal/oauth/api/api_test.go
Normal file
428
cli/internal/oauth/api/api_test.go
Normal file
@ -0,0 +1,428 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gotest.tools/v3/assert"
|
||||
)
|
||||
|
||||
func TestGetDeviceCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("success", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var clientID, audience, scope, path string
|
||||
expectedState := State{
|
||||
DeviceCode: "aDeviceCode",
|
||||
UserCode: "aUserCode",
|
||||
VerificationURI: "aVerificationURI",
|
||||
ExpiresIn: 60,
|
||||
}
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
r.ParseForm()
|
||||
clientID = r.FormValue("client_id")
|
||||
audience = r.FormValue("audience")
|
||||
scope = r.FormValue("scope")
|
||||
path = r.URL.Path
|
||||
|
||||
jsonState, err := json.Marshal(expectedState)
|
||||
assert.NilError(t, err)
|
||||
|
||||
_, _ = w.Write(jsonState)
|
||||
}))
|
||||
defer ts.Close()
|
||||
api := API{
|
||||
TenantURL: ts.URL,
|
||||
ClientID: "aClientID",
|
||||
Scopes: []string{"bork", "meow"},
|
||||
}
|
||||
|
||||
state, err := api.GetDeviceCode(context.Background(), "anAudience")
|
||||
assert.NilError(t, err)
|
||||
|
||||
assert.DeepEqual(t, expectedState, state)
|
||||
assert.Equal(t, clientID, "aClientID")
|
||||
assert.Equal(t, audience, "anAudience")
|
||||
assert.Equal(t, scope, "bork meow")
|
||||
assert.Equal(t, path, "/oauth/device/code")
|
||||
})
|
||||
|
||||
t.Run("error w/ description", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
jsonState, err := json.Marshal(TokenResponse{
|
||||
ErrorDescription: "invalid audience",
|
||||
})
|
||||
assert.NilError(t, err)
|
||||
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write(jsonState)
|
||||
}))
|
||||
defer ts.Close()
|
||||
api := API{
|
||||
TenantURL: ts.URL,
|
||||
ClientID: "aClientID",
|
||||
Scopes: []string{"bork", "meow"},
|
||||
}
|
||||
|
||||
_, err := api.GetDeviceCode(context.Background(), "bad_audience")
|
||||
|
||||
assert.ErrorContains(t, err, "invalid audience")
|
||||
})
|
||||
|
||||
t.Run("general error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "an error", http.StatusInternalServerError)
|
||||
}))
|
||||
defer ts.Close()
|
||||
api := API{
|
||||
TenantURL: ts.URL,
|
||||
ClientID: "aClientID",
|
||||
Scopes: []string{"bork", "meow"},
|
||||
}
|
||||
|
||||
_, err := api.GetDeviceCode(context.Background(), "anAudience")
|
||||
|
||||
assert.ErrorContains(t, err, "unexpected response from tenant: 500 Internal Server Error")
|
||||
})
|
||||
|
||||
t.Run("canceled context", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
time.Sleep(2 * time.Second)
|
||||
http.Error(w, "an error", http.StatusInternalServerError)
|
||||
}))
|
||||
defer ts.Close()
|
||||
api := API{
|
||||
TenantURL: ts.URL,
|
||||
ClientID: "aClientID",
|
||||
Scopes: []string{"bork", "meow"},
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go func() {
|
||||
time.Sleep(1 * time.Second)
|
||||
cancel()
|
||||
}()
|
||||
_, err := api.GetDeviceCode(ctx, "anAudience")
|
||||
|
||||
assert.ErrorContains(t, err, "context canceled")
|
||||
})
|
||||
}
|
||||
|
||||
func TestWaitForDeviceToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("success", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
expectedToken := TokenResponse{
|
||||
AccessToken: "a-real-token",
|
||||
IDToken: "",
|
||||
RefreshToken: "the-refresh-token",
|
||||
Scope: "",
|
||||
ExpiresIn: 3600,
|
||||
TokenType: "",
|
||||
}
|
||||
var respond atomic.Bool
|
||||
go func() {
|
||||
time.Sleep(5 * time.Second)
|
||||
respond.Store(true)
|
||||
}()
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
assert.Equal(t, "/oauth/token", r.URL.Path)
|
||||
assert.Equal(t, r.FormValue("client_id"), "aClientID")
|
||||
assert.Equal(t, r.FormValue("grant_type"), "urn:ietf:params:oauth:grant-type:device_code")
|
||||
assert.Equal(t, r.FormValue("device_code"), "aDeviceCode")
|
||||
|
||||
if respond.Load() {
|
||||
jsonState, err := json.Marshal(expectedToken)
|
||||
assert.NilError(t, err)
|
||||
w.Write(jsonState)
|
||||
} else {
|
||||
pendingError := "authorization_pending"
|
||||
jsonResponse, err := json.Marshal(TokenResponse{
|
||||
Error: &pendingError,
|
||||
})
|
||||
assert.NilError(t, err)
|
||||
w.Write(jsonResponse)
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
api := API{
|
||||
TenantURL: ts.URL,
|
||||
ClientID: "aClientID",
|
||||
Scopes: []string{"bork", "meow"},
|
||||
}
|
||||
state := State{
|
||||
DeviceCode: "aDeviceCode",
|
||||
UserCode: "aUserCode",
|
||||
Interval: 1,
|
||||
ExpiresIn: 30,
|
||||
}
|
||||
token, err := api.WaitForDeviceToken(context.Background(), state)
|
||||
assert.NilError(t, err)
|
||||
|
||||
assert.DeepEqual(t, token, expectedToken)
|
||||
})
|
||||
|
||||
t.Run("timeout", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
assert.Equal(t, "/oauth/token", r.URL.Path)
|
||||
assert.Equal(t, r.FormValue("client_id"), "aClientID")
|
||||
assert.Equal(t, r.FormValue("grant_type"), "urn:ietf:params:oauth:grant-type:device_code")
|
||||
assert.Equal(t, r.FormValue("device_code"), "aDeviceCode")
|
||||
|
||||
pendingError := "authorization_pending"
|
||||
jsonResponse, err := json.Marshal(TokenResponse{
|
||||
Error: &pendingError,
|
||||
})
|
||||
assert.NilError(t, err)
|
||||
w.Write(jsonResponse)
|
||||
}))
|
||||
defer ts.Close()
|
||||
api := API{
|
||||
TenantURL: ts.URL,
|
||||
ClientID: "aClientID",
|
||||
Scopes: []string{"bork", "meow"},
|
||||
}
|
||||
state := State{
|
||||
DeviceCode: "aDeviceCode",
|
||||
UserCode: "aUserCode",
|
||||
Interval: 1,
|
||||
ExpiresIn: 1,
|
||||
}
|
||||
|
||||
_, err := api.WaitForDeviceToken(context.Background(), state)
|
||||
|
||||
assert.ErrorIs(t, err, ErrTimeout)
|
||||
})
|
||||
|
||||
t.Run("canceled context", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
pendingError := "authorization_pending"
|
||||
jsonResponse, err := json.Marshal(TokenResponse{
|
||||
Error: &pendingError,
|
||||
})
|
||||
assert.NilError(t, err)
|
||||
w.Write(jsonResponse)
|
||||
}))
|
||||
defer ts.Close()
|
||||
api := API{
|
||||
TenantURL: ts.URL,
|
||||
ClientID: "aClientID",
|
||||
Scopes: []string{"bork", "meow"},
|
||||
}
|
||||
state := State{
|
||||
DeviceCode: "aDeviceCode",
|
||||
UserCode: "aUserCode",
|
||||
Interval: 1,
|
||||
ExpiresIn: 5,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go func() {
|
||||
time.Sleep(1 * time.Second)
|
||||
cancel()
|
||||
}()
|
||||
_, err := api.WaitForDeviceToken(ctx, state)
|
||||
|
||||
assert.ErrorContains(t, err, "context canceled")
|
||||
})
|
||||
}
|
||||
|
||||
func TestRevoke(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("success", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
assert.Equal(t, "/oauth/revoke", r.URL.Path)
|
||||
assert.Equal(t, r.FormValue("client_id"), "aClientID")
|
||||
assert.Equal(t, r.FormValue("token"), "v1.a-refresh-token")
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer ts.Close()
|
||||
api := API{
|
||||
TenantURL: ts.URL,
|
||||
ClientID: "aClientID",
|
||||
Scopes: []string{"bork", "meow"},
|
||||
}
|
||||
|
||||
err := api.RevokeToken(context.Background(), "v1.a-refresh-token")
|
||||
assert.NilError(t, err)
|
||||
})
|
||||
|
||||
t.Run("unexpected response", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
assert.Equal(t, "/oauth/revoke", r.URL.Path)
|
||||
assert.Equal(t, r.FormValue("client_id"), "aClientID")
|
||||
assert.Equal(t, r.FormValue("token"), "v1.a-refresh-token")
|
||||
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer ts.Close()
|
||||
api := API{
|
||||
TenantURL: ts.URL,
|
||||
ClientID: "aClientID",
|
||||
Scopes: []string{"bork", "meow"},
|
||||
}
|
||||
|
||||
err := api.RevokeToken(context.Background(), "v1.a-refresh-token")
|
||||
assert.ErrorContains(t, err, "unexpected response from tenant: 404 Not Found")
|
||||
})
|
||||
|
||||
t.Run("error w/ description", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
jsonState, err := json.Marshal(TokenResponse{
|
||||
ErrorDescription: "invalid client id",
|
||||
})
|
||||
assert.NilError(t, err)
|
||||
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write(jsonState)
|
||||
}))
|
||||
defer ts.Close()
|
||||
api := API{
|
||||
TenantURL: ts.URL,
|
||||
ClientID: "aClientID",
|
||||
Scopes: []string{"bork", "meow"},
|
||||
}
|
||||
|
||||
err := api.RevokeToken(context.Background(), "v1.a-refresh-token")
|
||||
assert.ErrorContains(t, err, "invalid client id")
|
||||
})
|
||||
|
||||
t.Run("canceled context", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
assert.Equal(t, "/oauth/revoke", r.URL.Path)
|
||||
assert.Equal(t, r.FormValue("client_id"), "aClientID")
|
||||
assert.Equal(t, r.FormValue("token"), "v1.a-refresh-token")
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer ts.Close()
|
||||
api := API{
|
||||
TenantURL: ts.URL,
|
||||
ClientID: "aClientID",
|
||||
Scopes: []string{"bork", "meow"},
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
err := api.RevokeToken(ctx, "v1.a-refresh-token")
|
||||
|
||||
assert.ErrorContains(t, err, "context canceled")
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetAutoPAT(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("success", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
assert.Equal(t, "/v2/access-tokens/desktop-generate", r.URL.Path)
|
||||
assert.Equal(t, "Bearer bork", r.Header.Get("Authorization"))
|
||||
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
|
||||
|
||||
marshalledResponse, err := json.Marshal(patGenerateResponse{
|
||||
Data: struct {
|
||||
Token string `json:"token"`
|
||||
}{
|
||||
Token: "a-docker-pat",
|
||||
},
|
||||
})
|
||||
assert.NilError(t, err)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
w.Write(marshalledResponse)
|
||||
}))
|
||||
defer ts.Close()
|
||||
api := API{
|
||||
TenantURL: ts.URL,
|
||||
ClientID: "aClientID",
|
||||
Scopes: []string{"bork", "meow"},
|
||||
}
|
||||
|
||||
pat, err := api.GetAutoPAT(context.Background(), ts.URL, TokenResponse{
|
||||
AccessToken: "bork",
|
||||
})
|
||||
assert.NilError(t, err)
|
||||
|
||||
assert.Equal(t, "a-docker-pat", pat)
|
||||
})
|
||||
|
||||
t.Run("general error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer ts.Close()
|
||||
api := API{
|
||||
TenantURL: ts.URL,
|
||||
ClientID: "aClientID",
|
||||
Scopes: []string{"bork", "meow"},
|
||||
}
|
||||
|
||||
_, err := api.GetAutoPAT(context.Background(), ts.URL, TokenResponse{
|
||||
AccessToken: "bork",
|
||||
})
|
||||
assert.ErrorContains(t, err, "unexpected response from Hub: 500 Internal Server Error")
|
||||
})
|
||||
|
||||
t.Run("context canceled", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
assert.Equal(t, "/v2/access-tokens/desktop-generate", r.URL.Path)
|
||||
assert.Equal(t, "Bearer bork", r.Header.Get("Authorization"))
|
||||
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
|
||||
|
||||
marshalledResponse, err := json.Marshal(patGenerateResponse{
|
||||
Data: struct {
|
||||
Token string `json:"token"`
|
||||
}{
|
||||
Token: "a-docker-pat",
|
||||
},
|
||||
})
|
||||
assert.NilError(t, err)
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
w.Write(marshalledResponse)
|
||||
}))
|
||||
defer ts.Close()
|
||||
api := API{
|
||||
TenantURL: ts.URL,
|
||||
ClientID: "aClientID",
|
||||
Scopes: []string{"bork", "meow"},
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
pat, err := api.GetAutoPAT(ctx, ts.URL, TokenResponse{
|
||||
AccessToken: "bork",
|
||||
})
|
||||
|
||||
assert.ErrorContains(t, err, "context canceled")
|
||||
assert.Equal(t, "", pat)
|
||||
})
|
||||
}
|
||||
26
cli/internal/oauth/api/state.go
Normal file
26
cli/internal/oauth/api/state.go
Normal file
@ -0,0 +1,26 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// State represents the state of exchange after submitting.
|
||||
type State struct {
|
||||
DeviceCode string `json:"device_code"`
|
||||
UserCode string `json:"user_code"`
|
||||
VerificationURI string `json:"verification_uri_complete"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Interval int `json:"interval"`
|
||||
}
|
||||
|
||||
// IntervalDuration returns the duration that should be waited between each auth
|
||||
// polling event.
|
||||
func (s State) IntervalDuration() time.Duration {
|
||||
return time.Second * time.Duration(s.Interval)
|
||||
}
|
||||
|
||||
// ExpiryDuration returns the total duration for which the client should keep
|
||||
// polling.
|
||||
func (s State) ExpiryDuration() time.Duration {
|
||||
return time.Second * time.Duration(s.ExpiresIn)
|
||||
}
|
||||
73
cli/internal/oauth/jwt.go
Normal file
73
cli/internal/oauth/jwt.go
Normal file
@ -0,0 +1,73 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"github.com/go-jose/go-jose/v3/jwt"
|
||||
)
|
||||
|
||||
// Claims represents standard claims along with some custom ones.
|
||||
type Claims struct {
|
||||
jwt.Claims
|
||||
|
||||
// Domain is the domain claims for the token.
|
||||
Domain DomainClaims `json:"https://hub.docker.com"`
|
||||
|
||||
// Scope is the scopes for the claims as a string that is space delimited.
|
||||
Scope string `json:"scope,omitempty"`
|
||||
}
|
||||
|
||||
// DomainClaims represents a custom claim data set that doesn't change the spec
|
||||
// payload. This is primarily introduced by Auth0 and is defined by a fully
|
||||
// specified URL as it's key. e.g. "https://hub.docker.com"
|
||||
type DomainClaims struct {
|
||||
// UUID is the user, machine client, or organization's UUID in our database.
|
||||
UUID string `json:"uuid"`
|
||||
|
||||
// Email is the user's email address.
|
||||
Email string `json:"email"`
|
||||
|
||||
// Username is the user's username.
|
||||
Username string `json:"username"`
|
||||
|
||||
// Source is the source of the JWT. This should look like
|
||||
// `docker_{type}|{id}`.
|
||||
Source string `json:"source"`
|
||||
|
||||
// SessionID is the unique ID of the token.
|
||||
SessionID string `json:"session_id"`
|
||||
|
||||
// ClientID is the client_id that generated the token. This is filled if
|
||||
// M2M.
|
||||
ClientID string `json:"client_id,omitempty"`
|
||||
|
||||
// ClientName is the name of the client that generated the token. This is
|
||||
// filled if M2M.
|
||||
ClientName string `json:"client_name,omitempty"`
|
||||
}
|
||||
|
||||
// Source represents a source of a JWT.
|
||||
type Source struct {
|
||||
// Type is the type of source. This could be "pat" etc.
|
||||
Type string `json:"type"`
|
||||
|
||||
// ID is the identifier to the source type. If "pat" then this will be the
|
||||
// ID of the PAT.
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
// GetClaims returns claims from an access token without verification.
|
||||
func GetClaims(accessToken string) (claims Claims, err error) {
|
||||
token, err := parseSigned(accessToken)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = token.UnsafeClaimsWithoutVerification(&claims)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// parseSigned parses a JWT and returns the signature object or error. This does
|
||||
// not verify the validity of the JWT.
|
||||
func parseSigned(token string) (*jwt.JSONWebToken, error) {
|
||||
return jwt.ParseSigned(token)
|
||||
}
|
||||
204
cli/internal/oauth/manager/manager.go
Normal file
204
cli/internal/oauth/manager/manager.go
Normal file
@ -0,0 +1,204 @@
|
||||
package manager
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/docker/cli/cli/config/credentials"
|
||||
"github.com/docker/cli/cli/config/types"
|
||||
"github.com/docker/cli/cli/internal/oauth"
|
||||
"github.com/docker/cli/cli/internal/oauth/api"
|
||||
"github.com/docker/docker/registry"
|
||||
"github.com/morikuni/aec"
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/pkg/browser"
|
||||
)
|
||||
|
||||
// OAuthManager is the manager responsible for handling authentication
|
||||
// flows with the oauth tenant.
|
||||
type OAuthManager struct {
|
||||
store credentials.Store
|
||||
tenant string
|
||||
audience string
|
||||
clientID string
|
||||
api api.OAuthAPI
|
||||
openBrowser func(string) error
|
||||
}
|
||||
|
||||
// OAuthManagerOptions are the options used for New to create a new auth manager.
|
||||
type OAuthManagerOptions struct {
|
||||
Store credentials.Store
|
||||
Audience string
|
||||
ClientID string
|
||||
Scopes []string
|
||||
Tenant string
|
||||
DeviceName string
|
||||
OpenBrowser func(string) error
|
||||
}
|
||||
|
||||
func New(options OAuthManagerOptions) *OAuthManager {
|
||||
scopes := []string{"openid", "offline_access"}
|
||||
if len(options.Scopes) > 0 {
|
||||
scopes = options.Scopes
|
||||
}
|
||||
|
||||
openBrowser := options.OpenBrowser
|
||||
if openBrowser == nil {
|
||||
// Prevent errors from missing binaries (like xdg-open) from
|
||||
// cluttering the output. We can handle errors ourselves.
|
||||
browser.Stdout = io.Discard
|
||||
browser.Stderr = io.Discard
|
||||
openBrowser = browser.OpenURL
|
||||
}
|
||||
|
||||
return &OAuthManager{
|
||||
clientID: options.ClientID,
|
||||
audience: options.Audience,
|
||||
tenant: options.Tenant,
|
||||
store: options.Store,
|
||||
api: api.API{
|
||||
TenantURL: "https://" + options.Tenant,
|
||||
ClientID: options.ClientID,
|
||||
Scopes: scopes,
|
||||
},
|
||||
openBrowser: openBrowser,
|
||||
}
|
||||
}
|
||||
|
||||
var ErrDeviceLoginStartFail = errors.New("failed to start device code flow login")
|
||||
|
||||
// LoginDevice launches the device authentication flow with the tenant,
|
||||
// printing instructions to the provided writer and attempting to open the
|
||||
// browser for the user to authenticate.
|
||||
// After the user completes the browser login, LoginDevice uses the retrieved
|
||||
// tokens to create a Hub PAT which is returned to the caller.
|
||||
// The retrieved tokens are stored in the credentials store (under a separate
|
||||
// key), and the refresh token is concatenated with the client ID.
|
||||
func (m *OAuthManager) LoginDevice(ctx context.Context, w io.Writer) (*types.AuthConfig, error) {
|
||||
state, err := m.api.GetDeviceCode(ctx, m.audience)
|
||||
if err != nil {
|
||||
logrus.Debugf("failed to start device code login: %v", err)
|
||||
return nil, ErrDeviceLoginStartFail
|
||||
}
|
||||
|
||||
if state.UserCode == "" {
|
||||
logrus.Debugf("failed to start device code login: missing user code")
|
||||
return nil, ErrDeviceLoginStartFail
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(w, aec.Bold.Apply("\nUSING WEB BASED LOGIN"))
|
||||
_, _ = fmt.Fprintln(w, "To sign in with credentials on the command line, use 'docker login -u <username>'")
|
||||
_, _ = fmt.Fprintf(w, "\nYour one-time device confirmation code is: "+aec.Bold.Apply("%s\n"), state.UserCode)
|
||||
_, _ = fmt.Fprintf(w, aec.Bold.Apply("Press ENTER")+" to open your browser or submit your device code here: "+aec.Underline.Apply("%s\n"), strings.Split(state.VerificationURI, "?")[0])
|
||||
|
||||
tokenResChan := make(chan api.TokenResponse)
|
||||
waitForTokenErrChan := make(chan error)
|
||||
go func() {
|
||||
tokenRes, err := m.api.WaitForDeviceToken(ctx, state)
|
||||
if err != nil {
|
||||
waitForTokenErrChan <- err
|
||||
return
|
||||
}
|
||||
tokenResChan <- tokenRes
|
||||
}()
|
||||
|
||||
go func() {
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
_, _ = reader.ReadString('\n')
|
||||
_ = m.openBrowser(state.VerificationURI)
|
||||
}()
|
||||
|
||||
_, _ = fmt.Fprint(w, "\nWaiting for authentication in the browser…\n")
|
||||
var tokenRes api.TokenResponse
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, errors.New("login canceled")
|
||||
case err := <-waitForTokenErrChan:
|
||||
return nil, fmt.Errorf("failed waiting for authentication: %w", err)
|
||||
case tokenRes = <-tokenResChan:
|
||||
}
|
||||
|
||||
claims, err := oauth.GetClaims(tokenRes.AccessToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse token claims: %w", err)
|
||||
}
|
||||
|
||||
err = m.storeTokensInStore(tokenRes, claims.Domain.Username)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to store tokens: %w", err)
|
||||
}
|
||||
|
||||
pat, err := m.api.GetAutoPAT(ctx, m.audience, tokenRes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &types.AuthConfig{
|
||||
Username: claims.Domain.Username,
|
||||
Password: pat,
|
||||
ServerAddress: registry.IndexServer,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Logout fetches the refresh token from the store and revokes it
|
||||
// with the configured oauth tenant. The stored access and refresh
|
||||
// tokens are then erased from the store.
|
||||
// If the refresh token is not found in the store, an error is not
|
||||
// returned.
|
||||
func (m *OAuthManager) Logout(ctx context.Context) error {
|
||||
refreshConfig, err := m.store.Get(refreshTokenKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if refreshConfig.Password == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(refreshConfig.Password, "..")
|
||||
if len(parts) != 2 {
|
||||
// the token wasn't stored by the CLI, so don't revoke it
|
||||
// or erase it from the store/error
|
||||
return nil
|
||||
}
|
||||
// erase the token from the store first, that way
|
||||
// if the revoke fails, the user can try to logout again
|
||||
if err := m.eraseTokensFromStore(); err != nil {
|
||||
return fmt.Errorf("failed to erase tokens: %w", err)
|
||||
}
|
||||
if err := m.api.RevokeToken(ctx, parts[0]); err != nil {
|
||||
return fmt.Errorf("credentials erased successfully, but there was a failure to revoke the OAuth refresh token with the tenant: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const (
|
||||
accessTokenKey = registry.IndexServer + "access-token"
|
||||
refreshTokenKey = registry.IndexServer + "refresh-token"
|
||||
)
|
||||
|
||||
func (m *OAuthManager) storeTokensInStore(tokens api.TokenResponse, username string) error {
|
||||
return errors.Join(
|
||||
m.store.Store(types.AuthConfig{
|
||||
Username: username,
|
||||
Password: tokens.AccessToken,
|
||||
ServerAddress: accessTokenKey,
|
||||
}),
|
||||
m.store.Store(types.AuthConfig{
|
||||
Username: username,
|
||||
Password: tokens.RefreshToken + ".." + m.clientID,
|
||||
ServerAddress: refreshTokenKey,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (m *OAuthManager) eraseTokensFromStore() error {
|
||||
return errors.Join(
|
||||
m.store.Erase(accessTokenKey),
|
||||
m.store.Erase(refreshTokenKey),
|
||||
)
|
||||
}
|
||||
363
cli/internal/oauth/manager/manager_test.go
Normal file
363
cli/internal/oauth/manager/manager_test.go
Normal file
@ -0,0 +1,363 @@
|
||||
package manager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/docker/cli/cli/config/credentials"
|
||||
"github.com/docker/cli/cli/config/types"
|
||||
"github.com/docker/cli/cli/internal/oauth/api"
|
||||
"gotest.tools/v3/assert"
|
||||
)
|
||||
|
||||
const (
|
||||
//nolint:lll
|
||||
validToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InhYa3BCdDNyV3MyRy11YjlscEpncSJ9.eyJodHRwczovL2h1Yi5kb2NrZXIuY29tIjp7ImVtYWlsIjoiYm9ya0Bkb2NrZXIuY29tIiwic2Vzc2lvbl9pZCI6ImEtc2Vzc2lvbi1pZCIsInNvdXJjZSI6InNhbWxwIiwidXNlcm5hbWUiOiJib3JrISIsInV1aWQiOiIwMTIzLTQ1Njc4OSJ9LCJpc3MiOiJodHRwczovL2xvZ2luLmRvY2tlci5jb20vIiwic3ViIjoic2FtbHB8c2FtbHAtZG9ja2VyfGJvcmtAZG9ja2VyLmNvbSIsImF1ZCI6WyJodHRwczovL2F1ZGllbmNlLmNvbSJdLCJpYXQiOjE3MTk1MDI5MzksImV4cCI6MTcxOTUwNjUzOSwic2NvcGUiOiJvcGVuaWQgb2ZmbGluZV9hY2Nlc3MifQ.VUSp-9_SOvMPWJPRrSh7p4kSPoye4DA3kyd2I0TW0QtxYSRq7xCzNj0NC_ywlPlKBFBeXKm4mh93d1vBSh79I9Heq5tj0Fr4KH77U5xJRMEpjHqoT5jxMEU1hYXX92xctnagBMXxDvzUfu3Yf0tvYSA0RRoGbGTHfdYYRwOrGbwQ75Qg1dyIxUkwsG053eYX2XkmLGxymEMgIq_gWksgAamOc40_0OCdGr-MmDeD2HyGUa309aGltzQUw7Z0zG1AKSXy3WwfMHdWNFioTAvQphwEyY3US8ybSJi78upSFTjwUcryMeHUwQ3uV9PxwPMyPoYxo1izVB-OUJxM8RqEbg"
|
||||
)
|
||||
|
||||
// parsed token:
|
||||
// {
|
||||
// "https://hub.docker.com": {
|
||||
// "email": "bork@docker.com",
|
||||
// "session_id": "a-session-id",
|
||||
// "source": "samlp",
|
||||
// "username": "bork!",
|
||||
// "uuid": "0123-456789"
|
||||
// },
|
||||
// "iss": "https://login.docker.com/",
|
||||
// "sub": "samlp|samlp-docker|bork@docker.com",
|
||||
// "aud": [
|
||||
// "https://audience.com"
|
||||
// ],
|
||||
// "iat": 1719502939,
|
||||
// "exp": 1719506539,
|
||||
// "scope": "openid offline_access"
|
||||
// }
|
||||
|
||||
func TestLoginDevice(t *testing.T) {
|
||||
t.Run("valid token", func(t *testing.T) {
|
||||
expectedState := api.State{
|
||||
DeviceCode: "device-code",
|
||||
UserCode: "0123-4567",
|
||||
VerificationURI: "an-url",
|
||||
ExpiresIn: 300,
|
||||
}
|
||||
var receivedAudience string
|
||||
getDeviceToken := func(audience string) (api.State, error) {
|
||||
receivedAudience = audience
|
||||
return expectedState, nil
|
||||
}
|
||||
var receivedState api.State
|
||||
waitForDeviceToken := func(state api.State) (api.TokenResponse, error) {
|
||||
receivedState = state
|
||||
return api.TokenResponse{
|
||||
AccessToken: validToken,
|
||||
RefreshToken: "refresh-token",
|
||||
}, nil
|
||||
}
|
||||
var receivedAccessToken, getPatReceivedAudience string
|
||||
getAutoPat := func(audience string, res api.TokenResponse) (string, error) {
|
||||
receivedAccessToken = res.AccessToken
|
||||
getPatReceivedAudience = audience
|
||||
return "a-pat", nil
|
||||
}
|
||||
api := &testAPI{
|
||||
getDeviceToken: getDeviceToken,
|
||||
waitForDeviceToken: waitForDeviceToken,
|
||||
getAutoPAT: getAutoPat,
|
||||
}
|
||||
store := newStore(map[string]types.AuthConfig{})
|
||||
manager := OAuthManager{
|
||||
store: credentials.NewFileStore(store),
|
||||
audience: "https://hub.docker.com",
|
||||
api: api,
|
||||
openBrowser: func(url string) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
authConfig, err := manager.LoginDevice(context.Background(), os.Stderr)
|
||||
assert.NilError(t, err)
|
||||
|
||||
assert.Equal(t, receivedAudience, "https://hub.docker.com")
|
||||
assert.Equal(t, receivedState, expectedState)
|
||||
assert.DeepEqual(t, authConfig, &types.AuthConfig{
|
||||
Username: "bork!",
|
||||
Password: "a-pat",
|
||||
ServerAddress: "https://index.docker.io/v1/",
|
||||
})
|
||||
assert.Equal(t, receivedAccessToken, validToken)
|
||||
assert.Equal(t, getPatReceivedAudience, "https://hub.docker.com")
|
||||
})
|
||||
|
||||
t.Run("stores in cred store", func(t *testing.T) {
|
||||
getDeviceToken := func(audience string) (api.State, error) {
|
||||
return api.State{
|
||||
DeviceCode: "device-code",
|
||||
UserCode: "0123-4567",
|
||||
}, nil
|
||||
}
|
||||
waitForDeviceToken := func(state api.State) (api.TokenResponse, error) {
|
||||
return api.TokenResponse{
|
||||
AccessToken: validToken,
|
||||
RefreshToken: "refresh-token",
|
||||
}, nil
|
||||
}
|
||||
getAutoPAT := func(audience string, res api.TokenResponse) (string, error) {
|
||||
return "a-pat", nil
|
||||
}
|
||||
a := &testAPI{
|
||||
getDeviceToken: getDeviceToken,
|
||||
waitForDeviceToken: waitForDeviceToken,
|
||||
getAutoPAT: getAutoPAT,
|
||||
}
|
||||
store := newStore(map[string]types.AuthConfig{})
|
||||
manager := OAuthManager{
|
||||
clientID: "client-id",
|
||||
store: credentials.NewFileStore(store),
|
||||
api: a,
|
||||
openBrowser: func(url string) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
authConfig, err := manager.LoginDevice(context.Background(), os.Stderr)
|
||||
assert.NilError(t, err)
|
||||
|
||||
assert.Equal(t, authConfig.Password, "a-pat")
|
||||
assert.Equal(t, authConfig.Username, "bork!")
|
||||
|
||||
assert.Equal(t, len(store.configs), 2)
|
||||
assert.Equal(t, store.configs["https://index.docker.io/v1/access-token"].Password, validToken)
|
||||
assert.Equal(t, store.configs["https://index.docker.io/v1/refresh-token"].Password, "refresh-token..client-id")
|
||||
})
|
||||
|
||||
t.Run("timeout", func(t *testing.T) {
|
||||
getDeviceToken := func(audience string) (api.State, error) {
|
||||
return api.State{
|
||||
DeviceCode: "device-code",
|
||||
UserCode: "0123-4567",
|
||||
VerificationURI: "an-url",
|
||||
ExpiresIn: 300,
|
||||
}, nil
|
||||
}
|
||||
waitForDeviceToken := func(state api.State) (api.TokenResponse, error) {
|
||||
return api.TokenResponse{}, api.ErrTimeout
|
||||
}
|
||||
a := &testAPI{
|
||||
getDeviceToken: getDeviceToken,
|
||||
waitForDeviceToken: waitForDeviceToken,
|
||||
}
|
||||
manager := OAuthManager{
|
||||
api: a,
|
||||
openBrowser: func(url string) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
_, err := manager.LoginDevice(context.Background(), os.Stderr)
|
||||
assert.ErrorContains(t, err, "failed waiting for authentication: timed out waiting for device token")
|
||||
})
|
||||
|
||||
t.Run("canceled context", func(t *testing.T) {
|
||||
getDeviceToken := func(audience string) (api.State, error) {
|
||||
return api.State{
|
||||
DeviceCode: "device-code",
|
||||
UserCode: "0123-4567",
|
||||
}, nil
|
||||
}
|
||||
waitForDeviceToken := func(state api.State) (api.TokenResponse, error) {
|
||||
// make sure that the context is cancelled before this returns
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
return api.TokenResponse{
|
||||
AccessToken: validToken,
|
||||
RefreshToken: "refresh-token",
|
||||
}, nil
|
||||
}
|
||||
a := &testAPI{
|
||||
getDeviceToken: getDeviceToken,
|
||||
waitForDeviceToken: waitForDeviceToken,
|
||||
}
|
||||
manager := OAuthManager{
|
||||
api: a,
|
||||
openBrowser: func(url string) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
_, err := manager.LoginDevice(ctx, os.Stderr)
|
||||
assert.ErrorContains(t, err, "login canceled")
|
||||
})
|
||||
}
|
||||
|
||||
func TestLogout(t *testing.T) {
|
||||
t.Run("successfully revokes token", func(t *testing.T) {
|
||||
var receivedToken string
|
||||
a := &testAPI{
|
||||
revokeToken: func(token string) error {
|
||||
receivedToken = token
|
||||
return nil
|
||||
},
|
||||
}
|
||||
store := newStore(map[string]types.AuthConfig{
|
||||
"https://index.docker.io/v1/access-token": {
|
||||
Password: validToken,
|
||||
},
|
||||
"https://index.docker.io/v1/refresh-token": {
|
||||
Password: "a-refresh-token..client-id",
|
||||
},
|
||||
})
|
||||
manager := OAuthManager{
|
||||
store: credentials.NewFileStore(store),
|
||||
api: a,
|
||||
}
|
||||
|
||||
err := manager.Logout(context.Background())
|
||||
assert.NilError(t, err)
|
||||
|
||||
assert.Equal(t, receivedToken, "a-refresh-token")
|
||||
assert.Equal(t, len(store.configs), 0)
|
||||
})
|
||||
|
||||
t.Run("error revoking token", func(t *testing.T) {
|
||||
a := &testAPI{
|
||||
revokeToken: func(token string) error {
|
||||
return errors.New("couldn't reach tenant")
|
||||
},
|
||||
}
|
||||
store := newStore(map[string]types.AuthConfig{
|
||||
"https://index.docker.io/v1/access-token": {
|
||||
Password: validToken,
|
||||
},
|
||||
"https://index.docker.io/v1/refresh-token": {
|
||||
Password: "a-refresh-token..client-id",
|
||||
},
|
||||
})
|
||||
manager := OAuthManager{
|
||||
store: credentials.NewFileStore(store),
|
||||
api: a,
|
||||
}
|
||||
|
||||
err := manager.Logout(context.Background())
|
||||
assert.ErrorContains(t, err, "credentials erased successfully, but there was a failure to revoke the OAuth refresh token with the tenant: couldn't reach tenant")
|
||||
|
||||
assert.Equal(t, len(store.configs), 0)
|
||||
})
|
||||
|
||||
t.Run("invalid refresh token", func(t *testing.T) {
|
||||
var triedRevoke bool
|
||||
a := &testAPI{
|
||||
revokeToken: func(token string) error {
|
||||
triedRevoke = true
|
||||
return nil
|
||||
},
|
||||
}
|
||||
store := newStore(map[string]types.AuthConfig{
|
||||
"https://index.docker.io/v1/access-token": {
|
||||
Password: validToken,
|
||||
},
|
||||
"https://index.docker.io/v1/refresh-token": {
|
||||
Password: "a-refresh-token-without-client-id",
|
||||
},
|
||||
})
|
||||
manager := OAuthManager{
|
||||
store: credentials.NewFileStore(store),
|
||||
api: a,
|
||||
}
|
||||
|
||||
err := manager.Logout(context.Background())
|
||||
assert.NilError(t, err)
|
||||
|
||||
assert.Check(t, !triedRevoke)
|
||||
})
|
||||
|
||||
t.Run("no refresh token", func(t *testing.T) {
|
||||
a := &testAPI{}
|
||||
var triedRevoke bool
|
||||
revokeToken := func(token string) error {
|
||||
triedRevoke = true
|
||||
return nil
|
||||
}
|
||||
a.revokeToken = revokeToken
|
||||
store := newStore(map[string]types.AuthConfig{})
|
||||
manager := OAuthManager{
|
||||
store: credentials.NewFileStore(store),
|
||||
api: a,
|
||||
}
|
||||
|
||||
err := manager.Logout(context.Background())
|
||||
assert.NilError(t, err)
|
||||
|
||||
assert.Check(t, !triedRevoke)
|
||||
})
|
||||
}
|
||||
|
||||
var _ api.OAuthAPI = &testAPI{}
|
||||
|
||||
type testAPI struct {
|
||||
getDeviceToken func(audience string) (api.State, error)
|
||||
waitForDeviceToken func(state api.State) (api.TokenResponse, error)
|
||||
refresh func(token string) (api.TokenResponse, error)
|
||||
revokeToken func(token string) error
|
||||
getAutoPAT func(audience string, res api.TokenResponse) (string, error)
|
||||
}
|
||||
|
||||
func (t *testAPI) GetDeviceCode(_ context.Context, audience string) (api.State, error) {
|
||||
if t.getDeviceToken != nil {
|
||||
return t.getDeviceToken(audience)
|
||||
}
|
||||
return api.State{}, nil
|
||||
}
|
||||
|
||||
func (t *testAPI) WaitForDeviceToken(_ context.Context, state api.State) (api.TokenResponse, error) {
|
||||
if t.waitForDeviceToken != nil {
|
||||
return t.waitForDeviceToken(state)
|
||||
}
|
||||
return api.TokenResponse{}, nil
|
||||
}
|
||||
|
||||
func (t *testAPI) Refresh(_ context.Context, token string) (api.TokenResponse, error) {
|
||||
if t.refresh != nil {
|
||||
return t.refresh(token)
|
||||
}
|
||||
return api.TokenResponse{}, nil
|
||||
}
|
||||
|
||||
func (t *testAPI) RevokeToken(_ context.Context, token string) error {
|
||||
if t.revokeToken != nil {
|
||||
return t.revokeToken(token)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *testAPI) GetAutoPAT(_ context.Context, audience string, res api.TokenResponse) (string, error) {
|
||||
if t.getAutoPAT != nil {
|
||||
return t.getAutoPAT(audience, res)
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
type fakeStore struct {
|
||||
configs map[string]types.AuthConfig
|
||||
}
|
||||
|
||||
func (f *fakeStore) Save() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) GetAuthConfigs() map[string]types.AuthConfig {
|
||||
return f.configs
|
||||
}
|
||||
|
||||
func (f *fakeStore) GetFilename() string {
|
||||
return "/tmp/docker-fakestore"
|
||||
}
|
||||
|
||||
func newStore(auths map[string]types.AuthConfig) *fakeStore {
|
||||
return &fakeStore{configs: auths}
|
||||
}
|
||||
28
cli/internal/oauth/manager/util.go
Normal file
28
cli/internal/oauth/manager/util.go
Normal file
@ -0,0 +1,28 @@
|
||||
package manager
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/docker/cli/cli/config/credentials"
|
||||
"github.com/docker/cli/cli/version"
|
||||
)
|
||||
|
||||
const (
|
||||
audience = "https://hub.docker.com"
|
||||
tenant = "login.docker.com"
|
||||
clientID = "L4v0dmlNBpYUjGGab0C2JtgTgXr1Qz4d"
|
||||
)
|
||||
|
||||
func NewManager(store credentials.Store) *OAuthManager {
|
||||
cliVersion := strings.ReplaceAll(version.Version, ".", "_")
|
||||
options := OAuthManagerOptions{
|
||||
Store: store,
|
||||
Audience: audience,
|
||||
ClientID: clientID,
|
||||
Tenant: tenant,
|
||||
DeviceName: fmt.Sprintf("docker-cli:%s:%s-%s", cliVersion, runtime.GOOS, runtime.GOARCH),
|
||||
}
|
||||
return New(options)
|
||||
}
|
||||
@ -27,16 +27,16 @@ func NoArgs(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
// RequiresMinArgs returns an error if there is not at least min args
|
||||
func RequiresMinArgs(min int) cobra.PositionalArgs {
|
||||
func RequiresMinArgs(minArgs int) cobra.PositionalArgs {
|
||||
return func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) >= min {
|
||||
if len(args) >= minArgs {
|
||||
return nil
|
||||
}
|
||||
return errors.Errorf(
|
||||
"%q requires at least %d %s.\nSee '%s --help'.\n\nUsage: %s\n\n%s",
|
||||
cmd.CommandPath(),
|
||||
min,
|
||||
pluralize("argument", min),
|
||||
minArgs,
|
||||
pluralize("argument", minArgs),
|
||||
cmd.CommandPath(),
|
||||
cmd.UseLine(),
|
||||
cmd.Short,
|
||||
@ -45,16 +45,16 @@ func RequiresMinArgs(min int) cobra.PositionalArgs {
|
||||
}
|
||||
|
||||
// RequiresMaxArgs returns an error if there is not at most max args
|
||||
func RequiresMaxArgs(max int) cobra.PositionalArgs {
|
||||
func RequiresMaxArgs(maxArgs int) cobra.PositionalArgs {
|
||||
return func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) <= max {
|
||||
if len(args) <= maxArgs {
|
||||
return nil
|
||||
}
|
||||
return errors.Errorf(
|
||||
"%q requires at most %d %s.\nSee '%s --help'.\n\nUsage: %s\n\n%s",
|
||||
cmd.CommandPath(),
|
||||
max,
|
||||
pluralize("argument", max),
|
||||
maxArgs,
|
||||
pluralize("argument", maxArgs),
|
||||
cmd.CommandPath(),
|
||||
cmd.UseLine(),
|
||||
cmd.Short,
|
||||
@ -63,17 +63,17 @@ func RequiresMaxArgs(max int) cobra.PositionalArgs {
|
||||
}
|
||||
|
||||
// RequiresRangeArgs returns an error if there is not at least min args and at most max args
|
||||
func RequiresRangeArgs(min int, max int) cobra.PositionalArgs {
|
||||
func RequiresRangeArgs(minArgs int, maxArgs int) cobra.PositionalArgs {
|
||||
return func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) >= min && len(args) <= max {
|
||||
if len(args) >= minArgs && len(args) <= maxArgs {
|
||||
return nil
|
||||
}
|
||||
return errors.Errorf(
|
||||
"%q requires at least %d and at most %d %s.\nSee '%s --help'.\n\nUsage: %s\n\n%s",
|
||||
cmd.CommandPath(),
|
||||
min,
|
||||
max,
|
||||
pluralize("argument", max),
|
||||
minArgs,
|
||||
maxArgs,
|
||||
pluralize("argument", maxArgs),
|
||||
cmd.CommandPath(),
|
||||
cmd.UseLine(),
|
||||
cmd.Short,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
variable "GO_VERSION" {
|
||||
default = "1.21.12"
|
||||
default = "1.21.13"
|
||||
}
|
||||
variable "VERSION" {
|
||||
default = ""
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
ARG GO_VERSION=1.21.12
|
||||
ARG GO_VERSION=1.21.13
|
||||
ARG ALPINE_VERSION=3.20
|
||||
|
||||
ARG BUILDX_VERSION=0.16.1
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
ARG GO_VERSION=1.21.12
|
||||
ARG GO_VERSION=1.21.13
|
||||
ARG ALPINE_VERSION=3.20
|
||||
ARG GOLANGCI_LINT_VERSION=v1.59.1
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
ARG GO_VERSION=1.21.12
|
||||
ARG GO_VERSION=1.21.13
|
||||
ARG ALPINE_VERSION=3.20
|
||||
ARG MODOUTDATED_VERSION=v0.8.0
|
||||
|
||||
|
||||
@ -562,8 +562,7 @@ Docker API v1.42 and up now ignores this option when set. Older versions of the
|
||||
API continue to accept the option, but depending on the OCI runtime used, may
|
||||
take no effect.
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> While not deprecated (yet) in Docker, the OCI runtime specification also
|
||||
> deprecated the `memory.kmem.tcp.limit_in_bytes` option. When using `runc` as
|
||||
> runtime, this option takes no effect. The linux kernel did not explicitly
|
||||
|
||||
@ -16,8 +16,7 @@ plugins using Docker Engine.
|
||||
For information about legacy (non-managed) plugins, refer to
|
||||
[Understand legacy Docker Engine plugins](legacy_plugins.md).
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> Docker Engine managed plugins are currently not supported on Windows daemons.
|
||||
|
||||
## Installing and using a plugin
|
||||
@ -38,8 +37,7 @@ operation, such as creating a volume.
|
||||
In the following example, you install the `sshfs` plugin, verify that it is
|
||||
enabled, and use it to create a volume.
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> This example is intended for instructional purposes only. Once the volume is
|
||||
> created, your SSH password to the remote host is exposed as plaintext when
|
||||
> inspecting the volume. Delete the volume as soon as you are done with the
|
||||
@ -126,8 +124,7 @@ commands and options, see the
|
||||
The `rootfs` directory represents the root filesystem of the plugin. In this
|
||||
example, it was created from a Dockerfile:
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> The `/run/docker/plugins` directory is mandatory inside of the
|
||||
> plugin's filesystem for Docker to communicate with the plugin.
|
||||
|
||||
|
||||
@ -43,8 +43,7 @@ Authorization plugins must follow the rules described in [Docker Plugin API](plu
|
||||
Each plugin must reside within directories described under the
|
||||
[Plugin discovery](plugin_api.md#plugin-discovery) section.
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> The abbreviations `AuthZ` and `AuthN` mean authorization and authentication
|
||||
> respectively.
|
||||
|
||||
|
||||
@ -8,8 +8,7 @@ Docker exposes internal metrics based on the Prometheus format. Metrics plugins
|
||||
enable accessing these metrics in a consistent way by providing a Unix
|
||||
socket at a predefined path where the plugin can scrape the metrics.
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> While the plugin interface for metrics is non-experimental, the naming of the
|
||||
> metrics and metric labels is still considered experimental and may change in a
|
||||
> future version.
|
||||
|
||||
@ -80,8 +80,7 @@ provide the Docker Daemon with writeable paths on the host filesystem. The Docke
|
||||
daemon provides these paths to containers to consume. The Docker daemon makes
|
||||
the volumes available by bind-mounting the provided paths into the containers.
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> Volume plugins should *not* write data to the `/var/lib/docker/` directory,
|
||||
> including `/var/lib/docker/volumes`. The `/var/lib/docker/` directory is
|
||||
> reserved for Docker.
|
||||
|
||||
@ -19,8 +19,7 @@ Creates a config using standard input or from a file for the config content.
|
||||
|
||||
For detailed information about using configs, refer to [store configuration data using Docker Configs](https://docs.docker.com/engine/swarm/configs/).
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> This is a cluster management command, and must be executed on a Swarm
|
||||
> manager node. To learn about managers and workers, refer to the
|
||||
> [Swarm mode section](https://docs.docker.com/engine/swarm/) in the
|
||||
|
||||
@ -25,8 +25,7 @@ describes all the details of the format.
|
||||
|
||||
For detailed information about using configs, refer to [store configuration data using Docker Configs](https://docs.docker.com/engine/swarm/configs/).
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> This is a cluster management command, and must be executed on a Swarm
|
||||
> manager node. To learn about managers and workers, refer to the
|
||||
> [Swarm mode section](https://docs.docker.com/engine/swarm/) in the
|
||||
|
||||
@ -24,8 +24,7 @@ Run this command on a manager node to list the configs in the Swarm.
|
||||
|
||||
For detailed information about using configs, refer to [store configuration data using Docker Configs](https://docs.docker.com/engine/swarm/configs/).
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> This is a cluster management command, and must be executed on a Swarm
|
||||
> manager node. To learn about managers and workers, refer to the
|
||||
> [Swarm mode section](https://docs.docker.com/engine/swarm/) in the
|
||||
|
||||
@ -16,8 +16,7 @@ Removes the specified configs from the Swarm.
|
||||
|
||||
For detailed information about using configs, refer to [store configuration data using Docker Configs](https://docs.docker.com/engine/swarm/configs/).
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> This is a cluster management command, and must be executed on a Swarm
|
||||
> manager node. To learn about managers and workers, refer to the
|
||||
> [Swarm mode section](https://docs.docker.com/engine/swarm/) in the
|
||||
@ -32,8 +31,7 @@ $ docker config rm my_config
|
||||
sapth4csdo5b6wz2p5uimh5xg
|
||||
```
|
||||
|
||||
> **Warning**
|
||||
>
|
||||
> [!WARNING]
|
||||
> This command doesn't ask for confirmation before removing a config.
|
||||
{ .warning }
|
||||
|
||||
|
||||
@ -25,8 +25,7 @@ Use `docker attach` to attach your terminal's standard input, output, and error
|
||||
ID or name. This lets you view its output or control it interactively, as
|
||||
though the commands were running directly in your terminal.
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> The `attach` command displays the output of the container's `ENTRYPOINT` and
|
||||
> `CMD` process. This can appear as if the attach command is hung when in fact
|
||||
> the process may simply not be writing any output at that time.
|
||||
@ -39,8 +38,7 @@ container. If `--sig-proxy` is true (the default),`CTRL-c` sends a `SIGINT` to
|
||||
the container. If the container was run with `-i` and `-t`, you can detach from
|
||||
a container and leave it running using the `CTRL-p CTRL-q` key sequence.
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> A process running as PID 1 inside a container is treated specially by
|
||||
> Linux: it ignores any signal with the default action. So, the process
|
||||
> doesn't terminate on `SIGINT` or `SIGTERM` unless it's coded to do so.
|
||||
@ -97,7 +95,7 @@ CONTAINER ID IMAGE COMMAND CREATED STATUS
|
||||
Repeating the example above, but this time with the `-i` and `-t` options set;
|
||||
|
||||
```console
|
||||
$ docker run -dit --name topdemo2 ubuntu:22.04 /usr/bin/top -b
|
||||
$ docker run -dit --name topdemo2 alpine /usr/bin/top -b
|
||||
```
|
||||
|
||||
Now, when attaching to the container, and pressing the `CTRL-p CTRL-q` ("read
|
||||
|
||||
@ -44,8 +44,8 @@ created. Supported `Dockerfile` instructions:
|
||||
$ docker ps
|
||||
|
||||
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
|
||||
c3f279d17e0a ubuntu:22.04 /bin/bash 7 days ago Up 25 hours desperate_dubinsky
|
||||
197387f1b436 ubuntu:22.04 /bin/bash 7 days ago Up 25 hours focused_hamilton
|
||||
c3f279d17e0a ubuntu:24.04 /bin/bash 7 days ago Up 25 hours desperate_dubinsky
|
||||
197387f1b436 ubuntu:24.04 /bin/bash 7 days ago Up 25 hours focused_hamilton
|
||||
|
||||
$ docker commit c3f279d17e0a svendowideit/testimage:version3
|
||||
|
||||
@ -63,8 +63,8 @@ svendowideit/testimage version3 f5283438590d 16 sec
|
||||
$ docker ps
|
||||
|
||||
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
|
||||
c3f279d17e0a ubuntu:22.04 /bin/bash 7 days ago Up 25 hours desperate_dubinsky
|
||||
197387f1b436 ubuntu:22.04 /bin/bash 7 days ago Up 25 hours focused_hamilton
|
||||
c3f279d17e0a ubuntu:24.04 /bin/bash 7 days ago Up 25 hours desperate_dubinsky
|
||||
197387f1b436 ubuntu:24.04 /bin/bash 7 days ago Up 25 hours focused_hamilton
|
||||
|
||||
$ docker inspect -f "{{ .Config.Env }}" c3f279d17e0a
|
||||
|
||||
@ -85,8 +85,8 @@ $ docker inspect -f "{{ .Config.Env }}" f5283438590d
|
||||
$ docker ps
|
||||
|
||||
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
|
||||
c3f279d17e0a ubuntu:22.04 /bin/bash 7 days ago Up 25 hours desperate_dubinsky
|
||||
197387f1b436 ubuntu:22.04 /bin/bash 7 days ago Up 25 hours focused_hamilton
|
||||
c3f279d17e0a ubuntu:24.04 /bin/bash 7 days ago Up 25 hours desperate_dubinsky
|
||||
197387f1b436 ubuntu:24.04 /bin/bash 7 days ago Up 25 hours focused_hamilton
|
||||
|
||||
$ docker commit --change='CMD ["apachectl", "-DFOREGROUND"]' -c "EXPOSE 80" c3f279d17e0a svendowideit/testimage:version4
|
||||
|
||||
@ -100,6 +100,6 @@ $ docker ps
|
||||
|
||||
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
|
||||
89373736e2e7 testimage:version4 "apachectl -DFOREGROU" 3 seconds ago Up 2 seconds 80/tcp distracted_fermat
|
||||
c3f279d17e0a ubuntu:22.04 /bin/bash 7 days ago Up 25 hours desperate_dubinsky
|
||||
197387f1b436 ubuntu:22.04 /bin/bash 7 days ago Up 25 hours focused_hamilton
|
||||
c3f279d17e0a ubuntu:24.04 /bin/bash 7 days ago Up 25 hours desperate_dubinsky
|
||||
197387f1b436 ubuntu:24.04 /bin/bash 7 days ago Up 25 hours focused_hamilton
|
||||
```
|
||||
|
||||
@ -23,7 +23,7 @@ with the container. If a volume is mounted on top of an existing directory in
|
||||
the container, `docker export` exports the contents of the underlying
|
||||
directory, not the contents of the volume.
|
||||
|
||||
Refer to [Backup, restore, or migrate data volumes](https://docs.docker.com/storage/volumes/#back-up-restore-or-migrate-data-volumes)
|
||||
Refer to [Backup, restore, or migrate data volumes](https://docs.docker.com/engine/storage/volumes/#back-up-restore-or-migrate-data-volumes)
|
||||
in the user guide for examples on exporting data in a volume.
|
||||
|
||||
## Examples
|
||||
|
||||
@ -33,8 +33,7 @@ set through `--signal` may be non-terminal, depending on the container's main
|
||||
process. For example, the `SIGHUP` signal in most cases will be non-terminal,
|
||||
and the container will continue running after receiving the signal.
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> `ENTRYPOINT` and `CMD` in the *shell* form run as a child process of
|
||||
> `/bin/sh -c`, which does not pass signals. This means that the executable is
|
||||
> not the container’s PID 1 and does not receive Unix signals.
|
||||
|
||||
@ -26,7 +26,7 @@ Fetch the logs of a container
|
||||
The `docker logs` command batch-retrieves logs present at the time of execution.
|
||||
|
||||
For more information about selecting and configuring logging drivers, refer to
|
||||
[Configure logging drivers](https://docs.docker.com/config/containers/logging/configure/).
|
||||
[Configure logging drivers](https://docs.docker.com/engine/logging/configure/).
|
||||
|
||||
The `docker logs --follow` command will continue streaming the new output from
|
||||
the container's `STDOUT` and `STDERR`.
|
||||
|
||||
@ -33,7 +33,7 @@ Running `docker ps --no-trunc` showing 2 linked containers.
|
||||
$ docker ps --no-trunc
|
||||
|
||||
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
|
||||
ca5534a51dd04bbcebe9b23ba05f389466cf0c190f1f8f182d7eea92a9671d00 ubuntu:22.04 bash 17 seconds ago Up 16 seconds 3300-3310/tcp webapp
|
||||
ca5534a51dd04bbcebe9b23ba05f389466cf0c190f1f8f182d7eea92a9671d00 ubuntu:24.04 bash 17 seconds ago Up 16 seconds 3300-3310/tcp webapp
|
||||
9ca9747b233100676a48cc7806131586213fa5dab86dd1972d6a8732e3a84a4d crosbymichael/redis:latest /redis-server --dir 33 minutes ago Up 33 minutes 6379/tcp redis,webapp/db
|
||||
```
|
||||
|
||||
@ -64,7 +64,7 @@ e90b8831a4b8 nginx "/bin/bash -c 'mkdir " 11 weeks ago Up 4 hours
|
||||
* The "size" information shows the amount of data (on disk) that is used for the _writable_ layer of each container
|
||||
* The "virtual size" is the total amount of disk-space used for the read-only _image_ data used by the container and the writable layer.
|
||||
|
||||
For more information, refer to the [container size on disk](https://docs.docker.com/storage/storagedriver/#container-size-on-disk) section.
|
||||
For more information, refer to the [container size on disk](https://docs.docker.com/engine/storage/drivers/#container-size-on-disk) section.
|
||||
|
||||
|
||||
### <a name="filter"></a> Filtering (--filter)
|
||||
@ -240,13 +240,13 @@ CONTAINER ID IMAGE COMMAND CREATED
|
||||
919e1179bdb8 ubuntu-c1 "top" About a minute ago Up About a minute admiring_lovelace
|
||||
```
|
||||
|
||||
Match containers based on the `ubuntu` version `22.04` image:
|
||||
Match containers based on the `ubuntu` version `24.04` image:
|
||||
|
||||
```console
|
||||
$ docker ps --filter ancestor=ubuntu:22.04
|
||||
$ docker ps --filter ancestor=ubuntu:24.04
|
||||
|
||||
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
|
||||
82a598284012 ubuntu:22.04 "top" 3 minutes ago Up 3 minutes sleepy_bose
|
||||
82a598284012 ubuntu:24.04 "top" 3 minutes ago Up 3 minutes sleepy_bose
|
||||
```
|
||||
|
||||
The following matches containers based on the layer `d0e008c6cf02` or an image
|
||||
@ -256,7 +256,7 @@ that have this layer in its layer stack.
|
||||
$ docker ps --filter ancestor=d0e008c6cf02
|
||||
|
||||
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
|
||||
82a598284012 ubuntu:22.04 "top" 3 minutes ago Up 3 minutes sleepy_bose
|
||||
82a598284012 ubuntu:24.04 "top" 3 minutes ago Up 3 minutes sleepy_bose
|
||||
```
|
||||
|
||||
#### Create time
|
||||
|
||||
@ -291,8 +291,7 @@ running processes in that namespace. By default, all containers, including
|
||||
those with `--network=host`, have their own UTS namespace. Setting `--uts` to
|
||||
`host` results in the container using the same UTS namespace as the host.
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> Docker disallows combining the `--hostname` and `--domainname` flags with
|
||||
> `--uts=host`. This is to prevent containers running in the host's UTS
|
||||
> namespace from attempting to change the hosts' configuration.
|
||||
@ -350,8 +349,7 @@ In other words, the container can then do almost everything that the host can
|
||||
do. This flag exists to allow special use-cases, like running Docker within
|
||||
Docker.
|
||||
|
||||
> **Warning**
|
||||
>
|
||||
> [!WARNING]
|
||||
> Use the `--privileged` flag with caution.
|
||||
> A container with `--privileged` is not a securely sandboxed process.
|
||||
> Containers in this mode can get a root shell on the host
|
||||
@ -363,7 +361,7 @@ Docker.
|
||||
> for example by adding individual kernel capabilities with `--cap-add`.
|
||||
>
|
||||
> For more information, see
|
||||
> [Runtime privilege and Linux capabilities](https://docs.docker.com/engine/reference/run/#runtime-privilege-and-linux-capabilities)
|
||||
> [Runtime privilege and Linux capabilities](https://docs.docker.com/engine/containers/run/#runtime-privilege-and-linux-capabilities)
|
||||
{ .warning }
|
||||
|
||||
The following example doesn't work, because by default, Docker drops most
|
||||
@ -533,8 +531,7 @@ host. You can also specify `udp` and `sctp` ports. The [Networking overview
|
||||
page](https://docs.docker.com/network/) explains in detail how to publish ports
|
||||
with Docker.
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> If you don't specify an IP address (i.e., `-p 80:80` instead of `-p
|
||||
> 127.0.0.1:80:80`) when publishing a container's ports, Docker publishes the
|
||||
> port on all interfaces (address `0.0.0.0`) by default. These ports are
|
||||
@ -715,8 +712,7 @@ or name. For `overlay` networks or custom plugins that support multi-host
|
||||
connectivity, containers connected to the same multi-host network but launched
|
||||
from different Engines can also communicate in this way.
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> The default bridge network only allows containers to communicate with each other using
|
||||
> internal IP addresses. User-created bridge networks provide DNS resolution between
|
||||
> containers using container names.
|
||||
@ -784,8 +780,7 @@ $ docker network create --subnet 192.0.2.0/24 my-net
|
||||
$ docker run -itd --network=name=my-net,\"driver-opt=com.docker.network.endpoint.sysctls=net.ipv4.conf.IFNAME.log_martians=1,net.ipv4.conf.IFNAME.forwarding=0\",ip=192.0.2.42 busybox
|
||||
```
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> Network drivers may restrict the sysctl settings that can be modified and, to protect
|
||||
> the operation of the network, new restrictions may be added in the future.
|
||||
|
||||
@ -912,8 +907,7 @@ $ docker run --device=/dev/sda:/dev/xvdc:m --rm -it ubuntu fdisk /dev/xvdc
|
||||
fdisk: unable to open /dev/xvdc: Operation not permitted
|
||||
```
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> The `--device` option cannot be safely used with ephemeral devices. You shouldn't
|
||||
> add block devices that may be removed to untrusted containers with `--device`.
|
||||
|
||||
@ -935,15 +929,13 @@ ports on the host visible in the container.
|
||||
PS C:\> docker run --device=class/86E0D1E0-8089-11D0-9CE4-08003E301F73 mcr.microsoft.com/windows/servercore:ltsc2019
|
||||
```
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> The `--device` option is only supported on process-isolated Windows containers,
|
||||
> and produces an error if the container isolation is `hyperv`.
|
||||
|
||||
#### CDI devices
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> The CDI feature is experimental, and potentially subject to change.
|
||||
> CDI is currently only supported for Linux containers.
|
||||
|
||||
@ -1010,8 +1002,7 @@ ID once the container has finished running.
|
||||
$ cat somefile | docker run -i -a stdin mybuilder dobuild
|
||||
```
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> A process running as PID 1 inside a container is treated specially by
|
||||
> Linux: it ignores any signal with the default action. So, the process
|
||||
> doesn't terminate on `SIGINT` or `SIGTERM` unless it's coded to do so.
|
||||
@ -1124,7 +1115,8 @@ $ docker run -d --device-cgroup-rule='c 42:* rmw' --name my-container my-image
|
||||
Then, a user could ask `udev` to execute a script that would `docker exec my-container mknod newDevX c 42 <minor>`
|
||||
the required device when it is added.
|
||||
|
||||
> **Note**: You still need to explicitly add initially present devices to the
|
||||
> [!NOTE]
|
||||
> You still need to explicitly add initially present devices to the
|
||||
> `docker run` / `docker create` command.
|
||||
|
||||
### <a name="gpus"></a> Access an NVIDIA GPU
|
||||
@ -1132,8 +1124,7 @@ the required device when it is added.
|
||||
The `--gpus` flag allows you to access NVIDIA GPU resources. First you need to
|
||||
install the [nvidia-container-runtime](https://nvidia.github.io/nvidia-container-runtime/).
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> You can also specify a GPU as a CDI device with the `--device` flag, see
|
||||
> [CDI devices](#cdi-devices).
|
||||
|
||||
@ -1246,8 +1237,7 @@ the container and remove the file system when the container exits, use the
|
||||
--rm=false: Automatically remove the container when it exits
|
||||
```
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> If you set the `--rm` flag, Docker also removes the anonymous volumes
|
||||
> associated with the container when the container is removed. This is similar
|
||||
> to running `docker rm -v my-container`. Only volumes that are specified
|
||||
@ -1322,7 +1312,7 @@ the `--log-driver=<DRIVER>` with the `docker run` command to configure the
|
||||
container's logging driver.
|
||||
|
||||
To learn about the supported logging drivers and how to use them, refer to
|
||||
[Configure logging drivers](https://docs.docker.com/config/containers/logging/configure/).
|
||||
[Configure logging drivers](https://docs.docker.com/engine/logging/configure/).
|
||||
|
||||
To disable logging for a container, set the `--log-driver` flag to `none`:
|
||||
|
||||
@ -1345,14 +1335,12 @@ $ docker run --ulimit nofile=1024:1024 --rm debian sh -c "ulimit -n"
|
||||
1024
|
||||
```
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> If you don't provide a hard limit value, Docker uses the soft limit value
|
||||
> for both values. If you don't provide any values, they are inherited from
|
||||
> the default `ulimits` set on the daemon.
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> The `as` option is deprecated.
|
||||
> In other words, the following script is not supported:
|
||||
>
|
||||
@ -1417,8 +1405,7 @@ the same content between containers.
|
||||
$ docker run --security-opt label=level:s0:c100,c200 -it fedora bash
|
||||
```
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> Automatic translation of MLS labels isn't supported.
|
||||
|
||||
To disable the security labeling for a container entirely, you can use
|
||||
@ -1436,8 +1423,7 @@ that's only allowed to listen on Apache ports:
|
||||
$ docker run --security-opt label=type:svirt_apache_t -it ubuntu bash
|
||||
```
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> You would have to write policy defining a `svirt_apache_t` type.
|
||||
|
||||
To prevent your container processes from gaining additional privileges, you can
|
||||
@ -1558,8 +1544,7 @@ network namespace, run this command:
|
||||
$ docker run --sysctl net.ipv4.ip_forward=1 someimage
|
||||
```
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> Not all sysctls are namespaced. Docker does not support changing sysctls
|
||||
> inside of a container that also modify the host system. As the kernel
|
||||
> evolves we expect to see more sysctls become namespaced.
|
||||
|
||||
@ -29,8 +29,7 @@ containers do not return any data.
|
||||
If you need more detailed information about a container's resource usage, use
|
||||
the `/containers/(id)/stats` API endpoint.
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> On Linux, the Docker CLI reports memory usage by subtracting cache usage from
|
||||
> the total memory usage. The API does not perform such a calculation but rather
|
||||
> provides the total memory usage and the amount from the cache so that clients
|
||||
@ -41,8 +40,7 @@ the `/containers/(id)/stats` API endpoint.
|
||||
> field. On cgroup v2 hosts, the cache usage is defined as the value of
|
||||
> `inactive_file` field.
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> The `PIDS` column contains the number of processes and kernel threads created
|
||||
> by that container. Threads is the term used by Linux kernel. Other equivalent
|
||||
> terms are "lightweight process" or "kernel task", etc. A large number in the
|
||||
|
||||
@ -42,8 +42,7 @@ options on a running or a stopped container. On kernel version older than
|
||||
4.6, you can only update `--kernel-memory` on a stopped container or on
|
||||
a running container with kernel memory initialized.
|
||||
|
||||
> **Warning**
|
||||
>
|
||||
> [!WARNING]
|
||||
> The `docker update` and `docker container update` commands are not supported
|
||||
> for Windows containers.
|
||||
{ .warning }
|
||||
@ -78,8 +77,7 @@ running container only if the container was started with `--kernel-memory`.
|
||||
If the container was started without `--kernel-memory` you need to stop
|
||||
the container before updating kernel memory.
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> The `--kernel-memory` option has been deprecated since Docker 20.10.
|
||||
|
||||
For example, if you started a container with this command:
|
||||
|
||||
@ -10,8 +10,7 @@ Block until one or more containers stop, then print their exit codes
|
||||
|
||||
<!---MARKER_GEN_END-->
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> `docker wait` returns `0` when run against a container which had already
|
||||
> exited before the `docker wait` command was run.
|
||||
|
||||
|
||||
@ -186,8 +186,7 @@ Sometimes, multiple options can call for a more complex value string as for
|
||||
$ docker run -v /host:/container example/mysql
|
||||
```
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> Do not use the `-t` and `-a stderr` options together due to
|
||||
> limitations in the `pty` implementation. All `stderr` in `pty` mode
|
||||
> simply goes to `stdout`.
|
||||
@ -247,8 +246,7 @@ By default, configuration file is stored in `~/.docker/config.json`. Refer to th
|
||||
[change the `.docker` directory](#change-the-docker-directory) section to use a
|
||||
different location.
|
||||
|
||||
> **Warning**
|
||||
>
|
||||
> [!WARNING]
|
||||
> The configuration file and other files inside the `~/.docker` configuration
|
||||
> directory may contain sensitive information, such as authentication information
|
||||
> for proxies or, depending on your credential store, credentials for your image
|
||||
@ -321,11 +319,10 @@ be set for each environment:
|
||||
|
||||
These settings are used to configure proxy settings for containers only, and not
|
||||
used as proxy settings for the `docker` CLI or the `dockerd` daemon. Refer to the
|
||||
[environment variables](#environment-variables) and [HTTP/HTTPS proxy](https://docs.docker.com/config/daemon/systemd/#httphttps-proxy)
|
||||
sections for configuring proxy settings for the cli and daemon.
|
||||
[environment variables](#environment-variables) and [HTTP/HTTPS proxy](https://docs.docker.com/engine/daemon/proxy/#httphttps-proxy)
|
||||
sections for configuring proxy settings for the CLI and daemon.
|
||||
|
||||
> **Warning**
|
||||
>
|
||||
> [!WARNING]
|
||||
> Proxy settings may contain sensitive information (for example, if the proxy
|
||||
> requires authentication). Environment variables are stored as plain text in
|
||||
> the container's configuration, and as such can be inspected through the remote
|
||||
@ -464,8 +461,7 @@ daemon with IP address `174.17.0.1`, listening on port `2376`:
|
||||
$ docker -H tcp://174.17.0.1:2376 ps
|
||||
```
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> By convention, the Docker daemon uses port `2376` for secure TLS connections,
|
||||
> and port `2375` for insecure, non-TLS connections.
|
||||
|
||||
|
||||
@ -47,8 +47,7 @@ Build an image from a Dockerfile
|
||||
|
||||
## Description
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> This page refers to the **legacy implementation** of `docker build`,
|
||||
> using the legacy (pre-BuildKit) build backend.
|
||||
> This configuration is only relevant if you're building Windows containers.
|
||||
@ -93,7 +92,7 @@ in the context.
|
||||
|
||||
When using the legacy builder, it's therefore extra important that you
|
||||
carefully consider what files you include in the context you specify. Use a
|
||||
[`.dockerignore`](https://docs.docker.com/build/building/context/#dockerignore-files)
|
||||
[`.dockerignore`](https://docs.docker.com/build/concepts/context/#dockerignore-files)
|
||||
file to exclude files and directories that you don't require in your build from
|
||||
being sent as part of the build context.
|
||||
|
||||
@ -146,7 +145,7 @@ the `credentialspec` option. The `credentialspec` must be in the format
|
||||
|
||||
#### Overview
|
||||
|
||||
> **Note**
|
||||
> [!NOTE]
|
||||
> The `--squash` option is an experimental feature, and should not be considered
|
||||
> stable.
|
||||
|
||||
|
||||
@ -17,6 +17,7 @@ List images
|
||||
| [`--format`](#format) | `string` | | Format output using a custom template:<br>'table': Print output in table format with column headers (default)<br>'table TEMPLATE': Print output in table format using the given Go template<br>'json': Print in JSON format<br>'TEMPLATE': Print output using the given Go template.<br>Refer to https://docs.docker.com/go/formatting/ for more information about formatting output with templates |
|
||||
| [`--no-trunc`](#no-trunc) | `bool` | | Don't truncate output |
|
||||
| `-q`, `--quiet` | `bool` | | Only show image IDs |
|
||||
| `--tree` | `bool` | | List multi-platform images as a tree (EXPERIMENTAL) |
|
||||
|
||||
|
||||
<!---MARKER_GEN_END-->
|
||||
|
||||
@ -82,6 +82,7 @@ which removes images with the specified labels. The other
|
||||
format is the `label!=...` (`label!=<key>` or `label!=<key>=<value>`), which removes
|
||||
images without the specified labels.
|
||||
|
||||
> [!NOTE]
|
||||
> **Predicting what will be removed**
|
||||
>
|
||||
> If you are using positive filtering (testing for the existence of a label or
|
||||
@ -186,8 +187,7 @@ This example removes images which have a maintainer label not set to `john`:
|
||||
$ docker image prune --filter="label!=maintainer=john"
|
||||
```
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> You are prompted for confirmation before the `prune` removes
|
||||
> anything, but you are not shown a list of what will potentially be removed.
|
||||
> In addition, `docker image ls` doesn't support negative filtering, so it
|
||||
|
||||
@ -99,7 +99,7 @@ the same image tagged with different names. Because they are the same image,
|
||||
their layers are stored only once and do not consume extra disk space.
|
||||
|
||||
For more information about images, layers, and the content-addressable store,
|
||||
refer to [understand images, containers, and storage drivers](https://docs.docker.com/storage/storagedriver/).
|
||||
refer to [understand images, containers, and storage drivers](https://docs.docker.com/engine/storage/drivers/).
|
||||
|
||||
|
||||
### Pull an image by digest (immutable identifier)
|
||||
@ -107,8 +107,8 @@ refer to [understand images, containers, and storage drivers](https://docs.docke
|
||||
So far, you've pulled images by their name (and "tag"). Using names and tags is
|
||||
a convenient way to work with images. When using tags, you can `docker pull` an
|
||||
image again to make sure you have the most up-to-date version of that image.
|
||||
For example, `docker pull ubuntu:22.04` pulls the latest version of the Ubuntu
|
||||
22.04 image.
|
||||
For example, `docker pull ubuntu:24.04` pulls the latest version of the Ubuntu
|
||||
24.04 image.
|
||||
|
||||
In some cases you don't want images to be updated to newer versions, but prefer
|
||||
to use a fixed version of an image. Docker enables you to pull an image by its
|
||||
@ -117,23 +117,23 @@ of an image to pull. Doing so, allows you to "pin" an image to that version,
|
||||
and guarantee that the image you're using is always the same.
|
||||
|
||||
To know the digest of an image, pull the image first. Let's pull the latest
|
||||
`ubuntu:22.04` image from Docker Hub:
|
||||
`ubuntu:24.04` image from Docker Hub:
|
||||
|
||||
```console
|
||||
$ docker pull ubuntu:22.04
|
||||
$ docker pull ubuntu:24.04
|
||||
|
||||
22.04: Pulling from library/ubuntu
|
||||
24.04: Pulling from library/ubuntu
|
||||
125a6e411906: Pull complete
|
||||
Digest: sha256:26c68657ccce2cb0a31b330cb0be2b5e108d467f641c62e13ab40cbec258c68d
|
||||
Status: Downloaded newer image for ubuntu:22.04
|
||||
docker.io/library/ubuntu:22.04
|
||||
Digest: sha256:2e863c44b718727c860746568e1d54afd13b2fa71b160f5cd9058fc436217b30
|
||||
Status: Downloaded newer image for ubuntu:24.04
|
||||
docker.io/library/ubuntu:24.04
|
||||
```
|
||||
|
||||
Docker prints the digest of the image after the pull has finished. In the example
|
||||
above, the digest of the image is:
|
||||
|
||||
```console
|
||||
sha256:26c68657ccce2cb0a31b330cb0be2b5e108d467f641c62e13ab40cbec258c68d
|
||||
sha256:2e863c44b718727c860746568e1d54afd13b2fa71b160f5cd9058fc436217b30
|
||||
```
|
||||
|
||||
Docker also prints the digest of an image when pushing to a registry. This
|
||||
@ -143,23 +143,22 @@ A digest takes the place of the tag when pulling an image, for example, to
|
||||
pull the above image by digest, run the following command:
|
||||
|
||||
```console
|
||||
$ docker pull ubuntu@sha256:26c68657ccce2cb0a31b330cb0be2b5e108d467f641c62e13ab40cbec258c68d
|
||||
$ docker pull ubuntu@sha256:2e863c44b718727c860746568e1d54afd13b2fa71b160f5cd9058fc436217b30
|
||||
|
||||
docker.io/library/ubuntu@sha256:26c68657ccce2cb0a31b330cb0be2b5e108d467f641c62e13ab40cbec258c68d: Pulling from library/ubuntu
|
||||
Digest: sha256:26c68657ccce2cb0a31b330cb0be2b5e108d467f641c62e13ab40cbec258c68d
|
||||
Status: Image is up to date for ubuntu@sha256:26c68657ccce2cb0a31b330cb0be2b5e108d467f641c62e13ab40cbec258c68d
|
||||
docker.io/library/ubuntu@sha256:26c68657ccce2cb0a31b330cb0be2b5e108d467f641c62e13ab40cbec258c68d
|
||||
docker.io/library/ubuntu@sha256:2e863c44b718727c860746568e1d54afd13b2fa71b160f5cd9058fc436217b30: Pulling from library/ubuntu
|
||||
Digest: sha256:2e863c44b718727c860746568e1d54afd13b2fa71b160f5cd9058fc436217b30
|
||||
Status: Image is up to date for ubuntu@sha256:2e863c44b718727c860746568e1d54afd13b2fa71b160f5cd9058fc436217b30
|
||||
docker.io/library/ubuntu@sha256:2e863c44b718727c860746568e1d54afd13b2fa71b160f5cd9058fc436217b30
|
||||
```
|
||||
|
||||
Digest can also be used in the `FROM` of a Dockerfile, for example:
|
||||
|
||||
```dockerfile
|
||||
FROM ubuntu@sha256:26c68657ccce2cb0a31b330cb0be2b5e108d467f641c62e13ab40cbec258c68d
|
||||
FROM ubuntu@sha256:2e863c44b718727c860746568e1d54afd13b2fa71b160f5cd9058fc436217b30
|
||||
LABEL org.opencontainers.image.authors="some maintainer <maintainer@example.com>"
|
||||
```
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> Using this feature "pins" an image to a specific version in time.
|
||||
> Docker does therefore not pull updated versions of an image, which may include
|
||||
> security updates. If you want to pull an updated image, you need to change the
|
||||
@ -215,13 +214,11 @@ shorthand) to see the images that were pulled. The example below shows all the
|
||||
```console
|
||||
$ docker image ls --filter reference=ubuntu
|
||||
REPOSITORY TAG IMAGE ID CREATED SIZE
|
||||
ubuntu 18.04 c6ad7e71ba7d 5 weeks ago 63.2MB
|
||||
ubuntu bionic c6ad7e71ba7d 5 weeks ago 63.2MB
|
||||
ubuntu 22.04 5ccefbfc0416 2 months ago 78MB
|
||||
ubuntu focal ff0fea8310f3 2 months ago 72.8MB
|
||||
ubuntu latest ff0fea8310f3 2 months ago 72.8MB
|
||||
ubuntu jammy 41ba606c8ab9 3 months ago 79MB
|
||||
ubuntu 20.04 ba6acccedd29 7 months ago 72.8MB
|
||||
ubuntu 22.04 8a3cdc4d1ad3 3 weeks ago 77.9MB
|
||||
ubuntu jammy 8a3cdc4d1ad3 3 weeks ago 77.9MB
|
||||
ubuntu 24.04 35a88802559d 6 weeks ago 78.1MB
|
||||
ubuntu latest 35a88802559d 6 weeks ago 78.1MB
|
||||
ubuntu noble 35a88802559d 6 weeks ago 78.1MB
|
||||
```
|
||||
|
||||
### Cancel a pull
|
||||
|
||||
@ -17,6 +17,7 @@ List images
|
||||
| `--format` | `string` | | Format output using a custom template:<br>'table': Print output in table format with column headers (default)<br>'table TEMPLATE': Print output in table format using the given Go template<br>'json': Print in JSON format<br>'TEMPLATE': Print output using the given Go template.<br>Refer to https://docs.docker.com/go/formatting/ for more information about formatting output with templates |
|
||||
| `--no-trunc` | `bool` | | Don't truncate output |
|
||||
| `-q`, `--quiet` | `bool` | | Only show image IDs |
|
||||
| `--tree` | `bool` | | List multi-platform images as a tree (EXPERIMENTAL) |
|
||||
|
||||
|
||||
<!---MARKER_GEN_END-->
|
||||
|
||||
@ -284,8 +284,7 @@ $ docker manifest create --insecure myprivateregistry.mycompany.com/repo/image:1
|
||||
$ docker manifest push --insecure myprivateregistry.mycompany.com/repo/image:tag
|
||||
```
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> The `--insecure` flag is not required to annotate a manifest list,
|
||||
> since annotations are to a locally-stored copy of a manifest list. You may also
|
||||
> skip the `--insecure` flag if you are performing a `docker manifest inspect`
|
||||
|
||||
@ -80,8 +80,7 @@ sets `net.ipv4.conf.eth3.log_martians=1` and `net.ipv4.conf.eth3.forwarding=0`.
|
||||
$ docker network connect --driver-opt=\"com.docker.network.endpoint.sysctls=net.ipv4.conf.IFNAME.log_martians=1,net.ipv4.conf.IFNAME.forwarding=0\" multi-host-network container2
|
||||
```
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> Network drivers may restrict the sysctl settings that can be modified and, to protect
|
||||
> the operation of the network, new restrictions may be added in the future.
|
||||
|
||||
|
||||
@ -10,8 +10,7 @@ Demote one or more nodes from manager in the swarm
|
||||
|
||||
Demotes an existing manager so that it is no longer a manager.
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> This is a cluster management command, and must be executed on a swarm
|
||||
> manager node. To learn about managers and workers, refer to the [Swarm mode
|
||||
> section](https://docs.docker.com/engine/swarm/) in the documentation.
|
||||
|
||||
@ -21,8 +21,7 @@ given template for each result. Go's
|
||||
[text/template](https://pkg.go.dev/text/template) package describes all the
|
||||
details of the format.
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> This is a cluster management command, and must be executed on a swarm
|
||||
> manager node. To learn about managers and workers, refer to the
|
||||
> [Swarm mode section](https://docs.docker.com/engine/swarm/) in the
|
||||
|
||||
@ -24,8 +24,7 @@ Lists all the nodes that the Docker Swarm manager knows about. You can filter
|
||||
using the `-f` or `--filter` flag. Refer to the [filtering](#filter) section
|
||||
for more information about available filter options.
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> This is a cluster management command, and must be executed on a swarm
|
||||
> manager node. To learn about managers and workers, refer to the
|
||||
> [Swarm mode section](https://docs.docker.com/engine/swarm/) in the
|
||||
@ -42,8 +41,7 @@ ID HOSTNAME STATUS AVAILABILITY MANAGER STATU
|
||||
e216jshn25ckzbvmwlnh5jr3g * swarm-manager1 Ready Active Leader
|
||||
```
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> In the above example output, there is a hidden column of `.Self` that indicates
|
||||
> if the node is the same node as the current docker daemon. A `*` (e.g.,
|
||||
> `e216jshn25ckzbvmwlnh5jr3g *`) means this node is the current docker daemon.
|
||||
|
||||
@ -10,8 +10,7 @@ Promote one or more nodes to manager in the swarm
|
||||
|
||||
Promotes a node to manager. This command can only be executed on a manager node.
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> This is a cluster management command, and must be executed on a swarm
|
||||
> manager node. To learn about managers and workers, refer to the
|
||||
> [Swarm mode section](https://docs.docker.com/engine/swarm/) in the
|
||||
|
||||
@ -22,8 +22,7 @@ Lists all the tasks on a Node that Docker knows about. You can filter using the
|
||||
`-f` or `--filter` flag. Refer to the [filtering](#filter) section for more
|
||||
information about available filter options.
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> This is a cluster management command, and must be executed on a swarm
|
||||
> manager node. To learn about managers and workers, refer to the
|
||||
> [Swarm mode section](https://docs.docker.com/engine/swarm/) in the
|
||||
|
||||
@ -20,8 +20,7 @@ Remove one or more nodes from the swarm
|
||||
|
||||
Removes the specified nodes from a swarm.
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> This is a cluster management command, and must be executed on a swarm
|
||||
> manager node. To learn about managers and workers, refer to the
|
||||
> [Swarm mode section](https://docs.docker.com/engine/swarm/) in the
|
||||
|
||||
@ -19,8 +19,7 @@ Update a node
|
||||
|
||||
Update metadata about a node, such as its availability, labels, or roles.
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> This is a cluster management command, and must be executed on a swarm
|
||||
> manager node. To learn about managers and workers, refer to the
|
||||
> [Swarm mode section](https://docs.docker.com/engine/swarm/) in the
|
||||
|
||||
@ -100,8 +100,7 @@ $ docker plugin inspect -f '{{with $mount := index .Settings.Mounts 0}}{{$mount.
|
||||
/bar
|
||||
```
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> Since only `source` is settable in `mymount`,
|
||||
> `docker plugins set mymount=/bar myplugin` would work too.
|
||||
|
||||
@ -122,8 +121,7 @@ $ docker plugin inspect -f '{{with $device := index .Settings.Devices 0}}{{$devi
|
||||
/dev/bar
|
||||
```
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> Since only `path` is settable in `mydevice`,
|
||||
> `docker plugins set mydevice=/dev/bar myplugin` would work too.
|
||||
|
||||
|
||||
@ -20,8 +20,7 @@ Creates a secret using standard input or from a file for the secret content.
|
||||
|
||||
For detailed information about using secrets, refer to [manage sensitive data with Docker secrets](https://docs.docker.com/engine/swarm/secrets/).
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> This is a cluster management command, and must be executed on a swarm
|
||||
> manager node. To learn about managers and workers, refer to the
|
||||
> [Swarm mode section](https://docs.docker.com/engine/swarm/) in the
|
||||
|
||||
@ -25,8 +25,7 @@ describes all the details of the format.
|
||||
|
||||
For detailed information about using secrets, refer to [manage sensitive data with Docker secrets](https://docs.docker.com/engine/swarm/secrets/).
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> This is a cluster management command, and must be executed on a swarm
|
||||
> manager node. To learn about managers and workers, refer to the
|
||||
> [Swarm mode section](https://docs.docker.com/engine/swarm/) in the
|
||||
|
||||
@ -24,8 +24,7 @@ Run this command on a manager node to list the secrets in the swarm.
|
||||
|
||||
For detailed information about using secrets, refer to [manage sensitive data with Docker secrets](https://docs.docker.com/engine/swarm/secrets/).
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> This is a cluster management command, and must be executed on a swarm
|
||||
> manager node. To learn about managers and workers, refer to the
|
||||
> [Swarm mode section](https://docs.docker.com/engine/swarm/) in the
|
||||
|
||||
@ -16,8 +16,7 @@ Removes the specified secrets from the swarm.
|
||||
|
||||
For detailed information about using secrets, refer to [manage sensitive data with Docker secrets](https://docs.docker.com/engine/swarm/secrets/).
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> This is a cluster management command, and must be executed on a swarm
|
||||
> manager node. To learn about managers and workers, refer to the
|
||||
> [Swarm mode section](https://docs.docker.com/engine/swarm/) in the
|
||||
@ -32,8 +31,7 @@ $ docker secret rm secret.json
|
||||
sapth4csdo5b6wz2p5uimh5xg
|
||||
```
|
||||
|
||||
> **Warning**
|
||||
>
|
||||
> [!WARNING]
|
||||
> Unlike `docker rm`, this command does not ask for confirmation before removing
|
||||
> a secret.
|
||||
{ .warning }
|
||||
|
||||
@ -25,8 +25,7 @@ Manage Swarm services
|
||||
|
||||
Manage Swarm services.
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> This is a cluster management command, and must be executed on a swarm
|
||||
> manager node. To learn about managers and workers, refer to the
|
||||
> [Swarm mode section](https://docs.docker.com/engine/swarm/) in the
|
||||
|
||||
@ -88,8 +88,7 @@ Create a new service
|
||||
|
||||
Creates a service as described by the specified parameters.
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> This is a cluster management command, and must be executed on a swarm
|
||||
> manager node. To learn about managers and workers, refer to the
|
||||
> [Swarm mode section](https://docs.docker.com/engine/swarm/) in the
|
||||
@ -699,7 +698,7 @@ follows:
|
||||
| `node.platform.os` | Node operating system | `node.platform.os==windows` |
|
||||
| `node.platform.arch` | Node architecture | `node.platform.arch==x86_64` |
|
||||
| `node.labels` | User-defined node labels | `node.labels.security==high` |
|
||||
| `engine.labels` | Docker Engine's labels | `engine.labels.operatingsystem==ubuntu-22.04` |
|
||||
| `engine.labels` | Docker Engine's labels | `engine.labels.operatingsystem==ubuntu-24.04` |
|
||||
|
||||
`engine.labels` apply to Docker Engine labels like operating system, drivers,
|
||||
etc. Swarm administrators add `node.labels` for operational purposes by using
|
||||
@ -941,7 +940,7 @@ $ docker service create \
|
||||
The swarm extends my-network to each node running the service.
|
||||
|
||||
Containers on the same network can access each other using
|
||||
[service discovery](https://docs.docker.com/network/drivers/overlay/#container-discovery).
|
||||
[service discovery](https://docs.docker.com/engine/network/drivers/overlay/#container-discovery).
|
||||
|
||||
Long form syntax of `--network` allows to specify list of aliases and driver options:
|
||||
`--network name=my-network,alias=web1,driver-opt=field1=value1`
|
||||
|
||||
@ -23,8 +23,7 @@ the given template will be executed for each result.
|
||||
Go's [text/template](https://pkg.go.dev/text/template) package
|
||||
describes all the details of the format.
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> This is a cluster management command, and must be executed on a swarm
|
||||
> manager node. To learn about managers and workers, refer to the
|
||||
> [Swarm mode section](https://docs.docker.com/engine/swarm/) in the
|
||||
|
||||
@ -24,8 +24,7 @@ Fetch the logs of a service or task
|
||||
|
||||
The `docker service logs` command batch-retrieves logs present at the time of execution.
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> This is a cluster management command, and must be executed on a swarm
|
||||
> manager node. To learn about managers and workers, refer to the
|
||||
> [Swarm mode section](https://docs.docker.com/engine/swarm/) in the
|
||||
@ -36,13 +35,12 @@ service, or with the ID of a task. If a service is passed, it will display logs
|
||||
for all of the containers in that service. If a task is passed, it will only
|
||||
display logs from that particular task.
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> This command is only functional for services that are started with
|
||||
> the `json-file` or `journald` logging driver.
|
||||
|
||||
For more information about selecting and configuring logging drivers, refer to
|
||||
[Configure logging drivers](https://docs.docker.com/config/containers/logging/configure/).
|
||||
[Configure logging drivers](https://docs.docker.com/engine/logging/configure/).
|
||||
|
||||
The `docker service logs --follow` command will continue streaming the new output from
|
||||
the service's `STDOUT` and `STDERR`.
|
||||
|
||||
@ -22,8 +22,7 @@ List services
|
||||
|
||||
This command lists services that are running in the swarm.
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> This is a cluster management command, and must be executed on a swarm
|
||||
> manager node. To learn about managers and workers, refer to the
|
||||
> [Swarm mode section](https://docs.docker.com/engine/swarm/) in the
|
||||
|
||||
@ -20,8 +20,7 @@ List the tasks of one or more services
|
||||
|
||||
Lists the tasks that are running as part of the specified services.
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> This is a cluster management command, and must be executed on a swarm
|
||||
> manager node. To learn about managers and workers, refer to the
|
||||
> [Swarm mode section](https://docs.docker.com/engine/swarm/) in the
|
||||
|
||||
@ -14,8 +14,7 @@ Remove one or more services
|
||||
|
||||
Removes the specified services from the swarm.
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> This is a cluster management command, and must be executed on a swarm
|
||||
> manager node. To learn about managers and workers, refer to the
|
||||
> [Swarm mode section](https://docs.docker.com/engine/swarm/) in the
|
||||
@ -35,8 +34,7 @@ $ docker service ls
|
||||
ID NAME MODE REPLICAS IMAGE
|
||||
```
|
||||
|
||||
> **Warning**
|
||||
>
|
||||
> [!WARNING]
|
||||
> Unlike `docker rm`, this command does not ask for confirmation before removing
|
||||
> a running service.
|
||||
|
||||
|
||||
@ -17,8 +17,7 @@ Revert changes to a service's configuration
|
||||
|
||||
Roll back a specified service to its previous version from the swarm.
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> This is a cluster management command, and must be executed on a swarm
|
||||
> manager node. To learn about managers and workers, refer to the
|
||||
> [Swarm mode section](https://docs.docker.com/engine/swarm/) in the
|
||||
|
||||
@ -20,8 +20,7 @@ services which are global mode. The command will return immediately, but the
|
||||
actual scaling of the service may take some time. To stop all replicas of a
|
||||
service while keeping the service active in the swarm you can set the scale to 0.
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> This is a cluster management command, and must be executed on a swarm
|
||||
> manager node. To learn about managers and workers, refer to the
|
||||
> [Swarm mode section](https://docs.docker.com/engine/swarm/) in the
|
||||
|
||||
@ -115,8 +115,7 @@ service requires recreating the tasks for it to take effect. For example, only c
|
||||
setting. However, the `--force` flag will cause the tasks to be recreated anyway. This can be used to perform a
|
||||
rolling restart without any changes to the service parameters.
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> This is a cluster management command, and must be executed on a swarm
|
||||
> manager node. To learn about managers and workers, refer to the
|
||||
> [Swarm mode section](https://docs.docker.com/engine/swarm/) in the
|
||||
|
||||
@ -25,8 +25,7 @@ Deploy a new stack or update an existing stack
|
||||
|
||||
Create and update a stack from a `compose` file on the swarm.
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> This is a cluster management command, and must be executed on a swarm
|
||||
> manager node. To learn about managers and workers, refer to the
|
||||
> [Swarm mode section](https://docs.docker.com/engine/swarm/) in the
|
||||
|
||||
@ -20,8 +20,7 @@ List stacks
|
||||
|
||||
Lists the stacks.
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> This is a cluster management command, and must be executed on a swarm
|
||||
> manager node. To learn about managers and workers, refer to the
|
||||
> [Swarm mode section](https://docs.docker.com/engine/swarm/) in the
|
||||
|
||||
@ -20,8 +20,7 @@ List the tasks in the stack
|
||||
|
||||
Lists the tasks that are running as part of the specified stack.
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> This is a cluster management command, and must be executed on a swarm
|
||||
> manager node. To learn about managers and workers, refer to the
|
||||
> [Swarm mode section](https://docs.docker.com/engine/swarm/) in the
|
||||
|
||||
@ -20,8 +20,7 @@ Remove one or more stacks
|
||||
|
||||
Remove the stack from the swarm.
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> This is a cluster management command, and must be executed on a swarm
|
||||
> manager node. To learn about managers and workers, refer to the
|
||||
> [Swarm mode section](https://docs.docker.com/engine/swarm/) in the
|
||||
|
||||
@ -18,8 +18,7 @@ List the services in the stack
|
||||
|
||||
Lists the services that are running as part of the specified stack.
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> This is a cluster management command, and must be executed on a swarm
|
||||
> manager node. To learn about managers and workers, refer to the
|
||||
> [Swarm mode section](https://docs.docker.com/engine/swarm/) in the
|
||||
|
||||
@ -22,8 +22,7 @@ Display and rotate the root CA
|
||||
|
||||
View or rotate the current swarm CA certificate.
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> This is a cluster management command, and must be executed on a swarm
|
||||
> manager node. To learn about managers and workers, refer to the
|
||||
> [Swarm mode section](https://docs.docker.com/engine/swarm/) in the
|
||||
@ -81,8 +80,7 @@ gyg5u9Iliel99l7SuMhNeLkrU7fXs+Of1nTyyM73ig==
|
||||
|
||||
### <a name="rotate"></a> Root CA rotation (--rotate)
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> Mirantis Kubernetes Engine (MKE), formerly known as Docker UCP, provides an external
|
||||
> certificate manager service for the swarm. If you run swarm on MKE, you shouldn't
|
||||
> rotate the CA certificates manually. Instead, contact Mirantis support if you need
|
||||
|
||||
@ -21,8 +21,7 @@ role. You pass the token using the `--token` flag when you run
|
||||
[swarm join](swarm_join.md). Nodes use the join token only when they join the
|
||||
swarm.
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> This is a cluster management command, and must be executed on a swarm
|
||||
> manager node. To learn about managers and workers, refer to the
|
||||
> [Swarm mode section](https://docs.docker.com/engine/swarm/) in the
|
||||
|
||||
@ -22,8 +22,7 @@ swarm.
|
||||
You can view or rotate the unlock key using `swarm unlock-key`. To view the key,
|
||||
run the `docker swarm unlock-key` command without any arguments:
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> This is a cluster management command, and must be executed on a swarm
|
||||
> manager node. To learn about managers and workers, refer to the
|
||||
> [Swarm mode section](https://docs.docker.com/engine/swarm/) in the
|
||||
|
||||
@ -13,8 +13,7 @@ used to reactivate a manager after its Docker daemon restarts if the autolock
|
||||
setting is turned on. The unlock key is printed at the time when autolock is
|
||||
enabled, and is also available from the `docker swarm unlock-key` command.
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> This is a cluster management command, and must be executed on a swarm
|
||||
> manager node. To learn about managers and workers, refer to the
|
||||
> [Swarm mode section](https://docs.docker.com/engine/swarm/) in the
|
||||
|
||||
@ -22,8 +22,7 @@ Update the swarm
|
||||
|
||||
Updates a swarm with new parameter values.
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> [!NOTE]
|
||||
> This is a cluster management command, and must be executed on a swarm
|
||||
> manager node. To learn about managers and workers, refer to the
|
||||
> [Swarm mode section](https://docs.docker.com/engine/swarm/) in the
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user