Skip to content

Move image references to shared content storage - #449

Merged
chruffins merged 15 commits into
mainfrom
hypeship/content-addressed-image-storage
Aug 27, 2026
Merged

Move image references to shared content storage#449
chruffins merged 15 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 Outdated
Comment thread lib/images/manager.go Outdated
@chruffins
chruffins requested a review from sjmiller609 August 26, 2026 20:47
@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
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 27eb82b to 1302e8d Compare August 27, 2026 15:43

@sjmiller609 sjmiller609 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Blocker

  • lib/images/storage.go:312-315 — startup promotion deletes legacy rootfs paths which breaks existing snapshots. Try this to reproduce

Nits

  • lib/images/manager.go:288-294,617-630 — when a second tag joins an in-progress shared-digest build, its pending claim is not recorded. If that tag already references an older digest, it remains there after completion because the shared metadata tracks only the first request’s tag. Track pending claims per (repository, tag) and test two existing tags joining one in-flight digest.

  • lib/images/storage_refs.go:219-227 — repository references share content-level metadata, including API resource tags. Two repositories requesting the same digest with different resource tags therefore expose the first pull’s tags. Persist reference-level fields separately, or merge them per repository reference, with cross-repository list/filter coverage.

validation notes

  • steady-state single and 25-way snapshot fanout showed no clear storage-related performance regression, looks good 👍
  • this deduplicates the final whole-image EROFS by manifest digest; it does not create per-layer EROFS blobs or make conversion incremental. We were discussing a possibility to do that here on slack, quoted below for OSS visibility:

the way hypeman stores images is not efficient for host storage. Because each downloaded image version is a full copy of the rootfs, then similar images duplicate their base layers on the exported hypeman erofs file. This matters in use cases where we want to host a lot of different but similar images at the same time (eg quickbox).

Potential solution: instead of exporting each image as one flattened EROFS file, export its OCI layers as content-addressed EROFS blobs. Store shared layers once across image versions, then compose them into the read-only root filesystem Hypeman mounts for each VM.

but I think addressing that or not is optional in this PR, as that seems potentially much more complicated so could be worth doing separately, at your discretion.

@chruffins

Copy link
Copy Markdown
Contributor Author

there's a PR further up the stack that will de-duplicate layers (and will need a decent amount of iteration b4 it's ready), but this will enable hypeman tag which is the next PR in the stack!

@chruffins
chruffins requested a review from sjmiller609 August 27, 2026 17:50
@chruffins
chruffins merged commit cec7225 into main Aug 27, 2026
10 of 11 checks passed
@chruffins
chruffins deleted the hypeship/content-addressed-image-storage branch August 27, 2026 19:46
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.

2 participants