docs: document library crate, exec and rootfs cache apis

README gains a Library section covering prepare, the exec options
(envp, bind targets, arch) and the provisioned-rootfs hot-cell flow.
SPEC documents the workspace layout, the library API, the cache
sidecars (.digest, .provisioned) and the updated execution flow.
AGENTS.md scope paths follow the new crates/ layout.
This commit is contained in:
2026-09-21 00:08:49 +02:00
parent b6ddd85525
commit af06264e60
3 changed files with 154 additions and 28 deletions
+83 -14
View File
@@ -1,5 +1,63 @@
# ecr - implementation specification
## Workspace Layout
Cargo workspace with two crates:
- `crates/ecr` — the library (package `ecr`). All functionality lives here:
image resolution, download, extraction, namespaces, mounts, chroot exec,
QEMU VM mode.
- `crates/ecr-cli` — the CLI front-end (package `ecr-cli`, binary `ecr`).
Parses flags, maps them onto the library API, propagates exit codes.
The library must not depend on CLI types (`clap` is a CLI-only dependency).
## Library API
### Rootfs lifecycle (`ecr::rootfs`)
- `RootfsCache::new(dir)` / `RootfsCache::default_dir()` — the image tarball
cache (default `~/.cache/ecr`).
- `cache.prepare(&PrepareRequest)` — parse the image reference, resolve
floating version aliases ("latest", "lts", "edge") to a concrete version
before computing the cache key, run the OCI `:latest` digest freshness
check, download through the cache when needed, and extract into a scratch
directory. Returns a `PreparedRootfs` (TempDir-backed; dropped on drop).
- `cache.prepare_provisioned(&req, provision)` — hot-cell amortization: on a
cache hit with a `.provisioned` sidecar the entry is extracted as-is (no
freshness check, no provisioning); otherwise the rootfs is prepared,
`provision(&PreparedRootfs)` runs once, and `persist` writes the result
back. `no_cache` forces re-download + re-provision.
- `PreparedRootfs::persist()` — packs the current rootfs into its cache
entry (compression chosen from the entry's filename extension: gz, xz,
zstd, plain tar; symlinks stored as links; permissions preserved) and
writes the `.provisioned` sidecar. The entry's contents are replaced:
plain `prepare` also returns the provisioned contents from then on.
### Execution (`ecr::exec`)
- `exec(rootfs, &ExecOptions)` — run a command inside a prepared rootfs in
fresh user/PID/mount/UTS namespaces; returns the exit code (128+signal on
kill). Empty option fields select defaults:
- `arch`: host architecture; a foreign arch requires binfmt_misc.
- `env`: caller-composed envp as `(key, value)` pairs; empty selects
`chroot::default_env`. The host environment is never inherited.
- `binds`: explicit `BindTarget { source, target, read_only }` — host
directory mounted at an absolute in-rootfs mount point; read-only via
overlay, read-write via bind. `..` targets are rejected.
- `dns`: nameservers written to /etc/resolv.conf; empty copies the host
resolver.
- `command`: argv; empty runs the rootfs default shell.
- `working_dir`: explicit in-rootfs cwd; empty picks the first read-write
bind target that exists, then `/root`, then `/`.
### Mount plumbing (`ecr::mount`)
`setup_mounts(rootfs, &[BindTarget])` mounts proc/dev/devpts/sys and applies
the bind targets; `in_rootfs(rootfs, target)` maps in-chroot paths and
rejects escaping targets. Overlay upper/work directories are temp dirs the
caller must keep alive (returned by `setup_mounts`).
## Synopsis
```
@@ -36,11 +94,21 @@ ecr [OPTIONS] <DISTRO[:VERSION]> -- [COMMAND]...
~/.cache/ecr/
├── ubuntu-noble-amd64.tar.gz
├── alpine-latest-x86_64.tar.gz
├── debian-bookworm-amd64.tar.gz
├── oci-docker_io-library_archlinux-latest-amd64.tar.gz
├── oci-docker_io-library_archlinux-latest-amd64.tar.gz.digest
└── ...
```
No metadata files. Tarballs are downloaded once and never redownloaded. Users can delete files manually or use `--no-cache` to fetch fresh.
Sidecar files, never counted as image entries:
- `<entry>.digest` — manifest digest of the last OCI download, used by the
`:latest` freshness check.
- `<entry>.provisioned` — marker written by `PreparedRootfs::persist`;
`prepare_provisioned` treats an entry with this marker as provisioned.
Tarballs are downloaded once and never redownloaded (unless the digest
moves, `--no-cache` is passed, or a provisioned entry is deleted). Users
can delete files manually.
### Config File
@@ -111,20 +179,20 @@ Error: No manifest found for architecture 'riscv64'. Available: amd64, arm64, pp
## Execution Flow
1. Parse CLI arguments
2. Resolve distro/version/arch to image source
The CLI delegates to the library; the namespace-mode flow is:
1. Parse CLI arguments, map flags onto library requests
2. `cache.prepare`: resolve distro/version/arch to image source
3. Check cache for existing tarball
4. If not cached, download tarball (direct or OCI)
5. Create temp directory for extraction
6. Extract tarball to temp directory
7. Create namespaces: user, pid, mount, uts
8. Set up mounts: /proc, /sys (ro), /dev, /dev/pts
5. Extract tarball to a temporary directory
6. `ecr::exec`: create namespaces: user, pid, mount, uts
7. Set up mounts: /proc, /sys (ro), /dev, /dev/pts
8. Apply bind targets: overlays (ro) and bind mounts (rw)
9. Write /etc/resolv.conf with DNS servers
10. Set up overlay mounts for bind paths
11. Set up read-write bind mounts
12. Set environment variables
13. Exec shell or command in chroot
14. On exit, clean up temp directory
10. Set the working directory
11. Exec shell or command in chroot with the composed envp
12. On exit, clean up the temporary directory; propagate the exit code
## Namespace Setup
@@ -300,7 +368,8 @@ dns:
## Environment Variables
Default environment inside chroot:
Default environment inside chroot (`chroot::default_env`, used by the CLI;
library callers compose their own envp):
- HOME=/root
- USER=root