Compare commits

...

86 Commits
v5.0.0 ... main

Author SHA1 Message Date
dependabot[bot]
7abaa06617 build(deps): bump go.yaml.in/yaml/v4 from 4.0.0-rc.3 to 4.0.0-rc.4
Bumps [go.yaml.in/yaml/v4](https://github.com/yaml/go-yaml) from 4.0.0-rc.3 to 4.0.0-rc.4.
- [Commits](https://github.com/yaml/go-yaml/compare/v4.0.0-rc.3...v4.0.0-rc.4)

---
updated-dependencies:
- dependency-name: go.yaml.in/yaml/v4
  dependency-version: 4.0.0-rc.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-09 10:43:48 +01:00
dependabot[bot]
3b0e8f538e build(deps): bump golang.org/x/sys from 0.40.0 to 0.41.0
Bumps [golang.org/x/sys](https://github.com/golang/sys) from 0.40.0 to 0.41.0.
- [Commits](https://github.com/golang/sys/compare/v0.40.0...v0.41.0)

---
updated-dependencies:
- dependency-name: golang.org/x/sys
  dependency-version: 0.41.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-09 10:31:58 +01:00
Sebastiaan van Stijn
af376603c3 update to go1.25.7
go1.25.7 (released 2026-02-04) includes security fixes to the go command
and the crypto/tls package, as well as bug fixes to the compiler and the
crypto/x509 package. See the Go 1.25.7 milestone on our issue tracker for
details:
https://github.com/golang/go/issues?q=milestone%3AGo1.25.7+label%3ACherryPickApproved

full diff: https://github.com/golang/go/compare/go1.25.6...go1.25.7

From the security mailing list:

> Hello gophers,
>
> We have just released Go versions 1.25.7 and 1.24.13, minor point releases.
>
> These releases include 2 security fixes following the security policy:
>
> - cmd/cgo: remove user-content from doc strings in cgo ASTs
>
>   A discrepancy between how Go and C/C++ comments
>   were parsed allowed for code smuggling into the
>   resulting cgo binary.
>
>   To prevent this behavior, the cgo compiler
>   will no longer parse user-provided doc
>   comments.
>
>   Thank you to RyotaK (https://ryotak.net) of
>   GMO Flatt Security Inc. for reporting this issue.
>
>   This is CVE-2025-61732 and https://go.dev/issue/76697.
>
> - crypto/tls: unexpected session resumption when using Config.GetConfigForClient
>
>   Config.GetConfigForClient is documented to use the original Config's session
>   ticket keys unless explicitly overridden. This can cause unexpected behavior if
>   the returned Config modifies authentication parameters, like ClientCAs: a
>   connection initially established with the parent (or a sibling) Config can be
>   resumed, bypassing the modified authentication requirements.
>
>   If ClientAuth is VerifyClientCertIfGiven or RequireAndVerifyClientCert (on the
>   server) or InsecureSkipVerify is false (on the client), crypto/tls now checks
>   that the root of the previously-verified chain is still in ClientCAs/RootCAs
>   when resuming a connection.
>
>   Go 1.26 Release Candidate 2, Go 1.25.6, and Go 1.24.12 had fixed a similar issue
>   related to session ticket keys being implicitly shared by Config.Clone. Since
>   this fix is broader, the Config.Clone behavior change has been reverted.
>
>   Note that VerifyPeerCertificate still behaves as documented: it does not apply
>   to resumed connections. Applications that use Config.GetConfigForClient or
>   Config.Clone and do not wish to blindly resume connections established with the
>   original Config must use VerifyConnection instead (or SetSessionTicketKeys or
>   SessionTicketsDisabled).
>
>   Thanks to Coia Prant (github.com/rbqvq) for reporting this issue.
>
>   This updates CVE-2025-68121 and Go issue https://go.dev/issue/77217.

Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
2026-02-09 09:21:32 +01:00
Michael Irwin
7f8814f4c5 Fix invalid path error when using OCI artifacts on Windows
When using OCI artifacts (e.g., `docker compose -f oci://dockersamples/welcome-to-docker up`)
on Windows, users encountered the following error:

  CreateFile C:\Users\username\oci:\dockersamples\.env: The filename, directory name,
  or volume label syntax is incorrect.

This issue was introduced between v5.0.0 and v5.0.1, specifically by commit 6c043929a
which fixed error handling in setEnvWithDotEnv. The bug existed in v5.0.0 but was
silently ignored due to improper error handling.

Root Cause:
-----------
The setEnvWithDotEnv function creates ProjectOptions without registering remote loaders.
Without remote loaders, the compose-go library doesn't recognize OCI paths as remote
resources. It falls through to filepath.Abs() which treats the OCI reference as a
relative path.

On Windows, filepath.Abs("oci://dockersamples/...") produces an invalid path like:
  C:\Users\username\oci:\dockersamples

Windows rejects this path because colons are only valid after drive letters.

Solution:
---------
Modified setEnvWithDotEnv to detect remote config paths and skip environment loading
for them. Instead of hardcoding string checks, the fix uses the actual remote loaders'
Accept() method to determine if a config path is remote. This is more maintainable
and consistent with how the compose-go library identifies remote resources.

The function now:
- Accepts a dockerCli parameter to access remote loaders
- Uses opts.remoteLoaders(dockerCli) to get loader instances
- Checks if any loader accepts the config path using loader.Accept()
- Skips .env loading for remote configs (happens later when loaders are initialized)
- Allows normal processing for local compose files

Testing:
--------
- Added tests for OCI artifacts, Git remotes, and local paths
- Verified fix works on Windows ARM64
- All existing tests pass

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Signed-off-by: Michael Irwin <mikesir87@gmail.com>
2026-02-09 09:15:37 +01:00
CrazyMax
af0029afe1 ci: use bin-image edge tag directly for e2e tests
Signed-off-by: CrazyMax <1951866+crazy-max@users.noreply.github.com>
2026-02-03 15:11:49 +01:00
CrazyMax
b76feb66e1 ci: fix missing dependency on bin-image job
Signed-off-by: CrazyMax <1951866+crazy-max@users.noreply.github.com>
2026-02-03 15:11:49 +01:00
CrazyMax
9dc7f1e70c ci: use docker/github-builder to build, sign and push bin image
Signed-off-by: CrazyMax <1951866+crazy-max@users.noreply.github.com>
2026-02-03 14:19:21 +01:00
CrazyMax
03205124fe ci: use docker/github-builder to build, sign binaries
Signed-off-by: CrazyMax <1951866+crazy-max@users.noreply.github.com>
2026-02-03 14:19:21 +01:00
Sebastiaan van Stijn
8b769bad6b pkg/compose: remove dependency on github.com/docker/buildx/driver
The driver.Auth interface was describing the configfile.GetAuthConfig
implementation; define a local interface instead of using buildx's
definition as an intermediate.

Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
2026-01-29 15:59:03 +01:00
Nicolas De Loof
671507a8b3 fix panic
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
2026-01-29 09:57:49 +01:00
ibrahim yapar
56ab28aef3 compose: recreate container when mounted image digest changes
Until now, mustRecreate logic only checked for divergence in TypeVolume
mounts but ignored TypeImage mounts. This inconsistency caused containers
to erroneously retain stale images even after the source image was rebuilt.
This commit updates ensureImagesExists to resolve image volume sources to
their digests using the official reference package. This enables ServiceHash
(config hash) to naturally detect underlying image digest changes,
triggering recreation via the standard convergence logic.
An E2E test case is added to verify this behavior.
Fixes #13547

Signed-off-by: ibrahim yapar <74625807+ibrahimypr@users.noreply.github.com>
2026-01-26 15:55:44 +01:00
Sebastiaan van Stijn
e7d870a106 update to go1.25.6
Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
2026-01-26 09:26:08 +01:00
Nepomuk Crhonek
d5bb3387ca Fix potential nil pointer dereference in container event monitoring
The condition for checking container restart state had incorrect operator
precedence. The expression:

  inspect.State != nil && inspect.State.Restarting || inspect.State.Running

is evaluated as:

  (inspect.State != nil && inspect.State.Restarting) || inspect.State.Running

This means if inspect.State is nil and inspect.State.Restarting is false
(which would trigger a panic), the code would attempt to access
inspect.State.Running, causing a nil pointer dereference.

This fix adds parentheses to ensure the nil check applies to both
state checks:

  inspect.State != nil && (inspect.State.Restarting || inspect.State.Running)

Signed-off-by: Nepomuk Crhonek <105591323+Nepomuk5665@users.noreply.github.com>
2026-01-25 21:15:37 +01:00
Salman Muin Kayser Chishti
d91fc63813 Upgrade GitHub Actions to latest versions
Signed-off-by: Salman Muin Kayser Chishti <13schishti@gmail.com>
2026-01-23 15:34:48 +01:00
Sebastiaan van Stijn
c51b1fea29 replace some uses of strings.Split(N) for strings.Cut
Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
2026-01-22 11:26:16 +01:00
Sebastiaan van Stijn
fa7549a851 Dockerfile: update golangci-lint to v2.8.0
Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
2026-01-21 10:11:02 +01:00
Mahesh Thakur
a061c17736 fix: emit container status events after network reconnection
Signed-off-by: Mahesh Thakur <maheshthakur9152@gmail.com>
2026-01-21 09:40:05 +01:00
Sebastiaan van Stijn
c5e7d9158c update to go1.24.12
This releases includes 6 security fixes following the security policy:

- archive/zip: denial of service when parsing arbitrary ZIP archives

    archive/zip used a super-linear file name indexing algorithm that is invoked the first time a file in an archive is opened. This can lead to a denial of service when consuming a maliciously constructed ZIP archive.

    Thanks to Thanks to Jakub Ciolek for reporting this issue.

    This is CVE-2025-61728 and Go issue https://go.dev/issue/77102.

- net/http: memory exhaustion in Request.ParseForm

    When parsing a URL-encoded form net/http may allocate an unexpected amount of
    memory when provided a large number of key-value pairs. This can result in a
    denial of service due to memory exhaustion.

    Thanks to jub0bs for reporting this issue.

    This is CVE-2025-61726 and Go issue https://go.dev/issue/77101.

- crypto/tls: Config.Clone copies automatically generated session ticket keys, session resumption does not account for the expiration of full certificate chain

    The Config.Clone methods allows cloning a Config which has already been passed
    to a TLS function, allowing it to be mutated and reused.

    If Config.SessionTicketKey has not been set, and Config.SetSessionTicketKeys has
    not been called, crypto/tls will generate random session ticket keys and
    automatically rotate them. Config.Clone would copy these automatically generated
    keys into the returned Config, meaning that the two Configs would share session
    ticket keys, allowing sessions created using one Config could be used to resume
    sessions with the other Config. This can allow clients to resume sessions even
    though the Config may be configured such that they should not be able to do so.

    Config.Clone no longer copies the automatically generated session ticket keys.
    Config.Clone still copies keys which are explicitly provided, either by setting
    Config.SessionTicketKey or by calling Config.SetSessionTicketKeys.

    This issue was discoverd by the Go Security team while investigating another
    issue reported by Coia Prant (github.com/rbqvq).

    Additionally, on the server side only the expiration of the leaf certificate, if
    one was provided during the initial handshake, was checked when considering if a
    session could be resumed. This allowed sessions to be resumed if an intermediate
    or root certificate in the chain had expired.

    Session resumption now takes into account of the full chain when determining if
    the session can be resumed.

    Thanks to Coia Prant (github.com/rbqvq) for reporting this issue.

    This is CVE-2025-68121 and Go issue https://go.dev/issue/77113.

- cmd/go: bypass of flag sanitization can lead to arbitrary code execution

    Usage of 'CgoPkgConfig' allowed execution of the pkg-config
    binary with flags that are not explicitly safe-listed.

    To prevent this behavior, compiler flags resulting from usage
    of 'CgoPkgConfig' are sanitized prior to invoking pkg-config.

    Thank you to RyotaK (https://ryotak.net) of GMO Flatt Security Inc.
    for reporting this issue.

    This is CVE-2025-61731 and go.dev/issue/77100.

- cmd/go: unexpected code execution when invoking toolchain

    The Go toolchain supports multiple VCS which are used retrieving modules and
    embedding build information into binaries.

    On systems with Mercurial installed (hg) downloading modules (e.g. via go get or
    go mod download) from non-standard sources (e.g. custom domains) can cause
    unexpected code execution due to how external VCS commands are constructed.

    On systems with Git installed, downloading and building modules with malicious
    version strings could allow an attacker to write to arbitrary files on the
    system the user has access to. This can only be triggered by explicitly
    providing the malicious version strings to the toolchain, and does not affect
    usage of @latest or bare module paths.

    The toolchain now uses safer VCS options to prevent misinterpretation of
    untrusted inputs. In addition, the toolchain now disallows module version
    strings prefixed with a "-" or "/" character.

    Thanks to splitline (@splitline) from DEVCORE Research Team for reporting this
    issue.

    This is CVE-2025-68119 and Go issue https://go.dev/issue/77099.

- crypto/tls: handshake messages may be processed at the incorrect encryption level

    During the TLS 1.3 handshake if multiple messages are sent in records that span
    encryption level boundaries (for instance the Client Hello and Encrypted
    Extensions messages), the subsequent messages may be processed before the
    encryption level changes. This can cause some minor information disclosure if a
    network-local attacker can inject messages during the handshake.

    Thanks to Coia Prant (github.com/rbqvq) for reporting this issue.

    This is CVE-2025-61730 and Go issue https://go.dev/issue/76443

View the release notes for more information:
https://go.dev/doc/devel/release#go1.24.12

Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
2026-01-21 09:27:04 +01:00
Nicolas De Loof
3783b8ada3 fsnotify is set in Dockerfile
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
2026-01-21 08:47:40 +01:00
Nicolas De Loof
c428a77111 set fsnotify build tag when building for OSX
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
2026-01-20 13:57:53 +01:00
David Gageot
04b4a832dc chore(lint): add forbidigo rules to enforce t.Context() in tests
Add linter rules to prevent usage of context.Background() and
context.TODO() in test files - t.Context() should be used instead.

The rules only apply to *_test.go files, not production code.

Note: os.Setenv is not covered by forbidigo due to a limitation where
it only catches calls when the return value is assigned. However,
errcheck will flag unchecked os.Setenv calls.

Assisted-By: cagent
Signed-off-by: David Gageot <david.gageot@docker.com>
2026-01-20 11:34:11 +01:00
David Gageot
27faa3b84e test: replace os.MkdirTemp with t.TempDir()
Use t.TempDir() which automatically cleans up the temporary directory
when the test completes, eliminating the need for manual cleanup.

Go 1.14 modernization pattern.

Assisted-By: cagent
Signed-off-by: David Gageot <david.gageot@docker.com>
2026-01-20 11:34:11 +01:00
David Gageot
bcc0401e0e test: replace os.Setenv with t.Setenv()
Use t.Setenv() which automatically restores the original value when
the test completes, eliminating the need for manual cleanup.

Go 1.18 modernization pattern.

Assisted-By: cagent
Signed-off-by: David Gageot <david.gageot@docker.com>
2026-01-20 11:34:11 +01:00
David Gageot
093205121c test: replace context.Background()/context.TODO() with t.Context()
Replace manual context creation with t.Context() which is automatically
cancelled when the test completes.

Go 1.24 modernization pattern.

Assisted-By: cagent
Signed-off-by: David Gageot <david.gageot@docker.com>
2026-01-20 11:34:11 +01:00
Amol Yadav
b92b87dd9c fix: robustly handle large file change batches in watch mode
Ensured all watcher and sync goroutines and channels are robustly closed on context cancellation or error.
Added explicit logging for large batches and context cancellation to prevent stuck processes and ensure graceful shutdown on Ctrl-C.

Signed-off-by: Amol Yadav <amyssnipet@yahoo.com>
2026-01-20 08:34:15 +01:00
hiroto.toyoda
06e1287483 fix: update github.com/moby/term to indirect dependency
Signed-off-by: hiroto.toyoda <hiroto.toyoda@dena.com>
2026-01-19 17:46:55 +01:00
hiroto.toyoda
d7bdb34ff5 refactor(attach): remove unused stdin from getContainerStream
Signed-off-by: hiroto.toyoda <hiroto.toyoda@dena.com>
2026-01-19 17:46:55 +01:00
hiroto.toyoda
79d7a8acd6 refactor(attach): simplify attachContainerStreams signature
Signed-off-by: hiroto.toyoda <hiroto.toyoda@dena.com>
2026-01-19 17:46:55 +01:00
hiroto.toyoda
abd99be4fd refactor(attach): remove unused detach watcher and keep attach behavior
Signed-off-by: hiroto.toyoda <hiroto.toyoda@dena.com>
2026-01-19 17:46:55 +01:00
hiroto.toyoda
2672d34217 Improve error handling in attach.go
Signed-off-by: hiroto.toyoda <hiroto.toyoda@dena.com>
2026-01-19 17:46:55 +01:00
Nicolas De Loof
27bf40357a Bump compose to v2.10.1
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
2026-01-19 16:46:17 +01:00
Nicolas De Loof
c8d687599a Fixed progress UI to adapt to terminal width
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
2026-01-19 11:14:23 +01:00
Stavros Kois
2f108ffaa8 handle healthcheck.disable true in isServiceHealthy
Signed-off-by: Stavros Kois <s.kois@outlook.com>
2026-01-19 10:18:34 +01:00
Sebastiaan van Stijn
0a07df0e5b build(deps): bump github.com/sirupsen/logrus v1.9.4
full diff: https://github.com/sirupsen/logrus/compare/v1.9.3...v1.9.4

Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
2026-01-15 19:45:49 +01:00
tensorworker
02b606ef8e use go-compose instead Signed-off-by: tensorworker <tensorworker@proton.me>
Signed-off-by: tensorworker <tensorworker@proton.me>
2026-01-15 08:24:20 +01:00
tensorworker
9856802945 fix: expand tilde in --env-file paths to user home directory
When using --env-file=~/.env, the tilde was not expanded to the user's
home directory. Instead, it was treated as a literal character and
resolved relative to the current working directory, resulting in errors
like "couldn't find env file: /current/dir/~/.env".

This adds an ExpandUser function that expands ~ to the home directory
before converting relative paths to absolute paths.

Fixes #13508

Signed-off-by: tensorworker <tensorworker@proton.me>
2026-01-15 08:24:20 +01:00
Adam Sven Johnson
63ae7eb0fa Replace tabbed indentation in sdk.md
Tabs and spaces were mixed in the example code which didn't indent cleanly in the github preview.

Signed-off-by: Adam Sven Johnson <adam@pkqk.net>
2026-01-14 07:56:25 +01:00
dependabot[bot]
f17d0dfc61 build(deps): bump github.com/go-viper/mapstructure/v2
Bumps [github.com/go-viper/mapstructure/v2](https://github.com/go-viper/mapstructure) from 2.4.0 to 2.5.0.
- [Release notes](https://github.com/go-viper/mapstructure/releases)
- [Changelog](https://github.com/go-viper/mapstructure/blob/main/CHANGELOG.md)
- [Commits](https://github.com/go-viper/mapstructure/compare/v2.4.0...v2.5.0)

---
updated-dependencies:
- dependency-name: github.com/go-viper/mapstructure/v2
  dependency-version: 2.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-13 10:21:23 +01:00
dependabot[bot]
ef14cfcfea build(deps): bump google.golang.org/grpc from 1.77.0 to 1.78.0
Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.77.0 to 1.78.0.
- [Release notes](https://github.com/grpc/grpc-go/releases)
- [Commits](https://github.com/grpc/grpc-go/compare/v1.77.0...v1.78.0)

---
updated-dependencies:
- dependency-name: google.golang.org/grpc
  dependency-version: 1.78.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-12 17:50:14 +01:00
hiroto.toyoda
b760afaf9f refactor: extract API version constants to dedicated file
Signed-off-by: hiroto.toyoda <hiroto.toyoda@dena.com>
2026-01-11 17:04:40 +01:00
dependabot[bot]
a2a5c86f53 build(deps): bump golang.org/x/sys from 0.39.0 to 0.40.0
Bumps [golang.org/x/sys](https://github.com/golang/sys) from 0.39.0 to 0.40.0.
- [Commits](https://github.com/golang/sys/compare/v0.39.0...v0.40.0)

---
updated-dependencies:
- dependency-name: golang.org/x/sys
  dependency-version: 0.40.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-09 10:22:48 +01:00
Sebastiaan van Stijn
98e82127b3 build(deps): bump github.com/containerd/containerd/v2 to v2.2.1
The pull request that was needed has been released now as part of v2.2.1;
full diff: https://github.com/containerd/containerd/compare/efd86f2b0bc2...v2.2.1

Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
2026-01-08 11:33:06 +01:00
Sebastiaan van Stijn
03e19e4a84 go.mod: remove exclude rules
Commit 640c7deae0 added these exclude
rules as a temporary workaround until these transitive dependency
versions would be gone;

> downgrade go-difflib and go-spew to tagged releases
>
> These dependencies were updated to "master" in some modules we depend on,
> but have no code-changes since their last release. Unfortunately, this also
> causes a ripple effect, forcing all users of the containerd module to also
> update these dependencies to an unrelease / un-tagged version.
>
> Both these dependencies will unlikely do a new release in the near future,
> so exclude these versions so that we can downgrade to the current release.

Kubernetes, and other dependencies have reverted those bumps, so these
exclude rules are no longer needed.

This reverts commit 640c7deae0.

Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
2026-01-08 07:07:57 +01:00
Sebastiaan van Stijn
b2c17ff118 build(deps): bump github.com/klauspost/compress to v1.18.2
Fixes a regression in v1.18.1 that resulted in invalid flate/zip/gzip encoding.
The v1.18.1 tag has been retracted.

full diff: https://github.com/klauspost/compress/compare/v1.18.1...v1.18.2

Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
2026-01-07 14:03:12 +01:00
Nicolas De Loof
ec88588cd8 Removed build warning when no explicit build has been requested.
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
2026-01-05 13:17:20 +01:00
Jan-Robin Aumann-O'Keefe
7d5913403a add service name completion to down command
Signed-off-by: Jan-Robin Aumann-O'Keefe <jan-robin@aumann.org>
2026-01-05 09:32:39 +01:00
hiroto.toyoda
d95aa57f01 fix: avoid setting timeout when waitTimeout is not positive
Signed-off-by: hiroto.toyoda <hiroto.toyoda@dena.com>
2026-01-05 09:31:14 +01:00
hiroto.toyoda
ee4c01b66b fix: correctly use errgroup.WithContext
Signed-off-by: hiroto.toyoda <hiroto.toyoda@dena.com>
2026-01-05 09:27:46 +01:00
hiroto.toyoda
d7a65f53f8 fix: correct typo in isSwarmEnabled method name
Signed-off-by: hiroto.toyoda <hiroto.toyoda@dena.com>
2026-01-05 09:11:58 +01:00
hiroto.toyoda
4520bcbaf6 fix: clean up temporary compose files after conversion
Signed-off-by: hiroto.toyoda <hiroto.toyoda@dena.com>
2026-01-05 09:01:56 +01:00
hiroto.toyoda
327be1fcd5 add unit test
Signed-off-by: hiroto.toyoda <hiroto.toyoda@dena.com>
2026-01-05 08:15:02 +01:00
Ignacio López Luna
59f04b85af remove duplicated version field
Signed-off-by: Ignacio López Luna <ignasi.lopez.luna@gmail.com>
2025-12-18 15:24:06 +01:00
Ignacio López Luna
b4574c8bd6 do not strip build metadata
Signed-off-by: Ignacio López Luna <ignasi.lopez.luna@gmail.com>
2025-12-18 15:24:06 +01:00
Ignacio López Luna
29d6c918c4 use github.com/docker/docker/api/types/versions for comparing versions and store plugin version obtained by pluginManager in newModelAPI
Signed-off-by: Ignacio López Luna <ignasi.lopez.luna@gmail.com>
2025-12-18 15:24:06 +01:00
Ignacio López Luna
58403169f3 Only append RuntimeFlags if docker model CLI version is >= v1.0.6
Signed-off-by: Ignacio López Luna <ignasi.lopez.luna@gmail.com>
2025-12-18 15:24:06 +01:00
Ignacio López Luna
6aee7f8370 gets back runtime flags when configuring models
Signed-off-by: Ignacio López Luna <ignasi.lopez.luna@gmail.com>
2025-12-18 15:24:06 +01:00
Nicolas De Loof
c89b8a2d6b warn user no service has been selected to build
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
2025-12-18 11:35:33 +01:00
Nicolas De Loof
aec9f54176 check model plugin is successfully loaded and store version
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
2025-12-18 11:01:35 +01:00
dependabot[bot]
232197d364 build(deps): bump github.com/moby/buildkit from 0.26.2 to 0.26.3
Bumps [github.com/moby/buildkit](https://github.com/moby/buildkit) from 0.26.2 to 0.26.3.
- [Release notes](https://github.com/moby/buildkit/releases)
- [Commits](https://github.com/moby/buildkit/compare/v0.26.2...v0.26.3)

---
updated-dependencies:
- dependency-name: github.com/moby/buildkit
  dependency-version: 0.26.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-17 10:16:54 +01:00
dependabot[bot]
81ba889bee build(deps): bump tags.cncf.io/container-device-interface
Bumps [tags.cncf.io/container-device-interface](https://github.com/cncf-tags/container-device-interface) from 1.0.1 to 1.1.0.
- [Release notes](https://github.com/cncf-tags/container-device-interface/releases)
- [Changelog](https://github.com/cncf-tags/container-device-interface/blob/main/RELEASE.md)
- [Commits](https://github.com/cncf-tags/container-device-interface/compare/v1.0.1...v1.1.0)

---
updated-dependencies:
- dependency-name: tags.cncf.io/container-device-interface
  dependency-version: 1.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-16 09:34:43 +01:00
Nicolas De Loof
8e5b25c0f1 Restored support for BUILDKIT_PROGRESS.
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
2025-12-15 18:03:04 +01:00
Nicolas De Loof
d4c1987638 Prevented incorrect progress metrics to break compose display.
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
2025-12-15 18:02:31 +01:00
Nicolas De Loof
1297f97aef prefer aec library over raw ANSI sequences
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
2025-12-15 16:36:57 +01:00
hiroto.toyoda
55cded1806 Avoid reassigning err variable
Signed-off-by: hiroto.toyoda <hiroto.toyoda@dena.com>
2025-12-15 15:18:54 +01:00
hiroto.toyoda
6c043929a0 Fix missing error handling in setEnvWithDotEnv
Signed-off-by: hiroto.toyoda <hiroto.toyoda@dena.com>
2025-12-15 15:18:54 +01:00
Alexis Lefebvre
2750330566 doc: do not mention v2 on README
Signed-off-by: Alexis Lefebvre <alexislefebvre+github@gmail.com>
2025-12-15 11:46:38 +01:00
Nicolas De Loof
e22426443e Introduced fsnotify build tag to select watcher implementation
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
2025-12-15 10:16:12 +01:00
Guillaume Lours
6599f8ad84 add 'configured' event at the end of model configuration phase
Currently when using models, the final message is 'confugiring' which could let users think the DMR configuration is still pending

Signed-off-by: Guillaume Lours <705411+glours@users.noreply.github.com>

# Conflicts:
#	pkg/api/event.go
2025-12-15 10:14:21 +01:00
Nicolas De Loof
3853ad3911 prefer *task for memory efficiency updating tasks
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
2025-12-12 14:22:02 +01:00
Nicolas De Loof
02008a0097 Restored image layer download progress details on pull.
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
2025-12-12 14:22:02 +01:00
dependabot[bot]
4f419e5098 build(deps): bump golang.org/x/sync from 0.18.0 to 0.19.0
Bumps [golang.org/x/sync](https://github.com/golang/sync) from 0.18.0 to 0.19.0.
- [Commits](https://github.com/golang/sync/compare/v0.18.0...v0.19.0)

---
updated-dependencies:
- dependency-name: golang.org/x/sync
  dependency-version: 0.19.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-11 17:13:15 +01:00
Nicolas De Loof
b62cbed87c Fixed status alignment in progress UI.
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
2025-12-11 15:25:03 +01:00
Nicolas De Loof
aa9a71f37a run finalization synchronously
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
2025-12-10 22:49:37 +01:00
dependabot[bot]
ac211e6e51 build(deps): bump github.com/docker/cli-docs-tool from 0.10.0 to 0.11.0
Bumps [github.com/docker/cli-docs-tool](https://github.com/docker/cli-docs-tool) from 0.10.0 to 0.11.0.
- [Release notes](https://github.com/docker/cli-docs-tool/releases)
- [Commits](https://github.com/docker/cli-docs-tool/compare/v0.10.0...v0.11.0)

---
updated-dependencies:
- dependency-name: github.com/docker/cli-docs-tool
  dependency-version: 0.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-10 10:14:31 +01:00
Austin Vazquez
778a627b8e Set Go min version to absolute minimum version required
Signed-off-by: Austin Vazquez <austin.vazquez@docker.com>
2025-12-09 20:33:00 +01:00
Austin Vazquez
359d2f076e ci: use .go-version file for actions/setup-go
Signed-off-by: Austin Vazquez <austin.vazquez@docker.com>
2025-12-09 20:33:00 +01:00
Austin Vazquez
c9e0d83e14 ci: upgrade actions/setup-go from v5 to v6
Signed-off-by: Austin Vazquez <austin.vazquez@docker.com>
2025-12-09 20:33:00 +01:00
dependabot[bot]
3e206fdcc6 build(deps): bump golang.org/x/sys from 0.38.0 to 0.39.0
Bumps [golang.org/x/sys](https://github.com/golang/sys) from 0.38.0 to 0.39.0.
- [Commits](https://github.com/golang/sys/compare/v0.38.0...v0.39.0)

---
updated-dependencies:
- dependency-name: golang.org/x/sys
  dependency-version: 0.39.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-09 16:18:09 +01:00
Nicolas De Loof
d12947e9f8 Fixed broken run --quiet.
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
2025-12-08 13:34:26 +01:00
xiaolinny
0878c59a74 chore: fix grammatical errors and improve clarity in code
Signed-off-by: xiaolinny <xiaolincode@outlook.com>
2025-12-08 11:20:48 +01:00
Nicolas De Loof
c0345e4f45 restore support for COMPOSE_COMPATIBILITY
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
2025-12-07 18:42:04 +01:00
Nicolas De Loof
9fada6cc23 Bumped build images: tonistiigi/xx:1.9.0, crazymax/osxcross:15.5
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
2025-12-05 15:19:02 +01:00
Rashmi Vagha
85ea24b62c Fix grammar: pluralize 'service' and remove apostrophes in lets
Signed-off-by: Rashmi Vagha <rvagha@umass.edu>
2025-12-05 10:33:35 +01:00
yangfeiyu
000a4a4b9f check buildx version before comparing it
Signed-off-by: yangfeiyu <yangfeiyu20102011@163.com>
2025-12-04 08:34:13 +01:00
Austin Vazquez
08de90c267 bump golang 1.24.11
Signed-off-by: Austin Vazquez <austin.vazquez@docker.com>
2025-12-03 19:30:45 +01:00
Nicolas De Loof
cfcee45a89 fix SDK example
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
2025-12-03 16:55:23 +01:00
73 changed files with 1960 additions and 782 deletions

View File

@@ -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: |

View File

@@ -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
View File

@@ -0,0 +1 @@
1.25.7

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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
[![GitHub release](https://img.shields.io/github/v/release/docker/compose.svg?style=flat-square)](https://github.com/docker/compose/releases/latest)
[![PkgGoDev](https://img.shields.io/badge/go.dev-docs-007d9c?style=flat-square&logo=go&logoColor=white)](https://pkg.go.dev/github.com/docker/compose/v5)

View File

@@ -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{

View 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)
}

View File

@@ -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
}

View File

@@ -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))

View File

@@ -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

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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

View File

@@ -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,

View File

@@ -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{

View File

@@ -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)
})
}
}

View File

@@ -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
View 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)
})
}
}

View File

@@ -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() {

View File

@@ -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

View File

@@ -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
View File

@@ -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
View File

@@ -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=

View File

@@ -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
View 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"

View File

@@ -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.

View File

@@ -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 {

View 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"
)

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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.

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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) {

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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": {

View File

@@ -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)

View File

@@ -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 = "_"
}

View File

@@ -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"},
})

View File

@@ -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"))

View File

@@ -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")
}

View File

@@ -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"

View File

@@ -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{
{

View File

@@ -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)

View File

@@ -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,
})
}

View File

@@ -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
}
}
}
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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,

View File

@@ -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)

View File

@@ -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))
}

View File

@@ -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{

View File

@@ -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)

View File

@@ -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.

View 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

View 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

View File

@@ -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)
}
}

View File

@@ -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)

View File

@@ -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())
}

View File

@@ -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)

View File

@@ -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 := &notifyFixture{
ctx: ctx,
cancel: cancel,

View File

@@ -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
}

View 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)
}

View File

@@ -1,4 +1,4 @@
//go:build !darwin
//go:build !fsnotify
/*
Copyright 2020 Docker Compose CLI authors