diff --git a/cmd/compose/logs.go b/cmd/compose/logs.go index f0704540c15..4931d9dcad9 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 a63aed46f85..08ea1837282 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 580ddf5a712..68d9004e0dc 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 ebb0a767bb1..ec800c6cc79 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/api/context.go b/pkg/api/context.go new file mode 100644 index 00000000000..af49c5d2433 --- /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 6aa77eaf2ca..44bf30088df 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 1c347528e50..ddc7a029030 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 4fa3fa3097c..986761cd4fe 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 } @@ -179,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), @@ -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 fa682e62e89..04fa8e6c501 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 = s.stdout() } 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 a84929193e5..fb3fd296719 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 d466679108e..0edac43356e 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 45de82b50b0..c44849d069c 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, @@ -73,9 +94,65 @@ 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 } +// 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 +196,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,6 +228,22 @@ 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 { return s.dockerCli.Out() } @@ -158,9 +258,76 @@ func (s *composeService) stderr() *streams.Out { func (s *composeService) stdinfo() *streams.Out { if stdioToStdout { - return s.dockerCli.Out() + return s.stdout() } - return s.dockerCli.Err() + 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 +} + +func (r *readCloserAdapter) Read(p []byte) (int, error) { + return r.r.Read(p) +} + +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 { diff --git a/pkg/compose/docker_cli_providers.go b/pkg/compose/docker_cli_providers.go new file mode 100644 index 00000000000..207fa3e37a7 --- /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 73161cf5f9f..21dc8ad2058 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 29ba255b83e..b7b90c1d8ea 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 6cf88b0b3a3..a2ee22948d4 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: }