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.
167 lines
6.4 KiB
Markdown
167 lines
6.4 KiB
Markdown
# ecr - ephemeral chroot
|
|
|
|

|
|
|
|
Instantly drop into a disposable Linux environment on your host machine.
|
|
No Docker daemon. No VM. No root. Just namespaces.
|
|
|
|
```sh
|
|
ecr debian # interactive Debian shell
|
|
ecr alpine:3.23 # specific Alpine version
|
|
ecr ubuntu:noble # Ubuntu by codename
|
|
ecr fedora # any Docker Hub image
|
|
```
|
|
|
|
## How it works
|
|
|
|
`ecr` pulls a root filesystem (Alpine/Ubuntu direct from their CDNs; everything else from Docker Hub), extracts it into a temporary directory, and execs a shell inside a user + mount + PID + UTS namespace. The process tree is isolated, the rootfs is discarded on exit, and your host is never touched.
|
|
|
|
## Library
|
|
|
|
`ecr` is a cargo workspace: `crates/ecr` is the library crate (package `ecr`), `crates/ecr-cli` the thin command-line front-end. Rust consumers can use the library directly — resolve an image, download it through the content cache, and run commands with a fully caller-controlled environment, bind targets and architecture:
|
|
|
|
```toml
|
|
[dependencies]
|
|
ecr = { git = "https://github.com/…" } # or a path / registry source
|
|
```
|
|
|
|
```rust
|
|
use ecr::{chroot, BindTarget, ExecOptions, PrepareRequest, RootfsCache};
|
|
|
|
// Download alpine through the cache (~/.cache/ecr) and extract to a scratch dir
|
|
let cache = RootfsCache::new(RootfsCache::default_dir()?);
|
|
let rootfs = cache.prepare(&PrepareRequest::new("alpine:3.23"))?;
|
|
|
|
// Caller-composed envp, explicit bind targets, target architecture
|
|
let code = rootfs.exec(&ExecOptions {
|
|
arch: "amd64".into(), // empty = host arch
|
|
binds: vec![BindTarget { // host dir -> in-rootfs mount
|
|
source: "./data".into(),
|
|
target: "/mnt/data".into(),
|
|
read_only: false, // true = overlay (ro)
|
|
}],
|
|
env: chroot::default_env(rootfs.dir()), // or your own envp
|
|
command: vec!["cat".into(), "/etc/os-release".into()],
|
|
..ExecOptions::default()
|
|
})?;
|
|
|
|
rootfs.persist()?; // write the rootfs back into the cache (see below)
|
|
```
|
|
|
|
### Provisioned-rootfs caching (hot cells)
|
|
|
|
Prepare once, provision once, amortize every later run — including runs from other processes sharing the same cache directory:
|
|
|
|
```rust
|
|
let rootfs = cache.prepare_provisioned(&PrepareRequest::new("alpine:3.23"), |rootfs| {
|
|
// runs at most once per cache entry
|
|
rootfs.exec(&ExecOptions {
|
|
command: vec!["apk".into(), "add".into(), "curl".into()],
|
|
env: chroot::default_env(rootfs.dir()),
|
|
..ExecOptions::default()
|
|
})?;
|
|
Ok(())
|
|
})?;
|
|
```
|
|
|
|
On the first call the pristine image is downloaded, the closure provisions it, and the result is persisted into the cache with a `.provisioned` marker. On every subsequent call the entry is hit directly: no download, no provisioning. Note a provisioned entry no longer tracks the upstream image; delete the entry (or pass `no_cache: true`) to re-provision.
|
|
|
|
## Usage
|
|
|
|
```
|
|
ecr [OPTIONS] <DISTRO[:VERSION]> [-- COMMAND...]
|
|
```
|
|
|
|
| Argument | Description |
|
|
|---|---|
|
|
| `ubuntu`, `alpine`, `debian`, `fedora`, … | Distribution to enter |
|
|
| `:version` | Optional version tag — codename, number, or `latest` / `lts` / `edge` |
|
|
| `-- cmd arg…` | Run a command instead of an interactive shell |
|
|
|
|
### Options
|
|
|
|
| Flag | Description |
|
|
|---|---|
|
|
| `--bind <PATH>` | Overlay-mount a directory read-only inside the chroot (default: current directory at `/root/<name>`) |
|
|
| `--bind-rw <PATH>` | Bind-mount a directory read-write at `/mnt/<name>` |
|
|
| `--no-bind` | Skip all directory mounts |
|
|
| `--no-cache` | Force a fresh download, bypassing the cache |
|
|
| `-v, --verbose` | Print diagnostic output (URLs, layer info, extraction steps) |
|
|
| `-a, --arch <ARCH>` | Target architecture (`amd64`, `arm64`, `armhf`, `riscv64`, …) |
|
|
| `--kernel[=PATH]` | Boot with QEMU system emulation. Downloads Alpine's `linux-virt` kernel if no `=PATH` given |
|
|
| `-m, --memory <SIZE>` | Memory for QEMU VM (default: 2G, only with `--kernel`) |
|
|
|
|
## Examples
|
|
|
|
```sh
|
|
# Quick shell in the latest Debian
|
|
ecr debian
|
|
|
|
# Run a one-shot command
|
|
ecr alpine -- sh -c 'apk add curl && curl -s https://example.com'
|
|
|
|
# Work on your project inside Ubuntu — changes are visible on the host
|
|
ecr ubuntu --bind-rw ~/projects/myapp
|
|
|
|
# Native-compile for ARM64 through emulation (requires QEMU binfmt_misc)
|
|
ecr --arch arm64 alpine -- uname -m
|
|
|
|
# Always pull a fresh image
|
|
ecr --no-cache fedora
|
|
|
|
# Boot with QEMU system emulation (auto-downloads default kernel)
|
|
ecr --kernel alpine
|
|
|
|
# Boot with your own kernel (note the `=` — a space would parse the path as the distro)
|
|
ecr --kernel=/boot/vmlinuz ubuntu
|
|
|
|
# Boot with custom memory
|
|
ecr --kernel --memory 4G alpine
|
|
```
|
|
|
|
## QEMU System Mode
|
|
|
|
When `--kernel` is specified, ecr boots the rootfs in a full QEMU virtual machine instead of using namespaces:
|
|
|
|
```sh
|
|
# Auto-download Alpine's linux-virt kernel (recommended)
|
|
ecr --kernel alpine
|
|
|
|
# Use your own kernel
|
|
ecr --kernel=/boot/vmlinuz ubuntu
|
|
```
|
|
|
|
This mode:
|
|
- Creates an uncompressed CPIO initramfs from the rootfs (streamed to disk)
|
|
- Boots QEMU with your kernel (or auto-downloads Alpine's `linux-virt` kernel)
|
|
- Provides full VM isolation
|
|
- Works for any architecture (no binfmt_misc needed)
|
|
- Caches the default kernel in `~/.cache/ecr/`
|
|
|
|
Host bind mounts (`--bind`, `--bind-rw`) are not applied in this mode.
|
|
|
|
Requirements:
|
|
- `qemu-system-<arch>` installed
|
|
- For custom kernels: kernel must have serial console support
|
|
|
|
## Supported distributions
|
|
|
|
| Name | Source | Version examples |
|
|
|---|---|---|
|
|
| `alpine` | Alpine CDN | `latest`, `stable`, `edge`, `3.23`, `3.22` |
|
|
| `ubuntu` | Ubuntu CDN | `latest`, `lts`, `noble`, `24.04` |
|
|
| `debian` `fedora` `arch` `gentoo` | Docker Hub | `latest`, any tag |
|
|
| Any Docker Hub / OCI image | Registry | `org/image:tag`, `ghcr.io/…`, `localhost:5000/…` |
|
|
|
|
## Cache
|
|
|
|
Downloaded images are cached in `~/.cache/ecr/`. For `latest` OCI tags the registry manifest digest is checked on each run — the image is only re-downloaded when it has actually changed. Use `--no-cache` to force a fresh pull regardless. The library's `PreparedRootfs::persist` can replace a cache entry with a provisioned rootfs (see *Provisioned-rootfs caching* above).
|
|
|
|
## Requirements
|
|
|
|
- Linux kernel ≥ 5.1
|
|
- Unprivileged user namespaces enabled (`/proc/sys/kernel/unprivileged_userns_clone` = 1 on some distros)
|
|
- `newuidmap` / `newgidmap` in `$PATH`
|
|
- An entry in `/etc/subuid` and `/etc/subgid` for your user
|
|
- For foreign-arch: QEMU user-mode emulation registered with `binfmt_misc`
|