mirror of
https://github.com/docker/compose.git
synced 2026-02-10 02:29:25 +08:00
Compare commits
86 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7abaa06617 | ||
|
|
3b0e8f538e | ||
|
|
af376603c3 | ||
|
|
7f8814f4c5 | ||
|
|
af0029afe1 | ||
|
|
b76feb66e1 | ||
|
|
9dc7f1e70c | ||
|
|
03205124fe | ||
|
|
8b769bad6b | ||
|
|
671507a8b3 | ||
|
|
56ab28aef3 | ||
|
|
e7d870a106 | ||
|
|
d5bb3387ca | ||
|
|
d91fc63813 | ||
|
|
c51b1fea29 | ||
|
|
fa7549a851 | ||
|
|
a061c17736 | ||
|
|
c5e7d9158c | ||
|
|
3783b8ada3 | ||
|
|
c428a77111 | ||
|
|
04b4a832dc | ||
|
|
27faa3b84e | ||
|
|
bcc0401e0e | ||
|
|
093205121c | ||
|
|
b92b87dd9c | ||
|
|
06e1287483 | ||
|
|
d7bdb34ff5 | ||
|
|
79d7a8acd6 | ||
|
|
abd99be4fd | ||
|
|
2672d34217 | ||
|
|
27bf40357a | ||
|
|
c8d687599a | ||
|
|
2f108ffaa8 | ||
|
|
0a07df0e5b | ||
|
|
02b606ef8e | ||
|
|
9856802945 | ||
|
|
63ae7eb0fa | ||
|
|
f17d0dfc61 | ||
|
|
ef14cfcfea | ||
|
|
b760afaf9f | ||
|
|
a2a5c86f53 | ||
|
|
98e82127b3 | ||
|
|
03e19e4a84 | ||
|
|
b2c17ff118 | ||
|
|
ec88588cd8 | ||
|
|
7d5913403a | ||
|
|
d95aa57f01 | ||
|
|
ee4c01b66b | ||
|
|
d7a65f53f8 | ||
|
|
4520bcbaf6 | ||
|
|
327be1fcd5 | ||
|
|
59f04b85af | ||
|
|
b4574c8bd6 | ||
|
|
29d6c918c4 | ||
|
|
58403169f3 | ||
|
|
6aee7f8370 | ||
|
|
c89b8a2d6b | ||
|
|
aec9f54176 | ||
|
|
232197d364 | ||
|
|
81ba889bee | ||
|
|
8e5b25c0f1 | ||
|
|
d4c1987638 | ||
|
|
1297f97aef | ||
|
|
55cded1806 | ||
|
|
6c043929a0 | ||
|
|
2750330566 | ||
|
|
e22426443e | ||
|
|
6599f8ad84 | ||
|
|
3853ad3911 | ||
|
|
02008a0097 | ||
|
|
4f419e5098 | ||
|
|
b62cbed87c | ||
|
|
aa9a71f37a | ||
|
|
ac211e6e51 | ||
|
|
778a627b8e | ||
|
|
359d2f076e | ||
|
|
c9e0d83e14 | ||
|
|
3e206fdcc6 | ||
|
|
d12947e9f8 | ||
|
|
0878c59a74 | ||
|
|
c0345e4f45 | ||
|
|
9fada6cc23 | ||
|
|
85ea24b62c | ||
|
|
000a4a4b9f | ||
|
|
08de90c267 | ||
|
|
cfcee45a89 |
161
.github/workflows/ci.yml
vendored
161
.github/workflows/ci.yml
vendored
@@ -22,24 +22,6 @@ permissions:
|
||||
contents: read # to fetch code (actions/checkout)
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
matrix: ${{ steps.platforms.outputs.matrix }}
|
||||
steps:
|
||||
-
|
||||
name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
-
|
||||
name: Create matrix
|
||||
id: platforms
|
||||
run: |
|
||||
echo matrix=$(docker buildx bake binary-cross --print | jq -cr '.target."binary-cross".platforms') >> $GITHUB_OUTPUT
|
||||
-
|
||||
name: Show matrix
|
||||
run: |
|
||||
echo ${{ steps.platforms.outputs.matrix }}
|
||||
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
@@ -63,63 +45,88 @@ jobs:
|
||||
make ${{ matrix.target }}
|
||||
|
||||
binary:
|
||||
uses: docker/github-builder/.github/workflows/bake.yml@v1
|
||||
permissions:
|
||||
contents: read # same as global permission
|
||||
id-token: write # for signing attestation(s) with GitHub OIDC Token
|
||||
with:
|
||||
runner: amd64
|
||||
artifact-name: compose
|
||||
artifact-upload: true
|
||||
cache: true
|
||||
cache-scope: binary
|
||||
target: release
|
||||
output: local
|
||||
sbom: true
|
||||
sign: ${{ github.event_name != 'pull_request' }}
|
||||
|
||||
binary-finalize:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- prepare
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
platform: ${{ fromJson(needs.prepare.outputs.matrix) }}
|
||||
- binary
|
||||
steps:
|
||||
-
|
||||
name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
-
|
||||
name: Prepare
|
||||
run: |
|
||||
platform=${MATRIX_PLATFORM}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
env:
|
||||
MATRIX_PLATFORM: ${{ matrix.platform }}
|
||||
-
|
||||
name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
-
|
||||
name: Build
|
||||
uses: docker/bake-action@v6
|
||||
name: Download artifacts
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
source: .
|
||||
targets: release
|
||||
provenance: mode=max
|
||||
sbom: true
|
||||
set: |
|
||||
*.platform=${{ matrix.platform }}
|
||||
*.cache-from=type=gha,scope=binary-${{ env.PLATFORM_PAIR }}
|
||||
*.cache-to=type=gha,scope=binary-${{ env.PLATFORM_PAIR }},mode=max
|
||||
path: /tmp/compose-output
|
||||
name: ${{ needs.binary.outputs.artifact-name }}
|
||||
-
|
||||
name: Rename provenance and sbom
|
||||
run: |
|
||||
for pdir in /tmp/compose-output/*/; do
|
||||
(
|
||||
cd "$pdir"
|
||||
binname=$(find . -name 'docker-compose-*')
|
||||
filename=$(basename "${binname%.exe}")
|
||||
mv "provenance.json" "${filename}.provenance.json"
|
||||
mv "sbom-binary.spdx.json" "${filename}.sbom.json"
|
||||
find . -name 'sbom*.json' -exec rm {} \;
|
||||
if [ -f "provenance.sigstore.json" ]; then
|
||||
mv "provenance.sigstore.json" "${filename}.sigstore.json"
|
||||
fi
|
||||
)
|
||||
done
|
||||
mkdir -p "./bin/release"
|
||||
mv /tmp/compose-output/**/* "./bin/release/"
|
||||
-
|
||||
name: Create checksum file
|
||||
working-directory: ./bin/release
|
||||
run: |
|
||||
binname=$(find . -name 'docker-compose-*')
|
||||
filename=$(basename "$binname" | sed -E 's/\.exe$//')
|
||||
mv "provenance.json" "${filename}.provenance.json"
|
||||
mv "sbom-binary.spdx.json" "${filename}.sbom.json"
|
||||
find . -name 'sbom*.json' -exec rm {} \;
|
||||
-
|
||||
name: List artifacts
|
||||
run: |
|
||||
tree -nh ./bin/release
|
||||
find . -type f -print0 | sort -z | xargs -r0 shasum -a 256 -b | sed 's# \*\./# *#' > $RUNNER_TEMP/checksums.txt
|
||||
shasum -a 256 -U -c $RUNNER_TEMP/checksums.txt
|
||||
mv $RUNNER_TEMP/checksums.txt .
|
||||
cat checksums.txt | while read sum file; do
|
||||
if [[ "${file#\*}" == docker-compose-* && "${file#\*}" != *.provenance.json && "${file#\*}" != *.sbom.json && "${file#\*}" != *.sigstore.json ]]; then
|
||||
echo "$sum $file" > ${file#\*}.sha256
|
||||
fi
|
||||
done
|
||||
-
|
||||
name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: compose-${{ env.PLATFORM_PAIR }}
|
||||
path: ./bin/release
|
||||
name: release
|
||||
path: ./bin/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
bin-image-test:
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: docker/github-builder/.github/workflows/bake.yml@v1
|
||||
with:
|
||||
runner: amd64
|
||||
target: image-cross
|
||||
cache: true
|
||||
cache-scope: bin-image-test
|
||||
output: image
|
||||
push: false
|
||||
sbom: true
|
||||
set-meta-labels: true
|
||||
meta-images: |
|
||||
compose-bin
|
||||
meta-tags: |
|
||||
type=ref,event=pr
|
||||
meta-bake-target: meta-helper
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -147,6 +154,7 @@ jobs:
|
||||
with:
|
||||
paths: bin/coverage/unit/report.xml
|
||||
if: always()
|
||||
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
name: e2e (${{ matrix.mode }}, ${{ matrix.channel }})
|
||||
@@ -199,9 +207,9 @@ jobs:
|
||||
docker model version
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
go-version-file: '.go-version'
|
||||
check-latest: true
|
||||
cache: true
|
||||
|
||||
@@ -254,6 +262,7 @@ jobs:
|
||||
with:
|
||||
paths: /tmp/report/report.xml
|
||||
if: always()
|
||||
|
||||
coverage:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
@@ -264,9 +273,9 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
go-version-file: '.go-version'
|
||||
check-latest: true
|
||||
- name: Download unit test coverage
|
||||
uses: actions/download-artifact@v4
|
||||
@@ -290,40 +299,26 @@ jobs:
|
||||
path: ./coverage.txt
|
||||
if-no-files-found: error
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
files: ./coverage.txt
|
||||
|
||||
release:
|
||||
permissions:
|
||||
contents: write # to create a release (ncipollo/release-action)
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- binary
|
||||
- binary-finalize
|
||||
steps:
|
||||
-
|
||||
name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
-
|
||||
name: Download artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
pattern: compose-*
|
||||
path: ./bin/release
|
||||
merge-multiple: true
|
||||
-
|
||||
name: Create checksums
|
||||
working-directory: ./bin/release
|
||||
run: |
|
||||
find . -type f -print0 | sort -z | xargs -r0 shasum -a 256 -b | sed 's# \*\./# *#' > $RUNNER_TEMP/checksums.txt
|
||||
shasum -a 256 -U -c $RUNNER_TEMP/checksums.txt
|
||||
mv $RUNNER_TEMP/checksums.txt .
|
||||
cat checksums.txt | while read sum file; do
|
||||
if [[ "${file#\*}" == docker-compose-* && "${file#\*}" != *.provenance.json && "${file#\*}" != *.sbom.json ]]; then
|
||||
echo "$sum $file" > ${file#\*}.sha256
|
||||
fi
|
||||
done
|
||||
name: release
|
||||
-
|
||||
name: List artifacts
|
||||
run: |
|
||||
|
||||
90
.github/workflows/merge.yml
vendored
90
.github/workflows/merge.yml
vendored
@@ -33,9 +33,9 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
go-version-file: '.go-version'
|
||||
cache: true
|
||||
check-latest: true
|
||||
|
||||
@@ -74,63 +74,41 @@ jobs:
|
||||
run: |
|
||||
make e2e-compose-standalone
|
||||
|
||||
bin-image:
|
||||
runs-on: ubuntu-22.04
|
||||
bin-image-prepare:
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
digest: ${{ fromJSON(steps.bake.outputs.metadata).image-cross['containerimage.digest'] }}
|
||||
repo-slug: ${{ env.REPO_SLUG }}
|
||||
steps:
|
||||
-
|
||||
name: Free disk space
|
||||
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1
|
||||
with:
|
||||
android: true
|
||||
dotnet: true
|
||||
haskell: true
|
||||
large-packages: true
|
||||
swap-storage: true
|
||||
-
|
||||
name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
# FIXME: can't use env object in reusable workflow inputs: https://github.com/orgs/community/discussions/26671
|
||||
- run: echo "Exposing env vars for reusable workflow"
|
||||
|
||||
bin-image:
|
||||
uses: docker/github-builder/.github/workflows/bake.yml@v1
|
||||
needs:
|
||||
- bin-image-prepare
|
||||
permissions:
|
||||
contents: read # same as global permission
|
||||
id-token: write # for signing attestation(s) with GitHub OIDC Token
|
||||
with:
|
||||
runner: amd64
|
||||
target: image-cross
|
||||
cache: true
|
||||
cache-scope: bin-image
|
||||
output: image
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
sbom: true
|
||||
set-meta-labels: true
|
||||
meta-images: |
|
||||
${{ needs.bin-image-prepare.outputs.repo-slug }}
|
||||
meta-tags: |
|
||||
type=ref,event=tag
|
||||
type=edge
|
||||
meta-bake-target: meta-helper
|
||||
secrets:
|
||||
registry-auths: |
|
||||
- registry: docker.io
|
||||
username: ${{ secrets.DOCKERPUBLICBOT_USERNAME }}
|
||||
password: ${{ secrets.DOCKERPUBLICBOT_WRITE_PAT }}
|
||||
-
|
||||
name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.REPO_SLUG }}
|
||||
tags: |
|
||||
type=ref,event=tag
|
||||
type=edge
|
||||
bake-target: meta-helper
|
||||
-
|
||||
name: Build and push image
|
||||
uses: docker/bake-action@v6
|
||||
id: bake
|
||||
with:
|
||||
source: .
|
||||
files: |
|
||||
./docker-bake.hcl
|
||||
${{ steps.meta.outputs.bake-file }}
|
||||
targets: image-cross
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
sbom: true
|
||||
provenance: mode=max
|
||||
set: |
|
||||
*.cache-from=type=gha,scope=bin-image
|
||||
*.cache-to=type=gha,scope=bin-image,mode=max
|
||||
|
||||
desktop-edge-test:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -158,6 +136,6 @@ jobs:
|
||||
workflow_id: 'compose-edge-integration.yml',
|
||||
ref: 'main',
|
||||
inputs: {
|
||||
"image-tag": "${{ needs.bin-image.outputs.digest }}"
|
||||
"image-tag": "${{ env.REPO_SLUG }}:edge"
|
||||
}
|
||||
})
|
||||
|
||||
1
.go-version
Normal file
1
.go-version
Normal file
@@ -0,0 +1 @@
|
||||
1.25.7
|
||||
@@ -8,6 +8,7 @@ linters:
|
||||
- depguard
|
||||
- errcheck
|
||||
- errorlint
|
||||
- forbidigo
|
||||
- gocritic
|
||||
- gocyclo
|
||||
- gomodguard
|
||||
@@ -38,6 +39,15 @@ linters:
|
||||
desc: use stdlib slices package
|
||||
- pkg: gopkg.in/yaml.v2
|
||||
desc: compose-go uses yaml.v3
|
||||
forbidigo:
|
||||
analyze-types: true
|
||||
forbid:
|
||||
- pattern: 'context\.Background'
|
||||
pkg: '^context$'
|
||||
msg: "in tests, use t.Context() instead of context.Background()"
|
||||
- pattern: 'context\.TODO'
|
||||
pkg: '^context$'
|
||||
msg: "in tests, use t.Context() instead of context.TODO()"
|
||||
gocritic:
|
||||
disabled-checks:
|
||||
- paramTypeCombine
|
||||
@@ -74,6 +84,10 @@ linters:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
rules:
|
||||
- path-except: '_test\.go'
|
||||
linters:
|
||||
- forbidigo
|
||||
issues:
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
|
||||
12
Dockerfile
12
Dockerfile
@@ -15,9 +15,9 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
ARG GO_VERSION=1.24.9
|
||||
ARG XX_VERSION=1.6.1
|
||||
ARG GOLANGCI_LINT_VERSION=v2.6.2
|
||||
ARG GO_VERSION=1.25.7
|
||||
ARG XX_VERSION=1.9.0
|
||||
ARG GOLANGCI_LINT_VERSION=v2.8.0
|
||||
ARG ADDLICENSE_VERSION=v1.0.0
|
||||
|
||||
ARG BUILD_TAGS="e2e"
|
||||
@@ -28,12 +28,12 @@ ARG LICENSE_FILES=".*\(Dockerfile\|Makefile\|\.go\|\.hcl\|\.sh\)"
|
||||
FROM --platform=${BUILDPLATFORM} tonistiigi/xx:${XX_VERSION} AS xx
|
||||
|
||||
# osxcross contains the MacOSX cross toolchain for xx
|
||||
FROM crazymax/osxcross:11.3-alpine AS osxcross
|
||||
FROM crazymax/osxcross:15.5-alpine AS osxcross
|
||||
|
||||
FROM golangci/golangci-lint:${GOLANGCI_LINT_VERSION}-alpine AS golangci-lint
|
||||
FROM ghcr.io/google/addlicense:${ADDLICENSE_VERSION} AS addlicense
|
||||
|
||||
FROM --platform=${BUILDPLATFORM} golang:${GO_VERSION}-alpine AS base
|
||||
FROM --platform=${BUILDPLATFORM} golang:${GO_VERSION}-alpine3.22 AS base
|
||||
COPY --from=xx / /
|
||||
RUN apk add --no-cache \
|
||||
clang \
|
||||
@@ -83,7 +83,7 @@ RUN --mount=type=bind,target=. \
|
||||
--mount=type=cache,target=/go/pkg/mod \
|
||||
--mount=type=bind,from=osxcross,src=/osxsdk,target=/xx-sdk \
|
||||
xx-go --wrap && \
|
||||
if [ "$(xx-info os)" == "darwin" ]; then export CGO_ENABLED=1; fi && \
|
||||
if [ "$(xx-info os)" == "darwin" ]; then export CGO_ENABLED=1; export BUILD_TAGS=fsnotify,$BUILD_TAGS; fi && \
|
||||
make build GO_BUILDTAGS="$BUILD_TAGS" DESTDIR=/out && \
|
||||
xx-verify --static /out/docker-compose
|
||||
|
||||
|
||||
4
Makefile
4
Makefile
@@ -62,11 +62,11 @@ build:
|
||||
|
||||
.PHONY: binary
|
||||
binary:
|
||||
$(BUILDX_CMD) bake binary
|
||||
BUILD_TAGS="$(GO_BUILDTAGS)" $(BUILDX_CMD) bake binary
|
||||
|
||||
.PHONY: binary-with-coverage
|
||||
binary-with-coverage:
|
||||
$(BUILDX_CMD) bake binary-with-coverage
|
||||
BUILD_TAGS="$(GO_BUILDTAGS)" $(BUILDX_CMD) bake binary-with-coverage
|
||||
|
||||
.PHONY: install
|
||||
install: binary
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
# Table of Contents
|
||||
- [Docker Compose v2](#docker-compose-v2)
|
||||
- [Docker Compose](#docker-compose)
|
||||
- [Where to get Docker Compose](#where-to-get-docker-compose)
|
||||
+ [Windows and macOS](#windows-and-macos)
|
||||
+ [Linux](#linux)
|
||||
- [Quick Start](#quick-start)
|
||||
- [Contributing](#contributing)
|
||||
- [Legacy](#legacy)
|
||||
# Docker Compose v2
|
||||
|
||||
# Docker Compose
|
||||
|
||||
[](https://github.com/docker/compose/releases/latest)
|
||||
[](https://pkg.go.dev/github.com/docker/compose/v5)
|
||||
|
||||
@@ -32,6 +32,7 @@ import (
|
||||
"github.com/compose-spec/compose-go/v2/cli"
|
||||
"github.com/compose-spec/compose-go/v2/dotenv"
|
||||
"github.com/compose-spec/compose-go/v2/loader"
|
||||
composepaths "github.com/compose-spec/compose-go/v2/paths"
|
||||
"github.com/compose-spec/compose-go/v2/types"
|
||||
composegoutils "github.com/compose-spec/compose-go/v2/utils"
|
||||
"github.com/docker/buildx/util/logutil"
|
||||
@@ -59,7 +60,7 @@ const (
|
||||
// ComposeProjectName define the project name to be used, instead of guessing from parent directory
|
||||
ComposeProjectName = "COMPOSE_PROJECT_NAME"
|
||||
// ComposeCompatibility try to mimic compose v1 as much as possible
|
||||
ComposeCompatibility = "COMPOSE_COMPATIBILITY"
|
||||
ComposeCompatibility = api.ComposeCompatibility
|
||||
// ComposeRemoveOrphans remove "orphaned" containers, i.e. containers tagged for current project but not declared as service
|
||||
ComposeRemoveOrphans = "COMPOSE_REMOVE_ORPHANS"
|
||||
// ComposeIgnoreOrphans ignore "orphaned" containers
|
||||
@@ -476,7 +477,7 @@ func RootCommand(dockerCli command.Cli, backendOptions *BackendOptions) *cobra.C
|
||||
logrus.SetLevel(logrus.TraceLevel)
|
||||
}
|
||||
|
||||
err := setEnvWithDotEnv(opts)
|
||||
err := setEnvWithDotEnv(opts, dockerCli)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -504,6 +505,7 @@ func RootCommand(dockerCli command.Cli, backendOptions *BackendOptions) *cobra.C
|
||||
display.Mode = display.ModeTTY
|
||||
}
|
||||
|
||||
detached, _ := cmd.Flags().GetBool("detach")
|
||||
var ep api.EventProcessor
|
||||
switch opts.Progress {
|
||||
case "", display.ModeAuto:
|
||||
@@ -512,7 +514,7 @@ func RootCommand(dockerCli command.Cli, backendOptions *BackendOptions) *cobra.C
|
||||
display.Mode = display.ModePlain
|
||||
ep = display.Plain(dockerCli.Err())
|
||||
case dockerCli.Out().IsTerminal():
|
||||
ep = display.Full(dockerCli.Err(), stdinfo(dockerCli))
|
||||
ep = display.Full(dockerCli.Err(), stdinfo(dockerCli), detached)
|
||||
default:
|
||||
ep = display.Plain(dockerCli.Err())
|
||||
}
|
||||
@@ -521,7 +523,7 @@ func RootCommand(dockerCli command.Cli, backendOptions *BackendOptions) *cobra.C
|
||||
return fmt.Errorf("can't use --progress tty while ANSI support is disabled")
|
||||
}
|
||||
display.Mode = display.ModeTTY
|
||||
ep = display.Full(dockerCli.Err(), stdinfo(dockerCli))
|
||||
ep = display.Full(dockerCli.Err(), stdinfo(dockerCli), detached)
|
||||
|
||||
case display.ModePlain:
|
||||
if ansi == "always" {
|
||||
@@ -550,12 +552,15 @@ func RootCommand(dockerCli command.Cli, backendOptions *BackendOptions) *cobra.C
|
||||
fmt.Fprint(os.Stderr, aec.Apply("option '--workdir' is DEPRECATED at root level! Please use '--project-directory' instead.\n", aec.RedF))
|
||||
}
|
||||
for i, file := range opts.EnvFiles {
|
||||
file = composepaths.ExpandUser(file)
|
||||
if !filepath.IsAbs(file) {
|
||||
file, err := filepath.Abs(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts.EnvFiles[i] = file
|
||||
} else {
|
||||
opts.EnvFiles[i] = file
|
||||
}
|
||||
}
|
||||
|
||||
@@ -672,7 +677,21 @@ func stdinfo(dockerCli command.Cli) io.Writer {
|
||||
return dockerCli.Err()
|
||||
}
|
||||
|
||||
func setEnvWithDotEnv(opts ProjectOptions) error {
|
||||
func setEnvWithDotEnv(opts ProjectOptions, dockerCli command.Cli) error {
|
||||
// Check if we're using a remote config (OCI or Git)
|
||||
// If so, skip env loading as remote loaders haven't been initialized yet
|
||||
// and trying to process the path would fail
|
||||
remoteLoaders := opts.remoteLoaders(dockerCli)
|
||||
for _, path := range opts.ConfigPaths {
|
||||
for _, loader := range remoteLoaders {
|
||||
if loader.Accept(path) {
|
||||
// Remote config - skip env loading for now
|
||||
// It will be loaded later when the project is fully initialized
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
options, err := cli.NewProjectOptions(opts.ConfigPaths,
|
||||
cli.WithWorkingDirectory(opts.ProjectDir),
|
||||
cli.WithOsEnv,
|
||||
@@ -680,20 +699,20 @@ func setEnvWithDotEnv(opts ProjectOptions) error {
|
||||
cli.WithDotEnv,
|
||||
)
|
||||
if err != nil {
|
||||
return nil
|
||||
return err
|
||||
}
|
||||
envFromFile, err := dotenv.GetEnvFromFile(composegoutils.GetAsEqualsMap(os.Environ()), options.EnvFiles)
|
||||
if err != nil {
|
||||
return nil
|
||||
return err
|
||||
}
|
||||
for k, v := range envFromFile {
|
||||
if _, ok := os.LookupEnv(k); !ok && strings.HasPrefix(k, "COMPOSE_") {
|
||||
if err = os.Setenv(k, v); err != nil {
|
||||
return nil
|
||||
if err := os.Setenv(k, v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return err
|
||||
return nil
|
||||
}
|
||||
|
||||
var printerModes = []string{
|
||||
|
||||
76
cmd/compose/compose_oci_test.go
Normal file
76
cmd/compose/compose_oci_test.go
Normal file
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
Copyright 2020 Docker Compose CLI authors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package compose
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"go.uber.org/mock/gomock"
|
||||
"gotest.tools/v3/assert"
|
||||
|
||||
"github.com/docker/compose/v5/pkg/mocks"
|
||||
)
|
||||
|
||||
func TestSetEnvWithDotEnv_WithOCIArtifact(t *testing.T) {
|
||||
// Test that setEnvWithDotEnv doesn't fail when using OCI artifacts
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
cli := mocks.NewMockCli(ctrl)
|
||||
|
||||
opts := ProjectOptions{
|
||||
ConfigPaths: []string{"oci://docker.io/dockersamples/welcome-to-docker"},
|
||||
ProjectDir: "",
|
||||
EnvFiles: []string{},
|
||||
}
|
||||
|
||||
err := setEnvWithDotEnv(opts, cli)
|
||||
assert.NilError(t, err, "setEnvWithDotEnv should not fail with OCI artifact path")
|
||||
}
|
||||
|
||||
func TestSetEnvWithDotEnv_WithGitRemote(t *testing.T) {
|
||||
// Test that setEnvWithDotEnv doesn't fail when using Git remotes
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
cli := mocks.NewMockCli(ctrl)
|
||||
|
||||
opts := ProjectOptions{
|
||||
ConfigPaths: []string{"https://github.com/docker/compose.git"},
|
||||
ProjectDir: "",
|
||||
EnvFiles: []string{},
|
||||
}
|
||||
|
||||
err := setEnvWithDotEnv(opts, cli)
|
||||
assert.NilError(t, err, "setEnvWithDotEnv should not fail with Git remote path")
|
||||
}
|
||||
|
||||
func TestSetEnvWithDotEnv_WithLocalPath(t *testing.T) {
|
||||
// Test that setEnvWithDotEnv still works with local paths
|
||||
// This will fail if the file doesn't exist, but it should not panic
|
||||
// or produce invalid paths
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
cli := mocks.NewMockCli(ctrl)
|
||||
|
||||
opts := ProjectOptions{
|
||||
ConfigPaths: []string{"compose.yaml"},
|
||||
ProjectDir: "",
|
||||
EnvFiles: []string{},
|
||||
}
|
||||
|
||||
// This may error if files don't exist, but should not panic
|
||||
_ = setEnvWithDotEnv(opts, cli)
|
||||
}
|
||||
@@ -198,12 +198,11 @@ func (opts createOptions) Apply(project *types.Project) error {
|
||||
|
||||
func applyScaleOpts(project *types.Project, opts []string) error {
|
||||
for _, scale := range opts {
|
||||
split := strings.Split(scale, "=")
|
||||
if len(split) != 2 {
|
||||
name, val, ok := strings.Cut(scale, "=")
|
||||
if !ok || val == "" {
|
||||
return fmt.Errorf("invalid --scale option %q. Should be SERVICE=NUM", scale)
|
||||
}
|
||||
name := split[0]
|
||||
replicas, err := strconv.Atoi(split[1])
|
||||
replicas, err := strconv.Atoi(val)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ func downCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *Backe
|
||||
RunE: Adapt(func(ctx context.Context, args []string) error {
|
||||
return runDown(ctx, dockerCli, backendOptions, opts, args)
|
||||
}),
|
||||
ValidArgsFunction: noCompletion(),
|
||||
ValidArgsFunction: completeServiceNames(dockerCli, p),
|
||||
}
|
||||
flags := downCmd.Flags()
|
||||
removeOrphans := utils.StringToBool(os.Getenv(ComposeRemoveOrphans))
|
||||
|
||||
@@ -213,9 +213,9 @@ func extractEnvCLIDefined(cmdEnvs []string) map[string]string {
|
||||
// Parse command-line environment variables
|
||||
cmdEnvMap := make(map[string]string)
|
||||
for _, env := range cmdEnvs {
|
||||
parts := strings.SplitN(env, "=", 2)
|
||||
if len(parts) == 2 {
|
||||
cmdEnvMap[parts[0]] = parts[1]
|
||||
key, val, ok := strings.Cut(env, "=")
|
||||
if ok {
|
||||
cmdEnvMap[key] = val
|
||||
}
|
||||
}
|
||||
return cmdEnvMap
|
||||
|
||||
@@ -18,7 +18,6 @@ package compose
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -214,10 +213,7 @@ func TestDisplayInterpolationVariables(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
// Create a temporary directory for the test
|
||||
tmpDir, err := os.MkdirTemp("", "compose-test")
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = os.RemoveAll(tmpDir) }()
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create a temporary compose file
|
||||
composeContent := `
|
||||
@@ -231,8 +227,7 @@ services:
|
||||
- UNSET_VAR # optional without default
|
||||
`
|
||||
composePath := filepath.Join(tmpDir, "docker-compose.yml")
|
||||
err = os.WriteFile(composePath, []byte(composeContent), 0o644)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.WriteFile(composePath, []byte(composeContent), 0o644))
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
cli := mocks.NewMockCli(ctrl)
|
||||
@@ -244,16 +239,11 @@ services:
|
||||
}
|
||||
|
||||
// Set up the context with necessary environment variables
|
||||
ctx := context.Background()
|
||||
_ = os.Setenv("TEST_VAR", "test-value")
|
||||
_ = os.Setenv("API_KEY", "123456")
|
||||
defer func() {
|
||||
_ = os.Unsetenv("TEST_VAR")
|
||||
_ = os.Unsetenv("API_KEY")
|
||||
}()
|
||||
t.Setenv("TEST_VAR", "test-value")
|
||||
t.Setenv("API_KEY", "123456")
|
||||
|
||||
// Extract variables from the model
|
||||
info, noVariables, err := extractInterpolationVariablesFromModel(ctx, cli, projectOptions, []string{})
|
||||
info, noVariables, err := extractInterpolationVariablesFromModel(t.Context(), cli, projectOptions, []string{})
|
||||
require.NoError(t, err)
|
||||
require.False(t, noVariables)
|
||||
|
||||
|
||||
@@ -50,19 +50,19 @@ func (p *psOptions) parseFilter() error {
|
||||
if p.Filter == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.SplitN(p.Filter, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
key, val, ok := strings.Cut(p.Filter, "=")
|
||||
if !ok {
|
||||
return errors.New("arguments to --filter should be in form KEY=VAL")
|
||||
}
|
||||
switch parts[0] {
|
||||
switch key {
|
||||
case "status":
|
||||
p.Status = append(p.Status, parts[1])
|
||||
p.Status = append(p.Status, val)
|
||||
return nil
|
||||
case "source":
|
||||
return api.ErrNotImplemented
|
||||
default:
|
||||
return fmt.Errorf("unknown filter %s", parts[0])
|
||||
return fmt.Errorf("unknown filter %s", key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func psCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *BackendOptions) *cobra.Command {
|
||||
|
||||
@@ -142,7 +142,7 @@ func (options runOptions) getEnvironment(resolve func(string) (string, bool)) (t
|
||||
return environment, nil
|
||||
}
|
||||
|
||||
func runCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *BackendOptions) *cobra.Command { //nolint:gocyclo
|
||||
func runCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *BackendOptions) *cobra.Command {
|
||||
options := runOptions{
|
||||
composeOptions: &composeOptions{
|
||||
ProjectOptions: p,
|
||||
@@ -185,7 +185,7 @@ func runCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *Backen
|
||||
}
|
||||
} else if !cmd.Flags().Changed("no-TTY") && !cmd.Flags().Changed("interactive") && !dockerCli.In().IsTerminal() {
|
||||
// while `docker run` requires explicit `-it` flags, Compose enables interactive mode and TTY by default
|
||||
// but when compose is used from a scripr has stdin piped from another command, we just can't
|
||||
// but when compose is used from a script that has stdin piped from another command, we just can't
|
||||
// Here, we detect we run "by default" (user didn't passed explicit flags) and disable TTY allocation if
|
||||
// we don't have an actual terminal to attach to for interactive mode
|
||||
options.noTty = true
|
||||
@@ -193,11 +193,7 @@ func runCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *Backen
|
||||
|
||||
if options.quiet {
|
||||
display.Mode = display.ModeQuiet
|
||||
devnull, err := os.Open(os.DevNull)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
os.Stdout = devnull
|
||||
backendOptions.Add(compose.WithEventProcessor(display.Quiet()))
|
||||
}
|
||||
createOpts.pullChanged = cmd.Flags().Changed("pull")
|
||||
return nil
|
||||
@@ -288,11 +284,11 @@ func runRun(ctx context.Context, backend api.Compose, project *types.Project, op
|
||||
|
||||
labels := types.Labels{}
|
||||
for _, s := range options.labels {
|
||||
parts := strings.SplitN(s, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
key, val, ok := strings.Cut(s, "=")
|
||||
if !ok {
|
||||
return fmt.Errorf("label must be set as KEY=VALUE")
|
||||
}
|
||||
labels[parts[0]] = parts[1]
|
||||
labels[key] = val
|
||||
}
|
||||
|
||||
var buildForRun *api.BuildOptions
|
||||
|
||||
@@ -63,7 +63,10 @@ func runStart(ctx context.Context, dockerCli command.Cli, backendOptions *Backen
|
||||
return err
|
||||
}
|
||||
|
||||
timeout := time.Duration(opts.waitTimeout) * time.Second
|
||||
var timeout time.Duration
|
||||
if opts.waitTimeout > 0 {
|
||||
timeout = time.Duration(opts.waitTimeout) * time.Second
|
||||
}
|
||||
return backend.Start(ctx, name, api.StartOptions{
|
||||
AttachTo: services,
|
||||
Project: project,
|
||||
|
||||
@@ -188,6 +188,9 @@ func upCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *Backend
|
||||
|
||||
//nolint:gocyclo
|
||||
func validateFlags(up *upOptions, create *createOptions) error {
|
||||
if up.waitTimeout < 0 {
|
||||
return fmt.Errorf("--wait-timeout must be a non-negative integer")
|
||||
}
|
||||
if up.exitCodeFrom != "" && !up.cascadeFail {
|
||||
up.cascadeStop = true
|
||||
}
|
||||
@@ -328,7 +331,10 @@ func runUp(
|
||||
attach = attachSet.Elements()
|
||||
}
|
||||
|
||||
timeout := time.Duration(upOptions.waitTimeout) * time.Second
|
||||
var timeout time.Duration
|
||||
if upOptions.waitTimeout > 0 {
|
||||
timeout = time.Duration(upOptions.waitTimeout) * time.Second
|
||||
}
|
||||
return backend.Up(ctx, project, api.UpOptions{
|
||||
Create: create,
|
||||
Start: api.StartOptions{
|
||||
|
||||
@@ -21,6 +21,8 @@ import (
|
||||
|
||||
"github.com/compose-spec/compose-go/v2/types"
|
||||
"gotest.tools/v3/assert"
|
||||
|
||||
"github.com/docker/compose/v5/pkg/api"
|
||||
)
|
||||
|
||||
func TestApplyScaleOpt(t *testing.T) {
|
||||
@@ -48,3 +50,42 @@ func TestApplyScaleOpt(t *testing.T) {
|
||||
assert.Equal(t, *bar.Scale, 3)
|
||||
assert.Equal(t, *bar.Deploy.Replicas, 3)
|
||||
}
|
||||
|
||||
func TestUpOptions_OnExit(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args upOptions
|
||||
want api.Cascade
|
||||
}{
|
||||
{
|
||||
name: "no cascade",
|
||||
args: upOptions{},
|
||||
want: api.CascadeIgnore,
|
||||
},
|
||||
{
|
||||
name: "cascade stop",
|
||||
args: upOptions{cascadeStop: true},
|
||||
want: api.CascadeStop,
|
||||
},
|
||||
{
|
||||
name: "cascade fail",
|
||||
args: upOptions{cascadeFail: true},
|
||||
want: api.CascadeFail,
|
||||
},
|
||||
{
|
||||
name: "both set - stop takes precedence",
|
||||
args: upOptions{
|
||||
cascadeStop: true,
|
||||
cascadeFail: true,
|
||||
},
|
||||
want: api.CascadeStop,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tt.args.OnExit()
|
||||
assert.Equal(t, got, tt.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,48 +20,54 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"iter"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/buger/goterm"
|
||||
"github.com/docker/go-units"
|
||||
"github.com/morikuni/aec"
|
||||
|
||||
"github.com/docker/compose/v5/pkg/api"
|
||||
"github.com/docker/compose/v5/pkg/utils"
|
||||
)
|
||||
|
||||
// Full creates an EventProcessor that render advanced UI within a terminal.
|
||||
// On Start, TUI lists task with a progress timer
|
||||
func Full(out io.Writer, info io.Writer) api.EventProcessor {
|
||||
func Full(out io.Writer, info io.Writer, detached bool) api.EventProcessor {
|
||||
return &ttyWriter{
|
||||
out: out,
|
||||
info: info,
|
||||
tasks: map[string]task{},
|
||||
done: make(chan bool),
|
||||
mtx: &sync.Mutex{},
|
||||
out: out,
|
||||
info: info,
|
||||
tasks: map[string]*task{},
|
||||
done: make(chan bool),
|
||||
mtx: &sync.Mutex{},
|
||||
detached: detached,
|
||||
}
|
||||
}
|
||||
|
||||
type ttyWriter struct {
|
||||
out io.Writer
|
||||
ids []string // tasks ids ordered as first event appeared
|
||||
tasks map[string]task
|
||||
repeated bool
|
||||
numLines int
|
||||
done chan bool
|
||||
mtx *sync.Mutex
|
||||
dryRun bool // FIXME(ndeloof) (re)implement support for dry-run
|
||||
skipChildEvents bool
|
||||
operation string
|
||||
ticker *time.Ticker
|
||||
suspended bool
|
||||
info io.Writer
|
||||
out io.Writer
|
||||
ids []string // tasks ids ordered as first event appeared
|
||||
tasks map[string]*task
|
||||
repeated bool
|
||||
numLines int
|
||||
done chan bool
|
||||
mtx *sync.Mutex
|
||||
dryRun bool // FIXME(ndeloof) (re)implement support for dry-run
|
||||
operation string
|
||||
ticker *time.Ticker
|
||||
suspended bool
|
||||
info io.Writer
|
||||
detached bool
|
||||
}
|
||||
|
||||
type task struct {
|
||||
ID string
|
||||
parentID string
|
||||
parent string // the resource this task receives updates from - other parents will be ignored
|
||||
parents utils.Set[string] // all resources to depend on this task
|
||||
startTime time.Time
|
||||
endTime time.Time
|
||||
text string
|
||||
@@ -73,6 +79,64 @@ type task struct {
|
||||
spinner *Spinner
|
||||
}
|
||||
|
||||
func newTask(e api.Resource) task {
|
||||
t := task{
|
||||
ID: e.ID,
|
||||
parents: utils.NewSet[string](),
|
||||
startTime: time.Now(),
|
||||
text: e.Text,
|
||||
details: e.Details,
|
||||
status: e.Status,
|
||||
current: e.Current,
|
||||
percent: e.Percent,
|
||||
total: e.Total,
|
||||
spinner: NewSpinner(),
|
||||
}
|
||||
if e.ParentID != "" {
|
||||
t.parent = e.ParentID
|
||||
t.parents.Add(e.ParentID)
|
||||
}
|
||||
if e.Status == api.Done || e.Status == api.Error {
|
||||
t.stop()
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// update adjusts task state based on last received event
|
||||
func (t *task) update(e api.Resource) {
|
||||
if e.ParentID != "" {
|
||||
t.parents.Add(e.ParentID)
|
||||
// we may receive same event from distinct parents (typically: images sharing layers)
|
||||
// to avoid status to flicker, only accept updates from our first declared parent
|
||||
if t.parent != e.ParentID {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// update task based on received event
|
||||
switch e.Status {
|
||||
case api.Done, api.Error, api.Warning:
|
||||
if t.status != e.Status {
|
||||
t.stop()
|
||||
}
|
||||
case api.Working:
|
||||
t.hasMore()
|
||||
}
|
||||
t.status = e.Status
|
||||
t.text = e.Text
|
||||
t.details = e.Details
|
||||
// progress can only go up
|
||||
if e.Total > t.total {
|
||||
t.total = e.Total
|
||||
}
|
||||
if e.Current > t.current {
|
||||
t.current = e.Current
|
||||
}
|
||||
if e.Percent > t.percent {
|
||||
t.percent = e.Percent
|
||||
}
|
||||
}
|
||||
|
||||
func (t *task) stop() {
|
||||
t.endTime = time.Now()
|
||||
t.spinner.Stop()
|
||||
@@ -82,6 +146,15 @@ func (t *task) hasMore() {
|
||||
t.spinner.Restart()
|
||||
}
|
||||
|
||||
func (t *task) Completed() bool {
|
||||
switch t.status {
|
||||
case api.Done, api.Error, api.Warning:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (w *ttyWriter) Start(ctx context.Context, operation string) {
|
||||
w.ticker = time.NewTicker(100 * time.Millisecond)
|
||||
w.operation = operation
|
||||
@@ -93,11 +166,6 @@ func (w *ttyWriter) Start(ctx context.Context, operation string) {
|
||||
w.ticker.Stop()
|
||||
return
|
||||
case <-w.done:
|
||||
w.print()
|
||||
w.mtx.Lock()
|
||||
w.ticker.Stop()
|
||||
w.operation = ""
|
||||
w.mtx.Unlock()
|
||||
return
|
||||
case <-w.ticker.C:
|
||||
w.print()
|
||||
@@ -107,6 +175,11 @@ func (w *ttyWriter) Start(ctx context.Context, operation string) {
|
||||
}
|
||||
|
||||
func (w *ttyWriter) Done(operation string, success bool) {
|
||||
w.print()
|
||||
w.mtx.Lock()
|
||||
defer w.mtx.Unlock()
|
||||
w.ticker.Stop()
|
||||
w.operation = ""
|
||||
w.done <- true
|
||||
}
|
||||
|
||||
@@ -119,7 +192,7 @@ func (w *ttyWriter) On(events ...api.Resource) {
|
||||
continue
|
||||
}
|
||||
|
||||
if w.operation != "start" && (e.Text == api.StatusStarted || e.Text == api.StatusStarting) {
|
||||
if w.operation != "start" && (e.Text == api.StatusStarted || e.Text == api.StatusStarting) && !w.detached {
|
||||
// skip those events to avoid mix with container logs
|
||||
continue
|
||||
}
|
||||
@@ -138,49 +211,10 @@ func (w *ttyWriter) event(e api.Resource) {
|
||||
}
|
||||
|
||||
if last, ok := w.tasks[e.ID]; ok {
|
||||
switch e.Status {
|
||||
case api.Done, api.Error, api.Warning:
|
||||
if last.status != e.Status {
|
||||
last.stop()
|
||||
}
|
||||
case api.Working:
|
||||
last.hasMore()
|
||||
}
|
||||
last.status = e.Status
|
||||
last.text = e.Text
|
||||
last.details = e.Details
|
||||
// progress can only go up
|
||||
if e.Total > last.total {
|
||||
last.total = e.Total
|
||||
}
|
||||
if e.Current > last.current {
|
||||
last.current = e.Current
|
||||
}
|
||||
if e.Percent > last.percent {
|
||||
last.percent = e.Percent
|
||||
}
|
||||
// allow set/unset of parent, but not swapping otherwise prompt is flickering
|
||||
if last.parentID == "" || e.ParentID == "" {
|
||||
last.parentID = e.ParentID
|
||||
}
|
||||
w.tasks[e.ID] = last
|
||||
last.update(e)
|
||||
} else {
|
||||
t := task{
|
||||
ID: e.ID,
|
||||
parentID: e.ParentID,
|
||||
startTime: time.Now(),
|
||||
text: e.Text,
|
||||
details: e.Details,
|
||||
status: e.Status,
|
||||
current: e.Current,
|
||||
percent: e.Percent,
|
||||
total: e.Total,
|
||||
spinner: NewSpinner(),
|
||||
}
|
||||
if e.Status == api.Done || e.Status == api.Error {
|
||||
t.stop()
|
||||
}
|
||||
w.tasks[e.ID] = t
|
||||
t := newTask(e)
|
||||
w.tasks[e.ID] = &t
|
||||
w.ids = append(w.ids, e.ID)
|
||||
}
|
||||
w.printEvent(e)
|
||||
@@ -206,25 +240,72 @@ func (w *ttyWriter) printEvent(e api.Resource) {
|
||||
_, _ = fmt.Fprintf(w.out, "%s %s %s\n", e.ID, color(e.Text), e.Details)
|
||||
}
|
||||
|
||||
func (w *ttyWriter) parentTasks() iter.Seq[*task] {
|
||||
return func(yield func(*task) bool) {
|
||||
for _, id := range w.ids { // iterate on ids to enforce a consistent order
|
||||
t := w.tasks[id]
|
||||
if len(t.parents) == 0 {
|
||||
yield(t)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *ttyWriter) childrenTasks(parent string) iter.Seq[*task] {
|
||||
return func(yield func(*task) bool) {
|
||||
for _, id := range w.ids { // iterate on ids to enforce a consistent order
|
||||
t := w.tasks[id]
|
||||
if t.parents.Has(parent) {
|
||||
yield(t)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// lineData holds pre-computed formatting for a task line
|
||||
type lineData struct {
|
||||
spinner string // rendered spinner with color
|
||||
prefix string // dry-run prefix if any
|
||||
taskID string // possibly abbreviated
|
||||
progress string // progress bar and size info
|
||||
status string // rendered status with color
|
||||
details string // possibly abbreviated
|
||||
timer string // rendered timer with color
|
||||
statusPad int // padding before status to align
|
||||
timerPad int // padding before timer to align
|
||||
statusColor colorFunc
|
||||
}
|
||||
|
||||
func (w *ttyWriter) print() {
|
||||
terminalWidth := goterm.Width()
|
||||
terminalHeight := goterm.Height()
|
||||
if terminalWidth <= 0 {
|
||||
terminalWidth = 80
|
||||
}
|
||||
if terminalHeight <= 0 {
|
||||
terminalHeight = 24
|
||||
}
|
||||
w.printWithDimensions(terminalWidth, terminalHeight)
|
||||
}
|
||||
|
||||
func (w *ttyWriter) printWithDimensions(terminalWidth, terminalHeight int) {
|
||||
w.mtx.Lock()
|
||||
defer w.mtx.Unlock()
|
||||
if len(w.tasks) == 0 {
|
||||
return
|
||||
}
|
||||
terminalWidth := goterm.Width()
|
||||
b := aec.EmptyBuilder
|
||||
for i := 0; i <= w.numLines; i++ {
|
||||
b = b.Up(1)
|
||||
}
|
||||
if !w.repeated {
|
||||
b = b.Down(1)
|
||||
}
|
||||
w.repeated = true
|
||||
_, _ = fmt.Fprint(w.out, b.Column(0).ANSI)
|
||||
|
||||
// Hide the cursor while we are printing
|
||||
_, _ = fmt.Fprint(w.out, aec.Hide)
|
||||
up := w.numLines + 1
|
||||
if !w.repeated {
|
||||
up--
|
||||
w.repeated = true
|
||||
}
|
||||
b := aec.NewBuilder(
|
||||
aec.Hide, // Hide the cursor while we are printing
|
||||
aec.Up(uint(up)),
|
||||
aec.Column(0),
|
||||
)
|
||||
_, _ = fmt.Fprint(w.out, b.ANSI)
|
||||
defer func() {
|
||||
_, _ = fmt.Fprint(w.out, aec.Show)
|
||||
}()
|
||||
@@ -232,51 +313,208 @@ func (w *ttyWriter) print() {
|
||||
firstLine := fmt.Sprintf("[+] %s %d/%d", w.operation, numDone(w.tasks), len(w.tasks))
|
||||
_, _ = fmt.Fprintln(w.out, firstLine)
|
||||
|
||||
var statusPadding int
|
||||
for _, t := range w.tasks {
|
||||
l := len(t.ID)
|
||||
if statusPadding < l {
|
||||
statusPadding = l
|
||||
}
|
||||
if t.parentID != "" {
|
||||
statusPadding -= 2
|
||||
// Collect parent tasks in original order
|
||||
allTasks := slices.Collect(w.parentTasks())
|
||||
|
||||
// Available lines: terminal height - 2 (header line + potential "more" line)
|
||||
maxLines := terminalHeight - 2
|
||||
if maxLines < 1 {
|
||||
maxLines = 1
|
||||
}
|
||||
|
||||
showMore := len(allTasks) > maxLines
|
||||
tasksToShow := allTasks
|
||||
if showMore {
|
||||
tasksToShow = allTasks[:maxLines-1] // Reserve one line for "more" message
|
||||
}
|
||||
|
||||
// collect line data and compute timerLen
|
||||
lines := make([]lineData, len(tasksToShow))
|
||||
var timerLen int
|
||||
for i, t := range tasksToShow {
|
||||
lines[i] = w.prepareLineData(t)
|
||||
if len(lines[i].timer) > timerLen {
|
||||
timerLen = len(lines[i].timer)
|
||||
}
|
||||
}
|
||||
|
||||
if len(w.tasks) > goterm.Height()-2 {
|
||||
w.skipChildEvents = true
|
||||
}
|
||||
// shorten details/taskID to fit terminal width
|
||||
w.adjustLineWidth(lines, timerLen, terminalWidth)
|
||||
|
||||
// compute padding
|
||||
w.applyPadding(lines, terminalWidth, timerLen)
|
||||
|
||||
// Render lines
|
||||
numLines := 0
|
||||
|
||||
for _, id := range w.ids { // iterate on ids to enforce a consistent order
|
||||
t := w.tasks[id]
|
||||
if t.parentID != "" {
|
||||
continue
|
||||
}
|
||||
line := w.lineText(t, "", terminalWidth, statusPadding, w.dryRun)
|
||||
_, _ = fmt.Fprint(w.out, line)
|
||||
for _, l := range lines {
|
||||
_, _ = fmt.Fprint(w.out, lineText(l))
|
||||
numLines++
|
||||
for _, t := range w.tasks {
|
||||
if t.parentID == t.ID {
|
||||
if w.skipChildEvents {
|
||||
continue
|
||||
}
|
||||
line := w.lineText(t, " ", terminalWidth, statusPadding, w.dryRun)
|
||||
_, _ = fmt.Fprint(w.out, line)
|
||||
numLines++
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := numLines; i < w.numLines; i++ {
|
||||
if numLines < goterm.Height()-2 {
|
||||
_, _ = fmt.Fprintln(w.out, strings.Repeat(" ", terminalWidth))
|
||||
numLines++
|
||||
|
||||
if showMore {
|
||||
moreCount := len(allTasks) - len(tasksToShow)
|
||||
moreText := fmt.Sprintf(" ... %d more", moreCount)
|
||||
pad := terminalWidth - len(moreText)
|
||||
if pad < 0 {
|
||||
pad = 0
|
||||
}
|
||||
_, _ = fmt.Fprintf(w.out, "%s%s\n", moreText, strings.Repeat(" ", pad))
|
||||
numLines++
|
||||
}
|
||||
|
||||
// Clear any remaining lines from previous render
|
||||
for i := numLines; i < w.numLines; i++ {
|
||||
_, _ = fmt.Fprintln(w.out, strings.Repeat(" ", terminalWidth))
|
||||
numLines++
|
||||
}
|
||||
w.numLines = numLines
|
||||
}
|
||||
|
||||
func (w *ttyWriter) lineText(t task, pad string, terminalWidth, statusPadding int, dryRun bool) string {
|
||||
func (w *ttyWriter) applyPadding(lines []lineData, terminalWidth int, timerLen int) {
|
||||
var maxBeforeStatus int
|
||||
for i := range lines {
|
||||
l := &lines[i]
|
||||
// Width before statusPad: space(1) + spinner(1) + prefix + space(1) + taskID + progress
|
||||
beforeStatus := 3 + lenAnsi(l.prefix) + utf8.RuneCountInString(l.taskID) + lenAnsi(l.progress)
|
||||
if beforeStatus > maxBeforeStatus {
|
||||
maxBeforeStatus = beforeStatus
|
||||
}
|
||||
}
|
||||
|
||||
for i, l := range lines {
|
||||
// Position before statusPad: space(1) + spinner(1) + prefix + space(1) + taskID + progress
|
||||
beforeStatus := 3 + lenAnsi(l.prefix) + utf8.RuneCountInString(l.taskID) + lenAnsi(l.progress)
|
||||
// statusPad aligns status; lineText adds 1 more space after statusPad
|
||||
l.statusPad = maxBeforeStatus - beforeStatus
|
||||
|
||||
// Format: beforeStatus + statusPad + space(1) + status
|
||||
lineLen := beforeStatus + l.statusPad + 1 + utf8.RuneCountInString(l.status)
|
||||
if l.details != "" {
|
||||
lineLen += 1 + utf8.RuneCountInString(l.details)
|
||||
}
|
||||
l.timerPad = terminalWidth - lineLen - timerLen
|
||||
if l.timerPad < 1 {
|
||||
l.timerPad = 1
|
||||
}
|
||||
lines[i] = l
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func (w *ttyWriter) adjustLineWidth(lines []lineData, timerLen int, terminalWidth int) {
|
||||
const minIDLen = 10
|
||||
maxStatusLen := maxStatusLength(lines)
|
||||
|
||||
// Iteratively truncate until all lines fit
|
||||
for range 100 { // safety limit
|
||||
maxBeforeStatus := maxBeforeStatusWidth(lines)
|
||||
overflow := computeOverflow(lines, maxBeforeStatus, maxStatusLen, timerLen, terminalWidth)
|
||||
|
||||
if overflow <= 0 {
|
||||
break
|
||||
}
|
||||
|
||||
// First try to truncate details, then taskID
|
||||
if !truncateDetails(lines, overflow) && !truncateLongestTaskID(lines, overflow, minIDLen) {
|
||||
break // Can't truncate further
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// maxStatusLength returns the maximum status text length across all lines.
|
||||
func maxStatusLength(lines []lineData) int {
|
||||
var maxLen int
|
||||
for i := range lines {
|
||||
if len(lines[i].status) > maxLen {
|
||||
maxLen = len(lines[i].status)
|
||||
}
|
||||
}
|
||||
return maxLen
|
||||
}
|
||||
|
||||
// maxBeforeStatusWidth computes the maximum width before statusPad across all lines.
|
||||
// This is: space(1) + spinner(1) + prefix + space(1) + taskID + progress
|
||||
func maxBeforeStatusWidth(lines []lineData) int {
|
||||
var maxWidth int
|
||||
for i := range lines {
|
||||
l := &lines[i]
|
||||
width := 3 + lenAnsi(l.prefix) + len(l.taskID) + lenAnsi(l.progress)
|
||||
if width > maxWidth {
|
||||
maxWidth = width
|
||||
}
|
||||
}
|
||||
return maxWidth
|
||||
}
|
||||
|
||||
// computeOverflow calculates how many characters the widest line exceeds the terminal width.
|
||||
// Returns 0 or negative if all lines fit.
|
||||
func computeOverflow(lines []lineData, maxBeforeStatus, maxStatusLen, timerLen, terminalWidth int) int {
|
||||
var maxOverflow int
|
||||
for i := range lines {
|
||||
l := &lines[i]
|
||||
detailsLen := len(l.details)
|
||||
if detailsLen > 0 {
|
||||
detailsLen++ // space before details
|
||||
}
|
||||
// Line width: maxBeforeStatus + space(1) + status + details + minTimerPad(1) + timer
|
||||
lineWidth := maxBeforeStatus + 1 + maxStatusLen + detailsLen + 1 + timerLen
|
||||
overflow := lineWidth - terminalWidth
|
||||
if overflow > maxOverflow {
|
||||
maxOverflow = overflow
|
||||
}
|
||||
}
|
||||
return maxOverflow
|
||||
}
|
||||
|
||||
// truncateDetails tries to truncate the first line's details to reduce overflow.
|
||||
// Returns true if any truncation was performed.
|
||||
func truncateDetails(lines []lineData, overflow int) bool {
|
||||
for i := range lines {
|
||||
l := &lines[i]
|
||||
if len(l.details) > 3 {
|
||||
reduction := overflow
|
||||
if reduction > len(l.details)-3 {
|
||||
reduction = len(l.details) - 3
|
||||
}
|
||||
l.details = l.details[:len(l.details)-reduction-3] + "..."
|
||||
return true
|
||||
} else if l.details != "" {
|
||||
l.details = ""
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// truncateLongestTaskID truncates the longest taskID to reduce overflow.
|
||||
// Returns true if truncation was performed.
|
||||
func truncateLongestTaskID(lines []lineData, overflow, minIDLen int) bool {
|
||||
longestIdx := -1
|
||||
longestLen := minIDLen
|
||||
for i := range lines {
|
||||
if len(lines[i].taskID) > longestLen {
|
||||
longestLen = len(lines[i].taskID)
|
||||
longestIdx = i
|
||||
}
|
||||
}
|
||||
|
||||
if longestIdx < 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
l := &lines[longestIdx]
|
||||
reduction := overflow + 3 // account for "..."
|
||||
newLen := len(l.taskID) - reduction
|
||||
if newLen < minIDLen-3 {
|
||||
newLen = minIDLen - 3
|
||||
}
|
||||
if newLen > 0 {
|
||||
l.taskID = l.taskID[:newLen] + "..."
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (w *ttyWriter) prepareLineData(t *task) lineData {
|
||||
endTime := time.Now()
|
||||
if t.status != api.Working {
|
||||
endTime = t.startTime
|
||||
@@ -284,8 +522,9 @@ func (w *ttyWriter) lineText(t task, pad string, terminalWidth, statusPadding in
|
||||
endTime = t.endTime
|
||||
}
|
||||
}
|
||||
|
||||
prefix := ""
|
||||
if dryRun {
|
||||
if w.dryRun {
|
||||
prefix = PrefixColor(DRYRUN_PREFIX)
|
||||
}
|
||||
|
||||
@@ -299,65 +538,65 @@ func (w *ttyWriter) lineText(t task, pad string, terminalWidth, statusPadding in
|
||||
)
|
||||
|
||||
// only show the aggregated progress while the root operation is in-progress
|
||||
if parent := t; parent.status == api.Working {
|
||||
for _, id := range w.ids {
|
||||
child := w.tasks[id]
|
||||
if child.parentID == parent.ID {
|
||||
if child.status == api.Working && child.total == 0 {
|
||||
// we don't have totals available for all the child events
|
||||
// so don't show the total progress yet
|
||||
hideDetails = true
|
||||
}
|
||||
total += child.total
|
||||
current += child.current
|
||||
completion = append(completion, percentChars[(len(percentChars)-1)*child.percent/100])
|
||||
if t.status == api.Working {
|
||||
for child := range w.childrenTasks(t.ID) {
|
||||
if child.status == api.Working && child.total == 0 {
|
||||
hideDetails = true
|
||||
}
|
||||
total += child.total
|
||||
current += child.current
|
||||
r := len(percentChars) - 1
|
||||
p := child.percent
|
||||
if p > 100 {
|
||||
p = 100
|
||||
}
|
||||
completion = append(completion, percentChars[r*p/100])
|
||||
}
|
||||
}
|
||||
|
||||
// don't try to show detailed progress if we don't have any idea
|
||||
if total == 0 {
|
||||
hideDetails = true
|
||||
}
|
||||
|
||||
txt := t.ID
|
||||
var progress string
|
||||
if len(completion) > 0 {
|
||||
var progress string
|
||||
progress = " [" + SuccessColor(strings.Join(completion, "")) + "]"
|
||||
if !hideDetails {
|
||||
progress = fmt.Sprintf(" %7s / %-7s", units.HumanSize(float64(current)), units.HumanSize(float64(total)))
|
||||
progress += fmt.Sprintf(" %7s / %-7s", units.HumanSize(float64(current)), units.HumanSize(float64(total)))
|
||||
}
|
||||
txt = fmt.Sprintf("%s [%s]%s",
|
||||
t.ID,
|
||||
SuccessColor(strings.Join(completion, "")),
|
||||
progress,
|
||||
)
|
||||
}
|
||||
textLen := len(txt)
|
||||
padding := statusPadding - textLen
|
||||
if padding < 0 {
|
||||
padding = 0
|
||||
}
|
||||
// calculate the max length for the status text, on errors it
|
||||
// is 2-3 lines long and breaks the line formatting
|
||||
maxDetailsLen := terminalWidth - textLen - statusPadding - 15
|
||||
details := t.details
|
||||
// in some cases (debugging under VS Code), terminalWidth is set to zero by goterm.Width() ; ensuring we don't tweak strings with negative char index
|
||||
if maxDetailsLen > 0 && len(details) > maxDetailsLen {
|
||||
details = details[:maxDetailsLen] + "..."
|
||||
}
|
||||
text := fmt.Sprintf("%s %s%s %s %s%s %s",
|
||||
pad,
|
||||
spinner(t),
|
||||
prefix,
|
||||
txt,
|
||||
strings.Repeat(" ", padding),
|
||||
colorFn(t.status)(t.text),
|
||||
details,
|
||||
)
|
||||
timer := fmt.Sprintf("%.1fs ", elapsed)
|
||||
o := align(text, TimerColor(timer), terminalWidth)
|
||||
|
||||
return o
|
||||
return lineData{
|
||||
spinner: spinner(t),
|
||||
prefix: prefix,
|
||||
taskID: t.ID,
|
||||
progress: progress,
|
||||
status: t.text,
|
||||
statusColor: colorFn(t.status),
|
||||
details: t.details,
|
||||
timer: fmt.Sprintf("%.1fs", elapsed),
|
||||
}
|
||||
}
|
||||
|
||||
func lineText(l lineData) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(" ")
|
||||
sb.WriteString(l.spinner)
|
||||
sb.WriteString(l.prefix)
|
||||
sb.WriteString(" ")
|
||||
sb.WriteString(l.taskID)
|
||||
sb.WriteString(l.progress)
|
||||
sb.WriteString(strings.Repeat(" ", l.statusPad))
|
||||
sb.WriteString(" ")
|
||||
sb.WriteString(l.statusColor(l.status))
|
||||
if l.details != "" {
|
||||
sb.WriteString(" ")
|
||||
sb.WriteString(l.details)
|
||||
}
|
||||
sb.WriteString(strings.Repeat(" ", l.timerPad))
|
||||
sb.WriteString(TimerColor(l.timer))
|
||||
sb.WriteString("\n")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -366,7 +605,7 @@ var (
|
||||
spinnerError = "✘"
|
||||
)
|
||||
|
||||
func spinner(t task) string {
|
||||
func spinner(t *task) string {
|
||||
switch t.status {
|
||||
case api.Done:
|
||||
return SuccessColor(spinnerDone)
|
||||
@@ -392,7 +631,7 @@ func colorFn(s api.EventStatus) colorFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func numDone(tasks map[string]task) int {
|
||||
func numDone(tasks map[string]*task) int {
|
||||
i := 0
|
||||
for _, t := range tasks {
|
||||
if t.status != api.Working {
|
||||
@@ -402,17 +641,6 @@ func numDone(tasks map[string]task) int {
|
||||
return i
|
||||
}
|
||||
|
||||
func align(l, r string, w int) string {
|
||||
ll := lenAnsi(l)
|
||||
lr := lenAnsi(r)
|
||||
pad := ""
|
||||
count := w - ll - lr
|
||||
if count > 0 {
|
||||
pad = strings.Repeat(" ", count)
|
||||
}
|
||||
return fmt.Sprintf("%s%s%s\n", l, pad, r)
|
||||
}
|
||||
|
||||
// lenAnsi count of user-perceived characters in ANSI string.
|
||||
func lenAnsi(s string) int {
|
||||
length := 0
|
||||
|
||||
424
cmd/display/tty_test.go
Normal file
424
cmd/display/tty_test.go
Normal file
@@ -0,0 +1,424 @@
|
||||
/*
|
||||
Copyright 2020 Docker Compose CLI authors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package display
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"gotest.tools/v3/assert"
|
||||
|
||||
"github.com/docker/compose/v5/pkg/api"
|
||||
)
|
||||
|
||||
func newTestWriter() (*ttyWriter, *bytes.Buffer) {
|
||||
var buf bytes.Buffer
|
||||
w := &ttyWriter{
|
||||
out: &buf,
|
||||
info: &buf,
|
||||
tasks: map[string]*task{},
|
||||
done: make(chan bool),
|
||||
mtx: &sync.Mutex{},
|
||||
operation: "pull",
|
||||
}
|
||||
return w, &buf
|
||||
}
|
||||
|
||||
func addTask(w *ttyWriter, id, text, details string, status api.EventStatus) {
|
||||
t := &task{
|
||||
ID: id,
|
||||
parents: make(map[string]struct{}),
|
||||
startTime: time.Now(),
|
||||
text: text,
|
||||
details: details,
|
||||
status: status,
|
||||
spinner: NewSpinner(),
|
||||
}
|
||||
w.tasks[id] = t
|
||||
w.ids = append(w.ids, id)
|
||||
}
|
||||
|
||||
// extractLines parses the output buffer and returns lines without ANSI control sequences
|
||||
func extractLines(buf *bytes.Buffer) []string {
|
||||
content := buf.String()
|
||||
// Split by newline
|
||||
rawLines := strings.Split(content, "\n")
|
||||
var lines []string
|
||||
for _, line := range rawLines {
|
||||
// Skip empty lines and lines that are just ANSI codes
|
||||
if lenAnsi(line) > 0 {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func TestPrintWithDimensions_LinesFitTerminalWidth(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
taskID string
|
||||
status string
|
||||
details string
|
||||
terminalWidth int
|
||||
}{
|
||||
{
|
||||
name: "short task fits wide terminal",
|
||||
taskID: "Image foo",
|
||||
status: "Pulling",
|
||||
details: "layer abc123",
|
||||
terminalWidth: 100,
|
||||
},
|
||||
{
|
||||
name: "long details truncated to fit",
|
||||
taskID: "Image foo",
|
||||
status: "Pulling",
|
||||
details: "downloading layer sha256:abc123def456789xyz0123456789abcdef",
|
||||
terminalWidth: 50,
|
||||
},
|
||||
{
|
||||
name: "long taskID truncated to fit",
|
||||
taskID: "very-long-image-name-that-exceeds-terminal-width",
|
||||
status: "Pulling",
|
||||
details: "",
|
||||
terminalWidth: 40,
|
||||
},
|
||||
{
|
||||
name: "both long taskID and details",
|
||||
taskID: "my-very-long-service-name-here",
|
||||
status: "Downloading",
|
||||
details: "layer sha256:abc123def456789xyz0123456789",
|
||||
terminalWidth: 50,
|
||||
},
|
||||
{
|
||||
name: "narrow terminal",
|
||||
taskID: "service-name",
|
||||
status: "Pulling",
|
||||
details: "some details",
|
||||
terminalWidth: 35,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
w, buf := newTestWriter()
|
||||
addTask(w, tc.taskID, tc.status, tc.details, api.Working)
|
||||
|
||||
w.printWithDimensions(tc.terminalWidth, 24)
|
||||
|
||||
lines := extractLines(buf)
|
||||
for i, line := range lines {
|
||||
lineLen := lenAnsi(line)
|
||||
assert.Assert(t, lineLen <= tc.terminalWidth,
|
||||
"line %d has length %d which exceeds terminal width %d: %q",
|
||||
i, lineLen, tc.terminalWidth, line)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintWithDimensions_MultipleTasksFitTerminalWidth(t *testing.T) {
|
||||
w, buf := newTestWriter()
|
||||
|
||||
// Add multiple tasks with varying lengths
|
||||
addTask(w, "Image nginx", "Pulling", "layer sha256:abc123", api.Working)
|
||||
addTask(w, "Image postgres-database", "Pulling", "downloading", api.Working)
|
||||
addTask(w, "Image redis", "Pulled", "", api.Done)
|
||||
|
||||
terminalWidth := 60
|
||||
w.printWithDimensions(terminalWidth, 24)
|
||||
|
||||
lines := extractLines(buf)
|
||||
for i, line := range lines {
|
||||
lineLen := lenAnsi(line)
|
||||
assert.Assert(t, lineLen <= terminalWidth,
|
||||
"line %d has length %d which exceeds terminal width %d: %q",
|
||||
i, lineLen, terminalWidth, line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintWithDimensions_VeryNarrowTerminal(t *testing.T) {
|
||||
w, buf := newTestWriter()
|
||||
addTask(w, "Image nginx", "Pulling", "details", api.Working)
|
||||
|
||||
terminalWidth := 30
|
||||
w.printWithDimensions(terminalWidth, 24)
|
||||
|
||||
lines := extractLines(buf)
|
||||
for i, line := range lines {
|
||||
lineLen := lenAnsi(line)
|
||||
assert.Assert(t, lineLen <= terminalWidth,
|
||||
"line %d has length %d which exceeds terminal width %d: %q",
|
||||
i, lineLen, terminalWidth, line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintWithDimensions_TaskWithProgress(t *testing.T) {
|
||||
w, buf := newTestWriter()
|
||||
|
||||
// Create parent task
|
||||
parent := &task{
|
||||
ID: "Image nginx",
|
||||
parents: make(map[string]struct{}),
|
||||
startTime: time.Now(),
|
||||
text: "Pulling",
|
||||
status: api.Working,
|
||||
spinner: NewSpinner(),
|
||||
}
|
||||
w.tasks["Image nginx"] = parent
|
||||
w.ids = append(w.ids, "Image nginx")
|
||||
|
||||
// Create child tasks to trigger progress display
|
||||
for i := 0; i < 3; i++ {
|
||||
child := &task{
|
||||
ID: "layer" + string(rune('a'+i)),
|
||||
parents: map[string]struct{}{"Image nginx": {}},
|
||||
startTime: time.Now(),
|
||||
text: "Downloading",
|
||||
status: api.Working,
|
||||
total: 1000,
|
||||
current: 500,
|
||||
percent: 50,
|
||||
spinner: NewSpinner(),
|
||||
}
|
||||
w.tasks[child.ID] = child
|
||||
w.ids = append(w.ids, child.ID)
|
||||
}
|
||||
|
||||
terminalWidth := 80
|
||||
w.printWithDimensions(terminalWidth, 24)
|
||||
|
||||
lines := extractLines(buf)
|
||||
for i, line := range lines {
|
||||
lineLen := lenAnsi(line)
|
||||
assert.Assert(t, lineLen <= terminalWidth,
|
||||
"line %d has length %d which exceeds terminal width %d: %q",
|
||||
i, lineLen, terminalWidth, line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdjustLineWidth_DetailsCorrectlyTruncated(t *testing.T) {
|
||||
w := &ttyWriter{}
|
||||
lines := []lineData{
|
||||
{
|
||||
taskID: "Image foo",
|
||||
status: "Pulling",
|
||||
details: "downloading layer sha256:abc123def456789xyz",
|
||||
},
|
||||
}
|
||||
|
||||
terminalWidth := 50
|
||||
timerLen := 5
|
||||
w.adjustLineWidth(lines, timerLen, terminalWidth)
|
||||
|
||||
// Verify the line fits
|
||||
detailsLen := len(lines[0].details)
|
||||
if detailsLen > 0 {
|
||||
detailsLen++ // space before details
|
||||
}
|
||||
// widthWithoutDetails = 5 + prefix(0) + taskID(9) + progress(0) + status(7) + timer(5) = 26
|
||||
lineWidth := 5 + len(lines[0].taskID) + len(lines[0].status) + detailsLen + timerLen
|
||||
|
||||
assert.Assert(t, lineWidth <= terminalWidth,
|
||||
"line width %d should not exceed terminal width %d (taskID=%q, details=%q)",
|
||||
lineWidth, terminalWidth, lines[0].taskID, lines[0].details)
|
||||
|
||||
// Verify details were truncated (not removed entirely)
|
||||
assert.Assert(t, lines[0].details != "", "details should be truncated, not removed")
|
||||
assert.Assert(t, strings.HasSuffix(lines[0].details, "..."), "truncated details should end with ...")
|
||||
}
|
||||
|
||||
func TestAdjustLineWidth_TaskIDCorrectlyTruncated(t *testing.T) {
|
||||
w := &ttyWriter{}
|
||||
lines := []lineData{
|
||||
{
|
||||
taskID: "very-long-image-name-that-exceeds-minimum-length",
|
||||
status: "Pulling",
|
||||
details: "",
|
||||
},
|
||||
}
|
||||
|
||||
terminalWidth := 40
|
||||
timerLen := 5
|
||||
w.adjustLineWidth(lines, timerLen, terminalWidth)
|
||||
|
||||
lineWidth := 5 + len(lines[0].taskID) + 7 + timerLen
|
||||
|
||||
assert.Assert(t, lineWidth <= terminalWidth,
|
||||
"line width %d should not exceed terminal width %d (taskID=%q)",
|
||||
lineWidth, terminalWidth, lines[0].taskID)
|
||||
|
||||
assert.Assert(t, strings.HasSuffix(lines[0].taskID, "..."), "truncated taskID should end with ...")
|
||||
}
|
||||
|
||||
func TestAdjustLineWidth_NoTruncationNeeded(t *testing.T) {
|
||||
w := &ttyWriter{}
|
||||
originalDetails := "short"
|
||||
originalTaskID := "Image foo"
|
||||
lines := []lineData{
|
||||
{
|
||||
taskID: originalTaskID,
|
||||
status: "Pulling",
|
||||
details: originalDetails,
|
||||
},
|
||||
}
|
||||
|
||||
// Wide terminal, nothing should be truncated
|
||||
w.adjustLineWidth(lines, 5, 100)
|
||||
|
||||
assert.Equal(t, originalTaskID, lines[0].taskID, "taskID should not be modified")
|
||||
assert.Equal(t, originalDetails, lines[0].details, "details should not be modified")
|
||||
}
|
||||
|
||||
func TestAdjustLineWidth_DetailsRemovedWhenTooShort(t *testing.T) {
|
||||
w := &ttyWriter{}
|
||||
lines := []lineData{
|
||||
{
|
||||
taskID: "Image foo",
|
||||
status: "Pulling",
|
||||
details: "abc", // Very short, can't be meaningfully truncated
|
||||
},
|
||||
}
|
||||
|
||||
// Terminal so narrow that even minimal details + "..." wouldn't help
|
||||
w.adjustLineWidth(lines, 5, 28)
|
||||
|
||||
assert.Equal(t, "", lines[0].details, "details should be removed entirely when too short to truncate")
|
||||
}
|
||||
|
||||
// stripAnsi removes ANSI escape codes from a string
|
||||
func stripAnsi(s string) string {
|
||||
var result strings.Builder
|
||||
inAnsi := false
|
||||
for _, r := range s {
|
||||
if r == '\x1b' {
|
||||
inAnsi = true
|
||||
continue
|
||||
}
|
||||
if inAnsi {
|
||||
// ANSI sequences end with a letter (m, h, l, G, etc.)
|
||||
if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') {
|
||||
inAnsi = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
result.WriteRune(r)
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
|
||||
func TestPrintWithDimensions_PulledAndPullingWithLongIDs(t *testing.T) {
|
||||
w, buf := newTestWriter()
|
||||
|
||||
// Add a completed task with long ID
|
||||
completedTask := &task{
|
||||
ID: "Image docker.io/library/nginx-long-name",
|
||||
parents: make(map[string]struct{}),
|
||||
startTime: time.Now().Add(-2 * time.Second),
|
||||
endTime: time.Now(),
|
||||
text: "Pulled",
|
||||
status: api.Done,
|
||||
spinner: NewSpinner(),
|
||||
}
|
||||
completedTask.spinner.Stop()
|
||||
w.tasks[completedTask.ID] = completedTask
|
||||
w.ids = append(w.ids, completedTask.ID)
|
||||
|
||||
// Add a pending task with long ID
|
||||
pendingTask := &task{
|
||||
ID: "Image docker.io/library/postgres-database",
|
||||
parents: make(map[string]struct{}),
|
||||
startTime: time.Now(),
|
||||
text: "Pulling",
|
||||
status: api.Working,
|
||||
spinner: NewSpinner(),
|
||||
}
|
||||
w.tasks[pendingTask.ID] = pendingTask
|
||||
w.ids = append(w.ids, pendingTask.ID)
|
||||
|
||||
terminalWidth := 50
|
||||
w.printWithDimensions(terminalWidth, 24)
|
||||
|
||||
// Strip all ANSI codes from output and split by newline
|
||||
stripped := stripAnsi(buf.String())
|
||||
lines := strings.Split(stripped, "\n")
|
||||
|
||||
// Filter non-empty lines
|
||||
var nonEmptyLines []string
|
||||
for _, line := range lines {
|
||||
if strings.TrimSpace(line) != "" {
|
||||
nonEmptyLines = append(nonEmptyLines, line)
|
||||
}
|
||||
}
|
||||
|
||||
// Expected output format (50 runes per task line)
|
||||
expected := `[+] pull 1/2
|
||||
✔ Image docker.io/library/nginx-l... Pulled 2.0s
|
||||
⠋ Image docker.io/library/postgre... Pulling 0.0s`
|
||||
|
||||
expectedLines := strings.Split(expected, "\n")
|
||||
|
||||
// Debug output
|
||||
t.Logf("Actual output:\n")
|
||||
for i, line := range nonEmptyLines {
|
||||
t.Logf(" line %d (%2d runes): %q", i, utf8.RuneCountInString(line), line)
|
||||
}
|
||||
|
||||
// Verify number of lines
|
||||
assert.Equal(t, len(expectedLines), len(nonEmptyLines), "number of lines should match")
|
||||
|
||||
// Verify each line matches expected
|
||||
for i, line := range nonEmptyLines {
|
||||
if i < len(expectedLines) {
|
||||
assert.Equal(t, expectedLines[i], line,
|
||||
"line %d should match expected", i)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify task lines fit within terminal width (strict - no tolerance)
|
||||
for i, line := range nonEmptyLines {
|
||||
if i > 0 { // Skip header line
|
||||
runeCount := utf8.RuneCountInString(line)
|
||||
assert.Assert(t, runeCount <= terminalWidth,
|
||||
"line %d has %d runes which exceeds terminal width %d: %q",
|
||||
i, runeCount, terminalWidth, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLenAnsi(t *testing.T) {
|
||||
testCases := []struct {
|
||||
input string
|
||||
expected int
|
||||
}{
|
||||
{"hello", 5},
|
||||
{"\x1b[32mhello\x1b[0m", 5},
|
||||
{"\x1b[1;32mgreen\x1b[0m text", 10},
|
||||
{"", 0},
|
||||
{"\x1b[0m", 0},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.input, func(t *testing.T) {
|
||||
result := lenAnsi(tc.input)
|
||||
assert.Equal(t, tc.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -20,47 +20,46 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/acarl005/stripansi"
|
||||
"github.com/morikuni/aec"
|
||||
)
|
||||
|
||||
var disableAnsi bool
|
||||
|
||||
func ansi(code string) string {
|
||||
return fmt.Sprintf("\033%s", code)
|
||||
}
|
||||
|
||||
func saveCursor() {
|
||||
if disableAnsi {
|
||||
return
|
||||
}
|
||||
fmt.Print(ansi("7"))
|
||||
// see https://github.com/morikuni/aec/pull/5
|
||||
fmt.Print(aec.Save)
|
||||
}
|
||||
|
||||
func restoreCursor() {
|
||||
if disableAnsi {
|
||||
return
|
||||
}
|
||||
fmt.Print(ansi("8"))
|
||||
// see https://github.com/morikuni/aec/pull/5
|
||||
fmt.Print(aec.Restore)
|
||||
}
|
||||
|
||||
func showCursor() {
|
||||
if disableAnsi {
|
||||
return
|
||||
}
|
||||
fmt.Print(ansi("[?25h"))
|
||||
fmt.Print(aec.Show)
|
||||
}
|
||||
|
||||
func moveCursor(y, x int) {
|
||||
if disableAnsi {
|
||||
return
|
||||
}
|
||||
fmt.Print(ansi(fmt.Sprintf("[%d;%dH", y, x)))
|
||||
fmt.Print(aec.Position(uint(y), uint(x)))
|
||||
}
|
||||
|
||||
func carriageReturn() {
|
||||
if disableAnsi {
|
||||
return
|
||||
}
|
||||
fmt.Print(ansi(fmt.Sprintf("[%dG", 0)))
|
||||
fmt.Print(aec.Column(0))
|
||||
}
|
||||
|
||||
func clearLine() {
|
||||
@@ -68,7 +67,7 @@ func clearLine() {
|
||||
return
|
||||
}
|
||||
// Does not move cursor from its current position
|
||||
fmt.Print(ansi("[2K"))
|
||||
fmt.Print(aec.EraseLine(aec.EraseModes.Tail))
|
||||
}
|
||||
|
||||
func moveCursorUp(lines int) {
|
||||
@@ -76,7 +75,7 @@ func moveCursorUp(lines int) {
|
||||
return
|
||||
}
|
||||
// Does not add new lines
|
||||
fmt.Print(ansi(fmt.Sprintf("[%dA", lines)))
|
||||
fmt.Print(aec.Up(uint(lines)))
|
||||
}
|
||||
|
||||
func moveCursorDown(lines int) {
|
||||
@@ -84,7 +83,7 @@ func moveCursorDown(lines int) {
|
||||
return
|
||||
}
|
||||
// Does not add new lines
|
||||
fmt.Print(ansi(fmt.Sprintf("[%dB", lines)))
|
||||
fmt.Print(aec.Down(uint(lines)))
|
||||
}
|
||||
|
||||
func newLine() {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# About
|
||||
|
||||
The Compose application model defines `service` as an abstraction for a computing unit managing (a subset of)
|
||||
application needs, which can interact with other service by relying on network(s). Docker Compose is designed
|
||||
application needs, which can interact with other services by relying on network(s). Docker Compose is designed
|
||||
to use the Docker Engine ("Moby") API to manage services as containers, but the abstraction _could_ also cover
|
||||
many other runtimes, typically cloud services or services natively provided by host.
|
||||
|
||||
@@ -55,8 +55,8 @@ JSON messages MUST include a `type` and a `message` attribute.
|
||||
|
||||
`type` can be either:
|
||||
- `info`: Reports status updates to the user. Compose will render message as the service state in the progress UI
|
||||
- `error`: Let's the user know something went wrong with details about the error. Compose will render the message as the reason for the service failure.
|
||||
- `setenv`: Let's the plugin tell Compose how dependent services can access the created resource. See next section for further details.
|
||||
- `error`: Lets the user know something went wrong with details about the error. Compose will render the message as the reason for the service failure.
|
||||
- `setenv`: Lets the plugin tell Compose how dependent services can access the created resource. See next section for further details.
|
||||
- `debug`: Those messages could help debugging the provider, but are not rendered to the user by default. They are rendered when Compose is started with `--verbose` flag.
|
||||
|
||||
```mermaid
|
||||
|
||||
22
docs/sdk.md
22
docs/sdk.md
@@ -28,8 +28,8 @@ import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"github.com/docker/cli/cli/command"
|
||||
"github.com/docker/cli/cli/flags"
|
||||
"github.com/docker/cli/cli/command"
|
||||
"github.com/docker/cli/cli/flags"
|
||||
"github.com/docker/compose/v5/pkg/api"
|
||||
"github.com/docker/compose/v5/pkg/compose"
|
||||
)
|
||||
@@ -37,15 +37,15 @@ import (
|
||||
func main() {
|
||||
ctx := context.Background()
|
||||
|
||||
dockerCLI, err := command.NewDockerCli()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create docker CLI: %v", err)
|
||||
}
|
||||
err = dockerCLI.Initialize(flags.ClientOptions{})
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to initialize docker CLI: %v", err)
|
||||
}
|
||||
|
||||
dockerCLI, err := command.NewDockerCli()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create docker CLI: %v", err)
|
||||
}
|
||||
err = dockerCLI.Initialize(&flags.ClientOptions{})
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to initialize docker CLI: %v", err)
|
||||
}
|
||||
|
||||
// Create a new Compose service instance
|
||||
service, err := compose.NewComposeService(dockerCLI)
|
||||
if err != nil {
|
||||
|
||||
46
go.mod
46
go.mod
@@ -1,6 +1,6 @@
|
||||
module github.com/docker/compose/v5
|
||||
|
||||
go 1.24.9
|
||||
go 1.24.3
|
||||
|
||||
require (
|
||||
github.com/AlecAivazis/survey/v2 v2.3.7
|
||||
@@ -8,39 +8,38 @@ require (
|
||||
github.com/Microsoft/go-winio v0.6.2
|
||||
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d
|
||||
github.com/buger/goterm v1.0.4
|
||||
github.com/compose-spec/compose-go/v2 v2.10.0
|
||||
github.com/compose-spec/compose-go/v2 v2.10.1
|
||||
github.com/containerd/console v1.0.5
|
||||
github.com/containerd/containerd/v2 v2.2.1-0.20251115011841-efd86f2b0bc2
|
||||
github.com/containerd/containerd/v2 v2.2.1
|
||||
github.com/containerd/errdefs v1.0.0
|
||||
github.com/containerd/platforms v1.0.0-rc.2
|
||||
github.com/distribution/reference v0.6.0
|
||||
github.com/docker/buildx v0.30.1
|
||||
github.com/docker/cli v28.5.2+incompatible
|
||||
github.com/docker/cli-docs-tool v0.10.0
|
||||
github.com/docker/cli-docs-tool v0.11.0
|
||||
github.com/docker/docker v28.5.2+incompatible
|
||||
github.com/docker/go-connections v0.6.0
|
||||
github.com/docker/go-units v0.5.0
|
||||
github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203
|
||||
github.com/fsnotify/fsevents v0.2.0
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0
|
||||
github.com/google/go-cmp v0.7.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/hashicorp/go-version v1.8.0
|
||||
github.com/jonboulle/clockwork v0.5.0
|
||||
github.com/mattn/go-shellwords v1.0.12
|
||||
github.com/mitchellh/go-ps v1.0.0
|
||||
github.com/moby/buildkit v0.26.2
|
||||
github.com/moby/buildkit v0.26.3
|
||||
github.com/moby/go-archive v0.1.0
|
||||
github.com/moby/patternmatcher v0.6.0
|
||||
github.com/moby/sys/atomicwriter v0.1.0
|
||||
github.com/moby/term v0.5.2
|
||||
github.com/morikuni/aec v1.0.0
|
||||
github.com/morikuni/aec v1.1.0
|
||||
github.com/opencontainers/go-digest v1.0.0
|
||||
github.com/opencontainers/image-spec v1.1.1
|
||||
github.com/otiai10/copy v1.14.1
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/sirupsen/logrus v1.9.4
|
||||
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
|
||||
github.com/spf13/cobra v1.10.1
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/pflag v1.0.10
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/tilt-dev/fsnotify v1.4.8-0.20220602155310-fff9c274a375
|
||||
@@ -53,12 +52,12 @@ require (
|
||||
go.opentelemetry.io/otel/trace v1.38.0
|
||||
go.uber.org/goleak v1.3.0
|
||||
go.uber.org/mock v0.6.0
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.3
|
||||
golang.org/x/sync v0.18.0
|
||||
golang.org/x/sys v0.38.0
|
||||
google.golang.org/grpc v1.77.0
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.4
|
||||
golang.org/x/sync v0.19.0
|
||||
golang.org/x/sys v0.41.0
|
||||
google.golang.org/grpc v1.78.0
|
||||
gotest.tools/v3 v3.5.2
|
||||
tags.cncf.io/container-device-interface v1.0.1
|
||||
tags.cncf.io/container-device-interface v1.1.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -95,7 +94,7 @@ require (
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/inhies/go-bytesize v0.0.0-20220417184213-4913239db9cf // indirect
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||
github.com/klauspost/compress v1.18.1 // indirect
|
||||
github.com/klauspost/compress v1.18.2 // indirect
|
||||
github.com/magiconair/properties v1.8.9 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
@@ -112,6 +111,7 @@ require (
|
||||
github.com/moby/sys/symlink v0.3.0 // indirect
|
||||
github.com/moby/sys/user v0.4.0 // indirect
|
||||
github.com/moby/sys/userns v0.1.0 // indirect
|
||||
github.com/moby/term v0.5.2 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/otiai10/mint v1.6.3 // indirect
|
||||
github.com/pelletier/go-toml v1.9.5 // indirect
|
||||
@@ -143,23 +143,15 @@ require (
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.7.1 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto v0.45.0 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/term v0.37.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
exclude (
|
||||
// FIXME(thaJeztah): remove this once kubernetes updated their dependencies to no longer need this.
|
||||
//
|
||||
// For additional details, see this PR and links mentioned in that PR:
|
||||
// https://github.com/kubernetes-sigs/kustomize/pull/5830#issuecomment-2569960859
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2
|
||||
)
|
||||
|
||||
82
go.sum
82
go.sum
@@ -46,16 +46,16 @@ github.com/cloudflare/cfssl v0.0.0-20180223231731-4e2dcbde5004 h1:lkAMpLVBDaj17e
|
||||
github.com/cloudflare/cfssl v0.0.0-20180223231731-4e2dcbde5004/go.mod h1:yMWuSON2oQp+43nFtAV/uvKQIFpSPerB57DCt9t8sSA=
|
||||
github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUoc7Ik9EfrFqcylYqgPZ9ANSbTAntnE=
|
||||
github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4=
|
||||
github.com/compose-spec/compose-go/v2 v2.10.0 h1:K2C5LQ3KXvkYpy5N/SG6kIYB90iiAirA9btoTh/gB0Y=
|
||||
github.com/compose-spec/compose-go/v2 v2.10.0/go.mod h1:Ohac1SzhO/4fXXrzWIztIVB6ckmKBv1Nt5Z5mGVESUg=
|
||||
github.com/containerd/cgroups/v3 v3.1.0 h1:azxYVj+91ZgSnIBp2eI3k9y2iYQSR/ZQIgh9vKO+HSY=
|
||||
github.com/containerd/cgroups/v3 v3.1.0/go.mod h1:SA5DLYnXO8pTGYiAHXz94qvLQTKfVM5GEVisn4jpins=
|
||||
github.com/compose-spec/compose-go/v2 v2.10.1 h1:mFbXobojGRFIVi1UknrvaDAZ+PkJfyjqkA1yseh+vAU=
|
||||
github.com/compose-spec/compose-go/v2 v2.10.1/go.mod h1:Ohac1SzhO/4fXXrzWIztIVB6ckmKBv1Nt5Z5mGVESUg=
|
||||
github.com/containerd/cgroups/v3 v3.1.2 h1:OSosXMtkhI6Qove637tg1XgK4q+DhR0mX8Wi8EhrHa4=
|
||||
github.com/containerd/cgroups/v3 v3.1.2/go.mod h1:PKZ2AcWmSBsY/tJUVhtS/rluX0b1uq1GmPO1ElCmbOw=
|
||||
github.com/containerd/console v1.0.5 h1:R0ymNeydRqH2DmakFNdmjR2k0t7UPuiOV/N/27/qqsc=
|
||||
github.com/containerd/console v1.0.5/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
|
||||
github.com/containerd/containerd/api v1.10.0 h1:5n0oHYVBwN4VhoX9fFykCV9dF1/BvAXeg2F8W6UYq1o=
|
||||
github.com/containerd/containerd/api v1.10.0/go.mod h1:NBm1OAk8ZL+LG8R0ceObGxT5hbUYj7CzTmR3xh0DlMM=
|
||||
github.com/containerd/containerd/v2 v2.2.1-0.20251115011841-efd86f2b0bc2 h1:WcvXNS/OmpiitTVdzRAudKwvShKxcOP4Elf2FyxSoTg=
|
||||
github.com/containerd/containerd/v2 v2.2.1-0.20251115011841-efd86f2b0bc2/go.mod h1:YCMjKjA4ZA7egdHNi3/93bJR1+2oniYlnS+c0N62HdE=
|
||||
github.com/containerd/containerd/v2 v2.2.1 h1:TpyxcY4AL5A+07dxETevunVS5zxqzuq7ZqJXknM11yk=
|
||||
github.com/containerd/containerd/v2 v2.2.1/go.mod h1:NR70yW1iDxe84F2iFWbR9xfAN0N2F0NcjTi1OVth4nU=
|
||||
github.com/containerd/continuity v0.4.5 h1:ZRoN1sXq9u7V6QoHMcVWGhOwDFqZ4B9i5H6un1Wh0x4=
|
||||
github.com/containerd/continuity v0.4.5/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE=
|
||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||
@@ -86,6 +86,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
|
||||
github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/cyphar/filepath-securejoin v0.5.1 h1:eYgfMq5yryL4fbWfkLpFFy2ukSELzaJOTaUTuh+oF48=
|
||||
github.com/cyphar/filepath-securejoin v0.5.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -98,8 +100,8 @@ github.com/docker/buildx v0.30.1 h1:3vthfaTQOLt5QfN2nl7rKuPLUvx69nL5ZikFIXp//c8=
|
||||
github.com/docker/buildx v0.30.1/go.mod h1:8nwT0V6UNYNo9rXq6WO/BQd9KrJ0JYcY/QX6x0y1Oro=
|
||||
github.com/docker/cli v28.5.2+incompatible h1:XmG99IHcBmIAoC1PPg9eLBZPlTrNUAijsHLm8PjhBlg=
|
||||
github.com/docker/cli v28.5.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||
github.com/docker/cli-docs-tool v0.10.0 h1:bOD6mKynPQgojQi3s2jgcUWGp/Ebqy1SeCr9VfKQLLU=
|
||||
github.com/docker/cli-docs-tool v0.10.0/go.mod h1:5EM5zPnT2E7yCLERZmrDA234Vwn09fzRHP4aX1qwp1U=
|
||||
github.com/docker/cli-docs-tool v0.11.0 h1:7d8QARFb7QEobizqxmEM7fOteZEHwH/zWgHQtHZEcfE=
|
||||
github.com/docker/cli-docs-tool v0.11.0/go.mod h1:ma8BKiisUo8D6W05XEYIh3oa1UbgrZhi1nowyKFJa8Q=
|
||||
github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
|
||||
github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk=
|
||||
github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
|
||||
@@ -141,8 +143,8 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre
|
||||
github.com/go-sql-driver/mysql v1.3.0 h1:pgwjLi/dvffoP9aabwkT3AKpXQM93QARkjFhDDqC1UE=
|
||||
github.com/go-sql-driver/mysql v1.3.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
|
||||
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
|
||||
github.com/gogo/protobuf v1.0.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
@@ -209,8 +211,8 @@ github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNU
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
|
||||
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
|
||||
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
||||
@@ -251,8 +253,8 @@ github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/z
|
||||
github.com/mitchellh/mapstructure v0.0.0-20150613213606-2caf8efc9366/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/moby/buildkit v0.26.2 h1:EIh5j0gzRsCZmQzvgNNWzSDbuKqwUIiBH7ssqLv8RU8=
|
||||
github.com/moby/buildkit v0.26.2/go.mod h1:ylDa7IqzVJgLdi/wO7H1qLREFQpmhFbw2fbn4yoTw40=
|
||||
github.com/moby/buildkit v0.26.3 h1:D+ruZVAk/3ipRq5XRxBH9/DIFpRjSlTtMbghT5gQP9g=
|
||||
github.com/moby/buildkit v0.26.3/go.mod h1:4T4wJzQS4kYWIfFRjsbJry4QoxDBjK+UGOEOs1izL7w=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ=
|
||||
@@ -283,8 +285,8 @@ github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJ
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ=
|
||||
github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||
@@ -299,10 +301,10 @@ github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3I
|
||||
github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||
github.com/opencontainers/runtime-spec v1.2.1 h1:S4k4ryNgEpxW1dzyqffOmhI1BHYcjzU8lpJfSlR0xww=
|
||||
github.com/opencontainers/runtime-spec v1.2.1/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0=
|
||||
github.com/opencontainers/selinux v1.12.0 h1:6n5JV4Cf+4y0KNXW48TLj5DwfXpvWlxXplUkdTrmPb8=
|
||||
github.com/opencontainers/selinux v1.12.0/go.mod h1:BTPX+bjVbWGXw7ZZWUbdENt8w0htPSrlgOOysQaU62U=
|
||||
github.com/opencontainers/runtime-spec v1.3.0 h1:YZupQUdctfhpZy3TM39nN9Ika5CBWT5diQ8ibYCRkxg=
|
||||
github.com/opencontainers/runtime-spec v1.3.0/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0=
|
||||
github.com/opencontainers/selinux v1.13.1 h1:A8nNeceYngH9Ow++M+VVEwJVpdFmrlxsN22F+ISDCJE=
|
||||
github.com/opencontainers/selinux v1.13.1/go.mod h1:S10WXZ/osk2kWOYKy1x2f/eXF5ZHJoUs8UU/2caNRbg=
|
||||
github.com/opentracing/opentracing-go v1.1.0 h1:pWlfV3Bxv7k65HYwkikxat0+s3pV4bsqf19k25Ur8rU=
|
||||
github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
|
||||
github.com/otiai10/copy v1.14.1 h1:5/7E6qsUMBaH5AnQ0sSLzzTg1oTECmcCmT6lvF45Na8=
|
||||
@@ -356,8 +358,8 @@ github.com/shibumi/go-pathspec v1.3.0/go.mod h1:Xutfslp817l2I1cZvgcfeMQJG5QnU2lh
|
||||
github.com/sirupsen/logrus v1.0.6/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc=
|
||||
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA=
|
||||
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
|
||||
github.com/spdx/tools-golang v0.5.5 h1:61c0KLfAcNqAjlg6UNMdkwpMernhw3zVRwDZ2x9XOmk=
|
||||
@@ -365,8 +367,8 @@ github.com/spdx/tools-golang v0.5.5/go.mod h1:MVIsXx8ZZzaRWNQpUDhC4Dud34edUYJYec
|
||||
github.com/spf13/cast v0.0.0-20150508191742-4d07383ffe94 h1:JmfC365KywYwHB946TTiQWEb8kqPY+pybPLoGE9GgVk=
|
||||
github.com/spf13/cast v0.0.0-20150508191742-4d07383ffe94/go.mod h1:r2rcYCSwa1IExKTDiTfzaxqT2FNHs8hODu4LnUfgKEg=
|
||||
github.com/spf13/cobra v0.0.1/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
|
||||
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
|
||||
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/jwalterweatherman v0.0.0-20141219030609-3d60171a6431 h1:XTHrT015sxHyJ5FnQ0AeemSspZWaDq7DoTRW0EVsDCE=
|
||||
github.com/spf13/jwalterweatherman v0.0.0-20141219030609-3d60171a6431/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
|
||||
github.com/spf13/pflag v1.0.0/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
|
||||
@@ -383,7 +385,6 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/theupdateframework/notary v0.7.0 h1:QyagRZ7wlSpjT5N2qQAh/pN+DVqgekv4DzbAiAiEL3c=
|
||||
@@ -445,8 +446,10 @@ go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.4 h1:UP4+v6fFrBIb1l934bDl//mmnoIZEDK0idg1+AIvX5U=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.4/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
@@ -480,8 +483,8 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -498,12 +501,11 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -528,13 +530,13 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 h1:M1rk8KBnUsBDg1oPGHNCxG4vc1f49epmTO7xscSajMk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda h1:+2XxjfsAu6vqFxwGBRcHiMaDCuZiqXGDUDVWVtrFAnE=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda h1:i/Q+bfisr7gq6feoJnS/DlpdwEL4ihp41fvRiM3Ork0=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/grpc v1.0.5/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
|
||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
||||
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U=
|
||||
@@ -562,5 +564,5 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||
tags.cncf.io/container-device-interface v1.0.1 h1:KqQDr4vIlxwfYh0Ed/uJGVgX+CHAkahrgabg6Q8GYxc=
|
||||
tags.cncf.io/container-device-interface v1.0.1/go.mod h1:JojJIOeW3hNbcnOH2q0NrWNha/JuHoDZcmYxAZwb2i0=
|
||||
tags.cncf.io/container-device-interface v1.1.0 h1:RnxNhxF1JOu6CJUVpetTYvrXHdxw9j9jFYgZpI+anSY=
|
||||
tags.cncf.io/container-device-interface v1.1.0/go.mod h1:76Oj0Yqp9FwTx/pySDc8Bxjpg+VqXfDb50cKAXVJ34Q=
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
package desktop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -34,9 +33,6 @@ func TestClientPing(t *testing.T) {
|
||||
t.Skip("Skipping - COMPOSE_TEST_DESKTOP_ENDPOINT not defined")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
|
||||
client := NewClient(desktopEndpoint)
|
||||
t.Cleanup(func() {
|
||||
_ = client.Close()
|
||||
@@ -44,7 +40,7 @@ func TestClientPing(t *testing.T) {
|
||||
|
||||
now := time.Now()
|
||||
|
||||
ret, err := client.Ping(ctx)
|
||||
ret, err := client.Ping(t.Context())
|
||||
require.NoError(t, err)
|
||||
|
||||
serverTime := time.Unix(0, ret.ServerTime)
|
||||
|
||||
20
pkg/api/env.go
Normal file
20
pkg/api/env.go
Normal file
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
Copyright 2020 Docker Compose CLI authors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package api
|
||||
|
||||
// ComposeCompatibility try to mimic compose v1 as much as possible
|
||||
const ComposeCompatibility = "COMPOSE_COMPATIBILITY"
|
||||
@@ -38,33 +38,37 @@ const (
|
||||
const ResourceCompose = "Compose"
|
||||
|
||||
const (
|
||||
StatusError = "Error"
|
||||
StatusCreating = "Creating"
|
||||
StatusStarting = "Starting"
|
||||
StatusStarted = "Started"
|
||||
StatusWaiting = "Waiting"
|
||||
StatusHealthy = "Healthy"
|
||||
StatusExited = "Exited"
|
||||
StatusRestarting = "Restarting"
|
||||
StatusRestarted = "Restarted"
|
||||
StatusRunning = "Running"
|
||||
StatusCreated = "Created"
|
||||
StatusStopping = "Stopping"
|
||||
StatusStopped = "Stopped"
|
||||
StatusKilling = "Killing"
|
||||
StatusKilled = "Killed"
|
||||
StatusRemoving = "Removing"
|
||||
StatusRemoved = "Removed"
|
||||
StatusBuilding = "Building"
|
||||
StatusBuilt = "Built"
|
||||
StatusPulling = "Pulling"
|
||||
StatusPulled = "Pulled"
|
||||
StatusCommitting = "Committing"
|
||||
StatusCommitted = "Committed"
|
||||
StatusCopying = "Copying"
|
||||
StatusCopied = "Copied"
|
||||
StatusExporting = "Exporting"
|
||||
StatusExported = "Exported"
|
||||
StatusError = "Error"
|
||||
StatusCreating = "Creating"
|
||||
StatusStarting = "Starting"
|
||||
StatusStarted = "Started"
|
||||
StatusWaiting = "Waiting"
|
||||
StatusHealthy = "Healthy"
|
||||
StatusExited = "Exited"
|
||||
StatusRestarting = "Restarting"
|
||||
StatusRestarted = "Restarted"
|
||||
StatusRunning = "Running"
|
||||
StatusCreated = "Created"
|
||||
StatusStopping = "Stopping"
|
||||
StatusStopped = "Stopped"
|
||||
StatusKilling = "Killing"
|
||||
StatusKilled = "Killed"
|
||||
StatusRemoving = "Removing"
|
||||
StatusRemoved = "Removed"
|
||||
StatusBuilding = "Building"
|
||||
StatusBuilt = "Built"
|
||||
StatusPulling = "Pulling"
|
||||
StatusPulled = "Pulled"
|
||||
StatusCommitting = "Committing"
|
||||
StatusCommitted = "Committed"
|
||||
StatusCopying = "Copying"
|
||||
StatusCopied = "Copied"
|
||||
StatusExporting = "Exporting"
|
||||
StatusExported = "Exported"
|
||||
StatusDownloading = "Downloading"
|
||||
StatusDownloadComplete = "Download complete"
|
||||
StatusConfiguring = "Configuring"
|
||||
StatusConfigured = "Configured"
|
||||
)
|
||||
|
||||
// Resource represents status change and progress for a compose resource.
|
||||
|
||||
@@ -35,6 +35,7 @@ import (
|
||||
"github.com/docker/docker/api/types/network"
|
||||
"github.com/docker/docker/pkg/jsonmessage"
|
||||
"github.com/docker/go-connections/nat"
|
||||
"github.com/sirupsen/logrus"
|
||||
"go.yaml.in/yaml/v4"
|
||||
|
||||
"github.com/docker/compose/v5/pkg/api"
|
||||
@@ -85,7 +86,17 @@ func convert(ctx context.Context, dockerCli command.Cli, model map[string]any, o
|
||||
return err
|
||||
}
|
||||
|
||||
dir := os.TempDir()
|
||||
dir, err := os.MkdirTemp("", "compose-convert-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
err := os.RemoveAll(dir)
|
||||
if err != nil {
|
||||
logrus.Warnf("failed to remove temp dir %s: %v", dir, err)
|
||||
}
|
||||
}()
|
||||
|
||||
composeYaml := filepath.Join(dir, "compose.yaml")
|
||||
err = os.WriteFile(composeYaml, raw, 0o600)
|
||||
if err != nil {
|
||||
|
||||
73
pkg/compose/api_versions.go
Normal file
73
pkg/compose/api_versions.go
Normal file
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
Copyright 2020 Docker Compose CLI authors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package compose
|
||||
|
||||
// Docker Engine API version constants.
|
||||
// These versions correspond to specific Docker Engine releases and their features.
|
||||
const (
|
||||
// APIVersion144 represents Docker Engine API version 1.44 (Engine v25.0).
|
||||
//
|
||||
// New features in this version:
|
||||
// - Endpoint-specific MAC address configuration
|
||||
// - Multiple networks can be connected during container creation
|
||||
// - healthcheck.start_interval parameter support
|
||||
//
|
||||
// Before this version:
|
||||
// - MAC address was container-wide only
|
||||
// - Extra networks required post-creation NetworkConnect calls
|
||||
// - healthcheck.start_interval was not available
|
||||
APIVersion144 = "1.44"
|
||||
|
||||
// APIVersion148 represents Docker Engine API version 1.48 (Engine v28.0).
|
||||
//
|
||||
// New features in this version:
|
||||
// - Volume mounts with type=image support
|
||||
//
|
||||
// Before this version:
|
||||
// - Only bind, volume, and tmpfs mount types were supported
|
||||
APIVersion148 = "1.48"
|
||||
|
||||
// APIVersion149 represents Docker Engine API version 1.49 (Engine v28.1).
|
||||
//
|
||||
// New features in this version:
|
||||
// - Network interface_name configuration
|
||||
// - Platform parameter in ImageList API
|
||||
//
|
||||
// Before this version:
|
||||
// - interface_name was not configurable
|
||||
// - ImageList didn't support platform filtering
|
||||
APIVersion149 = "1.49"
|
||||
)
|
||||
|
||||
// Docker Engine version strings for user-facing error messages.
|
||||
// These should be used in error messages to provide clear version requirements.
|
||||
const (
|
||||
// DockerEngineV25 is the major version string for Docker Engine 25.x
|
||||
DockerEngineV25 = "v25"
|
||||
|
||||
// DockerEngineV28 is the major version string for Docker Engine 28.x
|
||||
DockerEngineV28 = "v28"
|
||||
|
||||
// DockerEngineV28_1 is the specific version string for Docker Engine 28.1
|
||||
DockerEngineV28_1 = "v28.1"
|
||||
)
|
||||
|
||||
// Build tool version constants
|
||||
const (
|
||||
// BuildxMinVersion is the minimum required version of buildx for compose build
|
||||
BuildxMinVersion = "0.17.0"
|
||||
)
|
||||
@@ -24,10 +24,9 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/compose-spec/compose-go/v2/types"
|
||||
"github.com/docker/cli/cli/streams"
|
||||
containerType "github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/pkg/stdcopy"
|
||||
"github.com/moby/term"
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/docker/compose/v5/pkg/api"
|
||||
"github.com/docker/compose/v5/pkg/utils"
|
||||
@@ -49,7 +48,10 @@ func (s *composeService) attach(ctx context.Context, project *types.Project, lis
|
||||
names = append(names, getContainerNameWithoutProject(c))
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(s.stdout(), "Attaching to %s\n", strings.Join(names, ", "))
|
||||
_, err = fmt.Fprintf(s.stdout(), "Attaching to %s\n", strings.Join(names, ", "))
|
||||
if err != nil {
|
||||
logrus.Debugf("failed to write attach message: %v", err)
|
||||
}
|
||||
|
||||
for _, ctr := range containers {
|
||||
err := s.attachContainer(ctx, ctr, listener)
|
||||
@@ -57,7 +59,7 @@ func (s *composeService) attach(ctx context.Context, project *types.Project, lis
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return containers, err
|
||||
return containers, nil
|
||||
}
|
||||
|
||||
func (s *composeService) attachContainer(ctx context.Context, container containerType.Summary, listener api.ContainerEventListener) error {
|
||||
@@ -91,78 +93,59 @@ func (s *composeService) doAttachContainer(ctx context.Context, service, id, nam
|
||||
})
|
||||
})
|
||||
|
||||
_, _, err = s.attachContainerStreams(ctx, id, inspect.Config.Tty, nil, wOut, wErr)
|
||||
return err
|
||||
err = s.attachContainerStreams(ctx, id, inspect.Config.Tty, wOut, wErr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *composeService) attachContainerStreams(ctx context.Context, container string, tty bool, stdin io.ReadCloser, stdout, stderr io.WriteCloser) (func(), chan bool, error) {
|
||||
detached := make(chan bool)
|
||||
restore := func() { /* noop */ }
|
||||
if stdin != nil {
|
||||
in := streams.NewIn(stdin)
|
||||
if in.IsTerminal() {
|
||||
state, err := term.SetRawTerminal(in.FD())
|
||||
if err != nil {
|
||||
return restore, detached, err
|
||||
}
|
||||
restore = func() {
|
||||
term.RestoreTerminal(in.FD(), state) //nolint:errcheck
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
streamIn, streamOut, err := s.getContainerStreams(ctx, container)
|
||||
func (s *composeService) attachContainerStreams(ctx context.Context, container string, tty bool, stdout, stderr io.WriteCloser) error {
|
||||
streamOut, err := s.getContainerStreams(ctx, container)
|
||||
if err != nil {
|
||||
return restore, detached, err
|
||||
}
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
if stdin != nil {
|
||||
stdin.Close() //nolint:errcheck
|
||||
}
|
||||
}()
|
||||
|
||||
if streamIn != nil && stdin != nil {
|
||||
go func() {
|
||||
_, err := io.Copy(streamIn, stdin)
|
||||
var escapeErr term.EscapeError
|
||||
if errors.As(err, &escapeErr) {
|
||||
close(detached)
|
||||
}
|
||||
}()
|
||||
return err
|
||||
}
|
||||
|
||||
if stdout != nil {
|
||||
go func() {
|
||||
defer stdout.Close() //nolint:errcheck
|
||||
defer stderr.Close() //nolint:errcheck
|
||||
defer streamOut.Close() //nolint:errcheck
|
||||
defer func() {
|
||||
if err := stdout.Close(); err != nil {
|
||||
logrus.Debugf("failed to close stdout: %v", err)
|
||||
}
|
||||
if err := stderr.Close(); err != nil {
|
||||
logrus.Debugf("failed to close stderr: %v", err)
|
||||
}
|
||||
if err := streamOut.Close(); err != nil {
|
||||
logrus.Debugf("failed to close stream output: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
var err error
|
||||
if tty {
|
||||
io.Copy(stdout, streamOut) //nolint:errcheck
|
||||
_, err = io.Copy(stdout, streamOut)
|
||||
} else {
|
||||
stdcopy.StdCopy(stdout, stderr, streamOut) //nolint:errcheck
|
||||
_, err = stdcopy.StdCopy(stdout, stderr, streamOut)
|
||||
}
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
logrus.Debugf("stream copy error for container %s: %v", container, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
return restore, detached, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *composeService) getContainerStreams(ctx context.Context, container string) (io.WriteCloser, io.ReadCloser, error) {
|
||||
var stdout io.ReadCloser
|
||||
var stdin io.WriteCloser
|
||||
func (s *composeService) getContainerStreams(ctx context.Context, container string) (io.ReadCloser, error) {
|
||||
cnx, err := s.apiClient().ContainerAttach(ctx, container, containerType.AttachOptions{
|
||||
Stream: true,
|
||||
Stdin: true,
|
||||
Stdout: true,
|
||||
Stderr: true,
|
||||
Logs: false,
|
||||
DetachKeys: s.configFile().DetachKeys,
|
||||
Stream: true,
|
||||
Stdin: false,
|
||||
Stdout: true,
|
||||
Stderr: true,
|
||||
Logs: false,
|
||||
})
|
||||
if err == nil {
|
||||
stdout = ContainerStdout{HijackedResponse: cnx}
|
||||
stdin = ContainerStdin{HijackedResponse: cnx}
|
||||
return stdin, stdout, nil
|
||||
stdout := ContainerStdout{HijackedResponse: cnx}
|
||||
return stdout, nil
|
||||
}
|
||||
|
||||
// Fallback to logs API
|
||||
@@ -172,7 +155,7 @@ func (s *composeService) getContainerStreams(ctx context.Context, container stri
|
||||
Follow: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return nil, err
|
||||
}
|
||||
return stdin, logs, nil
|
||||
return logs, nil
|
||||
}
|
||||
|
||||
@@ -40,7 +40,10 @@ func (s *composeService) Build(ctx context.Context, project *types.Project, opti
|
||||
return Run(ctx, func(ctx context.Context) error {
|
||||
return tracing.SpanWrapFunc("project/build", tracing.ProjectOptions(ctx, project),
|
||||
func(ctx context.Context) error {
|
||||
_, err := s.build(ctx, project, options, nil)
|
||||
builtImages, err := s.build(ctx, project, options, nil)
|
||||
if err == nil && len(builtImages) == 0 {
|
||||
logrus.Warn("No services to build")
|
||||
}
|
||||
return err
|
||||
})(ctx)
|
||||
}, "build", s.events)
|
||||
@@ -61,6 +64,7 @@ func (s *composeService) build(ctx context.Context, project *types.Project, opti
|
||||
|
||||
// also include services used as additional_contexts with service: prefix
|
||||
options.Services = addBuildDependencies(options.Services, project)
|
||||
|
||||
// Some build dependencies we just introduced may not be enabled
|
||||
var err error
|
||||
project, err = project.WithServicesEnabled(options.Services...)
|
||||
@@ -85,10 +89,14 @@ func (s *composeService) build(ctx context.Context, project *types.Project, opti
|
||||
serviceToBuild[serviceName] = *service
|
||||
return nil
|
||||
}, policy)
|
||||
if err != nil || len(serviceToBuild) == 0 {
|
||||
if err != nil {
|
||||
return imageIDs, err
|
||||
}
|
||||
|
||||
if len(serviceToBuild) == 0 {
|
||||
return imageIDs, nil
|
||||
}
|
||||
|
||||
bake, err := buildWithBake(s.dockerCli)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -150,11 +158,37 @@ func (s *composeService) ensureImagesExists(ctx context.Context, project *types.
|
||||
if ok {
|
||||
service.CustomLabels.Add(api.ImageDigestLabel, img.ID)
|
||||
}
|
||||
|
||||
resolveImageVolumes(&service, images, project.Name)
|
||||
|
||||
project.Services[name] = service
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveImageVolumes(service *types.ServiceConfig, images map[string]api.ImageSummary, projectName string) {
|
||||
for i, vol := range service.Volumes {
|
||||
if vol.Type == types.VolumeTypeImage {
|
||||
imgName := vol.Source
|
||||
if _, ok := images[vol.Source]; !ok {
|
||||
// check if source is another service in the project
|
||||
imgName = api.GetImageNameOrDefault(types.ServiceConfig{Name: vol.Source}, projectName)
|
||||
// If we still can't find it, it might be an external image that wasn't pulled yet or doesn't exist
|
||||
if _, ok := images[imgName]; !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if img, ok := images[imgName]; ok {
|
||||
// Use Image ID directly as source.
|
||||
// Using name@digest format (via reference.WithDigest) fails for local-only images
|
||||
// that don't have RepoDigests (e.g. built locally in CI).
|
||||
// Image ID (sha256:...) is always valid and ensures ServiceHash changes on rebuild.
|
||||
service.Volumes[i].Source = img.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *composeService) getLocalImagesDigests(ctx context.Context, project *types.Project) (map[string]api.ImageSummary, error) {
|
||||
imageNames := utils.Set[string]{}
|
||||
for _, s := range project.Services {
|
||||
|
||||
@@ -119,8 +119,8 @@ func (s *composeService) doBuildBake(ctx context.Context, project *types.Project
|
||||
eg := errgroup.Group{}
|
||||
ch := make(chan *client.SolveStatus)
|
||||
displayMode := progressui.DisplayMode(options.Progress)
|
||||
if displayMode == progressui.AutoMode {
|
||||
options.Progress = os.Getenv("BUILDKIT_PROGRESS")
|
||||
if p, ok := os.LookupEnv("BUILDKIT_PROGRESS"); ok && displayMode == progressui.AutoMode {
|
||||
displayMode = progressui.DisplayMode(p)
|
||||
}
|
||||
out := options.Out
|
||||
if out == nil {
|
||||
@@ -289,15 +289,11 @@ func (s *composeService) doBuildBake(ctx context.Context, project *types.Project
|
||||
_ = os.Remove(metadataFile)
|
||||
}()
|
||||
|
||||
buildx, err := manager.GetPlugin("buildx", s.dockerCli, &cobra.Command{})
|
||||
buildx, err := s.getBuildxPlugin()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if versions.LessThan(buildx.Version[1:], "0.17.0") {
|
||||
return nil, fmt.Errorf("compose build requires buildx 0.17 or later")
|
||||
}
|
||||
|
||||
args := []string{"bake", "--file", "-", "--progress", "rawjson", "--metadata-file", metadataFile}
|
||||
// FIXME we should prompt user about this, but this is a breaking change in UX
|
||||
for _, path := range read {
|
||||
@@ -414,6 +410,27 @@ func (s *composeService) doBuildBake(ctx context.Context, project *types.Project
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (s *composeService) getBuildxPlugin() (*manager.Plugin, error) {
|
||||
buildx, err := manager.GetPlugin("buildx", s.dockerCli, &cobra.Command{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if buildx.Err != nil {
|
||||
return nil, buildx.Err
|
||||
}
|
||||
|
||||
if buildx.Version == "" {
|
||||
return nil, fmt.Errorf("failed to get version of buildx")
|
||||
}
|
||||
|
||||
if versions.LessThan(buildx.Version[1:], BuildxMinVersion) {
|
||||
return nil, fmt.Errorf("compose build requires buildx %s or later", BuildxMinVersion)
|
||||
}
|
||||
|
||||
return buildx, nil
|
||||
}
|
||||
|
||||
// makeConsole wraps the provided writer to match [containerd.File] interface if it is of type *streams.Out.
|
||||
// buildkit's NewDisplay doesn't actually require a [io.Reader], it only uses the [containerd.Console] type to
|
||||
// benefits from ANSI capabilities, but only does writes.
|
||||
|
||||
@@ -478,7 +478,7 @@ var swarmEnabled = struct {
|
||||
err error
|
||||
}{}
|
||||
|
||||
func (s *composeService) isSWarmEnabled(ctx context.Context) (bool, error) {
|
||||
func (s *composeService) isSwarmEnabled(ctx context.Context) (bool, error) {
|
||||
swarmEnabled.once.Do(func() {
|
||||
info, err := s.apiClient().Info(ctx)
|
||||
if err != nil {
|
||||
|
||||
@@ -121,7 +121,7 @@ func (c *convergence) ensureService(ctx context.Context, project *types.Project,
|
||||
actual := len(containers)
|
||||
updated := make(Containers, expected)
|
||||
|
||||
eg, _ := errgroup.WithContext(ctx)
|
||||
eg, ctx := errgroup.WithContext(ctx)
|
||||
|
||||
err = c.resolveServiceReferences(&service)
|
||||
if err != nil {
|
||||
@@ -451,7 +451,7 @@ func (s *composeService) waitDependencies(ctx context.Context, project *types.Pr
|
||||
defer cancelFunc()
|
||||
ctx = withTimeout
|
||||
}
|
||||
eg, _ := errgroup.WithContext(ctx)
|
||||
eg, ctx := errgroup.WithContext(ctx)
|
||||
for dep, config := range dependencies {
|
||||
if shouldWait, err := shouldWaitForDependency(dep, config, project); err != nil {
|
||||
return err
|
||||
@@ -743,7 +743,7 @@ func (s *composeService) createMobyContainer(ctx context.Context, project *types
|
||||
}
|
||||
// Starting API version 1.44, the ContainerCreate API call takes multiple networks
|
||||
// so we include all the configurations there and can skip the one-by-one calls here
|
||||
if versions.LessThan(apiVersion, "1.44") {
|
||||
if versions.LessThan(apiVersion, APIVersion144) {
|
||||
// the highest-priority network is the primary and is included in the ContainerCreate API
|
||||
// call via container.NetworkMode & network.NetworkingConfig
|
||||
// any remaining networks are connected one-by-one here after creation (but before start)
|
||||
@@ -774,11 +774,10 @@ func (s *composeService) getLinks(ctx context.Context, projectName string, servi
|
||||
}
|
||||
|
||||
for _, rawLink := range service.Links {
|
||||
linkSplit := strings.Split(rawLink, ":")
|
||||
linkServiceName := linkSplit[0]
|
||||
linkName := linkServiceName
|
||||
if len(linkSplit) == 2 {
|
||||
linkName = linkSplit[1] // linkName if informed like in: "serviceName:linkName"
|
||||
// linkName if informed like in: "serviceName[:linkName]"
|
||||
linkServiceName, linkName, ok := strings.Cut(rawLink, ":")
|
||||
if !ok {
|
||||
linkName = linkServiceName
|
||||
}
|
||||
cnts, err := getServiceContainers(linkServiceName)
|
||||
if err != nil {
|
||||
@@ -810,11 +809,9 @@ func (s *composeService) getLinks(ctx context.Context, projectName string, servi
|
||||
}
|
||||
|
||||
for _, rawExtLink := range service.ExternalLinks {
|
||||
extLinkSplit := strings.Split(rawExtLink, ":")
|
||||
externalLink := extLinkSplit[0]
|
||||
linkName := externalLink
|
||||
if len(extLinkSplit) == 2 {
|
||||
linkName = extLinkSplit[1]
|
||||
externalLink, linkName, ok := strings.Cut(rawExtLink, ":")
|
||||
if !ok {
|
||||
linkName = externalLink
|
||||
}
|
||||
links = append(links, format(externalLink, linkName))
|
||||
}
|
||||
@@ -833,7 +830,8 @@ func (s *composeService) isServiceHealthy(ctx context.Context, containers Contai
|
||||
return false, fmt.Errorf("container %s exited (%d)", name, ctr.State.ExitCode)
|
||||
}
|
||||
|
||||
if ctr.Config.Healthcheck == nil && fallbackRunning {
|
||||
noHealthcheck := ctr.Config.Healthcheck == nil || (len(ctr.Config.Healthcheck.Test) > 0 && ctr.Config.Healthcheck.Test[0] == "NONE")
|
||||
if noHealthcheck && fallbackRunning {
|
||||
// Container does not define a health check, but we can fall back to "running" state
|
||||
return ctr.State != nil && ctr.State.Status == container.StateRunning, nil
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -95,7 +94,7 @@ func TestServiceLinks(t *testing.T) {
|
||||
c := testContainer("db", dbContainerName, false)
|
||||
apiClient.EXPECT().ContainerList(gomock.Any(), containerListOptions).Return([]container.Summary{c}, nil)
|
||||
|
||||
links, err := tested.(*composeService).getLinks(context.Background(), testProject, s, 1)
|
||||
links, err := tested.(*composeService).getLinks(t.Context(), testProject, s, 1)
|
||||
assert.NilError(t, err)
|
||||
|
||||
assert.Equal(t, len(links), 3)
|
||||
@@ -118,7 +117,7 @@ func TestServiceLinks(t *testing.T) {
|
||||
c := testContainer("db", dbContainerName, false)
|
||||
|
||||
apiClient.EXPECT().ContainerList(gomock.Any(), containerListOptions).Return([]container.Summary{c}, nil)
|
||||
links, err := tested.(*composeService).getLinks(context.Background(), testProject, s, 1)
|
||||
links, err := tested.(*composeService).getLinks(t.Context(), testProject, s, 1)
|
||||
assert.NilError(t, err)
|
||||
|
||||
assert.Equal(t, len(links), 3)
|
||||
@@ -141,7 +140,7 @@ func TestServiceLinks(t *testing.T) {
|
||||
c := testContainer("db", dbContainerName, false)
|
||||
apiClient.EXPECT().ContainerList(gomock.Any(), containerListOptions).Return([]container.Summary{c}, nil)
|
||||
|
||||
links, err := tested.(*composeService).getLinks(context.Background(), testProject, s, 1)
|
||||
links, err := tested.(*composeService).getLinks(t.Context(), testProject, s, 1)
|
||||
assert.NilError(t, err)
|
||||
|
||||
assert.Equal(t, len(links), 3)
|
||||
@@ -165,7 +164,7 @@ func TestServiceLinks(t *testing.T) {
|
||||
c := testContainer("db", dbContainerName, false)
|
||||
apiClient.EXPECT().ContainerList(gomock.Any(), containerListOptions).Return([]container.Summary{c}, nil)
|
||||
|
||||
links, err := tested.(*composeService).getLinks(context.Background(), testProject, s, 1)
|
||||
links, err := tested.(*composeService).getLinks(t.Context(), testProject, s, 1)
|
||||
assert.NilError(t, err)
|
||||
|
||||
assert.Equal(t, len(links), 4)
|
||||
@@ -202,7 +201,7 @@ func TestServiceLinks(t *testing.T) {
|
||||
}
|
||||
apiClient.EXPECT().ContainerList(gomock.Any(), containerListOptionsOneOff).Return([]container.Summary{c}, nil)
|
||||
|
||||
links, err := tested.(*composeService).getLinks(context.Background(), testProject, s, 1)
|
||||
links, err := tested.(*composeService).getLinks(t.Context(), testProject, s, 1)
|
||||
assert.NilError(t, err)
|
||||
|
||||
assert.Equal(t, len(links), 3)
|
||||
@@ -233,7 +232,7 @@ func TestWaitDependencies(t *testing.T) {
|
||||
"db": {Condition: ServiceConditionRunningOrHealthy},
|
||||
"redis": {Condition: ServiceConditionRunningOrHealthy},
|
||||
}
|
||||
assert.NilError(t, tested.(*composeService).waitDependencies(context.Background(), &project, "", dependencies, nil, 0))
|
||||
assert.NilError(t, tested.(*composeService).waitDependencies(t.Context(), &project, "", dependencies, nil, 0))
|
||||
})
|
||||
t.Run("should skip dependencies with condition service_started", func(t *testing.T) {
|
||||
dbService := types.ServiceConfig{Name: "db", Scale: intPtr(1)}
|
||||
@@ -246,7 +245,149 @@ func TestWaitDependencies(t *testing.T) {
|
||||
"db": {Condition: types.ServiceConditionStarted, Required: true},
|
||||
"redis": {Condition: types.ServiceConditionStarted, Required: true},
|
||||
}
|
||||
assert.NilError(t, tested.(*composeService).waitDependencies(context.Background(), &project, "", dependencies, nil, 0))
|
||||
assert.NilError(t, tested.(*composeService).waitDependencies(t.Context(), &project, "", dependencies, nil, 0))
|
||||
})
|
||||
}
|
||||
|
||||
func TestIsServiceHealthy(t *testing.T) {
|
||||
mockCtrl := gomock.NewController(t)
|
||||
defer mockCtrl.Finish()
|
||||
|
||||
apiClient := mocks.NewMockAPIClient(mockCtrl)
|
||||
cli := mocks.NewMockCli(mockCtrl)
|
||||
tested, err := NewComposeService(cli)
|
||||
assert.NilError(t, err)
|
||||
cli.EXPECT().Client().Return(apiClient).AnyTimes()
|
||||
|
||||
ctx := t.Context()
|
||||
|
||||
t.Run("disabled healthcheck with fallback to running", func(t *testing.T) {
|
||||
containerID := "test-container-id"
|
||||
containers := Containers{
|
||||
{ID: containerID},
|
||||
}
|
||||
|
||||
// Container with disabled healthcheck (Test: ["NONE"])
|
||||
apiClient.EXPECT().ContainerInspect(ctx, containerID).Return(container.InspectResponse{
|
||||
ContainerJSONBase: &container.ContainerJSONBase{
|
||||
ID: containerID,
|
||||
Name: "test-container",
|
||||
State: &container.State{Status: "running"},
|
||||
},
|
||||
Config: &container.Config{
|
||||
Healthcheck: &container.HealthConfig{
|
||||
Test: []string{"NONE"},
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
isHealthy, err := tested.(*composeService).isServiceHealthy(ctx, containers, true)
|
||||
assert.NilError(t, err)
|
||||
assert.Equal(t, true, isHealthy, "Container with disabled healthcheck should be considered healthy when running with fallbackRunning=true")
|
||||
})
|
||||
|
||||
t.Run("disabled healthcheck without fallback", func(t *testing.T) {
|
||||
containerID := "test-container-id"
|
||||
containers := Containers{
|
||||
{ID: containerID},
|
||||
}
|
||||
|
||||
// Container with disabled healthcheck (Test: ["NONE"]) but fallbackRunning=false
|
||||
apiClient.EXPECT().ContainerInspect(ctx, containerID).Return(container.InspectResponse{
|
||||
ContainerJSONBase: &container.ContainerJSONBase{
|
||||
ID: containerID,
|
||||
Name: "test-container",
|
||||
State: &container.State{Status: "running"},
|
||||
},
|
||||
Config: &container.Config{
|
||||
Healthcheck: &container.HealthConfig{
|
||||
Test: []string{"NONE"},
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
_, err := tested.(*composeService).isServiceHealthy(ctx, containers, false)
|
||||
assert.ErrorContains(t, err, "has no healthcheck configured")
|
||||
})
|
||||
|
||||
t.Run("no healthcheck with fallback to running", func(t *testing.T) {
|
||||
containerID := "test-container-id"
|
||||
containers := Containers{
|
||||
{ID: containerID},
|
||||
}
|
||||
|
||||
// Container with no healthcheck at all
|
||||
apiClient.EXPECT().ContainerInspect(ctx, containerID).Return(container.InspectResponse{
|
||||
ContainerJSONBase: &container.ContainerJSONBase{
|
||||
ID: containerID,
|
||||
Name: "test-container",
|
||||
State: &container.State{Status: "running"},
|
||||
},
|
||||
Config: &container.Config{
|
||||
Healthcheck: nil,
|
||||
},
|
||||
}, nil)
|
||||
|
||||
isHealthy, err := tested.(*composeService).isServiceHealthy(ctx, containers, true)
|
||||
assert.NilError(t, err)
|
||||
assert.Equal(t, true, isHealthy, "Container with no healthcheck should be considered healthy when running with fallbackRunning=true")
|
||||
})
|
||||
|
||||
t.Run("exited container with disabled healthcheck", func(t *testing.T) {
|
||||
containerID := "test-container-id"
|
||||
containers := Containers{
|
||||
{ID: containerID},
|
||||
}
|
||||
|
||||
// Container with disabled healthcheck but exited
|
||||
apiClient.EXPECT().ContainerInspect(ctx, containerID).Return(container.InspectResponse{
|
||||
ContainerJSONBase: &container.ContainerJSONBase{
|
||||
ID: containerID,
|
||||
Name: "test-container",
|
||||
State: &container.State{
|
||||
Status: "exited",
|
||||
ExitCode: 1,
|
||||
},
|
||||
},
|
||||
Config: &container.Config{
|
||||
Healthcheck: &container.HealthConfig{
|
||||
Test: []string{"NONE"},
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
_, err := tested.(*composeService).isServiceHealthy(ctx, containers, true)
|
||||
assert.ErrorContains(t, err, "exited")
|
||||
})
|
||||
|
||||
t.Run("healthy container with healthcheck", func(t *testing.T) {
|
||||
containerID := "test-container-id"
|
||||
containers := Containers{
|
||||
{ID: containerID},
|
||||
}
|
||||
|
||||
// Container with actual healthcheck that is healthy
|
||||
apiClient.EXPECT().ContainerInspect(ctx, containerID).Return(container.InspectResponse{
|
||||
ContainerJSONBase: &container.ContainerJSONBase{
|
||||
ID: containerID,
|
||||
Name: "test-container",
|
||||
State: &container.State{
|
||||
Status: "running",
|
||||
Health: &container.Health{
|
||||
Status: container.Healthy,
|
||||
},
|
||||
},
|
||||
},
|
||||
Config: &container.Config{
|
||||
Healthcheck: &container.HealthConfig{
|
||||
Test: []string{"CMD", "curl", "-f", "http://localhost"},
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
isHealthy, err := tested.(*composeService).isServiceHealthy(ctx, containers, false)
|
||||
assert.NilError(t, err)
|
||||
assert.Equal(t, true, isHealthy, "Container with healthy status should be healthy")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -333,7 +474,7 @@ func TestCreateMobyContainer(t *testing.T) {
|
||||
Aliases: []string{"bork-test-0"},
|
||||
}))
|
||||
|
||||
_, err = tested.(*composeService).createMobyContainer(context.Background(), &project, service, "test", 0, nil, createOptions{
|
||||
_, err = tested.(*composeService).createMobyContainer(t.Context(), &project, service, "test", 0, nil, createOptions{
|
||||
Labels: make(types.Labels),
|
||||
})
|
||||
assert.NilError(t, err)
|
||||
@@ -353,7 +494,7 @@ func TestCreateMobyContainer(t *testing.T) {
|
||||
// force `RuntimeVersion` to fetch fresh version
|
||||
runtimeVersion = runtimeVersionCache{}
|
||||
apiClient.EXPECT().ServerVersion(gomock.Any()).Return(moby.Version{
|
||||
APIVersion: "1.44",
|
||||
APIVersion: APIVersion144,
|
||||
}, nil).AnyTimes()
|
||||
|
||||
service := types.ServiceConfig{
|
||||
@@ -419,7 +560,7 @@ func TestCreateMobyContainer(t *testing.T) {
|
||||
NetworkSettings: &container.NetworkSettings{},
|
||||
}, nil)
|
||||
|
||||
_, err = tested.(*composeService).createMobyContainer(context.Background(), &project, service, "test", 0, nil, createOptions{
|
||||
_, err = tested.(*composeService).createMobyContainer(t.Context(), &project, service, "test", 0, nil, createOptions{
|
||||
Labels: make(types.Labels),
|
||||
})
|
||||
assert.NilError(t, err)
|
||||
|
||||
@@ -73,8 +73,8 @@ func (s *composeService) ToMobyHealthCheck(ctx context.Context, check *compose.H
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if versions.LessThan(version, "1.44") {
|
||||
return nil, errors.New("can't set healthcheck.start_interval as feature require Docker Engine v25 or later")
|
||||
if versions.LessThan(version, APIVersion144) {
|
||||
return nil, fmt.Errorf("can't set healthcheck.start_interval as feature require Docker Engine %s or later", DockerEngineV25)
|
||||
} else {
|
||||
startInterval = time.Duration(*check.StartInterval)
|
||||
}
|
||||
|
||||
@@ -317,15 +317,15 @@ func splitCpArg(arg string) (ctr, path string) {
|
||||
return "", arg
|
||||
}
|
||||
|
||||
parts := strings.SplitN(arg, ":", 2)
|
||||
ctr, path, ok := strings.Cut(arg, ":")
|
||||
|
||||
if len(parts) == 1 || strings.HasPrefix(parts[0], ".") {
|
||||
if !ok || strings.HasPrefix(ctr, ".") {
|
||||
// Either there's no `:` in the arg
|
||||
// OR it's an explicit local relative path like `./file:name.txt`.
|
||||
return "", arg
|
||||
}
|
||||
|
||||
return parts[0], parts[1]
|
||||
return ctr, path
|
||||
}
|
||||
|
||||
func resolveLocalPath(localPath string) (absPath string, err error) {
|
||||
|
||||
@@ -241,11 +241,8 @@ func (s *composeService) getCreateConfigs(ctx context.Context,
|
||||
} // VOLUMES/MOUNTS/FILESYSTEMS
|
||||
tmpfs := map[string]string{}
|
||||
for _, t := range service.Tmpfs {
|
||||
if arr := strings.SplitN(t, ":", 2); len(arr) > 1 {
|
||||
tmpfs[arr[0]] = arr[1]
|
||||
} else {
|
||||
tmpfs[arr[0]] = ""
|
||||
}
|
||||
k, v, _ := strings.Cut(t, ":")
|
||||
tmpfs[k] = v
|
||||
}
|
||||
binds, mounts, err := s.buildContainerVolumes(ctx, *p, service, inherit)
|
||||
if err != nil {
|
||||
@@ -360,7 +357,7 @@ func (s *composeService) prepareContainerMACAddress(ctx context.Context, service
|
||||
if macAddress != "" && mainNw != nil && mainNw.MacAddress != "" && mainNw.MacAddress != macAddress {
|
||||
return "", fmt.Errorf("the service-level mac_address should have the same value as network %s", nwName)
|
||||
}
|
||||
if versions.GreaterThanOrEqualTo(version, "1.44") {
|
||||
if versions.GreaterThanOrEqualTo(version, APIVersion144) {
|
||||
if mainNw != nil && mainNw.MacAddress == "" {
|
||||
mainNw.MacAddress = macAddress
|
||||
}
|
||||
@@ -374,7 +371,7 @@ func (s *composeService) prepareContainerMACAddress(ctx context.Context, service
|
||||
}
|
||||
|
||||
if len(withMacAddress) > 1 {
|
||||
return "", fmt.Errorf("a MAC address is specified for multiple networks (%s), but this feature requires Docker Engine v25 or later", strings.Join(withMacAddress, ", "))
|
||||
return "", fmt.Errorf("a MAC address is specified for multiple networks (%s), but this feature requires Docker Engine %s or later", strings.Join(withMacAddress, ", "), DockerEngineV25)
|
||||
}
|
||||
|
||||
if mainNw != nil && mainNw.MacAddress != "" {
|
||||
@@ -527,7 +524,7 @@ func defaultNetworkSettings(project *types.Project,
|
||||
// so we can pass all the extra networks we want the container to be connected to
|
||||
// in the network configuration instead of connecting the container to each extra
|
||||
// network individually after creation.
|
||||
if versions.GreaterThanOrEqualTo(version, "1.44") {
|
||||
if versions.GreaterThanOrEqualTo(version, APIVersion144) {
|
||||
if len(service.Networks) > 1 {
|
||||
serviceNetworks := service.NetworksByPriority()
|
||||
for _, networkKey := range serviceNetworks[1:] {
|
||||
@@ -541,10 +538,10 @@ func defaultNetworkSettings(project *types.Project,
|
||||
}
|
||||
}
|
||||
|
||||
if versions.LessThan(version, "1.49") {
|
||||
if versions.LessThan(version, APIVersion149) {
|
||||
for _, config := range service.Networks {
|
||||
if config != nil && config.InterfaceName != "" {
|
||||
return "", nil, fmt.Errorf("interface_name requires Docker Engine v28.1 or later")
|
||||
return "", nil, fmt.Errorf("interface_name requires Docker Engine %s or later", DockerEngineV28_1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -563,13 +560,13 @@ func defaultNetworkSettings(project *types.Project,
|
||||
func getRestartPolicy(service types.ServiceConfig) container.RestartPolicy {
|
||||
var restart container.RestartPolicy
|
||||
if service.Restart != "" {
|
||||
split := strings.Split(service.Restart, ":")
|
||||
name, num, ok := strings.Cut(service.Restart, ":")
|
||||
var attempts int
|
||||
if len(split) > 1 {
|
||||
attempts, _ = strconv.Atoi(split[1])
|
||||
if ok {
|
||||
attempts, _ = strconv.Atoi(num)
|
||||
}
|
||||
restart = container.RestartPolicy{
|
||||
Name: mapRestartPolicyCondition(split[0]),
|
||||
Name: mapRestartPolicyCondition(name),
|
||||
MaximumRetryCount: attempts,
|
||||
}
|
||||
}
|
||||
@@ -861,8 +858,8 @@ func (s *composeService) buildContainerVolumes(
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if versions.LessThan(version, "1.48") {
|
||||
return nil, nil, fmt.Errorf("volume with type=image require Docker Engine v28 or later")
|
||||
if versions.LessThan(version, APIVersion148) {
|
||||
return nil, nil, fmt.Errorf("volume with type=image require Docker Engine %s or later", DockerEngineV28)
|
||||
}
|
||||
}
|
||||
mounts = append(mounts, m)
|
||||
@@ -1524,7 +1521,7 @@ func (s *composeService) resolveExternalNetwork(ctx context.Context, n *types.Ne
|
||||
case 1:
|
||||
return networks[0].ID, nil
|
||||
case 0:
|
||||
enabled, err := s.isSWarmEnabled(ctx)
|
||||
enabled, err := s.isSwarmEnabled(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@@ -164,7 +163,7 @@ func TestBuildContainerMountOptions(t *testing.T) {
|
||||
}
|
||||
mock.EXPECT().ImageInspect(gomock.Any(), "myProject-myService").AnyTimes().Return(image.InspectResponse{}, nil)
|
||||
|
||||
mounts, err := s.buildContainerMountOptions(context.TODO(), project, project.Services["myService"], inherit)
|
||||
mounts, err := s.buildContainerMountOptions(t.Context(), project, project.Services["myService"], inherit)
|
||||
sort.Slice(mounts, func(i, j int) bool {
|
||||
return mounts[i].Target < mounts[j].Target
|
||||
})
|
||||
@@ -176,7 +175,7 @@ func TestBuildContainerMountOptions(t *testing.T) {
|
||||
assert.Equal(t, mounts[2].VolumeOptions.Subpath, "etc")
|
||||
assert.Equal(t, mounts[3].Target, "\\\\.\\pipe\\docker_engine")
|
||||
|
||||
mounts, err = s.buildContainerMountOptions(context.TODO(), project, project.Services["myService"], inherit)
|
||||
mounts, err = s.buildContainerMountOptions(t.Context(), project, project.Services["myService"], inherit)
|
||||
sort.Slice(mounts, func(i, j int) bool {
|
||||
return mounts[i].Target < mounts[j].Target
|
||||
})
|
||||
@@ -435,7 +434,7 @@ volumes:
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
p, err := composeloader.LoadWithContext(context.TODO(), composetypes.ConfigDetails{
|
||||
p, err := composeloader.LoadWithContext(t.Context(), composetypes.ConfigDetails{
|
||||
ConfigFiles: []composetypes.ConfigFile{
|
||||
{
|
||||
Filename: "test",
|
||||
@@ -448,7 +447,7 @@ volumes:
|
||||
})
|
||||
assert.NilError(t, err)
|
||||
s := &composeService{}
|
||||
binds, mounts, err := s.buildContainerVolumes(context.TODO(), *p, p.Services["test"], nil)
|
||||
binds, mounts, err := s.buildContainerVolumes(t.Context(), *p, p.Services["test"], nil)
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, tt.binds, binds)
|
||||
assert.DeepEqual(t, tt.mounts, mounts)
|
||||
|
||||
@@ -71,9 +71,6 @@ func TestTraversalWithMultipleParents(t *testing.T) {
|
||||
project.Services[name] = svc
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
|
||||
svc := make(chan string, 10)
|
||||
seen := make(map[string]int)
|
||||
done := make(chan struct{})
|
||||
@@ -84,7 +81,7 @@ func TestTraversalWithMultipleParents(t *testing.T) {
|
||||
done <- struct{}{}
|
||||
}()
|
||||
|
||||
err := InDependencyOrder(ctx, &project, func(ctx context.Context, service string) error {
|
||||
err := InDependencyOrder(t.Context(), &project, func(ctx context.Context, service string) error {
|
||||
svc <- service
|
||||
return nil
|
||||
})
|
||||
@@ -99,11 +96,8 @@ func TestTraversalWithMultipleParents(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestInDependencyUpCommandOrder(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
|
||||
var order []string
|
||||
err := InDependencyOrder(ctx, createTestProject(), func(ctx context.Context, service string) error {
|
||||
err := InDependencyOrder(t.Context(), createTestProject(), func(ctx context.Context, service string) error {
|
||||
order = append(order, service)
|
||||
return nil
|
||||
})
|
||||
@@ -112,11 +106,8 @@ func TestInDependencyUpCommandOrder(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestInDependencyReverseDownCommandOrder(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
|
||||
var order []string
|
||||
err := InReverseDependencyOrder(ctx, createTestProject(), func(ctx context.Context, service string) error {
|
||||
err := InReverseDependencyOrder(t.Context(), createTestProject(), func(ctx context.Context, service string) error {
|
||||
order = append(order, service)
|
||||
return nil
|
||||
})
|
||||
@@ -429,7 +420,7 @@ func TestWith_RootNodesAndUp(t *testing.T) {
|
||||
return nil
|
||||
})
|
||||
WithRootNodesAndDown(tt.nodes)(gt)
|
||||
err := gt.visit(context.TODO(), graph)
|
||||
err := gt.visit(t.Context(), graph)
|
||||
assert.NilError(t, err)
|
||||
sort.Strings(visited)
|
||||
assert.DeepEqual(t, tt.want, visited)
|
||||
|
||||
@@ -119,7 +119,7 @@ func (s *composeService) down(ctx context.Context, projectName string, options a
|
||||
logrus.Warnf("Warning: No resource found to remove for project %q.", projectName)
|
||||
}
|
||||
|
||||
eg, _ := errgroup.WithContext(ctx)
|
||||
eg, ctx := errgroup.WithContext(ctx)
|
||||
for _, op := range ops {
|
||||
eg.Go(op)
|
||||
}
|
||||
@@ -335,7 +335,7 @@ func (s *composeService) stopContainers(ctx context.Context, serv *types.Service
|
||||
}
|
||||
|
||||
func (s *composeService) removeContainers(ctx context.Context, containers []containerType.Summary, service *types.ServiceConfig, timeout *time.Duration, volumes bool) error {
|
||||
eg, _ := errgroup.WithContext(ctx)
|
||||
eg, ctx := errgroup.WithContext(ctx)
|
||||
for _, ctr := range containers {
|
||||
eg.Go(func() error {
|
||||
return s.stopAndRemoveContainer(ctx, ctr, service, timeout, volumes)
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -90,7 +89,7 @@ func TestDown(t *testing.T) {
|
||||
api.EXPECT().NetworkRemove(gomock.Any(), "abc123").Return(nil)
|
||||
api.EXPECT().NetworkRemove(gomock.Any(), "def456").Return(nil)
|
||||
|
||||
err = tested.Down(context.Background(), strings.ToLower(testProject), compose.DownOptions{})
|
||||
err = tested.Down(t.Context(), strings.ToLower(testProject), compose.DownOptions{})
|
||||
assert.NilError(t, err)
|
||||
}
|
||||
|
||||
@@ -139,7 +138,7 @@ func TestDownWithGivenServices(t *testing.T) {
|
||||
api.EXPECT().NetworkInspect(gomock.Any(), "abc123", gomock.Any()).Return(network.Inspect{ID: "abc123"}, nil)
|
||||
api.EXPECT().NetworkRemove(gomock.Any(), "abc123").Return(nil)
|
||||
|
||||
err = tested.Down(context.Background(), strings.ToLower(testProject), compose.DownOptions{
|
||||
err = tested.Down(t.Context(), strings.ToLower(testProject), compose.DownOptions{
|
||||
Services: []string{"service1", "not-running-service"},
|
||||
})
|
||||
assert.NilError(t, err)
|
||||
@@ -175,7 +174,7 @@ func TestDownWithSpecifiedServiceButTheServicesAreNotRunning(t *testing.T) {
|
||||
{ID: "def456", Name: "myProject_default", Labels: map[string]string{compose.NetworkLabel: "default"}},
|
||||
}, nil)
|
||||
|
||||
err = tested.Down(context.Background(), strings.ToLower(testProject), compose.DownOptions{
|
||||
err = tested.Down(t.Context(), strings.ToLower(testProject), compose.DownOptions{
|
||||
Services: []string{"not-running-service1", "not-running-service2"},
|
||||
})
|
||||
assert.NilError(t, err)
|
||||
@@ -227,7 +226,7 @@ func TestDownRemoveOrphans(t *testing.T) {
|
||||
api.EXPECT().NetworkInspect(gomock.Any(), "abc123", gomock.Any()).Return(network.Inspect{ID: "abc123"}, nil)
|
||||
api.EXPECT().NetworkRemove(gomock.Any(), "abc123").Return(nil)
|
||||
|
||||
err = tested.Down(context.Background(), strings.ToLower(testProject), compose.DownOptions{RemoveOrphans: true})
|
||||
err = tested.Down(t.Context(), strings.ToLower(testProject), compose.DownOptions{RemoveOrphans: true})
|
||||
assert.NilError(t, err)
|
||||
}
|
||||
|
||||
@@ -259,7 +258,7 @@ func TestDownRemoveVolumes(t *testing.T) {
|
||||
|
||||
api.EXPECT().VolumeRemove(gomock.Any(), "myProject_volume", true).Return(nil)
|
||||
|
||||
err = tested.Down(context.Background(), strings.ToLower(testProject), compose.DownOptions{Volumes: true})
|
||||
err = tested.Down(t.Context(), strings.ToLower(testProject), compose.DownOptions{Volumes: true})
|
||||
assert.NilError(t, err)
|
||||
}
|
||||
|
||||
@@ -346,7 +345,7 @@ func TestDownRemoveImages(t *testing.T) {
|
||||
|
||||
t.Log("-> docker compose down --rmi=local")
|
||||
opts.Images = "local"
|
||||
err = tested.Down(context.Background(), strings.ToLower(testProject), opts)
|
||||
err = tested.Down(t.Context(), strings.ToLower(testProject), opts)
|
||||
assert.NilError(t, err)
|
||||
|
||||
otherImagesToBeRemoved := []string{
|
||||
@@ -361,7 +360,7 @@ func TestDownRemoveImages(t *testing.T) {
|
||||
|
||||
t.Log("-> docker compose down --rmi=all")
|
||||
opts.Images = "all"
|
||||
err = tested.Down(context.Background(), strings.ToLower(testProject), opts)
|
||||
err = tested.Down(t.Context(), strings.ToLower(testProject), opts)
|
||||
assert.NilError(t, err)
|
||||
}
|
||||
|
||||
@@ -406,7 +405,7 @@ func TestDownRemoveImages_NoLabel(t *testing.T) {
|
||||
|
||||
api.EXPECT().ImageRemove(gomock.Any(), "testproject-service1:latest", image.RemoveOptions{}).Return(nil, nil)
|
||||
|
||||
err = tested.Down(context.Background(), strings.ToLower(testProject), compose.DownOptions{Images: "local"})
|
||||
err = tested.Down(t.Context(), strings.ToLower(testProject), compose.DownOptions{Images: "local"})
|
||||
assert.NilError(t, err)
|
||||
}
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ func (s *composeService) Images(ctx context.Context, projectName string, options
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
withPlatform := versions.GreaterThanOrEqualTo(version, "1.49")
|
||||
withPlatform := versions.GreaterThanOrEqualTo(version, APIVersion149)
|
||||
|
||||
summary := map[string]api.ImageSummary{}
|
||||
var mux sync.Mutex
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -40,7 +39,6 @@ func TestImages(t *testing.T) {
|
||||
tested, err := NewComposeService(cli)
|
||||
assert.NilError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
args := filters.NewArgs(projectFilter(strings.ToLower(testProject)))
|
||||
listOpts := container.ListOptions{All: true, Filters: args}
|
||||
api.EXPECT().ServerVersion(gomock.Any()).Return(types.Version{APIVersion: "1.96"}, nil).AnyTimes()
|
||||
@@ -56,9 +54,9 @@ func TestImages(t *testing.T) {
|
||||
c2 := containerDetail("service1", "456", "running", "bar:2")
|
||||
c2.Ports = []container.Port{{PublicPort: 80, PrivatePort: 90, IP: "localhost"}}
|
||||
c3 := containerDetail("service2", "789", "exited", "foo:1")
|
||||
api.EXPECT().ContainerList(ctx, listOpts).Return([]container.Summary{c1, c2, c3}, nil)
|
||||
api.EXPECT().ContainerList(t.Context(), listOpts).Return([]container.Summary{c1, c2, c3}, nil)
|
||||
|
||||
images, err := tested.Images(ctx, strings.ToLower(testProject), compose.ImagesOptions{})
|
||||
images, err := tested.Images(t.Context(), strings.ToLower(testProject), compose.ImagesOptions{})
|
||||
|
||||
expected := map[string]compose.ImageSummary{
|
||||
"123": {
|
||||
|
||||
@@ -45,8 +45,7 @@ func TestKillAll(t *testing.T) {
|
||||
|
||||
name := strings.ToLower(testProject)
|
||||
|
||||
ctx := context.Background()
|
||||
api.EXPECT().ContainerList(ctx, container.ListOptions{
|
||||
api.EXPECT().ContainerList(t.Context(), container.ListOptions{
|
||||
Filters: filters.NewArgs(projectFilter(name), hasConfigHashLabel()),
|
||||
}).Return(
|
||||
[]container.Summary{testContainer("service1", "123", false), testContainer("service1", "456", false), testContainer("service2", "789", false)}, nil)
|
||||
@@ -64,7 +63,7 @@ func TestKillAll(t *testing.T) {
|
||||
api.EXPECT().ContainerKill(anyCancellableContext(), "456", "").Return(nil)
|
||||
api.EXPECT().ContainerKill(anyCancellableContext(), "789", "").Return(nil)
|
||||
|
||||
err = tested.Kill(ctx, name, compose.KillOptions{})
|
||||
err = tested.Kill(t.Context(), name, compose.KillOptions{})
|
||||
assert.NilError(t, err)
|
||||
}
|
||||
|
||||
@@ -82,8 +81,7 @@ func TestKillSignal(t *testing.T) {
|
||||
Filters: filters.NewArgs(projectFilter(name), serviceFilter(serviceName), hasConfigHashLabel()),
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
api.EXPECT().ContainerList(ctx, listOptions).Return([]container.Summary{testContainer(serviceName, "123", false)}, nil)
|
||||
api.EXPECT().ContainerList(t.Context(), listOptions).Return([]container.Summary{testContainer(serviceName, "123", false)}, nil)
|
||||
api.EXPECT().VolumeList(
|
||||
gomock.Any(),
|
||||
volume.ListOptions{
|
||||
@@ -96,7 +94,7 @@ func TestKillSignal(t *testing.T) {
|
||||
}, nil)
|
||||
api.EXPECT().ContainerKill(anyCancellableContext(), "123", "SIGTERM").Return(nil)
|
||||
|
||||
err = tested.Kill(ctx, name, compose.KillOptions{Services: []string{serviceName}, Signal: "SIGTERM"})
|
||||
err = tested.Kill(t.Context(), name, compose.KillOptions{Services: []string{serviceName}, Signal: "SIGTERM"})
|
||||
assert.NilError(t, err)
|
||||
}
|
||||
|
||||
@@ -129,6 +127,7 @@ func containerLabels(service string, oneOff bool) map[string]string {
|
||||
}
|
||||
|
||||
func anyCancellableContext() gomock.Matcher {
|
||||
//nolint:forbidigo // This creates a context type for gomock matching, not for actual test usage
|
||||
ctxWithCancel, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
return gomock.AssignableToTypeOf(ctxWithCancel)
|
||||
|
||||
@@ -28,6 +28,7 @@ import (
|
||||
|
||||
"github.com/docker/compose/v5/pkg/api"
|
||||
"github.com/docker/compose/v5/pkg/remote"
|
||||
"github.com/docker/compose/v5/pkg/utils"
|
||||
)
|
||||
|
||||
// LoadProject implements api.Compose.LoadProject
|
||||
@@ -48,7 +49,7 @@ func (s *composeService) LoadProject(ctx context.Context, options api.ProjectLoa
|
||||
}
|
||||
}
|
||||
|
||||
if options.Compatibility {
|
||||
if options.Compatibility || utils.StringToBool(projectOptions.Environment[api.ComposeCompatibility]) {
|
||||
api.Separator = "_"
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -48,13 +47,11 @@ services:
|
||||
err := os.WriteFile(composeFile, []byte(composeContent), 0o644)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create compose service
|
||||
service, err := NewComposeService(nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Load the project
|
||||
ctx := context.Background()
|
||||
project, err := service.LoadProject(ctx, api.ProjectLoadOptions{
|
||||
project, err := service.LoadProject(t.Context(), api.ProjectLoadOptions{
|
||||
ConfigPaths: []string{composeFile},
|
||||
})
|
||||
|
||||
@@ -87,19 +84,14 @@ services:
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set environment variable
|
||||
require.NoError(t, os.Setenv("TEST_VAR", "resolved_value"))
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, os.Unsetenv("TEST_VAR"))
|
||||
})
|
||||
t.Setenv("TEST_VAR", "resolved_value")
|
||||
|
||||
service, err := NewComposeService(nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Test with environment resolution (default)
|
||||
t.Run("WithResolution", func(t *testing.T) {
|
||||
project, err := service.LoadProject(ctx, api.ProjectLoadOptions{
|
||||
project, err := service.LoadProject(t.Context(), api.ProjectLoadOptions{
|
||||
ConfigPaths: []string{composeFile},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
@@ -114,7 +106,7 @@ services:
|
||||
|
||||
// Test without environment resolution
|
||||
t.Run("WithoutResolution", func(t *testing.T) {
|
||||
project, err := service.LoadProject(ctx, api.ProjectLoadOptions{
|
||||
project, err := service.LoadProject(t.Context(), api.ProjectLoadOptions{
|
||||
ConfigPaths: []string{composeFile},
|
||||
ProjectOptionsFns: []cli.ProjectOptionsFn{cli.WithoutEnvironmentResolution},
|
||||
})
|
||||
@@ -145,10 +137,8 @@ services:
|
||||
service, err := NewComposeService(nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Load only specific services
|
||||
project, err := service.LoadProject(ctx, api.ProjectLoadOptions{
|
||||
project, err := service.LoadProject(t.Context(), api.ProjectLoadOptions{
|
||||
ConfigPaths: []string{composeFile},
|
||||
Services: []string{"web", "db"},
|
||||
})
|
||||
@@ -177,11 +167,9 @@ services:
|
||||
service, err := NewComposeService(nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Without debug profile
|
||||
t.Run("WithoutProfile", func(t *testing.T) {
|
||||
project, err := service.LoadProject(ctx, api.ProjectLoadOptions{
|
||||
project, err := service.LoadProject(t.Context(), api.ProjectLoadOptions{
|
||||
ConfigPaths: []string{composeFile},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
@@ -191,7 +179,7 @@ services:
|
||||
|
||||
// With debug profile
|
||||
t.Run("WithProfile", func(t *testing.T) {
|
||||
project, err := service.LoadProject(ctx, api.ProjectLoadOptions{
|
||||
project, err := service.LoadProject(t.Context(), api.ProjectLoadOptions{
|
||||
ConfigPaths: []string{composeFile},
|
||||
Profiles: []string{"debug"},
|
||||
})
|
||||
@@ -216,15 +204,13 @@ services:
|
||||
service, err := NewComposeService(nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Track events received
|
||||
var events []string
|
||||
listener := func(event string, metadata map[string]any) {
|
||||
events = append(events, event)
|
||||
}
|
||||
|
||||
project, err := service.LoadProject(ctx, api.ProjectLoadOptions{
|
||||
project, err := service.LoadProject(t.Context(), api.ProjectLoadOptions{
|
||||
ConfigPaths: []string{composeFile},
|
||||
LoadListeners: []api.LoadListener{listener},
|
||||
})
|
||||
@@ -251,11 +237,9 @@ services:
|
||||
service, err := NewComposeService(nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Without explicit project name
|
||||
t.Run("InferredName", func(t *testing.T) {
|
||||
project, err := service.LoadProject(ctx, api.ProjectLoadOptions{
|
||||
project, err := service.LoadProject(t.Context(), api.ProjectLoadOptions{
|
||||
ConfigPaths: []string{composeFile},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
@@ -265,7 +249,7 @@ services:
|
||||
|
||||
// With explicit project name
|
||||
t.Run("ExplicitName", func(t *testing.T) {
|
||||
project, err := service.LoadProject(ctx, api.ProjectLoadOptions{
|
||||
project, err := service.LoadProject(t.Context(), api.ProjectLoadOptions{
|
||||
ConfigPaths: []string{composeFile},
|
||||
ProjectName: "my-custom-project",
|
||||
})
|
||||
@@ -288,10 +272,8 @@ services:
|
||||
service, err := NewComposeService(nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// With compatibility mode
|
||||
project, err := service.LoadProject(ctx, api.ProjectLoadOptions{
|
||||
project, err := service.LoadProject(t.Context(), api.ProjectLoadOptions{
|
||||
ConfigPaths: []string{composeFile},
|
||||
Compatibility: true,
|
||||
})
|
||||
@@ -317,10 +299,8 @@ this is not valid yaml: [[[
|
||||
service, err := NewComposeService(nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Should return an error for invalid YAML
|
||||
project, err := service.LoadProject(ctx, api.ProjectLoadOptions{
|
||||
project, err := service.LoadProject(t.Context(), api.ProjectLoadOptions{
|
||||
ConfigPaths: []string{composeFile},
|
||||
})
|
||||
|
||||
@@ -332,10 +312,8 @@ func TestLoadProject_MissingComposeFile(t *testing.T) {
|
||||
service, err := NewComposeService(nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Should return an error for missing file
|
||||
project, err := service.LoadProject(ctx, api.ProjectLoadOptions{
|
||||
project, err := service.LoadProject(t.Context(), api.ProjectLoadOptions{
|
||||
ConfigPaths: []string{"/nonexistent/compose.yaml"},
|
||||
})
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -44,8 +43,7 @@ func TestComposeService_Logs_Demux(t *testing.T) {
|
||||
|
||||
name := strings.ToLower(testProject)
|
||||
|
||||
ctx := context.Background()
|
||||
api.EXPECT().ContainerList(ctx, containerType.ListOptions{
|
||||
api.EXPECT().ContainerList(t.Context(), containerType.ListOptions{
|
||||
All: true,
|
||||
Filters: filters.NewArgs(oneOffFilter(false), projectFilter(name), hasConfigHashLabel()),
|
||||
}).Return(
|
||||
@@ -87,7 +85,7 @@ func TestComposeService_Logs_Demux(t *testing.T) {
|
||||
}
|
||||
|
||||
consumer := &testLogConsumer{}
|
||||
err = tested.Logs(ctx, name, consumer, opts)
|
||||
err = tested.Logs(t.Context(), name, consumer, opts)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(
|
||||
@@ -114,8 +112,7 @@ func TestComposeService_Logs_ServiceFiltering(t *testing.T) {
|
||||
|
||||
name := strings.ToLower(testProject)
|
||||
|
||||
ctx := context.Background()
|
||||
api.EXPECT().ContainerList(ctx, containerType.ListOptions{
|
||||
api.EXPECT().ContainerList(t.Context(), containerType.ListOptions{
|
||||
All: true,
|
||||
Filters: filters.NewArgs(oneOffFilter(false), projectFilter(name), hasConfigHashLabel()),
|
||||
}).Return(
|
||||
@@ -157,7 +154,7 @@ func TestComposeService_Logs_ServiceFiltering(t *testing.T) {
|
||||
opts := compose.LogOptions{
|
||||
Project: proj,
|
||||
}
|
||||
err = tested.Logs(ctx, name, consumer, opts)
|
||||
err = tested.Logs(t.Context(), name, consumer, opts)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, []string{"hello c1"}, consumer.LogsForContainer("c1"))
|
||||
|
||||
@@ -29,7 +29,7 @@ import (
|
||||
"github.com/compose-spec/compose-go/v2/types"
|
||||
"github.com/containerd/errdefs"
|
||||
"github.com/docker/cli/cli-plugins/manager"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/docker/docker/api/types/versions"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
@@ -75,6 +75,7 @@ type modelAPI struct {
|
||||
env []string
|
||||
prepare func(ctx context.Context, cmd *exec.Cmd) error
|
||||
cleanup func()
|
||||
version string
|
||||
}
|
||||
|
||||
func (s *composeService) newModelAPI(project *types.Project) (*modelAPI, error) {
|
||||
@@ -85,12 +86,16 @@ func (s *composeService) newModelAPI(project *types.Project) (*modelAPI, error)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if dockerModel.Err != nil {
|
||||
return nil, fmt.Errorf("failed to load Docker Model plugin: %w", dockerModel.Err)
|
||||
}
|
||||
endpoint, cleanup, err := s.propagateDockerEndpoint()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &modelAPI{
|
||||
path: dockerModel.Path,
|
||||
path: dockerModel.Path,
|
||||
version: dockerModel.Version,
|
||||
prepare: func(ctx context.Context, cmd *exec.Cmd) error {
|
||||
return s.prepareShellOut(ctx, project.Environment, cmd)
|
||||
},
|
||||
@@ -107,7 +112,7 @@ func (m *modelAPI) PullModel(ctx context.Context, model types.ModelConfig, quiet
|
||||
events.On(api.Resource{
|
||||
ID: model.Name,
|
||||
Status: api.Working,
|
||||
Text: "Pulling",
|
||||
Text: api.StatusPulling,
|
||||
})
|
||||
|
||||
cmd := exec.CommandContext(ctx, m.path, "pull", model.Model)
|
||||
@@ -154,27 +159,38 @@ func (m *modelAPI) PullModel(ctx context.Context, model types.ModelConfig, quiet
|
||||
}
|
||||
|
||||
func (m *modelAPI) ConfigureModel(ctx context.Context, config types.ModelConfig, events api.EventProcessor) error {
|
||||
if len(config.RuntimeFlags) != 0 {
|
||||
logrus.Warnf("Runtime flags are not supported and will be ignored for model %s", config.Model)
|
||||
config.RuntimeFlags = nil
|
||||
}
|
||||
events.On(api.Resource{
|
||||
ID: config.Name,
|
||||
Status: api.Working,
|
||||
Text: "Configuring",
|
||||
Text: api.StatusConfiguring,
|
||||
})
|
||||
// configure [--context-size=<n>] MODEL
|
||||
// configure [--context-size=<n>] MODEL [-- <runtime-flags...>]
|
||||
args := []string{"configure"}
|
||||
if config.ContextSize > 0 {
|
||||
args = append(args, "--context-size", strconv.Itoa(config.ContextSize))
|
||||
}
|
||||
args = append(args, config.Model)
|
||||
// Only append RuntimeFlags if docker model CLI version is >= v1.0.6
|
||||
if len(config.RuntimeFlags) != 0 && m.supportsRuntimeFlags() {
|
||||
args = append(args, "--")
|
||||
args = append(args, config.RuntimeFlags...)
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, m.path, args...)
|
||||
err := m.prepare(ctx, cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return cmd.Run()
|
||||
err = cmd.Run()
|
||||
if err != nil {
|
||||
events.On(errorEvent(config.Name, err.Error()))
|
||||
return err
|
||||
}
|
||||
events.On(api.Resource{
|
||||
ID: config.Name,
|
||||
Status: api.Done,
|
||||
Text: api.StatusConfigured,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *modelAPI) SetModelVariables(ctx context.Context, project *types.Project) error {
|
||||
@@ -263,3 +279,16 @@ func (m *modelAPI) ListModels(ctx context.Context) ([]string, error) {
|
||||
}
|
||||
return availableModels, nil
|
||||
}
|
||||
|
||||
// supportsRuntimeFlags checks if the docker model version supports runtime flags
|
||||
// Runtime flags are supported in version >= v1.0.6
|
||||
func (m *modelAPI) supportsRuntimeFlags() bool {
|
||||
// If version is not cached, don't append runtime flags to be safe
|
||||
if m.version == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Strip 'v' prefix if present (e.g., "v1.0.6" -> "1.0.6")
|
||||
versionStr := strings.TrimPrefix(m.version, "v")
|
||||
return !versions.LessThan(versionStr, "1.0.6")
|
||||
}
|
||||
|
||||
@@ -147,7 +147,7 @@ func (c *monitor) Start(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if inspect.State != nil && inspect.State.Restarting || inspect.State.Running {
|
||||
if inspect.State != nil && (inspect.State.Restarting || inspect.State.Running) {
|
||||
// State.Restarting is set by engine when container is configured to restart on exit
|
||||
// on ContainerRestart it doesn't (see https://github.com/moby/moby/issues/45538)
|
||||
// container state still is reported as "running"
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -37,7 +36,6 @@ func TestPs(t *testing.T) {
|
||||
tested, err := NewComposeService(cli)
|
||||
assert.NilError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
args := filters.NewArgs(projectFilter(strings.ToLower(testProject)), hasConfigHashLabel())
|
||||
args.Add("label", "com.docker.compose.oneoff=False")
|
||||
listOpts := containerType.ListOptions{Filters: args, All: false}
|
||||
@@ -45,12 +43,12 @@ func TestPs(t *testing.T) {
|
||||
c2, inspect2 := containerDetails("service1", "456", containerType.StateRunning, "", 0)
|
||||
c2.Ports = []containerType.Port{{PublicPort: 80, PrivatePort: 90, IP: "localhost"}}
|
||||
c3, inspect3 := containerDetails("service2", "789", containerType.StateExited, "", 130)
|
||||
api.EXPECT().ContainerList(ctx, listOpts).Return([]containerType.Summary{c1, c2, c3}, nil)
|
||||
api.EXPECT().ContainerList(t.Context(), listOpts).Return([]containerType.Summary{c1, c2, c3}, nil)
|
||||
api.EXPECT().ContainerInspect(anyCancellableContext(), "123").Return(inspect1, nil)
|
||||
api.EXPECT().ContainerInspect(anyCancellableContext(), "456").Return(inspect2, nil)
|
||||
api.EXPECT().ContainerInspect(anyCancellableContext(), "789").Return(inspect3, nil)
|
||||
|
||||
containers, err := tested.Ps(ctx, strings.ToLower(testProject), compose.PsOptions{})
|
||||
containers, err := tested.Ps(t.Context(), strings.ToLower(testProject), compose.PsOptions{})
|
||||
|
||||
expected := []compose.ContainerSummary{
|
||||
{
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
@@ -32,7 +31,7 @@ import (
|
||||
)
|
||||
|
||||
func Test_createLayers(t *testing.T) {
|
||||
project, err := loader.LoadWithContext(context.TODO(), types.ConfigDetails{
|
||||
project, err := loader.LoadWithContext(t.Context(), types.ConfigDetails{
|
||||
WorkingDir: "testdata/publish/",
|
||||
Environment: types.Mapping{},
|
||||
ConfigFiles: []types.ConfigFile{
|
||||
@@ -45,7 +44,7 @@ func Test_createLayers(t *testing.T) {
|
||||
project.ComposeFiles = []string{"testdata/publish/compose.yaml"}
|
||||
|
||||
service := &composeService{}
|
||||
layers, err := service.createLayers(context.TODO(), project, api.PublishOptions{
|
||||
layers, err := service.createLayers(t.Context(), project, api.PublishOptions{
|
||||
WithEnvironment: true,
|
||||
})
|
||||
assert.NilError(t, err)
|
||||
|
||||
@@ -29,8 +29,8 @@ import (
|
||||
|
||||
"github.com/compose-spec/compose-go/v2/types"
|
||||
"github.com/distribution/reference"
|
||||
"github.com/docker/buildx/driver"
|
||||
"github.com/docker/cli/cli/config/configfile"
|
||||
clitypes "github.com/docker/cli/cli/config/types"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/docker/docker/pkg/jsonmessage"
|
||||
@@ -260,7 +260,11 @@ func ImageDigestResolver(ctx context.Context, file *configfile.ConfigFile, apiCl
|
||||
}
|
||||
}
|
||||
|
||||
func encodedAuth(ref reference.Named, configFile driver.Auth) (string, error) {
|
||||
type authProvider interface {
|
||||
GetAuthConfig(registryHostname string) (clitypes.AuthConfig, error)
|
||||
}
|
||||
|
||||
func encodedAuth(ref reference.Named, configFile authProvider) (string, error) {
|
||||
authConfig, err := configFile.GetAuthConfig(registry.GetAuthConfigKey(reference.Domain(ref)))
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -398,14 +402,14 @@ func toPullProgressEvent(parent string, jm jsonmessage.JSONMessage, events api.E
|
||||
}
|
||||
|
||||
var (
|
||||
text string
|
||||
total int64
|
||||
percent int
|
||||
current int64
|
||||
status = api.Working
|
||||
progress string
|
||||
total int64
|
||||
percent int
|
||||
current int64
|
||||
status = api.Working
|
||||
)
|
||||
|
||||
text = jm.Progress.String()
|
||||
progress = jm.Progress.String()
|
||||
|
||||
switch jm.Status {
|
||||
case PreparingPhase, WaitingPhase, PullingFsPhase:
|
||||
@@ -416,6 +420,9 @@ func toPullProgressEvent(parent string, jm jsonmessage.JSONMessage, events api.E
|
||||
total = jm.Progress.Total
|
||||
if jm.Progress.Total > 0 {
|
||||
percent = int(jm.Progress.Current * 100 / jm.Progress.Total)
|
||||
if percent > 100 {
|
||||
percent = 100
|
||||
}
|
||||
}
|
||||
}
|
||||
case DownloadCompletePhase, AlreadyExistsPhase, PullCompletePhase:
|
||||
@@ -431,7 +438,7 @@ func toPullProgressEvent(parent string, jm jsonmessage.JSONMessage, events api.E
|
||||
|
||||
if jm.Error != nil {
|
||||
status = api.Error
|
||||
text = jm.Error.Message
|
||||
progress = jm.Error.Message
|
||||
}
|
||||
|
||||
events.On(api.Resource{
|
||||
@@ -441,6 +448,7 @@ func toPullProgressEvent(parent string, jm jsonmessage.JSONMessage, events api.E
|
||||
Total: total,
|
||||
Percent: percent,
|
||||
Status: status,
|
||||
Text: text,
|
||||
Text: jm.Status,
|
||||
Details: progress,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -155,6 +155,9 @@ func toPushProgressEvent(prefix string, jm jsonmessage.JSONMessage, events api.E
|
||||
total = jm.Progress.Total
|
||||
if jm.Progress.Total > 0 {
|
||||
percent = int(jm.Progress.Current * 100 / jm.Progress.Total)
|
||||
if percent > 100 {
|
||||
percent = 100
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ func (s *composeService) RunOneOffContainer(ctx context.Context, project *types.
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// remove cancellable context signal handler so we can forward signals to container without compose to exit
|
||||
// remove cancellable context signal handler so we can forward signals to container without compose from exiting
|
||||
signal.Reset()
|
||||
|
||||
sigc := make(chan os.Signal, 128)
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -41,7 +40,6 @@ func TestStopTimeout(t *testing.T) {
|
||||
tested, err := NewComposeService(cli)
|
||||
assert.NilError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
api.EXPECT().ContainerList(gomock.Any(), projectFilterListOpt(false)).Return(
|
||||
[]container.Summary{
|
||||
testContainer("service1", "123", false),
|
||||
@@ -63,7 +61,7 @@ func TestStopTimeout(t *testing.T) {
|
||||
api.EXPECT().ContainerStop(gomock.Any(), "456", stopConfig).Return(nil)
|
||||
api.EXPECT().ContainerStop(gomock.Any(), "789", stopConfig).Return(nil)
|
||||
|
||||
err = tested.Stop(ctx, strings.ToLower(testProject), compose.StopOptions{
|
||||
err = tested.Stop(t.Context(), strings.ToLower(testProject), compose.StopOptions{
|
||||
Timeout: &timeout,
|
||||
})
|
||||
assert.NilError(t, err)
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
@@ -119,10 +118,8 @@ func TestViz(t *testing.T) {
|
||||
tested, err := NewComposeService(cli)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("viz (no ports, networks or image)", func(t *testing.T) {
|
||||
graphStr, err := tested.Viz(ctx, &project, compose.VizOptions{
|
||||
graphStr, err := tested.Viz(t.Context(), &project, compose.VizOptions{
|
||||
Indentation: " ",
|
||||
IncludePorts: false,
|
||||
IncludeImageName: false,
|
||||
@@ -181,7 +178,7 @@ func TestViz(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("viz (with ports, networks and image)", func(t *testing.T) {
|
||||
graphStr, err := tested.Viz(ctx, &project, compose.VizOptions{
|
||||
graphStr, err := tested.Viz(t.Context(), &project, compose.VizOptions{
|
||||
Indentation: "\t",
|
||||
IncludePorts: true,
|
||||
IncludeImageName: true,
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
@@ -58,7 +57,6 @@ func TestVolumes(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
args := filters.NewArgs(projectFilter(testProject))
|
||||
listOpts := container.ListOptions{Filters: args}
|
||||
volumeListArgs := filters.NewArgs(projectFilter(testProject))
|
||||
@@ -68,20 +66,19 @@ func TestVolumes(t *testing.T) {
|
||||
}
|
||||
containerReturn := []container.Summary{c1, c2}
|
||||
|
||||
// Mock API calls
|
||||
mockApi.EXPECT().ContainerList(ctx, listOpts).Times(2).Return(containerReturn, nil)
|
||||
mockApi.EXPECT().VolumeList(ctx, volumeListOpts).Times(2).Return(volumeReturn, nil)
|
||||
mockApi.EXPECT().ContainerList(t.Context(), listOpts).Times(2).Return(containerReturn, nil)
|
||||
mockApi.EXPECT().VolumeList(t.Context(), volumeListOpts).Times(2).Return(volumeReturn, nil)
|
||||
|
||||
// Test without service filter - should return all project volumes
|
||||
volumeOptions := api.VolumesOptions{}
|
||||
volumes, err := tested.Volumes(ctx, testProject, volumeOptions)
|
||||
volumes, err := tested.Volumes(t.Context(), testProject, volumeOptions)
|
||||
expected := []api.VolumesSummary{vol1, vol2, vol3}
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, volumes, expected)
|
||||
|
||||
// Test with service filter - should only return volumes used by service1
|
||||
volumeOptions = api.VolumesOptions{Services: []string{"service1"}}
|
||||
volumes, err = tested.Volumes(ctx, testProject, volumeOptions)
|
||||
volumes, err = tested.Volumes(t.Context(), testProject, volumeOptions)
|
||||
expected = []api.VolumesSummary{vol1, vol2}
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, volumes, expected)
|
||||
|
||||
@@ -355,6 +355,8 @@ func (s *composeService) watchEvents(ctx context.Context, project *types.Project
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
options.LogTo.Log(api.WatchLogger, "Watch disabled")
|
||||
// Ensure watcher is closed to release resources
|
||||
_ = watcher.Close()
|
||||
return nil
|
||||
case err, open := <-watcher.Errors():
|
||||
if err != nil {
|
||||
@@ -363,13 +365,28 @@ func (s *composeService) watchEvents(ctx context.Context, project *types.Project
|
||||
if open {
|
||||
continue
|
||||
}
|
||||
_ = watcher.Close()
|
||||
return err
|
||||
case batch := <-batchEvents:
|
||||
case batch, ok := <-batchEvents:
|
||||
if !ok {
|
||||
options.LogTo.Log(api.WatchLogger, "Watch disabled")
|
||||
_ = watcher.Close()
|
||||
return nil
|
||||
}
|
||||
if len(batch) > 1000 {
|
||||
logrus.Warnf("Very large batch of file changes detected: %d files. This may impact performance.", len(batch))
|
||||
options.LogTo.Log(api.WatchLogger, "Large batch of file changes detected. If you just switched branches, this is expected.")
|
||||
}
|
||||
start := time.Now()
|
||||
logrus.Debugf("batch start: count[%d]", len(batch))
|
||||
err := s.handleWatchBatch(ctx, project, options, batch, rules, syncer)
|
||||
if err != nil {
|
||||
logrus.Warnf("Error handling changed files: %v", err)
|
||||
// If context was canceled, exit immediately
|
||||
if ctx.Err() != nil {
|
||||
_ = watcher.Close()
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
logrus.Debugf("batch complete: duration[%s] count[%d]", time.Since(start), len(batch))
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ func TestWatch_Sync(t *testing.T) {
|
||||
//
|
||||
cli.EXPECT().Client().Return(apiClient).AnyTimes()
|
||||
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
ctx, cancelFunc := context.WithCancel(t.Context())
|
||||
t.Cleanup(cancelFunc)
|
||||
|
||||
proj := types.Project{
|
||||
|
||||
@@ -313,7 +313,7 @@ func (d *DryRunClient) ContainerExecStart(ctx context.Context, execID string, co
|
||||
return nil
|
||||
}
|
||||
|
||||
// Functions delegated to original APIClient (not used by Compose or not modifying the Compose stack
|
||||
// Functions delegated to original APIClient (not used by Compose or not modifying the Compose stack)
|
||||
|
||||
func (d *DryRunClient) ConfigList(ctx context.Context, options swarm.ConfigListOptions) ([]swarm.Config, error) {
|
||||
return d.apiClient.ConfigList(ctx, options)
|
||||
|
||||
@@ -39,7 +39,7 @@ func TestComposeCancel(t *testing.T) {
|
||||
t.Run("metrics on cancel Compose build", func(t *testing.T) {
|
||||
const buildProjectPath = "fixtures/build-infinite/compose.yaml"
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
|
||||
// require a separate groupID from the process running tests, in order to simulate ctrl+C from a terminal.
|
||||
|
||||
21
pkg/e2e/fixtures/image-volume-recreate/Dockerfile
Normal file
21
pkg/e2e/fixtures/image-volume-recreate/Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
#
|
||||
# Copyright 2020 Docker Compose CLI authors
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
FROM alpine
|
||||
WORKDIR /app
|
||||
ARG CONTENT=initial
|
||||
RUN echo "$CONTENT" > /app/content.txt
|
||||
18
pkg/e2e/fixtures/image-volume-recreate/compose.yaml
Normal file
18
pkg/e2e/fixtures/image-volume-recreate/compose.yaml
Normal file
@@ -0,0 +1,18 @@
|
||||
services:
|
||||
source:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: image-volume-source
|
||||
|
||||
consumer:
|
||||
image: alpine
|
||||
depends_on:
|
||||
- source
|
||||
command: ["cat", "/data/content.txt"]
|
||||
volumes:
|
||||
- type: image
|
||||
source: image-volume-source
|
||||
target: /data
|
||||
image:
|
||||
subpath: app
|
||||
@@ -212,11 +212,9 @@ func TestNetworkRecreate(t *testing.T) {
|
||||
res := c.RunDockerComposeCmd(t, "-f", "./fixtures/network-recreate/compose.yaml", "--project-name", projectName, "--progress=plain", "up", "-d")
|
||||
err := res.Stderr()
|
||||
fmt.Println(err)
|
||||
res.Assert(t, icmd.Expected{Err: `
|
||||
Container network_recreate-web-1 Stopped
|
||||
Network network_recreate_test Removed
|
||||
Network network_recreate_test Creating
|
||||
Network network_recreate_test Created
|
||||
Container network_recreate-web-1 Starting
|
||||
Container network_recreate-web-1 Started`})
|
||||
hasStopped := strings.Contains(err, "Stopped")
|
||||
hasResumed := strings.Contains(err, "Started") || strings.Contains(err, "Recreated")
|
||||
if !hasStopped || !hasResumed {
|
||||
t.Fatalf("unexpected output, missing expected events, stderr: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ func TestUpDependenciesNotStopped(t *testing.T) {
|
||||
"app",
|
||||
)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
cmd, err := StartWithNewGroupID(ctx, testCmd, upOut, nil)
|
||||
|
||||
@@ -19,7 +19,6 @@ package e2e
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
@@ -104,9 +103,7 @@ func TestProjectVolumeBind(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Running on Windows. Skipping...")
|
||||
}
|
||||
tmpDir, err := os.MkdirTemp("", projectName)
|
||||
assert.NilError(t, err)
|
||||
defer os.RemoveAll(tmpDir) //nolint
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
c.RunDockerComposeCmd(t, "--project-name", projectName, "down")
|
||||
|
||||
@@ -193,3 +190,47 @@ func TestImageVolume(t *testing.T) {
|
||||
out := res.Combined()
|
||||
assert.Check(t, strings.Contains(out, "index.html"))
|
||||
}
|
||||
|
||||
func TestImageVolumeRecreateOnRebuild(t *testing.T) {
|
||||
c := NewCLI(t)
|
||||
const projectName = "compose-e2e-image-volume-recreate"
|
||||
t.Cleanup(func() {
|
||||
c.cleanupWithDown(t, projectName)
|
||||
c.RunDockerOrExitError(t, "rmi", "-f", "image-volume-source")
|
||||
})
|
||||
|
||||
version := c.RunDockerCmd(t, "version", "-f", "{{.Server.Version}}")
|
||||
major, _, found := strings.Cut(version.Combined(), ".")
|
||||
assert.Assert(t, found)
|
||||
if major == "26" || major == "27" {
|
||||
t.Skip("Skipping test due to docker version < 28")
|
||||
}
|
||||
|
||||
// First build and run with initial content
|
||||
c.RunDockerComposeCmd(t, "-f", "./fixtures/image-volume-recreate/compose.yaml",
|
||||
"--project-name", projectName, "build", "--build-arg", "CONTENT=foo")
|
||||
res := c.RunDockerComposeCmd(t, "-f", "./fixtures/image-volume-recreate/compose.yaml",
|
||||
"--project-name", projectName, "up", "-d")
|
||||
assert.Check(t, !strings.Contains(res.Combined(), "error"))
|
||||
|
||||
// Check initial content
|
||||
res = c.RunDockerComposeCmd(t, "-f", "./fixtures/image-volume-recreate/compose.yaml",
|
||||
"--project-name", projectName, "logs", "consumer")
|
||||
assert.Check(t, strings.Contains(res.Combined(), "foo"), "Expected 'foo' in output, got: %s", res.Combined())
|
||||
|
||||
// Rebuild source image with different content
|
||||
c.RunDockerComposeCmd(t, "-f", "./fixtures/image-volume-recreate/compose.yaml",
|
||||
"--project-name", projectName, "build", "--build-arg", "CONTENT=bar")
|
||||
|
||||
// Run up again - consumer should be recreated because source image changed
|
||||
res = c.RunDockerComposeCmd(t, "-f", "./fixtures/image-volume-recreate/compose.yaml",
|
||||
"--project-name", projectName, "up", "-d")
|
||||
// The consumer container should be recreated
|
||||
assert.Check(t, strings.Contains(res.Combined(), "Recreate") || strings.Contains(res.Combined(), "Created"),
|
||||
"Expected container to be recreated, got: %s", res.Combined())
|
||||
|
||||
// Check updated content
|
||||
res = c.RunDockerComposeCmd(t, "-f", "./fixtures/image-volume-recreate/compose.yaml",
|
||||
"--project-name", projectName, "logs", "consumer")
|
||||
assert.Check(t, strings.Contains(res.Combined(), "bar"), "Expected 'bar' in output after rebuild, got: %s", res.Combined())
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ import (
|
||||
func Test_BatchDebounceEvents(t *testing.T) {
|
||||
ch := make(chan FileEvent)
|
||||
clock := clockwork.NewFakeClock()
|
||||
ctx, stop := context.WithCancel(context.Background())
|
||||
ctx, stop := context.WithCancel(t.Context())
|
||||
t.Cleanup(stop)
|
||||
|
||||
eventBatchCh := BatchDebounceEvents(ctx, clock, ch)
|
||||
|
||||
@@ -35,20 +35,20 @@ import (
|
||||
// behavior.
|
||||
|
||||
func TestWindowsBufferSize(t *testing.T) {
|
||||
orig := os.Getenv(WindowsBufferSizeEnvVar)
|
||||
defer os.Setenv(WindowsBufferSizeEnvVar, orig) //nolint:errcheck
|
||||
t.Run("empty value", func(t *testing.T) {
|
||||
t.Setenv(WindowsBufferSizeEnvVar, "")
|
||||
assert.Equal(t, defaultBufferSize, DesiredWindowsBufferSize())
|
||||
})
|
||||
|
||||
err := os.Setenv(WindowsBufferSizeEnvVar, "")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, defaultBufferSize, DesiredWindowsBufferSize())
|
||||
t.Run("invalid value", func(t *testing.T) {
|
||||
t.Setenv(WindowsBufferSizeEnvVar, "a")
|
||||
assert.Equal(t, defaultBufferSize, DesiredWindowsBufferSize())
|
||||
})
|
||||
|
||||
err = os.Setenv(WindowsBufferSizeEnvVar, "a")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, defaultBufferSize, DesiredWindowsBufferSize())
|
||||
|
||||
err = os.Setenv(WindowsBufferSizeEnvVar, "10")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 10, DesiredWindowsBufferSize())
|
||||
t.Run("valid value", func(t *testing.T) {
|
||||
t.Setenv(WindowsBufferSizeEnvVar, "10")
|
||||
assert.Equal(t, 10, DesiredWindowsBufferSize())
|
||||
})
|
||||
}
|
||||
|
||||
func TestNoEvents(t *testing.T) {
|
||||
@@ -114,7 +114,7 @@ func TestGitBranchSwitch(t *testing.T) {
|
||||
f.events = nil
|
||||
|
||||
// consume all the events in the background
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
done := f.consumeEventsInBackground(ctx)
|
||||
|
||||
for i, dir := range dirs {
|
||||
@@ -501,7 +501,7 @@ type notifyFixture struct {
|
||||
|
||||
func newNotifyFixture(t *testing.T) *notifyFixture {
|
||||
out := bytes.NewBuffer(nil)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
nf := ¬ifyFixture{
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build darwin
|
||||
//go:build fsnotify
|
||||
|
||||
/*
|
||||
Copyright 2020 Docker Compose CLI authors
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsevents"
|
||||
@@ -38,6 +39,7 @@ type fseventNotify struct {
|
||||
stop chan struct{}
|
||||
|
||||
pathsWereWatching map[string]any
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
func (d *fseventNotify) loop() {
|
||||
@@ -81,6 +83,8 @@ func (d *fseventNotify) Start() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
d.closeOnce = sync.Once{}
|
||||
|
||||
numberOfWatches.Add(int64(len(d.stream.Paths)))
|
||||
|
||||
err := d.stream.Start()
|
||||
@@ -92,11 +96,13 @@ func (d *fseventNotify) Start() error {
|
||||
}
|
||||
|
||||
func (d *fseventNotify) Close() error {
|
||||
numberOfWatches.Add(int64(-len(d.stream.Paths)))
|
||||
d.closeOnce.Do(func() {
|
||||
numberOfWatches.Add(int64(-len(d.stream.Paths)))
|
||||
|
||||
d.stream.Stop()
|
||||
close(d.errors)
|
||||
close(d.stop)
|
||||
d.stream.Stop()
|
||||
close(d.errors)
|
||||
close(d.stop)
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
48
pkg/watch/watcher_darwin_test.go
Normal file
48
pkg/watch/watcher_darwin_test.go
Normal file
@@ -0,0 +1,48 @@
|
||||
//go:build fsnotify
|
||||
|
||||
/*
|
||||
Copyright 2020 Docker Compose CLI authors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package watch
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gotest.tools/v3/assert"
|
||||
)
|
||||
|
||||
func TestFseventNotifyCloseIdempotent(t *testing.T) {
|
||||
// Create a watcher with a temporary directory
|
||||
tmpDir := t.TempDir()
|
||||
watcher, err := newWatcher([]string{tmpDir})
|
||||
assert.NilError(t, err)
|
||||
|
||||
// Start the watcher
|
||||
err = watcher.Start()
|
||||
assert.NilError(t, err)
|
||||
|
||||
// Close should work the first time
|
||||
err = watcher.Close()
|
||||
assert.NilError(t, err)
|
||||
|
||||
// Close should be idempotent - calling it again should not panic
|
||||
err = watcher.Close()
|
||||
assert.NilError(t, err)
|
||||
|
||||
// Even a third time should be safe
|
||||
err = watcher.Close()
|
||||
assert.NilError(t, err)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build !darwin
|
||||
//go:build !fsnotify
|
||||
|
||||
/*
|
||||
Copyright 2020 Docker Compose CLI authors
|
||||
|
||||
Reference in New Issue
Block a user