From e0bd8fb0e0cf3c3a5230eea50405e5cf7a145c90 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Tue, 27 Oct 2026 10:00:04 +0100 Subject: [PATCH 1/2] refactor(containerops): move exec, cp, export and commit to internal/containerops These four operations act from the outside on the container of a service. They only need the Docker client, the concurrency bound, the event bus, the dry-run switch, the Docker CLI (which owns the terminal streams) and the two container lookups, so they move out of the flat pkg/compose package into internal/containerops behind a Backend interface declaring exactly that. composeService implements the interface in a new file and keeps Exec, Copy, Export and Commit as one-line forwarders (the progress wrapper of the last three stays there), so no caller changes. The now unused checkRelayTarget forwarder is dropped. Behavior is unchanged: the cp test still runs through the forwarders. Part of #14396 Signed-off-by: Nicolas De Loof --- .../containerops}/commit.go | 27 +++--- internal/containerops/containerops.go | 80 ++++++++++++++++ {pkg/compose => internal/containerops}/cp.go | 58 ++++++------ .../compose => internal/containerops}/exec.go | 13 ++- .../containerops}/export.go | 32 +++---- pkg/compose/containerops.go | 91 +++++++++++++++++++ pkg/compose/cp_test.go | 2 +- pkg/compose/relay.go | 4 - 8 files changed, 235 insertions(+), 72 deletions(-) rename {pkg/compose => internal/containerops}/commit.go (64%) create mode 100644 internal/containerops/containerops.go rename {pkg/compose => internal/containerops}/cp.go (80%) rename {pkg/compose => internal/containerops}/exec.go (70%) rename {pkg/compose => internal/containerops}/export.go (64%) create mode 100644 pkg/compose/containerops.go diff --git a/pkg/compose/commit.go b/internal/containerops/commit.go similarity index 64% rename from pkg/compose/commit.go rename to internal/containerops/commit.go index bfc2b413db0..ebc9b48fc37 100644 --- a/pkg/compose/commit.go +++ b/internal/containerops/commit.go @@ -14,7 +14,7 @@ limitations under the License. */ -package compose +package containerops import ( "context" @@ -22,33 +22,30 @@ import ( "github.com/moby/moby/client" + "github.com/docker/compose/v5/internal/inspect" + "github.com/docker/compose/v5/internal/naming" "github.com/docker/compose/v5/pkg/api" ) -func (s *composeService) Commit(ctx context.Context, projectName string, options api.CommitOptions) error { - return Run(ctx, func(ctx context.Context) error { - return s.commit(ctx, projectName, options) - }, "commit", s.events) -} - -func (s *composeService) commit(ctx context.Context, projectName string, options api.CommitOptions) error { +// Commit creates an image from the changes made to a container of a service. +func (s *Service) Commit(ctx context.Context, projectName string, options api.CommitOptions) error { projectName = strings.ToLower(projectName) - ctr, err := s.getSpecifiedContainer(ctx, projectName, oneOffInclude, false, options.Service, options.Index) + ctr, err := s.backend.SpecifiedContainer(ctx, projectName, inspect.OneOffInclude, false, options.Service, options.Index) if err != nil { return err } - name := getCanonicalContainerName(ctr) + name := naming.CanonicalContainerName(ctr) - s.events.On(api.Resource{ + s.backend.Events().On(api.Resource{ ID: name, Status: api.Working, Text: api.StatusCommitting, }) - if s.dryRun { - s.events.On(api.Resource{ + if s.backend.DryRun() { + s.backend.Events().On(api.Resource{ ID: name, Status: api.Done, Text: api.StatusCommitted, @@ -57,7 +54,7 @@ func (s *composeService) commit(ctx context.Context, projectName string, options return nil } - response, err := s.apiClient().ContainerCommit(ctx, ctr.ID, client.ContainerCommitOptions{ + response, err := s.backend.APIClient().ContainerCommit(ctx, ctr.ID, client.ContainerCommitOptions{ Reference: options.Reference, Comment: options.Comment, Author: options.Author, @@ -68,7 +65,7 @@ func (s *composeService) commit(ctx context.Context, projectName string, options return err } - s.events.On(api.Resource{ + s.backend.Events().On(api.Resource{ ID: name, Text: "Committed as " + response.ID, Status: api.Done, diff --git a/internal/containerops/containerops.go b/internal/containerops/containerops.go new file mode 100644 index 00000000000..d9b9c17e517 --- /dev/null +++ b/internal/containerops/containerops.go @@ -0,0 +1,80 @@ +/* + 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 containerops implements the operations that act from the outside on +// the container of a service: exec, cp, export and commit. +package containerops + +import ( + "context" + + "github.com/docker/cli/cli/command" + "github.com/moby/moby/api/types/container" + "github.com/moby/moby/client" + "golang.org/x/sync/errgroup" + + "github.com/docker/compose/v5/internal/inspect" + "github.com/docker/compose/v5/pkg/api" +) + +// Backend is what the operations need from the engine they act on. +// +// The terminal reaches them through CLI: stdin, stdout and the TTY all come +// from the Docker CLI's streams, the one seam through which an operation that +// talks to the user does so (exec hands the whole CLI to the Docker CLI's own +// implementation). +type Backend interface { + // APIClient talks to the Docker daemon. + APIClient() client.APIClient + // MaxConcurrency bounds the daemon calls made in parallel, 0 meaning no bound. + MaxConcurrency() int + // Events receives the progress of the operations. + Events() api.EventProcessor + // DryRun tells whether the operations only report what they would do. + DryRun() bool + // CLI is the Docker CLI Compose runs in, and the owner of the terminal streams. + CLI() command.Cli + // Containers lists the containers of a project, narrowed to services when given. + Containers(ctx context.Context, projectName string, oneOff inspect.OneOff, all bool, services ...string) ([]container.Summary, error) + // SpecifiedContainer returns the container of a service, the replica with + // the given index when it is above zero. + SpecifiedContainer(ctx context.Context, projectName string, oneOff inspect.OneOff, all bool, serviceName string, index int) (container.Summary, error) +} + +// Service runs the container operations on a Backend. +type Service struct { + backend Backend +} + +// New creates a Service acting on the given Backend. +func New(backend Backend) *Service { + return &Service{backend: backend} +} + +// forEachContainerConcurrent runs fn for every container concurrently, at most +// maxConcurrency at a time (0 meaning no bound), and waits for all of them. +func forEachContainerConcurrent(ctx context.Context, maxConcurrency int, containers []container.Summary, fn func(context.Context, container.Summary) error) error { + eg, ctx := errgroup.WithContext(ctx) + if maxConcurrency > 0 { + eg.SetLimit(maxConcurrency) + } + for _, ctr := range containers { + eg.Go(func() error { + return fn(ctx, ctr) + }) + } + return eg.Wait() +} diff --git a/pkg/compose/cp.go b/internal/containerops/cp.go similarity index 80% rename from pkg/compose/cp.go rename to internal/containerops/cp.go index 347672492ca..ced927c7545 100644 --- a/pkg/compose/cp.go +++ b/internal/containerops/cp.go @@ -14,7 +14,7 @@ limitations under the License. */ -package compose +package containerops import ( "context" @@ -30,6 +30,9 @@ import ( "github.com/moby/moby/api/types/container" "github.com/moby/moby/client" + "github.com/docker/compose/v5/internal/inspect" + "github.com/docker/compose/v5/internal/naming" + "github.com/docker/compose/v5/internal/relay" "github.com/docker/compose/v5/pkg/api" ) @@ -41,13 +44,8 @@ const ( acrossServices = fromService | toService ) -func (s *composeService) Copy(ctx context.Context, projectName string, options api.CopyOptions) error { - return Run(ctx, func(ctx context.Context) error { - return s.copy(ctx, projectName, options) - }, "copy", s.events) -} - -func (s *composeService) copy(ctx context.Context, projectName string, options api.CopyOptions) error { +// Copy copies files between the host and the containers of a service. +func (s *Service) Copy(ctx context.Context, projectName string, options api.CopyOptions) error { projectName = strings.ToLower(projectName) srcService, srcPath := splitCpArg(options.Source) destService, dstPath := splitCpArg(options.Destination) @@ -78,15 +76,15 @@ func (s *composeService) copy(ctx context.Context, projectName string, options a return err } - return forEachContainerConcurrent(ctx, s.maxConcurrency, containers, func(ctx context.Context, ctr container.Summary) error { - name := getCanonicalContainerName(ctr) + return forEachContainerConcurrent(ctx, s.backend.MaxConcurrency(), containers, func(ctx context.Context, ctr container.Summary) error { + name := naming.CanonicalContainerName(ctr) var msg string if direction == fromService { msg = fmt.Sprintf("%s:%s to %s", name, srcPath, dstPath) } else { msg = fmt.Sprintf("%s to %s:%s", srcPath, name, dstPath) } - s.events.On(api.Resource{ + s.backend.Events().On(api.Resource{ ID: name, Text: api.StatusCopying, Details: msg, @@ -95,7 +93,7 @@ func (s *composeService) copy(ctx context.Context, projectName string, options a if err := copyFunc(ctx, ctr.ID, srcPath, dstPath, options); err != nil { return err } - s.events.On(api.Resource{ + s.backend.Events().On(api.Resource{ ID: name, Text: api.StatusCopied, Details: msg, @@ -105,30 +103,30 @@ func (s *composeService) copy(ctx context.Context, projectName string, options a }) } -func (s *composeService) listContainersTargetedForCopy(ctx context.Context, projectName string, options api.CopyOptions, direction copyDirection, serviceName string) (Containers, error) { - var containers Containers +func (s *Service) listContainersTargetedForCopy(ctx context.Context, projectName string, options api.CopyOptions, direction copyDirection, serviceName string) ([]container.Summary, error) { + var containers []container.Summary var err error switch { case options.Index > 0: - ctr, err := s.getSpecifiedContainer(ctx, projectName, oneOffExclude, true, serviceName, options.Index) + ctr, err := s.backend.SpecifiedContainer(ctx, projectName, inspect.OneOffExclude, true, serviceName, options.Index) if err != nil { return nil, err } - if err := checkRelayTarget(ctr, serviceName, "cp"); err != nil { + if err := relay.CheckTarget(ctr, serviceName, "cp"); err != nil { return nil, err } return append(containers, ctr), nil default: - withOneOff := oneOffExclude + withOneOff := inspect.OneOffExclude if options.All { - withOneOff = oneOffInclude + withOneOff = inspect.OneOffInclude } - containers, err = s.getContainers(ctx, projectName, withOneOff, true, serviceName) + containers, err = s.backend.Containers(ctx, projectName, withOneOff, true, serviceName) if err != nil { return nil, err } for _, ctr := range containers { - if err := checkRelayTarget(ctr, serviceName, "cp"); err != nil { + if err := relay.CheckTarget(ctr, serviceName, "cp"); err != nil { return nil, err } } @@ -143,7 +141,7 @@ func (s *composeService) listContainersTargetedForCopy(ctx context.Context, proj } } -func (s *composeService) copyToContainer(ctx context.Context, containerID string, srcPath string, dstPath string, options api.CopyOptions) error { +func (s *Service) copyToContainer(ctx context.Context, containerID string, srcPath string, dstPath string, options api.CopyOptions) error { var err error if srcPath != "-" { // Get an absolute source path. @@ -156,7 +154,7 @@ func (s *composeService) copyToContainer(ctx context.Context, containerID string // Prepare destination copy info by stat-ing the container path. dstInfo := archive.CopyInfo{Path: dstPath} var dstStat container.PathStat - res, err := s.apiClient().ContainerStatPath(ctx, containerID, client.ContainerStatPathOptions{ + res, err := s.backend.APIClient().ContainerStatPath(ctx, containerID, client.ContainerStatPathOptions{ Path: dstPath, }) if err == nil { @@ -173,7 +171,7 @@ func (s *composeService) copyToContainer(ctx context.Context, containerID string } dstInfo.Path = linkTarget - res, err = s.apiClient().ContainerStatPath(ctx, containerID, client.ContainerStatPathOptions{ + res, err = s.backend.APIClient().ContainerStatPath(ctx, containerID, client.ContainerStatPathOptions{ Path: linkTarget, }) if err == nil { @@ -202,7 +200,7 @@ func (s *composeService) copyToContainer(ctx context.Context, containerID string ) if srcPath == "-" { - content = s.stdin() + content = s.backend.CLI().In() resolvedDstPath = dstInfo.Path if !dstInfo.IsDir { return fmt.Errorf("destination \"%s:%s\" must be a directory", containerID, dstPath) @@ -233,7 +231,7 @@ func (s *composeService) copyToContainer(ctx context.Context, containerID string // info which directory to extract to, which may be the parent of the // destination that the user specified. // Don't create the archive if running in Dry Run mode - if !s.dryRun { + if !s.backend.DryRun() { dstDir, preparedArchive, err := archive.PrepareArchiveCopy(srcArchive, srcInfo, dstInfo) if err != nil { return err @@ -245,7 +243,7 @@ func (s *composeService) copyToContainer(ctx context.Context, containerID string } } - _, err = s.apiClient().CopyToContainer(ctx, containerID, client.CopyToContainerOptions{ + _, err = s.backend.APIClient().CopyToContainer(ctx, containerID, client.CopyToContainerOptions{ DestinationPath: resolvedDstPath, Content: content, AllowOverwriteDirWithFile: false, @@ -254,7 +252,7 @@ func (s *composeService) copyToContainer(ctx context.Context, containerID string return err } -func (s *composeService) copyFromContainer(ctx context.Context, containerID, srcPath, dstPath string, options api.CopyOptions) error { +func (s *Service) copyFromContainer(ctx context.Context, containerID, srcPath, dstPath string, options api.CopyOptions) error { var err error if dstPath != "-" { // Get an absolute destination path. @@ -272,7 +270,7 @@ func (s *composeService) copyFromContainer(ctx context.Context, containerID, src var rebaseName string if options.FollowLink { var srcStat container.PathStat - res, err := s.apiClient().ContainerStatPath(ctx, containerID, client.ContainerStatPathOptions{ + res, err := s.backend.APIClient().ContainerStatPath(ctx, containerID, client.ContainerStatPathOptions{ Path: srcPath, }) if err == nil { @@ -293,7 +291,7 @@ func (s *composeService) copyFromContainer(ctx context.Context, containerID, src } } - res, err := s.apiClient().CopyFromContainer(ctx, containerID, client.CopyFromContainerOptions{ + res, err := s.backend.APIClient().CopyFromContainer(ctx, containerID, client.CopyFromContainerOptions{ SourcePath: srcPath, }) if err != nil { @@ -302,7 +300,7 @@ func (s *composeService) copyFromContainer(ctx context.Context, containerID, src defer res.Content.Close() //nolint:errcheck if dstPath == "-" { - _, err = io.Copy(s.stdout(), res.Content) + _, err = io.Copy(s.backend.CLI().Out(), res.Content) return err } diff --git a/pkg/compose/exec.go b/internal/containerops/exec.go similarity index 70% rename from pkg/compose/exec.go rename to internal/containerops/exec.go index 3d7536bf723..0ac7a845e72 100644 --- a/pkg/compose/exec.go +++ b/internal/containerops/exec.go @@ -14,7 +14,7 @@ limitations under the License. */ -package compose +package containerops import ( "context" @@ -24,16 +24,19 @@ import ( "github.com/docker/cli/cli" "github.com/docker/cli/cli/command/container" + "github.com/docker/compose/v5/internal/inspect" + "github.com/docker/compose/v5/internal/relay" "github.com/docker/compose/v5/pkg/api" ) -func (s *composeService) Exec(ctx context.Context, projectName string, options api.RunOptions) (int, error) { +// Exec runs a command in a running container of a service and returns its exit code. +func (s *Service) Exec(ctx context.Context, projectName string, options api.RunOptions) (int, error) { projectName = strings.ToLower(projectName) - target, err := s.getSpecifiedContainer(ctx, projectName, oneOffInclude, false, options.Service, options.Index) + target, err := s.backend.SpecifiedContainer(ctx, projectName, inspect.OneOffInclude, false, options.Service, options.Index) if err != nil { return 0, err } - if err := checkRelayTarget(target, options.Service, "exec"); err != nil { + if err := relay.CheckTarget(target, options.Service, "exec"); err != nil { return 0, err } @@ -52,7 +55,7 @@ func (s *composeService) Exec(ctx context.Context, projectName string, options a } } - err = container.RunExec(ctx, s.dockerCli, target.ID, exec) + err = container.RunExec(ctx, s.backend.CLI(), target.ID, exec) var sterr cli.StatusError if errors.As(err, &sterr) { return sterr.StatusCode, err diff --git a/pkg/compose/export.go b/internal/containerops/export.go similarity index 64% rename from pkg/compose/export.go rename to internal/containerops/export.go index 9b27232ba1d..a66919ab7a8 100644 --- a/pkg/compose/export.go +++ b/internal/containerops/export.go @@ -14,7 +14,7 @@ limitations under the License. */ -package compose +package containerops import ( "context" @@ -27,52 +27,50 @@ import ( "github.com/moby/moby/client" "github.com/moby/sys/atomicwriter" + "github.com/docker/compose/v5/internal/inspect" + "github.com/docker/compose/v5/internal/naming" + "github.com/docker/compose/v5/internal/progress" "github.com/docker/compose/v5/pkg/api" ) -func (s *composeService) Export(ctx context.Context, projectName string, options api.ExportOptions) error { - return Run(ctx, func(ctx context.Context) error { - return s.export(ctx, projectName, options) - }, "export", s.events) -} - -func (s *composeService) export(ctx context.Context, projectName string, options api.ExportOptions) error { +// Export writes the filesystem of a container of a service as a tar archive. +func (s *Service) Export(ctx context.Context, projectName string, options api.ExportOptions) error { projectName = strings.ToLower(projectName) - container, err := s.getSpecifiedContainer(ctx, projectName, oneOffInclude, false, options.Service, options.Index) + container, err := s.backend.SpecifiedContainer(ctx, projectName, inspect.OneOffInclude, false, options.Service, options.Index) if err != nil { return err } if options.Output == "" { - if s.stdout().IsTerminal() { + if s.backend.CLI().Out().IsTerminal() { return errors.New("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) } - name := getCanonicalContainerName(container) - s.events.On(api.Resource{ + name := naming.CanonicalContainerName(container) + s.backend.Events().On(api.Resource{ ID: name, Text: api.StatusExporting, Status: api.Working, }) - responseBody, err := s.apiClient().ContainerExport(ctx, container.ID, client.ContainerExportOptions{}) + responseBody, err := s.backend.APIClient().ContainerExport(ctx, container.ID, client.ContainerExportOptions{}) if err != nil { return err } defer func() { if err := responseBody.Close(); err != nil { - s.events.On(errorEventf(name, "Failed to close response body: %s", err.Error())) + s.backend.Events().On(progress.Error(name, "Failed to close response body: "+err.Error())) } }() - if !s.dryRun { + if !s.backend.DryRun() { if options.Output == "" { - _, err := io.Copy(s.stdout(), responseBody) + _, err := io.Copy(s.backend.CLI().Out(), responseBody) return err } else { writer, err := atomicwriter.New(options.Output, 0o600) @@ -86,7 +84,7 @@ func (s *composeService) export(ctx context.Context, projectName string, options } } - s.events.On(api.Resource{ + s.backend.Events().On(api.Resource{ ID: name, Text: api.StatusExported, Status: api.Done, diff --git a/pkg/compose/containerops.go b/pkg/compose/containerops.go new file mode 100644 index 00000000000..a7897a80995 --- /dev/null +++ b/pkg/compose/containerops.go @@ -0,0 +1,91 @@ +/* + 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 ( + "context" + + "github.com/docker/cli/cli/command" + "github.com/moby/moby/api/types/container" + "github.com/moby/moby/client" + + "github.com/docker/compose/v5/internal/containerops" + "github.com/docker/compose/v5/internal/inspect" + "github.com/docker/compose/v5/pkg/api" +) + +// exec, cp, export and commit live in internal/containerops: composeService +// serves them by handing that package the engine they act on. + +func (s *composeService) containerOps() *containerops.Service { + return containerops.New(containerOpsBackend{s}) +} + +func (s *composeService) Exec(ctx context.Context, projectName string, options api.RunOptions) (int, error) { + return s.containerOps().Exec(ctx, projectName, options) +} + +func (s *composeService) Copy(ctx context.Context, projectName string, options api.CopyOptions) error { + return Run(ctx, func(ctx context.Context) error { + return s.containerOps().Copy(ctx, projectName, options) + }, "copy", s.events) +} + +func (s *composeService) Export(ctx context.Context, projectName string, options api.ExportOptions) error { + return Run(ctx, func(ctx context.Context) error { + return s.containerOps().Export(ctx, projectName, options) + }, "export", s.events) +} + +func (s *composeService) Commit(ctx context.Context, projectName string, options api.CommitOptions) error { + return Run(ctx, func(ctx context.Context) error { + return s.containerOps().Commit(ctx, projectName, options) + }, "commit", s.events) +} + +// containerOpsBackend is the engine of a composeService, as internal/containerops sees it. +type containerOpsBackend struct { + s *composeService +} + +func (b containerOpsBackend) APIClient() client.APIClient { + return b.s.apiClient() +} + +func (b containerOpsBackend) MaxConcurrency() int { + return b.s.maxConcurrency +} + +func (b containerOpsBackend) Events() api.EventProcessor { + return b.s.events +} + +func (b containerOpsBackend) DryRun() bool { + return b.s.dryRun +} + +func (b containerOpsBackend) CLI() command.Cli { + return b.s.dockerCli +} + +func (b containerOpsBackend) Containers(ctx context.Context, projectName string, oneOff inspect.OneOff, all bool, services ...string) ([]container.Summary, error) { + return b.s.getContainers(ctx, projectName, toOneOff(oneOff), all, services...) +} + +func (b containerOpsBackend) SpecifiedContainer(ctx context.Context, projectName string, oneOff inspect.OneOff, all bool, serviceName string, index int) (container.Summary, error) { + return b.s.getSpecifiedContainer(ctx, projectName, toOneOff(oneOff), all, serviceName, index) +} diff --git a/pkg/compose/cp_test.go b/pkg/compose/cp_test.go index 99edd47bafb..6a688211258 100644 --- a/pkg/compose/cp_test.go +++ b/pkg/compose/cp_test.go @@ -66,7 +66,7 @@ func TestCopyToService_ConcurrencyIsBoundedAcrossContainers(t *testing.T) { }). Times(numContainers) - err := svc.copy(t.Context(), "prj", compose.CopyOptions{ + err := svc.Copy(t.Context(), "prj", compose.CopyOptions{ Source: srcFile, Destination: "myservice:/dest", }) diff --git a/pkg/compose/relay.go b/pkg/compose/relay.go index 00353bbdabe..28241197cc8 100644 --- a/pkg/compose/relay.go +++ b/pkg/compose/relay.go @@ -42,10 +42,6 @@ func isRelayContainer(ctr container.Summary) bool { return relaymgr.IsContainer(ctr) } -func checkRelayTarget(target container.Summary, serviceName, operation string) error { - return relaymgr.CheckTarget(target, serviceName, operation) -} - // ensureRelayLinkNetworksDown returns down ops removing every provider // service's relay link network, one op per network, each gated on the shared // limiter like its siblings. From 773b488d1550885b09f9db13812f455a804ba67b Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Tue, 27 Oct 2026 10:03:55 +0100 Subject: [PATCH 2/2] test(containerops): pin which containers cp targets Cover the container selection and the refusals of Copy through a fake Backend: the replica asked for, one-off containers only with --all, a copy from a service reading its first container only, a service without container, copying between services and a copy with no service at all. Part of #14396 Signed-off-by: Nicolas De Loof --- internal/containerops/cp_test.go | 126 +++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 internal/containerops/cp_test.go diff --git a/internal/containerops/cp_test.go b/internal/containerops/cp_test.go new file mode 100644 index 00000000000..d6908c0d0ab --- /dev/null +++ b/internal/containerops/cp_test.go @@ -0,0 +1,126 @@ +/* + 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 containerops + +import ( + "context" + "testing" + + "github.com/docker/cli/cli/command" + "github.com/moby/moby/api/types/container" + "github.com/moby/moby/client" + "gotest.tools/v3/assert" + + "github.com/docker/compose/v5/internal/inspect" + "github.com/docker/compose/v5/pkg/api" +) + +// fakeBackend serves canned containers and records how it was asked for them. +type fakeBackend struct { + listed []container.Summary + + oneOff inspect.OneOff + all bool + services []string + index int + asked string +} + +func (f *fakeBackend) APIClient() client.APIClient { return nil } +func (f *fakeBackend) MaxConcurrency() int { return 0 } +func (f *fakeBackend) Events() api.EventProcessor { return nil } +func (f *fakeBackend) DryRun() bool { return false } +func (f *fakeBackend) CLI() command.Cli { return nil } + +func (f *fakeBackend) Containers(_ context.Context, _ string, oneOff inspect.OneOff, all bool, services ...string) ([]container.Summary, error) { + f.asked = "Containers" + f.oneOff, f.all, f.services = oneOff, all, services + return f.listed, nil +} + +func (f *fakeBackend) SpecifiedContainer(_ context.Context, _ string, oneOff inspect.OneOff, all bool, serviceName string, index int) (container.Summary, error) { + f.asked = "SpecifiedContainer" + f.oneOff, f.all, f.services, f.index = oneOff, all, []string{serviceName}, index + return f.listed[0], nil +} + +func webContainer(id string) container.Summary { + return container.Summary{ + ID: id, + Names: []string{"/" + id}, + Labels: map[string]string{api.ServiceLabel: "web"}, + } +} + +func TestCopyTargetsTheReplicaAskedFor(t *testing.T) { + backend := &fakeBackend{listed: []container.Summary{webContainer("web2")}} + + got, err := New(backend).listContainersTargetedForCopy(t.Context(), "proj", api.CopyOptions{Index: 2}, toService, "web") + + assert.NilError(t, err) + assert.Equal(t, len(got), 1) + assert.Equal(t, got[0].ID, "web2") + assert.Equal(t, backend.asked, "SpecifiedContainer") + assert.Equal(t, backend.index, 2) + assert.Equal(t, backend.oneOff, inspect.OneOffExclude) + assert.Assert(t, backend.all) +} + +func TestCopyOneOffContainersOnlyWithAll(t *testing.T) { + for _, all := range []bool{false, true} { + backend := &fakeBackend{listed: []container.Summary{webContainer("web1")}} + + _, err := New(backend).listContainersTargetedForCopy(t.Context(), "proj", api.CopyOptions{All: all}, toService, "web") + + assert.NilError(t, err) + assert.DeepEqual(t, backend.services, []string{"web"}) + if all { + assert.Equal(t, backend.oneOff, inspect.OneOffInclude) + } else { + assert.Equal(t, backend.oneOff, inspect.OneOffExclude) + } + } +} + +func TestCopyFromAServiceReadsFromItsFirstContainerOnly(t *testing.T) { + backend := &fakeBackend{listed: []container.Summary{webContainer("web1"), webContainer("web2")}} + + from, err := New(backend).listContainersTargetedForCopy(t.Context(), "proj", api.CopyOptions{}, fromService, "web") + assert.NilError(t, err) + assert.Equal(t, len(from), 1) + assert.Equal(t, from[0].ID, "web1") + + to, err := New(backend).listContainersTargetedForCopy(t.Context(), "proj", api.CopyOptions{}, toService, "web") + assert.NilError(t, err) + assert.Equal(t, len(to), 2) +} + +func TestCopyNeedsAContainer(t *testing.T) { + _, err := New(&fakeBackend{}).listContainersTargetedForCopy(t.Context(), "proj", api.CopyOptions{}, toService, "web") + + assert.Error(t, err, `no container found for service "web"`) +} + +func TestCopyRefusesWhatItCannotDo(t *testing.T) { + service := New(&fakeBackend{}) + + err := service.Copy(t.Context(), "proj", api.CopyOptions{Source: "web:/a", Destination: "db:/b"}) + assert.Error(t, err, "copying between services is not supported") + + err = service.Copy(t.Context(), "proj", api.CopyOptions{Source: "/a", Destination: "/b"}) + assert.Error(t, err, "unknown copy direction") +}