diff --git a/aci/compose.go b/aci/compose.go index 22300c29..06626f90 100644 --- a/aci/compose.go +++ b/aci/compose.go @@ -80,6 +80,10 @@ func (cs *aciComposeService) UnPause(ctx context.Context, project string, option return errdefs.ErrNotImplemented } +func (cs *aciComposeService) Copy(ctx context.Context, project *types.Project, options compose.CopyOptions) error { + return errdefs.ErrNotImplemented +} + func (cs *aciComposeService) Up(ctx context.Context, project *types.Project, options compose.UpOptions) error { logrus.Debugf("Up on project with name %q", project.Name) diff --git a/api/client/compose.go b/api/client/compose.go index 77a7e34f..38179057 100644 --- a/api/client/compose.go +++ b/api/client/compose.go @@ -96,6 +96,10 @@ func (c *composeService) Exec(ctx context.Context, project *types.Project, opts return 0, errdefs.ErrNotImplemented } +func (c *composeService) Copy(ctx context.Context, project *types.Project, opts compose.CopyOptions) error { + return errdefs.ErrNotImplemented +} + func (c *composeService) Pause(ctx context.Context, project string, options compose.PauseOptions) error { return errdefs.ErrNotImplemented } diff --git a/api/compose/api.go b/api/compose/api.go index eae71aa6..e25a0b7b 100644 --- a/api/compose/api.go +++ b/api/compose/api.go @@ -62,6 +62,8 @@ type Service interface { Remove(ctx context.Context, project *types.Project, options RemoveOptions) ([]string, error) // Exec executes a command in a running service container Exec(ctx context.Context, project *types.Project, opts RunOptions) (int, error) + // Copy copies a file/folder between a service container and the local filesystem + Copy(ctx context.Context, project *types.Project, opts CopyOptions) error // Pause executes the equivalent to a `compose pause` Pause(ctx context.Context, project string, options PauseOptions) error // UnPause executes the equivalent to a `compose unpause` @@ -271,6 +273,15 @@ type PsOptions struct { All bool } +// CopyOptions group options of the cp API +type CopyOptions struct { + Source string + Destination string + Index int + FollowLink bool + CopyUIDGID bool +} + // PortPublisher hold status about published port type PortPublisher struct { URL string diff --git a/api/compose/delegator.go b/api/compose/delegator.go index bab938db..08905bd5 100644 --- a/api/compose/delegator.go +++ b/api/compose/delegator.go @@ -112,6 +112,11 @@ func (s *ServiceDelegator) Exec(ctx context.Context, project *types.Project, opt return s.Delegate.Exec(ctx, project, options) } +//Copy implements Service interface +func (s *ServiceDelegator) Copy(ctx context.Context, project *types.Project, options CopyOptions) error { + return s.Delegate.Copy(ctx, project, options) +} + //Pause implements Service interface func (s *ServiceDelegator) Pause(ctx context.Context, project string, options PauseOptions) error { return s.Delegate.Pause(ctx, project, options) diff --git a/api/compose/noimpl.go b/api/compose/noimpl.go index ec7963ef..356b9a99 100644 --- a/api/compose/noimpl.go +++ b/api/compose/noimpl.go @@ -112,6 +112,11 @@ func (s NoImpl) Exec(ctx context.Context, project *types.Project, opts RunOption return 0, errdefs.ErrNotImplemented } +//Copy implements Service interface +func (s NoImpl) Copy(ctx context.Context, project *types.Project, opts CopyOptions) error { + return errdefs.ErrNotImplemented +} + //Pause implements Service interface func (s NoImpl) Pause(ctx context.Context, project string, options PauseOptions) error { return errdefs.ErrNotImplemented diff --git a/cli/cmd/compose/compose.go b/cli/cmd/compose/compose.go index 0c205f42..d9d82dba 100644 --- a/cli/cmd/compose/compose.go +++ b/cli/cmd/compose/compose.go @@ -228,6 +228,7 @@ func RootCommand(contextType string, backend compose.Service) *cobra.Command { pushCommand(&opts, backend), pullCommand(&opts, backend), createCommand(&opts, backend), + copyCommand(&opts, backend), ) } command.Flags().SetInterspersed(false) diff --git a/cli/cmd/compose/cp.go b/cli/cmd/compose/cp.go new file mode 100644 index 00000000..0f0b3548 --- /dev/null +++ b/cli/cmd/compose/cp.go @@ -0,0 +1,83 @@ +/* + 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" + "errors" + + "github.com/docker/cli/cli" + "github.com/spf13/cobra" + + "github.com/docker/compose-cli/api/compose" +) + +type copyOptions struct { + *projectOptions + + source string + destination string + index int + followLink bool + copyUIDGID bool +} + +func copyCommand(p *projectOptions, backend compose.Service) *cobra.Command { + opts := copyOptions{ + projectOptions: p, + } + copyCmd := &cobra.Command{ + Use: `cp [OPTIONS] SERVICE:SRC_PATH DEST_PATH|- + docker compose cp [OPTIONS] SRC_PATH|- SERVICE:DEST_PATH`, + Short: "Copy files/folders between a service container and the local filesystem", + Args: cli.ExactArgs(2), + RunE: Adapt(func(ctx context.Context, args []string) error { + if args[0] == "" { + return errors.New("source can not be empty") + } + if args[1] == "" { + return errors.New("destination can not be empty") + } + + opts.source = args[0] + opts.destination = args[1] + return runCopy(ctx, backend, opts) + }), + } + + flags := copyCmd.Flags() + flags.IntVar(&opts.index, "index", 1, "index of the container if there are multiple instances of a service [default: 1].") + flags.BoolVarP(&opts.followLink, "follow-link", "L", false, "Always follow symbol link in SRC_PATH") + flags.BoolVarP(&opts.copyUIDGID, "archive", "a", false, "Archive mode (copy all uid/gid information)") + + return copyCmd +} + +func runCopy(ctx context.Context, backend compose.Service, opts copyOptions) error { + projects, err := opts.toProject(nil) + if err != nil { + return err + } + + return backend.Copy(ctx, projects, compose.CopyOptions{ + Source: opts.source, + Destination: opts.destination, + Index: opts.index, + FollowLink: opts.followLink, + CopyUIDGID: opts.copyUIDGID, + }) +} diff --git a/cli/metrics/commands.go b/cli/metrics/commands.go index af491a89..e021897f 100644 --- a/cli/metrics/commands.go +++ b/cli/metrics/commands.go @@ -63,6 +63,7 @@ var commands = []string{ "deploy", "list", "ls", + "cp", "merge", "pull", "push", diff --git a/ecs/local/compose.go b/ecs/local/compose.go index 21484313..c0214b49 100644 --- a/ecs/local/compose.go +++ b/ecs/local/compose.go @@ -188,6 +188,10 @@ func (e ecsLocalSimulation) Exec(ctx context.Context, project *types.Project, op return 0, errdefs.ErrNotImplemented } +func (e ecsLocalSimulation) Copy(ctx context.Context, project *types.Project, opts compose.CopyOptions) error { + return e.compose.Copy(ctx, project, opts) +} + func (e ecsLocalSimulation) Pause(ctx context.Context, project string, options compose.PauseOptions) error { return e.compose.Pause(ctx, project, options) } diff --git a/ecs/up.go b/ecs/up.go index 0749719d..691a52c2 100644 --- a/ecs/up.go +++ b/ecs/up.go @@ -75,6 +75,10 @@ func (b *ecsAPIService) Port(ctx context.Context, project string, service string return "", 0, errdefs.ErrNotImplemented } +func (b *ecsAPIService) Copy(ctx context.Context, project *types.Project, options compose.CopyOptions) error { + return errdefs.ErrNotImplemented +} + func (b *ecsAPIService) Up(ctx context.Context, project *types.Project, options compose.UpOptions) error { logrus.Debugf("deploying on AWS with region=%q", b.Region) err := b.aws.CheckRequirements(ctx, b.Region) diff --git a/kube/compose.go b/kube/compose.go index 9fb4e973..ced73d8f 100644 --- a/kube/compose.go +++ b/kube/compose.go @@ -198,6 +198,11 @@ func (s *composeService) Stop(ctx context.Context, project *types.Project, optio return errdefs.ErrNotImplemented } +// Copy copies a file/folder between a service container and the local filesystem +func (s *composeService) Copy(ctx context.Context, project *types.Project, options compose.CopyOptions) error { + return errdefs.ErrNotImplemented +} + // Logs executes the equivalent to a `compose logs` func (s *composeService) Logs(ctx context.Context, projectName string, consumer compose.LogConsumer, options compose.LogOptions) error { if len(options.Services) > 0 { diff --git a/local/compose/cp.go b/local/compose/cp.go new file mode 100644 index 00000000..7e049c79 --- /dev/null +++ b/local/compose/cp.go @@ -0,0 +1,265 @@ +/* + 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" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/compose-spec/compose-go/types" + "github.com/docker/cli/cli/command" + "github.com/docker/compose-cli/api/compose" + apitypes "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/filters" + "github.com/docker/docker/pkg/archive" + "github.com/docker/docker/pkg/system" + "github.com/pkg/errors" +) + +type copyDirection int + +const ( + fromService copyDirection = 1 << iota + toService + acrossServices = fromService | toService +) + +func (s *composeService) Copy(ctx context.Context, project *types.Project, opts compose.CopyOptions) error { + srcService, srcPath := splitCpArg(opts.Source) + destService, dstPath := splitCpArg(opts.Destination) + + var direction copyDirection + var serviceName string + if srcService != "" { + direction |= fromService + serviceName = srcService + } + if destService != "" { + direction |= toService + serviceName = destService + } + + containers, err := s.apiClient.ContainerList(ctx, apitypes.ContainerListOptions{ + Filters: filters.NewArgs( + projectFilter(project.Name), + serviceFilter(serviceName), + filters.Arg("label", fmt.Sprintf("%s=%d", containerNumberLabel, opts.Index)), + ), + }) + if err != nil { + return err + } + + if len(containers) < 1 { + return fmt.Errorf("service %s not running", serviceName) + } + + containerID := containers[0].ID + switch direction { + case fromService: + return s.copyFromContainer(ctx, containerID, srcPath, dstPath, opts) + case toService: + return s.copyToContainer(ctx, containerID, srcPath, dstPath, opts) + case acrossServices: + return errors.New("copying between services is not supported") + default: + return errors.New("unknown copy direction") + } +} + +func (s *composeService) copyToContainer(ctx context.Context, containerID string, srcPath string, dstPath string, opts compose.CopyOptions) error { + var err error + if srcPath != "-" { + // Get an absolute source path. + srcPath, err = resolveLocalPath(srcPath) + if err != nil { + return err + } + } + + // Prepare destination copy info by stat-ing the container path. + dstInfo := archive.CopyInfo{Path: dstPath} + dstStat, err := s.apiClient.ContainerStatPath(ctx, containerID, dstPath) + + // If the destination is a symbolic link, we should evaluate it. + if err == nil && dstStat.Mode&os.ModeSymlink != 0 { + linkTarget := dstStat.LinkTarget + if !system.IsAbs(linkTarget) { + // Join with the parent directory. + dstParent, _ := archive.SplitPathDirEntry(dstPath) + linkTarget = filepath.Join(dstParent, linkTarget) + } + + dstInfo.Path = linkTarget + dstStat, err = s.apiClient.ContainerStatPath(ctx, containerID, linkTarget) + } + + // Validate the destination path + if err := command.ValidateOutputPathFileMode(dstStat.Mode); err != nil { + return errors.Wrapf(err, `destination "%s:%s" must be a directory or a regular file`, containerID, dstPath) + } + + // Ignore any error and assume that the parent directory of the destination + // path exists, in which case the copy may still succeed. If there is any + // type of conflict (e.g., non-directory overwriting an existing directory + // or vice versa) the extraction will fail. If the destination simply did + // not exist, but the parent directory does, the extraction will still + // succeed. + if err == nil { + dstInfo.Exists, dstInfo.IsDir = true, dstStat.Mode.IsDir() + } + + var ( + content io.Reader + resolvedDstPath string + ) + + if srcPath == "-" { + content = os.Stdin + resolvedDstPath = dstInfo.Path + if !dstInfo.IsDir { + return errors.Errorf("destination \"%s:%s\" must be a directory", containerID, dstPath) + } + } else { + // Prepare source copy info. + srcInfo, err := archive.CopyInfoSourcePath(srcPath, opts.FollowLink) + if err != nil { + return err + } + + srcArchive, err := archive.TarResource(srcInfo) + if err != nil { + return err + } + defer srcArchive.Close() //nolint:errcheck + + // With the stat info about the local source as well as the + // destination, we have enough information to know whether we need to + // alter the archive that we upload so that when the server extracts + // it to the specified directory in the container we get the desired + // copy behavior. + + // See comments in the implementation of `archive.PrepareArchiveCopy` + // for exactly what goes into deciding how and whether the source + // archive needs to be altered for the correct copy behavior when it is + // extracted. This function also infers from the source and destination + // info which directory to extract to, which may be the parent of the + // destination that the user specified. + dstDir, preparedArchive, err := archive.PrepareArchiveCopy(srcArchive, srcInfo, dstInfo) + if err != nil { + return err + } + defer preparedArchive.Close() //nolint:errcheck + + resolvedDstPath = dstDir + content = preparedArchive + } + + options := apitypes.CopyToContainerOptions{ + AllowOverwriteDirWithFile: false, + CopyUIDGID: opts.CopyUIDGID, + } + return s.apiClient.CopyToContainer(ctx, containerID, resolvedDstPath, content, options) +} + +func (s *composeService) copyFromContainer(ctx context.Context, containerID, srcPath, dstPath string, opts compose.CopyOptions) error { + var err error + if dstPath != "-" { + // Get an absolute destination path. + dstPath, err = resolveLocalPath(dstPath) + if err != nil { + return err + } + } + + if err := command.ValidateOutputPath(dstPath); err != nil { + return err + } + + // if client requests to follow symbol link, then must decide target file to be copied + var rebaseName string + if opts.FollowLink { + srcStat, err := s.apiClient.ContainerStatPath(ctx, containerID, srcPath) + + // If the destination is a symbolic link, we should follow it. + if err == nil && srcStat.Mode&os.ModeSymlink != 0 { + linkTarget := srcStat.LinkTarget + if !system.IsAbs(linkTarget) { + // Join with the parent directory. + srcParent, _ := archive.SplitPathDirEntry(srcPath) + linkTarget = filepath.Join(srcParent, linkTarget) + } + + linkTarget, rebaseName = archive.GetRebaseName(srcPath, linkTarget) + srcPath = linkTarget + } + } + + content, stat, err := s.apiClient.CopyFromContainer(ctx, containerID, srcPath) + if err != nil { + return err + } + defer content.Close() //nolint:errcheck + + if dstPath == "-" { + _, err = io.Copy(os.Stdout, content) + return err + } + + srcInfo := archive.CopyInfo{ + Path: srcPath, + Exists: true, + IsDir: stat.Mode.IsDir(), + RebaseName: rebaseName, + } + + preArchive := content + if len(srcInfo.RebaseName) != 0 { + _, srcBase := archive.SplitPathDirEntry(srcInfo.Path) + preArchive = archive.RebaseArchiveEntries(content, srcBase, srcInfo.RebaseName) + } + + return archive.CopyTo(preArchive, srcInfo, dstPath) +} + +func splitCpArg(arg string) (container, path string) { + if system.IsAbs(arg) { + // Explicit local absolute path, e.g., `C:\foo` or `/foo`. + return "", arg + } + + parts := strings.SplitN(arg, ":", 2) + + if len(parts) == 1 || strings.HasPrefix(parts[0], ".") { + // Either there's no `:` in the arg + // OR it's an explicit local relative path like `./file:name.txt`. + return "", arg + } + + return parts[0], parts[1] +} + +func resolveLocalPath(localPath string) (absPath string, err error) { + if absPath, err = filepath.Abs(localPath); err != nil { + return + } + return archive.PreserveTrailingDotOrSeparator(absPath, localPath, filepath.Separator), nil +}