mirror of
https://github.com/containers/skopeo.git
synced 2026-07-14 22:28:37 +00:00
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:
@@ -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