From deac463970fee84385d99e66ce06acc73858dfa4 Mon Sep 17 00:00:00 2001 From: Guillaume Lours <705411+glours@users.noreply.github.com> Date: Thu, 23 Oct 2025 16:10:30 +0200 Subject: [PATCH 1/2] Introduce abstractions to support SDK usage without requiring Docker CLI This commit prepares the Compose service for SDK usage by abstracting away the hard dependency on command.Cli. The Docker CLI remains the standard path for the CLI tool, but SDK users can now provide custom implementations of streams and context information. Signed-off-by: Guillaume Lours <705411+glours@users.noreply.github.com> --- pkg/api/context.go | 32 +++++++ pkg/api/io.go | 27 ++++-- pkg/compose/apiSocket.go | 3 +- pkg/compose/build.go | 10 +-- pkg/compose/build_bake.go | 6 +- pkg/compose/build_classic.go | 7 +- pkg/compose/commit.go | 4 +- pkg/compose/compose.go | 126 +++++++++++++++++++++++++++- pkg/compose/docker_cli_providers.go | 38 +++++++++ pkg/compose/export.go | 8 +- pkg/compose/shellout.go | 2 + pkg/compose/wait.go | 2 +- 12 files changed, 233 insertions(+), 32 deletions(-) create mode 100644 pkg/api/context.go create mode 100644 pkg/compose/docker_cli_providers.go diff --git a/pkg/api/context.go b/pkg/api/context.go new file mode 100644 index 0000000000..af49c5d243 --- /dev/null +++ b/pkg/api/context.go @@ -0,0 +1,32 @@ +/* + Copyright 2020 Docker Compose CLI authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package api + +// ContextInfo provides Docker context information for advanced scenarios +type ContextInfo interface { + // CurrentContext returns the name of the current Docker context + // Returns "default" for simple clients without context support + CurrentContext() string + + // ServerOSType returns the Docker daemon's operating system (linux/windows/darwin) + // Used for OS-specific compatibility checks + ServerOSType() string + + // BuildKitEnabled determines whether BuildKit should be used for builds + // Checks DOCKER_BUILDKIT env var, config, and daemon capabilities + BuildKitEnabled() (bool, error) +} diff --git a/pkg/api/io.go b/pkg/api/io.go index 6aa77eaf2c..44bf30088d 100644 --- a/pkg/api/io.go +++ b/pkg/api/io.go @@ -17,12 +17,27 @@ package api import ( - "github.com/docker/cli/cli/streams" + "io" ) -// Streams defines the standard streams (stdin, stdout, stderr) used by the CLI. -type Streams interface { - Out() *streams.Out - Err() *streams.Out - In() *streams.In +// OutputStream is a writable stream with terminal detection capabilities +type OutputStream interface { + io.Writer + + // IsTerminal returns true if the stream is connected to a terminal + IsTerminal() bool + + // FD returns the file descriptor for the stream + FD() uintptr +} + +// InputStream is a readable stream with terminal detection capabilities +type InputStream interface { + io.Reader + + // IsTerminal returns true if the stream is connected to a terminal + IsTerminal() bool + + // FD returns the file descriptor for the stream + FD() uintptr } diff --git a/pkg/compose/apiSocket.go b/pkg/compose/apiSocket.go index 1c347528e5..ddc7a02903 100644 --- a/pkg/compose/apiSocket.go +++ b/pkg/compose/apiSocket.go @@ -41,7 +41,7 @@ func (s *composeService) useAPISocket(project *types.Project) (*types.Project, e return project, nil } - if s.dockerCli.ServerInfo().OSType == "windows" { + if s.getContextInfo().ServerOSType() == "windows" { return nil, errors.New("use_api_socket can't be used with a Windows Docker Engine") } @@ -49,6 +49,7 @@ func (s *composeService) useAPISocket(project *types.Project) (*types.Project, e if err != nil { return nil, fmt.Errorf("resolving credentials failed: %w", err) } + newConfig := &configfile.ConfigFile{ AuthConfigs: creds, } diff --git a/pkg/compose/build.go b/pkg/compose/build.go index 4fa3fa3097..8e2b3e4714 100644 --- a/pkg/compose/build.go +++ b/pkg/compose/build.go @@ -29,10 +29,8 @@ import ( "github.com/containerd/platforms" "github.com/docker/buildx/build" "github.com/docker/buildx/builder" - "github.com/docker/buildx/store/storeutil" "github.com/docker/buildx/util/buildflags" xprogress "github.com/docker/buildx/util/progress" - "github.com/docker/cli/cli/command" cliopts "github.com/docker/cli/opts" "github.com/docker/compose/v2/internal/tracing" "github.com/docker/compose/v2/pkg/api" @@ -143,7 +141,7 @@ func (s *composeService) build(ctx context.Context, project *types.Project, opti } // Initialize buildkit nodes - buildkitEnabled, err := s.dockerCli.BuildKitEnabled() + buildkitEnabled, err := s.getContextInfo().BuildKitEnabled() if err != nil { return nil, err } @@ -384,7 +382,7 @@ func (s *composeService) getLocalImagesDigests(ctx context.Context, project *typ // // Finally, standard proxy variables based on the Docker client configuration are added, but will not overwrite // any values if already present. -func resolveAndMergeBuildArgs(dockerCli command.Cli, project *types.Project, service types.ServiceConfig, opts api.BuildOptions) types.MappingWithEquals { +func resolveAndMergeBuildArgs(proxyConfig map[string]string, project *types.Project, service types.ServiceConfig, opts api.BuildOptions) types.MappingWithEquals { result := make(types.MappingWithEquals). OverrideBy(service.Build.Args). OverrideBy(opts.Args). @@ -392,7 +390,7 @@ func resolveAndMergeBuildArgs(dockerCli command.Cli, project *types.Project, ser // proxy arguments do NOT override and should NOT have env resolution applied, // so they're handled last - for k, v := range storeutil.GetProxyConfig(dockerCli) { + for k, v := range proxyConfig { if _, ok := result[k]; !ok { v := v result[k] = &v @@ -502,7 +500,7 @@ func (s *composeService) toBuildOptions(project *types.Project, service types.Se CacheTo: build.CreateCaches(cacheTo), NoCache: service.Build.NoCache, Pull: service.Build.Pull, - BuildArgs: flatten(resolveAndMergeBuildArgs(s.dockerCli, project, service, options)), + BuildArgs: flatten(resolveAndMergeBuildArgs(s.getProxyConfig(), project, service, options)), Tags: tags, Target: service.Build.Target, Exports: exports, diff --git a/pkg/compose/build_bake.go b/pkg/compose/build_bake.go index fa682e62e8..a793151f32 100644 --- a/pkg/compose/build_bake.go +++ b/pkg/compose/build_bake.go @@ -139,10 +139,10 @@ func (s *composeService) doBuildBake(ctx context.Context, project *types.Project displayMode := progressui.DisplayMode(options.Progress) out := options.Out if out == nil { - if displayMode == progress.ModeAuto && !s.dockerCli.Out().IsTerminal() { + if displayMode == progress.ModeAuto && !s.stdout().IsTerminal() { displayMode = progressui.PlainMode } - out = os.Stdout // should be s.dockerCli.Out(), but NewDisplay require access to the underlying *File + out = os.Stdout // should be s.stdout(), but NewDisplay require access to the underlying *File } display, err := progressui.NewDisplay(out, displayMode) if err != nil { @@ -185,7 +185,7 @@ func (s *composeService) doBuildBake(ctx context.Context, project *types.Project build := *service.Build labels := getImageBuildLabels(project, service) - args := resolveAndMergeBuildArgs(s.dockerCli, project, service, options).ToMapping() + args := resolveAndMergeBuildArgs(s.getProxyConfig(), project, service, options).ToMapping() for k, v := range args { args[k] = strings.ReplaceAll(v, "${", "$${") } diff --git a/pkg/compose/build_classic.go b/pkg/compose/build_classic.go index a84929193e..fb3fd29671 100644 --- a/pkg/compose/build_classic.go +++ b/pkg/compose/build_classic.go @@ -28,7 +28,6 @@ import ( "github.com/compose-spec/compose-go/v2/types" "github.com/docker/cli/cli" - "github.com/docker/cli/cli/command" "github.com/docker/cli/cli/command/image/build" "github.com/docker/compose/v2/pkg/api" buildtypes "github.com/docker/docker/api/types/build" @@ -175,7 +174,7 @@ func (s *composeService) doBuildClassic(ctx context.Context, project *types.Proj RegistryToken: authConfig.RegistryToken, } } - buildOpts := imageBuildOptions(s.dockerCli, project, service, options) + buildOpts := imageBuildOptions(s.getProxyConfig(), project, service, options) imageName := api.GetImageNameOrDefault(service, project.Name) buildOpts.Tags = append(buildOpts.Tags, imageName) buildOpts.Dockerfile = relDockerfile @@ -215,7 +214,7 @@ func (s *composeService) doBuildClassic(ctx context.Context, project *types.Proj return imageID, nil } -func imageBuildOptions(dockerCli command.Cli, project *types.Project, service types.ServiceConfig, options api.BuildOptions) buildtypes.ImageBuildOptions { +func imageBuildOptions(proxyConfigs map[string]string, project *types.Project, service types.ServiceConfig, options api.BuildOptions) buildtypes.ImageBuildOptions { config := service.Build return buildtypes.ImageBuildOptions{ Version: buildtypes.BuilderV1, @@ -223,7 +222,7 @@ func imageBuildOptions(dockerCli command.Cli, project *types.Project, service ty NoCache: config.NoCache, Remove: true, PullParent: config.Pull, - BuildArgs: resolveAndMergeBuildArgs(dockerCli, project, service, options), + BuildArgs: resolveAndMergeBuildArgs(proxyConfigs, project, service, options), Labels: config.Labels, NetworkMode: config.Network, ExtraHosts: config.ExtraHosts.AsList(":"), diff --git a/pkg/compose/commit.go b/pkg/compose/commit.go index d466679108..0edac43356 100644 --- a/pkg/compose/commit.go +++ b/pkg/compose/commit.go @@ -40,8 +40,6 @@ func (s *composeService) commit(ctx context.Context, projectName string, options return err } - clnt := s.apiClient() - w := progress.ContextWriter(ctx) name := getCanonicalContainerName(ctr) @@ -65,7 +63,7 @@ func (s *composeService) commit(ctx context.Context, projectName string, options return nil } - response, err := clnt.ContainerCommit(ctx, ctr.ID, container.CommitOptions{ + response, err := s.apiClient().ContainerCommit(ctx, ctr.ID, container.CommitOptions{ Reference: options.Reference, Comment: options.Comment, Author: options.Author, diff --git a/pkg/compose/compose.go b/pkg/compose/compose.go index 45de82b50b..fd096f06c0 100644 --- a/pkg/compose/compose.go +++ b/pkg/compose/compose.go @@ -20,12 +20,14 @@ import ( "context" "errors" "fmt" + "io" "os" "strconv" "strings" "sync" "github.com/compose-spec/compose-go/v2/types" + "github.com/docker/buildx/store/storeutil" "github.com/docker/cli/cli/command" "github.com/docker/cli/cli/config/configfile" "github.com/docker/cli/cli/flags" @@ -53,7 +55,26 @@ func init() { type Option func(service *composeService) error -// NewComposeService create a local implementation of the compose.Compose API +// NewComposeService creates a Compose service using Docker CLI. +// This is the standard constructor that requires command.Cli for full functionality. +// +// Example usage: +// +// dockerCli, _ := command.NewDockerCli() +// service := NewComposeService(dockerCli) +// +// For advanced configuration with custom overrides, use ServiceOption functions: +// +// service := NewComposeService(dockerCli, +// WithPrompt(prompt.NewPrompt(cli.In(), cli.Out()).Confirm), +// WithOutputStream(customOut), +// WithErrorStream(customErr), +// WithInputStream(customIn)) +// +// Or set all streams at once: +// +// service := NewComposeService(dockerCli, +// WithStreams(customOut, customErr, customIn)) func NewComposeService(dockerCli command.Cli, options ...Option) (api.Compose, error) { s := &composeService{ dockerCli: dockerCli, @@ -76,6 +97,56 @@ func NewComposeService(dockerCli command.Cli, options ...Option) (api.Compose, e return s, nil } +// WithStreams sets custom I/O streams for output and interaction +func WithStreams(out, err api.OutputStream, in api.InputStream) Option { + return func(s *composeService) error { + s.outStream = out + s.errStream = err + s.inStream = in + return nil + } +} + +// WithOutputStream sets a custom output stream +func WithOutputStream(out api.OutputStream) Option { + return func(s *composeService) error { + s.outStream = out + return nil + } +} + +// WithErrorStream sets a custom error stream +func WithErrorStream(err api.OutputStream) Option { + return func(s *composeService) error { + s.errStream = err + return nil + } +} + +// WithInputStream sets a custom input stream +func WithInputStream(in api.InputStream) Option { + return func(s *composeService) error { + s.inStream = in + return nil + } +} + +// WithContextInfo sets custom Docker context information +func WithContextInfo(info api.ContextInfo) Option { + return func(s *composeService) error { + s.contextInfo = info + return nil + } +} + +// WithProxyConfig sets custom HTTP proxy configuration for builds +func WithProxyConfig(config map[string]string) Option { + return func(s *composeService) error{ + s.proxyConfig = config + return nil + } +} + // WithPrompt configure a UI component for Compose service to interact with user and confirm actions func WithPrompt(prompt Prompt) Option { return func(s *composeService) error { @@ -119,6 +190,13 @@ type composeService struct { // prompt is used to interact with user and confirm actions prompt Prompt + // Optional overrides for specific components (for SDK users) + outStream api.OutputStream + errStream api.OutputStream + inStream api.InputStream + contextInfo api.ContextInfo + proxyConfig map[string]string + clock clockwork.Clock maxConcurrency int dryRun bool @@ -144,23 +222,65 @@ func (s *composeService) configFile() *configfile.ConfigFile { return s.dockerCli.ConfigFile() } +// getContextInfo returns the context info - either custom override or dockerCli adapter +func (s *composeService) getContextInfo() api.ContextInfo { + if s.contextInfo != nil { + return s.contextInfo + } + return &dockerCliContextInfo{cli: s.dockerCli} +} + +// getProxyConfig returns the proxy config - either custom override or environment-based +func (s *composeService) getProxyConfig() map[string]string { + if s.proxyConfig != nil { + return s.proxyConfig + } + return storeutil.GetProxyConfig(s.dockerCli) +} + + func (s *composeService) stdout() *streams.Out { + // If stream overrides are provided, use them + if s.outStream != nil { + return streams.NewOut(s.outStream) + } return s.dockerCli.Out() } func (s *composeService) stdin() *streams.In { + // If stream overrides are provided, use them + if s.inStream != nil { + return streams.NewIn(&readCloserAdapter{r: s.inStream}) + } return s.dockerCli.In() } func (s *composeService) stderr() *streams.Out { + // If stream overrides are provided, use them + if s.errStream != nil { + return streams.NewOut(s.errStream) + } return s.dockerCli.Err() } func (s *composeService) stdinfo() *streams.Out { if stdioToStdout { - return s.dockerCli.Out() + return s.stdout() } - return s.dockerCli.Err() + return s.stderr() +} + +// readCloserAdapter adapts io.Reader to io.ReadCloser +type readCloserAdapter struct { + r io.Reader +} + +func (r *readCloserAdapter) Read(p []byte) (int, error) { + return r.r.Read(p) +} + +func (r *readCloserAdapter) Close() error { + return nil } func getCanonicalContainerName(c container.Summary) string { diff --git a/pkg/compose/docker_cli_providers.go b/pkg/compose/docker_cli_providers.go new file mode 100644 index 0000000000..207fa3e37a --- /dev/null +++ b/pkg/compose/docker_cli_providers.go @@ -0,0 +1,38 @@ +/* + Copyright 2020 Docker Compose CLI authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package compose + +import ( + "github.com/docker/cli/cli/command" +) + +// dockerCliContextInfo implements api.ContextInfo using Docker CLI +type dockerCliContextInfo struct { + cli command.Cli +} + +func (c *dockerCliContextInfo) CurrentContext() string { + return c.cli.CurrentContext() +} + +func (c *dockerCliContextInfo) ServerOSType() string { + return c.cli.ServerInfo().OSType +} + +func (c *dockerCliContextInfo) BuildKitEnabled() (bool, error) { + return c.cli.BuildKitEnabled() +} diff --git a/pkg/compose/export.go b/pkg/compose/export.go index 73161cf5f9..21dc8ad205 100644 --- a/pkg/compose/export.go +++ b/pkg/compose/export.go @@ -43,15 +43,13 @@ func (s *composeService) export(ctx context.Context, projectName string, options } if options.Output == "" { - if s.dockerCli.Out().IsTerminal() { + if s.stdout().IsTerminal() { return fmt.Errorf("output option is required when exporting to terminal") } } else if err := command.ValidateOutputPath(options.Output); err != nil { return fmt.Errorf("failed to export container: %w", err) } - clnt := s.apiClient() - w := progress.ContextWriter(ctx) name := getCanonicalContainerName(container) @@ -64,7 +62,7 @@ func (s *composeService) export(ctx context.Context, projectName string, options StatusText: "Exporting", }) - responseBody, err := clnt.ContainerExport(ctx, container.ID) + responseBody, err := s.apiClient().ContainerExport(ctx, container.ID) if err != nil { return err } @@ -82,7 +80,7 @@ func (s *composeService) export(ctx context.Context, projectName string, options if !s.dryRun { if options.Output == "" { - _, err := io.Copy(s.dockerCli.Out(), responseBody) + _, err := io.Copy(s.stdout(), responseBody) return err } else { writer, err := atomicwriter.New(options.Output, 0o600) diff --git a/pkg/compose/shellout.go b/pkg/compose/shellout.go index 29ba255b83..b7b90c1d8e 100644 --- a/pkg/compose/shellout.go +++ b/pkg/compose/shellout.go @@ -52,8 +52,10 @@ func (s *composeService) prepareShellOut(gctx context.Context, env types.Mapping func (s *composeService) propagateDockerEndpoint() ([]string, func(), error) { cleanup := func() {} env := types.Mapping{} + env[command.EnvOverrideContext] = s.dockerCli.CurrentContext() env["USER_AGENT"] = "compose/" + internal.Version + endpoint := s.dockerCli.DockerEndpoint() env[client.EnvOverrideHost] = endpoint.Host if endpoint.TLSData != nil { diff --git a/pkg/compose/wait.go b/pkg/compose/wait.go index 6cf88b0b3a..a2ee22948d 100644 --- a/pkg/compose/wait.go +++ b/pkg/compose/wait.go @@ -42,7 +42,7 @@ func (s *composeService) Wait(ctx context.Context, projectName string, options a select { case result := <-resultC: - _, _ = fmt.Fprintf(s.dockerCli.Out(), "container %q exited with status code %d\n", ctr.ID, result.StatusCode) + _, _ = fmt.Fprintf(s.stdout(), "container %q exited with status code %d\n", ctr.ID, result.StatusCode) statusCode = result.StatusCode case err = <-errC: } From 16e9ec747a229cd62eaecaceffddbed96e664c55 Mon Sep 17 00:00:00 2001 From: Guillaume Lours <705411+glours@users.noreply.github.com> Date: Fri, 24 Oct 2025 16:32:45 +0200 Subject: [PATCH 2/2] Add streamOverrideWrapper to intercepts command.Cli stream methods and transparently returns custom streams when provided via options Add new GetConfiguredStreams function to Compose API definition Signed-off-by: Guillaume Lours <705411+glours@users.noreply.github.com> --- cmd/compose/logs.go | 3 +- cmd/compose/up.go | 3 +- cmd/compose/watch.go | 3 +- pkg/api/api.go | 3 ++ pkg/compose/build.go | 2 +- pkg/compose/build_bake.go | 2 +- pkg/compose/compose.go | 75 +++++++++++++++++++++++++++++++-------- 7 files changed, 72 insertions(+), 19 deletions(-) diff --git a/cmd/compose/logs.go b/cmd/compose/logs.go index f0704540c1..4931d9dcad 100644 --- a/cmd/compose/logs.go +++ b/cmd/compose/logs.go @@ -86,11 +86,12 @@ func runLogs(ctx context.Context, dockerCli command.Cli, backendOptions *Backend } } - consumer := formatter.NewLogConsumer(ctx, dockerCli.Out(), dockerCli.Err(), !opts.noColor, !opts.noPrefix, false) backend, err := compose.NewComposeService(dockerCli, backendOptions.Options...) if err != nil { return err } + outStream, errStream, _ := backend.GetConfiguredStreams() + consumer := formatter.NewLogConsumer(ctx, outStream, errStream, !opts.noColor, !opts.noPrefix, false) return backend.Logs(ctx, name, consumer, api.LogOptions{ Project: project, Services: services, diff --git a/cmd/compose/up.go b/cmd/compose/up.go index a63aed46f8..08ea183728 100644 --- a/cmd/compose/up.go +++ b/cmd/compose/up.go @@ -293,7 +293,8 @@ func runUp( var consumer api.LogConsumer var attach []string if !upOptions.Detach { - consumer = formatter.NewLogConsumer(ctx, dockerCli.Out(), dockerCli.Err(), !upOptions.noColor, !upOptions.noPrefix, upOptions.timestamp) + outStream, errStream, _ := backend.GetConfiguredStreams() + consumer = formatter.NewLogConsumer(ctx, outStream, errStream, !upOptions.noColor, !upOptions.noPrefix, upOptions.timestamp) var attachSet utils.Set[string] if len(upOptions.attach) != 0 { diff --git a/cmd/compose/watch.go b/cmd/compose/watch.go index 580ddf5a71..68d9004e0d 100644 --- a/cmd/compose/watch.go +++ b/cmd/compose/watch.go @@ -121,11 +121,12 @@ func runWatch(ctx context.Context, dockerCli command.Cli, backendOptions *Backen } } - consumer := formatter.NewLogConsumer(ctx, dockerCli.Out(), dockerCli.Err(), false, false, false) backend, err := compose.NewComposeService(dockerCli, backendOptions.Options...) if err != nil { return err } + outStream, errStream, _ := backend.GetConfiguredStreams() + consumer := formatter.NewLogConsumer(ctx, outStream, errStream, false, false, false) return backend.Watch(ctx, project, api.WatchOptions{ Build: &build, LogTo: consumer, diff --git a/pkg/api/api.go b/pkg/api/api.go index ebb0a767bb..ec800c6cc7 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -99,6 +99,9 @@ type Compose interface { Generate(ctx context.Context, options GenerateOptions) (*types.Project, error) // Volumes executes the equivalent to a `docker volume ls` Volumes(ctx context.Context, project string, options VolumesOptions) ([]VolumesSummary, error) + // GetConfiguredStreams returns the configured I/O streams (stdout, stderr, stdin). + // If no custom streams were configured, it returns the dockerCli streams. + GetConfiguredStreams() (stdout io.Writer, stderr io.Writer, stdin io.Reader) } type VolumesOptions struct { diff --git a/pkg/compose/build.go b/pkg/compose/build.go index 8e2b3e4714..986761cd4f 100644 --- a/pkg/compose/build.go +++ b/pkg/compose/build.go @@ -177,7 +177,7 @@ func (s *composeService) build(ctx context.Context, project *types.Project, opti if options.Progress == progress.ModeAuto { options.Progress = os.Getenv("BUILDKIT_PROGRESS") } - w, err = xprogress.NewPrinter(progressCtx, os.Stdout, progressui.DisplayMode(options.Progress), + w, err = xprogress.NewPrinter(progressCtx, s.stdout(), progressui.DisplayMode(options.Progress), xprogress.WithDesc( fmt.Sprintf("building with %q instance using %s driver", b.Name, b.Driver), fmt.Sprintf("%s:%s", b.Driver, b.Name), diff --git a/pkg/compose/build_bake.go b/pkg/compose/build_bake.go index a793151f32..04fa8e6c50 100644 --- a/pkg/compose/build_bake.go +++ b/pkg/compose/build_bake.go @@ -142,7 +142,7 @@ func (s *composeService) doBuildBake(ctx context.Context, project *types.Project if displayMode == progress.ModeAuto && !s.stdout().IsTerminal() { displayMode = progressui.PlainMode } - out = os.Stdout // should be s.stdout(), but NewDisplay require access to the underlying *File + out = s.stdout() } display, err := progressui.NewDisplay(out, displayMode) if err != nil { diff --git a/pkg/compose/compose.go b/pkg/compose/compose.go index fd096f06c0..c44849d069 100644 --- a/pkg/compose/compose.go +++ b/pkg/compose/compose.go @@ -94,6 +94,12 @@ func NewComposeService(dockerCli command.Cli, options ...Option) (api.Compose, e return defaultValue, nil } } + + // If custom streams were provided, wrap the Docker CLI to use them + if s.outStream != nil || s.errStream != nil || s.inStream != nil { + s.dockerCli = s.wrapDockerCliWithStreams(dockerCli) + } + return s, nil } @@ -141,7 +147,7 @@ func WithContextInfo(info api.ContextInfo) Option { // WithProxyConfig sets custom HTTP proxy configuration for builds func WithProxyConfig(config map[string]string) Option { - return func(s *composeService) error{ + return func(s *composeService) error { s.proxyConfig = config return nil } @@ -238,28 +244,15 @@ func (s *composeService) getProxyConfig() map[string]string { return storeutil.GetProxyConfig(s.dockerCli) } - func (s *composeService) stdout() *streams.Out { - // If stream overrides are provided, use them - if s.outStream != nil { - return streams.NewOut(s.outStream) - } return s.dockerCli.Out() } func (s *composeService) stdin() *streams.In { - // If stream overrides are provided, use them - if s.inStream != nil { - return streams.NewIn(&readCloserAdapter{r: s.inStream}) - } return s.dockerCli.In() } func (s *composeService) stderr() *streams.Out { - // If stream overrides are provided, use them - if s.errStream != nil { - return streams.NewOut(s.errStream) - } return s.dockerCli.Err() } @@ -270,6 +263,11 @@ func (s *composeService) stdinfo() *streams.Out { return s.stderr() } +// GetConfiguredStreams returns the configured I/O streams (implements api.Compose interface) +func (s *composeService) GetConfiguredStreams() (io.Writer, io.Writer, io.Reader) { + return s.stdout(), s.stderr(), s.stdin() +} + // readCloserAdapter adapts io.Reader to io.ReadCloser type readCloserAdapter struct { r io.Reader @@ -283,6 +281,55 @@ func (r *readCloserAdapter) Close() error { return nil } +// wrapDockerCliWithStreams wraps the Docker CLI to intercept and override stream methods +func (s *composeService) wrapDockerCliWithStreams(baseCli command.Cli) command.Cli { + wrapper := &streamOverrideWrapper{ + Cli: baseCli, + } + + // Wrap custom streams in Docker CLI's stream types + if s.outStream != nil { + wrapper.outStream = streams.NewOut(s.outStream) + } + if s.errStream != nil { + wrapper.errStream = streams.NewOut(s.errStream) + } + if s.inStream != nil { + wrapper.inStream = streams.NewIn(&readCloserAdapter{r: s.inStream}) + } + + return wrapper +} + +// streamOverrideWrapper wraps command.Cli to override streams with custom implementations +type streamOverrideWrapper struct { + command.Cli + outStream *streams.Out + errStream *streams.Out + inStream *streams.In +} + +func (w *streamOverrideWrapper) Out() *streams.Out { + if w.outStream != nil { + return w.outStream + } + return w.Cli.Out() +} + +func (w *streamOverrideWrapper) Err() *streams.Out { + if w.errStream != nil { + return w.errStream + } + return w.Cli.Err() +} + +func (w *streamOverrideWrapper) In() *streams.In { + if w.inStream != nil { + return w.inStream + } + return w.Cli.In() +} + func getCanonicalContainerName(c container.Summary) string { if len(c.Names) == 0 { // corner case, sometime happens on removal. return short ID as a safeguard value