diff --git a/cmd/skopeo/copy.go b/cmd/skopeo/copy.go index b02808b6e..2b71f994a 100644 --- a/cmd/skopeo/copy.go +++ b/cmd/skopeo/copy.go @@ -20,22 +20,23 @@ import ( ) type copyOptions struct { - global *globalOptions - deprecatedTLSVerify *deprecatedTLSVerifyOption - srcImage *imageOptions - destImage *imageDestOptions - retryOpts *retry.Options - copy *sharedCopyOptions - additionalTags []string // For docker-archive: destinations, in addition to the name:tag specified as destination, also add these - signIdentity string // Identity of the signed image, must be a fully specified docker reference - digestFile string // Write digest to this file - quiet bool // Suppress output information when copying images - all bool // Copy all of the images if the source is a list - multiArch commonFlag.OptionalString // How to handle multi architecture images - encryptLayer []int // The list of layers to encrypt - encryptionKeys []string // Keys needed to encrypt the image - decryptionKeys []string // Keys needed to decrypt the image - imageParallelCopies uint // Maximum number of parallel requests when copying images + global *globalOptions + deprecatedTLSVerify *deprecatedTLSVerifyOption + srcImage *imageOptions + destImage *imageDestOptions + retryOpts *retry.Options + copy *sharedCopyOptions + additionalTags []string // For docker-archive: destinations, in addition to the name:tag specified as destination, also add these + signIdentity string // Identity of the signed image, must be a fully specified docker reference + digestFile string // Write digest to this file + quiet bool // Suppress output information when copying images + all bool // Copy all of the images if the source is a list + multiArch commonFlag.OptionalString // How to handle multi architecture images + encryptLayer []int // The list of layers to encrypt + encryptionKeys []string // Keys needed to encrypt the image + decryptionKeys []string // Keys needed to decrypt the image + imageParallelCopies uint // Maximum number of parallel requests when copying images + stripRemovedPlatforms bool // Strip platforms not selected for copy when using --multi-arch with a platform list } func copyCmd(global *globalOptions) *cobra.Command { @@ -85,6 +86,7 @@ See skopeo(1) section "IMAGE NAMES" for the expected format flags.IntSliceVar(&opts.encryptLayer, "encrypt-layer", []int{}, "*Experimental* the 0-indexed layer indices, with support for negative indexing (e.g. 0 is the first layer, -1 is the last layer)") flags.StringSliceVar(&opts.decryptionKeys, "decryption-key", []string{}, "*Experimental* key needed to decrypt the image") flags.UintVar(&opts.imageParallelCopies, "image-parallel-copies", 0, "Maximum number of image layers to be copied (pulled/pushed) simultaneously. Not setting this field will fall back to containers/image defaults.") + flags.BoolVar(&opts.stripRemovedPlatforms, "strip-removed-platforms", false, "Strip removed platforms when copying specific platforms (requires signature removal)") return cmd } @@ -257,6 +259,11 @@ func (opts *copyOptions) run(args []string, stdout io.Writer) (retErr error) { copyOpts.OciEncryptConfig = encConfig copyOpts.MaxParallelDownloads = opts.imageParallelCopies copyOpts.ForceCompressionFormat = opts.destImage.forceCompressionFormat + if opts.stripRemovedPlatforms { + copyOpts.SparseManifestListAction = copy.StripSparseManifestList + } else { + copyOpts.SparseManifestListAction = copy.KeepSparseManifestList + } return retry.IfNecessary(ctx, func() error { manifestBytes, err := copy.Image(ctx, policyContext, destRef, srcRef, copyOpts) diff --git a/cmd/skopeo/utils.go b/cmd/skopeo/utils.go index cf4988907..bb5434c17 100644 --- a/cmd/skopeo/utils.go +++ b/cmd/skopeo/utils.go @@ -332,6 +332,7 @@ func (opts *imageDestOptions) warnAboutIneffectiveOptions(destTransport types.Im // sharedCopyOptions collects CLI flags that affect copying images, currently shared between the copy and sync commands. type sharedCopyOptions struct { removeSignatures bool // Do not copy signatures from the source image + removeListSignatures bool // Do not copy manifest list signatures (preserves per-instance signatures) signByFingerprint string // Sign the image using a GPG key with the specified fingerprint signBySequoiaFingerprint string // Sign the image using a Sequoia-PGP key with the specified fingerprint signBySigstoreParamFile string // Sign the image using a sigstore signature per configuration in a param file @@ -346,6 +347,7 @@ func sharedCopyFlags() (pflag.FlagSet, *sharedCopyOptions) { opts := sharedCopyOptions{} fs := pflag.FlagSet{} fs.BoolVar(&opts.removeSignatures, "remove-signatures", false, "Do not copy signatures from source") + fs.BoolVar(&opts.removeListSignatures, "remove-list-signatures", false, "Do not copy manifest list signatures (preserves per-instance signatures)") fs.StringVar(&opts.signByFingerprint, "sign-by", "", "Sign the image using a GPG key with the specified `FINGERPRINT`") fs.StringVar(&opts.signBySequoiaFingerprint, "sign-by-sq-fingerprint", "", "Sign the image using a Sequoia-PGP key with the specified `FINGERPRINT`") fs.StringVar(&opts.signBySigstoreParamFile, "sign-by-sigstore", "", "Sign the image using a sigstore parameter file at `PATH`") @@ -368,6 +370,10 @@ func (opts *sharedCopyOptions) copyOptions(stdout io.Writer) (*copy.Options, fun manifestType = mt } + if opts.removeSignatures && opts.removeListSignatures { + return nil, nil, fmt.Errorf("Only one of --remove-signatures and --remove-list-signatures can be specified") + } + // c/image/copy.Image does allow creating both simple signing and sigstore signatures simultaneously, // with independent passphrases, but that would make the CLI probably too confusing. // For now, use the passphrase with either, but only one of them. @@ -453,6 +459,7 @@ func (opts *sharedCopyOptions) copyOptions(stdout io.Writer) (*copy.Options, fun succeeded = true return ©.Options{ RemoveSignatures: opts.removeSignatures, + RemoveListSignatures: opts.removeListSignatures, Signers: signers, SignBy: opts.signByFingerprint, SignPassphrase: passphrase, diff --git a/cmd/skopeo/utils_test.go b/cmd/skopeo/utils_test.go index d0e9c108f..7e841a552 100644 --- a/cmd/skopeo/utils_test.go +++ b/cmd/skopeo/utils_test.go @@ -404,6 +404,15 @@ func TestSharedCopyOptionsCopyOptions(t *testing.T) { ForceManifestMIMEType: imgspecv1.MediaTypeImageManifest, }, }, + { // --remove-list-signatures + options: []string{ + "--remove-list-signatures", + }, + expected: copy.Options{ + RemoveListSignatures: true, + ReportWriter: &someStdout, + }, + }, { // --sign-passphrase-file + --sign-by work options: []string{ "--sign-by", "gpgFingerprint", @@ -469,7 +478,8 @@ func TestSharedCopyOptionsCopyOptions(t *testing.T) { } for _, opts := range [][]string{ - {"--format", "invalid"}, // Invalid --format + {"--format", "invalid"}, // Invalid --format + {"--remove-signatures", "--remove-list-signatures"}, // Mutually exclusive flags // More --sign-by-sigstore-private-key, --sign-by-sigstore failure cases should be tested here. // --sign-passphrase-file + more than one key option {"--sign-by", "gpgFingerprint", "--sign-by-sq-fingerprint", "sqFingerprint", "--sign-passphrase-file", passphraseFile}, diff --git a/docs/skopeo-copy.1.md b/docs/skopeo-copy.1.md index 919d0b8a5..834bb8960 100644 --- a/docs/skopeo-copy.1.md +++ b/docs/skopeo-copy.1.md @@ -97,6 +97,16 @@ Suppress output information when copying images. Do not copy signatures, if any, from _source-image_. Necessary when copying a signed image to a destination which does not support signatures. +**--remove-list-signatures** + +Do not copy the manifest list signature while preserving per-instance signatures. This provides more granular control than **--remove-signatures**, which removes all signatures. + +**--strip-removed-platforms** + +When copying only a subset of platforms from a multi-architecture image (using **--multi-arch** with a platform list), write a manifest list containing only the copied platforms, instead of leaving entries that refer to per-platform images not present in the destination. This is useful for registries that reject manifest lists containing references to missing images. + +Note: This option requires signatures to be removed using either **--remove-signatures** or **--remove-list-signatures**, as stripping instances invalidates the manifest list signature and changes the manifest list digest. The operation will fail if signatures are present and not explicitly removed. + **--sign-by** _key-id_ Add a “simple signing” signature using that key ID for an image name corresponding to _destination-image_ @@ -273,6 +283,11 @@ To copy only specific platforms from a multi-architecture image (creates a spars $ skopeo copy --multi-arch=linux/amd64,linux/arm64 docker://quay.io/skopeo/stable:latest docker://registry.example.com/skopeo:latest ``` +To copy specific platforms and strip the sparse manifest list (for registries that don't support sparse lists): +```console +$ skopeo copy --multi-arch=linux/amd64,linux/arm64 --strip-removed-platforms --remove-list-signatures docker://quay.io/skopeo/stable:latest docker://registry.example.com/skopeo:latest +``` + To encrypt an image: ```console $ skopeo copy docker://docker.io/library/nginx:1.17.8 oci:local_nginx:1.17.8 diff --git a/docs/skopeo-sync.1.md b/docs/skopeo-sync.1.md index 0b986366c..4de02310b 100644 --- a/docs/skopeo-sync.1.md +++ b/docs/skopeo-sync.1.md @@ -90,6 +90,10 @@ This option does not change what will be copied; consider using `--all` at the s **--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. +**--remove-list-signatures** + +Do not copy the manifest list signature while preserving per-instance signatures. This provides more granular control than **--remove-signatures**, which removes all signatures. + **--sign-by** _key-id_ Add a “simple signing” signature using that key ID for an image name corresponding to _destination-image_ diff --git a/integration/copy_test.go b/integration/copy_test.go index 09eef81e0..5572056da 100644 --- a/integration/copy_test.go +++ b/integration/copy_test.go @@ -204,6 +204,58 @@ func (s *copySuite) TestCopyWithPlatformList() { assert.Equal(t, 3, len(manifestFiles), "Expected manifest list + 2 platform manifests") } +func (s *copySuite) TestCopyPlatformListWithStripSparse() { + t := s.T() + dir1 := t.TempDir() + + sourceManifest := combinedOutputOfCommand(t, skopeoBinary, "inspect", "--retry-times", "3", "--raw", knownListImage) + sourceList, err := manifest.Schema2ListFromManifest([]byte(sourceManifest)) + require.NoError(t, err) + var strippedPlatform string + for _, m := range sourceList.Manifests { + p := m.Platform.OS + "/" + m.Platform.Architecture + if p != "linux/amd64" && p != "linux/arm64" { + strippedPlatform = p + break + } + } + require.NotEmpty(t, strippedPlatform, "source image should contain a platform outside linux/amd64,linux/arm64") + + assertSkopeoSucceeds(t, "", "copy", "--retry-times", "3", "--multi-arch=linux/amd64,linux/arm64", + "--strip-removed-platforms", "--remove-list-signatures", knownListImage, "dir:"+dir1) + + manifestPath := filepath.Join(dir1, "manifest.json") + readManifest, err := os.ReadFile(manifestPath) + require.NoError(t, err) + mimeType := manifest.GuessMIMEType(readManifest) + assert.Equal(t, "application/vnd.docker.distribution.manifest.list.v2+json", mimeType) + + destList, err := manifest.Schema2ListFromManifest(readManifest) + require.NoError(t, err) + assert.Len(t, destList.Manifests, 2) + + destPlatforms := map[string]struct{}{} + for _, m := range destList.Manifests { + destPlatforms[m.Platform.OS+"/"+m.Platform.Architecture] = struct{}{} + } + assert.Contains(t, destPlatforms, "linux/amd64") + assert.Contains(t, destPlatforms, "linux/arm64") + assert.NotContains(t, destPlatforms, strippedPlatform) + + // Verify that we still have 2 platform manifests (manifest list + 2 platforms = 3 manifest files) + manifestFiles, err := filepath.Glob(filepath.Join(dir1, "*manifest.json")) + require.NoError(t, err) + assert.Equal(t, 3, len(manifestFiles), "Expected manifest list + 2 platform manifests after stripping") +} + +func (s *copySuite) TestCopyPlatformListStripRemovedPlatformsFailsWhenManifestListCannotBeModified() { + t := s.T() + dir1 := t.TempDir() + assertSkopeoFails(t, `.*Instructed to preserve digests.*`, + "copy", "--retry-times", "3", "--multi-arch=linux/amd64,linux/arm64", + "--strip-removed-platforms", "--preserve-digests", knownListImage, "dir:"+dir1) +} + func (s *copySuite) TestCopyWithManifestListConverge() { t := s.T() oci1 := t.TempDir()