From 2796c9cd448a5a71303d9568c06c499f5d7d3c25 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Mon, 17 Aug 2026 08:07:35 +0200 Subject: [PATCH 1/3] lint: replace gocyclo with gocognit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cognitive complexity (gocognit, threshold 30 — the linter default) fits this codebase better than raw cyclomatic complexity: it barely charges guard clauses and early returns, and penalizes nesting instead. As a result 8 of the 21 //nolint:gocyclo suppressions become unnecessary, while deeply nested functions gocyclo never flagged are now caught. The 19 functions still above the threshold keep a suppression, each marked FIXME to complete the migration by restructuring them. Co-Authored-By: Claude Fable 5 Signed-off-by: Nicolas De Loof --- .golangci.yml | 6 +++--- cmd/compose/compose.go | 5 ++++- cmd/compose/ps.go | 2 +- cmd/compose/up.go | 2 -- pkg/bridge/convert.go | 3 +++ pkg/compose/build_bake.go | 5 ++++- pkg/compose/build_classic.go | 4 +++- pkg/compose/convergence.go | 4 +++- pkg/compose/create.go | 7 ++++++- pkg/compose/down.go | 2 +- pkg/compose/logs.go | 3 +++ pkg/compose/monitor.go | 4 +++- pkg/compose/plugins.go | 2 +- pkg/compose/ps.go | 4 +++- pkg/compose/publish.go | 7 ++++++- pkg/compose/pull.go | 5 ++++- pkg/compose/remove.go | 2 +- pkg/compose/restart.go | 5 ++++- pkg/compose/up.go | 5 ++++- pkg/compose/viz_test.go | 3 +++ pkg/compose/watch.go | 10 +++++++--- pkg/remote/oci.go | 4 +++- 22 files changed, 70 insertions(+), 24 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index dda1822b1c6..1ba9cfd5f1d 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -9,8 +9,8 @@ linters: - errcheck - errorlint - forbidigo + - gocognit - gocritic - - gocyclo - gomodguard - govet - ineffassign @@ -63,8 +63,8 @@ linters: - diagnostic - opinionated - style - gocyclo: - min-complexity: 16 + gocognit: + min-complexity: 30 gomodguard: blocked: modules: diff --git a/cmd/compose/compose.go b/cmd/compose/compose.go index 1544c402e52..9f537caf831 100644 --- a/cmd/compose/compose.go +++ b/cmd/compose/compose.go @@ -434,7 +434,10 @@ func (o *BackendOptions) Add(option compose.Option) { } // RootCommand returns the compose command with its child commands -func RootCommand(dockerCli command.Cli, backendOptions *BackendOptions) *cobra.Command { //nolint:gocyclo +// FIXME(ndeloof) complete migration to gocognit +// +//nolint:gocognit +func RootCommand(dockerCli command.Cli, backendOptions *BackendOptions) *cobra.Command { opts := ProjectOptions{} var ( ansi string diff --git a/cmd/compose/ps.go b/cmd/compose/ps.go index 2528fccacfb..c9e61d6e09d 100644 --- a/cmd/compose/ps.go +++ b/cmd/compose/ps.go @@ -92,7 +92,7 @@ func psCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *Backend return psCmd } -func runPs(ctx context.Context, dockerCli command.Cli, backendOptions *BackendOptions, services []string, opts psOptions) error { //nolint:gocyclo +func runPs(ctx context.Context, dockerCli command.Cli, backendOptions *BackendOptions, services []string, opts psOptions) error { project, name, err := opts.projectOrName(ctx, dockerCli, services...) if err != nil { return err diff --git a/cmd/compose/up.go b/cmd/compose/up.go index abd4fd10d94..14e071ecbba 100644 --- a/cmd/compose/up.go +++ b/cmd/compose/up.go @@ -186,7 +186,6 @@ func upCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *Backend return upCmd } -//nolint:gocyclo func validateFlags(up *upOptions, create *createOptions) error { if up.waitTimeout < 0 { return fmt.Errorf("--wait-timeout must be a non-negative integer") @@ -228,7 +227,6 @@ func validateFlags(up *upOptions, create *createOptions) error { return nil } -//nolint:gocyclo func runUp( ctx context.Context, dockerCli command.Cli, diff --git a/pkg/bridge/convert.go b/pkg/bridge/convert.go index bdb48465aed..f4589afb1fb 100644 --- a/pkg/bridge/convert.go +++ b/pkg/bridge/convert.go @@ -162,6 +162,9 @@ func convert(ctx context.Context, dockerCli command.Cli, model map[string]any, o } // LoadAdditionalResources loads additional resources from the project, such as image references, secrets, configs and exposed ports +// FIXME(ndeloof) complete migration to gocognit +// +//nolint:gocognit func LoadAdditionalResources(ctx context.Context, dockerCLI command.Cli, project *types.Project) (*types.Project, error) { for name, service := range project.Services { imageName := api.GetImageNameOrDefault(service, project.Name) diff --git a/pkg/compose/build_bake.go b/pkg/compose/build_bake.go index 699f7da319a..0db63ee17b8 100644 --- a/pkg/compose/build_bake.go +++ b/pkg/compose/build_bake.go @@ -115,7 +115,10 @@ type buildStatus struct { Image string `json:"image.name"` } -func (s *composeService) doBuildBake(ctx context.Context, project *types.Project, serviceToBeBuild types.Services, options api.BuildOptions) (map[string]string, error) { //nolint:gocyclo +// FIXME(ndeloof) complete migration to gocognit +// +//nolint:gocognit +func (s *composeService) doBuildBake(ctx context.Context, project *types.Project, serviceToBeBuild types.Services, options api.BuildOptions) (map[string]string, error) { eg := errgroup.Group{} ch := make(chan *client.SolveStatus) displayMode := progressui.DisplayMode(options.Progress) diff --git a/pkg/compose/build_classic.go b/pkg/compose/build_classic.go index dba0e956ac7..cc8f12fea99 100644 --- a/pkg/compose/build_classic.go +++ b/pkg/compose/build_classic.go @@ -121,7 +121,9 @@ func (s *composeService) doBuildClassic(ctx context.Context, project *types.Proj return imageIDs, err } -//nolint:gocyclo +// FIXME(ndeloof) complete migration to gocognit +// +//nolint:gocognit func (s *composeService) doBuildImage(ctx context.Context, project *types.Project, service types.ServiceConfig, options api.BuildOptions) (string, error) { var ( buildCtx io.ReadCloser diff --git a/pkg/compose/convergence.go b/pkg/compose/convergence.go index 67917f20db1..59a39b60e71 100644 --- a/pkg/compose/convergence.go +++ b/pkg/compose/convergence.go @@ -153,7 +153,9 @@ func containerReasonEvents(containers Containers, eventFunc func(string, string) // ServiceConditionRunningOrHealthy is a service condition on status running or healthy const ServiceConditionRunningOrHealthy = "running_or_healthy" -//nolint:gocyclo +// FIXME(ndeloof) complete migration to gocognit +// +//nolint:gocognit func (s *composeService) waitDependencies(ctx context.Context, project *types.Project, dependant string, dependencies types.DependsOnConfig, containers Containers, timeout time.Duration) error { if timeout > 0 { withTimeout, cancelFunc := context.WithTimeout(ctx, timeout) diff --git a/pkg/compose/create.go b/pkg/compose/create.go index 9f4decfd401..e3a86b66375 100644 --- a/pkg/compose/create.go +++ b/pkg/compose/create.go @@ -249,7 +249,6 @@ func warnUnmanagedVolumes(project *types.Project, observed *ObservedState) { } } -//nolint:gocyclo func (s *composeService) getCreateConfigs(ctx context.Context, p *types.Project, service types.ServiceConfig, @@ -474,6 +473,9 @@ func getAliases(project *types.Project, service types.ServiceConfig, serviceInde return aliases } +// FIXME(ndeloof) complete migration to gocognit +// +//nolint:gocognit func createEndpointSettings(p *types.Project, service types.ServiceConfig, serviceIndex int, networkKey string, links []string, useNetworkAliases bool) (*network.EndpointSettings, error) { const ifname = "com.docker.network.endpoint.ifname" @@ -931,6 +933,9 @@ func getDependentServiceFromMode(mode string) string { return "" } +// FIXME(ndeloof) complete migration to gocognit +// +//nolint:gocognit func (s *composeService) buildContainerVolumes( ctx context.Context, p types.Project, diff --git a/pkg/compose/down.go b/pkg/compose/down.go index 9969c84e680..d3523b32fc1 100644 --- a/pkg/compose/down.go +++ b/pkg/compose/down.go @@ -41,7 +41,7 @@ func (s *composeService) Down(ctx context.Context, projectName string, options a }, "down", s.events) } -func (s *composeService) down(ctx context.Context, projectName string, options api.DownOptions) error { //nolint:gocyclo +func (s *composeService) down(ctx context.Context, projectName string, options api.DownOptions) error { resourceToRemove := false include := oneOffExclude diff --git a/pkg/compose/logs.go b/pkg/compose/logs.go index 46be4bd8b10..08b520ce95a 100644 --- a/pkg/compose/logs.go +++ b/pkg/compose/logs.go @@ -31,6 +31,9 @@ import ( "github.com/docker/compose/v5/pkg/utils" ) +// FIXME(ndeloof) complete migration to gocognit +// +//nolint:gocognit func (s *composeService) Logs( ctx context.Context, projectName string, diff --git a/pkg/compose/monitor.go b/pkg/compose/monitor.go index 8d8567c60f2..461b8643e08 100644 --- a/pkg/compose/monitor.go +++ b/pkg/compose/monitor.go @@ -53,7 +53,9 @@ func (c *monitor) withServices(services []string) { // Start runs monitor to detect application events and return after termination // -//nolint:gocyclo +// FIXME(ndeloof) complete migration to gocognit +// +//nolint:gocognit func (c *monitor) Start(ctx context.Context) error { // collect initial application container initialState, err := c.apiClient.ContainerList(ctx, client.ContainerListOptions{ diff --git a/pkg/compose/plugins.go b/pkg/compose/plugins.go index ba75d0d56f3..efdbd93c6ec 100644 --- a/pkg/compose/plugins.go +++ b/pkg/compose/plugins.go @@ -105,7 +105,7 @@ func (s *composeService) runPlugin(ctx context.Context, project *types.Project, return nil } -func (s *composeService) executePlugin(cmd *exec.Cmd, command string, service types.ServiceConfig) (pluginVariables, error) { //nolint:gocyclo +func (s *composeService) executePlugin(cmd *exec.Cmd, command string, service types.ServiceConfig) (pluginVariables, error) { var action string switch command { case "up": diff --git a/pkg/compose/ps.go b/pkg/compose/ps.go index 8988adf7428..af79619f503 100644 --- a/pkg/compose/ps.go +++ b/pkg/compose/ps.go @@ -28,7 +28,9 @@ import ( "github.com/docker/compose/v5/pkg/api" ) -//nolint:gocyclo +// FIXME(ndeloof) complete migration to gocognit +// +//nolint:gocognit func (s *composeService) Ps(ctx context.Context, projectName string, options api.PsOptions) ([]api.ContainerSummary, error) { projectName = strings.ToLower(projectName) oneOff := oneOffExclude diff --git a/pkg/compose/publish.go b/pkg/compose/publish.go index 53344ef3101..5598a2cf70b 100644 --- a/pkg/compose/publish.go +++ b/pkg/compose/publish.go @@ -51,7 +51,9 @@ func (s *composeService) Publish(ctx context.Context, project *types.Project, re }, "publish", s.events) } -//nolint:gocyclo +// FIXME(ndeloof) complete migration to gocognit +// +//nolint:gocognit func (s *composeService) publish(ctx context.Context, project *types.Project, repository string, options api.PublishOptions) error { project, err := project.WithProfiles([]string{"*"}) if err != nil { @@ -684,6 +686,9 @@ func (s *composeService) checkForBindMount(project *types.Project) map[string][] return allFindings } +// FIXME(ndeloof) complete migration to gocognit +// +//nolint:gocognit func (s *composeService) checkForSensitiveData(ctx context.Context, project *types.Project) ([]secrets.DetectedSecret, error) { var allFindings []secrets.DetectedSecret scan := scanner.NewDefaultScanner() diff --git a/pkg/compose/pull.go b/pkg/compose/pull.go index 3c2aac62373..486c2624f17 100644 --- a/pkg/compose/pull.go +++ b/pkg/compose/pull.go @@ -50,7 +50,10 @@ func (s *composeService) Pull(ctx context.Context, project *types.Project, optio }, "pull", s.events) } -func (s *composeService) pull(ctx context.Context, project *types.Project, opts api.PullOptions) error { //nolint:gocyclo +// FIXME(ndeloof) complete migration to gocognit +// +//nolint:gocognit +func (s *composeService) pull(ctx context.Context, project *types.Project, opts api.PullOptions) error { images, _, err := s.getLocalImagesDigests(ctx, project) if err != nil { return err diff --git a/pkg/compose/remove.go b/pkg/compose/remove.go index 017de4a0098..b9bf0f3368a 100644 --- a/pkg/compose/remove.go +++ b/pkg/compose/remove.go @@ -27,7 +27,7 @@ import ( "github.com/docker/compose/v5/pkg/api" ) -func (s *composeService) Remove(ctx context.Context, projectName string, options api.RemoveOptions) error { //nolint:gocyclo +func (s *composeService) Remove(ctx context.Context, projectName string, options api.RemoveOptions) error { projectName = strings.ToLower(projectName) if options.Stop { diff --git a/pkg/compose/restart.go b/pkg/compose/restart.go index 97d10b2dcb1..587e57095b7 100644 --- a/pkg/compose/restart.go +++ b/pkg/compose/restart.go @@ -34,7 +34,10 @@ func (s *composeService) Restart(ctx context.Context, projectName string, option }, "restart", s.events) } -func (s *composeService) restart(ctx context.Context, projectName string, options api.RestartOptions) error { //nolint:gocyclo +// FIXME(ndeloof) complete migration to gocognit +// +//nolint:gocognit +func (s *composeService) restart(ctx context.Context, projectName string, options api.RestartOptions) error { containers, err := s.getContainers(ctx, projectName, oneOffExclude, true) if err != nil { return err diff --git a/pkg/compose/up.go b/pkg/compose/up.go index b2bcb3f7dbb..074ea80975d 100644 --- a/pkg/compose/up.go +++ b/pkg/compose/up.go @@ -41,7 +41,10 @@ import ( "github.com/docker/compose/v5/pkg/api" ) -func (s *composeService) Up(ctx context.Context, project *types.Project, options api.UpOptions) error { //nolint:gocyclo +// FIXME(ndeloof) complete migration to gocognit +// +//nolint:gocognit +func (s *composeService) Up(ctx context.Context, project *types.Project, options api.UpOptions) error { err := Run(ctx, tracing.SpanWrapFunc("project/up", tracing.ProjectOptions(ctx, project), func(ctx context.Context) error { err := s.create(ctx, project, options.Create) if err != nil { diff --git a/pkg/compose/viz_test.go b/pkg/compose/viz_test.go index 9a56bc4dda0..d72d793a902 100644 --- a/pkg/compose/viz_test.go +++ b/pkg/compose/viz_test.go @@ -29,6 +29,9 @@ import ( "github.com/docker/compose/v5/pkg/mocks" ) +// FIXME(ndeloof) complete migration to gocognit +// +//nolint:gocognit func TestViz(t *testing.T) { project := types.Project{ Name: "viz-test", diff --git a/pkg/compose/watch.go b/pkg/compose/watch.go index ed5e84a5431..94d518c1953 100644 --- a/pkg/compose/watch.go +++ b/pkg/compose/watch.go @@ -186,7 +186,10 @@ func (r watchRule) Matches(event watch.FileEvent) *sync.PathMapping { } } -func (s *composeService) watch(ctx context.Context, project *types.Project, options api.WatchOptions) (func() error, error) { //nolint: gocyclo +// FIXME(ndeloof) complete migration to gocognit +// +//nolint:gocognit +func (s *composeService) watch(ctx context.Context, project *types.Project, options api.WatchOptions) (func() error, error) { var err error if project, err = project.WithSelectedServices(options.Services); err != nil { return nil, err @@ -529,7 +532,6 @@ func (t tarDockerClient) Untar(ctx context.Context, id string, archive io.ReadCl return err } -//nolint:gocyclo func (s *composeService) handleWatchBatch(ctx context.Context, project *types.Project, options api.WatchOptions, batch []watch.FileEvent, rules []watchRule, syncer sync.Syncer) error { var ( restart = map[string]bool{} @@ -776,7 +778,9 @@ func (s *composeService) initialSync(ctx context.Context, project *types.Project // Syncs files from develop.watch.path if they have been modified after the image has been created // -//nolint:gocyclo +// FIXME(ndeloof) complete migration to gocognit +// +//nolint:gocognit func (s *composeService) initialSyncFiles(ctx context.Context, project *types.Project, service types.ServiceConfig, trigger types.Trigger, ignore watch.PathMatcher) ([]*sync.PathMapping, error) { fi, err := os.Stat(trigger.Path) if err != nil { diff --git a/pkg/remote/oci.go b/pkg/remote/oci.go index a81b1644edb..cbc43edddc9 100644 --- a/pkg/remote/oci.go +++ b/pkg/remote/oci.go @@ -113,7 +113,9 @@ func (g *ociRemoteLoader) Accept(path string) bool { return strings.HasPrefix(path, OciPrefix) } -//nolint:gocyclo +// FIXME(ndeloof) complete migration to gocognit +// +//nolint:gocognit func (g *ociRemoteLoader) Load(ctx context.Context, path string) (string, error) { enabled, err := ociRemoteLoaderEnabled() if err != nil { From 5fd2c8ecbcee9e9009db7243c060fa916ed61c54 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Mon, 17 Aug 2026 08:41:03 +0200 Subject: [PATCH 2/3] build: split doBuildBake at its responsibility boundaries doBuildBake mixed five concerns in a 300-line body (cognitive complexity 88): progress display setup, translation of the project into a bake file definition, temp metadata file allocation, bake command construction, stderr rawjson streaming, and result collection. Each now lives in its own function; the driver reads as the sequence of those stages (cognitive complexity 17), and the FIXME suppression is gone. The bake variable was named to avoid shadowing the docker/cli 'build' package. No behavior change: prepareBakeBuild emits the same config, and the stderr loop keeps the decoder-per-line semantics. Co-Authored-By: Claude Fable 5 Signed-off-by: Nicolas De Loof --- pkg/compose/build_bake.go | 377 +++++++++++++++++++++----------------- 1 file changed, 210 insertions(+), 167 deletions(-) diff --git a/pkg/compose/build_bake.go b/pkg/compose/build_bake.go index 0db63ee17b8..b72789c6b0f 100644 --- a/pkg/compose/build_bake.go +++ b/pkg/compose/build_bake.go @@ -115,9 +115,18 @@ type buildStatus struct { Image string `json:"image.name"` } -// FIXME(ndeloof) complete migration to gocognit -// -//nolint:gocognit +// bakeBuild is everything derived from the project that doBuildBake needs to +// drive `buildx bake`: the bake file definition, plus the settings that travel +// as command arguments or environment variables rather than in the file. +type bakeBuild struct { + cfg bakeConfig + targetNames map[string]string // service name -> bake target name + expectedImages map[string]string // service name -> expected image + localPaths []string // local build contexts bake needs `--allow fs.read` for + privileged bool + secretsEnv []string +} + func (s *composeService) doBuildBake(ctx context.Context, project *types.Project, serviceToBeBuild types.Services, options api.BuildOptions) (map[string]string, error) { eg := errgroup.Group{} ch := make(chan *client.SolveStatus) @@ -138,103 +147,128 @@ func (s *composeService) doBuildBake(ctx context.Context, project *types.Project return err }) - cfg := bakeConfig{ - Groups: map[string]bakeGroup{}, - Targets: map[string]bakeTarget{}, + bake := s.prepareBakeBuild(project, serviceToBeBuild, options) + + cfgJSON, err := json.MarshalIndent(bake.cfg, "", " ") + if err != nil { + return nil, err } - var ( - group bakeGroup - privileged bool - read []string - expectedImages = make(map[string]string, len(serviceToBeBuild)) // service name -> expected image - targets = make(map[string]string, len(serviceToBeBuild)) // service name -> build target - ) + if options.Print { + _, err = fmt.Fprintln(s.stdout(), string(cfgJSON)) + return nil, err + } + logrus.Debugf("bake build config:\n%s", string(cfgJSON)) - // produce a unique ID for service used as bake target - for serviceName := range project.Services { - t := strings.ReplaceAll(serviceName, ".", "_") - for { - if _, ok := targets[serviceName]; !ok { - targets[serviceName] = t - break - } - t += "_" + metadataFile, err := bakeMetadataPath() + if err != nil { + return nil, err + } + defer func() { + _ = os.Remove(metadataFile) + }() + + buildx, err := s.getBuildxPlugin() + if err != nil { + return nil, err + } + args := bakeArgs(bake, metadataFile, options) + logrus.Debugf("Executing bake with args: %v", args) + + if s.dryRun { + return s.dryRunBake(bake.cfg), nil + } + cmd := exec.CommandContext(ctx, buildx.Path, args...) + + err = s.prepareShellOut(ctx, types.NewMapping(os.Environ()), cmd) + if err != nil { + return nil, err + } + endpoint, cleanup, err := s.propagateDockerEndpoint() + if err != nil { + return nil, err + } + defer cleanup() + cmd.Env = append(cmd.Env, endpoint...) + cmd.Env = append(cmd.Env, bake.secretsEnv...) + + cmd.Stdout = s.stdout() + cmd.Stdin = bytes.NewBuffer(cfgJSON) + pipe, err := cmd.StderrPipe() + if err != nil { + return nil, err + } + + err = cmd.Start() + if err != nil { + return nil, err + } + eg.Go(cmd.Wait) + + errMessage, err := forwardBakeStatus(pipe, ch) + if err != nil { + return nil, err + } + close(ch) // stop build progress UI + + err = eg.Wait() + if err != nil { + if len(errMessage) > 0 { + return nil, errors.New(strings.Join(errMessage, "\n")) } + return nil, fmt.Errorf("failed to execute bake: %w", err) } - var secretsEnv []string + return s.collectBakeResults(ctx, metadataFile, serviceToBeBuild, bake) +} + +// prepareBakeBuild translates the project's build configuration into a bake +// file definition and the side-band settings bake takes on its command line. +func (s *composeService) prepareBakeBuild(project *types.Project, serviceToBeBuild types.Services, options api.BuildOptions) *bakeBuild { + bake := &bakeBuild{ + cfg: bakeConfig{ + Groups: map[string]bakeGroup{}, + Targets: map[string]bakeTarget{}, + }, + targetNames: bakeTargetNames(project), + expectedImages: make(map[string]string, len(serviceToBeBuild)), + } + + // project.Services lists every service (we still need their bake targets + // defined so additional_contexts: service:xxx references can resolve), + // but only emit "Building" progress and track expected images for + // services we actually plan to build. for serviceName, service := range project.Services { if service.Build == nil { continue } buildConfig := *service.Build - labels := getImageBuildLabels(project, service) args := resolveAndMergeBuildArgs(s.getProxyConfig(), project, service, options).ToMapping() for k, v := range args { args[k] = strings.ReplaceAll(v, "${", "$${") } - entitlements := buildConfig.Entitlements - if slices.Contains(buildConfig.Entitlements, "security.insecure") { - privileged = true - } - if buildConfig.Privileged { - entitlements = append(entitlements, "security.insecure") - privileged = true - } - - var outputs []string - var call string - push := options.Push && service.Image != "" - switch { - case options.Check: - call = "lint" - case len(service.Build.Platforms) > 1: - outputs = []string{fmt.Sprintf("type=image,push=%t", push)} - default: - if push { - outputs = []string{"type=registry"} - } else { - outputs = []string{"type=docker"} - } - } - - if _, _, err := gitutil.ParseGitRef(buildConfig.Context); !strings.Contains(buildConfig.Context, "://") && err != nil { - read = append(read, buildConfig.Context) - } - for _, path := range buildConfig.AdditionalContexts { - _, _, err := gitutil.ParseGitRef(path) - if !strings.Contains(path, "://") && err != nil { - read = append(read, path) - } - } + entitlements, privileged := bakeEntitlements(buildConfig) + bake.privileged = bake.privileged || privileged + bake.localPaths = append(bake.localPaths, localBuildPaths(buildConfig)...) image := api.GetImageNameOrDefault(service, project.Name) - // project.Services lists every service (we still need their bake - // targets defined so additional_contexts: service:xxx references can - // resolve), but only emit "Building" progress and track expected - // images for services we actually plan to build. if _, ok := serviceToBeBuild[serviceName]; ok { s.events.On(buildingEvent(image)) - expectedImages[serviceName] = image + bake.expectedImages[serviceName] = image } - pull := service.Build.Pull || options.Pull - noCache := service.Build.NoCache || options.NoCache - - target := targets[serviceName] - secrets, env := toBakeSecrets(project, buildConfig.Secrets) - secretsEnv = append(secretsEnv, env...) + bake.secretsEnv = append(bake.secretsEnv, env...) - cfg.Targets[target] = bakeTarget{ + outputs, call := bakeOutputs(service, options) + bake.cfg.Targets[bake.targetNames[serviceName]] = bakeTarget{ Context: buildConfig.Context, - Contexts: additionalContexts(buildConfig.AdditionalContexts, targets), + Contexts: additionalContexts(buildConfig.AdditionalContexts, bake.targetNames), Dockerfile: dockerFilePath(buildConfig.Context, buildConfig.Dockerfile), DockerfileInline: strings.ReplaceAll(buildConfig.DockerfileInline, "${", "$${"), Args: args, - Labels: labels, + Labels: getImageBuildLabels(project, service), Tags: append(buildConfig.Tags, image), CacheFrom: buildConfig.CacheFrom, @@ -245,8 +279,8 @@ func (s *composeService) doBuildBake(ctx context.Context, project *types.Project Target: buildConfig.Target, Secrets: secrets, SSH: toBakeSSH(append(buildConfig.SSH, options.SSHs...)), - Pull: pull, - NoCache: noCache, + Pull: buildConfig.Pull || options.Pull, + NoCache: buildConfig.NoCache || options.NoCache, ShmSize: buildConfig.ShmSize, Ulimits: toBakeUlimits(buildConfig.Ulimits), Entitlements: entitlements, @@ -259,57 +293,105 @@ func (s *composeService) doBuildBake(ctx context.Context, project *types.Project } // create a bake group with targets for services to build + var group bakeGroup for serviceName, service := range serviceToBeBuild { if service.Build == nil { continue } - group.Targets = append(group.Targets, targets[serviceName]) + group.Targets = append(group.Targets, bake.targetNames[serviceName]) } + bake.cfg.Groups["default"] = group - cfg.Groups["default"] = group + return bake +} - b, err := json.MarshalIndent(cfg, "", " ") - if err != nil { - return nil, err +// bakeTargetNames produces a unique ID for each service, used as bake target +func bakeTargetNames(project *types.Project) map[string]string { + targets := make(map[string]string, len(project.Services)) + for serviceName := range project.Services { + t := strings.ReplaceAll(serviceName, ".", "_") + for { + if _, ok := targets[serviceName]; !ok { + targets[serviceName] = t + break + } + t += "_" + } } + return targets +} - if options.Print { - _, err = fmt.Fprintln(s.stdout(), string(b)) - return nil, err +// bakeEntitlements returns the entitlements for a build target, and whether +// bake must be granted `security.insecure`. +func bakeEntitlements(buildConfig types.BuildConfig) ([]string, bool) { + entitlements := buildConfig.Entitlements + privileged := slices.Contains(buildConfig.Entitlements, "security.insecure") + if buildConfig.Privileged { + entitlements = append(entitlements, "security.insecure") + privileged = true } - logrus.Debugf("bake build config:\n%s", string(b)) + return entitlements, privileged +} - tmpdir := os.TempDir() - var metadataFile string - for { - // we don't use os.CreateTemp here as we need a temporary file name, but don't want it actually created - // as bake relies on atomicwriter and this creates conflict during rename - metadataFile = filepath.Join(tmpdir, fmt.Sprintf("compose-build-metadataFile-%s.json", uuid.New().String())) - if _, err = os.Stat(metadataFile); err != nil { - if os.IsNotExist(err) { - break - } - var pathError *fs.PathError - if errors.As(err, &pathError) { - return nil, fmt.Errorf("can't access os.tempDir %s: %w", tmpdir, pathError.Err) - } +// bakeOutputs selects the bake output type — or the lint call for `--check` — +// for a service build. +func bakeOutputs(service types.ServiceConfig, options api.BuildOptions) (outputs []string, call string) { + push := options.Push && service.Image != "" + switch { + case options.Check: + return nil, "lint" + case len(service.Build.Platforms) > 1: + return []string{fmt.Sprintf("type=image,push=%t", push)}, "" + case push: + return []string{"type=registry"}, "" + default: + return []string{"type=docker"}, "" + } +} + +// localBuildPaths returns the build context paths that live on the local +// filesystem — remote (git or URL) contexts need no fs.read entitlement. +func localBuildPaths(buildConfig types.BuildConfig) []string { + paths := []string{buildConfig.Context} + for _, path := range buildConfig.AdditionalContexts { + paths = append(paths, path) + } + var local []string + for _, path := range paths { + if _, _, err := gitutil.ParseGitRef(path); !strings.Contains(path, "://") && err != nil { + local = append(local, path) } } - defer func() { - _ = os.Remove(metadataFile) - }() + return local +} - buildx, err := s.getBuildxPlugin() - if err != nil { - return nil, err +// bakeMetadataPath picks a fresh temporary path for bake's --metadata-file. +// We don't use os.CreateTemp here as we need a temporary file name, but don't +// want it actually created, as bake relies on atomicwriter and this creates +// conflict during rename. +func bakeMetadataPath() (string, error) { + tmpdir := os.TempDir() + for { + metadataFile := filepath.Join(tmpdir, fmt.Sprintf("compose-build-metadataFile-%s.json", uuid.New().String())) + _, err := os.Stat(metadataFile) + if os.IsNotExist(err) { + return metadataFile, nil + } + var pathError *fs.PathError + if errors.As(err, &pathError) { + return "", fmt.Errorf("can't access os.tempDir %s: %w", tmpdir, pathError.Err) + } } +} +// bakeArgs assembles the buildx bake command line. +func bakeArgs(bake *bakeBuild, metadataFile string, options api.BuildOptions) []string { args := []string{"bake", "--file", "-", "--progress", "rawjson", "--metadata-file", metadataFile} // FIXME we should prompt user about this, but this is a breaking change in UX - for _, path := range read { + for _, path := range bake.localPaths { args = append(args, "--allow", "fs.read="+path) } - if privileged { + if bake.privileged { args = append(args, "--allow", "security.insecure") } if options.SBOM != "" { @@ -318,90 +400,52 @@ func (s *composeService) doBuildBake(ctx context.Context, project *types.Project if options.Provenance != "" { args = append(args, "--provenance="+options.Provenance) } - if options.Builder != "" { args = append(args, "--builder", options.Builder) } if options.Quiet { args = append(args, "--progress=quiet") } + return args +} - logrus.Debugf("Executing bake with args: %v", args) - - if s.dryRun { - return s.dryRunBake(cfg), nil - } - cmd := exec.CommandContext(ctx, buildx.Path, args...) - - err = s.prepareShellOut(ctx, types.NewMapping(os.Environ()), cmd) - if err != nil { - return nil, err - } - endpoint, cleanup, err := s.propagateDockerEndpoint() - if err != nil { - return nil, err - } - cmd.Env = append(cmd.Env, endpoint...) - cmd.Env = append(cmd.Env, secretsEnv...) - defer cleanup() - - cmd.Stdout = s.stdout() - cmd.Stdin = bytes.NewBuffer(b) - pipe, err := cmd.StderrPipe() - if err != nil { - return nil, err - } - +// forwardBakeStatus reads bake's rawjson stderr stream, forwarding solve +// statuses to the progress UI channel. Lines that are not solve statuses are +// collected as error messages, to be reported if bake exits non-zero. +func forwardBakeStatus(pipe io.Reader, ch chan<- *client.SolveStatus) ([]string, error) { var errMessage []string reader := bufio.NewReader(pipe) - - err = cmd.Start() - if err != nil { - return nil, err - } - eg.Go(cmd.Wait) for { line, readErr := reader.ReadString('\n') + if readErr == io.EOF { + return errMessage, nil + } + if errors.Is(readErr, os.ErrClosed) { + logrus.Debugf("bake stopped") + return errMessage, nil + } if readErr != nil { - if readErr == io.EOF { - break - } - if errors.Is(readErr, os.ErrClosed) { - logrus.Debugf("bake stopped") - break - } return nil, fmt.Errorf("failed to execute bake: %w", readErr) } decoder := json.NewDecoder(strings.NewReader(line)) var status client.SolveStatus - err := decoder.Decode(&status) - if err != nil { - if strings.HasPrefix(line, "ERROR: ") { - errMessage = append(errMessage, line[7:]) - } else { - errMessage = append(errMessage, line) - } + if err := decoder.Decode(&status); err != nil { + errMessage = append(errMessage, strings.TrimPrefix(line, "ERROR: ")) continue } ch <- &status } - close(ch) // stop build progress UI - - err = eg.Wait() - if err != nil { - if len(errMessage) > 0 { - return nil, errors.New(strings.Join(errMessage, "\n")) - } - return nil, fmt.Errorf("failed to execute bake: %w", err) - } +} - b, err = os.ReadFile(metadataFile) +// collectBakeResults reads bake's metadata file and maps each built image to +// its canonical digest. +func (s *composeService) collectBakeResults(ctx context.Context, metadataFile string, serviceToBeBuild types.Services, bake *bakeBuild) (map[string]string, error) { + raw, err := os.ReadFile(metadataFile) if err != nil { return nil, err } - var md bakeMetadata - err = json.Unmarshal(b, &md) + err = json.Unmarshal(raw, &md) if err != nil { return nil, err } @@ -414,9 +458,8 @@ func (s *composeService) doBuildBake(ctx context.Context, project *types.Project // set — so unchanged rebuilds don't recreate containers. results := map[string]string{} for name, service := range serviceToBeBuild { - image := expectedImages[name] - target := targets[name] - built, ok := md[target] + image := bake.expectedImages[name] + built, ok := md[bake.targetNames[name]] if !ok { return nil, fmt.Errorf("build result not found in Bake metadata for service %s", name) } From eeb025cf7b940d2ceace7c1895da7da0b8162c0c Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Mon, 17 Aug 2026 08:43:18 +0200 Subject: [PATCH 3/3] lint: explicitly disable gocyclo so it doesn't sneak back in gocognit replaced gocyclo on purpose: raw cyclomatic complexity charges a flat guard clause the same as a deeply nested branch, so our fail-fast style accumulated //nolint suppressions on functions that are long but flat. Cognitive complexity penalizes nesting and barely charges early returns, which keeps the linter's signal on genuinely tangled code instead. With 'default: none' the disable entry is functionally redundant, but it records that decision exactly where someone would re-add the linter, with a hard stop: golangci-lint rejects a config listing the same linter in both enable and disable. Co-Authored-By: Claude Fable 5 Signed-off-by: Nicolas De Loof --- .golangci.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.golangci.yml b/.golangci.yml index 1ba9cfd5f1d..b143fa85a0b 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -3,6 +3,13 @@ run: concurrency: 2 linters: default: none + disable: + # gocognit deliberately replaced gocyclo — do not re-enable. Cyclomatic + # complexity charges a flat guard clause the same as a nested branch, so + # our fail-fast style accumulated //nolint suppressions on functions that + # are long but flat. Cognitive complexity penalizes nesting and barely + # charges early returns, keeping the signal on genuinely tangled code. + - gocyclo enable: - copyloopvar - depguard