feat(plugin-oci): OCI/container image integration (build, push, load, pull) - #159
Open
raphaelvigee wants to merge 34 commits into
Open
feat(plugin-oci): OCI/container image integration (build, push, load, pull)#159raphaelvigee wants to merge 34 commits into
raphaelvigee wants to merge 34 commits into
Conversation
raphaelvigee
force-pushed
the
worktree-bridge-cse_01Eu3bwzWeKmzmR38LBBjtJk
branch
9 times, most recently
from
August 3, 2026 13:34
ae98f7f to
3812062
Compare
raphaelvigee
force-pushed
the
worktree-bridge-cse_01Eu3bwzWeKmzmR38LBBjtJk
branch
2 times, most recently
from
August 4, 2026 22:28
262c35a to
f22fdfa
Compare
Introduces the `plugin-oci` crate and its first driver, `oci_image`, which builds a container image archive (OCI or docker format) from a Dockerfile + build context and exposes it as a cacheable target output, plus a `digest` output group for cheap downstream consumption. The builder is host `docker buildx` (a host capability, like http_fetch's network). Caching is layered: heph's input-hash cache is the primary win — an unchanged context is a cache hit and the image is not rebuilt; `cache_from` / `cache_to` wire BuildKit's registry/inline layer cache when a build does run, and are deliberately excluded from the input hash since they are build optimizations, not part of the image's identity. First milestone of an OCI integration; `oci_push` / `oci_load` action drivers and `oci_pull` base-image caching follow. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two non-cached action drivers that ship an oci_image archive off the build:
- oci_push: uploads the image to a registry with `skopeo copy`
(`<transport>:<tar>` -> `docker://<ref>`), which reads both OCI and docker
archives daemonlessly and skips blobs the registry already has.
- oci_load: loads the image into the local docker daemon — `docker load -i`
for a docker-format archive, `skopeo copy oci-archive:<tar>
docker-daemon:<tag>` for an OCI archive (which needs an explicit tag).
Both consume only the image archive output group ("") of their `image` target
via the `|`-group selector, never the digest group. Both are actions with
external side effects, so they set CacheConfig::off() and run every time.
Shared helpers land in pluginoci/mod.rs: run_cmd / run_cmd_cancellable (spawn
off-runtime, race cancellation) and dep_single_file (read a dep input's .list
to get the one materialized archive path).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
oci_pull is the image-world analogue of http_fetch: it pulls an image from a registry into a cacheable archive output (no target inputs — bytes come from the network), so a base image shared across many oci_image builds is pulled once and served from the local/remote cache thereafter. Uses `skopeo copy docker://<ref> <transport>:<tar>`. Like an http_fetch without sha256, pulling a mutable tag (no @digest) warns: heph keys the cache on the ref string, so a moved tag would serve the stale archive — pin by @sha256:digest for a reproducible pull. The BuildKit layer-cache wiring (cache_from / cache_to) shipped with oci_image in the first commit; this completes the caching story: input-hash cache (whole image) -> registry/inline layer cache -> oci_pull base cache. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rounds out oci_image build inputs: - secrets: raw `--secret` specs (id=...,src=... / id=...,env=...), consumed via `RUN --mount=type=secret`. Hashed. - ssh: raw `--ssh` specs (default / id=...,src=...), consumed via `RUN --mount=type=ssh`. Hashed. Multi-arch was already wired via the `platforms` field (`--platform` CSV); the doc now notes it needs a container-driver buildx builder, since the default daemon builder is single-platform. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a `tool` knob (skopeo | docker) to oci_push / oci_pull / oci_load so a docker-format image can move without skopeo installed. It defaults per format — docker-format archives use the `docker` CLI, oci-format archives use skopeo — so skopeo is only required for OCI archives. - oci_push tool=docker: `docker load -i` + `docker tag <loaded> <ref>` + `docker push <ref>` (parses the loaded ref from `docker load` stdout). - oci_pull tool=docker: `docker pull <ref>` + `docker save <ref> -o <tar>`. - oci_load tool=docker: `docker load -i <tar>` (already the docker-format path, now selectable); skopeo path handles either archive via `<transport>:` and needs a tag. `tool="docker"` with an `oci` archive is rejected at parse (the docker CLI is docker-format only), and a skopeo load still requires `tag`. The shared run_cmd helpers now return captured stdout so the docker push path can read the loaded image ref. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`tst` links a test binary per workspace crate; with the nix store plus the
heavy dependency graph this intermittently exhausts the ~14 GB ubuntu-latest
root disk during linking ("No space left on device"), failing Test linux/amd64
while every other job (Builds, Lint, darwin Test) passes. Drop preinstalled
toolchains the job never uses (android SDK, dotnet, ghc, CodeQL) to reclaim
~15 GB before the build. Linux-only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…exec Review of the four OCI drivers turned up three ways a build could be silently wrong and one way it could hang. Fixed together because they share the same root: the drivers assembled commands but never owned what those commands actually read or produced. Cache-key soundness: - The built platform is now in the key. `platforms = []` builds for the builder's default platform, which differs per host, while `OciImageDef::hash` saw only the empty vec. With the remote cache on by default, an arm64 laptop and an amd64 runner computed one key for two different images and traded them. `parse` now probes `docker buildx inspect --bootstrap` (memoized per process) and hashes the answer. Same defect in `oci_pull`, where both skopeo and docker resolve a manifest list against the host: `platform` is now an explicit, hashed `os/arch`, defaulting to Linux on the host arch. - `secrets` / `ssh` are sorted before hashing. Reordering them produces the same image and must not force a rebuild. `platforms` stays order-sensitive — it orders the manifest list — and now says so at the hash site. Correctness: - The build context is the sandbox workspace root, not the package dir. Deps materialize at their workspace-relative paths, so a `context` dep from another package landed outside the context that was passed to buildx: the Dockerfile could not see it, while it still moved the cache key. `COPY` paths are now workspace-relative. - `skopeo copy` passes `--multi-arch all`. The default (`system`) pushed one architecture out of a multi-platform archive on Linux, exit 0, and failed outright on macOS where no instance matches `darwin`. - `oci_load` applies an explicit `tag` on the docker path. `docker load` has no `--tag`, so the attribute was accepted, hashed, and dropped, leaving a dangling `<none>:<none>` image. - `oci_push` drops the temporary local tag after pushing, so it no longer does `oci_load`'s job as a side effect. Subprocess handling — replaces the hand-rolled `std::process::Command` + `spawn_blocking` with `hproc::proc_exec`, the primitive the rest of the tree uses: - Cancellation kills the child. Ctrl-C previously left `docker buildx` running, streaming into a sandbox the cleaner was deleting, and parked runtime shutdown until it finished. Cancellation now also returns `CancelledError`, so the engine records an abort rather than a failure. - The environment is cleared and explicitly repopulated. `BUILDX_BUILDER`, `DOCKER_DEFAULT_PLATFORM` and `SOURCE_DATE_EPOCH` all change the image and none are in the key. - The working directory is explicit. - Output streams to the user as it arrives instead of being buffered whole, discarded on success, and pasted entire into the error on failure. Errors quote a bounded tail. New surface: - `bases` maps a name to an `oci_pull(layout = True)` target and wires it to `--build-context <name>=oci-layout://…`, so a Dockerfile can `FROM <name>` a base heph produced and hashed. Until now nothing could consume an `oci_pull` output, so its stated purpose was unreachable. - Each `context` group is exported as a `SRC_<GROUP>` build arg holding context-relative paths, matching exec's `SRC_*` convention. The group names were previously inert. - `oci_pull(layout = True)` writes an OCI layout directory, which is what `oci-layout://` reads. Renames and validation (pre-release, no users yet): `target` -> `stage`, since `target()` already means something else in a BUILD file; per-target output names (`<name>.tar` / `<name>.digest`) so two image targets in one package do not declare the same path; reject an absolute `dockerfile`, a `src=` secret or ssh source, `=` in a build-arg key, `insecure` with the docker tool, multi- platform with `format = "docker"`, an `out` with a directory component, and an explicit output group on a push/load `image` ref. Tests: `run()` had no coverage on any driver and the injection hooks meant to provide it were private and asserted only that a setter set a field. The constructors are now public, a shared fake-binary harness drives `run()` end to end, and the four tautological tests are gone. 36 -> 71 tests, covering the digest round-trip, stderr on failure, missing metadata, missing binary, cancellation killing the child, env clearing, the docker push sequence, and the layout transport. Docs: the module header listed what feeds the hash in a way that read as exhaustive. It now names what does not — `FROM` base images, `RUN` network fetches, secret values, host tool versions, `.dockerignore` — since an undocumented exclusion is a trap even when the exclusion is right. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WqQZxsTqanJRWLWdPSchBo
The unit tests drive `run()` directly; these go through the real engine, so they cover what a driver does not own on its own: - the archive and `digest` groups are collected as real artifacts a downstream bash target can depend on and read; - an unchanged context is a cache hit and does not shell out to the builder a second time — the driver's whole efficiency claim, and only the builder can prove it; - a `context` dep from another package is actually inside the build context and reachable through its `SRC_*` build arg (the bug fixed in the previous commit: it used to be hashed but invisible to the build); - a failing build fails the target with the builder's own message, and never produces an artifact. No daemon required: the driver is registered pointed at a shell script that records its argv and writes what buildx would have written. The fake's stand-in "archive" is a listing of the build context, so a test can assert what the build could actually see rather than only what the command line said. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WqQZxsTqanJRWLWdPSchBo
Multi-stage already worked (`stage` -> buildx `--target`); this covers the multi-arch half of the family, which had two gaps. `oci_pull` could only ever select one instance out of a manifest list, so it could not produce a base image for a multi-platform build: the layout handed to buildx via `bases` has no manifest for the architectures it was not pulled for, and the build fails on whichever one that is. Add `all_platforms = True` (skopeo `--multi-arch all`), which keeps the index intact. It is rejected with `tool = "docker"` (`docker save` writes one image) and alongside an explicit `platform` (a contradiction), and it is part of the cache key -- a full index and a single instance are different bytes under the same ref. `oci_load` left the instance choice to skopeo's own default, which matches the host's GOOS/GOARCH. For a multi-arch archive that is a `darwin` no Linux manifest list contains, so the load failed outright on macOS. It now pins `platform` (default linux/<host arch>) onto the copy, hashed, and rejects it for `docker load`, which has no instance selection. Also: the multi-platform build-failure hint now names the `bases` that must be pulled with `all_platforms`, and `default_platform`/`split_platform` move to the shared module. Tests: `--multi-arch all` drops the overrides; all_platforms hashes apart from a single instance and is rejected for docker/with platform; load pins the instance, defaults to linux, hashes per platform, rejects docker; e2e proves a multi-stage multi-arch build reaches the builder with both `--target` and the full `--platform` list, and that two stages are two cache entries. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WqQZxsTqanJRWLWdPSchBo
Rebase onto master, where `proc_exec` replaced the two per-stream `ChunkReader`s with one `OutputReader` carrying `(StreamId, Vec<u8>)` (which is what removes head-of-line blocking between the streams on macOS), and made `wait_or_cancel` crate-private behind `Handle::spawn_wait` so the wait cannot share a task with the reader. `run_tool` now tees the single merged reader, routing by `StreamId`, and waits through `spawn_wait(ctoken.clone_arc())`. Also silence the newly-enforced `panic_in_result_fn` in the oci e2e test, the same way every other test file in the workspace does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WqQZxsTqanJRWLWdPSchBo
The fake-binary suite proves the driver builds the argv it means to; it cannot prove BuildKit accepts that argv, that the archive is the format the driver claims, or that a `COPY` written against heph's context layout resolves. This adds a suite that runs the real thing. It found a real bug: `bases` never worked. A dep's unpack list names files, not the directories holding them, so `resolve_named_contexts` found no directory entry and fell back to the first staged path — handing buildx `oci-layout://…/base.oci/oci-layout`, a file. Every `FROM <base>` build failed. The layout root is now recovered from its `oci-layout` marker, and a base that is not a layout at all says so and names the fix. Coverage, all skipping rather than failing when the host cannot run it: - oci_image: real OCI archive + a real sha256 digest; `format = "docker"` produces a `manifest.json` archive (modern BuildKit ships an `index.json` in it too, so the docker manifest is the only discriminator); a cross-package dep reached through `SRC_<GROUP>`; `stage` narrowing the build to one stage's DAG, proven by a sibling stage that cannot build; a multi-platform build whose manifest list carries both architectures. - skopeo: an `oci` archive loaded into the daemon; and a full multi-arch round-trip against a throwaway `registry:2` — push with the index intact, pull it back as an all-platforms layout, then build `FROM base` for both platforms off that layout. That is every multi-arch claim the drivers make, end to end. skopeo joins the dev shell (`devenv.nix`), which is how CI gets it. Probes are bounded: `docker info` walks every configured builder, so one stale context hangs it forever, and skopeo's `docker-daemon:` transport goes to `$DOCKER_HOST` / `/var/run/docker.sock` rather than the docker context — on a host running another runtime that socket is dead and the copy never returns. A deadline turns both into skips. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WqQZxsTqanJRWLWdPSchBo
`oci_image` gains `builder`, wired to `docker buildx --builder`. The gap it closes: which builder runs decides the platforms available, the BuildKit version, and the layer cache behind the image — and until now that was pure ambient host state. `BUILDX_BUILDER` is stripped from the child environment precisely because an ambient builder would change the image behind the cache key's back, but with nothing to replace it a multi-platform target was stuck with whatever builder the host's `docker buildx use` had selected. On a default Docker install that is the daemon driver, which cannot emit a manifest list at all, so `platforms = [...]` simply failed with no in-BUILD-file remedy. `builder` is a hashed input, which is what makes it legitimate where the environment variable was not. What is hashed is the name: two machines whose `multi` builders differ still agree on the key, the same way they already do for the `docker` version itself. Naming the builder narrows that gap rather than closing it, and it is now stated in the file where the reader can see it. The default-platform probe follows the selection — asking the default builder would put another builder's platform in this target's key — and is memoized per builder rather than once per process, since bootstrapping a container builder is seconds and a workspace has many targets. The multi-platform failure hint now tells the user how to fix it: create a `docker-container` builder and name it, or, if one is already named, check that it is the driver it claims to be. Def format version 2 -> 3. Tests: `--builder` in the argv; the hash differs per builder; the probe names the selected builder; and an e2e that creates a real `docker-container` builder, points a target at it, and checks both architectures came out. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WqQZxsTqanJRWLWdPSchBo
CI on stock Docker Engine failed every `oci_image` build with
ERROR: failed to build: OCI exporter is not supported for the docker driver.
ERROR: failed to build: Docker exporter is not supported for the docker driver.
The plain `docker` driver — what a stock daemon selects by default — has no file
exporters at all. It can load or push into the daemon and nothing else, so
`--output type=oci,dest=…` and `type=docker,dest=…` both fail, and with them
every `oci_image` target regardless of `format`. It needs a `docker-container`
builder or the daemon's containerd image store. Local development missed this
because OrbStack's builder has the exporters.
BuildKit names the exporter but not the remedy, so the driver adds it: create a
container builder and select it with `builder = "…"`, or turn on the containerd
image store. When `builder` is already set, the message says to check that one
instead. BuildKit's own text stays in the chain.
Not diagnosed at parse time: `buildx inspect` reports the driver but not the
image store, so a containerd-backed daemon would be rejected wrongly. The check
that cannot produce a false negative is the build itself.
The e2e suite now does what a user on such a host has to do — every
archive-producing test gets a `docker-container` builder when the default one
cannot export, and uses the default otherwise. That also retires the
"multi-platform capable" gate: those tests now run everywhere instead of
skipping on exactly the hosts they were written for. Builder names are unique
per instance, not per process: tests run in parallel and each removes its
builder on drop, so a shared name tore the builder out from under a running
build.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqQZxsTqanJRWLWdPSchBo
The `oci_*` drivers were compiled into the CLI; the go and gha plugins are not —
they ship as per-os/arch cdylibs published as release assets with a
`heph-<name>-plugin.json` manifest a workspace selects with
plugins:
- url: https://…/heph-oci-plugin.json
oci now follows that pattern. New `crates/plugin-oci-cdylib` exports the stable
ABI trio (`create` / `set_log_sink` / `set_supervisor`) and hands back the four
drivers as a driver-only bundle — no provider, no hooks: images are declared
through whatever provider the workspace already uses.
The supervisor entry matters more here than elsewhere. This cdylib links its own
`proc`, whose tracker the host's startup `init` never reached, so without it
every `docker buildx` and `skopeo` child goes unregistered with the sidecar. A
detached buildx is exactly the child that must not outlive the run: it keeps
writing into a sandbox the cleaner is deleting.
The CLI no longer depends on `plugin-oci` at all — the bootstrap registrations
and the `heph::pluginoci` re-export are gone, so the binary does not carry code
it never registers. The e2e suites depend on the crate directly, the way
plugingo-e2e does for the go plugin.
CI builds the cdylib in the same cargo invocation as the other two (shared dep
graph, overlapping LTO tails), runs macos-portable over it, uploads it per
os/arch, and generates `heph-oci-plugin.json` alongside the go and gha manifests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqQZxsTqanJRWLWdPSchBo
CI linux/amd64 failed in `build_args_affect_hash`:
docker buildx inspect: spawn `…/docker`: Text file busy (os error 26)
The fake-binary writers used `fs::write` + `set_permissions`, which is exactly
the sequence `hcore::fsutil::write_executable` exists to replace. `File` sets
`O_CLOEXEC`, but that only closes the descriptor at `execve`, so a sibling test
forking between our create and our exec leaves its child holding an inherited
writable fd for the whole fork→execve window — and the exec of that file fails
with ETXTBSY. Tests in one binary run in parallel, so the racing sibling is
another oci test spawning its own fake.
`write_executable` drains those descriptors (flock on the writable fd, close,
reopen read-only and acquire shared) before returning. Linux-only symptom, which
is why the arm64 and darwin legs stayed green and local runs never showed it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqQZxsTqanJRWLWdPSchBo
`heph run //…:docker_image` aborted the whole process:
panicked at tokio/src/process/unix/mod.rs: there is no reactor running,
must be called from the context of a Tokio 1.x runtime
…
thread caused non-unwinding panic. aborting.
A loaded cdylib's statically-linked tokio is a separate instance from the
host's, and the future the plugin returns is polled by a *host* worker thread,
whose tokio thread-locals belong to the host's copy — the plugin's copy sees no
runtime there at all. Any reactor touch panics, and a panic across the ABI seam
is a non-unwinding abort, not an error.
`run` has hopped onto `cdylib_runtime()` for exactly this reason since it was
written. `parse` did not, because until now no plugin shelled out from it:
`oci_image` asks buildx for its default platform at parse time, since that
platform is part of the cache key. `apply_transitive` gets the same treatment —
it is the same shape one call later.
Cost is a task hop per parse. Parse already crosses the ABI seam with a
protobuf-encoded TargetSpec in and a TargetDef out, so a spawn plus a oneshot is
noise against what it already pays.
Covered by a bin_e2e smoke test, which is the only place this is observable: it
needs a real dlopen'd cdylib in the real binary, and every in-process test runs
on the host's runtime where the reactor is right there. Verified by reverting
this fix — the test reproduces the abort — and restoring it.
The oci cdylib joins the staged dist (devenv `e2e`) so that suite can load it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqQZxsTqanJRWLWdPSchBo
`dockerfile` was a path only, which made a generated Dockerfile awkward in the
one way that matters: the user had to list the producing target in `context`
*and* separately spell the workspace-relative path its output lands at. Two
statements of the same fact, and the second one is a guess about staging that
the driver could change under them.
It now also takes an address — `:gen` or `//base:Dockerfile`, the same two
prefixes `context` / `bases` / `image` already accept, neither of which any
sensible path starts with. That target becomes a hashed, runtime dep of its
own, and `run` reads the file from where the dep actually staged it:
oci_image(name = "img", dockerfile = ":gen", context = [":srcs"])
The address itself is not hashed — the dep's own content hash is what the key
follows, so renaming the target that produces identical bytes does not split
the cache entry.
Paths keep working unchanged, including the absolute-path rejection, whose
message now names the address form as the other way out.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqQZxsTqanJRWLWdPSchBo
One INFO line per built image, on a run that may build dozens, saying only what the target already exposes: the digest is the `digest` output group, and the addr is what the progress display is already showing. Nothing is lost and the default-verbosity output gets quieter. The action drivers keep theirs — `oci_push` / `oci_load` / `oci_pull` mutate a registry or the daemon, run rarely, and leave no output group behind to inspect. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WqQZxsTqanJRWLWdPSchBo
`docker buildx build` can run for minutes with nothing to show for it. Its output went to the `stdout`/`stderr` sinks on the run request and nowhere else, and outside an interactive run those are `None` — so the whole progress log, including the one BuildKit prints when a `COPY` cannot resolve, was discarded. The tool's output now also goes to `<sandbox>/log.txt`, which is the file the engine collects as the target's output artifact and renders a tail of in the failure box — the same place `pluginexec` puts a `bash` target's output, so an `oci_image` target now behaves like every other target rather than being silently mute. Both streams land there in arrival order, appended rather than truncated so a multi-command run (`oci_push` does load, tag, push, rmi) keeps one log. Second half, in the SDK: a cdylib driver had no way to stream at all. The run stream's `stdout_chunk` / `stderr_chunk` frames were reserved but never sent — `run_once` handed the driver `stdout: None, stderr: None` — and the host's `drain_run` discarded them in turn. The guest now writes them as frames on the same channel that carries the terminal result (so the log arrives before the result that ends the stream), and the host relays them to the target's own stdio as they arrive. Backpressure is real: the sinks reserve channel capacity rather than dropping, so a host that stops reading slows the child. Verified against the real binary loading the real cdylib: a failing build now prints BuildKit's whole progress log, ending at the `COPY` that could not resolve. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WqQZxsTqanJRWLWdPSchBo
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WqQZxsTqanJRWLWdPSchBo
Master's `pluginbuildfile::Provider::new` now takes a `tokio::runtime::Handle` alongside the root; the oci suites still called the one-argument form. Same `init.runtime.clone()` every other fixture in the crate passes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WqQZxsTqanJRWLWdPSchBo
Every assertion that the build context is the sandbox workspace root was made on an OCI-format build: the argv test, the `run` test, and both cross-package `SRC_*` tests all use the default format. The docker-format tests only checked the exporter (`type=docker`) and the archive shape (`manifest.json`). `format` selects the exporter and nothing else — `context_dir` is `sandbox_ws_dir` unconditionally, and it also anchors the Dockerfile join and the `SRC_*` relativization — so the two cannot diverge today. Nothing pinned that, though, and it is the kind of shared branch someone splits later. The docker-format argv test now asserts the context arg, and the real-docker one `COPY`s a cross-package dep through `SRC_BIN`: a package-rooted context puts that path outside the context and BuildKit fails the build. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WqQZxsTqanJRWLWdPSchBo
…rate
Every CI job died in seconds:
error: cannot update the lock file … because --locked was passed
`gen/proto/Cargo.toml` is generated and untracked. Mine predated master's
dependency sweep and still pinned `pbjson = "0.7"`, so when the merge left the
lock inconsistent and cargo repaired it here, it resolved against that stale
crate: pbjson came back down to 0.7 and dragged `base64 0.21` with it —
re-adding the duplicate stack #339 had just removed. CI regenerates `gen/`
itself, gets pbjson 0.9, and the committed lock then matches nothing.
Regenerated the proto crate, reset the lock to master's, and let cargo add only
what this branch actually introduces. The delta is now exactly the two new
workspace members and nothing else.
Verified the way CI does: `cargo check --locked --workspace --all-targets`
inside the dev shell, plus the test suites and lint under `--locked`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqQZxsTqanJRWLWdPSchBo
Master changed `#[derive(Spec)]`'s generated `from` to take `&HashMap<String, Value>`; the four `oci_*` parsers still handed it an owned clone. Passing the borrow drops the clone with it, which is what the other plugins already do. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WqQZxsTqanJRWLWdPSchBo
…ime probe `oci_image` with no explicit `platforms` builds whatever buildx defaults to, and nothing else in the key varies by platform — the remote key is `package/name/hashin`, no arch segment — so that default has to reach the key or two machines file different image bytes under one entry. It did, via a `docker buildx inspect --bootstrap` in `parse`, memoized per process. Paying for that in `parse` was wrong three ways. It ran on fully-cached runs, where nothing rebuilds and `--bootstrap` can still start a container. It made `heph inspect def` shell out. And a driver that shells out from `parse` is what aborted the process once the plugin became a cdylib, because `parse` is polled on a host worker with no reactor for the plugin's tokio. The answer is a target: `//@heph/oci:platform`, declared by a small provider this plugin now carries, running the probe and writing the platform it reports. An image without explicit `platforms` depends on it, so the platform enters the key as an ordinary input hash. It shows up in `heph inspect deps`, the engine single-flights it across every image target, and its subprocess gets the same cancellation and logging every other target gets. `parse` is pure again. It is cached **locally but never remotely**. Local caching is the point — an uncached dep must execute every invocation to produce the hashout its consumer needs, so only a cached one makes a warm run free. Remote caching would be the original bug wearing a hat: the platform is host state, and publishing it would let one machine's answer serve another's. Invalidation: the env vars selecting daemon and builder are hashed into the def, and a named `builder` rides the target's address, so both re-probe. What stays sticky is `docker buildx use <other>`, which moves docker's own state with no env change — a stale default, not a wrong artifact, since the recorded platform is passed explicitly as `--platform` so the image and its key agree. Documented on the attribute; `platforms` opts out entirely. Gone with it: `resolve_builder_platform`, the per-process memo, the `builder_platform` def field and its hash entry. Tests: the dep is declared (and named per builder) when `platforms` is empty and absent when it is not; the probe def is local-cached and not remote-cached; the selecting env and the builder name key apart; and an engine e2e that the probe runs once across two runs — the property the whole design exists for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WqQZxsTqanJRWLWdPSchBo
A dep with no `unpack_root` annotation materializes at the sandbox workspace dir, and that dir is exactly what `run` hands buildx as the build context. Two deps had no business being there. The builder-platform probe: `@heph/oci/platform.txt` sat in the context, so a `COPY . /app` shipped heph's plumbing inside the user's image. Worse than the stray file, silently: the probe dep exists only when `platforms` is unset, so adding `platforms = [...]` changed the built image's contents with no error and nothing to point at. `bases`: an OCI layout is a directory of layer blobs, hundreds of MB for a real base. It sat in the context too, so buildx transferred the whole base to the builder as context bytes *on top of* reading it through `--build-context`, every build — and a wildcard `COPY` pulled the base image into the image built from it. Both now land in `<sandbox>/exec_oci_<origin>`. `context` groups are untouched: being in the context is their entire purpose. Neither consumer notices — `dep_single_file` and `resolve_named_contexts` read absolute paths out of the dep's list file. Tests: the cross-package e2e asserts the context contains the dep and *not* the probe file — verified it fails without the annotation, reporting `./@heph/oci/platform.txt` in the listing — and a parse test pins the rule itself: annotated exactly when the input is not a `context` dep. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WqQZxsTqanJRWLWdPSchBo
`--output type=…,dest=` pointed straight at the declared output path, which lives in the workspace dir, which is what buildx is handed as the build context. So the image archive was created and grown *inside* the context that produces it: a wildcard `COPY` pulled the in-progress image into the image, and BuildKit walked a tree the build was still writing to. The build now writes to the sandbox dir and the archive is renamed into its declared path once the digest has been read — same filesystem, so a rename, not a copy of the image. This is what the metadata file has always done, twenty lines up, and for the same reason. Chosen over staging a generated `.dockerignore`, which would have masked the problem rather than removed it: the file would still be created inside the context mid-build, it collides with a `.dockerignore` a `context` dep may legitimately produce (and `!` negations make merging unsafe in both directions), and BuildKit's two candidate locations — `<context>/.dockerignore` and `<dockerfile>.dockerignore` — make a silently-ineffective ignore file easy to produce. An ignore file is the right tool for letting users exclude their *own* context files, which is a different feature. Test: the cross-package e2e now asserts no `.tar` in the context listing — verified it fails without this, reporting `./app/img.tar`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WqQZxsTqanJRWLWdPSchBo
raphaelvigee
force-pushed
the
worktree-bridge-cse_01Eu3bwzWeKmzmR38LBBjtJk
branch
from
August 5, 2026 08:37
3768c4b to
30721f9
Compare
`linux/x86_64` and `linux/amd64` are the same machine; `linux/arm64/v8` and `linux/arm64` are the same CPU. Every spelling was carried verbatim into `--platform` and into the cache key, so one image had as many cache entries as the user had ways of spelling its platform — a silent miss, not an error. Normalized once at parse, before argv and before the hash, in `oci_image`'s `platforms` and in `oci_pull`/`oci_load`'s `platform`. A deliberate subset of containerd's rules: the aliases people actually type, plus dropping arm64's redundant default variant. An unknown but well-formed platform passes through lowercased rather than being rejected — BuildKit accepts platforms heph has never heard of, and guessing wrong would be worse than carrying the user's word. This also has to land before deps can stage per platform: the platform string becomes a directory name that must byte-match what BuildKit expands `TARGETPLATFORM` to, and `linux/x86_64` never would. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WqQZxsTqanJRWLWdPSchBo
A multi-platform image whose binary differs per architecture had no way to say
so, and no workaround. Both go variants of one binary produce the same
workspace-relative path — `target_bin.rs` derives the filename from the import
path, not the variant — so listing both in `context` is an `output collision`,
and BuildKit's own `COPY bin-${TARGETARCH}` idiom needs distinct names heph
cannot give them. The only escape was a per-platform `sh` target that renames
the binary: two extra targets and a full copy, per image.
`context_by_platform` takes `{platform: {group: [addr]}}` — `context`'s inner
shape, keyed by platform, so the outer keys are the strings in `platforms` three
lines above and coverage is eyeballable. Each platform's deps unpack under their
own root (which is what stops the collision) and are linked into the build
context at `.heph/ctx/<platform>/…`. Links, not copies: the bytes are already
staged. The Dockerfile selects with BuildKit's own variable, and never learns
heph's layout:
ARG TARGETPLATFORM
ARG CTX_BY_PLATFORM
ARG SRC_BIN
COPY ${CTX_BY_PLATFORM}/${TARGETPLATFORM}/${SRC_BIN} /usr/bin/server
`SRC_<GROUP>` is relative to the platform prefix, so a group must produce the
same paths on every platform — one target built two ways, the usual case — and
disagreement is an error rather than a silently platform-dependent build arg.
Every mismatch is a hard parse error naming the fix: a platform in the map that
is not in `platforms`, a platform in `platforms` with no entry (which would
otherwise fail one leg of the fan-out deep in a BuildKit log), no explicit
`platforms` at all (the probe answers at run time, so heph cannot say which key
to write), and a group name shared with `context` (two writers of one `SRC_` arg).
Silently dropping a platform's deps is the one outcome that must never ship — it
removes a binary from an image with no signal.
The platform rides in the origin id, so a `HEPH_DEBUG_HASH` trace names *the
arm64 binary* rather than "a context dep". The `(platform, group)` key set is
hashed — sorted, since unlike `platforms` these carry no manifest ordering — so
adding or removing a platform busts the key even when every dep is unchanged.
Def format version 3 -> 4, covering this and the platform normalization.
`crates/plugin` and `crates/core` gain the nested `{string: {string: [string]}}`
spec type this needed; strict, with no bare-string shorthand, because the outer
key carries meaning that cannot be defaulted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqQZxsTqanJRWLWdPSchBo
…kopeo `oci_push` and `oci_pull` shelled out to skopeo, which is standard on Linux CI and absent from a stock Mac — the reason the default `format = "oci"` was awkward to recommend. They now speak the OCI distribution protocol in-process via `oci-client` (maintained by the ORAS project). Two new modules carry it. `archive.rs` reads and writes OCI layouts, as a tar or a directory, because `oci-client` has no concept of an archive — it speaks to registries and nothing else. `registry.rs` is the transport: blobs the registry already has are skipped via `blob_exists`, and a multi-platform layout pushes every instance plus the manifest list that ties them together. Credentials are the part skopeo did invisibly. `oci-client`'s `RegistryAuth` is only Anonymous/Basic/Bearer, so `docker_credential` resolves `~/.docker/config.json`, podman's `auth.json`, and the `docker-credential-*` helpers named by `credsStore`/`credHelpers` — without it ECR/GCR/ACR would stop working. An unconfigured registry falls back to anonymous rather than failing, since a public pull needs no credentials. BUILD-file surface, both breaking and both deliberate: - `tool` is gone from both drivers. There is one implementation now, and it handles what the two tools handled between them. - `format` is gone too, which is the larger cut: `oci-client` speaks OCI manifests, so push/pull no longer handle docker-format archives at all. `oci_image` keeps `format` (buildx still emits both shapes), so the drivers now differ on this — deliberately. For a docker-format round trip, use `oci_load` and the docker CLI directly. Both fail loudly: the `Spec` derive rejects unknown keys, so an old BUILD file gets a parse error, never a silent reinterpretation. Def versions: push 1 -> 2, pull 3 -> 4, since `tool` and `format` were in the key. `oci_push` is uncached so its bump costs nothing, but **`oci_pull`'s invalidates every cached pull artifact, local and remote** — every base image refetches from its registry on first use after this lands, and the shared remote cache carries a dead generation under the old key space. Correct direction (a full miss, never a wrong hit), but it is a real fleet-wide cost. Archives are written reproducibly — fixed mtime, blobs in sorted order — because they are cached artifacts and a wall-clock timestamp would make one image hash two ways. The argv tests for both drivers go with the argv. Two of them had started reaching docker.io the moment the fake-binary indirection disappeared, and one in `pull.rs` was passing vacuously: it asserted a "docker tool + oci format" error contained "oci", which it still did — via the words "parse oci_pull config" wrapping an "unknown entries: tool" failure. The real coverage for this path is the local-registry round trip in the docker e2e. `oci_load` still uses skopeo; it converts next. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WqQZxsTqanJRWLWdPSchBo
Three things that only made sense together, because they are the last of skopeo coming out. `oci_load` no longer shells out at all. It converts the layout to the hybrid shape a daemon accepts (`manifest.json` alongside the OCI blobs, layers left gzipped — the daemon inflates them) and POSTs it to `/images/load` through bollard, then tags the result. Two things skopeo was quietly doing had to be written out: resolving the active docker *context* (`currentContext` → `contexts/meta/<sha256>/meta.json`) rather than assuming `/var/run/docker.sock`, which is why the load hung for the full 120s on a colima/orbstack host; and reading the loaded reference out of the daemon's own narration, because a containerd-backed daemon ignores `RepoTags` in the archive. `oci_pull` takes `platforms = [...]` or `all_platforms = True`. It used to resolve one instance against the *client's* default platform, which on an arm64 mac matches nothing in a `linux/*` index. Asking for a platform the registry does not publish now fails naming what it does publish, rather than yielding a layout that breaks later inside someone else's build. A layout written to disk carries `org.opencontainers.image.ref.name` on its index — skopeo's `oci:<dir>:latest` supplied that tag, and without it buildx cannot resolve `oci-layout://` for a `FROM` on a pulled base. skopeo is gone from devenv, from the docs, and from the e2e gates. The whole real-docker suite (10 tests) passes without it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WqQZxsTqanJRWLWdPSchBo
The Dockerfile rule was called `oci_image`, but every attribute it has names a docker concept — `dockerfile`, `context`, `build_args`, `stage`, `builder`, `secrets`, `ssh`, `cache_from`, `cache_to` — and it depends on a probe target whose only job is to ask buildx a question. It is a `docker_build`, and calling it `oci_image` claimed the standards name for the one driver that cannot uphold the standard's properties. The name matters more than usual here because a daemonless, layer-based image rule is coming, and that rule is the one users and agents should reach for by default: no daemon, no QEMU for cross-arch, and a cache key that actually covers what the build reads. It should be `oci_image`, and that name has to be free. None of this is released — `crates/plugin-oci` is not on master — so the rename costs nothing today and would be permanent the day #159 merges. Also splits `pluginoci/mod.rs`: the Dockerfile driver moves to its own `docker_build.rs`, and `mod.rs` keeps only what more than one driver needs (the archive-format enum, platform-string handling, the dep-list helpers, the test fakes). `mod.rs` was 3353 lines hosting one driver plus everything shared; adding a second image driver to it was not an option. Drive-bys, both stale rather than new: - the cdylib docs still described `skopeo` as a host capability, three commits after it stopped being one, and still said "four drivers". - a test's doc comment had been orphaned onto the following test, so `run_names_the_fix_when_the_builder_has_no_file_exporter` carried a first line about `containerimage.digest`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WqQZxsTqanJRWLWdPSchBo
`context`, `context_by_platform` and `bases` map a *name* to a target, and
only the names reached the cache key: `DockerBuildDef` had no `context`
field at all, `bases` hashed `Vec<String>` of group names, and
`platform_context_keys` hashed sorted `(platform, group)` pairs.
Nothing else carried the mapping. `hashin` (engine/meta.rs:54-88) folds a
*sorted, unlabeled multiset* of dep hashouts — no origin_id, no address —
and `inputs_result_meta` folds every output group a dep has regardless of
the `|group` selector on the ref. So all of these were one cache entry
for two different images, with no error and nothing to point at:
context = {"a": [":x"], "b": [":y"]} vs the swap
-> SRC_A and SRC_B name each other's files
bases = {"base": ":alpine", "tools": ":ubuntu"} vs the swap
-> `FROM base` resolves to the other image
context_by_platform amd64/arm64 with their targets swapped
-> the amd64 leg stages the arm64 binary
context = {"a": [":bin|release"]} vs [":bin|debug"]
-> the selector reaches no hash at all
The def now carries the normalized `TargetAddr` rendering (which includes
`|output`) alongside each name, ordered by name and, within a group, in
declaration order. Normalized rather than as-written so `:x` and `//app:x`
in package `app` stay one key rather than trading a wrong-artifact bug for
a spurious-miss one.
`dockerfile = ":target"` still omits its address, and that stays correct:
it is a single-occupant role, so changing which target fills it changes
that dep's hashout and therefore `hashin`. An N-occupant named mapping has
no such property — which is the generalization that produced this bug.
DOCKER_BUILD_FORMAT_VERSION 4 -> 5.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqQZxsTqanJRWLWdPSchBo
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
A full OCI/container image integration for heph, as the
plugin-ocicrate. Four drivers, selected in a BUILD file viadriver = "...".It ships the way the go and gha plugins do — not compiled into the CLI, but as a per-os/arch cdylib published with a manifest a workspace opts into:
oci_image— build (cacheable)Builds a container image archive from a Dockerfile + build context via host
docker buildx. Outputs the archive (<name>.tar) and adigestgroup.The build context is the sandbox workspace root, so a
contextdep from any package is reachable andCOPYpaths are workspace-relative. Each context group is also exported as aSRC_<GROUP>build arg, so a Dockerfile need not hardcode the layout:oci_pull— base-image fetch (cacheable)The image-world
http_fetch: pulls a registry image into a cacheable archive so a shared base is pulled once. Warns on unpinned tags (pin@sha256:for reproducibility).oci_push— push to registry (action, uncached)skopeo copy --multi-arch all <transport>:<tar> docker://<ref>— daemonless, keeps a manifest list intact, skips blobs the registry already has.tool = "docker"doesload+tag+push+rmiinstead, for a docker-format archive.oci_load— load into local daemon (action, uncached)docker load -ifor a docker archive;skopeo copy oci-archive:<tar> docker-daemon:<tag>for an OCI archive. A daemon tag holds one image, so a multi-arch archive is narrowed byplatform(defaultlinux/<host arch>).Multi-stage and multi-arch
Multi-stage is
stage→ buildx--target. Multi-arch has one working route, and every step of it is exercised end to end against a real registry:A docker-CLI-only route (
format = "docker", no skopeo anywhere) is fully supported but single-arch end to end;platformswith more than one entry andformat = "docker"is rejected at parse.Caching (the efficiency goal)
Layered, in order of impact:
oci_imageinputs are the context files, Dockerfile, build args, stage, platforms, the resolved builder platform, base contexts, secrets and ssh specs. Unchanged inputs → cache hit → no rebuild; the archive is served from the local or remote cache. Image timestamp nondeterminism doesn't defeat this: heph keys on inputs and serves the identical cached archive to consumers.cache_from/cache_towire--cache-from/--cache-to(registry/inline). Deliberately excluded from the input hash (a build optimization, not part of the image's identity — changing them never busts the heph cache).oci_pullbase cache — base images cached (local + remote), pulled once.What is in the key, and why
The remote cache key carries no OS or arch segment, so an unpinned platform is a wrong-artifact bug, not a miss:
platformsempty → the builder is asked for its default platform at parse time (docker buildx inspect --bootstrap, memoized per builder) and the answer is hashed. Without it an arm64 laptop and an amd64 runner compute the same key for different images.builderis hashed — which builder runs decides the platforms, the BuildKit version and the layer cache. That is whyBUILDX_BUILDERis stripped from the child environment: an ambient builder would change the image behind the key's back. What is hashed is the name, the same gap thedockerversion itself has.oci_pullalways resolves to a concreteos/arch(orall), never "whatever the host is".Known, deliberate exemptions are documented in the module header: unpinned
FROMbase images, whateverRUNfetches, secret/ssh values, the hostdocker/skopeoversion, and.dockerignore.Design
oci_image/oci_pullare cacheable artifact drivers;oci_push/oci_loadareCacheConfig::off()actions (external side effects, run every time)."") of theirimagetarget — an explicit group selector is rejected rather than honoured.hproc::proc_exec: cleared environment with an explicit passthrough allowlist, explicit cwd, output streamed to the user as it arrives, and the childSIGKILLed on cancellation (a detachedbuildxwould otherwise keep writing into a sandbox the cleaner is deleting, and park runtime shutdown).docker buildx/skopeo(host capability, likehttp_fetch's network). A hermetic toolchain can replace it later without changing targets.plugin-httpManagedDriver template; sandbox/staging/output-collection viaManagedDriverBridge.Tests
86 unit tests (
plugin-oci) — argv assembly, parse → def, hash semantics, andrun()against fake binaries. Notably: cache refs do not affect the input hash while build args / secrets / ssh / builder / platform do; the child environment really is cleared.5 engine e2e against a fake docker (
crates/e2e/tests/oci.rs) — outputs are real artifacts a downstream target consumes, an unchanged context is a cache hit, a cross-package dep reaches the build asSRC_*.10 e2e against the real
dockerandskopeo(crates/e2e/tests/oci_docker.rs) — every assertion is something only a real builder can answer: the archive is the format the driver claims, the digest is a real sha256,COPY ${SRC_BIN}resolves,stagenarrows the build to one stage's DAG (proven by a sibling stage that cannot build), a multi-platform build indexes both architectures,builderworks, and a full multi-arch push → pull →FROM baseround trip against a throwawayregistry:2. They skip rather than fail where the host cannot run them (macOS CI has no daemon);skopeois on the dev shell, which is how CI gets it.Three bugs the real-docker suite caught that the fake could not:
basesnever worked. A dep's unpack list names files, not the directories holding them, so the build context resolved to…/base.oci/oci-layout— a file. EveryFROM <base>build failed.oci_imagetarget can build at all. The plaindockerdriver has no file exporters, so--output type=oci,dest=…andtype=docker,dest=…both fail regardless offormat. BuildKit names the exporter but not the remedy; the driver now supplies it (create adocker-containerbuilder and name it withbuilder =, or enable the containerd image store). Not checked at parse:buildx inspectcannot see the image store, so only the build itself avoids false negatives.oci_loadfailed outright on macOS for a multi-arch archive — skopeo matched the host'sdarwin/arm64against a manifest list that has no such instance.Toolchain requirements (runtime)
Host
docker buildx(build) andskopeo(push/pull, and OCI load). The buildx builder must be able to write an image archive to a file: adocker-containerbuilder, or a daemon with the containerd image store. Multi-platform builds need the same. A hermetic toolchain is a possible follow-up.🤖 Generated with Claude Code
https://claude.ai/code/session_01WqQZxsTqanJRWLWdPSchBo