Introduce the sync command

The skopeo sync command can sync images between a SOURCE and a
destination.

The purpose of this command is to assist with the mirroring of
container images from different docker registries to a single
docker registry.

Right now the following source/destination locations are implemented:

  * docker -> docker
  * docker-> dir
  * dir -> docker

The dir location is supported to handle the use case
of air-gapped environments.
In this context users can perform an initial sync on a trusted machine
connected to the internet; that would be a `docker` -> `dir` sync.
The target directory can be copied to a removable drive that can then be
plugged into a node of the air-gapped environment. From there a
`dir` -> `docker` sync will import all the images into the registry serving
the air-gapped environment.

Notes when specifying the `--scoped` option:

The image namespace is changed during the  `docker` to `docker` or `dir` copy.
The FQDN of the registry hosting the image will be added as new root namespace
of the image. For example, the image `registry.example.com/busybox:latest`
will be copied to
`registry.local.lan/registry.example.com/busybox:latest`.

The image namespace is not changed when doing a
`dir:` -> `docker` sync operation.

The alteration of the image namespace is used to nicely scope images
coming from different registries (the Docker Hub, quay.io, gcr,
other registries). That allows all of them to be hosted on the same
registry without incurring in clashes and making their origin explicit.

Signed-off-by: Flavio Castelli <fcastelli@suse.com>
Co-authored-by: Marco Vedovati <mvedovati@suse.com>
This commit is contained in:
Flavio Castelli
2018-07-09 15:27:01 +02:00
committed by Valentin Rothberg
parent 8ccd7b994d
commit 9c402f3799
7 changed files with 1245 additions and 19 deletions

View File

@@ -98,6 +98,7 @@ func createApp() (*cli.App, *globalOptions) {
layersCmd(&opts),
deleteCmd(&opts),
manifestDigestCmd(),
syncCmd(&opts),
standaloneSignCmd(),
standaloneVerifyCmd(),
untrustedSignatureDumpCmd(),

549
cmd/skopeo/sync.go Normal file
View File

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

View File

@@ -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 which are the same across subcommands, but may be different for each image
// 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 imageOptions struct {
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:)
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
}
// 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) {
// 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 {
dockerImageOptions
sharedBlobDir string // A directory to use for OCI blobs, shared across repositories
dockerDaemonHost string // docker-daemon: host to connect to
}
// 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{
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.

142
docs/skopeo-sync.1.md Normal file
View File

@@ -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 <fcastelli@suse.com>, Marco Vedovati <mvedovati@suse.com>

View File

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

1
go.mod
View File

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

514
integration/sync_test.go Normal file
View File

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