From 6aee7f837050d3fa1d40726b654ef594f05df7a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacio=20L=C3=B3pez=20Luna?= Date: Tue, 16 Dec 2025 12:13:02 +0100 Subject: [PATCH 01/37] gets back runtime flags when configuring models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Ignacio López Luna --- pkg/compose/model.go | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/pkg/compose/model.go b/pkg/compose/model.go index 2cbf7e814c..12b7c73e4a 100644 --- a/pkg/compose/model.go +++ b/pkg/compose/model.go @@ -29,7 +29,6 @@ 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/spf13/cobra" "golang.org/x/sync/errgroup" @@ -159,21 +158,21 @@ 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: api.StatusConfiguring, }) - // configure [--context-size=] MODEL + // configure [--context-size=] MODEL [-- ] args := []string{"configure"} if config.ContextSize > 0 { args = append(args, "--context-size", strconv.Itoa(config.ContextSize)) } args = append(args, config.Model) + if len(config.RuntimeFlags) != 0 { + args = append(args, "--") + args = append(args, config.RuntimeFlags...) + } cmd := exec.CommandContext(ctx, m.path, args...) err := m.prepare(ctx, cmd) if err != nil { From 58403169f34aa2f9aa9ee4315810f6653fb1814e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacio=20L=C3=B3pez=20Luna?= Date: Wed, 17 Dec 2025 17:16:35 +0100 Subject: [PATCH 02/37] Only append RuntimeFlags if docker model CLI version is >= v1.0.6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Ignacio López Luna --- pkg/compose/model.go | 114 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 113 insertions(+), 1 deletion(-) diff --git a/pkg/compose/model.go b/pkg/compose/model.go index 12b7c73e4a..0b225609cd 100644 --- a/pkg/compose/model.go +++ b/pkg/compose/model.go @@ -169,7 +169,8 @@ func (m *modelAPI) ConfigureModel(ctx context.Context, config types.ModelConfig, args = append(args, "--context-size", strconv.Itoa(config.ContextSize)) } args = append(args, config.Model) - if len(config.RuntimeFlags) != 0 { + // Only append RuntimeFlags if docker model CLI version is >= v1.0.6 + if len(config.RuntimeFlags) != 0 && m.supportsRuntimeFlags(ctx) { args = append(args, "--") args = append(args, config.RuntimeFlags...) } @@ -277,3 +278,114 @@ func (m *modelAPI) ListModels(ctx context.Context) ([]string, error) { } return availableModels, nil } + +// getModelVersion retrieves the docker model CLI version +func (m *modelAPI) getModelVersion(ctx context.Context) (string, error) { + cmd := exec.CommandContext(ctx, m.path, "version") + err := m.prepare(ctx, cmd) + if err != nil { + return "", err + } + + output, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("error getting docker model version: %w", err) + } + + // Parse output like: "Docker Model Runner version v1.0.4" + // We need to extract the version string (e.g., "v1.0.4") + lines := strings.Split(strings.TrimSpace(string(output)), "\n") + for _, line := range lines { + if strings.Contains(line, "version") { + parts := strings.Fields(line) + for i, part := range parts { + if part == "version" && i+1 < len(parts) { + return parts[i+1], nil + } + } + } + } + + return "", fmt.Errorf("could not parse docker model version from output: %s", string(output)) +} + +// supportsRuntimeFlags checks if the docker model version supports runtime flags +// Runtime flags are supported in version >= v1.0.6 +func (m *modelAPI) supportsRuntimeFlags(ctx context.Context) bool { + versionStr, err := m.getModelVersion(ctx) + if err != nil { + // If we can't determine the version, don't append runtime flags to be safe + return false + } + + // Parse version strings + currentVersion, err := parseVersion(versionStr) + if err != nil { + return false + } + + minVersion, err := parseVersion("1.0.6") + if err != nil { + return false + } + + return !currentVersion.LessThan(minVersion) +} + +// parseVersion parses a semantic version string +// Strips build metadata and prerelease suffixes (e.g., "1.0.6-dirty" or "1.0.6+build") +func parseVersion(versionStr string) (*semVersion, error) { + // Remove 'v' prefix if present + versionStr = strings.TrimPrefix(versionStr, "v") + + // Strip build metadata or prerelease suffix after "-" or "+" + // Examples: "1.0.6-dirty" -> "1.0.6", "1.0.6+build" -> "1.0.6" + if idx := strings.IndexAny(versionStr, "-+"); idx != -1 { + versionStr = versionStr[:idx] + } + + parts := strings.Split(versionStr, ".") + if len(parts) < 2 { + return nil, fmt.Errorf("invalid version format: %s", versionStr) + } + + var v semVersion + var err error + + v.major, err = strconv.Atoi(parts[0]) + if err != nil { + return nil, fmt.Errorf("invalid major version: %s", parts[0]) + } + + v.minor, err = strconv.Atoi(parts[1]) + if err != nil { + return nil, fmt.Errorf("invalid minor version: %s", parts[1]) + } + + if len(parts) > 2 { + v.patch, err = strconv.Atoi(parts[2]) + if err != nil { + return nil, fmt.Errorf("invalid patch version: %s", parts[2]) + } + } + + return &v, nil +} + +// semVersion represents a semantic version +type semVersion struct { + major int + minor int + patch int +} + +// LessThan compares two semantic versions +func (v *semVersion) LessThan(other *semVersion) bool { + if v.major != other.major { + return v.major < other.major + } + if v.minor != other.minor { + return v.minor < other.minor + } + return v.patch < other.patch +} From 29d6c918c4bb0ade9db9b7cd1ea0f3dfa1d7ffb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacio=20L=C3=B3pez=20Luna?= Date: Wed, 17 Dec 2025 17:38:07 +0100 Subject: [PATCH 03/37] use github.com/docker/docker/api/types/versions for comparing versions and store plugin version obtained by pluginManager in newModelAPI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Ignacio López Luna --- pkg/compose/model.go | 110 +++++-------------------------------------- 1 file changed, 11 insertions(+), 99 deletions(-) diff --git a/pkg/compose/model.go b/pkg/compose/model.go index 0b225609cd..3b455b139d 100644 --- a/pkg/compose/model.go +++ b/pkg/compose/model.go @@ -29,6 +29,7 @@ import ( "github.com/compose-spec/compose-go/v2/types" "github.com/containerd/errdefs" "github.com/docker/cli/cli-plugins/manager" + "github.com/docker/docker/api/types/versions" "github.com/spf13/cobra" "golang.org/x/sync/errgroup" @@ -71,6 +72,7 @@ func (s *composeService) ensureModels(ctx context.Context, project *types.Projec type modelAPI struct { path string + version string // cached plugin version env []string prepare func(ctx context.Context, cmd *exec.Cmd) error cleanup func() @@ -170,7 +172,7 @@ func (m *modelAPI) ConfigureModel(ctx context.Context, config types.ModelConfig, } args = append(args, config.Model) // Only append RuntimeFlags if docker model CLI version is >= v1.0.6 - if len(config.RuntimeFlags) != 0 && m.supportsRuntimeFlags(ctx) { + if len(config.RuntimeFlags) != 0 && m.supportsRuntimeFlags() { args = append(args, "--") args = append(args, config.RuntimeFlags...) } @@ -279,113 +281,23 @@ func (m *modelAPI) ListModels(ctx context.Context) ([]string, error) { return availableModels, nil } -// getModelVersion retrieves the docker model CLI version -func (m *modelAPI) getModelVersion(ctx context.Context) (string, error) { - cmd := exec.CommandContext(ctx, m.path, "version") - err := m.prepare(ctx, cmd) - if err != nil { - return "", err - } - - output, err := cmd.CombinedOutput() - if err != nil { - return "", fmt.Errorf("error getting docker model version: %w", err) - } - - // Parse output like: "Docker Model Runner version v1.0.4" - // We need to extract the version string (e.g., "v1.0.4") - lines := strings.Split(strings.TrimSpace(string(output)), "\n") - for _, line := range lines { - if strings.Contains(line, "version") { - parts := strings.Fields(line) - for i, part := range parts { - if part == "version" && i+1 < len(parts) { - return parts[i+1], nil - } - } - } - } - - return "", fmt.Errorf("could not parse docker model version from output: %s", string(output)) -} - // supportsRuntimeFlags checks if the docker model version supports runtime flags // Runtime flags are supported in version >= v1.0.6 -func (m *modelAPI) supportsRuntimeFlags(ctx context.Context) bool { - versionStr, err := m.getModelVersion(ctx) - if err != nil { - // If we can't determine the version, don't append runtime flags to be safe - return false - } - - // Parse version strings - currentVersion, err := parseVersion(versionStr) - if err != nil { - return false - } - - minVersion, err := parseVersion("1.0.6") - if err != nil { +func (m *modelAPI) supportsRuntimeFlags() bool { + // If version is not cached, don't append runtime flags to be safe + if m.version == "" { return false } - return !currentVersion.LessThan(minVersion) -} - -// parseVersion parses a semantic version string -// Strips build metadata and prerelease suffixes (e.g., "1.0.6-dirty" or "1.0.6+build") -func parseVersion(versionStr string) (*semVersion, error) { - // Remove 'v' prefix if present - versionStr = strings.TrimPrefix(versionStr, "v") + // Strip 'v' prefix if present (e.g., "v1.0.6" -> "1.0.6") + versionStr := strings.TrimPrefix(m.version, "v") // Strip build metadata or prerelease suffix after "-" or "+" - // Examples: "1.0.6-dirty" -> "1.0.6", "1.0.6+build" -> "1.0.6" + // This is necessary because versions.LessThan treats "1.0.6-dirty" < "1.0.6" per semver rules + // but we want to compare the base version numbers only if idx := strings.IndexAny(versionStr, "-+"); idx != -1 { versionStr = versionStr[:idx] } - parts := strings.Split(versionStr, ".") - if len(parts) < 2 { - return nil, fmt.Errorf("invalid version format: %s", versionStr) - } - - var v semVersion - var err error - - v.major, err = strconv.Atoi(parts[0]) - if err != nil { - return nil, fmt.Errorf("invalid major version: %s", parts[0]) - } - - v.minor, err = strconv.Atoi(parts[1]) - if err != nil { - return nil, fmt.Errorf("invalid minor version: %s", parts[1]) - } - - if len(parts) > 2 { - v.patch, err = strconv.Atoi(parts[2]) - if err != nil { - return nil, fmt.Errorf("invalid patch version: %s", parts[2]) - } - } - - return &v, nil -} - -// semVersion represents a semantic version -type semVersion struct { - major int - minor int - patch int -} - -// LessThan compares two semantic versions -func (v *semVersion) LessThan(other *semVersion) bool { - if v.major != other.major { - return v.major < other.major - } - if v.minor != other.minor { - return v.minor < other.minor - } - return v.patch < other.patch + return !versions.LessThan(versionStr, "1.0.6") } From b4574c8bd6b81fc5b103d0e6f9548c01474e5962 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacio=20L=C3=B3pez=20Luna?= Date: Thu, 18 Dec 2025 11:50:15 +0100 Subject: [PATCH 04/37] do not strip build metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Ignacio López Luna --- pkg/compose/model.go | 8 -------- 1 file changed, 8 deletions(-) diff --git a/pkg/compose/model.go b/pkg/compose/model.go index 3b455b139d..f75cc99b40 100644 --- a/pkg/compose/model.go +++ b/pkg/compose/model.go @@ -291,13 +291,5 @@ func (m *modelAPI) supportsRuntimeFlags() bool { // Strip 'v' prefix if present (e.g., "v1.0.6" -> "1.0.6") versionStr := strings.TrimPrefix(m.version, "v") - - // Strip build metadata or prerelease suffix after "-" or "+" - // This is necessary because versions.LessThan treats "1.0.6-dirty" < "1.0.6" per semver rules - // but we want to compare the base version numbers only - if idx := strings.IndexAny(versionStr, "-+"); idx != -1 { - versionStr = versionStr[:idx] - } - return !versions.LessThan(versionStr, "1.0.6") } From 59f04b85af552dab7561255dcc08e9328c5bfe67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacio=20L=C3=B3pez=20Luna?= Date: Thu, 18 Dec 2025 15:00:38 +0100 Subject: [PATCH 05/37] remove duplicated version field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Ignacio López Luna --- pkg/compose/model.go | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/compose/model.go b/pkg/compose/model.go index f75cc99b40..e4614d199c 100644 --- a/pkg/compose/model.go +++ b/pkg/compose/model.go @@ -72,7 +72,6 @@ func (s *composeService) ensureModels(ctx context.Context, project *types.Projec type modelAPI struct { path string - version string // cached plugin version env []string prepare func(ctx context.Context, cmd *exec.Cmd) error cleanup func() From 327be1fcd53a606fc9901a9395cacd22220d5889 Mon Sep 17 00:00:00 2001 From: "hiroto.toyoda" Date: Thu, 1 Jan 2026 21:56:18 +0900 Subject: [PATCH 06/37] add unit test Signed-off-by: hiroto.toyoda --- cmd/compose/up_test.go | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/cmd/compose/up_test.go b/cmd/compose/up_test.go index 9019a40ff5..f567e97ad4 100644 --- a/cmd/compose/up_test.go +++ b/cmd/compose/up_test.go @@ -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) + }) + } +} From 4520bcbaf62673f1d7f5a171c0008774a7584309 Mon Sep 17 00:00:00 2001 From: "hiroto.toyoda" Date: Tue, 30 Dec 2025 02:29:25 +0900 Subject: [PATCH 07/37] fix: clean up temporary compose files after conversion Signed-off-by: hiroto.toyoda --- pkg/bridge/convert.go | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/pkg/bridge/convert.go b/pkg/bridge/convert.go index aba3476021..cceb99fdd4 100644 --- a/pkg/bridge/convert.go +++ b/pkg/bridge/convert.go @@ -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 { From d7a65f53f85eb6595a22905cf333c7c039d586d4 Mon Sep 17 00:00:00 2001 From: "hiroto.toyoda" Date: Sun, 28 Dec 2025 01:26:36 +0900 Subject: [PATCH 08/37] fix: correct typo in isSwarmEnabled method name Signed-off-by: hiroto.toyoda --- pkg/compose/compose.go | 2 +- pkg/compose/create.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/compose/compose.go b/pkg/compose/compose.go index 2ce1255d91..45bda0bf3e 100644 --- a/pkg/compose/compose.go +++ b/pkg/compose/compose.go @@ -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 { diff --git a/pkg/compose/create.go b/pkg/compose/create.go index 60773541d8..2f1ec38e2d 100644 --- a/pkg/compose/create.go +++ b/pkg/compose/create.go @@ -1524,7 +1524,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 } From ee4c01b66b0f122763514486b4f2041931d8c4b6 Mon Sep 17 00:00:00 2001 From: "hiroto.toyoda" Date: Sat, 27 Dec 2025 14:51:09 +0900 Subject: [PATCH 09/37] fix: correctly use errgroup.WithContext Signed-off-by: hiroto.toyoda --- pkg/compose/convergence.go | 4 ++-- pkg/compose/down.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/compose/convergence.go b/pkg/compose/convergence.go index 701d005821..0db3d1b104 100644 --- a/pkg/compose/convergence.go +++ b/pkg/compose/convergence.go @@ -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 diff --git a/pkg/compose/down.go b/pkg/compose/down.go index 7915e2dcb0..35eec82ebe 100644 --- a/pkg/compose/down.go +++ b/pkg/compose/down.go @@ -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) From d95aa57f01da1032cec53d7d3518cc499fbd69ca Mon Sep 17 00:00:00 2001 From: "hiroto.toyoda" Date: Sat, 20 Dec 2025 15:46:39 +0900 Subject: [PATCH 10/37] fix: avoid setting timeout when waitTimeout is not positive Signed-off-by: hiroto.toyoda --- cmd/compose/start.go | 5 ++++- cmd/compose/up.go | 8 +++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/cmd/compose/start.go b/cmd/compose/start.go index a2bb05797c..bd5f10c463 100644 --- a/cmd/compose/start.go +++ b/cmd/compose/start.go @@ -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, diff --git a/cmd/compose/up.go b/cmd/compose/up.go index 5819900823..cda2678bbb 100644 --- a/cmd/compose/up.go +++ b/cmd/compose/up.go @@ -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{ From 7d5913403a1fc46156f8c2f21eb4ee119053c378 Mon Sep 17 00:00:00 2001 From: Jan-Robin Aumann-O'Keefe Date: Fri, 19 Dec 2025 17:39:52 +0100 Subject: [PATCH 11/37] add service name completion to down command Signed-off-by: Jan-Robin Aumann-O'Keefe --- cmd/compose/down.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/compose/down.go b/cmd/compose/down.go index 9678316650..d74c817529 100644 --- a/cmd/compose/down.go +++ b/cmd/compose/down.go @@ -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)) From ec88588cd81a5b01eb2853d4ef538db4cb11e093 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Mon, 5 Jan 2026 08:35:15 +0100 Subject: [PATCH 12/37] Removed build warning when no explicit build has been requested. Signed-off-by: Nicolas De Loof --- pkg/compose/build.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pkg/compose/build.go b/pkg/compose/build.go index 6072ada2e0..f0cdf4f164 100644 --- a/pkg/compose/build.go +++ b/pkg/compose/build.go @@ -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) @@ -91,7 +94,6 @@ func (s *composeService) build(ctx context.Context, project *types.Project, opti } if len(serviceToBuild) == 0 { - logrus.Warn("No services to build") return imageIDs, nil } From b2c17ff118154fbeb2d5661f3c0dc7046eede482 Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Wed, 7 Jan 2026 12:22:55 +0100 Subject: [PATCH 13/37] 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 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index f6eb21cff9..e9b15bf534 100644 --- a/go.mod +++ b/go.mod @@ -95,7 +95,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 diff --git a/go.sum b/go.sum index c5fffdd496..0e9dc579e6 100644 --- a/go.sum +++ b/go.sum @@ -209,8 +209,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= From 03e19e4a847bad814f573b87d41a1777b51a0450 Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Wed, 7 Jan 2026 11:50:11 +0100 Subject: [PATCH 14/37] go.mod: remove exclude rules Commit 640c7deae0ea892024004b310bb54951825cb5d4 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 640c7deae0ea892024004b310bb54951825cb5d4. Signed-off-by: Sebastiaan van Stijn --- go.mod | 9 --------- 1 file changed, 9 deletions(-) diff --git a/go.mod b/go.mod index e9b15bf534..20d0e53e00 100644 --- a/go.mod +++ b/go.mod @@ -155,12 +155,3 @@ require ( 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 -) From 98e82127b3e789cebce692b07fbfe8f748ac28cf Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Wed, 7 Jan 2026 11:44:23 +0100 Subject: [PATCH 15/37] 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 --- go.mod | 2 +- go.sum | 14 ++++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/go.mod b/go.mod index 20d0e53e00..5d5c106c5a 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/buger/goterm v1.0.4 github.com/compose-spec/compose-go/v2 v2.10.0 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 diff --git a/go.sum b/go.sum index 0e9dc579e6..0c71983ee7 100644 --- a/go.sum +++ b/go.sum @@ -48,14 +48,14 @@ github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUo 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/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= @@ -301,8 +303,8 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= 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.12.0 h1:6n5JV4Cf+4y0KNXW48TLj5DwfXpvWlxXplUkdTrmPb8= -github.com/opencontainers/selinux v1.12.0/go.mod h1:BTPX+bjVbWGXw7ZZWUbdENt8w0htPSrlgOOysQaU62U= +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= From a2a5c86f53de17e72e4f384213a6aec934adab7b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 9 Jan 2026 09:07:45 +0000 Subject: [PATCH 16/37] 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] --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 5d5c106c5a..9aee68f4e2 100644 --- a/go.mod +++ b/go.mod @@ -55,7 +55,7 @@ require ( go.uber.org/mock v0.6.0 go.yaml.in/yaml/v4 v4.0.0-rc.3 golang.org/x/sync v0.19.0 - golang.org/x/sys v0.39.0 + golang.org/x/sys v0.40.0 google.golang.org/grpc v1.77.0 gotest.tools/v3 v3.5.2 tags.cncf.io/container-device-interface v1.1.0 diff --git a/go.sum b/go.sum index 0c71983ee7..3d0bdfc778 100644 --- a/go.sum +++ b/go.sum @@ -506,8 +506,8 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc 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.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.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= From b760afaf9f137edc940ceb9f4dc1e10340378150 Mon Sep 17 00:00:00 2001 From: "hiroto.toyoda" Date: Sun, 11 Jan 2026 01:18:11 +0900 Subject: [PATCH 17/37] refactor: extract API version constants to dedicated file Signed-off-by: hiroto.toyoda --- pkg/compose/api_versions.go | 73 +++++++++++++++++++++++++++++++++ pkg/compose/build_bake.go | 4 +- pkg/compose/convergence.go | 2 +- pkg/compose/convergence_test.go | 2 +- pkg/compose/convert.go | 4 +- pkg/compose/create.go | 14 +++---- pkg/compose/images.go | 2 +- 7 files changed, 87 insertions(+), 14 deletions(-) create mode 100644 pkg/compose/api_versions.go diff --git a/pkg/compose/api_versions.go b/pkg/compose/api_versions.go new file mode 100644 index 0000000000..2caa520dbd --- /dev/null +++ b/pkg/compose/api_versions.go @@ -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" +) diff --git a/pkg/compose/build_bake.go b/pkg/compose/build_bake.go index 5100ce0fa5..90bd0d5a35 100644 --- a/pkg/compose/build_bake.go +++ b/pkg/compose/build_bake.go @@ -424,8 +424,8 @@ func (s *composeService) getBuildxPlugin() (*manager.Plugin, error) { return nil, fmt.Errorf("failed to get version of buildx") } - if versions.LessThan(buildx.Version[1:], "0.17.0") { - return nil, fmt.Errorf("compose build requires buildx 0.17 or later") + if versions.LessThan(buildx.Version[1:], BuildxMinVersion) { + return nil, fmt.Errorf("compose build requires buildx %s or later", BuildxMinVersion) } return buildx, nil diff --git a/pkg/compose/convergence.go b/pkg/compose/convergence.go index 0db3d1b104..fbd8756a29 100644 --- a/pkg/compose/convergence.go +++ b/pkg/compose/convergence.go @@ -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) diff --git a/pkg/compose/convergence_test.go b/pkg/compose/convergence_test.go index 637be02961..843689a112 100644 --- a/pkg/compose/convergence_test.go +++ b/pkg/compose/convergence_test.go @@ -353,7 +353,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{ diff --git a/pkg/compose/convert.go b/pkg/compose/convert.go index 17d5a90186..fd8fb7b3fb 100644 --- a/pkg/compose/convert.go +++ b/pkg/compose/convert.go @@ -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) } diff --git a/pkg/compose/create.go b/pkg/compose/create.go index 2f1ec38e2d..e4293cefbd 100644 --- a/pkg/compose/create.go +++ b/pkg/compose/create.go @@ -360,7 +360,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 +374,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 +527,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 +541,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) } } } @@ -861,8 +861,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) diff --git a/pkg/compose/images.go b/pkg/compose/images.go index f94eeba978..e322920e59 100644 --- a/pkg/compose/images.go +++ b/pkg/compose/images.go @@ -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 From ef14cfcfeaca1f9eb7d06d900b8fd3c14f820a3e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 9 Jan 2026 09:23:57 +0000 Subject: [PATCH 18/37] 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] --- go.mod | 6 +++--- go.sum | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index 9aee68f4e2..32a2b34ecf 100644 --- a/go.mod +++ b/go.mod @@ -56,7 +56,7 @@ require ( go.yaml.in/yaml/v4 v4.0.0-rc.3 golang.org/x/sync v0.19.0 golang.org/x/sys v0.40.0 - google.golang.org/grpc v1.77.0 + google.golang.org/grpc v1.78.0 gotest.tools/v3 v3.5.2 tags.cncf.io/container-device-interface v1.1.0 ) @@ -149,8 +149,8 @@ require ( 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 diff --git a/go.sum b/go.sum index 3d0bdfc778..0faf49ae47 100644 --- a/go.sum +++ b/go.sum @@ -532,13 +532,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= From f17d0dfc61570ea5e522d190cb2d26474f997171 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Jan 2026 09:10:46 +0000 Subject: [PATCH 19/37] 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] --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 32a2b34ecf..9b3c58ee99 100644 --- a/go.mod +++ b/go.mod @@ -22,7 +22,7 @@ require ( 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 diff --git a/go.sum b/go.sum index 0faf49ae47..146505ad48 100644 --- a/go.sum +++ b/go.sum @@ -143,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= From 63ae7eb0fabd20484936381a29d4f3c98227097e Mon Sep 17 00:00:00 2001 From: Adam Sven Johnson Date: Tue, 13 Jan 2026 10:39:07 +1300 Subject: [PATCH 20/37] 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 --- docs/sdk.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/sdk.md b/docs/sdk.md index 6e5a9b12ec..3a03c7fdc1 100644 --- a/docs/sdk.md +++ b/docs/sdk.md @@ -28,8 +28,8 @@ import ( "context" "log" - "github.com/docker/cli/cli/command" - "github.com/docker/cli/cli/flags" + "github.com/docker/cli/cli/command" + "github.com/docker/cli/cli/flags" "github.com/docker/compose/v5/pkg/api" "github.com/docker/compose/v5/pkg/compose" ) @@ -37,15 +37,15 @@ import ( func main() { ctx := context.Background() - dockerCLI, err := command.NewDockerCli() - if err != nil { - log.Fatalf("Failed to create docker CLI: %v", err) - } - err = dockerCLI.Initialize(&flags.ClientOptions{}) - if err != nil { - log.Fatalf("Failed to initialize docker CLI: %v", err) - } - + dockerCLI, err := command.NewDockerCli() + if err != nil { + log.Fatalf("Failed to create docker CLI: %v", err) + } + err = dockerCLI.Initialize(&flags.ClientOptions{}) + if err != nil { + log.Fatalf("Failed to initialize docker CLI: %v", err) + } + // Create a new Compose service instance service, err := compose.NewComposeService(dockerCLI) if err != nil { From 985680294547938082a78eb2d6be0aa8a1cb6c29 Mon Sep 17 00:00:00 2001 From: tensorworker Date: Tue, 13 Jan 2026 21:46:06 -0500 Subject: [PATCH 21/37] 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 --- cmd/compose/compose.go | 4 ++ internal/paths/paths.go | 24 +++++++++++ internal/paths/paths_test.go | 84 ++++++++++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+) create mode 100644 internal/paths/paths_test.go diff --git a/cmd/compose/compose.go b/cmd/compose/compose.go index 7796a1a6c2..5a3860bde1 100644 --- a/cmd/compose/compose.go +++ b/cmd/compose/compose.go @@ -46,6 +46,7 @@ import ( "github.com/docker/compose/v5/cmd/display" "github.com/docker/compose/v5/cmd/formatter" + "github.com/docker/compose/v5/internal/paths" "github.com/docker/compose/v5/internal/tracing" "github.com/docker/compose/v5/pkg/api" "github.com/docker/compose/v5/pkg/compose" @@ -550,12 +551,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 = paths.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 } } diff --git a/internal/paths/paths.go b/internal/paths/paths.go index 4e4c01b8cc..e247f68b28 100644 --- a/internal/paths/paths.go +++ b/internal/paths/paths.go @@ -22,6 +22,30 @@ import ( "strings" ) +// ExpandUser expands a leading tilde (~) in a path to the user's home directory. +// If the path doesn't start with ~, it is returned unchanged. +// If the home directory cannot be determined, the original path is returned. +func ExpandUser(path string) string { + if path == "" { + return path + } + if path[0] != '~' { + return path + } + if len(path) > 1 && path[1] != '/' && path[1] != filepath.Separator { + // ~otheruser/... syntax is not supported + return path + } + home, err := os.UserHomeDir() + if err != nil { + return path + } + if len(path) == 1 { + return home + } + return filepath.Join(home, path[2:]) +} + func IsChild(dir string, file string) bool { if dir == "" { return false diff --git a/internal/paths/paths_test.go b/internal/paths/paths_test.go new file mode 100644 index 0000000000..8488ea8e21 --- /dev/null +++ b/internal/paths/paths_test.go @@ -0,0 +1,84 @@ +/* + 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 paths + +import ( + "os" + "path/filepath" + "testing" + + "gotest.tools/v3/assert" +) + +func TestExpandUser(t *testing.T) { + home, err := os.UserHomeDir() + assert.NilError(t, err) + + tests := []struct { + name string + input string + expected string + }{ + { + name: "empty string", + input: "", + expected: "", + }, + { + name: "tilde only", + input: "~", + expected: home, + }, + { + name: "tilde with slash", + input: "~/.env", + expected: filepath.Join(home, ".env"), + }, + { + name: "tilde with subdir", + input: "~/subdir/.env", + expected: filepath.Join(home, "subdir", ".env"), + }, + { + name: "absolute path unchanged", + input: "/absolute/path/.env", + expected: "/absolute/path/.env", + }, + { + name: "relative path unchanged", + input: "relative/path/.env", + expected: "relative/path/.env", + }, + { + name: "tilde in middle unchanged", + input: "/path/~/file", + expected: "/path/~/file", + }, + { + name: "tilde other user unchanged", + input: "~otheruser/.env", + expected: "~otheruser/.env", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ExpandUser(tt.input) + assert.Equal(t, result, tt.expected) + }) + } +} From 02b606ef8e1189aa5bf61092006c51286b3b531a Mon Sep 17 00:00:00 2001 From: tensorworker Date: Wed, 14 Jan 2026 18:58:21 -0500 Subject: [PATCH 22/37] use go-compose instead Signed-off-by: tensorworker Signed-off-by: tensorworker --- cmd/compose/compose.go | 4 +- internal/paths/paths.go | 24 ----------- internal/paths/paths_test.go | 84 ------------------------------------ 3 files changed, 2 insertions(+), 110 deletions(-) delete mode 100644 internal/paths/paths_test.go diff --git a/cmd/compose/compose.go b/cmd/compose/compose.go index 5a3860bde1..300164ca52 100644 --- a/cmd/compose/compose.go +++ b/cmd/compose/compose.go @@ -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" @@ -46,7 +47,6 @@ import ( "github.com/docker/compose/v5/cmd/display" "github.com/docker/compose/v5/cmd/formatter" - "github.com/docker/compose/v5/internal/paths" "github.com/docker/compose/v5/internal/tracing" "github.com/docker/compose/v5/pkg/api" "github.com/docker/compose/v5/pkg/compose" @@ -551,7 +551,7 @@ 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 = paths.ExpandUser(file) + file = composepaths.ExpandUser(file) if !filepath.IsAbs(file) { file, err := filepath.Abs(file) if err != nil { diff --git a/internal/paths/paths.go b/internal/paths/paths.go index e247f68b28..4e4c01b8cc 100644 --- a/internal/paths/paths.go +++ b/internal/paths/paths.go @@ -22,30 +22,6 @@ import ( "strings" ) -// ExpandUser expands a leading tilde (~) in a path to the user's home directory. -// If the path doesn't start with ~, it is returned unchanged. -// If the home directory cannot be determined, the original path is returned. -func ExpandUser(path string) string { - if path == "" { - return path - } - if path[0] != '~' { - return path - } - if len(path) > 1 && path[1] != '/' && path[1] != filepath.Separator { - // ~otheruser/... syntax is not supported - return path - } - home, err := os.UserHomeDir() - if err != nil { - return path - } - if len(path) == 1 { - return home - } - return filepath.Join(home, path[2:]) -} - func IsChild(dir string, file string) bool { if dir == "" { return false diff --git a/internal/paths/paths_test.go b/internal/paths/paths_test.go deleted file mode 100644 index 8488ea8e21..0000000000 --- a/internal/paths/paths_test.go +++ /dev/null @@ -1,84 +0,0 @@ -/* - 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 paths - -import ( - "os" - "path/filepath" - "testing" - - "gotest.tools/v3/assert" -) - -func TestExpandUser(t *testing.T) { - home, err := os.UserHomeDir() - assert.NilError(t, err) - - tests := []struct { - name string - input string - expected string - }{ - { - name: "empty string", - input: "", - expected: "", - }, - { - name: "tilde only", - input: "~", - expected: home, - }, - { - name: "tilde with slash", - input: "~/.env", - expected: filepath.Join(home, ".env"), - }, - { - name: "tilde with subdir", - input: "~/subdir/.env", - expected: filepath.Join(home, "subdir", ".env"), - }, - { - name: "absolute path unchanged", - input: "/absolute/path/.env", - expected: "/absolute/path/.env", - }, - { - name: "relative path unchanged", - input: "relative/path/.env", - expected: "relative/path/.env", - }, - { - name: "tilde in middle unchanged", - input: "/path/~/file", - expected: "/path/~/file", - }, - { - name: "tilde other user unchanged", - input: "~otheruser/.env", - expected: "~otheruser/.env", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := ExpandUser(tt.input) - assert.Equal(t, result, tt.expected) - }) - } -} From 0a07df0e5b6d04b3c3ffece23568c0b0314c3306 Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Thu, 15 Jan 2026 11:19:03 +0100 Subject: [PATCH 23/37] 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 --- go.mod | 2 +- go.sum | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/go.mod b/go.mod index 9b3c58ee99..f1f341f415 100644 --- a/go.mod +++ b/go.mod @@ -38,7 +38,7 @@ require ( 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.2 github.com/spf13/pflag v1.0.10 diff --git a/go.sum b/go.sum index 146505ad48..ff01a5ce48 100644 --- a/go.sum +++ b/go.sum @@ -358,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= @@ -385,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= @@ -502,7 +501,6 @@ 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= From 2f108ffaa85b0b031d647b15597514af76f7dca1 Mon Sep 17 00:00:00 2001 From: Stavros Kois Date: Mon, 5 Jan 2026 11:44:07 +0200 Subject: [PATCH 24/37] handle healthcheck.disable true in isServiceHealthy Signed-off-by: Stavros Kois --- pkg/compose/convergence.go | 3 +- pkg/compose/convergence_test.go | 142 ++++++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+), 1 deletion(-) diff --git a/pkg/compose/convergence.go b/pkg/compose/convergence.go index fbd8756a29..3644c9cf35 100644 --- a/pkg/compose/convergence.go +++ b/pkg/compose/convergence.go @@ -833,7 +833,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 } diff --git a/pkg/compose/convergence_test.go b/pkg/compose/convergence_test.go index 843689a112..920587cd38 100644 --- a/pkg/compose/convergence_test.go +++ b/pkg/compose/convergence_test.go @@ -250,6 +250,148 @@ func TestWaitDependencies(t *testing.T) { }) } +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 := context.Background() + + 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") + }) +} + func TestCreateMobyContainer(t *testing.T) { t.Run("connects container networks one by one if API <1.44", func(t *testing.T) { mockCtrl := gomock.NewController(t) From c8d687599a425502116dac1de9c55d8ecc52bb77 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Thu, 15 Jan 2026 11:01:39 +0100 Subject: [PATCH 25/37] Fixed progress UI to adapt to terminal width Signed-off-by: Nicolas De Loof --- cmd/display/tty.go | 331 ++++++++++++++++++++++++------- cmd/display/tty_test.go | 424 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 682 insertions(+), 73 deletions(-) create mode 100644 cmd/display/tty_test.go diff --git a/cmd/display/tty.go b/cmd/display/tty.go index 04469cfd71..dd45ffd70a 100644 --- a/cmd/display/tty.go +++ b/cmd/display/tty.go @@ -21,9 +21,11 @@ import ( "fmt" "io" "iter" + "slices" "strings" "sync" "time" + "unicode/utf8" "github.com/buger/goterm" "github.com/docker/go-units" @@ -258,13 +260,39 @@ func (w *ttyWriter) childrenTasks(parent string) iter.Seq[*task] { } } +// 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() + up := w.numLines + 1 if !w.repeated { up-- @@ -283,39 +311,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 len(t.parents) == 0 && statusPadding < l { - statusPadding = l + // 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) } } - skipChildEvents := len(w.tasks) > goterm.Height()-2 + // shorten details/taskID to fit terminal width + w.adjustLineWidth(lines, timerLen, terminalWidth) + + // compute padding + w.applyPadding(lines, terminalWidth, timerLen) + + // Render lines numLines := 0 - for t := range w.parentTasks() { - line := w.lineText(t, "", terminalWidth, statusPadding, w.dryRun) - _, _ = fmt.Fprint(w.out, line) + for _, l := range lines { + _, _ = fmt.Fprint(w.out, lineText(l)) numLines++ - if skipChildEvents { - continue - } - for child := range w.childrenTasks(t.ID) { - line := w.lineText(child, " ", terminalWidth, statusPadding-2, w.dryRun) - _, _ = fmt.Fprint(w.out, line) - 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++ { - if numLines < goterm.Height()-2 { - _, _ = fmt.Fprintln(w.out, strings.Repeat(" ", terminalWidth)) - numLines++ - } + _, _ = 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 @@ -323,8 +520,9 @@ func (w *ttyWriter) lineText(t *task, pad string, terminalWidth, statusPadding i endTime = t.endTime } } + prefix := "" - if dryRun { + if w.dryRun { prefix = PrefixColor(DRYRUN_PREFIX) } @@ -338,11 +536,9 @@ func (w *ttyWriter) lineText(t *task, pad string, terminalWidth, statusPadding i ) // only show the aggregated progress while the root operation is in-progress - if parent := t; parent.status == api.Working { - for child := range w.childrenTasks(parent.ID) { + if t.status == api.Working { + for child := range w.childrenTasks(t.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 @@ -356,49 +552,49 @@ func (w *ttyWriter) lineText(t *task, pad string, terminalWidth, statusPadding i } } - // 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 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), + } +} - return o +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 ( @@ -443,17 +639,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 diff --git a/cmd/display/tty_test.go b/cmd/display/tty_test.go new file mode 100644 index 0000000000..875f5f029f --- /dev/null +++ b/cmd/display/tty_test.go @@ -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) + }) + } +} From 27bf40357a3b8d0e5b010606a4ceba8f31c4ea91 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Mon, 19 Jan 2026 16:28:34 +0100 Subject: [PATCH 26/37] Bump compose to v2.10.1 Signed-off-by: Nicolas De Loof --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index f1f341f415..90517e7024 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ 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 github.com/containerd/errdefs v1.0.0 diff --git a/go.sum b/go.sum index ff01a5ce48..45e35c62d0 100644 --- a/go.sum +++ b/go.sum @@ -46,8 +46,8 @@ 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/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= From 2672d342171821741304cd65471dc36258910f55 Mon Sep 17 00:00:00 2001 From: "hiroto.toyoda" Date: Thu, 1 Jan 2026 01:02:31 +0900 Subject: [PATCH 27/37] Improve error handling in attach.go Signed-off-by: hiroto.toyoda --- pkg/compose/attach.go | 67 ++++++++++++++++++++++++++++++++----------- 1 file changed, 51 insertions(+), 16 deletions(-) diff --git a/pkg/compose/attach.go b/pkg/compose/attach.go index 47b5888ff5..639e0ea53f 100644 --- a/pkg/compose/attach.go +++ b/pkg/compose/attach.go @@ -28,6 +28,7 @@ import ( 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 +50,11 @@ 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) + return nil, err + } for _, ctr := range containers { err := s.attachContainer(ctx, ctr, listener) @@ -57,7 +62,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,10 +96,21 @@ func (s *composeService) doAttachContainer(ctx context.Context, service, id, nam }) }) - _, _, err = s.attachContainerStreams(ctx, id, inspect.Config.Tty, nil, wOut, wErr) - return err + restore, detached, err := s.attachContainerStreams(ctx, id, inspect.Config.Tty, nil, wOut, wErr) + if err != nil { + return err + } + defer restore() + + go func() { + <-detached + logrus.Debugf("detached from container %s", name) + }() + + return nil } +//nolint:gocyclo 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 */ } @@ -106,7 +122,10 @@ func (s *composeService) attachContainerStreams(ctx context.Context, container s return restore, detached, err } restore = func() { - term.RestoreTerminal(in.FD(), state) //nolint:errcheck + err := term.RestoreTerminal(in.FD(), state) + if err != nil { + logrus.Warnf("failed to restore terminal: %v", err) + } } } } @@ -119,7 +138,10 @@ func (s *composeService) attachContainerStreams(ctx context.Context, container s go func() { <-ctx.Done() if stdin != nil { - stdin.Close() //nolint:errcheck + err := stdin.Close() + if err != nil { + logrus.Debugf("failed to close stdin: %v", err) + } } }() @@ -129,19 +151,34 @@ func (s *composeService) attachContainerStreams(ctx context.Context, container s var escapeErr term.EscapeError if errors.As(err, &escapeErr) { close(detached) + } else if err != nil && !errors.Is(err, io.EOF) { + logrus.Debugf("stdin copy error for container %s: %v", container, 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) } }() } @@ -149,8 +186,6 @@ func (s *composeService) attachContainerStreams(ctx context.Context, container s } func (s *composeService) getContainerStreams(ctx context.Context, container string) (io.WriteCloser, io.ReadCloser, error) { - var stdout io.ReadCloser - var stdin io.WriteCloser cnx, err := s.apiClient().ContainerAttach(ctx, container, containerType.AttachOptions{ Stream: true, Stdin: true, @@ -160,8 +195,8 @@ func (s *composeService) getContainerStreams(ctx context.Context, container stri DetachKeys: s.configFile().DetachKeys, }) if err == nil { - stdout = ContainerStdout{HijackedResponse: cnx} - stdin = ContainerStdin{HijackedResponse: cnx} + stdout := ContainerStdout{HijackedResponse: cnx} + stdin := ContainerStdin{HijackedResponse: cnx} return stdin, stdout, nil } @@ -174,5 +209,5 @@ func (s *composeService) getContainerStreams(ctx context.Context, container stri if err != nil { return nil, nil, err } - return stdin, logs, nil + return nil, logs, nil } From abd99be4fd093a282efc0adb7b7e9b06d57cceb3 Mon Sep 17 00:00:00 2001 From: "hiroto.toyoda" Date: Thu, 1 Jan 2026 01:22:59 +0900 Subject: [PATCH 28/37] refactor(attach): remove unused detach watcher and keep attach behavior Signed-off-by: hiroto.toyoda --- pkg/compose/attach.go | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/pkg/compose/attach.go b/pkg/compose/attach.go index 639e0ea53f..7e7460e258 100644 --- a/pkg/compose/attach.go +++ b/pkg/compose/attach.go @@ -53,7 +53,6 @@ func (s *composeService) attach(ctx context.Context, project *types.Project, lis _, err = fmt.Fprintf(s.stdout(), "Attaching to %s\n", strings.Join(names, ", ")) if err != nil { logrus.Debugf("failed to write attach message: %v", err) - return nil, err } for _, ctr := range containers { @@ -96,17 +95,12 @@ func (s *composeService) doAttachContainer(ctx context.Context, service, id, nam }) }) - restore, detached, err := s.attachContainerStreams(ctx, id, inspect.Config.Tty, nil, wOut, wErr) + restore, _, err := s.attachContainerStreams(ctx, id, inspect.Config.Tty, nil, wOut, wErr) if err != nil { return err } defer restore() - go func() { - <-detached - logrus.Debugf("detached from container %s", name) - }() - return nil } From 79d7a8acd6ae2b45e9f3462a985391bbbe98c157 Mon Sep 17 00:00:00 2001 From: "hiroto.toyoda" Date: Mon, 5 Jan 2026 23:59:19 +0900 Subject: [PATCH 29/37] refactor(attach): simplify attachContainerStreams signature Signed-off-by: hiroto.toyoda --- pkg/compose/attach.go | 54 ++++--------------------------------------- 1 file changed, 5 insertions(+), 49 deletions(-) diff --git a/pkg/compose/attach.go b/pkg/compose/attach.go index 7e7460e258..41b666bb43 100644 --- a/pkg/compose/attach.go +++ b/pkg/compose/attach.go @@ -24,10 +24,8 @@ 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" @@ -95,60 +93,18 @@ func (s *composeService) doAttachContainer(ctx context.Context, service, id, nam }) }) - restore, _, err := s.attachContainerStreams(ctx, id, inspect.Config.Tty, nil, wOut, wErr) + err = s.attachContainerStreams(ctx, id, inspect.Config.Tty, wOut, wErr) if err != nil { return err } - defer restore() return nil } -//nolint:gocyclo -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() { - err := term.RestoreTerminal(in.FD(), state) - if err != nil { - logrus.Warnf("failed to restore terminal: %v", err) - } - } - } - } - - 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 { - err := stdin.Close() - if err != nil { - logrus.Debugf("failed to close stdin: %v", err) - } - } - }() - - if streamIn != nil && stdin != nil { - go func() { - _, err := io.Copy(streamIn, stdin) - var escapeErr term.EscapeError - if errors.As(err, &escapeErr) { - close(detached) - } else if err != nil && !errors.Is(err, io.EOF) { - logrus.Debugf("stdin copy error for container %s: %v", container, err) - } - }() + return err } if stdout != nil { @@ -176,7 +132,7 @@ func (s *composeService) attachContainerStreams(ctx context.Context, container s } }() } - return restore, detached, nil + return nil } func (s *composeService) getContainerStreams(ctx context.Context, container string) (io.WriteCloser, io.ReadCloser, error) { From d7bdb34ff5667935539c0ea84a3636b117b4d185 Mon Sep 17 00:00:00 2001 From: "hiroto.toyoda" Date: Tue, 6 Jan 2026 21:05:08 +0900 Subject: [PATCH 30/37] refactor(attach): remove unused stdin from getContainerStream Signed-off-by: hiroto.toyoda --- pkg/compose/attach.go | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/pkg/compose/attach.go b/pkg/compose/attach.go index 41b666bb43..2ab07b1e99 100644 --- a/pkg/compose/attach.go +++ b/pkg/compose/attach.go @@ -102,7 +102,7 @@ func (s *composeService) doAttachContainer(ctx context.Context, service, id, nam } func (s *composeService) attachContainerStreams(ctx context.Context, container string, tty bool, stdout, stderr io.WriteCloser) error { - _, streamOut, err := s.getContainerStreams(ctx, container) + streamOut, err := s.getContainerStreams(ctx, container) if err != nil { return err } @@ -135,19 +135,17 @@ func (s *composeService) attachContainerStreams(ctx context.Context, container s return nil } -func (s *composeService) getContainerStreams(ctx context.Context, container string) (io.WriteCloser, io.ReadCloser, error) { +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 + return stdout, nil } // Fallback to logs API @@ -157,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 nil, logs, nil + return logs, nil } From 06e1287483f0988c33b3880882a7145af4252a1a Mon Sep 17 00:00:00 2001 From: "hiroto.toyoda" Date: Mon, 12 Jan 2026 21:45:59 +0900 Subject: [PATCH 31/37] fix: update github.com/moby/term to indirect dependency Signed-off-by: hiroto.toyoda --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 90517e7024..9c0183eb4c 100644 --- a/go.mod +++ b/go.mod @@ -33,7 +33,6 @@ require ( 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.1.0 github.com/opencontainers/go-digest v1.0.0 github.com/opencontainers/image-spec v1.1.1 @@ -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 From b92b87dd9c3abdd2baa87daafa6d9a94d66daaa1 Mon Sep 17 00:00:00 2001 From: Amol Yadav Date: Tue, 20 Jan 2026 12:44:01 +0530 Subject: [PATCH 32/37] 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 --- pkg/compose/watch.go | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/pkg/compose/watch.go b/pkg/compose/watch.go index 1307b426ee..77575bcdca 100644 --- a/pkg/compose/watch.go +++ b/pkg/compose/watch.go @@ -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)) } From 093205121cbe3cf8a05b5a02e024bf7185b3c970 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Tue, 20 Jan 2026 10:30:31 +0100 Subject: [PATCH 33/37] 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 --- cmd/compose/options_test.go | 12 +++--------- internal/desktop/client_test.go | 6 +----- pkg/compose/convergence_test.go | 21 ++++++++++----------- pkg/compose/create_test.go | 9 ++++----- pkg/compose/dependencies_test.go | 17 ++++------------- pkg/compose/down_test.go | 17 ++++++++--------- pkg/compose/images_test.go | 6 ++---- pkg/compose/kill_test.go | 10 ++++------ pkg/compose/logs_test.go | 11 ++++------- pkg/compose/ps_test.go | 6 ++---- pkg/compose/publish_test.go | 5 ++--- pkg/compose/stop_test.go | 4 +--- pkg/compose/viz_test.go | 7 ++----- pkg/compose/volumes_test.go | 11 ++++------- pkg/compose/watch_test.go | 2 +- pkg/e2e/cancel_test.go | 2 +- pkg/e2e/up_test.go | 2 +- pkg/watch/debounce_test.go | 2 +- pkg/watch/notify_test.go | 2 +- 19 files changed, 56 insertions(+), 96 deletions(-) diff --git a/cmd/compose/options_test.go b/cmd/compose/options_test.go index db6e8c0a87..1686dfaf77 100644 --- a/cmd/compose/options_test.go +++ b/cmd/compose/options_test.go @@ -18,7 +18,6 @@ package compose import ( "bytes" - "context" "fmt" "io" "os" @@ -244,16 +243,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) diff --git a/internal/desktop/client_test.go b/internal/desktop/client_test.go index 0355dd501a..abc4f33122 100644 --- a/internal/desktop/client_test.go +++ b/internal/desktop/client_test.go @@ -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) diff --git a/pkg/compose/convergence_test.go b/pkg/compose/convergence_test.go index 920587cd38..eb4c1adfbd 100644 --- a/pkg/compose/convergence_test.go +++ b/pkg/compose/convergence_test.go @@ -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,7 @@ 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)) }) } @@ -260,7 +259,7 @@ func TestIsServiceHealthy(t *testing.T) { assert.NilError(t, err) cli.EXPECT().Client().Return(apiClient).AnyTimes() - ctx := context.Background() + ctx := t.Context() t.Run("disabled healthcheck with fallback to running", func(t *testing.T) { containerID := "test-container-id" @@ -475,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) @@ -561,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) diff --git a/pkg/compose/create_test.go b/pkg/compose/create_test.go index a3cc540692..41cd3bcb8a 100644 --- a/pkg/compose/create_test.go +++ b/pkg/compose/create_test.go @@ -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) diff --git a/pkg/compose/dependencies_test.go b/pkg/compose/dependencies_test.go index 56e9298b50..b22b68b990 100644 --- a/pkg/compose/dependencies_test.go +++ b/pkg/compose/dependencies_test.go @@ -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) diff --git a/pkg/compose/down_test.go b/pkg/compose/down_test.go index 8966617504..0b5852a2fa 100644 --- a/pkg/compose/down_test.go +++ b/pkg/compose/down_test.go @@ -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) } diff --git a/pkg/compose/images_test.go b/pkg/compose/images_test.go index 9c0367d8d6..b6eb9cce13 100644 --- a/pkg/compose/images_test.go +++ b/pkg/compose/images_test.go @@ -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": { diff --git a/pkg/compose/kill_test.go b/pkg/compose/kill_test.go index b25dc48e6f..a70228a656 100644 --- a/pkg/compose/kill_test.go +++ b/pkg/compose/kill_test.go @@ -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) } diff --git a/pkg/compose/logs_test.go b/pkg/compose/logs_test.go index d2292a753f..b5823c908d 100644 --- a/pkg/compose/logs_test.go +++ b/pkg/compose/logs_test.go @@ -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")) diff --git a/pkg/compose/ps_test.go b/pkg/compose/ps_test.go index c76bfdfd23..b006d2f57f 100644 --- a/pkg/compose/ps_test.go +++ b/pkg/compose/ps_test.go @@ -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{ { diff --git a/pkg/compose/publish_test.go b/pkg/compose/publish_test.go index 52398af77c..8f91f663e6 100644 --- a/pkg/compose/publish_test.go +++ b/pkg/compose/publish_test.go @@ -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) diff --git a/pkg/compose/stop_test.go b/pkg/compose/stop_test.go index 2508be06bf..adecd1d615 100644 --- a/pkg/compose/stop_test.go +++ b/pkg/compose/stop_test.go @@ -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) diff --git a/pkg/compose/viz_test.go b/pkg/compose/viz_test.go index ae66de34f4..dad8bac36a 100644 --- a/pkg/compose/viz_test.go +++ b/pkg/compose/viz_test.go @@ -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, diff --git a/pkg/compose/volumes_test.go b/pkg/compose/volumes_test.go index 1fb99297a3..85a838eaee 100644 --- a/pkg/compose/volumes_test.go +++ b/pkg/compose/volumes_test.go @@ -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) diff --git a/pkg/compose/watch_test.go b/pkg/compose/watch_test.go index 560784cd5e..956ca4e27e 100644 --- a/pkg/compose/watch_test.go +++ b/pkg/compose/watch_test.go @@ -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{ diff --git a/pkg/e2e/cancel_test.go b/pkg/e2e/cancel_test.go index d3c64155d2..64f3ff609a 100644 --- a/pkg/e2e/cancel_test.go +++ b/pkg/e2e/cancel_test.go @@ -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. diff --git a/pkg/e2e/up_test.go b/pkg/e2e/up_test.go index b7011391c9..d34f2061e2 100644 --- a/pkg/e2e/up_test.go +++ b/pkg/e2e/up_test.go @@ -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) diff --git a/pkg/watch/debounce_test.go b/pkg/watch/debounce_test.go index fd1c40bbf2..39029f845c 100644 --- a/pkg/watch/debounce_test.go +++ b/pkg/watch/debounce_test.go @@ -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) diff --git a/pkg/watch/notify_test.go b/pkg/watch/notify_test.go index 9b68c4d050..15a4f59137 100644 --- a/pkg/watch/notify_test.go +++ b/pkg/watch/notify_test.go @@ -501,7 +501,7 @@ type notifyFixture struct { func newNotifyFixture(t *testing.T) *notifyFixture { out := bytes.NewBuffer(nil) - ctx, cancel := context.WithCancel(context.Background()) + ctx, cancel := context.WithCancel(t.Context()) nf := ¬ifyFixture{ ctx: ctx, cancel: cancel, From bcc0401e0e3e3b70bd9e28f46e8e2e7d6eb4b089 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Tue, 20 Jan 2026 10:32:02 +0100 Subject: [PATCH 34/37] 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 --- pkg/compose/loader_test.go | 48 +++++++++++--------------------------- pkg/watch/notify_test.go | 26 ++++++++++----------- 2 files changed, 26 insertions(+), 48 deletions(-) diff --git a/pkg/compose/loader_test.go b/pkg/compose/loader_test.go index 027235ef9a..8006d4169d 100644 --- a/pkg/compose/loader_test.go +++ b/pkg/compose/loader_test.go @@ -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"}, }) diff --git a/pkg/watch/notify_test.go b/pkg/watch/notify_test.go index 15a4f59137..be52ced3a3 100644 --- a/pkg/watch/notify_test.go +++ b/pkg/watch/notify_test.go @@ -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()) - - err = os.Setenv(WindowsBufferSizeEnvVar, "a") - 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, "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 { From 27faa3b84e659a3dc7fbf8639b77193387f79980 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Tue, 20 Jan 2026 10:33:54 +0100 Subject: [PATCH 35/37] 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 --- cmd/compose/options_test.go | 8 ++------ pkg/e2e/volumes_test.go | 5 +---- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/cmd/compose/options_test.go b/cmd/compose/options_test.go index 1686dfaf77..50f8275213 100644 --- a/cmd/compose/options_test.go +++ b/cmd/compose/options_test.go @@ -213,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 := ` @@ -230,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) diff --git a/pkg/e2e/volumes_test.go b/pkg/e2e/volumes_test.go index d018ed699d..df138130db 100644 --- a/pkg/e2e/volumes_test.go +++ b/pkg/e2e/volumes_test.go @@ -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") From 04b4a832dc48d1e4a9fa5793a6725cb3320c691d Mon Sep 17 00:00:00 2001 From: David Gageot Date: Tue, 20 Jan 2026 10:56:25 +0100 Subject: [PATCH 36/37] 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 --- .golangci.yml | 14 ++++++++++++++ pkg/compose/kill_test.go | 1 + 2 files changed, 15 insertions(+) diff --git a/.golangci.yml b/.golangci.yml index 48898c4571..7d75550fea 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -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 diff --git a/pkg/compose/kill_test.go b/pkg/compose/kill_test.go index a70228a656..f27606b7f2 100644 --- a/pkg/compose/kill_test.go +++ b/pkg/compose/kill_test.go @@ -127,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) From c428a77111d56683b823a47590eb08de5c59a162 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Tue, 20 Jan 2026 13:40:08 +0100 Subject: [PATCH 37/37] set fsnotify build tag when building for OSX Signed-off-by: Nicolas De Loof --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 24fa5c0561..3a81af9afd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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