From be8d592ac2bd02596db6e1175faf1f90d844a680 Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:11:31 +0000 Subject: [PATCH 01/20] Move image references to shared content storage --- lib/images/disk_usage.go | 81 +++++- lib/images/disk_usage_test.go | 42 +++ lib/images/manager.go | 79 ++++-- lib/images/manager_test.go | 35 ++- lib/images/storage.go | 501 +++++++++++++++++++++------------- lib/images/storage_refs.go | 354 ++++++++++++++++++++++++ lib/images/storage_test.go | 185 +++++++++++++ lib/paths/paths.go | 29 ++ 8 files changed, 1078 insertions(+), 228 deletions(-) create mode 100644 lib/images/storage_refs.go diff --git a/lib/images/disk_usage.go b/lib/images/disk_usage.go index 47914c27b..b65dba1ac 100644 --- a/lib/images/disk_usage.go +++ b/lib/images/disk_usage.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "syscall" ) // totalReadyImageBytesFromMetadata sums ready image sizes directly from metadata.json files. @@ -14,6 +15,7 @@ import ( // files found in the digest directory so we do not undercount host disk usage. func totalReadyImageBytesFromMetadata(imagesDir string) (int64, error) { var total int64 + seenRootfs := make(map[rootfsIdentity]struct{}) err := filepath.Walk(imagesDir, func(path string, info os.FileInfo, err error) error { if err != nil { @@ -28,7 +30,7 @@ func totalReadyImageBytesFromMetadata(imagesDir string) (int64, error) { data, err := os.ReadFile(path) if err != nil { - rootfsBytes, fallbackErr := totalRootfsBytesInDigestDir(filepath.Dir(path)) + rootfsBytes, fallbackErr := totalUniqueRootfsBytesInDigestDir(filepath.Dir(path), seenRootfs) if fallbackErr == nil { total += rootfsBytes return nil @@ -38,18 +40,33 @@ func totalReadyImageBytesFromMetadata(imagesDir string) (int64, error) { var meta imageMetadata if err := json.Unmarshal(data, &meta); err != nil { - rootfsBytes, fallbackErr := totalRootfsBytesInDigestDir(filepath.Dir(path)) + rootfsBytes, fallbackErr := totalUniqueRootfsBytesInDigestDir(filepath.Dir(path), seenRootfs) if fallbackErr == nil { total += rootfsBytes return nil } return fmt.Errorf("unmarshal image metadata %s: %w", path, err) } - if meta.Status == StatusReady && meta.SizeBytes > 0 { - total += meta.SizeBytes - return nil - } if meta.Status == StatusReady { + rootfsPaths, globErr := filepath.Glob(filepath.Join(filepath.Dir(path), "rootfs.*")) + if globErr != nil { + return fmt.Errorf("find ready image rootfs for %s: %w", path, globErr) + } + for _, rootfsPath := range rootfsPaths { + rootfsInfo, statErr := os.Stat(rootfsPath) + if statErr != nil { + continue + } + if !markUniqueRootfs(rootfsInfo, seenRootfs) { + return nil + } + break + } + + if meta.SizeBytes > 0 { + total += meta.SizeBytes + return nil + } rootfsBytes, err := totalRootfsBytesInDigestDir(filepath.Dir(path)) if err != nil { return fmt.Errorf("stat ready image rootfs for %s: %w", path, err) @@ -172,3 +189,55 @@ func totalRootfsBytesInDigestDir(digestDir string) (int64, error) { } return total, nil } + +type rootfsIdentity struct { + dev uint64 + ino uint64 +} + +func markUniqueRootfs(info os.FileInfo, seen map[rootfsIdentity]struct{}) bool { + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return true + } + identity := rootfsIdentity{dev: uint64(stat.Dev), ino: uint64(stat.Ino)} + if _, exists := seen[identity]; exists { + return false + } + seen[identity] = struct{}{} + return true +} + +func totalUniqueRootfsBytesInDigestDir(digestDir string, seen map[rootfsIdentity]struct{}) (int64, error) { + rootfsPaths, err := filepath.Glob(filepath.Join(digestDir, "rootfs.*")) + if err != nil { + return 0, err + } + if len(rootfsPaths) == 0 { + return 0, os.ErrNotExist + } + + var total int64 + found := false + for _, rootfsPath := range rootfsPaths { + info, err := os.Stat(rootfsPath) + if err != nil { + if os.IsNotExist(err) { + continue + } + return 0, err + } + if info.IsDir() { + continue + } + found = true + if !markUniqueRootfs(info, seen) { + continue + } + total += info.Size() + } + if !found { + return 0, os.ErrNotExist + } + return total, nil +} diff --git a/lib/images/disk_usage_test.go b/lib/images/disk_usage_test.go index 36e5bc0df..bd6056fef 100644 --- a/lib/images/disk_usage_test.go +++ b/lib/images/disk_usage_test.go @@ -22,6 +22,48 @@ func TestTotalReadyImageBytesFromMetadata_UsesRootfsFallbackForMalformedMetadata require.Equal(t, int64(len("rootfs-data")), total) } +func TestTotalReadyImageBytesFromMetadata_DeduplicatesHardLinkedAliases(t *testing.T) { + t.Parallel() + + imagesDir := t.TempDir() + sourceDir := filepath.Join(imagesDir, "source", "digest") + targetDir := filepath.Join(imagesDir, "target", "digest") + require.NoError(t, os.MkdirAll(sourceDir, 0o755)) + require.NoError(t, os.MkdirAll(targetDir, 0o755)) + + sourceRootfs := filepath.Join(sourceDir, "rootfs.erofs") + targetRootfs := filepath.Join(targetDir, "rootfs.erofs") + require.NoError(t, os.WriteFile(sourceRootfs, []byte("shared-rootfs"), 0o644)) + require.NoError(t, os.Link(sourceRootfs, targetRootfs)) + metadata := []byte(`{"status":"ready","size_bytes":13}`) + require.NoError(t, os.WriteFile(filepath.Join(sourceDir, "metadata.json"), metadata, 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(targetDir, "metadata.json"), metadata, 0o644)) + + total, err := totalReadyImageBytesFromMetadata(imagesDir) + require.NoError(t, err) + require.Equal(t, int64(len("shared-rootfs")), total) +} + +func TestTotalReadyImageBytesFromMetadata_DeduplicatesMalformedAliases(t *testing.T) { + t.Parallel() + + imagesDir := t.TempDir() + malformedDir := filepath.Join(imagesDir, "a-malformed", "digest") + validDir := filepath.Join(imagesDir, "b-valid", "digest") + require.NoError(t, os.MkdirAll(malformedDir, 0o755)) + require.NoError(t, os.MkdirAll(validDir, 0o755)) + + rootfs := filepath.Join(malformedDir, "rootfs.erofs") + require.NoError(t, os.WriteFile(rootfs, []byte("shared-rootfs"), 0o644)) + require.NoError(t, os.Link(rootfs, filepath.Join(validDir, "rootfs.erofs"))) + require.NoError(t, os.WriteFile(filepath.Join(malformedDir, "metadata.json"), []byte("{not-json"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(validDir, "metadata.json"), []byte(`{"status":"ready","size_bytes":13}`), 0o644)) + + total, err := totalReadyImageBytesFromMetadata(imagesDir) + require.NoError(t, err) + require.Equal(t, int64(len("shared-rootfs")), total) +} + func TestTotalReadyImageBytesFromMetadata_UsesRootfsFallbackForReadyImageWithoutSize(t *testing.T) { t.Parallel() diff --git a/lib/images/manager.go b/lib/images/manager.go index c1a0da0ac..8e263902b 100644 --- a/lib/images/manager.go +++ b/lib/images/manager.go @@ -209,23 +209,29 @@ func (m *manager) CreateImage(ctx context.Context, req CreateImageRequest) (*Ima if meta, err := readMetadata(m.paths, ref.Repository(), ref.DigestHex()); err == nil { // Don't cache failed builds - allow retry if meta.Status == StatusFailed { - // Clean up the failed build directory so we can retry - digestDir := filepath.Join(m.paths.ImagesDir(), ref.Repository(), ref.DigestHex()) - os.RemoveAll(digestDir) + // Clean up the failed build directory so we can retry. Shared content + // is retained if another tag or pull still references it. + if err := removeDigestIfUnreferenced(m.paths, ref.Repository(), ref.DigestHex(), false); err != nil { + return nil, fmt.Errorf("remove failed image: %w", err) + } // Fall through to re-queue the build } else { // We have this digest already (ready, pending, pulling, or converting). - // last-pull-wins: repoint the tag to it (see createTagSymlink). - if meta.Status == StatusReady { - if ref.Tag() != "" { - createTagSymlink(m.paths, ref.Repository(), ref.Tag(), ref.DigestHex()) + // Register the requested tag immediately so a second caller can wait on + // the same shared content while its build is still in progress. + if ref.Tag() != "" { + if err := createTagSymlink(m.paths, ref.Repository(), ref.Tag(), ref.DigestHex()); err != nil { + return nil, fmt.Errorf("create image tag: %w", err) } - return meta.toImage(), nil + } + img := meta.toImage() + img.Name = ref.String() + if meta.Status == StatusReady { + return img, nil } if !m.inflightCredentialsMatch(ref.Digest(), req.Credentials) { return nil, fmt.Errorf("%w: retry after the current pull completes", ErrCredentialConflict) } - img := meta.toImage() if meta.Status == StatusPending { img.QueuePosition = m.queue.GetPosition(meta.Digest) } @@ -267,15 +273,20 @@ func (m *manager) ImportLocalImage(ctx context.Context, repo, reference, digest if meta, err := readMetadata(m.paths, ref.Repository(), ref.DigestHex()); err == nil { // Don't cache failed builds - allow retry if meta.Status == StatusFailed { - digestDir := filepath.Join(m.paths.ImagesDir(), ref.Repository(), ref.DigestHex()) - os.RemoveAll(digestDir) + if err := removeDigestIfUnreferenced(m.paths, ref.Repository(), ref.DigestHex(), false); err != nil { + return nil, fmt.Errorf("remove failed image: %w", err) + } // Fall through to re-queue the build } else { - // We have this digest already; last-pull-wins repoints the tag. - if meta.Status == StatusReady && ref.Tag() != "" { - createTagSymlink(m.paths, ref.Repository(), ref.Tag(), ref.DigestHex()) + // Register the requested tag immediately so callers can wait on shared + // content while its build is still in progress. + if ref.Tag() != "" { + if err := createTagSymlink(m.paths, ref.Repository(), ref.Tag(), ref.DigestHex()); err != nil { + return nil, fmt.Errorf("create image tag: %w", err) + } } img := meta.toImage() + img.Name = ref.String() if meta.Status == StatusPending { img.QueuePosition = m.queue.GetPosition(meta.Digest) } @@ -465,10 +476,14 @@ func (m *manager) buildImage(ctx context.Context, ref *ResolvedRef, credentials m.updateStatusByDigest(ref, StatusConverting, nil, buildID) - diskPath := digestPath(m.paths, ref.Repository(), ref.DigestHex()) + diskPath := resolveImageLayout(m.paths, ref.Repository(), ref.DigestHex()).disk + // Keep the temporary filesystem beside its final path so finalization stays + // atomic even when system/builds and images are on different filesystems. + diskTempPath := diskPath + ".tmp-" + buildID + defer os.Remove(diskTempPath) // Use default image format (erofs on Linux, ext4 on Darwin) convertStart := time.Now() - diskSize, err := ExportRootfs(tempDir, diskPath, DefaultImageFormat) + diskSize, err := ExportRootfs(tempDir, diskTempPath, DefaultImageFormat) m.recordImageBuildPhase(ctx, ref.Digest(), "filesystem_export", time.Since(convertStart), phaseStatus(err), "not_applicable") if err != nil { m.updateStatusByDigest(ref, StatusFailed, fmt.Errorf("convert to %s: %w", DefaultImageFormat, err), buildID) @@ -476,7 +491,7 @@ func (m *manager) buildImage(ctx context.Context, ref *ResolvedRef, credentials } finalizeStart := time.Now() - err = m.finalizeImage(ref, result, diskSize, buildID) + err = m.finalizeImage(ref, result, diskSize, buildID, diskTempPath) m.recordImageBuildPhase(ctx, ref.Digest(), "finalize", time.Since(finalizeStart), phaseStatus(err), "not_applicable") if err != nil { if errors.Is(err, errStaleBuild) { @@ -489,7 +504,11 @@ func (m *manager) buildImage(ctx context.Context, ref *ResolvedRef, credentials buildStatus = "success" } -func (m *manager) finalizeImage(ref *ResolvedRef, result *pullResult, diskSize int64, buildID string) error { +func (m *manager) finalizeImage(ref *ResolvedRef, result *pullResult, diskSize int64, buildID, diskTempPath string) error { + if diskTempPath != "" { + defer os.Remove(diskTempPath) + } + m.createMu.Lock() defer m.createMu.Unlock() @@ -499,6 +518,13 @@ func (m *manager) finalizeImage(ref *ResolvedRef, result *pullResult, diskSize i return errStaleBuild } + finalDiskPath := resolveImageLayout(m.paths, ref.Repository(), ref.DigestHex()).disk + if err := installAtomically(finalDiskPath, func(path string) error { + return os.Rename(diskTempPath, path) + }); err != nil { + return fmt.Errorf("install image disk: %w", err) + } + // The pulled image config is the source of truth for the platform. var requestedPlatform string if meta.Request != nil { @@ -608,6 +634,7 @@ func (m *manager) RecoverInterruptedBuilds() { return metas[i].CreatedAt.Before(metas[j].CreatedAt) }) + seenDigests := make(map[string]struct{}) for _, meta := range metas { if meta.Status != StatusPending && meta.Status != StatusPulling && meta.Status != StatusConverting { continue @@ -615,6 +642,10 @@ func (m *manager) RecoverInterruptedBuilds() { if meta.Request == nil || meta.Digest == "" { continue } + if _, seen := seenDigests[meta.Digest]; seen { + continue + } + seenDigests[meta.Digest] = struct{}{} normalized, err := ParseNormalizedRef(meta.Name) if err != nil { continue @@ -660,6 +691,7 @@ func (m *manager) GetImage(ctx context.Context, name string) (*Image, error) { } img := meta.toImage() + img.Name = ref.String() if meta.Status == StatusPending { img.QueuePosition = m.queue.GetPosition(meta.Digest) @@ -690,7 +722,7 @@ func (m *manager) DeleteImage(ctx context.Context, name string) error { if err := deleteTagsForDigest(m.paths, repository, digestHex); err != nil { return err } - if err := deleteDigest(m.paths, repository, digestHex); err != nil { + if err := removeDigestIfUnreferenced(m.paths, repository, digestHex, false); err != nil { return err } m.refreshDiskUsageTotals() @@ -713,15 +745,12 @@ func (m *manager) DeleteImage(ctx context.Context, name string) error { // Check if the digest is now orphaned (no other tags reference it) count, err := countTagsForDigest(m.paths, repository, digestHex) if err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to count tags for digest %s: %v\n", digestHex, err) - return nil + return fmt.Errorf("count tags for digest %s: %w", digestHex, err) } if count == 0 { - // Digest is orphaned, delete it - if err := deleteDigest(m.paths, repository, digestHex); err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to delete orphaned digest %s: %v\n", digestHex, err) - return nil + if err := removeDigestIfUnreferenced(m.paths, repository, digestHex, true); err != nil { + return fmt.Errorf("delete orphaned digest %s: %w", digestHex, err) } m.refreshDiskUsageTotals() } diff --git a/lib/images/manager_test.go b/lib/images/manager_test.go index 89d3fb4fe..659a7f67f 100644 --- a/lib/images/manager_test.go +++ b/lib/images/manager_test.go @@ -395,6 +395,39 @@ func TestDeleteImagePreservesSharedDigest(t *testing.T) { require.True(t, os.IsNotExist(err), "digest directory should be deleted when last tag is removed") } +func TestDeleteImagePreservesCrossRepositoryContent(t *testing.T) { + p := paths.New(t.TempDir()) + mgr, err := NewManager(p, 1, nil) + require.NoError(t, err) + + const digest = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + meta := &imageMetadata{ + Name: "docker.io/library/alpine@sha256:" + digest, + Digest: "sha256:" + digest, + Status: StatusReady, + SizeBytes: 6, + CreatedAt: time.Now().UTC(), + } + require.NoError(t, writeMetadataFile(p.ImageContentMetadata(digest), meta)) + require.NoError(t, os.WriteFile(p.ImageContentPath(digest), []byte("rootfs"), 0o644)) + require.NoError(t, createTagSymlink(p, "docker.io/library/alpine", "latest", digest)) + require.NoError(t, createTagSymlink(p, "registry.example.com/app", "v1", digest)) + + require.NoError(t, mgr.DeleteImage(context.Background(), "docker.io/library/alpine:latest")) + _, err = mgr.GetImage(context.Background(), "registry.example.com/app:v1") + require.NoError(t, err) + _, err = os.Stat(p.ImageContentPath(digest)) + require.NoError(t, err) + + require.NoError(t, mgr.DeleteImage(context.Background(), "registry.example.com/app:v1")) + _, err = os.Stat(p.ImageContentDir(digest)) + require.NoError(t, err) + + require.NoError(t, mgr.DeleteImage(context.Background(), "docker.io/library/alpine@sha256:"+digest)) + _, err = os.Stat(p.ImageContentDir(digest)) + require.ErrorIs(t, err, os.ErrNotExist) +} + func TestNormalizedRefParsing(t *testing.T) { tests := []struct { input string @@ -698,7 +731,7 @@ func TestDeleteAndRecreateDuringBuildTail(t *testing.T) { m.updateStatusByDigest(staleRef, StatusFailed, errors.New("stale build"), firstMeta.BuildID) staleResult, _, _, err := m.ociClient.extractOCIImageDetails(digestHex) require.NoError(t, err) - require.ErrorIs(t, m.finalizeImage(staleRef, &pullResult{Metadata: staleResult}, 1, firstMeta.BuildID), errStaleBuild) + require.ErrorIs(t, m.finalizeImage(staleRef, &pullResult{Metadata: staleResult}, 1, firstMeta.BuildID, ""), errStaleBuild) currentMeta, err = readMetadata(p, repo, digestHex) require.NoError(t, err) require.Equal(t, StatusPending, currentMeta.Status) diff --git a/lib/images/storage.go b/lib/images/storage.go index 8764400c1..9adbbfcb4 100644 --- a/lib/images/storage.go +++ b/lib/images/storage.go @@ -77,16 +77,73 @@ func (m *imageMetadata) toImage() *Image { return img } -// digestDir returns the directory for a specific digest -// e.g., /var/lib/hypeman/images/docker.io/library/alpine/abc123def456... +// imageLayout contains the paths for one image layout. +type imageLayout struct { + dir string + metadata string + disk string + content bool +} + +// resolveImageLayout selects one layout for all operations on a digest. A +// complete legacy image remains authoritative while content is incomplete; +// once content metadata is ready, content becomes canonical even if its disk +// is missing so callers report the corruption instead of mixing layouts. +func resolveImageLayout(p *paths.Paths, repository, digestHex string) imageLayout { + legacy := imageLayout{ + dir: p.ImageDigestDir(repository, digestHex), + metadata: p.ImageMetadata(repository, digestHex), + disk: p.ImageDigestPath(repository, digestHex), + } + content := imageLayout{ + dir: p.ImageContentDir(digestHex), + metadata: p.ImageContentMetadata(digestHex), + disk: p.ImageContentPath(digestHex), + content: true, + } + + if legacyImageExists(p, repository, digestHex) { + contentStatus, contentOK := metadataStatus(content.metadata) + if !contentOK || contentStatus != StatusReady { + return legacy + } + } + if pathExists(content.metadata) || pathExists(content.disk) { + return content + } + if pathExists(legacy.metadata) { + return legacy + } + return content +} + +func pathExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// digestDir returns the directory for a specific digest, using the same layout +// selection as metadata and disk lookup. func digestDir(p *paths.Paths, repository, digestHex string) string { - return p.ImageDigestDir(repository, digestHex) + return resolveImageLayout(p, repository, digestHex).dir } -// digestPath returns the path to the rootfs disk file for a digest -// Uses .erofs on Linux (compressed) or .ext4 on Darwin (VZ kernel lacks erofs support) -func digestPath(p *paths.Paths, repository, digestHex string) string { - return p.ImageDigestPath(repository, digestHex) +func legacyImageExists(p *paths.Paths, repository, digestHex string) bool { + _, metadataErr := os.Stat(p.ImageMetadata(repository, digestHex)) + _, diskErr := os.Stat(p.ImageDigestPath(repository, digestHex)) + return metadataErr == nil && diskErr == nil +} + +func metadataStatus(path string) (string, bool) { + data, err := os.ReadFile(path) + if err != nil { + return "", false + } + var meta imageMetadata + if err := json.Unmarshal(data, &meta); err != nil { + return "", false + } + return meta.Status, true } // GetDiskPath returns the filesystem path to an image's rootfs disk file (public for instances manager) @@ -100,25 +157,34 @@ func GetDiskPath(p *paths.Paths, imageName string, digest string) (string, error // Extract digest hex (remove "sha256:" prefix) digestHex := strings.TrimPrefix(digest, "sha256:") - return digestPath(p, ref.Repository(), digestHex), nil + return resolveImageLayout(p, ref.Repository(), digestHex).disk, nil +} + +func digestPath(p *paths.Paths, repository, digestHex string) string { + return resolveImageLayout(p, repository, digestHex).disk } -// metadataPath returns the path to metadata.json for a digest func metadataPath(p *paths.Paths, repository, digestHex string) string { - return p.ImageMetadata(repository, digestHex) + return resolveImageLayout(p, repository, digestHex).metadata } -// tagSymlinkPath returns the path to a tag symlink -// e.g., /var/lib/hypeman/images/docker.io/library/alpine/latest +// tagSymlinkPath returns the path to a tag symlink in the active layout. func tagSymlinkPath(p *paths.Paths, repository, tag string) string { + newPath := p.ImageRepositoryTagSymlink(repository, tag) + if _, err := os.Lstat(newPath); err == nil { + return newPath + } return p.ImageTagSymlink(repository, tag) } -// writeMetadata writes metadata for a digest +// writeMetadata writes metadata for a digest. func writeMetadata(p *paths.Paths, repository, digestHex string, meta *imageMetadata) error { - dir := digestDir(p, repository, digestHex) - if err := os.MkdirAll(dir, 0755); err != nil { - return fmt.Errorf("create digest directory: %w", err) + return writeMetadataFile(resolveImageLayout(p, repository, digestHex).metadata, meta) +} + +func writeMetadataFile(path string, meta *imageMetadata) error { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return fmt.Errorf("create metadata directory: %w", err) } data, err := json.MarshalIndent(meta, "", " ") @@ -126,23 +192,32 @@ func writeMetadata(p *paths.Paths, repository, digestHex string, meta *imageMeta return fmt.Errorf("marshal metadata: %w", err) } - tempPath := metadataPath(p, repository, digestHex) + ".tmp" + tempPath := path + ".tmp" if err := os.WriteFile(tempPath, data, 0644); err != nil { return fmt.Errorf("write temp metadata: %w", err) } - - finalPath := metadataPath(p, repository, digestHex) - if err := os.Rename(tempPath, finalPath); err != nil { - os.Remove(tempPath) + if err := os.Rename(tempPath, path); err != nil { + _ = os.Remove(tempPath) return fmt.Errorf("rename metadata: %w", err) } - return nil } -// readMetadata reads metadata for a digest func readMetadata(p *paths.Paths, repository, digestHex string) (*imageMetadata, error) { - path := metadataPath(p, repository, digestHex) + return readMetadataAt(resolveImageLayout(p, repository, digestHex)) +} + +func readContentMetadata(p *paths.Paths, digestHex string) (*imageMetadata, error) { + return readMetadataAt(imageLayout{ + dir: p.ImageContentDir(digestHex), + metadata: p.ImageContentMetadata(digestHex), + disk: p.ImageContentPath(digestHex), + content: true, + }) +} + +func readMetadataAt(layout imageLayout) (*imageMetadata, error) { + path := layout.metadata data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { @@ -157,10 +232,9 @@ func readMetadata(p *paths.Paths, repository, digestHex string) (*imageMetadata, } if meta.Status == StatusReady { - diskPath := digestPath(p, repository, digestHex) - if _, err := os.Stat(diskPath); err != nil { + if _, err := os.Stat(layout.disk); err != nil { if os.IsNotExist(err) { - return nil, fmt.Errorf("disk image missing: %s", diskPath) + return nil, fmt.Errorf("disk image missing: %s", layout.disk) } return nil, fmt.Errorf("stat disk image: %w", err) } @@ -169,220 +243,255 @@ func readMetadata(p *paths.Paths, repository, digestHex string) (*imageMetadata, return &meta, nil } -// createTagSymlink creates or updates a tag symlink to point to a digest (only -// if the digest dir exists and the build is ready). -// -// Tag ownership is Docker last-pull-wins: the most recent pull of a tag always -// owns the symlink, regardless of platform. An earlier gate only repointed for -// host-native pulls, which silently stranded emulated variants (e.g. -// `pull --platform linux/amd64 alpine:3.19` could never make `image get` report -// amd64) and was non-recoverable. Always repointing is symmetric and matches -// Docker; callers repoint unconditionally on a ready digest. -func createTagSymlink(p *paths.Paths, repository, tag, digestHex string) error { - linkPath := tagSymlinkPath(p, repository, tag) - targetPath := digestHex // Relative path (just the digest hex) - - // Ensure parent directory exists - if err := os.MkdirAll(filepath.Dir(linkPath), 0755); err != nil { - return fmt.Errorf("create parent directory: %w", err) +func promoteImageToContent(p *paths.Paths, sourceRepository, digestHex string, sourceMeta *imageMetadata) error { + contentReady := false + if contentMeta, err := readContentMetadata(p, digestHex); err == nil { + contentReady = contentMeta.Status == StatusReady } - - // Remove existing symlink if present - os.Remove(linkPath) - - // Create new symlink - if err := os.Symlink(targetPath, linkPath); err != nil { - return fmt.Errorf("create symlink: %w", err) - } - - return nil -} - -// resolveTag follows a tag symlink to get the digest hex -func resolveTag(p *paths.Paths, repository, tag string) (string, error) { - linkPath := tagSymlinkPath(p, repository, tag) - - // Read the symlink - target, err := os.Readlink(linkPath) - if err != nil { - if os.IsNotExist(err) { - return "", ErrNotFound + if !contentReady { + sourceLayout := resolveImageLayout(p, sourceRepository, digestHex) + sourceDiskPath := sourceLayout.disk + if _, err := os.Stat(sourceDiskPath); err != nil { + return fmt.Errorf("stat source disk: %w", err) } - return "", fmt.Errorf("read symlink: %w", err) - } - - // Validate it's just a digest hex (not an absolute path) - if filepath.IsAbs(target) || strings.Contains(target, "/") { - return "", fmt.Errorf("invalid symlink target: %s", target) - } - - return target, nil -} - -// listTags returns all tags for a repository -func listTags(p *paths.Paths, repository string) ([]string, error) { - repoDir := p.ImageRepositoryDir(repository) - - entries, err := os.ReadDir(repoDir) - if err != nil { - if os.IsNotExist(err) { - return nil, nil + if err := os.MkdirAll(p.ImageContentDir(digestHex), 0755); err != nil { + return fmt.Errorf("create content directory: %w", err) } - return nil, fmt.Errorf("read repository directory: %w", err) - } - - var tags []string - for _, entry := range entries { - // Check if it's a symlink - info, err := os.Lstat(filepath.Join(repoDir, entry.Name())) - if err != nil { - continue + contentMeta := *sourceMeta + contentMeta.Status = StatusConverting + if err := writeMetadataFile(p.ImageContentMetadata(digestHex), &contentMeta); err != nil { + return fmt.Errorf("write content metadata: %w", err) } - - if info.Mode()&os.ModeSymlink != 0 { - tags = append(tags, entry.Name()) + if err := installAtomically(p.ImageContentPath(digestHex), func(path string) error { + return os.Link(sourceDiskPath, path) + }); err != nil { + return fmt.Errorf("link source disk: %w", err) + } + if err := writeMetadataFile(p.ImageContentMetadata(digestHex), sourceMeta); err != nil { + return fmt.Errorf("finalize content metadata: %w", err) } } - return tags, nil -} - -// listAllMetadata returns one metadata record per digest across all repositories. -// Tagged images are discovered through tag symlinks, and digest-only images are -// discovered directly from their metadata.json files. -func listAllMetadata(p *paths.Paths) ([]*imageMetadata, error) { - imagesDir := p.ImagesDir() - seen := make(map[string]struct{}) - metas := make([]*imageMetadata, 0) - - err := filepath.Walk(imagesDir, func(path string, info os.FileInfo, err error) error { + // A legacy source may still have tags pointing at its repository-local + // digest directory. Move those references to the shared content before + // removing the duplicate legacy tree. + legacyDir := p.ImageDigestDir(sourceRepository, digestHex) + if _, err := os.Stat(legacyDir); err == nil { + tags, err := listTags(p, sourceRepository) if err != nil { - return nil // Skip errors + return err } - - switch { - case info.Mode()&os.ModeSymlink != 0: - digestHex, err := os.Readlink(path) - if err != nil { - return nil // Skip invalid symlinks + staged := make([]stagedTagSymlink, 0, len(tags)) + cleanupStaged := func() { + for _, ref := range staged { + _ = os.RemoveAll(ref.tempDir) } - - repository, err := filepath.Rel(imagesDir, filepath.Dir(path)) - if err != nil { - return nil + } + defer cleanupStaged() + for _, tag := range tags { + target, err := resolveTag(p, sourceRepository, tag) + if err != nil || target != digestHex { + continue } - - return appendMetadataIfNew(p, repository, digestHex, seen, &metas) - case !info.IsDir() && info.Name() == "metadata.json": - digestHex := filepath.Base(filepath.Dir(path)) - repository, err := filepath.Rel(imagesDir, filepath.Dir(filepath.Dir(path))) + ref, err := stageTagSymlink(p, sourceRepository, tag, digestHex) if err != nil { - return nil + return fmt.Errorf("stage legacy tag %s: %w", tag, err) + } + staged = append(staged, ref) + } + for i, ref := range staged { + if err := os.Rename(ref.tempPath, ref.linkPath); err != nil { + if rollbackErr := rollbackTagSymlinks(staged[:i]); rollbackErr != nil { + return errors.Join(fmt.Errorf("promote legacy tag: %w", err), rollbackErr) + } + return fmt.Errorf("promote legacy tag: %w", err) + } + } + staleClean := true + for _, ref := range staged { + if err := removeStaleTagSymlink(p, &ref); err != nil { + staleClean = false + fmt.Fprintf(os.Stderr, "Warning: failed to remove stale tag symlink %s: %v\n", ref.tag, err) + } + } + if staleClean { + if err := os.RemoveAll(legacyDir); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to remove legacy digest directory %s: %v\n", digestHex, err) } - - return appendMetadataIfNew(p, repository, digestHex, seen, &metas) - default: - return nil } - }) - - if err != nil && !os.IsNotExist(err) { - return nil, fmt.Errorf("walk images directory: %w", err) } - return metas, nil + return nil } -func appendMetadataIfNew(p *paths.Paths, repository, digestHex string, seen map[string]struct{}, metas *[]*imageMetadata) error { - key := repository + "@" + digestHex - if _, ok := seen[key]; ok { - return nil +func installAtomically(path string, install func(string) error) error { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err } - - meta, err := readMetadata(p, repository, digestHex) + tempDir, err := os.MkdirTemp(filepath.Dir(path), ".install-*") if err != nil { - return nil // Skip if metadata can't be read + return err } + defer os.RemoveAll(tempDir) + tempPath := filepath.Join(tempDir, filepath.Base(path)) + if err := install(tempPath); err != nil { + return err + } + return os.Rename(tempPath, path) +} - seen[key] = struct{}{} - *metas = append(*metas, meta) - return nil +type symlinkState struct { + exists bool + target string } -// digestExists checks if a digest directory exists -func digestExists(p *paths.Paths, repository, digestHex string) bool { - dir := digestDir(p, repository, digestHex) - _, err := os.Stat(dir) - return err == nil +type stagedTagSymlink struct { + repository string + tag string + linkPath string + tempDir string + tempPath string + previous symlinkState } -// deleteTag removes a tag symlink (does not delete the digest directory) -func deleteTag(p *paths.Paths, repository, tag string) error { - linkPath := tagSymlinkPath(p, repository, tag) +func stageTagSymlink(p *paths.Paths, repository, tag, digestHex string) (stagedTagSymlink, error) { + layout := resolveImageLayout(p, repository, digestHex) + linkPath := p.ImageTagSymlink(repository, tag) + targetPath := digestHex // Relative path (just the digest hex) + if layout.content { + linkPath = p.ImageRepositoryTagSymlink(repository, tag) + var err error + targetPath, err = filepath.Rel(filepath.Dir(linkPath), layout.dir) + if err != nil { + return stagedTagSymlink{}, fmt.Errorf("calculate content symlink target: %w", err) + } + } + previous, err := readSymlinkState(linkPath) + if err != nil { + return stagedTagSymlink{}, err + } + if err := os.MkdirAll(filepath.Dir(linkPath), 0755); err != nil { + return stagedTagSymlink{}, fmt.Errorf("create parent directory: %w", err) + } + tempDir, err := os.MkdirTemp(filepath.Dir(linkPath), ".tag-stage-*") + if err != nil { + return stagedTagSymlink{}, fmt.Errorf("create temporary tag directory: %w", err) + } + tempPath := filepath.Join(tempDir, filepath.Base(linkPath)) + if err := os.Symlink(targetPath, tempPath); err != nil { + _ = os.RemoveAll(tempDir) + return stagedTagSymlink{}, fmt.Errorf("create temporary tag symlink: %w", err) + } + return stagedTagSymlink{ + repository: repository, + tag: tag, + linkPath: linkPath, + tempDir: tempDir, + tempPath: tempPath, + previous: previous, + }, nil +} - // Check if symlink exists - if _, err := os.Lstat(linkPath); err != nil { +func readSymlinkState(path string) (symlinkState, error) { + target, err := os.Readlink(path) + if err != nil { if os.IsNotExist(err) { - return ErrNotFound + return symlinkState{}, nil } - return fmt.Errorf("stat symlink: %w", err) + return symlinkState{}, fmt.Errorf("read existing tag symlink: %w", err) } + return symlinkState{exists: true, target: target}, nil +} - // Remove symlink - if err := os.Remove(linkPath); err != nil { - return fmt.Errorf("remove symlink: %w", err) +func restoreSymlinkState(path string, state symlinkState) error { + if !state.exists { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove tag symlink during rollback: %w", err) + } + return nil + } + if err := installAtomically(path, func(tempPath string) error { + return os.Symlink(state.target, tempPath) + }); err != nil { + return fmt.Errorf("restore tag symlink during rollback: %w", err) } - return nil } -// countTagsForDigest counts how many tags in a repository point to a given digest -func countTagsForDigest(p *paths.Paths, repository, digestHex string) (int, error) { - tags, err := listTags(p, repository) - if err != nil { - return 0, err +func rollbackTagSymlinks(refs []stagedTagSymlink) error { + var rollbackErrs []error + for i := len(refs) - 1; i >= 0; i-- { + if err := restoreSymlinkState(refs[i].linkPath, refs[i].previous); err != nil { + rollbackErrs = append(rollbackErrs, err) + } } + return errors.Join(rollbackErrs...) +} - count := 0 - for _, tag := range tags { - target, err := resolveTag(p, repository, tag) - if err != nil { - continue - } - if target == digestHex { - count++ - } +func removeStaleTagSymlink(p *paths.Paths, ref *stagedTagSymlink) error { + stalePath := p.ImageTagSymlink(ref.repository, ref.tag) + if stalePath == ref.linkPath { + stalePath = p.ImageRepositoryTagSymlink(ref.repository, ref.tag) + } + if err := os.Remove(stalePath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove stale tag symlink: %w", err) } - return count, nil + return nil } -func deleteTagsForDigest(p *paths.Paths, repository, digestHex string) error { - tags, err := listTags(p, repository) +// createTagSymlink creates or updates a tag symlink to point to a digest (only +// if the digest dir exists and the build is ready). +// +// Tag ownership is Docker last-pull-wins: the most recent pull of a tag always +// owns the symlink, regardless of platform. An earlier gate only repointed for +// host-native pulls, which silently stranded emulated variants (e.g. +// `pull --platform linux/amd64 alpine:3.19` could never make `image get` report +// amd64) and was non-recoverable. Always repointing is symmetric and matches +// Docker; callers repoint unconditionally on a ready digest. +func createTagSymlink(p *paths.Paths, repository, tag, digestHex string) error { + ref, err := stageTagSymlink(p, repository, tag, digestHex) if err != nil { - return err + return fmt.Errorf("stage tag symlink: %w", err) } - - for _, tag := range tags { - target, err := resolveTag(p, repository, tag) - if err != nil { - continue - } - if target != digestHex { - continue - } - if err := deleteTag(p, repository, tag); err != nil && !errors.Is(err, ErrNotFound) { - return err - } + if err := os.Rename(ref.tempPath, ref.linkPath); err != nil { + _ = os.RemoveAll(ref.tempDir) + return fmt.Errorf("install tag symlink: %w", err) } - + if err := removeStaleTagSymlink(p, &ref); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to remove stale tag symlink %s: %v\n", tag, err) + } + _ = os.RemoveAll(ref.tempDir) return nil } -// deleteDigest removes a digest directory and all its contents -func deleteDigest(p *paths.Paths, repository, digestHex string) error { - dir := digestDir(p, repository, digestHex) - if err := os.RemoveAll(dir); err != nil { - return fmt.Errorf("remove digest directory: %w", err) +// resolveTag follows a tag symlink to get the digest hex +func resolveTag(p *paths.Paths, repository, tag string) (string, error) { + linkPath := tagSymlinkPath(p, repository, tag) + + // Read the symlink + target, err := os.Readlink(linkPath) + if err != nil { + if os.IsNotExist(err) { + return "", ErrNotFound + } + return "", fmt.Errorf("read symlink: %w", err) } - return nil + + // Legacy links contain only the digest. New links point relatively into the + // shared content directory; validate that they resolve to that digest only. + if filepath.IsAbs(target) { + return "", fmt.Errorf("invalid symlink target: %s", target) + } + digestHex := filepath.Base(target) + if digestHex == "." || digestHex == string(filepath.Separator) { + return "", fmt.Errorf("invalid symlink target: %s", target) + } + if target != digestHex { + resolved := filepath.Clean(filepath.Join(filepath.Dir(linkPath), target)) + if resolved != filepath.Clean(p.ImageContentDir(digestHex)) { + return "", fmt.Errorf("invalid symlink target: %s", target) + } + } + + return digestHex, nil } diff --git a/lib/images/storage_refs.go b/lib/images/storage_refs.go new file mode 100644 index 000000000..702213b95 --- /dev/null +++ b/lib/images/storage_refs.go @@ -0,0 +1,354 @@ +package images + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/kernel/hypeman/lib/paths" +) + +// listTags returns all tags for a repository. +func listTags(p *paths.Paths, repository string) ([]string, error) { + dirs := []string{filepath.Join(p.ImageRepositoriesDir(), repository), p.ImageRepositoryDir(repository)} + seen := make(map[string]struct{}) + tags := make([]string, 0) + for _, repoDir := range dirs { + entries, err := os.ReadDir(repoDir) + if err != nil { + if os.IsNotExist(err) { + continue + } + return nil, fmt.Errorf("read repository directory: %w", err) + } + for _, entry := range entries { + path := filepath.Join(repoDir, entry.Name()) + info, err := os.Lstat(path) + if err != nil || info.Mode()&os.ModeSymlink == 0 { + continue + } + if _, ok := seen[entry.Name()]; ok { + continue + } + seen[entry.Name()] = struct{}{} + tags = append(tags, entry.Name()) + } + } + return tags, nil +} + +// listAllMetadata returns one metadata record per tag across all repositories. +// Tagged images are discovered through tag symlinks, and digest-only images are +// discovered directly from their metadata.json files. +func listAllMetadata(p *paths.Paths) ([]*imageMetadata, error) { + imagesDir := p.ImagesDir() + seen := make(map[string]struct{}) + contentDigests := make(map[string]struct{}) + taggedDigests := make(map[string]struct{}) + taggedContentDigests := make(map[string]struct{}) + metadataRefs := make([]metadataReference, 0) + seenMetadataRefs := make(map[string]struct{}) + metas := make([]*imageMetadata, 0) + + err := filepath.Walk(imagesDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil // Skip errors + } + rel, err := filepath.Rel(imagesDir, path) + if err != nil { + return nil + } + parts := strings.Split(rel, string(filepath.Separator)) + if len(parts) > 0 && parts[0] == "content" { + if info.IsDir() { + return nil + } + if info.Name() != "metadata.json" { + return nil + } + digestHex := filepath.Base(filepath.Dir(path)) + contentDigests[digestHex] = struct{}{} + return nil + } + + switch { + case info.Mode()&os.ModeSymlink != 0: + digestHex, err := os.Readlink(path) + if err != nil { + return nil // Skip invalid symlinks + } + digestHex = filepath.Base(digestHex) + + var repository, tag string + if len(parts) > 1 && parts[0] == "repositories" { + repository = filepath.Join(parts[1 : len(parts)-1]...) + tag = parts[len(parts)-1] + } else { + repository = filepath.Dir(rel) + tag = filepath.Base(path) + } + return appendMetadataForTag(p, repository, tag, digestHex, seen, taggedDigests, taggedContentDigests, &metas) + case !info.IsDir() && info.Name() == "metadata.json": + digestHex := filepath.Base(filepath.Dir(path)) + repository, err := filepath.Rel(imagesDir, filepath.Dir(filepath.Dir(path))) + if err != nil { + return nil + } + key := repository + "@" + digestHex + if _, ok := seenMetadataRefs[key]; !ok { + seenMetadataRefs[key] = struct{}{} + metadataRefs = append(metadataRefs, metadataReference{repository: repository, digestHex: digestHex}) + } + return nil + default: + return nil + } + }) + + if err != nil && !os.IsNotExist(err) { + return nil, fmt.Errorf("walk images directory: %w", err) + } + seenDigests := make(map[string]struct{}, len(metas)) + for _, ref := range metadataRefs { + if _, tagged := taggedDigests[ref.repository+"@"+ref.digestHex]; tagged { + continue + } + if err := appendMetadataIfNew(p, ref.repository, ref.digestHex, seen, &metas); err != nil { + return nil, err + } + seenDigests[ref.digestHex] = struct{}{} + } + for digestHex := range contentDigests { + if _, found := taggedContentDigests[digestHex]; found { + continue + } + if _, found := seenDigests[digestHex]; found { + continue + } + if err := appendContentMetadataIfNew(p, digestHex, seen, &metas); err != nil { + return nil, err + } + } + + return metas, nil +} + +type metadataReference struct { + repository string + digestHex string +} + +func appendMetadataIfNew(p *paths.Paths, repository, digestHex string, seen map[string]struct{}, metas *[]*imageMetadata) error { + key := repository + "@" + digestHex + if _, ok := seen[key]; ok { + return nil + } + + meta, err := readMetadata(p, repository, digestHex) + if err != nil { + return nil // Skip if metadata can't be read + } + + seen[key] = struct{}{} + *metas = append(*metas, meta) + return nil +} + +func appendContentMetadataIfNew(p *paths.Paths, digestHex string, seen map[string]struct{}, metas *[]*imageMetadata) error { + key := "@" + digestHex + if _, ok := seen[key]; ok { + return nil + } + meta, err := readContentMetadata(p, digestHex) + if err != nil { + return nil + } + seen[key] = struct{}{} + *metas = append(*metas, meta) + return nil +} + +func appendMetadataForTag(p *paths.Paths, repository, tag, digestHex string, seen, taggedDigests, taggedContentDigests map[string]struct{}, metas *[]*imageMetadata) error { + tagKey := repository + ":" + tag + if _, ok := seen[tagKey]; ok { + return nil + } + meta, err := readMetadata(p, repository, digestHex) + if err != nil { + return nil + } + meta.Name = repository + ":" + tag + seen[tagKey] = struct{}{} + taggedDigests[repository+"@"+digestHex] = struct{}{} + taggedContentDigests[digestHex] = struct{}{} + *metas = append(*metas, meta) + return nil +} + +// deleteTag removes a tag symlink in either supported layout (does not delete +// the digest directory). +func deleteTag(p *paths.Paths, repository, tag string) error { + pathsToRemove := []string{ + p.ImageRepositoryTagSymlink(repository, tag), + p.ImageTagSymlink(repository, tag), + } + found := false + for _, linkPath := range pathsToRemove { + if _, err := os.Lstat(linkPath); err != nil { + if os.IsNotExist(err) { + continue + } + return fmt.Errorf("stat symlink: %w", err) + } + found = true + if err := os.Remove(linkPath); err != nil { + return fmt.Errorf("remove symlink: %w", err) + } + } + if !found { + return ErrNotFound + } + return nil +} + +// countTagsForDigest counts how many tags in a repository point to a given digest. +func countTagsForDigest(p *paths.Paths, repository, digestHex string) (int, error) { + tags, err := listTags(p, repository) + if err != nil { + return 0, err + } + + count := 0 + for _, tag := range tags { + target, err := resolveTag(p, repository, tag) + if err != nil { + continue + } + if target == digestHex { + count++ + } + } + return count, nil +} + +func deleteTagsForDigest(p *paths.Paths, repository, digestHex string) error { + tags, err := listTags(p, repository) + if err != nil { + return err + } + + for _, tag := range tags { + target, err := resolveTag(p, repository, tag) + if err != nil { + continue + } + if target != digestHex { + continue + } + if err := deleteTag(p, repository, tag); err != nil && !errors.Is(err, ErrNotFound) { + return err + } + } + + return nil +} + +func contentTagsForDigest(p *paths.Paths, digestHex string) ([]string, error) { + root := p.ImageRepositoriesDir() + refs := make([]string, 0) + err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() || info.Mode()&os.ModeSymlink == 0 { + return nil + } + rel, err := filepath.Rel(root, path) + if err != nil { + return nil + } + parts := strings.Split(rel, string(filepath.Separator)) + if len(parts) < 2 { + return nil + } + repository := filepath.Join(parts[:len(parts)-1]...) + tag := parts[len(parts)-1] + target, err := resolveTag(p, repository, tag) + if err == nil && target == digestHex { + refs = append(refs, path) + } + return nil + }) + if err != nil && !os.IsNotExist(err) { + return nil, fmt.Errorf("walk content tags: %w", err) + } + return refs, nil +} + +func contentMetadataStatus(p *paths.Paths, digestHex string) (string, error) { + data, err := os.ReadFile(p.ImageContentMetadata(digestHex)) + if err != nil { + return "", err + } + var meta imageMetadata + if err := json.Unmarshal(data, &meta); err != nil { + return "", fmt.Errorf("unmarshal content metadata: %w", err) + } + return meta.Status, nil +} + +func contentPullInProgress(p *paths.Paths, digestHex string) bool { + status, err := contentMetadataStatus(p, digestHex) + if err != nil { + return false + } + switch status { + case StatusPending, StatusPulling, StatusConverting: + return true + default: + return false + } +} + +func contentIsDigestOnly(p *paths.Paths, digestHex string) bool { + meta, err := readContentMetadata(p, digestHex) + if err != nil { + return false + } + ref, err := ParseNormalizedRef(meta.Name) + return err == nil && ref.IsDigest() +} + +// removeDigestIfUnreferenced removes the repository-local legacy tree and +// removes shared content only when no tag or active pull still references it. +// Digest-only content is retained when removing a tag, but an explicit digest +// deletion removes it. +func removeDigestIfUnreferenced(p *paths.Paths, repository, digestHex string, preserveDigestOnly bool) error { + contentDir := p.ImageContentDir(digestHex) + contentExists := false + if _, err := os.Stat(contentDir); err == nil { + contentExists = true + } else if !os.IsNotExist(err) { + return fmt.Errorf("stat content digest directory: %w", err) + } + + if err := os.RemoveAll(p.ImageDigestDir(repository, digestHex)); err != nil { + return fmt.Errorf("remove legacy digest directory: %w", err) + } + if !contentExists { + return nil + } + + refs, err := contentTagsForDigest(p, digestHex) + if err != nil { + return err + } + if len(refs) > 0 || contentPullInProgress(p, digestHex) || (preserveDigestOnly && contentIsDigestOnly(p, digestHex)) { + return nil + } + + if err := os.RemoveAll(contentDir); err != nil { + return fmt.Errorf("remove content digest directory: %w", err) + } + return nil +} diff --git a/lib/images/storage_test.go b/lib/images/storage_test.go index f8ec44dcd..ce776f0ea 100644 --- a/lib/images/storage_test.go +++ b/lib/images/storage_test.go @@ -1,12 +1,197 @@ package images import ( + "os" + "path/filepath" "testing" "time" + "github.com/kernel/hypeman/lib/paths" "github.com/stretchr/testify/require" ) +func TestDeleteTagRemovesBothLayoutReferences(t *testing.T) { + p := paths.New(t.TempDir()) + repository := "docker.io/library/alpine" + tag := "latest" + digest := "abababababababababababababababababababababababababababababababab" + legacyPath := p.ImageTagSymlink(repository, tag) + contentPath := p.ImageRepositoryTagSymlink(repository, tag) + require.NoError(t, os.MkdirAll(filepath.Dir(legacyPath), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Dir(contentPath), 0o755)) + require.NoError(t, os.Symlink(digest, legacyPath)) + target, err := filepath.Rel(filepath.Dir(contentPath), p.ImageContentDir(digest)) + require.NoError(t, err) + require.NoError(t, os.Symlink(target, contentPath)) + + require.NoError(t, deleteTag(p, repository, tag)) + _, err = os.Lstat(legacyPath) + require.ErrorIs(t, err, os.ErrNotExist) + _, err = os.Lstat(contentPath) + require.ErrorIs(t, err, os.ErrNotExist) +} + +func TestLegacyImageIsNotShadowedByContentMetadata(t *testing.T) { + p := paths.New(t.TempDir()) + repository := "docker.io/library/alpine" + digest := "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + legacyMeta := &imageMetadata{ + Name: repository + ":latest", + Digest: "sha256:" + digest, + Status: StatusReady, + } + legacyDir := p.ImageDigestDir(repository, digest) + require.NoError(t, os.MkdirAll(legacyDir, 0o755)) + require.NoError(t, writeMetadataFile(p.ImageMetadata(repository, digest), legacyMeta)) + require.NoError(t, os.WriteFile(p.ImageDigestPath(repository, digest), []byte("legacy rootfs"), 0o644)) + require.NoError(t, writeMetadataFile(p.ImageContentMetadata(digest), &imageMetadata{ + Name: "docker.io/library/busybox:latest", + Digest: "sha256:" + digest, + Status: StatusFailed, + })) + + meta, err := readMetadata(p, repository, digest) + require.NoError(t, err) + require.Equal(t, StatusReady, meta.Status) + require.Equal(t, p.ImageDigestPath(repository, digest), digestPath(p, repository, digest)) +} + +func TestListAllMetadataDeduplicatesDualLayouts(t *testing.T) { + p := paths.New(t.TempDir()) + repository := "docker.io/library/alpine" + digest := "9999999999999999999999999999999999999999999999999999999999999999" + meta := &imageMetadata{ + Name: repository + "@sha256:" + digest, + Digest: "sha256:" + digest, + Status: StatusReady, + } + + require.NoError(t, os.MkdirAll(p.ImageDigestDir(repository, digest), 0o755)) + require.NoError(t, writeMetadataFile(p.ImageMetadata(repository, digest), meta)) + require.NoError(t, os.WriteFile(p.ImageDigestPath(repository, digest), []byte("legacy rootfs"), 0o644)) + require.NoError(t, writeMetadataFile(p.ImageContentMetadata(digest), meta)) + require.NoError(t, os.WriteFile(p.ImageContentPath(digest), []byte("content rootfs"), 0o644)) + + metas, err := listAllMetadata(p) + require.NoError(t, err) + require.Len(t, metas, 1) + require.Equal(t, meta.Digest, metas[0].Digest) +} + +func TestFailedLegacyImageUsesReadyContent(t *testing.T) { + p := paths.New(t.TempDir()) + repository := "docker.io/library/alpine" + digest := "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + legacyDir := p.ImageDigestDir(repository, digest) + require.NoError(t, os.MkdirAll(legacyDir, 0o755)) + require.NoError(t, writeMetadataFile(p.ImageMetadata(repository, digest), &imageMetadata{ + Name: repository + ":latest", + Digest: "sha256:" + digest, + Status: StatusFailed, + })) + require.NoError(t, os.WriteFile(p.ImageDigestPath(repository, digest), []byte("legacy rootfs"), 0o644)) + require.NoError(t, writeMetadataFile(p.ImageContentMetadata(digest), &imageMetadata{ + Name: "registry.example.com/app:latest", + Digest: "sha256:" + digest, + Status: StatusReady, + SizeBytes: 13, + })) + require.NoError(t, os.WriteFile(p.ImageContentPath(digest), []byte("content rootfs"), 0o644)) + + meta, err := readMetadata(p, repository, digest) + require.NoError(t, err) + require.Equal(t, StatusReady, meta.Status) + require.Equal(t, p.ImageContentDir(digest), digestDir(p, repository, digest)) + require.Equal(t, p.ImageContentPath(digest), digestPath(p, repository, digest)) +} + +func TestReadyContentDoesNotFallBackToLegacyDisk(t *testing.T) { + p := paths.New(t.TempDir()) + repository := "docker.io/library/alpine" + digest := "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + legacyDir := p.ImageDigestDir(repository, digest) + require.NoError(t, os.MkdirAll(legacyDir, 0o755)) + require.NoError(t, writeMetadataFile(p.ImageMetadata(repository, digest), &imageMetadata{ + Name: repository + ":latest", + Digest: "sha256:" + digest, + Status: StatusReady, + })) + require.NoError(t, os.WriteFile(p.ImageDigestPath(repository, digest), []byte("legacy rootfs"), 0o644)) + require.NoError(t, writeMetadataFile(p.ImageContentMetadata(digest), &imageMetadata{ + Name: repository + ":latest", + Digest: "sha256:" + digest, + Status: StatusReady, + })) + + require.Equal(t, p.ImageContentMetadata(digest), metadataPath(p, repository, digest)) + require.Equal(t, p.ImageContentPath(digest), digestPath(p, repository, digest)) + _, err := readMetadata(p, repository, digest) + require.ErrorContains(t, err, "disk image missing") +} + +func TestWriteMetadataUsesContentWhenLegacyDirectoryIsEmpty(t *testing.T) { + p := paths.New(t.TempDir()) + repository := "docker.io/library/alpine" + digest := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + require.NoError(t, os.MkdirAll(p.ImageDigestDir(repository, digest), 0o755)) + require.NoError(t, writeMetadata(p, repository, digest, &imageMetadata{ + Name: repository + ":latest", + Digest: "sha256:" + digest, + Status: StatusPending, + })) + require.FileExists(t, p.ImageContentMetadata(digest)) + _, err := os.Stat(p.ImageMetadata(repository, digest)) + require.ErrorIs(t, err, os.ErrNotExist) +} + +func TestContentLayoutResolvesDiskByDigest(t *testing.T) { + p := paths.New(t.TempDir()) + repository := "docker.io/library/alpine" + digest := "1212121212121212121212121212121212121212121212121212121212121212" + meta := &imageMetadata{ + Name: repository + ":latest", + Digest: "sha256:" + digest, + Status: StatusReady, + } + require.NoError(t, writeMetadataFile(p.ImageContentMetadata(digest), meta)) + require.NoError(t, os.WriteFile(p.ImageContentPath(digest), []byte("rootfs"), 0o644)) + + got, err := GetDiskPath(p, repository+":latest", "sha256:"+digest) + require.NoError(t, err) + require.Equal(t, p.ImageContentPath(digest), got) +} + +func TestListAllMetadataContentLayout(t *testing.T) { + p := paths.New(t.TempDir()) + digest := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + meta := &imageMetadata{ + Name: "docker.io/library/alpine:latest", + Digest: "sha256:" + digest, + Status: StatusReady, + SizeBytes: 5, + CreatedAt: time.Now().UTC(), + } + require.NoError(t, writeMetadataFile(p.ImageContentMetadata(digest), meta)) + require.NoError(t, os.WriteFile(p.ImageContentPath(digest), []byte("rootfs"), 0o644)) + linkPath := p.ImageRepositoryTagSymlink("docker.io/library/alpine", "latest") + require.NoError(t, os.MkdirAll(filepath.Dir(linkPath), 0o755)) + target, err := filepath.Rel(filepath.Dir(linkPath), p.ImageContentDir(digest)) + require.NoError(t, err) + require.NoError(t, os.Symlink(target, linkPath)) + require.NoError(t, createTagSymlink(p, "docker.io/library/alpine", "stable", digest)) + _, err = os.Lstat(p.ImageRepositoryTagSymlink("docker.io/library/alpine", "stable")) + require.NoError(t, err) + + metas, err := listAllMetadata(p) + require.NoError(t, err) + require.Len(t, metas, 2) + names := []string{metas[0].Name, metas[1].Name} + require.ElementsMatch(t, []string{ + "docker.io/library/alpine:latest", + "docker.io/library/alpine:stable", + }, names) +} + func TestImageMetadataToImage_ClonesMetadata(t *testing.T) { createdAt := time.Now().UTC().Truncate(time.Second) source := &imageMetadata{ diff --git a/lib/paths/paths.go b/lib/paths/paths.go index add23ff52..814dc1432 100644 --- a/lib/paths/paths.go +++ b/lib/paths/paths.go @@ -142,6 +142,35 @@ func (p *Paths) UFFDSessionsDir(versionKey string) string { // Image path methods +// ImageContentDir returns the directory for content-addressed image data. +func (p *Paths) ImageContentDir(digestHex string) string { + return filepath.Join(p.dataDir, "images", "content", digestHex) +} + +// ImageContentPath returns the path to a content-addressed rootfs disk file. +func (p *Paths) ImageContentPath(digestHex string) string { + ext := "erofs" + if runtime.GOOS == "darwin" { + ext = "ext4" + } + return filepath.Join(p.ImageContentDir(digestHex), "rootfs."+ext) +} + +// ImageContentMetadata returns the path to metadata for content-addressed image data. +func (p *Paths) ImageContentMetadata(digestHex string) string { + return filepath.Join(p.ImageContentDir(digestHex), "metadata.json") +} + +// ImageRepositoriesDir returns the root directory for repository tag references. +func (p *Paths) ImageRepositoriesDir() string { + return filepath.Join(p.dataDir, "images", "repositories") +} + +// ImageRepositoryTagSymlink returns the path to a tag reference in the new layout. +func (p *Paths) ImageRepositoryTagSymlink(repository, tag string) string { + return filepath.Join(p.ImageRepositoriesDir(), repository, tag) +} + // ImageDigestDir returns the directory for a specific image digest. func (p *Paths) ImageDigestDir(repository, digestHex string) string { return filepath.Join(p.dataDir, "images", repository, digestHex) From a878f2389b1be571c0692d4d1c6fb8ac3395c413 Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:57:42 +0000 Subject: [PATCH 02/20] Wire legacy image promotion into shared content storage promoteImageToContent was implemented but never invoked, so ready per-repository images were never migrated into the digest-keyed content layout. Promote them at manager startup: hardlink the rootfs into shared content, repoint repository tags, and retire the legacy tree. Non-ready images are left untouched and failures only warn. --- lib/images/manager.go | 48 +++++++++++++++++++++++++++ lib/images/storage_test.go | 66 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+) diff --git a/lib/images/manager.go b/lib/images/manager.go index 8e263902b..1c7702642 100644 --- a/lib/images/manager.go +++ b/lib/images/manager.go @@ -111,9 +111,57 @@ func NewManager(p *paths.Paths, maxConcurrentBuilds int, meter metric.Meter) (Ma } m.RecoverInterruptedBuilds() + m.promoteLegacyImages() return m, nil } +// promoteLegacyImages migrates ready legacy per-repository images into the +// shared content layout so every repository referencing the same digest +// shares one rootfs copy. Promotion hardlinks the disk, repoints tags, and +// removes the legacy tree; failures only warn so a partial migration never +// blocks startup. +func (m *manager) promoteLegacyImages() { + imagesDir := m.paths.ImagesDir() + type legacyRef struct { + repository string + digestHex string + } + refs := make([]legacyRef, 0) + err := filepath.Walk(imagesDir, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() || info.Name() != "metadata.json" { + return nil + } + rel, relErr := filepath.Rel(imagesDir, path) + if relErr != nil { + return nil + } + parts := strings.Split(rel, string(filepath.Separator)) + if len(parts) < 3 || parts[0] == "content" || parts[0] == "repositories" { + return nil + } + refs = append(refs, legacyRef{ + repository: filepath.Join(parts[:len(parts)-2]...), + digestHex: parts[len(parts)-2], + }) + return nil + }) + if err != nil && !os.IsNotExist(err) { + fmt.Fprintf(os.Stderr, "Warning: failed to scan legacy images for promotion: %v\n", err) + return + } + + for _, ref := range refs { + layout := resolveImageLayout(m.paths, ref.repository, ref.digestHex) + meta, readErr := readMetadataAt(layout) + if readErr != nil || meta.Status != StatusReady { + continue + } + if promoteErr := promoteImageToContent(m.paths, ref.repository, ref.digestHex, meta); promoteErr != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to promote legacy image %s@%s: %v\n", ref.repository, ref.digestHex, promoteErr) + } + } +} + func credentialsPresent(credentials *authn.AuthConfig) bool { return credentials != nil && (credentials.Username != "" || credentials.Password != "" || credentials.Auth != "" || credentials.IdentityToken != "" || credentials.RegistryToken != "") } diff --git a/lib/images/storage_test.go b/lib/images/storage_test.go index ce776f0ea..1bceb066f 100644 --- a/lib/images/storage_test.go +++ b/lib/images/storage_test.go @@ -224,3 +224,69 @@ func TestImageMetadataToImage_EmptyMetadataOmitted(t *testing.T) { require.Nil(t, img.Tags) } + +func TestPromoteLegacyImagesMovesContentAndTags(t *testing.T) { + p := paths.New(t.TempDir()) + repository := "docker.io/library/alpine" + tag := "latest" + digest := "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + + // Ready legacy image with a legacy tag symlink. + legacyDir := p.ImageDigestDir(repository, digest) + require.NoError(t, os.MkdirAll(legacyDir, 0o755)) + meta := &imageMetadata{ + Name: repository + ":" + tag, + Digest: "sha256:" + digest, + Status: StatusReady, + SizeBytes: 7, + CreatedAt: time.Now().UTC(), + } + require.NoError(t, writeMetadataFile(p.ImageMetadata(repository, digest), meta)) + require.NoError(t, os.WriteFile(p.ImageDigestPath(repository, digest), []byte("rootfs!"), 0o644)) + tagPath := p.ImageTagSymlink(repository, tag) + require.NoError(t, os.MkdirAll(filepath.Dir(tagPath), 0o755)) + require.NoError(t, os.Symlink(digest, tagPath)) + + m := &manager{paths: p} + m.promoteLegacyImages() + + // Content exists with the same bytes and is ready. + contentMeta, err := readContentMetadata(p, digest) + require.NoError(t, err) + require.Equal(t, StatusReady, contentMeta.Status) + data, err := os.ReadFile(p.ImageContentPath(digest)) + require.NoError(t, err) + require.Equal(t, "rootfs!", string(data)) + + // Legacy digest tree is retired once content is installed and tags moved. + _, err = os.Stat(legacyDir) + require.True(t, os.IsNotExist(err), "legacy digest dir should be removed") + + // The tag now resolves through the shared content layout. + resolved, err := resolveTag(p, repository, tag) + require.NoError(t, err) + require.Equal(t, digest, resolved) +} + +func TestPromoteLegacyImagesSkipsNonReady(t *testing.T) { + p := paths.New(t.TempDir()) + repository := "docker.io/library/alpine" + digest := "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + + legacyDir := p.ImageDigestDir(repository, digest) + require.NoError(t, os.MkdirAll(legacyDir, 0o755)) + require.NoError(t, writeMetadataFile(p.ImageMetadata(repository, digest), &imageMetadata{ + Name: repository + ":latest", + Digest: "sha256:" + digest, + Status: StatusConverting, + CreatedAt: time.Now().UTC(), + })) + + m := &manager{paths: p} + m.promoteLegacyImages() + + _, err := os.Stat(p.ImageContentMetadata(digest)) + require.True(t, os.IsNotExist(err), "non-ready legacy image must not be promoted") + _, err = os.Stat(legacyDir) + require.NoError(t, err, "non-ready legacy tree must be preserved") +} From e48f0f17715265a199a5dd9092a7327da4aa5323 Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:58:01 +0000 Subject: [PATCH 03/20] Fix image tests and helpers for content-addressed layout Tag symlinks now point relatively into the shared content directory, so assert on the resolved digest instead of the raw link target. Deleting a digest whose build is still in flight keeps the shared content and a re-import joins that build, so the recreate-race test must expect the same build id. Recovery writes metadata through the layout resolver, so the credentials scrub check reads the content metadata path. Reuse metadataStatus for content metadata instead of duplicating the parse. --- lib/images/credentials_test.go | 3 +-- lib/images/manager_test.go | 11 ++++++++--- lib/images/storage_refs.go | 13 ++++--------- 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/lib/images/credentials_test.go b/lib/images/credentials_test.go index 46212ba77..7f680b980 100644 --- a/lib/images/credentials_test.go +++ b/lib/images/credentials_test.go @@ -5,7 +5,6 @@ import ( "net/http" "net/http/httptest" "os" - "path/filepath" "strings" "testing" "time" @@ -203,7 +202,7 @@ func TestRecoverInterruptedCredentialedPullFailsForFreshRetry(t *testing.T) { assert.Equal(t, ErrBorrowedCredentialsExpired.Error(), *stored.Error) assert.Zero(t, m.queue.QueueLength()) - data, err := os.ReadFile(filepath.Join(p.ImageDigestDir(repository, strings.TrimPrefix(digest, "sha256:")), "metadata.json")) + data, err := os.ReadFile(p.ImageContentMetadata(strings.TrimPrefix(digest, "sha256:"))) require.NoError(t, err) assert.NotContains(t, string(data), "password") } diff --git a/lib/images/manager_test.go b/lib/images/manager_test.go index 659a7f67f..8abd90a65 100644 --- a/lib/images/manager_test.go +++ b/lib/images/manager_test.go @@ -98,10 +98,12 @@ func TestCreateImage(t *testing.T) { require.NoError(t, err) require.NotEqual(t, 0, linkStat.Mode()&os.ModeSymlink, "should be a symlink") - // Verify symlink points to digest directory + // Verify symlink resolves to the digest linkTarget, err := os.Readlink(linkPath) require.NoError(t, err) - require.Equal(t, digestHex, linkTarget, "symlink should point to digest") + resolvedDigest, err := resolveTag(paths.New(dataDir), ref.Repository(), ref.Tag()) + require.NoError(t, err) + require.Equal(t, digestHex, resolvedDigest, "symlink should resolve to digest") t.Logf("Tag symlink: %s -> %s", linkPath, linkTarget) } @@ -704,6 +706,9 @@ func TestDeleteAndRecreateDuringBuildTail(t *testing.T) { require.NoError(t, err) require.NotEqual(t, firstMeta.BuildID, currentMeta.BuildID) require.NoError(t, m.DeleteImage(ctx, repo+"@"+digestStr)) + // Deleting a digest whose build is still in flight keeps the shared content + // so the running build can finish; re-import joins that build instead of + // starting a second one for the same digest. recreatedAgain, err := m.ImportLocalImage(ctx, repo, tag, digestStr) require.NoError(t, err) require.Equal(t, StatusPending, recreatedAgain.Status) @@ -711,7 +716,7 @@ func TestDeleteAndRecreateDuringBuildTail(t *testing.T) { require.Equal(t, 1, *recreatedAgain.QueuePosition) latestMeta, err := readMetadata(p, repo, digestHex) require.NoError(t, err) - require.NotEqual(t, currentMeta.BuildID, latestMeta.BuildID) + require.Equal(t, currentMeta.BuildID, latestMeta.BuildID) currentMeta = latestMeta waitCtx, cancelWait := context.WithCancel(ctx) diff --git a/lib/images/storage_refs.go b/lib/images/storage_refs.go index 702213b95..05549129a 100644 --- a/lib/images/storage_refs.go +++ b/lib/images/storage_refs.go @@ -1,7 +1,6 @@ package images import ( - "encoding/json" "errors" "fmt" "os" @@ -286,15 +285,11 @@ func contentTagsForDigest(p *paths.Paths, digestHex string) ([]string, error) { } func contentMetadataStatus(p *paths.Paths, digestHex string) (string, error) { - data, err := os.ReadFile(p.ImageContentMetadata(digestHex)) - if err != nil { - return "", err - } - var meta imageMetadata - if err := json.Unmarshal(data, &meta); err != nil { - return "", fmt.Errorf("unmarshal content metadata: %w", err) + status, ok := metadataStatus(p.ImageContentMetadata(digestHex)) + if !ok { + return "", os.ErrNotExist } - return meta.Status, nil + return status, nil } func contentPullInProgress(p *paths.Paths, digestHex string) bool { From 3c0a142af8f5a12d290d5af99af6d1d68a8e196d Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:01:20 +0000 Subject: [PATCH 04/20] Seed snapshot test images from the content layout New builds write rootfs and metadata under the shared content directory, so EnsureImageReady must copy from the resolved disk location instead of the legacy per-repository digest directory and create its tag symlink in the repository references layout. --- lib/snapshot/testsupport/images.go | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/lib/snapshot/testsupport/images.go b/lib/snapshot/testsupport/images.go index d033cd0ff..0608368c3 100644 --- a/lib/snapshot/testsupport/images.go +++ b/lib/snapshot/testsupport/images.go @@ -73,15 +73,19 @@ func EnsureImageReady(t *testing.T, ctx context.Context, p *paths.Paths, imageMa digestHex := strings.TrimPrefix(cached.Digest, "sha256:") require.NotEmpty(t, digestHex) - srcDigestDir := cachePaths.ImageDigestDir(ref.Repository(), digestHex) - dstDigestDir := p.ImageDigestDir(ref.Repository(), digestHex) + srcDiskPath, err := images.GetDiskPath(cachePaths, waitName, cached.Digest) + require.NoError(t, err) + srcDigestDir := filepath.Dir(srcDiskPath) + dstDigestDir := p.ImageContentDir(digestHex) require.NoError(t, copyDirWithHardlinks(srcDigestDir, dstDigestDir)) if ref.Tag() != "" { - linkPath := p.ImageTagSymlink(ref.Repository(), ref.Tag()) + linkPath := p.ImageRepositoryTagSymlink(ref.Repository(), ref.Tag()) + target, err := filepath.Rel(filepath.Dir(linkPath), dstDigestDir) + require.NoError(t, err) require.NoError(t, os.MkdirAll(filepath.Dir(linkPath), 0755)) _ = os.Remove(linkPath) - require.NoError(t, os.Symlink(digestHex, linkPath)) + require.NoError(t, os.Symlink(target, linkPath)) } reference := ref.Tag() From 23b6d90faf0edbaf8d94e01d558fb77f53b33445 Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:42:28 +0000 Subject: [PATCH 05/20] Preserve image tags during replacement --- lib/images/manager.go | 114 ++++++++++++++++++++++++++----------- lib/images/storage.go | 34 +++++------ lib/images/storage_refs.go | 11 ++++ 3 files changed, 111 insertions(+), 48 deletions(-) diff --git a/lib/images/manager.go b/lib/images/manager.go index 1c7702642..f50113573 100644 --- a/lib/images/manager.go +++ b/lib/images/manager.go @@ -264,12 +264,16 @@ func (m *manager) CreateImage(ctx context.Context, req CreateImageRequest) (*Ima } // Fall through to re-queue the build } else { - // We have this digest already (ready, pending, pulling, or converting). - // Register the requested tag immediately so a second caller can wait on - // the same shared content while its build is still in progress. + // Keep an existing ready tag visible until a replacement is ready. if ref.Tag() != "" { - if err := createTagSymlink(m.paths, ref.Repository(), ref.Tag(), ref.DigestHex()); err != nil { - return nil, fmt.Errorf("create image tag: %w", err) + var tagErr error + if meta.Status == StatusReady { + tagErr = createTagSymlink(m.paths, ref.Repository(), ref.Tag(), ref.DigestHex()) + } else { + tagErr = ensurePendingTag(m.paths, ref.Repository(), ref.Tag(), ref.DigestHex()) + } + if tagErr != nil { + return nil, fmt.Errorf("create image tag: %w", tagErr) } } img := meta.toImage() @@ -326,11 +330,15 @@ func (m *manager) ImportLocalImage(ctx context.Context, repo, reference, digest } // Fall through to re-queue the build } else { - // Register the requested tag immediately so callers can wait on shared - // content while its build is still in progress. if ref.Tag() != "" { - if err := createTagSymlink(m.paths, ref.Repository(), ref.Tag(), ref.DigestHex()); err != nil { - return nil, fmt.Errorf("create image tag: %w", err) + var tagErr error + if meta.Status == StatusReady { + tagErr = createTagSymlink(m.paths, ref.Repository(), ref.Tag(), ref.DigestHex()) + } else { + tagErr = ensurePendingTag(m.paths, ref.Repository(), ref.Tag(), ref.DigestHex()) + } + if tagErr != nil { + return nil, fmt.Errorf("create image tag: %w", tagErr) } } img := meta.toImage() @@ -426,22 +434,37 @@ func (m *manager) createAndQueueImage(ref *ResolvedRef, req CreateImageRequest, // Record the requested platform optimistically while the build is pending; // buildImage overwrites it with the authoritative manifest platform once the // image config is pulled. + previousTagDigest := "" + var err error + if ref.Tag() != "" { + previousTagDigest, err = resolveTag(m.paths, ref.Repository(), ref.Tag()) + if err != nil && !errors.Is(err, ErrNotFound) { + return nil, fmt.Errorf("resolve existing image tag: %w", err) + } + } meta := &imageMetadata{ - Name: ref.String(), - Digest: ref.Digest(), - Platform: requestedPlatform.String(), - Status: StatusPending, - Request: &storedReq, - BorrowedAuth: req.Credentials != nil, - BuildID: uuid.New().String(), - Tags: tags.Clone(req.Tags), - CreatedAt: time.Now(), + Name: ref.String(), + Digest: ref.Digest(), + Platform: requestedPlatform.String(), + Status: StatusPending, + Request: &storedReq, + BorrowedAuth: req.Credentials != nil, + BuildID: uuid.New().String(), + Tags: tags.Clone(req.Tags), + RequestedTag: ref.Tag(), + PreviousTagDigest: previousTagDigest, + CreatedAt: time.Now(), } // Write initial metadata if err := writeMetadata(m.paths, ref.Repository(), ref.DigestHex(), meta); err != nil { return nil, fmt.Errorf("write initial metadata: %w", err) } + if ref.Tag() != "" && previousTagDigest == "" { + if err := createTagSymlink(m.paths, ref.Repository(), ref.Tag(), ref.DigestHex()); err != nil { + return nil, fmt.Errorf("create pending image tag: %w", err) + } + } // Keep borrowed credentials outside the queued closure so their lifetime is // bounded even when this job waits behind another pull. @@ -598,9 +621,12 @@ func (m *manager) finalizeImage(ref *ResolvedRef, result *pullResult, diskSize i } m.notifyReady(ref.DigestHex(), StatusReady, nil) - if ref.Tag() != "" { - if err := createTagSymlink(m.paths, ref.Repository(), ref.Tag(), ref.DigestHex()); err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to create tag symlink: %v\n", err) + if meta.RequestedTag != "" { + current, resolveErr := resolveTag(m.paths, ref.Repository(), meta.RequestedTag) + if resolveErr == nil && (current == ref.DigestHex() || current == meta.PreviousTagDigest) { + if err := createTagSymlink(m.paths, ref.Repository(), meta.RequestedTag, ref.DigestHex()); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to create tag symlink: %v\n", err) + } } } m.refreshDiskUsageTotals() @@ -824,25 +850,49 @@ func (m *manager) TotalOCICacheBytes(ctx context.Context) (int64, error) { return ociCacheBytes, nil } +func (m *manager) findRequestedTagImage(ref *NormalizedRef) *Image { + metas, err := listAllMetadata(m.paths) + if err != nil { + return nil + } + var newest *imageMetadata + for _, meta := range metas { + if meta.RequestedTag != ref.Tag() || !strings.HasPrefix(meta.Name, ref.Repository()+":") { + continue + } + if newest == nil || newest.CreatedAt.Before(meta.CreatedAt) { + newest = meta + } + } + if newest == nil { + return nil + } + image := newest.toImage() + image.Name = ref.String() + return image +} + // WaitForReady blocks until the image reaches a terminal state (ready or failed) // or the context is cancelled. -// -// The image may not exist yet when this is called (e.g., the registry's -// triggerConversion goroutine hasn't called ImportLocalImage yet), so we -// poll briefly for the image to appear before subscribing for notifications. func (m *manager) WaitForReady(ctx context.Context, name string) error { - // Wait for the image to appear in the store. In the build flow, the - // registry triggers ImportLocalImage asynchronously after a push, so the - // image may not exist when the build manager calls WaitForReady. + ref, err := ParseNormalizedRef(name) + if err != nil { + return fmt.Errorf("parse image name: %w", err) + } + const maxWaitForExist = 30 * time.Second const pollInterval = 100 * time.Millisecond var img *Image deadline := time.Now().Add(maxWaitForExist) for { - got, err := m.GetImage(ctx, name) - if err == nil { - img = got + if !ref.IsDigest() { + img = m.findRequestedTagImage(ref) + } + if img == nil { + img, err = m.GetImage(ctx, name) + } + if img != nil { break } if time.Now().After(deadline) { @@ -871,7 +921,7 @@ func (m *manager) WaitForReady(ctx context.Context, name string) error { defer m.unsubscribeFromReady(digestHex, ch) // Re-check after subscribing to close the race window - img, err := m.GetImage(ctx, name) + img, err = m.GetImage(ctx, ref.Repository()+"@"+img.Digest) if err == nil { switch img.Status { case StatusReady: diff --git a/lib/images/storage.go b/lib/images/storage.go index 9adbbfcb4..505c5db30 100644 --- a/lib/images/storage.go +++ b/lib/images/storage.go @@ -14,22 +14,24 @@ import ( ) type imageMetadata struct { - Name string `json:"name"` // Normalized ref (tag or digest) - Digest string `json:"digest"` // Always present: sha256:... - Platform string `json:"platform,omitempty"` - Status string `json:"status"` - Error *string `json:"error,omitempty"` - Request *CreateImageRequest `json:"request,omitempty"` - SizeBytes int64 `json:"size_bytes"` - Entrypoint []string `json:"entrypoint,omitempty"` - Cmd []string `json:"cmd,omitempty"` - Env map[string]string `json:"env,omitempty"` - Labels map[string]string `json:"labels,omitempty"` - Tags tags.Tags `json:"tags,omitempty"` - WorkingDir string `json:"working_dir,omitempty"` - CreatedAt time.Time `json:"created_at"` - BorrowedAuth bool `json:"borrowed_auth,omitempty"` - BuildID string `json:"build_id,omitempty"` + Name string `json:"name"` // Normalized ref (tag or digest) + Digest string `json:"digest"` // Always present: sha256:... + Platform string `json:"platform,omitempty"` + Status string `json:"status"` + Error *string `json:"error,omitempty"` + Request *CreateImageRequest `json:"request,omitempty"` + SizeBytes int64 `json:"size_bytes"` + Entrypoint []string `json:"entrypoint,omitempty"` + Cmd []string `json:"cmd,omitempty"` + Env map[string]string `json:"env,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + Tags tags.Tags `json:"tags,omitempty"` + WorkingDir string `json:"working_dir,omitempty"` + CreatedAt time.Time `json:"created_at"` + BorrowedAuth bool `json:"borrowed_auth,omitempty"` + BuildID string `json:"build_id,omitempty"` + RequestedTag string `json:"requested_tag,omitempty"` + PreviousTagDigest string `json:"previous_tag_digest,omitempty"` } func (m *imageMetadata) toImage() *Image { diff --git a/lib/images/storage_refs.go b/lib/images/storage_refs.go index 05549129a..cf70b25ff 100644 --- a/lib/images/storage_refs.go +++ b/lib/images/storage_refs.go @@ -10,6 +10,17 @@ import ( "github.com/kernel/hypeman/lib/paths" ) +func ensurePendingTag(p *paths.Paths, repository, tag, digestHex string) error { + _, err := resolveTag(p, repository, tag) + if err == nil { + return nil + } + if !errors.Is(err, ErrNotFound) { + return err + } + return createTagSymlink(p, repository, tag, digestHex) +} + // listTags returns all tags for a repository. func listTags(p *paths.Paths, repository string) ([]string, error) { dirs := []string{filepath.Join(p.ImageRepositoriesDir(), repository), p.ImageRepositoryDir(repository)} From 035f1b033f16aab1f3018edf12262ccb469b21b9 Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:51:48 +0000 Subject: [PATCH 06/20] Simplify existing image reuse --- lib/images/manager.go | 75 +++++++++++++++++++++---------------------- 1 file changed, 37 insertions(+), 38 deletions(-) diff --git a/lib/images/manager.go b/lib/images/manager.go index f50113573..f6bb2bcc2 100644 --- a/lib/images/manager.go +++ b/lib/images/manager.go @@ -253,45 +253,9 @@ func (m *manager) CreateImage(ctx context.Context, req CreateImageRequest) (*Ima m.createMu.Lock() defer m.createMu.Unlock() - // Check if we already have this digest (deduplication) - if meta, err := readMetadata(m.paths, ref.Repository(), ref.DigestHex()); err == nil { - // Don't cache failed builds - allow retry - if meta.Status == StatusFailed { - // Clean up the failed build directory so we can retry. Shared content - // is retained if another tag or pull still references it. - if err := removeDigestIfUnreferenced(m.paths, ref.Repository(), ref.DigestHex(), false); err != nil { - return nil, fmt.Errorf("remove failed image: %w", err) - } - // Fall through to re-queue the build - } else { - // Keep an existing ready tag visible until a replacement is ready. - if ref.Tag() != "" { - var tagErr error - if meta.Status == StatusReady { - tagErr = createTagSymlink(m.paths, ref.Repository(), ref.Tag(), ref.DigestHex()) - } else { - tagErr = ensurePendingTag(m.paths, ref.Repository(), ref.Tag(), ref.DigestHex()) - } - if tagErr != nil { - return nil, fmt.Errorf("create image tag: %w", tagErr) - } - } - img := meta.toImage() - img.Name = ref.String() - if meta.Status == StatusReady { - return img, nil - } - if !m.inflightCredentialsMatch(ref.Digest(), req.Credentials) { - return nil, fmt.Errorf("%w: retry after the current pull completes", ErrCredentialConflict) - } - if meta.Status == StatusPending { - img.QueuePosition = m.queue.GetPosition(meta.Digest) - } - return img, nil - } + if img, found, err := m.reuseExistingImage(ref, req.Credentials); found || err != nil { + return img, err } - - // Don't have this digest yet, queue the build return m.createAndQueueImage(ref, req, platform) } @@ -354,6 +318,41 @@ func (m *manager) ImportLocalImage(ctx context.Context, repo, reference, digest return m.createAndQueueImage(ref, CreateImageRequest{Name: imageRef}, hostPlatform()) } +func (m *manager) reuseExistingImage(ref *ResolvedRef, credentials *authn.AuthConfig) (*Image, bool, error) { + meta, err := readMetadata(m.paths, ref.Repository(), ref.DigestHex()) + if err != nil { + return nil, false, nil + } + if meta.Status == StatusFailed { + if err := removeDigestIfUnreferenced(m.paths, ref.Repository(), ref.DigestHex(), false); err != nil { + return nil, true, fmt.Errorf("remove failed image: %w", err) + } + return nil, false, nil + } + if ref.Tag() != "" { + if meta.Status == StatusReady { + err = createTagSymlink(m.paths, ref.Repository(), ref.Tag(), ref.DigestHex()) + } else { + err = ensurePendingTag(m.paths, ref.Repository(), ref.Tag(), ref.DigestHex()) + } + if err != nil { + return nil, true, fmt.Errorf("create image tag: %w", err) + } + } + img := meta.toImage() + img.Name = ref.String() + if meta.Status == StatusReady { + return img, true, nil + } + if !m.inflightCredentialsMatch(ref.Digest(), credentials) { + return nil, true, fmt.Errorf("%w: retry after the current pull completes", ErrCredentialConflict) + } + if meta.Status == StatusPending { + img.QueuePosition = m.queue.GetPosition(meta.Digest) + } + return img, true, nil +} + func (m *manager) inflightCredentialsMatch(digest string, credentials *authn.AuthConfig) bool { inflight := m.inflightPulls[digest] var existingFingerprint [32]byte From 521133f41d2e8467d6dcdc61a92f5738a866574f Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:54:59 +0000 Subject: [PATCH 07/20] Simplify image readiness waiting --- lib/images/manager.go | 81 ++++++++++++++++++++++++------------------- 1 file changed, 45 insertions(+), 36 deletions(-) diff --git a/lib/images/manager.go b/lib/images/manager.go index f6bb2bcc2..a6882a560 100644 --- a/lib/images/manager.go +++ b/lib/images/manager.go @@ -878,38 +878,12 @@ func (m *manager) WaitForReady(ctx context.Context, name string) error { if err != nil { return fmt.Errorf("parse image name: %w", err) } - - const maxWaitForExist = 30 * time.Second - const pollInterval = 100 * time.Millisecond - - var img *Image - deadline := time.Now().Add(maxWaitForExist) - for { - if !ref.IsDigest() { - img = m.findRequestedTagImage(ref) - } - if img == nil { - img, err = m.GetImage(ctx, name) - } - if img != nil { - break - } - if time.Now().After(deadline) { - return fmt.Errorf("get image: %w", err) - } - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(pollInterval): - } + img, err := m.waitForImage(ctx, name, ref) + if err != nil { + return err } - - // Check if already in terminal state - switch img.Status { - case StatusReady: - return nil - case StatusFailed: - return conversionFailedErr(img.Error, nil) + if terminal, err := terminalImageError(img); terminal { + return err } digestHex := strings.TrimPrefix(img.Digest, "sha256:") @@ -922,11 +896,8 @@ func (m *manager) WaitForReady(ctx context.Context, name string) error { // Re-check after subscribing to close the race window img, err = m.GetImage(ctx, ref.Repository()+"@"+img.Digest) if err == nil { - switch img.Status { - case StatusReady: - return nil - case StatusFailed: - return conversionFailedErr(img.Error, nil) + if terminal, terminalErr := terminalImageError(img); terminal { + return terminalErr } } @@ -942,6 +913,44 @@ func (m *manager) WaitForReady(ctx context.Context, name string) error { } } +func (m *manager) waitForImage(ctx context.Context, name string, ref *NormalizedRef) (*Image, error) { + const maxWaitForExist = 30 * time.Second + const pollInterval = 100 * time.Millisecond + var lastErr error + deadline := time.Now().Add(maxWaitForExist) + for { + var img *Image + if !ref.IsDigest() { + img = m.findRequestedTagImage(ref) + } + if img == nil { + img, lastErr = m.GetImage(ctx, name) + } + if img != nil { + return img, nil + } + if time.Now().After(deadline) { + return nil, fmt.Errorf("get image: %w", lastErr) + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(pollInterval): + } + } +} + +func terminalImageError(img *Image) (bool, error) { + switch img.Status { + case StatusReady: + return true, nil + case StatusFailed: + return true, conversionFailedErr(img.Error, nil) + default: + return false, nil + } +} + func conversionFailedErr(message *string, cause error) error { if cause != nil { return fmt.Errorf("image conversion failed: %w", cause) From 3e20e731215fa6187f22e29c18a5fd3a4e5f1289 Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:57:55 +0000 Subject: [PATCH 08/20] Centralize legacy image promotion --- lib/images/manager.go | 40 +-------------------------------- lib/images/storage_refs.go | 45 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 39 deletions(-) diff --git a/lib/images/manager.go b/lib/images/manager.go index a6882a560..bcb2f3d70 100644 --- a/lib/images/manager.go +++ b/lib/images/manager.go @@ -121,45 +121,7 @@ func NewManager(p *paths.Paths, maxConcurrentBuilds int, meter metric.Meter) (Ma // removes the legacy tree; failures only warn so a partial migration never // blocks startup. func (m *manager) promoteLegacyImages() { - imagesDir := m.paths.ImagesDir() - type legacyRef struct { - repository string - digestHex string - } - refs := make([]legacyRef, 0) - err := filepath.Walk(imagesDir, func(path string, info os.FileInfo, err error) error { - if err != nil || info.IsDir() || info.Name() != "metadata.json" { - return nil - } - rel, relErr := filepath.Rel(imagesDir, path) - if relErr != nil { - return nil - } - parts := strings.Split(rel, string(filepath.Separator)) - if len(parts) < 3 || parts[0] == "content" || parts[0] == "repositories" { - return nil - } - refs = append(refs, legacyRef{ - repository: filepath.Join(parts[:len(parts)-2]...), - digestHex: parts[len(parts)-2], - }) - return nil - }) - if err != nil && !os.IsNotExist(err) { - fmt.Fprintf(os.Stderr, "Warning: failed to scan legacy images for promotion: %v\n", err) - return - } - - for _, ref := range refs { - layout := resolveImageLayout(m.paths, ref.repository, ref.digestHex) - meta, readErr := readMetadataAt(layout) - if readErr != nil || meta.Status != StatusReady { - continue - } - if promoteErr := promoteImageToContent(m.paths, ref.repository, ref.digestHex, meta); promoteErr != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to promote legacy image %s@%s: %v\n", ref.repository, ref.digestHex, promoteErr) - } - } + promoteLegacyImages(m.paths) } func credentialsPresent(credentials *authn.AuthConfig) bool { diff --git a/lib/images/storage_refs.go b/lib/images/storage_refs.go index cf70b25ff..5cba6b6f4 100644 --- a/lib/images/storage_refs.go +++ b/lib/images/storage_refs.go @@ -50,6 +50,51 @@ func listTags(p *paths.Paths, repository string) ([]string, error) { return tags, nil } +// promoteLegacyImages migrates ready legacy images into shared content storage. +// Keeping the traversal here makes storage layout migration share the same +// ownership boundary as metadata enumeration. +func promoteLegacyImages(p *paths.Paths) { + imagesDir := p.ImagesDir() + type legacyRef struct { + repository string + digestHex string + } + refs := make([]legacyRef, 0) + err := filepath.Walk(imagesDir, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() || info.Name() != "metadata.json" { + return nil + } + rel, relErr := filepath.Rel(imagesDir, path) + if relErr != nil { + return nil + } + parts := strings.Split(rel, string(filepath.Separator)) + if len(parts) < 3 || parts[0] == "content" || parts[0] == "repositories" { + return nil + } + refs = append(refs, legacyRef{ + repository: filepath.Join(parts[:len(parts)-2]...), + digestHex: parts[len(parts)-2], + }) + return nil + }) + if err != nil && !os.IsNotExist(err) { + fmt.Fprintf(os.Stderr, "Warning: failed to scan legacy images for promotion: %v\n", err) + return + } + + for _, ref := range refs { + layout := resolveImageLayout(p, ref.repository, ref.digestHex) + meta, readErr := readMetadataAt(layout) + if readErr != nil || meta.Status != StatusReady { + continue + } + if promoteErr := promoteImageToContent(p, ref.repository, ref.digestHex, meta); promoteErr != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to promote legacy image %s@%s: %v\n", ref.repository, ref.digestHex, promoteErr) + } + } +} + // listAllMetadata returns one metadata record per tag across all repositories. // Tagged images are discovered through tag symlinks, and digest-only images are // discovered directly from their metadata.json files. From c8543ca28a828a70afa5c7e8aa1919224fe1b16e Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:26:05 +0000 Subject: [PATCH 09/20] Track image tag mutations --- lib/images/manager.go | 21 ++++++++++++++++++++- lib/images/storage.go | 1 + 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/lib/images/manager.go b/lib/images/manager.go index bcb2f3d70..08d5ac0ba 100644 --- a/lib/images/manager.go +++ b/lib/images/manager.go @@ -72,6 +72,7 @@ type manager struct { queue *queue.Queue createMu sync.Mutex diskUsageMu sync.RWMutex + tagGenerations map[string]uint64 diskUsageLoaded bool readyImageBytes int64 ociCacheBytes int64 @@ -99,6 +100,7 @@ func NewManager(p *paths.Paths, maxConcurrentBuilds int, meter metric.Meter) (Ma inflightPulls: make(map[string]*inflightImagePull), borrowedCredentialsTimeout: DefaultBorrowedCredentialsTimeout, readySubscribers: make(map[string][]chan StatusEvent), + tagGenerations: make(map[string]uint64), } // Initialize metrics if meter is provided @@ -315,6 +317,16 @@ func (m *manager) reuseExistingImage(ref *ResolvedRef, credentials *authn.AuthCo return img, true, nil } +func tagGenerationKey(repository, tag string) string { + return repository + ":" + tag +} + +func (m *manager) nextTagGeneration(repository, tag string) uint64 { + key := tagGenerationKey(repository, tag) + m.tagGenerations[key]++ + return m.tagGenerations[key] +} + func (m *manager) inflightCredentialsMatch(digest string, credentials *authn.AuthConfig) bool { inflight := m.inflightPulls[digest] var existingFingerprint [32]byte @@ -403,6 +415,10 @@ func (m *manager) createAndQueueImage(ref *ResolvedRef, req CreateImageRequest, return nil, fmt.Errorf("resolve existing image tag: %w", err) } } + tagGeneration := uint64(0) + if ref.Tag() != "" { + tagGeneration = m.nextTagGeneration(ref.Repository(), ref.Tag()) + } meta := &imageMetadata{ Name: ref.String(), Digest: ref.Digest(), @@ -414,6 +430,7 @@ func (m *manager) createAndQueueImage(ref *ResolvedRef, req CreateImageRequest, Tags: tags.Clone(req.Tags), RequestedTag: ref.Tag(), PreviousTagDigest: previousTagDigest, + TagGeneration: tagGeneration, CreatedAt: time.Now(), } @@ -584,7 +601,8 @@ func (m *manager) finalizeImage(ref *ResolvedRef, result *pullResult, diskSize i m.notifyReady(ref.DigestHex(), StatusReady, nil) if meta.RequestedTag != "" { current, resolveErr := resolveTag(m.paths, ref.Repository(), meta.RequestedTag) - if resolveErr == nil && (current == ref.DigestHex() || current == meta.PreviousTagDigest) { + generationMatches := m.tagGenerations[tagGenerationKey(ref.Repository(), meta.RequestedTag)] == meta.TagGeneration + if resolveErr == nil && generationMatches && (current == ref.DigestHex() || current == meta.PreviousTagDigest) { if err := createTagSymlink(m.paths, ref.Repository(), meta.RequestedTag, ref.DigestHex()); err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to create tag symlink: %v\n", err) } @@ -765,6 +783,7 @@ func (m *manager) DeleteImage(ctx context.Context, name string) error { } tag := ref.Tag() + m.nextTagGeneration(repository, tag) // Resolve the tag to get the digest before deleting digestHex, err := resolveTag(m.paths, repository, tag) diff --git a/lib/images/storage.go b/lib/images/storage.go index 505c5db30..de65f954f 100644 --- a/lib/images/storage.go +++ b/lib/images/storage.go @@ -32,6 +32,7 @@ type imageMetadata struct { BuildID string `json:"build_id,omitempty"` RequestedTag string `json:"requested_tag,omitempty"` PreviousTagDigest string `json:"previous_tag_digest,omitempty"` + TagGeneration uint64 `json:"tag_generation,omitempty"` } func (m *imageMetadata) toImage() *Image { From fd53483c04f8033e3b602b5d9c83e723a1a3b17a Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:30:13 +0000 Subject: [PATCH 10/20] Reject unsupported Firecracker versions --- lib/hypervisor/firecracker/binaries.go | 14 +++++++++----- lib/hypervisor/firecracker/binaries_test.go | 21 +++++++++++++++------ 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/lib/hypervisor/firecracker/binaries.go b/lib/hypervisor/firecracker/binaries.go index 52fedd90a..32389d13a 100644 --- a/lib/hypervisor/firecracker/binaries.go +++ b/lib/hypervisor/firecracker/binaries.go @@ -84,19 +84,23 @@ func resolveBinaryPath(p *paths.Paths, version string) (string, error) { return "", fmt.Errorf("paths are required when using embedded firecracker binaries") } - return extractBinary(p, parseVersion(version)) + parsedVersion, err := parseVersion(version) + if err != nil { + return "", err + } + return extractBinary(p, parsedVersion) } -func parseVersion(version string) Version { +func parseVersion(version string) (Version, error) { if version == "" { - return defaultVersion + return defaultVersion, nil } for _, supported := range supportedVersions { if version == string(supported) { - return supported + return supported, nil } } - return defaultVersion + return "", fmt.Errorf("unsupported firecracker version %q", version) } func extractBinary(p *paths.Paths, version Version) (string, error) { diff --git a/lib/hypervisor/firecracker/binaries_test.go b/lib/hypervisor/firecracker/binaries_test.go index 6683fed31..2ebfe936e 100644 --- a/lib/hypervisor/firecracker/binaries_test.go +++ b/lib/hypervisor/firecracker/binaries_test.go @@ -35,12 +35,21 @@ func TestResolveBinaryPathInvalidCustomPath(t *testing.T) { assert.Contains(t, err.Error(), "invalid firecracker custom binary path") } -func TestParseVersionFallback(t *testing.T) { - assert.Equal(t, V1_16_1, defaultVersion) - assert.Equal(t, defaultVersion, parseVersion("")) - assert.Equal(t, defaultVersion, parseVersion("unknown")) - assert.Equal(t, V1_14_2, parseVersion("v1.14.2")) - assert.Equal(t, V1_16_1, parseVersion("v1.16.1")) +func TestParseVersion(t *testing.T) { + version, err := parseVersion("") + require.NoError(t, err) + assert.Equal(t, defaultVersion, version) + + _, err = parseVersion("unknown") + require.ErrorContains(t, err, "unsupported firecracker version") + + version, err = parseVersion("v1.14.2") + require.NoError(t, err) + assert.Equal(t, V1_14_2, version) + + version, err = parseVersion("v1.16.1") + require.NoError(t, err) + assert.Equal(t, V1_16_1, version) } func TestResolveEmbeddedBinaryVersions(t *testing.T) { From e3cab9b11f49ff7b7cb3eb941fac53c13ecf4d07 Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:47:04 +0000 Subject: [PATCH 11/20] Restore tag generations after restart --- lib/images/manager.go | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/lib/images/manager.go b/lib/images/manager.go index 08d5ac0ba..b1abbde9f 100644 --- a/lib/images/manager.go +++ b/lib/images/manager.go @@ -261,6 +261,7 @@ func (m *manager) ImportLocalImage(ctx context.Context, repo, reference, digest if ref.Tag() != "" { var tagErr error if meta.Status == StatusReady { + m.nextTagGeneration(ref.Repository(), ref.Tag()) tagErr = createTagSymlink(m.paths, ref.Repository(), ref.Tag(), ref.DigestHex()) } else { tagErr = ensurePendingTag(m.paths, ref.Repository(), ref.Tag(), ref.DigestHex()) @@ -295,6 +296,7 @@ func (m *manager) reuseExistingImage(ref *ResolvedRef, credentials *authn.AuthCo } if ref.Tag() != "" { if meta.Status == StatusReady { + m.nextTagGeneration(ref.Repository(), ref.Tag()) err = createTagSymlink(m.paths, ref.Repository(), ref.Tag(), ref.DigestHex()) } else { err = ensurePendingTag(m.paths, ref.Repository(), ref.Tag(), ref.DigestHex()) @@ -327,6 +329,24 @@ func (m *manager) nextTagGeneration(repository, tag string) uint64 { return m.tagGenerations[key] } +func (m *manager) restoreTagGenerations(metas []*imageMetadata) { + m.createMu.Lock() + defer m.createMu.Unlock() + for _, meta := range metas { + if meta.RequestedTag == "" { + continue + } + ref, err := ParseNormalizedRef(meta.Name) + if err != nil { + continue + } + key := tagGenerationKey(ref.Repository(), meta.RequestedTag) + if meta.TagGeneration > m.tagGenerations[key] { + m.tagGenerations[key] = meta.TagGeneration + } + } +} + func (m *manager) inflightCredentialsMatch(digest string, credentials *authn.AuthConfig) bool { inflight := m.inflightPulls[digest] var existingFingerprint [32]byte @@ -516,7 +536,13 @@ func (m *manager) buildImage(ctx context.Context, ref *ResolvedRef, credentials if meta.Status == StatusReady { // Another build completed first; last-pull-wins repoints the tag. if ref.Tag() != "" { - createTagSymlink(m.paths, ref.Repository(), ref.Tag(), ref.DigestHex()) + m.createMu.Lock() + m.nextTagGeneration(ref.Repository(), ref.Tag()) + err := createTagSymlink(m.paths, ref.Repository(), ref.Tag(), ref.DigestHex()) + m.createMu.Unlock() + if err != nil { + slog.Warn("failed to claim ready image tag", "repository", ref.Repository(), "tag", ref.Tag(), "error", err) + } } buildStatus = "success" return @@ -681,6 +707,7 @@ func (m *manager) RecoverInterruptedBuilds() { if err != nil { return // Best effort } + m.restoreTagGenerations(metas) // Sort by created_at to maintain FIFO order sort.Slice(metas, func(i, j int) bool { From 411ff566fbf555cc5344268dc019190f7ada31b8 Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:15:24 +0000 Subject: [PATCH 12/20] Add local image tagging API POST /images/{name}/tag creates or updates a repository tag pointing at an existing ready image without pulling or reconverting it. Sources may be a tag or digest; targets must carry a tag. Cross-repository targets promote the digest into shared content first so every alias resolves to one rootfs copy, and deletion keeps content alive while any repository tag references it. The tag symlink is staged and renamed atomically, so retagging an existing name never leaves a dangling reference. --- cmd/api/api/images.go | 39 ++ cmd/api/api/images_test.go | 137 +++++ lib/images/errors.go | 1 + lib/images/manager.go | 56 ++ lib/images/tag_test.go | 188 +++++++ lib/oapi/oapi.go | 1076 +++++++++++++++++++++++------------- lib/scopes/scopes.go | 9 +- openapi.yaml | 60 ++ stainless.yaml | 2 + 9 files changed, 1187 insertions(+), 381 deletions(-) create mode 100644 lib/images/tag_test.go diff --git a/cmd/api/api/images.go b/cmd/api/api/images.go index dc003370d..e9f6defc8 100644 --- a/cmd/api/api/images.go +++ b/cmd/api/api/images.go @@ -94,6 +94,45 @@ func (s *ApiService) CreateImage(ctx context.Context, request oapi.CreateImageRe return oapi.CreateImage202JSONResponse(imageToOAPI(*img)), nil } +func (s *ApiService) TagImage(ctx context.Context, request oapi.TagImageRequestObject) (oapi.TagImageResponseObject, error) { + if request.Body == nil { + return oapi.TagImage400JSONResponse{ + Code: "invalid_request", + Message: "request body is required", + }, nil + } + + img, err := s.ImageManager.TagImage(ctx, request.Name, request.Body.Target) + if err != nil { + log := logger.FromContext(ctx) + switch { + case errors.Is(err, images.ErrInvalidName): + return oapi.TagImage400JSONResponse{ + Code: "invalid_name", + Message: err.Error(), + }, nil + case errors.Is(err, images.ErrNotFound): + return oapi.TagImage404JSONResponse{ + Code: "not_found", + Message: "source image not found", + }, nil + case errors.Is(err, images.ErrImageNotReady): + return oapi.TagImage409JSONResponse{ + Code: "image_not_ready", + Message: err.Error(), + }, nil + default: + log.ErrorContext(ctx, "failed to tag image", "error", err, "source", request.Name, "target", request.Body.Target) + return oapi.TagImage500JSONResponse{ + Code: "internal_error", + Message: "failed to tag image", + }, nil + } + } + + return oapi.TagImage200JSONResponse(imageToOAPI(*img)), nil +} + // GetImage gets image details by name // Note: Resolution is handled by ResolveResource middleware func (s *ApiService) GetImage(ctx context.Context, request oapi.GetImageRequestObject) (oapi.GetImageResponseObject, error) { diff --git a/cmd/api/api/images_test.go b/cmd/api/api/images_test.go index bb13a9c02..2a5d02367 100644 --- a/cmd/api/api/images_test.go +++ b/cmd/api/api/images_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "os" + "path/filepath" "testing" "time" @@ -37,6 +38,15 @@ func (m *captureCreateImageManager) CreateImage(_ context.Context, req images.Cr return &images.Image{Name: req.Name, Digest: "sha256:test", Status: images.StatusPending, CreatedAt: time.Now()}, nil } +type tagImageErrManager struct { + images.Manager + err error +} + +func (m tagImageErrManager) TagImage(context.Context, string, string) (*images.Image, error) { + return nil, m.err +} + func TestCreateImage_MapsBorrowedCredentials(t *testing.T) { t.Parallel() @@ -530,3 +540,130 @@ func seedReadyDigestOnlyImage(t *testing.T, svc *ApiService, imageRef string, im require.NoError(t, err) require.NoError(t, os.WriteFile(p.ImageMetadata(ref.Repository(), ref.DigestHex()), data, 0o644)) } + +func TestTagImage_ErrorStatusMapping(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + err error + wantType any + wantCode string + }{ + { + name: "invalid name -> 400", + err: fmt.Errorf("tag: %w", images.ErrInvalidName), + wantType: oapi.TagImage400JSONResponse{}, + wantCode: "invalid_name", + }, + { + name: "not found -> 404", + err: fmt.Errorf("tag: %w", images.ErrNotFound), + wantType: oapi.TagImage404JSONResponse{}, + wantCode: "not_found", + }, + { + name: "not ready -> 409", + err: fmt.Errorf("tag: %w", images.ErrImageNotReady), + wantType: oapi.TagImage409JSONResponse{}, + wantCode: "image_not_ready", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + svc := &ApiService{ImageManager: tagImageErrManager{err: tc.err}} + resp, err := svc.TagImage(ctx(), oapi.TagImageRequestObject{ + Name: "docker.io/library/alpine:latest", + Body: &oapi.TagImageRequest{Target: "docker.io/library/alpine:stable"}, + }) + require.NoError(t, err) + require.IsType(t, tc.wantType, resp) + require.Equal(t, tc.wantCode, tagImageErrorCode(resp)) + }) + } +} + +func TestTagImage_MissingBody(t *testing.T) { + t.Parallel() + + svc := &ApiService{ImageManager: tagImageErrManager{}} + resp, err := svc.TagImage(ctx(), oapi.TagImageRequestObject{ + Name: "docker.io/library/alpine:latest", + }) + require.NoError(t, err) + require.IsType(t, oapi.TagImage400JSONResponse{}, resp) +} + +func tagImageErrorCode(resp oapi.TagImageResponseObject) string { + switch r := resp.(type) { + case oapi.TagImage400JSONResponse: + return r.Code + case oapi.TagImage404JSONResponse: + return r.Code + case oapi.TagImage409JSONResponse: + return r.Code + case oapi.TagImage500JSONResponse: + return r.Code + default: + return "" + } +} + +// seedReadyContentImage writes a ready image into the shared content layout +// plus a repository tag reference, without pulling from a registry. +func seedReadyContentImage(t *testing.T, svc *ApiService, repository, tag, digestHex string) { + t.Helper() + + p := paths.New(svc.Config.DataDir) + contentDir := p.ImageContentDir(digestHex) + require.NoError(t, os.MkdirAll(contentDir, 0o755)) + require.NoError(t, os.WriteFile(p.ImageContentPath(digestHex), []byte("rootfs"), 0o644)) + + meta := struct { + Name string `json:"name"` + Digest string `json:"digest"` + Status string `json:"status"` + SizeBytes int64 `json:"size_bytes"` + CreatedAt time.Time `json:"created_at"` + }{ + Name: repository + ":" + tag, + Digest: "sha256:" + digestHex, + Status: "ready", + SizeBytes: int64(len("rootfs")), + CreatedAt: time.Now().UTC(), + } + data, err := json.Marshal(meta) + require.NoError(t, err) + require.NoError(t, os.WriteFile(p.ImageContentMetadata(digestHex), data, 0o644)) + + linkPath := p.ImageRepositoryTagSymlink(repository, tag) + target, err := filepath.Rel(filepath.Dir(linkPath), contentDir) + require.NoError(t, err) + require.NoError(t, os.MkdirAll(filepath.Dir(linkPath), 0o755)) + require.NoError(t, os.Symlink(target, linkPath)) +} + +func TestTagImage_Success(t *testing.T) { + t.Parallel() + svc := newTestService(t) + + const repository = "docker.io/library/alpine" + const digestHex = "ab12cd34ab12cd34ab12cd34ab12cd34ab12cd34ab12cd34ab12cd34ab12cd34" + seedReadyContentImage(t, svc, repository, "latest", digestHex) + + resp, err := svc.TagImage(ctx(), oapi.TagImageRequestObject{ + Name: repository + ":latest", + Body: &oapi.TagImageRequest{Target: "registry.example/apps/alpine:v1"}, + }) + require.NoError(t, err) + okResp, ok := resp.(oapi.TagImage200JSONResponse) + require.True(t, ok, "expected 200 response, got %T", resp) + require.Equal(t, "registry.example/apps/alpine:v1", okResp.Name) + require.Equal(t, "sha256:"+digestHex, okResp.Digest) + + // The new tag resolves through GetImage. + img, err := svc.ImageManager.GetImage(ctx(), "registry.example/apps/alpine:v1") + require.NoError(t, err) + require.Equal(t, images.StatusReady, img.Status) +} diff --git a/lib/images/errors.go b/lib/images/errors.go index 6a63741f0..47be5032c 100644 --- a/lib/images/errors.go +++ b/lib/images/errors.go @@ -17,6 +17,7 @@ var ( ErrNotFound = errors.New("image not found") ErrInvalidName = errors.New("invalid image name") ErrInvalidPlatform = errors.New("invalid platform") + ErrImageNotReady = errors.New("image is not ready") // ErrPlatformNotAvailable means the requested platform is well-formed but the // image's manifest index does not publish a matching variant. Unlike // ErrInvalidPlatform (bad user syntax), the platform itself is valid; the diff --git a/lib/images/manager.go b/lib/images/manager.go index b1abbde9f..cbbfb073b 100644 --- a/lib/images/manager.go +++ b/lib/images/manager.go @@ -46,6 +46,10 @@ type Manager interface { // Unlike CreateImage, it does not resolve from a remote registry. ImportLocalImage(ctx context.Context, repo, reference, digest string) (*Image, error) GetImage(ctx context.Context, name string) (*Image, error) + // TagImage creates or updates a local tag pointing at an existing ready + // image, without pulling or reconverting. Source and target may be in + // different repositories. + TagImage(ctx context.Context, source, target string) (*Image, error) DeleteImage(ctx context.Context, name string) error RecoverInterruptedBuilds() // TotalImageBytes returns the total size of all ready images on disk. @@ -780,6 +784,58 @@ func (m *manager) GetImage(ctx context.Context, name string) (*Image, error) { return img, nil } +// TagImage creates or updates a local tag pointing at an existing ready +// image. The source may be a tag or a digest; the target must carry a tag. +// No pull or conversion happens: the target becomes another reference to the +// same shared content. Source and target may live in different repositories; +// a cross-repository target first promotes the digest into shared content so +// both references resolve to one rootfs copy. +func (m *manager) TagImage(ctx context.Context, source, target string) (*Image, error) { + sourceRef, err := ParseNormalizedRef(source) + if err != nil { + return nil, fmt.Errorf("%w: invalid source reference: %s", ErrInvalidName, err) + } + targetRef, err := ParseNormalizedRef(target) + if err != nil { + return nil, fmt.Errorf("%w: invalid target reference: %s", ErrInvalidName, err) + } + if targetRef.IsDigest() { + return nil, fmt.Errorf("%w: target must include a tag", ErrInvalidName) + } + + m.createMu.Lock() + defer m.createMu.Unlock() + + digestHex := sourceRef.DigestHex() + if !sourceRef.IsDigest() { + digestHex, err = resolveTag(m.paths, sourceRef.Repository(), sourceRef.Tag()) + if err != nil { + return nil, err + } + } + + meta, err := readMetadata(m.paths, sourceRef.Repository(), digestHex) + if err != nil { + return nil, err + } + if meta.Status != StatusReady { + return nil, fmt.Errorf("%w: %s", ErrImageNotReady, meta.Status) + } + + if sourceRef.Repository() != targetRef.Repository() { + if err := promoteImageToContent(m.paths, sourceRef.Repository(), digestHex, meta); err != nil { + return nil, fmt.Errorf("create image alias: %w", err) + } + } + if err := createTagSymlink(m.paths, targetRef.Repository(), targetRef.Tag(), digestHex); err != nil { + return nil, fmt.Errorf("create image tag: %w", err) + } + + img := meta.toImage() + img.Name = targetRef.String() + return img, nil +} + func (m *manager) DeleteImage(ctx context.Context, name string) error { // Parse and normalize the reference ref, err := ParseNormalizedRef(name) diff --git a/lib/images/tag_test.go b/lib/images/tag_test.go new file mode 100644 index 000000000..e44e32259 --- /dev/null +++ b/lib/images/tag_test.go @@ -0,0 +1,188 @@ +package images + +import ( + "context" + "os" + "testing" + "time" + + "github.com/kernel/hypeman/lib/paths" + "github.com/stretchr/testify/require" +) + +// seedReadyContentImage writes a ready image directly into the shared content +// layout and tags it, without going through a pull. +func seedReadyContentImage(t *testing.T, p *paths.Paths, repository, tag, digestHex string) { + t.Helper() + require.NoError(t, os.MkdirAll(p.ImageContentDir(digestHex), 0o755)) + require.NoError(t, writeMetadataFile(p.ImageContentMetadata(digestHex), &imageMetadata{ + Name: repository + ":" + tag, + Digest: "sha256:" + digestHex, + Status: StatusReady, + SizeBytes: 7, + CreatedAt: time.Now().UTC(), + })) + require.NoError(t, os.WriteFile(p.ImageContentPath(digestHex), []byte("rootfs!"), 0o644)) + require.NoError(t, createTagSymlink(p, repository, tag, digestHex)) +} + +func newTagTestManager(p *paths.Paths) *manager { + return &manager{paths: p} +} + +func TestTagImageSameRepository(t *testing.T) { + p := paths.New(t.TempDir()) + m := newTagTestManager(p) + repository := "docker.io/library/alpine" + digest := "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1" + seedReadyContentImage(t, p, repository, "latest", digest) + + img, err := m.TagImage(context.Background(), repository+":latest", repository+":stable") + require.NoError(t, err) + require.Equal(t, repository+":stable", img.Name) + require.Equal(t, "sha256:"+digest, img.Digest) + + resolved, err := resolveTag(p, repository, "stable") + require.NoError(t, err) + require.Equal(t, digest, resolved) + + // Both tags keep the shared content alive. + require.NoError(t, m.DeleteImage(context.Background(), repository+":latest")) + _, err = os.Stat(p.ImageContentDir(digest)) + require.NoError(t, err, "content must survive while another tag references it") + + require.NoError(t, m.DeleteImage(context.Background(), repository+":stable")) + _, err = os.Stat(p.ImageContentDir(digest)) + require.True(t, os.IsNotExist(err), "content must be removed once unreferenced") +} + +func TestTagImageFromDigestSource(t *testing.T) { + p := paths.New(t.TempDir()) + m := newTagTestManager(p) + repository := "docker.io/library/alpine" + digest := "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2" + seedReadyContentImage(t, p, repository, "latest", digest) + + img, err := m.TagImage(context.Background(), repository+"@sha256:"+digest, repository+":pinned") + require.NoError(t, err) + require.Equal(t, repository+":pinned", img.Name) + + resolved, err := resolveTag(p, repository, "pinned") + require.NoError(t, err) + require.Equal(t, digest, resolved) +} + +func TestTagImageCrossRepository(t *testing.T) { + p := paths.New(t.TempDir()) + m := newTagTestManager(p) + sourceRepo := "docker.io/library/alpine" + targetRepo := "registry.example/apps/alpine" + digest := "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3" + + // Ready image still living in the legacy per-repository layout. + legacyDir := p.ImageDigestDir(sourceRepo, digest) + require.NoError(t, os.MkdirAll(legacyDir, 0o755)) + require.NoError(t, writeMetadataFile(p.ImageMetadata(sourceRepo, digest), &imageMetadata{ + Name: sourceRepo + ":latest", + Digest: "sha256:" + digest, + Status: StatusReady, + SizeBytes: 7, + CreatedAt: time.Now().UTC(), + })) + require.NoError(t, os.WriteFile(p.ImageDigestPath(sourceRepo, digest), []byte("rootfs!"), 0o644)) + require.NoError(t, createTagSymlink(p, sourceRepo, "latest", digest)) + + img, err := m.TagImage(context.Background(), sourceRepo+":latest", targetRepo+":v1") + require.NoError(t, err) + require.Equal(t, targetRepo+":v1", img.Name) + + // Promotion moved the digest into shared content and retired the legacy tree. + data, err := os.ReadFile(p.ImageContentPath(digest)) + require.NoError(t, err) + require.Equal(t, "rootfs!", string(data)) + _, err = os.Stat(legacyDir) + require.True(t, os.IsNotExist(err), "legacy tree should be retired after promotion") + + // Both repositories resolve to the same digest. + for repo, tag := range map[string]string{sourceRepo: "latest", targetRepo: "v1"} { + resolved, err := resolveTag(p, repo, tag) + require.NoError(t, err) + require.Equal(t, digest, resolved) + } + + // Deleting the source tag leaves the cross-repository alias intact. + require.NoError(t, m.DeleteImage(context.Background(), sourceRepo+":latest")) + _, err = os.Stat(p.ImageContentDir(digest)) + require.NoError(t, err, "cross-repository tag must keep content alive") + + require.NoError(t, m.DeleteImage(context.Background(), targetRepo+":v1")) + _, err = os.Stat(p.ImageContentDir(digest)) + require.True(t, os.IsNotExist(err), "content must be removed once unreferenced") +} + +func TestTagImageRejectsNotReady(t *testing.T) { + p := paths.New(t.TempDir()) + m := newTagTestManager(p) + repository := "docker.io/library/alpine" + digest := "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4" + + require.NoError(t, os.MkdirAll(p.ImageContentDir(digest), 0o755)) + require.NoError(t, writeMetadataFile(p.ImageContentMetadata(digest), &imageMetadata{ + Name: repository + ":latest", + Digest: "sha256:" + digest, + Status: StatusConverting, + CreatedAt: time.Now().UTC(), + })) + require.NoError(t, createTagSymlink(p, repository, "latest", digest)) + + _, err := m.TagImage(context.Background(), repository+":latest", repository+":stable") + require.ErrorIs(t, err, ErrImageNotReady) + + _, err = resolveTag(p, repository, "stable") + require.ErrorIs(t, err, ErrNotFound, "failed tag must not create a reference") +} + +func TestTagImageSourceNotFound(t *testing.T) { + p := paths.New(t.TempDir()) + m := newTagTestManager(p) + repository := "docker.io/library/alpine" + + _, err := m.TagImage(context.Background(), repository+":missing", repository+":stable") + require.ErrorIs(t, err, ErrNotFound) +} + +func TestTagImageRejectsDigestTarget(t *testing.T) { + p := paths.New(t.TempDir()) + m := newTagTestManager(p) + repository := "docker.io/library/alpine" + digest := "e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5" + seedReadyContentImage(t, p, repository, "latest", digest) + + _, err := m.TagImage(context.Background(), repository+":latest", repository+"@sha256:"+digest) + require.ErrorIs(t, err, ErrInvalidName) +} + +func TestTagImageReplacesExistingTag(t *testing.T) { + p := paths.New(t.TempDir()) + m := newTagTestManager(p) + repository := "docker.io/library/alpine" + first := "f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6" + second := "a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7" + seedReadyContentImage(t, p, repository, "latest", first) + seedReadyContentImage(t, p, repository, "stable", second) + + // "stable" moves from the second digest to the first. + img, err := m.TagImage(context.Background(), repository+":latest", repository+":stable") + require.NoError(t, err) + require.Equal(t, repository+":stable", img.Name) + + resolved, err := resolveTag(p, repository, "stable") + require.NoError(t, err) + require.Equal(t, first, resolved) + + // The orphaned second digest is cleaned up by delete semantics, not by tag; + // it is still referenced by no tag after the repoint only if it had no other + // tag. Here "stable" was its only tag, so it remains on disk until deleted. + _, err = os.Stat(p.ImageContentDir(second)) + require.NoError(t, err) +} diff --git a/lib/oapi/oapi.go b/lib/oapi/oapi.go index ef827c489..bda3c0909 100644 --- a/lib/oapi/oapi.go +++ b/lib/oapi/oapi.go @@ -1841,6 +1841,12 @@ type StandbyInstanceRequest struct { CompressionDelay *string `json:"compression_delay,omitempty"` } +// TagImageRequest defines model for TagImageRequest. +type TagImageRequest struct { + // Target Target OCI image reference with a tag. The local tag points to the source image without pulling it again. + Target string `json:"target"` +} + // Tags User-defined key-value tags. type Tags map[string]string @@ -2131,6 +2137,9 @@ type CreateDeviceJSONRequestBody = CreateDeviceRequest // CreateImageJSONRequestBody defines body for CreateImage for application/json ContentType. type CreateImageJSONRequestBody = CreateImageRequest +// TagImageJSONRequestBody defines body for TagImage for application/json ContentType. +type TagImageJSONRequestBody = TagImageRequest + // CreateIngressJSONRequestBody defines body for CreateIngress for application/json ContentType. type CreateIngressJSONRequestBody = CreateIngressRequest @@ -2315,6 +2324,11 @@ type ClientInterface interface { // GetImage request GetImage(ctx context.Context, name string, reqEditors ...RequestEditorFn) (*http.Response, error) + // TagImageWithBody request with any body + TagImageWithBody(ctx context.Context, name string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + TagImage(ctx context.Context, name string, body TagImageJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // ListIngresses request ListIngresses(ctx context.Context, params *ListIngressesParams, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -2755,6 +2769,30 @@ func (c *Client) GetImage(ctx context.Context, name string, reqEditors ...Reques return c.Client.Do(req) } +func (c *Client) TagImageWithBody(ctx context.Context, name string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewTagImageRequestWithBody(c.Server, name, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) TagImage(ctx context.Context, name string, body TagImageJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewTagImageRequest(c.Server, name, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) ListIngresses(ctx context.Context, params *ListIngressesParams, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewListIngressesRequest(c.Server, params) if err != nil { @@ -4203,6 +4241,53 @@ func NewGetImageRequest(server string, name string) (*http.Request, error) { return req, nil } +// NewTagImageRequest calls the generic TagImage builder with application/json body +func NewTagImageRequest(server string, name string, body TagImageJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewTagImageRequestWithBody(server, name, "application/json", bodyReader) +} + +// NewTagImageRequestWithBody generates requests for TagImage with any type of body +func NewTagImageRequestWithBody(server string, name string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "name", runtime.ParamLocationPath, name) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/images/%s/tag", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + // NewListIngressesRequest generates requests for ListIngresses func NewListIngressesRequest(server string, params *ListIngressesParams) (*http.Request, error) { var err error @@ -6166,6 +6251,11 @@ type ClientWithResponsesInterface interface { // GetImageWithResponse request GetImageWithResponse(ctx context.Context, name string, reqEditors ...RequestEditorFn) (*GetImageResponse, error) + // TagImageWithBodyWithResponse request with any body + TagImageWithBodyWithResponse(ctx context.Context, name string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*TagImageResponse, error) + + TagImageWithResponse(ctx context.Context, name string, body TagImageJSONRequestBody, reqEditors ...RequestEditorFn) (*TagImageResponse, error) + // ListIngressesWithResponse request ListIngressesWithResponse(ctx context.Context, params *ListIngressesParams, reqEditors ...RequestEditorFn) (*ListIngressesResponse, error) @@ -6833,6 +6923,32 @@ func (r GetImageResponse) StatusCode() int { return 0 } +type TagImageResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *Image + JSON400 *Error + JSON404 *Error + JSON409 *Error + JSON500 *Error +} + +// Status returns HTTPResponse.Status +func (r TagImageResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r TagImageResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + type ListIngressesResponse struct { Body []byte HTTPResponse *http.Response @@ -8065,6 +8181,23 @@ func (c *ClientWithResponses) GetImageWithResponse(ctx context.Context, name str return ParseGetImageResponse(rsp) } +// TagImageWithBodyWithResponse request with arbitrary body returning *TagImageResponse +func (c *ClientWithResponses) TagImageWithBodyWithResponse(ctx context.Context, name string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*TagImageResponse, error) { + rsp, err := c.TagImageWithBody(ctx, name, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseTagImageResponse(rsp) +} + +func (c *ClientWithResponses) TagImageWithResponse(ctx context.Context, name string, body TagImageJSONRequestBody, reqEditors ...RequestEditorFn) (*TagImageResponse, error) { + rsp, err := c.TagImage(ctx, name, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseTagImageResponse(rsp) +} + // ListIngressesWithResponse request returning *ListIngressesResponse func (c *ClientWithResponses) ListIngressesWithResponse(ctx context.Context, params *ListIngressesParams, reqEditors ...RequestEditorFn) (*ListIngressesResponse, error) { rsp, err := c.ListIngresses(ctx, params, reqEditors...) @@ -9463,6 +9596,60 @@ func ParseGetImageResponse(rsp *http.Response) (*GetImageResponse, error) { return response, nil } +// ParseTagImageResponse parses an HTTP response from a TagImageWithResponse call +func ParseTagImageResponse(rsp *http.Response) (*TagImageResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &TagImageResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest Image + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + // ParseListIngressesResponse parses an HTTP response from a ListIngressesWithResponse call func ParseListIngressesResponse(rsp *http.Response) (*ListIngressesResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -11413,6 +11600,9 @@ type ServerInterface interface { // Get image details // (GET /images/{name}) GetImage(w http.ResponseWriter, r *http.Request, name string) + // Create or update a local image tag + // (POST /images/{name}/tag) + TagImage(w http.ResponseWriter, r *http.Request, name string) // List ingresses // (GET /ingresses) ListIngresses(w http.ResponseWriter, r *http.Request, params ListIngressesParams) @@ -11668,6 +11858,12 @@ func (_ Unimplemented) GetImage(w http.ResponseWriter, r *http.Request, name str w.WriteHeader(http.StatusNotImplemented) } +// Create or update a local image tag +// (POST /images/{name}/tag) +func (_ Unimplemented) TagImage(w http.ResponseWriter, r *http.Request, name string) { + w.WriteHeader(http.StatusNotImplemented) +} + // List ingresses // (GET /ingresses) func (_ Unimplemented) ListIngresses(w http.ResponseWriter, r *http.Request, params ListIngressesParams) { @@ -12510,6 +12706,37 @@ func (siw *ServerInterfaceWrapper) GetImage(w http.ResponseWriter, r *http.Reque handler.ServeHTTP(w, r) } +// TagImage operation middleware +func (siw *ServerInterfaceWrapper) TagImage(w http.ResponseWriter, r *http.Request) { + + var err error + + // ------------- Path parameter "name" ------------- + var name string + + err = runtime.BindStyledParameterWithOptions("simple", "name", chi.URLParam(r, "name"), &name, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "name", Err: err}) + return + } + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.TagImage(w, r, name) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // ListIngresses operation middleware func (siw *ServerInterfaceWrapper) ListIngresses(w http.ResponseWriter, r *http.Request) { @@ -14066,6 +14293,9 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/images/{name}", wrapper.GetImage) }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/images/{name}/tag", wrapper.TagImage) + }) r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/ingresses", wrapper.ListIngresses) }) @@ -15076,6 +15306,60 @@ func (response GetImage500JSONResponse) VisitGetImageResponse(w http.ResponseWri return json.NewEncoder(w).Encode(response) } +type TagImageRequestObject struct { + Name string `json:"name"` + Body *TagImageJSONRequestBody +} + +type TagImageResponseObject interface { + VisitTagImageResponse(w http.ResponseWriter) error +} + +type TagImage200JSONResponse Image + +func (response TagImage200JSONResponse) VisitTagImageResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type TagImage400JSONResponse Error + +func (response TagImage400JSONResponse) VisitTagImageResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type TagImage404JSONResponse Error + +func (response TagImage404JSONResponse) VisitTagImageResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(404) + + return json.NewEncoder(w).Encode(response) +} + +type TagImage409JSONResponse Error + +func (response TagImage409JSONResponse) VisitTagImageResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(409) + + return json.NewEncoder(w).Encode(response) +} + +type TagImage500JSONResponse Error + +func (response TagImage500JSONResponse) VisitTagImageResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + type ListIngressesRequestObject struct { Params ListIngressesParams } @@ -16974,6 +17258,9 @@ type StrictServerInterface interface { // Get image details // (GET /images/{name}) GetImage(ctx context.Context, request GetImageRequestObject) (GetImageResponseObject, error) + // Create or update a local image tag + // (POST /images/{name}/tag) + TagImage(ctx context.Context, request TagImageRequestObject) (TagImageResponseObject, error) // List ingresses // (GET /ingresses) ListIngresses(ctx context.Context, request ListIngressesRequestObject) (ListIngressesResponseObject, error) @@ -17689,6 +17976,39 @@ func (sh *strictHandler) GetImage(w http.ResponseWriter, r *http.Request, name s } } +// TagImage operation middleware +func (sh *strictHandler) TagImage(w http.ResponseWriter, r *http.Request, name string) { + var request TagImageRequestObject + + request.Name = name + + var body TagImageJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.TagImage(ctx, request.(TagImageRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "TagImage") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(TagImageResponseObject); ok { + if err := validResponse.VisitTagImageResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + // ListIngresses operation middleware func (sh *strictHandler) ListIngresses(w http.ResponseWriter, r *http.Request, params ListIngressesParams) { var request ListIngressesRequestObject @@ -18850,385 +19170,387 @@ func (sh *strictHandler) GetVolume(w http.ResponseWriter, r *http.Request, id st // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/lib/scopes/scopes.go b/lib/scopes/scopes.go index 4773437a0..9d078c1fb 100644 --- a/lib/scopes/scopes.go +++ b/lib/scopes/scopes.go @@ -229,10 +229,11 @@ var RouteScopes = map[string]Scope{ "POST /resources/memory/reclaim": ResourceWrite, // Images - "GET /images": ImageRead, - "POST /images": ImageWrite, - "DELETE /images/{name}": ImageDelete, - "GET /images/{name}": ImageRead, + "GET /images": ImageRead, + "POST /images": ImageWrite, + "POST /images/{name}/tag": ImageWrite, + "DELETE /images/{name}": ImageDelete, + "GET /images/{name}": ImageRead, // Pushes (outbound image pushes to remote registries) "POST /pushes": ImageWrite, diff --git a/openapi.yaml b/openapi.yaml index d6016524d..e7cef8a6c 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1290,6 +1290,15 @@ components: tags: $ref: "#/components/schemas/Tags" + TagImageRequest: + type: object + required: [target] + properties: + target: + type: string + description: Target OCI image reference with a tag. The local tag points to the source image without pulling it again. + example: docker.io/library/nginx:stable + Image: type: object required: [name, digest, status, created_at] @@ -2640,6 +2649,57 @@ paths: schema: $ref: "#/components/schemas/Error" + /images/{name}/tag: + post: + summary: Create or update a local image tag + operationId: tagImage + security: + - bearerAuth: [] + parameters: + - name: name + in: path + required: true + schema: + type: string + description: URL-encoded source image name or digest + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/TagImageRequest" + responses: + 200: + description: Image tag created + content: + application/json: + schema: + $ref: "#/components/schemas/Image" + 400: + description: Invalid source or target image reference + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + 404: + description: Source image not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + 409: + description: Source image is not ready + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + 500: + description: Internal server error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /images/{name}: get: summary: Get image details diff --git a/stainless.yaml b/stainless.yaml index 0d95928b2..dbd40fbc9 100644 --- a/stainless.yaml +++ b/stainless.yaml @@ -97,11 +97,13 @@ resources: # [reference]: https://www.stainless.com/docs/reference/config#model models: image: "#/components/schemas/Image" + tag_image_request: "#/components/schemas/TagImageRequest" methods: list: get /images create: post /images get: get /images/{name} delete: delete /images/{name} + tag: post /images/{name}/tag instances: models: From 273a4ba84b594795ec4ded859b6d991f6ace7094 Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:18:37 +0000 Subject: [PATCH 13/20] Add route-level URL decoding test and builds mock TagImage Ported from the earlier tag draft: verify the generated tag route decodes URL-encoded source names, and keep the builds test mock satisfying the extended Manager interface. --- cmd/api/main_test.go | 44 ++++++++++++++++++++++++++++++++++++++ lib/builds/manager_test.go | 13 +++++++++++ 2 files changed, 57 insertions(+) diff --git a/cmd/api/main_test.go b/cmd/api/main_test.go index 34dbba428..ef88b586b 100644 --- a/cmd/api/main_test.go +++ b/cmd/api/main_test.go @@ -252,6 +252,50 @@ func (h *testImageHandler) GetImage(w http.ResponseWriter, r *http.Request, name } } +func TestTagImageRoute_URLDecodedName(t *testing.T) { + // Verifies the generated POST /images/{name}/tag route exists and that the + // URL-encoded source name is decoded before reaching the handler. + r := chi.NewRouter() + + var receivedName string + handler := &testTagImageHandler{ + tagImage: func(w http.ResponseWriter, r *http.Request, name string) { + receivedName = name + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"name":"` + name + `","digest":"sha256:test","status":"ready","created_at":"2026-01-01T00:00:00Z"}`)) + }, + } + + oapi.HandlerFromMux(handler, r) + + token, err := generateValidJWT("user-123") + require.NoError(t, err) + + body := bytes.NewReader([]byte(`{"target":"docker.io/library/alpine:stable"}`)) + req := httptest.NewRequest(http.MethodPost, "/images/docker.io%2Flibrary%2Falpine%3Alatest/tag", body) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) + assert.Equal(t, "docker.io/library/alpine:latest", receivedName) +} + +// testTagImageHandler implements oapi.ServerInterface with just TagImage for testing +type testTagImageHandler struct { + oapi.Unimplemented + tagImage func(w http.ResponseWriter, r *http.Request, name string) +} + +func (h *testTagImageHandler) TagImage(w http.ResponseWriter, r *http.Request, name string) { + if h.tagImage != nil { + h.tagImage(w, r, name) + } +} + func TestImageNameWithSlashes_URLEncoding(t *testing.T) { // This test verifies how chi router handles image names with slashes. // Image names like "docker.io/onkernel/chromium-headful:latest" contain slashes diff --git a/lib/builds/manager_test.go b/lib/builds/manager_test.go index 7be58b0fc..a947090a2 100644 --- a/lib/builds/manager_test.go +++ b/lib/builds/manager_test.go @@ -369,6 +369,19 @@ func (m *mockImageManager) DeleteImage(ctx context.Context, name string) error { return nil } +func (m *mockImageManager) TagImage(ctx context.Context, source, target string) (*images.Image, error) { + m.mu.Lock() + defer m.mu.Unlock() + img, ok := m.images[source] + if !ok { + return nil, images.ErrNotFound + } + tagged := *img + tagged.Name = target + m.images[target] = &tagged + return &tagged, nil +} + func (m *mockImageManager) RecoverInterruptedBuilds() {} func (m *mockImageManager) TotalImageBytes(ctx context.Context) (int64, error) { From 69350affca0314ba639061a0bf0fc81ffac2281a Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:52:52 +0000 Subject: [PATCH 14/20] Simplify local tag creation --- lib/images/manager.go | 34 ++++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/lib/images/manager.go b/lib/images/manager.go index cbbfb073b..f3af938c4 100644 --- a/lib/images/manager.go +++ b/lib/images/manager.go @@ -806,22 +806,14 @@ func (m *manager) TagImage(ctx context.Context, source, target string) (*Image, m.createMu.Lock() defer m.createMu.Unlock() - digestHex := sourceRef.DigestHex() - if !sourceRef.IsDigest() { - digestHex, err = resolveTag(m.paths, sourceRef.Repository(), sourceRef.Tag()) - if err != nil { - return nil, err - } - } - - meta, err := readMetadata(m.paths, sourceRef.Repository(), digestHex) + digestHex, err := m.resolveTagSource(sourceRef) if err != nil { return nil, err } - if meta.Status != StatusReady { - return nil, fmt.Errorf("%w: %s", ErrImageNotReady, meta.Status) + meta, err := m.readyImageMetadata(sourceRef.Repository(), digestHex) + if err != nil { + return nil, err } - if sourceRef.Repository() != targetRef.Repository() { if err := promoteImageToContent(m.paths, sourceRef.Repository(), digestHex, meta); err != nil { return nil, fmt.Errorf("create image alias: %w", err) @@ -836,6 +828,24 @@ func (m *manager) TagImage(ctx context.Context, source, target string) (*Image, return img, nil } +func (m *manager) resolveTagSource(ref *NormalizedRef) (string, error) { + if ref.IsDigest() { + return ref.DigestHex(), nil + } + return resolveTag(m.paths, ref.Repository(), ref.Tag()) +} + +func (m *manager) readyImageMetadata(repository, digestHex string) (*imageMetadata, error) { + meta, err := readMetadata(m.paths, repository, digestHex) + if err != nil { + return nil, err + } + if meta.Status != StatusReady { + return nil, fmt.Errorf("%w: %s", ErrImageNotReady, meta.Status) + } + return meta, nil +} + func (m *manager) DeleteImage(ctx context.Context, name string) error { // Parse and normalize the reference ref, err := ParseNormalizedRef(name) From 32b6564a6e782dc0ed83e2550030d238f6d6d92a Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:26:12 +0000 Subject: [PATCH 15/20] Track tag API mutations --- lib/images/manager.go | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/images/manager.go b/lib/images/manager.go index f3af938c4..fb9716a9d 100644 --- a/lib/images/manager.go +++ b/lib/images/manager.go @@ -805,6 +805,7 @@ func (m *manager) TagImage(ctx context.Context, source, target string) (*Image, m.createMu.Lock() defer m.createMu.Unlock() + m.nextTagGeneration(targetRef.Repository(), targetRef.Tag()) digestHex, err := m.resolveTagSource(sourceRef) if err != nil { From 3781c058da896bc11ecccb7e8e83878fd4fe1db3 Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:26:02 +0000 Subject: [PATCH 16/20] Persist OCI manifest content model per image digest Record the manifest's ordered layer descriptors, config blob with its diff ids, and resolved platform beside the shared content (images/content//manifest.json). The model is extracted from the existing OCI layout cache during the metadata phase of a pull, so no second blob downloader is introduced. Layer digests pair with config diff ids by position, giving later stages everything needed to materialize per-layer artifacts and recompose the rootfs, and GC the set of OCI blobs each ready image still references. Metadata writes now share one atomic temp-file-then-rename helper. --- lib/images/manager.go | 11 ++ lib/images/manifest_model.go | 108 ++++++++++++++ lib/images/manifest_model_test.go | 230 ++++++++++++++++++++++++++++++ lib/images/oci.go | 69 +++++++++ lib/images/storage.go | 15 +- lib/paths/paths.go | 6 + 6 files changed, 425 insertions(+), 14 deletions(-) create mode 100644 lib/images/manifest_model.go create mode 100644 lib/images/manifest_model_test.go diff --git a/lib/images/manager.go b/lib/images/manager.go index fb9716a9d..a9b78c8b4 100644 --- a/lib/images/manager.go +++ b/lib/images/manager.go @@ -614,6 +614,17 @@ func (m *manager) finalizeImage(ref *ResolvedRef, result *pullResult, diskSize i return err } + // Persist the manifest content model beside the shared content so later + // stages can recompose the image from per-layer artifacts and GC can tell + // which OCI blobs are still referenced. + if result.Manifest != nil { + model := *result.Manifest + model.Platform = actualPlatform.String() + if err := writeManifestModel(m.paths, ref.DigestHex(), &model); err != nil { + return fmt.Errorf("write manifest model: %w", err) + } + } + meta.Status = StatusReady meta.Error = nil meta.Platform = actualPlatform.String() diff --git a/lib/images/manifest_model.go b/lib/images/manifest_model.go new file mode 100644 index 000000000..f7ebc5675 --- /dev/null +++ b/lib/images/manifest_model.go @@ -0,0 +1,108 @@ +package images + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/kernel/hypeman/lib/paths" +) + +// imageManifestModel is the persisted content model for one OCI manifest. It +// records everything needed to recompose the image from shared layer +// artifacts and to decide which OCI blobs are still referenced: the immutable +// manifest digest, the resolved platform, the image config with its diff ids, +// and the ordered layer descriptors. +type imageManifestModel struct { + SchemaVersion int `json:"schema_version"` + Digest string `json:"digest"` // manifest digest, sha256:... + MediaType string `json:"media_type,omitempty"` + Platform string `json:"platform"` // os/arch[/variant] + Config manifestConfigRef `json:"config"` + Layers []layerDescriptor `json:"layers"` // manifest order, base layer first +} + +const manifestModelSchemaVersion = 1 + +type manifestConfigRef struct { + Digest string `json:"digest"` // config blob digest, sha256:... + MediaType string `json:"media_type,omitempty"` + DiffIDs []string `json:"diff_ids,omitempty"` // uncompressed layer ids, matching Layers order +} + +// layerDescriptor describes one compressed layer blob exactly as it appears in +// the manifest, so the blob can be located in the shared OCI cache by digest. +type layerDescriptor struct { + Digest string `json:"digest"` // compressed blob digest, sha256:... + Size int64 `json:"size"` // compressed bytes + MediaType string `json:"media_type,omitempty"` + DiffID string `json:"diff_id,omitempty"` // uncompressed diff id from the image config +} + +// digestFromHex returns the full sha256 digest string for a bare hex value. +func digestFromHex(hex string) string { + if strings.HasPrefix(hex, "sha256:") { + return hex + } + return "sha256:" + hex +} + +// blobReferences returns every OCI blob digest the manifest depends on: the +// config blob plus all layer blobs. GC must keep these while the manifest is +// referenced. +func (m *imageManifestModel) blobReferences() []string { + refs := make([]string, 0, len(m.Layers)+1) + if m.Config.Digest != "" { + refs = append(refs, m.Config.Digest) + } + for _, layer := range m.Layers { + refs = append(refs, layer.Digest) + } + return refs +} + +// writeManifestModel persists the manifest model for a digest atomically. +func writeManifestModel(p *paths.Paths, digestHex string, model *imageManifestModel) error { + data, err := json.MarshalIndent(model, "", " ") + if err != nil { + return fmt.Errorf("marshal manifest model: %w", err) + } + return writeJSONAtomic(p.ImageContentManifestModel(digestHex), data) +} + +// readManifestModel loads the manifest model for a digest, if present. +// Missing models return (nil, nil): images converted before the manifest model +// existed only have a flattened rootfs. +func readManifestModel(p *paths.Paths, digestHex string) (*imageManifestModel, error) { + data, err := os.ReadFile(p.ImageContentManifestModel(digestHex)) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("read manifest model: %w", err) + } + var model imageManifestModel + if err := json.Unmarshal(data, &model); err != nil { + return nil, fmt.Errorf("unmarshal manifest model: %w", err) + } + return &model, nil +} + +// writeJSONAtomic writes data to path via a temp file in the same directory +// followed by a rename, so readers never observe a partial document. +func writeJSONAtomic(path string, data []byte) error { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return fmt.Errorf("create directory: %w", err) + } + tempPath := path + ".tmp" + if err := os.WriteFile(tempPath, data, 0644); err != nil { + return fmt.Errorf("write temp file: %w", err) + } + if err := os.Rename(tempPath, path); err != nil { + _ = os.Remove(tempPath) + return fmt.Errorf("rename into place: %w", err) + } + return nil +} diff --git a/lib/images/manifest_model_test.go b/lib/images/manifest_model_test.go new file mode 100644 index 000000000..91222c453 --- /dev/null +++ b/lib/images/manifest_model_test.go @@ -0,0 +1,230 @@ +package images + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "io" + "os" + "testing" + "time" + + gcr "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/layout" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/tarball" + "github.com/google/go-containerregistry/pkg/v1/types" + "github.com/kernel/hypeman/lib/paths" + "github.com/stretchr/testify/require" +) + +// syntheticLayer builds a gzipped tar layer containing one file. +func syntheticLayer(t *testing.T, name, content string) gcr.Layer { + t.Helper() + + var buf bytes.Buffer + gzw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gzw) + require.NoError(t, tw.WriteHeader(&tar.Header{ + Name: name, + Size: int64(len(content)), + Typeflag: tar.TypeReg, + Mode: 0644, + })) + _, err := tw.Write([]byte(content)) + require.NoError(t, err) + require.NoError(t, tw.Close()) + require.NoError(t, gzw.Close()) + + data := buf.Bytes() + layer, err := tarball.LayerFromOpener(func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(data)), nil + }) + require.NoError(t, err) + return layer +} + +// writeSyntheticLayout writes img into a fresh OCI layout cache tagged with the +// image's own digest, mirroring pullToOCILayout. +func writeSyntheticLayout(t *testing.T, img gcr.Image) (*ociClient, string) { + t.Helper() + + client, err := newOCIClient(t.TempDir()) + require.NoError(t, err) + + digest, err := img.Digest() + require.NoError(t, err) + layoutPath, err := layout.Write(client.cacheDir, empty.Index) + require.NoError(t, err) + require.NoError(t, layoutPath.AppendImage(img, layout.WithAnnotations(map[string]string{ + "org.opencontainers.image.ref.name": digestToLayoutTag(digest.String()), + }))) + return client, digestToLayoutTag(digest.String()) +} + +func TestExtractManifestModel(t *testing.T) { + base := syntheticLayer(t, "base.txt", "base layer content") + top := syntheticLayer(t, "top.txt", "top layer content") + img, err := mutate.AppendLayers(empty.Image, base, top) + require.NoError(t, err) + img = mutate.MediaType(img, types.OCIManifestSchema1) + + client, layoutTag := writeSyntheticLayout(t, img) + + model, err := client.extractManifestModel(layoutTag) + require.NoError(t, err) + + manifest, err := img.Manifest() + require.NoError(t, err) + configFile, err := img.ConfigFile() + require.NoError(t, err) + configDigest, err := img.ConfigName() + require.NoError(t, err) + + require.Equal(t, "sha256:"+layoutTag, model.Digest) + require.Equal(t, manifest.MediaType, types.MediaType(model.MediaType)) + require.Equal(t, configDigest.String(), model.Config.Digest) + require.Len(t, model.Layers, 2) + require.Len(t, model.Config.DiffIDs, 2) + + // Layer order and pairing must match the manifest and config diff ids. + for i, desc := range model.Layers { + require.Equal(t, manifest.Layers[i].Digest.String(), desc.Digest) + require.Equal(t, manifest.Layers[i].Size, desc.Size) + require.Equal(t, configFile.RootFS.DiffIDs[i].String(), desc.DiffID) + require.Equal(t, model.Config.DiffIDs[i], desc.DiffID) + } + + // Digests are content addresses, so identical layer content dedupes. + require.NotEqual(t, model.Layers[0].Digest, model.Layers[1].Digest) +} + +func TestExtractManifestModelPlatform(t *testing.T) { + layer := syntheticLayer(t, "file.txt", "content") + img, err := mutate.AppendLayers(empty.Image, layer) + require.NoError(t, err) + cfgFile, err := img.ConfigFile() + require.NoError(t, err) + cfgFile = cfgFile.DeepCopy() + cfgFile.OS = "linux" + cfgFile.Architecture = "amd64" + img, err = mutate.ConfigFile(img, cfgFile) + require.NoError(t, err) + + client, layoutTag := writeSyntheticLayout(t, img) + + model, err := client.extractManifestModel(layoutTag) + require.NoError(t, err) + require.Equal(t, "linux/amd64", model.Platform) +} + +func TestManifestModelWriteReadRoundtrip(t *testing.T) { + p := paths.New(t.TempDir()) + digestHex := "ab01ab01ab01ab01ab01ab01ab01ab01ab01ab01ab01ab01ab01ab01ab01ab01" + + model := &imageManifestModel{ + SchemaVersion: manifestModelSchemaVersion, + Digest: "sha256:" + digestHex, + Platform: "linux/amd64", + Config: manifestConfigRef{ + Digest: "sha256:cfg", + DiffIDs: []string{"sha256:diff0", "sha256:diff1"}, + }, + Layers: []layerDescriptor{ + {Digest: "sha256:layer0", Size: 10, DiffID: "sha256:diff0"}, + {Digest: "sha256:layer1", Size: 20, DiffID: "sha256:diff1"}, + }, + } + require.NoError(t, writeManifestModel(p, digestHex, model)) + + read, err := readManifestModel(p, digestHex) + require.NoError(t, err) + require.Equal(t, model, read) + + // No leftover temp files from the atomic write. + entries, err := os.ReadDir(p.ImageContentDir(digestHex)) + require.NoError(t, err) + require.Len(t, entries, 1) + require.Equal(t, "manifest.json", entries[0].Name()) +} + +func TestReadManifestModelMissing(t *testing.T) { + p := paths.New(t.TempDir()) + model, err := readManifestModel(p, "deadbeef") + require.NoError(t, err) + require.Nil(t, model, "missing model must read as nil for pre-model images") +} + +func TestManifestModelBlobReferences(t *testing.T) { + model := &imageManifestModel{ + Config: manifestConfigRef{Digest: "sha256:cfg"}, + Layers: []layerDescriptor{ + {Digest: "sha256:layer0"}, + {Digest: "sha256:layer1"}, + }, + } + require.Equal(t, []string{"sha256:cfg", "sha256:layer0", "sha256:layer1"}, model.blobReferences()) +} + +func TestImportLocalImagePersistsManifestModel(t *testing.T) { + dataDir := t.TempDir() + p := paths.New(dataDir) + mgr, err := NewManager(p, 1, nil) + require.NoError(t, err) + m := mgr.(*manager) + + ctx := context.Background() + const repo = "kernel.local/test/manifest-model" + const tag = "v1" + + testImg := createTestDockerImage(t) + imgDigest, err := testImg.Digest() + require.NoError(t, err) + digestStr := imgDigest.String() + + cacheDir := p.SystemOCICache() + layoutPath, err := layout.Write(cacheDir, empty.Index) + require.NoError(t, err) + require.NoError(t, layoutPath.AppendImage(testImg, layout.WithAnnotations(map[string]string{ + "org.opencontainers.image.ref.name": digestToLayoutTag(digestStr), + }))) + + digestHex := digestToLayoutTag(digestStr) + events := make(chan StatusEvent, 2) + m.subscribeToReady(digestHex, events) + defer m.unsubscribeFromReady(digestHex, events) + + _, err = m.ImportLocalImage(ctx, repo, tag, digestStr) + require.NoError(t, err) + select { + case event := <-events: + require.Equal(t, StatusReady, event.Status) + case <-time.After(30 * time.Second): + t.Fatal("build did not become ready") + } + + model, err := readManifestModel(p, digestHex) + require.NoError(t, err) + require.NotNil(t, model, "ready image must persist a manifest model") + require.Equal(t, "sha256:"+digestHex, model.Digest) + + configFile, err := testImg.ConfigFile() + require.NoError(t, err) + manifest, err := testImg.Manifest() + require.NoError(t, err) + require.Len(t, model.Layers, len(manifest.Layers)) + for i, desc := range model.Layers { + require.Equal(t, manifest.Layers[i].Digest.String(), desc.Digest) + require.Equal(t, configFile.RootFS.DiffIDs[i].String(), desc.DiffID) + } + require.Equal(t, StatusReady, mustReadContentStatus(t, p, digestHex)) +} + +func mustReadContentStatus(t *testing.T, p *paths.Paths, digestHex string) string { + t.Helper() + meta, err := readContentMetadata(p, digestHex) + require.NoError(t, err) + return meta.Status +} diff --git a/lib/images/oci.go b/lib/images/oci.go index 33206d134..7b3c1ee6e 100644 --- a/lib/images/oci.go +++ b/lib/images/oci.go @@ -194,6 +194,7 @@ func (c *ociClient) inspectDigestPlatformAuth(ctx context.Context, imageRef stri // pullResult contains the metadata and digest from pulling an image type pullResult struct { Metadata *containerMetadata + Manifest *imageManifestModel Digest string // sha256:abc123... CacheHit bool LayerCount int @@ -263,15 +264,21 @@ func (c *ociClient) pullAndExportWithPlatformAuth(ctx context.Context, imageRef, var meta *containerMetadata var layerCount int var compressedBytes int64 + var model *imageManifestModel err := result.measure("metadata_extract", func() error { var err error meta, layerCount, compressedBytes, err = c.extractOCIImageDetails(layoutTag) + if err != nil { + return err + } + model, err = c.extractManifestModel(layoutTag) return err }) if err != nil { return result, fmt.Errorf("extract metadata: %w", err) } result.Metadata = meta + result.Manifest = model result.LayerCount = layerCount result.CompressedBytes = compressedBytes @@ -463,6 +470,68 @@ func (c *ociClient) extractOCIImageDetails(layoutTag string) (*containerMetadata return meta, len(manifest.Layers), compressedBytes, nil } +// extractManifestModel reads the persisted-ready content model for one image +// from the shared OCI layout cache: ordered layer descriptors, the config blob +// with its diff ids, and the platform declared by the image config. It reuses +// the blobs already downloaded by the pull instead of fetching anything new. +func (c *ociClient) extractManifestModel(layoutTag string) (*imageManifestModel, error) { + path, err := layout.FromPath(c.cacheDir) + if err != nil { + return nil, fmt.Errorf("open oci layout: %w", err) + } + + img, err := imageByAnnotation(path, layoutTag) + if err != nil { + return nil, fmt.Errorf("find image by tag %s: %w", layoutTag, err) + } + + manifest, err := img.Manifest() + if err != nil { + return nil, fmt.Errorf("get manifest: %w", err) + } + configFile, err := img.ConfigFile() + if err != nil { + return nil, fmt.Errorf("get config file: %w", err) + } + configDigest, err := img.ConfigName() + if err != nil { + return nil, fmt.Errorf("get config digest: %w", err) + } + + model := &imageManifestModel{ + SchemaVersion: manifestModelSchemaVersion, + Digest: digestFromHex(layoutTag), + MediaType: string(manifest.MediaType), + Platform: Platform{ + OS: configFile.OS, + Architecture: configFile.Architecture, + Variant: configFile.Variant, + }.String(), + Config: manifestConfigRef{ + Digest: configDigest.String(), + MediaType: string(manifest.Config.MediaType), + DiffIDs: make([]string, 0, len(configFile.RootFS.DiffIDs)), + }, + Layers: make([]layerDescriptor, 0, len(manifest.Layers)), + } + for _, diffID := range configFile.RootFS.DiffIDs { + model.Config.DiffIDs = append(model.Config.DiffIDs, diffID.String()) + } + for i, layer := range manifest.Layers { + desc := layerDescriptor{ + Digest: layer.Digest.String(), + Size: layer.Size, + MediaType: string(layer.MediaType), + } + if i < len(model.Config.DiffIDs) { + desc.DiffID = model.Config.DiffIDs[i] + } + model.Layers = append(model.Layers, desc) + } + + return model, nil +} + // unpackLayers unpacks all OCI layers to a target directory using umoci // Uses go-containerregistry to get the manifest (handles both Docker v2 and OCI v1) // then converts it to OCI v1 format for umoci's layer unpacker. diff --git a/lib/images/storage.go b/lib/images/storage.go index de65f954f..5d4da61f1 100644 --- a/lib/images/storage.go +++ b/lib/images/storage.go @@ -186,24 +186,11 @@ func writeMetadata(p *paths.Paths, repository, digestHex string, meta *imageMeta } func writeMetadataFile(path string, meta *imageMetadata) error { - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - return fmt.Errorf("create metadata directory: %w", err) - } - data, err := json.MarshalIndent(meta, "", " ") if err != nil { return fmt.Errorf("marshal metadata: %w", err) } - - tempPath := path + ".tmp" - if err := os.WriteFile(tempPath, data, 0644); err != nil { - return fmt.Errorf("write temp metadata: %w", err) - } - if err := os.Rename(tempPath, path); err != nil { - _ = os.Remove(tempPath) - return fmt.Errorf("rename metadata: %w", err) - } - return nil + return writeJSONAtomic(path, data) } func readMetadata(p *paths.Paths, repository, digestHex string) (*imageMetadata, error) { diff --git a/lib/paths/paths.go b/lib/paths/paths.go index 814dc1432..bc9403cd6 100644 --- a/lib/paths/paths.go +++ b/lib/paths/paths.go @@ -161,6 +161,12 @@ func (p *Paths) ImageContentMetadata(digestHex string) string { return filepath.Join(p.ImageContentDir(digestHex), "metadata.json") } +// ImageContentManifestModel returns the path to the persisted OCI manifest +// model (layer descriptors, config, platform) for content-addressed image data. +func (p *Paths) ImageContentManifestModel(digestHex string) string { + return filepath.Join(p.ImageContentDir(digestHex), "manifest.json") +} + // ImageRepositoriesDir returns the root directory for repository tag references. func (p *Paths) ImageRepositoriesDir() string { return filepath.Join(p.dataDir, "images", "repositories") From e2a074409b8d3c41df01383c5da3f3d70fd7700d Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:43:19 +0000 Subject: [PATCH 17/20] Validate persisted image manifests --- lib/images/manifest_model.go | 48 +++++++++++++++ lib/images/manifest_model_test.go | 18 ++++-- lib/images/oci.go | 98 ++++++++++--------------------- 3 files changed, 93 insertions(+), 71 deletions(-) diff --git a/lib/images/manifest_model.go b/lib/images/manifest_model.go index f7ebc5675..2980547f5 100644 --- a/lib/images/manifest_model.go +++ b/lib/images/manifest_model.go @@ -8,6 +8,8 @@ import ( "strings" "github.com/kernel/hypeman/lib/paths" + digest "github.com/opencontainers/go-digest" + v1 "github.com/opencontainers/image-spec/specs-go/v1" ) // imageManifestModel is the persisted content model for one OCI manifest. It @@ -21,6 +23,7 @@ type imageManifestModel struct { MediaType string `json:"media_type,omitempty"` Platform string `json:"platform"` // os/arch[/variant] Config manifestConfigRef `json:"config"` + RootFSType string `json:"rootfs_type,omitempty"` Layers []layerDescriptor `json:"layers"` // manifest order, base layer first } @@ -64,6 +67,48 @@ func (m *imageManifestModel) blobReferences() []string { } // writeManifestModel persists the manifest model for a digest atomically. +func validateManifestModel(digestHex string, model *imageManifestModel) error { + if model == nil { + return fmt.Errorf("manifest model is nil") + } + if model.SchemaVersion != manifestModelSchemaVersion { + return fmt.Errorf("unsupported manifest model schema version: %d", model.SchemaVersion) + } + if model.Digest != digestFromHex(digestHex) { + return fmt.Errorf("manifest model digest %q does not match %q", model.Digest, digestFromHex(digestHex)) + } + if model.RootFSType != "" && model.RootFSType != "layers" { + return fmt.Errorf("unsupported manifest rootfs type: %q", model.RootFSType) + } + if model.Config.Digest == "" { + return fmt.Errorf("manifest model config digest is empty") + } + configDigest, err := digest.Parse(model.Config.Digest) + if err != nil || configDigest.Algorithm() != digest.SHA256 { + return fmt.Errorf("invalid manifest model config digest: %q", model.Config.Digest) + } + if model.Config.MediaType != "" && convertToOCIMediaType(model.Config.MediaType) != v1.MediaTypeImageConfig { + return fmt.Errorf("invalid manifest model config media type: %q", model.Config.MediaType) + } + if len(model.Config.DiffIDs) != len(model.Layers) { + return fmt.Errorf("manifest model has %d diff ids for %d layers", len(model.Config.DiffIDs), len(model.Layers)) + } + for i, layer := range model.Layers { + layerDigest, err := digest.Parse(layer.Digest) + if err != nil || layerDigest.Algorithm() != digest.SHA256 { + return fmt.Errorf("invalid manifest model layer %d digest: %q", i, layer.Digest) + } + diffID, err := digest.Parse(model.Config.DiffIDs[i]) + if err != nil || diffID.Algorithm() != digest.SHA256 || layer.DiffID != diffID.String() { + return fmt.Errorf("invalid manifest model layer %d diff id", i) + } + if layer.Size < 0 { + return fmt.Errorf("invalid manifest model layer %d size: %d", i, layer.Size) + } + } + return nil +} + func writeManifestModel(p *paths.Paths, digestHex string, model *imageManifestModel) error { data, err := json.MarshalIndent(model, "", " ") if err != nil { @@ -87,6 +132,9 @@ func readManifestModel(p *paths.Paths, digestHex string) (*imageManifestModel, e if err := json.Unmarshal(data, &model); err != nil { return nil, fmt.Errorf("unmarshal manifest model: %w", err) } + if err := validateManifestModel(digestHex, &model); err != nil { + return nil, err + } return &model, nil } diff --git a/lib/images/manifest_model_test.go b/lib/images/manifest_model_test.go index 91222c453..8e9e63777 100644 --- a/lib/images/manifest_model_test.go +++ b/lib/images/manifest_model_test.go @@ -7,6 +7,7 @@ import ( "context" "io" "os" + "strings" "testing" "time" @@ -129,12 +130,12 @@ func TestManifestModelWriteReadRoundtrip(t *testing.T) { Digest: "sha256:" + digestHex, Platform: "linux/amd64", Config: manifestConfigRef{ - Digest: "sha256:cfg", - DiffIDs: []string{"sha256:diff0", "sha256:diff1"}, + Digest: "sha256:" + strings.Repeat("c", 64), + DiffIDs: []string{"sha256:" + strings.Repeat("d", 64), "sha256:" + strings.Repeat("e", 64)}, }, Layers: []layerDescriptor{ - {Digest: "sha256:layer0", Size: 10, DiffID: "sha256:diff0"}, - {Digest: "sha256:layer1", Size: 20, DiffID: "sha256:diff1"}, + {Digest: "sha256:" + strings.Repeat("f", 64), Size: 10, DiffID: "sha256:" + strings.Repeat("d", 64)}, + {Digest: "sha256:" + strings.Repeat("0", 64), Size: 20, DiffID: "sha256:" + strings.Repeat("e", 64)}, }, } require.NoError(t, writeManifestModel(p, digestHex, model)) @@ -150,6 +151,15 @@ func TestManifestModelWriteReadRoundtrip(t *testing.T) { require.Equal(t, "manifest.json", entries[0].Name()) } +func TestReadManifestModelRejectsInvalidSchema(t *testing.T) { + p := paths.New(t.TempDir()) + digestHex := strings.Repeat("a", 64) + require.NoError(t, os.MkdirAll(p.ImageContentDir(digestHex), 0755)) + require.NoError(t, os.WriteFile(p.ImageContentManifestModel(digestHex), []byte(`{"schema_version":1,"digest":"sha256:`+digestHex+`"}`), 0644)) + _, err := readManifestModel(p, digestHex) + require.ErrorContains(t, err, "config digest is empty") +} + func TestReadManifestModelMissing(t *testing.T) { p := paths.New(t.TempDir()) model, err := readManifestModel(p, "deadbeef") diff --git a/lib/images/oci.go b/lib/images/oci.go index 7b3c1ee6e..1412ad06a 100644 --- a/lib/images/oci.go +++ b/lib/images/oci.go @@ -410,36 +410,37 @@ func (c *ociClient) extractOCIMetadata(layoutTag string) (*containerMetadata, er } func (c *ociClient) extractOCIImageDetails(layoutTag string) (*containerMetadata, int, int64, error) { - // Open OCI layout using go-containerregistry (handles Docker v2 and OCI v1) + meta, _, layerCount, compressedBytes, err := c.extractOCIImageBundle(layoutTag) + return meta, layerCount, compressedBytes, err +} + +func (c *ociClient) extractManifestModel(layoutTag string) (*imageManifestModel, error) { + _, model, _, _, err := c.extractOCIImageBundle(layoutTag) + return model, err +} + +func (c *ociClient) extractOCIImageBundle(layoutTag string) (*containerMetadata, *imageManifestModel, int, int64, error) { path, err := layout.FromPath(c.cacheDir) if err != nil { - return nil, 0, 0, fmt.Errorf("open oci layout: %w", err) + return nil, nil, 0, 0, fmt.Errorf("open oci layout: %w", err) } - - // Get the image by annotation tag from the layout img, err := imageByAnnotation(path, layoutTag) if err != nil { - return nil, 0, 0, fmt.Errorf("find image by tag %s: %w", layoutTag, err) + return nil, nil, 0, 0, fmt.Errorf("find image by tag %s: %w", layoutTag, err) } - - // Get config file (go-containerregistry handles manifest format automatically) configFile, err := img.ConfigFile() if err != nil { - return nil, 0, 0, fmt.Errorf("get config file: %w", err) + return nil, nil, 0, 0, fmt.Errorf("get config file: %w", err) } - manifest, err := img.Manifest() if err != nil { - return nil, 0, 0, fmt.Errorf("get manifest: %w", err) + return nil, nil, 0, 0, fmt.Errorf("get manifest: %w", err) } - var compressedBytes int64 - for _, layer := range manifest.Layers { - compressedBytes += layer.Size + configDigest, err := img.ConfigName() + if err != nil { + return nil, nil, 0, 0, fmt.Errorf("get config digest: %w", err) } - // Extract metadata from config. OS/Architecture/Variant come straight from - // the pulled image config, so they reflect the manifest actually fetched - // rather than what the caller requested. meta := &containerMetadata{ OS: configFile.OS, Architecture: configFile.Architecture, @@ -450,54 +451,15 @@ func (c *ociClient) extractOCIImageDetails(layoutTag string) (*containerMetadata Labels: make(map[string]string), WorkingDir: configFile.Config.WorkingDir, } - - // Parse environment variables for _, env := range configFile.Config.Env { - for i := 0; i < len(env); i++ { - if env[i] == '=' { - key := env[:i] - val := env[i+1:] - meta.Env[key] = val - break - } + if key, value, ok := strings.Cut(env, "="); ok { + meta.Env[key] = value } } - for key, value := range configFile.Config.Labels { meta.Labels[key] = value } - return meta, len(manifest.Layers), compressedBytes, nil -} - -// extractManifestModel reads the persisted-ready content model for one image -// from the shared OCI layout cache: ordered layer descriptors, the config blob -// with its diff ids, and the platform declared by the image config. It reuses -// the blobs already downloaded by the pull instead of fetching anything new. -func (c *ociClient) extractManifestModel(layoutTag string) (*imageManifestModel, error) { - path, err := layout.FromPath(c.cacheDir) - if err != nil { - return nil, fmt.Errorf("open oci layout: %w", err) - } - - img, err := imageByAnnotation(path, layoutTag) - if err != nil { - return nil, fmt.Errorf("find image by tag %s: %w", layoutTag, err) - } - - manifest, err := img.Manifest() - if err != nil { - return nil, fmt.Errorf("get manifest: %w", err) - } - configFile, err := img.ConfigFile() - if err != nil { - return nil, fmt.Errorf("get config file: %w", err) - } - configDigest, err := img.ConfigName() - if err != nil { - return nil, fmt.Errorf("get config digest: %w", err) - } - model := &imageManifestModel{ SchemaVersion: manifestModelSchemaVersion, Digest: digestFromHex(layoutTag), @@ -512,24 +474,26 @@ func (c *ociClient) extractManifestModel(layoutTag string) (*imageManifestModel, MediaType: string(manifest.Config.MediaType), DiffIDs: make([]string, 0, len(configFile.RootFS.DiffIDs)), }, - Layers: make([]layerDescriptor, 0, len(manifest.Layers)), + RootFSType: configFile.RootFS.Type, + Layers: make([]layerDescriptor, 0, len(manifest.Layers)), } for _, diffID := range configFile.RootFS.DiffIDs { model.Config.DiffIDs = append(model.Config.DiffIDs, diffID.String()) } - for i, layer := range manifest.Layers { - desc := layerDescriptor{ - Digest: layer.Digest.String(), - Size: layer.Size, - MediaType: string(layer.MediaType), + var compressedBytes int64 + for i, descriptor := range manifest.Layers { + compressedBytes += descriptor.Size + layer := layerDescriptor{ + Digest: descriptor.Digest.String(), + Size: descriptor.Size, + MediaType: string(descriptor.MediaType), } if i < len(model.Config.DiffIDs) { - desc.DiffID = model.Config.DiffIDs[i] + layer.DiffID = model.Config.DiffIDs[i] } - model.Layers = append(model.Layers, desc) + model.Layers = append(model.Layers, layer) } - - return model, nil + return meta, model, len(manifest.Layers), compressedBytes, nil } // unpackLayers unpacks all OCI layers to a target directory using umoci From ac5b6e1e62fff0749203702b3bbebf5561f6a9d9 Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:54:00 +0000 Subject: [PATCH 18/20] Simplify manifest model validation --- lib/images/manifest_model.go | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/lib/images/manifest_model.go b/lib/images/manifest_model.go index 2980547f5..6348319aa 100644 --- a/lib/images/manifest_model.go +++ b/lib/images/manifest_model.go @@ -80,11 +80,17 @@ func validateManifestModel(digestHex string, model *imageManifestModel) error { if model.RootFSType != "" && model.RootFSType != "layers" { return fmt.Errorf("unsupported manifest rootfs type: %q", model.RootFSType) } + if err := validateManifestConfig(model); err != nil { + return err + } + return validateManifestLayers(model) +} + +func validateManifestConfig(model *imageManifestModel) error { if model.Config.Digest == "" { return fmt.Errorf("manifest model config digest is empty") } - configDigest, err := digest.Parse(model.Config.Digest) - if err != nil || configDigest.Algorithm() != digest.SHA256 { + if _, err := parseSHA256Digest(model.Config.Digest); err != nil { return fmt.Errorf("invalid manifest model config digest: %q", model.Config.Digest) } if model.Config.MediaType != "" && convertToOCIMediaType(model.Config.MediaType) != v1.MediaTypeImageConfig { @@ -93,13 +99,16 @@ func validateManifestModel(digestHex string, model *imageManifestModel) error { if len(model.Config.DiffIDs) != len(model.Layers) { return fmt.Errorf("manifest model has %d diff ids for %d layers", len(model.Config.DiffIDs), len(model.Layers)) } + return nil +} + +func validateManifestLayers(model *imageManifestModel) error { for i, layer := range model.Layers { - layerDigest, err := digest.Parse(layer.Digest) - if err != nil || layerDigest.Algorithm() != digest.SHA256 { + if _, err := parseSHA256Digest(layer.Digest); err != nil { return fmt.Errorf("invalid manifest model layer %d digest: %q", i, layer.Digest) } - diffID, err := digest.Parse(model.Config.DiffIDs[i]) - if err != nil || diffID.Algorithm() != digest.SHA256 || layer.DiffID != diffID.String() { + diffID, err := parseSHA256Digest(model.Config.DiffIDs[i]) + if err != nil || layer.DiffID != diffID.String() { return fmt.Errorf("invalid manifest model layer %d diff id", i) } if layer.Size < 0 { @@ -109,6 +118,14 @@ func validateManifestModel(digestHex string, model *imageManifestModel) error { return nil } +func parseSHA256Digest(value string) (digest.Digest, error) { + parsed, err := digest.Parse(value) + if err != nil || parsed.Algorithm() != digest.SHA256 { + return "", fmt.Errorf("not a sha256 digest") + } + return parsed, nil +} + func writeManifestModel(p *paths.Paths, digestHex string, model *imageManifestModel) error { data, err := json.MarshalIndent(model, "", " ") if err != nil { From 62de9d922919805001affd79b3b6c8f619f5ec0f Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:31:11 +0000 Subject: [PATCH 19/20] Extract OCI metadata once --- lib/images/oci.go | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/lib/images/oci.go b/lib/images/oci.go index 1412ad06a..407d77604 100644 --- a/lib/images/oci.go +++ b/lib/images/oci.go @@ -267,11 +267,7 @@ func (c *ociClient) pullAndExportWithPlatformAuth(ctx context.Context, imageRef, var model *imageManifestModel err := result.measure("metadata_extract", func() error { var err error - meta, layerCount, compressedBytes, err = c.extractOCIImageDetails(layoutTag) - if err != nil { - return err - } - model, err = c.extractManifestModel(layoutTag) + meta, model, layerCount, compressedBytes, err = c.extractOCIImageBundle(layoutTag) return err }) if err != nil { From cc7944c5fdd4ac3393d1926c19735038ceda3800 Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:47:21 +0000 Subject: [PATCH 20/20] Use unique manifest temp files --- lib/images/manifest_model.go | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/lib/images/manifest_model.go b/lib/images/manifest_model.go index 6348319aa..0a8331fa9 100644 --- a/lib/images/manifest_model.go +++ b/lib/images/manifest_model.go @@ -161,12 +161,24 @@ func writeJSONAtomic(path string, data []byte) error { if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { return fmt.Errorf("create directory: %w", err) } - tempPath := path + ".tmp" - if err := os.WriteFile(tempPath, data, 0644); err != nil { + tempFile, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".tmp-*") + if err != nil { + return fmt.Errorf("create temp file: %w", err) + } + tempPath := tempFile.Name() + defer os.Remove(tempPath) + if err := tempFile.Chmod(0644); err != nil { + _ = tempFile.Close() + return fmt.Errorf("chmod temp file: %w", err) + } + if _, err := tempFile.Write(data); err != nil { + _ = tempFile.Close() return fmt.Errorf("write temp file: %w", err) } + if err := tempFile.Close(); err != nil { + return fmt.Errorf("close temp file: %w", err) + } if err := os.Rename(tempPath, path); err != nil { - _ = os.Remove(tempPath) return fmt.Errorf("rename into place: %w", err) } return nil