feat(plugin-oci): oci_image and oci_layer — container images without docker - #378
feat(plugin-oci): oci_image and oci_layer — container images without docker#378raphaelvigee wants to merge 2 commits into
Conversation
Two new drivers that build a container image from target outputs: no Dockerfile, no BuildKit, no daemon, no execution of any kind. `oci_layer` tars a set of target outputs into one image layer; `oci_image` stacks layers on an optional base and writes the same OCI layout `docker_build` already emits, so `oci_push`, `oci_load` and `bases` consume it unchanged. The reason to prefer it is not that it avoids a daemon. It is that this is the only image rule whose cache key can cover what the build reads. `docker_build` says so itself: the buildx version is unhashed, `FROM` is resolved by BuildKit from the network, `RUN` fetches whatever it fetches, and secret values cannot be hashed. Here there is no host binary, no subprocess, no env var and no run-time network — the inputs are the declared deps and the attributes. The docs scope that claim rather than overreach: a base is only as hermetic as the `oci_pull` behind it. The other consequence is cross-arch. A layer is a file tree, so nothing executes for the target architecture: an arm64 mac emits linux/amd64 and linux/arm64 in one run, no QEMU, same digests as CI. Three cache-key hazards the design review turned up, all fixed here rather than found later: - `hashin` folds a *sorted, unlabeled multiset* of dep hashouts, so `layers = [":a", ":b"]` and the swap reach it identically — as do `base = ":a", layers = [":b"]` and its mirror, and the per-platform map with its values swapped. The def hash carries the ordered, normalized addresses. (`docker_build`'s single `dockerfile = ":t"` can safely omit its address: a one-occupant role changes hashout whenever the target changes. An ordered list has no such property.) - `inputs_result_meta` folds every output group a dep has regardless of the `|group` selector on the ref, so `[":bin|release"]` and `[":bin|debug"]` were one key. The normalized address includes it. - File modes cannot be preserved. heph records one permission bit (walk/cached_walker.rs), the pack step normalizes to 0755/0644, and the sandbox mode then depends on the umask, on whether another target marked the dep read-only, and on a 1 MiB threshold choosing FUSE over unpack. Modes come from the exec bit alone, or from an explicit hashed `mode`; setuid/setgid/sticky are rejected loudly. Also: base *config* inheritance is specified and tested per field (a dropped `PATH` is the easiest way to ship an image that starts and then cannot find its entrypoint); layers are uncompressed, keeping the digest out of the hands of whichever deflate backend cargo resolves; blobs are written to a temp name and renamed, because a blob's filename asserts a digest nothing re-verifies; and layer blobs travel as file paths, never `Vec<u8>`, so an image is not held in memory once per concurrent target. Silent failures made loud, each with a test: an empty layer (naming what the srcs did produce and which strip values would work), two entries mapping to one path, a `.wh.` whiteout name, an absolute symlink, a base with no matching platform, `platforms` unset. Symlinks are preserved rather than followed — symlink-ness is covered by the dep's hash, and following one would silently duplicate bytes or embed an undeclared sandbox file. The e2e suite is deliberately not gated on docker: "works without docker" is the feature, and a test that needed a daemon would be testing something else. It includes a frozen manifest digest, which CI's three native targets turn into the cross-platform reproducibility guarantee the docs claim — two runs on one machine share a filesystem, a umask and an architecture, which is where the interesting divergences live. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WqQZxsTqanJRWLWdPSchBo
`Layout::read` slurped every blob into a `HashMap<String, Vec<u8>>`, and
`write_layout_dir` opened with `blobs.clone()` on top of that. Peak was
roughly 2x (base image + own layers) per concurrent image target — a 1 GB
base across eight targets is ~16 GB — and `oci_image` made it easy to
reach by putting a base and several layers in one graph.
A `Layout` now records *where* each blob is, not what it holds:
- a layout directory's blobs are already files, so they stay files;
- an `oci-archive` is a tar, so a blob is a contiguous range of it —
`raw_file_position` plus the entry size, no extraction;
- only manifests, indexes and configs are read, and those are kilobytes.
That turns every consumer into a streaming one. `write_layout_{dir,tar}`
copy through a 64 KiB `io::copy`; `oci_image` carries a base's layers by
reference straight into the image it is building; `write_docker_archive`
no longer takes a third copy of every blob it selects.
The registry ends of it too, which were the other half of the problem:
- `push_layout` uses `push_blob_stream` with a chunked reader instead of
`push_blob`, which wants the whole layer as one `Bytes`.
- `pull_layout` writes each blob to disk as it arrives, via
`pull_blob_stream`, instead of accumulating every layer and handing the
lot to the writer. A pull's whole job is to produce a file; buffering it
first was pure overhead. Chunks are written with `std::fs`, not
`tokio::fs`: a plugin cdylib's tokio is a separate runtime instance
polled by host workers, so reaching for a reactor or a blocking pool
aborts across the ABI seam.
Pulled blobs are written to a temp name and renamed, like the layout
writer already does — a blob's filename asserts a digest that nothing
re-verifies, so an interrupted pull must not leave a truncated file
behind claiming to be the whole layer.
The buffering wrappers are deleted rather than kept as a convenience, so
no future caller can reintroduce the copy by picking the shorter name.
Tests: a structural one asserting a read layout hands back *locations*
(`Blob::FileRange` from an archive, `Blob::File` from a directory) and
that a blob located in one layout writes into another without being read;
a chunking test for the push path, whose only other coverage is the
docker-gated suite; and a docker-format archive test, likewise.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqQZxsTqanJRWLWdPSchBo
fcadb0b to
4da06dc
Compare
|
Both follow-ups from the review are now in, so nothing is left deferred. The The def now carries the normalized The whole-image buffering is fixed here.
Both registry ends follow: Pulled blobs get the same temp+rename the layout writer already had: a blob's filename asserts a digest nothing re-verifies, so an interrupted pull must not leave a truncated file claiming to be the whole layer. The buffering wrappers are deleted rather than kept as a convenience, so no future caller reintroduces the copy by reaching for the shorter name. New tests: a structural one asserting a read layout hands back locations ( |
Stacked on #159 (needs the
oci_image→docker_buildrename that landed there).What
Two drivers that build a container image from target outputs — no Dockerfile, no BuildKit, no daemon, no execution.
Output is the same OCI layout
docker_buildemits, sooci_push,oci_loadandbasesconsume it unchanged.Why
Not "avoids a daemon". It is the only image rule whose cache key can cover what the build reads.
docker_build's own docs concede the gap: the buildx version is unhashed,FROMis resolved by BuildKit from the network,RUNfetches whatever it fetches, secret values cannot be hashed. Here there is no host binary, no subprocess, no env var, no run-time network.The docs scope that claim rather than overreach — a
baseis only as hermetic as theoci_pullbehind it, andoci_pullkeys on the ref string.Second consequence: cross-arch is free. A layer is a file tree, so nothing executes for the target architecture. An arm64 Mac emits
linux/amd64andlinux/arm64in one run, no QEMU, same digests as CI.Cache-key hazards fixed here, not later
Three ways two different images would have shared one cache entry:
hashinfolds a sorted, unlabeled multiset of dep hashouts.layers = [":a", ":b"]and the swap reach it identically — as dobase = ":a", layers = [":b"]and its mirror, andlayers_by_platformwith its values swapped. The def hash carries the ordered, normalized addresses. (docker_build's singledockerfile = ":t"role can safely omit its address — one occupant means any change moves that dep's hashout. An ordered list has no such property; the design's original "same rule asdockerfile" was the wrong generalization.)inputs_result_metafolds every output group regardless of the|groupselector, so[":bin|release"]and[":bin|debug"]were one key. The normalized address includes it.walk/cached_walker.rs), the pack step normalizes to 0755/0644, and the sandbox mode then depends on the umask, on whether another target marked the dep read-only, and on a 1 MiB threshold choosing FUSE over unpack. Mode comes from the exec bit or an explicit hashedmode; setuid/setgid/sticky are rejected loudly.Reproducibility
mtime 0, uid/gid 0, empty uname/gname, entries sorted by raw path bytes, headers built by hand (
append_pathcopies mtime/uid/gid/mode off the filesystem;append_dir_allwalks inread_dirorder). Config JSON goes throughserde_json, whose Map is aBTreeMaphere —oci-spec's config usesHashMapforLabelsand Rust'sRandomStateis seeded per process, so two runs on one machine would emit two byte orders. Nocreated, no per-layer timestamps.Layers are uncompressed. Spec-legal, and it keeps the layer digest out of the hands of whichever deflate backend cargo's feature resolution picks — a swap to zlib-ng would otherwise move every layer digest in every cache with no code change. It also stops gzipping bytes the remote cache is about to gzip again.
Silent failures made loud
Each with a test: empty layer (names what the srcs produced and which
stripvalues would work), two entries mapping to one path, a.wh.whiteout name, an absolute symlink, a base with no matching platform,platformsunset. Symlinks are preserved, not followed — following one silently duplicates bytes or embeds an undeclared sandbox file.Memory
Layer blobs travel as file paths, never
Vec<u8>, so an image is not held in memory once per concurrent target. Blobs are written to a temp name and renamed: a blob's filename asserts a digest and nothing re-verifies it, so a Ctrl-C mid-write would otherwise leave a truncated file served from cache forever.Tests
plugin-oci(layer determinism, sort key, symlinks, modes, def-hash collisions, config merge matrix, canonical encoding)crates/e2e/tests/oci_image.rs, none gated on docker — that is the featureDesign
Four design-stage agent consults (
product-vision,feature-quality,hermeticity,compatibility) shaped this. Three calls were the user's: renameoci_image→docker_build(in #159),platformsrequired with no default, and theDirPathsort fix as its own PR (#377).Not in scope, documented: no
RUN, no whiteouts/deletions, no hardlinks, gzip layers.🤖 Generated with Claude Code
https://claude.ai/code/session_01WqQZxsTqanJRWLWdPSchBo