Files
werkdock/docs/rfcs/0004-docker-compatible-image-creation.md
miandmhoennig 4cf7604e8f Pull (#2)
Co-authored-by: mhoennig <michael@hoennig.de>
Reviewed-on: #2
2026-09-10 19:26:55 +02:00

97 lines
8.3 KiB
Markdown

# RFC 0004: Docker-Compatible Image Creation
**Status:**
- proposed: 2026-09-05
- accepted: -
- rejected: -
**Proposal:** Werkdock images are created the way docker images travel, not the way docker builds them: `import` takes a rootfs archive, `load` takes a `docker save` or OCI image archive and flattens its layers, `pull` (later) fetches the same from a registry. All three end in one image model — a flat rootfs plus the execution-relevant OCI config — and a toolchain is nothing but an image of its own, mounted into the sandbox with docker's `--mount type=image`. That keeps RFC 0003's composition reachable without a werkdock-only surface.
## Context and Problem Statement
On the run side, werkdock follows `docker run --rm` closely (RFC 0002 and the CHANGELOG entries that implemented it).
On the image side it matches only `docker import`: the rootfs archive has to be produced elsewhere, and its ENTRYPOINT/CMD/ENV metadata is lost on the way (`docker export` drops it) and re-typed with `import -c`.
Two requirements pull on the design:
1. Image creation shall become docker-compatible: an image built with a Dockerfile anywhere shall land in the werkdock store with its metadata, by one command.
2. Werkator shall later combine a base image with several *apps* — a JDK, a Go toolchain, a Node runtime, each in its own version — without one fat image per combination (RFC 0003).
Nothing decided now may foreclose that.
## What Docker Offers, and What Fits
Docker creates images by `build` (Dockerfile), `commit` (from a container), `import` (from a rootfs tar), `load` (from `docker save`), and `pull` (from a registry).
Only the last three describe *finished* images, and only they are meaningful without a build daemon:
| Verb | Input | Werkdock |
|---|---|---|
| `import` | flat rootfs tar (`docker export` output) | done; metadata via `-c`, as docker |
| `load` | `docker save` archive or OCI image layout: layers + config | **this RFC** |
| `pull` | the same from a registry over HTTP | done as a wrapper around the `crane` binary: crane writes a `docker save` tarball, `load` flattens it; the registry client stays replaceable behind `werkdock pull` (RFC 0002 level 2) |
| `build` | Dockerfile | not planned: needs writable rootfs snapshots per step and `pull` for FROM; own RFC when a consumer needs it |
| `commit` | a running container | not planned: werkdock instances are ephemeral by design |
Flattening layers at load time is the werkdock equivalent of docker's layered store: apply each layer's tar in order, honour the OCI whiteouts (`.wh.NAME` deletes, `.wh..wh..opq` empties a directory), take `config.json` as the image metadata.
The store keeps no layers, no copy-on-write; the cost is disk per image (README, Disk Footprint), the gain is that bwrap needs nothing but a directory.
## Composition Without Foreclosure
RFC 0003 sketched `--with NAME` over store artifacts called *toolchains*.
Docker has since gained the same thing under its own name: `--mount type=image,source=IMAGE,destination=/prefix[,image-subpath=/sub][,readonly]` (Docker Engine 28) binds an image's filesystem — or a subtree of it — into a container.
That is exactly a read-only bind of an unpacked store directory, which bwrap has always been able to do.
So the composition model becomes:
- A toolchain **is an image**: `golang:1.24` loaded from `docker save`, or a Temurin tarball imported with `import -c 'ENV JAVA_HOME=/usr/local/jdk-21'`.
There is no second artifact kind in the store, and `images`, `inspect`, `rmi` apply to toolchains unchanged.
- A build composes at run time:
```
werkdock run --rm \
--mount type=image,src=golang:1.24,dst=/usr/local/go,image-subpath=/usr/local/go \
--mount type=image,src=eclipse-temurin:21,dst=/opt/java/openjdk,image-subpath=/opt/java/openjdk \
-e PATH=/usr/local/go/bin:/opt/java/openjdk/bin:/usr/bin:/bin \
base:trixie sh -c './gradlew build'
```
The same line works against Docker 28, which is the compatibility goal stated in the README.
- Environment merging (the `PATH`, `JAVA_HOME`, `GOROOT` a toolchain needs) is where docker stops: an image mount carries no Env.
Werkator, which knows what it mounts, passes `-e`; a later werkdock convenience (`--with IMAGE` = image mount at the image's own prefix plus its Env) can be added on top without touching the docker-compatible core.
What this RFC therefore requires of the image model, and what `load` must respect:
- An image stays a flat directory tree; nothing in the store may assume it is only ever mounted at `/`.
- The OCI config is stored with every image, whichever verb created it; `image-subpath` and Env-merging read it later.
- Image references are the docker ones (done), so a `docker save` name round-trips.
## Concrete Proposal
### `werkdock load -i ARCHIVE`
- Accepts the archive `docker save` writes (both the legacy `manifest.json` layout and the OCI layout Docker 25+ writes, which carries `manifest.json` as well) and a plain OCI image layout (`oci-layout` + `index.json`, as skopeo or buildah write it).
- Unpacks the outer archive into the staging directory the way `import` does, then for every manifest: applies the layers in order into `rootfs/` with whiteout handling, maps the config's `architecture`, `os`, and `config` (Env, Entrypoint, Cmd, WorkingDir, Labels) into `image.json`, and names the image after the manifest's `RepoTags` (or the OCI `org.opencontainers.image.ref.name` annotation); `--name` is not needed, the archive knows its name.
- Layer extraction is werkdock's own (Go `archive/tar`), not the tar CLI: whiteouts, a file replacing a directory of a lower layer, and paths that pass through a lower layer's symlink (`/bin → usr/bin`) must be resolved inside the rootfs, never on the host. gzip layers are handled in-process, zstd layers through the `zstd` binary doctor already checks.
- Ownership is dropped (`--no-same-owner` semantics, as `import`): inside the sandbox uid 0 is the calling user anyway.
- Multi-arch OCI indexes pick the manifest matching the host (`linux/amd64` on the webspaces); an archive without a matching manifest is refused with the platforms it does contain.
### Later, in this order
1. `rmi IMAGE` (done, 0.3.0) and `tag SOURCE TARGET` — the store housekeeping composition needs (a tag is a copy; hardlink dedup can follow).
2. `--mount type=image,...` in `run` — the composition itself; small, since a store directory is already what `-v` binds.
3. `save -o ARCHIVE IMAGE` — one-layer `docker save` format from rootfs + config, so a werkdock image can go back to Docker or to another webspace without the original archive.
4. ~~`pull` — RFC 0002 level 2, reusing the flattening.~~ Done (0.3.0), through crane rather than an in-process registry client: the managed webspaces have no package for crane, but it is one static binary shipped next to werkdock, and the token dance, manifest lists, and digest checks it brings would otherwise be a week of stdlib code. An in-process client can replace it behind the same verb once the second binary proves a burden in operation.
### Consequences for Werkator
- The build environment can become a Dockerfile: `docker build`, `docker save`, upload, `werkdock load` — with ENTRYPOINT, ENV, WORKDIR intact, replacing `tools/build-bwrap-rootfs.sh`'s debootstrap-in-Docker dance.
- The apps Werkator wants to combine are docker images (`golang:1.24`, `eclipse-temurin:21`, `node:20`), loaded once each and mounted per build; the base shrinks to what apt must provide (RFC 0003).
## Considered Alternatives
- **A separate toolchain artifact kind with its own manifest** (RFC 0003 sketch): one more store concept, one more verb, and a werkdock-only surface — rejected in favour of "a toolchain is an image", now that docker itself mounts images.
- **Layered store with overlayfs**: the general solution, blocked on bwrap 0.9+ on the target (README); flattening at load time is the cheap path that needs no kernel feature, and dedup can be added later without changing the CLI.
- **`build` first**: the most docker-looking verb, but the least useful one here — builds want a registry for FROM and network-heavy `RUN` steps on a shared webspace, which step 21 deliberately moved off the target.
## Decision Outcome
Pending — `load` and `pull` are implemented as the first steps under this RFC; the composition verbs follow when Werkator's second toolchain combination comes due (the RFC 0003 trigger).