Compare commits

..
Author SHA1 Message Date
Paweł Gronowski d260a54c81 Merge pull request #5007 from vvoland/vendor-docker
[26.0] vendor: github.com/docker/docker v26.0.1-dev (60b9add796ae)
2024-04-11 12:45:30 +02:00
Paweł Gronowski 3369ffe3e5 vendor: github.com/docker/docker v26.0.1-dev (60b9add796ae)
full diff: https://github.com/docker/docker/compare/v26.0.0...60b9add796ae

Signed-off-by: Paweł Gronowski <pawel.gronowski@docker.com>
2024-04-11 12:03:04 +02:00
Paweł Gronowski 3cf84fbc5b Merge pull request #5006 from vvoland/v26.0-5005
[26.0 backport] cli-bin/windows: Add .exe extension
2024-04-11 12:01:10 +02:00
Paweł Gronowski b1b03b3caa cli-bin/windows: Add .exe extension
Before this commit, the CLI binary in `dockereng/cli-bin` image was
named `docker` regardless of platform.

Change the binary name to `docker.exe` in Windows images.

Signed-off-by: Paweł Gronowski <pawel.gronowski@docker.com>
(cherry picked from commit 718203d50b)
Signed-off-by: Paweł Gronowski <pawel.gronowski@docker.com>
2024-04-11 11:35:51 +02:00
Paweł Gronowski 57d2fbb70e Merge pull request #4999 from thaJeztah/26.0_backport_bump_x_net
[26.0 backport] vendor: golang.org/x/sys v0.18.0, golang.org/x/term v0.18.0, golang.org/x/crypto v0.21.0, golang.org/x/net v0.23.0
2024-04-10 11:41:52 +02:00
Sebastiaan van Stijn c33cc928bb vendor: golang.org/x/net v0.23.0
full diff: https://github.com/golang/net/compare/v0.22.0...v0.23.0

Includes a fix for CVE-2023-45288, which is also addressed in go1.22.2
and go1.21.9;

> http2: close connections when receiving too many headers
>
> Maintaining HPACK state requires that we parse and process
> all HEADERS and CONTINUATION frames on a connection.
> When a request's headers exceed MaxHeaderBytes, we don't
> allocate memory to store the excess headers but we do
> parse them. This permits an attacker to cause an HTTP/2
> endpoint to read arbitrary amounts of data, all associated
> with a request which is going to be rejected.
>
> Set a limit on the amount of excess header frames we
> will process before closing a connection.
>
> Thanks to Bartek Nowotarski for reporting this issue.

Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
(cherry picked from commit 5fcbbde4b9)
Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
2024-04-10 09:41:15 +02:00
Sebastiaan van Stijn 156e20ca86 vendor: golang.org/x/net v0.22.0, golang.org/x/crypto v0.21.0
full diffs changes relevant to vendored code:

- https://github.com/golang/net/compare/v0.19.0...v0.22.0
    - http2: remove suspicious uint32->v conversion in frame code
    - http2: send an error of FLOW_CONTROL_ERROR when exceed the maximum octets
- https://github.com/golang/crypto/compare/v0.17.0...v0.21.0
    - (no changes in vendored code)

Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
(cherry picked from commit 4745b957d2)
Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
2024-04-10 09:41:04 +02:00
Sebastiaan van Stijn 7522a62d26 vendor: golang.org/x/term v0.18.0
no changes in vendored code

full diff: https://github.com/golang/term/compare/v0.15.0...v0.18.0

Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
(cherry picked from commit c7a50ebb9f)
Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
2024-04-10 09:39:02 +02:00
Sebastiaan van Stijn 073e4e850e vendor: golang.org/x/sys v0.18.0
full diff: https://github.com/golang/sys/compare/v0.16.0...v0.18.0

Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
(cherry picked from commit 9a2133f2d4)
Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
2024-04-10 09:37:34 +02:00
Laura Brehm 6d46ff7438 Merge pull request #4987 from vvoland/v26.0-4986
[26.0 backport] update to go1.21.9
2024-04-05 15:48:43 +01:00
Paweł Gronowski 1d214b009d update to go1.21.9
go1.21.9 (released 2024-04-03) includes a security fix to the net/http
package, as well as bug fixes to the linker, and the go/types and
net/http packages. See the Go 1.21.9 milestone on our issue tracker for
details.

- https://github.com/golang/go/issues?q=milestone%3AGo1.21.9+label%3ACherryPickApproved
- full diff: https://github.com/golang/go/compare/go1.21.8...go1.21.9

**- Description for the changelog**

```markdown changelog
Update Go runtime to 1.21.9
```

Signed-off-by: Paweł Gronowski <pawel.gronowski@docker.com>
(cherry picked from commit 0a5bd6c75b)
Signed-off-by: Paweł Gronowski <pawel.gronowski@docker.com>
2024-04-05 12:44:07 +02:00
Paweł Gronowski 3092f67d5d Merge pull request #4962 from vvoland/vendor-26.0-docker-v26.0.0
[26.0] vendor: github.com/docker/docker v26.0.0
2024-03-22 17:14:55 +01:00
Paweł Gronowski f01c09045d vendor: github.com/docker/docker v26.0.0
no changes in vendored files

full diff: https://github.com/docker/docker/compare/8b79278316b5...v26.0.0

Signed-off-by: Paweł Gronowski <pawel.gronowski@docker.com>
2024-03-22 11:44:53 +01:00
3422 changed files with 223314 additions and 308195 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
/build/ /build/
/cmd/docker/winresources/versioninfo.json /cli/winresources/versioninfo.json
/cmd/docker/winresources/*.syso /cli/winresources/*.syso
/man/man*/ /man/man*/
/man/vendor/ /man/vendor/
/man/go.sum /man/go.sum
-11
View File
@@ -1,14 +1,3 @@
* text=auto
Dockerfile* linguist-language=Dockerfile Dockerfile* linguist-language=Dockerfile
vendor.mod linguist-language=Go-Module vendor.mod linguist-language=Go-Module
vendor.sum linguist-language=Go-Checksums vendor.sum linguist-language=Go-Checksums
*.go -text diff=golang
# scripts directory contains shell scripts
# without extensions, so we need to force
scripts/** text=auto eol=lf
# shell scripts should always have LF
*.sh text eol=lf
+4 -3
View File
@@ -1,6 +1,7 @@
# GitHub code owners # GitHub code owners
# See https://github.com/blog/2392-introducing-code-owners # See https://github.com/blog/2392-introducing-code-owners
cli/command/stack/** @silvin-lubecki @docker/runtime-owners cli/command/stack/** @silvin-lubecki
contrib/completion/bash/** @albers @docker/runtime-owners contrib/completion/bash/** @albers
docs/** @thaJeztah @docker/runtime-owners contrib/completion/zsh/** @sdurrheimer
docs/** @thaJeztah
+2 -2
View File
@@ -8,12 +8,12 @@ body:
attributes: attributes:
value: | value: |
Thank you for taking the time to report a bug! Thank you for taking the time to report a bug!
If this is a security issue report it to the [Docker Security team](mailto:security@docker.com). If this is a security issue please report it to the [Docker Security team](mailto:security@docker.com).
- type: textarea - type: textarea
id: description id: description
attributes: attributes:
label: Description label: Description
description: Give a clear and concise description of the bug description: Please give a clear and concise description of the bug
validations: validations:
required: true required: true
- type: textarea - type: textarea
+4 -10
View File
@@ -1,17 +1,11 @@
blank_issues_enabled: false blank_issues_enabled: false
contact_links: contact_links:
- name: "🗃️ Docker Sandboxes: report an issue" - name: "Contributing to Docker"
about: "Issues with Docker Sandboxes should be filed in the Docker Desktop feedback tracker (not docker/cli)."
url: "https://github.com/docker/desktop-feedback/issues"
- name: "🖥️ Docker Desktop: report an issue"
about: "General Docker Desktop issues (installation, upgrades, UI, networking on macOS/Windows, WSL2, etc.) should be filed in the Docker Desktop feedback tracker (not docker/cli)."
url: "https://github.com/docker/desktop-feedback/issues"
- name: "🧑‍💻 Contributing to Docker"
about: "Read guidelines and tips about contributing to Docker." about: "Read guidelines and tips about contributing to Docker."
url: "https://github.com/docker/cli/blob/master/CONTRIBUTING.md" url: "https://github.com/docker/cli/blob/master/CONTRIBUTING.md"
- name: "🔒 Security and Vulnerabilities" - name: "Security and Vulnerabilities"
about: "Report any security issues or vulnerabilities responsibly to the Docker security team. Do not use the public issue tracker." about: "Please report any security issues or vulnerabilities responsibly to the Docker security team. Please do not use the public issue tracker."
url: "https://github.com/moby/moby/security/policy" url: "https://github.com/moby/moby/security/policy"
- name: "💬 General Support" - name: "General Support"
about: "Get the help you need to build, share, and run your Docker applications" about: "Get the help you need to build, share, and run your Docker applications"
url: "https://www.docker.com/support/" url: "https://www.docker.com/support/"
+15 -22
View File
@@ -2,36 +2,29 @@
Please make sure you've read and understood our contributing guidelines; Please make sure you've read and understood our contributing guidelines;
https://github.com/docker/cli/blob/master/CONTRIBUTING.md https://github.com/docker/cli/blob/master/CONTRIBUTING.md
Make sure commits are signed off (`git commit -s`) with your real name. ** Make sure all your commits include a signature generated with `git commit -s` **
If the PR relates to an existing issue or PR, mention it at the top: For additional information on our contributing process, read our contributing
guide https://docs.docker.com/opensource/code/
- Fixes: https://github.com/docker/cli/issues/12345678 If this is a bug fix, make sure your description includes "fixes #xxxx", or
- Replaces: https://github.com/docker/cli/pull/12345678 "closes #xxxx"
- Follow up to: https://github.com/docker/cli/pull/12345678
- Related to: https://github.com/docker/cli/issues/12345678 Please provide the following information:
--> -->
## Summary **- What I did**
## Release notes (optional) **- How I did it**
**- How to verify it**
**- Description for the changelog**
<!-- <!--
One-sentence user-facing release note. Write a short (one line) summary that describes the changes in this
Leave empty if the change is not changelog-worthy. pull request for inclusion in the changelog:
Use present tense and imperative tone.
Good:
- Fix a panic when running `docker top` on a non-running Windows container.
- Don't print warnings in `docker info` for broken symlinks in CLI plugin directories.
Bad:
- Refactor test TestFooWithBar
- Fixed a panic when running docker top
--> -->
```markdown changelog
``` **- A picture of a cute animal (not mandatory but encouraged)**
## A picture of a cute animal (not mandatory but encouraged)
+1 -10
View File
@@ -5,14 +5,5 @@ updates:
schedule: schedule:
interval: "daily" interval: "daily"
labels: labels:
- "area/ci" - "area/testing"
- "status/2-code-review" - "status/2-code-review"
cooldown:
default-days: 7
groups:
codeql-actions:
patterns:
- "github/codeql-action/*"
docker-actions:
patterns:
- "docker/*"
+63 -86
View File
@@ -1,14 +1,5 @@
name: build name: build
# Default to 'contents: read', which grants actions to read commits.
#
# If any permission is set, any permission not included in the list is
# implicitly set to "none".
#
# see https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#permissions
permissions:
contents: read
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: true
@@ -22,30 +13,19 @@ on:
branches: branches:
- 'master' - 'master'
- '[0-9]+.[0-9]+' - '[0-9]+.[0-9]+'
- '[0-9]+.x'
tags: tags:
- 'v*' - 'v*'
pull_request: pull_request:
jobs: jobs:
prepare: prepare:
runs-on: ubuntu-26.04 runs-on: ubuntu-22.04
outputs: outputs:
matrix: ${{ steps.platforms.outputs.matrix }} matrix: ${{ steps.platforms.outputs.matrix }}
steps: steps:
- name: Configure credentials
continue-on-error: true
uses: tracebit-com/tracebit-community-action@27de2ed8de16af5270d32003a4c8bfc3c54e0a20 # v1.2.0
with:
customer-id: ${{ secrets.TRACEBIT_CUSTOMER_ID }}
api-token: ${{ secrets.TRACEBIT_API_TOKEN }}
profile: administrator
profile-region: us-east-1
- -
name: Checkout name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@v4
with:
persist-credentials: false
- -
name: Create matrix name: Create matrix
id: platforms id: platforms
@@ -57,7 +37,7 @@ jobs:
echo ${{ steps.platforms.outputs.matrix }} echo ${{ steps.platforms.outputs.matrix }}
build: build:
runs-on: ubuntu-26.04 runs-on: ubuntu-22.04
needs: needs:
- prepare - prepare
strategy: strategy:
@@ -71,20 +51,17 @@ jobs:
- "" - ""
- glibc - glibc
steps: steps:
- name: Configure credentials -
continue-on-error: true name: Checkout
uses: tracebit-com/tracebit-community-action@27de2ed8de16af5270d32003a4c8bfc3c54e0a20 # v1.2.0 uses: actions/checkout@v4
with: with:
customer-id: ${{ secrets.TRACEBIT_CUSTOMER_ID }} fetch-depth: 0
api-token: ${{ secrets.TRACEBIT_API_TOKEN }}
profile: administrator
profile-region: us-east-1
- -
name: Set up Docker Buildx name: Set up Docker Buildx
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0 uses: docker/setup-buildx-action@v3
- -
name: Build name: Build
uses: docker/bake-action@018cb6412ab401ebaa809aa5f85966b74628600f # v7.4.0 uses: docker/bake-action@v4
with: with:
targets: ${{ matrix.target }} targets: ${{ matrix.target }}
set: | set: |
@@ -96,69 +73,74 @@ jobs:
working-directory: ./build working-directory: ./build
run: | run: |
mkdir /tmp/out mkdir /tmp/out
platformPair=${PLATFORM//\//-} platform=${{ matrix.platform }}
platformPair=${platform//\//-}
tar -cvzf "/tmp/out/docker-${platformPair}.tar.gz" . tar -cvzf "/tmp/out/docker-${platformPair}.tar.gz" .
if [ -z "${{ matrix.use_glibc }}" ]; then if [ -z "${{ matrix.use_glibc }}" ]; then
echo "ARTIFACT_NAME=${{ matrix.target }}-${platformPair}" >> $GITHUB_ENV echo "ARTIFACT_NAME=${{ matrix.target }}" >> $GITHUB_ENV
else else
echo "ARTIFACT_NAME=${{ matrix.target }}-${platformPair}-glibc" >> $GITHUB_ENV echo "ARTIFACT_NAME=${{ matrix.target }}-glibc" >> $GITHUB_ENV
fi fi
env:
PLATFORM: ${{ matrix.platform }}
- -
name: Upload artifacts name: Upload artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@v3
with: with:
name: ${{ env.ARTIFACT_NAME }} name: ${{ env.ARTIFACT_NAME }}
path: /tmp/out/* path: /tmp/out/*
if-no-files-found: error if-no-files-found: error
bin-image: bin-image:
runs-on: ubuntu-22.04
if: ${{ github.event_name != 'pull_request' && github.repository == 'docker/cli' }} if: ${{ github.event_name != 'pull_request' && github.repository == 'docker/cli' }}
uses: docker/github-builder/.github/workflows/bake.yml@58cb9f5b71b1836d6f690c1e95effdeb9b98cb8a # v1.17.0 steps:
permissions: -
contents: read # same as global permission name: Checkout
id-token: write # for signing attestation(s) and authenticating to Docker Hub with GitHub OIDC Token uses: actions/checkout@v4
with: -
setup-qemu: true name: Set up QEMU
target: bin-image-cross uses: docker/setup-qemu-action@v3
cache: true -
cache-scope: bin-image name: Set up Docker Buildx
output: image uses: docker/setup-buildx-action@v3
push: true -
vars: | name: Docker meta
VERSION=${{ github.ref }} id: meta
meta-images: | uses: docker/metadata-action@v5
dockereng/cli-bin with:
meta-tags: | images: dockereng/cli-bin
type=semver,pattern={{version}} tags: |
type=ref,event=branch type=semver,pattern={{version}}
type=ref,event=pr type=ref,event=branch
type=semver,pattern={{major}} type=ref,event=pr
type=semver,pattern={{major}}.{{minor}} type=sha
registry-identities: | -
- type: dockerhub name: Login to DockerHub
username: dockereng if: github.event_name != 'pull_request'
connection_id: ${{ vars.DOCKERHUB_OIDC_CONNECTIONID }} uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_CLIBIN_USERNAME }}
password: ${{ secrets.DOCKERHUB_CLIBIN_TOKEN }}
-
name: Build and push image
uses: docker/bake-action@v4
with:
files: |
./docker-bake.hcl
${{ steps.meta.outputs.bake-file }}
targets: bin-image-cross
push: ${{ github.event_name != 'pull_request' }}
set: |
*.cache-from=type=gha,scope=bin-image
*.cache-to=type=gha,scope=bin-image,mode=max
prepare-plugins: prepare-plugins:
runs-on: ubuntu-26.04 runs-on: ubuntu-22.04
outputs: outputs:
matrix: ${{ steps.platforms.outputs.matrix }} matrix: ${{ steps.platforms.outputs.matrix }}
steps: steps:
- name: Configure credentials
continue-on-error: true
uses: tracebit-com/tracebit-community-action@27de2ed8de16af5270d32003a4c8bfc3c54e0a20 # v1.2.0
with:
customer-id: ${{ secrets.TRACEBIT_CUSTOMER_ID }}
api-token: ${{ secrets.TRACEBIT_API_TOKEN }}
profile: administrator
profile-region: us-east-1
- -
name: Checkout name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@v4
with:
persist-credentials: false
- -
name: Create matrix name: Create matrix
id: platforms id: platforms
@@ -170,7 +152,7 @@ jobs:
echo ${{ steps.platforms.outputs.matrix }} echo ${{ steps.platforms.outputs.matrix }}
plugins: plugins:
runs-on: ubuntu-26.04 runs-on: ubuntu-22.04
needs: needs:
- prepare-plugins - prepare-plugins
strategy: strategy:
@@ -178,20 +160,15 @@ jobs:
matrix: matrix:
platform: ${{ fromJson(needs.prepare-plugins.outputs.matrix) }} platform: ${{ fromJson(needs.prepare-plugins.outputs.matrix) }}
steps: steps:
- name: Configure credentials -
continue-on-error: true name: Checkout
uses: tracebit-com/tracebit-community-action@27de2ed8de16af5270d32003a4c8bfc3c54e0a20 # v1.2.0 uses: actions/checkout@v4
with:
customer-id: ${{ secrets.TRACEBIT_CUSTOMER_ID }}
api-token: ${{ secrets.TRACEBIT_API_TOKEN }}
profile: administrator
profile-region: us-east-1
- -
name: Set up Docker Buildx name: Set up Docker Buildx
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0 uses: docker/setup-buildx-action@v3
- -
name: Build name: Build
uses: docker/bake-action@018cb6412ab401ebaa809aa5f85966b74628600f # v7.4.0 uses: docker/bake-action@v4
with: with:
targets: plugins-cross targets: plugins-cross
set: | set: |
+27 -34
View File
@@ -1,25 +1,15 @@
name: codeql name: codeql
# Default to 'contents: read', which grants actions to read commits.
#
# If any permission is set, any permission not included in the list is
# implicitly set to "none".
#
# see https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#permissions
permissions:
contents: read
on: on:
push: push:
branches: branches:
- 'master' - 'master'
- '[0-9]+.[0-9]+' - '[0-9]+.[0-9]+'
- '[0-9]+.x' tags:
tags: - 'v*'
- 'v*'
pull_request: pull_request:
# The branches below must be a subset of the branches above # The branches below must be a subset of the branches above
branches: ["master"] branches: [ "master" ]
schedule: schedule:
# ┌───────────── minute (0 - 59) # ┌───────────── minute (0 - 59)
# │ ┌───────────── hour (0 - 23) # │ ┌───────────── hour (0 - 23)
@@ -34,22 +24,36 @@ on:
jobs: jobs:
codeql: codeql:
runs-on: ubuntu-26.04 runs-on: 'ubuntu-latest'
timeout-minutes: 10 timeout-minutes: 360
env: env:
DISABLE_WARN_OUTSIDE_CONTAINER: '1' DISABLE_WARN_OUTSIDE_CONTAINER: '1'
permissions: permissions:
actions: read actions: read
contents: read contents: read
security-events: write security-events: write
steps: steps:
- -
name: Checkout name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@v4
with: with:
fetch-depth: 2 fetch-depth: 2
persist-credentials: false -
name: Checkout HEAD on PR
if: ${{ github.event_name == 'pull_request' }}
run: |
git checkout HEAD^2
-
name: Update Go
uses: actions/setup-go@v5
with:
go-version: '1.21'
-
name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: go
# CodeQL 2.16.4's auto-build added support for multi-module repositories, # CodeQL 2.16.4's auto-build added support for multi-module repositories,
# and is trying to be smart by searching for modules in every directory, # and is trying to be smart by searching for modules in every directory,
# including vendor directories. If no module is found, it's creating one # including vendor directories. If no module is found, it's creating one
@@ -60,22 +64,11 @@ jobs:
run: | run: |
ln -s vendor.mod go.mod ln -s vendor.mod go.mod
ln -s vendor.sum go.sum ln -s vendor.sum go.sum
-
name: Update Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: "1.26.8"
cache: false
-
name: Initialize CodeQL
uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
with:
languages: go
- -
name: Autobuild name: Autobuild
uses: github/codeql-action/autobuild@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 uses: github/codeql-action/autobuild@v3
- -
name: Perform CodeQL Analysis name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 uses: github/codeql-action/analyze@v3
with: with:
category: "/language:go" category: "/language:go"
+15 -40
View File
@@ -1,14 +1,5 @@
name: e2e name: e2e
# Default to 'contents: read', which grants actions to read commits.
#
# If any permission is set, any permission not included in the list is
# implicitly set to "none".
#
# see https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#permissions
permissions:
contents: read
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: true
@@ -19,61 +10,45 @@ on:
branches: branches:
- 'master' - 'master'
- '[0-9]+.[0-9]+' - '[0-9]+.[0-9]+'
- '[0-9]+.x'
tags: tags:
- 'v*' - 'v*'
pull_request: pull_request:
jobs: jobs:
tests: e2e:
runs-on: ubuntu-26.04 runs-on: ubuntu-22.04
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
target: target:
- local - non-experimental
- experimental
- connhelper-ssh - connhelper-ssh
base: base:
- alpine - alpine
- debian - debian
engine-version: engine-version:
- rc # latest rc - 25.0 # latest
- 29 # latest - 24.0 # latest - 1
- 28 # latest - 1 - 23.0 # mirantis lts
- 25 # mirantis lts # TODO(krissetto) 19.03 needs a look, doesn't work ubuntu 22.04 (cgroup errors).
# we could have a separate job that tests it against ubuntu 20.04
steps: steps:
- name: Configure credentials
continue-on-error: true
uses: tracebit-com/tracebit-community-action@27de2ed8de16af5270d32003a4c8bfc3c54e0a20 # v1.2.0
with:
customer-id: ${{ secrets.TRACEBIT_CUSTOMER_ID }}
api-token: ${{ secrets.TRACEBIT_API_TOKEN }}
profile: administrator
profile-region: us-east-1
- -
name: Checkout name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@v4
with:
persist-credentials: false
- -
name: Update daemon.json name: Update daemon.json
run: | run: |
if [ ! -f /etc/docker/daemon.json ]; then sudo jq '.experimental = true' < /etc/docker/daemon.json > /tmp/docker.json
# ubuntu runners no longer have a default daemon.json present sudo mv /tmp/docker.json /etc/docker/daemon.json
sudo mkdir -p /etc/docker/
echo '{"experimental": true}' | sudo tee /etc/docker/daemon.json
else
# but if there is one; let's patch it to keep other options that may be set.
sudo jq '.experimental = true' < /etc/docker/daemon.json > /tmp/docker.json
sudo mv /tmp/docker.json /etc/docker/daemon.json
fi
sudo cat /etc/docker/daemon.json sudo cat /etc/docker/daemon.json
sudo service docker restart sudo service docker restart
docker version docker version
docker info docker info
- -
name: Set up Docker Buildx name: Set up Docker Buildx
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0 uses: docker/setup-buildx-action@v3
- -
name: Run ${{ matrix.target }} name: Run ${{ matrix.target }}
run: | run: |
@@ -84,7 +59,7 @@ jobs:
TESTFLAGS: -coverprofile=/tmp/coverage/coverage.txt TESTFLAGS: -coverprofile=/tmp/coverage/coverage.txt
- -
name: Send to Codecov name: Send to Codecov
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 uses: codecov/codecov-action@v4
with: with:
files: ./build/coverage/coverage.txt file: ./build/coverage/coverage.txt
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
-50
View File
@@ -1,50 +0,0 @@
name: PR Review - Trigger
on:
pull_request:
types: [ready_for_review, opened, review_requested]
pull_request_review_comment:
types: [created]
permissions: {}
# Deduplicate simultaneous pull_request events for the same fork PR.
# When reviewers are requested at the same time, GitHub fires multiple
# review_requested events. Without this group each event triggers a
# separate review via workflow_run, producing duplicate reviews.
concurrency:
group: pr-review-trigger-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
save-context:
if: github.event.pull_request.head.repo.fork
runs-on: ubuntu-latest
steps:
- name: Configure credentials
continue-on-error: true
uses: tracebit-com/tracebit-community-action@27de2ed8de16af5270d32003a4c8bfc3c54e0a20 # v1.2.0
with:
customer-id: ${{ secrets.TRACEBIT_CUSTOMER_ID }}
api-token: ${{ secrets.TRACEBIT_API_TOKEN }}
profile: administrator
profile-region: us-east-1
- name: Save event context
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
COMMENT_JSON: ${{ toJSON(github.event.comment) }}
run: |
mkdir -p context
printf '%s' "${{ github.event_name }}" > context/event_name.txt
printf '%s' "$PR_NUMBER" > context/pr_number.txt
printf '%s' "$PR_HEAD_SHA" > context/pr_head_sha.txt
if [ "${{ github.event_name }}" = "pull_request_review_comment" ]; then
printf '%s' "$COMMENT_JSON" > context/comment.json
fi
- name: Upload context
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: pr-review-context
path: context/
retention-days: 1
-23
View File
@@ -1,23 +0,0 @@
name: PR Review
on:
issue_comment:
types: [created]
workflow_run:
workflows: ["PR Review - Trigger"]
types: [completed]
permissions:
contents: read
jobs:
review:
uses: docker/docker-agent-action/.github/workflows/review-pr.yml@3654ec391b070e247de2e2e3c1ff2519b6378b3a # v2.0.9
permissions:
contents: read # Read repository files and PR diffs
pull-requests: write # Post review comments
issues: write # Create security incident issues if secrets detected
checks: write # (Optional) Show review progress as a check run
id-token: write # Required for OIDC authentication to AWS Secrets Manager
actions: write # Required since v2.0.3 — review-lock cache cleanup and feedback artifacts
with:
trigger-run-id: ${{ github.event_name == 'workflow_run' && format('{0}', github.event.workflow_run.id) || '' }}
-157
View File
@@ -1,157 +0,0 @@
name: Sync Docker release branch
concurrency:
group: ${{ github.workflow }}-${{ inputs.release_branch }}
cancel-in-progress: false
permissions:
contents: read
on:
workflow_dispatch:
inputs:
release_branch:
description: Release branch to sync, for example 29.x
required: true
type: string
tag:
description: Tag to sync from, for example v29.6.0
required: true
type: string
dry_run:
description: Merge but don't push
required: true
default: false
type: boolean
jobs:
sync-release-branch:
runs-on: ubuntu-26.04
permissions:
contents: write
actions: write
outputs:
base_sha: ${{ steps.sync.outputs.base_sha }}
has_changes: ${{ steps.sync.outputs.has_changes }}
temporary_branch: ${{ steps.sync.outputs.temporary_branch }}
temporary_sha: ${{ steps.sync.outputs.temporary_sha }}
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- name: Validate
env:
BRANCH: ${{ github.ref_name }}
RELEASE_BRANCH: ${{ inputs.release_branch }}
run: |
if [ "$BRANCH" != "master" ]; then
echo "::error::This workflow is expected to be run on master, not $BRANCH"
exit 1
fi
if [ "$RELEASE_BRANCH" = "master" ]; then
echo "::error::The release branch must not be master"
exit 1
fi
if ! [[ "$RELEASE_BRANCH" =~ ^[0-9]+\.[x0-9]+$ ]]; then
echo "::error::Invalid release branch name: '$RELEASE_BRANCH'. Expected format: 29.x"
exit 1
fi
- name: Configure git author
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Sync release branch to tag range
id: sync
env:
DRY_RUN: ${{ inputs.dry_run }}
RELEASE_BRANCH: ${{ inputs.release_branch }}
RUN_ATTEMPT: ${{ github.run_attempt }}
RUN_ID: ${{ github.run_id }}
TAG: ${{ inputs.tag }}
run: |
set -o pipefail
base_sha=$(git rev-parse "origin/$RELEASE_BRANCH")
temporary_branch="process/sync-release-branch/$RUN_ID-$RUN_ATTEMPT"
echo "base_sha=$base_sha" >> "$GITHUB_OUTPUT"
echo "temporary_branch=$temporary_branch" >> "$GITHUB_OUTPUT"
# Keep the master checkout unchanged so scripts run from the dispatched revision.
release_worktree="$RUNNER_TEMP/release-branch"
git worktree add --detach "$release_worktree" "$base_sha"
cd "$release_worktree"
tags_file=$(mktemp)
"$GITHUB_WORKSPACE/scripts/unmerged-tags" \
"origin/$RELEASE_BRANCH" \
"$TAG" \
> "$tags_file"
echo >> "$GITHUB_STEP_SUMMARY"
echo "## Tags to sync" >> "$GITHUB_STEP_SUMMARY"
echo >> "$GITHUB_STEP_SUMMARY"
sed 's/^/- /' "$tags_file" >> "$GITHUB_STEP_SUMMARY"
xargs -r "$GITHUB_WORKSPACE/scripts/sync-branch" < "$tags_file" | tee -a "$GITHUB_STEP_SUMMARY"
if [[ "$DRY_RUN" == "true" ]]; then
echo "has_changes=false" >> "$GITHUB_OUTPUT"
exit 0
fi
if [[ $(git rev-parse HEAD) == $(git rev-parse "origin/$RELEASE_BRANCH") ]]; then
echo "has_changes=false" >> "$GITHUB_OUTPUT"
echo "No changes to push"
exit 0
fi
echo "has_changes=true" >> "$GITHUB_OUTPUT"
git push origin "HEAD:refs/heads/$temporary_branch"
echo "temporary_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
push-release-branch:
needs: sync-release-branch
if: ${{ !inputs.dry_run && needs.sync-release-branch.outputs.has_changes == 'true' }}
runs-on: ubuntu-26.04
environment: docker-releases
permissions:
contents: write
actions: write
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- name: Push release branch
env:
BASE_SHA: ${{ needs.sync-release-branch.outputs.base_sha }}
RELEASE_BRANCH: ${{ inputs.release_branch }}
TEMPORARY_BRANCH: ${{ needs.sync-release-branch.outputs.temporary_branch }}
TEMPORARY_SHA: ${{ needs.sync-release-branch.outputs.temporary_sha }}
run: |
git fetch origin "$RELEASE_BRANCH"
current_sha=$(git rev-parse "origin/$RELEASE_BRANCH")
if [[ "$current_sha" != "$BASE_SHA" ]]; then
echo "$RELEASE_BRANCH changed from $BASE_SHA to $current_sha"
exit 1
fi
git fetch origin "$TEMPORARY_BRANCH"
current_temporary_sha=$(git rev-parse FETCH_HEAD)
if [[ "$current_temporary_sha" != "$TEMPORARY_SHA" ]]; then
echo "$TEMPORARY_BRANCH changed from $TEMPORARY_SHA to $current_temporary_sha"
exit 1
fi
git push origin "FETCH_HEAD:$RELEASE_BRANCH"
- name: Delete temporary branch
env:
TEMPORARY_BRANCH: ${{ needs.sync-release-branch.outputs.temporary_branch }}
run: git push origin --delete "$TEMPORARY_BRANCH"
+21 -45
View File
@@ -1,14 +1,5 @@
name: test name: test
# Default to 'contents: read', which grants actions to read commits.
#
# If any permission is set, any permission not included in the list is
# implicitly set to "none".
#
# see https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#permissions
permissions:
contents: read
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: true
@@ -19,36 +10,30 @@ on:
branches: branches:
- 'master' - 'master'
- '[0-9]+.[0-9]+' - '[0-9]+.[0-9]+'
- '[0-9]+.x'
tags: tags:
- 'v*' - 'v*'
pull_request: pull_request:
jobs: jobs:
ctn: ctn:
runs-on: ubuntu-26.04 runs-on: ubuntu-22.04
steps: steps:
- name: Configure credentials -
continue-on-error: true name: Checkout
uses: tracebit-com/tracebit-community-action@27de2ed8de16af5270d32003a4c8bfc3c54e0a20 # v1.2.0 uses: actions/checkout@v4
with:
customer-id: ${{ secrets.TRACEBIT_CUSTOMER_ID }}
api-token: ${{ secrets.TRACEBIT_API_TOKEN }}
profile: administrator
profile-region: us-east-1
- -
name: Set up Docker Buildx name: Set up Docker Buildx
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0 uses: docker/setup-buildx-action@v3
- -
name: Test name: Test
uses: docker/bake-action@018cb6412ab401ebaa809aa5f85966b74628600f # v7.4.0 uses: docker/bake-action@v4
with: with:
targets: test-coverage targets: test-coverage
- -
name: Send to Codecov name: Send to Codecov
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 uses: codecov/codecov-action@v4
with: with:
files: ./build/coverage/coverage.txt file: ./build/coverage/coverage.txt
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
host: host:
@@ -61,45 +46,36 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
os: os:
- macos-14 # macOS 14 on arm64 (Apple Silicon M1) - macos-12
- macos-15-intel # macOS 15 on Intel
- macos-15 # macOS 15 on arm64 (Apple Silicon M1)
# - windows-2022 # FIXME: some tests are failing on the Windows runner, as well as on Appveyor since June 24, 2018: https://ci.appveyor.com/project/docker/cli/history # - windows-2022 # FIXME: some tests are failing on the Windows runner, as well as on Appveyor since June 24, 2018: https://ci.appveyor.com/project/docker/cli/history
steps: steps:
- name: Configure credentials -
continue-on-error: true name: Prepare git
uses: tracebit-com/tracebit-community-action@27de2ed8de16af5270d32003a4c8bfc3c54e0a20 # v1.2.0 if: matrix.os == 'windows-latest'
with: run: |
customer-id: ${{ secrets.TRACEBIT_CUSTOMER_ID }} git config --system core.autocrlf false
api-token: ${{ secrets.TRACEBIT_API_TOKEN }} git config --system core.eol lf
profile: administrator
profile-region: us-east-1
- -
name: Checkout name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@v4
with: with:
path: ${{ env.GOPATH }}/src/github.com/docker/cli path: ${{ env.GOPATH }}/src/github.com/docker/cli
persist-credentials: false
- -
name: Set up Go name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 uses: actions/setup-go@v5
with: with:
go-version: "1.26.8" go-version: 1.21.9
cache: false
- -
name: Test name: Test
run: | run: |
# run in go modules mode to prevent traversing to nested modules go test -coverprofile=/tmp/coverage.txt $(go list ./... | grep -vE '/vendor/|/e2e/')
ln -s vendor.mod go.mod
ln -s vendor.sum go.sum
go test -coverprofile=/tmp/coverage.txt $(go list ./... | grep -vE '^github.com/docker/cli/e2e/')
go tool cover -func=/tmp/coverage.txt go tool cover -func=/tmp/coverage.txt
working-directory: ${{ env.GOPATH }}/src/github.com/docker/cli working-directory: ${{ env.GOPATH }}/src/github.com/docker/cli
shell: bash shell: bash
- -
name: Send to Codecov name: Send to Codecov
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 uses: codecov/codecov-action@v4
with: with:
files: /tmp/coverage.txt file: /tmp/coverage.txt
working-directory: ${{ env.GOPATH }}/src/github.com/docker/cli working-directory: ${{ env.GOPATH }}/src/github.com/docker/cli
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
-66
View File
@@ -1,66 +0,0 @@
name: validate-milestone
permissions:
contents: read
pull-requests: read
on:
pull_request:
types: [opened, synchronize, milestoned, demilestoned, edited]
jobs:
validate-milestone:
runs-on: ubuntu-26.04
timeout-minutes: 5
steps:
- name: Configure credentials
continue-on-error: true
uses: tracebit-com/tracebit-community-action@27de2ed8de16af5270d32003a4c8bfc3c54e0a20 # v1.2.0
with:
customer-id: ${{ secrets.TRACEBIT_CUSTOMER_ID }}
api-token: ${{ secrets.TRACEBIT_API_TOKEN }}
profile: administrator
profile-region: us-east-1
- name: Validate milestone matches VERSION
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
MILESTONE: ${{ github.event.pull_request.milestone.title }}
with:
script: |
const files = await github.paginate(github.rest.pulls.listFiles, {
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.pull_request.number,
});
core.info(`Modified files: ${files.map(f => f.filename).join(', ')}`);
const touchesVersion = files.some(f => f.filename === 'VERSION');
core.info(`Touches VERSION: ${touchesVersion}`);
// Use the PR's version when it bumps the file, base branch otherwise.
// It's fine to trust the author in this case, it's not meant to be
// a security gate, just a helpful check for maintainers.
const ref = touchesVersion
? context.payload.pull_request.head.sha
: context.payload.pull_request.base.ref;
core.info(`Base ref: ${ref}`);
const resp = await github.rest.repos.getContent({
owner: context.repo.owner,
repo: context.repo.repo,
path: 'VERSION',
ref,
});
const expected = Buffer.from(resp.data.content, resp.data.encoding).toString('utf8').trim();
const milestone = process.env.MILESTONE;
if (!milestone) {
core.setFailed(`PR must have a milestone set (expected: ${expected})`);
return;
}
if (milestone !== expected) {
core.setFailed(`Milestone '${milestone}' does not match VERSION '${expected}'`);
return;
}
core.info(`Milestone: ${milestone} ✓`);
-117
View File
@@ -1,117 +0,0 @@
name: validate-pr
# Default to 'contents: read', which grants actions to read commits.
#
# If any permission is set, any permission not included in the list is
# implicitly set to "none".
#
# see https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#permissions
permissions:
contents: read
on:
pull_request:
types: [opened, edited, labeled, unlabeled, synchronize]
jobs:
check-labels:
runs-on: ubuntu-26.04
timeout-minutes: 120 # guardrails timeout for the whole job
steps:
- name: Configure credentials
continue-on-error: true
uses: tracebit-com/tracebit-community-action@27de2ed8de16af5270d32003a4c8bfc3c54e0a20 # v1.2.0
with:
customer-id: ${{ secrets.TRACEBIT_CUSTOMER_ID }}
api-token: ${{ secrets.TRACEBIT_API_TOKEN }}
profile: administrator
profile-region: us-east-1
- name: Missing `area/` label
if: always() && contains(join(github.event.pull_request.labels.*.name, ','), 'impact/') && !contains(join(github.event.pull_request.labels.*.name, ','), 'area/')
run: |
echo "::error::Every PR with an 'impact/*' label should also have an 'area/*' label"
exit 1
- name: Missing `kind/` label
if: always() && contains(join(github.event.pull_request.labels.*.name, ','), 'impact/') && !contains(join(github.event.pull_request.labels.*.name, ','), 'kind/')
run: |
echo "::error::Every PR with an 'impact/*' label should also have a 'kind/*' label"
exit 1
- name: OK
run: exit 0
check-changelog:
runs-on: ubuntu-26.04
timeout-minutes: 120 # guardrails timeout for the whole job
env:
HAS_IMPACT_LABEL: ${{ contains(join(github.event.pull_request.labels.*.name, ','), 'impact/') }}
PR_BODY: |
${{ github.event.pull_request.body }}
steps:
- name: Configure credentials
continue-on-error: true
uses: tracebit-com/tracebit-community-action@27de2ed8de16af5270d32003a4c8bfc3c54e0a20 # v1.2.0
with:
customer-id: ${{ secrets.TRACEBIT_CUSTOMER_ID }}
api-token: ${{ secrets.TRACEBIT_API_TOKEN }}
profile: administrator
profile-region: us-east-1
- name: Check changelog description
run: |
# Extract the `markdown changelog` note code block
block=$(echo -n "$PR_BODY" | tr -d '\r' | awk '/^```markdown changelog$/{flag=1;next}/^```$/{flag=0}flag')
# Strip empty lines
desc=$(echo "$block" | awk NF)
if [ "$HAS_IMPACT_LABEL" = "true" ]; then
if [ -z "$desc" ]; then
echo "::error::Changelog section is empty. Please provide a description for the changelog."
exit 1
fi
len=$(echo -n "$desc" | wc -c)
if [[ $len -le 6 ]]; then
echo "::error::Description looks too short: $desc"
exit 1
fi
else
if [ -n "$desc" ]; then
echo "::error::PR has a changelog description, but no changelog label"
echo "::error::Please add the relevant 'impact/' label to the PR or remove the changelog description"
exit 1
fi
fi
echo "This PR will be included in the release notes with the following note:"
echo "$desc"
check-pr-branch:
runs-on: ubuntu-26.04
timeout-minutes: 120 # guardrails timeout for the whole job
env:
PR_TITLE: ${{ github.event.pull_request.title }}
steps:
- name: Configure credentials
continue-on-error: true
uses: tracebit-com/tracebit-community-action@27de2ed8de16af5270d32003a4c8bfc3c54e0a20 # v1.2.0
with:
customer-id: ${{ secrets.TRACEBIT_CUSTOMER_ID }}
api-token: ${{ secrets.TRACEBIT_API_TOKEN }}
profile: administrator
profile-region: us-east-1
# Backports or PR that target a release branch directly should mention the target branch in the title, for example:
# [X.Y backport] Some change that needs backporting to X.Y
# [X.Y] Change directly targeting the X.Y branch
- name: Check release branch
id: title_branch
run: |
# get the intended major version prefix ("[27.1 backport]" -> "27.") from the PR title.
[[ "$PR_TITLE" =~ ^\[([0-9]*\.)[^]]*\] ]] && branch="${BASH_REMATCH[1]}"
# get major version prefix from the release branch ("27.x -> "27.")
[[ "$GITHUB_BASE_REF" =~ ^([0-9]*\.) ]] && target_branch="${BASH_REMATCH[1]}" || target_branch="$GITHUB_BASE_REF"
if [[ "$target_branch" != "$branch" ]] && ! [[ "$GITHUB_BASE_REF" == "master" && "$branch" == "" ]]; then
echo "::error::PR is opened against the $GITHUB_BASE_REF branch, but its title suggests otherwise."
exit 1
fi
+9 -77
View File
@@ -1,14 +1,5 @@
name: validate name: validate
# Default to 'contents: read', which grants actions to read commits.
#
# If any permission is set, any permission not included in the list is
# implicitly set to "none".
#
# see https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#permissions
permissions:
contents: read
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: true
@@ -19,14 +10,13 @@ on:
branches: branches:
- 'master' - 'master'
- '[0-9]+.[0-9]+' - '[0-9]+.[0-9]+'
- '[0-9]+.x'
tags: tags:
- 'v*' - 'v*'
pull_request: pull_request:
jobs: jobs:
validate: validate:
runs-on: ubuntu-26.04 runs-on: ubuntu-22.04
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@@ -36,37 +26,22 @@ jobs:
- validate-vendor - validate-vendor
- update-authors # ensure authors update target runs fine - update-authors # ensure authors update target runs fine
steps: steps:
- name: Configure credentials -
continue-on-error: true name: Checkout
uses: tracebit-com/tracebit-community-action@27de2ed8de16af5270d32003a4c8bfc3c54e0a20 # v1.2.0 uses: actions/checkout@v4
with:
customer-id: ${{ secrets.TRACEBIT_CUSTOMER_ID }}
api-token: ${{ secrets.TRACEBIT_API_TOKEN }}
profile: administrator
profile-region: us-east-1
- -
name: Run name: Run
uses: docker/bake-action@018cb6412ab401ebaa809aa5f85966b74628600f # v7.4.0 uses: docker/bake-action@v4
with: with:
targets: ${{ matrix.target }} targets: ${{ matrix.target }}
# check that the generated Markdown and the checked-in files match # check that the generated Markdown and the checked-in files match
validate-md: validate-md:
runs-on: ubuntu-26.04 runs-on: ubuntu-22.04
steps: steps:
- name: Configure credentials
continue-on-error: true
uses: tracebit-com/tracebit-community-action@27de2ed8de16af5270d32003a4c8bfc3c54e0a20 # v1.2.0
with:
customer-id: ${{ secrets.TRACEBIT_CUSTOMER_ID }}
api-token: ${{ secrets.TRACEBIT_API_TOKEN }}
profile: administrator
profile-region: us-east-1
- -
name: Checkout name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@v4
with:
persist-credentials: false
- -
name: Generate name: Generate
shell: 'script --return --quiet --command "bash {0}"' shell: 'script --return --quiet --command "bash {0}"'
@@ -82,7 +57,7 @@ jobs:
fi fi
validate-make: validate-make:
runs-on: ubuntu-26.04 runs-on: ubuntu-22.04
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@@ -90,54 +65,11 @@ jobs:
- yamldocs # ensure yamldocs target runs fine - yamldocs # ensure yamldocs target runs fine
- manpages # ensure manpages target runs fine - manpages # ensure manpages target runs fine
steps: steps:
- name: Configure credentials
continue-on-error: true
uses: tracebit-com/tracebit-community-action@27de2ed8de16af5270d32003a4c8bfc3c54e0a20 # v1.2.0
with:
customer-id: ${{ secrets.TRACEBIT_CUSTOMER_ID }}
api-token: ${{ secrets.TRACEBIT_API_TOKEN }}
profile: administrator
profile-region: us-east-1
- -
name: Checkout name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@v4
with:
persist-credentials: false
- -
name: Run name: Run
shell: 'script --return --quiet --command "bash {0}"' shell: 'script --return --quiet --command "bash {0}"'
run: | run: |
make -f docker.Makefile ${{ matrix.target }} make -f docker.Makefile ${{ matrix.target }}
validate-gocompat:
runs-on: ubuntu-26.04
env:
GOPATH: ${{ github.workspace }}
GO111MODULE: off
steps:
- name: Configure credentials
continue-on-error: true
uses: tracebit-com/tracebit-community-action@27de2ed8de16af5270d32003a4c8bfc3c54e0a20 # v1.2.0
with:
customer-id: ${{ secrets.TRACEBIT_CUSTOMER_ID }}
api-token: ${{ secrets.TRACEBIT_API_TOKEN }}
profile: administrator
profile-region: us-east-1
-
name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
path: src/github.com/docker/cli
persist-credentials: false
-
name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: "1.26.8"
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
-31
View File
@@ -1,31 +0,0 @@
name: zizmor
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
on:
workflow_dispatch:
push:
branches:
- 'main'
- 'master'
- '[0-9]+.[0-9]+'
- '[0-9]+.x'
tags:
- 'v*'
pull_request:
jobs:
run:
uses: crazy-max/.github/.github/workflows/zizmor.yml@f91220cba053bd54299ba645a599ee8ed436f222 # v1.11.0
permissions:
contents: read
security-events: write
with:
min-severity: medium
min-confidence: medium
persona: pedantic
+3 -3
View File
@@ -1,5 +1,5 @@
# if you want to ignore files created by your editor/tools, # if you want to ignore files created by your editor/tools,
# consider a global .gitignore https://help.github.com/articles/ignoring-files # please consider a global .gitignore https://help.github.com/articles/ignoring-files
*.exe *.exe
*.exe~ *.exe~
*.orig *.orig
@@ -8,8 +8,8 @@
Thumbs.db Thumbs.db
.editorconfig .editorconfig
/build/ /build/
/cmd/docker/winresources/versioninfo.json /cli/winresources/versioninfo.json
/cmd/docker/winresources/*.syso /cli/winresources/*.syso
profile.out profile.out
# top-level go.mod is not meant to be checked in # top-level go.mod is not meant to be checked in
+153 -205
View File
@@ -1,237 +1,185 @@
version: "2"
run:
# prevent golangci-lint from deducting the go version to lint for through go.mod,
# 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.26.8"
timeout: 5m
issues:
# Maximum issues count per one linter. Set to 0 to disable. Default is 50.
max-issues-per-linter: 0
# Maximum count of issues with the same text. Set to 0 to disable. Default is 3.
max-same-issues: 0
formatters:
enable:
- gofumpt # Detects whether code was gofumpt-ed.
- goimports
exclusions:
generated: strict
linters: linters:
enable: enable:
- asasalint # Detects "[]any" used as argument for variadic "func(...any)".
- bodyclose - bodyclose
- copyloopvar # Detects places where loop variables are copied.
- depguard - depguard
- dogsled # Detects assignments with too many blank identifiers. - dogsled
- dupword # Detects duplicate words. - dupword # Detects duplicate words.
- durationcheck # Detect cases where two time.Duration values are being multiplied in possibly erroneous ways. - durationcheck
- errcheck - errchkjson
- errchkjson # Detects unsupported types passed to json encoding functions and reports if checks for the returned error can be omitted. - exportloopref # Detects pointers to enclosing loop variables.
- exhaustive # Detects missing options in enum switch statements. - gocritic # Metalinter; detects bugs, performance, and styling issues.
- exptostd # Detects functions from golang.org/x/exp/ that can be replaced by std functions.
- fatcontext # Detects nested contexts in loops and function literals.
- forbidigo
- gocheckcompilerdirectives # Detects invalid go compiler directive comments (//go:).
- gocritic # Metalinter; detects bugs, performance, and styling issues.
- gocyclo - gocyclo
- gosec # Detects security problems. - gofumpt # Detects whether code was gofumpt-ed.
- goimports
- gosec # Detects security problems.
- gosimple
- govet - govet
- iface # Detects incorrect use of interfaces. Currently only used for "identical" interfaces in the same package.
- importas # Enforces consistent import aliases.
- ineffassign - ineffassign
- makezero # Finds slice declarations with non-zero initial length. - lll
- mirror # Detects wrong mirror patterns of bytes/strings usage. - megacheck
- misspell # Detects commonly misspelled English words in comments. - misspell # Detects commonly misspelled English words in comments.
- nakedret # Detects uses of naked returns. - nakedret
- nilnesserr # Detects returning nil errors. It combines the features of nilness and nilerr, - nilerr # Detects code that returns nil even if it checks that the error is not nil.
- nosprintfhostport # Detects misuse of Sprintf to construct a host with port in a URL. - nolintlint # Detects ill-formed or insufficient nolint directives.
- nolintlint # Detects ill-formed or insufficient nolint directives. - perfsprint # Detects fmt.Sprintf uses that can be replaced with a faster alternative.
- perfsprint # Detects fmt.Sprintf uses that can be replaced with a faster alternative. - prealloc # Detects slice declarations that could potentially be pre-allocated.
- prealloc # Detects slice declarations that could potentially be pre-allocated. - predeclared # Detects code that shadows one of Go's predeclared identifiers
- predeclared # Detects code that shadows one of Go's predeclared identifiers - reassign
- reassign # Detects reassigning a top-level variable in another package. - revive # Metalinter; drop-in replacement for golint.
- revive # Metalinter; drop-in replacement for golint.
- spancheck # Detects mistakes with OpenTelemetry/Census spans.
- staticcheck - staticcheck
- thelper # Detects test helpers without t.Helper(). - stylecheck # Replacement for golint
- tparallel # Detects inappropriate usage of t.Parallel(). - tenv # Detects using os.Setenv instead of t.Setenv.
- unconvert # Detects unnecessary type conversions. - thelper # Detects test helpers without t.Helper().
- tparallel # Detects inappropriate usage of t.Parallel().
- typecheck
- unconvert # Detects unnecessary type conversions.
- unparam - unparam
- unused - unused
- usestdlibvars # Detects the possibility to use variables/constants from the Go standard library. - usestdlibvars
- usetesting # Reports uses of functions with replacement inside the testing package. - vet
- wastedassign # Detects wasted assignment statements. - wastedassign
disable: disable:
- errcheck - errcheck
settings: run:
depguard: timeout: 5m
rules: skip-files:
main: - cli/compose/schema/bindata.go
deny: - .*generated.*
- pkg: "github.com/containerd/containerd/errdefs"
desc: The containerd errdefs package was migrated to a separate module. Use github.com/containerd/errdefs instead.
- pkg: "github.com/containerd/containerd/log"
desc: The containerd log package was migrated to a separate module. Use github.com/containerd/log instead.
- pkg: "github.com/containerd/containerd/pkg/userns"
desc: Use github.com/moby/sys/userns instead.
- pkg: "github.com/containerd/containerd/platforms"
desc: The containerd platforms package was migrated to a separate module. Use github.com/containerd/platforms instead.
- pkg: "github.com/docker/docker/errdefs"
desc: Use github.com/containerd/errdefs instead.
- pkg: "github.com/docker/docker/pkg/system"
desc: This package should not be used unless strictly necessary.
- pkg: "github.com/docker/distribution/uuid"
desc: Use github.com/google/uuid instead.
- pkg: "io/ioutil"
desc: The io/ioutil package has been deprecated, see https://go.dev/doc/go1.16#ioutil
- pkg: "gopkg.in/yaml.v3"
desc: Use go.yaml.in/yaml/v3 instead.
forbidigo: linters-settings:
forbid: depguard:
- pkg: ^regexp$ rules:
pattern: ^regexp\.MustCompile main:
msg: Use internal/lazyregexp.New instead. deny:
- pkg: io/ioutil
desc: The io/ioutil package has been deprecated, see https://go.dev/doc/go1.16#ioutil
gocyclo:
min-complexity: 16
govet:
check-shadowing: true
settings:
shadow:
strict: true
lll:
line-length: 200
nakedret:
command: nakedret
pattern: ^(?P<path>.*?\\.go):(?P<line>\\d+)\\s*(?P<message>.*)$
gocyclo: revive:
min-complexity: 16 rules:
# https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#import-shadowing
- name: import-shadowing
severity: warning
disabled: false
# https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#empty-block
- name: empty-block
severity: warning
disabled: false
# https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#empty-lines
- name: empty-lines
severity: warning
disabled: false
# https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#use-any
- name: use-any
severity: warning
disabled: false
gosec: issues:
excludes: # The default exclusion rules are a bit too permissive, so copying the relevant ones below
- G104 # G104: Errors unhandled; (TODO: reduce unhandled errors, or explicitly ignore) exclude-use-default: false
- G115 # G115: integer overflow conversion; (TODO: verify these: https://github.com/docker/cli/issues/5584)
- G117 # G117: Exported struct field matches secret pattern (false positives for legitimate field names)
- G118 # G118: Goroutine uses context.Background/TODO while request-scoped context is available (TODO: evaluate these)
- G122 # G122: Filesystem operation in filepath.Walk/WalkDir callback uses race-prone path (TODO: evaluate these)
- G306 # G306: Expect WriteFile permissions to be 0600 or less (too restrictive; also flags "0o644" permissions)
- G307 # G307: Deferring unsafe method "*os.File" on type "Close" (also EXC0008); (TODO: evaluate these and fix where needed: G307: Deferring unsafe method "*os.File" on type "Close")
- G702 # G702: Command injection via taint analysis (TODO: evaluate these)
- G703 # G703: Path traversal via taint analysis (TODO: evaluate these)
- G704 # G704: SSRF via taint analysis (TODO: evaluate these)
- G705 # G705: XSS via taint analysis (TODO: evaluate these)
govet: exclude:
enable: - parameter .* always receives
- shadow
settings:
shadow:
strict: true
lll: exclude-rules:
line-length: 200 # We prefer to use an "exclude-list" so that new "default" exclusions are not
importas:
# Do not allow unaliased imports of aliased packages.
no-unaliased: true
alias:
# Should no longer be aliased, because we no longer allow moby/docker errdefs.
- pkg: "github.com/docker/docker/errdefs"
alias: ""
- pkg: github.com/opencontainers/image-spec/specs-go/v1
alias: ocispec
# Enforce that gotest.tools/v3/assert/cmp is always aliased as "is"
- pkg: gotest.tools/v3/assert/cmp
alias: is
nakedret:
# Disallow naked returns if func has more lines of code than this setting.
# Default: 30
max-func-lines: 0
staticcheck:
checks:
- all
- -QF1008 # Omit embedded fields from selector expression; https://staticcheck.dev/docs/checks/#QF1008
revive:
rules:
- name: empty-block # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#empty-block
- name: empty-lines # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#empty-lines
- name: import-shadowing # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#import-shadowing
- name: line-length-limit # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#line-length-limit
arguments: [200]
- name: unused-receiver # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#unused-receiver
- 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
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 # automatically inherited. We can decide whether or not to follow upstream
# defaults when updating golang-ci-lint versions. # defaults when updating golang-ci-lint versions.
# Unfortunately, this means we have to copy the whole exclusion pattern, as # Unfortunately, this means we have to copy the whole exclusion pattern, as
# (unlike the "include" option), the "exclude" option does not take exclusion # (unlike the "include" option), the "exclude" option does not take exclusion
# ID's. # ID's.
# #
# These exclusion patterns are copied from the default excludes at: # These exclusion patterns are copied from the default excluses at:
# https://github.com/golangci/golangci-lint/blob/v1.61.0/pkg/config/issues.go#L11-L104 # https://github.com/golangci/golangci-lint/blob/v1.44.0/pkg/config/issues.go#L10-L104
#
# The default list of exclusions can be found at:
# https://golangci-lint.run/usage/false-positives/#default-exclusions
generated: strict
rules: # EXC0001
# EXC0003 - text: "Error return value of .((os\\.)?std(out|err)\\..*|.*Close|.*Flush|os\\.Remove(All)?|.*print(f|ln)?|os\\.(Un)?Setenv). is not checked"
- text: "func name will be used as test\\.Test.* by other packages, and that stutters; consider calling this" linters:
linters: - errcheck
- revive # EXC0003
- text: "func name will be used as test\\.Test.* by other packages, and that stutters; consider calling this"
linters:
- revive
# EXC0006
- text: "Use of unsafe calls should be audited"
linters:
- gosec
# EXC0007
- text: "Subprocess launch(ed with variable|ing should be audited)"
linters:
- gosec
# EXC0008
# TODO: evaluate these and fix where needed: G307: Deferring unsafe method "*os.File" on type "Close" (gosec)
- text: "G307"
linters:
- gosec
# EXC0009
- text: "(Expect directory permissions to be 0750 or less|Expect file permissions to be 0600 or less)"
linters:
- gosec
# EXC0010
- text: "Potential file inclusion via variable"
linters:
- gosec
# EXC0007 # G113 Potential uncontrolled memory consumption in Rat.SetString (CVE-2022-23772)
- text: "Subprocess launch(ed with variable|ing should be audited)" # only affects gp < 1.16.14. and go < 1.17.7
linters: - text: "G113"
- gosec linters:
- gosec
# TODO: G104: Errors unhandled. (gosec)
- text: "G104"
linters:
- gosec
# Looks like the match in "EXC0007" above doesn't catch this one
# TODO: consider upstreaming this to golangci-lint's default exclusion rules
- text: "G204: Subprocess launched with a potential tainted input or cmd arguments"
linters:
- gosec
# Looks like the match in "EXC0009" above doesn't catch this one
# TODO: consider upstreaming this to golangci-lint's default exclusion rules
- text: "G306: Expect WriteFile permissions to be 0600 or less"
linters:
- gosec
# EXC0009 # TODO: make sure all packages have a description. Currently, there's 67 packages without.
- text: "(Expect directory permissions to be 0750 or less|Expect file permissions to be 0600 or less)" - text: "package-comments: should have a package comment"
linters: linters:
- gosec - revive
# FIXME temporarily suppress these (see https://github.com/gotestyourself/gotest.tools/issues/272)
- text: "SA1019: (assert|cmp|is)\\.ErrorType is deprecated"
linters:
- staticcheck
# Exclude some linters from running on tests files.
- path: _test\.go
linters:
- errcheck
- gosec
- text: "ST1000: at least one file in a package should have a package comment"
linters:
- stylecheck
# EXC0010 # Allow "err" and "ok" vars to shadow existing declarations, otherwise we get too many false positives.
- text: "Potential file inclusion via variable" - text: '^shadow: declaration of "(err|ok)" shadows declaration'
linters: linters:
- gosec - govet
# TODO: make sure all packages have a description. Currently, there's 67 packages without.
- text: "package-comments: should have a package comment"
linters:
- revive
# Exclude some linters from running on tests files. # Maximum issues count per one linter. Set to 0 to disable. Default is 50.
- path: _test\.go max-issues-per-linter: 0
linters:
- errcheck
- gosec
- text: "ST1000: at least one file in a package should have a package comment" # Maximum count of issues with the same text. Set to 0 to disable. Default is 3.
linters: max-same-issues: 0
- staticcheck
# Allow "err" and "ok" vars to shadow existing declarations, otherwise we get too many false positives.
- text: '^shadow: declaration of "(err|ok)" shadows declaration'
linters:
- govet
# Ignore for cli/command/formatter/tabwriter, which is forked from go stdlib, so we want to align with it.
- text: '^(ST1020|ST1022): comment on exported'
path: "cli/command/formatter/tabwriter"
linters:
- staticcheck
# TODO(thaJeztah): remove once https://github.com/leighmcculloch/gocheckcompilerdirectives/issues/7 is fixed.
- text: "compiler directive unrecognized: //go:fix"
linters:
- gocheckcompilerdirectives
# Log a warning if an exclusion rule is unused.
# Default: false
warn-unused: true
-34
View File
@@ -22,10 +22,7 @@ Akihiro Matsushima <amatsusbit@gmail.com> <amatsus@users.noreply.github.com>
Akihiro Suda <akihiro.suda.cz@hco.ntt.co.jp> Akihiro Suda <akihiro.suda.cz@hco.ntt.co.jp>
Akihiro Suda <akihiro.suda.cz@hco.ntt.co.jp> <suda.akihiro@lab.ntt.co.jp> Akihiro Suda <akihiro.suda.cz@hco.ntt.co.jp> <suda.akihiro@lab.ntt.co.jp>
Akihiro Suda <akihiro.suda.cz@hco.ntt.co.jp> <suda.kyoto@gmail.com> Akihiro Suda <akihiro.suda.cz@hco.ntt.co.jp> <suda.kyoto@gmail.com>
Alano Terblanche <alano.terblanche@docker.com>
Alano Terblanche <alano.terblanche@docker.com> <18033717+Benehiko@users.noreply.github.com>
Albin Kerouanton <albinker@gmail.com> Albin Kerouanton <albinker@gmail.com>
Albin Kerouanton <albinker@gmail.com> <557933+akerouanton@users.noreply.github.com>
Albin Kerouanton <albinker@gmail.com> <albin@akerouanton.name> Albin Kerouanton <albinker@gmail.com> <albin@akerouanton.name>
Aleksa Sarai <asarai@suse.de> Aleksa Sarai <asarai@suse.de>
Aleksa Sarai <asarai@suse.de> <asarai@suse.com> Aleksa Sarai <asarai@suse.de> <asarai@suse.com>
@@ -39,12 +36,10 @@ Alexander Larsson <alexl@redhat.com> <alexander.larsson@gmail.com>
Alexander Morozov <lk4d4math@gmail.com> Alexander Morozov <lk4d4math@gmail.com>
Alexander Morozov <lk4d4math@gmail.com> <lk4d4@docker.com> Alexander Morozov <lk4d4math@gmail.com> <lk4d4@docker.com>
Alexandre Beslic <alexandre.beslic@gmail.com> <abronan@docker.com> Alexandre Beslic <alexandre.beslic@gmail.com> <abronan@docker.com>
Alexandre Levavasseur <alexandre+oss@13x.fr>
Alexis Couvreur <alexiscouvreur.pro@gmail.com> Alexis Couvreur <alexiscouvreur.pro@gmail.com>
Alicia Lauerman <alicia@eta.im> <allydevour@me.com> Alicia Lauerman <alicia@eta.im> <allydevour@me.com>
Allen Sun <allensun.shl@alibaba-inc.com> <allen.sun@daocloud.io> Allen Sun <allensun.shl@alibaba-inc.com> <allen.sun@daocloud.io>
Allen Sun <allensun.shl@alibaba-inc.com> <shlallen1990@gmail.com> Allen Sun <allensun.shl@alibaba-inc.com> <shlallen1990@gmail.com>
Allie Sadler <allie.sadler@docker.com>
Andrew Weiss <andrew.weiss@docker.com> <andrew.weiss@microsoft.com> Andrew Weiss <andrew.weiss@docker.com> <andrew.weiss@microsoft.com>
Andrew Weiss <andrew.weiss@docker.com> <andrew.weiss@outlook.com> Andrew Weiss <andrew.weiss@docker.com> <andrew.weiss@outlook.com>
André Martins <aanm90@gmail.com> <martins@noironetworks.com> André Martins <aanm90@gmail.com> <martins@noironetworks.com>
@@ -65,14 +60,8 @@ Arko Dasgupta <arko@tetrate.io> <arko.dasgupta@docker.com>
Arko Dasgupta <arko@tetrate.io> <arkodg@users.noreply.github.com> Arko Dasgupta <arko@tetrate.io> <arkodg@users.noreply.github.com>
Arnaud Porterie <icecrime@gmail.com> Arnaud Porterie <icecrime@gmail.com>
Arnaud Porterie <icecrime@gmail.com> <arnaud.porterie@docker.com> Arnaud Porterie <icecrime@gmail.com> <arnaud.porterie@docker.com>
Arthur Flageul <arthur.flageul@gmail.com>
Arthur Flageul <arthur.flageul@gmail.com> <arthur.flageul@docker.com>
Arthur Gautier <baloo@gandi.net> <superbaloo+registrations.github@superbaloo.net> Arthur Gautier <baloo@gandi.net> <superbaloo+registrations.github@superbaloo.net>
Arthur Peka <arthur.peka@outlook.com> <arthrp@users.noreply.github.com> Arthur Peka <arthur.peka@outlook.com> <arthrp@users.noreply.github.com>
Austin Vazquez <austin.vazquez@docker.com>
Austin Vazquez <austin.vazquez@docker.com> <55906459+austinvazquez@users.noreply.github.com>
Austin Vazquez <austin.vazquez@docker.com> <austin.vazquez.dev@gmail.com>
Austin Vazquez <austin.vazquez@docker.com> <macedonv@amazon.com>
Avi Miller <avi.miller@oracle.com> <avi.miller@gmail.com> Avi Miller <avi.miller@oracle.com> <avi.miller@gmail.com>
Ben Bonnefoy <frenchben@docker.com> Ben Bonnefoy <frenchben@docker.com>
Ben Golub <ben.golub@dotcloud.com> Ben Golub <ben.golub@dotcloud.com>
@@ -99,7 +88,6 @@ Brian Goff <cpuguy83@gmail.com>
Brian Goff <cpuguy83@gmail.com> <bgoff@cpuguy83-mbp.home> Brian Goff <cpuguy83@gmail.com> <bgoff@cpuguy83-mbp.home>
Brian Goff <cpuguy83@gmail.com> <bgoff@cpuguy83-mbp.local> Brian Goff <cpuguy83@gmail.com> <bgoff@cpuguy83-mbp.local>
Brian Tracy <brian.tracy33@gmail.com> Brian Tracy <brian.tracy33@gmail.com>
Calvin Liu <flycalvin@qq.com>
Carlos de Paula <me@carlosedp.com> Carlos de Paula <me@carlosedp.com>
Chad Faragher <wyckster@hotmail.com> Chad Faragher <wyckster@hotmail.com>
Chander Govindarajan <chandergovind@gmail.com> Chander Govindarajan <chandergovind@gmail.com>
@@ -154,8 +142,6 @@ Dave Henderson <dhenderson@gmail.com> <Dave.Henderson@ca.ibm.com>
Dave Tucker <dt@docker.com> <dave@dtucker.co.uk> Dave Tucker <dt@docker.com> <dave@dtucker.co.uk>
David Alvarez <david.alvarez@flyeralarm.com> David Alvarez <david.alvarez@flyeralarm.com>
David Alvarez <david.alvarez@flyeralarm.com> <busilezas@gmail.com> David Alvarez <david.alvarez@flyeralarm.com> <busilezas@gmail.com>
David Dooling <david.dooling@docker.com>
David Dooling <david.dooling@docker.com> <dooling@gmail.com>
David Karlsson <david.karlsson@docker.com> David Karlsson <david.karlsson@docker.com>
David Karlsson <david.karlsson@docker.com> <35727626+dvdksn@users.noreply.github.com> David Karlsson <david.karlsson@docker.com> <35727626+dvdksn@users.noreply.github.com>
David M. Karr <davidmichaelkarr@gmail.com> David M. Karr <davidmichaelkarr@gmail.com>
@@ -205,8 +191,6 @@ Gaetan de Villele <gdevillele@gmail.com>
Gang Qiao <qiaohai8866@gmail.com> <1373319223@qq.com> Gang Qiao <qiaohai8866@gmail.com> <1373319223@qq.com>
George Kontridze <george@bugsnag.com> George Kontridze <george@bugsnag.com>
Gerwim Feiken <g.feiken@tfe.nl> <gerwim@gmail.com> Gerwim Feiken <g.feiken@tfe.nl> <gerwim@gmail.com>
Giau. Tran Minh <hello@giautm.dev>
Giau. Tran Minh <hello@giautm.dev> <12751435+giautm@users.noreply.github.com>
Giampaolo Mancini <giampaolo@trampolineup.com> Giampaolo Mancini <giampaolo@trampolineup.com>
Gopikannan Venugopalsamy <gopikannan.venugopalsamy@gmail.com> Gopikannan Venugopalsamy <gopikannan.venugopalsamy@gmail.com>
Gou Rao <gou@portworx.com> <gourao@users.noreply.github.com> Gou Rao <gou@portworx.com> <gourao@users.noreply.github.com>
@@ -226,7 +210,6 @@ Günther Jungbluth <gunther@gameslabs.net>
Hakan Özler <hakan.ozler@kodcu.com> Hakan Özler <hakan.ozler@kodcu.com>
Hao Shu Wei <haosw@cn.ibm.com> Hao Shu Wei <haosw@cn.ibm.com>
Hao Shu Wei <haosw@cn.ibm.com> <haoshuwei1989@163.com> Hao Shu Wei <haosw@cn.ibm.com> <haoshuwei1989@163.com>
Harald Albers <github@albersweb.de>
Harald Albers <github@albersweb.de> <albers@users.noreply.github.com> Harald Albers <github@albersweb.de> <albers@users.noreply.github.com>
Harold Cooper <hrldcpr@gmail.com> Harold Cooper <hrldcpr@gmail.com>
Harry Zhang <harryz@hyper.sh> <harryzhang@zju.edu.cn> Harry Zhang <harryz@hyper.sh> <harryzhang@zju.edu.cn>
@@ -271,7 +254,6 @@ Jessica Frazelle <jess@oxide.computer> <jessfraz@google.com>
Jessica Frazelle <jess@oxide.computer> <jfrazelle@users.noreply.github.com> Jessica Frazelle <jess@oxide.computer> <jfrazelle@users.noreply.github.com>
Jessica Frazelle <jess@oxide.computer> <me@jessfraz.com> Jessica Frazelle <jess@oxide.computer> <me@jessfraz.com>
Jessica Frazelle <jess@oxide.computer> <princess@docker.com> Jessica Frazelle <jess@oxide.computer> <princess@docker.com>
Jim Chen <njucjc@gmail.com>
Jim Galasyn <jim.galasyn@docker.com> Jim Galasyn <jim.galasyn@docker.com>
Jiuyue Ma <majiuyue@huawei.com> Jiuyue Ma <majiuyue@huawei.com>
Joey Geiger <jgeiger@gmail.com> Joey Geiger <jgeiger@gmail.com>
@@ -287,8 +269,6 @@ John Howard <github@lowenna.com> <jhowardmsft@users.noreply.github.com>
John Howard <github@lowenna.com> <John.Howard@microsoft.com> John Howard <github@lowenna.com> <John.Howard@microsoft.com>
John Howard <github@lowenna.com> <john.howard@microsoft.com> John Howard <github@lowenna.com> <john.howard@microsoft.com>
John Stephens <johnstep@docker.com> <johnstep@users.noreply.github.com> John Stephens <johnstep@docker.com> <johnstep@users.noreply.github.com>
Jonathan A. Sternberg <jonathansternberg@gmail.com>
Jonathan A. Sternberg <jonathansternberg@gmail.com> <jonathan.sternberg@docker.com>
Jordan Arentsen <blissdev@gmail.com> Jordan Arentsen <blissdev@gmail.com>
Jordan Jennings <jjn2009@gmail.com> <jjn2009@users.noreply.github.com> Jordan Jennings <jjn2009@gmail.com> <jjn2009@users.noreply.github.com>
Jorit Kleine-Möllhoff <joppich@bricknet.de> <joppich@users.noreply.github.com> Jorit Kleine-Möllhoff <joppich@bricknet.de> <joppich@users.noreply.github.com>
@@ -343,8 +323,6 @@ Lajos Papp <lajos.papp@sequenceiq.com> <lalyos@yahoo.com>
Lei Jitang <leijitang@huawei.com> Lei Jitang <leijitang@huawei.com>
Lei Jitang <leijitang@huawei.com> <leijitang@gmail.com> Lei Jitang <leijitang@huawei.com> <leijitang@gmail.com>
Li Fu Bang <lifubang@acmcoder.com> Li Fu Bang <lifubang@acmcoder.com>
Li Yi <denverdino@gmail.com>
Li Yi <denverdino@gmail.com> <weiyuan.yl@alibaba-inc.com>
Liang Mingqiang <mqliang.zju@gmail.com> Liang Mingqiang <mqliang.zju@gmail.com>
Liang-Chi Hsieh <viirya@gmail.com> Liang-Chi Hsieh <viirya@gmail.com>
Liao Qingwei <liaoqingwei@huawei.com> Liao Qingwei <liaoqingwei@huawei.com>
@@ -354,11 +332,8 @@ Lokesh Mandvekar <lsm5@fedoraproject.org> <lsm5@redhat.com>
Lorenzo Fontana <lo@linux.com> <fontanalorenzo@me.com> Lorenzo Fontana <lo@linux.com> <fontanalorenzo@me.com>
Louis Opter <kalessin@kalessin.fr> Louis Opter <kalessin@kalessin.fr>
Louis Opter <kalessin@kalessin.fr> <louis@dotcloud.com> Louis Opter <kalessin@kalessin.fr> <louis@dotcloud.com>
Lovekesh Kumar <lovekesh.kumar@rtcamp.com>
Luca Favatella <luca.favatella@erlang-solutions.com> <lucafavatella@users.noreply.github.com> Luca Favatella <luca.favatella@erlang-solutions.com> <lucafavatella@users.noreply.github.com>
Lukas Michael <lukas.23022005@gmail.com>
Luke Marsden <me@lukemarsden.net> <luke@digital-crocus.com> Luke Marsden <me@lukemarsden.net> <luke@digital-crocus.com>
Luo Jiyin <luojiyin@hotmail.com>
Lyn <energylyn@zju.edu.cn> Lyn <energylyn@zju.edu.cn>
Lynda O'Leary <lyndaoleary29@gmail.com> Lynda O'Leary <lyndaoleary29@gmail.com>
Lynda O'Leary <lyndaoleary29@gmail.com> <lyndaoleary@hotmail.com> Lynda O'Leary <lyndaoleary29@gmail.com> <lyndaoleary@hotmail.com>
@@ -405,7 +380,6 @@ Michael Huettermann <michael@huettermann.net>
Michael Käufl <docker@c.michael-kaeufl.de> <michael-k@users.noreply.github.com> Michael Käufl <docker@c.michael-kaeufl.de> <michael-k@users.noreply.github.com>
Michael Spetsiotis <michael_spets@hotmail.com> Michael Spetsiotis <michael_spets@hotmail.com>
Michal Minář <miminar@redhat.com> Michal Minář <miminar@redhat.com>
Mickael Emirkanian <mickael.emirkanian@docker.com>
Miguel Angel Alvarez Cabrerizo <doncicuto@gmail.com> <30386061+doncicuto@users.noreply.github.com> Miguel Angel Alvarez Cabrerizo <doncicuto@gmail.com> <30386061+doncicuto@users.noreply.github.com>
Miguel Angel Fernández <elmendalerenda@gmail.com> Miguel Angel Fernández <elmendalerenda@gmail.com>
Mihai Borobocea <MihaiBorob@gmail.com> <MihaiBorobocea@gmail.com> Mihai Borobocea <MihaiBorob@gmail.com> <MihaiBorobocea@gmail.com>
@@ -415,7 +389,6 @@ Mike Dalton <mikedalton@github.com> <19153140+mikedalton@users.noreply.github.co
Mike Goelzer <mike.goelzer@docker.com> <mgoelzer@docker.com> Mike Goelzer <mike.goelzer@docker.com> <mgoelzer@docker.com>
Milind Chawre <milindchawre@gmail.com> Milind Chawre <milindchawre@gmail.com>
Misty Stanley-Jones <misty@docker.com> <misty@apache.org> Misty Stanley-Jones <misty@docker.com> <misty@apache.org>
Mohammad Hossein <mhm98035@gmail.com>
Mohit Soni <mosoni@ebay.com> <mohitsoni1989@gmail.com> Mohit Soni <mosoni@ebay.com> <mohitsoni1989@gmail.com>
Moorthy RS <rsmoorthy@gmail.com> <rsmoorthy@users.noreply.github.com> Moorthy RS <rsmoorthy@gmail.com> <rsmoorthy@users.noreply.github.com>
Morten Hekkvang <morten.hekkvang@sbab.se> Morten Hekkvang <morten.hekkvang@sbab.se>
@@ -439,7 +412,6 @@ O.S. Tezer <ostezer@gmail.com> <ostezer@users.noreply.github.com>
Oh Jinkyun <tintypemolly@gmail.com> <tintypemolly@Ohui-MacBook-Pro.local> Oh Jinkyun <tintypemolly@gmail.com> <tintypemolly@Ohui-MacBook-Pro.local>
Oliver Pomeroy <oppomeroy@gmail.com> Oliver Pomeroy <oppomeroy@gmail.com>
Ouyang Liduo <oyld0210@163.com> Ouyang Liduo <oyld0210@163.com>
Patrick St. laurent <patrick@saint-laurent.us>
Patrick Stapleton <github@gdi2290.com> Patrick Stapleton <github@gdi2290.com>
Paul Liljenberg <liljenberg.paul@gmail.com> <letters@paulnotcom.se> Paul Liljenberg <liljenberg.paul@gmail.com> <letters@paulnotcom.se>
Pavel Tikhomirov <ptikhomirov@virtuozzo.com> <ptikhomirov@parallels.com> Pavel Tikhomirov <ptikhomirov@virtuozzo.com> <ptikhomirov@parallels.com>
@@ -472,7 +444,6 @@ Roch Feuillade <roch.feuillade@pandobac.com>
Roch Feuillade <roch.feuillade@pandobac.com> <46478807+rochfeu@users.noreply.github.com> Roch Feuillade <roch.feuillade@pandobac.com> <46478807+rochfeu@users.noreply.github.com>
Roman Dudin <katrmr@gmail.com> <decadent@users.noreply.github.com> Roman Dudin <katrmr@gmail.com> <decadent@users.noreply.github.com>
Ross Boucher <rboucher@gmail.com> Ross Boucher <rboucher@gmail.com>
Rui JingAn <quiterace@gmail.com>
Runshen Zhu <runshen.zhu@gmail.com> Runshen Zhu <runshen.zhu@gmail.com>
Ryan Stelly <ryan.stelly@live.com> Ryan Stelly <ryan.stelly@live.com>
Sakeven Jiang <jc5930@sina.cn> Sakeven Jiang <jc5930@sina.cn>
@@ -519,7 +490,6 @@ Stephen Day <stevvooe@gmail.com> <stephen.day@docker.com>
Stephen Day <stevvooe@gmail.com> <stevvooe@users.noreply.github.com> Stephen Day <stevvooe@gmail.com> <stevvooe@users.noreply.github.com>
Steve Desmond <steve@vtsv.ca> <stevedesmond-ca@users.noreply.github.com> Steve Desmond <steve@vtsv.ca> <stevedesmond-ca@users.noreply.github.com>
Steve Richards <steve.richards@docker.com> stevejr <> Steve Richards <steve.richards@docker.com> stevejr <>
Stuart Williams <pid@pidster.com>
Sun Gengze <690388648@qq.com> Sun Gengze <690388648@qq.com>
Sun Jianbo <wonderflow.sun@gmail.com> Sun Jianbo <wonderflow.sun@gmail.com>
Sun Jianbo <wonderflow.sun@gmail.com> <wonderflow@zju.edu.cn> Sun Jianbo <wonderflow.sun@gmail.com> <wonderflow@zju.edu.cn>
@@ -554,8 +524,6 @@ Tim Bart <tim@fewagainstmany.com>
Tim Bosse <taim@bosboot.org> <maztaim@users.noreply.github.com> Tim Bosse <taim@bosboot.org> <maztaim@users.noreply.github.com>
Tim Ruffles <oi@truffles.me.uk> <timruffles@googlemail.com> Tim Ruffles <oi@truffles.me.uk> <timruffles@googlemail.com>
Tim Terhorst <mynamewastaken+git@gmail.com> Tim Terhorst <mynamewastaken+git@gmail.com>
Tim Welsh <timothy.welsh@docker.com>
Tim Welsh <timothy.welsh@docker.com> <84401379+twelsh-aw@users.noreply.github.com>
Tim Zju <21651152@zju.edu.cn> Tim Zju <21651152@zju.edu.cn>
Timothy Hobbs <timothyhobbs@seznam.cz> Timothy Hobbs <timothyhobbs@seznam.cz>
Toli Kuznets <toli@docker.com> Toli Kuznets <toli@docker.com>
@@ -575,8 +543,6 @@ Ulysses Souza <ulysses.souza@docker.com>
Ulysses Souza <ulysses.souza@docker.com> <ulyssessouza@gmail.com> Ulysses Souza <ulysses.souza@docker.com> <ulyssessouza@gmail.com>
Umesh Yadav <umesh4257@gmail.com> Umesh Yadav <umesh4257@gmail.com>
Umesh Yadav <umesh4257@gmail.com> <dungeonmaster18@users.noreply.github.com> Umesh Yadav <umesh4257@gmail.com> <dungeonmaster18@users.noreply.github.com>
Varun Hotani <varunhotani@gmail.com>
Vibhu Anan <vibhuanand@outlook.com>
Victor Lyuboslavsky <victor@victoreda.com> Victor Lyuboslavsky <victor@victoreda.com>
Victor Vieux <victor.vieux@docker.com> <dev@vvieux.com> Victor Vieux <victor.vieux@docker.com> <dev@vvieux.com>
Victor Vieux <victor.vieux@docker.com> <victor.vieux@dotcloud.com> Victor Vieux <victor.vieux@docker.com> <victor.vieux@dotcloud.com>
+3 -87
View File
@@ -2,7 +2,6 @@
# This file lists all contributors to the repository. # This file lists all contributors to the repository.
# See scripts/docs/generate-authors.sh to make modifications. # See scripts/docs/generate-authors.sh to make modifications.
4RH1T3CT0R7 <iprintercanon@gmail.com>
A. Lester Buck III <github-reg@nbolt.com> A. Lester Buck III <github-reg@nbolt.com>
Aanand Prasad <aanand.prasad@gmail.com> Aanand Prasad <aanand.prasad@gmail.com>
Aaron L. Xu <liker.xu@foxmail.com> Aaron L. Xu <liker.xu@foxmail.com>
@@ -27,7 +26,6 @@ Akhil Mohan <akhil.mohan@mayadata.io>
Akihiro Suda <akihiro.suda.cz@hco.ntt.co.jp> Akihiro Suda <akihiro.suda.cz@hco.ntt.co.jp>
Akim Demaille <akim.demaille@docker.com> Akim Demaille <akim.demaille@docker.com>
Alan Thompson <cloojure@gmail.com> Alan Thompson <cloojure@gmail.com>
Alano Terblanche <alano.terblanche@docker.com>
Albert Callarisa <shark234@gmail.com> Albert Callarisa <shark234@gmail.com>
Alberto Roura <mail@albertoroura.com> Alberto Roura <mail@albertoroura.com>
Albin Kerouanton <albinker@gmail.com> Albin Kerouanton <albinker@gmail.com>
@@ -43,15 +41,12 @@ Alexander Larsson <alexl@redhat.com>
Alexander Morozov <lk4d4math@gmail.com> Alexander Morozov <lk4d4math@gmail.com>
Alexander Ryabov <i@sepa.spb.ru> Alexander Ryabov <i@sepa.spb.ru>
Alexandre González <agonzalezro@gmail.com> Alexandre González <agonzalezro@gmail.com>
Alexandre Levavasseur <alexandre+oss@13x.fr>
Alexandre Vallières-Lagacé <alexandre.valliereslagace@docker.com>
Alexey Igrychev <alexey.igrychev@flant.com> Alexey Igrychev <alexey.igrychev@flant.com>
Alexis Couvreur <alexiscouvreur.pro@gmail.com> Alexis Couvreur <alexiscouvreur.pro@gmail.com>
Alfred Landrum <alfred.landrum@docker.com> Alfred Landrum <alfred.landrum@docker.com>
Ali Rostami <rostami.ali@gmail.com> Ali Rostami <rostami.ali@gmail.com>
Alicia Lauerman <alicia@eta.im> Alicia Lauerman <alicia@eta.im>
Allen Sun <allensun.shl@alibaba-inc.com> Allen Sun <allensun.shl@alibaba-inc.com>
Allie Sadler <allie.sadler@docker.com>
Alvin Deng <alvin.q.deng@utexas.edu> Alvin Deng <alvin.q.deng@utexas.edu>
Amen Belayneh <amenbelayneh@gmail.com> Amen Belayneh <amenbelayneh@gmail.com>
Amey Shrivastava <72866602+AmeyShrivastava@users.noreply.github.com> Amey Shrivastava <72866602+AmeyShrivastava@users.noreply.github.com>
@@ -66,13 +61,10 @@ Andreas Köhler <andi5.py@gmx.net>
Andres G. Aragoneses <knocte@gmail.com> Andres G. Aragoneses <knocte@gmail.com>
Andres Leon Rangel <aleon1220@gmail.com> Andres Leon Rangel <aleon1220@gmail.com>
Andrew France <andrew@avito.co.uk> 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 Hsu <andrewhsu@docker.com>
Andrew Macpherson <hopscotch23@gmail.com> Andrew Macpherson <hopscotch23@gmail.com>
Andrew McDonnell <bugs@andrewmcdonnell.net> Andrew McDonnell <bugs@andrewmcdonnell.net>
Andrew Po <absourd.noise@gmail.com> Andrew Po <absourd.noise@gmail.com>
Andrew-Zipperer <atzipperer@gmail.com>
Andrey Petrov <andrey.petrov@shazow.net> Andrey Petrov <andrey.petrov@shazow.net>
Andrii Berehuliak <berkusandrew@gmail.com> Andrii Berehuliak <berkusandrew@gmail.com>
André Martins <aanm90@gmail.com> André Martins <aanm90@gmail.com>
@@ -87,16 +79,13 @@ Antonis Kalipetis <akalipetis@gmail.com>
Anusha Ragunathan <anusha.ragunathan@docker.com> Anusha Ragunathan <anusha.ragunathan@docker.com>
Ao Li <la9249@163.com> Ao Li <la9249@163.com>
Arash Deshmeh <adeshmeh@ca.ibm.com> Arash Deshmeh <adeshmeh@ca.ibm.com>
Archimedes Trajano <developer@trajano.net>
Arko Dasgupta <arko@tetrate.io> Arko Dasgupta <arko@tetrate.io>
Arnaud Porterie <icecrime@gmail.com> Arnaud Porterie <icecrime@gmail.com>
Arnaud Rebillout <elboulangero@gmail.com> Arnaud Rebillout <elboulangero@gmail.com>
Arthur Flageul <arthur.flageul@gmail.com>
Arthur Peka <arthur.peka@outlook.com> Arthur Peka <arthur.peka@outlook.com>
Ashly Mathew <ashly.mathew@sap.com> Ashly Mathew <ashly.mathew@sap.com>
Ashwini Oruganti <ashwini.oruganti@gmail.com> Ashwini Oruganti <ashwini.oruganti@gmail.com>
Aslam Ahemad <aslamahemad@gmail.com> Aslam Ahemad <aslamahemad@gmail.com>
Austin Vazquez <austin.vazquez@docker.com>
Azat Khuyiyakhmetov <shadow_uz@mail.ru> Azat Khuyiyakhmetov <shadow_uz@mail.ru>
Bardia Keyoumarsi <bkeyouma@ucsc.edu> Bardia Keyoumarsi <bkeyouma@ucsc.edu>
Barnaby Gray <barnaby@pickle.me.uk> Barnaby Gray <barnaby@pickle.me.uk>
@@ -131,23 +120,17 @@ Brian Goff <cpuguy83@gmail.com>
Brian Tracy <brian.tracy33@gmail.com> Brian Tracy <brian.tracy33@gmail.com>
Brian Wieder <brian@4wieders.com> Brian Wieder <brian@4wieders.com>
Bruno Sousa <bruno.sousa@docker.com> Bruno Sousa <bruno.sousa@docker.com>
Bruno Verachten <gounthar@gmail.com>
Bryan Bess <squarejaw@bsbess.com> Bryan Bess <squarejaw@bsbess.com>
Bryan Boreham <bjboreham@gmail.com> Bryan Boreham <bjboreham@gmail.com>
Bryan Murphy <bmurphy1976@gmail.com> Bryan Murphy <bmurphy1976@gmail.com>
bryfry <bryon.fryer@gmail.com> bryfry <bryon.fryer@gmail.com>
Calvin Liu <flycalvin@qq.com>
Cameron Spear <cameronspear@gmail.com> Cameron Spear <cameronspear@gmail.com>
Cao Weiwei <cao.weiwei30@zte.com.cn> Cao Weiwei <cao.weiwei30@zte.com.cn>
Carlo Mion <mion00@gmail.com> Carlo Mion <mion00@gmail.com>
Carlos Alexandro Becker <caarlos0@gmail.com> Carlos Alexandro Becker <caarlos0@gmail.com>
Carlos de Paula <me@carlosedp.com> Carlos de Paula <me@carlosedp.com>
carsontham <carsontham@outlook.com>
Carston Schilds <Carston.Schilds@visier.com>
Casey Korver <casey@korver.dev>
Ce Gao <ce.gao@outlook.com> Ce Gao <ce.gao@outlook.com>
Cedric Davies <cedricda@microsoft.com> Cedric Davies <cedricda@microsoft.com>
Cesar Talledo <cesar.talledo@docker.com>
Cezar Sa Espinola <cezarsa@gmail.com> Cezar Sa Espinola <cezarsa@gmail.com>
Chad Faragher <wyckster@hotmail.com> Chad Faragher <wyckster@hotmail.com>
Chao Wang <wangchao.fnst@cn.fujitsu.com> Chao Wang <wangchao.fnst@cn.fujitsu.com>
@@ -162,7 +145,6 @@ Chen Chuanliang <chen.chuanliang@zte.com.cn>
Chen Hanxiao <chenhanxiao@cn.fujitsu.com> Chen Hanxiao <chenhanxiao@cn.fujitsu.com>
Chen Mingjie <chenmingjie0828@163.com> Chen Mingjie <chenmingjie0828@163.com>
Chen Qiu <cheney-90@hotmail.com> Chen Qiu <cheney-90@hotmail.com>
Ching Wei Kang <164879897+WilliamK112@users.noreply.github.com>
Chris Chinchilla <chris@chrischinchilla.com> Chris Chinchilla <chris@chrischinchilla.com>
Chris Couzens <ccouzens@gmail.com> Chris Couzens <ccouzens@gmail.com>
Chris Gavin <chris@chrisgavin.me> Chris Gavin <chris@chrisgavin.me>
@@ -178,13 +160,10 @@ Christophe Vidal <kriss@krizalys.com>
Christopher Biscardi <biscarch@sketcht.com> Christopher Biscardi <biscarch@sketcht.com>
Christopher Crone <christopher.crone@docker.com> Christopher Crone <christopher.crone@docker.com>
Christopher Jones <tophj@linux.vnet.ibm.com> Christopher Jones <tophj@linux.vnet.ibm.com>
Christopher Petito <47751006+krissetto@users.noreply.github.com>
Christopher Petito <chrisjpetito@gmail.com>
Christopher Svensson <stoffus@stoffus.com> Christopher Svensson <stoffus@stoffus.com>
Christy Norman <christy@linux.vnet.ibm.com> Christy Norman <christy@linux.vnet.ibm.com>
Chun Chen <ramichen@tencent.com> Chun Chen <ramichen@tencent.com>
Clinton Kitson <clintonskitson@gmail.com> Clinton Kitson <clintonskitson@gmail.com>
Codex <codex@openai.com>
Coenraad Loubser <coenraad@wish.org.za> Coenraad Loubser <coenraad@wish.org.za>
Colin Hebert <hebert.colin@gmail.com> Colin Hebert <hebert.colin@gmail.com>
Collin Guarino <collin.guarino@gmail.com> Collin Guarino <collin.guarino@gmail.com>
@@ -204,7 +183,6 @@ Daisuke Ito <itodaisuke00@gmail.com>
dalanlan <dalanlan925@gmail.com> dalanlan <dalanlan925@gmail.com>
Damien Nadé <github@livna.org> Damien Nadé <github@livna.org>
Dan Cotora <dan@bluevision.ro> Dan Cotora <dan@bluevision.ro>
Dan Wallis <dan@wallis.nz>
Danial Gharib <danial.mail.gh@gmail.com> Danial Gharib <danial.mail.gh@gmail.com>
Daniel Artine <daniel.artine@ufrj.br> Daniel Artine <daniel.artine@ufrj.br>
Daniel Cassidy <mail@danielcassidy.me.uk> Daniel Cassidy <mail@danielcassidy.me.uk>
@@ -231,17 +209,15 @@ David Alvarez <david.alvarez@flyeralarm.com>
David Beitey <david@davidjb.com> David Beitey <david@davidjb.com>
David Calavera <david.calavera@gmail.com> David Calavera <david.calavera@gmail.com>
David Cramer <davcrame@cisco.com> David Cramer <davcrame@cisco.com>
David Dooling <david.dooling@docker.com> David Dooling <dooling@gmail.com>
David Gageot <david@gageot.net> David Gageot <david@gageot.net>
David Karlsson <david.karlsson@docker.com> David Karlsson <david.karlsson@docker.com>
David le Blanc <systemmonkey42@users.noreply.github.com>
David Lechner <david@lechnology.com> David Lechner <david@lechnology.com>
David Scott <dave@recoil.org> David Scott <dave@recoil.org>
David Sheets <dsheets@docker.com> David Sheets <dsheets@docker.com>
David Williamson <david.williamson@docker.com> David Williamson <david.williamson@docker.com>
David Xia <dxia@spotify.com> David Xia <dxia@spotify.com>
David Young <yangboh@cn.ibm.com> David Young <yangboh@cn.ibm.com>
Davlat Davydov <literally_user@hotmail.com>
Deng Guangxing <dengguangxing@huawei.com> Deng Guangxing <dengguangxing@huawei.com>
Denis Defreyne <denis@soundcloud.com> Denis Defreyne <denis@soundcloud.com>
Denis Gladkikh <denis@gladkikh.email> Denis Gladkikh <denis@gladkikh.email>
@@ -249,13 +225,11 @@ Denis Ollier <larchunix@users.noreply.github.com>
Dennis Docter <dennis@d23.nl> Dennis Docter <dennis@d23.nl>
dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Derek McGowan <derek@mcg.dev> Derek McGowan <derek@mcg.dev>
Derek Misler <derek.misler@docker.com>
Des Preston <despreston@gmail.com> Des Preston <despreston@gmail.com>
Deshi Xiao <dxiao@redhat.com> Deshi Xiao <dxiao@redhat.com>
Dharmit Shah <shahdharmit@gmail.com> Dharmit Shah <shahdharmit@gmail.com>
Dhawal Yogesh Bhanushali <dbhanushali@vmware.com> Dhawal Yogesh Bhanushali <dbhanushali@vmware.com>
Dieter Reuter <dieter.reuter@me.com> Dieter Reuter <dieter.reuter@me.com>
Dilep Dev <34891655+DilepDev@users.noreply.github.com>
Dima Stopel <dima@twistlock.com> Dima Stopel <dima@twistlock.com>
Dimitry Andric <d.andric@activevideo.com> Dimitry Andric <d.andric@activevideo.com>
Ding Fei <dingfei@stars.org.cn> Ding Fei <dingfei@stars.org.cn>
@@ -269,7 +243,6 @@ Dominik Braun <dominik.braun@nbsp.de>
Don Kjer <don.kjer@gmail.com> Don Kjer <don.kjer@gmail.com>
Dong Chen <dongluo.chen@docker.com> Dong Chen <dongluo.chen@docker.com>
DongGeon Lee <secmatth1996@gmail.com> DongGeon Lee <secmatth1996@gmail.com>
Dorin Geman <dorin.geman@docker.com>
Doug Davis <dug@us.ibm.com> Doug Davis <dug@us.ibm.com>
Drew Erny <derny@mirantis.com> Drew Erny <derny@mirantis.com>
Ed Costello <epc@epcostello.com> Ed Costello <epc@epcostello.com>
@@ -279,7 +252,6 @@ Eli Uriegas <eli.uriegas@docker.com>
Eli Uriegas <seemethere101@gmail.com> Eli Uriegas <seemethere101@gmail.com>
Elias Faxö <elias.faxo@tre.se> Elias Faxö <elias.faxo@tre.se>
Elliot Luo <956941328@qq.com> Elliot Luo <956941328@qq.com>
Eng Zer Jun <engzerjun@gmail.com>
Eric Bode <eric.bode@foundries.io> Eric Bode <eric.bode@foundries.io>
Eric Curtin <ericcurtin17@gmail.com> Eric Curtin <ericcurtin17@gmail.com>
Eric Engestrom <eric@engestrom.ch> Eric Engestrom <eric@engestrom.ch>
@@ -326,18 +298,14 @@ Gang Qiao <qiaohai8866@gmail.com>
Gary Schaetz <gary@schaetzkc.com> Gary Schaetz <gary@schaetzkc.com>
Genki Takiuchi <genki@s21g.com> Genki Takiuchi <genki@s21g.com>
George MacRorie <gmacr31@gmail.com> George MacRorie <gmacr31@gmail.com>
George Margaritis <gmargaritis@protonmail.com>
George Xie <georgexsh@gmail.com> George Xie <georgexsh@gmail.com>
Gianluca Borello <g.borello@gmail.com> Gianluca Borello <g.borello@gmail.com>
Giau. Tran Minh <hello@giautm.dev>
Giedrius Jonikas <giedriusj1@gmail.com>
Gildas Cuisinier <gildas.cuisinier@gcuisinier.net> Gildas Cuisinier <gildas.cuisinier@gcuisinier.net>
Gio d'Amelio <giodamelio@gmail.com> Gio d'Amelio <giodamelio@gmail.com>
Gleb Stsenov <gleb.stsenov@gmail.com> Gleb Stsenov <gleb.stsenov@gmail.com>
Goksu Toprak <goksu.toprak@docker.com> Goksu Toprak <goksu.toprak@docker.com>
Gou Rao <gou@portworx.com> Gou Rao <gou@portworx.com>
Govind Rai <raigovind93@gmail.com> Govind Rai <raigovind93@gmail.com>
Grace Choi <grace.54109@gmail.com>
Graeme Wiebe <graeme.wiebe@gmail.com> Graeme Wiebe <graeme.wiebe@gmail.com>
Grant Reaber <grant.reaber@gmail.com> Grant Reaber <grant.reaber@gmail.com>
Greg Pflaum <gpflaum@users.noreply.github.com> Greg Pflaum <gpflaum@users.noreply.github.com>
@@ -360,7 +328,6 @@ Henning Sprang <henning.sprang@gmail.com>
Henry N <henrynmail-github@yahoo.de> Henry N <henrynmail-github@yahoo.de>
Hernan Garcia <hernandanielg@gmail.com> Hernan Garcia <hernandanielg@gmail.com>
Hongbin Lu <hongbin034@gmail.com> Hongbin Lu <hongbin034@gmail.com>
Hossein Abbasi <16090309+hsnabszhdn@users.noreply.github.com>
Hu Keping <hukeping@huawei.com> Hu Keping <hukeping@huawei.com>
Huayi Zhang <irachex@gmail.com> Huayi Zhang <irachex@gmail.com>
Hugo Chastel <Hugo-C@users.noreply.github.com> Hugo Chastel <Hugo-C@users.noreply.github.com>
@@ -368,7 +335,6 @@ Hugo Gabriel Eyherabide <hugogabriel.eyherabide@gmail.com>
huqun <huqun@zju.edu.cn> huqun <huqun@zju.edu.cn>
Huu Nguyen <huu@prismskylabs.com> Huu Nguyen <huu@prismskylabs.com>
Hyzhou Zhy <hyzhou.zhy@alibaba-inc.com> Hyzhou Zhy <hyzhou.zhy@alibaba-inc.com>
Iain MacDonald <IJMacD@gmail.com>
Iain Samuel McLean Elder <iain@isme.es> Iain Samuel McLean Elder <iain@isme.es>
Ian Campbell <ian.campbell@docker.com> Ian Campbell <ian.campbell@docker.com>
Ian Philpot <ian.philpot@microsoft.com> Ian Philpot <ian.philpot@microsoft.com>
@@ -418,10 +384,8 @@ Jesse Adametz <jesseadametz@gmail.com>
Jessica Frazelle <jess@oxide.computer> Jessica Frazelle <jess@oxide.computer>
Jezeniel Zapanta <jpzapanta22@gmail.com> Jezeniel Zapanta <jpzapanta22@gmail.com>
Jian Zhang <zhangjian.fnst@cn.fujitsu.com> Jian Zhang <zhangjian.fnst@cn.fujitsu.com>
Jianyong Wu <wujianyong@hygon.cn>
Jie Luo <luo612@zju.edu.cn> Jie Luo <luo612@zju.edu.cn>
Jilles Oldenbeuving <ojilles@gmail.com> Jilles Oldenbeuving <ojilles@gmail.com>
Jim Chen <njucjc@gmail.com>
Jim Galasyn <jim.galasyn@docker.com> Jim Galasyn <jim.galasyn@docker.com>
Jim Lin <b04705003@ntu.edu.tw> Jim Lin <b04705003@ntu.edu.tw>
Jimmy Leger <jimmy.leger@gmail.com> Jimmy Leger <jimmy.leger@gmail.com>
@@ -452,7 +416,6 @@ John Willis <john.willis@docker.com>
Jon Johnson <jonjohnson@google.com> Jon Johnson <jonjohnson@google.com>
Jon Zeolla <zeolla@gmail.com> Jon Zeolla <zeolla@gmail.com>
Jonatas Baldin <jonatas.baldin@gmail.com> Jonatas Baldin <jonatas.baldin@gmail.com>
Jonathan A. Sternberg <jonathansternberg@gmail.com>
Jonathan Boulle <jonathanboulle@gmail.com> Jonathan Boulle <jonathanboulle@gmail.com>
Jonathan Lee <jonjohn1232009@gmail.com> Jonathan Lee <jonjohn1232009@gmail.com>
Jonathan Lomas <jonathan@floatinglomas.ca> Jonathan Lomas <jonathan@floatinglomas.ca>
@@ -472,7 +435,6 @@ Julian <gitea+julian@ic.thejulian.uk>
Julien Barbier <write0@gmail.com> Julien Barbier <write0@gmail.com>
Julien Kassar <github@kassisol.com> Julien Kassar <github@kassisol.com>
Julien Maitrehenry <julien.maitrehenry@me.com> Julien Maitrehenry <julien.maitrehenry@me.com>
Julio Cesar Garcia <juliogarciamelgarejo@gmail.com>
Justas Brazauskas <brazauskasjustas@gmail.com> Justas Brazauskas <brazauskasjustas@gmail.com>
Justin Chadwell <me@jedevc.com> Justin Chadwell <me@jedevc.com>
Justin Cormack <justin.cormack@docker.com> Justin Cormack <justin.cormack@docker.com>
@@ -481,7 +443,6 @@ Justyn Temme <justyntemme@gmail.com>
Jyrki Puttonen <jyrkiput@gmail.com> Jyrki Puttonen <jyrkiput@gmail.com>
Jérémie Drouet <jeremie.drouet@gmail.com> Jérémie Drouet <jeremie.drouet@gmail.com>
Jérôme Petazzoni <jerome.petazzoni@docker.com> Jérôme Petazzoni <jerome.petazzoni@docker.com>
Jörg Sommer <joerg@jo-so.de>
Jörg Thalheim <joerg@higgsboson.tk> Jörg Thalheim <joerg@higgsboson.tk>
Kai Blin <kai@samba.org> Kai Blin <kai@samba.org>
Kai Qiang Wu (Kennan) <wkq5325@gmail.com> Kai Qiang Wu (Kennan) <wkq5325@gmail.com>
@@ -509,7 +470,6 @@ Kevin Woblick <mail@kovah.de>
khaled souf <khaled.souf@gmail.com> khaled souf <khaled.souf@gmail.com>
Kim Eik <kim@heldig.org> Kim Eik <kim@heldig.org>
Kir Kolyshkin <kolyshkin@gmail.com> Kir Kolyshkin <kolyshkin@gmail.com>
Kirill A. Korinsky <kirill@korins.ky>
Kotaro Yoshimatsu <kotaro.yoshimatsu@gmail.com> Kotaro Yoshimatsu <kotaro.yoshimatsu@gmail.com>
Krasi Georgiev <krasi@vip-consult.solutions> Krasi Georgiev <krasi@vip-consult.solutions>
Kris-Mikael Krister <krismikael@protonmail.com> Kris-Mikael Krister <krismikael@protonmail.com>
@@ -518,22 +478,19 @@ Kunal Kushwaha <kushwaha_kunal_v7@lab.ntt.co.jp>
Kyle Mitofsky <Kylemit@gmail.com> Kyle Mitofsky <Kylemit@gmail.com>
Lachlan Cooper <lachlancooper@gmail.com> Lachlan Cooper <lachlancooper@gmail.com>
Lai Jiangshan <jiangshanlai@gmail.com> Lai Jiangshan <jiangshanlai@gmail.com>
Lajos Papp <lajos.papp@sequenceiq.com>
Lars Kellogg-Stedman <lars@redhat.com> Lars Kellogg-Stedman <lars@redhat.com>
Laura Brehm <laurabrehm@hey.com> Laura Brehm <laurabrehm@hey.com>
Laura Frank <ljfrank@gmail.com> Laura Frank <ljfrank@gmail.com>
Laurent Erignoux <lerignoux@gmail.com> Laurent Erignoux <lerignoux@gmail.com>
Laurent Goderre <laurent.goderre@docker.com>
Lee Gaines <eightlimbed@gmail.com> Lee Gaines <eightlimbed@gmail.com>
Lei Jitang <leijitang@huawei.com> Lei Jitang <leijitang@huawei.com>
Lennie <github@consolejunkie.net> Lennie <github@consolejunkie.net>
lentil32 <lentil32@icloud.com>
Leo Gallucci <elgalu3@gmail.com> Leo Gallucci <elgalu3@gmail.com>
Leonid Skorospelov <leosko94@gmail.com> Leonid Skorospelov <leosko94@gmail.com>
Lewis Daly <lewisdaly@me.com> Lewis Daly <lewisdaly@me.com>
Li Fu Bang <lifubang@acmcoder.com> Li Fu Bang <lifubang@acmcoder.com>
Li Yi <denverdino@gmail.com> Li Yi <denverdino@gmail.com>
Li Zeghong <zeghong@hotmail.com> Li Yi <weiyuan.yl@alibaba-inc.com>
Liang-Chi Hsieh <viirya@gmail.com> Liang-Chi Hsieh <viirya@gmail.com>
Lihua Tang <lhtang@alauda.io> Lihua Tang <lhtang@alauda.io>
Lily Guo <lily.guo@docker.com> Lily Guo <lily.guo@docker.com>
@@ -546,17 +503,13 @@ lixiaobing10051267 <li.xiaobing1@zte.com.cn>
Lloyd Dewolf <foolswisdom@gmail.com> Lloyd Dewolf <foolswisdom@gmail.com>
Lorenzo Fontana <lo@linux.com> Lorenzo Fontana <lo@linux.com>
Louis Opter <kalessin@kalessin.fr> Louis Opter <kalessin@kalessin.fr>
Lovekesh Kumar <lovekesh.kumar@rtcamp.com>
Luca Favatella <luca.favatella@erlang-solutions.com> Luca Favatella <luca.favatella@erlang-solutions.com>
Luca Marturana <lucamarturana@gmail.com> Luca Marturana <lucamarturana@gmail.com>
Lucas Chan <lucas-github@lucaschan.com> Lucas Chan <lucas-github@lucaschan.com>
Ludovic Temgoua Abanda <abandaludovic500@gmail.com>
Luis Henrique Mulinari <luis.mulinari@gmail.com> Luis Henrique Mulinari <luis.mulinari@gmail.com>
Luka Hartwig <mail@lukahartwig.de> Luka Hartwig <mail@lukahartwig.de>
Lukas Heeren <lukas-heeren@hotmail.com> Lukas Heeren <lukas-heeren@hotmail.com>
Lukas Michael <lukas.23022005@gmail.com>
Lukasz Zajaczkowski <Lukasz.Zajaczkowski@ts.fujitsu.com> Lukasz Zajaczkowski <Lukasz.Zajaczkowski@ts.fujitsu.com>
Luo Jiyin <luojiyin@hotmail.com>
Lydell Manganti <LydellManganti@users.noreply.github.com> Lydell Manganti <LydellManganti@users.noreply.github.com>
Lénaïc Huard <lhuard@amadeus.com> Lénaïc Huard <lhuard@amadeus.com>
Ma Shimiao <mashimiao.fnst@cn.fujitsu.com> Ma Shimiao <mashimiao.fnst@cn.fujitsu.com>
@@ -565,7 +518,6 @@ Maciej Kalisz <maciej.d.kalisz@gmail.com>
Madhav Puri <madhav.puri@gmail.com> Madhav Puri <madhav.puri@gmail.com>
Madhu Venugopal <madhu@socketplane.io> Madhu Venugopal <madhu@socketplane.io>
Madhur Batra <madhurbatra097@gmail.com> Madhur Batra <madhurbatra097@gmail.com>
Mahesh Thakur <maheshthakur9152@gmail.com>
Malte Janduda <mail@janduda.net> Malte Janduda <mail@janduda.net>
Manjunath A Kumatagi <mkumatag@in.ibm.com> Manjunath A Kumatagi <mkumatag@in.ibm.com>
Mansi Nahar <mmn4185@rit.edu> Mansi Nahar <mmn4185@rit.edu>
@@ -578,7 +530,6 @@ Marco Vedovati <mvedovati@suse.com>
Marcus Martins <marcus@docker.com> Marcus Martins <marcus@docker.com>
Marianna Tessel <mtesselh@gmail.com> Marianna Tessel <mtesselh@gmail.com>
Marius Ileana <marius.ileana@gmail.com> Marius Ileana <marius.ileana@gmail.com>
Marius Meschter <marius@meschter.me>
Marius Sturm <marius@graylog.com> Marius Sturm <marius@graylog.com>
Mark Oates <fl0yd@me.com> Mark Oates <fl0yd@me.com>
Marsh Macy <marsma@microsoft.com> Marsh Macy <marsma@microsoft.com>
@@ -587,25 +538,19 @@ Mary Anthony <mary.anthony@docker.com>
Mason Fish <mason.fish@docker.com> Mason Fish <mason.fish@docker.com>
Mason Malone <mason.malone@gmail.com> Mason Malone <mason.malone@gmail.com>
Mateusz Major <apkd@users.noreply.github.com> Mateusz Major <apkd@users.noreply.github.com>
Mathias Duedahl <64321057+Lussebullen@users.noreply.github.com>
Mathieu Champlon <mathieu.champlon@docker.com> Mathieu Champlon <mathieu.champlon@docker.com>
Mathieu Rollet <matletix@gmail.com> Mathieu Rollet <matletix@gmail.com>
Matt Gucci <matt9ucci@gmail.com> Matt Gucci <matt9ucci@gmail.com>
Matt Robenolt <matt@ydekproductions.com> Matt Robenolt <matt@ydekproductions.com>
Matteo Orefice <matteo.orefice@bites4bits.software> Matteo Orefice <matteo.orefice@bites4bits.software>
Matteo Panzeri <matteo1782@gmail.com>
Matthew Heon <mheon@redhat.com> Matthew Heon <mheon@redhat.com>
Matthieu Hauglustaine <matt.hauglustaine@gmail.com> Matthieu Hauglustaine <matt.hauglustaine@gmail.com>
Matthieu MOREL <matthieu.morel35@gmail.com>
Mauro Porras P <mauroporrasp@gmail.com> Mauro Porras P <mauroporrasp@gmail.com>
Max Morozov <gtmax.yo@gmail.com>
Max Shytikov <mshytikov@gmail.com> Max Shytikov <mshytikov@gmail.com>
Max-Julian Pogner <max-julian@pogner.at>
Maxime Petazzoni <max@signalfuse.com> Maxime Petazzoni <max@signalfuse.com>
Maximillian Fan Xavier <maximillianfx@gmail.com> Maximillian Fan Xavier <maximillianfx@gmail.com>
Mei ChunTao <mei.chuntao@zte.com.cn> Mei ChunTao <mei.chuntao@zte.com.cn>
Melroy van den Berg <melroy@melroy.org> Melroy van den Berg <melroy@melroy.org>
Mert Şişmanoğlu <mert190737fb@gmail.com>
Metal <2466052+tedhexaflow@users.noreply.github.com> Metal <2466052+tedhexaflow@users.noreply.github.com>
Micah Zoltu <micah@newrelic.com> Micah Zoltu <micah@newrelic.com>
Michael A. Smith <michael@smith-li.com> Michael A. Smith <michael@smith-li.com>
@@ -618,12 +563,9 @@ Michael Prokop <github@michael-prokop.at>
Michael Scharf <github@scharf.gr> Michael Scharf <github@scharf.gr>
Michael Spetsiotis <michael_spets@hotmail.com> Michael Spetsiotis <michael_spets@hotmail.com>
Michael Steinert <mike.steinert@gmail.com> Michael Steinert <mike.steinert@gmail.com>
Michael Tews <michael@tews.dev>
Michael West <mwest@mdsol.com> Michael West <mwest@mdsol.com>
Michael Zampani <michael.zampani@docker.com>
Michal Minář <miminar@redhat.com> Michal Minář <miminar@redhat.com>
Michał Czeraszkiewicz <czerasz@gmail.com> Michał Czeraszkiewicz <czerasz@gmail.com>
Mickael Emirkanian <mickael.emirkanian@docker.com>
Miguel Angel Alvarez Cabrerizo <doncicuto@gmail.com> Miguel Angel Alvarez Cabrerizo <doncicuto@gmail.com>
Mihai Borobocea <MihaiBorob@gmail.com> Mihai Borobocea <MihaiBorob@gmail.com>
Mihuleacc Sergiu <mihuleac.sergiu@gmail.com> Mihuleacc Sergiu <mihuleac.sergiu@gmail.com>
@@ -636,16 +578,12 @@ Mike Goelzer <mike.goelzer@docker.com>
Mike MacCana <mike.maccana@gmail.com> Mike MacCana <mike.maccana@gmail.com>
mikelinjie <294893458@qq.com> mikelinjie <294893458@qq.com>
Mikhail Vasin <vasin@cloud-tv.ru> Mikhail Vasin <vasin@cloud-tv.ru>
Milas Bowman <milas.bowman@docker.com>
Milind Chawre <milindchawre@gmail.com> Milind Chawre <milindchawre@gmail.com>
Mindaugas Rukas <momomg@gmail.com> Mindaugas Rukas <momomg@gmail.com>
Miroslav Gula <miroslav.gula@naytrolabs.com> Miroslav Gula <miroslav.gula@naytrolabs.com>
Misty Stanley-Jones <misty@docker.com> Misty Stanley-Jones <misty@docker.com>
Mohammad Banikazemi <mb@us.ibm.com> Mohammad Banikazemi <mb@us.ibm.com>
Mohammad Hossein <mhm98035@gmail.com>
Mohammed Aaqib Ansari <maaquib@gmail.com> Mohammed Aaqib Ansari <maaquib@gmail.com>
Mohammed Aminu Futa <mohammedfuta2000@gmail.com>
Mohammed Thaha <mohammedthahacse@gmail.com>
Mohini Anne Dsouza <mohini3917@gmail.com> Mohini Anne Dsouza <mohini3917@gmail.com>
Moorthy RS <rsmoorthy@gmail.com> Moorthy RS <rsmoorthy@gmail.com>
Morgan Bauer <mbauer@us.ibm.com> Morgan Bauer <mbauer@us.ibm.com>
@@ -672,7 +610,6 @@ Nathan McCauley <nathan.mccauley@docker.com>
Neil Peterson <neilpeterson@outlook.com> Neil Peterson <neilpeterson@outlook.com>
Nick Adcock <nick.adcock@docker.com> Nick Adcock <nick.adcock@docker.com>
Nick Santos <nick.santos@docker.com> Nick Santos <nick.santos@docker.com>
Nick Sieger <nick@nicksieger.com>
Nico Stapelbroek <nstapelbroek@gmail.com> Nico Stapelbroek <nstapelbroek@gmail.com>
Nicola Kabar <nicolaka@gmail.com> Nicola Kabar <nicolaka@gmail.com>
Nicolas Borboën <ponsfrilus@gmail.com> Nicolas Borboën <ponsfrilus@gmail.com>
@@ -680,11 +617,9 @@ Nicolas De Loof <nicolas.deloof@gmail.com>
Nikhil Chawla <chawlanikhil24@gmail.com> Nikhil Chawla <chawlanikhil24@gmail.com>
Nikolas Garofil <nikolas.garofil@uantwerpen.be> Nikolas Garofil <nikolas.garofil@uantwerpen.be>
Nikolay Milovanov <nmil@itransformers.net> Nikolay Milovanov <nmil@itransformers.net>
NinaLua <iturf@sina.cn>
Nir Soffer <nsoffer@redhat.com> Nir Soffer <nsoffer@redhat.com>
Nishant Totla <nishanttotla@gmail.com> Nishant Totla <nishanttotla@gmail.com>
NIWA Hideyuki <niwa.niwa@nifty.ne.jp> NIWA Hideyuki <niwa.niwa@nifty.ne.jp>
Noah Silas <noah@hustle.com>
Noah Treuhaft <noah.treuhaft@docker.com> Noah Treuhaft <noah.treuhaft@docker.com>
O.S. Tezer <ostezer@gmail.com> O.S. Tezer <ostezer@gmail.com>
Oded Arbel <oded@geek.co.il> Oded Arbel <oded@geek.co.il>
@@ -697,18 +632,15 @@ Olli Janatuinen <olli.janatuinen@gmail.com>
Oscar Wieman <oscrx@icloud.com> Oscar Wieman <oscrx@icloud.com>
Otto Kekäläinen <otto@seravo.fi> Otto Kekäläinen <otto@seravo.fi>
Ovidio Mallo <ovidio.mallo@gmail.com> Ovidio Mallo <ovidio.mallo@gmail.com>
Park Jaeon <me@finalchild.dev>
Pascal Borreli <pascal@borreli.com> Pascal Borreli <pascal@borreli.com>
Patrick Böänziger <patrick.baenziger@bsi-software.com> Patrick Böänziger <patrick.baenziger@bsi-software.com>
Patrick Daigle <114765035+pdaig@users.noreply.github.com> Patrick Daigle <114765035+pdaig@users.noreply.github.com>
Patrick Hemmer <patrick.hemmer@gmail.com> Patrick Hemmer <patrick.hemmer@gmail.com>
Patrick Lang <plang@microsoft.com> Patrick Lang <plang@microsoft.com>
Patrick St. laurent <patrick@saint-laurent.us>
Paul <paul9869@gmail.com> Paul <paul9869@gmail.com>
Paul Kehrer <paul.l.kehrer@gmail.com> Paul Kehrer <paul.l.kehrer@gmail.com>
Paul Lietar <paul@lietar.net> Paul Lietar <paul@lietar.net>
Paul Mulders <justinkb@gmail.com> Paul Mulders <justinkb@gmail.com>
Paul Rogalski <mail@paul-rogalski.de>
Paul Seyfert <pseyfert.mathphys@gmail.com> Paul Seyfert <pseyfert.mathphys@gmail.com>
Paul Weaver <pauweave@cisco.com> Paul Weaver <pauweave@cisco.com>
Pavel Pospisil <pospispa@gmail.com> Pavel Pospisil <pospispa@gmail.com>
@@ -724,13 +656,13 @@ Peter Jaffe <pjaffe@nevo.com>
Peter Kehl <peter.kehl@gmail.com> Peter Kehl <peter.kehl@gmail.com>
Peter Nagy <xificurC@gmail.com> Peter Nagy <xificurC@gmail.com>
Peter Salvatore <peter@psftw.com> Peter Salvatore <peter@psftw.com>
Peter Valdemar Mørch <peter@morch.com>
Peter Waller <p@pwaller.net> Peter Waller <p@pwaller.net>
Phil Estes <estesp@gmail.com> Phil Estes <estesp@gmail.com>
Philip Alexander Etling <paetling@gmail.com> Philip Alexander Etling <paetling@gmail.com>
Philipp Gillé <philipp.gille@gmail.com> Philipp Gillé <philipp.gille@gmail.com>
Philipp Schmied <pschmied@schutzwerk.com> Philipp Schmied <pschmied@schutzwerk.com>
Phong Tran <tran.pho@northeastern.edu> Phong Tran <tran.pho@northeastern.edu>
pidster <pid@pidster.com>
Pieter E Smit <diepes@github.com> Pieter E Smit <diepes@github.com>
pixelistik <pixelistik@users.noreply.github.com> pixelistik <pixelistik@users.noreply.github.com>
Pratik Karki <prertik@outlook.com> Pratik Karki <prertik@outlook.com>
@@ -749,7 +681,6 @@ Ray Tsang <rayt@google.com>
Reficul <xuzhenglun@gmail.com> Reficul <xuzhenglun@gmail.com>
Remy Suen <remy.suen@gmail.com> Remy Suen <remy.suen@gmail.com>
Renaud Gaubert <rgaubert@nvidia.com> Renaud Gaubert <rgaubert@nvidia.com>
René Hermenau <rene-hermenau@users.noreply.github.com>
Ricardo N Feliciano <FelicianoTech@gmail.com> Ricardo N Feliciano <FelicianoTech@gmail.com>
Rich Moyse <rich@moyse.us> Rich Moyse <rich@moyse.us>
Richard Chen Zheng <58443436+rchenzheng@users.noreply.github.com> Richard Chen Zheng <58443436+rchenzheng@users.noreply.github.com>
@@ -773,7 +704,6 @@ Rory Hunter <roryhunter2@gmail.com>
Ross Boucher <rboucher@gmail.com> Ross Boucher <rboucher@gmail.com>
Rubens Figueiredo <r.figueiredo.52@gmail.com> Rubens Figueiredo <r.figueiredo.52@gmail.com>
Rui Cao <ruicao@alauda.io> Rui Cao <ruicao@alauda.io>
Rui JingAn <quiterace@gmail.com>
Ryan Belgrave <rmb1993@gmail.com> Ryan Belgrave <rmb1993@gmail.com>
Ryan Detzel <ryan.detzel@gmail.com> Ryan Detzel <ryan.detzel@gmail.com>
Ryan Stelly <ryan.stelly@live.com> Ryan Stelly <ryan.stelly@live.com>
@@ -791,7 +721,6 @@ Samuel Cochran <sj26@sj26.com>
Samuel Karp <skarp@amazon.com> Samuel Karp <skarp@amazon.com>
Sandro Jäckel <sandro.jaeckel@gmail.com> Sandro Jäckel <sandro.jaeckel@gmail.com>
Santhosh Manohar <santhosh@docker.com> Santhosh Manohar <santhosh@docker.com>
Sarah Sanders <sarah.sanders@docker.com>
Sargun Dhillon <sargun@netflix.com> Sargun Dhillon <sargun@netflix.com>
Saswat Bhattacharya <sas.saswat@gmail.com> Saswat Bhattacharya <sas.saswat@gmail.com>
Saurabh Kumar <saurabhkumar0184@gmail.com> Saurabh Kumar <saurabhkumar0184@gmail.com>
@@ -800,7 +729,6 @@ Scott Collier <emailscottcollier@gmail.com>
Sean Christopherson <sean.j.christopherson@intel.com> Sean Christopherson <sean.j.christopherson@intel.com>
Sean Rodman <srodman7689@gmail.com> Sean Rodman <srodman7689@gmail.com>
Sebastiaan van Stijn <github@gone.nl> Sebastiaan van Stijn <github@gone.nl>
Seiya Miyata <odradek38@gmail.com>
Sergey Tryuber <Sergeant007@users.noreply.github.com> Sergey Tryuber <Sergeant007@users.noreply.github.com>
Serhat Gülçiçek <serhat25@gmail.com> Serhat Gülçiçek <serhat25@gmail.com>
Sevki Hasirci <s@sevki.org> Sevki Hasirci <s@sevki.org>
@@ -825,7 +753,6 @@ Spencer Brown <spencer@spencerbrown.org>
Spring Lee <xi.shuai@outlook.com> Spring Lee <xi.shuai@outlook.com>
squeegels <lmscrewy@gmail.com> squeegels <lmscrewy@gmail.com>
Srini Brahmaroutu <srbrahma@us.ibm.com> Srini Brahmaroutu <srbrahma@us.ibm.com>
Stavros Panakakis <stavrospanakakis@gmail.com>
Stefan S. <tronicum@user.github.com> Stefan S. <tronicum@user.github.com>
Stefan Scherer <stefan.scherer@docker.com> Stefan Scherer <stefan.scherer@docker.com>
Stefan Weil <sw@weilnetz.de> Stefan Weil <sw@weilnetz.de>
@@ -836,7 +763,6 @@ Steve Durrheimer <s.durrheimer@gmail.com>
Steve Richards <steve.richards@docker.com> Steve Richards <steve.richards@docker.com>
Steven Burgess <steven.a.burgess@hotmail.com> Steven Burgess <steven.a.burgess@hotmail.com>
Stoica-Marcu Floris-Andrei <floris.sm@gmail.com> Stoica-Marcu Floris-Andrei <floris.sm@gmail.com>
Stuart Williams <pid@pidster.com>
Subhajit Ghosh <isubuz.g@gmail.com> Subhajit Ghosh <isubuz.g@gmail.com>
Sun Jianbo <wonderflow.sun@gmail.com> Sun Jianbo <wonderflow.sun@gmail.com>
Sune Keller <absukl@almbrand.dk> Sune Keller <absukl@almbrand.dk>
@@ -871,7 +797,6 @@ Tim Hockin <thockin@google.com>
Tim Sampson <tim@sampson.fi> Tim Sampson <tim@sampson.fi>
Tim Smith <timbot@google.com> Tim Smith <timbot@google.com>
Tim Waugh <twaugh@redhat.com> Tim Waugh <twaugh@redhat.com>
Tim Welsh <timothy.welsh@docker.com>
Tim Wraight <tim.wraight@tangentlabs.co.uk> Tim Wraight <tim.wraight@tangentlabs.co.uk>
timfeirg <kkcocogogo@gmail.com> timfeirg <kkcocogogo@gmail.com>
Timothy Hobbs <timothyhobbs@seznam.cz> Timothy Hobbs <timothyhobbs@seznam.cz>
@@ -901,10 +826,8 @@ Umesh Yadav <umesh4257@gmail.com>
Vaclav Struhar <struharv@gmail.com> Vaclav Struhar <struharv@gmail.com>
Valentin Lorentz <progval+git@progval.net> Valentin Lorentz <progval+git@progval.net>
Vardan Pogosian <vardan.pogosyan@gmail.com> Vardan Pogosian <vardan.pogosyan@gmail.com>
Varun Hotani <varunhotani@gmail.com>
Venkateswara Reddy Bukkasamudram <bukkasamudram@outlook.com> Venkateswara Reddy Bukkasamudram <bukkasamudram@outlook.com>
Veres Lajos <vlajos@gmail.com> Veres Lajos <vlajos@gmail.com>
Vibhu Anan <vibhuanand@outlook.com>
Victor Vieux <victor.vieux@docker.com> Victor Vieux <victor.vieux@docker.com>
Victoria Bialas <victoria.bialas@docker.com> Victoria Bialas <victoria.bialas@docker.com>
Viktor Stanchev <me@viktorstanchev.com> Viktor Stanchev <me@viktorstanchev.com>
@@ -914,7 +837,6 @@ Vincent Batts <vbatts@redhat.com>
Vincent Bernat <Vincent.Bernat@exoscale.ch> Vincent Bernat <Vincent.Bernat@exoscale.ch>
Vincent Demeester <vincent.demeester@docker.com> Vincent Demeester <vincent.demeester@docker.com>
Vincent Woo <me@vincentwoo.com> Vincent Woo <me@vincentwoo.com>
Vineet Kumar <vineetkumar17112004@gmail.com>
Vishnu Kannan <vishnuk@google.com> Vishnu Kannan <vishnuk@google.com>
Vivek Goyal <vgoyal@redhat.com> Vivek Goyal <vgoyal@redhat.com>
Wang Jie <wangjie5@chinaskycloud.com> Wang Jie <wangjie5@chinaskycloud.com>
@@ -927,11 +849,9 @@ Wang Yumu <37442693@qq.com>
Wataru Ishida <ishida.wataru@lab.ntt.co.jp> Wataru Ishida <ishida.wataru@lab.ntt.co.jp>
Wayne Song <wsong@docker.com> Wayne Song <wsong@docker.com>
Wen Cheng Ma <wenchma@cn.ibm.com> Wen Cheng Ma <wenchma@cn.ibm.com>
Wenlong Zhang <zhangwenlong@loongson.cn>
Wenzhi Liang <wenzhi.liang@gmail.com> Wenzhi Liang <wenzhi.liang@gmail.com>
Wes Morgan <cap10morgan@gmail.com> Wes Morgan <cap10morgan@gmail.com>
Wewang Xiaorenfine <wang.xiaoren@zte.com.cn> Wewang Xiaorenfine <wang.xiaoren@zte.com.cn>
Will Wang <willww64@gmail.com>
William Henry <whenry@redhat.com> William Henry <whenry@redhat.com>
Xianglin Gao <xlgao@zju.edu.cn> Xianglin Gao <xlgao@zju.edu.cn>
Xiaodong Liu <liuxiaodong@loongson.cn> Xiaodong Liu <liuxiaodong@loongson.cn>
@@ -944,7 +864,6 @@ Yanqiang Miao <miao.yanqiang@zte.com.cn>
Yassine Tijani <yasstij11@gmail.com> Yassine Tijani <yasstij11@gmail.com>
Yi EungJun <eungjun.yi@navercorp.com> Yi EungJun <eungjun.yi@navercorp.com>
Ying Li <ying.li@docker.com> Ying Li <ying.li@docker.com>
Yoan Wainmann <thebook90yw@gmail.com>
Yong Tang <yong.tang.github@outlook.com> Yong Tang <yong.tang.github@outlook.com>
Yosef Fertel <yfertel@gmail.com> Yosef Fertel <yfertel@gmail.com>
Yu Peng <yu.peng36@zte.com.cn> Yu Peng <yu.peng36@zte.com.cn>
@@ -961,14 +880,11 @@ Zhang Wei <zhangwei555@huawei.com>
Zhang Wentao <zhangwentao234@huawei.com> Zhang Wentao <zhangwentao234@huawei.com>
ZhangHang <stevezhang2014@gmail.com> ZhangHang <stevezhang2014@gmail.com>
zhenghenghuo <zhenghenghuo@zju.edu.cn> zhenghenghuo <zhenghenghuo@zju.edu.cn>
Zhiwei Liang <zliang@akamai.com>
Zhou Hao <zhouhao@cn.fujitsu.com> Zhou Hao <zhouhao@cn.fujitsu.com>
Zhoulin Xie <zhoulin.xie@daocloud.io> Zhoulin Xie <zhoulin.xie@daocloud.io>
Zhu Guihua <zhugh.fnst@cn.fujitsu.com> Zhu Guihua <zhugh.fnst@cn.fujitsu.com>
Zhuo Zhi <h.dwwwwww@gmail.com>
Álex González <agonzalezro@gmail.com> Álex González <agonzalezro@gmail.com>
Álvaro Lázaro <alvaro.lazaro.g@gmail.com> Álvaro Lázaro <alvaro.lazaro.g@gmail.com>
Átila Camurça Alves <camurca.home@gmail.com> Átila Camurça Alves <camurca.home@gmail.com>
Александр Менщиков <__Singleton__@hackerdom.ru> Александр Менщиков <__Singleton__@hackerdom.ru>
徐俊杰 <paco.xu@daocloud.io> 徐俊杰 <paco.xu@daocloud.io>
林博仁 Buo-ren Lin <Buo.Ren.Lin@gmail.com>
+15 -50
View File
@@ -16,9 +16,9 @@ start participating.
## Reporting security issues ## Reporting security issues
The Docker maintainers take security seriously. If you discover a security The Docker maintainers take security seriously. If you discover a security
issue, bring it to their attention right away! issue, please bring it to their attention right away!
**DO NOT** file a public issue, instead send your report privately to Please **DO NOT** file a public issue, instead send your report privately to
[security@docker.com](mailto:security@docker.com). [security@docker.com](mailto:security@docker.com).
Security reports are greatly appreciated and we will publicly thank you for it. Security reports are greatly appreciated and we will publicly thank you for it.
@@ -39,7 +39,7 @@ If you find a match, you can use the "subscribe" button to get notified on
updates. Do *not* leave random "+1" or "I have this too" comments, as they updates. Do *not* leave random "+1" or "I have this too" comments, as they
only clutter the discussion, and don't help resolving it. However, if you only clutter the discussion, and don't help resolving it. However, if you
have ways to reproduce the issue or have additional information that may help have ways to reproduce the issue or have additional information that may help
resolving the issue, leave a comment. resolving the issue, please leave a comment.
When reporting issues, always include: When reporting issues, always include:
@@ -66,7 +66,7 @@ anybody starts working on it.
We are always thrilled to receive pull requests. We do our best to process them We are always thrilled to receive pull requests. We do our best to process them
quickly. If your pull request is not accepted on the first try, quickly. If your pull request is not accepted on the first try,
don't get discouraged! Our contributor's guide explains [the review process we don't get discouraged! Our contributor's guide explains [the review process we
use for simple changes](https://github.com/moby/moby/blob/master/project/REVIEWING.md). use for simple changes](https://docs.docker.com/opensource/workflow/make-a-contribution/).
### Talking to other Docker users and contributors ### Talking to other Docker users and contributors
@@ -84,7 +84,7 @@ use for simple changes](https://github.com/moby/moby/blob/master/project/REVIEWI
<tr> <tr>
<td>Community Slack</td> <td>Community Slack</td>
<td> <td>
The Docker Community has a dedicated Slack chat to discuss features and issues. You can sign-up <a href="https://dockr.ly/comm-slack" target="_blank">with this link</a>. The Docker Community has a dedicated Slack chat to discuss features and issues. You can sign-up <a href="https://dockr.ly/slack" target="_blank">with this link</a>.
</td> </td>
</tr> </tr>
<tr> <tr>
@@ -124,7 +124,8 @@ submitting a pull request.
Update the documentation when creating or modifying features. Test your Update the documentation when creating or modifying features. Test your
documentation changes for clarity, concision, and correctness, as well as a documentation changes for clarity, concision, and correctness, as well as a
clean documentation build. See our contributors guide for [our style clean documentation build. See our contributors guide for [our style
guide](https://github.com/docker/docs/blob/main/STYLE.md). guide](https://docs.docker.com/opensource/doc-style) and instructions on [building
the documentation](https://docs.docker.com/opensource/project/test-and-docs/#build-and-test-the-documentation).
Write clean code. Universally formatted code promotes ease of writing, reading, Write clean code. Universally formatted code promotes ease of writing, reading,
and maintenance. Always run `gofmt -s -w file.go` on each changed file before and maintenance. Always run `gofmt -s -w file.go` on each changed file before
@@ -133,45 +134,9 @@ committing your changes. Most editors have plug-ins that do this automatically.
Pull request descriptions should be as clear as possible and include a reference Pull request descriptions should be as clear as possible and include a reference
to all the issues that they address. to all the issues that they address.
Commit messages must be written in the imperative mood (max. 72 chars), followed Commit messages must start with a capitalized and short summary (max. 50 chars)
by an optional, more detailed explanatory text usually expanding on written in the imperative, followed by an optional, more detailed explanatory
why the work is necessary. The explanatory text should be separated by an text which is separated from the summary by an empty line.
empty line.
The commit message *could* have a prefix scoping the change, however this is
not enforced. Common prefixes are `docs: <message>`, `vendor: <message>`,
`chore: <message>` or the package/area related to the change such as `pkg/foo: <message>`
or `telemetry: <message>`.
A standard commit.
```
Fix the exploding flux capacitor
A call to function A causes the flux capacitor to blow up every time
the sun and the moon align.
```
Using a package as prefix.
```
pkg/foo: prevent panic in flux capacitor
Calling function A causes the flux capacitor to blow up every time
the sun and the moon align.
```
Updating a specific vendored dependency.
```
vendor: github.com/moby/moby/client v0.4.0
```
Fixing a broken docs link.
```
docs: fix style/lint issues in deprecated.md
```
Code review comments may be added to your pull request. Discuss, then make the Code review comments may be added to your pull request. Discuss, then make the
suggested modifications and push additional commits to your feature branch. Post suggested modifications and push additional commits to your feature branch. Post
@@ -201,10 +166,10 @@ Include an issue reference like `Closes #XXXX` or `Fixes #XXXX` in the pull requ
description that close an issue. Including references automatically closes the issue description that close an issue. Including references automatically closes the issue
on a merge. on a merge.
Do not add yourself to the `AUTHORS` file, as it is regenerated regularly Please do not add yourself to the `AUTHORS` file, as it is regenerated regularly
from the Git history. from the Git history.
See the [Coding Style](#coding-style) for further guidelines. Please see the [Coding Style](#coding-style) for further guidelines.
### Merge approval ### Merge approval
@@ -267,7 +232,7 @@ Then you just add a line to every git commit message:
Signed-off-by: Joe Smith <joe.smith@email.com> Signed-off-by: Joe Smith <joe.smith@email.com>
Use a known identity (sorry, no anonymous contributions.) Use your real name (sorry, no pseudonyms or anonymous contributions.)
If you set your `user.name` and `user.email` git configs, you can sign your If you set your `user.name` and `user.email` git configs, you can sign your
commit automatically with `git commit -s`. commit automatically with `git commit -s`.
@@ -304,8 +269,8 @@ guidelines for the community as a whole:
* Stay on topic: Make sure that you are posting to the correct channel and * Stay on topic: Make sure that you are posting to the correct channel and
avoid off-topic discussions. Remember when you update an issue or respond avoid off-topic discussions. Remember when you update an issue or respond
to an email you are potentially sending to a large number of people. Consider to an email you are potentially sending to a large number of people. Please
this before you update. Also remember that nobody likes spam. consider this before you update. Also remember that nobody likes spam.
* Don't send email to the maintainers: There's no need to send email to the * Don't send email to the maintainers: There's no need to send email to the
maintainers to ask them to investigate an issue or to take a look at a maintainers to ask them to investigate an issue or to take a look at a
+17 -32
View File
@@ -1,43 +1,22 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
ARG BASE_VARIANT=alpine ARG BASE_VARIANT=alpine
ARG ALPINE_VERSION=3.18
# ALPINE_VERSION sets the version of the alpine base image to use, including for the golang image.
# It must be a supported tag in the docker.io/library/alpine image repository
# that's also available as alpine image variant for the Golang version used.
ARG ALPINE_VERSION=3.23
ARG BASE_DEBIAN_DISTRO=bookworm ARG BASE_DEBIAN_DISTRO=bookworm
ARG GO_VERSION=1.26.8 ARG GO_VERSION=1.21.9
ARG XX_VERSION=1.4.0
# XX_VERSION specifies the version of the xx utility to use. ARG GOVERSIONINFO_VERSION=v1.3.0
# It must be a valid tag in the docker.io/tonistiigi/xx image repository. ARG GOTESTSUM_VERSION=v1.10.0
ARG XX_VERSION=1.9.0 ARG BUILDX_VERSION=0.12.1
ARG COMPOSE_VERSION=v2.24.3
# GOVERSIONINFO_VERSION is the version of GoVersionInfo to install.
# It must be a valid tag from https://github.com/josephspurrier/goversioninfo
ARG GOVERSIONINFO_VERSION=v1.5.0
# GOTESTSUM_VERSION sets the version of gotestsum to install in the dev container.
# It must be a valid tag in the https://github.com/gotestyourself/gotestsum repository.
ARG GOTESTSUM_VERSION=v1.13.0
# BUILDX_VERSION sets the version of buildx to use for the e2e tests.
# It must be a tag in the docker.io/docker/buildx-bin image repository
# on Docker Hub.
ARG BUILDX_VERSION=0.34.1
# COMPOSE_VERSION is the version of compose to install in the dev container.
# It must be a tag in the docker.io/docker/compose-bin image repository
# on Docker Hub.
ARG COMPOSE_VERSION=v5.1.3
FROM --platform=$BUILDPLATFORM tonistiigi/xx:${XX_VERSION} AS xx FROM --platform=$BUILDPLATFORM tonistiigi/xx:${XX_VERSION} AS xx
FROM --platform=$BUILDPLATFORM golang:${GO_VERSION}-alpine${ALPINE_VERSION} AS build-base-alpine FROM --platform=$BUILDPLATFORM golang:${GO_VERSION}-alpine${ALPINE_VERSION} AS build-base-alpine
ENV GOTOOLCHAIN=local ENV GOTOOLCHAIN=local
COPY --link --from=xx / / COPY --link --from=xx / /
RUN apk add --no-cache bash clang lld llvm file git git-daemon RUN apk add --no-cache bash clang lld llvm file git
WORKDIR /go/src/github.com/docker/cli WORKDIR /go/src/github.com/docker/cli
FROM build-base-alpine AS build-alpine FROM build-base-alpine AS build-alpine
@@ -84,7 +63,8 @@ ARG PACKAGER_NAME
COPY --link --from=goversioninfo /out/goversioninfo /usr/bin/goversioninfo COPY --link --from=goversioninfo /out/goversioninfo /usr/bin/goversioninfo
RUN --mount=type=bind,target=.,ro \ RUN --mount=type=bind,target=.,ro \
--mount=type=cache,target=/root/.cache \ --mount=type=cache,target=/root/.cache \
--mount=type=tmpfs,target=cmd/docker/winresources \ --mount=from=dockercore/golang-cross:xx-sdk-extras,target=/xx-sdk,src=/xx-sdk \
--mount=type=tmpfs,target=cli/winresources \
# override the default behavior of go with xx-go # override the default behavior of go with xx-go
xx-go --wrap && \ xx-go --wrap && \
# export GOCACHE=$(go env GOCACHE)/$(xx-info)$([ -f /etc/alpine-release ] && echo "alpine") && \ # export GOCACHE=$(go env GOCACHE)/$(xx-info)$([ -f /etc/alpine-release ] && echo "alpine") && \
@@ -97,7 +77,7 @@ ENV GO111MODULE=auto
RUN --mount=type=bind,target=.,rw \ RUN --mount=type=bind,target=.,rw \
--mount=type=cache,target=/root/.cache \ --mount=type=cache,target=/root/.cache \
--mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/go/pkg/mod \
gotestsum -- -coverprofile=/tmp/coverage.txt $(go list ./... | grep -vE '/vendor/|/e2e/|/cmd/docker-trust') gotestsum -- -coverprofile=/tmp/coverage.txt $(go list ./... | grep -vE '/vendor/|/e2e/')
FROM scratch AS test-coverage FROM scratch AS test-coverage
COPY --from=test /tmp/coverage.txt /coverage.txt COPY --from=test /tmp/coverage.txt /coverage.txt
@@ -109,6 +89,7 @@ ARG GO_STRIP
ARG CGO_ENABLED ARG CGO_ENABLED
ARG VERSION ARG VERSION
RUN --mount=ro --mount=type=cache,target=/root/.cache \ RUN --mount=ro --mount=type=cache,target=/root/.cache \
--mount=from=dockercore/golang-cross:xx-sdk-extras,target=/xx-sdk,src=/xx-sdk \
xx-go --wrap && \ xx-go --wrap && \
TARGET=/out ./scripts/build/plugins e2e/cli-plugins/plugins/* TARGET=/out ./scripts/build/plugins e2e/cli-plugins/plugins/*
@@ -122,6 +103,10 @@ FROM docker/buildx-bin:${BUILDX_VERSION} AS buildx
FROM docker/compose-bin:${COMPOSE_VERSION} AS compose FROM docker/compose-bin:${COMPOSE_VERSION} AS compose
FROM e2e-base-${BASE_VARIANT} AS e2e FROM e2e-base-${BASE_VARIANT} AS e2e
ARG NOTARY_VERSION=v0.6.1
ADD --chmod=0755 https://github.com/theupdateframework/notary/releases/download/${NOTARY_VERSION}/notary-Linux-amd64 /usr/local/bin/notary
COPY --link e2e/testdata/notary/root-ca.cert /usr/share/ca-certificates/notary.cert
RUN echo 'notary.cert' >> /etc/ca-certificates.conf && update-ca-certificates
COPY --link --from=gotestsum /out/gotestsum /usr/bin/gotestsum COPY --link --from=gotestsum /out/gotestsum /usr/bin/gotestsum
COPY --link --from=build /out ./build/ COPY --link --from=build /out ./build/
COPY --link --from=build-plugins /out ./build/ COPY --link --from=build-plugins /out ./build/
@@ -130,7 +115,7 @@ COPY --link --from=compose /docker-compose /usr/libexec/docker/cli-plugins/docke
COPY --link . . COPY --link . .
ENV DOCKER_BUILDKIT=1 ENV DOCKER_BUILDKIT=1
ENV PATH=/go/src/github.com/docker/cli/build:$PATH ENV PATH=/go/src/github.com/docker/cli/build:$PATH
CMD ["./scripts/test/e2e/entry"] CMD ./scripts/test/e2e/entry
FROM build-base-${BASE_VARIANT} AS dev FROM build-base-${BASE_VARIANT} AS dev
COPY --link . . COPY --link . .
+10 -46
View File
@@ -34,12 +34,12 @@ test: test-unit ## run tests
.PHONY: test-unit .PHONY: test-unit
test-unit: ## run unit tests, to change the output format use: GOTESTSUM_FORMAT=(dots|short|standard-quiet|short-verbose|standard-verbose) make test-unit test-unit: ## run unit tests, to change the output format use: GOTESTSUM_FORMAT=(dots|short|standard-quiet|short-verbose|standard-verbose) make test-unit
gotestsum -- $${TESTDIRS:-$(shell go list ./... | grep -vE '/vendor/|/e2e/|/cmd/docker-trust')} $(TESTFLAGS) gotestsum -- $${TESTDIRS:-$(shell go list ./... | grep -vE '/vendor/|/e2e/')} $(TESTFLAGS)
.PHONY: test-coverage .PHONY: test-coverage
test-coverage: ## run test coverage test-coverage: ## run test coverage
mkdir -p $(CURDIR)/build/coverage mkdir -p $(CURDIR)/build/coverage
gotestsum -- $(shell go list ./... | grep -vE '/vendor/|/e2e/|/cmd/docker-trust') -coverprofile=$(CURDIR)/build/coverage/coverage.txt gotestsum -- $(shell go list ./... | grep -vE '/vendor/|/e2e/') -coverprofile=$(CURDIR)/build/coverage/coverage.txt
.PHONY: lint .PHONY: lint
lint: ## run all the lint tools lint: ## run all the lint tools
@@ -52,7 +52,7 @@ shellcheck: ## run shellcheck validation
.PHONY: fmt .PHONY: fmt
fmt: ## run gofumpt (if present) or gofmt fmt: ## run gofumpt (if present) or gofmt
@if command -v gofumpt > /dev/null; then \ @if command -v gofumpt > /dev/null; then \
gofumpt -w -d -lang=1.24 . ; \ gofumpt -w -d -lang=1.21 . ; \
else \ else \
go list -f {{.Dir}} ./... | xargs gofmt -w -s -d ; \ go list -f {{.Dir}} ./... | xargs gofmt -w -s -d ; \
fi fi
@@ -67,72 +67,36 @@ dynbinary: ## build dynamically linked binary
.PHONY: plugins .PHONY: plugins
plugins: ## build example CLI plugins plugins: ## build example CLI plugins
scripts/build/plugins ./scripts/build/plugins
.PHONY: trust-plugin
trust-plugin: ## build docker-trust CLI plugins
scripts/build/trust-plugin
.PHONY: install-trust-plugin
install-trust-plugin: trust-plugin
install-trust-plugin: ## install docker-trust CLI plugins
install -D -m 0755 "$$(readlink -f build/docker-trust)" /usr/libexec/docker/cli-plugins/docker-trust
.PHONY: vendor .PHONY: vendor
vendor: ## update vendor with go modules vendor: ## update vendor with go modules
rm -rf vendor rm -rf vendor
scripts/with-go-mod.sh scripts/vendor update ./scripts/vendor update
.PHONY: validate-vendor .PHONY: validate-vendor
validate-vendor: ## validate vendor validate-vendor: ## validate vendor
scripts/with-go-mod.sh scripts/vendor validate ./scripts/vendor validate
.PHONY: mod-outdated .PHONY: mod-outdated
mod-outdated: ## check outdated dependencies mod-outdated: ## check outdated dependencies
scripts/with-go-mod.sh scripts/vendor outdated ./scripts/vendor outdated
.PHONY: authors .PHONY: authors
authors: ## generate AUTHORS file from git history authors: ## generate AUTHORS file from git history
scripts/docs/generate-authors.sh scripts/docs/generate-authors.sh
.PHONY: completion
completion: shell-completion
completion: ## generate and install the shell-completion scripts
# Note: this uses system-wide paths, and so may overwrite completion
# scripts installed as part of deb/rpm packages.
#
# Given that this target is intended to debug/test updated versions, we could
# consider installing in per-user (~/.config, XDG_DATA_DIR) paths instead, but
# this will add more complexity.
#
# See https://github.com/docker/cli/pull/5770#discussion_r1927772710
install -D -p -m 0644 ./build/completion/bash/docker /usr/share/bash-completion/completions/docker
install -D -p -m 0644 ./build/completion/fish/docker.fish debian/docker-ce-cli/usr/share/fish/vendor_completions.d/docker.fish
install -D -p -m 0644 ./build/completion/zsh/_docker debian/docker-ce-cli/usr/share/zsh/vendor-completions/_docker
build/docker:
# This target is used by the "shell-completion" target, which requires either
# "binary" or "dynbinary" to have been built. We don't want to trigger those
# to prevent replacing a static binary with a dynamic one, or vice-versa.
@echo "Run 'make binary' or 'make dynbinary' first" && exit 1
.PHONY: shell-completion
shell-completion: build/docker # requires either "binary" or "dynbinary" to be built.
shell-completion: ## generate shell-completion scripts
@ ./scripts/build/shell-completion
.PHONY: manpages .PHONY: manpages
manpages: ## generate man pages from go source and markdown manpages: ## generate man pages from go source and markdown
scripts/with-go-mod.sh scripts/docs/generate-man.sh scripts/docs/generate-man.sh
.PHONY: mddocs .PHONY: mddocs
mddocs: ## generate markdown files from go source mddocs: ## generate markdown files from go source
scripts/with-go-mod.sh scripts/docs/generate-md.sh scripts/docs/generate-md.sh
.PHONY: yamldocs .PHONY: yamldocs
yamldocs: ## generate documentation YAML files consumed by docs repo yamldocs: ## generate documentation YAML files consumed by docs repo
scripts/with-go-mod.sh scripts/docs/generate-yaml.sh scripts/docs/generate-yaml.sh
.PHONY: help .PHONY: help
help: ## print this help help: ## print this help
+1 -1
View File
@@ -14,6 +14,6 @@ United States and other governments.
It is your responsibility to ensure that your use and/or transfer does not It is your responsibility to ensure that your use and/or transfer does not
violate applicable laws. violate applicable laws.
For more information, see https://www.bis.doc.gov For more information, please see https://www.bis.doc.gov
See also https://www.apache.org/dev/crypto.html and/or seek legal counsel. See also https://www.apache.org/dev/crypto.html and/or seek legal counsel.
+5 -18
View File
@@ -1,11 +1,10 @@
# Docker CLI # Docker CLI
[![PkgGoDev](https://pkg.go.dev/badge/github.com/docker/cli)](https://pkg.go.dev/github.com/docker/cli) [![PkgGoDev](https://img.shields.io/badge/go.dev-docs-007d9c?logo=go&logoColor=white)](https://pkg.go.dev/github.com/docker/cli)
[![Build Status](https://img.shields.io/github/actions/workflow/status/docker/cli/build.yml?branch=master&label=build&logo=github)](https://github.com/docker/cli/actions?query=workflow%3Abuild) [![Build Status](https://img.shields.io/github/actions/workflow/status/docker/cli/build.yml?branch=master&label=build&logo=github)](https://github.com/docker/cli/actions?query=workflow%3Abuild)
[![Test Status](https://img.shields.io/github/actions/workflow/status/docker/cli/test.yml?branch=master&label=test&logo=github)](https://github.com/docker/cli/actions?query=workflow%3Atest) [![Test Status](https://img.shields.io/github/actions/workflow/status/docker/cli/test.yml?branch=master&label=test&logo=github)](https://github.com/docker/cli/actions?query=workflow%3Atest)
[![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/docker/cli/badge)](https://scorecard.dev/viewer/?uri=github.com/docker/cli) [![Go Report Card](https://goreportcard.com/badge/github.com/docker/cli)](https://goreportcard.com/report/github.com/docker/cli)
[![Codecov](https://img.shields.io/codecov/c/github/docker/cli?logo=codecov)](https://codecov.io/gh/docker/cli) [![Codecov](https://img.shields.io/codecov/c/github/docker/cli?logo=codecov)](https://codecov.io/gh/docker/cli)
[![Protected by Tracebit Community Edition](https://github.com/tracebit-com/tracebit-community-action/blob/main/assets/badges/tracebit-badge-compact.svg?raw=1)](https://community.tracebit.com/supplychainattacks)
## About ## About
@@ -51,18 +50,6 @@ Run test:
docker buildx bake test docker buildx bake test
``` ```
Run the unit test:
```
$ make -f docker.Makefile test-unit
```
Run the full test suite:
```
$ make -f docker.Makefile test
```
List all the available targets: List all the available targets:
```shell ```shell
@@ -74,13 +61,13 @@ make help
Start an interactive development environment: Start an interactive development environment:
```shell ```shell
make shell make -f docker.Makefile shell
``` ```
## Legal ## Legal
*Brought to you courtesy of our legal counsel. For more context, *Brought to you courtesy of our legal counsel. For more context,
see the [NOTICE](https://github.com/docker/cli/blob/master/NOTICE) document in this repo.* please see the [NOTICE](https://github.com/docker/cli/blob/master/NOTICE) document in this repo.*
Use and transfer of Docker may be subject to certain restrictions by the Use and transfer of Docker may be subject to certain restrictions by the
United States and other governments. United States and other governments.
@@ -88,7 +75,7 @@ United States and other governments.
It is your responsibility to ensure that your use and/or transfer does not It is your responsibility to ensure that your use and/or transfer does not
violate applicable laws. violate applicable laws.
For more information, see https://www.bis.doc.gov For more information, please see https://www.bis.doc.gov
## Licensing ## Licensing
-44
View File
@@ -1,44 +0,0 @@
# Security Policy
The maintainers of the Docker CLI take security seriously. If you discover
a security issue, please bring it to their attention right away!
## Reporting a Vulnerability
Please **DO NOT** file a public issue, instead send your report privately
to [security@docker.com](mailto:security@docker.com).
Reporter(s) can expect a response within 72 hours, acknowledging the issue was
received.
## Review Process
After receiving the report, an initial triage and technical analysis is
performed to confirm the report and determine its scope. We may request
additional information in this stage of the process.
Once a reviewer has confirmed the relevance of the report, a draft security
advisory will be created on GitHub. The draft advisory will be used to discuss
the issue with maintainers, the reporter(s), and where applicable, other
affected parties under embargo.
If the vulnerability is accepted, a timeline for developing a patch, public
disclosure, and patch release will be determined. If there is an embargo period
on public disclosure before the patch release, the reporter(s) are expected to
participate in the discussion of the timeline and abide by agreed upon dates
for public disclosure.
## Accreditation
Security reports are greatly appreciated and we will publicly thank you,
although we will keep your name confidential if you request it. We also like to
send gifts - if you're into swag, make sure to let us know. We do not currently
offer a paid security bounty program at this time.
## Supported Versions
This project uses long-lived branches to maintain releases, and follows
the maintenance cycle of the Moby project.
Refer to [BRANCHES-AND-TAGS.md](https://github.com/moby/moby/blob/master/project/BRANCHES-AND-TAGS.md)
in the default branch of the moby repository to learn about the current
maintenance status of each branch.
+1 -1
View File
@@ -1 +1 @@
29.8.2 25.0.0-dev
+15 -16
View File
@@ -5,32 +5,31 @@ import (
"fmt" "fmt"
"os" "os"
"github.com/docker/cli/cli-plugins/metadata" "github.com/docker/cli/cli-plugins/manager"
"github.com/docker/cli/cli-plugins/plugin" "github.com/docker/cli/cli-plugins/plugin"
"github.com/docker/cli/cli/command" "github.com/docker/cli/cli/command"
"github.com/moby/moby/client"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
func main() { func main() {
plugin.Run(func(dockerCLI command.Cli) *cobra.Command { plugin.Run(func(dockerCli command.Cli) *cobra.Command {
goodbye := &cobra.Command{ goodbye := &cobra.Command{
Use: "goodbye", Use: "goodbye",
Short: "Say Goodbye instead of Hello", Short: "Say Goodbye instead of Hello",
Run: func(cmd *cobra.Command, _ []string) { Run: func(cmd *cobra.Command, _ []string) {
_, _ = fmt.Fprintln(dockerCLI.Out(), "Goodbye World!") fmt.Fprintln(dockerCli.Out(), "Goodbye World!")
}, },
} }
apiversion := &cobra.Command{ apiversion := &cobra.Command{
Use: "apiversion", Use: "apiversion",
Short: "Print the API version of the server", Short: "Print the API version of the server",
RunE: func(_ *cobra.Command, _ []string) error { RunE: func(_ *cobra.Command, _ []string) error {
apiClient := dockerCLI.Client() cli := dockerCli.Client()
ping, err := apiClient.Ping(context.Background(), client.PingOptions{}) ping, err := cli.Ping(context.Background())
if err != nil { if err != nil {
return err return err
} }
_, _ = fmt.Println(ping.APIVersion) fmt.Println(ping.APIVersion)
return nil return nil
}, },
} }
@@ -39,7 +38,7 @@ func main() {
Use: "exitstatus2", Use: "exitstatus2",
Short: "Exit with status 2", Short: "Exit with status 2",
RunE: func(_ *cobra.Command, _ []string) error { RunE: func(_ *cobra.Command, _ []string) error {
_, _ = fmt.Fprintln(dockerCLI.Err(), "Exiting with error status 2") fmt.Fprintln(dockerCli.Err(), "Exiting with error status 2")
os.Exit(2) os.Exit(2)
return nil return nil
}, },
@@ -57,33 +56,33 @@ func main() {
return err return err
} }
if preRun { if preRun {
_, _ = fmt.Fprintln(dockerCLI.Err(), "Plugin PersistentPreRunE called") fmt.Fprintf(dockerCli.Err(), "Plugin PersistentPreRunE called")
} }
return nil return nil
}, },
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
if debug { if debug {
_, _ = fmt.Fprintln(dockerCLI.Err(), "Plugin debug mode enabled") fmt.Fprintf(dockerCli.Err(), "Plugin debug mode enabled")
} }
switch optContext { switch optContext {
case "Christmas": case "Christmas":
_, _ = fmt.Fprintln(dockerCLI.Out(), "Merry Christmas!") fmt.Fprintf(dockerCli.Out(), "Merry Christmas!\n")
return nil return nil
case "": case "":
// nothing // nothing
} }
if who == "" { if who == "" {
who, _ = dockerCLI.ConfigFile().PluginConfig("helloworld", "who") who, _ = dockerCli.ConfigFile().PluginConfig("helloworld", "who")
} }
if who == "" { if who == "" {
who = "World" who = "World"
} }
_, _ = fmt.Fprintln(dockerCLI.Out(), "Hello", who) fmt.Fprintf(dockerCli.Out(), "Hello %s!\n", who)
dockerCLI.ConfigFile().SetPluginConfig("helloworld", "lastwho", who) dockerCli.ConfigFile().SetPluginConfig("helloworld", "lastwho", who)
return dockerCLI.ConfigFile().Save() return dockerCli.ConfigFile().Save()
}, },
} }
@@ -98,7 +97,7 @@ func main() {
cmd.AddCommand(goodbye, apiversion, exitStatus2) cmd.AddCommand(goodbye, apiversion, exitStatus2)
return cmd return cmd
}, },
metadata.Metadata{ manager.Metadata{
SchemaVersion: "0.1.0", SchemaVersion: "0.1.0",
Vendor: "Docker Inc.", Vendor: "Docker Inc.",
Version: "testing", Version: "testing",
-85
View File
@@ -1,85 +0,0 @@
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
//go:build go1.26
// 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
-75
View File
@@ -1,75 +0,0 @@
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)
}
-50
View File
@@ -1,50 +0,0 @@
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)
}
})
}
}
-23
View File
@@ -1,23 +0,0 @@
package hooks
import "io"
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
}
_, _ = io.WriteString(out, whatsNext)
for _, msg := range messages {
_, _ = io.WriteString(out, indent)
_, _ = io.WriteString(out, msg)
_, _ = io.WriteString(out, "\n")
}
}
-45
View File
@@ -1,45 +0,0 @@
package hooks_test
import (
"strings"
"testing"
"github.com/docker/cli/cli-plugins/hooks"
"gotest.tools/v3/assert"
)
func TestPrintHookMessages(t *testing.T) {
const header = "\n\x1b[1mWhat's next:\x1b[0m\n"
tests := []struct {
doc string
messages []string
expectedOutput string
}{
{
doc: "no messages",
messages: nil,
expectedOutput: "",
},
{
doc: "single message",
messages: []string{"Bork!"},
expectedOutput: header +
" Bork!\n",
},
{
doc: "multiple messages",
messages: []string{"Foo", "bar"},
expectedOutput: header +
" Foo\n" +
" bar\n",
},
}
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)
})
}
}
-95
View File
@@ -1,95 +0,0 @@
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
//go:build go1.26
package hooks
import (
"bytes"
"errors"
"fmt"
"strings"
"text/template"
"github.com/spf13/cobra"
)
const maxMessages = 10
func ParseTemplate(hookTemplate string, cmd *cobra.Command) ([]string, error) {
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()
}
if n := strings.Count(out, "\n"); n > maxMessages {
return nil, fmt.Errorf("hook template contains too many messages (%d): maximum is %d", n, maxMessages)
}
return strings.SplitN(out, "\n", maxMessages), nil
}
var ErrHookTemplateParse = errors.New("failed to parse hook template")
// 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
}
// 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 c.cmd.Name()
}
// 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)
}
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
}
-125
View File
@@ -1,125 +0,0 @@
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
}
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"},
},
{
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"},
},
expectedOutput: []string{"my-tag"},
},
{
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",
value: "value",
},
{
name: "test2",
value: "value2",
},
},
expectedOutput: []string{"value value2"},
},
{
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"},
},
{
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 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)
// 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)
}
})
}
}
+7 -5
View File
@@ -1,10 +1,12 @@
package manager package manager
import ( import "os/exec"
"os/exec"
"github.com/docker/cli/cli-plugins/metadata" // Candidate represents a possible plugin candidate, for mocking purposes
) type Candidate interface {
Path() string
Metadata() ([]byte, error)
}
type candidate struct { type candidate struct {
path string path string
@@ -15,5 +17,5 @@ func (c *candidate) Path() string {
} }
func (c *candidate) Metadata() ([]byte, error) { func (c *candidate) Metadata() ([]byte, error) {
return exec.Command(c.path, metadata.MetadataSubcommandName).Output() // #nosec G204 -- ignore "Subprocess launched with a potential tainted input or cmd arguments" return exec.Command(c.path, MetadataSubcommandName).Output()
} }
+32 -103
View File
@@ -6,10 +6,9 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/docker/cli/cli-plugins/metadata"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"gotest.tools/v3/assert" "gotest.tools/v3/assert"
is "gotest.tools/v3/assert/cmp" "gotest.tools/v3/assert/cmp"
) )
type fakeCandidate struct { type fakeCandidate struct {
@@ -31,132 +30,62 @@ func (c *fakeCandidate) Metadata() ([]byte, error) {
func TestValidateCandidate(t *testing.T) { func TestValidateCandidate(t *testing.T) {
const ( const (
goodPluginName = metadata.NamePrefix + "goodplugin" goodPluginName = NamePrefix + "goodplugin"
builtinName = metadata.NamePrefix + "builtin"
builtinAlias = metadata.NamePrefix + "alias"
badPrefixPath = "/usr/local/libexec/cli-plugins/wobble" builtinName = NamePrefix + "builtin"
badNamePath = "/usr/local/libexec/cli-plugins/docker-123456" builtinAlias = NamePrefix + "alias"
goodPluginPath = "/usr/local/libexec/cli-plugins/" + goodPluginName
badPrefixPath = "/usr/local/libexec/cli-plugins/wobble"
badNamePath = "/usr/local/libexec/cli-plugins/docker-123456"
goodPluginPath = "/usr/local/libexec/cli-plugins/" + goodPluginName
metaExperimental = `{"SchemaVersion": "0.1.0", "Vendor": "e2e-testing", "Experimental": true}`
) )
fakeroot := &cobra.Command{Use: "docker"} fakeroot := &cobra.Command{Use: "docker"}
fakeroot.AddCommand(&cobra.Command{ fakeroot.AddCommand(&cobra.Command{
Use: strings.TrimPrefix(builtinName, metadata.NamePrefix), Use: strings.TrimPrefix(builtinName, NamePrefix),
Aliases: []string{ Aliases: []string{
strings.TrimPrefix(builtinAlias, metadata.NamePrefix), strings.TrimPrefix(builtinAlias, NamePrefix),
}, },
}) })
for _, tc := range []struct { for _, tc := range []struct {
name string name string
plugin *fakeCandidate c *fakeCandidate
// Either err or invalid may be non-empty, but not both (both can be empty for a good plugin). // Either err or invalid may be non-empty, but not both (both can be empty for a good plugin).
err string err string
invalid string invalid string
expVer string
}{ }{
// Invalid cases. /* Each failing one of the tests */
{ {name: "empty path", c: &fakeCandidate{path: ""}, err: "plugin candidate path cannot be empty"},
name: "empty path", {name: "bad prefix", c: &fakeCandidate{path: badPrefixPath}, err: fmt.Sprintf("does not have %q prefix", NamePrefix)},
plugin: &fakeCandidate{path: ""}, {name: "bad path", c: &fakeCandidate{path: badNamePath}, invalid: "did not match"},
err: "plugin candidate path cannot be empty", {name: "builtin command", c: &fakeCandidate{path: builtinName}, invalid: `plugin "builtin" duplicates builtin command`},
}, {name: "builtin alias", c: &fakeCandidate{path: builtinAlias}, invalid: `plugin "alias" duplicates an alias of builtin command "builtin"`},
{ {name: "fetch failure", c: &fakeCandidate{path: goodPluginPath, exec: false}, invalid: fmt.Sprintf("failed to fetch metadata: faked a failure to exec %q", goodPluginPath)},
name: "bad prefix", {name: "metadata not json", c: &fakeCandidate{path: goodPluginPath, exec: true, meta: `xyzzy`}, invalid: "invalid character"},
plugin: &fakeCandidate{path: badPrefixPath}, {name: "empty schemaversion", c: &fakeCandidate{path: goodPluginPath, exec: true, meta: `{}`}, invalid: `plugin SchemaVersion "" is not valid`},
err: fmt.Sprintf("does not have %q prefix", metadata.NamePrefix), {name: "invalid schemaversion", c: &fakeCandidate{path: goodPluginPath, exec: true, meta: `{"SchemaVersion": "xyzzy"}`}, invalid: `plugin SchemaVersion "xyzzy" is not valid`},
}, {name: "no vendor", c: &fakeCandidate{path: goodPluginPath, exec: true, meta: `{"SchemaVersion": "0.1.0"}`}, invalid: "plugin metadata does not define a vendor"},
{ {name: "empty vendor", c: &fakeCandidate{path: goodPluginPath, exec: true, meta: `{"SchemaVersion": "0.1.0", "Vendor": ""}`}, invalid: "plugin metadata does not define a vendor"},
name: "bad path", // This one should work
plugin: &fakeCandidate{path: badNamePath}, {name: "valid", c: &fakeCandidate{path: goodPluginPath, exec: true, meta: `{"SchemaVersion": "0.1.0", "Vendor": "e2e-testing"}`}},
invalid: "did not match", {name: "experimental + allowing experimental", c: &fakeCandidate{path: goodPluginPath, exec: true, meta: metaExperimental}},
},
{
name: "builtin command",
plugin: &fakeCandidate{path: builtinName},
invalid: `plugin "builtin" duplicates builtin command`,
},
{
name: "builtin alias",
plugin: &fakeCandidate{path: builtinAlias},
invalid: `plugin "alias" duplicates an alias of builtin command "builtin"`,
},
{
name: "fetch failure",
plugin: &fakeCandidate{path: goodPluginPath, exec: false},
invalid: fmt.Sprintf("failed to fetch metadata: faked a failure to exec %q", goodPluginPath),
},
{
name: "metadata not json",
plugin: &fakeCandidate{path: goodPluginPath, exec: true, meta: `xyzzy`},
invalid: "invalid character",
},
{
name: "empty schemaversion",
plugin: &fakeCandidate{path: goodPluginPath, exec: true, meta: `{}`},
invalid: `plugin SchemaVersion version cannot be empty`,
},
{
name: "invalid schemaversion",
plugin: &fakeCandidate{path: goodPluginPath, exec: true, meta: `{"SchemaVersion": "xyzzy"}`},
invalid: `plugin SchemaVersion "xyzzy" has wrong format: must be <major>.<minor>.<patch>`,
},
{
name: "invalid schemaversion major",
plugin: &fakeCandidate{path: goodPluginPath, exec: true, meta: `{"SchemaVersion": "2.0.0"}`},
invalid: `plugin SchemaVersion "2.0.0" is not supported: must be lower than 2.0.0`,
},
{
name: "no vendor",
plugin: &fakeCandidate{path: goodPluginPath, exec: true, meta: `{"SchemaVersion": "0.1.0"}`},
invalid: "plugin metadata does not define a vendor",
},
{
name: "empty vendor",
plugin: &fakeCandidate{path: goodPluginPath, exec: true, meta: `{"SchemaVersion": "0.1.0", "Vendor": ""}`},
invalid: "plugin metadata does not define a vendor",
},
// Valid cases.
{
name: "valid",
plugin: &fakeCandidate{path: goodPluginPath, exec: true, meta: `{"SchemaVersion": "0.1.0", "Vendor": "e2e-testing"}`},
expVer: "0.1.0",
},
{
// Including the deprecated "experimental" field should not break processing.
name: "with legacy experimental",
plugin: &fakeCandidate{path: goodPluginPath, exec: true, meta: `{"SchemaVersion": "0.1.0", "Vendor": "e2e-testing", "Experimental": true}`},
expVer: "0.1.0",
},
{
// note that this may not be supported by older CLIs
name: "new minor schema version",
plugin: &fakeCandidate{path: goodPluginPath, exec: true, meta: `{"SchemaVersion": "0.2.0", "Vendor": "e2e-testing"}`},
expVer: "0.2.0",
},
{
// note that this may not be supported by older CLIs
name: "new major schema version",
plugin: &fakeCandidate{path: goodPluginPath, exec: true, meta: `{"SchemaVersion": "1.0.0", "Vendor": "e2e-testing"}`},
expVer: "1.0.0",
},
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
p, err := newPlugin(tc.plugin, fakeroot.Commands()) p, err := newPlugin(tc.c, fakeroot.Commands())
switch { switch {
case tc.err != "": case tc.err != "":
assert.ErrorContains(t, err, tc.err) assert.ErrorContains(t, err, tc.err)
case tc.invalid != "": case tc.invalid != "":
assert.NilError(t, err) assert.NilError(t, err)
assert.Assert(t, is.ErrorType(p.Err, reflect.TypeFor[*pluginError]())) assert.Assert(t, cmp.ErrorType(p.Err, reflect.TypeOf(&pluginError{})))
assert.ErrorContains(t, p.Err, tc.invalid) assert.ErrorContains(t, p.Err, tc.invalid)
default: default:
assert.NilError(t, err) assert.NilError(t, err)
assert.Equal(t, metadata.NamePrefix+p.Name, goodPluginName) assert.Equal(t, NamePrefix+p.Name, goodPluginName)
assert.Equal(t, p.SchemaVersion, tc.expVer) assert.Equal(t, p.SchemaVersion, "0.1.0")
assert.Equal(t, p.Vendor, "e2e-testing") assert.Equal(t, p.Vendor, "e2e-testing")
} }
}) })
+84 -17
View File
@@ -2,12 +2,41 @@ package manager
import ( import (
"fmt" "fmt"
"net/url"
"os" "os"
"strings"
"sync" "sync"
"github.com/docker/cli/cli-plugins/metadata" "github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/config"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"go.opentelemetry.io/otel/attribute"
)
const (
// CommandAnnotationPlugin is added to every stub command added by
// AddPluginCommandStubs with the value "true" and so can be
// used to distinguish plugin stubs from regular commands.
CommandAnnotationPlugin = "com.docker.cli.plugin"
// CommandAnnotationPluginVendor is added to every stub command
// added by AddPluginCommandStubs and contains the vendor of
// that plugin.
CommandAnnotationPluginVendor = "com.docker.cli.plugin.vendor"
// CommandAnnotationPluginVersion is added to every stub command
// added by AddPluginCommandStubs and contains the version of
// that plugin.
CommandAnnotationPluginVersion = "com.docker.cli.plugin.version"
// CommandAnnotationPluginInvalid is added to any stub command
// added by AddPluginCommandStubs for an invalid command (that
// is, one which failed it's candidate test) and contains the
// reason for the failure.
CommandAnnotationPluginInvalid = "com.docker.cli.plugin-invalid"
// CommandAnnotationPluginCommandPath is added to overwrite the
// command path for a plugin invocation.
CommandAnnotationPluginCommandPath = "com.docker.cli.plugin.command_path"
) )
var pluginCommandStubsOnce sync.Once var pluginCommandStubsOnce sync.Once
@@ -15,56 +44,53 @@ var pluginCommandStubsOnce sync.Once
// AddPluginCommandStubs adds a stub cobra.Commands for each valid and invalid // AddPluginCommandStubs adds a stub cobra.Commands for each valid and invalid
// plugin. The command stubs will have several annotations added, see // plugin. The command stubs will have several annotations added, see
// `CommandAnnotationPlugin*`. // `CommandAnnotationPlugin*`.
func AddPluginCommandStubs(dockerCLI config.Provider, rootCmd *cobra.Command) (err error) { func AddPluginCommandStubs(dockerCli command.Cli, rootCmd *cobra.Command) (err error) {
pluginCommandStubsOnce.Do(func() { pluginCommandStubsOnce.Do(func() {
var plugins []Plugin var plugins []Plugin
plugins, err = ListPlugins(dockerCLI, rootCmd) plugins, err = ListPlugins(dockerCli, rootCmd)
if err != nil { if err != nil {
return return
} }
for _, p := range plugins { for _, p := range plugins {
p := p
vendor := p.Vendor vendor := p.Vendor
if vendor == "" { if vendor == "" {
vendor = "unknown" vendor = "unknown"
} }
annotations := map[string]string{ annotations := map[string]string{
metadata.CommandAnnotationPlugin: "true", CommandAnnotationPlugin: "true",
metadata.CommandAnnotationPluginVendor: vendor, CommandAnnotationPluginVendor: vendor,
metadata.CommandAnnotationPluginVersion: p.Version, CommandAnnotationPluginVersion: p.Version,
} }
if p.Err != nil { if p.Err != nil {
annotations[metadata.CommandAnnotationPluginInvalid] = p.Err.Error() annotations[CommandAnnotationPluginInvalid] = p.Err.Error()
} }
rootCmd.AddCommand(&cobra.Command{ rootCmd.AddCommand(&cobra.Command{
Use: p.Name, Use: p.Name,
Short: p.ShortDescription, Short: p.ShortDescription,
Hidden: p.Hidden,
Run: func(_ *cobra.Command, _ []string) {}, Run: func(_ *cobra.Command, _ []string) {},
Annotations: annotations, Annotations: annotations,
DisableFlagParsing: true, DisableFlagParsing: true,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
flags := rootCmd.PersistentFlags() flags := rootCmd.PersistentFlags()
flags.SetOutput(nil) flags.SetOutput(nil)
if err := flags.Parse(args); err != nil { perr := flags.Parse(args)
if perr != nil {
return err return err
} }
if flags.Changed("help") { if flags.Changed("help") {
cmd.HelpFunc()(rootCmd, args) cmd.HelpFunc()(rootCmd, args)
return nil return nil
} }
return fmt.Errorf("docker: unknown command: docker %s\n\nRun 'docker --help' for more information", cmd.Name()) return fmt.Errorf("docker: '%s' is not a docker command.\nSee 'docker --help'", cmd.Name())
}, },
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
// Delegate completion to plugin // Delegate completion to plugin
cargs := []string{p.Path, cobra.ShellCompRequestCmd, p.Name} //nolint:prealloc // no need to over-complicate things. cargs := []string{p.Path, cobra.ShellCompRequestCmd, p.Name}
cargs = append(cargs, args...) cargs = append(cargs, args...)
cargs = append(cargs, toComplete) cargs = append(cargs, toComplete)
origArgs := os.Args
os.Args = cargs os.Args = cargs
defer func() { runCommand, runErr := PluginRunCommand(dockerCli, p.Name, cmd)
os.Args = origArgs
}()
runCommand, runErr := PluginRunCommand(dockerCLI, p.Name, cmd)
if runErr != nil { if runErr != nil {
return nil, cobra.ShellCompDirectiveError return nil, cobra.ShellCompDirectiveError
} }
@@ -79,3 +105,44 @@ func AddPluginCommandStubs(dockerCLI config.Provider, rootCmd *cobra.Command) (e
}) })
return err return err
} }
const (
dockerCliAttributePrefix = attribute.Key("docker.cli")
cobraCommandPath = attribute.Key("cobra.command_path")
)
func getPluginResourceAttributes(cmd *cobra.Command, plugin Plugin) attribute.Set {
commandPath := cmd.Annotations[CommandAnnotationPluginCommandPath]
if commandPath == "" {
commandPath = fmt.Sprintf("%s %s", cmd.CommandPath(), plugin.Name)
}
attrSet := attribute.NewSet(
cobraCommandPath.String(commandPath),
)
kvs := make([]attribute.KeyValue, 0, attrSet.Len())
for iter := attrSet.Iter(); iter.Next(); {
attr := iter.Attribute()
kvs = append(kvs, attribute.KeyValue{
Key: dockerCliAttributePrefix + "." + attr.Key,
Value: attr.Value,
})
}
return attribute.NewSet(kvs...)
}
func appendPluginResourceAttributesEnvvar(env []string, cmd *cobra.Command, plugin Plugin) []string {
if attrs := getPluginResourceAttributes(cmd, plugin); attrs.Len() > 0 {
// values in environment variables need to be in baggage format
// otel/baggage package can be used after update to v1.22, currently it encodes incorrectly
attrsSlice := make([]string, attrs.Len())
for iter := attrs.Iter(); iter.Next(); {
i, v := iter.IndexedAttribute()
attrsSlice[i] = string(v.Key) + "=" + url.PathEscape(v.Value.AsString())
}
env = append(env, ResourceAttributesEnvvar+"="+strings.Join(attrsSlice, ","))
}
return env
}
-86
View File
@@ -1,86 +0,0 @@
package manager
import (
"os"
"path/filepath"
"slices"
"sync"
"testing"
"github.com/docker/cli/internal/test"
"github.com/spf13/cobra"
"gotest.tools/v3/assert"
)
func TestPluginResourceAttributesEnvvar(t *testing.T) {
cmd := &cobra.Command{
Annotations: map[string]string{
cobra.CommandDisplayNameAnnotation: "docker",
},
}
// Ensure basic usage is fine.
env := appendPluginResourceAttributesEnvvar(nil, cmd, Plugin{Name: "compose"})
assert.DeepEqual(t, []string{"OTEL_RESOURCE_ATTRIBUTES=docker.cli.cobra.command_path=docker%20compose"}, env)
// Add a user-based environment variable to OTEL_RESOURCE_ATTRIBUTES.
t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "a.b.c=foo")
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 = slices.Clone(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
}
+13 -8
View File
@@ -1,10 +1,10 @@
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16: // FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
//go:build go1.26 //go:build go1.19
package manager package manager
import ( import (
"fmt" "github.com/pkg/errors"
) )
// pluginError is set as Plugin.Err by NewPlugin if the plugin // pluginError is set as Plugin.Err by NewPlugin if the plugin
@@ -23,24 +23,29 @@ func (e *pluginError) Error() string {
return e.cause.Error() return e.cause.Error()
} }
// Cause satisfies the errors.causer interface for pluginError.
func (e *pluginError) Cause() error {
return e.cause
}
// Unwrap provides compatibility for Go 1.13 error chains. // Unwrap provides compatibility for Go 1.13 error chains.
func (e *pluginError) Unwrap() error { func (e *pluginError) Unwrap() error {
return e.cause return e.cause
} }
// MarshalText marshals the pluginError into a textual form. // MarshalText marshalls the pluginError into a textual form.
func (e *pluginError) MarshalText() (text []byte, err error) { func (e *pluginError) MarshalText() (text []byte, err error) {
return []byte(e.cause.Error()), nil return []byte(e.cause.Error()), nil
} }
// wrapAsPluginError wraps an error in a pluginError with an // wrapAsPluginError wraps an error in a pluginError with an
// additional message. // additional message, analogous to errors.Wrapf.
func wrapAsPluginError(err error, msg string) error { func wrapAsPluginError(err error, msg string) error {
return &pluginError{cause: fmt.Errorf("%s: %w", msg, err)} return &pluginError{cause: errors.Wrap(err, msg)}
} }
// newPluginError creates a new pluginError, analogous to // NewPluginError creates a new pluginError, analogous to
// errors.Errorf. // errors.Errorf.
func newPluginError(msg string, args ...any) error { func NewPluginError(msg string, args ...any) error {
return &pluginError{cause: fmt.Errorf(msg, args...)} return &pluginError{cause: errors.Errorf(msg, args...)}
} }
+3 -6
View File
@@ -2,7 +2,7 @@ package manager
import ( import (
"encoding/json" "encoding/json"
"errors" "fmt"
"testing" "testing"
"gotest.tools/v3/assert" "gotest.tools/v3/assert"
@@ -10,10 +10,10 @@ import (
) )
func TestPluginError(t *testing.T) { func TestPluginError(t *testing.T) {
err := newPluginError("new error") err := NewPluginError("new error")
assert.Check(t, is.Error(err, "new error")) assert.Check(t, is.Error(err, "new error"))
inner := errors.New("testing") inner := fmt.Errorf("testing")
err = wrapAsPluginError(inner, "wrapping") err = wrapAsPluginError(inner, "wrapping")
assert.Check(t, is.Error(err, "wrapping: testing")) assert.Check(t, is.Error(err, "wrapping: testing"))
assert.Check(t, is.ErrorIs(err, inner)) assert.Check(t, is.ErrorIs(err, inner))
@@ -21,7 +21,4 @@ func TestPluginError(t *testing.T) {
actual, err := json.Marshal(err) actual, err := json.Marshal(err)
assert.Check(t, err) assert.Check(t, err)
assert.Check(t, is.Equal(`"wrapping: testing"`, string(actual))) assert.Check(t, is.Equal(`"wrapping: testing"`, string(actual)))
err = wrapAsPluginError(nil, "wrapping")
assert.Check(t, is.Error(err, "wrapping: %!w(<nil>)"))
} }
-237
View File
@@ -1,237 +0,0 @@
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
//go:build go1.26
package manager
import (
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"github.com/docker/cli/cli-plugins/hooks"
"github.com/docker/cli/cli/config"
"github.com/docker/cli/cli/config/configfile"
"github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// HookPluginData is the type representing the information
// that plugins declaring support for hooks get passed when
// being invoked following a CLI command execution.
//
// 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
// present CLI plugins that declare support for hooks in their metadata and
// parses/prints their responses.
func RunCLICommandHooks(ctx context.Context, dockerCLI config.Provider, rootCmd, subCommand *cobra.Command, cmdErrorMessage string) {
commandName := strings.TrimPrefix(subCommand.CommandPath(), rootCmd.Name()+" ")
flags := getCommandFlags(subCommand)
runHooks(ctx, dockerCLI.ConfigFile(), rootCmd, subCommand, commandName, flags, cmdErrorMessage)
}
// RunPluginHooks is the entrypoint for the hooks execution flow
// after a plugin command was just executed by the CLI.
func RunPluginHooks(ctx context.Context, dockerCLI config.Provider, rootCmd, subCommand *cobra.Command, args []string, cmdErrorMessage string) {
commandName := strings.Join(args, " ")
flags := getNaiveFlags(args)
runHooks(ctx, dockerCLI.ConfigFile(), rootCmd, subCommand, commandName, flags, cmdErrorMessage)
}
func runHooks(ctx context.Context, cfg *configfile.ConfigFile, rootCmd, subCommand *cobra.Command, invokedCommand string, flags map[string]string, cmdErrorMessage string) {
nextSteps := invokeAndCollectHooks(ctx, cfg, rootCmd, subCommand, invokedCommand, flags, cmdErrorMessage)
hooks.PrintNextSteps(subCommand.ErrOrStderr(), nextSteps)
}
func invokeAndCollectHooks(ctx context.Context, cfg *configfile.ConfigFile, rootCmd, subCmd *cobra.Command, subCmdStr string, flags map[string]string, cmdErrorMessage string) []string {
if ctx.Err() != nil {
return nil
}
pluginsCfg := cfg.Plugins
if pluginsCfg == nil {
return nil
}
pluginDirs := getPluginDirs(cfg)
nextSteps := make([]string, 0, len(pluginsCfg))
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 {
return nil, false, err
}
resp, err := p.RunHook(ctx, hooks.Request{
RootCmd: match,
Flags: flags,
CommandError: cmdErrorMessage,
})
if err != nil {
return nil, false, err
}
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 message.Type != hooks.NextSteps {
return nil, false, errors.New("unexpected hook response type: " + strconv.Itoa(int(message.Type)))
}
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, messages)
if !appended {
logrus.WithFields(logrus.Fields{
"plugin": pluginName,
}).Debug("Plugin responded with an empty hook message; ignoring")
}
}
return nextSteps
}
// appendNextSteps appends the processed hook output to the nextSteps slice.
// If the processed hook output is empty, it is not appended.
// Empty lines are not stripped if there's at least one non-empty line.
func appendNextSteps(nextSteps []string, processed []string) ([]string, bool) {
empty := true
for _, l := range processed {
if strings.TrimSpace(l) != "" {
empty = false
break
}
}
if empty {
return nextSteps, false
}
return append(nextSteps, processed...), true
}
// pluginMatch takes a plugin configuration and a string representing the
// command being executed (such as 'image ls' – the root 'docker' is omitted)
// and, if the configuration includes a hook for the invoked command, returns
// the configured hook string.
//
// Plugins can declare two types of hooks in their configuration:
// - "hooks": fires on every command invocation (success or failure)
// - "error-hooks": fires only when a command fails (cmdErrorMessage is non-empty)
func pluginMatch(pluginCfg map[string]string, subCmd string, cmdErrorMessage string) (string, bool) {
// Check "hooks" first — these always fire regardless of command outcome.
if match, ok := matchHookConfig(pluginCfg["hooks"], subCmd); ok {
return match, true
}
// Check "error-hooks" — these only fire when there was an error.
if cmdErrorMessage != "" {
if match, ok := matchHookConfig(pluginCfg["error-hooks"], subCmd); ok {
return match, true
}
}
return "", false
}
// matchHookConfig checks if a comma-separated hook configuration string
// contains a prefix match for the given subcommand.
func matchHookConfig(configuredHooks string, subCmd string) (string, bool) {
if configuredHooks == "" {
return "", false
}
for hookCmd := range strings.SplitSeq(configuredHooks, ",") {
if hookMatch(hookCmd, subCmd) {
return hookCmd, true
}
}
return "", false
}
func hookMatch(hookCmd, subCmd string) bool {
hookCmdTokens := strings.Split(hookCmd, " ")
subCmdTokens := strings.Split(subCmd, " ")
if len(hookCmdTokens) > len(subCmdTokens) {
return false
}
for i, v := range hookCmdTokens {
if v != subCmdTokens[i] {
return false
}
}
return true
}
func getCommandFlags(cmd *cobra.Command) map[string]string {
flags := make(map[string]string)
cmd.Flags().Visit(func(f *pflag.Flag) {
var fValue string
if f.Value.Type() == "bool" {
fValue = f.Value.String()
}
flags[f.Name] = fValue
})
return flags
}
// getNaiveFlags string-matches argv and parses them into a map.
// This is used when calling hooks after a plugin command, since
// in this case we can't rely on the cobra command tree to parse
// flags in this case. In this case, no values are ever passed,
// since we don't have enough information to process them.
func getNaiveFlags(args []string) map[string]string {
flags := make(map[string]string)
for _, arg := range args {
if strings.HasPrefix(arg, "--") {
flags[arg[2:]] = ""
continue
}
if strings.HasPrefix(arg, "-") {
flags[arg[1:]] = ""
}
}
return flags
}
-375
View File
@@ -1,375 +0,0 @@
package manager
import (
"context"
"testing"
"github.com/docker/cli/cli/config/configfile"
"github.com/spf13/cobra"
"gotest.tools/v3/assert"
is "gotest.tools/v3/assert/cmp"
)
type fakeConfigProvider struct {
cfg *configfile.ConfigFile
}
func (f *fakeConfigProvider) ConfigFile() *configfile.ConfigFile {
return f.cfg
}
func TestGetNaiveFlags(t *testing.T) {
testCases := []struct {
args []string
expectedFlags map[string]string
}{
{
args: []string{"docker"},
expectedFlags: map[string]string{},
},
{
args: []string{"docker", "build", "-q", "--file", "test.Dockerfile", "."},
expectedFlags: map[string]string{
"q": "",
"file": "",
},
},
{
args: []string{"docker", "--context", "a-context", "pull", "-q", "--progress", "auto", "alpine"},
expectedFlags: map[string]string{
"context": "",
"q": "",
"progress": "",
},
},
}
for _, tc := range testCases {
assert.DeepEqual(t, getNaiveFlags(tc.args), tc.expectedFlags)
}
}
func TestPluginMatch(t *testing.T) {
testCases := []struct {
doc string
commandString string
pluginConfig map[string]string
cmdErrorMessage string
expectedMatch string
expectedOk bool
}{
{
doc: "hooks prefix match",
commandString: "image ls",
pluginConfig: map[string]string{
"hooks": "image",
},
expectedMatch: "image",
expectedOk: true,
},
{
doc: "hooks no match",
commandString: "context ls",
pluginConfig: map[string]string{
"hooks": "build",
},
expectedMatch: "",
expectedOk: false,
},
{
doc: "hooks exact match",
commandString: "context ls",
pluginConfig: map[string]string{
"hooks": "context ls",
},
expectedMatch: "context ls",
expectedOk: true,
},
{
doc: "hooks first match wins",
commandString: "image ls",
pluginConfig: map[string]string{
"hooks": "image ls,image",
},
expectedMatch: "image ls",
expectedOk: true,
},
{
doc: "hooks empty string",
commandString: "image ls",
pluginConfig: map[string]string{
"hooks": "",
},
expectedMatch: "",
expectedOk: false,
},
{
doc: "hooks partial token no match",
commandString: "image inspect",
pluginConfig: map[string]string{
"hooks": "image i",
},
expectedMatch: "",
expectedOk: false,
},
{
doc: "hooks prefix token match",
commandString: "image inspect",
pluginConfig: map[string]string{
"hooks": "image",
},
expectedMatch: "image",
expectedOk: true,
},
{
doc: "error-hooks match on error",
commandString: "build",
pluginConfig: map[string]string{
"error-hooks": "build",
},
cmdErrorMessage: "exit status 1",
expectedMatch: "build",
expectedOk: true,
},
{
doc: "error-hooks no match on success",
commandString: "build",
pluginConfig: map[string]string{
"error-hooks": "build",
},
cmdErrorMessage: "",
expectedMatch: "",
expectedOk: false,
},
{
doc: "error-hooks prefix match on error",
commandString: "compose up",
pluginConfig: map[string]string{
"error-hooks": "compose",
},
cmdErrorMessage: "exit status 1",
expectedMatch: "compose",
expectedOk: true,
},
{
doc: "error-hooks no match for wrong command",
commandString: "pull",
pluginConfig: map[string]string{
"error-hooks": "build",
},
cmdErrorMessage: "exit status 1",
expectedMatch: "",
expectedOk: false,
},
{
doc: "hooks takes precedence over error-hooks",
commandString: "build",
pluginConfig: map[string]string{
"hooks": "build",
"error-hooks": "build",
},
cmdErrorMessage: "exit status 1",
expectedMatch: "build",
expectedOk: true,
},
{
doc: "hooks fires on success even with error-hooks configured",
commandString: "build",
pluginConfig: map[string]string{
"hooks": "build",
"error-hooks": "build",
},
cmdErrorMessage: "",
expectedMatch: "build",
expectedOk: true,
},
{
doc: "error-hooks with multiple commands",
commandString: "compose up",
pluginConfig: map[string]string{
"error-hooks": "build,compose up,pull",
},
cmdErrorMessage: "exit status 1",
expectedMatch: "compose up",
expectedOk: true,
},
}
for _, tc := range testCases {
t.Run(tc.doc, func(t *testing.T) {
match, ok := pluginMatch(tc.pluginConfig, tc.commandString, tc.cmdErrorMessage)
assert.Equal(t, ok, tc.expectedOk)
assert.Equal(t, match, tc.expectedMatch)
})
}
}
func TestMatchHookConfig(t *testing.T) {
testCases := []struct {
doc string
configuredHooks string
subCmd string
expectedMatch string
expectedOk bool
}{
{
doc: "empty config",
configuredHooks: "",
subCmd: "build",
expectedMatch: "",
expectedOk: false,
},
{
doc: "exact match",
configuredHooks: "build",
subCmd: "build",
expectedMatch: "build",
expectedOk: true,
},
{
doc: "prefix match",
configuredHooks: "image",
subCmd: "image ls",
expectedMatch: "image",
expectedOk: true,
},
{
doc: "comma-separated match",
configuredHooks: "pull,build,push",
subCmd: "build",
expectedMatch: "build",
expectedOk: true,
},
{
doc: "no match",
configuredHooks: "pull,push",
subCmd: "build",
expectedMatch: "",
expectedOk: false,
},
}
for _, tc := range testCases {
t.Run(tc.doc, func(t *testing.T) {
match, ok := matchHookConfig(tc.configuredHooks, tc.subCmd)
assert.Equal(t, ok, tc.expectedOk)
assert.Equal(t, match, tc.expectedMatch)
})
}
}
func TestAppendNextSteps(t *testing.T) {
testCases := []struct {
processed []string
expectedOut []string
}{
{
processed: []string{},
expectedOut: []string{},
},
{
processed: []string{"", ""},
expectedOut: []string{},
},
{
processed: []string{"Some hint", "", "Some other hint"},
expectedOut: []string{"Some hint", "", "Some other hint"},
},
{
processed: []string{"Hint 1", "Hint 2"},
expectedOut: []string{"Hint 1", "Hint 2"},
},
}
for _, tc := range testCases {
t.Run("", func(t *testing.T) {
got, appended := appendNextSteps([]string{}, tc.processed)
assert.Check(t, is.DeepEqual(got, tc.expectedOut))
assert.Check(t, is.Equal(appended, len(got) > 0))
})
}
}
func TestRunPluginHooksPassesErrorMessage(t *testing.T) {
cfg := configfile.New("")
cfg.Plugins = map[string]map[string]string{
"test-plugin": {"hooks": "build"},
}
provider := &fakeConfigProvider{cfg: cfg}
root := &cobra.Command{Use: "docker"}
sub := &cobra.Command{Use: "build"}
root.AddCommand(sub)
// Should not panic with empty error message (success case)
RunPluginHooks(context.Background(), provider, root, sub, []string{"build"}, "")
// Should not panic with non-empty error message (failure case)
RunPluginHooks(context.Background(), provider, root, sub, []string{"build"}, "exit status 1")
}
func TestRunPluginHooksErrorHooks(t *testing.T) {
cfg := configfile.New("")
cfg.Plugins = map[string]map[string]string{
"test-plugin": {"error-hooks": "build"},
}
provider := &fakeConfigProvider{cfg: cfg}
root := &cobra.Command{Use: "docker"}
sub := &cobra.Command{Use: "build"}
root.AddCommand(sub)
// Should not panic — error-hooks with error message
RunPluginHooks(context.Background(), provider, root, sub, []string{"build"}, "exit status 1")
// Should not panic — error-hooks with no error (should be skipped)
RunPluginHooks(context.Background(), provider, root, sub, []string{"build"}, "")
}
func TestInvokeAndCollectHooksErrorHooksSkippedOnSuccess(t *testing.T) {
cfg := configfile.New("")
cfg.Plugins = map[string]map[string]string{
"nonexistent": {"error-hooks": "build"},
}
root := &cobra.Command{Use: "docker"}
sub := &cobra.Command{Use: "build"}
root.AddCommand(sub)
// On success, error-hooks should not match, so the plugin
// binary is never looked up and no results are returned.
result := invokeAndCollectHooks(
context.Background(), cfg, root, sub,
"build", map[string]string{}, "",
)
assert.Check(t, is.Len(result, 0))
}
func TestInvokeAndCollectHooksNoPlugins(t *testing.T) {
cfg := configfile.New("")
root := &cobra.Command{Use: "docker"}
sub := &cobra.Command{Use: "build"}
root.AddCommand(sub)
result := invokeAndCollectHooks(
context.Background(), cfg, root, sub,
"build", map[string]string{}, "some error",
)
assert.Check(t, is.Len(result, 0))
}
func TestInvokeAndCollectHooksCancelledContext(t *testing.T) {
cfg := configfile.New("")
cfg.Plugins = map[string]map[string]string{
"test-plugin": {"hooks": "build"},
}
root := &cobra.Command{Use: "docker"}
sub := &cobra.Command{Use: "build"}
root.AddCommand(sub)
ctx, cancel := context.WithCancel(context.Background())
cancel() // cancel immediately
result := invokeAndCollectHooks(
ctx, cfg, root, sub,
"build", map[string]string{}, "exit status 1",
)
assert.Check(t, is.Nil(result))
}
+94 -74
View File
@@ -1,112 +1,130 @@
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
//go:build go1.26
package manager package manager
import ( import (
"context" "context"
"errors"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
slices "slices" "sort"
"strings" "strings"
"sync" "sync"
"github.com/containerd/errdefs" "github.com/docker/cli/cli/command"
"github.com/docker/cli/cli-plugins/metadata"
"github.com/docker/cli/cli/config" "github.com/docker/cli/cli/config"
"github.com/docker/cli/cli/config/configfile" "github.com/docker/cli/cli/config/configfile"
"github.com/docker/cli/cli/debug"
"github.com/fvbommel/sortorder" "github.com/fvbommel/sortorder"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"golang.org/x/sync/errgroup" "golang.org/x/sync/errgroup"
) )
const (
// ReexecEnvvar is the name of an ennvar which is set to the command
// used to originally invoke the docker CLI when executing a
// plugin. Assuming $PATH and $CWD remain unchanged this should allow
// the plugin to re-execute the original CLI.
ReexecEnvvar = "DOCKER_CLI_PLUGIN_ORIGINAL_CLI_COMMAND"
// ResourceAttributesEnvvar is the name of the envvar that includes additional
// resource attributes for OTEL.
ResourceAttributesEnvvar = "OTEL_RESOURCE_ATTRIBUTES"
)
// errPluginNotFound is the error returned when a plugin could not be found. // errPluginNotFound is the error returned when a plugin could not be found.
type errPluginNotFound string type errPluginNotFound string
func (errPluginNotFound) NotFound() {} func (e errPluginNotFound) NotFound() {}
func (e errPluginNotFound) Error() string { func (e errPluginNotFound) Error() string {
return "Error: No such CLI plugin: " + string(e) return "Error: No such CLI plugin: " + string(e)
} }
// getPluginDirs returns the platform-specific locations to search for plugins type notFound interface{ NotFound() }
// in order of preference.
// // IsNotFound is true if the given error is due to a plugin not being found.
// Plugin-discovery is performed in the following order of preference: func IsNotFound(err error) bool {
// if e, ok := err.(*pluginError); ok {
// 1. The "cli-plugins" directory inside the CLIs [config.Path] (usually "~/.docker/cli-plugins"). err = e.Cause()
// 2. Additional plugin directories as configured through [ConfigFile.CLIPluginsExtraDirs]. }
// 3. Platform-specific defaultSystemPluginDirs. _, ok := err.(notFound)
// return ok
// [ConfigFile.CLIPluginsExtraDirs]: https://pkg.go.dev/github.com/docker/cli@v26.1.4+incompatible/cli/config/configfile#ConfigFile.CLIPluginsExtraDirs }
func getPluginDirs(cfg *configfile.ConfigFile) []string {
func getPluginDirs(cfg *configfile.ConfigFile) ([]string, error) {
var pluginDirs []string var pluginDirs []string
if cfg != nil { if cfg != nil {
pluginDirs = append(pluginDirs, cfg.CLIPluginsExtraDirs...) pluginDirs = append(pluginDirs, cfg.CLIPluginsExtraDirs...)
} }
pluginDir := filepath.Join(config.Dir(), "cli-plugins") pluginDir, err := config.Path("cli-plugins")
if err != nil {
return nil, err
}
pluginDirs = append(pluginDirs, pluginDir) pluginDirs = append(pluginDirs, pluginDir)
pluginDirs = append(pluginDirs, defaultSystemPluginDirs...) pluginDirs = append(pluginDirs, defaultSystemPluginDirs...)
return pluginDirs return pluginDirs, nil
} }
func addPluginCandidatesFromDir(res map[string][]string, d string) { func addPluginCandidatesFromDir(res map[string][]string, d string) error {
dentries, err := os.ReadDir(d) dentries, err := os.ReadDir(d)
// Silently ignore any directories which we cannot list (e.g. due to
// permissions or anything else) or which is not a directory
if err != nil { if err != nil {
return return err
} }
for _, dentry := range dentries { for _, dentry := range dentries {
switch mode := dentry.Type() & os.ModeType; mode { //nolint:exhaustive,nolintlint // no need to include all possible file-modes in this list switch dentry.Type() & os.ModeType {
case os.ModeSymlink: case 0, os.ModeSymlink:
if !debug.IsEnabled() { // Regular file or symlink, keep going
// Skip broken symlinks unless debug is enabled. With debug
// enabled, this will print a warning in "docker info".
if _, err := os.Stat(filepath.Join(d, dentry.Name())); errors.Is(err, os.ErrNotExist) {
continue
}
}
case 0:
// Regular file, keep going
default: default:
// Something else, ignore. // Something else, ignore.
continue continue
} }
name := dentry.Name() name := dentry.Name()
if !strings.HasPrefix(name, metadata.NamePrefix) { if !strings.HasPrefix(name, NamePrefix) {
continue continue
} }
name = strings.TrimPrefix(name, metadata.NamePrefix) name = strings.TrimPrefix(name, NamePrefix)
var err error var err error
if name, err = trimExeSuffix(name); err != nil { if name, err = trimExeSuffix(name); err != nil {
continue continue
} }
res[name] = append(res[name], filepath.Join(d, dentry.Name())) res[name] = append(res[name], filepath.Join(d, dentry.Name()))
} }
return nil
} }
// listPluginCandidates returns a map from plugin name to the list of (unvalidated) Candidates. The list is in descending order of priority. // listPluginCandidates returns a map from plugin name to the list of (unvalidated) Candidates. The list is in descending order of priority.
func listPluginCandidates(dirs []string) map[string][]string { func listPluginCandidates(dirs []string) (map[string][]string, error) {
result := make(map[string][]string) result := make(map[string][]string)
for _, d := range dirs { for _, d := range dirs {
addPluginCandidatesFromDir(result, d) // Silently ignore any directories which we cannot
// Stat (e.g. due to permissions or anything else) or
// which is not a directory.
if fi, err := os.Stat(d); err != nil || !fi.IsDir() {
continue
}
if err := addPluginCandidatesFromDir(result, d); err != nil {
// Silently ignore paths which don't exist.
if os.IsNotExist(err) {
continue
}
return nil, err // Or return partial result?
}
} }
return result return result, nil
} }
// GetPlugin returns a plugin on the system by its name // GetPlugin returns a plugin on the system by its name
func GetPlugin(name string, dockerCLI config.Provider, rootcmd *cobra.Command) (*Plugin, error) { func GetPlugin(name string, dockerCli command.Cli, rootcmd *cobra.Command) (*Plugin, error) {
pluginDirs := getPluginDirs(dockerCLI.ConfigFile()) pluginDirs, err := getPluginDirs(dockerCli.ConfigFile())
return getPlugin(name, pluginDirs, rootcmd) if err != nil {
} return nil, err
}
candidates, err := listPluginCandidates(pluginDirs)
if err != nil {
return nil, err
}
func getPlugin(name string, pluginDirs []string, rootcmd *cobra.Command) (*Plugin, error) {
candidates := listPluginCandidates(pluginDirs)
if paths, ok := candidates[name]; ok { if paths, ok := candidates[name]; ok {
if len(paths) == 0 { if len(paths) == 0 {
return nil, errPluginNotFound(name) return nil, errPluginNotFound(name)
@@ -116,7 +134,7 @@ func getPlugin(name string, pluginDirs []string, rootcmd *cobra.Command) (*Plugi
if err != nil { if err != nil {
return nil, err return nil, err
} }
if !errdefs.IsNotFound(p.Err) { if !IsNotFound(p.Err) {
p.ShadowedPaths = paths[1:] p.ShadowedPaths = paths[1:]
} }
return &p, nil return &p, nil
@@ -126,21 +144,20 @@ func getPlugin(name string, pluginDirs []string, rootcmd *cobra.Command) (*Plugi
} }
// ListPlugins produces a list of the plugins available on the system // ListPlugins produces a list of the plugins available on the system
func ListPlugins(dockerCli config.Provider, rootcmd *cobra.Command) ([]Plugin, error) { func ListPlugins(dockerCli command.Cli, rootcmd *cobra.Command) ([]Plugin, error) {
pluginDirs := getPluginDirs(dockerCli.ConfigFile()) pluginDirs, err := getPluginDirs(dockerCli.ConfigFile())
candidates := listPluginCandidates(pluginDirs) if err != nil {
if len(candidates) == 0 { return nil, err
return nil, nil }
candidates, err := listPluginCandidates(pluginDirs)
if err != nil {
return nil, err
} }
var plugins []Plugin var plugins []Plugin
var mu sync.Mutex var mu sync.Mutex
ctx := rootcmd.Context() eg, _ := errgroup.WithContext(context.TODO())
if ctx == nil {
// Fallback, mostly for tests that pass a bare cobra.command
ctx = context.Background()
}
eg, _ := errgroup.WithContext(ctx)
cmds := rootcmd.Commands() cmds := rootcmd.Commands()
for _, paths := range candidates { for _, paths := range candidates {
func(paths []string) { func(paths []string) {
@@ -153,7 +170,7 @@ func ListPlugins(dockerCli config.Provider, rootcmd *cobra.Command) ([]Plugin, e
if err != nil { if err != nil {
return err return err
} }
if !errdefs.IsNotFound(p.Err) { if !IsNotFound(p.Err) {
p.ShadowedPaths = paths[1:] p.ShadowedPaths = paths[1:]
mu.Lock() mu.Lock()
defer mu.Unlock() defer mu.Unlock()
@@ -167,28 +184,31 @@ func ListPlugins(dockerCli config.Provider, rootcmd *cobra.Command) ([]Plugin, e
return nil, err return nil, err
} }
slices.SortFunc(plugins, func(a, b Plugin) int { sort.Slice(plugins, func(i, j int) bool {
return sortorder.NaturalCompare(a.Name, b.Name) return sortorder.NaturalLess(plugins[i].Name, plugins[j].Name)
}) })
return plugins, nil return plugins, nil
} }
// PluginRunCommand returns an [os/exec.Cmd] which when [os/exec.Cmd.Run] will execute the named plugin. // PluginRunCommand returns an "os/exec".Cmd which when .Run() will execute the named plugin.
// The rootcmd argument is referenced to determine the set of builtin commands in order to detect conflicts. // The rootcmd argument is referenced to determine the set of builtin commands in order to detect conficts.
// The error returned satisfies the [errdefs.IsNotFound] predicate if no plugin was found or if the first candidate plugin was invalid somehow. // The error returned satisfies the IsNotFound() predicate if no plugin was found or if the first candidate plugin was invalid somehow.
func PluginRunCommand(dockerCli config.Provider, name string, rootcmd *cobra.Command) (*exec.Cmd, error) { func PluginRunCommand(dockerCli command.Cli, name string, rootcmd *cobra.Command) (*exec.Cmd, error) {
// This uses the full original args, not the args which may // This uses the full original args, not the args which may
// have been provided by cobra to our caller. This is because // have been provided by cobra to our caller. This is because
// they lack e.g. global options which we must propagate here. // they lack e.g. global options which we must propagate here.
args := os.Args[1:] args := os.Args[1:]
if !isValidPluginName(name) { if !pluginNameRe.MatchString(name) {
// We treat this as "not found" so that callers will // We treat this as "not found" so that callers will
// fallback to their "invalid" command path. // fallback to their "invalid" command path.
return nil, errPluginNotFound(name) return nil, errPluginNotFound(name)
} }
exename := addExeSuffix(metadata.NamePrefix + name) exename := addExeSuffix(NamePrefix + name)
pluginDirs := getPluginDirs(dockerCli.ConfigFile()) pluginDirs, err := getPluginDirs(dockerCli.ConfigFile())
if err != nil {
return nil, err
}
for _, d := range pluginDirs { for _, d := range pluginDirs {
path := filepath.Join(d, exename) path := filepath.Join(d, exename)
@@ -210,8 +230,7 @@ func PluginRunCommand(dockerCli config.Provider, name string, rootcmd *cobra.Com
// TODO: why are we not returning plugin.Err? // TODO: why are we not returning plugin.Err?
return nil, errPluginNotFound(name) return nil, errPluginNotFound(name)
} }
cmd := exec.Command(plugin.Path, args...) // #nosec G204 -- ignore "Subprocess launched with a potential tainted input or cmd arguments" cmd := exec.Command(plugin.Path, args...)
// Using dockerCli.{In,Out,Err}() here results in a hang until something is input. // Using dockerCli.{In,Out,Err}() here results in a hang until something is input.
// See: - https://github.com/golang/go/issues/10338 // See: - https://github.com/golang/go/issues/10338
// - https://github.com/golang/go/commit/d000e8742a173aa0659584aa01b7ba2834ba28ab // - https://github.com/golang/go/commit/d000e8742a173aa0659584aa01b7ba2834ba28ab
@@ -221,7 +240,8 @@ func PluginRunCommand(dockerCli config.Provider, name string, rootcmd *cobra.Com
cmd.Stdout = os.Stdout cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr cmd.Stderr = os.Stderr
cmd.Env = append(cmd.Environ(), metadata.ReexecEnvvar+"="+os.Args[0]) cmd.Env = os.Environ()
cmd.Env = append(cmd.Env, ReexecEnvvar+"="+os.Args[0])
cmd.Env = appendPluginResourceAttributesEnvvar(cmd.Env, rootcmd, plugin) cmd.Env = appendPluginResourceAttributesEnvvar(cmd.Env, rootcmd, plugin)
return cmd, nil return cmd, nil
@@ -231,5 +251,5 @@ func PluginRunCommand(dockerCli config.Provider, name string, rootcmd *cobra.Com
// IsPluginCommand checks if the given cmd is a plugin-stub. // IsPluginCommand checks if the given cmd is a plugin-stub.
func IsPluginCommand(cmd *cobra.Command) bool { func IsPluginCommand(cmd *cobra.Command) bool {
return cmd.Annotations[metadata.CommandAnnotationPlugin] == "true" return cmd.Annotations[CommandAnnotationPlugin] == "true"
} }
+19 -41
View File
@@ -1,11 +1,9 @@
package manager package manager
import ( import (
"path/filepath"
"strings" "strings"
"testing" "testing"
"github.com/containerd/errdefs"
"github.com/docker/cli/cli/config" "github.com/docker/cli/cli/config"
"github.com/docker/cli/cli/config/configfile" "github.com/docker/cli/cli/config/configfile"
"github.com/docker/cli/internal/test" "github.com/docker/cli/internal/test"
@@ -38,7 +36,7 @@ func TestListPluginCandidates(t *testing.T) {
"plugins3-target", // Will be referenced as a symlink from below "plugins3-target", // Will be referenced as a symlink from below
fs.WithFile("docker-plugin1", ""), fs.WithFile("docker-plugin1", ""),
fs.WithDir("ignored3"), fs.WithDir("ignored3"),
fs.WithSymlink("docker-brokensymlink", "broken"), // A broken symlink is ignored fs.WithSymlink("docker-brokensymlink", "broken"), // A broken symlink is still a candidate (but would fail tests later)
fs.WithFile("non-plugin-symlinked", ""), // This shouldn't appear, but ... fs.WithFile("non-plugin-symlinked", ""), // This shouldn't appear, but ...
fs.WithSymlink("docker-symlinked", "non-plugin-symlinked"), // ... this link to it should. fs.WithSymlink("docker-symlinked", "non-plugin-symlinked"), // ... this link to it should.
), ),
@@ -53,7 +51,8 @@ func TestListPluginCandidates(t *testing.T) {
dirs = append(dirs, dir.Join(d)) dirs = append(dirs, dir.Join(d))
} }
candidates := listPluginCandidates(dirs) candidates, err := listPluginCandidates(dirs)
assert.NilError(t, err)
exp := map[string][]string{ exp := map[string][]string{
"plugin1": { "plugin1": {
dir.Join("plugins1", "docker-plugin1"), dir.Join("plugins1", "docker-plugin1"),
@@ -72,6 +71,9 @@ func TestListPluginCandidates(t *testing.T) {
"hardlink2": { "hardlink2": {
dir.Join("plugins2", "docker-hardlink2"), dir.Join("plugins2", "docker-hardlink2"),
}, },
"brokensymlink": {
dir.Join("plugins3", "docker-brokensymlink"),
},
"symlinked": { "symlinked": {
dir.Join("plugins3", "docker-symlinked"), dir.Join("plugins3", "docker-symlinked"),
}, },
@@ -80,35 +82,6 @@ func TestListPluginCandidates(t *testing.T) {
assert.DeepEqual(t, candidates, exp) assert.DeepEqual(t, candidates, exp)
} }
func TestListPluginCandidatesEmpty(t *testing.T) {
tmpDir := t.TempDir()
candidates := listPluginCandidates([]string{tmpDir, filepath.Join(tmpDir, "no-such-dir")})
assert.Assert(t, len(candidates) == 0)
}
// Regression test for https://github.com/docker/cli/issues/5643.
// Check that inaccessible directories that come before accessible ones are ignored
// and do not prevent the latter from being processed.
func TestListPluginCandidatesInaccessibleDir(t *testing.T) {
dir := fs.NewDir(t, t.Name(),
fs.WithDir("no-perm", fs.WithMode(0)),
fs.WithDir("plugins",
fs.WithFile("docker-buildx", ""),
),
)
defer dir.Remove()
candidates := listPluginCandidates([]string{
dir.Join("no-perm"),
dir.Join("plugins"),
})
assert.DeepEqual(t, candidates, map[string][]string{
"buildx": {
dir.Join("plugins", "docker-buildx"),
},
})
}
func TestGetPlugin(t *testing.T) { func TestGetPlugin(t *testing.T) {
dir := fs.NewDir(t, t.Name(), dir := fs.NewDir(t, t.Name(),
fs.WithFile("docker-bbb", ` fs.WithFile("docker-bbb", `
@@ -129,7 +102,7 @@ echo '{"SchemaVersion":"0.1.0"}'`, fs.WithMode(0o777)),
_, err = GetPlugin("ccc", cli, &cobra.Command{}) _, err = GetPlugin("ccc", cli, &cobra.Command{})
assert.Error(t, err, "Error: No such CLI plugin: ccc") assert.Error(t, err, "Error: No such CLI plugin: ccc")
assert.Assert(t, errdefs.IsNotFound(err)) assert.Assert(t, IsNotFound(err))
} }
func TestListPluginsIsSorted(t *testing.T) { func TestListPluginsIsSorted(t *testing.T) {
@@ -164,25 +137,30 @@ func TestErrPluginNotFound(t *testing.T) {
var err error = errPluginNotFound("test") var err error = errPluginNotFound("test")
err.(errPluginNotFound).NotFound() err.(errPluginNotFound).NotFound()
assert.Error(t, err, "Error: No such CLI plugin: test") assert.Error(t, err, "Error: No such CLI plugin: test")
assert.Assert(t, errdefs.IsNotFound(err)) assert.Assert(t, IsNotFound(err))
assert.Assert(t, !errdefs.IsNotFound(nil)) assert.Assert(t, !IsNotFound(nil))
} }
func TestGetPluginDirs(t *testing.T) { func TestGetPluginDirs(t *testing.T) {
cli := test.NewFakeCli(nil) cli := test.NewFakeCli(nil)
pluginDir := filepath.Join(config.Dir(), "cli-plugins") pluginDir, err := config.Path("cli-plugins")
assert.NilError(t, err)
expected := append([]string{pluginDir}, defaultSystemPluginDirs...) expected := append([]string{pluginDir}, defaultSystemPluginDirs...)
pluginDirs := getPluginDirs(cli.ConfigFile()) var pluginDirs []string
pluginDirs, err = getPluginDirs(cli.ConfigFile())
assert.Equal(t, strings.Join(expected, ":"), strings.Join(pluginDirs, ":")) assert.Equal(t, strings.Join(expected, ":"), strings.Join(pluginDirs, ":"))
assert.NilError(t, err)
extras := make([]string, 0, 3+len(expected)) extras := []string{
extras = append(extras, "foo", "bar", "baz") "foo", "bar", "baz",
}
expected = append(extras, expected...) expected = append(extras, expected...)
cli.SetConfigFile(&configfile.ConfigFile{ cli.SetConfigFile(&configfile.ConfigFile{
CLIPluginsExtraDirs: extras, CLIPluginsExtraDirs: extras,
}) })
pluginDirs = getPluginDirs(cli.ConfigFile()) pluginDirs, err = getPluginDirs(cli.ConfigFile())
assert.DeepEqual(t, expected, pluginDirs) assert.DeepEqual(t, expected, pluginDirs)
assert.NilError(t, err)
} }
+2 -14
View File
@@ -2,19 +2,7 @@
package manager package manager
// defaultSystemPluginDirs are the platform-specific locations to search
// for plugins in order of preference.
//
// Plugin-discovery is performed in the following order of preference:
//
// 1. The "cli-plugins" directory inside the CLIs config-directory (usually "~/.docker/cli-plugins").
// 2. Additional plugin directories as configured through [ConfigFile.CLIPluginsExtraDirs].
// 3. Platform-specific defaultSystemPluginDirs (as defined below).
//
// [ConfigFile.CLIPluginsExtraDirs]: https://pkg.go.dev/github.com/docker/cli@v26.1.4+incompatible/cli/config/configfile#ConfigFile.CLIPluginsExtraDirs
var defaultSystemPluginDirs = []string{ var defaultSystemPluginDirs = []string{
"/usr/local/lib/docker/cli-plugins", "/usr/local/lib/docker/cli-plugins", "/usr/local/libexec/docker/cli-plugins",
"/usr/local/libexec/docker/cli-plugins", "/usr/lib/docker/cli-plugins", "/usr/libexec/docker/cli-plugins",
"/usr/lib/docker/cli-plugins",
"/usr/libexec/docker/cli-plugins",
} }
+1 -10
View File
@@ -5,16 +5,7 @@ import (
"path/filepath" "path/filepath"
) )
// defaultSystemPluginDirs are the platform-specific locations to search
// for plugins in order of preference.
//
// Plugin-discovery is performed in the following order of preference:
//
// 1. The "cli-plugins" directory inside the CLIs config-directory (usually "~/.docker/cli-plugins").
// 2. Additional plugin directories as configured through [ConfigFile.CLIPluginsExtraDirs].
// 3. Platform-specific defaultSystemPluginDirs (as defined below).
//
// [ConfigFile.CLIPluginsExtraDirs]: https://pkg.go.dev/github.com/docker/cli@v26.1.4+incompatible/cli/config/configfile#ConfigFile.CLIPluginsExtraDirs
var defaultSystemPluginDirs = []string{ var defaultSystemPluginDirs = []string{
filepath.Join(os.Getenv("ProgramData"), "Docker", "cli-plugins"),
filepath.Join(os.Getenv("ProgramFiles"), "Docker", "cli-plugins"), filepath.Join(os.Getenv("ProgramFiles"), "Docker", "cli-plugins"),
} }
@@ -1,4 +1,4 @@
package metadata package manager
const ( const (
// NamePrefix is the prefix required on all plugin binary names // NamePrefix is the prefix required on all plugin binary names
@@ -8,17 +8,6 @@ const (
// which must be supported by every plugin and returns the // which must be supported by every plugin and returns the
// plugin metadata. // plugin metadata.
MetadataSubcommandName = "docker-cli-plugin-metadata" MetadataSubcommandName = "docker-cli-plugin-metadata"
// HookSubcommandName is the name of the plugin subcommand
// which must be implemented by plugins declaring support
// for hooks in their metadata.
HookSubcommandName = "docker-cli-plugin-hooks"
// ReexecEnvvar is the name of an ennvar which is set to the command
// used to originally invoke the docker CLI when executing a
// plugin. Assuming $PATH and $CWD remain unchanged this should allow
// the plugin to re-execute the original CLI.
ReexecEnvvar = "DOCKER_CLI_PLUGIN_ORIGINAL_CLI_COMMAND"
) )
// Metadata provided by the plugin. // Metadata provided by the plugin.
@@ -33,6 +22,4 @@ type Metadata struct {
ShortDescription string `json:",omitempty"` ShortDescription string `json:",omitempty"`
// URL is a pointer to the plugin's homepage. // URL is a pointer to the plugin's homepage.
URL string `json:",omitempty"` URL string `json:",omitempty"`
// Hidden hides the plugin in completion and help message output.
Hidden bool `json:",omitempty"`
} }
+18 -118
View File
@@ -1,28 +1,20 @@
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
//go:build go1.26
package manager package manager
import ( import (
"context"
"encoding"
"encoding/json" "encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath" "path/filepath"
"strconv" "regexp"
"strings" "strings"
"github.com/docker/cli/cli-plugins/hooks" "github.com/pkg/errors"
"github.com/docker/cli/cli-plugins/metadata"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
var pluginNameRe = regexp.MustCompile("^[a-z][a-z0-9]*$")
// Plugin represents a potential plugin with all it's metadata. // Plugin represents a potential plugin with all it's metadata.
type Plugin struct { type Plugin struct {
metadata.Metadata Metadata
Name string `json:",omitempty"` Name string `json:",omitempty"`
Path string `json:",omitempty"` Path string `json:",omitempty"`
@@ -34,34 +26,12 @@ type Plugin struct {
ShadowedPaths []string `json:",omitempty"` ShadowedPaths []string `json:",omitempty"`
} }
// MarshalJSON implements [json.Marshaler] to handle marshaling the
// [Plugin.Err] field (Go doesn't marshal errors by default).
func (p *Plugin) MarshalJSON() ([]byte, error) {
type Alias Plugin // avoid recursion
cp := *p // shallow copy to avoid mutating original
if cp.Err != nil {
if _, ok := cp.Err.(encoding.TextMarshaler); !ok {
cp.Err = &pluginError{cp.Err}
}
}
return json.Marshal((*Alias)(&cp))
}
// pluginCandidate represents a possible plugin candidate, for mocking purposes.
type pluginCandidate interface {
Path() string
Metadata() ([]byte, error)
}
// newPlugin determines if the given candidate is valid and returns a // newPlugin determines if the given candidate is valid and returns a
// Plugin. If the candidate fails one of the tests then `Plugin.Err` // Plugin. If the candidate fails one of the tests then `Plugin.Err`
// is set, and is always a `pluginError`, but the `Plugin` is still // is set, and is always a `pluginError`, but the `Plugin` is still
// returned with no error. An error is only returned due to a // returned with no error. An error is only returned due to a
// non-recoverable error. // non-recoverable error.
func newPlugin(c pluginCandidate, cmds []*cobra.Command) (Plugin, error) { func newPlugin(c Candidate, cmds []*cobra.Command) (Plugin, error) {
path := c.Path() path := c.Path()
if path == "" { if path == "" {
return Plugin{}, errors.New("plugin candidate path cannot be empty") return Plugin{}, errors.New("plugin candidate path cannot be empty")
@@ -71,24 +41,24 @@ func newPlugin(c pluginCandidate, cmds []*cobra.Command) (Plugin, error) {
// which would fail here, so there are all real errors. // which would fail here, so there are all real errors.
fullname := filepath.Base(path) fullname := filepath.Base(path)
if fullname == "." { if fullname == "." {
return Plugin{}, fmt.Errorf("unable to determine basename of plugin candidate %q", path) return Plugin{}, errors.Errorf("unable to determine basename of plugin candidate %q", path)
} }
var err error var err error
if fullname, err = trimExeSuffix(fullname); err != nil { if fullname, err = trimExeSuffix(fullname); err != nil {
return Plugin{}, fmt.Errorf("plugin candidate %q: %w", path, err) return Plugin{}, errors.Wrapf(err, "plugin candidate %q", path)
} }
if !strings.HasPrefix(fullname, metadata.NamePrefix) { if !strings.HasPrefix(fullname, NamePrefix) {
return Plugin{}, fmt.Errorf("plugin candidate %q: does not have %q prefix", path, metadata.NamePrefix) return Plugin{}, errors.Errorf("plugin candidate %q: does not have %q prefix", path, NamePrefix)
} }
p := Plugin{ p := Plugin{
Name: strings.TrimPrefix(fullname, metadata.NamePrefix), Name: strings.TrimPrefix(fullname, NamePrefix),
Path: path, Path: path,
} }
// Now apply the candidate tests, so these update p.Err. // Now apply the candidate tests, so these update p.Err.
if !isValidPluginName(p.Name) { if !pluginNameRe.MatchString(p.Name) {
p.Err = newPluginError("plugin candidate %q did not match %q", p.Name, pluginNameFormat) p.Err = NewPluginError("plugin candidate %q did not match %q", p.Name, pluginNameRe.String())
return p, nil return p, nil
} }
@@ -100,11 +70,11 @@ func newPlugin(c pluginCandidate, cmds []*cobra.Command) (Plugin, error) {
continue continue
} }
if cmd.Name() == p.Name { if cmd.Name() == p.Name {
p.Err = newPluginError("plugin %q duplicates builtin command", p.Name) p.Err = NewPluginError("plugin %q duplicates builtin command", p.Name)
return p, nil return p, nil
} }
if cmd.HasAlias(p.Name) { if cmd.HasAlias(p.Name) {
p.Err = newPluginError("plugin %q duplicates an alias of builtin command %q", p.Name, cmd.Name()) p.Err = NewPluginError("plugin %q duplicates an alias of builtin command %q", p.Name, cmd.Name())
return p, nil return p, nil
} }
} }
@@ -120,83 +90,13 @@ func newPlugin(c pluginCandidate, cmds []*cobra.Command) (Plugin, error) {
p.Err = wrapAsPluginError(err, "invalid metadata") p.Err = wrapAsPluginError(err, "invalid metadata")
return p, nil return p, nil
} }
if err := validateSchemaVersion(p.Metadata.SchemaVersion); err != nil { if p.Metadata.SchemaVersion != "0.1.0" {
p.Err = &pluginError{cause: err} p.Err = NewPluginError("plugin SchemaVersion %q is not valid, must be 0.1.0", p.Metadata.SchemaVersion)
return p, nil return p, nil
} }
if p.Metadata.Vendor == "" { if p.Metadata.Vendor == "" {
p.Err = newPluginError("plugin metadata does not define a vendor") p.Err = NewPluginError("plugin metadata does not define a vendor")
return p, nil return p, nil
} }
return p, nil return p, nil
} }
// validateSchemaVersion validates if the plugin's schemaVersion is supported.
//
// The current schema-version is "0.1.0", but we don't want to break compatibility
// until v2.0.0 of the schema version. Check for the major version to be < 2.0.0.
//
// Note that CLI versions before 28.4.1 may not support these versions as they were
// hard-coded to only accept "0.1.0".
func validateSchemaVersion(version string) error {
if version == "0.1.0" {
return nil
}
if version == "" {
return errors.New("plugin SchemaVersion version cannot be empty")
}
major, _, ok := strings.Cut(version, ".")
majorVersion, err := strconv.Atoi(major)
if !ok || err != nil {
return fmt.Errorf("plugin SchemaVersion %q has wrong format: must be <major>.<minor>.<patch>", version)
}
if majorVersion > 1 {
return fmt.Errorf("plugin SchemaVersion %q is not supported: must be lower than 2.0.0", version)
}
return nil
}
// RunHook executes the plugin's hooks command
// and returns its unprocessed output.
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")
}
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])
out, err := pCmd.Output()
if err != nil {
if _, ok := errors.AsType[*exec.ExitError](err); ok {
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.
// We should consider making this less technical ("must start with "a-z",
// and only consist of lowercase alphanumeric characters").
const pluginNameFormat = `^[a-z][a-z0-9]*$`
func isValidPluginName(s string) bool {
if len(s) == 0 {
return false
}
// first character must be a-z
if c := s[0]; c < 'a' || c > 'z' {
return false
}
// followed by a-z or 0-9
for i := 1; i < len(s); i++ {
c := s[i]
if (c < 'a' || c > 'z') && (c < '0' || c > '9') {
return false
}
}
return true
}
-43
View File
@@ -1,43 +0,0 @@
package manager
import (
"encoding/json"
"errors"
"testing"
"gotest.tools/v3/assert"
is "gotest.tools/v3/assert/cmp"
)
func TestPluginMarshal(t *testing.T) {
const jsonWithError = `{"Name":"some-plugin","Err":"something went wrong"}`
const jsonNoError = `{"Name":"some-plugin"}`
tests := []struct {
doc string
error error
expected string
}{
{
doc: "no error",
expected: jsonNoError,
},
{
doc: "regular error",
error: errors.New("something went wrong"),
expected: jsonWithError,
},
{
doc: "custom error",
error: newPluginError("something went wrong"),
expected: jsonWithError,
},
}
for _, tc := range tests {
t.Run(tc.doc, func(t *testing.T) {
actual, err := json.Marshal(&Plugin{Name: "some-plugin", Err: tc.error})
assert.NilError(t, err)
assert.Check(t, is.Equal(string(actual), tc.expected))
})
}
}
+10 -4
View File
@@ -1,16 +1,22 @@
package manager package manager
import ( import (
"fmt"
"path/filepath" "path/filepath"
"strings" "strings"
"github.com/pkg/errors"
) )
// This is made slightly more complex due to needing to be case-insensitive. // This is made slightly more complex due to needing to be case insensitive.
func trimExeSuffix(s string) (string, error) { func trimExeSuffix(s string) (string, error) {
ext := filepath.Ext(s) ext := filepath.Ext(s)
if ext == "" || !strings.EqualFold(ext, ".exe") { if ext == "" {
return "", fmt.Errorf("path %q lacks required file extension (.exe)", s) return "", errors.Errorf("path %q lacks required file extension", s)
}
exe := ".exe"
if !strings.EqualFold(ext, exe) {
return "", errors.Errorf("path %q lacks required %q suffix", s, exe)
} }
return strings.TrimSuffix(s, ext), nil return strings.TrimSuffix(s, ext), nil
} }
-85
View File
@@ -1,85 +0,0 @@
package manager
import (
"fmt"
"os"
"strings"
"github.com/docker/cli/cli-plugins/metadata"
"github.com/spf13/cobra"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/baggage"
)
const (
// resourceAttributesEnvVar is the name of the envvar that includes additional
// resource attributes for OTEL as defined in the [OpenTelemetry specification].
//
// [OpenTelemetry specification]: https://opentelemetry.io/docs/specs/otel/configuration/sdk-environment-variables/#general-sdk-configuration
resourceAttributesEnvVar = "OTEL_RESOURCE_ATTRIBUTES"
// dockerCLIAttributePrefix is the prefix for any docker cli OTEL attributes.
//
// It is a copy of the const defined in [command.dockerCLIAttributePrefix].
dockerCLIAttributePrefix = "docker.cli."
cobraCommandPath = attribute.Key("cobra.command_path")
)
func getPluginResourceAttributes(cmd *cobra.Command, plugin Plugin) attribute.Set {
commandPath := cmd.Annotations[metadata.CommandAnnotationPluginCommandPath]
if commandPath == "" {
commandPath = fmt.Sprintf("%s %s", cmd.CommandPath(), plugin.Name)
}
attrSet := attribute.NewSet(
cobraCommandPath.String(commandPath),
)
kvs := make([]attribute.KeyValue, 0, attrSet.Len())
for iter := attrSet.Iter(); iter.Next(); {
attr := iter.Attribute()
kvs = append(kvs, attribute.KeyValue{
Key: dockerCLIAttributePrefix + attr.Key,
Value: attr.Value,
})
}
return attribute.NewSet(kvs...)
}
func appendPluginResourceAttributesEnvvar(env []string, cmd *cobra.Command, plugin Plugin) []string {
if attrs := getPluginResourceAttributes(cmd, plugin); attrs.Len() > 0 {
// Construct baggage members for each of the attributes.
// Ignore any failures as these aren't significant and
// represent an internal issue.
members := make([]baggage.Member, 0, attrs.Len())
for iter := attrs.Iter(); iter.Next(); {
attr := iter.Attribute()
m, err := baggage.NewMemberRaw(string(attr.Key), attr.Value.AsString())
if err != nil {
otel.Handle(err)
continue
}
members = append(members, m)
}
// Combine plugin added resource attributes with ones found in the environment
// variable. Our own attributes should be namespaced so there shouldn't be a
// conflict. We do not parse the environment variable because we do not want
// to handle errors in user configuration.
attrsSlice := make([]string, 0, 2)
if v := strings.TrimSpace(os.Getenv(resourceAttributesEnvVar)); v != "" {
attrsSlice = append(attrsSlice, v)
}
if b, err := baggage.New(members...); err != nil {
otel.Handle(err)
} else if b.Len() > 0 {
attrsSlice = append(attrsSlice, b.String())
}
if len(attrsSlice) > 0 {
env = append(env, resourceAttributesEnvVar+"="+strings.Join(attrsSlice, ","))
}
}
return env
}
-28
View File
@@ -1,28 +0,0 @@
package metadata
const (
// CommandAnnotationPlugin is added to every stub command added by
// AddPluginCommandStubs with the value "true" and so can be
// used to distinguish plugin stubs from regular commands.
CommandAnnotationPlugin = "com.docker.cli.plugin"
// CommandAnnotationPluginVendor is added to every stub command
// added by AddPluginCommandStubs and contains the vendor of
// that plugin.
CommandAnnotationPluginVendor = "com.docker.cli.plugin.vendor"
// CommandAnnotationPluginVersion is added to every stub command
// added by AddPluginCommandStubs and contains the version of
// that plugin.
CommandAnnotationPluginVersion = "com.docker.cli.plugin.version"
// CommandAnnotationPluginInvalid is added to any stub command
// added by AddPluginCommandStubs for an invalid command (that
// is, one which failed it's candidate test) and contains the
// reason for the failure.
CommandAnnotationPluginInvalid = "com.docker.cli.plugin-invalid"
// CommandAnnotationPluginCommandPath is added to overwrite the
// command path for a plugin invocation.
CommandAnnotationPluginCommandPath = "com.docker.cli.plugin.command_path"
)
+42 -86
View File
@@ -1,46 +1,46 @@
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
//go:build go1.26
package plugin package plugin
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"os" "os"
"sync" "sync"
"github.com/docker/cli/cli" "github.com/docker/cli/cli"
"github.com/docker/cli/cli-plugins/metadata" "github.com/docker/cli/cli-plugins/manager"
"github.com/docker/cli/cli-plugins/socket" "github.com/docker/cli/cli-plugins/socket"
"github.com/docker/cli/cli/command" "github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/connhelper" "github.com/docker/cli/cli/connhelper"
"github.com/docker/cli/cli/debug" "github.com/docker/docker/client"
"github.com/moby/moby/client"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"go.opentelemetry.io/otel"
) )
// PersistentPreRunE must be called by any plugin command (or // PersistentPreRunE must be called by any plugin command (or
// subcommand) which uses the cobra `PersistentPreRun*` hook. Plugins // subcommand) which uses the cobra `PersistentPreRun*` hook. Plugins
// which do not make use of `PersistentPreRun*` do not need to call // which do not make use of `PersistentPreRun*` do not need to call
// this (although it remains safe to do so). Plugins are recommended // this (although it remains safe to do so). Plugins are recommended
// to use `PersistentPreRunE` to enable the error to be // to use `PersistenPreRunE` to enable the error to be
// returned. Should not be called outside of a command's // returned. Should not be called outside of a command's
// PersistentPreRunE hook and must not be run unless Run has been // PersistentPreRunE hook and must not be run unless Run has been
// called. // called.
var PersistentPreRunE func(*cobra.Command, []string) error var PersistentPreRunE func(*cobra.Command, []string) error
// RunPlugin executes the specified plugin command // RunPlugin executes the specified plugin command
func RunPlugin(dockerCli *command.DockerCli, plugin *cobra.Command, meta metadata.Metadata) error { func RunPlugin(dockerCli *command.DockerCli, plugin *cobra.Command, meta manager.Metadata) error {
tcmd := newPluginCommand(dockerCli, plugin, meta) tcmd := newPluginCommand(dockerCli, plugin, meta)
var persistentPreRunOnce sync.Once var persistentPreRunOnce sync.Once
PersistentPreRunE = func(cmd *cobra.Command, _ []string) error { PersistentPreRunE = func(cmd *cobra.Command, _ []string) error {
var retErr error var err error
persistentPreRunOnce.Do(func() { persistentPreRunOnce.Do(func() {
ctx, cancel := context.WithCancel(cmd.Context()) cmdContext := cmd.Context()
// TODO: revisit and make sure this check makes sense
// see: https://github.com/docker/cli/pull/4599#discussion_r1422487271
if cmdContext == nil {
cmdContext = context.TODO()
}
ctx, cancel := context.WithCancel(cmdContext)
cmd.SetContext(ctx) cmd.SetContext(ctx)
// Set up the context to cancel based on signalling via CLI socket. // Set up the context to cancel based on signalling via CLI socket.
socket.ConnectAndWait(cancel) socket.ConnectAndWait(cancel)
@@ -49,28 +49,9 @@ func RunPlugin(dockerCli *command.DockerCli, plugin *cobra.Command, meta metadat
if os.Getenv("DOCKER_CLI_PLUGIN_USE_DIAL_STDIO") != "" { if os.Getenv("DOCKER_CLI_PLUGIN_USE_DIAL_STDIO") != "" {
opts = append(opts, withPluginClientConn(plugin.Name())) opts = append(opts, withPluginClientConn(plugin.Name()))
} }
opts = append(opts, command.WithEnableGlobalMeterProvider(), command.WithEnableGlobalTracerProvider()) err = tcmd.Initialize(opts...)
retErr = tcmd.Initialize(opts...)
ogRunE := cmd.RunE
if ogRunE == nil {
ogRun := cmd.Run
// necessary because error will always be nil here
// see: https://github.com/golangci/golangci-lint/issues/1379
//nolint:unparam
ogRunE = func(cmd *cobra.Command, args []string) error {
ogRun(cmd, args)
return nil
}
cmd.Run = nil
}
cmd.RunE = func(cmd *cobra.Command, args []string) error {
stopInstrumentation := dockerCli.StartInstrumentation(cmd)
err := ogRunE(cmd, args)
stopInstrumentation(err)
return err
}
}) })
return retErr return err
} }
cmd, args, err := tcmd.HandleGlobalFlags() cmd, args, err := tcmd.HandleGlobalFlags()
@@ -83,41 +64,37 @@ func RunPlugin(dockerCli *command.DockerCli, plugin *cobra.Command, meta metadat
return cmd.Execute() return cmd.Execute()
} }
// Run is the top-level entry point to the CLI plugin framework. It should // Run is the top-level entry point to the CLI plugin framework. It should be called from your plugin's `main()` function.
// be called from the plugin's "main()" function. It initializes a new func Run(makeCmd func(command.Cli) *cobra.Command, meta manager.Metadata) {
// [command.DockerCli] instance with the given options before calling dockerCli, err := command.NewDockerCli()
// makeCmd to construct the plugin command, then invokes the plugin command
// using [RunPlugin].
func Run(makeCmd func(command.Cli) *cobra.Command, meta metadata.Metadata, ops ...command.CLIOption) {
otel.SetErrorHandler(debug.OTELErrorHandler)
dockerCLI, err := command.NewDockerCli(ops...)
if err != nil { if err != nil {
_, _ = fmt.Fprintln(os.Stderr, err) fmt.Fprintln(os.Stderr, err)
os.Exit(1) os.Exit(1)
} }
plugin := makeCmd(dockerCLI) plugin := makeCmd(dockerCli)
if err := RunPlugin(dockerCLI, plugin, meta); err != nil { if err := RunPlugin(dockerCli, plugin, meta); err != nil {
if stErr, ok := errors.AsType[cli.StatusError](err); ok { if sterr, ok := err.(cli.StatusError); ok {
if sterr.Status != "" {
fmt.Fprintln(dockerCli.Err(), sterr.Status)
}
// StatusError should only be used for errors, and all errors should // StatusError should only be used for errors, and all errors should
// have a non-zero exit status, so never exit with 0 // have a non-zero exit status, so never exit with 0
if stErr.StatusCode == 0 { // FIXME(thaJeztah): this should never be used with a zero status-code. Check if we do this anywhere. if sterr.StatusCode == 0 {
stErr.StatusCode = 1 os.Exit(1)
} }
_, _ = fmt.Fprintln(dockerCLI.Err(), stErr) os.Exit(sterr.StatusCode)
os.Exit(stErr.StatusCode)
} }
_, _ = fmt.Fprintln(dockerCLI.Err(), err) fmt.Fprintln(dockerCli.Err(), err)
os.Exit(1) os.Exit(1)
} }
} }
func withPluginClientConn(name string) command.CLIOption { func withPluginClientConn(name string) command.CLIOption {
return func(cli *command.DockerCli) error { return command.WithInitializeClient(func(dockerCli *command.DockerCli) (client.APIClient, error) {
cmd := "docker" cmd := "docker"
if x := os.Getenv(metadata.ReexecEnvvar); x != "" { if x := os.Getenv(manager.ReexecEnvvar); x != "" {
cmd = x cmd = x
} }
var flags []string var flags []string
@@ -139,19 +116,16 @@ func withPluginClientConn(name string) command.CLIOption {
helper, err := connhelper.GetCommandConnectionHelper(cmd, flags...) helper, err := connhelper.GetCommandConnectionHelper(cmd, flags...)
if err != nil { if err != nil {
return err return nil, err
} }
apiClient, err := client.New(client.WithDialContext(helper.Dialer))
if err != nil { return client.NewClientWithOpts(client.WithDialContext(helper.Dialer))
return err })
}
return command.WithAPIClient(apiClient)(cli)
}
} }
func newPluginCommand(dockerCli *command.DockerCli, plugin *cobra.Command, meta metadata.Metadata) *cli.TopLevelCommand { func newPluginCommand(dockerCli *command.DockerCli, plugin *cobra.Command, meta manager.Metadata) *cli.TopLevelCommand {
name := plugin.Name() name := plugin.Name()
fullname := metadata.NamePrefix + name fullname := manager.NamePrefix + name
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: fmt.Sprintf("docker [OPTIONS] %s [ARG...]", name), Use: fmt.Sprintf("docker [OPTIONS] %s [ARG...]", name),
@@ -167,14 +141,9 @@ func newPluginCommand(dockerCli *command.DockerCli, plugin *cobra.Command, meta
CompletionOptions: cobra.CompletionOptions{ CompletionOptions: cobra.CompletionOptions{
DisableDefaultCmd: false, DisableDefaultCmd: false,
HiddenDefaultCmd: true, HiddenDefaultCmd: true,
DisableDescriptions: os.Getenv("DOCKER_CLI_DISABLE_COMPLETION_DESCRIPTION") != "", DisableDescriptions: true,
}, },
} }
// Disable file-completion by default. Most commands and flags should not
// complete with filenames.
cmd.CompletionOptions.SetDefaultShellCompDirective(cobra.ShellCompDirectiveNoFileComp)
opts, _ := cli.SetupPluginRootCommand(cmd) opts, _ := cli.SetupPluginRootCommand(cmd)
cmd.SetIn(dockerCli.In()) cmd.SetIn(dockerCli.In())
@@ -186,30 +155,17 @@ func newPluginCommand(dockerCli *command.DockerCli, plugin *cobra.Command, meta
newMetadataSubcommand(plugin, meta), newMetadataSubcommand(plugin, meta),
) )
visitAll(cmd, cli.DisableFlagsInUseLine(cmd)
// prevent adding "[flags]" to the end of the usage line.
func(c *cobra.Command) { c.DisableFlagsInUseLine = true },
)
return cli.NewTopLevelCommand(cmd, dockerCli, opts, cmd.Flags()) return cli.NewTopLevelCommand(cmd, dockerCli, opts, cmd.Flags())
} }
// visitAll traverses all commands from the root. func newMetadataSubcommand(plugin *cobra.Command, meta manager.Metadata) *cobra.Command {
func visitAll(root *cobra.Command, fns ...func(*cobra.Command)) {
for _, cmd := range root.Commands() {
visitAll(cmd, fns...)
}
for _, fn := range fns {
fn(root)
}
}
func newMetadataSubcommand(plugin *cobra.Command, meta metadata.Metadata) *cobra.Command {
if meta.ShortDescription == "" { if meta.ShortDescription == "" {
meta.ShortDescription = plugin.Short meta.ShortDescription = plugin.Short
} }
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: metadata.MetadataSubcommandName, Use: manager.MetadataSubcommandName,
Hidden: true, Hidden: true,
// Suppress the global/parent PersistentPreRunE, which // Suppress the global/parent PersistentPreRunE, which
// needlessly initializes the client and tries to // needlessly initializes the client and tries to
@@ -227,8 +183,8 @@ func newMetadataSubcommand(plugin *cobra.Command, meta metadata.Metadata) *cobra
// RunningStandalone tells a CLI plugin it is run standalone by direct execution // RunningStandalone tells a CLI plugin it is run standalone by direct execution
func RunningStandalone() bool { func RunningStandalone() bool {
if os.Getenv(metadata.ReexecEnvvar) != "" { if os.Getenv(manager.ReexecEnvvar) != "" {
return false return false
} }
return len(os.Args) < 2 || os.Args[1] != metadata.MetadataSubcommandName return len(os.Args) < 2 || os.Args[1] != manager.MetadataSubcommandName
} }
-28
View File
@@ -1,28 +0,0 @@
package plugin
import (
"slices"
"testing"
"github.com/spf13/cobra"
)
func TestVisitAll(t *testing.T) {
root := &cobra.Command{Use: "root"}
sub1 := &cobra.Command{Use: "sub1"}
sub1sub1 := &cobra.Command{Use: "sub1sub1"}
sub1sub2 := &cobra.Command{Use: "sub1sub2"}
sub2 := &cobra.Command{Use: "sub2"}
root.AddCommand(sub1, sub2)
sub1.AddCommand(sub1sub1, sub1sub2)
var visited []string
visitAll(root, func(ccmd *cobra.Command) {
visited = append(visited, ccmd.Name())
})
expected := []string{"sub1sub1", "sub1sub2", "sub1", "sub2", "root"}
if !slices.Equal(expected, visited) {
t.Errorf("expected %#v, got %#v", expected, visited)
}
}
+21 -110
View File
@@ -7,125 +7,24 @@ import (
"io" "io"
"net" "net"
"os" "os"
"runtime"
"sync"
"github.com/sirupsen/logrus"
) )
// EnvKey represents the well-known environment variable used to pass the // EnvKey represents the well-known environment variable used to pass the plugin being
// plugin being executed the socket name it should listen on to coordinate with // executed the socket name it should listen on to coordinate with the host CLI.
// the host CLI.
const EnvKey = "DOCKER_CLI_PLUGIN_SOCKET" const EnvKey = "DOCKER_CLI_PLUGIN_SOCKET"
// NewPluginServer creates a plugin server that listens on a new Unix domain // SetupConn sets up a Unix socket listener, establishes a goroutine to handle connections
// socket. h is called for each new connection to the socket in a goroutine. // and update the conn pointer, and returns the listener for the socket (which the caller
func NewPluginServer(h func(net.Conn)) (*PluginServer, error) { // is responsible for closing when it's no longer needed).
// Listen on a Unix socket, with the address being platform-dependent. func SetupConn(conn **net.UnixConn) (*net.UnixListener, error) {
// When a non-abstract address is used, Go will unlink(2) the socket listener, err := listen("docker_cli_" + randomID())
// for us once the listener is closed, as documented in
// [net.UnixListener.SetUnlinkOnClose].
l, err := net.ListenUnix("unix", &net.UnixAddr{
Name: socketName("docker_cli_" + randomID()),
Net: "unix",
})
if err != nil { if err != nil {
return nil, err return nil, err
} }
logrus.Trace("Plugin server listening on ", l.Addr())
if h == nil { accept(listener, conn)
h = func(net.Conn) {}
}
pl := &PluginServer{ return listener, nil
l: l,
h: h,
}
go func() {
defer pl.Close()
for {
err := pl.accept()
if err != nil {
return
}
}
}()
return pl, nil
}
type PluginServer struct {
mu sync.Mutex
conns []net.Conn
l *net.UnixListener
h func(net.Conn)
closed bool
}
func (pl *PluginServer) accept() error {
conn, err := pl.l.Accept()
if err != nil {
return err
}
pl.mu.Lock()
defer pl.mu.Unlock()
if pl.closed {
// Handle potential race between Close and accept.
conn.Close()
return errors.New("plugin server is closed")
}
pl.conns = append(pl.conns, conn)
go pl.h(conn)
return nil
}
// Addr returns the [net.Addr] of the underlying [net.Listener].
func (pl *PluginServer) Addr() net.Addr {
return pl.l.Addr()
}
// Close ensures that the server is no longer accepting new connections and
// closes all existing connections. Existing connections will receive [io.EOF].
//
// The error value is that of the underlying [net.Listener.Close] call.
func (pl *PluginServer) Close() error {
if pl == nil {
return nil
}
logrus.Trace("Closing plugin server")
// Close connections first to ensure the connections get io.EOF instead
// of a connection reset.
pl.closeAllConns()
// Try to ensure that any active connections have a chance to receive
// io.EOF.
runtime.Gosched()
return pl.l.Close()
}
func (pl *PluginServer) closeAllConns() {
pl.mu.Lock()
defer pl.mu.Unlock()
if pl.closed {
return
}
// Prevent new connections from being accepted.
pl.closed = true
for _, conn := range pl.conns {
conn.Close()
}
pl.conns = nil
} }
func randomID() string { func randomID() string {
@@ -136,6 +35,18 @@ func randomID() string {
return hex.EncodeToString(b) return hex.EncodeToString(b)
} }
func accept(listener *net.UnixListener, conn **net.UnixConn) {
go func() {
for {
// ignore error here, if we failed to accept a connection,
// conn is nil and we fallback to previous behavior
*conn, _ = listener.AcceptUnix()
// perform any platform-specific actions on accept (e.g. unlink non-abstract sockets)
onAccept(*conn, listener)
}
}()
}
// ConnectAndWait connects to the socket passed via well-known env var, // ConnectAndWait connects to the socket passed via well-known env var,
// if present, and attempts to read from it until it receives an EOF, at which // if present, and attempts to read from it until it receives an EOF, at which
// point cb is called. // point cb is called.
-9
View File
@@ -1,9 +0,0 @@
//go:build windows || linux
package socket
func socketName(basename string) string {
// Address of an abstract socket -- this socket can be opened by name,
// but is not present in the filesystem.
return "@" + basename
}
+19
View File
@@ -0,0 +1,19 @@
package socket
import (
"net"
"os"
"path/filepath"
"syscall"
)
func listen(socketname string) (*net.UnixListener, error) {
return net.ListenUnix("unix", &net.UnixAddr{
Name: filepath.Join(os.TempDir(), socketname),
Net: "unix",
})
}
func onAccept(conn *net.UnixConn, listener *net.UnixListener) {
syscall.Unlink(listener.Addr().String())
}
-14
View File
@@ -1,14 +0,0 @@
//go:build !windows && !linux
package socket
import (
"os"
"path/filepath"
)
func socketName(basename string) string {
// Because abstract sockets are unavailable, use a socket path in the
// system temporary directory.
return filepath.Join(os.TempDir(), basename)
}
+20
View File
@@ -0,0 +1,20 @@
//go:build !darwin && !openbsd
package socket
import (
"net"
)
func listen(socketname string) (*net.UnixListener, error) {
return net.ListenUnix("unix", &net.UnixAddr{
Name: "@" + socketname,
Net: "unix",
})
}
func onAccept(conn *net.UnixConn, listener *net.UnixListener) {
// do nothing
// while on darwin and OpenBSD we would unlink here;
// on non-darwin the socket is abstract and not present on the filesystem
}
+19
View File
@@ -0,0 +1,19 @@
package socket
import (
"net"
"os"
"path/filepath"
"syscall"
)
func listen(socketname string) (*net.UnixListener, error) {
return net.ListenUnix("unix", &net.UnixAddr{
Name: filepath.Join(os.TempDir(), socketname),
Net: "unix",
})
}
func onAccept(conn *net.UnixConn, listener *net.UnixListener) {
syscall.Unlink(listener.Addr().String())
}
+53 -136
View File
@@ -1,14 +1,11 @@
package socket package socket
import ( import (
"errors"
"io"
"io/fs" "io/fs"
"net" "net"
"os" "os"
"runtime" "runtime"
"strings" "strings"
"sync/atomic"
"testing" "testing"
"time" "time"
@@ -16,122 +13,54 @@ import (
"gotest.tools/v3/poll" "gotest.tools/v3/poll"
) )
func TestPluginServer(t *testing.T) { func TestSetupConn(t *testing.T) {
t.Run("connection closes with EOF when server closes", func(t *testing.T) { t.Run("updates conn when connected", func(t *testing.T) {
called := make(chan struct{}) var conn *net.UnixConn
srv, err := NewPluginServer(func(_ net.Conn) { close(called) }) listener, err := SetupConn(&conn)
assert.NilError(t, err) assert.NilError(t, err)
assert.Assert(t, srv != nil, "returned nil server but no error") assert.Check(t, listener != nil, "returned nil listener but no error")
addr, err := net.ResolveUnixAddr("unix", listener.Addr().String())
assert.NilError(t, err, "failed to resolve listener address")
addr, err := net.ResolveUnixAddr("unix", srv.Addr().String()) _, err = net.DialUnix("unix", nil, addr)
assert.NilError(t, err, "failed to resolve server address") assert.NilError(t, err, "failed to dial returned listener")
conn, err := net.DialUnix("unix", nil, addr) pollConnNotNil(t, &conn)
assert.NilError(t, err, "failed to dial returned server")
defer conn.Close()
done := make(chan error, 1)
go func() {
_, err := conn.Read(make([]byte, 1))
done <- err
}()
select {
case <-called:
case <-time.After(10 * time.Millisecond):
t.Fatal("handler not called")
}
srv.Close()
select {
case err := <-done:
if !errors.Is(err, io.EOF) {
t.Fatalf("expected EOF error, got: %v", err)
}
case <-time.After(10 * time.Millisecond):
}
}) })
t.Run("allows reconnects", func(t *testing.T) { t.Run("allows reconnects", func(t *testing.T) {
var calls atomic.Int32 var conn *net.UnixConn
h := func(_ net.Conn) { listener, err := SetupConn(&conn)
calls.Add(1)
}
srv, err := NewPluginServer(h)
assert.NilError(t, err) assert.NilError(t, err)
defer srv.Close() assert.Check(t, listener != nil, "returned nil listener but no error")
addr, err := net.ResolveUnixAddr("unix", listener.Addr().String())
assert.Check(t, srv.Addr() != nil, "returned nil addr but no error") assert.NilError(t, err, "failed to resolve listener address")
addr, err := net.ResolveUnixAddr("unix", srv.Addr().String())
assert.NilError(t, err, "failed to resolve server address")
waitForCalls := func(n int) {
poll.WaitOn(t, func(t poll.LogT) poll.Result {
if calls.Load() == int32(n) {
return poll.Success()
}
return poll.Continue("waiting for handler to be called")
})
}
otherConn, err := net.DialUnix("unix", nil, addr) otherConn, err := net.DialUnix("unix", nil, addr)
assert.NilError(t, err, "failed to dial returned server") assert.NilError(t, err, "failed to dial returned listener")
otherConn.Close() otherConn.Close()
waitForCalls(1)
conn, err := net.DialUnix("unix", nil, addr) _, err = net.DialUnix("unix", nil, addr)
assert.NilError(t, err, "failed to redial server") assert.NilError(t, err, "failed to redial listener")
defer conn.Close()
waitForCalls(2)
// and again but don't close the existing connection
conn2, err := net.DialUnix("unix", nil, addr)
assert.NilError(t, err, "failed to redial server")
defer conn2.Close()
waitForCalls(3)
srv.Close()
// now make sure we get EOF on the existing connections
buf := make([]byte, 1)
_, err = conn.Read(buf)
assert.ErrorIs(t, err, io.EOF, "expected EOF error, got: %v", err)
_, err = conn2.Read(buf)
assert.ErrorIs(t, err, io.EOF, "expected EOF error, got: %v", err)
}) })
t.Run("does not leak sockets to local directory", func(t *testing.T) { t.Run("does not leak sockets to local directory", func(t *testing.T) {
srv, err := NewPluginServer(nil) var conn *net.UnixConn
listener, err := SetupConn(&conn)
assert.NilError(t, err) assert.NilError(t, err)
assert.Check(t, srv != nil, "returned nil server but no error") assert.Check(t, listener != nil, "returned nil listener but no error")
checkDirNoNewPluginServer(t) checkDirNoPluginSocket(t)
addr, err := net.ResolveUnixAddr("unix", srv.Addr().String())
assert.NilError(t, err, "failed to resolve server address")
addr, err := net.ResolveUnixAddr("unix", listener.Addr().String())
assert.NilError(t, err, "failed to resolve listener address")
_, err = net.DialUnix("unix", nil, addr) _, err = net.DialUnix("unix", nil, addr)
assert.NilError(t, err, "failed to dial returned server") assert.NilError(t, err, "failed to dial returned listener")
checkDirNoNewPluginServer(t) checkDirNoPluginSocket(t)
})
t.Run("does not panic on Close if server is nil", func(t *testing.T) {
var srv *PluginServer
defer func() {
if r := recover(); r != nil {
t.Errorf("panicked on Close")
}
}()
err := srv.Close()
assert.NilError(t, err)
}) })
} }
func checkDirNoNewPluginServer(t *testing.T) { func checkDirNoPluginSocket(t *testing.T) {
t.Helper() t.Helper()
files, err := os.ReadDir(".") files, err := os.ReadDir(".")
@@ -149,24 +78,18 @@ func checkDirNoNewPluginServer(t *testing.T) {
func TestConnectAndWait(t *testing.T) { func TestConnectAndWait(t *testing.T) {
t.Run("calls cancel func on EOF", func(t *testing.T) { t.Run("calls cancel func on EOF", func(t *testing.T) {
srv, err := NewPluginServer(nil) var conn *net.UnixConn
assert.NilError(t, err, "failed to setup server") listener, err := SetupConn(&conn)
defer srv.Close() assert.NilError(t, err, "failed to setup listener")
done := make(chan struct{}) done := make(chan struct{})
t.Setenv(EnvKey, srv.Addr().String()) t.Setenv(EnvKey, listener.Addr().String())
cancelFunc := func() { cancelFunc := func() {
done <- struct{}{} done <- struct{}{}
} }
ConnectAndWait(cancelFunc) ConnectAndWait(cancelFunc)
pollConnNotNil(t, &conn)
select { conn.Close()
case <-done:
t.Fatal("unexpectedly done")
default:
}
srv.Close()
select { select {
case <-done: case <-done:
@@ -178,39 +101,33 @@ func TestConnectAndWait(t *testing.T) {
// TODO: this test cannot be executed with `t.Parallel()`, due to // TODO: this test cannot be executed with `t.Parallel()`, due to
// relying on goroutine numbers to ensure correct behaviour // relying on goroutine numbers to ensure correct behaviour
t.Run("connect goroutine exits after EOF", func(t *testing.T) { t.Run("connect goroutine exits after EOF", func(t *testing.T) {
runtime.LockOSThread() var conn *net.UnixConn
defer runtime.UnlockOSThread() listener, err := SetupConn(&conn)
srv, err := NewPluginServer(nil) assert.NilError(t, err, "failed to setup listener")
assert.NilError(t, err, "failed to setup server") t.Setenv(EnvKey, listener.Addr().String())
defer srv.Close()
t.Setenv(EnvKey, srv.Addr().String())
runtime.Gosched()
numGoroutines := runtime.NumGoroutine() numGoroutines := runtime.NumGoroutine()
ConnectAndWait(func() {}) ConnectAndWait(func() {})
assert.Equal(t, runtime.NumGoroutine(), numGoroutines+1)
runtime.Gosched() pollConnNotNil(t, &conn)
conn.Close()
poll.WaitOn(t, func(t poll.LogT) poll.Result { poll.WaitOn(t, func(t poll.LogT) poll.Result {
// +1 goroutine for the poll.WaitOn
// +1 goroutine for the connect goroutine
if runtime.NumGoroutine() < numGoroutines+1+1 {
return poll.Continue("waiting for connect goroutine to spawn")
}
return poll.Success()
}, poll.WithDelay(1*time.Millisecond), poll.WithTimeout(500*time.Millisecond))
srv.Close()
runtime.Gosched()
poll.WaitOn(t, func(t poll.LogT) poll.Result {
// +1 goroutine for the poll.WaitOn
if runtime.NumGoroutine() > numGoroutines+1 { if runtime.NumGoroutine() > numGoroutines+1 {
return poll.Continue("waiting for connect goroutine to exit") return poll.Continue("waiting for connect goroutine to exit")
} }
return poll.Success() return poll.Success()
}, poll.WithDelay(1*time.Millisecond), poll.WithTimeout(500*time.Millisecond)) }, poll.WithDelay(1*time.Millisecond), poll.WithTimeout(10*time.Millisecond))
}) })
} }
func pollConnNotNil(t *testing.T, conn **net.UnixConn) {
t.Helper()
poll.WaitOn(t, func(t poll.LogT) poll.Result {
if *conn == nil {
return poll.Continue("waiting for conn to not be nil")
}
return poll.Success()
}, poll.WithDelay(1*time.Millisecond), poll.WithTimeout(10*time.Millisecond))
}
+65 -24
View File
@@ -1,20 +1,21 @@
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
//go:build go1.26
package cli package cli
import ( import (
"fmt" "fmt"
"os" "os"
"slices" "path/filepath"
"sort"
"strings" "strings"
"github.com/docker/cli/cli-plugins/metadata" pluginmanager "github.com/docker/cli/cli-plugins/manager"
"github.com/docker/cli/cli/command" "github.com/docker/cli/cli/command"
cliflags "github.com/docker/cli/cli/flags" cliflags "github.com/docker/cli/cli/flags"
"github.com/docker/docker/pkg/homedir"
"github.com/docker/docker/registry"
"github.com/fvbommel/sortorder" "github.com/fvbommel/sortorder"
"github.com/moby/term" "github.com/moby/term"
"github.com/morikuni/aec" "github.com/morikuni/aec"
"github.com/pkg/errors"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/pflag" "github.com/spf13/pflag"
) )
@@ -53,7 +54,7 @@ func setupCommonRootCommand(rootCmd *cobra.Command) (*cliflags.ClientOptions, *c
rootCmd.SetHelpCommand(helpCommand) rootCmd.SetHelpCommand(helpCommand)
rootCmd.PersistentFlags().BoolP("help", "h", false, "Print usage") rootCmd.PersistentFlags().BoolP("help", "h", false, "Print usage")
rootCmd.PersistentFlags().MarkShorthandDeprecated("help", "use --help") rootCmd.PersistentFlags().MarkShorthandDeprecated("help", "please use --help")
rootCmd.PersistentFlags().Lookup("help").Hidden = true rootCmd.PersistentFlags().Lookup("help").Hidden = true
rootCmd.Annotations = map[string]string{ rootCmd.Annotations = map[string]string{
@@ -61,6 +62,13 @@ func setupCommonRootCommand(rootCmd *cobra.Command) (*cliflags.ClientOptions, *c
"docs.code-delimiter": `"`, // https://github.com/docker/cli-docs-tool/blob/77abede22166eaea4af7335096bdcedd043f5b19/annotation/annotation.go#L20-L22 "docs.code-delimiter": `"`, // https://github.com/docker/cli-docs-tool/blob/77abede22166eaea4af7335096bdcedd043f5b19/annotation/annotation.go#L20-L22
} }
// Configure registry.CertsDir() when running in rootless-mode
if os.Getenv("ROOTLESSKIT_STATE_DIR") != "" {
if configHome, err := homedir.GetConfigHome(); err == nil {
registry.SetCertsDir(filepath.Join(configHome, "docker/certs.d"))
}
}
return opts, helpCommand return opts, helpCommand
} }
@@ -84,8 +92,12 @@ func FlagErrorFunc(cmd *cobra.Command, err error) error {
return nil return nil
} }
usage := ""
if cmd.HasSubCommands() {
usage = "\n\n" + cmd.UsageString()
}
return StatusError{ return StatusError{
Status: fmt.Sprintf("%s\n\nUsage: %s\n\nRun '%s --help' for more information", err, cmd.UseLine(), cmd.CommandPath()), Status: fmt.Sprintf("%s\nSee '%s --help'.%s", err, cmd.CommandPath(), usage),
StatusCode: 125, StatusCode: 125,
} }
} }
@@ -169,6 +181,35 @@ func (tcmd *TopLevelCommand) Initialize(ops ...command.CLIOption) error {
return tcmd.dockerCli.Initialize(tcmd.opts, ops...) return tcmd.dockerCli.Initialize(tcmd.opts, ops...)
} }
// VisitAll will traverse all commands from the root.
// This is different from the VisitAll of cobra.Command where only parents
// are checked.
func VisitAll(root *cobra.Command, fn func(*cobra.Command)) {
for _, cmd := range root.Commands() {
VisitAll(cmd, fn)
}
fn(root)
}
// DisableFlagsInUseLine sets the DisableFlagsInUseLine flag on all
// commands within the tree rooted at cmd.
func DisableFlagsInUseLine(cmd *cobra.Command) {
VisitAll(cmd, func(ccmd *cobra.Command) {
// do not add a `[flags]` to the end of the usage line.
ccmd.DisableFlagsInUseLine = true
})
}
// HasCompletionArg returns true if a cobra completion arg request is found.
func HasCompletionArg(args []string) bool {
for _, arg := range args {
if arg == cobra.ShellCompRequestCmd || arg == cobra.ShellCompNoDescRequestCmd {
return true
}
}
return false
}
var helpCommand = &cobra.Command{ var helpCommand = &cobra.Command{
Use: "help [command]", Use: "help [command]",
Short: "Help about the command", Short: "Help about the command",
@@ -177,7 +218,7 @@ var helpCommand = &cobra.Command{
RunE: func(c *cobra.Command, args []string) error { RunE: func(c *cobra.Command, args []string) error {
cmd, args, e := c.Root().Find(args) cmd, args, e := c.Root().Find(args)
if cmd == nil || e != nil || len(args) > 0 { if cmd == nil || e != nil || len(args) > 0 {
return fmt.Errorf("unknown help topic: %v", strings.Join(args, " ")) return errors.Errorf("unknown help topic: %v", strings.Join(args, " "))
} }
helpFunc := cmd.HelpFunc() helpFunc := cmd.HelpFunc()
helpFunc(cmd, args) helpFunc(cmd, args)
@@ -215,7 +256,7 @@ func hasAdditionalHelp(cmd *cobra.Command) bool {
} }
func isPlugin(cmd *cobra.Command) bool { func isPlugin(cmd *cobra.Command) bool {
return cmd.Annotations[metadata.CommandAnnotationPlugin] == "true" return pluginmanager.IsPluginCommand(cmd)
} }
func hasAliases(cmd *cobra.Command) bool { func hasAliases(cmd *cobra.Command) bool {
@@ -253,12 +294,11 @@ func commandAliases(cmd *cobra.Command) string {
if cmd.HasParent() { if cmd.HasParent() {
parentPath = cmd.Parent().CommandPath() + " " parentPath = cmd.Parent().CommandPath() + " "
} }
var aliases strings.Builder aliases := cmd.CommandPath()
aliases.WriteString(cmd.CommandPath())
for _, alias := range cmd.Aliases { for _, alias := range cmd.Aliases {
aliases.WriteString(", " + parentPath + alias) aliases += ", " + parentPath + alias
} }
return aliases.String() return aliases
} }
func topCommands(cmd *cobra.Command) []*cobra.Command { func topCommands(cmd *cobra.Command) []*cobra.Command {
@@ -276,8 +316,8 @@ func topCommands(cmd *cobra.Command) []*cobra.Command {
cmds = append(cmds, sub) cmds = append(cmds, sub)
} }
} }
slices.SortStableFunc(cmds, func(a, b *cobra.Command) int { sort.SliceStable(cmds, func(i, j int) bool {
return sortorder.NaturalCompare(a.Annotations["category-top"], b.Annotations["category-top"]) return sortorder.NaturalLess(cmds[i].Annotations["category-top"], cmds[j].Annotations["category-top"])
}) })
return cmds return cmds
} }
@@ -301,10 +341,8 @@ func operationSubCommands(cmd *cobra.Command) []*cobra.Command {
return cmds return cmds
} }
const defaultTermWidth = 80
func wrappedFlagUsages(cmd *cobra.Command) string { func wrappedFlagUsages(cmd *cobra.Command) string {
width := defaultTermWidth width := 80
if ws, err := term.GetWinsize(0); err == nil { if ws, err := term.GetWinsize(0); err == nil {
width = int(ws.Width) width = int(ws.Width)
} }
@@ -320,9 +358,9 @@ func decoratedName(cmd *cobra.Command) string {
} }
func vendorAndVersion(cmd *cobra.Command) string { func vendorAndVersion(cmd *cobra.Command) string {
if vendor, ok := cmd.Annotations[metadata.CommandAnnotationPluginVendor]; ok && isPlugin(cmd) { if vendor, ok := cmd.Annotations[pluginmanager.CommandAnnotationPluginVendor]; ok && isPlugin(cmd) {
version := "" version := ""
if v, ok := cmd.Annotations[metadata.CommandAnnotationPluginVersion]; ok && v != "" { if v, ok := cmd.Annotations[pluginmanager.CommandAnnotationPluginVersion]; ok && v != "" {
version = ", " + v version = ", " + v
} }
return fmt.Sprintf("(%s%s)", vendor, version) return fmt.Sprintf("(%s%s)", vendor, version)
@@ -354,10 +392,13 @@ func orchestratorSubCommands(cmd *cobra.Command) []*cobra.Command {
func allManagementSubCommands(cmd *cobra.Command) []*cobra.Command { func allManagementSubCommands(cmd *cobra.Command) []*cobra.Command {
cmds := []*cobra.Command{} cmds := []*cobra.Command{}
for _, sub := range cmd.Commands() { for _, sub := range cmd.Commands() {
if invalidPluginReason(sub) != "" { if isPlugin(sub) {
if invalidPluginReason(sub) == "" {
cmds = append(cmds, sub)
}
continue continue
} }
if sub.IsAvailableCommand() && (isPlugin(sub) || sub.HasSubCommands()) { if sub.IsAvailableCommand() && sub.HasSubCommands() {
cmds = append(cmds, sub) cmds = append(cmds, sub)
} }
} }
@@ -378,7 +419,7 @@ func invalidPlugins(cmd *cobra.Command) []*cobra.Command {
} }
func invalidPluginReason(cmd *cobra.Command) string { func invalidPluginReason(cmd *cobra.Command) string {
return cmd.Annotations[metadata.CommandAnnotationPluginInvalid] return cmd.Annotations[pluginmanager.CommandAnnotationPluginInvalid]
} }
const usageTemplate = `Usage: const usageTemplate = `Usage:
@@ -481,4 +522,4 @@ Run '{{.CommandPath}} COMMAND --help' for more information on a command.
` `
const helpTemplate = ` const helpTemplate = `
{{- if or .Runnable .HasSubCommands}}{{.UsageString}}{{end}}` {{if or .Runnable .HasSubCommands}}{{.UsageString}}{{end}}`
+29 -34
View File
@@ -3,13 +3,35 @@ package cli
import ( import (
"testing" "testing"
"github.com/docker/cli/cli-plugins/metadata" pluginmanager "github.com/docker/cli/cli-plugins/manager"
"github.com/google/go-cmp/cmp/cmpopts" "github.com/google/go-cmp/cmp/cmpopts"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"gotest.tools/v3/assert" "gotest.tools/v3/assert"
is "gotest.tools/v3/assert/cmp" is "gotest.tools/v3/assert/cmp"
) )
func TestVisitAll(t *testing.T) {
root := &cobra.Command{Use: "root"}
sub1 := &cobra.Command{Use: "sub1"}
sub1sub1 := &cobra.Command{Use: "sub1sub1"}
sub1sub2 := &cobra.Command{Use: "sub1sub2"}
sub2 := &cobra.Command{Use: "sub2"}
root.AddCommand(sub1, sub2)
sub1.AddCommand(sub1sub1, sub1sub2)
// Take the opportunity to test DisableFlagsInUseLine too
DisableFlagsInUseLine(root)
var visited []string
VisitAll(root, func(ccmd *cobra.Command) {
visited = append(visited, ccmd.Name())
assert.Assert(t, ccmd.DisableFlagsInUseLine, "DisableFlagsInUseLine not set on %q", ccmd.Name())
})
expected := []string{"sub1sub1", "sub1sub2", "sub1", "sub2", "root"}
assert.DeepEqual(t, expected, visited)
}
func TestVendorAndVersion(t *testing.T) { func TestVendorAndVersion(t *testing.T) {
// Non plugin. // Non plugin.
assert.Equal(t, vendorAndVersion(&cobra.Command{Use: "test"}), "") assert.Equal(t, vendorAndVersion(&cobra.Command{Use: "test"}), "")
@@ -27,9 +49,9 @@ func TestVendorAndVersion(t *testing.T) {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "test", Use: "test",
Annotations: map[string]string{ Annotations: map[string]string{
metadata.CommandAnnotationPlugin: "true", pluginmanager.CommandAnnotationPlugin: "true",
metadata.CommandAnnotationPluginVendor: tc.vendor, pluginmanager.CommandAnnotationPluginVendor: tc.vendor,
metadata.CommandAnnotationPluginVersion: tc.version, pluginmanager.CommandAnnotationPluginVersion: tc.version,
}, },
} }
assert.Equal(t, vendorAndVersion(cmd), tc.expected) assert.Equal(t, vendorAndVersion(cmd), tc.expected)
@@ -47,8 +69,8 @@ func TestInvalidPlugin(t *testing.T) {
assert.Assert(t, is.Len(invalidPlugins(root), 0)) assert.Assert(t, is.Len(invalidPlugins(root), 0))
sub1.Annotations = map[string]string{ sub1.Annotations = map[string]string{
metadata.CommandAnnotationPlugin: "true", pluginmanager.CommandAnnotationPlugin: "true",
metadata.CommandAnnotationPluginInvalid: "foo", pluginmanager.CommandAnnotationPluginInvalid: "foo",
} }
root.AddCommand(sub1, sub2) root.AddCommand(sub1, sub2)
sub1.AddCommand(sub1sub1, sub1sub2) sub1.AddCommand(sub1sub1, sub1sub2)
@@ -56,33 +78,6 @@ func TestInvalidPlugin(t *testing.T) {
assert.DeepEqual(t, invalidPlugins(root), []*cobra.Command{sub1}, cmpopts.IgnoreUnexported(cobra.Command{})) assert.DeepEqual(t, invalidPlugins(root), []*cobra.Command{sub1}, cmpopts.IgnoreUnexported(cobra.Command{}))
} }
func TestHiddenPlugin(t *testing.T) {
root := &cobra.Command{Use: "root"}
sub1 := &cobra.Command{
Use: "sub1",
Hidden: true,
Annotations: map[string]string{
metadata.CommandAnnotationPlugin: "true",
},
Run: func(cmd *cobra.Command, args []string) {},
}
sub1sub1 := &cobra.Command{Use: "sub1sub1"}
sub1sub2 := &cobra.Command{Use: "sub1sub2"}
sub2 := &cobra.Command{
Use: "sub2",
Annotations: map[string]string{
metadata.CommandAnnotationPlugin: "true",
},
Run: func(cmd *cobra.Command, args []string) {},
}
root.AddCommand(sub1, sub2)
sub1.AddCommand(sub1sub1, sub1sub2)
assert.DeepEqual(t, allManagementSubCommands(root), []*cobra.Command{sub2}, cmpopts.IgnoreFields(cobra.Command{}, "Run"), cmpopts.IgnoreUnexported(cobra.Command{}))
}
func TestCommandAliases(t *testing.T) { func TestCommandAliases(t *testing.T) {
root := &cobra.Command{Use: "root"} root := &cobra.Command{Use: "root"}
sub := &cobra.Command{Use: "subcommand", Aliases: []string{"alias1", "alias2"}} sub := &cobra.Command{Use: "subcommand", Aliases: []string{"alias1", "alias2"}}
@@ -105,6 +100,6 @@ func TestDecoratedName(t *testing.T) {
topLevelCommand := &cobra.Command{Use: "pluginTopLevelCommand"} topLevelCommand := &cobra.Command{Use: "pluginTopLevelCommand"}
root.AddCommand(topLevelCommand) root.AddCommand(topLevelCommand)
assert.Equal(t, decoratedName(topLevelCommand), "pluginTopLevelCommand ") assert.Equal(t, decoratedName(topLevelCommand), "pluginTopLevelCommand ")
topLevelCommand.Annotations = map[string]string{metadata.CommandAnnotationPlugin: "true"} topLevelCommand.Annotations = map[string]string{pluginmanager.CommandAnnotationPlugin: "true"}
assert.Equal(t, decoratedName(topLevelCommand), "pluginTopLevelCommand*") assert.Equal(t, decoratedName(topLevelCommand), "pluginTopLevelCommand*")
} }
+5 -4
View File
@@ -3,17 +3,18 @@ package builder
import ( import (
"context" "context"
"github.com/moby/moby/client" "github.com/docker/docker/api/types"
"github.com/docker/docker/client"
) )
type fakeClient struct { type fakeClient struct {
client.Client client.Client
builderPruneFunc func(ctx context.Context, opts client.BuildCachePruneOptions) (client.BuildCachePruneResult, error) builderPruneFunc func(ctx context.Context, opts types.BuildCachePruneOptions) (*types.BuildCachePruneReport, error)
} }
func (c *fakeClient) BuildCachePrune(ctx context.Context, opts client.BuildCachePruneOptions) (client.BuildCachePruneResult, error) { func (c *fakeClient) BuildCachePrune(ctx context.Context, opts types.BuildCachePruneOptions) (*types.BuildCachePruneReport, error) {
if c.builderPruneFunc != nil { if c.builderPruneFunc != nil {
return c.builderPruneFunc(ctx, opts) return c.builderPruneFunc(ctx, opts)
} }
return client.BuildCachePruneResult{}, nil return nil, nil
} }
+5 -37
View File
@@ -6,52 +6,20 @@ import (
"github.com/docker/cli/cli" "github.com/docker/cli/cli"
"github.com/docker/cli/cli/command" "github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/command/image" "github.com/docker/cli/cli/command/image"
"github.com/docker/cli/internal/commands"
) )
func init() { // NewBuilderCommand returns a cobra command for `builder` subcommands
commands.Register(newBuilderCommand) func NewBuilderCommand(dockerCli command.Cli) *cobra.Command {
commands.Register(func(c command.Cli) *cobra.Command {
return newBakeStubCommand(c)
})
}
// newBuilderCommand returns a cobra command for `builder` subcommands
func newBuilderCommand(dockerCLI command.Cli) *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "builder", Use: "builder",
Short: "Manage builds", Short: "Manage builds",
Args: cli.NoArgs, Args: cli.NoArgs,
RunE: command.ShowHelp(dockerCLI.Err()), RunE: command.ShowHelp(dockerCli.Err()),
Annotations: map[string]string{"version": "1.31"}, Annotations: map[string]string{"version": "1.31"},
DisableFlagsInUseLine: true,
} }
cmd.AddCommand( cmd.AddCommand(
newPruneCommand(dockerCLI), NewPruneCommand(dockerCli),
// we should have a mechanism for registering sub-commands in the cli/internal/commands.Register function. image.NewBuildCommand(dockerCli),
//nolint:staticcheck // TODO: Remove when migration to cli/internal/commands.Register is complete. (see #6283)
image.NewBuildCommand(dockerCLI),
) )
return cmd return cmd
} }
// newBakeStubCommand returns a cobra command "stub" for the "bake" subcommand.
// This command is a placeholder / stub that is dynamically replaced by an
// alias for "docker buildx bake" if BuildKit is enabled (and the buildx plugin
// installed).
func newBakeStubCommand(dockerCLI command.Streams) *cobra.Command {
return &cobra.Command{
Use: "bake [OPTIONS] [TARGET...]",
Short: "Build from a file",
RunE: command.ShowHelp(dockerCLI.Err()),
Annotations: map[string]string{
// We want to show this command in the "top" category in --help
// output, and not to be grouped under "management commands".
"category-top": "5",
"aliases": "docker buildx bake",
"version": "1.31",
},
DisableFlagsInUseLine: true,
}
}
+26 -58
View File
@@ -2,36 +2,27 @@ package builder
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"strings" "strings"
"github.com/docker/cli/cli" "github.com/docker/cli/cli"
"github.com/docker/cli/cli/command" "github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/command/system/pruner" "github.com/docker/cli/cli/command/completion"
"github.com/docker/cli/internal/prompt"
"github.com/docker/cli/opts" "github.com/docker/cli/opts"
"github.com/docker/go-units" "github.com/docker/docker/api/types"
"github.com/moby/moby/client" units "github.com/docker/go-units"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
func init() {
// Register the prune command to run as part of "docker system prune"
if err := pruner.Register(pruner.TypeBuildCache, pruneFn); err != nil {
panic(err)
}
}
type pruneOptions struct { type pruneOptions struct {
force bool force bool
all bool all bool
filter opts.FilterOpt filter opts.FilterOpt
reservedSpace opts.MemBytes keepStorage opts.MemBytes
} }
// newPruneCommand returns a new cobra prune command for images // NewPruneCommand returns a new cobra prune command for images
func newPruneCommand(dockerCLI command.Cli) *cobra.Command { func NewPruneCommand(dockerCli command.Cli) *cobra.Command {
options := pruneOptions{filter: opts.NewFilterOpt()} options := pruneOptions{filter: opts.NewFilterOpt()}
cmd := &cobra.Command{ cmd := &cobra.Command{
@@ -39,26 +30,25 @@ func newPruneCommand(dockerCLI command.Cli) *cobra.Command {
Short: "Remove build cache", Short: "Remove build cache",
Args: cli.NoArgs, Args: cli.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
spaceReclaimed, output, err := runPrune(cmd.Context(), dockerCLI, options) spaceReclaimed, output, err := runPrune(cmd.Context(), dockerCli, options)
if err != nil { if err != nil {
return err return err
} }
if output != "" { if output != "" {
_, _ = fmt.Fprintln(dockerCLI.Out(), output) fmt.Fprintln(dockerCli.Out(), output)
} }
_, _ = fmt.Fprintln(dockerCLI.Out(), "Total reclaimed space:", units.HumanSize(float64(spaceReclaimed))) fmt.Fprintln(dockerCli.Out(), "Total reclaimed space:", units.HumanSize(float64(spaceReclaimed)))
return nil return nil
}, },
Annotations: map[string]string{"version": "1.39"}, Annotations: map[string]string{"version": "1.39"},
ValidArgsFunction: cobra.NoFileCompletions, ValidArgsFunction: completion.NoComplete,
DisableFlagsInUseLine: true,
} }
flags := cmd.Flags() flags := cmd.Flags()
flags.BoolVarP(&options.force, "force", "f", false, "Do not prompt for confirmation") flags.BoolVarP(&options.force, "force", "f", false, "Do not prompt for confirmation")
flags.BoolVarP(&options.all, "all", "a", false, "Remove all unused build cache, not just dangling ones") flags.BoolVarP(&options.all, "all", "a", false, "Remove all unused build cache, not just dangling ones")
flags.Var(&options.filter, "filter", `Provide filter values (e.g. "until=24h")`) flags.Var(&options.filter, "filter", `Provide filter values (e.g. "until=24h")`)
flags.Var(&options.reservedSpace, "keep-storage", "Amount of disk space to keep for cache") flags.Var(&options.keepStorage, "keep-storage", "Amount of disk space to keep for cache")
return cmd return cmd
} }
@@ -69,31 +59,28 @@ const (
) )
func runPrune(ctx context.Context, dockerCli command.Cli, options pruneOptions) (spaceReclaimed uint64, output string, err error) { func runPrune(ctx context.Context, dockerCli command.Cli, options pruneOptions) (spaceReclaimed uint64, output string, err error) {
pruneFilters := command.PruneFilters(dockerCli, options.filter.Value()) pruneFilters := options.filter.Value()
pruneFilters = command.PruneFilters(dockerCli, pruneFilters)
warning := normalWarning warning := normalWarning
if options.all { if options.all {
warning = allCacheWarning warning = allCacheWarning
} }
if !options.force { if !options.force {
r, err := prompt.Confirm(ctx, dockerCli.In(), dockerCli.Out(), warning) if r, err := command.PromptForConfirmation(ctx, dockerCli.In(), dockerCli.Out(), warning); !r || err != nil {
if err != nil {
return 0, "", err return 0, "", err
} }
if !r {
return 0, "", cancelledErr{errors.New("builder prune has been cancelled")}
}
} }
resp, err := dockerCli.Client().BuildCachePrune(ctx, client.BuildCachePruneOptions{ report, err := dockerCli.Client().BuildCachePrune(ctx, types.BuildCachePruneOptions{
All: options.all, All: options.all,
ReservedSpace: options.reservedSpace.Value(), KeepStorage: options.keepStorage.Value(),
Filters: pruneFilters, Filters: pruneFilters,
}) })
if err != nil { if err != nil {
return 0, "", err return 0, "", err
} }
report := resp.Report
if len(report.CachesDeleted) > 0 { if len(report.CachesDeleted) > 0 {
var sb strings.Builder var sb strings.Builder
sb.WriteString("Deleted build cache objects:\n") sb.WriteString("Deleted build cache objects:\n")
@@ -107,26 +94,7 @@ func runPrune(ctx context.Context, dockerCli command.Cli, options pruneOptions)
return report.SpaceReclaimed, output, nil return report.SpaceReclaimed, output, nil
} }
type cancelledErr struct{ error } // CachePrune executes a prune command for build cache
func CachePrune(ctx context.Context, dockerCli command.Cli, all bool, filter opts.FilterOpt) (uint64, string, error) {
func (cancelledErr) Cancelled() {} return runPrune(ctx, dockerCli, pruneOptions{force: true, all: all, filter: filter})
// pruneFn prunes the build cache for use in "docker system prune" and
// returns the amount of space reclaimed and a detailed output string.
func pruneFn(ctx context.Context, dockerCLI command.Cli, options pruner.PruneOptions) (uint64, string, error) {
if !options.Confirmed {
// Dry-run: perform validation and produce confirmation before pruning.
var confirmMsg string
if options.All {
confirmMsg = "all build cache"
} else {
confirmMsg = "unused build cache"
}
return 0, confirmMsg, cancelledErr{errors.New("builder prune has been cancelled")}
}
return runPrune(ctx, dockerCLI, pruneOptions{
force: true,
all: options.All,
filter: options.Filter,
})
} }
+10 -8
View File
@@ -3,11 +3,12 @@ package builder
import ( import (
"context" "context"
"errors" "errors"
"io"
"testing" "testing"
"github.com/docker/cli/cli/command"
"github.com/docker/cli/internal/test" "github.com/docker/cli/internal/test"
"github.com/moby/moby/client" "github.com/docker/docker/api/types"
"gotest.tools/v3/assert"
) )
func TestBuilderPromptTermination(t *testing.T) { func TestBuilderPromptTermination(t *testing.T) {
@@ -15,12 +16,13 @@ func TestBuilderPromptTermination(t *testing.T) {
t.Cleanup(cancel) t.Cleanup(cancel)
cli := test.NewFakeCli(&fakeClient{ cli := test.NewFakeCli(&fakeClient{
builderPruneFunc: func(ctx context.Context, opts client.BuildCachePruneOptions) (client.BuildCachePruneResult, error) { builderPruneFunc: func(ctx context.Context, opts types.BuildCachePruneOptions) (*types.BuildCachePruneReport, error) {
return client.BuildCachePruneResult{}, errors.New("fakeClient builderPruneFunc should not be called") return nil, errors.New("fakeClient builderPruneFunc should not be called")
}, },
}) })
cmd := newPruneCommand(cli) cmd := NewPruneCommand(cli)
cmd.SetOut(io.Discard) test.TerminatePrompt(ctx, t, cmd, cli, func(t *testing.T, err error) {
cmd.SetErr(io.Discard) t.Helper()
test.TerminatePrompt(ctx, t, cmd, cli) assert.ErrorIs(t, err, command.ErrPromptTerminated)
})
} }
+11 -10
View File
@@ -3,33 +3,34 @@ package checkpoint
import ( import (
"context" "context"
"github.com/moby/moby/client" "github.com/docker/docker/api/types/checkpoint"
"github.com/docker/docker/client"
) )
type fakeClient struct { type fakeClient struct {
client.Client client.Client
checkpointCreateFunc func(container string, options client.CheckpointCreateOptions) (client.CheckpointCreateResult, error) checkpointCreateFunc func(container string, options checkpoint.CreateOptions) error
checkpointDeleteFunc func(container string, options client.CheckpointRemoveOptions) (client.CheckpointRemoveResult, error) checkpointDeleteFunc func(container string, options checkpoint.DeleteOptions) error
checkpointListFunc func(container string, options client.CheckpointListOptions) (client.CheckpointListResult, error) checkpointListFunc func(container string, options checkpoint.ListOptions) ([]checkpoint.Summary, error)
} }
func (cli *fakeClient) CheckpointCreate(_ context.Context, container string, options client.CheckpointCreateOptions) (client.CheckpointCreateResult, error) { func (cli *fakeClient) CheckpointCreate(_ context.Context, container string, options checkpoint.CreateOptions) error {
if cli.checkpointCreateFunc != nil { if cli.checkpointCreateFunc != nil {
return cli.checkpointCreateFunc(container, options) return cli.checkpointCreateFunc(container, options)
} }
return client.CheckpointCreateResult{}, nil return nil
} }
func (cli *fakeClient) CheckpointRemove(_ context.Context, container string, options client.CheckpointRemoveOptions) (client.CheckpointRemoveResult, error) { func (cli *fakeClient) CheckpointDelete(_ context.Context, container string, options checkpoint.DeleteOptions) error {
if cli.checkpointDeleteFunc != nil { if cli.checkpointDeleteFunc != nil {
return cli.checkpointDeleteFunc(container, options) return cli.checkpointDeleteFunc(container, options)
} }
return client.CheckpointRemoveResult{}, nil return nil
} }
func (cli *fakeClient) CheckpointList(_ context.Context, container string, options client.CheckpointListOptions) (client.CheckpointListResult, error) { func (cli *fakeClient) CheckpointList(_ context.Context, container string, options checkpoint.ListOptions) ([]checkpoint.Summary, error) {
if cli.checkpointListFunc != nil { if cli.checkpointListFunc != nil {
return cli.checkpointListFunc(container, options) return cli.checkpointListFunc(container, options)
} }
return client.CheckpointListResult{}, nil return []checkpoint.Summary{}, nil
} }
+6 -12
View File
@@ -3,32 +3,26 @@ package checkpoint
import ( import (
"github.com/docker/cli/cli" "github.com/docker/cli/cli"
"github.com/docker/cli/cli/command" "github.com/docker/cli/cli/command"
"github.com/docker/cli/internal/commands"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
func init() { // NewCheckpointCommand returns the `checkpoint` subcommand (only in experimental)
commands.Register(newCheckpointCommand) func NewCheckpointCommand(dockerCli command.Cli) *cobra.Command {
}
// newCheckpointCommand returns the `checkpoint` subcommand (only in experimental)
func newCheckpointCommand(dockerCLI command.Cli) *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "checkpoint", Use: "checkpoint",
Short: "Manage checkpoints", Short: "Manage checkpoints",
Args: cli.NoArgs, Args: cli.NoArgs,
RunE: command.ShowHelp(dockerCLI.Err()), RunE: command.ShowHelp(dockerCli.Err()),
Annotations: map[string]string{ Annotations: map[string]string{
"experimental": "", "experimental": "",
"ostype": "linux", "ostype": "linux",
"version": "1.25", "version": "1.25",
}, },
DisableFlagsInUseLine: true,
} }
cmd.AddCommand( cmd.AddCommand(
newCreateCommand(dockerCLI), newCreateCommand(dockerCli),
newListCommand(dockerCLI), newListCommand(dockerCli),
newRemoveCommand(dockerCLI), newRemoveCommand(dockerCli),
) )
return cmd return cmd
} }
+8 -8
View File
@@ -6,7 +6,8 @@ import (
"github.com/docker/cli/cli" "github.com/docker/cli/cli"
"github.com/docker/cli/cli/command" "github.com/docker/cli/cli/command"
"github.com/moby/moby/client" "github.com/docker/cli/cli/command/completion"
"github.com/docker/docker/api/types/checkpoint"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -17,7 +18,7 @@ type createOptions struct {
leaveRunning bool leaveRunning bool
} }
func newCreateCommand(dockerCLI command.Cli) *cobra.Command { func newCreateCommand(dockerCli command.Cli) *cobra.Command {
var opts createOptions var opts createOptions
cmd := &cobra.Command{ cmd := &cobra.Command{
@@ -27,10 +28,9 @@ func newCreateCommand(dockerCLI command.Cli) *cobra.Command {
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
opts.container = args[0] opts.container = args[0]
opts.checkpoint = args[1] opts.checkpoint = args[1]
return runCreate(cmd.Context(), dockerCLI, opts) return runCreate(cmd.Context(), dockerCli, opts)
}, },
ValidArgsFunction: cobra.NoFileCompletions, ValidArgsFunction: completion.NoComplete,
DisableFlagsInUseLine: true,
} }
flags := cmd.Flags() flags := cmd.Flags()
@@ -40,8 +40,8 @@ func newCreateCommand(dockerCLI command.Cli) *cobra.Command {
return cmd return cmd
} }
func runCreate(ctx context.Context, dockerCLI command.Cli, opts createOptions) error { func runCreate(ctx context.Context, dockerCli command.Cli, opts createOptions) error {
_, err := dockerCLI.Client().CheckpointCreate(ctx, opts.container, client.CheckpointCreateOptions{ err := dockerCli.Client().CheckpointCreate(ctx, opts.container, checkpoint.CreateOptions{
CheckpointID: opts.checkpoint, CheckpointID: opts.checkpoint,
CheckpointDir: opts.checkpointDir, CheckpointDir: opts.checkpointDir,
Exit: !opts.leaveRunning, Exit: !opts.leaveRunning,
@@ -50,6 +50,6 @@ func runCreate(ctx context.Context, dockerCLI command.Cli, opts createOptions) e
return err return err
} }
_, _ = fmt.Fprintln(dockerCLI.Out(), opts.checkpoint) fmt.Fprintf(dockerCli.Out(), "%s\n", opts.checkpoint)
return nil return nil
} }
+29 -44
View File
@@ -1,14 +1,13 @@
package checkpoint package checkpoint
import ( import (
"errors"
"io" "io"
"strconv"
"strings" "strings"
"testing" "testing"
"github.com/docker/cli/internal/test" "github.com/docker/cli/internal/test"
"github.com/moby/moby/client" "github.com/docker/docker/api/types/checkpoint"
"github.com/pkg/errors"
"gotest.tools/v3/assert" "gotest.tools/v3/assert"
is "gotest.tools/v3/assert/cmp" is "gotest.tools/v3/assert/cmp"
) )
@@ -16,21 +15,21 @@ import (
func TestCheckpointCreateErrors(t *testing.T) { func TestCheckpointCreateErrors(t *testing.T) {
testCases := []struct { testCases := []struct {
args []string args []string
checkpointCreateFunc func(container string, options client.CheckpointCreateOptions) (client.CheckpointCreateResult, error) checkpointCreateFunc func(container string, options checkpoint.CreateOptions) error
expectedError string expectedError string
}{ }{
{ {
args: []string{"too-few-arguments"}, args: []string{"too-few-arguments"},
expectedError: "requires 2 arguments", expectedError: "requires exactly 2 arguments",
}, },
{ {
args: []string{"too", "many", "arguments"}, args: []string{"too", "many", "arguments"},
expectedError: "requires 2 arguments", expectedError: "requires exactly 2 arguments",
}, },
{ {
args: []string{"foo", "bar"}, args: []string{"foo", "bar"},
checkpointCreateFunc: func(container string, options client.CheckpointCreateOptions) (client.CheckpointCreateResult, error) { checkpointCreateFunc: func(container string, options checkpoint.CreateOptions) error {
return client.CheckpointCreateResult{}, errors.New("error creating checkpoint for container foo") return errors.Errorf("error creating checkpoint for container foo")
}, },
expectedError: "error creating checkpoint for container foo", expectedError: "error creating checkpoint for container foo",
}, },
@@ -43,45 +42,31 @@ func TestCheckpointCreateErrors(t *testing.T) {
cmd := newCreateCommand(cli) cmd := newCreateCommand(cli)
cmd.SetArgs(tc.args) cmd.SetArgs(tc.args)
cmd.SetOut(io.Discard) cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
assert.ErrorContains(t, cmd.Execute(), tc.expectedError) assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
} }
} }
func TestCheckpointCreateWithOptions(t *testing.T) { func TestCheckpointCreateWithOptions(t *testing.T) {
const ( var containerID, checkpointID, checkpointDir string
containerName = "container-foo" var exit bool
checkpointName = "checkpoint-bar" cli := test.NewFakeCli(&fakeClient{
checkpointDir = "/dir/foo" checkpointCreateFunc: func(container string, options checkpoint.CreateOptions) error {
) containerID = container
checkpointID = options.CheckpointID
for _, tc := range []bool{true, false} { checkpointDir = options.CheckpointDir
leaveRunning := strconv.FormatBool(tc) exit = options.Exit
t.Run("leave-running="+leaveRunning, func(t *testing.T) { return nil
var actualContainerName string },
var actualOptions client.CheckpointCreateOptions })
cli := test.NewFakeCli(&fakeClient{ cmd := newCreateCommand(cli)
checkpointCreateFunc: func(container string, options client.CheckpointCreateOptions) (client.CheckpointCreateResult, error) { cp := "checkpoint-bar"
actualContainerName = container cmd.SetArgs([]string{"container-foo", cp})
actualOptions = options cmd.Flags().Set("leave-running", "true")
return client.CheckpointCreateResult{}, nil cmd.Flags().Set("checkpoint-dir", "/dir/foo")
}, assert.NilError(t, cmd.Execute())
}) assert.Check(t, is.Equal("container-foo", containerID))
cmd := newCreateCommand(cli) assert.Check(t, is.Equal(cp, checkpointID))
cmd.SetOut(io.Discard) assert.Check(t, is.Equal("/dir/foo", checkpointDir))
cmd.SetErr(io.Discard) assert.Check(t, is.Equal(false, exit))
cmd.SetArgs([]string{containerName, checkpointName}) assert.Check(t, is.Equal(cp, strings.TrimSpace(cli.OutBuffer().String())))
assert.Check(t, cmd.Flags().Set("leave-running", leaveRunning))
assert.Check(t, cmd.Flags().Set("checkpoint-dir", checkpointDir))
assert.NilError(t, cmd.Execute())
assert.Check(t, is.Equal(actualContainerName, containerName))
expected := client.CheckpointCreateOptions{
CheckpointID: checkpointName,
CheckpointDir: checkpointDir,
Exit: !tc,
}
assert.Check(t, is.Equal(actualOptions, expected))
assert.Check(t, is.Equal(strings.TrimSpace(cli.OutBuffer().String()), checkpointName))
})
}
} }
+16 -14
View File
@@ -2,7 +2,7 @@ package checkpoint
import ( import (
"github.com/docker/cli/cli/command/formatter" "github.com/docker/cli/cli/command/formatter"
"github.com/moby/moby/api/types/checkpoint" "github.com/docker/docker/api/types/checkpoint"
) )
const ( const (
@@ -10,31 +10,25 @@ const (
checkpointNameHeader = "CHECKPOINT NAME" checkpointNameHeader = "CHECKPOINT NAME"
) )
// newFormat returns a format for use with a checkpointContext. // NewFormat returns a format for use with a checkpoint Context
func newFormat(source string) formatter.Format { func NewFormat(source string) formatter.Format {
if source == formatter.TableFormatKey { if source == formatter.TableFormatKey {
return defaultCheckpointFormat return defaultCheckpointFormat
} }
return formatter.Format(source) return formatter.Format(source)
} }
// formatWrite writes formatted checkpoints using the Context // FormatWrite writes formatted checkpoints using the Context
func formatWrite(fmtCtx formatter.Context, checkpoints []checkpoint.Summary) error { func FormatWrite(ctx formatter.Context, checkpoints []checkpoint.Summary) error {
cpContext := &checkpointContext{ render := func(format func(subContext formatter.SubContext) error) error {
HeaderContext: formatter.HeaderContext{
Header: formatter.SubHeaderContext{
"Name": checkpointNameHeader,
},
},
}
return fmtCtx.Write(cpContext, func(format func(subContext formatter.SubContext) error) error {
for _, cp := range checkpoints { for _, cp := range checkpoints {
if err := format(&checkpointContext{c: cp}); err != nil { if err := format(&checkpointContext{c: cp}); err != nil {
return err return err
} }
} }
return nil return nil
}) }
return ctx.Write(newCheckpointContext(), render)
} }
type checkpointContext struct { type checkpointContext struct {
@@ -42,6 +36,14 @@ type checkpointContext struct {
c checkpoint.Summary c checkpoint.Summary
} }
func newCheckpointContext() *checkpointContext {
cpCtx := checkpointContext{}
cpCtx.Header = formatter.SubHeaderContext{
"Name": checkpointNameHeader,
}
return &cpCtx
}
func (c *checkpointContext) MarshalJSON() ([]byte, error) { func (c *checkpointContext) MarshalJSON() ([]byte, error) {
return formatter.MarshalJSON(c) return formatter.MarshalJSON(c)
} }
+5 -5
View File
@@ -5,7 +5,7 @@ import (
"testing" "testing"
"github.com/docker/cli/cli/command/formatter" "github.com/docker/cli/cli/command/formatter"
"github.com/moby/moby/api/types/checkpoint" "github.com/docker/docker/api/types/checkpoint"
"gotest.tools/v3/assert" "gotest.tools/v3/assert"
) )
@@ -15,7 +15,7 @@ func TestCheckpointContextFormatWrite(t *testing.T) {
expected string expected string
}{ }{
{ {
formatter.Context{Format: newFormat(defaultCheckpointFormat)}, formatter.Context{Format: NewFormat(defaultCheckpointFormat)},
`CHECKPOINT NAME `CHECKPOINT NAME
checkpoint-1 checkpoint-1
checkpoint-2 checkpoint-2
@@ -23,14 +23,14 @@ checkpoint-3
`, `,
}, },
{ {
formatter.Context{Format: newFormat("{{.Name}}")}, formatter.Context{Format: NewFormat("{{.Name}}")},
`checkpoint-1 `checkpoint-1
checkpoint-2 checkpoint-2
checkpoint-3 checkpoint-3
`, `,
}, },
{ {
formatter.Context{Format: newFormat("{{.Name}}:")}, formatter.Context{Format: NewFormat("{{.Name}}:")},
`checkpoint-1: `checkpoint-1:
checkpoint-2: checkpoint-2:
checkpoint-3: checkpoint-3:
@@ -41,7 +41,7 @@ checkpoint-3:
for _, testcase := range cases { for _, testcase := range cases {
out := bytes.NewBufferString("") out := bytes.NewBufferString("")
testcase.context.Output = out testcase.context.Output = out
err := formatWrite(testcase.context, []checkpoint.Summary{ err := FormatWrite(testcase.context, []checkpoint.Summary{
{Name: "checkpoint-1"}, {Name: "checkpoint-1"},
{Name: "checkpoint-2"}, {Name: "checkpoint-2"},
{Name: "checkpoint-3"}, {Name: "checkpoint-3"},
+9 -10
View File
@@ -7,7 +7,7 @@ import (
"github.com/docker/cli/cli/command" "github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/command/completion" "github.com/docker/cli/cli/command/completion"
"github.com/docker/cli/cli/command/formatter" "github.com/docker/cli/cli/command/formatter"
"github.com/moby/moby/client" "github.com/docker/docker/api/types/checkpoint"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -15,7 +15,7 @@ type listOptions struct {
checkpointDir string checkpointDir string
} }
func newListCommand(dockerCLI command.Cli) *cobra.Command { func newListCommand(dockerCli command.Cli) *cobra.Command {
var opts listOptions var opts listOptions
cmd := &cobra.Command{ cmd := &cobra.Command{
@@ -24,10 +24,9 @@ func newListCommand(dockerCLI command.Cli) *cobra.Command {
Short: "List checkpoints for a container", Short: "List checkpoints for a container",
Args: cli.ExactArgs(1), Args: cli.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
return runList(cmd.Context(), dockerCLI, args[0], opts) return runList(cmd.Context(), dockerCli, args[0], opts)
}, },
ValidArgsFunction: completion.ContainerNames(dockerCLI, false), ValidArgsFunction: completion.ContainerNames(dockerCli, false),
DisableFlagsInUseLine: true,
} }
flags := cmd.Flags() flags := cmd.Flags()
@@ -36,8 +35,8 @@ func newListCommand(dockerCLI command.Cli) *cobra.Command {
return cmd return cmd
} }
func runList(ctx context.Context, dockerCLI command.Cli, container string, opts listOptions) error { func runList(ctx context.Context, dockerCli command.Cli, container string, opts listOptions) error {
checkpoints, err := dockerCLI.Client().CheckpointList(ctx, container, client.CheckpointListOptions{ checkpoints, err := dockerCli.Client().CheckpointList(ctx, container, checkpoint.ListOptions{
CheckpointDir: opts.checkpointDir, CheckpointDir: opts.checkpointDir,
}) })
if err != nil { if err != nil {
@@ -45,8 +44,8 @@ func runList(ctx context.Context, dockerCLI command.Cli, container string, opts
} }
cpCtx := formatter.Context{ cpCtx := formatter.Context{
Output: dockerCLI.Out(), Output: dockerCli.Out(),
Format: newFormat(formatter.TableFormatKey), Format: NewFormat(formatter.TableFormatKey),
} }
return formatWrite(cpCtx, checkpoints.Items) return FormatWrite(cpCtx, checkpoints)
} }
+11 -15
View File
@@ -1,13 +1,12 @@
package checkpoint package checkpoint
import ( import (
"errors"
"io" "io"
"testing" "testing"
"github.com/docker/cli/internal/test" "github.com/docker/cli/internal/test"
"github.com/moby/moby/api/types/checkpoint" "github.com/docker/docker/api/types/checkpoint"
"github.com/moby/moby/client" "github.com/pkg/errors"
"gotest.tools/v3/assert" "gotest.tools/v3/assert"
is "gotest.tools/v3/assert/cmp" is "gotest.tools/v3/assert/cmp"
"gotest.tools/v3/golden" "gotest.tools/v3/golden"
@@ -16,21 +15,21 @@ import (
func TestCheckpointListErrors(t *testing.T) { func TestCheckpointListErrors(t *testing.T) {
testCases := []struct { testCases := []struct {
args []string args []string
checkpointListFunc func(container string, options client.CheckpointListOptions) (client.CheckpointListResult, error) checkpointListFunc func(container string, options checkpoint.ListOptions) ([]checkpoint.Summary, error)
expectedError string expectedError string
}{ }{
{ {
args: []string{}, args: []string{},
expectedError: "requires 1 argument", expectedError: "requires exactly 1 argument",
}, },
{ {
args: []string{"too", "many", "arguments"}, args: []string{"too", "many", "arguments"},
expectedError: "requires 1 argument", expectedError: "requires exactly 1 argument",
}, },
{ {
args: []string{"foo"}, args: []string{"foo"},
checkpointListFunc: func(container string, options client.CheckpointListOptions) (client.CheckpointListResult, error) { checkpointListFunc: func(container string, options checkpoint.ListOptions) ([]checkpoint.Summary, error) {
return client.CheckpointListResult{}, errors.New("error getting checkpoints for container foo") return []checkpoint.Summary{}, errors.Errorf("error getting checkpoints for container foo")
}, },
expectedError: "error getting checkpoints for container foo", expectedError: "error getting checkpoints for container foo",
}, },
@@ -43,7 +42,6 @@ func TestCheckpointListErrors(t *testing.T) {
cmd := newListCommand(cli) cmd := newListCommand(cli)
cmd.SetArgs(tc.args) cmd.SetArgs(tc.args)
cmd.SetOut(io.Discard) cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
assert.ErrorContains(t, cmd.Execute(), tc.expectedError) assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
} }
} }
@@ -51,19 +49,17 @@ func TestCheckpointListErrors(t *testing.T) {
func TestCheckpointListWithOptions(t *testing.T) { func TestCheckpointListWithOptions(t *testing.T) {
var containerID, checkpointDir string var containerID, checkpointDir string
cli := test.NewFakeCli(&fakeClient{ cli := test.NewFakeCli(&fakeClient{
checkpointListFunc: func(container string, options client.CheckpointListOptions) (client.CheckpointListResult, error) { checkpointListFunc: func(container string, options checkpoint.ListOptions) ([]checkpoint.Summary, error) {
containerID = container containerID = container
checkpointDir = options.CheckpointDir checkpointDir = options.CheckpointDir
return client.CheckpointListResult{ return []checkpoint.Summary{
Items: []checkpoint.Summary{ {Name: "checkpoint-foo"},
{Name: "checkpoint-foo"},
},
}, nil }, nil
}, },
}) })
cmd := newListCommand(cli) cmd := newListCommand(cli)
cmd.SetArgs([]string{"container-foo"}) cmd.SetArgs([]string{"container-foo"})
assert.Check(t, cmd.Flags().Set("checkpoint-dir", "/dir/foo")) cmd.Flags().Set("checkpoint-dir", "/dir/foo")
assert.NilError(t, cmd.Execute()) assert.NilError(t, cmd.Execute())
assert.Check(t, is.Equal("container-foo", containerID)) assert.Check(t, is.Equal("container-foo", containerID))
assert.Check(t, is.Equal("/dir/foo", checkpointDir)) assert.Check(t, is.Equal("/dir/foo", checkpointDir))
+12 -9
View File
@@ -1,9 +1,11 @@
package checkpoint package checkpoint
import ( import (
"context"
"github.com/docker/cli/cli" "github.com/docker/cli/cli"
"github.com/docker/cli/cli/command" "github.com/docker/cli/cli/command"
"github.com/moby/moby/client" "github.com/docker/docker/api/types/checkpoint"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -11,7 +13,7 @@ type removeOptions struct {
checkpointDir string checkpointDir string
} }
func newRemoveCommand(dockerCLI command.Cli) *cobra.Command { func newRemoveCommand(dockerCli command.Cli) *cobra.Command {
var opts removeOptions var opts removeOptions
cmd := &cobra.Command{ cmd := &cobra.Command{
@@ -20,14 +22,8 @@ func newRemoveCommand(dockerCLI command.Cli) *cobra.Command {
Short: "Remove a checkpoint", Short: "Remove a checkpoint",
Args: cli.ExactArgs(2), Args: cli.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
containerID, checkpointID := args[0], args[1] return runRemove(cmd.Context(), dockerCli, args[0], args[1], opts)
_, err := dockerCLI.Client().CheckpointRemove(cmd.Context(), containerID, client.CheckpointRemoveOptions{
CheckpointID: checkpointID,
CheckpointDir: opts.checkpointDir,
})
return err
}, },
DisableFlagsInUseLine: true,
} }
flags := cmd.Flags() flags := cmd.Flags()
@@ -35,3 +31,10 @@ func newRemoveCommand(dockerCLI command.Cli) *cobra.Command {
return cmd return cmd
} }
func runRemove(ctx context.Context, dockerCli command.Cli, container string, checkpointID string, opts removeOptions) error {
return dockerCli.Client().CheckpointDelete(ctx, container, checkpoint.DeleteOptions{
CheckpointID: checkpointID,
CheckpointDir: opts.checkpointDir,
})
}
+10 -11
View File
@@ -1,12 +1,12 @@
package checkpoint package checkpoint
import ( import (
"errors"
"io" "io"
"testing" "testing"
"github.com/docker/cli/internal/test" "github.com/docker/cli/internal/test"
"github.com/moby/moby/client" "github.com/docker/docker/api/types/checkpoint"
"github.com/pkg/errors"
"gotest.tools/v3/assert" "gotest.tools/v3/assert"
is "gotest.tools/v3/assert/cmp" is "gotest.tools/v3/assert/cmp"
) )
@@ -14,21 +14,21 @@ import (
func TestCheckpointRemoveErrors(t *testing.T) { func TestCheckpointRemoveErrors(t *testing.T) {
testCases := []struct { testCases := []struct {
args []string args []string
checkpointDeleteFunc func(container string, options client.CheckpointRemoveOptions) (client.CheckpointRemoveResult, error) checkpointDeleteFunc func(container string, options checkpoint.DeleteOptions) error
expectedError string expectedError string
}{ }{
{ {
args: []string{"too-few-arguments"}, args: []string{"too-few-arguments"},
expectedError: "requires 2 arguments", expectedError: "requires exactly 2 arguments",
}, },
{ {
args: []string{"too", "many", "arguments"}, args: []string{"too", "many", "arguments"},
expectedError: "requires 2 arguments", expectedError: "requires exactly 2 arguments",
}, },
{ {
args: []string{"foo", "bar"}, args: []string{"foo", "bar"},
checkpointDeleteFunc: func(container string, options client.CheckpointRemoveOptions) (client.CheckpointRemoveResult, error) { checkpointDeleteFunc: func(container string, options checkpoint.DeleteOptions) error {
return client.CheckpointRemoveResult{}, errors.New("error deleting checkpoint") return errors.Errorf("error deleting checkpoint")
}, },
expectedError: "error deleting checkpoint", expectedError: "error deleting checkpoint",
}, },
@@ -41,7 +41,6 @@ func TestCheckpointRemoveErrors(t *testing.T) {
cmd := newRemoveCommand(cli) cmd := newRemoveCommand(cli)
cmd.SetArgs(tc.args) cmd.SetArgs(tc.args)
cmd.SetOut(io.Discard) cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
assert.ErrorContains(t, cmd.Execute(), tc.expectedError) assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
} }
} }
@@ -49,16 +48,16 @@ func TestCheckpointRemoveErrors(t *testing.T) {
func TestCheckpointRemoveWithOptions(t *testing.T) { func TestCheckpointRemoveWithOptions(t *testing.T) {
var containerID, checkpointID, checkpointDir string var containerID, checkpointID, checkpointDir string
cli := test.NewFakeCli(&fakeClient{ cli := test.NewFakeCli(&fakeClient{
checkpointDeleteFunc: func(container string, options client.CheckpointRemoveOptions) (client.CheckpointRemoveResult, error) { checkpointDeleteFunc: func(container string, options checkpoint.DeleteOptions) error {
containerID = container containerID = container
checkpointID = options.CheckpointID checkpointID = options.CheckpointID
checkpointDir = options.CheckpointDir checkpointDir = options.CheckpointDir
return client.CheckpointRemoveResult{}, nil return nil
}, },
}) })
cmd := newRemoveCommand(cli) cmd := newRemoveCommand(cli)
cmd.SetArgs([]string{"container-foo", "checkpoint-bar"}) cmd.SetArgs([]string{"container-foo", "checkpoint-bar"})
assert.Check(t, cmd.Flags().Set("checkpoint-dir", "/dir/foo")) cmd.Flags().Set("checkpoint-dir", "/dir/foo")
assert.NilError(t, cmd.Execute()) assert.NilError(t, cmd.Execute())
assert.Check(t, is.Equal("container-foo", containerID)) assert.Check(t, is.Equal("container-foo", containerID))
assert.Check(t, is.Equal("checkpoint-bar", checkpointID)) assert.Check(t, is.Equal("checkpoint-bar", checkpointID))
+114 -197
View File
@@ -1,14 +1,14 @@
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16: // FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
//go:build go1.26 //go:build go1.19
package command package command
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"io" "io"
"os" "os"
"path/filepath"
"runtime" "runtime"
"strconv" "strconv"
"sync" "sync"
@@ -21,12 +21,21 @@ import (
"github.com/docker/cli/cli/context/store" "github.com/docker/cli/cli/context/store"
"github.com/docker/cli/cli/debug" "github.com/docker/cli/cli/debug"
cliflags "github.com/docker/cli/cli/flags" cliflags "github.com/docker/cli/cli/flags"
manifeststore "github.com/docker/cli/cli/manifest/store"
registryclient "github.com/docker/cli/cli/registry/client"
"github.com/docker/cli/cli/streams" "github.com/docker/cli/cli/streams"
"github.com/docker/cli/cli/trust"
"github.com/docker/cli/cli/version" "github.com/docker/cli/cli/version"
dopts "github.com/docker/cli/opts" dopts "github.com/docker/cli/opts"
"github.com/moby/moby/api/types/build" "github.com/docker/docker/api"
"github.com/moby/moby/client" "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/registry"
"github.com/docker/docker/api/types/swarm"
"github.com/docker/docker/client"
"github.com/docker/go-connections/tlsconfig"
"github.com/pkg/errors"
"github.com/spf13/cobra" "github.com/spf13/cobra"
notaryclient "github.com/theupdateframework/notary/client"
) )
const defaultInitTimeout = 2 * time.Second const defaultInitTimeout = 2 * time.Second
@@ -35,7 +44,7 @@ const defaultInitTimeout = 2 * time.Second
type Streams interface { type Streams interface {
In() *streams.In In() *streams.In
Out() *streams.Out Out() *streams.Out
Err() *streams.Out Err() io.Writer
} }
// Cli represents the docker command line client. // Cli represents the docker command line client.
@@ -43,44 +52,49 @@ type Cli interface {
Client() client.APIClient Client() client.APIClient
Streams Streams
SetIn(in *streams.In) SetIn(in *streams.In)
config.Provider Apply(ops ...CLIOption) error
ConfigFile() *configfile.ConfigFile
ServerInfo() ServerInfo ServerInfo() ServerInfo
NotaryClient(imgRefAndAuth trust.ImageRefAndAuth, actions []string) (notaryclient.Repository, error)
DefaultVersion() string
CurrentVersion() string CurrentVersion() string
ManifestStore() manifeststore.Store
RegistryClient(bool) registryclient.RegistryClient
ContentTrustEnabled() bool
BuildKitEnabled() (bool, error) BuildKitEnabled() (bool, error)
ContextStore() store.Store ContextStore() store.Store
CurrentContext() string CurrentContext() string
DockerEndpoint() docker.Endpoint DockerEndpoint() docker.Endpoint
TelemetryClient
} }
// DockerCli is an instance the docker command line client. // DockerCli is an instance the docker command line client.
// Instances of the client should be created using the [NewDockerCli] // Instances of the client can be returned from NewDockerCli.
// constructor to make sure they are properly initialized with defaults
// set.
type DockerCli struct { type DockerCli struct {
configFile *configfile.ConfigFile configFile *configfile.ConfigFile
options *cliflags.ClientOptions options *cliflags.ClientOptions
clientOpts []client.Opt
in *streams.In in *streams.In
out *streams.Out out *streams.Out
err *streams.Out err io.Writer
client client.APIClient client client.APIClient
serverInfo ServerInfo serverInfo ServerInfo
contentTrust bool
contextStore store.Store contextStore store.Store
currentContext string currentContext string
init sync.Once init sync.Once
initErr error initErr error
dockerEndpoint docker.Endpoint dockerEndpoint docker.Endpoint
contextStoreConfig *store.Config contextStoreConfig store.Config
initTimeout time.Duration initTimeout time.Duration
res telemetryResource
// baseCtx is the base context used for internal operations. In the future // baseCtx is the base context used for internal operations. In the future
// this may be replaced by explicitly passing a context to functions that // this may be replaced by explicitly passing a context to functions that
// need it. // need it.
baseCtx context.Context baseCtx context.Context
}
enableGlobalMeter, enableGlobalTracer bool // DefaultVersion returns api.defaultVersion.
func (cli *DockerCli) DefaultVersion() string {
return api.DefaultVersion
} }
// CurrentVersion returns the API version currently negotiated, or the default // CurrentVersion returns the API version currently negotiated, or the default
@@ -88,7 +102,7 @@ type DockerCli struct {
func (cli *DockerCli) CurrentVersion() string { func (cli *DockerCli) CurrentVersion() string {
_ = cli.initialize() _ = cli.initialize()
if cli.client == nil { if cli.client == nil {
return client.MaxAPIVersion return api.DefaultVersion
} }
return cli.client.ClientVersion() return cli.client.ClientVersion()
} }
@@ -96,7 +110,7 @@ func (cli *DockerCli) CurrentVersion() string {
// Client returns the APIClient // Client returns the APIClient
func (cli *DockerCli) Client() client.APIClient { func (cli *DockerCli) Client() client.APIClient {
if err := cli.initialize(); err != nil { if err := cli.initialize(); err != nil {
_, _ = fmt.Fprintln(cli.Err(), "Failed to initialize:", err) _, _ = fmt.Fprintf(cli.Err(), "Failed to initialize: %s\n", err)
os.Exit(1) os.Exit(1)
} }
return cli.client return cli.client
@@ -108,7 +122,7 @@ func (cli *DockerCli) Out() *streams.Out {
} }
// Err returns the writer used for stderr // Err returns the writer used for stderr
func (cli *DockerCli) Err() *streams.Out { func (cli *DockerCli) Err() io.Writer {
return cli.err return cli.err
} }
@@ -147,13 +161,19 @@ func (cli *DockerCli) ServerInfo() ServerInfo {
return cli.serverInfo return cli.serverInfo
} }
// ContentTrustEnabled returns whether content trust has been enabled by an
// environment variable.
func (cli *DockerCli) ContentTrustEnabled() bool {
return cli.contentTrust
}
// BuildKitEnabled returns buildkit is enabled or not. // BuildKitEnabled returns buildkit is enabled or not.
func (cli *DockerCli) BuildKitEnabled() (bool, error) { func (cli *DockerCli) BuildKitEnabled() (bool, error) {
// use DOCKER_BUILDKIT env var value if set and not empty // use DOCKER_BUILDKIT env var value if set and not empty
if v := os.Getenv("DOCKER_BUILDKIT"); v != "" { if v := os.Getenv("DOCKER_BUILDKIT"); v != "" {
enabled, err := strconv.ParseBool(v) enabled, err := strconv.ParseBool(v)
if err != nil { if err != nil {
return false, fmt.Errorf("DOCKER_BUILDKIT environment variable expects boolean value: %w", err) return false, errors.Wrap(err, "DOCKER_BUILDKIT environment variable expects boolean value")
} }
return enabled, nil return enabled, nil
} }
@@ -162,48 +182,33 @@ func (cli *DockerCli) BuildKitEnabled() (bool, error) {
if _, ok := aliasMap["builder"]; ok { if _, ok := aliasMap["builder"]; ok {
return true, nil return true, nil
} }
// otherwise, assume BuildKit is enabled but
si := cli.ServerInfo() // not if wcow reported from server side
if si.BuildkitVersion == build.BuilderBuildKit { return cli.ServerInfo().OSType != "windows", nil
// The daemon advertised BuildKit as the preferred builder; this may
// be either a Linux daemon or a Windows daemon with experimental
// BuildKit support enabled.
return true, nil
}
// otherwise, assume BuildKit is enabled for Linux, but disabled for
// Windows / WCOW, which does not yet support BuildKit by default.
return si.OSType != "windows", nil
} }
// HooksEnabled returns whether plugin hooks are enabled. // ManifestStore returns a store for local manifests
func (cli *DockerCli) HooksEnabled() bool { func (cli *DockerCli) ManifestStore() manifeststore.Store {
// use DOCKER_CLI_HOOKS env var value if set and not empty // TODO: support override default location from config file
if v := os.Getenv("DOCKER_CLI_HOOKS"); v != "" { return manifeststore.NewStore(filepath.Join(config.Dir(), "manifests"))
enabled, err := strconv.ParseBool(v) }
if err != nil {
return false // RegistryClient returns a client for communicating with a Docker distribution
} // registry
return enabled func (cli *DockerCli) RegistryClient(allowInsecure bool) registryclient.RegistryClient {
resolver := func(ctx context.Context, index *registry.IndexInfo) registry.AuthConfig {
return ResolveAuthConfig(cli.ConfigFile(), index)
} }
// legacy support DOCKER_CLI_HINTS env var return registryclient.NewRegistryClient(resolver, UserAgent(), allowInsecure)
if v := os.Getenv("DOCKER_CLI_HINTS"); v != "" { }
enabled, err := strconv.ParseBool(v)
if err != nil { // WithInitializeClient is passed to DockerCli.Initialize by callers who wish to set a particular API Client for use by the CLI.
return false func WithInitializeClient(makeClient func(dockerCli *DockerCli) (client.APIClient, error)) CLIOption {
} return func(dockerCli *DockerCli) error {
return enabled var err error
dockerCli.client, err = makeClient(dockerCli)
return err
} }
featuresMap := cli.ConfigFile().Features
if v, ok := featuresMap["hooks"]; ok {
enabled, err := strconv.ParseBool(v)
if err != nil {
return false
}
return enabled
}
// default to false
return false
} }
// Initialize the dockerCli runs initialization that must happen after command // Initialize the dockerCli runs initialization that must happen after command
@@ -224,83 +229,42 @@ func (cli *DockerCli) Initialize(opts *cliflags.ClientOptions, ops ...CLIOption)
debug.Enable() debug.Enable()
} }
if opts.Context != "" && len(opts.Hosts) > 0 { if opts.Context != "" && len(opts.Hosts) > 0 {
return errors.New("conflicting options: cannot specify both --host and --context") return errors.New("conflicting options: either specify --host or --context, not both")
}
if cli.contextStoreConfig == nil {
// This path can be hit when calling Initialize on a DockerCli that's
// not constructed through [NewDockerCli]. Using the default context
// store without a config set will result in Endpoints from contexts
// not being type-mapped correctly, and used as a generic "map[string]any",
// instead of a [docker.EndpointMeta].
//
// When looking up the API endpoint (using [EndpointFromContext]), no
// endpoint will be found, and a default, empty endpoint will be used
// instead which in its turn, causes newAPIClientFromEndpoint to
// be initialized with the default config instead of settings for
// the current context (which may mean; connecting with the wrong
// endpoint and/or TLS Config to be missing).
//
// [EndpointFromContext]: https://github.com/docker/cli/blob/33494921b80fd0b5a06acc3a34fa288de4bb2e6b/cli/context/docker/load.go#L139-L149
if err := WithDefaultContextStoreConfig()(cli); err != nil {
return err
}
} }
cli.options = opts cli.options = opts
cli.configFile = config.LoadDefaultConfigFile(cli.err) cli.configFile = config.LoadDefaultConfigFile(cli.err)
cli.currentContext = resolveContextName(cli.options, cli.configFile) cli.currentContext = resolveContextName(cli.options, cli.configFile)
cli.contextStore = &ContextStoreWithDefault{ cli.contextStore = &ContextStoreWithDefault{
Store: store.New(config.ContextStoreDir(), *cli.contextStoreConfig), Store: store.New(config.ContextStoreDir(), cli.contextStoreConfig),
Resolver: func() (*DefaultContext, error) { Resolver: func() (*DefaultContext, error) {
return resolveDefaultContext(cli.options, *cli.contextStoreConfig) return ResolveDefaultContext(cli.options, cli.contextStoreConfig)
}, },
} }
// TODO(krissetto): pass ctx to the funcs instead of using this
if cli.enableGlobalMeter {
cli.createGlobalMeterProvider(cli.baseCtx)
}
if cli.enableGlobalTracer {
cli.createGlobalTracerProvider(cli.baseCtx)
}
filterResourceAttributesEnvvar()
// early return if GODEBUG is already set or the docker context is
// the default context, i.e. is a virtual context where we won't override
// any GODEBUG values.
if v := os.Getenv("GODEBUG"); cli.currentContext == DefaultContextName || v != "" {
return nil
}
meta, err := cli.contextStore.GetMetadata(cli.currentContext)
if err == nil {
setGoDebug(meta)
}
return nil return nil
} }
// NewAPIClientFromFlags creates a new APIClient from command line flags // NewAPIClientFromFlags creates a new APIClient from command line flags
func NewAPIClientFromFlags(opts *cliflags.ClientOptions, configFile *configfile.ConfigFile) (client.APIClient, error) { func NewAPIClientFromFlags(opts *cliflags.ClientOptions, configFile *configfile.ConfigFile) (client.APIClient, error) {
if opts.Context != "" && len(opts.Hosts) > 0 { if opts.Context != "" && len(opts.Hosts) > 0 {
return nil, errors.New("conflicting options: cannot specify both --host and --context") return nil, errors.New("conflicting options: either specify --host or --context, not both")
} }
storeConfig := DefaultContextStoreConfig() storeConfig := DefaultContextStoreConfig()
contextStore := &ContextStoreWithDefault{ contextStore := &ContextStoreWithDefault{
Store: store.New(config.ContextStoreDir(), storeConfig), Store: store.New(config.ContextStoreDir(), storeConfig),
Resolver: func() (*DefaultContext, error) { Resolver: func() (*DefaultContext, error) {
return resolveDefaultContext(opts, storeConfig) return ResolveDefaultContext(opts, storeConfig)
}, },
} }
endpoint, err := resolveDockerEndpoint(contextStore, resolveContextName(opts, configFile)) endpoint, err := resolveDockerEndpoint(contextStore, resolveContextName(opts, configFile))
if err != nil { if err != nil {
return nil, fmt.Errorf("unable to resolve docker endpoint: %w", err) return nil, errors.Wrap(err, "unable to resolve docker endpoint")
} }
return newAPIClientFromEndpoint(endpoint, configFile, client.WithUserAgent(UserAgent())) return newAPIClientFromEndpoint(endpoint, configFile)
} }
func newAPIClientFromEndpoint(ep docker.Endpoint, configFile *configfile.ConfigFile, extraOpts ...client.Opt) (client.APIClient, error) { func newAPIClientFromEndpoint(ep docker.Endpoint, configFile *configfile.ConfigFile) (client.APIClient, error) {
opts, err := ep.ClientOpts() opts, err := ep.ClientOpts()
if err != nil { if err != nil {
return nil, err return nil, err
@@ -308,24 +272,13 @@ func newAPIClientFromEndpoint(ep docker.Endpoint, configFile *configfile.ConfigF
if len(configFile.HTTPHeaders) > 0 { if len(configFile.HTTPHeaders) > 0 {
opts = append(opts, client.WithHTTPHeaders(configFile.HTTPHeaders)) opts = append(opts, client.WithHTTPHeaders(configFile.HTTPHeaders))
} }
withCustomHeaders, err := withCustomHeadersFromEnv() opts = append(opts, client.WithUserAgent(UserAgent()))
if err != nil { return client.NewClientWithOpts(opts...)
return nil, err
}
if withCustomHeaders != nil {
opts = append(opts, withCustomHeaders)
}
opts = append(opts, extraOpts...)
apiClient, err := client.New(opts...)
if err != nil {
return nil, err
}
return apiClient, nil
} }
func resolveDockerEndpoint(s store.Reader, contextName string) (docker.Endpoint, error) { func resolveDockerEndpoint(s store.Reader, contextName string) (docker.Endpoint, error) {
if s == nil { if s == nil {
return docker.Endpoint{}, errors.New("no context store initialized") return docker.Endpoint{}, fmt.Errorf("no context store initialized")
} }
ctxMeta, err := s.GetMetadata(contextName) ctxMeta, err := s.GetMetadata(contextName)
if err != nil { if err != nil {
@@ -340,10 +293,7 @@ func resolveDockerEndpoint(s store.Reader, contextName string) (docker.Endpoint,
// Resolve the Docker endpoint for the default context (based on config, env vars and CLI flags) // Resolve the Docker endpoint for the default context (based on config, env vars and CLI flags)
func resolveDefaultDockerEndpoint(opts *cliflags.ClientOptions) (docker.Endpoint, error) { func resolveDefaultDockerEndpoint(opts *cliflags.ClientOptions) (docker.Endpoint, error) {
// defaultToTLS determines whether we should use a TLS host as default host, err := getServerHost(opts.Hosts, opts.TLSOptions)
// if nothing was configured by the user.
defaultToTLS := opts.TLSOptions != nil
host, err := getServerHost(opts.Hosts, defaultToTLS)
if err != nil { if err != nil {
return docker.Endpoint{}, err return docker.Endpoint{}, err
} }
@@ -381,21 +331,29 @@ func (cli *DockerCli) initializeFromClient() {
ctx, cancel := context.WithTimeout(cli.baseCtx, cli.getInitTimeout()) ctx, cancel := context.WithTimeout(cli.baseCtx, cli.getInitTimeout())
defer cancel() defer cancel()
ping, err := cli.client.Ping(ctx, client.PingOptions{ ping, err := cli.client.Ping(ctx)
NegotiateAPIVersion: true,
ForceNegotiate: true,
})
if err != nil { if err != nil {
// Default to true if we fail to connect to daemon // Default to true if we fail to connect to daemon
cli.serverInfo = ServerInfo{HasExperimental: true} cli.serverInfo = ServerInfo{HasExperimental: true}
if ping.APIVersion != "" {
cli.client.NegotiateAPIVersionPing(ping)
}
return return
} }
cli.serverInfo = ServerInfo{ cli.serverInfo = ServerInfo{
HasExperimental: ping.Experimental, HasExperimental: ping.Experimental,
OSType: ping.OSType, OSType: ping.OSType,
BuildkitVersion: ping.BuilderVersion, BuildkitVersion: ping.BuilderVersion,
SwarmStatus: ping.SwarmStatus, SwarmStatus: ping.SwarmStatus,
} }
cli.client.NegotiateAPIVersionPing(ping)
}
// NotaryClient provides a Notary Repository to interact with signed metadata for an image
func (cli *DockerCli) NotaryClient(imgRefAndAuth trust.ImageRefAndAuth, actions []string) (notaryclient.Repository, error) {
return trust.GetNotaryRepository(cli.In(), cli.Out(), UserAgent(), imgRefAndAuth.RepoInfo(), imgRefAndAuth.AuthConfig(), actions...)
} }
// ContextStore returns the ContextStore // ContextStore returns the ContextStore
@@ -465,7 +423,7 @@ func (cli *DockerCli) DockerEndpoint() docker.Endpoint {
if err := cli.initialize(); err != nil { if err := cli.initialize(); err != nil {
// Note that we're not terminating here, as this function may be used // Note that we're not terminating here, as this function may be used
// in cases where we're able to continue. // in cases where we're able to continue.
_, _ = fmt.Fprintln(cli.Err(), cli.initErr) _, _ = fmt.Fprintf(cli.Err(), "%v\n", cli.initErr)
} }
return cli.dockerEndpoint return cli.dockerEndpoint
} }
@@ -478,66 +436,15 @@ func (cli *DockerCli) getDockerEndPoint() (ep docker.Endpoint, err error) {
return resolveDockerEndpoint(cli.contextStore, cn) return resolveDockerEndpoint(cli.contextStore, cn)
} }
// setGoDebug is an escape hatch that sets the GODEBUG environment
// variable value using docker context metadata.
//
// {
// "Name": "my-context",
// "Metadata": { "GODEBUG": "x509negativeserial=1" }
// }
//
// WARNING: Setting x509negativeserial=1 allows Go's x509 library to accept
// X.509 certificates with negative serial numbers.
// This behavior is deprecated and non-compliant with current security
// standards (RFC 5280). Accepting negative serial numbers can introduce
// serious security vulnerabilities, including the risk of certificate
// collision or bypass attacks.
// This option should only be used for legacy compatibility and never in
// production environments.
// Use at your own risk.
func setGoDebug(meta store.Metadata) {
fieldName := "GODEBUG"
godebugEnv := os.Getenv(fieldName)
// early return if GODEBUG is already set. We don't want to override what
// the user already sets.
if godebugEnv != "" {
return
}
var cfg any
var ok bool
switch m := meta.Metadata.(type) {
case DockerContext:
cfg, ok = m.AdditionalFields[fieldName]
if !ok {
return
}
case map[string]any:
cfg, ok = m[fieldName]
if !ok {
return
}
default:
return
}
v, ok := cfg.(string)
if !ok {
return
}
// set the GODEBUG environment variable with whatever was in the context
_ = os.Setenv(fieldName, v)
}
func (cli *DockerCli) initialize() error { func (cli *DockerCli) initialize() error {
cli.init.Do(func() { cli.init.Do(func() {
cli.dockerEndpoint, cli.initErr = cli.getDockerEndPoint() cli.dockerEndpoint, cli.initErr = cli.getDockerEndPoint()
if cli.initErr != nil { if cli.initErr != nil {
cli.initErr = fmt.Errorf("unable to resolve docker endpoint: %w", cli.initErr) cli.initErr = errors.Wrap(cli.initErr, "unable to resolve docker endpoint")
return return
} }
if cli.client == nil { if cli.client == nil {
if cli.client, cli.initErr = newAPIClientFromEndpoint(cli.dockerEndpoint, cli.configFile, cli.clientOpts...); cli.initErr != nil { if cli.client, cli.initErr = newAPIClientFromEndpoint(cli.dockerEndpoint, cli.configFile); cli.initErr != nil {
return return
} }
} }
@@ -549,12 +456,22 @@ func (cli *DockerCli) initialize() error {
return cli.initErr return cli.initErr
} }
// Apply all the operation on the cli
func (cli *DockerCli) Apply(ops ...CLIOption) error {
for _, op := range ops {
if err := op(cli); err != nil {
return err
}
}
return nil
}
// ServerInfo stores details about the supported features and platform of the // ServerInfo stores details about the supported features and platform of the
// server // server
type ServerInfo struct { type ServerInfo struct {
HasExperimental bool HasExperimental bool
OSType string OSType string
BuildkitVersion build.BuilderVersion BuildkitVersion types.BuilderVersion
// SwarmStatus provides information about the current swarm status of the // SwarmStatus provides information about the current swarm status of the
// engine, obtained from the "Swarm" header in the API response. // engine, obtained from the "Swarm" header in the API response.
@@ -563,42 +480,42 @@ type ServerInfo struct {
// in the ping response, or if an error occurred, in which case the client // in the ping response, or if an error occurred, in which case the client
// should use other ways to get the current swarm status, such as the /swarm // should use other ways to get the current swarm status, such as the /swarm
// endpoint. // endpoint.
SwarmStatus *client.SwarmStatus SwarmStatus *swarm.Status
} }
// NewDockerCli returns a DockerCli instance with all operators applied on it. // NewDockerCli returns a DockerCli instance with all operators applied on it.
// It applies by default the standard streams, and the content trust from // It applies by default the standard streams, and the content trust from
// environment. // environment.
func NewDockerCli(ops ...CLIOption) (*DockerCli, error) { func NewDockerCli(ops ...CLIOption) (*DockerCli, error) {
defaultOps := make([]CLIOption, 0, 3+len(ops)) defaultOps := []CLIOption{
defaultOps = append(defaultOps, WithContentTrustFromEnv(),
WithDefaultContextStoreConfig(), WithDefaultContextStoreConfig(),
WithStandardStreams(), WithStandardStreams(),
WithUserAgent(UserAgent()), }
)
ops = append(defaultOps, ops...) ops = append(defaultOps, ops...)
cli := &DockerCli{baseCtx: context.Background()} cli := &DockerCli{baseCtx: context.Background()}
for _, op := range ops { if err := cli.Apply(ops...); err != nil {
if err := op(cli); err != nil { return nil, err
return nil, err
}
} }
return cli, nil return cli, nil
} }
func getServerHost(hosts []string, defaultToTLS bool) (string, error) { func getServerHost(hosts []string, tlsOptions *tlsconfig.Options) (string, error) {
var host string
switch len(hosts) { switch len(hosts) {
case 0: case 0:
return dopts.ParseHost(defaultToTLS, os.Getenv(client.EnvOverrideHost)) host = os.Getenv(client.EnvOverrideHost)
case 1: case 1:
return dopts.ParseHost(defaultToTLS, hosts[0]) host = hosts[0]
default: default:
return "", errors.New("specify only one -H") return "", errors.New("Please specify only one -H")
} }
return dopts.ParseHost(tlsOptions != nil, host)
} }
// UserAgent returns the default user agent string used for making API requests. // UserAgent returns the user agent string used for making API requests
func UserAgent() string { func UserAgent() string {
return "Docker-Client/" + version.Version + " (" + runtime.GOOS + ")" return "Docker-Client/" + version.Version + " (" + runtime.GOOS + ")"
} }
+29 -157
View File
@@ -2,16 +2,12 @@ package command
import ( import (
"context" "context"
"encoding/csv"
"errors"
"fmt"
"io" "io"
"net/http"
"os" "os"
"strings" "strconv"
"github.com/docker/cli/cli/streams" "github.com/docker/cli/cli/streams"
"github.com/moby/moby/client" "github.com/docker/docker/client"
"github.com/moby/term" "github.com/moby/term"
) )
@@ -27,7 +23,7 @@ func WithStandardStreams() CLIOption {
stdin, stdout, stderr := term.StdStreams() stdin, stdout, stderr := term.StdStreams()
cli.in = streams.NewIn(stdin) cli.in = streams.NewIn(stdin)
cli.out = streams.NewOut(stdout) cli.out = streams.NewOut(stdout)
cli.err = streams.NewOut(stderr) cli.err = stderr
return nil return nil
} }
} }
@@ -44,9 +40,8 @@ func WithBaseContext(ctx context.Context) CLIOption {
// WithCombinedStreams uses the same stream for the output and error streams. // WithCombinedStreams uses the same stream for the output and error streams.
func WithCombinedStreams(combined io.Writer) CLIOption { func WithCombinedStreams(combined io.Writer) CLIOption {
return func(cli *DockerCli) error { return func(cli *DockerCli) error {
s := streams.NewOut(combined) cli.out = streams.NewOut(combined)
cli.out = s cli.err = combined
cli.err = s
return nil return nil
} }
} }
@@ -70,7 +65,29 @@ func WithOutputStream(out io.Writer) CLIOption {
// WithErrorStream sets a cli error stream. // WithErrorStream sets a cli error stream.
func WithErrorStream(err io.Writer) CLIOption { func WithErrorStream(err io.Writer) CLIOption {
return func(cli *DockerCli) error { return func(cli *DockerCli) error {
cli.err = streams.NewOut(err) cli.err = err
return nil
}
}
// WithContentTrustFromEnv enables content trust on a cli from environment variable DOCKER_CONTENT_TRUST value.
func WithContentTrustFromEnv() CLIOption {
return func(cli *DockerCli) error {
cli.contentTrust = false
if e := os.Getenv("DOCKER_CONTENT_TRUST"); e != "" {
if t, err := strconv.ParseBool(e); t || err != nil {
// treat any other value as true
cli.contentTrust = true
}
}
return nil
}
}
// WithContentTrust enables content trust on a cli.
func WithContentTrust(enabled bool) CLIOption {
return func(cli *DockerCli) error {
cli.contentTrust = enabled
return nil return nil
} }
} }
@@ -78,8 +95,7 @@ func WithErrorStream(err io.Writer) CLIOption {
// WithDefaultContextStoreConfig configures the cli to use the default context store configuration. // WithDefaultContextStoreConfig configures the cli to use the default context store configuration.
func WithDefaultContextStoreConfig() CLIOption { func WithDefaultContextStoreConfig() CLIOption {
return func(cli *DockerCli) error { return func(cli *DockerCli) error {
cfg := DefaultContextStoreConfig() cli.contextStoreConfig = DefaultContextStoreConfig()
cli.contextStoreConfig = &cfg
return nil return nil
} }
} }
@@ -91,147 +107,3 @@ func WithAPIClient(c client.APIClient) CLIOption {
return nil return nil
} }
} }
// WithInitializeClient is passed to [DockerCli.Initialize] to initialize
// an API Client for use by the CLI.
func WithInitializeClient(makeClient func(*DockerCli) (client.APIClient, error)) CLIOption {
return func(cli *DockerCli) error {
c, err := makeClient(cli)
if err != nil {
return err
}
return WithAPIClient(c)(cli)
}
}
// WithAPIClientOptions configures additional [client.Opt] to use when
// initializing the API client. These options have no effect if a custom
// client is set (through [WithAPIClient] or [WithInitializeClient]).
func WithAPIClientOptions(c ...client.Opt) CLIOption {
return func(cli *DockerCli) error {
cli.clientOpts = append(cli.clientOpts, c...)
return nil
}
}
// envOverrideHTTPHeaders is the name of the environment-variable that can be
// used to set custom HTTP headers to be sent by the client. This environment
// variable is the equivalent to the HttpHeaders field in the configuration
// file.
//
// WARNING: If both config and environment-variable are set, the environment
// variable currently overrides all headers set in the configuration file.
// This behavior may change in a future update, as we are considering the
// environment variable to be appending to existing headers (and to only
// override headers with the same name).
//
// While this env-var allows for custom headers to be set, it does not allow
// for built-in headers (such as "User-Agent", if set) to be overridden.
// Also see [client.WithHTTPHeaders] and [client.WithUserAgent].
//
// This environment variable can be used in situations where headers must be
// set for a specific invocation of the CLI, but should not be set by default,
// and therefore cannot be set in the config-file.
//
// envOverrideHTTPHeaders accepts a comma-separated (CSV) list of key=value pairs,
// where key must be a non-empty, valid MIME header format. Whitespaces surrounding
// the key are trimmed, and the key is normalised. Whitespaces in values are
// preserved, but "key=value" pairs with an empty value (e.g. "key=") are ignored.
// Tuples without a "=" produce an error.
//
// It follows CSV rules for escaping, allowing "key=value" pairs to be quoted
// if they must contain commas, which allows for multiple values for a single
// header to be set. If a key is repeated in the list, later values override
// prior values.
//
// For example, the following value:
//
// one=one-value,"two=two,value","three= a value with whitespace ",four=,five=five=one,five=five-two
//
// Produces four headers (four is omitted as it has an empty value set):
//
// - one (value is "one-value")
// - two (value is "two,value")
// - three (value is " a value with whitespace ")
// - five (value is "five-two", the later value has overridden the prior value)
const envOverrideHTTPHeaders = "DOCKER_CUSTOM_HEADERS"
// withCustomHeadersFromEnv overriding custom HTTP headers to be sent by the
// client through the [envOverrideHTTPHeaders] environment-variable. This
// environment variable is the equivalent to the HttpHeaders field in the
// configuration file.
//
// WARNING: If both config and environment-variable are set, the environment-
// variable currently overrides all headers set in the configuration file.
// This behavior may change in a future update, as we are considering the
// environment-variable to be appending to existing headers (and to only
// override headers with the same name).
//
// TODO(thaJeztah): this is a client Option, and should be moved to the client. It is non-exported for that reason.
func withCustomHeadersFromEnv() (client.Opt, error) {
value := os.Getenv(envOverrideHTTPHeaders)
if value == "" {
return nil, nil
}
csvReader := csv.NewReader(strings.NewReader(value))
fields, err := csvReader.Read()
if err != nil {
return nil, invalidParameter(fmt.Errorf(
"failed to parse custom headers from %s environment variable: value must be formatted as comma-separated key=value pairs",
envOverrideHTTPHeaders,
))
}
if len(fields) == 0 {
return nil, nil
}
env := map[string]string{}
for _, kv := range fields {
k, v, hasValue := strings.Cut(kv, "=")
// Only strip whitespace in keys; preserve whitespace in values.
k = strings.TrimSpace(k)
if k == "" {
return nil, invalidParameter(fmt.Errorf(
`failed to set custom headers from %s environment variable: value contains a key=value pair with an empty key: '%s'`,
envOverrideHTTPHeaders, kv,
))
}
// We don't currently allow empty key=value pairs, and produce an error.
// This is something we could allow in future (e.g. to read value
// from an environment variable with the same name). In the meantime,
// produce an error to prevent users from depending on this.
if !hasValue {
return nil, invalidParameter(fmt.Errorf(
`failed to set custom headers from %s environment variable: missing "=" in key=value pair: '%s'`,
envOverrideHTTPHeaders, kv,
))
}
env[http.CanonicalHeaderKey(k)] = v
}
if len(env) == 0 {
// We should probably not hit this case, as we don't skip values
// (only return errors), but we don't want to discard existing
// headers with an empty set.
return nil, nil
}
// TODO(thaJeztah): add a client.WithExtraHTTPHeaders() function to allow these headers to be _added_ to existing ones, instead of _replacing_
// see https://github.com/docker/cli/pull/5098#issuecomment-2147403871 (when updating, also update the WARNING in the function and env-var GoDoc)
return client.WithHTTPHeaders(env), nil
}
// WithUserAgent configures the User-Agent string for cli HTTP requests.
func WithUserAgent(userAgent string) CLIOption {
return func(cli *DockerCli) error {
if userAgent == "" {
return errors.New("user agent cannot be blank")
}
cli.clientOpts = append(cli.clientOpts, client.WithUserAgent(userAgent))
return nil
}
}
+28
View File
@@ -0,0 +1,28 @@
package command
import (
"os"
"testing"
"gotest.tools/v3/assert"
)
func contentTrustEnabled(t *testing.T) bool {
t.Helper()
var cli DockerCli
assert.NilError(t, WithContentTrustFromEnv()(&cli))
return cli.contentTrust
}
// NB: Do not t.Parallel() this test -- it messes with the process environment.
func TestWithContentTrustFromEnv(t *testing.T) {
const envvar = "DOCKER_CONTENT_TRUST"
t.Setenv(envvar, "true")
assert.Check(t, contentTrustEnabled(t))
t.Setenv(envvar, "false")
assert.Check(t, !contentTrustEnabled(t))
t.Setenv(envvar, "invalid")
assert.Check(t, contentTrustEnabled(t))
os.Unsetenv(envvar)
assert.Check(t, !contentTrustEnabled(t))
}
+113 -249
View File
@@ -3,7 +3,6 @@ package command
import ( import (
"bytes" "bytes"
"context" "context"
"errors"
"fmt" "fmt"
"io" "io"
"net" "net"
@@ -18,10 +17,13 @@ import (
"github.com/docker/cli/cli/config" "github.com/docker/cli/cli/config"
"github.com/docker/cli/cli/config/configfile" "github.com/docker/cli/cli/config/configfile"
"github.com/docker/cli/cli/context/store"
"github.com/docker/cli/cli/flags" "github.com/docker/cli/cli/flags"
"github.com/moby/moby/client" "github.com/docker/docker/api"
"github.com/docker/docker/api/types"
"github.com/docker/docker/client"
"github.com/pkg/errors"
"gotest.tools/v3/assert" "gotest.tools/v3/assert"
"gotest.tools/v3/fs"
) )
func TestNewAPIClientFromFlags(t *testing.T) { func TestNewAPIClientFromFlags(t *testing.T) {
@@ -33,7 +35,7 @@ func TestNewAPIClientFromFlags(t *testing.T) {
apiClient, err := NewAPIClientFromFlags(opts, &configfile.ConfigFile{}) apiClient, err := NewAPIClientFromFlags(opts, &configfile.ConfigFile{})
assert.NilError(t, err) assert.NilError(t, err)
assert.Equal(t, apiClient.DaemonHost(), host) assert.Equal(t, apiClient.DaemonHost(), host)
assert.Equal(t, apiClient.ClientVersion(), client.MaxAPIVersion) assert.Equal(t, apiClient.ClientVersion(), api.DefaultVersion)
} }
func TestNewAPIClientFromFlagsForDefaultSchema(t *testing.T) { func TestNewAPIClientFromFlagsForDefaultSchema(t *testing.T) {
@@ -46,7 +48,7 @@ func TestNewAPIClientFromFlagsForDefaultSchema(t *testing.T) {
apiClient, err := NewAPIClientFromFlags(opts, &configfile.ConfigFile{}) apiClient, err := NewAPIClientFromFlags(opts, &configfile.ConfigFile{})
assert.NilError(t, err) assert.NilError(t, err)
assert.Equal(t, apiClient.DaemonHost(), slug+host) assert.Equal(t, apiClient.DaemonHost(), slug+host)
assert.Equal(t, apiClient.ClientVersion(), client.MaxAPIVersion) assert.Equal(t, apiClient.ClientVersion(), api.DefaultVersion)
} }
func TestNewAPIClientFromFlagsWithCustomHeaders(t *testing.T) { func TestNewAPIClientFromFlagsWithCustomHeaders(t *testing.T) {
@@ -70,7 +72,7 @@ func TestNewAPIClientFromFlagsWithCustomHeaders(t *testing.T) {
apiClient, err := NewAPIClientFromFlags(opts, configFile) apiClient, err := NewAPIClientFromFlags(opts, configFile)
assert.NilError(t, err) assert.NilError(t, err)
assert.Equal(t, apiClient.DaemonHost(), host) assert.Equal(t, apiClient.DaemonHost(), host)
assert.Equal(t, apiClient.ClientVersion(), client.MaxAPIVersion) assert.Equal(t, apiClient.ClientVersion(), api.DefaultVersion)
// verify User-Agent is not appended to the configfile. see https://github.com/docker/cli/pull/2756 // verify User-Agent is not appended to the configfile. see https://github.com/docker/cli/pull/2756
assert.DeepEqual(t, configFile.HTTPHeaders, map[string]string{"My-Header": "Custom-Value"}) assert.DeepEqual(t, configFile.HTTPHeaders, map[string]string{"My-Header": "Custom-Value"})
@@ -79,49 +81,13 @@ func TestNewAPIClientFromFlagsWithCustomHeaders(t *testing.T) {
"My-Header": "Custom-Value", "My-Header": "Custom-Value",
"User-Agent": UserAgent(), "User-Agent": UserAgent(),
} }
_, err = apiClient.Ping(context.TODO(), client.PingOptions{}) _, err = apiClient.Ping(context.Background())
assert.NilError(t, err)
assert.DeepEqual(t, received, expectedHeaders)
}
func TestNewAPIClientFromFlagsWithCustomHeadersFromEnv(t *testing.T) {
var received http.Header
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
received = r.Header.Clone()
_, _ = w.Write([]byte("OK"))
}))
defer ts.Close()
host := strings.Replace(ts.URL, "http://", "tcp://", 1)
opts := &flags.ClientOptions{Hosts: []string{host}}
configFile := &configfile.ConfigFile{
HTTPHeaders: map[string]string{
"My-Header": "Custom-Value from config-file",
},
}
// envOverrideHTTPHeaders should override the HTTPHeaders from the config-file,
// so "My-Header" should not be present.
t.Setenv(envOverrideHTTPHeaders, `one=one-value,"two=two,value",three=,four=four-value,four=four-value-override`)
apiClient, err := NewAPIClientFromFlags(opts, configFile)
assert.NilError(t, err)
assert.Equal(t, apiClient.DaemonHost(), host)
assert.Equal(t, apiClient.ClientVersion(), client.MaxAPIVersion)
expectedHeaders := http.Header{
"One": []string{"one-value"},
"Two": []string{"two,value"},
"Three": []string{""},
"Four": []string{"four-value-override"},
"User-Agent": []string{UserAgent()},
}
_, err = apiClient.Ping(context.TODO(), client.PingOptions{})
assert.NilError(t, err) assert.NilError(t, err)
assert.DeepEqual(t, received, expectedHeaders) assert.DeepEqual(t, received, expectedHeaders)
} }
func TestNewAPIClientFromFlagsWithAPIVersionFromEnv(t *testing.T) { func TestNewAPIClientFromFlagsWithAPIVersionFromEnv(t *testing.T) {
const customVersion = "v3.3" customVersion := "v3.3.3"
const expectedVersion = "3.3"
t.Setenv("DOCKER_API_VERSION", customVersion) t.Setenv("DOCKER_API_VERSION", customVersion)
t.Setenv("DOCKER_HOST", ":2375") t.Setenv("DOCKER_HOST", ":2375")
@@ -129,87 +95,75 @@ func TestNewAPIClientFromFlagsWithAPIVersionFromEnv(t *testing.T) {
configFile := &configfile.ConfigFile{} configFile := &configfile.ConfigFile{}
apiclient, err := NewAPIClientFromFlags(opts, configFile) apiclient, err := NewAPIClientFromFlags(opts, configFile)
assert.NilError(t, err) assert.NilError(t, err)
assert.Equal(t, apiclient.ClientVersion(), expectedVersion) assert.Equal(t, apiclient.ClientVersion(), customVersion)
}
func TestNewAPIClientFromFlagsWithInvalidAPIVersionFromEnv(t *testing.T) {
t.Setenv("DOCKER_API_VERSION", "1")
t.Setenv("DOCKER_HOST", ":2375")
apiClient, err := NewAPIClientFromFlags(&flags.ClientOptions{}, &configfile.ConfigFile{})
assert.ErrorContains(t, err, "invalid API version")
assert.Check(t, apiClient == nil)
} }
type fakeClient struct { type fakeClient struct {
client.Client client.Client
pingFunc func() (client.PingResult, error) pingFunc func() (types.Ping, error)
version string version string
negotiated bool negotiated bool
} }
func (c *fakeClient) Ping(_ context.Context, options client.PingOptions) (client.PingResult, error) { func (c *fakeClient) Ping(_ context.Context) (types.Ping, error) {
res, err := c.pingFunc() return c.pingFunc()
if options.NegotiateAPIVersion {
if res.APIVersion != "" {
if c.negotiated || options.ForceNegotiate {
c.negotiated = true
}
}
}
return res, err
} }
func (c *fakeClient) ClientVersion() string { func (c *fakeClient) ClientVersion() string {
return c.version return c.version
} }
func (c *fakeClient) NegotiateAPIVersionPing(types.Ping) {
c.negotiated = true
}
func TestInitializeFromClient(t *testing.T) { func TestInitializeFromClient(t *testing.T) {
const defaultVersion = "v1.55" const defaultVersion = "v1.55"
testcases := []struct { testcases := []struct {
doc string doc string
pingFunc func() (client.PingResult, error) pingFunc func() (types.Ping, error)
expectedServer ServerInfo expectedServer ServerInfo
negotiated bool negotiated bool
}{ }{
{ {
doc: "successful ping", doc: "successful ping",
pingFunc: func() (client.PingResult, error) { pingFunc: func() (types.Ping, error) {
return client.PingResult{Experimental: true, OSType: "linux", APIVersion: "v1.44"}, nil return types.Ping{Experimental: true, OSType: "linux", APIVersion: "v1.30"}, nil
}, },
expectedServer: ServerInfo{HasExperimental: true, OSType: "linux"}, expectedServer: ServerInfo{HasExperimental: true, OSType: "linux"},
negotiated: true, negotiated: true,
}, },
{ {
doc: "failed ping, no API version", doc: "failed ping, no API version",
pingFunc: func() (client.PingResult, error) { pingFunc: func() (types.Ping, error) {
return client.PingResult{}, errors.New("failed") return types.Ping{}, errors.New("failed")
}, },
expectedServer: ServerInfo{HasExperimental: true}, expectedServer: ServerInfo{HasExperimental: true},
}, },
{ {
doc: "failed ping, with API version", doc: "failed ping, with API version",
pingFunc: func() (client.PingResult, error) { pingFunc: func() (types.Ping, error) {
return client.PingResult{APIVersion: "v1.44"}, errors.New("failed") return types.Ping{APIVersion: "v1.33"}, errors.New("failed")
}, },
expectedServer: ServerInfo{HasExperimental: true}, expectedServer: ServerInfo{HasExperimental: true},
negotiated: true, negotiated: true,
}, },
} }
for _, tc := range testcases { for _, testcase := range testcases {
t.Run(tc.doc, func(t *testing.T) { testcase := testcase
apiClient := &fakeClient{ t.Run(testcase.doc, func(t *testing.T) {
pingFunc: tc.pingFunc, apiclient := &fakeClient{
pingFunc: testcase.pingFunc,
version: defaultVersion, version: defaultVersion,
} }
cli := &DockerCli{client: apiClient} cli := &DockerCli{client: apiclient}
err := cli.Initialize(flags.NewClientOptions()) err := cli.Initialize(flags.NewClientOptions())
assert.NilError(t, err) assert.NilError(t, err)
assert.DeepEqual(t, cli.ServerInfo(), tc.expectedServer) assert.DeepEqual(t, cli.ServerInfo(), testcase.expectedServer)
assert.Equal(t, apiClient.negotiated, tc.negotiated) assert.Equal(t, apiclient.negotiated, testcase.negotiated)
}) })
} }
} }
@@ -217,100 +171,129 @@ func TestInitializeFromClient(t *testing.T) {
// Makes sure we don't hang forever on the initial connection. // Makes sure we don't hang forever on the initial connection.
// https://github.com/docker/cli/issues/3652 // https://github.com/docker/cli/issues/3652
func TestInitializeFromClientHangs(t *testing.T) { func TestInitializeFromClientHangs(t *testing.T) {
const ( dir := t.TempDir()
// Sized against measured scheduler stalls: socket := filepath.Join(dir, "my.sock")
// under CPU pressure this test sees 40-90ms stalls;
// this should give about 5x headroom.
// See https://github.com/docker/cli/issues/6003.
clientInitTimeout = 500 * time.Millisecond
// This is only a backstop against a genuine hang.
// It should never be reached on a healthy run.
// So, it should be fine to have a lenient timeout here.
waitTimeout = 10 * time.Second
)
tmpDir := t.TempDir()
socket := filepath.Join(tmpDir, "my.sock")
l, err := net.Listen("unix", socket) l, err := net.Listen("unix", socket)
assert.NilError(t, err) assert.NilError(t, err)
// Buffered, so the handler can record receiveReqCh := make(chan bool)
// that it was reached without a reader timeoutCtx, cancel := context.WithTimeout(context.Background(), time.Second)
// having to be ready at that instant. defer cancel()
receivedReqCh := make(chan struct{}, 1)
releaseHandlerCh := make(chan struct{})
// Simulate a server that hangs on connections. // Simulate a server that hangs on connections.
ts := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ts := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select { select {
case receivedReqCh <- struct{}{}: case <-timeoutCtx.Done():
default: case receiveReqCh <- true: // Blocks until someone receives on the channel.
} }
<-releaseHandlerCh _, _ = w.Write([]byte("OK"))
})) }))
ts.Listener = l ts.Listener = l
ts.Start() ts.Start()
t.Cleanup(func() { defer ts.Close()
close(releaseHandlerCh)
ts.Close()
})
opts := &flags.ClientOptions{Hosts: []string{"unix://" + socket}} opts := &flags.ClientOptions{Hosts: []string{fmt.Sprintf("unix://%s", socket)}}
configFile := &configfile.ConfigFile{} configFile := &configfile.ConfigFile{}
apiClient, err := NewAPIClientFromFlags(opts, configFile) apiClient, err := NewAPIClientFromFlags(opts, configFile)
assert.NilError(t, err) assert.NilError(t, err)
initErrCh := make(chan error, 1) initializedCh := make(chan bool)
go func() { go func() {
cli := &DockerCli{client: apiClient, initTimeout: clientInitTimeout} cli := &DockerCli{client: apiClient, initTimeout: time.Millisecond}
err := cli.Initialize(flags.NewClientOptions()) err := cli.Initialize(flags.NewClientOptions())
assert.Check(t, err)
cli.CurrentVersion() cli.CurrentVersion()
initErrCh <- err close(initializedCh)
}() }()
select { select {
case err := <-initErrCh: case <-timeoutCtx.Done():
assert.Check(t, err)
case <-time.After(waitTimeout):
t.Fatal("timeout waiting for initialization to complete") t.Fatal("timeout waiting for initialization to complete")
case <-initializedCh:
} }
select { select {
case <-receivedReqCh: case <-timeoutCtx.Done():
case <-time.After(waitTimeout):
t.Fatal("server never received an init request") t.Fatal("server never received an init request")
case <-receiveReqCh:
}
}
// The CLI no longer disables/hides experimental CLI features, however, we need
// to verify that existing configuration files do not break
func TestExperimentalCLI(t *testing.T) {
defaultVersion := "v1.55"
testcases := []struct {
doc string
configfile string
}{
{
doc: "default",
configfile: `{}`,
},
{
doc: "experimental",
configfile: `{
"experimental": "enabled"
}`,
},
}
for _, testcase := range testcases {
testcase := testcase
t.Run(testcase.doc, func(t *testing.T) {
dir := fs.NewDir(t, testcase.doc, fs.WithFile("config.json", testcase.configfile))
defer dir.Remove()
apiclient := &fakeClient{
version: defaultVersion,
pingFunc: func() (types.Ping, error) {
return types.Ping{Experimental: true, OSType: "linux", APIVersion: defaultVersion}, nil
},
}
cli := &DockerCli{client: apiclient, err: os.Stderr}
config.SetDir(dir.Path())
err := cli.Initialize(flags.NewClientOptions())
assert.NilError(t, err)
})
} }
} }
func TestNewDockerCliAndOperators(t *testing.T) { func TestNewDockerCliAndOperators(t *testing.T) {
outbuf := bytes.NewBuffer(nil) // Test default operations and also overriding default ones
errbuf := bytes.NewBuffer(nil)
cli, err := NewDockerCli( cli, err := NewDockerCli(
WithInputStream(io.NopCloser(strings.NewReader("some input"))), WithContentTrust(true),
WithOutputStream(outbuf),
WithErrorStream(errbuf),
) )
assert.NilError(t, err) assert.NilError(t, err)
// Check streams are initialized // Check streams are initialized
assert.Check(t, cli.In() != nil) assert.Check(t, cli.In() != nil)
assert.Check(t, cli.Out() != nil) assert.Check(t, cli.Out() != nil)
assert.Check(t, cli.Err() != nil) assert.Check(t, cli.Err() != nil)
assert.Equal(t, cli.ContentTrustEnabled(), true)
// Apply can modify a dockerCli after construction
inbuf := bytes.NewBuffer([]byte("input"))
outbuf := bytes.NewBuffer(nil)
errbuf := bytes.NewBuffer(nil)
err = cli.Apply(
WithInputStream(io.NopCloser(inbuf)),
WithOutputStream(outbuf),
WithErrorStream(errbuf),
)
assert.NilError(t, err)
// Check input stream
inputStream, err := io.ReadAll(cli.In()) inputStream, err := io.ReadAll(cli.In())
assert.NilError(t, err) assert.NilError(t, err)
assert.Equal(t, string(inputStream), "some input") assert.Equal(t, string(inputStream), "input")
// Check output stream // Check output stream
_, err = fmt.Fprint(cli.Out(), "output") fmt.Fprintf(cli.Out(), "output")
assert.NilError(t, err)
outputStream, err := io.ReadAll(outbuf) outputStream, err := io.ReadAll(outbuf)
assert.NilError(t, err) assert.NilError(t, err)
assert.Equal(t, string(outputStream), "output") assert.Equal(t, string(outputStream), "output")
// Check error stream // Check error stream
_, err = fmt.Fprint(cli.Err(), "error") fmt.Fprintf(cli.Err(), "error")
assert.NilError(t, err)
errStream, err := io.ReadAll(errbuf) errStream, err := io.ReadAll(errbuf)
assert.NilError(t, err) assert.NilError(t, err)
assert.Equal(t, string(errStream), "error") assert.Equal(t, string(errStream), "error")
@@ -319,127 +302,8 @@ func TestNewDockerCliAndOperators(t *testing.T) {
func TestInitializeShouldAlwaysCreateTheContextStore(t *testing.T) { func TestInitializeShouldAlwaysCreateTheContextStore(t *testing.T) {
cli, err := NewDockerCli() cli, err := NewDockerCli()
assert.NilError(t, err) assert.NilError(t, err)
apiClient, err := client.New() assert.NilError(t, cli.Initialize(flags.NewClientOptions(), WithInitializeClient(func(cli *DockerCli) (client.APIClient, error) {
assert.NilError(t, err) return client.NewClientWithOpts()
assert.NilError(t, cli.Initialize(flags.NewClientOptions(), WithAPIClient(apiClient))) })))
assert.Check(t, cli.ContextStore() != nil) assert.Check(t, cli.ContextStore() != nil)
} }
func TestHooksEnabled(t *testing.T) {
t.Run("disabled by default", func(t *testing.T) {
// Make sure we don't depend on any existing ~/.docker/config.json
config.SetDir(t.TempDir())
cli, err := NewDockerCli()
assert.NilError(t, err)
assert.Check(t, !cli.HooksEnabled())
})
t.Run("enabled in configFile", func(t *testing.T) {
configFile := `{
"features": {
"hooks": "true"
}}`
config.SetDir(t.TempDir())
err := os.WriteFile(filepath.Join(config.Dir(), "config.json"), []byte(configFile), 0o600)
assert.NilError(t, err)
cli, err := NewDockerCli()
assert.NilError(t, err)
assert.Check(t, cli.HooksEnabled())
})
t.Run("env var overrides configFile", func(t *testing.T) {
configFile := `{
"features": {
"hooks": "true"
}}`
t.Setenv("DOCKER_CLI_HOOKS", "false")
config.SetDir(t.TempDir())
err := os.WriteFile(filepath.Join(config.Dir(), "config.json"), []byte(configFile), 0o600)
assert.NilError(t, err)
cli, err := NewDockerCli()
assert.NilError(t, err)
assert.Check(t, !cli.HooksEnabled())
})
t.Run("legacy env var overrides configFile", func(t *testing.T) {
configFile := `{
"features": {
"hooks": "true"
}}`
t.Setenv("DOCKER_CLI_HINTS", "false")
config.SetDir(t.TempDir())
err := os.WriteFile(filepath.Join(config.Dir(), "config.json"), []byte(configFile), 0o600)
assert.NilError(t, err)
cli, err := NewDockerCli()
assert.NilError(t, err)
assert.Check(t, !cli.HooksEnabled())
})
}
func TestSetGoDebug(t *testing.T) {
t.Run("GODEBUG already set", func(t *testing.T) {
t.Setenv("GODEBUG", "val1,val2")
meta := store.Metadata{}
setGoDebug(meta)
assert.Equal(t, "val1,val2", os.Getenv("GODEBUG"))
})
t.Run("GODEBUG in context metadata can set env", func(t *testing.T) {
t.Setenv("GODEBUG", "")
meta := store.Metadata{
Metadata: DockerContext{
AdditionalFields: map[string]any{
"GODEBUG": "val1,val2=1",
},
},
}
setGoDebug(meta)
assert.Equal(t, "val1,val2=1", os.Getenv("GODEBUG"))
})
}
func TestNewDockerCliWithCustomUserAgent(t *testing.T) {
var received string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
received = r.UserAgent()
w.WriteHeader(http.StatusOK)
}))
defer ts.Close()
host := strings.Replace(ts.URL, "http://", "tcp://", 1)
opts := &flags.ClientOptions{Hosts: []string{host}}
cli, err := NewDockerCli(
WithUserAgent("fake-agent/0.0.1"),
)
assert.NilError(t, err)
cli.currentContext = DefaultContextName
cli.options = opts
cli.configFile = &configfile.ConfigFile{}
_, err = cli.Client().Ping(context.TODO(), client.PingOptions{})
assert.NilError(t, err)
assert.DeepEqual(t, received, "fake-agent/0.0.1")
}
func TestNewDockerCliWithAPIClientOptions(t *testing.T) {
var received string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
received = r.UserAgent()
w.WriteHeader(http.StatusOK)
}))
defer ts.Close()
host := strings.Replace(ts.URL, "http://", "tcp://", 1)
opts := &flags.ClientOptions{Hosts: []string{host}}
cli, err := NewDockerCli(
WithAPIClientOptions(client.WithUserAgent("fake-agent/0.0.1")),
)
assert.NilError(t, err)
cli.currentContext = DefaultContextName
cli.options = opts
cli.configFile = &configfile.ConfigFile{}
_, err = cli.Client().Ping(t.Context(), client.PingOptions{})
assert.NilError(t, err)
assert.DeepEqual(t, received, "fake-agent/0.0.1")
}
+100 -21
View File
@@ -1,30 +1,109 @@
package commands package commands
import ( import (
"os"
"github.com/docker/cli/cli/command" "github.com/docker/cli/cli/command"
_ "github.com/docker/cli/cli/command/builder" "github.com/docker/cli/cli/command/builder"
_ "github.com/docker/cli/cli/command/checkpoint" "github.com/docker/cli/cli/command/checkpoint"
_ "github.com/docker/cli/cli/command/config" "github.com/docker/cli/cli/command/config"
_ "github.com/docker/cli/cli/command/container" "github.com/docker/cli/cli/command/container"
_ "github.com/docker/cli/cli/command/context" "github.com/docker/cli/cli/command/context"
_ "github.com/docker/cli/cli/command/image" "github.com/docker/cli/cli/command/image"
_ "github.com/docker/cli/cli/command/manifest" "github.com/docker/cli/cli/command/manifest"
_ "github.com/docker/cli/cli/command/network" "github.com/docker/cli/cli/command/network"
_ "github.com/docker/cli/cli/command/node" "github.com/docker/cli/cli/command/node"
_ "github.com/docker/cli/cli/command/plugin" "github.com/docker/cli/cli/command/plugin"
_ "github.com/docker/cli/cli/command/registry" "github.com/docker/cli/cli/command/registry"
_ "github.com/docker/cli/cli/command/secret" "github.com/docker/cli/cli/command/secret"
_ "github.com/docker/cli/cli/command/service" "github.com/docker/cli/cli/command/service"
_ "github.com/docker/cli/cli/command/stack" "github.com/docker/cli/cli/command/stack"
_ "github.com/docker/cli/cli/command/swarm" "github.com/docker/cli/cli/command/swarm"
_ "github.com/docker/cli/cli/command/system" "github.com/docker/cli/cli/command/system"
_ "github.com/docker/cli/cli/command/volume" "github.com/docker/cli/cli/command/trust"
"github.com/docker/cli/internal/commands" "github.com/docker/cli/cli/command/volume"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
func AddCommands(cmd *cobra.Command, dockerCLI command.Cli) { // AddCommands adds all the commands from cli/command to the root command
for _, c := range commands.Commands() { func AddCommands(cmd *cobra.Command, dockerCli command.Cli) {
cmd.AddCommand(c(dockerCLI)) cmd.AddCommand(
// commonly used shorthands
container.NewRunCommand(dockerCli),
container.NewExecCommand(dockerCli),
container.NewPsCommand(dockerCli),
image.NewBuildCommand(dockerCli),
image.NewPullCommand(dockerCli),
image.NewPushCommand(dockerCli),
image.NewImagesCommand(dockerCli),
registry.NewLoginCommand(dockerCli),
registry.NewLogoutCommand(dockerCli),
registry.NewSearchCommand(dockerCli),
system.NewVersionCommand(dockerCli),
system.NewInfoCommand(dockerCli),
// management commands
builder.NewBuilderCommand(dockerCli),
checkpoint.NewCheckpointCommand(dockerCli),
container.NewContainerCommand(dockerCli),
context.NewContextCommand(dockerCli),
image.NewImageCommand(dockerCli),
manifest.NewManifestCommand(dockerCli),
network.NewNetworkCommand(dockerCli),
plugin.NewPluginCommand(dockerCli),
system.NewSystemCommand(dockerCli),
trust.NewTrustCommand(dockerCli),
volume.NewVolumeCommand(dockerCli),
// orchestration (swarm) commands
config.NewConfigCommand(dockerCli),
node.NewNodeCommand(dockerCli),
secret.NewSecretCommand(dockerCli),
service.NewServiceCommand(dockerCli),
stack.NewStackCommand(dockerCli),
swarm.NewSwarmCommand(dockerCli),
// legacy commands may be hidden
hide(container.NewAttachCommand(dockerCli)),
hide(container.NewCommitCommand(dockerCli)),
hide(container.NewCopyCommand(dockerCli)),
hide(container.NewCreateCommand(dockerCli)),
hide(container.NewDiffCommand(dockerCli)),
hide(container.NewExportCommand(dockerCli)),
hide(container.NewKillCommand(dockerCli)),
hide(container.NewLogsCommand(dockerCli)),
hide(container.NewPauseCommand(dockerCli)),
hide(container.NewPortCommand(dockerCli)),
hide(container.NewRenameCommand(dockerCli)),
hide(container.NewRestartCommand(dockerCli)),
hide(container.NewRmCommand(dockerCli)),
hide(container.NewStartCommand(dockerCli)),
hide(container.NewStatsCommand(dockerCli)),
hide(container.NewStopCommand(dockerCli)),
hide(container.NewTopCommand(dockerCli)),
hide(container.NewUnpauseCommand(dockerCli)),
hide(container.NewUpdateCommand(dockerCli)),
hide(container.NewWaitCommand(dockerCli)),
hide(image.NewHistoryCommand(dockerCli)),
hide(image.NewImportCommand(dockerCli)),
hide(image.NewLoadCommand(dockerCli)),
hide(image.NewRemoveCommand(dockerCli)),
hide(image.NewSaveCommand(dockerCli)),
hide(image.NewTagCommand(dockerCli)),
hide(system.NewEventsCommand(dockerCli)),
hide(system.NewInspectCommand(dockerCli)),
)
}
func hide(cmd *cobra.Command) *cobra.Command {
// If the environment variable with name "DOCKER_HIDE_LEGACY_COMMANDS" is not empty,
// these legacy commands (such as `docker ps`, `docker exec`, etc)
// will not be shown in output console.
if os.Getenv("DOCKER_HIDE_LEGACY_COMMANDS") == "" {
return cmd
} }
cmdCopy := *cmd
cmdCopy.Hidden = true
cmdCopy.Aliases = []string{}
return &cmdCopy
} }
+35 -216
View File
@@ -2,81 +2,40 @@ package completion
import ( import (
"os" "os"
"strings"
"github.com/distribution/reference" "github.com/docker/cli/cli/command"
"github.com/moby/moby/api/types/container" "github.com/docker/cli/cli/command/formatter"
"github.com/moby/moby/client" "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/api/types/volume"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
// APIClientProvider provides a method to get a [client.APIClient], initializing // ValidArgsFn a function to be used by cobra command as `ValidArgsFunction` to offer command line completion
// it if needed. type ValidArgsFn func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective)
//
// It's a smaller interface than [command.Cli], and used in situations where an
// APIClient is needed, but we want to postpone initializing the client until
// it's used.
type APIClientProvider interface {
Client() client.APIClient
}
// ImageNames offers completion for images present within the local store // ImageNames offers completion for images present within the local store
func ImageNames(dockerCLI APIClientProvider, limit int) cobra.CompletionFunc { func ImageNames(dockerCli command.Cli) ValidArgsFn {
return Unique(func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if limit > 0 && len(args) >= limit { list, err := dockerCli.Client().ImageList(cmd.Context(), image.ListOptions{})
return nil, cobra.ShellCompDirectiveNoFileComp
}
res, err := dockerCLI.Client().ImageList(cmd.Context(), client.ImageListOptions{})
if err != nil { if err != nil {
return nil, cobra.ShellCompDirectiveError return nil, cobra.ShellCompDirectiveError
} }
var names []string var names []string
for _, img := range res.Items { for _, image := range list {
names = append(names, img.RepoTags...) names = append(names, image.RepoTags...)
} }
return names, cobra.ShellCompDirectiveNoFileComp 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 Unique(func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if limit > 0 && len(args) >= limit {
return nil, cobra.ShellCompDirectiveNoFileComp
}
res, err := dockerCLI.Client().ImageList(cmd.Context(), client.ImageListOptions{})
if err != nil {
return nil, cobra.ShellCompDirectiveError
}
var names []string
baseNameCounts := make(map[string]int)
for _, img := range res.Items {
names = append(names, img.RepoTags...)
for _, tag := range img.RepoTags {
ref, err := reference.ParseNormalizedNamed(tag)
if err != nil {
continue
}
baseNameCounts[reference.FamiliarName(ref)]++
}
}
for baseName, count := range baseNameCounts {
if count > 1 {
names = append(names, baseName)
}
}
return names, cobra.ShellCompDirectiveNoSpace | cobra.ShellCompDirectiveNoFileComp
})
} }
// ContainerNames offers completion for container names and IDs // ContainerNames offers completion for container names and IDs
// By default, only names are returned. // By default, only names are returned.
// Set DOCKER_COMPLETION_SHOW_CONTAINER_IDS=yes to also complete IDs. // Set DOCKER_COMPLETION_SHOW_CONTAINER_IDS=yes to also complete IDs.
func ContainerNames(dockerCLI APIClientProvider, all bool, filters ...func(container.Summary) bool) cobra.CompletionFunc { func ContainerNames(dockerCli command.Cli, all bool, filters ...func(types.Container) bool) ValidArgsFn {
return Unique(func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
res, err := dockerCLI.Client().ContainerList(cmd.Context(), client.ContainerListOptions{ list, err := dockerCli.Client().ContainerList(cmd.Context(), container.ListOptions{
All: all, All: all,
}) })
if err != nil { if err != nil {
@@ -86,10 +45,10 @@ func ContainerNames(dockerCLI APIClientProvider, all bool, filters ...func(conta
showContainerIDs := os.Getenv("DOCKER_COMPLETION_SHOW_CONTAINER_IDS") == "yes" showContainerIDs := os.Getenv("DOCKER_COMPLETION_SHOW_CONTAINER_IDS") == "yes"
var names []string var names []string
for _, ctr := range res.Items { for _, container := range list {
skip := false skip := false
for _, fn := range filters { for _, fn := range filters {
if fn != nil && !fn(ctr) { if !fn(container) {
skip = true skip = true
break break
} }
@@ -98,185 +57,45 @@ func ContainerNames(dockerCLI APIClientProvider, all bool, filters ...func(conta
continue continue
} }
if showContainerIDs { if showContainerIDs {
names = append(names, ctr.ID) names = append(names, container.ID)
}
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, "/"))
} }
names = append(names, formatter.StripNamePrefix(container.Names)...)
} }
return names, cobra.ShellCompDirectiveNoFileComp return names, cobra.ShellCompDirectiveNoFileComp
}) }
} }
// VolumeNames offers completion for volumes // VolumeNames offers completion for volumes
func VolumeNames(dockerCLI APIClientProvider) cobra.CompletionFunc { func VolumeNames(dockerCli command.Cli) ValidArgsFn {
return Unique(func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
res, err := dockerCLI.Client().VolumeList(cmd.Context(), client.VolumeListOptions{}) list, err := dockerCli.Client().VolumeList(cmd.Context(), volume.ListOptions{})
if err != nil { if err != nil {
return nil, cobra.ShellCompDirectiveError return nil, cobra.ShellCompDirectiveError
} }
var names []string var names []string
for _, vol := range res.Items { for _, vol := range list.Volumes {
names = append(names, vol.Name) names = append(names, vol.Name)
} }
return names, cobra.ShellCompDirectiveNoFileComp return names, cobra.ShellCompDirectiveNoFileComp
}) }
} }
// NetworkNames offers completion for networks // NetworkNames offers completion for networks
func NetworkNames(dockerCLI APIClientProvider) cobra.CompletionFunc { func NetworkNames(dockerCli command.Cli) ValidArgsFn {
return Unique(func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
res, err := dockerCLI.Client().NetworkList(cmd.Context(), client.NetworkListOptions{}) list, err := dockerCli.Client().NetworkList(cmd.Context(), types.NetworkListOptions{})
if err != nil { if err != nil {
return nil, cobra.ShellCompDirectiveError return nil, cobra.ShellCompDirectiveError
} }
var names []string var names []string
for _, nw := range res.Items { for _, network := range list {
names = append(names, nw.Name) names = append(names, network.Name)
} }
return names, cobra.ShellCompDirectiveNoFileComp return names, cobra.ShellCompDirectiveNoFileComp
})
}
// EnvVarNames offers completion for environment-variable names. This
// completion can be used for "--env" and "--build-arg" flags, which
// allow obtaining the value of the given environment-variable if present
// in the local environment, so we only should complete the names of the
// environment variables, and not their value. This also prevents the
// completion script from printing values of environment variables
// containing sensitive values.
//
// For example;
//
// export MY_VAR=hello
// docker run --rm --env MY_VAR alpine printenv MY_VAR
// hello
func EnvVarNames() cobra.CompletionFunc {
return Unique(func(_ *cobra.Command, _ []string, _ string) (names []string, _ cobra.ShellCompDirective) {
envs := os.Environ()
names = make([]string, 0, len(envs))
for _, env := range envs {
name, _, _ := strings.Cut(env, "=")
names = append(names, name)
}
return names, cobra.ShellCompDirectiveNoFileComp
})
}
// FromList offers completion for the given list of options.
func FromList(options ...string) cobra.CompletionFunc {
return Unique(cobra.FixedCompletions(options, cobra.ShellCompDirectiveNoFileComp))
}
// WithPrefix prefixes every element in the slice with the given prefix.
// It is a helper for building "--filter" completions, where each candidate
// value is offered as "key=value".
func WithPrefix(prefix string, values []string) []string {
result := make([]string, len(values))
for i, v := range values {
result[i] = prefix + v
}
return result
}
// WithSuffix appends the given suffix to every element in the slice. It is a
// helper for building "--filter" completions, where filter keys are offered
// with a trailing "=" (combined with [cobra.ShellCompDirectiveNoSpace]).
func WithSuffix(suffix string, values []string) []string {
result := make([]string, len(values))
for i, v := range values {
result[i] = v + suffix
}
return result
}
// FileNames is a convenience function to use [cobra.ShellCompDirectiveDefault],
// which indicates to let the shell perform its default behavior after
// completions have been provided.
func FileNames() cobra.CompletionFunc {
return func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return nil, cobra.ShellCompDirectiveDefault
} }
} }
var commonPlatforms = []string{ // NoComplete is used for commands where there's no relevant completion
"linux/386", func NoComplete(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
"linux/amd64", return nil, cobra.ShellCompDirectiveNoFileComp
"linux/arm",
"linux/arm/v5",
"linux/arm/v6",
"linux/arm/v7",
"linux/arm64",
"linux/arm64/v8",
// IBM power and z platforms
"linux/ppc64le",
"linux/s390x",
// Not yet supported
"linux/riscv64",
"windows/amd64",
"wasip1/wasm",
}
// Platforms offers completion for platform-strings. It provides a non-exhaustive
// list of platforms to be used for completion. Platform-strings are based on
// [runtime.GOOS] and [runtime.GOARCH], but with (optional) variants added. A
// list of recognised os/arch combinations from the Go runtime can be obtained
// through "go tool dist list".
//
// Some noteworthy exclusions from this list:
//
// - arm64 images ("windows/arm64", "windows/arm64/v8") do not yet exist for windows.
// - we don't (yet) include `os-variant` for completion (as can be used for Windows images)
// - we don't (yet) include platforms for which we don't build binaries, such as
// BSD platforms (freebsd, netbsd, openbsd), android, macOS (darwin).
// - we currently exclude architectures that may have unofficial builds,
// but don't have wide adoption (and no support), such as loong64, mipsXXX,
// ppc64 (non-le) to prevent confusion.
func Platforms() cobra.CompletionFunc {
return func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
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
}
} }
-386
View File
@@ -1,386 +0,0 @@
package completion
import (
"context"
"errors"
"testing"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/moby/moby/api/types/container"
"github.com/moby/moby/api/types/image"
"github.com/moby/moby/api/types/network"
"github.com/moby/moby/api/types/volume"
"github.com/moby/moby/client"
"github.com/spf13/cobra"
"gotest.tools/v3/assert"
is "gotest.tools/v3/assert/cmp"
"gotest.tools/v3/env"
)
type fakeCLI struct {
*fakeClient
}
// Client implements [APIClientProvider].
func (c fakeCLI) Client() client.APIClient {
return c.fakeClient
}
type fakeClient struct {
client.Client
containerListFunc func(context.Context, client.ContainerListOptions) (client.ContainerListResult, error)
imageListFunc func(context.Context, client.ImageListOptions) (client.ImageListResult, error)
networkListFunc func(context.Context, client.NetworkListOptions) (client.NetworkListResult, error)
volumeListFunc func(context.Context, client.VolumeListOptions) (client.VolumeListResult, error)
}
func (c *fakeClient) ContainerList(ctx context.Context, options client.ContainerListOptions) (client.ContainerListResult, error) {
if c.containerListFunc != nil {
return c.containerListFunc(ctx, options)
}
return client.ContainerListResult{}, nil
}
func (c *fakeClient) ImageList(ctx context.Context, options client.ImageListOptions) (client.ImageListResult, error) {
if c.imageListFunc != nil {
return c.imageListFunc(ctx, options)
}
return client.ImageListResult{}, nil
}
func (c *fakeClient) NetworkList(ctx context.Context, options client.NetworkListOptions) (client.NetworkListResult, error) {
if c.networkListFunc != nil {
return c.networkListFunc(ctx, options)
}
return client.NetworkListResult{}, nil
}
func (c *fakeClient) VolumeList(ctx context.Context, options client.VolumeListOptions) (client.VolumeListResult, error) {
if c.volumeListFunc != nil {
return c.volumeListFunc(ctx, options)
}
return client.VolumeListResult{}, nil
}
func TestCompleteContainerNames(t *testing.T) {
tests := []struct {
doc string
showAll, showIDs bool
filters []func(container.Summary) bool
containers []container.Summary
expOut []string
expOpts client.ContainerListOptions
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"}},
{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-b", "container-a"},
expOpts: client.ContainerListOptions{All: true},
expDirective: cobra.ShellCompDirectiveNoFileComp,
},
{
doc: "all containers with ids",
showAll: true,
showIDs: true,
containers: []container.Summary{
{ID: "id-c", State: container.StateRunning, Names: []string{"/container-c", "/container-c/link-b"}},
{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", "id-b", "container-b", "id-a", "container-a"},
expOpts: client.ContainerListOptions{All: true},
expDirective: cobra.ShellCompDirectiveNoFileComp,
},
{
doc: "only running containers",
showAll: false,
containers: []container.Summary{
{ID: "id-c", State: container.StateRunning, Names: []string{"/container-c", "/container-c/link-b"}},
},
expOut: []string{"container-c"},
expDirective: cobra.ShellCompDirectiveNoFileComp,
},
{
doc: "with filter",
showAll: true,
filters: []func(container.Summary) bool{
func(ctr container.Summary) bool { return ctr.State == container.StateCreated },
},
containers: []container.Summary{
{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"}},
},
expOut: []string{"container-b"},
expOpts: client.ContainerListOptions{All: true},
expDirective: cobra.ShellCompDirectiveNoFileComp,
},
{
doc: "multiple filters",
showAll: true,
filters: []func(container.Summary) bool{
func(ctr container.Summary) bool { return ctr.ID == "id-a" },
func(ctr container.Summary) bool { return ctr.State == container.StateCreated },
},
containers: []container.Summary{
{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"}},
},
expOut: []string{"container-a"},
expOpts: client.ContainerListOptions{All: true},
expDirective: cobra.ShellCompDirectiveNoFileComp,
},
{
doc: "with error",
expDirective: cobra.ShellCompDirectiveError,
},
}
for _, tc := range tests {
t.Run(tc.doc, func(t *testing.T) {
if tc.showIDs {
t.Setenv("DOCKER_COMPLETION_SHOW_CONTAINER_IDS", "yes")
}
comp := ContainerNames(fakeCLI{&fakeClient{
containerListFunc: func(_ context.Context, opts client.ContainerListOptions) (client.ContainerListResult, error) {
assert.Check(t, is.DeepEqual(opts, tc.expOpts))
if tc.expDirective == cobra.ShellCompDirectiveError {
return client.ContainerListResult{}, errors.New("some error occurred")
}
return client.ContainerListResult{Items: tc.containers}, nil
},
}}, tc.showAll, tc.filters...)
containers, directives := comp(&cobra.Command{}, nil, "")
assert.Check(t, is.Equal(directives&tc.expDirective, tc.expDirective))
assert.Check(t, is.DeepEqual(containers, tc.expOut))
})
}
}
func TestCompleteEnvVarNames(t *testing.T) {
env.PatchAll(t, map[string]string{
"ENV_A": "hello-a",
"ENV_B": "hello-b",
})
values, directives := EnvVarNames()(nil, nil, "")
assert.Check(t, is.Equal(directives&cobra.ShellCompDirectiveNoFileComp, cobra.ShellCompDirectiveNoFileComp), "Should not perform file completion")
expected := []string{"ENV_A", "ENV_B"}
assert.Check(t, is.DeepEqual(values, expected, cmpopts.SortSlices(func(a, b string) bool {
return a < b
})))
}
func TestCompleteFileNames(t *testing.T) {
values, directives := FileNames()(nil, nil, "")
assert.Check(t, is.Equal(directives, cobra.ShellCompDirectiveDefault))
assert.Check(t, is.Len(values, 0))
}
func TestCompleteFromList(t *testing.T) {
expected := []string{"one", "two", "three"}
values, directives := FromList(expected...)(nil, nil, "")
assert.Check(t, is.Equal(directives&cobra.ShellCompDirectiveNoFileComp, cobra.ShellCompDirectiveNoFileComp), "Should not perform file completion")
assert.Check(t, is.DeepEqual(values, expected))
}
func TestWithPrefix(t *testing.T) {
assert.Check(t, is.DeepEqual(WithPrefix("node=", []string{"n1", "n2"}), []string{"node=n1", "node=n2"}))
assert.Check(t, is.DeepEqual(WithPrefix("node=", []string{}), []string{}))
assert.Check(t, is.DeepEqual(WithPrefix("", []string{"n1"}), []string{"n1"}))
}
func TestWithSuffix(t *testing.T) {
assert.Check(t, is.DeepEqual(WithSuffix("=", []string{"id", "name"}), []string{"id=", "name="}))
assert.Check(t, is.DeepEqual(WithSuffix("=", []string{}), []string{}))
assert.Check(t, is.DeepEqual(WithSuffix("", []string{"id"}), []string{"id"}))
}
func TestCompleteImageNames(t *testing.T) {
tests := []struct {
doc string
images []image.Summary
expOut []string
expDirective cobra.ShellCompDirective
}{
{
doc: "no results",
expDirective: cobra.ShellCompDirectiveNoFileComp,
},
{
doc: "with results",
images: []image.Summary{
{RepoTags: []string{"image-c:latest", "image-c:other"}},
{RepoTags: []string{"image-b:latest", "image-b:other"}},
{RepoTags: []string{"image-a:latest", "image-a:other"}},
},
expOut: []string{"image-c:latest", "image-c:other", "image-b:latest", "image-b:other", "image-a:latest", "image-a:other"},
expDirective: cobra.ShellCompDirectiveNoFileComp,
},
{
doc: "with error",
expDirective: cobra.ShellCompDirectiveError,
},
}
for _, tc := range tests {
t.Run(tc.doc, func(t *testing.T) {
comp := ImageNames(fakeCLI{&fakeClient{
imageListFunc: func(context.Context, client.ImageListOptions) (client.ImageListResult, error) {
if tc.expDirective == cobra.ShellCompDirectiveError {
return client.ImageListResult{}, errors.New("some error occurred")
}
return client.ImageListResult{Items: tc.images}, nil
},
}}, -1)
volumes, directives := comp(&cobra.Command{}, nil, "")
assert.Check(t, is.Equal(directives&tc.expDirective, tc.expDirective))
assert.Check(t, is.DeepEqual(volumes, tc.expOut))
})
}
}
func TestCompleteNetworkNames(t *testing.T) {
tests := []struct {
doc string
networks []network.Summary
expOut []string
expDirective cobra.ShellCompDirective
}{
{
doc: "no results",
expDirective: cobra.ShellCompDirectiveNoFileComp,
},
{
doc: "with results",
networks: []network.Summary{
{
Network: network.Network{
ID: "nw-c",
Name: "network-c",
},
},
{
Network: network.Network{
ID: "nw-b",
Name: "network-b",
},
},
{
Network: network.Network{
ID: "nw-a",
Name: "network-a",
},
},
},
expOut: []string{"network-c", "network-b", "network-a"},
expDirective: cobra.ShellCompDirectiveNoFileComp,
},
{
doc: "with error",
expDirective: cobra.ShellCompDirectiveError,
},
}
for _, tc := range tests {
t.Run(tc.doc, func(t *testing.T) {
comp := NetworkNames(fakeCLI{&fakeClient{
networkListFunc: func(context.Context, client.NetworkListOptions) (client.NetworkListResult, error) {
if tc.expDirective == cobra.ShellCompDirectiveError {
return client.NetworkListResult{}, errors.New("some error occurred")
}
return client.NetworkListResult{Items: tc.networks}, nil
},
}})
volumes, directives := comp(&cobra.Command{}, nil, "")
assert.Check(t, is.Equal(directives&tc.expDirective, tc.expDirective))
assert.Check(t, is.DeepEqual(volumes, tc.expOut))
})
}
}
func TestCompletePlatforms(t *testing.T) {
values, directives := Platforms()(nil, nil, "")
assert.Check(t, is.Equal(directives&cobra.ShellCompDirectiveNoFileComp, cobra.ShellCompDirectiveNoFileComp), "Should not perform file completion")
assert.Check(t, is.DeepEqual(values, commonPlatforms))
}
func TestCompleteVolumeNames(t *testing.T) {
tests := []struct {
doc string
volumes []volume.Volume
expOut []string
expDirective cobra.ShellCompDirective
}{
{
doc: "no results",
expDirective: cobra.ShellCompDirectiveNoFileComp,
},
{
doc: "with results",
volumes: []volume.Volume{
{Name: "volume-c"},
{Name: "volume-b"},
{Name: "volume-a"},
},
expOut: []string{"volume-c", "volume-b", "volume-a"},
expDirective: cobra.ShellCompDirectiveNoFileComp,
},
{
doc: "with error",
expDirective: cobra.ShellCompDirectiveError,
},
}
for _, tc := range tests {
t.Run(tc.doc, func(t *testing.T) {
comp := VolumeNames(fakeCLI{&fakeClient{
volumeListFunc: func(context.Context, client.VolumeListOptions) (client.VolumeListResult, error) {
if tc.expDirective == cobra.ShellCompDirectiveError {
return client.VolumeListResult{}, errors.New("some error occurred")
}
return client.VolumeListResult{Items: tc.volumes}, nil
},
}})
volumes, directives := comp(&cobra.Command{}, nil, "")
assert.Check(t, is.Equal(directives&tc.expDirective, tc.expDirective))
assert.Check(t, is.DeepEqual(volumes, tc.expOut))
})
}
}
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"}))
}
+18 -16
View File
@@ -3,41 +3,43 @@ package config
import ( import (
"context" "context"
"github.com/moby/moby/client" "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/swarm"
"github.com/docker/docker/client"
) )
type fakeClient struct { type fakeClient struct {
client.Client client.Client
configCreateFunc func(context.Context, client.ConfigCreateOptions) (client.ConfigCreateResult, error) configCreateFunc func(context.Context, swarm.ConfigSpec) (types.ConfigCreateResponse, error)
configInspectFunc func(context.Context, string, client.ConfigInspectOptions) (client.ConfigInspectResult, error) configInspectFunc func(context.Context, string) (swarm.Config, []byte, error)
configListFunc func(context.Context, client.ConfigListOptions) (client.ConfigListResult, error) configListFunc func(context.Context, types.ConfigListOptions) ([]swarm.Config, error)
configRemoveFunc func(context.Context, string, client.ConfigRemoveOptions) (client.ConfigRemoveResult, error) configRemoveFunc func(string) error
} }
func (c *fakeClient) ConfigCreate(ctx context.Context, options client.ConfigCreateOptions) (client.ConfigCreateResult, error) { func (c *fakeClient) ConfigCreate(ctx context.Context, spec swarm.ConfigSpec) (types.ConfigCreateResponse, error) {
if c.configCreateFunc != nil { if c.configCreateFunc != nil {
return c.configCreateFunc(ctx, options) return c.configCreateFunc(ctx, spec)
} }
return client.ConfigCreateResult{}, nil return types.ConfigCreateResponse{}, nil
} }
func (c *fakeClient) ConfigInspect(ctx context.Context, id string, options client.ConfigInspectOptions) (client.ConfigInspectResult, error) { func (c *fakeClient) ConfigInspectWithRaw(ctx context.Context, id string) (swarm.Config, []byte, error) {
if c.configInspectFunc != nil { if c.configInspectFunc != nil {
return c.configInspectFunc(ctx, id, options) return c.configInspectFunc(ctx, id)
} }
return client.ConfigInspectResult{}, nil return swarm.Config{}, nil, nil
} }
func (c *fakeClient) ConfigList(ctx context.Context, options client.ConfigListOptions) (client.ConfigListResult, error) { func (c *fakeClient) ConfigList(ctx context.Context, options types.ConfigListOptions) ([]swarm.Config, error) {
if c.configListFunc != nil { if c.configListFunc != nil {
return c.configListFunc(ctx, options) return c.configListFunc(ctx, options)
} }
return client.ConfigListResult{}, nil return []swarm.Config{}, nil
} }
func (c *fakeClient) ConfigRemove(ctx context.Context, name string, options client.ConfigRemoveOptions) (client.ConfigRemoveResult, error) { func (c *fakeClient) ConfigRemove(_ context.Context, name string) error {
if c.configRemoveFunc != nil { if c.configRemoveFunc != nil {
return c.configRemoveFunc(ctx, name, options) return c.configRemoveFunc(name)
} }
return client.ConfigRemoveResult{}, nil return nil
} }
+11 -17
View File
@@ -4,46 +4,40 @@ import (
"github.com/docker/cli/cli" "github.com/docker/cli/cli"
"github.com/docker/cli/cli/command" "github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/command/completion" "github.com/docker/cli/cli/command/completion"
"github.com/docker/cli/internal/commands" "github.com/docker/docker/api/types"
"github.com/moby/moby/client"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
func init() { // NewConfigCommand returns a cobra command for `config` subcommands
commands.Register(newConfigCommand) func NewConfigCommand(dockerCli command.Cli) *cobra.Command {
}
// newConfigCommand returns a cobra command for `config` subcommands
func newConfigCommand(dockerCLI command.Cli) *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "config", Use: "config",
Short: "Manage Swarm configs", Short: "Manage Swarm configs",
Args: cli.NoArgs, Args: cli.NoArgs,
RunE: command.ShowHelp(dockerCLI.Err()), RunE: command.ShowHelp(dockerCli.Err()),
Annotations: map[string]string{ Annotations: map[string]string{
"version": "1.30", "version": "1.30",
"swarm": "manager", "swarm": "manager",
}, },
DisableFlagsInUseLine: true,
} }
cmd.AddCommand( cmd.AddCommand(
newConfigListCommand(dockerCLI), newConfigListCommand(dockerCli),
newConfigCreateCommand(dockerCLI), newConfigCreateCommand(dockerCli),
newConfigInspectCommand(dockerCLI), newConfigInspectCommand(dockerCli),
newConfigRemoveCommand(dockerCLI), newConfigRemoveCommand(dockerCli),
) )
return cmd return cmd
} }
// completeNames offers completion for swarm configs // completeNames offers completion for swarm configs
func completeNames(dockerCLI completion.APIClientProvider) cobra.CompletionFunc { func completeNames(dockerCli command.Cli) completion.ValidArgsFn {
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
res, err := dockerCLI.Client().ConfigList(cmd.Context(), client.ConfigListOptions{}) list, err := dockerCli.Client().ConfigList(cmd.Context(), types.ConfigListOptions{})
if err != nil { if err != nil {
return nil, cobra.ShellCompDirectiveError return nil, cobra.ShellCompDirectiveError
} }
var names []string var names []string
for _, config := range res.Items { for _, config := range list {
names = append(names, config.ID) names = append(names, config.ID)
} }
return names, cobra.ShellCompDirectiveNoFileComp return names, cobra.ShellCompDirectiveNoFileComp
+40 -97
View File
@@ -2,30 +2,30 @@ package config
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"io" "io"
"github.com/docker/cli/cli" "github.com/docker/cli/cli"
"github.com/docker/cli/cli/command" "github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/command/completion"
"github.com/docker/cli/opts" "github.com/docker/cli/opts"
"github.com/moby/moby/api/types/swarm" "github.com/docker/docker/api/types/swarm"
"github.com/moby/moby/client"
"github.com/moby/sys/sequential" "github.com/moby/sys/sequential"
"github.com/pkg/errors"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
// createOptions specifies some options that are used when creating a config. // CreateOptions specifies some options that are used when creating a config.
type createOptions struct { type CreateOptions struct {
name string Name string
templateDriver string TemplateDriver string
file string File string
labels opts.ListOpts Labels opts.ListOpts
} }
func newConfigCreateCommand(dockerCLI command.Cli) *cobra.Command { func newConfigCreateCommand(dockerCli command.Cli) *cobra.Command {
createOpts := createOptions{ createOpts := CreateOptions{
labels: opts.NewListOpts(opts.ValidateLabel), Labels: opts.NewListOpts(opts.ValidateLabel),
} }
cmd := &cobra.Command{ cmd := &cobra.Command{
@@ -33,113 +33,56 @@ func newConfigCreateCommand(dockerCLI command.Cli) *cobra.Command {
Short: "Create a config from a file or STDIN", Short: "Create a config from a file or STDIN",
Args: cli.ExactArgs(2), Args: cli.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
createOpts.name = args[0] createOpts.Name = args[0]
createOpts.file = args[1] createOpts.File = args[1]
return runCreate(cmd.Context(), dockerCLI, createOpts) return RunConfigCreate(cmd.Context(), dockerCli, createOpts)
}, },
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { ValidArgsFunction: completion.NoComplete,
switch len(args) {
case 0:
// No completion for the first argument, which is the name for
// the new config, but if a non-empty name is given, we return
// it as completion to allow "tab"-ing to the next completion.
return []string{toComplete}, cobra.ShellCompDirectiveNoFileComp
case 1:
// Second argument is either "-" or a file to load.
//
// TODO(thaJeztah): provide completion for "-".
return nil, cobra.ShellCompDirectiveNoSpace | cobra.ShellCompDirectiveDefault
default:
// Command only accepts two arguments.
return nil, cobra.ShellCompDirectiveNoSpace | cobra.ShellCompDirectiveNoFileComp
}
},
DisableFlagsInUseLine: true,
} }
flags := cmd.Flags() flags := cmd.Flags()
flags.VarP(&createOpts.labels, "label", "l", "Config labels") flags.VarP(&createOpts.Labels, "label", "l", "Config labels")
flags.StringVar(&createOpts.templateDriver, "template-driver", "", "Template driver") flags.StringVar(&createOpts.TemplateDriver, "template-driver", "", "Template driver")
_ = flags.SetAnnotation("template-driver", "version", []string{"1.37"}) flags.SetAnnotation("template-driver", "version", []string{"1.37"})
return cmd return cmd
} }
// runCreate creates a config with the given options. // RunConfigCreate creates a config with the given options.
func runCreate(ctx context.Context, dockerCLI command.Cli, options createOptions) error { func RunConfigCreate(ctx context.Context, dockerCli command.Cli, options CreateOptions) error {
apiClient := dockerCLI.Client() client := dockerCli.Client()
configData, err := readConfigData(dockerCLI.In(), options.file) var in io.Reader = dockerCli.In()
if options.File != "-" {
file, err := sequential.Open(options.File)
if err != nil {
return err
}
in = file
defer file.Close()
}
configData, err := io.ReadAll(in)
if err != nil { if err != nil {
return fmt.Errorf("error reading content from %q: %v", options.file, err) return errors.Errorf("Error reading content from %q: %v", options.File, err)
} }
spec := swarm.ConfigSpec{ spec := swarm.ConfigSpec{
Annotations: swarm.Annotations{ Annotations: swarm.Annotations{
Name: options.name, Name: options.Name,
Labels: opts.ConvertKVStringsToMap(options.labels.GetSlice()), Labels: opts.ConvertKVStringsToMap(options.Labels.GetAll()),
}, },
Data: configData, Data: configData,
} }
if options.templateDriver != "" { if options.TemplateDriver != "" {
spec.Templating = &swarm.Driver{ spec.Templating = &swarm.Driver{
Name: options.templateDriver, Name: options.TemplateDriver,
} }
} }
r, err := apiClient.ConfigCreate(ctx, client.ConfigCreateOptions{ r, err := client.ConfigCreate(ctx, spec)
Spec: spec,
})
if err != nil { if err != nil {
return err return err
} }
_, _ = fmt.Fprintln(dockerCLI.Out(), r.ID) fmt.Fprintln(dockerCli.Out(), r.ID)
return nil return nil
} }
// maxConfigSize is the maximum byte length of the [swarm.ConfigSpec.Data] field,
// as defined by [MaxConfigSize] in SwarmKit.
//
// [MaxConfigSize]: https://pkg.go.dev/github.com/moby/swarmkit/v2@v2.0.0-20250103191802-8c1959736554/manager/controlapi#MaxConfigSize
const maxConfigSize = 1000 * 1024 // 1000KB
// readConfigData reads the config from either stdin or the given fileName.
//
// It reads up to twice the maximum size of the config ([maxConfigSize]),
// just in case swarm's limit changes; this is only a safeguard to prevent
// reading arbitrary files into memory.
func readConfigData(in io.Reader, fileName string) ([]byte, error) {
switch fileName {
case "-":
data, err := io.ReadAll(io.LimitReader(in, 2*maxConfigSize))
if err != nil {
return nil, fmt.Errorf("error reading from STDIN: %w", err)
}
if len(data) == 0 {
return nil, errors.New("error reading from STDIN: data is empty")
}
return data, nil
case "":
return nil, errors.New("config file is required")
default:
// Open file with [FILE_FLAG_SEQUENTIAL_SCAN] on Windows, which
// prevents Windows from aggressively caching it. We expect this
// file to be only read once. Given that this is expected to be
// a small file, this may not be a significant optimization, so
// we could choose to omit this, and use a regular [os.Open].
//
// [FILE_FLAG_SEQUENTIAL_SCAN]: https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilea#FILE_FLAG_SEQUENTIAL_SCAN
f, err := sequential.Open(fileName)
if err != nil {
return nil, fmt.Errorf("error reading from %s: %w", fileName, err)
}
defer f.Close()
data, err := io.ReadAll(io.LimitReader(f, 2*maxConfigSize))
if err != nil {
return nil, fmt.Errorf("error reading from %s: %w", fileName, err)
}
if len(data) == 0 {
return nil, fmt.Errorf("error reading from %s: data is empty", fileName)
}
return data, nil
}
}
+37 -41
View File
@@ -2,8 +2,6 @@ package config
import ( import (
"context" "context"
"errors"
"fmt"
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
@@ -12,8 +10,9 @@ import (
"testing" "testing"
"github.com/docker/cli/internal/test" "github.com/docker/cli/internal/test"
"github.com/moby/moby/api/types/swarm" "github.com/docker/docker/api/types"
"github.com/moby/moby/client" "github.com/docker/docker/api/types/swarm"
"github.com/pkg/errors"
"gotest.tools/v3/assert" "gotest.tools/v3/assert"
is "gotest.tools/v3/assert/cmp" is "gotest.tools/v3/assert/cmp"
"gotest.tools/v3/golden" "gotest.tools/v3/golden"
@@ -24,53 +23,50 @@ const configDataFile = "config-create-with-name.golden"
func TestConfigCreateErrors(t *testing.T) { func TestConfigCreateErrors(t *testing.T) {
testCases := []struct { testCases := []struct {
args []string args []string
configCreateFunc func(context.Context, client.ConfigCreateOptions) (client.ConfigCreateResult, error) configCreateFunc func(context.Context, swarm.ConfigSpec) (types.ConfigCreateResponse, error)
expectedError string expectedError string
}{ }{
{ {
args: []string{"too_few"}, args: []string{"too_few"},
expectedError: "requires 2 arguments", expectedError: "requires exactly 2 arguments",
}, },
{ {
args: []string{"too", "many", "arguments"}, args: []string{"too", "many", "arguments"},
expectedError: "requires 2 arguments", expectedError: "requires exactly 2 arguments",
}, },
{ {
args: []string{"name", filepath.Join("testdata", configDataFile)}, args: []string{"name", filepath.Join("testdata", configDataFile)},
configCreateFunc: func(_ context.Context, options client.ConfigCreateOptions) (client.ConfigCreateResult, error) { configCreateFunc: func(_ context.Context, configSpec swarm.ConfigSpec) (types.ConfigCreateResponse, error) {
return client.ConfigCreateResult{}, errors.New("error creating config") return types.ConfigCreateResponse{}, errors.Errorf("error creating config")
}, },
expectedError: "error creating config", expectedError: "error creating config",
}, },
} }
for _, tc := range testCases { for _, tc := range testCases {
t.Run(tc.expectedError, func(t *testing.T) { cmd := newConfigCreateCommand(
cmd := newConfigCreateCommand( test.NewFakeCli(&fakeClient{
test.NewFakeCli(&fakeClient{ configCreateFunc: tc.configCreateFunc,
configCreateFunc: tc.configCreateFunc, }),
}), )
) cmd.SetArgs(tc.args)
cmd.SetArgs(tc.args) cmd.SetOut(io.Discard)
cmd.SetOut(io.Discard) assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
cmd.SetErr(io.Discard)
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
})
} }
} }
func TestConfigCreateWithName(t *testing.T) { func TestConfigCreateWithName(t *testing.T) {
const name = "config-with-name" name := "foo"
var actual []byte var actual []byte
cli := test.NewFakeCli(&fakeClient{ cli := test.NewFakeCli(&fakeClient{
configCreateFunc: func(_ context.Context, options client.ConfigCreateOptions) (client.ConfigCreateResult, error) { configCreateFunc: func(_ context.Context, spec swarm.ConfigSpec) (types.ConfigCreateResponse, error) {
if options.Spec.Name != name { if spec.Name != name {
return client.ConfigCreateResult{}, fmt.Errorf("expected name %q, got %q", name, options.Spec.Name) return types.ConfigCreateResponse{}, errors.Errorf("expected name %q, got %q", name, spec.Name)
} }
actual = options.Spec.Data actual = spec.Data
return client.ConfigCreateResult{ return types.ConfigCreateResponse{
ID: "ID-" + options.Spec.Name, ID: "ID-" + spec.Name,
}, nil }, nil
}, },
}) })
@@ -87,7 +83,7 @@ func TestConfigCreateWithLabels(t *testing.T) {
"lbl1": "Label-foo", "lbl1": "Label-foo",
"lbl2": "Label-bar", "lbl2": "Label-bar",
} }
const name = "config-with-labels" name := "foo"
data, err := os.ReadFile(filepath.Join("testdata", configDataFile)) data, err := os.ReadFile(filepath.Join("testdata", configDataFile))
assert.NilError(t, err) assert.NilError(t, err)
@@ -101,13 +97,13 @@ func TestConfigCreateWithLabels(t *testing.T) {
} }
cli := test.NewFakeCli(&fakeClient{ cli := test.NewFakeCli(&fakeClient{
configCreateFunc: func(_ context.Context, options client.ConfigCreateOptions) (client.ConfigCreateResult, error) { configCreateFunc: func(_ context.Context, spec swarm.ConfigSpec) (types.ConfigCreateResponse, error) {
if !reflect.DeepEqual(options.Spec, expected) { if !reflect.DeepEqual(spec, expected) {
return client.ConfigCreateResult{}, fmt.Errorf("expected %+v, got %+v", expected, options.Spec) return types.ConfigCreateResponse{}, errors.Errorf("expected %+v, got %+v", expected, spec)
} }
return client.ConfigCreateResult{ return types.ConfigCreateResponse{
ID: "ID-" + options.Spec.Name, ID: "ID-" + spec.Name,
}, nil }, nil
}, },
}) })
@@ -124,20 +120,20 @@ func TestConfigCreateWithTemplatingDriver(t *testing.T) {
expectedDriver := &swarm.Driver{ expectedDriver := &swarm.Driver{
Name: "template-driver", Name: "template-driver",
} }
const name = "config-with-template-driver" name := "foo"
cli := test.NewFakeCli(&fakeClient{ cli := test.NewFakeCli(&fakeClient{
configCreateFunc: func(_ context.Context, options client.ConfigCreateOptions) (client.ConfigCreateResult, error) { configCreateFunc: func(_ context.Context, spec swarm.ConfigSpec) (types.ConfigCreateResponse, error) {
if options.Spec.Name != name { if spec.Name != name {
return client.ConfigCreateResult{}, fmt.Errorf("expected name %q, got %q", name, options.Spec.Name) return types.ConfigCreateResponse{}, errors.Errorf("expected name %q, got %q", name, spec.Name)
} }
if options.Spec.Templating.Name != expectedDriver.Name { if spec.Templating.Name != expectedDriver.Name {
return client.ConfigCreateResult{}, fmt.Errorf("expected driver %v, got %v", expectedDriver, options.Spec.Labels) return types.ConfigCreateResponse{}, errors.Errorf("expected driver %v, got %v", expectedDriver, spec.Labels)
} }
return client.ConfigCreateResult{ return types.ConfigCreateResponse{
ID: "ID-" + options.Spec.Name, ID: "ID-" + spec.Name,
}, nil }, nil
}, },
}) })
+33 -34
View File
@@ -1,19 +1,15 @@
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
//go:build go1.26
package config package config
import ( import (
"fmt" "fmt"
"slices"
"strings" "strings"
"time" "time"
"github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/command/formatter" "github.com/docker/cli/cli/command/formatter"
"github.com/docker/cli/cli/command/inspect" "github.com/docker/cli/cli/command/inspect"
"github.com/docker/go-units" "github.com/docker/docker/api/types/swarm"
"github.com/moby/moby/api/types/swarm" units "github.com/docker/go-units"
"github.com/moby/moby/client"
) )
const ( const (
@@ -34,8 +30,8 @@ Data:
{{.Data}}` {{.Data}}`
) )
// newFormat returns a Format for rendering using a configContext. // NewFormat returns a Format for rendering using a config Context
func newFormat(source string, quiet bool) formatter.Format { func NewFormat(source string, quiet bool) formatter.Format {
switch source { switch source {
case formatter.PrettyFormatKey: case formatter.PrettyFormatKey:
return configInspectPrettyTemplate return configInspectPrettyTemplate
@@ -48,28 +44,31 @@ func newFormat(source string, quiet bool) formatter.Format {
return formatter.Format(source) return formatter.Format(source)
} }
// formatWrite writes the context // FormatWrite writes the context
func formatWrite(fmtCtx formatter.Context, configs client.ConfigListResult) error { func FormatWrite(ctx formatter.Context, configs []swarm.Config) error {
cCtx := &configContext{ render := func(format func(subContext formatter.SubContext) error) error {
HeaderContext: formatter.HeaderContext{ for _, config := range configs {
Header: formatter.SubHeaderContext{
"ID": configIDHeader,
"Name": formatter.NameHeader,
"CreatedAt": configCreatedHeader,
"UpdatedAt": configUpdatedHeader,
"Labels": formatter.LabelsHeader,
},
},
}
return fmtCtx.Write(cCtx, func(format func(subContext formatter.SubContext) error) error {
for _, config := range configs.Items {
configCtx := &configContext{c: config} configCtx := &configContext{c: config}
if err := format(configCtx); err != nil { if err := format(configCtx); err != nil {
return err return err
} }
} }
return nil return nil
}) }
return ctx.Write(newConfigContext(), render)
}
func newConfigContext() *configContext {
cCtx := &configContext{}
cCtx.Header = formatter.SubHeaderContext{
"ID": configIDHeader,
"Name": formatter.NameHeader,
"CreatedAt": configCreatedHeader,
"UpdatedAt": configUpdatedHeader,
"Labels": formatter.LabelsHeader,
}
return cCtx
} }
type configContext struct { type configContext struct {
@@ -106,7 +105,6 @@ func (c *configContext) Labels() string {
for k, v := range mapLabels { for k, v := range mapLabels {
joinLabels = append(joinLabels, k+"="+v) joinLabels = append(joinLabels, k+"="+v)
} }
slices.Sort(joinLabels)
return strings.Join(joinLabels, ",") return strings.Join(joinLabels, ",")
} }
@@ -117,12 +115,12 @@ func (c *configContext) Label(name string) string {
return c.c.Spec.Annotations.Labels[name] return c.c.Spec.Annotations.Labels[name]
} }
// inspectFormatWrite renders the context for a list of configs // InspectFormatWrite renders the context for a list of configs
func inspectFormatWrite(fmtCtx formatter.Context, refs []string, getRef inspect.GetRefFunc) error { func InspectFormatWrite(ctx formatter.Context, refs []string, getRef inspect.GetRefFunc) error {
if fmtCtx.Format != configInspectPrettyTemplate { if ctx.Format != configInspectPrettyTemplate {
return inspect.Inspect(fmtCtx.Output, refs, string(fmtCtx.Format), getRef) return inspect.Inspect(ctx.Output, refs, string(ctx.Format), getRef)
} }
return fmtCtx.Write(&configInspectContext{}, func(format func(subContext formatter.SubContext) error) error { render := func(format func(subContext formatter.SubContext) error) error {
for _, ref := range refs { for _, ref := range refs {
configI, _, err := getRef(ref) configI, _, err := getRef(ref)
if err != nil { if err != nil {
@@ -137,7 +135,8 @@ func inspectFormatWrite(fmtCtx formatter.Context, refs []string, getRef inspect.
} }
} }
return nil return nil
}) }
return ctx.Write(&configInspectContext{}, render)
} }
type configInspectContext struct { type configInspectContext struct {
@@ -158,11 +157,11 @@ func (ctx *configInspectContext) Labels() map[string]string {
} }
func (ctx *configInspectContext) CreatedAt() string { func (ctx *configInspectContext) CreatedAt() string {
return formatter.PrettyPrint(ctx.Config.CreatedAt) return command.PrettyPrint(ctx.Config.CreatedAt)
} }
func (ctx *configInspectContext) UpdatedAt() string { func (ctx *configInspectContext) UpdatedAt() string {
return formatter.PrettyPrint(ctx.Config.UpdatedAt) return command.PrettyPrint(ctx.Config.UpdatedAt)
} }
func (ctx *configInspectContext) Data() string { func (ctx *configInspectContext) Data() string {
+16 -18
View File
@@ -6,8 +6,7 @@ import (
"time" "time"
"github.com/docker/cli/cli/command/formatter" "github.com/docker/cli/cli/command/formatter"
"github.com/moby/moby/api/types/swarm" "github.com/docker/docker/api/types/swarm"
"github.com/moby/moby/client"
"gotest.tools/v3/assert" "gotest.tools/v3/assert"
) )
@@ -28,46 +27,45 @@ func TestConfigContextFormatWrite(t *testing.T) {
}, },
// Table format // Table format
{ {
formatter.Context{Format: newFormat("table", false)}, formatter.Context{Format: NewFormat("table", false)},
`ID NAME CREATED UPDATED `ID NAME CREATED UPDATED
1 passwords Less than a second ago Less than a second ago 1 passwords Less than a second ago Less than a second ago
2 id_rsa Less than a second ago Less than a second ago 2 id_rsa Less than a second ago Less than a second ago
`, `,
}, },
{ {
formatter.Context{Format: newFormat("table {{.Name}}", true)}, formatter.Context{Format: NewFormat("table {{.Name}}", true)},
`NAME `NAME
passwords passwords
id_rsa id_rsa
`, `,
}, },
{ {
formatter.Context{Format: newFormat("{{.ID}}-{{.Name}}", false)}, formatter.Context{Format: NewFormat("{{.ID}}-{{.Name}}", false)},
`1-passwords `1-passwords
2-id_rsa 2-id_rsa
`, `,
}, },
} }
res := client.ConfigListResult{ configs := []swarm.Config{
Items: []swarm.Config{ {
{ ID: "1",
ID: "1", Meta: swarm.Meta{CreatedAt: time.Now(), UpdatedAt: time.Now()},
Meta: swarm.Meta{CreatedAt: time.Now(), UpdatedAt: time.Now()}, Spec: swarm.ConfigSpec{Annotations: swarm.Annotations{Name: "passwords"}},
Spec: swarm.ConfigSpec{Annotations: swarm.Annotations{Name: "passwords"}}, },
}, {
{ ID: "2",
ID: "2", Meta: swarm.Meta{CreatedAt: time.Now(), UpdatedAt: time.Now()},
Meta: swarm.Meta{CreatedAt: time.Now(), UpdatedAt: time.Now()}, Spec: swarm.ConfigSpec{Annotations: swarm.Annotations{Name: "id_rsa"}},
Spec: swarm.ConfigSpec{Annotations: swarm.Annotations{Name: "id_rsa"}},
},
}, },
} }
for _, tc := range cases { for _, tc := range cases {
tc := tc
t.Run(string(tc.context.Format), func(t *testing.T) { t.Run(string(tc.context.Format), func(t *testing.T) {
var out bytes.Buffer var out bytes.Buffer
tc.context.Output = &out tc.context.Output = &out
if err := formatWrite(tc.context, res); err != nil { if err := FormatWrite(tc.context, configs); err != nil {
assert.ErrorContains(t, err, tc.expected) assert.ErrorContains(t, err, tc.expected)
} else { } else {
assert.Equal(t, out.String(), tc.expected) assert.Equal(t, out.String(), tc.expected)
+26 -26
View File
@@ -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: // FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
//go:build go1.26 //go:build go1.19
package config package config
@@ -12,61 +12,61 @@ import (
"github.com/docker/cli/cli/command" "github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/command/formatter" "github.com/docker/cli/cli/command/formatter"
flagsHelper "github.com/docker/cli/cli/flags" flagsHelper "github.com/docker/cli/cli/flags"
"github.com/moby/moby/client"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
// inspectOptions contains options for the docker config inspect command. // InspectOptions contains options for the docker config inspect command.
type inspectOptions struct { type InspectOptions struct {
names []string Names []string
format string Format string
pretty bool Pretty bool
} }
func newConfigInspectCommand(dockerCLI command.Cli) *cobra.Command { func newConfigInspectCommand(dockerCli command.Cli) *cobra.Command {
opts := inspectOptions{} opts := InspectOptions{}
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "inspect [OPTIONS] CONFIG [CONFIG...]", Use: "inspect [OPTIONS] CONFIG [CONFIG...]",
Short: "Display detailed information on one or more configs", Short: "Display detailed information on one or more configs",
Args: cli.RequiresMinArgs(1), Args: cli.RequiresMinArgs(1),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
opts.names = args opts.Names = args
return runInspect(cmd.Context(), dockerCLI, opts) return RunConfigInspect(cmd.Context(), dockerCli, opts)
},
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return completeNames(dockerCli)(cmd, args, toComplete)
}, },
ValidArgsFunction: completeNames(dockerCLI),
DisableFlagsInUseLine: true,
} }
cmd.Flags().StringVarP(&opts.format, "format", "f", "", flagsHelper.InspectFormatHelp) cmd.Flags().StringVarP(&opts.Format, "format", "f", "", flagsHelper.InspectFormatHelp)
cmd.Flags().BoolVar(&opts.pretty, "pretty", false, "Print the information in a human friendly format") cmd.Flags().BoolVar(&opts.Pretty, "pretty", false, "Print the information in a human friendly format")
return cmd return cmd
} }
// runInspect inspects the given Swarm config. // RunConfigInspect inspects the given Swarm config.
func runInspect(ctx context.Context, dockerCLI command.Cli, opts inspectOptions) error { func RunConfigInspect(ctx context.Context, dockerCli command.Cli, opts InspectOptions) error {
apiClient := dockerCLI.Client() client := dockerCli.Client()
if opts.pretty { if opts.Pretty {
opts.format = "pretty" opts.Format = "pretty"
} }
getRef := func(id string) (any, []byte, error) { getRef := func(id string) (any, []byte, error) {
res, err := apiClient.ConfigInspect(ctx, id, client.ConfigInspectOptions{}) return client.ConfigInspectWithRaw(ctx, id)
return res.Config, res.Raw, err
} }
f := opts.Format
// check if the user is trying to apply a template to the pretty format, which // check if the user is trying to apply a template to the pretty format, which
// is not supported // is not supported
if strings.HasPrefix(opts.format, "pretty") && opts.format != "pretty" { if strings.HasPrefix(f, "pretty") && f != "pretty" {
return errors.New("cannot supply extra formatting options to the pretty template") return errors.New("cannot supply extra formatting options to the pretty template")
} }
configCtx := formatter.Context{ configCtx := formatter.Context{
Output: dockerCLI.Out(), Output: dockerCli.Out(),
Format: newFormat(opts.format, false), Format: NewFormat(f, false),
} }
if err := inspectFormatWrite(configCtx, opts.names, getRef); err != nil { if err := InspectFormatWrite(configCtx, opts.Names, getRef); err != nil {
return cli.StatusError{StatusCode: 1, Status: err.Error()} return cli.StatusError{StatusCode: 1, Status: err.Error()}
} }
return nil return nil
+33 -50
View File
@@ -2,7 +2,6 @@ package config
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"io" "io"
"testing" "testing"
@@ -10,7 +9,8 @@ import (
"github.com/docker/cli/internal/test" "github.com/docker/cli/internal/test"
"github.com/docker/cli/internal/test/builders" "github.com/docker/cli/internal/test/builders"
"github.com/moby/moby/client" "github.com/docker/docker/api/types/swarm"
"github.com/pkg/errors"
"gotest.tools/v3/assert" "gotest.tools/v3/assert"
"gotest.tools/v3/golden" "gotest.tools/v3/golden"
) )
@@ -19,7 +19,7 @@ func TestConfigInspectErrors(t *testing.T) {
testCases := []struct { testCases := []struct {
args []string args []string
flags map[string]string flags map[string]string
configInspectFunc func(_ context.Context, configID string, _ client.ConfigInspectOptions) (client.ConfigInspectResult, error) configInspectFunc func(_ context.Context, configID string) (swarm.Config, []byte, error)
expectedError string expectedError string
}{ }{
{ {
@@ -27,8 +27,8 @@ func TestConfigInspectErrors(t *testing.T) {
}, },
{ {
args: []string{"foo"}, args: []string{"foo"},
configInspectFunc: func(context.Context, string, client.ConfigInspectOptions) (client.ConfigInspectResult, error) { configInspectFunc: func(_ context.Context, configID string) (swarm.Config, []byte, error) {
return client.ConfigInspectResult{}, errors.New("error while inspecting the config") return swarm.Config{}, nil, errors.Errorf("error while inspecting the config")
}, },
expectedError: "error while inspecting the config", expectedError: "error while inspecting the config",
}, },
@@ -41,13 +41,11 @@ func TestConfigInspectErrors(t *testing.T) {
}, },
{ {
args: []string{"foo", "bar"}, args: []string{"foo", "bar"},
configInspectFunc: func(_ context.Context, configID string, _ client.ConfigInspectOptions) (client.ConfigInspectResult, error) { configInspectFunc: func(_ context.Context, configID string) (swarm.Config, []byte, error) {
if configID == "foo" { if configID == "foo" {
return client.ConfigInspectResult{ return *builders.Config(builders.ConfigName("foo")), nil, nil
Config: *builders.Config(builders.ConfigName("foo")),
}, nil
} }
return client.ConfigInspectResult{}, errors.New("error while inspecting the config") return swarm.Config{}, nil, errors.Errorf("error while inspecting the config")
}, },
expectedError: "error while inspecting the config", expectedError: "error while inspecting the config",
}, },
@@ -63,7 +61,6 @@ func TestConfigInspectErrors(t *testing.T) {
assert.Check(t, cmd.Flags().Set(key, value)) assert.Check(t, cmd.Flags().Set(key, value))
} }
cmd.SetOut(io.Discard) cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
assert.ErrorContains(t, cmd.Execute(), tc.expectedError) assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
} }
} }
@@ -72,34 +69,25 @@ func TestConfigInspectWithoutFormat(t *testing.T) {
testCases := []struct { testCases := []struct {
name string name string
args []string args []string
configInspectFunc func(_ context.Context, configID string, _ client.ConfigInspectOptions) (client.ConfigInspectResult, error) configInspectFunc func(_ context.Context, configID string) (swarm.Config, []byte, error)
}{ }{
{ {
name: "single-config", name: "single-config",
args: []string{"foo"}, args: []string{"foo"},
configInspectFunc: func(_ context.Context, name string, _ client.ConfigInspectOptions) (client.ConfigInspectResult, error) { configInspectFunc: func(_ context.Context, name string) (swarm.Config, []byte, error) {
if name != "foo" { if name != "foo" {
return client.ConfigInspectResult{}, fmt.Errorf("invalid name, expected %s, got %s", "foo", name) return swarm.Config{}, nil, errors.Errorf("Invalid name, expected %s, got %s", "foo", name)
} }
return client.ConfigInspectResult{ return *builders.Config(builders.ConfigID("ID-foo"), builders.ConfigName("foo")), nil, nil
Config: *builders.Config(
builders.ConfigID("ID-foo"),
builders.ConfigName("foo"),
),
}, nil
}, },
}, },
{ {
name: "multiple-configs-with-labels", name: "multiple-configs-with-labels",
args: []string{"foo", "bar"}, args: []string{"foo", "bar"},
configInspectFunc: func(_ context.Context, name string, _ client.ConfigInspectOptions) (client.ConfigInspectResult, error) { configInspectFunc: func(_ context.Context, name string) (swarm.Config, []byte, error) {
return client.ConfigInspectResult{ return *builders.Config(builders.ConfigID("ID-"+name), builders.ConfigName(name), builders.ConfigLabels(map[string]string{
Config: *builders.Config( "label1": "label-foo",
builders.ConfigID("ID-"+name), })), nil, nil
builders.ConfigName(name),
builders.ConfigLabels(map[string]string{"label1": "label-foo"}),
),
}, nil
}, },
}, },
} }
@@ -113,19 +101,16 @@ func TestConfigInspectWithoutFormat(t *testing.T) {
} }
func TestConfigInspectWithFormat(t *testing.T) { func TestConfigInspectWithFormat(t *testing.T) {
configInspectFunc := func(_ context.Context, name string, _ client.ConfigInspectOptions) (client.ConfigInspectResult, error) { configInspectFunc := func(_ context.Context, name string) (swarm.Config, []byte, error) {
return client.ConfigInspectResult{ return *builders.Config(builders.ConfigName("foo"), builders.ConfigLabels(map[string]string{
Config: *builders.Config( "label1": "label-foo",
builders.ConfigName("foo"), })), nil, nil
builders.ConfigLabels(map[string]string{"label1": "label-foo"}),
),
}, nil
} }
testCases := []struct { testCases := []struct {
name string name string
format string format string
args []string args []string
configInspectFunc func(_ context.Context, name string, _ client.ConfigInspectOptions) (client.ConfigInspectResult, error) configInspectFunc func(_ context.Context, name string) (swarm.Config, []byte, error)
}{ }{
{ {
name: "simple-template", name: "simple-template",
@@ -155,23 +140,21 @@ func TestConfigInspectWithFormat(t *testing.T) {
func TestConfigInspectPretty(t *testing.T) { func TestConfigInspectPretty(t *testing.T) {
testCases := []struct { testCases := []struct {
name string name string
configInspectFunc func(context.Context, string, client.ConfigInspectOptions) (client.ConfigInspectResult, error) configInspectFunc func(context.Context, string) (swarm.Config, []byte, error)
}{ }{
{ {
name: "simple", name: "simple",
configInspectFunc: func(_ context.Context, id string, _ client.ConfigInspectOptions) (client.ConfigInspectResult, error) { configInspectFunc: func(_ context.Context, id string) (swarm.Config, []byte, error) {
return client.ConfigInspectResult{ return *builders.Config(
Config: *builders.Config( builders.ConfigLabels(map[string]string{
builders.ConfigLabels(map[string]string{ "lbl1": "value1",
"lbl1": "value1", }),
}), builders.ConfigID("configID"),
builders.ConfigID("configID"), builders.ConfigName("configName"),
builders.ConfigName("configName"), builders.ConfigCreatedAt(time.Time{}),
builders.ConfigCreatedAt(time.Time{}), builders.ConfigUpdatedAt(time.Time{}),
builders.ConfigUpdatedAt(time.Time{}), builders.ConfigData([]byte("payload here")),
builders.ConfigData([]byte("payload here")), ), []byte{}, nil
),
}, nil
}, },
}, },
} }
+27 -31
View File
@@ -1,32 +1,29 @@
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
//go:build go1.26
package config package config
import ( import (
"context" "context"
"slices" "sort"
"github.com/docker/cli/cli" "github.com/docker/cli/cli"
"github.com/docker/cli/cli/command" "github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/command/completion"
"github.com/docker/cli/cli/command/formatter" "github.com/docker/cli/cli/command/formatter"
flagsHelper "github.com/docker/cli/cli/flags" flagsHelper "github.com/docker/cli/cli/flags"
"github.com/docker/cli/opts" "github.com/docker/cli/opts"
"github.com/docker/docker/api/types"
"github.com/fvbommel/sortorder" "github.com/fvbommel/sortorder"
"github.com/moby/moby/api/types/swarm"
"github.com/moby/moby/client"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
// listOptions contains options for the docker config ls command. // ListOptions contains options for the docker config ls command.
type listOptions struct { type ListOptions struct {
quiet bool Quiet bool
format string Format string
filter opts.FilterOpt Filter opts.FilterOpt
} }
func newConfigListCommand(dockerCLI command.Cli) *cobra.Command { func newConfigListCommand(dockerCli command.Cli) *cobra.Command {
listOpts := listOptions{filter: opts.NewFilterOpt()} listOpts := ListOptions{Filter: opts.NewFilterOpt()}
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "ls [OPTIONS]", Use: "ls [OPTIONS]",
@@ -34,45 +31,44 @@ func newConfigListCommand(dockerCLI command.Cli) *cobra.Command {
Short: "List configs", Short: "List configs",
Args: cli.NoArgs, Args: cli.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
return runList(cmd.Context(), dockerCLI, listOpts) return RunConfigList(cmd.Context(), dockerCli, listOpts)
}, },
ValidArgsFunction: cobra.NoFileCompletions, ValidArgsFunction: completion.NoComplete,
DisableFlagsInUseLine: true,
} }
flags := cmd.Flags() flags := cmd.Flags()
flags.BoolVarP(&listOpts.quiet, "quiet", "q", false, "Only display IDs") flags.BoolVarP(&listOpts.Quiet, "quiet", "q", false, "Only display IDs")
flags.StringVar(&listOpts.format, "format", "", flagsHelper.FormatHelp) flags.StringVar(&listOpts.Format, "format", "", flagsHelper.FormatHelp)
flags.VarP(&listOpts.filter, "filter", "f", "Filter output based on conditions provided") flags.VarP(&listOpts.Filter, "filter", "f", "Filter output based on conditions provided")
return cmd return cmd
} }
// runList lists Swarm configs. // RunConfigList lists Swarm configs.
func runList(ctx context.Context, dockerCLI command.Cli, options listOptions) error { func RunConfigList(ctx context.Context, dockerCli command.Cli, options ListOptions) error {
apiClient := dockerCLI.Client() client := dockerCli.Client()
res, err := apiClient.ConfigList(ctx, client.ConfigListOptions{Filters: options.filter.Value()}) configs, err := client.ConfigList(ctx, types.ConfigListOptions{Filters: options.Filter.Value()})
if err != nil { if err != nil {
return err return err
} }
format := options.format format := options.Format
if len(format) == 0 { if len(format) == 0 {
if len(dockerCLI.ConfigFile().ConfigFormat) > 0 && !options.quiet { if len(dockerCli.ConfigFile().ConfigFormat) > 0 && !options.Quiet {
format = dockerCLI.ConfigFile().ConfigFormat format = dockerCli.ConfigFile().ConfigFormat
} else { } else {
format = formatter.TableFormatKey format = formatter.TableFormatKey
} }
} }
slices.SortFunc(res.Items, func(a, b swarm.Config) int { sort.Slice(configs, func(i, j int) bool {
return sortorder.NaturalCompare(a.Spec.Name, b.Spec.Name) return sortorder.NaturalLess(configs[i].Spec.Name, configs[j].Spec.Name)
}) })
configCtx := formatter.Context{ configCtx := formatter.Context{
Output: dockerCLI.Out(), Output: dockerCli.Out(),
Format: newFormat(format, options.quiet), Format: NewFormat(format, options.Quiet),
} }
return formatWrite(configCtx, res) return FormatWrite(configCtx, configs)
} }
+61 -71
View File
@@ -2,7 +2,6 @@ package config
import ( import (
"context" "context"
"errors"
"io" "io"
"testing" "testing"
"time" "time"
@@ -10,16 +9,18 @@ import (
"github.com/docker/cli/cli/config/configfile" "github.com/docker/cli/cli/config/configfile"
"github.com/docker/cli/internal/test" "github.com/docker/cli/internal/test"
"github.com/docker/cli/internal/test/builders" "github.com/docker/cli/internal/test/builders"
"github.com/moby/moby/api/types/swarm" "github.com/docker/docker/api/types"
"github.com/moby/moby/client" "github.com/docker/docker/api/types/swarm"
"github.com/pkg/errors"
"gotest.tools/v3/assert" "gotest.tools/v3/assert"
is "gotest.tools/v3/assert/cmp"
"gotest.tools/v3/golden" "gotest.tools/v3/golden"
) )
func TestConfigListErrors(t *testing.T) { func TestConfigListErrors(t *testing.T) {
testCases := []struct { testCases := []struct {
args []string args []string
configListFunc func(context.Context, client.ConfigListOptions) (client.ConfigListResult, error) configListFunc func(context.Context, types.ConfigListOptions) ([]swarm.Config, error)
expectedError string expectedError string
}{ }{
{ {
@@ -27,8 +28,8 @@ func TestConfigListErrors(t *testing.T) {
expectedError: "accepts no argument", expectedError: "accepts no argument",
}, },
{ {
configListFunc: func(_ context.Context, options client.ConfigListOptions) (client.ConfigListResult, error) { configListFunc: func(_ context.Context, options types.ConfigListOptions) ([]swarm.Config, error) {
return client.ConfigListResult{}, errors.New("error listing configs") return []swarm.Config{}, errors.Errorf("error listing configs")
}, },
expectedError: "error listing configs", expectedError: "error listing configs",
}, },
@@ -41,35 +42,32 @@ func TestConfigListErrors(t *testing.T) {
) )
cmd.SetArgs(tc.args) cmd.SetArgs(tc.args)
cmd.SetOut(io.Discard) cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
assert.ErrorContains(t, cmd.Execute(), tc.expectedError) assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
} }
} }
func TestConfigList(t *testing.T) { func TestConfigList(t *testing.T) {
cli := test.NewFakeCli(&fakeClient{ cli := test.NewFakeCli(&fakeClient{
configListFunc: func(_ context.Context, options client.ConfigListOptions) (client.ConfigListResult, error) { configListFunc: func(_ context.Context, options types.ConfigListOptions) ([]swarm.Config, error) {
return client.ConfigListResult{ return []swarm.Config{
Items: []swarm.Config{ *builders.Config(builders.ConfigID("ID-1-foo"),
*builders.Config(builders.ConfigID("ID-1-foo"), builders.ConfigName("1-foo"),
builders.ConfigName("1-foo"), builders.ConfigVersion(swarm.Version{Index: 10}),
builders.ConfigVersion(swarm.Version{Index: 10}), builders.ConfigCreatedAt(time.Now().Add(-2*time.Hour)),
builders.ConfigCreatedAt(time.Now().Add(-2*time.Hour)), builders.ConfigUpdatedAt(time.Now().Add(-1*time.Hour)),
builders.ConfigUpdatedAt(time.Now().Add(-1*time.Hour)), ),
), *builders.Config(builders.ConfigID("ID-10-foo"),
*builders.Config(builders.ConfigID("ID-10-foo"), builders.ConfigName("10-foo"),
builders.ConfigName("10-foo"), builders.ConfigVersion(swarm.Version{Index: 11}),
builders.ConfigVersion(swarm.Version{Index: 11}), builders.ConfigCreatedAt(time.Now().Add(-2*time.Hour)),
builders.ConfigCreatedAt(time.Now().Add(-2*time.Hour)), builders.ConfigUpdatedAt(time.Now().Add(-1*time.Hour)),
builders.ConfigUpdatedAt(time.Now().Add(-1*time.Hour)), ),
), *builders.Config(builders.ConfigID("ID-2-foo"),
*builders.Config(builders.ConfigID("ID-2-foo"), builders.ConfigName("2-foo"),
builders.ConfigName("2-foo"), builders.ConfigVersion(swarm.Version{Index: 11}),
builders.ConfigVersion(swarm.Version{Index: 11}), builders.ConfigCreatedAt(time.Now().Add(-2*time.Hour)),
builders.ConfigCreatedAt(time.Now().Add(-2*time.Hour)), builders.ConfigUpdatedAt(time.Now().Add(-1*time.Hour)),
builders.ConfigUpdatedAt(time.Now().Add(-1*time.Hour)), ),
),
},
}, nil }, nil
}, },
}) })
@@ -80,14 +78,12 @@ func TestConfigList(t *testing.T) {
func TestConfigListWithQuietOption(t *testing.T) { func TestConfigListWithQuietOption(t *testing.T) {
cli := test.NewFakeCli(&fakeClient{ cli := test.NewFakeCli(&fakeClient{
configListFunc: func(_ context.Context, options client.ConfigListOptions) (client.ConfigListResult, error) { configListFunc: func(_ context.Context, options types.ConfigListOptions) ([]swarm.Config, error) {
return client.ConfigListResult{ return []swarm.Config{
Items: []swarm.Config{ *builders.Config(builders.ConfigID("ID-foo"), builders.ConfigName("foo")),
*builders.Config(builders.ConfigID("ID-foo"), builders.ConfigName("foo")), *builders.Config(builders.ConfigID("ID-bar"), builders.ConfigName("bar"), builders.ConfigLabels(map[string]string{
*builders.Config(builders.ConfigID("ID-bar"), builders.ConfigName("bar"), builders.ConfigLabels(map[string]string{ "label": "label-bar",
"label": "label-bar", })),
})),
},
}, nil }, nil
}, },
}) })
@@ -99,14 +95,12 @@ func TestConfigListWithQuietOption(t *testing.T) {
func TestConfigListWithConfigFormat(t *testing.T) { func TestConfigListWithConfigFormat(t *testing.T) {
cli := test.NewFakeCli(&fakeClient{ cli := test.NewFakeCli(&fakeClient{
configListFunc: func(_ context.Context, options client.ConfigListOptions) (client.ConfigListResult, error) { configListFunc: func(_ context.Context, options types.ConfigListOptions) ([]swarm.Config, error) {
return client.ConfigListResult{ return []swarm.Config{
Items: []swarm.Config{ *builders.Config(builders.ConfigID("ID-foo"), builders.ConfigName("foo")),
*builders.Config(builders.ConfigID("ID-foo"), builders.ConfigName("foo")), *builders.Config(builders.ConfigID("ID-bar"), builders.ConfigName("bar"), builders.ConfigLabels(map[string]string{
*builders.Config(builders.ConfigID("ID-bar"), builders.ConfigName("bar"), builders.ConfigLabels(map[string]string{ "label": "label-bar",
"label": "label-bar", })),
})),
},
}, nil }, nil
}, },
}) })
@@ -120,14 +114,12 @@ func TestConfigListWithConfigFormat(t *testing.T) {
func TestConfigListWithFormat(t *testing.T) { func TestConfigListWithFormat(t *testing.T) {
cli := test.NewFakeCli(&fakeClient{ cli := test.NewFakeCli(&fakeClient{
configListFunc: func(_ context.Context, options client.ConfigListOptions) (client.ConfigListResult, error) { configListFunc: func(_ context.Context, options types.ConfigListOptions) ([]swarm.Config, error) {
return client.ConfigListResult{ return []swarm.Config{
Items: []swarm.Config{ *builders.Config(builders.ConfigID("ID-foo"), builders.ConfigName("foo")),
*builders.Config(builders.ConfigID("ID-foo"), builders.ConfigName("foo")), *builders.Config(builders.ConfigID("ID-bar"), builders.ConfigName("bar"), builders.ConfigLabels(map[string]string{
*builders.Config(builders.ConfigID("ID-bar"), builders.ConfigName("bar"), builders.ConfigLabels(map[string]string{ "label": "label-bar",
"label": "label-bar", })),
})),
},
}, nil }, nil
}, },
}) })
@@ -139,24 +131,22 @@ func TestConfigListWithFormat(t *testing.T) {
func TestConfigListWithFilter(t *testing.T) { func TestConfigListWithFilter(t *testing.T) {
cli := test.NewFakeCli(&fakeClient{ cli := test.NewFakeCli(&fakeClient{
configListFunc: func(_ context.Context, options client.ConfigListOptions) (client.ConfigListResult, error) { configListFunc: func(_ context.Context, options types.ConfigListOptions) ([]swarm.Config, error) {
assert.Check(t, options.Filters["name"]["foo"]) assert.Check(t, is.Equal("foo", options.Filters.Get("name")[0]))
assert.Check(t, options.Filters["label"]["lbl1=Label-bar"]) assert.Check(t, is.Equal("lbl1=Label-bar", options.Filters.Get("label")[0]))
return client.ConfigListResult{ return []swarm.Config{
Items: []swarm.Config{ *builders.Config(builders.ConfigID("ID-foo"),
*builders.Config(builders.ConfigID("ID-foo"), builders.ConfigName("foo"),
builders.ConfigName("foo"), builders.ConfigVersion(swarm.Version{Index: 10}),
builders.ConfigVersion(swarm.Version{Index: 10}), builders.ConfigCreatedAt(time.Now().Add(-2*time.Hour)),
builders.ConfigCreatedAt(time.Now().Add(-2*time.Hour)), builders.ConfigUpdatedAt(time.Now().Add(-1*time.Hour)),
builders.ConfigUpdatedAt(time.Now().Add(-1*time.Hour)), ),
), *builders.Config(builders.ConfigID("ID-bar"),
*builders.Config(builders.ConfigID("ID-bar"), builders.ConfigName("bar"),
builders.ConfigName("bar"), builders.ConfigVersion(swarm.Version{Index: 11}),
builders.ConfigVersion(swarm.Version{Index: 11}), builders.ConfigCreatedAt(time.Now().Add(-2*time.Hour)),
builders.ConfigCreatedAt(time.Now().Add(-2*time.Hour)), builders.ConfigUpdatedAt(time.Now().Add(-1*time.Hour)),
builders.ConfigUpdatedAt(time.Now().Add(-1*time.Hour)), ),
),
},
}, nil }, nil
}, },
}) })
+30 -15
View File
@@ -2,41 +2,56 @@ package config
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"strings"
"github.com/docker/cli/cli" "github.com/docker/cli/cli"
"github.com/docker/cli/cli/command" "github.com/docker/cli/cli/command"
"github.com/moby/moby/client" "github.com/pkg/errors"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
func newConfigRemoveCommand(dockerCLI command.Cli) *cobra.Command { // RemoveOptions contains options for the docker config rm command.
type RemoveOptions struct {
Names []string
}
func newConfigRemoveCommand(dockerCli command.Cli) *cobra.Command {
return &cobra.Command{ return &cobra.Command{
Use: "rm CONFIG [CONFIG...]", Use: "rm CONFIG [CONFIG...]",
Aliases: []string{"remove"}, Aliases: []string{"remove"},
Short: "Remove one or more configs", Short: "Remove one or more configs",
Args: cli.RequiresMinArgs(1), Args: cli.RequiresMinArgs(1),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
return runRemove(cmd.Context(), dockerCLI, args) opts := RemoveOptions{
Names: args,
}
return RunConfigRemove(cmd.Context(), dockerCli, opts)
},
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return completeNames(dockerCli)(cmd, args, toComplete)
}, },
ValidArgsFunction: completeNames(dockerCLI),
DisableFlagsInUseLine: true,
} }
} }
// runRemove removes the given Swarm configs. // RunConfigRemove removes the given Swarm configs.
func runRemove(ctx context.Context, dockerCLI command.Cli, names []string) error { func RunConfigRemove(ctx context.Context, dockerCli command.Cli, opts RemoveOptions) error {
apiClient := dockerCLI.Client() client := dockerCli.Client()
var errs []error var errs []string
for _, name := range names {
if _, err := apiClient.ConfigRemove(ctx, name, client.ConfigRemoveOptions{}); err != nil { for _, name := range opts.Names {
errs = append(errs, err) if err := client.ConfigRemove(ctx, name); err != nil {
errs = append(errs, err.Error())
continue continue
} }
_, _ = fmt.Fprintln(dockerCLI.Out(), name)
fmt.Fprintln(dockerCli.Out(), name)
} }
return errors.Join(errs...) if len(errs) > 0 {
return errors.Errorf("%s", strings.Join(errs, "\n"))
}
return nil
} }
+10 -14
View File
@@ -1,14 +1,12 @@
package config package config
import ( import (
"context"
"errors"
"io" "io"
"strings" "strings"
"testing" "testing"
"github.com/docker/cli/internal/test" "github.com/docker/cli/internal/test"
"github.com/moby/moby/client" "github.com/pkg/errors"
"gotest.tools/v3/assert" "gotest.tools/v3/assert"
is "gotest.tools/v3/assert/cmp" is "gotest.tools/v3/assert/cmp"
) )
@@ -16,17 +14,17 @@ import (
func TestConfigRemoveErrors(t *testing.T) { func TestConfigRemoveErrors(t *testing.T) {
testCases := []struct { testCases := []struct {
args []string args []string
configRemoveFunc func(context.Context, string, client.ConfigRemoveOptions) (client.ConfigRemoveResult, error) configRemoveFunc func(string) error
expectedError string expectedError string
}{ }{
{ {
args: []string{}, args: []string{},
expectedError: "requires at least 1 argument", expectedError: "requires at least 1 argument.",
}, },
{ {
args: []string{"foo"}, args: []string{"foo"},
configRemoveFunc: func(ctx context.Context, name string, options client.ConfigRemoveOptions) (client.ConfigRemoveResult, error) { configRemoveFunc: func(name string) error {
return client.ConfigRemoveResult{}, errors.New("error removing config") return errors.Errorf("error removing config")
}, },
expectedError: "error removing config", expectedError: "error removing config",
}, },
@@ -39,7 +37,6 @@ func TestConfigRemoveErrors(t *testing.T) {
) )
cmd.SetArgs(tc.args) cmd.SetArgs(tc.args)
cmd.SetOut(io.Discard) cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
assert.ErrorContains(t, cmd.Execute(), tc.expectedError) assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
} }
} }
@@ -48,9 +45,9 @@ func TestConfigRemoveWithName(t *testing.T) {
names := []string{"foo", "bar"} names := []string{"foo", "bar"}
var removedConfigs []string var removedConfigs []string
cli := test.NewFakeCli(&fakeClient{ cli := test.NewFakeCli(&fakeClient{
configRemoveFunc: func(_ context.Context, name string, _ client.ConfigRemoveOptions) (client.ConfigRemoveResult, error) { configRemoveFunc: func(name string) error {
removedConfigs = append(removedConfigs, name) removedConfigs = append(removedConfigs, name)
return client.ConfigRemoveResult{}, nil return nil
}, },
}) })
cmd := newConfigRemoveCommand(cli) cmd := newConfigRemoveCommand(cli)
@@ -65,19 +62,18 @@ func TestConfigRemoveContinueAfterError(t *testing.T) {
var removedConfigs []string var removedConfigs []string
cli := test.NewFakeCli(&fakeClient{ cli := test.NewFakeCli(&fakeClient{
configRemoveFunc: func(_ context.Context, name string, _ client.ConfigRemoveOptions) (client.ConfigRemoveResult, error) { configRemoveFunc: func(name string) error {
removedConfigs = append(removedConfigs, name) removedConfigs = append(removedConfigs, name)
if name == "foo" { if name == "foo" {
return client.ConfigRemoveResult{}, errors.New("error removing config: " + name) return errors.Errorf("error removing config: %s", name)
} }
return client.ConfigRemoveResult{}, nil return nil
}, },
}) })
cmd := newConfigRemoveCommand(cli) cmd := newConfigRemoveCommand(cli)
cmd.SetArgs(names) cmd.SetArgs(names)
cmd.SetOut(io.Discard) cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
assert.Error(t, cmd.Execute(), "error removing config: foo") assert.Error(t, cmd.Execute(), "error removing config: foo")
assert.Check(t, is.DeepEqual(names, removedConfigs)) assert.Check(t, is.DeepEqual(names, removedConfigs))
} }
+38 -47
View File
@@ -2,15 +2,17 @@ package container
import ( import (
"context" "context"
"errors" "fmt"
"io" "io"
"github.com/docker/cli/cli" "github.com/docker/cli/cli"
"github.com/docker/cli/cli/command" "github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/command/completion" "github.com/docker/cli/cli/command/completion"
"github.com/moby/moby/api/types/container" "github.com/docker/docker/api/types"
"github.com/moby/moby/client" "github.com/docker/docker/api/types/container"
"github.com/docker/docker/client"
"github.com/moby/sys/signal" "github.com/moby/sys/signal"
"github.com/pkg/errors"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -22,26 +24,26 @@ type AttachOptions struct {
DetachKeys string DetachKeys string
} }
func inspectContainerAndCheckState(ctx context.Context, apiClient client.APIClient, args string) (*container.InspectResponse, error) { func inspectContainerAndCheckState(ctx context.Context, apiClient client.APIClient, args string) (*types.ContainerJSON, error) {
c, err := apiClient.ContainerInspect(ctx, args, client.ContainerInspectOptions{}) c, err := apiClient.ContainerInspect(ctx, args)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if !c.Container.State.Running { if !c.State.Running {
return nil, errors.New("cannot attach to a stopped container, start it first") return nil, errors.New("You cannot attach to a stopped container, start it first")
} }
if c.Container.State.Paused { if c.State.Paused {
return nil, errors.New("cannot attach to a paused container, unpause it first") return nil, errors.New("You cannot attach to a paused container, unpause it first")
} }
if c.Container.State.Restarting { if c.State.Restarting {
return nil, errors.New("cannot attach to a restarting container, wait until it is running") return nil, errors.New("You cannot attach to a restarting container, wait until it is running")
} }
return &c.Container, nil return &c, nil
} }
// newAttachCommand creates a new cobra.Command for `docker attach` // NewAttachCommand creates a new cobra.Command for `docker attach`
func newAttachCommand(dockerCLI command.Cli) *cobra.Command { func NewAttachCommand(dockerCLI command.Cli) *cobra.Command {
var opts AttachOptions var opts AttachOptions
cmd := &cobra.Command{ cmd := &cobra.Command{
@@ -55,10 +57,9 @@ func newAttachCommand(dockerCLI command.Cli) *cobra.Command {
Annotations: map[string]string{ Annotations: map[string]string{
"aliases": "docker container attach, docker attach", "aliases": "docker container attach, docker attach",
}, },
ValidArgsFunction: completion.ContainerNames(dockerCLI, false, func(ctr container.Summary) bool { ValidArgsFunction: completion.ContainerNames(dockerCLI, false, func(ctr types.Container) bool {
return ctr.State != container.StatePaused return ctr.State != "paused"
}), }),
DisableFlagsInUseLine: true,
} }
flags := cmd.Flags() flags := cmd.Flags()
@@ -70,19 +71,10 @@ func newAttachCommand(dockerCLI command.Cli) *cobra.Command {
// RunAttach executes an `attach` command // RunAttach executes an `attach` command
func RunAttach(ctx context.Context, dockerCLI command.Cli, containerID string, opts *AttachOptions) error { func RunAttach(ctx context.Context, dockerCLI command.Cli, containerID string, opts *AttachOptions) error {
detachKeys := opts.DetachKeys
if detachKeys == "" {
detachKeys = dockerCLI.ConfigFile().DetachKeys
}
if err := validateDetachKeys(detachKeys); err != nil {
return err
}
apiClient := dockerCLI.Client() apiClient := dockerCLI.Client()
// request channel to wait for client // request channel to wait for client
waitCtx := context.WithoutCancel(ctx) resultC, errC := apiClient.ContainerWait(ctx, containerID, "")
waitRes := apiClient.ContainerWait(waitCtx, containerID, client.ContainerWaitOptions{})
c, err := inspectContainerAndCheckState(ctx, apiClient, containerID) c, err := inspectContainerAndCheckState(ctx, apiClient, containerID)
if err != nil { if err != nil {
@@ -93,7 +85,12 @@ func RunAttach(ctx context.Context, dockerCLI command.Cli, containerID string, o
return err return err
} }
options := client.ContainerAttachOptions{ detachKeys := dockerCLI.ConfigFile().DetachKeys
if opts.DetachKeys != "" {
detachKeys = opts.DetachKeys
}
options := container.AttachOptions{
Stream: true, Stream: true,
Stdin: !opts.NoStdin && c.Config.OpenStdin, Stdin: !opts.NoStdin && c.Config.OpenStdin,
Stdout: true, Stdout: true,
@@ -108,20 +105,15 @@ func RunAttach(ctx context.Context, dockerCLI command.Cli, containerID string, o
if opts.Proxy && !c.Config.Tty { if opts.Proxy && !c.Config.Tty {
sigc := notifyAllSignals() sigc := notifyAllSignals()
// since we're explicitly setting up signal handling here, and the daemon will go ForwardAllSignals(ctx, apiClient, containerID, sigc)
// get notified independently of the clients ctx cancellation, we use this context
// but without cancellation to avoid ForwardAllSignals from returning
// before all signals are forwarded.
bgCtx := context.WithoutCancel(ctx)
go ForwardAllSignals(bgCtx, apiClient, containerID, sigc)
defer signal.StopCatch(sigc) defer signal.StopCatch(sigc)
} }
res, err := apiClient.ContainerAttach(ctx, containerID, options) resp, errAttach := apiClient.ContainerAttach(ctx, containerID, options)
if err != nil { if errAttach != nil {
return err return errAttach
} }
defer res.HijackedResponse.Close() defer resp.Close()
// If use docker attach command to attach to a stop container, it will return // If use docker attach command to attach to a stop container, it will return
// "You cannot attach to a stopped container" error, it's ok, but when // "You cannot attach to a stopped container" error, it's ok, but when
@@ -145,29 +137,28 @@ func RunAttach(ctx context.Context, dockerCLI command.Cli, containerID string, o
inputStream: in, inputStream: in,
outputStream: dockerCLI.Out(), outputStream: dockerCLI.Out(),
errorStream: dockerCLI.Err(), errorStream: dockerCLI.Err(),
resp: res.HijackedResponse, resp: resp,
tty: c.Config.Tty, tty: c.Config.Tty,
detachKeys: options.DetachKeys, detachKeys: options.DetachKeys,
} }
// if the context was canceled, this was likely intentional and we shouldn't return an error if err := streamer.stream(ctx); err != nil {
if err := streamer.stream(ctx); err != nil && !errors.Is(err, context.Canceled) {
return err return err
} }
return getExitStatus(waitRes) return getExitStatus(errC, resultC)
} }
func getExitStatus(waitRes client.ContainerWaitResult) error { func getExitStatus(errC <-chan error, resultC <-chan container.WaitResponse) error {
select { select {
case result := <-waitRes.Result: case result := <-resultC:
if result.Error != nil { if result.Error != nil {
return errors.New(result.Error.Message) return fmt.Errorf(result.Error.Message)
} }
if result.StatusCode != 0 { if result.StatusCode != 0 {
return cli.StatusError{StatusCode: int(result.StatusCode)} return cli.StatusError{StatusCode: int(result.StatusCode)}
} }
case err := <-waitRes.Error: case err := <-errC:
return err return err
} }
@@ -180,7 +171,7 @@ func resizeTTY(ctx context.Context, dockerCli command.Cli, containerID string) {
// terminal, the only way to get the shell prompt to display for attaches 2+ is to artificially // terminal, the only way to get the shell prompt to display for attaches 2+ is to artificially
// resize it, then go back to normal. Without this, every attach after the first will // resize it, then go back to normal. Without this, every attach after the first will
// require the user to manually resize or hit enter. // require the user to manually resize or hit enter.
resizeTTYTo(ctx, dockerCli.Client(), containerID, height+1, width+1, false) resizeTtyTo(ctx, dockerCli.Client(), containerID, height+1, width+1, false)
// After the above resizing occurs, the call to MonitorTtySize below will handle resetting back // After the above resizing occurs, the call to MonitorTtySize below will handle resetting back
// to the actual size. // to the actual size.

Some files were not shown because too many files have changed in this diff Show More