Merge pull request #1623 from JulienTant/1391-add-cp

This commit is contained in:
Nicolas De loof 2021-05-06 15:49:51 +02:00 committed by GitHub
commit 4c0cbea24b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 537 additions and 0 deletions

View File

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

View File

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

View File

@ -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`
@ -272,6 +274,16 @@ type PsOptions struct {
Services []string
}
// CopyOptions group options of the cp API
type CopyOptions struct {
Source string
Destination string
All bool
Index int
FollowLink bool
CopyUIDGID bool
}
// PortPublisher hold status about published port
type PortPublisher struct {
URL string

View File

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

View File

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

View File

@ -230,6 +230,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)

86
cli/cmd/compose/cp.go Normal file
View File

@ -0,0 +1,86 @@
/*
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
all bool
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.BoolVar(&opts.all, "all", false, "Copy to all the containers of the service.")
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,
All: opts.all,
Index: opts.index,
FollowLink: opts.followLink,
CopyUIDGID: opts.copyUIDGID,
})
}

View File

@ -63,6 +63,7 @@ var commands = []string{
"deploy",
"list",
"ls",
"cp",
"merge",
"pull",
"push",

View File

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

View File

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

View File

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

281
local/compose/cp.go Normal file
View File

@ -0,0 +1,281 @@
/*
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"
"golang.org/x/sync/errgroup"
"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
// copying from multiple containers of a services doesn't make sense.
if opts.All {
return errors.New("cannot use the --all flag when copying from a service")
}
}
if destService != "" {
direction |= toService
serviceName = destService
}
f := filters.NewArgs(
projectFilter(project.Name),
serviceFilter(serviceName),
)
if !opts.All {
f.Add("label", fmt.Sprintf("%s=%d", containerNumberLabel, opts.Index))
}
containers, err := s.apiClient.ContainerList(ctx, apitypes.ContainerListOptions{Filters: f})
if err != nil {
return err
}
if len(containers) < 1 {
return fmt.Errorf("service %s not running", serviceName)
}
g := errgroup.Group{}
for i := range containers {
containerID := containers[i].ID
g.Go(func() error {
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")
}
})
}
return g.Wait()
}
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
}

View File

@ -0,0 +1,120 @@
/*
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 e2e
import (
"os"
"strings"
"testing"
"gotest.tools/v3/assert"
"gotest.tools/v3/icmd"
. "github.com/docker/compose-cli/utils/e2e"
)
func TestCopy(t *testing.T) {
c := NewParallelE2eCLI(t, binDir)
const projectName = "copy_e2e"
t.Cleanup(func() {
c.RunDockerCmd("compose", "-f", "./fixtures/cp-test/docker-compose.yml", "--project-name", projectName, "down")
os.Remove("./fixtures/cp-test/from-default.txt") //nolint:errcheck
os.Remove("./fixtures/cp-test/from-indexed.txt") //nolint:errcheck
os.RemoveAll("./fixtures/cp-test/cp-folder2") //nolint:errcheck
})
t.Run("start service", func(t *testing.T) {
c.RunDockerCmd("compose", "-f", "./fixtures/cp-test/docker-compose.yml", "--project-name", projectName, "up", "--scale", "nginx=5", "-d")
})
t.Run("make sure service is running", func(t *testing.T) {
res := c.RunDockerCmd("compose", "-p", projectName, "ps")
res.Assert(t, icmd.Expected{Out: `nginx running`})
})
t.Run("copy to container copies the file to the first container by default", func(t *testing.T) {
res := c.RunDockerCmd("compose", "-f", "./fixtures/cp-test/docker-compose.yml", "-p", projectName, "cp", "./fixtures/cp-test/cp-me.txt", "nginx:/tmp/default.txt")
res.Assert(t, icmd.Expected{ExitCode: 0})
output := c.RunDockerCmd("exec", projectName+"_nginx_1", "cat", "/tmp/default.txt").Stdout()
assert.Assert(t, strings.Contains(output, `hello world`), output)
res = c.RunDockerOrExitError("exec", projectName+"_nginx_2", "cat", "/tmp/default.txt")
res.Assert(t, icmd.Expected{ExitCode: 1})
})
t.Run("copy to container with a given index copies the file to the given container", func(t *testing.T) {
res := c.RunDockerCmd("compose", "-f", "./fixtures/cp-test/docker-compose.yml", "-p", projectName, "cp", "--index=3", "./fixtures/cp-test/cp-me.txt", "nginx:/tmp/indexed.txt")
res.Assert(t, icmd.Expected{ExitCode: 0})
output := c.RunDockerCmd("exec", projectName+"_nginx_3", "cat", "/tmp/indexed.txt").Stdout()
assert.Assert(t, strings.Contains(output, `hello world`), output)
res = c.RunDockerOrExitError("exec", projectName+"_nginx_2", "cat", "/tmp/indexed.txt")
res.Assert(t, icmd.Expected{ExitCode: 1})
})
t.Run("copy to container with the all flag copies the file to all containers", func(t *testing.T) {
res := c.RunDockerCmd("compose", "-f", "./fixtures/cp-test/docker-compose.yml", "-p", projectName, "cp", "--all", "./fixtures/cp-test/cp-me.txt", "nginx:/tmp/all.txt")
res.Assert(t, icmd.Expected{ExitCode: 0})
output := c.RunDockerCmd("exec", projectName+"_nginx_1", "cat", "/tmp/all.txt").Stdout()
assert.Assert(t, strings.Contains(output, `hello world`), output)
output = c.RunDockerCmd("exec", projectName+"_nginx_2", "cat", "/tmp/all.txt").Stdout()
assert.Assert(t, strings.Contains(output, `hello world`), output)
output = c.RunDockerCmd("exec", projectName+"_nginx_3", "cat", "/tmp/all.txt").Stdout()
assert.Assert(t, strings.Contains(output, `hello world`), output)
})
t.Run("copy from a container copies the file to the host from the first container by default", func(t *testing.T) {
res := c.RunDockerCmd("compose", "-f", "./fixtures/cp-test/docker-compose.yml", "-p", projectName, "cp", "nginx:/tmp/default.txt", "./fixtures/cp-test/from-default.txt")
res.Assert(t, icmd.Expected{ExitCode: 0})
data, err := os.ReadFile("./fixtures/cp-test/from-default.txt")
assert.NilError(t, err)
assert.Equal(t, `hello world`, string(data))
})
t.Run("copy from a container with a given index copies the file to host", func(t *testing.T) {
res := c.RunDockerCmd("compose", "-f", "./fixtures/cp-test/docker-compose.yml", "-p", projectName, "cp", "--index=3", "nginx:/tmp/indexed.txt", "./fixtures/cp-test/from-indexed.txt")
res.Assert(t, icmd.Expected{ExitCode: 0})
data, err := os.ReadFile("./fixtures/cp-test/from-indexed.txt")
assert.NilError(t, err)
assert.Equal(t, `hello world`, string(data))
})
t.Run("copy to and from a container also work with folder", func(t *testing.T) {
res := c.RunDockerCmd("compose", "-f", "./fixtures/cp-test/docker-compose.yml", "-p", projectName, "cp", "./fixtures/cp-test/cp-folder", "nginx:/tmp")
res.Assert(t, icmd.Expected{ExitCode: 0})
output := c.RunDockerCmd("exec", projectName+"_nginx_1", "cat", "/tmp/cp-folder/cp-me.txt").Stdout()
assert.Assert(t, strings.Contains(output, `hello world from folder`), output)
res = c.RunDockerCmd("compose", "-f", "./fixtures/cp-test/docker-compose.yml", "-p", projectName, "cp", "nginx:/tmp/cp-folder", "./fixtures/cp-test/cp-folder2")
res.Assert(t, icmd.Expected{ExitCode: 0})
data, err := os.ReadFile("./fixtures/cp-test/cp-folder2/cp-me.txt")
assert.NilError(t, err)
assert.Equal(t, `hello world from folder`, string(data))
})
}

View File

@ -0,0 +1 @@
hello world from folder

View File

@ -0,0 +1 @@
hello world

View File

@ -0,0 +1,3 @@
services:
nginx:
image: nginx:alpine