Skip to content

Move image references to shared content storage - #449

Open
chruffins wants to merge 13 commits into
mainfrom
hypeship/content-addressed-image-storage
Open

Move image references to shared content storage#449
chruffins wants to merge 13 commits into
mainfrom
hypeship/content-addressed-image-storage

Conversation

@chruffins

@chruffins chruffins commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

summary

Move image storage from repository-local digest directories to digest-keyed shared content, while keeping existing image operations and legacy data readable during migration.

  • store each converted rootfs once under images/content/<digest>
  • store repository tag references under images/repositories/<repository>/<tag>
  • migrate ready legacy images at manager startup using hardlinks
  • retain reference-safe cleanup, atomic disk installation, and deduplicated disk accounting
  • reject unsupported Firecracker versions instead of silently falling back to the default

This change does not deduplicate or compose OCI layers.

pre-existing flows

The existing image APIs continue to support the current pull/import, lookup, wait, list, and delete flows:

  • CreateImage and ImportLocalImage still reuse an existing digest, return in-progress status, enforce credential matching, and apply Docker-style last-pull-wins tag behavior.
  • GetImage, listing, and disk-path resolution can read both the legacy repository-local layout and the new shared-content layout.
  • DeleteImage removes a tag without deleting content still referenced by another tag or an active pull. Explicit digest deletion still removes the digest content.
  • Interrupted-build recovery continues to requeue pending, pulling, and converting images; duplicate recovery attempts for the same digest are collapsed.

new flows

  • A new pull writes metadata and the converted rootfs directly to the shared content directory keyed by digest.
  • A tagged pull creates a repository reference symlink. Pending tags are created early when no previous tag exists, and finalization only updates a tag if the pull is still the latest operation for that tag.
  • On startup, ready legacy images are promoted into shared content: the rootfs is hardlinked, tag references are moved, and the old digest tree is removed. Non-ready images remain in the legacy layout for recovery.
  • Shared content is removed only after all repository references and in-flight work are gone.

data model and storage changes

The on-disk layout changes from repository-local image data to shared content with separate repository tag references:

images/
├── docker.io/library/alpine/       # existing legacy layout, untouched
│   ├── latest -> <digest>
│   └── <digest>/
│       ├── metadata.json
│       └── rootfs.erofs
│
├── content/                        # new layout only
│   └── <digest>/
│       ├── metadata.json
│       └── rootfs.erofs
│
└── repositories/                   # new tags only
    └── example.com/app/
        └── v1 -> ../../../content/<digest>

Legacy paths remain readable during migration. Image metadata gains fields for tag ownership and stale-build protection:

  • requested_tag
  • previous_tag_digest
  • tag_generation

These fields prevent an older asynchronous pull from overwriting a tag after a newer pull or delete. Disk accounting identifies hard-linked rootfs files so aliases count once.

UX and behavior changes

  • Existing API callers keep using repository/tag and repository/digest references; the storage layout is internal.
  • WaitForReady can observe a tagged pull before its final symlink is installed, reducing races in the registry conversion flow.
  • Tag updates are deterministic: the latest pull wins, including pulls for non-host platforms.
  • Deleting one tag no longer removes shared image data needed by another repository or in-flight pull.
  • Missing or corrupt content is reported from the canonical layout instead of silently mixing it with a legacy disk.
  • Unsupported Firecracker versions now return an explicit error.

validation

  • GOCACHE=/tmp/hypeman-go-cache go test -race -run 'Test(DeleteImagePreservesCrossRepositoryContent|DeleteTagRemovesBothLayoutReferences|LegacyImageIsNotShadowedByContentMetadata|ListAllMetadataDeduplicatesDualLayouts|FailedLegacyImageUsesReadyContent|ReadyContentDoesNotFallBackToLegacyDisk|WriteMetadataUsesContentWhenLegacyDirectoryIsEmpty|ContentLayoutResolvesDiskByDigest|ListAllMetadataContentLayout|TotalReadyImageBytes|ImageMetadata|TagFollowsLastPull)' ./lib/images ./lib/paths -count=1
  • GOCACHE=/tmp/hypeman-go-cache go test -run '^$' ./lib/images ./lib/paths
  • full image integration tests were not run because this environment lacks mkfs.erofs.

Comment thread lib/images/manager.go Outdated
Comment thread lib/images/storage.go
@chruffins

Copy link
Copy Markdown
Contributor Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 6b7d984. Configure here.

Comment thread lib/images/manager.go
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)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tag generations lost on restart

High Severity

Last-pull-wins uses the in-memory tagGenerations map, but RecoverInterruptedBuilds never restores it. After restart that map is empty, so a recovered pull's TagGeneration cannot match and finalizeImage skips the tag update. A later pull of the same tag also restarts the counter at 1 and can collide with persisted generations, letting a stale build steal the tag.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 6b7d984. Configure here.

Comment thread lib/images/manager.go
}
if err != nil {
return nil, true, fmt.Errorf("create image tag: %w", err)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ready tag claim skips generation bump

High Severity

reuseExistingImage immediately repoints a tag when the digest is already ready, but it does not call nextTagGeneration. An in-flight last-pull-wins pull of a different digest still holds the current generation, so when it finishes finalizeImage treats the ready claim as the previous digest and moves the tag back. The same gap exists in ImportLocalImage.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 6b7d984. Configure here.

@chruffins
chruffins requested a review from sjmiller609 August 26, 2026 20:47
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.
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.
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.
@chruffins
chruffins force-pushed the hypeship/content-addressed-image-storage branch from 7aac5c0 to e3cab9b Compare August 26, 2026 22:22
@chruffins
chruffins marked this pull request as ready for review August 27, 2026 14:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant