mirror of
https://github.com/docker/cli.git
synced 2026-08-24 18:14:17 -05:00
Compare commits
@@ -63,7 +63,7 @@ jobs:
|
||||
name: Update Go
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6
|
||||
with:
|
||||
go-version: "1.25.8"
|
||||
go-version: "1.26.1"
|
||||
cache: false
|
||||
-
|
||||
name: Initialize CodeQL
|
||||
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
TESTFLAGS: -coverprofile=/tmp/coverage/coverage.txt
|
||||
-
|
||||
name: Send to Codecov
|
||||
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v5
|
||||
with:
|
||||
files: ./build/coverage/coverage.txt
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
targets: test-coverage
|
||||
-
|
||||
name: Send to Codecov
|
||||
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v5
|
||||
with:
|
||||
files: ./build/coverage/coverage.txt
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
name: Set up Go
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6
|
||||
with:
|
||||
go-version: "1.25.8"
|
||||
go-version: "1.26.1"
|
||||
cache: false
|
||||
-
|
||||
name: Test
|
||||
@@ -81,7 +81,7 @@ jobs:
|
||||
shell: bash
|
||||
-
|
||||
name: Send to Codecov
|
||||
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v5
|
||||
with:
|
||||
files: /tmp/coverage.txt
|
||||
working-directory: ${{ env.GOPATH }}/src/github.com/docker/cli
|
||||
|
||||
@@ -80,3 +80,27 @@ jobs:
|
||||
shell: 'script --return --quiet --command "bash {0}"'
|
||||
run: |
|
||||
make -f docker.Makefile ${{ matrix.target }}
|
||||
|
||||
validate-gocompat:
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
GOPATH: ${{ github.workspace }}
|
||||
GO111MODULE: off
|
||||
steps:
|
||||
-
|
||||
name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
path: src/github.com/docker/cli
|
||||
-
|
||||
name: Set up Go
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6
|
||||
with:
|
||||
go-version: "1.26.1"
|
||||
cache: false
|
||||
-
|
||||
name: Run gocompat check
|
||||
shell: 'script --return --quiet --command "bash {0}"'
|
||||
working-directory: ${{ github.workspace }}/src/github.com/docker/cli
|
||||
run: |
|
||||
make -C ./internal/gocompat verify
|
||||
|
||||
+4
-12
@@ -5,7 +5,7 @@ run:
|
||||
# which causes it to fallback to go1.17 semantics.
|
||||
#
|
||||
# TODO(thaJeztah): update "usetesting" settings to enable go1.24 features once our minimum version is go1.24
|
||||
go: "1.25.8"
|
||||
go: "1.26.1"
|
||||
|
||||
timeout: 5m
|
||||
|
||||
@@ -158,11 +158,6 @@ linters:
|
||||
- name: use-any # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#use-any
|
||||
- name: use-errors-new # https://github.com/mgechev/revive/blob/HEAD/RULES_DESCRIPTIONS.md#use-errors-new
|
||||
|
||||
usetesting:
|
||||
os-chdir: false # FIXME(thaJeztah): Disable `os.Chdir()` detections; should be automatically disabled on Go < 1.24; see https://github.com/docker/cli/pull/5835#issuecomment-2665302478
|
||||
context-background: false # FIXME(thaJeztah): Disable `context.Background()` detections; should be automatically disabled on Go < 1.24; see https://github.com/docker/cli/pull/5835#issuecomment-2665302478
|
||||
context-todo: false # FIXME(thaJeztah): Disable `context.TODO()` detections; should be automatically disabled on Go < 1.24; see https://github.com/docker/cli/pull/5835#issuecomment-2665302478
|
||||
|
||||
exclusions:
|
||||
# We prefer to use an "linters.exclusions.rules" so that new "default" exclusions are not
|
||||
# automatically inherited. We can decide whether or not to follow upstream
|
||||
@@ -225,13 +220,10 @@ linters:
|
||||
linters:
|
||||
- staticcheck
|
||||
|
||||
# Ignore deprecation linting for cli/command/stack/*.
|
||||
#
|
||||
# FIXME(thaJeztah): remove exception once these functions are un-exported or internal; see https://github.com/docker/cli/pull/6389
|
||||
- text: '^(SA1019): '
|
||||
path: "cli/command/stack"
|
||||
# TODO(thaJeztah): remove once https://github.com/leighmcculloch/gocheckcompilerdirectives/issues/7 is fixed.
|
||||
- text: "compiler directive unrecognized: //go:fix"
|
||||
linters:
|
||||
- staticcheck
|
||||
- gocheckcompilerdirectives
|
||||
|
||||
# Log a warning if an exclusion rule is unused.
|
||||
# Default: false
|
||||
|
||||
@@ -354,6 +354,7 @@ Lorenzo Fontana <lo@linux.com> <fontanalorenzo@me.com>
|
||||
Louis Opter <kalessin@kalessin.fr>
|
||||
Louis Opter <kalessin@kalessin.fr> <louis@dotcloud.com>
|
||||
Lovekesh Kumar <lovekesh.kumar@rtcamp.com>
|
||||
Luo Jiyin <luojiyin@hotmail.com>
|
||||
Luca Favatella <luca.favatella@erlang-solutions.com> <lucafavatella@users.noreply.github.com>
|
||||
Luke Marsden <me@lukemarsden.net> <luke@digital-crocus.com>
|
||||
Lyn <energylyn@zju.edu.cn>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
# This file lists all contributors to the repository.
|
||||
# See scripts/docs/generate-authors.sh to make modifications.
|
||||
|
||||
4RH1T3CT0R7 <iprintercanon@gmail.com>
|
||||
A. Lester Buck III <github-reg@nbolt.com>
|
||||
Aanand Prasad <aanand.prasad@gmail.com>
|
||||
Aaron L. Xu <liker.xu@foxmail.com>
|
||||
@@ -42,6 +43,7 @@ Alexander Larsson <alexl@redhat.com>
|
||||
Alexander Morozov <lk4d4math@gmail.com>
|
||||
Alexander Ryabov <i@sepa.spb.ru>
|
||||
Alexandre González <agonzalezro@gmail.com>
|
||||
Alexandre Vallières-Lagacé <alexandre.valliereslagace@docker.com>
|
||||
Alexey Igrychev <alexey.igrychev@flant.com>
|
||||
Alexis Couvreur <alexiscouvreur.pro@gmail.com>
|
||||
Alfred Landrum <alfred.landrum@docker.com>
|
||||
@@ -64,6 +66,7 @@ Andres G. Aragoneses <knocte@gmail.com>
|
||||
Andres Leon Rangel <aleon1220@gmail.com>
|
||||
Andrew France <andrew@avito.co.uk>
|
||||
Andrew He <he.andrew.mail@gmail.com>
|
||||
Andrew Hopp <andrew.hopp@me.com>
|
||||
Andrew Hsu <andrewhsu@docker.com>
|
||||
Andrew Macpherson <hopscotch23@gmail.com>
|
||||
Andrew McDonnell <bugs@andrewmcdonnell.net>
|
||||
@@ -127,6 +130,7 @@ Brian Goff <cpuguy83@gmail.com>
|
||||
Brian Tracy <brian.tracy33@gmail.com>
|
||||
Brian Wieder <brian@4wieders.com>
|
||||
Bruno Sousa <bruno.sousa@docker.com>
|
||||
Bruno Verachten <gounthar@gmail.com>
|
||||
Bryan Bess <squarejaw@bsbess.com>
|
||||
Bryan Boreham <bjboreham@gmail.com>
|
||||
Bryan Murphy <bmurphy1976@gmail.com>
|
||||
@@ -178,6 +182,7 @@ Christopher Svensson <stoffus@stoffus.com>
|
||||
Christy Norman <christy@linux.vnet.ibm.com>
|
||||
Chun Chen <ramichen@tencent.com>
|
||||
Clinton Kitson <clintonskitson@gmail.com>
|
||||
Codex <codex@openai.com>
|
||||
Coenraad Loubser <coenraad@wish.org.za>
|
||||
Colin Hebert <hebert.colin@gmail.com>
|
||||
Collin Guarino <collin.guarino@gmail.com>
|
||||
@@ -234,6 +239,7 @@ David Sheets <dsheets@docker.com>
|
||||
David Williamson <david.williamson@docker.com>
|
||||
David Xia <dxia@spotify.com>
|
||||
David Young <yangboh@cn.ibm.com>
|
||||
Davlat Davydov <literally_user@hotmail.com>
|
||||
Deng Guangxing <dengguangxing@huawei.com>
|
||||
Denis Defreyne <denis@soundcloud.com>
|
||||
Denis Gladkikh <denis@gladkikh.email>
|
||||
@@ -241,6 +247,7 @@ Denis Ollier <larchunix@users.noreply.github.com>
|
||||
Dennis Docter <dennis@d23.nl>
|
||||
dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
|
||||
Derek McGowan <derek@mcg.dev>
|
||||
Derek Misler <derek.misler@docker.com>
|
||||
Des Preston <despreston@gmail.com>
|
||||
Deshi Xiao <dxiao@redhat.com>
|
||||
Dharmit Shah <shahdharmit@gmail.com>
|
||||
@@ -260,6 +267,7 @@ Dominik Braun <dominik.braun@nbsp.de>
|
||||
Don Kjer <don.kjer@gmail.com>
|
||||
Dong Chen <dongluo.chen@docker.com>
|
||||
DongGeon Lee <secmatth1996@gmail.com>
|
||||
Dorin Geman <dorin.geman@docker.com>
|
||||
Doug Davis <dug@us.ibm.com>
|
||||
Drew Erny <derny@mirantis.com>
|
||||
Ed Costello <epc@epcostello.com>
|
||||
@@ -358,7 +366,7 @@ Hugo Gabriel Eyherabide <hugogabriel.eyherabide@gmail.com>
|
||||
huqun <huqun@zju.edu.cn>
|
||||
Huu Nguyen <huu@prismskylabs.com>
|
||||
Hyzhou Zhy <hyzhou.zhy@alibaba-inc.com>
|
||||
Iain MacDonald <IJMacD@gmail.com>
|
||||
Iain MacDonald <ijmacd@gmail.com>
|
||||
Iain Samuel McLean Elder <iain@isme.es>
|
||||
Ian Campbell <ian.campbell@docker.com>
|
||||
Ian Philpot <ian.philpot@microsoft.com>
|
||||
@@ -471,6 +479,7 @@ Justyn Temme <justyntemme@gmail.com>
|
||||
Jyrki Puttonen <jyrkiput@gmail.com>
|
||||
Jérémie Drouet <jeremie.drouet@gmail.com>
|
||||
Jérôme Petazzoni <jerome.petazzoni@docker.com>
|
||||
Jörg Sommer <joerg@jo-so.de>
|
||||
Jörg Thalheim <joerg@higgsboson.tk>
|
||||
Kai Blin <kai@samba.org>
|
||||
Kai Qiang Wu (Kennan) <wkq5325@gmail.com>
|
||||
@@ -539,10 +548,12 @@ Lovekesh Kumar <lovekesh.kumar@rtcamp.com>
|
||||
Luca Favatella <luca.favatella@erlang-solutions.com>
|
||||
Luca Marturana <lucamarturana@gmail.com>
|
||||
Lucas Chan <lucas-github@lucaschan.com>
|
||||
Ludovic Temgoua Abanda <abandaludovic500@gmail.com>
|
||||
Luis Henrique Mulinari <luis.mulinari@gmail.com>
|
||||
Luka Hartwig <mail@lukahartwig.de>
|
||||
Lukas Heeren <lukas-heeren@hotmail.com>
|
||||
Lukasz Zajaczkowski <Lukasz.Zajaczkowski@ts.fujitsu.com>
|
||||
Luo Jiyin <luojiyin@hotmail.com>
|
||||
Lydell Manganti <LydellManganti@users.noreply.github.com>
|
||||
Lénaïc Huard <lhuard@amadeus.com>
|
||||
Ma Shimiao <mashimiao.fnst@cn.fujitsu.com>
|
||||
@@ -603,6 +614,7 @@ Michael Spetsiotis <michael_spets@hotmail.com>
|
||||
Michael Steinert <mike.steinert@gmail.com>
|
||||
Michael Tews <michael@tews.dev>
|
||||
Michael West <mwest@mdsol.com>
|
||||
Michael Zampani <michael.zampani@docker.com>
|
||||
Michal Minář <miminar@redhat.com>
|
||||
Michał Czeraszkiewicz <czerasz@gmail.com>
|
||||
Miguel Angel Alvarez Cabrerizo <doncicuto@gmail.com>
|
||||
@@ -617,6 +629,7 @@ Mike Goelzer <mike.goelzer@docker.com>
|
||||
Mike MacCana <mike.maccana@gmail.com>
|
||||
mikelinjie <294893458@qq.com>
|
||||
Mikhail Vasin <vasin@cloud-tv.ru>
|
||||
Milas Bowman <milas.bowman@docker.com>
|
||||
Milind Chawre <milindchawre@gmail.com>
|
||||
Mindaugas Rukas <momomg@gmail.com>
|
||||
Miroslav Gula <miroslav.gula@naytrolabs.com>
|
||||
@@ -887,6 +900,7 @@ Vincent Batts <vbatts@redhat.com>
|
||||
Vincent Bernat <Vincent.Bernat@exoscale.ch>
|
||||
Vincent Demeester <vincent.demeester@docker.com>
|
||||
Vincent Woo <me@vincentwoo.com>
|
||||
Vineet Kumar <vineetkumar17112004@gmail.com>
|
||||
Vishnu Kannan <vishnuk@google.com>
|
||||
Vivek Goyal <vgoyal@redhat.com>
|
||||
Wang Jie <wangjie5@chinaskycloud.com>
|
||||
@@ -916,6 +930,7 @@ Yanqiang Miao <miao.yanqiang@zte.com.cn>
|
||||
Yassine Tijani <yasstij11@gmail.com>
|
||||
Yi EungJun <eungjun.yi@navercorp.com>
|
||||
Ying Li <ying.li@docker.com>
|
||||
Yoan Wainmann <thebook90yw@gmail.com>
|
||||
Yong Tang <yong.tang.github@outlook.com>
|
||||
Yosef Fertel <yfertel@gmail.com>
|
||||
Yu Peng <yu.peng36@zte.com.cn>
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ ARG BASE_VARIANT=alpine
|
||||
ARG ALPINE_VERSION=3.23
|
||||
ARG BASE_DEBIAN_DISTRO=bookworm
|
||||
|
||||
ARG GO_VERSION=1.25.8
|
||||
ARG GO_VERSION=1.26.1
|
||||
|
||||
# XX_VERSION specifies the version of the xx utility to use.
|
||||
# It must be a valid tag in the docker.io/tonistiigi/xx image repository.
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.25
|
||||
|
||||
// Package hooks defines the contract between the Docker CLI and CLI plugin hook
|
||||
// implementations.
|
||||
//
|
||||
// # Audience
|
||||
//
|
||||
// This package is intended to be imported by CLI plugin implementations that
|
||||
// implement a "hooks" subcommand, and by the Docker CLI when invoking those
|
||||
// hooks.
|
||||
//
|
||||
// # Contract and wire format
|
||||
//
|
||||
// Hook inputs (see [Request]) are serialized as JSON and passed to the plugin hook
|
||||
// subcommand (currently as a command-line argument). Hook outputs are emitted by
|
||||
// the plugin as JSON (see [Response]).
|
||||
//
|
||||
// # Stability
|
||||
//
|
||||
// The types that represent the hook contract ([Request], [Response] and related
|
||||
// constants) are considered part of Docker CLI's public Go API.
|
||||
// Fields and values may be extended in a backwards-compatible way (for example,
|
||||
// adding new fields), but existing fields and their meaning should remain stable.
|
||||
// Plugins should ignore unknown fields and unknown hook types to remain
|
||||
// forwards-compatible.
|
||||
package hooks
|
||||
|
||||
// ResponseType is the type of response from the plugin.
|
||||
type ResponseType int
|
||||
|
||||
const (
|
||||
NextSteps ResponseType = 0
|
||||
)
|
||||
|
||||
// Request is the type representing the information
|
||||
// that plugins declaring support for hooks get passed when
|
||||
// being invoked following a CLI command execution.
|
||||
type Request struct {
|
||||
// RootCmd is a string representing the matching hook configuration
|
||||
// which is currently being invoked. If a hook for "docker context"
|
||||
// is configured and the user executes "docker context ls", the plugin
|
||||
// is invoked with "context".
|
||||
RootCmd string `json:"RootCmd,omitzero"`
|
||||
|
||||
// Flags contains flags that were set on the command for which the
|
||||
// hook was invoked. It uses flag names as key, with leading hyphens
|
||||
// removed ("--flag" and "-flag" are included as "flag" and "f").
|
||||
//
|
||||
// Flag values are not included and are set to an empty string,
|
||||
// except for boolean flags known to the CLI itself, for which
|
||||
// the value is either "true", or "false".
|
||||
//
|
||||
// Plugins can use this information to adjust their [Response]
|
||||
// based on whether the command triggering the hook was invoked
|
||||
// with.
|
||||
Flags map[string]string `json:"Flags,omitzero"`
|
||||
|
||||
// CommandError is a string containing the error output (if any)
|
||||
// of the command for which the hook was invoked.
|
||||
CommandError string `json:"CommandError,omitzero"`
|
||||
}
|
||||
|
||||
// Response represents a plugin hook response. Plugins
|
||||
// declaring support for CLI hooks need to print a JSON
|
||||
// representation of this type when their hook subcommand
|
||||
// is invoked.
|
||||
type Response struct {
|
||||
Type ResponseType `json:"Type"`
|
||||
Template string `json:"Template,omitzero"`
|
||||
}
|
||||
|
||||
// HookType is the type of response from the plugin.
|
||||
//
|
||||
// Deprecated: use [ResponseType] instead.
|
||||
//
|
||||
//go:fix inline
|
||||
type HookType = ResponseType
|
||||
|
||||
// HookMessage represents a plugin hook response.
|
||||
//
|
||||
// Deprecated: use [Response] instead.
|
||||
//
|
||||
//go:fix inline
|
||||
type HookMessage = Response
|
||||
@@ -0,0 +1,75 @@
|
||||
package hooks
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
const (
|
||||
hookTemplateCommandName = `{{command}}`
|
||||
hookTemplateFlagValue = `{{flagValue %q}}`
|
||||
hookTemplateArg = `{{argValue %d}}`
|
||||
)
|
||||
|
||||
// TemplateReplaceSubcommandName returns a hook template string
|
||||
// that will be replaced by the CLI subcommand being executed
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// Response{
|
||||
// Type: NextSteps,
|
||||
// Template: "you ran the subcommand: " + TemplateReplaceSubcommandName(),
|
||||
// }
|
||||
//
|
||||
// When being executed after the command:
|
||||
//
|
||||
// docker run --name "my-container" alpine
|
||||
//
|
||||
// It results in the message:
|
||||
//
|
||||
// you ran the subcommand: run
|
||||
func TemplateReplaceSubcommandName() string {
|
||||
return hookTemplateCommandName
|
||||
}
|
||||
|
||||
// TemplateReplaceFlagValue returns a hook template string that will be
|
||||
// replaced with the flags value when printed by the CLI.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// Response{
|
||||
// Type: NextSteps,
|
||||
// Template: "you ran a container named: " + TemplateReplaceFlagValue("name"),
|
||||
// }
|
||||
//
|
||||
// when executed after the command:
|
||||
//
|
||||
// docker run --name "my-container" alpine
|
||||
//
|
||||
// it results in the message:
|
||||
//
|
||||
// you ran a container named: my-container
|
||||
func TemplateReplaceFlagValue(flag string) string {
|
||||
return fmt.Sprintf(hookTemplateFlagValue, flag)
|
||||
}
|
||||
|
||||
// TemplateReplaceArg takes an index i and returns a hook
|
||||
// template string that the CLI will replace the template with
|
||||
// the ith argument after processing the passed flags.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// Response{
|
||||
// Type: NextSteps,
|
||||
// Template: "run this image with `docker run " + TemplateReplaceArg(0) + "`",
|
||||
// }
|
||||
//
|
||||
// when being executed after the command:
|
||||
//
|
||||
// docker pull alpine
|
||||
//
|
||||
// It results in the message:
|
||||
//
|
||||
// Run this image with `docker run alpine`
|
||||
func TemplateReplaceArg(i int) string {
|
||||
return fmt.Sprintf(hookTemplateArg, i)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package hooks_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/docker/cli/cli-plugins/hooks"
|
||||
)
|
||||
|
||||
func TestTemplateHelpers(t *testing.T) {
|
||||
tests := []struct {
|
||||
doc string
|
||||
got func() string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
doc: "subcommand name",
|
||||
got: hooks.TemplateReplaceSubcommandName,
|
||||
want: `{{command}}`,
|
||||
},
|
||||
{
|
||||
doc: "flag value",
|
||||
got: func() string {
|
||||
return hooks.TemplateReplaceFlagValue("name")
|
||||
},
|
||||
want: `{{flagValue "name"}}`,
|
||||
},
|
||||
{
|
||||
doc: "arg",
|
||||
got: func() string {
|
||||
return hooks.TemplateReplaceArg(0)
|
||||
},
|
||||
want: `{{argValue 0}}`,
|
||||
},
|
||||
{
|
||||
doc: "arg",
|
||||
got: func() string {
|
||||
return hooks.TemplateReplaceArg(3)
|
||||
},
|
||||
want: `{{argValue 3}}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.doc, func(t *testing.T) {
|
||||
if got := tc.got(); got != tc.want {
|
||||
t.Fatalf("expected %q, got %q", tc.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,23 @@
|
||||
package hooks
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
import "io"
|
||||
|
||||
"github.com/morikuni/aec"
|
||||
const (
|
||||
whatsNext = "\n\033[1mWhat's next:\033[0m\n"
|
||||
indent = " "
|
||||
)
|
||||
|
||||
// PrintNextSteps renders list of [NextSteps] messages and writes them
|
||||
// to out. It is a no-op if messages is empty.
|
||||
func PrintNextSteps(out io.Writer, messages []string) {
|
||||
if len(messages) == 0 {
|
||||
return
|
||||
}
|
||||
_, _ = fmt.Fprintln(out, aec.Bold.Apply("\nWhat's next:"))
|
||||
for _, n := range messages {
|
||||
_, _ = fmt.Fprintln(out, " ", n)
|
||||
|
||||
_, _ = io.WriteString(out, whatsNext)
|
||||
for _, msg := range messages {
|
||||
_, _ = io.WriteString(out, indent)
|
||||
_, _ = io.WriteString(out, msg)
|
||||
_, _ = io.WriteString(out, "\n")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +1,45 @@
|
||||
package hooks
|
||||
package hooks_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/morikuni/aec"
|
||||
"github.com/docker/cli/cli-plugins/hooks"
|
||||
"gotest.tools/v3/assert"
|
||||
)
|
||||
|
||||
func TestPrintHookMessages(t *testing.T) {
|
||||
testCases := []struct {
|
||||
const header = "\n\x1b[1mWhat's next:\x1b[0m\n"
|
||||
|
||||
tests := []struct {
|
||||
doc string
|
||||
messages []string
|
||||
expectedOutput string
|
||||
}{
|
||||
{
|
||||
messages: []string{},
|
||||
doc: "no messages",
|
||||
messages: nil,
|
||||
expectedOutput: "",
|
||||
},
|
||||
{
|
||||
doc: "single message",
|
||||
messages: []string{"Bork!"},
|
||||
expectedOutput: aec.Bold.Apply("\nWhat's next:") + "\n" +
|
||||
expectedOutput: header +
|
||||
" Bork!\n",
|
||||
},
|
||||
{
|
||||
doc: "multiple messages",
|
||||
messages: []string{"Foo", "bar"},
|
||||
expectedOutput: aec.Bold.Apply("\nWhat's next:") + "\n" +
|
||||
expectedOutput: header +
|
||||
" Foo\n" +
|
||||
" bar\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
w := bytes.Buffer{}
|
||||
PrintNextSteps(&w, tc.messages)
|
||||
assert.Equal(t, w.String(), tc.expectedOutput)
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.doc, func(t *testing.T) {
|
||||
var w strings.Builder
|
||||
hooks.PrintNextSteps(&w, tc.messages)
|
||||
assert.Equal(t, w.String(), tc.expectedOutput)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,116 +1,90 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.25
|
||||
|
||||
package hooks
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type HookType int
|
||||
|
||||
const (
|
||||
NextSteps = iota
|
||||
)
|
||||
|
||||
// HookMessage represents a plugin hook response. Plugins
|
||||
// declaring support for CLI hooks need to print a json
|
||||
// representation of this type when their hook subcommand
|
||||
// is invoked.
|
||||
type HookMessage struct {
|
||||
Type HookType
|
||||
Template string
|
||||
}
|
||||
|
||||
// TemplateReplaceSubcommandName returns a hook template string
|
||||
// that will be replaced by the CLI subcommand being executed
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// "you ran the subcommand: " + TemplateReplaceSubcommandName()
|
||||
//
|
||||
// when being executed after the command:
|
||||
// `docker run --name "my-container" alpine`
|
||||
// will result in the message:
|
||||
// `you ran the subcommand: run`
|
||||
func TemplateReplaceSubcommandName() string {
|
||||
return hookTemplateCommandName
|
||||
}
|
||||
|
||||
// TemplateReplaceFlagValue returns a hook template string
|
||||
// that will be replaced by the flags value.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// "you ran a container named: " + TemplateReplaceFlagValue("name")
|
||||
//
|
||||
// when being executed after the command:
|
||||
// `docker run --name "my-container" alpine`
|
||||
// will result in the message:
|
||||
// `you ran a container named: my-container`
|
||||
func TemplateReplaceFlagValue(flag string) string {
|
||||
return fmt.Sprintf(hookTemplateFlagValue, flag)
|
||||
}
|
||||
|
||||
// TemplateReplaceArg takes an index i and returns a hook
|
||||
// template string that the CLI will replace the template with
|
||||
// the ith argument, after processing the passed flags.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// "run this image with `docker run " + TemplateReplaceArg(0) + "`"
|
||||
//
|
||||
// when being executed after the command:
|
||||
// `docker pull alpine`
|
||||
// will result in the message:
|
||||
// "Run this image with `docker run alpine`"
|
||||
func TemplateReplaceArg(i int) string {
|
||||
return fmt.Sprintf(hookTemplateArg, strconv.Itoa(i))
|
||||
}
|
||||
|
||||
func ParseTemplate(hookTemplate string, cmd *cobra.Command) ([]string, error) {
|
||||
tmpl := template.New("").Funcs(commandFunctions)
|
||||
tmpl, err := tmpl.Parse(hookTemplate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
out := hookTemplate
|
||||
if strings.Contains(hookTemplate, "{{") {
|
||||
// Message may be a template.
|
||||
msgContext := commandInfo{cmd: cmd}
|
||||
|
||||
tmpl, err := template.New("").Funcs(template.FuncMap{
|
||||
"command": msgContext.command,
|
||||
"flagValue": msgContext.flagValue,
|
||||
"argValue": msgContext.argValue,
|
||||
|
||||
// kept for backward-compatibility with old templates.
|
||||
"flag": func(_ any, flagName string) (string, error) { return msgContext.flagValue(flagName) },
|
||||
"arg": func(_ any, i int) (string, error) { return msgContext.argValue(i) },
|
||||
}).Parse(hookTemplate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var b bytes.Buffer
|
||||
err = tmpl.Execute(&b, msgContext)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = b.String()
|
||||
}
|
||||
b := bytes.Buffer{}
|
||||
err = tmpl.Execute(&b, cmd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return strings.Split(b.String(), "\n"), nil
|
||||
return strings.Split(out, "\n"), nil
|
||||
}
|
||||
|
||||
var ErrHookTemplateParse = errors.New("failed to parse hook template")
|
||||
|
||||
const (
|
||||
hookTemplateCommandName = "{{.Name}}"
|
||||
hookTemplateFlagValue = `{{flag . "%s"}}`
|
||||
hookTemplateArg = "{{arg . %s}}"
|
||||
)
|
||||
|
||||
var commandFunctions = template.FuncMap{
|
||||
"flag": getFlagValue,
|
||||
"arg": getArgValue,
|
||||
// commandInfo provides info about the command for which the hook was invoked.
|
||||
// It is used for templated hook-messages.
|
||||
type commandInfo struct {
|
||||
cmd *cobra.Command
|
||||
}
|
||||
|
||||
func getFlagValue(cmd *cobra.Command, flag string) (string, error) {
|
||||
cmdFlag := cmd.Flag(flag)
|
||||
if cmdFlag == nil {
|
||||
return "", ErrHookTemplateParse
|
||||
// Name returns the name of the (sub)command for which the hook was invoked.
|
||||
//
|
||||
// It's used for backward-compatibility with old templates.
|
||||
func (c commandInfo) Name() string {
|
||||
return c.command()
|
||||
}
|
||||
|
||||
// command returns the name of the (sub)command for which the hook was invoked.
|
||||
func (c commandInfo) command() string {
|
||||
if c.cmd == nil {
|
||||
return ""
|
||||
}
|
||||
return cmdFlag.Value.String(), nil
|
||||
return c.cmd.Name()
|
||||
}
|
||||
|
||||
func getArgValue(cmd *cobra.Command, i int) (string, error) {
|
||||
flags := cmd.Flags()
|
||||
if flags == nil {
|
||||
return "", ErrHookTemplateParse
|
||||
// flagValue returns the value that was set for the given flag when the hook was invoked.
|
||||
func (c commandInfo) flagValue(flagName string) (string, error) {
|
||||
if c.cmd == nil {
|
||||
return "", fmt.Errorf("%w: flagValue: cmd is nil", ErrHookTemplateParse)
|
||||
}
|
||||
return flags.Arg(i), nil
|
||||
f := c.cmd.Flag(flagName)
|
||||
if f == nil {
|
||||
return "", fmt.Errorf("%w: flagValue: no flags found", ErrHookTemplateParse)
|
||||
}
|
||||
return f.Value.String(), nil
|
||||
}
|
||||
|
||||
// argValue returns the value of the nth argument.
|
||||
func (c commandInfo) argValue(n int) (string, error) {
|
||||
if c.cmd == nil {
|
||||
return "", fmt.Errorf("%w: arg: cmd is nil", ErrHookTemplateParse)
|
||||
}
|
||||
flags := c.cmd.Flags()
|
||||
v := flags.Arg(n)
|
||||
if v == "" && n >= flags.NArg() {
|
||||
return "", fmt.Errorf("%w: arg: %dth argument not set", ErrHookTemplateParse, n)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
@@ -1,43 +1,67 @@
|
||||
package hooks
|
||||
package hooks_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/docker/cli/cli-plugins/hooks"
|
||||
"github.com/spf13/cobra"
|
||||
"gotest.tools/v3/assert"
|
||||
)
|
||||
|
||||
// TestParseTemplate tests parsing templates as returned by plugins.
|
||||
//
|
||||
// It uses fixed string fixtures to lock in compatibility with existing
|
||||
// plugin templates, so older formats continue to work even if we add new
|
||||
// template forms.
|
||||
//
|
||||
// For helper-backed cases, it also verifies that templates produced by the
|
||||
// current TemplateReplace* helpers parse to the same output. This lets us
|
||||
// evolve the emitted template format without breaking older plugins.
|
||||
func TestParseTemplate(t *testing.T) {
|
||||
type testFlag struct {
|
||||
name string
|
||||
value string
|
||||
}
|
||||
testCases := []struct {
|
||||
template string
|
||||
tests := []struct {
|
||||
doc string
|
||||
template string // compatibility fixture; keep even if helpers emit a newer form
|
||||
templateFunc func() string
|
||||
flags []testFlag
|
||||
args []string
|
||||
expectedOutput []string
|
||||
}{
|
||||
{
|
||||
doc: "empty template",
|
||||
template: "",
|
||||
expectedOutput: []string{""},
|
||||
},
|
||||
{
|
||||
doc: "plain message",
|
||||
template: "a plain template message",
|
||||
expectedOutput: []string{"a plain template message"},
|
||||
},
|
||||
{
|
||||
template: TemplateReplaceFlagValue("tag"),
|
||||
doc: "subcommand name",
|
||||
template: "hello {{.Name}}", // NOTE: fixture; do not modify without considering plugin compatibility
|
||||
templateFunc: func() string { return "hello " + hooks.TemplateReplaceSubcommandName() },
|
||||
|
||||
expectedOutput: []string{"hello pull"},
|
||||
},
|
||||
{
|
||||
doc: "single flag",
|
||||
template: `{{flag . "tag"}}`, // NOTE: fixture; do not modify without considering plugin compatibility
|
||||
templateFunc: func() string { return hooks.TemplateReplaceFlagValue("tag") },
|
||||
flags: []testFlag{
|
||||
{
|
||||
name: "tag",
|
||||
value: "my-tag",
|
||||
},
|
||||
{name: "tag", value: "my-tag"},
|
||||
},
|
||||
expectedOutput: []string{"my-tag"},
|
||||
},
|
||||
{
|
||||
template: TemplateReplaceFlagValue("test-one") + " " + TemplateReplaceFlagValue("test2"),
|
||||
doc: "multiple flags",
|
||||
template: `{{flag . "test-one"}} {{flag . "test2"}}`, // NOTE: fixture; do not modify without considering plugin compatibility
|
||||
templateFunc: func() string {
|
||||
return hooks.TemplateReplaceFlagValue("test-one") + " " + hooks.TemplateReplaceFlagValue("test2")
|
||||
},
|
||||
flags: []testFlag{
|
||||
{
|
||||
name: "test-one",
|
||||
@@ -51,36 +75,51 @@ func TestParseTemplate(t *testing.T) {
|
||||
expectedOutput: []string{"value value2"},
|
||||
},
|
||||
{
|
||||
template: TemplateReplaceArg(0) + " " + TemplateReplaceArg(1),
|
||||
doc: "multiple args",
|
||||
template: `{{arg . 0}} {{arg . 1}}`, // NOTE: fixture; do not modify without considering plugin compatibility
|
||||
templateFunc: func() string { return hooks.TemplateReplaceArg(0) + " " + hooks.TemplateReplaceArg(1) },
|
||||
args: []string{"zero", "one"},
|
||||
expectedOutput: []string{"zero one"},
|
||||
},
|
||||
{
|
||||
template: "You just pulled " + TemplateReplaceArg(0),
|
||||
doc: "arg in sentence",
|
||||
template: "You just pulled {{arg . 0}}", // NOTE: fixture; do not modify without considering plugin compatibility
|
||||
templateFunc: func() string { return "You just pulled " + hooks.TemplateReplaceArg(0) },
|
||||
args: []string{"alpine"},
|
||||
expectedOutput: []string{"You just pulled alpine"},
|
||||
},
|
||||
{
|
||||
doc: "multiline output",
|
||||
template: "one line\nanother line!",
|
||||
expectedOutput: []string{"one line", "another line!"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
testCmd := &cobra.Command{
|
||||
Use: "pull",
|
||||
Args: cobra.ExactArgs(len(tc.args)),
|
||||
}
|
||||
for _, f := range tc.flags {
|
||||
_ = testCmd.Flags().String(f.name, "", "")
|
||||
err := testCmd.Flag(f.name).Value.Set(f.value)
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.doc, func(t *testing.T) {
|
||||
testCmd := &cobra.Command{
|
||||
Use: "pull",
|
||||
Args: cobra.ExactArgs(len(tc.args)),
|
||||
}
|
||||
for _, f := range tc.flags {
|
||||
_ = testCmd.Flags().String(f.name, "", "")
|
||||
err := testCmd.Flag(f.name).Value.Set(f.value)
|
||||
assert.NilError(t, err)
|
||||
}
|
||||
err := testCmd.Flags().Parse(tc.args)
|
||||
assert.NilError(t, err)
|
||||
}
|
||||
err := testCmd.Flags().Parse(tc.args)
|
||||
assert.NilError(t, err)
|
||||
|
||||
out, err := ParseTemplate(tc.template, testCmd)
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, out, tc.expectedOutput)
|
||||
// Validate using fixtures.
|
||||
out, err := hooks.ParseTemplate(tc.template, testCmd)
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, out, tc.expectedOutput)
|
||||
|
||||
if tc.templateFunc != nil {
|
||||
// Validate using the current template function equivalent.
|
||||
out, err = hooks.ParseTemplate(tc.templateFunc(), testCmd)
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, out, tc.expectedOutput)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,8 +45,7 @@ func AddPluginCommandStubs(dockerCLI config.Provider, rootCmd *cobra.Command) (e
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
flags := rootCmd.PersistentFlags()
|
||||
flags.SetOutput(nil)
|
||||
perr := flags.Parse(args)
|
||||
if perr != nil {
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if flags.Changed("help") {
|
||||
@@ -60,7 +59,11 @@ func AddPluginCommandStubs(dockerCLI config.Provider, rootCmd *cobra.Command) (e
|
||||
cargs := []string{p.Path, cobra.ShellCompRequestCmd, p.Name} //nolint:prealloc // no need to over-complicate things.
|
||||
cargs = append(cargs, args...)
|
||||
cargs = append(cargs, toComplete)
|
||||
origArgs := os.Args
|
||||
os.Args = cargs
|
||||
defer func() {
|
||||
os.Args = origArgs
|
||||
}()
|
||||
runCommand, runErr := PluginRunCommand(dockerCLI, p.Name, cmd)
|
||||
if runErr != nil {
|
||||
return nil, cobra.ShellCompDirectiveError
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
package manager
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/docker/cli/internal/test"
|
||||
"github.com/spf13/cobra"
|
||||
"gotest.tools/v3/assert"
|
||||
)
|
||||
@@ -24,3 +28,58 @@ func TestPluginResourceAttributesEnvvar(t *testing.T) {
|
||||
env = appendPluginResourceAttributesEnvvar(nil, cmd, Plugin{Name: "compose"})
|
||||
assert.DeepEqual(t, []string{"OTEL_RESOURCE_ATTRIBUTES=a.b.c=foo,docker.cli.cobra.command_path=docker%20compose"}, env)
|
||||
}
|
||||
|
||||
func TestPluginStubRunEReturnsParseError(t *testing.T) {
|
||||
cmd, err := preparePluginStubCommand(t)
|
||||
assert.NilError(t, err)
|
||||
|
||||
err = cmd.RunE(cmd, []string{"--definitely-not-a-real-flag"})
|
||||
assert.ErrorContains(t, err, "unknown flag: --definitely-not-a-real-flag")
|
||||
}
|
||||
|
||||
func TestPluginStubCompletionRestoresOSArgs(t *testing.T) {
|
||||
cmd, err := preparePluginStubCommand(t)
|
||||
assert.NilError(t, err)
|
||||
|
||||
savedArgs := os.Args
|
||||
t.Cleanup(func() { os.Args = savedArgs })
|
||||
|
||||
originalArgs := []string{"docker", "image", "ls"}
|
||||
os.Args = append([]string(nil), originalArgs...)
|
||||
|
||||
_, directive := cmd.ValidArgsFunction(cmd, []string{"--all"}, "alp")
|
||||
assert.Equal(t, directive, cobra.ShellCompDirectiveError)
|
||||
assert.DeepEqual(t, os.Args, originalArgs)
|
||||
}
|
||||
|
||||
func preparePluginStubCommand(t *testing.T) (*cobra.Command, error) {
|
||||
t.Helper()
|
||||
pluginCommandStubsOnce = sync.Once{}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
const cliPlugin = `#!/bin/sh
|
||||
printf '%s' '{"SchemaVersion":"0.1.0"}'
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "docker-testplugin"), []byte(cliPlugin), 0o777); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cli := test.NewFakeCli(nil)
|
||||
cli.ConfigFile().CLIPluginsExtraDirs = []string{tmpDir}
|
||||
|
||||
root := &cobra.Command{Use: "docker"}
|
||||
root.PersistentFlags().Bool("debug", false, "")
|
||||
|
||||
if err := AddPluginCommandStubs(cli, root); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cmd, _, err := root.Find([]string{"testplugin"})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cmd == nil {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
return cmd, nil
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package manager
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package manager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/docker/cli/cli-plugins/hooks"
|
||||
@@ -19,15 +22,11 @@ import (
|
||||
// HookPluginData is the type representing the information
|
||||
// that plugins declaring support for hooks get passed when
|
||||
// being invoked following a CLI command execution.
|
||||
type HookPluginData struct {
|
||||
// RootCmd is a string representing the matching hook configuration
|
||||
// which is currently being invoked. If a hook for `docker context` is
|
||||
// configured and the user executes `docker context ls`, the plugin will
|
||||
// be invoked with `context`.
|
||||
RootCmd string
|
||||
Flags map[string]string
|
||||
CommandError string
|
||||
}
|
||||
//
|
||||
// Deprecated: use [hooks.Request] instead.
|
||||
//
|
||||
//go:fix inline
|
||||
type HookPluginData = hooks.Request
|
||||
|
||||
// RunCLICommandHooks is the entrypoint into the hooks execution flow after
|
||||
// a main CLI command was executed. It calls the hook subcommand for all
|
||||
@@ -55,11 +54,8 @@ func runHooks(ctx context.Context, cfg *configfile.ConfigFile, rootCmd, subComma
|
||||
}
|
||||
|
||||
func invokeAndCollectHooks(ctx context.Context, cfg *configfile.ConfigFile, rootCmd, subCmd *cobra.Command, subCmdStr string, flags map[string]string, cmdErrorMessage string) []string {
|
||||
// check if the context was cancelled before invoking hooks
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
default:
|
||||
}
|
||||
|
||||
pluginsCfg := cfg.Plugins
|
||||
@@ -69,47 +65,65 @@ func invokeAndCollectHooks(ctx context.Context, cfg *configfile.ConfigFile, root
|
||||
|
||||
pluginDirs := getPluginDirs(cfg)
|
||||
nextSteps := make([]string, 0, len(pluginsCfg))
|
||||
for pluginName, pluginCfg := range pluginsCfg {
|
||||
match, ok := pluginMatch(pluginCfg, subCmdStr, cmdErrorMessage)
|
||||
if !ok {
|
||||
continue
|
||||
|
||||
tryInvokeHook := func(pluginName string, pluginCfg map[string]string) (messages []string, ok bool, err error) {
|
||||
match, matched := pluginMatch(pluginCfg, subCmdStr, cmdErrorMessage)
|
||||
if !matched {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
p, err := getPlugin(pluginName, pluginDirs, rootCmd)
|
||||
if err != nil {
|
||||
continue
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
hookReturn, err := p.RunHook(ctx, HookPluginData{
|
||||
resp, err := p.RunHook(ctx, hooks.Request{
|
||||
RootCmd: match,
|
||||
Flags: flags,
|
||||
CommandError: cmdErrorMessage,
|
||||
})
|
||||
if err != nil {
|
||||
// skip misbehaving plugins, but don't halt execution
|
||||
continue
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
var hookMessageData hooks.HookMessage
|
||||
err = json.Unmarshal(hookReturn, &hookMessageData)
|
||||
if err != nil {
|
||||
continue
|
||||
var message hooks.Response
|
||||
if err := json.Unmarshal(resp, &message); err != nil {
|
||||
return nil, false, fmt.Errorf("failed to unmarshal hook response (%q): %w", string(resp), err)
|
||||
}
|
||||
|
||||
// currently the only hook type
|
||||
if hookMessageData.Type != hooks.NextSteps {
|
||||
continue
|
||||
if message.Type != hooks.NextSteps {
|
||||
return nil, false, errors.New("unexpected hook response type: " + strconv.Itoa(int(message.Type)))
|
||||
}
|
||||
|
||||
processedHook, err := hooks.ParseTemplate(hookMessageData.Template, subCmd)
|
||||
messages, err = hooks.ParseTemplate(message.Template, subCmd)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
return messages, true, nil
|
||||
}
|
||||
|
||||
for pluginName, pluginCfg := range pluginsCfg {
|
||||
messages, ok, err := tryInvokeHook(pluginName, pluginCfg)
|
||||
if err != nil {
|
||||
// skip misbehaving plugins, but don't halt execution
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"error": err,
|
||||
"plugin": pluginName,
|
||||
}).Debug("Plugin hook invocation failed")
|
||||
continue
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
var appended bool
|
||||
nextSteps, appended = appendNextSteps(nextSteps, processedHook)
|
||||
nextSteps, appended = appendNextSteps(nextSteps, messages)
|
||||
if !appended {
|
||||
logrus.Debugf("Plugin %s responded with an empty hook message %q. Ignoring.", pluginName, string(hookReturn))
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"plugin": pluginName,
|
||||
}).Debug("Plugin responded with an empty hook message; ignoring")
|
||||
}
|
||||
}
|
||||
return nextSteps
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/docker/cli/cli-plugins/hooks"
|
||||
"github.com/docker/cli/cli-plugins/metadata"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -154,7 +155,7 @@ func validateSchemaVersion(version string) error {
|
||||
|
||||
// RunHook executes the plugin's hooks command
|
||||
// and returns its unprocessed output.
|
||||
func (p *Plugin) RunHook(ctx context.Context, hookData HookPluginData) ([]byte, error) {
|
||||
func (p *Plugin) RunHook(ctx context.Context, hookData hooks.Request) ([]byte, error) {
|
||||
hDataBytes, err := json.Marshal(hookData)
|
||||
if err != nil {
|
||||
return nil, wrapAsPluginError(err, "failed to marshall hook data")
|
||||
@@ -163,12 +164,16 @@ func (p *Plugin) RunHook(ctx context.Context, hookData HookPluginData) ([]byte,
|
||||
pCmd := exec.CommandContext(ctx, p.Path, p.Name, metadata.HookSubcommandName, string(hDataBytes)) // #nosec G204 -- ignore "Subprocess launched with a potential tainted input or cmd arguments"
|
||||
pCmd.Env = os.Environ()
|
||||
pCmd.Env = append(pCmd.Env, metadata.ReexecEnvvar+"="+os.Args[0])
|
||||
hookCmdOutput, err := pCmd.Output()
|
||||
if err != nil {
|
||||
return nil, wrapAsPluginError(err, "failed to execute plugin hook subcommand")
|
||||
}
|
||||
|
||||
return hookCmdOutput, nil
|
||||
out, err := pCmd.Output()
|
||||
if err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return nil, wrapAsPluginError(err, "plugin hook subcommand exited unsuccessfully")
|
||||
}
|
||||
return nil, wrapAsPluginError(err, "failed to execute plugin hook subcommand: "+pCmd.String())
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// pluginNameFormat is used as part of errors for invalid plugin-names.
|
||||
|
||||
@@ -54,9 +54,9 @@ func TestPluginServer(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("allows reconnects", func(t *testing.T) {
|
||||
var calls int32
|
||||
var calls atomic.Int32
|
||||
h := func(_ net.Conn) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
calls.Add(1)
|
||||
}
|
||||
|
||||
srv, err := NewPluginServer(h)
|
||||
@@ -70,7 +70,7 @@ func TestPluginServer(t *testing.T) {
|
||||
|
||||
waitForCalls := func(n int) {
|
||||
poll.WaitOn(t, func(t poll.LogT) poll.Result {
|
||||
if atomic.LoadInt32(&calls) == int32(n) {
|
||||
if calls.Load() == int32(n) {
|
||||
return poll.Success()
|
||||
}
|
||||
return poll.Continue("waiting for handler to be called")
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package command
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/distribution/reference"
|
||||
"github.com/docker/cli/cli/command/formatter"
|
||||
"github.com/moby/moby/api/types/container"
|
||||
"github.com/moby/moby/client"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -23,7 +22,7 @@ type APIClientProvider interface {
|
||||
|
||||
// ImageNames offers completion for images present within the local store
|
||||
func ImageNames(dockerCLI APIClientProvider, limit int) cobra.CompletionFunc {
|
||||
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return Unique(func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
if limit > 0 && len(args) >= limit {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
@@ -36,14 +35,14 @@ func ImageNames(dockerCLI APIClientProvider, limit int) cobra.CompletionFunc {
|
||||
names = append(names, img.RepoTags...)
|
||||
}
|
||||
return names, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ImageNamesWithBase offers completion for images present within the local store,
|
||||
// including both full image names with tags and base image names (repository names only)
|
||||
// when multiple tags exist for the same base name
|
||||
func ImageNamesWithBase(dockerCLI APIClientProvider, limit int) cobra.CompletionFunc {
|
||||
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return Unique(func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
if limit > 0 && len(args) >= limit {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
@@ -69,14 +68,14 @@ func ImageNamesWithBase(dockerCLI APIClientProvider, limit int) cobra.Completion
|
||||
}
|
||||
}
|
||||
return names, cobra.ShellCompDirectiveNoSpace | cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ContainerNames offers completion for container names and IDs
|
||||
// By default, only names are returned.
|
||||
// Set DOCKER_COMPLETION_SHOW_CONTAINER_IDS=yes to also complete IDs.
|
||||
func ContainerNames(dockerCLI APIClientProvider, all bool, filters ...func(container.Summary) bool) cobra.CompletionFunc {
|
||||
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return Unique(func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
res, err := dockerCLI.Client().ContainerList(cmd.Context(), client.ContainerListOptions{
|
||||
All: all,
|
||||
})
|
||||
@@ -101,15 +100,21 @@ func ContainerNames(dockerCLI APIClientProvider, all bool, filters ...func(conta
|
||||
if showContainerIDs {
|
||||
names = append(names, ctr.ID)
|
||||
}
|
||||
names = append(names, formatter.StripNamePrefix(ctr.Names)...)
|
||||
for _, n := range ctr.Names {
|
||||
// Skip legacy link names: "/linked-container/link-name"
|
||||
if len(n) <= 1 || strings.IndexByte(n[1:], '/') != -1 {
|
||||
continue
|
||||
}
|
||||
names = append(names, strings.TrimPrefix(n, "/"))
|
||||
}
|
||||
}
|
||||
return names, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// VolumeNames offers completion for volumes
|
||||
func VolumeNames(dockerCLI APIClientProvider) cobra.CompletionFunc {
|
||||
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return Unique(func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
res, err := dockerCLI.Client().VolumeList(cmd.Context(), client.VolumeListOptions{})
|
||||
if err != nil {
|
||||
return nil, cobra.ShellCompDirectiveError
|
||||
@@ -119,12 +124,12 @@ func VolumeNames(dockerCLI APIClientProvider) cobra.CompletionFunc {
|
||||
names = append(names, vol.Name)
|
||||
}
|
||||
return names, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// NetworkNames offers completion for networks
|
||||
func NetworkNames(dockerCLI APIClientProvider) cobra.CompletionFunc {
|
||||
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return Unique(func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
res, err := dockerCLI.Client().NetworkList(cmd.Context(), client.NetworkListOptions{})
|
||||
if err != nil {
|
||||
return nil, cobra.ShellCompDirectiveError
|
||||
@@ -134,7 +139,7 @@ func NetworkNames(dockerCLI APIClientProvider) cobra.CompletionFunc {
|
||||
names = append(names, nw.Name)
|
||||
}
|
||||
return names, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// EnvVarNames offers completion for environment-variable names. This
|
||||
@@ -151,7 +156,7 @@ func NetworkNames(dockerCLI APIClientProvider) cobra.CompletionFunc {
|
||||
// docker run --rm --env MY_VAR alpine printenv MY_VAR
|
||||
// hello
|
||||
func EnvVarNames() cobra.CompletionFunc {
|
||||
return func(_ *cobra.Command, _ []string, _ string) (names []string, _ cobra.ShellCompDirective) {
|
||||
return Unique(func(_ *cobra.Command, _ []string, _ string) (names []string, _ cobra.ShellCompDirective) {
|
||||
envs := os.Environ()
|
||||
names = make([]string, 0, len(envs))
|
||||
for _, env := range envs {
|
||||
@@ -159,12 +164,12 @@ func EnvVarNames() cobra.CompletionFunc {
|
||||
names = append(names, name)
|
||||
}
|
||||
return names, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// FromList offers completion for the given list of options.
|
||||
func FromList(options ...string) cobra.CompletionFunc {
|
||||
return cobra.FixedCompletions(options, cobra.ShellCompDirectiveNoFileComp)
|
||||
return Unique(cobra.FixedCompletions(options, cobra.ShellCompDirectiveNoFileComp))
|
||||
}
|
||||
|
||||
// FileNames is a convenience function to use [cobra.ShellCompDirectiveDefault],
|
||||
@@ -218,3 +223,38 @@ func Platforms() cobra.CompletionFunc {
|
||||
return commonPlatforms, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
}
|
||||
|
||||
// Unique wraps a completion func and removes completion results that are
|
||||
// already consumed (i.e., appear in "args").
|
||||
//
|
||||
// For example:
|
||||
//
|
||||
// # initial completion: args is empty, so all results are shown
|
||||
// command <tab>
|
||||
// one two three
|
||||
//
|
||||
// # "one" is already used so omitted
|
||||
// command one <tab>
|
||||
// two three
|
||||
func Unique(fn cobra.CompletionFunc) cobra.CompletionFunc {
|
||||
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
all, dir := fn(cmd, args, toComplete)
|
||||
if len(all) == 0 || len(args) == 0 {
|
||||
return all, dir
|
||||
}
|
||||
|
||||
alreadyCompleted := make(map[string]struct{}, len(args))
|
||||
for _, a := range args {
|
||||
alreadyCompleted[a] = struct{}{}
|
||||
}
|
||||
|
||||
out := make([]string, 0, len(all))
|
||||
for _, c := range all {
|
||||
if _, ok := alreadyCompleted[c]; !ok {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
|
||||
return out, dir
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ func TestCompleteContainerNames(t *testing.T) {
|
||||
{ID: "id-b", State: container.StateCreated, Names: []string{"/container-b"}},
|
||||
{ID: "id-a", State: container.StateExited, Names: []string{"/container-a"}},
|
||||
},
|
||||
expOut: []string{"container-c", "container-c/link-b", "container-b", "container-a"},
|
||||
expOut: []string{"container-c", "container-b", "container-a"},
|
||||
expOpts: client.ContainerListOptions{All: true},
|
||||
expDirective: cobra.ShellCompDirectiveNoFileComp,
|
||||
},
|
||||
@@ -97,7 +97,7 @@ func TestCompleteContainerNames(t *testing.T) {
|
||||
{ID: "id-b", State: container.StateCreated, Names: []string{"/container-b"}},
|
||||
{ID: "id-a", State: container.StateExited, Names: []string{"/container-a"}},
|
||||
},
|
||||
expOut: []string{"id-c", "container-c", "container-c/link-b", "id-b", "container-b", "id-a", "container-a"},
|
||||
expOut: []string{"id-c", "container-c", "id-b", "container-b", "id-a", "container-a"},
|
||||
expOpts: client.ContainerListOptions{All: true},
|
||||
expDirective: cobra.ShellCompDirectiveNoFileComp,
|
||||
},
|
||||
@@ -107,7 +107,7 @@ func TestCompleteContainerNames(t *testing.T) {
|
||||
containers: []container.Summary{
|
||||
{ID: "id-c", State: container.StateRunning, Names: []string{"/container-c", "/container-c/link-b"}},
|
||||
},
|
||||
expOut: []string{"container-c", "container-c/link-b"},
|
||||
expOut: []string{"container-c"},
|
||||
expDirective: cobra.ShellCompDirectiveNoFileComp,
|
||||
},
|
||||
{
|
||||
@@ -117,7 +117,7 @@ func TestCompleteContainerNames(t *testing.T) {
|
||||
func(ctr container.Summary) bool { return ctr.State == container.StateCreated },
|
||||
},
|
||||
containers: []container.Summary{
|
||||
{ID: "id-c", State: container.StateRunning, Names: []string{"/container-c", "/container-c/link-b"}},
|
||||
{ID: "id-c", State: container.StateRunning, Names: []string{"/container-c"}},
|
||||
{ID: "id-b", State: container.StateCreated, Names: []string{"/container-b"}},
|
||||
{ID: "id-a", State: container.StateExited, Names: []string{"/container-a"}},
|
||||
},
|
||||
@@ -133,7 +133,7 @@ func TestCompleteContainerNames(t *testing.T) {
|
||||
func(ctr container.Summary) bool { return ctr.State == container.StateCreated },
|
||||
},
|
||||
containers: []container.Summary{
|
||||
{ID: "id-c", State: container.StateRunning, Names: []string{"/container-c", "/container-c/link-b"}},
|
||||
{ID: "id-c", State: container.StateRunning, Names: []string{"/container-c"}},
|
||||
{ID: "id-b", State: container.StateCreated, Names: []string{"/container-b"}},
|
||||
{ID: "id-a", State: container.StateCreated, Names: []string{"/container-a"}},
|
||||
},
|
||||
@@ -351,3 +351,23 @@ func TestCompleteVolumeNames(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnique(t *testing.T) {
|
||||
base := []string{"alpha", "beta", "gamma"}
|
||||
|
||||
comp := Unique(func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
|
||||
return base, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
|
||||
values, directives := comp(&cobra.Command{}, []string{"beta"}, "")
|
||||
assert.Check(t, is.Equal(directives&cobra.ShellCompDirectiveNoFileComp, cobra.ShellCompDirectiveNoFileComp))
|
||||
assert.Check(t, is.DeepEqual(values, []string{"alpha", "gamma"}))
|
||||
|
||||
assert.Check(t, is.DeepEqual(base, []string{"alpha", "beta", "gamma"}))
|
||||
|
||||
values, directives = comp(&cobra.Command{}, []string{"gamma"}, "")
|
||||
assert.Check(t, is.Equal(directives&cobra.ShellCompDirectiveNoFileComp, cobra.ShellCompDirectiveNoFileComp))
|
||||
assert.Check(t, is.DeepEqual(values, []string{"alpha", "beta"}))
|
||||
|
||||
assert.Check(t, is.DeepEqual(base, []string{"alpha", "beta", "gamma"}))
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package config
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ type fakeClient struct {
|
||||
infoFunc func() (client.SystemInfoResult, error)
|
||||
containerStatPathFunc func(containerID, path string) (client.ContainerStatPathResult, error)
|
||||
containerCopyFromFunc func(containerID, srcPath string) (client.CopyFromContainerResult, error)
|
||||
containerCopyToFunc func(containerID string, options client.CopyToContainerOptions) (client.CopyToContainerResult, error)
|
||||
logFunc func(string, client.ContainerLogsOptions) (client.ContainerLogsResult, error)
|
||||
waitFunc func(string) client.ContainerWaitResult
|
||||
containerListFunc func(client.ContainerListOptions) (client.ContainerListResult, error)
|
||||
@@ -128,6 +129,13 @@ func (f *fakeClient) CopyFromContainer(_ context.Context, containerID string, op
|
||||
return client.CopyFromContainerResult{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeClient) CopyToContainer(_ context.Context, containerID string, options client.CopyToContainerOptions) (client.CopyToContainerResult, error) {
|
||||
if f.containerCopyToFunc != nil {
|
||||
return f.containerCopyToFunc(containerID, options)
|
||||
}
|
||||
return client.CopyToContainerResult{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeClient) ContainerLogs(_ context.Context, containerID string, options client.ContainerLogsOptions) (client.ContainerLogsResult, error) {
|
||||
if f.logFunc != nil {
|
||||
return f.logFunc(containerID, options)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package container
|
||||
|
||||
@@ -182,6 +182,35 @@ func completeLink(dockerCLI completion.APIClientProvider) cobra.CompletionFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// completeLinks implements shell completion for the `--link` option of `rm --link`.
|
||||
//
|
||||
// It contacts the API to get names of legacy links on containers.
|
||||
// In case of an error, an empty list is returned.
|
||||
func completeLinks(dockerCLI completion.APIClientProvider) cobra.CompletionFunc {
|
||||
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
res, err := dockerCLI.Client().ContainerList(cmd.Context(), client.ContainerListOptions{
|
||||
All: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, cobra.ShellCompDirectiveError
|
||||
}
|
||||
var names []string
|
||||
for _, ctr := range res.Items {
|
||||
if len(ctr.Names) <= 1 {
|
||||
// Container has no links names.
|
||||
continue
|
||||
}
|
||||
for _, n := range ctr.Names {
|
||||
// Skip legacy link names: "/linked-container/link-name"
|
||||
if len(n) > 1 && strings.IndexByte(n[1:], '/') != -1 {
|
||||
names = append(names, strings.TrimPrefix(n, "/"))
|
||||
}
|
||||
}
|
||||
}
|
||||
return names, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
}
|
||||
|
||||
// completeLogDriver implements shell completion for the `--log-driver` option of `run` and `create`.
|
||||
// The log drivers are collected from a call to the Info endpoint with a fallback to a hard-coded list
|
||||
// of the build-in log drivers.
|
||||
|
||||
@@ -135,3 +135,44 @@ func TestCompleteSignals(t *testing.T) {
|
||||
assert.Check(t, len(values) > 1)
|
||||
assert.Check(t, is.Len(values, len(signal.SignalMap)))
|
||||
}
|
||||
|
||||
func TestCompleteLinks(t *testing.T) {
|
||||
tests := []struct {
|
||||
doc string
|
||||
showAll, showIDs bool
|
||||
filters []func(container.Summary) bool
|
||||
containers []container.Summary
|
||||
expOut []string
|
||||
expDirective cobra.ShellCompDirective
|
||||
}{
|
||||
{
|
||||
doc: "no results",
|
||||
expDirective: cobra.ShellCompDirectiveNoFileComp,
|
||||
},
|
||||
{
|
||||
doc: "all containers",
|
||||
showAll: true,
|
||||
containers: []container.Summary{
|
||||
{ID: "id-c", State: container.StateRunning, Names: []string{"/container-c", "/container-c/link-b", "/container-c/link-c"}},
|
||||
{ID: "id-b", State: container.StateCreated, Names: []string{"/container-b", "/container-b/link-a"}},
|
||||
{ID: "id-a", State: container.StateExited, Names: []string{"/container-a"}},
|
||||
},
|
||||
expOut: []string{"container-c/link-b", "container-c/link-c", "container-b/link-a"},
|
||||
expDirective: cobra.ShellCompDirectiveNoFileComp,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.doc, func(t *testing.T) {
|
||||
comp := completeLinks(test.NewFakeCli(&fakeClient{
|
||||
containerListFunc: func(client.ContainerListOptions) (client.ContainerListResult, error) {
|
||||
return client.ContainerListResult{Items: tc.containers}, nil
|
||||
},
|
||||
}))
|
||||
|
||||
containers, directives := comp(&cobra.Command{}, nil, "")
|
||||
assert.Check(t, is.Equal(directives&tc.expDirective, tc.expDirective))
|
||||
assert.Check(t, is.DeepEqual(containers, tc.expOut))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +168,50 @@ func progressHumanSize(n int64) string {
|
||||
return units.HumanSizeWithPrecision(float64(n), 3)
|
||||
}
|
||||
|
||||
// localContentSize returns the total size of regular file content at path.
|
||||
// For a regular file it returns the file size. For a directory it walks
|
||||
// the tree and sums sizes of all regular files.
|
||||
func localContentSize(path string) (int64, error) {
|
||||
fi, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return -1, err
|
||||
}
|
||||
if !fi.IsDir() {
|
||||
if fi.Mode().IsRegular() {
|
||||
return fi.Size(), nil
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
var total int64
|
||||
err = filepath.WalkDir(path, func(_ string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.Type().IsRegular() {
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
total += info.Size()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return total, err
|
||||
}
|
||||
|
||||
// copySummary formats the "Successfully copied ..." message.
|
||||
// When contentSize differs from transferredSize, both values are shown.
|
||||
func copySummary(contentSize, transferredSize int64, dest string) string {
|
||||
if contentSize != transferredSize {
|
||||
return fmt.Sprintf("Successfully copied %s (transferred %s) to %s\n",
|
||||
progressHumanSize(contentSize), progressHumanSize(transferredSize), dest,
|
||||
)
|
||||
}
|
||||
return fmt.Sprintf("Successfully copied %s to %s\n",
|
||||
progressHumanSize(contentSize), dest,
|
||||
)
|
||||
}
|
||||
|
||||
func runCopy(ctx context.Context, dockerCli command.Cli, opts copyOptions) error {
|
||||
srcContainer, srcPath := splitCpArg(opts.source)
|
||||
destContainer, destPath := splitCpArg(opts.destination)
|
||||
@@ -295,7 +339,11 @@ func copyFromContainer(ctx context.Context, dockerCLI command.Cli, copyConfig cp
|
||||
cancel()
|
||||
<-done
|
||||
restore()
|
||||
_, _ = fmt.Fprintln(dockerCLI.Err(), "Successfully copied", progressHumanSize(copiedSize), "to", dstPath)
|
||||
reportedSize := copiedSize
|
||||
if !cpRes.Stat.Mode.IsDir() {
|
||||
reportedSize = cpRes.Stat.Size
|
||||
}
|
||||
_, _ = fmt.Fprint(dockerCLI.Err(), copySummary(reportedSize, copiedSize, dstPath))
|
||||
|
||||
return res
|
||||
}
|
||||
@@ -354,11 +402,14 @@ func copyToContainer(ctx context.Context, dockerCLI command.Cli, copyConfig cpCo
|
||||
content io.ReadCloser
|
||||
resolvedDstPath string
|
||||
copiedSize int64
|
||||
contentSize int64
|
||||
sizeErr error
|
||||
)
|
||||
|
||||
if srcPath == "-" {
|
||||
content = os.Stdin
|
||||
resolvedDstPath = dstInfo.Path
|
||||
sizeErr = errors.New("content size not available for stdin")
|
||||
if !dstInfo.IsDir {
|
||||
return fmt.Errorf(`destination "%s:%s" must be a directory`, copyConfig.container, dstPath)
|
||||
}
|
||||
@@ -369,6 +420,8 @@ func copyToContainer(ctx context.Context, dockerCLI command.Cli, copyConfig cpCo
|
||||
return err
|
||||
}
|
||||
|
||||
contentSize, sizeErr = localContentSize(srcInfo.Path)
|
||||
|
||||
srcArchive, err := archive.TarResource(srcInfo)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -421,7 +474,11 @@ func copyToContainer(ctx context.Context, dockerCLI command.Cli, copyConfig cpCo
|
||||
cancel()
|
||||
<-done
|
||||
restore()
|
||||
_, _ = fmt.Fprintln(dockerCLI.Err(), "Successfully copied", progressHumanSize(copiedSize), "to", copyConfig.container+":"+dstInfo.Path)
|
||||
reportedSize := copiedSize
|
||||
if sizeErr == nil {
|
||||
reportedSize = contentSize
|
||||
}
|
||||
_, _ = fmt.Fprint(dockerCLI.Err(), copySummary(reportedSize, copiedSize, copyConfig.container+":"+dstInfo.Path))
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/docker/cli/internal/test"
|
||||
"github.com/moby/go-archive"
|
||||
"github.com/moby/go-archive/compression"
|
||||
"github.com/moby/moby/api/types/container"
|
||||
"github.com/moby/moby/client"
|
||||
"gotest.tools/v3/assert"
|
||||
is "gotest.tools/v3/assert/cmp"
|
||||
@@ -211,3 +212,237 @@ func TestRunCopyFromContainerToFilesystemIrregularDestination(t *testing.T) {
|
||||
expected := `"/dev/random" must be a directory or a regular file`
|
||||
assert.ErrorContains(t, err, expected)
|
||||
}
|
||||
|
||||
func TestCopySummary(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
contentSize int64
|
||||
transferredSize int64
|
||||
dest string
|
||||
wantContains string
|
||||
wantNoContain string
|
||||
}{
|
||||
{
|
||||
name: "different sizes shows both",
|
||||
contentSize: 5,
|
||||
transferredSize: 2048,
|
||||
dest: "/dst",
|
||||
wantContains: "(transferred",
|
||||
},
|
||||
{
|
||||
name: "equal sizes shows single value",
|
||||
contentSize: 100,
|
||||
transferredSize: 100,
|
||||
dest: "/dst",
|
||||
wantNoContain: "(transferred",
|
||||
},
|
||||
{
|
||||
name: "both zero",
|
||||
contentSize: 0,
|
||||
transferredSize: 0,
|
||||
dest: "ctr:/dst",
|
||||
wantContains: "Successfully copied 0B to ctr:/dst",
|
||||
wantNoContain: "(transferred",
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := copySummary(tc.contentSize, tc.transferredSize, tc.dest)
|
||||
if tc.wantContains != "" {
|
||||
assert.Check(t, is.Contains(got, tc.wantContains))
|
||||
}
|
||||
if tc.wantNoContain != "" {
|
||||
assert.Check(t, !strings.Contains(got, tc.wantNoContain), "unexpected substring %q in %q", tc.wantNoContain, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyFromContainerReportsFileSize(t *testing.T) {
|
||||
// The file content is "hello" (5 bytes), but the TAR archive wrapping
|
||||
// it is much larger due to headers and padding. The success message
|
||||
// should report the actual file size (5B), not the TAR stream size.
|
||||
srcDir := fs.NewDir(t, "cp-test-from",
|
||||
fs.WithFile("file1", "hello"))
|
||||
|
||||
destDir := fs.NewDir(t, "cp-test-from-dest")
|
||||
|
||||
const fileSize int64 = 5
|
||||
fakeCli := test.NewFakeCli(&fakeClient{
|
||||
containerCopyFromFunc: func(ctr, srcPath string) (client.CopyFromContainerResult, error) {
|
||||
readCloser, err := archive.Tar(srcDir.Path(), compression.None)
|
||||
return client.CopyFromContainerResult{
|
||||
Content: readCloser,
|
||||
Stat: container.PathStat{
|
||||
Name: "file1",
|
||||
Size: fileSize,
|
||||
},
|
||||
}, err
|
||||
},
|
||||
})
|
||||
err := runCopy(context.TODO(), fakeCli, copyOptions{
|
||||
source: "container:/file1",
|
||||
destination: destDir.Path(),
|
||||
})
|
||||
assert.NilError(t, err)
|
||||
errOut := fakeCli.ErrBuffer().String()
|
||||
assert.Check(t, is.Contains(errOut, "Successfully copied 5B"))
|
||||
assert.Check(t, is.Contains(errOut, "(transferred"))
|
||||
}
|
||||
|
||||
func TestCopyToContainerReportsFileSize(t *testing.T) {
|
||||
// Create a temp file with known content ("hello" = 5 bytes).
|
||||
// The TAR archive sent to the container is larger, but the success
|
||||
// message should report the actual content size.
|
||||
srcFile := fs.NewFile(t, "cp-test-to", fs.WithContent("hello"))
|
||||
|
||||
fakeCli := test.NewFakeCli(&fakeClient{
|
||||
containerStatPathFunc: func(containerID, path string) (client.ContainerStatPathResult, error) {
|
||||
return client.ContainerStatPathResult{
|
||||
Stat: container.PathStat{
|
||||
Name: "tmp",
|
||||
Mode: os.ModeDir | 0o755,
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
containerCopyToFunc: func(containerID string, options client.CopyToContainerOptions) (client.CopyToContainerResult, error) {
|
||||
_, _ = io.Copy(io.Discard, options.Content)
|
||||
return client.CopyToContainerResult{}, nil
|
||||
},
|
||||
})
|
||||
err := runCopy(context.TODO(), fakeCli, copyOptions{
|
||||
source: srcFile.Path(),
|
||||
destination: "container:/tmp",
|
||||
})
|
||||
assert.NilError(t, err)
|
||||
errOut := fakeCli.ErrBuffer().String()
|
||||
assert.Check(t, is.Contains(errOut, "Successfully copied 5B"))
|
||||
assert.Check(t, is.Contains(errOut, "(transferred"))
|
||||
}
|
||||
|
||||
func TestCopyToContainerReportsEmptyFileSize(t *testing.T) {
|
||||
srcFile := fs.NewFile(t, "cp-test-empty", fs.WithContent(""))
|
||||
|
||||
fakeCli := test.NewFakeCli(&fakeClient{
|
||||
containerStatPathFunc: func(containerID, path string) (client.ContainerStatPathResult, error) {
|
||||
return client.ContainerStatPathResult{
|
||||
Stat: container.PathStat{
|
||||
Name: "tmp",
|
||||
Mode: os.ModeDir | 0o755,
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
containerCopyToFunc: func(containerID string, options client.CopyToContainerOptions) (client.CopyToContainerResult, error) {
|
||||
_, _ = io.Copy(io.Discard, options.Content)
|
||||
return client.CopyToContainerResult{}, nil
|
||||
},
|
||||
})
|
||||
err := runCopy(context.TODO(), fakeCli, copyOptions{
|
||||
source: srcFile.Path(),
|
||||
destination: "container:/tmp",
|
||||
})
|
||||
assert.NilError(t, err)
|
||||
errOut := fakeCli.ErrBuffer().String()
|
||||
assert.Check(t, is.Contains(errOut, "Successfully copied 0B"))
|
||||
assert.Check(t, is.Contains(errOut, "(transferred"))
|
||||
}
|
||||
|
||||
func TestCopyToContainerReportsDirectorySize(t *testing.T) {
|
||||
// Create a temp directory with files "aaa" (3 bytes) + "bbb" (3 bytes) = 6 bytes.
|
||||
// The TAR archive is much larger, but the success message should report 6B.
|
||||
srcDir := fs.NewDir(t, "cp-test-dir",
|
||||
fs.WithFile("aaa", "aaa"),
|
||||
fs.WithFile("bbb", "bbb"),
|
||||
)
|
||||
|
||||
fakeCli := test.NewFakeCli(&fakeClient{
|
||||
containerStatPathFunc: func(containerID, path string) (client.ContainerStatPathResult, error) {
|
||||
return client.ContainerStatPathResult{
|
||||
Stat: container.PathStat{
|
||||
Name: "tmp",
|
||||
Mode: os.ModeDir | 0o755,
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
containerCopyToFunc: func(containerID string, options client.CopyToContainerOptions) (client.CopyToContainerResult, error) {
|
||||
_, _ = io.Copy(io.Discard, options.Content)
|
||||
return client.CopyToContainerResult{}, nil
|
||||
},
|
||||
})
|
||||
err := runCopy(context.TODO(), fakeCli, copyOptions{
|
||||
source: srcDir.Path() + string(os.PathSeparator),
|
||||
destination: "container:/tmp",
|
||||
})
|
||||
assert.NilError(t, err)
|
||||
errOut := fakeCli.ErrBuffer().String()
|
||||
assert.Check(t, is.Contains(errOut, "Successfully copied 6B"))
|
||||
assert.Check(t, is.Contains(errOut, "(transferred"))
|
||||
}
|
||||
|
||||
func TestCopyFromContainerReportsDirectorySize(t *testing.T) {
|
||||
// When copying a directory from a container, cpRes.Stat.Mode.IsDir() is true,
|
||||
// so reportedSize falls back to copiedSize (the tar stream bytes).
|
||||
srcDir := fs.NewDir(t, "cp-test-fromdir",
|
||||
fs.WithFile("file1", "hello"))
|
||||
|
||||
destDir := fs.NewDir(t, "cp-test-fromdir-dest")
|
||||
|
||||
fakeCli := test.NewFakeCli(&fakeClient{
|
||||
containerCopyFromFunc: func(ctr, srcPath string) (client.CopyFromContainerResult, error) {
|
||||
readCloser, err := archive.Tar(srcDir.Path(), compression.None)
|
||||
return client.CopyFromContainerResult{
|
||||
Content: readCloser,
|
||||
Stat: container.PathStat{
|
||||
Name: "mydir",
|
||||
Mode: os.ModeDir | 0o755,
|
||||
},
|
||||
}, err
|
||||
},
|
||||
})
|
||||
err := runCopy(context.TODO(), fakeCli, copyOptions{
|
||||
source: "container:/mydir",
|
||||
destination: destDir.Path(),
|
||||
})
|
||||
assert.NilError(t, err)
|
||||
errOut := fakeCli.ErrBuffer().String()
|
||||
assert.Check(t, is.Contains(errOut, "Successfully copied"))
|
||||
// For directories from container, content size is unknown so
|
||||
// reportedSize == copiedSize and "(transferred ...)" is omitted.
|
||||
assert.Check(t, !strings.Contains(errOut, "(transferred"))
|
||||
}
|
||||
|
||||
func TestCopyToContainerStdinReportsTransferredSize(t *testing.T) {
|
||||
// When copying from stdin, content size is unknown.
|
||||
// The message should report transferred bytes without "(transferred ...)".
|
||||
r, w, _ := os.Pipe()
|
||||
_, _ = w.WriteString("some data from stdin")
|
||||
w.Close()
|
||||
oldStdin := os.Stdin
|
||||
os.Stdin = r
|
||||
t.Cleanup(func() { os.Stdin = oldStdin })
|
||||
|
||||
fakeCli := test.NewFakeCli(&fakeClient{
|
||||
containerStatPathFunc: func(containerID, path string) (client.ContainerStatPathResult, error) {
|
||||
return client.ContainerStatPathResult{
|
||||
Stat: container.PathStat{
|
||||
Name: "tmp",
|
||||
Mode: os.ModeDir | 0o755,
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
containerCopyToFunc: func(containerID string, options client.CopyToContainerOptions) (client.CopyToContainerResult, error) {
|
||||
_, _ = io.Copy(io.Discard, options.Content)
|
||||
return client.CopyToContainerResult{}, nil
|
||||
},
|
||||
})
|
||||
err := runCopy(context.TODO(), fakeCli, copyOptions{
|
||||
source: "-",
|
||||
destination: "container:/tmp",
|
||||
})
|
||||
assert.NilError(t, err)
|
||||
errOut := fakeCli.ErrBuffer().String()
|
||||
assert.Check(t, is.Contains(errOut, "Successfully copied"))
|
||||
// stdin has no content size, so reportedSize == copiedSize and
|
||||
// "(transferred ...)" should not appear.
|
||||
assert.Check(t, !strings.Contains(errOut, "(transferred"))
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package container
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/docker/cli/cli/command/formatter"
|
||||
@@ -111,31 +112,27 @@ func NewStatsFormat(source, osType string) formatter.Format {
|
||||
return formatter.Format(source)
|
||||
}
|
||||
|
||||
// NewStats returns a new Stats entity and sets in it the given name
|
||||
func NewStats(container string) *Stats {
|
||||
return &Stats{StatsEntry: StatsEntry{Container: container}}
|
||||
// NewStats returns a new Stats entity using the given ID, ID-prefix, or
|
||||
// name to resolve the container.
|
||||
func NewStats(idOrName string) *Stats {
|
||||
// FIXME(thaJeztah): "idOrName" is used for fuzzy-matching the container, which can result in multiple stats for the same container.
|
||||
// We should resolve the canonical ID once, then use that as reference
|
||||
// to prevent duplicates. Various parts in the code compare Container
|
||||
// against "ID" only (not considering "name" or "ID-prefix").
|
||||
return &Stats{StatsEntry: StatsEntry{Container: idOrName}}
|
||||
}
|
||||
|
||||
// statsFormatWrite renders the context for a list of containers statistics
|
||||
func statsFormatWrite(ctx formatter.Context, stats []StatsEntry, osType string, trunc bool) error {
|
||||
render := func(format func(subContext formatter.SubContext) error) error {
|
||||
for _, cstats := range stats {
|
||||
statsCtx := &statsContext{
|
||||
s: cstats,
|
||||
os: osType,
|
||||
trunc: trunc,
|
||||
}
|
||||
if err := format(statsCtx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// TODO(thaJeztah): this should be taken from the (first) StatsEntry instead.
|
||||
// also, assuming all stats are for the same platform (and basing the
|
||||
// column headers on that) won't allow aggregated results, which could
|
||||
// be mixed platform.
|
||||
memUsage := memUseHeader
|
||||
if osType == winOSType {
|
||||
memUsage = winMemUseHeader
|
||||
}
|
||||
statsCtx := statsContext{}
|
||||
statsCtx := statsContext{os: osType}
|
||||
statsCtx.Header = formatter.SubHeaderContext{
|
||||
"Container": containerHeader,
|
||||
"Name": formatter.NameHeader,
|
||||
@@ -147,8 +144,18 @@ func statsFormatWrite(ctx formatter.Context, stats []StatsEntry, osType string,
|
||||
"BlockIO": blockIOHeader,
|
||||
"PIDs": pidsHeader,
|
||||
}
|
||||
statsCtx.os = osType
|
||||
return ctx.Write(&statsCtx, render)
|
||||
return ctx.Write(&statsCtx, func(format func(subContext formatter.SubContext) error) error {
|
||||
for _, cstats := range stats {
|
||||
if err := format(&statsContext{
|
||||
s: cstats,
|
||||
os: osType,
|
||||
trunc: trunc,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
type statsContext struct {
|
||||
@@ -167,9 +174,9 @@ func (c *statsContext) Container() string {
|
||||
}
|
||||
|
||||
func (c *statsContext) Name() string {
|
||||
// TODO(thaJeztah): make this explicitly trim the "/" prefix, not just any char.
|
||||
if len(c.s.Name) > 1 {
|
||||
return c.s.Name[1:]
|
||||
// Trim the "/" prefix (if present).
|
||||
if name := strings.TrimPrefix(c.s.Name, "/"); name != "" {
|
||||
return name
|
||||
}
|
||||
return noValue
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package container
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package container
|
||||
|
||||
|
||||
@@ -27,6 +27,11 @@ type rmOptions struct {
|
||||
func newRmCommand(dockerCLI command.Cli) *cobra.Command {
|
||||
var opts rmOptions
|
||||
|
||||
completeLinkNames := completeLinks(dockerCLI)
|
||||
completeNames := completion.ContainerNames(dockerCLI, true, func(ctr container.Summary) bool {
|
||||
return opts.force || ctr.State == container.StateExited || ctr.State == container.StateCreated
|
||||
})
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "rm [OPTIONS] CONTAINER [CONTAINER...]",
|
||||
Short: "Remove one or more containers",
|
||||
@@ -38,9 +43,13 @@ func newRmCommand(dockerCLI command.Cli) *cobra.Command {
|
||||
Annotations: map[string]string{
|
||||
"aliases": "docker container rm, docker container remove, docker rm",
|
||||
},
|
||||
ValidArgsFunction: completion.ContainerNames(dockerCLI, true, func(ctr container.Summary) bool {
|
||||
return opts.force || ctr.State == container.StateExited || ctr.State == container.StateCreated
|
||||
}),
|
||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
if opts.rmLink {
|
||||
// "--link" (remove link) is set; provide link names instead of container (primary) names.
|
||||
return completeLinkNames(cmd, args, toComplete)
|
||||
}
|
||||
return completeNames(cmd, args, toComplete)
|
||||
},
|
||||
DisableFlagsInUseLine: true,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package container
|
||||
|
||||
@@ -7,9 +7,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -144,39 +142,32 @@ func RunStats(ctx context.Context, dockerCLI command.Cli, options *StatsOptions)
|
||||
}
|
||||
|
||||
eh := newEventHandler()
|
||||
addEvents := []events.Action{events.ActionStart}
|
||||
if options.All {
|
||||
eh.setHandler(events.ActionCreate, func(e events.Message) {
|
||||
if s := NewStats(e.Actor.ID); cStats.add(s) {
|
||||
waitFirst.Add(1)
|
||||
log.G(ctx).WithFields(log.Fields{
|
||||
"event": e.Action,
|
||||
"container": e.Actor.ID,
|
||||
}).Debug("collecting stats for container")
|
||||
go collect(ctx, s, apiClient, !options.NoStream, waitFirst)
|
||||
}
|
||||
})
|
||||
addEvents = append(addEvents, events.ActionCreate)
|
||||
}
|
||||
|
||||
eh.setHandler(events.ActionStart, func(e events.Message) {
|
||||
eh.setHandler(addEvents, func(ctx context.Context, e events.Message) {
|
||||
if s := NewStats(e.Actor.ID); cStats.add(s) {
|
||||
waitFirst.Add(1)
|
||||
log.G(ctx).WithFields(log.Fields{
|
||||
"event": e.Action,
|
||||
"container": e.Actor.ID,
|
||||
}).Debug("collecting stats for container")
|
||||
log.G(ctx).Debug("collecting stats for container")
|
||||
go collect(ctx, s, apiClient, !options.NoStream, waitFirst)
|
||||
}
|
||||
})
|
||||
|
||||
// Remove containers when they are removed ("destroyed"); containers
|
||||
// do not emit [events.ActionRemove], only [events.ActionDestroy].
|
||||
//
|
||||
// When running with "--all" we don't remove containers when they die,
|
||||
// because they may come back, but without "--all" we remove them
|
||||
// on the first possible occasion (either "die" or "destroy").
|
||||
rmEvents := []events.Action{events.ActionDestroy}
|
||||
if !options.All {
|
||||
eh.setHandler(events.ActionDie, func(e events.Message) {
|
||||
log.G(ctx).WithFields(log.Fields{
|
||||
"event": e.Action,
|
||||
"container": e.Actor.ID,
|
||||
}).Debug("stop collecting stats for container")
|
||||
cStats.remove(e.Actor.ID)
|
||||
})
|
||||
rmEvents = append(rmEvents, events.ActionDie)
|
||||
}
|
||||
eh.setHandler(rmEvents, func(ctx context.Context, e events.Message) {
|
||||
log.G(ctx).Debug("stop collecting stats for container")
|
||||
cStats.remove(e.Actor.ID)
|
||||
})
|
||||
|
||||
// monitorContainerEvents watches for container creation and removal (only
|
||||
// used when calling `docker stats` without arguments).
|
||||
@@ -216,7 +207,7 @@ func RunStats(ctx context.Context, dockerCLI command.Cli, options *StatsOptions)
|
||||
}
|
||||
|
||||
eventChan := make(chan events.Message)
|
||||
go eh.watch(eventChan)
|
||||
go eh.watch(ctx, eventChan)
|
||||
stopped := make(chan struct{})
|
||||
go monitorContainerEvents(started, eventChan, stopped)
|
||||
defer close(stopped)
|
||||
@@ -294,30 +285,32 @@ func RunStats(ctx context.Context, dockerCLI command.Cli, options *StatsOptions)
|
||||
}
|
||||
}
|
||||
|
||||
// Buffer to store formatted stats text.
|
||||
// Once formatted, it will be printed in one write to avoid screen flickering.
|
||||
var statsTextBuffer bytes.Buffer
|
||||
// renderBuf holds the formatted stats output produced by statsFormatWrite.
|
||||
// It does not include any terminal control sequences.
|
||||
var renderBuf bytes.Buffer
|
||||
|
||||
// frameBuf holds the final terminal frame, including cursor movement and
|
||||
// line-clearing escape sequences, written in a single pass to avoid flicker.
|
||||
var frameBuf bytes.Buffer
|
||||
|
||||
statsCtx := formatter.Context{
|
||||
Output: &statsTextBuffer,
|
||||
Output: &renderBuf,
|
||||
Format: NewStatsFormat(format, daemonOSType),
|
||||
}
|
||||
|
||||
if options.NoStream {
|
||||
cStats.mu.RLock()
|
||||
ccStats := make([]StatsEntry, 0, len(cStats.cs))
|
||||
for _, c := range cStats.cs {
|
||||
ccStats = append(ccStats, c.GetStatistics())
|
||||
}
|
||||
cStats.mu.RUnlock()
|
||||
|
||||
if len(ccStats) == 0 {
|
||||
statsList := cStats.snapshot()
|
||||
if len(statsList) == 0 {
|
||||
return nil
|
||||
}
|
||||
ccStats := make([]StatsEntry, 0, len(statsList))
|
||||
for _, c := range statsList {
|
||||
ccStats = append(ccStats, c.GetStatistics())
|
||||
}
|
||||
if err := statsFormatWrite(statsCtx, ccStats, daemonOSType, !options.NoTrunc); err != nil {
|
||||
return err
|
||||
}
|
||||
_, _ = fmt.Fprint(dockerCLI.Out(), statsTextBuffer.String())
|
||||
_, _ = dockerCLI.Out().Write(renderBuf.Bytes())
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -326,34 +319,38 @@ func RunStats(ctx context.Context, dockerCLI command.Cli, options *StatsOptions)
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
cStats.mu.RLock()
|
||||
ccStats := make([]StatsEntry, 0, len(cStats.cs))
|
||||
for _, c := range cStats.cs {
|
||||
renderBuf.Reset()
|
||||
frameBuf.Reset()
|
||||
statsList := cStats.snapshot()
|
||||
if len(statsList) == 0 && !showAll {
|
||||
// Clear screen
|
||||
_, _ = io.WriteString(dockerCLI.Out(), "\033[H\033[J")
|
||||
return nil
|
||||
}
|
||||
ccStats := make([]StatsEntry, 0, len(statsList))
|
||||
for _, c := range statsList {
|
||||
ccStats = append(ccStats, c.GetStatistics())
|
||||
}
|
||||
cStats.mu.RUnlock()
|
||||
|
||||
// Start by moving the cursor to the top-left
|
||||
_, _ = fmt.Fprint(&statsTextBuffer, "\033[H")
|
||||
|
||||
if err := statsFormatWrite(statsCtx, ccStats, daemonOSType, !options.NoTrunc); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for line := range strings.SplitSeq(statsTextBuffer.String(), "\n") {
|
||||
// Start by moving the cursor to the top-left
|
||||
_, _ = io.WriteString(&frameBuf, "\033[H")
|
||||
|
||||
// TODO(thaJeztah): consider wrapping the writer to inject ANSI (line-clearing) during formatting.
|
||||
// instead of post-processing the results.
|
||||
for line := range bytes.SplitSeq(renderBuf.Bytes(), []byte{'\n'}) {
|
||||
// In case the new text is shorter than the one we are writing over,
|
||||
// we'll append the "erase line" escape sequence to clear the remaining text.
|
||||
_, _ = fmt.Fprintln(&statsTextBuffer, line, "\033[K")
|
||||
_, _ = frameBuf.Write(line)
|
||||
_, _ = io.WriteString(&frameBuf, "\033[K")
|
||||
_ = frameBuf.WriteByte('\n')
|
||||
}
|
||||
// We might have fewer containers than before, so let's clear the remaining text
|
||||
_, _ = fmt.Fprint(&statsTextBuffer, "\033[J")
|
||||
|
||||
_, _ = fmt.Fprint(dockerCLI.Out(), statsTextBuffer.String())
|
||||
statsTextBuffer.Reset()
|
||||
|
||||
if len(ccStats) == 0 && !showAll {
|
||||
return nil
|
||||
}
|
||||
_, _ = io.WriteString(&frameBuf, "\033[J")
|
||||
_, _ = dockerCLI.Out().Write(frameBuf.Bytes())
|
||||
case err, ok := <-closeChan:
|
||||
if !ok || err == nil || errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
// Suppress "unexpected EOF" errors in the CLI so that
|
||||
@@ -369,33 +366,38 @@ func RunStats(ctx context.Context, dockerCLI command.Cli, options *StatsOptions)
|
||||
|
||||
// newEventHandler initializes and returns an eventHandler
|
||||
func newEventHandler() *eventHandler {
|
||||
return &eventHandler{handlers: make(map[events.Action]func(events.Message))}
|
||||
return &eventHandler{handlers: make(map[events.Action]func(context.Context, events.Message))}
|
||||
}
|
||||
|
||||
// eventHandler allows for registering specific events to setHandler.
|
||||
type eventHandler struct {
|
||||
handlers map[events.Action]func(events.Message)
|
||||
handlers map[events.Action]func(context.Context, events.Message)
|
||||
}
|
||||
|
||||
func (eh *eventHandler) setHandler(action events.Action, handler func(events.Message)) {
|
||||
eh.handlers[action] = handler
|
||||
func (eh *eventHandler) setHandler(actions []events.Action, handler func(context.Context, events.Message)) {
|
||||
for _, action := range actions {
|
||||
eh.handlers[action] = handler
|
||||
}
|
||||
}
|
||||
|
||||
// watch ranges over the passed in event chan and processes the events based on the
|
||||
// handlers created for a given action.
|
||||
// To stop watching, close the event chan.
|
||||
func (eh *eventHandler) watch(c <-chan events.Message) {
|
||||
func (eh *eventHandler) watch(ctx context.Context, c <-chan events.Message) {
|
||||
for e := range c {
|
||||
h, exists := eh.handlers[e.Action]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
if e.Actor.ID == "" {
|
||||
log.G(context.TODO()).WithField("event", e).Errorf("event handler: received %s event with empty ID", e.Action)
|
||||
log.G(ctx).WithField("event", e).Errorf("event handler: received %s event with empty ID", e.Action)
|
||||
continue
|
||||
}
|
||||
logger := log.G(ctx).WithFields(log.Fields{
|
||||
"event": e.Action,
|
||||
"container": e.Actor.ID,
|
||||
})
|
||||
|
||||
log.G(context.TODO()).WithField("event", e).Debugf("event handler: received %s event for: %s", e.Action, e.Actor.ID)
|
||||
go h(e)
|
||||
go h(log.WithLogger(ctx, logger), e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,22 @@ func (s *stats) isKnownContainer(cid string) (int, bool) {
|
||||
return -1, false
|
||||
}
|
||||
|
||||
// snapshot returns a point-in-time copy of the tracked container list
|
||||
// (the slice of *Stats pointers). The returned slice is safe for use
|
||||
// without holding the stats lock, but the underlying Stats values may
|
||||
// continue to change concurrently.
|
||||
func (s *stats) snapshot() []*Stats {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if len(s.cs) == 0 {
|
||||
return nil
|
||||
}
|
||||
// https://github.com/golang/go/issues/53643
|
||||
cp := make([]*Stats, len(s.cs))
|
||||
copy(cp, s.cs)
|
||||
return cp
|
||||
}
|
||||
|
||||
func collect(ctx context.Context, s *Stats, cli client.ContainerAPIClient, streamStats bool, waitFirst *sync.WaitGroup) { //nolint:gocyclo
|
||||
var getFirst bool
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package container
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package command
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package context
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package context
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package context
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package context
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package context
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package command
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package command
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package command
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package formatter
|
||||
|
||||
@@ -141,25 +141,36 @@ func (c *ContainerContext) ID() string {
|
||||
|
||||
// Names returns a comma-separated string of the container's names, with their
|
||||
// slash (/) prefix stripped. Additional names for the container (related to the
|
||||
// legacy `--link` feature) are omitted.
|
||||
// legacy `--link` feature) are omitted when formatting "truncated".
|
||||
func (c *ContainerContext) Names() string {
|
||||
names := StripNamePrefix(c.c.Names)
|
||||
if c.trunc {
|
||||
for _, name := range names {
|
||||
if len(strings.Split(name, "/")) == 1 {
|
||||
names = []string{name}
|
||||
break
|
||||
var b strings.Builder
|
||||
for i, n := range c.c.Names {
|
||||
name := strings.TrimPrefix(n, "/")
|
||||
if c.trunc {
|
||||
// When printing truncated, we only print a single name.
|
||||
//
|
||||
// Pick the first name that's not a legacy link (does not have
|
||||
// slashes inside the name itself (e.g., "/other-container/link")).
|
||||
// Normally this would be the first name found.
|
||||
if strings.IndexByte(name, '/') == -1 {
|
||||
return name
|
||||
}
|
||||
continue
|
||||
}
|
||||
if i > 0 {
|
||||
b.WriteByte(',')
|
||||
}
|
||||
b.WriteString(name)
|
||||
}
|
||||
return strings.Join(names, ",")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// StripNamePrefix removes prefix from string, typically container names as returned by `ContainersList` API
|
||||
// StripNamePrefix removes any "/" prefix from container names returned
|
||||
// by the "ContainersList" API.
|
||||
func StripNamePrefix(ss []string) []string {
|
||||
sss := make([]string, len(ss))
|
||||
for i, s := range ss {
|
||||
sss[i] = s[1:]
|
||||
sss[i] = strings.TrimPrefix(s, "/")
|
||||
}
|
||||
return sss
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package formatter
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package formatter
|
||||
|
||||
|
||||
@@ -46,7 +46,6 @@ func (ctx *DiskUsageContext) startSubsection(format Format) (*template.Template,
|
||||
ctx.buffer = &bytes.Buffer{}
|
||||
ctx.header = ""
|
||||
ctx.Format = format
|
||||
ctx.preFormat()
|
||||
|
||||
return ctx.parseFormat()
|
||||
}
|
||||
@@ -88,7 +87,6 @@ func (ctx *DiskUsageContext) Write() (err error) {
|
||||
return ctx.verboseWrite()
|
||||
}
|
||||
ctx.buffer = &bytes.Buffer{}
|
||||
ctx.preFormat()
|
||||
|
||||
tmpl, err := ctx.parseFormat()
|
||||
if err != nil {
|
||||
@@ -213,7 +211,6 @@ func (ctx *DiskUsageContext) verboseWrite() error {
|
||||
return ctx.verboseWriteTable(duc)
|
||||
}
|
||||
|
||||
ctx.preFormat()
|
||||
tmpl, err := ctx.parseFormat()
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package formatter
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package formatter
|
||||
|
||||
@@ -33,7 +33,7 @@ func (f Format) IsTable() bool {
|
||||
return strings.HasPrefix(string(f), TableFormatKey)
|
||||
}
|
||||
|
||||
// IsJSON returns true if the format is the json format
|
||||
// IsJSON returns true if the format is the JSON format
|
||||
func (f Format) IsJSON() bool {
|
||||
return string(f) == JSONFormatKey
|
||||
}
|
||||
@@ -43,6 +43,31 @@ func (f Format) Contains(sub string) bool {
|
||||
return strings.Contains(string(f), sub)
|
||||
}
|
||||
|
||||
// templateString pre-processes the format and returns it as a string
|
||||
// for templating.
|
||||
func (f Format) templateString() string {
|
||||
out := string(f)
|
||||
switch out {
|
||||
case TableFormatKey:
|
||||
// A bare "--format table" should already be handled before we
|
||||
// hit this; a literal "table" here means a custom "table" format
|
||||
// without template.
|
||||
return ""
|
||||
case JSONFormatKey:
|
||||
// "--format json" only; not JSON formats ("--format '{{json .Field}}'").
|
||||
return JSONFormat
|
||||
}
|
||||
|
||||
// "--format 'table {{.Field}}\t{{.Field}}'" -> "{{.Field}}\t{{.Field}}"
|
||||
if after, isTable := strings.CutPrefix(out, TableFormatKey); isTable {
|
||||
out = after
|
||||
}
|
||||
|
||||
out = strings.Trim(out, " ") // trim spaces, but preserve other whitespace.
|
||||
out = strings.NewReplacer(`\t`, "\t", `\n`, "\n").Replace(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// Context contains information required by the formatter to print the output as desired.
|
||||
type Context struct {
|
||||
// Output is the output stream to which the formatted string is written.
|
||||
@@ -53,28 +78,12 @@ type Context struct {
|
||||
Trunc bool
|
||||
|
||||
// internal element
|
||||
finalFormat string
|
||||
header any
|
||||
buffer *bytes.Buffer
|
||||
}
|
||||
|
||||
func (c *Context) preFormat() {
|
||||
c.finalFormat = string(c.Format)
|
||||
// TODO: handle this in the Format type
|
||||
switch {
|
||||
case c.Format.IsTable():
|
||||
c.finalFormat = c.finalFormat[len(TableFormatKey):]
|
||||
case c.Format.IsJSON():
|
||||
c.finalFormat = JSONFormat
|
||||
}
|
||||
|
||||
c.finalFormat = strings.Trim(c.finalFormat, " ")
|
||||
r := strings.NewReplacer(`\t`, "\t", `\n`, "\n")
|
||||
c.finalFormat = r.Replace(c.finalFormat)
|
||||
header any
|
||||
buffer *bytes.Buffer
|
||||
}
|
||||
|
||||
func (c *Context) parseFormat() (*template.Template, error) {
|
||||
tmpl, err := templates.Parse(c.finalFormat)
|
||||
tmpl, err := templates.Parse(c.Format.templateString())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("template parsing error: %w", err)
|
||||
}
|
||||
@@ -82,20 +91,21 @@ func (c *Context) parseFormat() (*template.Template, error) {
|
||||
}
|
||||
|
||||
func (c *Context) postFormat(tmpl *template.Template, subContext SubContext) {
|
||||
if c.Output == nil {
|
||||
c.Output = io.Discard
|
||||
out := c.Output
|
||||
if out == nil {
|
||||
out = io.Discard
|
||||
}
|
||||
if c.Format.IsTable() {
|
||||
t := tabwriter.NewWriter(c.Output, 10, 1, 3, ' ', 0)
|
||||
buffer := bytes.NewBufferString("")
|
||||
tmpl.Funcs(templates.HeaderFunctions).Execute(buffer, subContext.FullHeader())
|
||||
buffer.WriteTo(t)
|
||||
t.Write([]byte("\n"))
|
||||
c.buffer.WriteTo(t)
|
||||
t.Flush()
|
||||
} else {
|
||||
c.buffer.WriteTo(c.Output)
|
||||
if !c.Format.IsTable() {
|
||||
_, _ = c.buffer.WriteTo(out)
|
||||
return
|
||||
}
|
||||
|
||||
// Write column-headers and rows to the tab-writer buffer, then flush the output.
|
||||
tw := tabwriter.NewWriter(out, 10, 1, 3, ' ', 0)
|
||||
_ = tmpl.Funcs(templates.HeaderFunctions).Execute(tw, subContext.FullHeader())
|
||||
_, _ = tw.Write([]byte{'\n'})
|
||||
_, _ = c.buffer.WriteTo(tw)
|
||||
_ = tw.Flush()
|
||||
}
|
||||
|
||||
func (c *Context) contextFormat(tmpl *template.Template, subContext SubContext) error {
|
||||
@@ -115,8 +125,6 @@ type SubFormat func(func(SubContext) error) error
|
||||
// Write the template to the buffer using this Context
|
||||
func (c *Context) Write(sub SubContext, f SubFormat) error {
|
||||
c.buffer = &bytes.Buffer{}
|
||||
c.preFormat()
|
||||
|
||||
tmpl, err := c.parseFormat()
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package formatter
|
||||
|
||||
@@ -8,20 +8,75 @@ import (
|
||||
"testing"
|
||||
|
||||
"gotest.tools/v3/assert"
|
||||
is "gotest.tools/v3/assert/cmp"
|
||||
)
|
||||
|
||||
func TestFormat(t *testing.T) {
|
||||
f := Format("json")
|
||||
assert.Assert(t, f.IsJSON())
|
||||
assert.Assert(t, !f.IsTable())
|
||||
tests := []struct {
|
||||
doc string
|
||||
f Format
|
||||
isJSON bool
|
||||
isTable bool
|
||||
template string
|
||||
}{
|
||||
{
|
||||
doc: "json format",
|
||||
f: "json",
|
||||
isJSON: true,
|
||||
isTable: false,
|
||||
template: JSONFormat,
|
||||
},
|
||||
{
|
||||
doc: "empty table format (no template)",
|
||||
f: "table",
|
||||
isJSON: false,
|
||||
isTable: true,
|
||||
template: "",
|
||||
},
|
||||
{
|
||||
doc: "table with escaped tabs",
|
||||
f: "table {{.Field}}\\t{{.Field2}}",
|
||||
isJSON: false,
|
||||
isTable: true,
|
||||
template: "{{.Field}}\t{{.Field2}}",
|
||||
},
|
||||
{
|
||||
doc: "table with raw string",
|
||||
f: `table {{.Field}}\t{{.Field2}}`,
|
||||
isJSON: false,
|
||||
isTable: true,
|
||||
template: "{{.Field}}\t{{.Field2}}",
|
||||
},
|
||||
{
|
||||
doc: "other format",
|
||||
f: "other",
|
||||
isJSON: false,
|
||||
isTable: false,
|
||||
template: "other",
|
||||
},
|
||||
{
|
||||
doc: "other with spaces",
|
||||
f: " other ",
|
||||
isJSON: false,
|
||||
isTable: false,
|
||||
template: "other",
|
||||
},
|
||||
{
|
||||
doc: "other with newline preserved",
|
||||
f: " other\n ",
|
||||
isJSON: false,
|
||||
isTable: false,
|
||||
template: "other\n",
|
||||
},
|
||||
}
|
||||
|
||||
f = Format("table")
|
||||
assert.Assert(t, !f.IsJSON())
|
||||
assert.Assert(t, f.IsTable())
|
||||
|
||||
f = Format("other")
|
||||
assert.Assert(t, !f.IsJSON())
|
||||
assert.Assert(t, !f.IsTable())
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.doc, func(t *testing.T) {
|
||||
assert.Check(t, is.Equal(tc.f.IsJSON(), tc.isJSON))
|
||||
assert.Check(t, is.Equal(tc.f.IsTable(), tc.isTable))
|
||||
assert.Check(t, is.Equal(tc.f.templateString(), tc.template))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type fakeSubContext struct {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package formatter
|
||||
|
||||
@@ -24,7 +24,7 @@ func MarshalJSON(x any) ([]byte, error) {
|
||||
// marshalMap marshals x to map[string]any
|
||||
func marshalMap(x any) (map[string]any, error) {
|
||||
val := reflect.ValueOf(x)
|
||||
if val.Kind() != reflect.Ptr {
|
||||
if val.Kind() != reflect.Pointer {
|
||||
return nil, fmt.Errorf("expected a pointer to a struct, got %v", val.Kind())
|
||||
}
|
||||
if val.IsNil() {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package formatter
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package formatter
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package idresolver
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ func TestGetContextFromLocalDirWithNoDirectory(t *testing.T) {
|
||||
contextDir := createTestTempDir(t)
|
||||
createTestTempFile(t, contextDir, defaultDockerfileName, dockerfileContents)
|
||||
|
||||
chdir(t, contextDir)
|
||||
t.Chdir(contextDir)
|
||||
|
||||
absContextDir, relDockerfile, err := GetContextFromLocalDir(contextDir, "")
|
||||
assert.NilError(t, err)
|
||||
@@ -110,7 +110,7 @@ func TestGetContextFromLocalDirLocalFile(t *testing.T) {
|
||||
|
||||
func TestGetContextFromLocalDirWithCustomDockerfile(t *testing.T) {
|
||||
contextDir := createTestTempDir(t)
|
||||
chdir(t, contextDir)
|
||||
t.Chdir(contextDir)
|
||||
|
||||
createTestTempFile(t, contextDir, defaultDockerfileName, dockerfileContents)
|
||||
|
||||
@@ -248,20 +248,6 @@ func createTestTempFile(t *testing.T, dir, filename, contents string) string {
|
||||
return filePath
|
||||
}
|
||||
|
||||
// chdir changes current working directory to dir.
|
||||
// It returns a function which changes working directory back to the previous one.
|
||||
// This function is meant to be executed as a deferred call.
|
||||
// When an error occurs, it terminates the test.
|
||||
func chdir(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
workingDirectory, err := os.Getwd()
|
||||
assert.NilError(t, err)
|
||||
assert.NilError(t, os.Chdir(dir))
|
||||
t.Cleanup(func() {
|
||||
assert.NilError(t, os.Chdir(workingDirectory))
|
||||
})
|
||||
}
|
||||
|
||||
func TestIsArchive(t *testing.T) {
|
||||
tests := []struct {
|
||||
doc string
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package image
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package image
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package image
|
||||
|
||||
|
||||
+25
-17
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package image
|
||||
|
||||
@@ -206,30 +206,38 @@ var allChips = []imageChip{
|
||||
chipInUse,
|
||||
}
|
||||
|
||||
// getPossibleChips returns the list of chips used by at least one image.
|
||||
// It is used to determine which columns to print (and how much width to
|
||||
// reserve).
|
||||
func getPossibleChips(view treeView) (chips []imageChip) {
|
||||
remaining := make([]imageChip, len(allChips))
|
||||
copy(remaining, allChips)
|
||||
remaining := slices.Clone(allChips)
|
||||
|
||||
var possible []imageChip
|
||||
for _, img := range view.images {
|
||||
details := []imageDetails{img.Details}
|
||||
|
||||
for _, c := range img.Children {
|
||||
details = append(details, c.Details)
|
||||
check := func(d imageDetails) (done bool) {
|
||||
// filter without allocating
|
||||
out := remaining[:0]
|
||||
for _, chip := range remaining {
|
||||
if chip.check(&d) {
|
||||
chips = append(chips, chip)
|
||||
continue
|
||||
}
|
||||
out = append(out, chip)
|
||||
}
|
||||
remaining = out
|
||||
return len(remaining) == 0
|
||||
}
|
||||
|
||||
for _, d := range details {
|
||||
for idx := len(remaining) - 1; idx >= 0; idx-- {
|
||||
chip := remaining[idx]
|
||||
if chip.check(&d) {
|
||||
possible = append(possible, chip)
|
||||
remaining = append(remaining[:idx], remaining[idx+1:]...)
|
||||
}
|
||||
for _, img := range view.images {
|
||||
if check(img.Details) {
|
||||
return chips
|
||||
}
|
||||
for _, c := range img.Children {
|
||||
if check(c.Details) {
|
||||
return chips
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return possible
|
||||
return chips
|
||||
}
|
||||
|
||||
func printImageTree(outs command.Streams, view treeView) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package inspect
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package manifest
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package network
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package network
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package node
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package node
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package node
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package plugin
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package plugin
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package secret
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package service
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package service
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package service
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
// Package genericresource is a local fork of SwarmKit's [genericresource] package,
|
||||
// without protobuf dependencies.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package service
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package progress
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package service
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package stack
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package system
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package system
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package system
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
// Package pruner registers "prune" functions to be included as part of
|
||||
// "docker system prune".
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package command
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package command
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package volume
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package volume
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package volume
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package convert
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package interpolation
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package interpolation
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package loader
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package loader
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package loader
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package loader
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package loader
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package loader
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.24
|
||||
//go:build go1.25
|
||||
|
||||
package schema
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user