Skip to content

Commit 86d6e38

Browse files
banjohclaude
andauthored
fix(airgap): make push-images idempotent against tag-immutable registries (#5940)
* fix(airgap): skip pushing manifests that already exist on destination Add a manifest precheck before each image copy and enable OptimizeDestinationImageAlreadyExists on copy.Options. KOTS push-images now succeeds against tag-immutable registries (Artifactory, Harbor, JFrog, Quay, WORM-backed OCI) when re-pushing a release whose tags are unchanged from a prior push. The precheck opens the destination ref, fetches its current manifest, and compares the digest against the source. If they match, the copy is skipped — no PUT is issued, so the registry never sees an overwrite attempt to reject. Destination errors fall through to the normal push path; only source failures bubble up. OptimizeDestinationImageAlreadyExists adds per-child coverage in case the parent manifest list differs but children are present. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(airgap): log swallowed destination errors and document precheck gap Greptile review feedback on #5940: - Surface transient/auth/rate-limit failures on the destination side at debug level so operators have a trail when the precheck isn't firing as expected. Behavior is unchanged: these errors still fall through to the normal push path. - Document the known byte-level comparison gap on destinationManifestMatches: if a prior push succeeded through the copy library and the library silently mutated the manifest bytes, the digests will never match and subsequent pushes to tag-immutable destinations still fail. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Changes Signed-off-by: Evans Mungai <evans@replicated.com> * Attempt comparison after library modifications of manifest Signed-off-by: Evans Mungai <evans@replicated.com> * fix(airgap): make idempotent-push behavior opt-in The always-on precheck and OptimizeDestinationImageAlreadyExists short-circuited the copy library's manifest write events, which the online smoke-test e2e asserts on ("Writing manifest to image destination" never appears in the UI). Gate both behind a new SkipExistingImages flag on CopyImageOptions / PushImagesOptions / ProcessImageOptions / RewriteOptions. Wire it through: - CLI: --skip-existing-images on `kots admin-console push-images` - Admin Console: "Skip Pushing Images That Already Exist" checkbox in AirgapRegistrySettings, plumbed via UpdateAppRegistryRequest → registry.RewriteImages → rewrite.Rewrite → CopyOnlineImages. Default behavior is unchanged; opt-in only when re-pushing to registries that enforce tag immutability (Artifactory, Harbor, JFrog, Quay, WORM-backed OCI). Signed-off-by: Evans Mungai <evans@replicated.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Remove unwanted registry refs Signed-off-by: Evans Mungai <evans@replicated.com> * Log level comment fix Signed-off-by: Evans Mungai <evans@replicated.com> --------- Signed-off-by: Evans Mungai <evans@replicated.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d24a18f commit 86d6e38

9 files changed

Lines changed: 517 additions & 53 deletions

File tree

cmd/kots/cli/admin-console-push-images.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ func AdminPushImagesCmd() *cobra.Command {
6969
cmd.Flags().String("registry-username", "", "user name to use to authenticate with the registry")
7070
cmd.Flags().String("registry-password", "", "password to use to authenticate with the registry")
7171
cmd.Flags().Bool("skip-registry-check", false, "skip the connectivity test and validation of the provided registry information")
72+
cmd.Flags().Bool("skip-existing-images", false, "skip pushing images whose manifest is already present at the destination tag. Required when re-pushing to registries that enforce tag immutability.")
7273

7374
cmd.Flags().String("kotsadm-tag", "", "set to override the tag of kotsadm. this may create an incompatible deployment because the version of kots and kotsadm are designed to work together")
7475
cmd.Flags().MarkHidden("kotsadm-tag")
@@ -125,7 +126,8 @@ func genAndCheckPushOptions(endpoint string, namespace string, log *logger.CLILo
125126
Username: username,
126127
Password: password,
127128
},
128-
ProgressWriter: os.Stdout,
129+
ProgressWriter: os.Stdout,
130+
SkipExistingImages: v.GetBool("skip-existing-images"),
129131
}
130132

131133
return &options, nil

pkg/handlers/registry.go

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,12 @@ import (
2727
)
2828

2929
type UpdateAppRegistryRequest struct {
30-
Hostname string `json:"hostname"`
31-
Username string `json:"username"`
32-
Password string `json:"password"`
33-
Namespace string `json:"namespace"`
34-
IsReadOnly bool `json:"isReadOnly"`
30+
Hostname string `json:"hostname"`
31+
Username string `json:"username"`
32+
Password string `json:"password"`
33+
Namespace string `json:"namespace"`
34+
IsReadOnly bool `json:"isReadOnly"`
35+
SkipExistingImages bool `json:"skipExistingImages"`
3536
}
3637

3738
type UpdateAppRegistryResponse struct {
@@ -237,7 +238,8 @@ func (h *Handler) UpdateAppRegistry(w http.ResponseWriter, r *http.Request) {
237238
appDir, err := registry.RewriteImages(
238239
foundApp.ID, latestSequence, updateAppRegistryRequest.Hostname,
239240
updateAppRegistryRequest.Username, registryPassword,
240-
updateAppRegistryRequest.Namespace, skipImagePush)
241+
updateAppRegistryRequest.Namespace, skipImagePush,
242+
updateAppRegistryRequest.SkipExistingImages)
241243
if err != nil {
242244
// log credential errors at info level
243245
causeErr := errors.Cause(err)

pkg/image/airgap.go

Lines changed: 30 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -124,9 +124,10 @@ func CopyAirgapImages(opts imagetypes.ProcessImageOptions, log *logger.CLILogger
124124
Username: opts.RegistrySettings.Username,
125125
Password: opts.RegistrySettings.Password,
126126
},
127-
Log: log,
128-
ProgressWriter: opts.ReportWriter,
129-
LogForUI: true,
127+
Log: log,
128+
ProgressWriter: opts.ReportWriter,
129+
LogForUI: true,
130+
SkipExistingImages: opts.SkipExistingImages,
130131
}
131132

132133
err := TagAndPushImagesFromBundle(opts.AirgapBundle, pushOpts)
@@ -270,13 +271,14 @@ func PushECImagesFromTempRegistry(airgapRootDir string, airgap *kotsv1beta1.Airg
270271
Username: options.Registry.Username,
271272
Password: options.Registry.Password,
272273
},
273-
CopyAll: true,
274-
PreserveDigests: isDigestPinned,
275-
SrcDisableV1Ping: true,
276-
SrcSkipTLSVerify: true,
277-
DestDisableV1Ping: true,
278-
DestSkipTLSVerify: true,
279-
ReportWriter: reportWriter,
274+
CopyAll: true,
275+
PreserveDigests: isDigestPinned,
276+
SrcDisableV1Ping: true,
277+
SrcSkipTLSVerify: true,
278+
DestDisableV1Ping: true,
279+
DestSkipTLSVerify: true,
280+
ReportWriter: reportWriter,
281+
SkipExistingImages: options.SkipExistingImages,
280282
},
281283
}
282284
imageCounter++
@@ -387,13 +389,14 @@ func PushImagesFromTempRegistry(airgapRootDir string, imageList []string, option
387389
Username: options.Registry.Username,
388390
Password: options.Registry.Password,
389391
},
390-
CopyAll: copyAll,
391-
PreserveDigests: isDigestPinned,
392-
SrcDisableV1Ping: true,
393-
SrcSkipTLSVerify: true,
394-
DestDisableV1Ping: true,
395-
DestSkipTLSVerify: true,
396-
ReportWriter: reportWriter,
392+
CopyAll: copyAll,
393+
PreserveDigests: isDigestPinned,
394+
SrcDisableV1Ping: true,
395+
SrcSkipTLSVerify: true,
396+
DestDisableV1Ping: true,
397+
DestSkipTLSVerify: true,
398+
ReportWriter: reportWriter,
399+
SkipExistingImages: options.SkipExistingImages,
397400
},
398401
}
399402
imageCounter++
@@ -488,10 +491,11 @@ func PushImagesFromDockerArchivePath(airgapRootDir string, options imagetypes.Pu
488491
Username: options.Registry.Username,
489492
Password: options.Registry.Password,
490493
},
491-
CopyAll: false, // docker-archive format does not support multi-arch images
492-
DestSkipTLSVerify: true,
493-
DestDisableV1Ping: true,
494-
ReportWriter: reportWriter,
494+
CopyAll: false, // docker-archive format does not support multi-arch images
495+
DestSkipTLSVerify: true,
496+
DestDisableV1Ping: true,
497+
ReportWriter: reportWriter,
498+
SkipExistingImages: options.SkipExistingImages,
495499
},
496500
}
497501
if err := pushImage(pushImageOpts); err != nil {
@@ -614,10 +618,11 @@ func PushImagesFromDockerArchiveBundle(airgapBundle string, options imagetypes.P
614618
Username: options.Registry.Username,
615619
Password: options.Registry.Password,
616620
},
617-
CopyAll: false, // docker-archive format does not support multi-arch images
618-
DestSkipTLSVerify: true,
619-
DestDisableV1Ping: true,
620-
ReportWriter: reportWriter,
621+
CopyAll: false, // docker-archive format does not support multi-arch images
622+
DestSkipTLSVerify: true,
623+
DestDisableV1Ping: true,
624+
ReportWriter: reportWriter,
625+
SkipExistingImages: options.SkipExistingImages,
621626
},
622627
}
623628
if err := pushImage(pushImageOpts); err != nil {

pkg/image/online.go

Lines changed: 127 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package image
22

33
import (
4+
"bytes"
45
"context"
56
"fmt"
67
"io"
@@ -182,7 +183,7 @@ func CopyOnlineImages(opts imagetypes.ProcessImageOptions, images []string, kots
182183
if _, copied := copiedImages[img]; copied {
183184
continue
184185
}
185-
if err := copyOnlineImage(sourceRegistry, destRegistry, img, opts.AppSlug, opts.ReportWriter, log, installationImages, dockerHubRegistry); err != nil {
186+
if err := copyOnlineImage(sourceRegistry, destRegistry, img, opts.AppSlug, opts.SkipExistingImages, opts.ReportWriter, log, installationImages, dockerHubRegistry); err != nil {
186187
return errors.Wrapf(err, "failed to copy online image %s", img)
187188
}
188189
copiedImages[img] = true
@@ -191,7 +192,7 @@ func CopyOnlineImages(opts imagetypes.ProcessImageOptions, images []string, kots
191192
return nil
192193
}
193194

194-
func copyOnlineImage(srcRegistry, destRegistry dockerregistrytypes.RegistryOptions, image string, appSlug string, reportWriter io.Writer, log *logger.CLILogger, installationImages map[string]types.InstallationImageInfo, dockerHubRegistry dockerregistrytypes.RegistryOptions) error {
195+
func copyOnlineImage(srcRegistry, destRegistry dockerregistrytypes.RegistryOptions, image string, appSlug string, skipExistingImages bool, reportWriter io.Writer, log *logger.CLILogger, installationImages map[string]types.InstallationImageInfo, dockerHubRegistry dockerregistrytypes.RegistryOptions) error {
195196
// TODO: This reaches out to internet in airgap installs. It shouldn't.
196197
sourceImage := image
197198
srcAuth := imagetypes.RegistryAuth{}
@@ -243,13 +244,14 @@ func copyOnlineImage(srcRegistry, destRegistry dockerregistrytypes.RegistryOptio
243244
Username: destRegistry.Username,
244245
Password: destRegistry.Password,
245246
},
246-
CopyAll: copyAll,
247-
PreserveDigests: copyAll,
248-
SrcDisableV1Ping: true,
249-
SrcSkipTLSVerify: os.Getenv("KOTSADM_INSECURE_SRCREGISTRY") == "true",
250-
DestDisableV1Ping: true,
251-
DestSkipTLSVerify: true,
252-
ReportWriter: reportWriter,
247+
CopyAll: copyAll,
248+
PreserveDigests: copyAll,
249+
SrcDisableV1Ping: true,
250+
SrcSkipTLSVerify: os.Getenv("KOTSADM_INSECURE_SRCREGISTRY") == "true",
251+
DestDisableV1Ping: true,
252+
DestSkipTLSVerify: true,
253+
ReportWriter: reportWriter,
254+
SkipExistingImages: skipExistingImages,
253255
}
254256
if err := CopyImage(copyImageOpts); err != nil {
255257
return errors.Wrapf(err, "failed to copy %s to %s", sourceImage, destImage)
@@ -306,6 +308,25 @@ func CopyImage(opts types.CopyImageOptions) error {
306308
imageListSelection = copy.CopyAllImages
307309
}
308310

311+
// Opt-in idempotency precheck: if the destination tag already holds a manifest
312+
// matching the source, skip the copy. Avoids re-pushing manifests to tag-immutable
313+
// registries. Off by default — callers (CLI flag, admin-console checkbox) enable
314+
// it when targeting registries that enforce tag immutability.
315+
if opts.SkipExistingImages {
316+
matches, err := destinationManifestMatches(context.Background(), opts, srcCtx, destCtx)
317+
if err != nil {
318+
return errors.Wrap(err, "failed manifest precheck")
319+
}
320+
if matches {
321+
destRefName := ""
322+
if opts.DestRef.DockerReference() != nil {
323+
destRefName = opts.DestRef.DockerReference().String()
324+
}
325+
logger.Infof("Skipping push: destination manifest already matches source (dest=%s)", destRefName)
326+
return nil
327+
}
328+
}
329+
309330
_, err := CopyImageWithGC(context.Background(), opts.DestRef, opts.SrcRef, &copy.Options{
310331
RemoveSignatures: true,
311332
SignBy: "",
@@ -315,6 +336,13 @@ func CopyImage(opts types.CopyImageOptions) error {
315336
ForceManifestMIMEType: "",
316337
ImageListSelection: imageListSelection,
317338
PreserveDigests: opts.PreserveDigests,
339+
// Skip per-child copies when the destination already has the same manifest.
340+
// The precheck above handles the parent manifest list; this covers individual
341+
// child images and any case where the parent differs but children are already
342+
// present. The upstream library skips this for the parent manifest-list write
343+
// in the multi-arch path, so the precheck is still required.
344+
// known bug: https://github.com/podman-container-tools/container-libs/issues/918
345+
OptimizeDestinationImageAlreadyExists: opts.SkipExistingImages,
318346
})
319347
if err != nil {
320348
// When copying multi-arch images with PreserveDigests, the library may fail to write
@@ -438,6 +466,96 @@ func getPolicyContext() (*signature.PolicyContext, error) {
438466
return policyContext, nil
439467
}
440468

469+
// destinationManifestMatches reports whether the destination tag already holds a
470+
// manifest equivalent to the source. When true, the push can be skipped —
471+
// this makes the copy idempotent against registries that enforce tag
472+
// immutability and reject overwrite regardless of digest equality.
473+
//
474+
// Equivalence is checked in two passes:
475+
// 1. Raw bytes equal — covers PreserveDigests=true and the pushSourceManifestList
476+
// fallback path, where destination bytes match source bytes verbatim.
477+
// 2. Canonical bytes equal — both manifests are parsed and re-serialized
478+
// through the copy library's own parser. Two manifests with the same
479+
// image content but different on-the-wire form (whitespace, field order,
480+
// small library-normalization differences) round-trip to identical bytes.
481+
// This catches the case where a prior push wrote a library-normalized
482+
// form to the destination so the raw source bytes no longer match.
483+
//
484+
// If the destination is unreachable or the tag does not exist, returns (false, nil)
485+
// so the caller proceeds with the normal push. Only source-side failures surface as
486+
// errors, since a missing or unreadable destination should not block the push.
487+
func destinationManifestMatches(ctx context.Context, opts types.CopyImageOptions, srcCtx, destCtx *containerstypes.SystemContext) (bool, error) {
488+
destImg, err := opts.DestRef.NewImageSource(ctx, destCtx)
489+
if err != nil {
490+
// Destination does not exist or is unreachable; fall through to the normal push.
491+
// Most commonly this is a 404 on first push, but it also masks transient network
492+
// errors, 401/403 from misconfigured destination auth, and 429 rate limits — log
493+
// at debug to leave a trail when the optimization isn't firing as expected.
494+
logger.Errorf("manifest precheck: opening destination failed, falling through to push: %v", err)
495+
return false, nil
496+
}
497+
defer destImg.Close()
498+
499+
destManifest, destMIME, err := destImg.GetManifest(ctx, nil)
500+
if err != nil {
501+
// Destination manifest unreadable; fall through to the normal push.
502+
logger.Errorf("manifest precheck: reading destination manifest failed, falling through to push: %v", err)
503+
return false, nil
504+
}
505+
506+
srcImg, err := opts.SrcRef.NewImageSource(ctx, srcCtx)
507+
if err != nil {
508+
return false, errors.Wrap(err, "failed to open source for manifest precheck")
509+
}
510+
defer srcImg.Close()
511+
512+
srcManifest, srcMIME, err := srcImg.GetManifest(ctx, nil)
513+
if err != nil {
514+
return false, errors.Wrap(err, "failed to get source manifest for precheck")
515+
}
516+
517+
if bytes.Equal(destManifest, srcManifest) {
518+
return true, nil
519+
}
520+
521+
// Second pass: round-trip both manifests through the library's parser+serializer.
522+
// If they describe the same image content, the canonical forms are identical
523+
// even when the raw bytes differ.
524+
canonicalSrc, srcErr := canonicalManifestBytes(srcManifest, srcMIME)
525+
canonicalDest, destErr := canonicalManifestBytes(destManifest, destMIME)
526+
if srcErr == nil && destErr == nil && bytes.Equal(canonicalSrc, canonicalDest) {
527+
logger.Infof("manifest precheck: raw bytes differ but canonical forms match (dest=%s)", opts.DestRef.DockerReference())
528+
return true, nil
529+
}
530+
if srcErr != nil {
531+
logger.Errorf("manifest precheck: canonicalizing source failed: %v", srcErr)
532+
}
533+
if destErr != nil {
534+
logger.Errorf("manifest precheck: canonicalizing destination failed: %v", destErr)
535+
}
536+
537+
return false, nil
538+
}
539+
540+
// canonicalManifestBytes returns the manifest bytes after round-tripping
541+
// through the copy library's parser and serializer. Two semantically equivalent
542+
// manifests with different raw bytes (different whitespace, field order, etc.)
543+
// produce identical canonical bytes.
544+
func canonicalManifestBytes(b []byte, mime string) ([]byte, error) {
545+
if manifest.MIMETypeIsMultiImage(mime) {
546+
list, err := manifest.ListFromBlob(b, mime)
547+
if err != nil {
548+
return nil, err
549+
}
550+
return list.Serialize()
551+
}
552+
m, err := manifest.FromBlob(b, mime)
553+
if err != nil {
554+
return nil, err
555+
}
556+
return m.Serialize()
557+
}
558+
441559
// pushSourceManifestList fetches the raw manifest list from the source and pushes it
442560
// directly to the destination registry. This is used as a fallback when the copy library
443561
// fails to write the manifest list due to spurious byte differences caused by annotation

0 commit comments

Comments
 (0)