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>
This commit is contained in:
Guillaume Lours 2025-10-23 16:10:30 +02:00 • committed by Nicolas De loof
parent 5924387e89
commit e1678c5c43
12 changed files with 233 additions and 32 deletions

32
pkg/api/context.go Normal file
View file

@ -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)
}

View file

@ -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
}

View file

@ -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,
}

View file

@ -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,

View file

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

View file

@ -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(":"),

View file

@ -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,

View file

@ -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 {

View file

@ -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()
}

View file

@ -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)

View file

@ -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 {

View file

@ -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:
}