diff --git a/cmd/hauler/cli/store/lifecycle_test.go b/cmd/hauler/cli/store/lifecycle_test.go index bef8a4f..17f4243 100644 --- a/cmd/hauler/cli/store/lifecycle_test.go +++ b/cmd/hauler/cli/store/lifecycle_test.go @@ -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. diff --git a/cmd/hauler/cli/store/save.go b/cmd/hauler/cli/store/save.go index f0615e4..f316458 100644 --- a/cmd/hauler/cli/store/save.go +++ b/cmd/hauler/cli/store/save.go @@ -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-". + 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) diff --git a/cmd/hauler/cli/store/save_test.go b/cmd/hauler/cli/store/save_test.go index 2a251a8..113fbf2 100644 --- a/cmd/hauler/cli/store/save_test.go +++ b/cmd/hauler/cli/store/save_test.go @@ -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) diff --git a/pkg/store/store.go b/pkg/store/store.go index 10d6a8e..cf05bc3 100644 --- a/pkg/store/store.go +++ b/pkg/store/store.go @@ -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) }