Files
werkdock/README.md
miandmhoennig f9c1ae835d Api socket (#5)
Co-authored-by: mhoennig <michael@hoennig.de>
Reviewed-on: #5
2026-09-10 22:09:58 +02:00

76 lines
9.1 KiB
Markdown

# Werkdock
A docker-like sandbox CLI over `bwrap` — filesystem isolation only.
A dock is the enclosed basin in which ships are built: the dock gate controls what passes, the water outside is shared with the whole harbor.
Accordingly, network, uid, `/proc`, `/dev`, and `/tmp` come from the host by contract; that is what makes Werkdock work without root on a Hostsharing Managed Webspace.
Semantics — docker-compatible as far as the filesystem-only contract allows (see [RFC 0002](docs/rfcs/0002-docker-compatible-surface.md)):
- An *image* is a rootfs archive; an *instance* is an unpacked, writable directory tree and corresponds to a docker container.
- `import` takes a plain rootfs archive, as `docker import` does; `load` takes a `docker save` archive or an OCI image layout, flattens the layers (whiteouts honored, paths resolved inside the rootfs as in a chroot) and keeps the OCI config — so a Dockerfile-built image lands with its ENTRYPOINT, CMD, ENV, and WORKDIR ([RFC 0004](docs/rfcs/0004-docker-compatible-image-creation.md)). `load -i … --name` still routes a rootfs archive to `import`, for callers that predate it. `pull` fetches an image from a registry the same way, with the registry side (auth, manifest lists, blobs) delegated to the [crane](https://github.com/google/go-containerregistry) binary — a single unprivileged executable found in `PATH`, beside `werkdock`, or at `WERKDOCK_CRANE`; it reads `~/.docker/config.json` (`DOCKER_CONFIG`) for credentials as docker does. `images` lists one reference per line by default (what a script matches against) and takes docker's `--format`, with `--format table` giving docker's columns; `rmi IMAGE...` removes images.
- An image carries the execution-relevant OCI metadata (`Entrypoint`, `Cmd`, `Env`, `WorkingDir`, `User`, `Volumes`, `Labels`, architecture, os), set at `import -c 'ENTRYPOINT [...]'` the way `docker import --change` does; `inspect` prints it docker-shaped, and `run` without a command executes `ENTRYPOINT`+`CMD`.
- The caller is the sandbox's only user — an unprivileged user namespace maps exactly one uid. Which one is docker's choice: `--user USER[:GROUP]` (a name from the image's `/etc/passwd`, or a number), else the image's `USER`, else uid 0. Podman's `--userns=keep-id` maps the caller to their own uid. Service images that refuse root (PostgreSQL) run this way ([RFC 0005](docs/rfcs/0005-services-and-testcontainers.md)).
- The image's `VOLUME` paths get an anonymous volume unless a `-v`/`--mount` covers them: a directory of the instance, kept across `stop`/`start`, gone with `rm` — docker's anonymous volumes. `/run` (the target of `/var/run`) is an instance directory as well, so services keep pid files, locks, and sockets there without declaring it and `exec` finds the socket; `/tmp` and `/root` are tmpfs per run.
- Images are named the docker/OCI way, `[registry/]repository[:tag]` (`eclipse-temurin:21`, `ghcr.io/example/build:2026.09`), the tag defaulting to `latest`, so build configurations can use one reference for both.
- The network is the host's; `--network host` says so in docker's words and is accepted as a no-op, every other mode and `-p` are refused with the reason.
- Mounts are written as `-v SRC:DEST[:ro]`, `--tmpfs DEST`, or docker's explicit `--mount type=bind|tmpfs,...`; all three feed one ordered mount list.
- `werkdock run [flags] IMAGE [CMD...]` creates an instance and executes in the sandbox; verbs and flags follow docker, unsupported docker flags fail loudly.
- Instances have docker's lifecycle ([RFC 0005](docs/rfcs/0005-services-and-testcontainers.md) milestone 2): `run -d` starts a service under a small supervisor process and prints the id; `ps [-a]`, `logs [-f]`, `stop [-t]`, `kill`, `wait`, `rm [-f]`, `create`/`start`, `inspect` work on ids, unique prefixes, or `--name`s; `exec INSTANCE CMD` runs a second command in the same sandbox (same rootfs, mounts, uid, `/run`, volumes — not the same pid namespace). Instance state is one directory each under `instances/`: `config.json`, `state.json`, `stdout`, `stderr`, `run/`, `volumes/`. `--rm` foreground runs behave as before and record no logs. The command is pid 1 of its sandbox, as in docker: `stop` reaches it with SIGTERM if it handles the signal, SIGKILL follows after the timeout.
- `werkdock doctor` checks the host: user-namespace capability, disk and quota headroom.
- `werkdock system service` serves the Docker Engine API subset on a unix socket, so a client that speaks docker uses werkdock without knowing it. Testcontainers does: `PostgreSQLContainer` starts, waits, executes, and stops against werkdock ([RFC 0005](docs/rfcs/0005-services-and-testcontainers.md) milestones 3 and 4). The endpoints are the ones measured from a real run ([docs/testcontainers-api.md](docs/testcontainers-api.md)); everything else answers 501 with the reason. How to use it: [docs/testcontainers.md](docs/testcontainers.md).
- Ports are the one deliberate deviation: the sandbox shares the host's network, so werkdock cannot map a port. It picks a free one and tells the service to bind it through the image's own environment variable (`PGPORT` and its kin); an image outside that table keeps its port.
## Disk Footprint
Werkdock's storage model is coarser than Docker's on the image side and cheaper on the instance side:
- An image is a flat, complete directory tree — there are no layers, and nothing is shared between images.
A JDK+Go+Node build image is roughly 2 GiB unpacked, plus its compressed archive (~0.5 GiB) as long as that is kept around.
- An instance costs (almost) nothing: the rootfs is bound read-only into every sandbox, writable are only tmpfs (`/tmp`, `/root`), the instance's own `run/` and `volumes/` directories, and the caller's binds.
Ten parallel runs in one image add zero filesystem copies; what grows per project are its own caches in bound volumes, and per service its data volume until `rm`.
- Consequence: prefer ONE fat image shared by all projects over per-project images.
- Watch out for orphans: consumers that key an unpacked environment by the archive's source path (Werkator's bwrap runtime does) leave the old tree behind on every path change; pruning is manual until `rmi`/`prune` verbs exist.
- Future options that would remove the flat-tree cost, in their own RFCs when they come due: composable toolchain mounts — a slim base plus per-toolchain prefix binds, no overlayfs needed ([RFC 0003](docs/rfcs/0003-composable-toolchain-mounts.md), candidate; docker's `--mount type=image` is the compatible spelling, [RFC 0004](docs/rfcs/0004-docker-compatible-image-creation.md)) — overlayfs layers (the kernel allows it unprivileged in a user namespace since 5.11; the webspaces' bwrap 0.8.0 cannot yet), or hardlink deduplication between image versions in the store (the ostree principle, no root needed).
Changes that matter to consumers of the CLI (Werkator) are recorded in the [CHANGELOG](CHANGELOG.md), with what stays compatible and what to adapt.
## Build and Test
```bash
go test ./... # all tests; sandbox integration tests skip without bwrap/userns
go vet ./... && gofmt -l . # quality gates (gofmt must print nothing)
CGO_ENABLED=0 go build . # one static linux binary, ~3 MB
```
First steps on a host:
```bash
werkdock doctor # can this host run sandboxes?
werkdock import -c 'CMD ["bash"]' rootfs.tar.zst IMAGE # create an image from a rootfs archive (docker import semantics)
werkdock load -i image.tar # load a docker save / OCI archive (docker build … && docker save -o image.tar …)
werkdock pull eclipse-temurin:21 # fetch from a registry (needs the crane binary next to werkdock)
werkdock images --format table # REPOSITORY TAG IMAGE ID CREATED SIZE, as docker prints it
werkdock rmi eclipse-temurin:21 # remove an image (docker rmi)
werkdock inspect IMAGE # its metadata, docker-shaped JSON
werkdock run --rm -v /repo:/repo -w /repo IMAGE sh -c './gradlew build'
werkdock run -d --name pg --user postgres -e POSTGRES_PASSWORD=x -e PGPORT=54329 postgres:16-alpine # a service
werkdock ps; werkdock logs -f pg; werkdock exec pg psql -U postgres -c 'select 1'; werkdock stop pg; werkdock rm pg
```
For Testcontainers, run the socket and point a client at it:
```bash
werkdock system service & # $XDG_RUNTIME_DIR/werkdock.sock
export DOCKER_HOST=unix://$XDG_RUNTIME_DIR/werkdock.sock
export TESTCONTAINERS_RYUK_DISABLED=true
```
Status: bootstrap.
The implementation language is Go, decided in [RFC 0001](docs/rfcs/0001-implementation-language.md).
Werkdock grew in the `werkdock/` subdirectory of the [Werkator](https://github.com/mhoennig/werkator) repository and
moved here once it stood on its own (Werkator plan step 21, session E); the history above the split is preserved.
It stays self-contained: no imports from Werkator code, no coupling to the Werkator build — Werkator consumes the
binary through `PATH` (`bwrap.werkdock`), the way it consumes `git`.
Its own CI is the `.werkator.yml` in this repository; the roadmap that created it is session B of Werkator's
[plan step 21](https://github.com/mhoennig/werkator/blob/main/docs/plan/21-werkdock-extraction-and-webspace-install.md).