digest only regression fix (#643)

Signed-off-by: Adam Martin <adam.martin@ranchergovernment.com>
This commit is contained in:
Adam Martin
2026-06-26 09:55:51 -04:00
committed by GitHub
parent 7d00a53c94
commit d912cfa85f
4 changed files with 110 additions and 2 deletions
+57
View File
@@ -241,6 +241,63 @@ func TestLifecycle_Chart_AddSaveLoadExtract(t *testing.T) {
}
}
// TestLifecycle_DigestOnlyImage_AddSaveLoad exercises the full save/load round-trip
// for an image added by digest only (no tag). Before fix #642, the image disappeared
// from index.json after LoadCmd because CopyAll appended a double-@ digest.
func TestLifecycle_DigestOnlyImage_AddSaveLoad(t *testing.T) {
ctx := newTestContext(t)
// Step 1: seed a tagged image so we can get its digest
srcHost, srcOpts := newLocalhostRegistry(t)
srcImg := seedImage(t, srcHost, "lifecycle/digestonly", "v1", srcOpts...)
hash, err := srcImg.Digest()
if err != nil {
t.Fatalf("srcImg.Digest: %v", err)
}
// Step 2: add BY DIGEST (not tag) into store A
storeA := newTestStore(t)
rso := defaultRootOpts(storeA.Root)
ro := defaultCliOpts()
digestRef := srcHost + "/lifecycle/digestonly@" + hash.String()
if err := storeImage(ctx, storeA, v1.Image{Name: digestRef}, "", false, rso, ro, ""); err != nil {
t.Fatalf("storeImage by digest: %v", err)
}
// The image should be findable by its digest hex
assertArtifactInStore(t, storeA, hash.Hex)
// Flush index.json for SaveCmd
if err := storeA.SaveIndex(); err != nil {
t.Fatalf("SaveIndex: %v", err)
}
// Step 3: SaveCmd -> archive
archivePath := filepath.Join(t.TempDir(), "lifecycle-digestonly.tar.zst")
saveOpts := newSaveOpts(storeA.Root, archivePath)
if err := SaveCmd(ctx, saveOpts, defaultRootOpts(storeA.Root), defaultCliOpts()); err != nil {
t.Fatalf("SaveCmd: %v", err)
}
// Step 4: LoadCmd -> fresh store B
storeBDir := t.TempDir()
loadOpts := &flags.LoadOpts{
StoreRootOpts: defaultRootOpts(storeBDir),
FileName: []string{archivePath},
}
if err := LoadCmd(ctx, loadOpts, defaultRootOpts(storeBDir), defaultCliOpts()); err != nil {
t.Fatalf("LoadCmd: %v", err)
}
storeB, err := store.NewLayout(storeBDir)
if err != nil {
t.Fatalf("store.NewLayout(storeB): %v", err)
}
// Regression assertion: the digest-only image must survive the save/load round-trip.
// Before fix 1, the image disappears from the loaded store's index.json.
assertArtifactInStore(t, storeB, hash.Hex)
}
// TestLifecycle_Remove_ThenSave verifies that removing one artifact from a store
// with two file artifacts, then saving/loading, results in only the retained
// artifact being present.
+14
View File
@@ -322,6 +322,20 @@ func (x *exports) record(ctx context.Context, index libv1.ImageIndex, desc libv1
slices.Sort(xd.RepoTags)
xd.RepoTags = slices.Compact(xd.RepoTags)
ref = tag.Digest(digest)
case name.Digest:
// For digest-only refs, derive a deterministic, docker-valid tag from
// the manifest digest so ctr/docker can import the image (#642).
// Convention mirrors copy.go:229: "sha256-<hex>".
named, err := referencev3.ParseNormalizedNamed(tag.Repository.Name())
if err != nil {
return err
}
familiarRepo := referencev3.FamiliarName(named)
digestTag := strings.ReplaceAll(digest, ":", "-") // e.g. "sha256-498a..."
repotag := familiarRepo + ":" + digestTag
xd.RepoTags = append(xd.RepoTags[:], repotag)
slices.Sort(xd.RepoTags)
xd.RepoTags = slices.Compact(xd.RepoTags)
}
l.Debugf("image [%s]: type=%s, size=%d", ref.Name(), desc.MediaType, desc.Size)
+31
View File
@@ -113,6 +113,37 @@ func TestWriteExportsManifest(t *testing.T) {
})
}
func TestWriteExportsManifest_DigestOnlyImageHasRepoTag(t *testing.T) {
ctx := newTestContext(t)
// Seed a tagged image so we can reference it by digest
host, srcOpts := newLocalhostRegistry(t)
img := seedImage(t, host, "test/digestonly", "v1", srcOpts...)
hash, err := img.Digest()
if err != nil {
t.Fatalf("img.Digest: %v", err)
}
// Add the image BY DIGEST
s := newTestStore(t)
if err := s.AddImage(ctx, host+"/test/digestonly@"+hash.String(), "", false); err != nil {
t.Fatalf("AddImage by digest: %v", err)
}
if err := writeExportsManifest(ctx, s.Root, ""); err != nil {
t.Fatalf("writeExportsManifest: %v", err)
}
entries := readManifestJSON(t, s.Root)
if len(entries) != 1 {
t.Fatalf("expected 1 manifest entry, got %d", len(entries))
}
// Before fix 2, digest-only refs fall through the switch without setting RepoTags.
if len(entries[0].RepoTags) == 0 {
t.Errorf("expected at least one RepoTag for digest-only image, got none")
}
}
func TestWriteExportsManifest_SkipsNonImages(t *testing.T) {
ctx := newTestContext(t)
+8 -2
View File
@@ -753,9 +753,15 @@ func (l *Layout) CopyAll(ctx context.Context, to content.Target, toMapper func(s
toRef = tr
}
// Append the digest to help the target pusher identify the root descriptor
// Format: "reference@digest" allows the pusher to update its index.json
// Append the digest to help the target pusher identify the root descriptor.
// AnnotationRefName for digest-only images already ends in "@sha256:...".
// Strip any existing digest before appending the authoritative descriptor
// digest so the destination pusher can match the root manifest. A double "@"
// yields a digest the pusher never matches, leaving the image unindexed (#642).
if desc.Digest.Validate() == nil {
if at := strings.Index(toRef, "@"); at != -1 {
toRef = toRef[:at]
}
toRef = fmt.Sprintf("%s@%s", toRef, desc.Digest)
}