diff --git a/cmd/skopeo/main.go b/cmd/skopeo/main.go index 5e7309ef..df608063 100644 --- a/cmd/skopeo/main.go +++ b/cmd/skopeo/main.go @@ -98,6 +98,7 @@ func createApp() (*cli.App, *globalOptions) { layersCmd(&opts), deleteCmd(&opts), manifestDigestCmd(), + syncCmd(&opts), standaloneSignCmd(), standaloneVerifyCmd(), untrustedSignatureDumpCmd(), diff --git a/cmd/skopeo/sync.go b/cmd/skopeo/sync.go new file mode 100644 index 00000000..93f6a362 --- /dev/null +++ b/cmd/skopeo/sync.go @@ -0,0 +1,549 @@ +package main + +import ( + "context" + "fmt" + "io" + "io/ioutil" + "os" + "path" + "path/filepath" + "strings" + + "github.com/containers/image/v5/copy" + "github.com/containers/image/v5/directory" + "github.com/containers/image/v5/docker" + "github.com/containers/image/v5/docker/reference" + "github.com/containers/image/v5/transports" + "github.com/containers/image/v5/types" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + "github.com/urfave/cli" + "gopkg.in/yaml.v2" +) + +// syncOptions contains information retrieved from the skopeo sync command line. +type syncOptions struct { + global *globalOptions // Global (not command dependant) skopeo options + srcImage *imageOptions // Source image options + destImage *imageDestOptions // Destination image options + removeSignatures bool // Do not copy signatures from the source image + signByFingerprint string // Sign the image using a GPG key with the specified fingerprint + source string // Source repository name + destination string // Destination registry name + scoped bool // When true, namespace copied images at destination using the source repository name +} + +// repoDescriptor contains information of a single repository used as a sync source. +type repoDescriptor struct { + DirBasePath string // base path when source is 'dir' + TaggedImages []types.ImageReference // List of tagged image found for the repository + Context *types.SystemContext // SystemContext for the sync command +} + +// tlsVerify is an implementation of the Unmarshaler interface, used to +// customize the unmarshaling behaviour of the tls-verify YAML key. +type tlsVerifyConfig struct { + skip types.OptionalBool // skip TLS verification check (false by default) +} + +// registrySyncConfig contains information about a single registry, read from +// the source YAML file +type registrySyncConfig struct { + Images map[string][]string // Images map images name to slices with the images' tags + Credentials types.DockerAuthConfig // Username and password used to authenticate with the registry + TLSVerify tlsVerifyConfig `yaml:"tls-verify"` // TLS verification mode (enabled by default) + CertDir string `yaml:"cert-dir"` // Path to the TLS certificates of the registry +} + +// sourceConfig contains all registries information read from the source YAML file +type sourceConfig map[string]registrySyncConfig + +func syncCmd(global *globalOptions) cli.Command { + sharedFlags, sharedOpts := sharedImageFlags() + srcFlags, srcOpts := dockerImageFlags(global, sharedOpts, "src-", "screds") + destFlags, destOpts := dockerImageFlags(global, sharedOpts, "dest-", "dcreds") + + opts := syncOptions{ + global: global, + srcImage: srcOpts, + destImage: &imageDestOptions{imageOptions: destOpts}, + } + + return cli.Command{ + Name: "sync", + Usage: "Synchronize one or more images from one location to another", + Description: fmt.Sprint(` + + Copy all the images from a SOURCE to a DESTINATION. + + Allowed SOURCE transports (specified with --src): docker, dir, yaml. + Allowed DESTINATION transports (specified with --dest): docker, dir. + + See skopeo-sync(1) for details. + `), + ArgsUsage: "--src SOURCE-LOCATION --dest DESTINATION-LOCATION SOURCE DESTINATION", + Action: commandAction(opts.run), + // FIXME: Do we need to namespace the GPG aspect? + Flags: append(append(append([]cli.Flag{ + cli.BoolFlag{ + Name: "remove-signatures", + Usage: "Do not copy signatures from SOURCE images", + Destination: &opts.removeSignatures, + }, + cli.StringFlag{ + Name: "sign-by", + Usage: "Sign the image using a GPG key with the specified `FINGERPRINT`", + Destination: &opts.signByFingerprint, + }, + cli.StringFlag{ + Name: "src, s", + Usage: "SOURCE transport type", + Destination: &opts.source, + }, + cli.StringFlag{ + Name: "dest, d", + Usage: "DESTINATION transport type", + Destination: &opts.destination, + }, + cli.BoolFlag{ + Name: "scoped", + Usage: "Images at DESTINATION are prefix using the full source image path as scope", + Destination: &opts.scoped, + }, + }, sharedFlags...), srcFlags...), destFlags...), + } +} + +// unmarshalYAML is the implementation of the Unmarshaler interface method +// method for the tlsVerifyConfig type. +// It unmarshals the 'tls-verify' YAML key so that, when they key is not +// specified, tls verification is enforced. +func (tls *tlsVerifyConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { + var verify bool + if err := unmarshal(&verify); err != nil { + return err + } + + tls.skip = types.NewOptionalBool(!verify) + return nil +} + +// newSourceConfig unmarshals the provided YAML file path to the sourceConfig type. +// It returns a new unmarshaled sourceConfig object and any error encountered. +func newSourceConfig(yamlFile string) (sourceConfig, error) { + var cfg sourceConfig + source, err := ioutil.ReadFile(yamlFile) + if err != nil { + return cfg, err + } + err = yaml.Unmarshal(source, &cfg) + if err != nil { + return cfg, errors.Wrapf(err, "Failed to unmarshal %q", yamlFile) + } + return cfg, nil +} + +// destinationReference creates an image reference using the provided transport. +// It returns a image reference to be used as destination of an image copy and +// any error encountered. +func destinationReference(destination string, transport string) (types.ImageReference, error) { + var imageTransport types.ImageTransport + + switch transport { + case docker.Transport.Name(): + destination = fmt.Sprintf("//%s", destination) + imageTransport = docker.Transport + case directory.Transport.Name(): + _, err := os.Stat(destination) + if err == nil { + return nil, errors.Errorf(fmt.Sprintf("Refusing to overwrite destination directory %q", destination)) + } + if !os.IsNotExist(err) { + return nil, errors.Wrap(err, "Destination directory could not be used") + } + // the directory holding the image must be created here + if err = os.MkdirAll(destination, 0755); err != nil { + return nil, errors.Wrapf(err, fmt.Sprintf("Error creating directory for image %s", + destination)) + } + imageTransport = directory.Transport + default: + return nil, errors.Errorf("%q is not a valid destination transport", transport) + } + logrus.Debugf("Destination for transport %q: %s", transport, destination) + + destRef, err := imageTransport.ParseReference(destination) + if err != nil { + return nil, errors.Wrapf(err, fmt.Sprintf("Cannot obtain a valid image reference for transport %q and reference %q", imageTransport.Name(), destination)) + } + + return destRef, nil +} + +// getImageTags retrieves all the tags associated to an image hosted on a +// container registry. +// It returns a string slice of tags and any error encountered. +func getImageTags(ctx context.Context, sysCtx *types.SystemContext, imgRef types.ImageReference) ([]string, error) { + name := imgRef.DockerReference().Name() + logrus.WithFields(logrus.Fields{ + "image": name, + }).Info("Getting tags") + tags, err := docker.GetRepositoryTags(ctx, sysCtx, imgRef) + + switch err := err.(type) { + case nil: + break + case docker.ErrUnauthorizedForCredentials: + // Some registries may decide to block the "list all tags" endpoint. + // Gracefully allow the sync to continue in this case. + logrus.Warnf("Registry disallows tag list retrieval: %s", err) + break + default: + return tags, errors.Wrapf(err, fmt.Sprintf("Error determining repository tags for image %s", name)) + } + + return tags, nil +} + +// isTagSpecified checks if an image name includes a tag and returns any errors +// encountered. +func isTagSpecified(imageName string) (bool, error) { + normNamed, err := reference.ParseNormalizedNamed(imageName) + if err != nil { + return false, err + } + + tagged := !reference.IsNameOnly(normNamed) + logrus.WithFields(logrus.Fields{ + "imagename": imageName, + "tagged": tagged, + }).Info("Tag presence check") + return tagged, nil +} + +// imagesTopCopyFromRepo builds a list of image references from the tags +// found in the source repository. +// It returns an image reference slice with as many elements as the tags found +// and any error encountered. +func imagesToCopyFromRepo(repoReference types.ImageReference, repoName string, sourceCtx *types.SystemContext) ([]types.ImageReference, error) { + var sourceReferences []types.ImageReference + tags, err := getImageTags(context.Background(), sourceCtx, repoReference) + if err != nil { + return sourceReferences, err + } + + for _, tag := range tags { + imageAndTag := fmt.Sprintf("%s:%s", repoName, tag) + ref, err := docker.ParseReference(imageAndTag) + if err != nil { + return nil, errors.Wrapf(err, fmt.Sprintf("Cannot obtain a valid image reference for transport %q and reference %q", docker.Transport.Name(), imageAndTag)) + } + sourceReferences = append(sourceReferences, ref) + } + return sourceReferences, nil +} + +// imagesTopCopyFromDir builds a list of image references from the images found +// in the source directory. +// It returns an image reference slice with as many elements as the images found +// and any error encountered. +func imagesToCopyFromDir(dirPath string) ([]types.ImageReference, error) { + var sourceReferences []types.ImageReference + err := filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if !info.IsDir() && info.Name() == "manifest.json" { + dirname := filepath.Dir(path) + ref, err := directory.Transport.ParseReference(dirname) + if err != nil { + return errors.Wrapf(err, fmt.Sprintf("Cannot obtain a valid image reference for transport %q and reference %q", directory.Transport.Name(), dirname)) + } + sourceReferences = append(sourceReferences, ref) + return filepath.SkipDir + } + return nil + }) + + if err != nil { + return sourceReferences, + errors.Wrapf(err, fmt.Sprintf("Error walking the path %q", dirPath)) + } + + return sourceReferences, nil +} + +// imagesTopCopyFromDir builds a list of repository descriptors from the images +// in a registry configuration. +// It returns a repository descriptors slice with as many elements as the images +// found and any error encountered. Each element of the slice is a list of +// tagged image references, to be used as sync source. +func imagesToCopyFromRegistry(registryName string, cfg registrySyncConfig, sourceCtx types.SystemContext) ([]repoDescriptor, error) { + var repoDescList []repoDescriptor + for imageName, tags := range cfg.Images { + repoName := fmt.Sprintf("//%s", path.Join(registryName, imageName)) + logrus.WithFields(logrus.Fields{ + "repo": imageName, + "registry": registryName, + }).Info("Processing repo") + + serverCtx := &sourceCtx + // override ctx with per-registryName options + serverCtx.DockerCertPath = cfg.CertDir + serverCtx.DockerDaemonCertPath = cfg.CertDir + serverCtx.DockerDaemonInsecureSkipTLSVerify = (cfg.TLSVerify.skip == types.OptionalBoolTrue) + serverCtx.DockerInsecureSkipTLSVerify = cfg.TLSVerify.skip + serverCtx.DockerAuthConfig = &cfg.Credentials + + var sourceReferences []types.ImageReference + for _, tag := range tags { + source := fmt.Sprintf("%s:%s", repoName, tag) + + imageRef, err := docker.ParseReference(source) + if err != nil { + logrus.WithFields(logrus.Fields{ + "tag": source, + }).Error("Error processing tag, skipping") + logrus.Errorf("Error getting image reference: %s", err) + continue + } + sourceReferences = append(sourceReferences, imageRef) + } + + if len(tags) == 0 { + logrus.WithFields(logrus.Fields{ + "repo": imageName, + "registry": registryName, + }).Info("Querying registry for image tags") + + imageRef, err := docker.ParseReference(repoName) + if err != nil { + logrus.WithFields(logrus.Fields{ + "repo": imageName, + "registry": registryName, + }).Error("Error processing repo, skipping") + logrus.Error(err) + continue + } + + sourceReferences, err = imagesToCopyFromRepo(imageRef, repoName, serverCtx) + if err != nil { + logrus.WithFields(logrus.Fields{ + "repo": imageName, + "registry": registryName, + }).Error("Error processing repo, skipping") + logrus.Error(err) + continue + } + } + + if len(sourceReferences) == 0 { + logrus.WithFields(logrus.Fields{ + "repo": imageName, + "registry": registryName, + }).Warnf("No tags to sync found") + continue + } + repoDescList = append(repoDescList, repoDescriptor{ + TaggedImages: sourceReferences, + Context: serverCtx}) + } + + return repoDescList, nil +} + +// imagesToCopy retrieves all the images to copy from a specified sync source +// and transport. +// It returns a slice of repository descriptors, where each descriptor is a +// list of tagged image references to be used as sync source, and any error +// encountered. +func imagesToCopy(source string, transport string, sourceCtx *types.SystemContext) ([]repoDescriptor, error) { + var descriptors []repoDescriptor + + switch transport { + case docker.Transport.Name(): + desc := repoDescriptor{ + Context: sourceCtx, + } + refName := fmt.Sprintf("//%s", source) + srcRef, err := docker.ParseReference(refName) + if err != nil { + return nil, errors.Wrapf(err, fmt.Sprintf("Cannot obtain a valid image reference for transport %q and reference %q", docker.Transport.Name(), refName)) + } + imageTagged, err := isTagSpecified(source) + if err != nil { + return descriptors, err + } + + if imageTagged { + desc.TaggedImages = append(desc.TaggedImages, srcRef) + descriptors = append(descriptors, desc) + break + } + + desc.TaggedImages, err = imagesToCopyFromRepo( + srcRef, + fmt.Sprintf("//%s", source), + sourceCtx) + + if err != nil { + return descriptors, err + } + if len(desc.TaggedImages) == 0 { + return descriptors, errors.Errorf("No images to sync found in %q", source) + } + descriptors = append(descriptors, desc) + + case directory.Transport.Name(): + desc := repoDescriptor{ + Context: sourceCtx, + } + + if _, err := os.Stat(source); err != nil { + return descriptors, errors.Wrap(err, "Invalid source directory specified") + } + desc.DirBasePath = source + var err error + desc.TaggedImages, err = imagesToCopyFromDir(source) + if err != nil { + return descriptors, err + } + if len(desc.TaggedImages) == 0 { + return descriptors, errors.Errorf("No images to sync found in %q", source) + } + descriptors = append(descriptors, desc) + + case "yaml": + cfg, err := newSourceConfig(source) + if err != nil { + return descriptors, err + } + for registryName, registryConfig := range cfg { + if len(registryConfig.Images) == 0 { + logrus.WithFields(logrus.Fields{ + "registry": registryName, + }).Warn("No images specified for registry") + continue + } + + descs, err := imagesToCopyFromRegistry(registryName, registryConfig, *sourceCtx) + if err != nil { + return descriptors, errors.Wrapf(err, "Failed to retrieve list of images from registry %q", registryName) + } + descriptors = append(descriptors, descs...) + } + } + + return descriptors, nil +} + +func (opts *syncOptions) run(args []string, stdout io.Writer) error { + if len(args) != 2 { + return errorShouldDisplayUsage{errors.New("Exactly two arguments expected")} + } + + policyContext, err := opts.global.getPolicyContext() + if err != nil { + return errors.Wrapf(err, "Error loading trust policy") + } + defer policyContext.Destroy() + + // validate source and destination options + contains := func(val string, list []string) (_ bool) { + for _, l := range list { + if l == val { + return true + } + } + return + } + + if len(opts.source) == 0 { + return errors.New("A source transport must be specified") + } + if !contains(opts.source, []string{docker.Transport.Name(), directory.Transport.Name(), "yaml"}) { + return errors.Errorf("%q is not a valid source transport", opts.source) + } + + if len(opts.destination) == 0 { + return errors.New("A destination transport must be specified") + } + if !contains(opts.destination, []string{docker.Transport.Name(), directory.Transport.Name()}) { + return errors.Errorf("%q is not a valid destination transport", opts.destination) + } + + if opts.source == opts.destination && opts.source == directory.Transport.Name() { + return errors.New("sync from 'dir' to 'dir' not implemented, consider using rsync instead") + } + + sourceCtx, err := opts.srcImage.newSystemContext() + if err != nil { + return err + } + + sourceArg := args[0] + srcRepoList, err := imagesToCopy(sourceArg, opts.source, sourceCtx) + if err != nil { + return err + } + + destination := args[1] + destinationCtx, err := opts.destImage.newSystemContext() + if err != nil { + return err + } + + ctx, cancel := opts.global.commandTimeoutContext() + defer cancel() + + imagesNumber := 0 + options := copy.Options{ + RemoveSignatures: opts.removeSignatures, + SignBy: opts.signByFingerprint, + ReportWriter: os.Stdout, + DestinationCtx: destinationCtx, + } + + for _, srcRepo := range srcRepoList { + options.SourceCtx = srcRepo.Context + for counter, ref := range srcRepo.TaggedImages { + var destSuffix string + switch ref.Transport() { + case docker.Transport: + // docker -> dir or docker -> docker + destSuffix = ref.DockerReference().String() + case directory.Transport: + // dir -> docker (we don't allow `dir` -> `dir` sync operations) + destSuffix = strings.TrimPrefix(ref.StringWithinTransport(), srcRepo.DirBasePath) + if destSuffix == "" { + // if source is a full path to an image, have destPath scoped to repo:tag + destSuffix = path.Base(srcRepo.DirBasePath) + } + } + + if !opts.scoped { + destSuffix = path.Base(destSuffix) + } + + destRef, err := destinationReference(path.Join(destination, destSuffix), opts.destination) + if err != nil { + return err + } + + logrus.WithFields(logrus.Fields{ + "from": transports.ImageName(ref), + "to": transports.ImageName(destRef), + }).Infof("Copying image tag %d/%d", counter+1, len(srcRepo.TaggedImages)) + + _, err = copy.Image(ctx, policyContext, destRef, ref, &options) + if err != nil { + return errors.Wrapf(err, fmt.Sprintf("Error copying tag %q", transports.ImageName(ref))) + } + imagesNumber++ + } + } + + logrus.Infof("Synced %d images from %d sources", imagesNumber, len(srcRepoList)) + return nil +} diff --git a/cmd/skopeo/utils.go b/cmd/skopeo/utils.go index c2207130..f66cfc35 100644 --- a/cmd/skopeo/utils.go +++ b/cmd/skopeo/utils.go @@ -2,13 +2,13 @@ package main import ( "context" - "errors" "io" "strings" "github.com/containers/image/v5/pkg/compression" "github.com/containers/image/v5/transports/alltransports" "github.com/containers/image/v5/types" + "github.com/pkg/errors" "github.com/urfave/cli" ) @@ -50,24 +50,34 @@ func sharedImageFlags() ([]cli.Flag, *sharedImageOptions) { }, &opts } +// imageOptions collects CLI flags specific to the "docker" transport, which are +// the same across subcommands, but may be different for each image +// (e.g. may differ between the source and destination of a copy) +type dockerImageOptions struct { + global *globalOptions // May be shared across several imageOptions instances. + shared *sharedImageOptions // May be shared across several imageOptions instances. + credsOption optionalString // username[:password] for accessing a registry + dockerCertPath string // A directory using Docker-like *.{crt,cert,key} files for connecting to a registry or a daemon + tlsVerify optionalBool // Require HTTPS and verify certificates (for docker: and docker-daemon:) + noCreds bool // Access the registry anonymously +} + // imageOptions collects CLI flags which are the same across subcommands, but may be different for each image // (e.g. may differ between the source and destination of a copy) type imageOptions struct { - global *globalOptions // May be shared across several imageOptions instances. - shared *sharedImageOptions // May be shared across several imageOptions instances. - credsOption optionalString // username[:password] for accessing a registry - dockerCertPath string // A directory using Docker-like *.{crt,cert,key} files for connecting to a registry or a daemon - tlsVerify optionalBool // Require HTTPS and verify certificates (for docker: and docker-daemon:) - sharedBlobDir string // A directory to use for OCI blobs, shared across repositories - dockerDaemonHost string // docker-daemon: host to connect to - noCreds bool // Access the registry anonymously + dockerImageOptions + sharedBlobDir string // A directory to use for OCI blobs, shared across repositories + dockerDaemonHost string // docker-daemon: host to connect to } -// imageFlags prepares a collection of CLI flags writing into imageOptions, and the managed imageOptions structure. -func imageFlags(global *globalOptions, shared *sharedImageOptions, flagPrefix, credsOptionAlias string) ([]cli.Flag, *imageOptions) { +// dockerImageFlags prepares a collection of docker-transport specific CLI flags +// writing into imageOptions, and the managed imageOptions structure. +func dockerImageFlags(global *globalOptions, shared *sharedImageOptions, flagPrefix, credsOptionAlias string) ([]cli.Flag, *imageOptions) { opts := imageOptions{ - global: global, - shared: shared, + dockerImageOptions: dockerImageOptions{ + global: global, + shared: shared, + }, } // This is horribly ugly, but we need to support the old option forms of (skopeo copy) for compatibility. @@ -93,6 +103,19 @@ func imageFlags(global *globalOptions, shared *sharedImageOptions, flagPrefix, c Usage: "require HTTPS and verify certificates when talking to the container registry or daemon (defaults to true)", Value: newOptionalBoolValue(&opts.tlsVerify), }, + cli.BoolFlag{ + Name: flagPrefix + "no-creds", + Usage: "Access the registry anonymously", + Destination: &opts.noCreds, + }, + }, &opts +} + +// imageFlags prepares a collection of CLI flags writing into imageOptions, and the managed imageOptions structure. +func imageFlags(global *globalOptions, shared *sharedImageOptions, flagPrefix, credsOptionAlias string) ([]cli.Flag, *imageOptions) { + dockerFlags, opts := dockerImageFlags(global, shared, flagPrefix, credsOptionAlias) + + return append(dockerFlags, []cli.Flag{ cli.StringFlag{ Name: flagPrefix + "shared-blob-dir", Usage: "`DIRECTORY` to use to share blobs across OCI repositories", @@ -103,12 +126,7 @@ func imageFlags(global *globalOptions, shared *sharedImageOptions, flagPrefix, c Usage: "use docker daemon host at `HOST` (docker-daemon: only)", Destination: &opts.dockerDaemonHost, }, - cli.BoolFlag{ - Name: flagPrefix + "no-creds", - Usage: "Access the registry anonymously", - Destination: &opts.noCreds, - }, - }, &opts + }...), opts } // newSystemContext returns a *types.SystemContext corresponding to opts. diff --git a/docs/skopeo-sync.1.md b/docs/skopeo-sync.1.md new file mode 100644 index 00000000..d26bfe66 --- /dev/null +++ b/docs/skopeo-sync.1.md @@ -0,0 +1,142 @@ +% skopeo-sync(1) + +## NAME +skopeo\-sync - Synchronize images between container registries and local directories. + + +## SYNOPSIS +**skopeo sync** --src _transport_ --dest _transport_ _source_ _destination_ + +## DESCRIPTION +Synchronize images between container registries and local directories. +The synchronization is achieved by copying all the images found at _source_ to _destination_. + +Useful to synchronize a local container registry mirror, and to to populate registries running inside of air-gapped environments. + +Differently from other skopeo commands, skopeo sync requires both source and destination transports to be specified separately from _source_ and _destination_. +One of the problems of prefixing a destination with its transport is that, the registry `docker://hostname:port` would be wrongly interpreted as an image reference at a non-fully qualified registry, with `hostname` and `port` the image name and tag. + +Available _source_ transports: + - _docker_ (i.e. `--src docker`): _source_ is a repository hosted on a container registry (e.g.: `registry.example.com/busybox`). + If no image tag is specified, skopeo sync copies all the tags found in that repository. + - _dir_ (i.e. `--src dir`): _source_ is a local directory path (e.g.: `/media/usb/`). Refer to skopeo(1) **dir:**_path_ for the local image format. + - _yaml_ (i.e. `--src yaml`): _source_ is local YAML file path. + The YAML file should specify the list of images copied from different container registries (local directories are not supported). Refer to EXAMPLES for the file format. + +Available _destination_ transports: + - _docker_ (i.e. `--dest docker`): _destination_ is a container registry (e.g.: `my-registry.local.lan`). + - _dir_ (i.e. `--dest dir`): _destination_ is a local directory path (e.g.: `/media/usb/`). + One directory per source 'image:tag' is created for each copied image. + +When the `--scoped` option is specified, images are prefixed with the source image path so that multiple images with the same +name can be stored at _destination_. + +## OPTIONS +**--authfile** _path_ + +Path of the authentication file. Default is ${XDG\_RUNTIME\_DIR}/containers/auth.json, which is set using `podman login`. +If the authorization state is not found there, $HOME/.docker/config.json is checked, which is set using `docker login`. + +**--src** _transport_ Transport for the source repository. + +**--dest** _transport_ Destination transport. + +**--scoped** Prefix images with the source image path, so that multiple images with the same name can be stored at _destination_. + +**--remove-signatures** Do not copy signatures, if any, from _source-image_. This is necessary when copying a signed image to a destination which does not support signatures. + +**--sign-by=**_key-id_ Add a signature using that key ID for an image name corresponding to _destination-image_. + +**--src-creds** _username[:password]_ for accessing the source registry. + +**--dest-creds** _username[:password]_ for accessing the destination registry. + +**--src-cert-dir** _path_ Use certificates (*.crt, *.cert, *.key) at _path_ to connect to the source registry or daemon. + +**--src-no-creds** _bool-value_ Access the registry anonymously. + +**--src-tls-verify** _bool-value_ Require HTTPS and verify certificates when talking to a container source registry or daemon (defaults to true). + +**--dest-cert-dir** _path_ Use certificates (*.crt, *.cert, *.key) at _path_ to connect to the destination registry or daemon. + +**--dest-no-creds** _bool-value_ Access the registry anonymously. + +**--dest-tls-verify** _bool-value_ Require HTTPS and verify certificates when talking to a container destination registry or daemon (defaults to true). + +## EXAMPLES + +### Synchronizing to a local directory +``` +$ skopeo sync --src docker --dest dir registry.example.com/busybox /media/usb +``` +Images are located at: +``` +/media/usb/busybox:1-glibc +/media/usb/busybox:1-musl +/media/usb/busybox:1-ubuntu +... +/media/usb/busybox:latest +``` + +### Synchronizing to a local directory, scoped +``` +$ skopeo sync --src docker --dest dir --scoped registry.example.com/busybox /media/usb +``` +Images are located at: +``` +/media/usb/registry.example.com/busybox:1-glibc +/media/usb/registry.example.com/busybox:1-musl +/media/usb/registry.example.com/busybox:1-ubuntu +... +/media/usb/registry.example.com/busybox:latest +``` + +### Synchronizing to a container registry +``` +skopeo sync --src docker --dest docker registry.example.com/busybox my-registry.local.lan +``` +Destination registry content: +``` +REPO TAGS +registry.example.com/busybox 1-glibc, 1-musl, 1-ubuntu, ..., latest +``` + +### YAML file content (used _source_ for `**--src yaml**`) + +```yaml +registry.example.com: + images: + busybox: [] + redis: + - "1.0" + - "2.0" + credentials: + username: john + password: this is a secret + tls-verify: true + cert-dir: /home/john/certs +quay.io: + tls-verify: false + images: + coreos/etcd: + - latest +``` + +This will copy the following images: +- Repository `registry.example.com/busybox`: all images, as no tags are specified. +- Repository `registry.example.com/redis`: images tagged "1.0" and "2.0". +- Repository `quay.io/coreos/etcd`: images tagged "latest". + +For the registry `registry.example.com`, the "john"/"this is a secret" credentials are used, with server TLS certificates located at `/home/john/certs`. + +TLS verification is normally enabled, and it can be disabled setting `tls-verify` to `true`. +In the above example, TLS verification is enabled for `reigstry.example.com`, while is +disabled for `quay.io`. + +## SEE ALSO +skopeo(1), podman-login(1), docker-login(1) + +## AUTHORS + +Flavio Castelli , Marco Vedovati + diff --git a/docs/skopeo.1.md b/docs/skopeo.1.md index f486c69c..dca05ec6 100644 --- a/docs/skopeo.1.md +++ b/docs/skopeo.1.md @@ -74,6 +74,7 @@ Most commands refer to container images, using a _transport_`:`_details_ format. | [skopeo-manifest-digest(1)](skopeo-manifest-digest.1.md) | Compute a manifest digest of manifest-file and write it to standard output.| | [skopeo-standalone-sign(1)](skopeo-standalone-sign.1.md) | Sign an image. | | [skopeo-standalone-verify(1)](skopeo-standalone-verify.1.md)| Verify an image. | +| [skopeo-sync(1)](skopeo-sync.1.md)| Copy images from one or more repositories to a user specified destination. | ## FILES **/etc/containers/policy.json** diff --git a/go.mod b/go.mod index 8d46e3c9..12f4e926 100644 --- a/go.mod +++ b/go.mod @@ -21,4 +21,5 @@ require ( github.com/syndtr/gocapability v0.0.0-20180916011248-d98352740cb2 github.com/urfave/cli v1.22.1 go4.org v0.0.0-20190218023631-ce4c26f7be8e // indirect + gopkg.in/yaml.v2 v2.2.2 ) diff --git a/integration/sync_test.go b/integration/sync_test.go new file mode 100644 index 00000000..18d27062 --- /dev/null +++ b/integration/sync_test.go @@ -0,0 +1,514 @@ +package main + +import ( + "context" + "fmt" + "io/ioutil" + "os" + "path" + "path/filepath" + "regexp" + "strings" + + "github.com/containers/image/v5/docker" + "github.com/containers/image/v5/types" + "github.com/go-check/check" +) + +func init() { + check.Suite(&SyncSuite{}) +} + +type SyncSuite struct { + cluster *openshiftCluster + registry *testRegistryV2 + gpgHome string +} + +func (s *SyncSuite) SetUpSuite(c *check.C) { + const registryAuth = false + const registrySchema1 = false + + if os.Getenv("SKOPEO_LOCAL_TESTS") == "1" { + c.Log("Running tests without a container") + fmt.Printf("NOTE: tests requires a V2 registry at url=%s, with auth=%t, schema1=%t \n", v2DockerRegistryURL, registryAuth, registrySchema1) + return + } + + if os.Getenv("SKOPEO_CONTAINER_TESTS") != "1" { + c.Skip("Not running in a container, refusing to affect user state") + } + + s.cluster = startOpenshiftCluster(c) // FIXME: Set up TLS for the docker registry port instead of using "--tls-verify=false" all over the place. + + for _, stream := range []string{"unsigned", "personal", "official", "naming", "cosigned", "compression", "schema1", "schema2"} { + isJSON := fmt.Sprintf(`{ + "kind": "ImageStream", + "apiVersion": "v1", + "metadata": { + "name": "%s" + }, + "spec": {} + }`, stream) + runCommandWithInput(c, isJSON, "oc", "create", "-f", "-") + } + + // FIXME: Set up TLS for the docker registry port instead of using "--tls-verify=false" all over the place. + s.registry = setupRegistryV2At(c, v2DockerRegistryURL, registryAuth, registrySchema1) + + gpgHome, err := ioutil.TempDir("", "skopeo-gpg") + c.Assert(err, check.IsNil) + s.gpgHome = gpgHome + os.Setenv("GNUPGHOME", s.gpgHome) + + for _, key := range []string{"personal", "official"} { + batchInput := fmt.Sprintf("Key-Type: RSA\nName-Real: Test key - %s\nName-email: %s@example.com\n%%no-protection\n%%commit\n", + key, key) + runCommandWithInput(c, batchInput, gpgBinary, "--batch", "--gen-key") + + out := combinedOutputOfCommand(c, gpgBinary, "--armor", "--export", fmt.Sprintf("%s@example.com", key)) + err := ioutil.WriteFile(filepath.Join(s.gpgHome, fmt.Sprintf("%s-pubkey.gpg", key)), + []byte(out), 0600) + c.Assert(err, check.IsNil) + } +} + +func (s *SyncSuite) TearDownSuite(c *check.C) { + if os.Getenv("SKOPEO_LOCAL_TESTS") == "1" { + return + } + + if s.gpgHome != "" { + os.RemoveAll(s.gpgHome) + } + if s.registry != nil { + s.registry.Close() + } + if s.cluster != nil { + s.cluster.tearDown(c) + } +} + +func (s *SyncSuite) TestDocker2DirTagged(c *check.C) { + tmpDir, err := ioutil.TempDir("", "skopeo-sync-test") + c.Assert(err, check.IsNil) + defer os.RemoveAll(tmpDir) + + // FIXME: It would be nice to use one of the local Docker registries instead of neeeding an Internet connection. + image := "busybox:latest" + imageRef, err := docker.ParseReference(fmt.Sprintf("//%s", image)) + c.Assert(err, check.IsNil) + imagePath := imageRef.DockerReference().String() + + dir1 := path.Join(tmpDir, "dir1") + dir2 := path.Join(tmpDir, "dir2") + + // sync docker => dir + assertSkopeoSucceeds(c, "", "sync", "--scoped", "--src", "docker", "--dest", "dir", image, dir1) + _, err = os.Stat(path.Join(dir1, imagePath, "manifest.json")) + c.Assert(err, check.IsNil) + + // copy docker => dir + assertSkopeoSucceeds(c, "", "copy", "docker://"+image, "dir:"+dir2) + _, err = os.Stat(path.Join(dir2, "manifest.json")) + c.Assert(err, check.IsNil) + + out := combinedOutputOfCommand(c, "diff", "-urN", path.Join(dir1, imagePath), dir2) + c.Assert(out, check.Equals, "") +} + +func (s *SyncSuite) TestScoped(c *check.C) { + // FIXME: It would be nice to use one of the local Docker registries instead of neeeding an Internet connection. + image := "busybox:latest" + imageRef, err := docker.ParseReference(fmt.Sprintf("//%s", image)) + c.Assert(err, check.IsNil) + imagePath := imageRef.DockerReference().String() + + dir1, err := ioutil.TempDir("", "skopeo-sync-test") + c.Assert(err, check.IsNil) + assertSkopeoSucceeds(c, "", "sync", "--src", "docker", "--dest", "dir", image, dir1) + _, err = os.Stat(path.Join(dir1, image, "manifest.json")) + c.Assert(err, check.IsNil) + + assertSkopeoSucceeds(c, "", "sync", "--scoped", "--src", "docker", "--dest", "dir", image, dir1) + _, err = os.Stat(path.Join(dir1, imagePath, "manifest.json")) + c.Assert(err, check.IsNil) + + os.RemoveAll(dir1) +} + +func (s *SyncSuite) TestDirIsNotOverwritten(c *check.C) { + // FIXME: It would be nice to use one of the local Docker registries instead of neeeding an Internet connection. + image := "busybox:latest" + imageRef, err := docker.ParseReference(fmt.Sprintf("//%s", image)) + c.Assert(err, check.IsNil) + imagePath := imageRef.DockerReference().String() + + // make a copy of the image in the local registry + assertSkopeoSucceeds(c, "", "copy", "--dest-tls-verify=false", "docker://"+image, "docker://"+path.Join(v2DockerRegistryURL, image)) + + //sync upstream image to dir, not scoped + dir1, err := ioutil.TempDir("", "skopeo-sync-test") + c.Assert(err, check.IsNil) + assertSkopeoSucceeds(c, "", "sync", "--src", "docker", "--dest", "dir", image, dir1) + _, err = os.Stat(path.Join(dir1, image, "manifest.json")) + c.Assert(err, check.IsNil) + + //sync local registry image to dir, not scoped + assertSkopeoFails(c, ".*Refusing to overwrite destination directory.*", "sync", "--src-tls-verify=false", "--src", "docker", "--dest", "dir", path.Join(v2DockerRegistryURL, image), dir1) + + //sync local registry image to dir, scoped + imageRef, err = docker.ParseReference(fmt.Sprintf("//%s", path.Join(v2DockerRegistryURL, image))) + c.Assert(err, check.IsNil) + imagePath = imageRef.DockerReference().String() + assertSkopeoSucceeds(c, "", "sync", "--scoped", "--src-tls-verify=false", "--src", "docker", "--dest", "dir", path.Join(v2DockerRegistryURL, image), dir1) + _, err = os.Stat(path.Join(dir1, imagePath, "manifest.json")) + c.Assert(err, check.IsNil) + os.RemoveAll(dir1) +} + +func (s *SyncSuite) TestDocker2DirUntagged(c *check.C) { + + tmpDir, err := ioutil.TempDir("", "skopeo-sync-test") + c.Assert(err, check.IsNil) + defer os.RemoveAll(tmpDir) + + // FIXME: It would be nice to use one of the local Docker registries instead of neeeding an Internet connection. + image := "alpine" + imageRef, err := docker.ParseReference(fmt.Sprintf("//%s", image)) + c.Assert(err, check.IsNil) + imagePath := imageRef.DockerReference().String() + + dir1 := path.Join(tmpDir, "dir1") + assertSkopeoSucceeds(c, "", "sync", "--scoped", "--src", "docker", "--dest", "dir", image, dir1) + + sysCtx := types.SystemContext{} + tags, err := docker.GetRepositoryTags(context.Background(), &sysCtx, imageRef) + c.Assert(err, check.IsNil) + c.Check(len(tags), check.Not(check.Equals), 0) + + nManifests, err := filepath.Glob(path.Join(dir1, path.Dir(imagePath), "*", "manifest.json")) + c.Assert(err, check.IsNil) + c.Assert(len(nManifests), check.Equals, len(tags)) +} + +func (s *SyncSuite) TestYamlUntagged(c *check.C) { + tmpDir, err := ioutil.TempDir("", "skopeo-sync-test") + c.Assert(err, check.IsNil) + defer os.RemoveAll(tmpDir) + dir1 := path.Join(tmpDir, "dir1") + + image := "alpine" + imageRef, err := docker.ParseReference(fmt.Sprintf("//%s", image)) + c.Assert(err, check.IsNil) + imagePath := imageRef.DockerReference().Name() + + sysCtx := types.SystemContext{} + tags, err := docker.GetRepositoryTags(context.Background(), &sysCtx, imageRef) + c.Assert(err, check.IsNil) + c.Check(len(tags), check.Not(check.Equals), 0) + + yamlConfig := fmt.Sprintf(` +docker.io: + images: + %s: +`, image) + + //sync to the local reg + yamlFile := path.Join(tmpDir, "registries.yaml") + ioutil.WriteFile(yamlFile, []byte(yamlConfig), 0644) + assertSkopeoSucceeds(c, "", "sync", "--scoped", "--src", "yaml", "--dest", "docker", "--dest-tls-verify=false", yamlFile, v2DockerRegistryURL) + // sync back from local reg to a folder + os.Remove(yamlFile) + yamlConfig = fmt.Sprintf(` +%s: + tls-verify: false + images: + %s: + +`, v2DockerRegistryURL, imagePath) + + ioutil.WriteFile(yamlFile, []byte(yamlConfig), 0644) + assertSkopeoSucceeds(c, "", "sync", "--scoped", "--src", "yaml", "--dest", "dir", yamlFile, dir1) + + sysCtx = types.SystemContext{ + DockerInsecureSkipTLSVerify: types.NewOptionalBool(true), + } + localTags, err := docker.GetRepositoryTags(context.Background(), &sysCtx, imageRef) + c.Assert(err, check.IsNil) + c.Check(len(localTags), check.Not(check.Equals), 0) + c.Assert(len(localTags), check.Equals, len(tags)) + + nManifests := 0 + //count the number of manifest.json in dir1 + err = filepath.Walk(dir1, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if !info.IsDir() && info.Name() == "manifest.json" { + nManifests++ + return filepath.SkipDir + } + return nil + }) + c.Assert(err, check.IsNil) + c.Assert(nManifests, check.Equals, len(tags)) +} + +func (s *SyncSuite) TestYaml2Dir(c *check.C) { + tmpDir, err := ioutil.TempDir("", "skopeo-sync-test") + c.Assert(err, check.IsNil) + defer os.RemoveAll(tmpDir) + dir1 := path.Join(tmpDir, "dir1") + + yamlConfig := ` +docker.io: + images: + busybox: + - latest + - musl + alpine: + - edge + - 3.8 + + opensuse/leap: + - latest + +quay.io: + images: + quay/busybox: + - latest` + + // get the number of tags + re := regexp.MustCompile(`^ +- +[^:/ ]+`) + var nTags int + for _, l := range strings.Split(yamlConfig, "\n") { + if re.MatchString(l) { + nTags++ + } + } + c.Assert(nTags, check.Not(check.Equals), 0) + + yamlFile := path.Join(tmpDir, "registries.yaml") + ioutil.WriteFile(yamlFile, []byte(yamlConfig), 0644) + assertSkopeoSucceeds(c, "", "sync", "--scoped", "--src", "yaml", "--dest", "dir", yamlFile, dir1) + + nManifests := 0 + err = filepath.Walk(dir1, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if !info.IsDir() && info.Name() == "manifest.json" { + nManifests++ + return filepath.SkipDir + } + return nil + }) + c.Assert(err, check.IsNil) + c.Assert(nManifests, check.Equals, nTags) +} + +func (s *SyncSuite) TestYamlTLSVerify(c *check.C) { + const localRegURL = "docker://" + v2DockerRegistryURL + "/" + tmpDir, err := ioutil.TempDir("", "skopeo-sync-test") + c.Assert(err, check.IsNil) + defer os.RemoveAll(tmpDir) + dir1 := path.Join(tmpDir, "dir1") + image := "busybox" + tag := "latest" + + // FIXME: It would be nice to use one of the local Docker registries instead of neeeding an Internet connection. + // copy docker => docker + assertSkopeoSucceeds(c, "", "copy", "--dest-tls-verify=false", "docker://"+image+":"+tag, localRegURL+image+":"+tag) + + yamlTemplate := ` +%s: + %s + images: + %s: + - %s` + + testCfg := []struct { + tlsVerify string + msg string + checker func(c *check.C, regexp string, args ...string) + }{ + { + tlsVerify: "tls-verify: false", + msg: "", + checker: assertSkopeoSucceeds, + }, + { + tlsVerify: "tls-verify: true", + msg: ".*server gave HTTP response to HTTPS client.*", + checker: assertSkopeoFails, + }, + // no "tls-verify" line means default TLS verify must be ON + { + tlsVerify: "", + msg: ".*server gave HTTP response to HTTPS client.*", + checker: assertSkopeoFails, + }, + } + + for _, cfg := range testCfg { + yamlConfig := fmt.Sprintf(yamlTemplate, v2DockerRegistryURL, cfg.tlsVerify, image, tag) + yamlFile := path.Join(tmpDir, "registries.yaml") + ioutil.WriteFile(yamlFile, []byte(yamlConfig), 0644) + + cfg.checker(c, cfg.msg, "sync", "--scoped", "--src", "yaml", "--dest", "dir", yamlFile, dir1) + os.Remove(yamlFile) + os.RemoveAll(dir1) + } + +} + +func (s *SyncSuite) TestDocker2DockerTagged(c *check.C) { + const localRegURL = "docker://" + v2DockerRegistryURL + "/" + + tmpDir, err := ioutil.TempDir("", "skopeo-sync-test") + c.Assert(err, check.IsNil) + defer os.RemoveAll(tmpDir) + + // FIXME: It would be nice to use one of the local Docker registries instead of neeeding an Internet connection. + image := "busybox:latest" + imageRef, err := docker.ParseReference(fmt.Sprintf("//%s", image)) + c.Assert(err, check.IsNil) + imagePath := imageRef.DockerReference().String() + + dir1 := path.Join(tmpDir, "dir1") + dir2 := path.Join(tmpDir, "dir2") + + // sync docker => docker + assertSkopeoSucceeds(c, "", "sync", "--scoped", "--dest-tls-verify=false", "--src", "docker", "--dest", "docker", image, v2DockerRegistryURL) + + // copy docker => dir + assertSkopeoSucceeds(c, "", "copy", "docker://"+image, "dir:"+dir1) + _, err = os.Stat(path.Join(dir1, "manifest.json")) + c.Assert(err, check.IsNil) + + // copy docker => dir + assertSkopeoSucceeds(c, "", "copy", "--src-tls-verify=false", localRegURL+imagePath, "dir:"+dir2) + _, err = os.Stat(path.Join(dir2, "manifest.json")) + c.Assert(err, check.IsNil) + + out := combinedOutputOfCommand(c, "diff", "-urN", dir1, dir2) + c.Assert(out, check.Equals, "") +} + +func (s *SyncSuite) TestDir2DockerTagged(c *check.C) { + const localRegURL = "docker://" + v2DockerRegistryURL + "/" + + tmpDir, err := ioutil.TempDir("", "skopeo-sync-test") + c.Assert(err, check.IsNil) + defer os.RemoveAll(tmpDir) + + // FIXME: It would be nice to use one of the local Docker registries instead of neeeding an Internet connection. + image := "busybox:latest" + + dir1 := path.Join(tmpDir, "dir1") + err = os.Mkdir(dir1, 0755) + c.Assert(err, check.IsNil) + dir2 := path.Join(tmpDir, "dir2") + err = os.Mkdir(dir2, 0755) + c.Assert(err, check.IsNil) + + // copy docker => dir + assertSkopeoSucceeds(c, "", "copy", "docker://"+image, "dir:"+path.Join(dir1, image)) + _, err = os.Stat(path.Join(dir1, image, "manifest.json")) + c.Assert(err, check.IsNil) + + // sync dir => docker + assertSkopeoSucceeds(c, "", "sync", "--scoped", "--dest-tls-verify=false", "--src", "dir", "--dest", "docker", dir1, v2DockerRegistryURL) + + // copy docker => dir + assertSkopeoSucceeds(c, "", "copy", "--src-tls-verify=false", localRegURL+image, "dir:"+path.Join(dir2, image)) + _, err = os.Stat(path.Join(path.Join(dir2, image), "manifest.json")) + c.Assert(err, check.IsNil) + + out := combinedOutputOfCommand(c, "diff", "-urN", dir1, dir2) + c.Assert(out, check.Equals, "") +} + +func (s *SyncSuite) TestFailsWithDir2Dir(c *check.C) { + tmpDir, err := ioutil.TempDir("", "skopeo-sync-test") + c.Assert(err, check.IsNil) + defer os.RemoveAll(tmpDir) + + dir1 := path.Join(tmpDir, "dir1") + dir2 := path.Join(tmpDir, "dir2") + + // sync dir => dir is not allowed + assertSkopeoFails(c, ".*sync from 'dir' to 'dir' not implemented.*", "sync", "--scoped", "--src", "dir", "--dest", "dir", dir1, dir2) +} + +func (s *SyncSuite) TestFailsNoSourceImages(c *check.C) { + tmpDir, err := ioutil.TempDir("", "skopeo-sync-test") + c.Assert(err, check.IsNil) + defer os.RemoveAll(tmpDir) + + assertSkopeoFails(c, ".*No images to sync found in .*", + "sync", "--scoped", "--dest-tls-verify=false", "--src", "dir", "--dest", "docker", tmpDir, v2DockerRegistryURL) + + assertSkopeoFails(c, ".*No images to sync found in .*", + "sync", "--scoped", "--dest-tls-verify=false", "--src", "docker", "--dest", "docker", "hopefully_no_images_will_ever_be_called_like_this", v2DockerRegistryURL) +} + +func (s *SyncSuite) TestFailsWithDockerSourceNoRegistry(c *check.C) { + const regURL = "google.com/namespace/imagename" + + tmpDir, err := ioutil.TempDir("", "skopeo-sync-test") + c.Assert(err, check.IsNil) + defer os.RemoveAll(tmpDir) + + //untagged + assertSkopeoFails(c, ".*invalid status code from registry 404.*", + "sync", "--scoped", "--src", "docker", "--dest", "dir", regURL, tmpDir) + + //tagged + assertSkopeoFails(c, ".*invalid status code from registry 404.*", + "sync", "--scoped", "--src", "docker", "--dest", "dir", regURL+":thetag", tmpDir) +} + +func (s *SyncSuite) TestFailsWithDockerSourceUnauthorized(c *check.C) { + const repo = "privateimagenamethatshouldnotbepublic" + tmpDir, err := ioutil.TempDir("", "skopeo-sync-test") + c.Assert(err, check.IsNil) + defer os.RemoveAll(tmpDir) + + //untagged + assertSkopeoFails(c, ".*Registry disallows tag list retrieval.*", + "sync", "--scoped", "--src", "docker", "--dest", "dir", repo, tmpDir) + + //tagged + assertSkopeoFails(c, ".*unauthorized: authentication required.*", + "sync", "--scoped", "--src", "docker", "--dest", "dir", repo+":thetag", tmpDir) +} + +func (s *SyncSuite) TestFailsWithDockerSourceNotExisting(c *check.C) { + repo := path.Join(v2DockerRegistryURL, "imagedoesdotexist") + tmpDir, err := ioutil.TempDir("", "skopeo-sync-test") + c.Assert(err, check.IsNil) + defer os.RemoveAll(tmpDir) + + //untagged + assertSkopeoFails(c, ".*invalid status code from registry 404.*", + "sync", "--scoped", "--src-tls-verify=false", "--src", "docker", "--dest", "dir", repo, tmpDir) + + //tagged + assertSkopeoFails(c, ".*Error reading manifest.*", + "sync", "--scoped", "--src-tls-verify=false", "--src", "docker", "--dest", "dir", repo+":thetag", tmpDir) +} + +func (s *SyncSuite) TestFailsWithDirSourceNotExisting(c *check.C) { + // Make sure the dir does not exist! + tmpDir, err := ioutil.TempDir("", "skopeo-sync-test") + c.Assert(err, check.IsNil) + err = os.RemoveAll(tmpDir) + c.Assert(err, check.IsNil) + _, err = os.Stat(path.Join(tmpDir)) + c.Check(os.IsNotExist(err), check.Equals, true) + + assertSkopeoFails(c, ".*no such file or directory.*", + "sync", "--scoped", "--dest-tls-verify=false", "--src", "dir", "--dest", "docker", tmpDir, v2DockerRegistryURL) +}