Skip to content

Compose VM rootfs from shared layer blobs - #457

Draft
chruffins wants to merge 8 commits into
hypeship/layer-artifactsfrom
hypeship/rootfs-composition
Draft

Compose VM rootfs from shared layer blobs#457
chruffins wants to merge 8 commits into
hypeship/layer-artifactsfrom
hypeship/rootfs-composition

Conversation

@chruffins

@chruffins chruffins commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

summary

Stacked stage of the image-storage project: the production composition path.

  • composeRootfs builds one merged rootfs per image by applying the manifest's ordered layer blobs from the shared OCI cache in tar order. Whiteout and opaque-directory markers are interpreted as each layer is applied; entries replace conflicting types safely; hardlink targets resolve against the accumulated tree. The merged tree is exported to the same single read-only disk the guest mounts as its lower filesystem.
  • validateManifestModel enforces one config diff id per manifest layer and requires rootfs.type == "layers" (same guard the previous unpack path enforced), so a corrupt manifest fails the build with a clean error instead of indexing past the pairing (preserves the captured-fixture regression behavior). Composition is the only unpack path: the umoci unpackLayers path and its helpers are removed.
  • Layers are applied with umoci's layer.UnpackLayer using the DirRootfs on-disk format, so a layer can replace a directory with a file (and vice versa) and non-empty directories are cleared before writing.
  • Unlike the previous unpack path, the composed rootfs preserves each layer's tar ownership when running as root (no container-root → current-user uid/gid mapping), matching the layer-artifact flow.
  • The staged tree is installed by removePath(dest) + rename into the export directory. This replaces any stale tree from a previous partial build, but is not atomic: a failure between the remove and the rename leaves dest absent, and a crash can strand a .compose-* staging dir (same class as the .unpack-* leftovers the layer-artifact path already tolerates). Current callers always pass fresh temp dirs and never read dest concurrently.
  • Each layer's blob digest and diff id are re-verified against the manifest model on every compose, including cache hits. The layer_unpack build-phase metric label is retained for dashboard continuity, though the measured work is now composition.

why this path

The guest init mounts exactly one read-only lower (/dev/vda) and one writable upper (/dev/vdb), and QEMU microvm virtio-mmio slots bound how many devices can be attached. A one-disk-per-layer layout therefore cannot be supported uniformly across cloud-hypervisor, Firecracker, QEMU, and vz, so it was rejected rather than attached blindly. Sharing happens at the content-addressed blob and layer-artifact level; every hypervisor keeps its existing disk contract (vda read-only rootfs, vdb writable overlay), and guest init and hypervisor config are unchanged.

validation

  • Synthetic two-layer fixture with whiteout, opaque mask, file/directory replacement, and mode checks composes to the expected tree; composed output exports to erofs and passes fsck.erofs --extract.
  • Captured corrupt-manifest fixture still fails cleanly with the original integrity error.
  • go test ./lib/images ./lib/paths ./lib/builds ./lib/scopes ./cmd/api/api: green except Docker Hub pull tests (anonymous rate limit in this environment) and VM lifecycle tests, which require network-bridge privileges (verified failing identically on unmodified main).

Note

Medium Risk
Switches the primary rootfs build path for normal images and changes tar extraction semantics shared with layer unpacking; guest disk contract is unchanged and umoci fallback limits blast radius.

Overview
Image export now merges manifest-ordered layer blobs from the shared OCI cache into one staging rootfs (composeRootfs) instead of using the old umoci unpackLayers path, which this change deletes. Whiteout and opaque-directory markers are applied as each layer is streamed, so the composed tree is meant for a single read-only guest disk without relying on overlayfs to interpret .wh.* files.

validateModelPairing rejects configs where rootfs.diff_ids length mismatches manifest layer count (same integrity message as the umoci path). If no model or empty layers, umoci unpackLayers remains the fallback.

extractTarEntry now uses clearExisting (including RemoveAll for directories) so later layer entries can replace a path with a different type—e.g. directory → file—fixing composition cases like replacedir in the new tests.

Tests cover whiteout/opaque/replacement ordering, missing blobs, and erofs export + fsck.erofs --extract on the composed tree.

Reviewed by Cursor Bugbot for commit 2b465b6. Configure here.

@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 2b465b6. Configure here.

Comment thread lib/images/compose.go Outdated
continue
}
clearExisting(filepath.Join(dest, dir, hidden))
continue

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Whiteouts escape compose root

