Merge pull request #2892 from aguidirh/RUN-4764

Add support for manifest list signature removal and sparse manifest list stripping
This commit is contained in:
Miloslav Trmač
2026-07-13 17:30:06 +02:00
committed by GitHub
6 changed files with 112 additions and 17 deletions

View File

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

View File

@@ -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 &copy.Options{
RemoveSignatures: opts.removeSignatures,
RemoveListSignatures: opts.removeListSignatures,
Signers: signers,
SignBy: opts.signByFingerprint,
SignPassphrase: passphrase,

View File

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

View File

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

View File

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

View File

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