mirror of
https://github.com/containers/skopeo.git
synced 2026-07-14 22:28:37 +00:00
Add support for selective signature removal and sparse manifest list stripping
This commit introduces two new flags for the skopeo copy command to provide more granular control over multi-architecture image copying: --remove-list-signatures: Removes only the manifest list signature while preserving per-instance signatures. This provides finer control than the existing --remove-signatures flag which removes all signatures. --strip-sparse-manifest-list: Strips missing instances from manifest lists when copying only a subset of platforms (using --multi-arch with a platform list). This is useful for registries that don't support sparse manifest lists. The --strip-sparse-manifest-list flag requires explicit signature removal using either --remove-signatures or --remove-list-signatures, as stripping instances invalidates the manifest list signature. These flags address scenarios where users need to copy multi-architecture images to registries with different signature requirements or those that don't support sparse manifest lists, while still maintaining control over which signatures are preserved. Documentation has been updated with usage examples, and integration tests have been added to verify the new functionality. Signed-off-by: Alex Guidi <aguidi@redhat.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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_
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user