High Severity

Opaque and whiteout handling joins header.Name onto dest with filepath.Join and never runs safeJoin, unlike regular entries. A layer whose whiteout path contains .. can make clearDirContents / clearExisting delete files outside the compose tree during image build, including host paths under the build parent.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2b465b6. Configure here.

Comment thread lib/images/compose.go Outdated
continue
}
clearExisting(filepath.Join(dest, dir, hidden))
continue

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Empty whiteout deletes parent

Medium Severity

A whiteout whose name is exactly .wh. yields an empty hidden target. After skipping only . and .., clearExisting runs on the parent directory and RemoveAlls it, so a single marker can wipe the containing directory instead of hiding one entry.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2b465b6. Configure here.

@chruffins
chruffins force-pushed the hypeship/rootfs-composition branch from 2b465b6 to fb40cc1 Compare August 26, 2026 18:44
@chruffins
chruffins force-pushed the hypeship/rootfs-composition branch 2 times, most recently from 5675147 to 6bde47c Compare August 26, 2026 18:50
@chruffins
chruffins force-pushed the hypeship/rootfs-composition branch from 6bde47c to abdceaf Compare August 26, 2026 18:52
@chruffins
chruffins force-pushed the hypeship/rootfs-composition branch from abdceaf to 0d9f270 Compare August 26, 2026 18:53
@chruffins
chruffins force-pushed the hypeship/rootfs-composition branch from 0d9f270 to de83eaa Compare August 26, 2026 18:54
@chruffins
chruffins force-pushed the hypeship/rootfs-composition branch 2 times, most recently from 2093a74 to 92dd458 Compare August 26, 2026 18:56
@chruffins
chruffins force-pushed the hypeship/rootfs-composition branch from 92dd458 to 5ec53e4 Compare August 26, 2026 18:58
@chruffins
chruffins force-pushed the hypeship/rootfs-composition branch from 5ec53e4 to e290067 Compare August 26, 2026 19:26
@chruffins
chruffins force-pushed the hypeship/rootfs-composition branch from e290067 to c69e90b Compare August 26, 2026 19:30
@chruffins
chruffins force-pushed the hypeship/rootfs-composition branch 3 times, most recently from d4801d7 to f24fed3 Compare August 26, 2026 19:41
@chruffins
chruffins force-pushed the hypeship/rootfs-composition branch 2 times, most recently from cb42b2d to 3a95ff6 Compare August 26, 2026 19:50
@chruffins
chruffins force-pushed the hypeship/rootfs-composition branch from 3a95ff6 to 3f9ef7f Compare August 26, 2026 22:22
@chruffins
chruffins force-pushed the hypeship/rootfs-composition branch from c323a22 to fb28360 Compare September 1, 2026 16:02
@chruffins
chruffins force-pushed the hypeship/rootfs-composition branch from fb28360 to e48db8d Compare September 1, 2026 16:37
@chruffins
chruffins force-pushed the hypeship/rootfs-composition branch from e48db8d to 0d729b3 Compare September 1, 2026 20:41
@chruffins
chruffins force-pushed the hypeship/rootfs-composition branch from 0d729b3 to dac88a2 Compare September 1, 2026 21:07
@chruffins
chruffins force-pushed the hypeship/rootfs-composition branch 10 times, most recently from 41aad4a to f9d76cf Compare September 3, 2026 15:05
composition is the only unpack path now: move composeOnDiskFormat into
production, point unpackCachedLayer at the cache blob directory directly,
and delete the unused umoci unpackLayers path and its helpers.

Also: drop the unused composeRootfs wrapper, restore the 0755 export
directory mode, reuse removePath for staging and destination cleanup,
migrate the unpackLayers tests to the compose path, fix the diff id
mismatch test to exercise the diff id check, share the tar layer
builders, and require a layered rootfs in manifest model validation.
Name the compose entry point composeRootfs like the other ctx-taking
methods, derive the cache blob directory in one place, and describe the
replace-on-compose semantics in the doc comment. Tighten the BuildKit
cache test assertion to the actual rejection, drop the rootfs_type
omitempty now that validation requires it, and fix the compose rootfs
error wrap.
Test the config mediatype rejection for BuildKit cache images
separately from the rootfs type rejection, cover composition into a
pre-populated export directory and the restored 0755 mode, assert the
rootfs type guard directly, drop the dead diff_id omitempty tag, and
deduplicate the layer digest in the apply-layer error wrap.
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