Browse Source

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 <[email protected]>
Guillaume Lours 4 months ago
parent
commit
e1678c5c43

+ 32 - 0
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)
+}

+ 21 - 6
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
 }

+ 2 - 1
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,
 	}

+ 4 - 6
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,

+ 3 - 3
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, "${", "$${")
 		}

+ 3 - 4
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(":"),

+ 1 - 3
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,

+ 123 - 3
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 {

+ 38 - 0
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()
+}

+ 3 - 5
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)

+ 2 - 0
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 {

+ 1 - 1
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:
 			}