Files
mhoennigandClaude Fable 5 e4bfeacf5a Werkdock skeleton: doctor, load, run over the bwrap engine (Go)
The minimal build-capable CLI decided in RFC 0002's outcome: stdlib-only
Go module, one static binary.

- engine: RunSpec behind the Engine interface (RFC 0001); the Bwrap
  engine ports Werkator's hardened invocation — uid-0 mapping, read-only
  rootfs at /, proc/dev/tmp/root before the user binds so binds below
  them land inside, mountpoint pre-creation in the rootfs including file
  mountpoints, and a guard against binds escaping the rootfs. --clearenv
  gives docker-style clean environments (HOME/PATH set explicitly).
- store: images under $WERKDOCK_HOME (default ~/.werkdock), load
  unpacks via the tar CLI into a tmp dir and renames atomically.
- cli: docker-shaped run flags (-v/-e/-w/--rm); refused docker flags
  (-p, --network, --memory, --cpus, --user, -d) fail loudly with the
  reason; exit codes follow docker (125 CLI errors, child code through).
- doctor: port of werkator-build-prerequisites.sh — userns probe with
  the three signals, tar/zstd, free space and group-quota headroom via
  testable df/quota parsers, same PASS/FAIL output.
- tests: argv golden test, mountpoint and escape tests, flag refusals,
  store round trip, doctor parsers — plus real-sandbox integration
  tests that skip where bwrap or userns are unavailable.

Also records in step 21: RFC 0002 levels 2/3 deferred; next goal is
sandbox builds of Werkator, Werkbaum, and Werkdock itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 07:01:21 +02:00

293 lines
8.6 KiB
Go

// Package doctor checks whether this host can run werkdock sandboxes:
// unprivileged user namespaces with a uid-0 mapping and enforced
// read-only root binds, the required CLI tools, and disk/quota headroom
// for the build footprint. It is a port of Werkator's
// werkator-build-prerequisites.sh, with the same PASS/FAIL output.
package doctor
import (
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
)
// MinFreeKiB is the disk footprint a sandbox build needs headroom for:
// unpacked rootfs (zstd expands roughly 3-4x), toolchain caches, build
// output. ~5 GiB, in KiB.
const MinFreeKiB = 5 * 1024 * 1024
// Runner executes a command and returns its combined output; injected
// so the evaluation logic is testable against captured fixtures.
type Runner func(name string, args ...string) (string, error)
// Report is the outcome of all checks.
type Report struct {
Checks []Check
Warnings []string
}
// Check is one PASS/FAIL line.
type Check struct {
OK bool
Msg string
}
func (r *Report) pass(format string, a ...any) {
r.Checks = append(r.Checks, Check{OK: true, Msg: fmt.Sprintf(format, a...)})
}
func (r *Report) fail(format string, a ...any) {
r.Checks = append(r.Checks, Check{OK: false, Msg: fmt.Sprintf(format, a...)})
}
func (r *Report) warn(format string, a ...any) {
r.Warnings = append(r.Warnings, fmt.Sprintf(format, a...))
}
// OK reports whether no check failed.
func (r *Report) OK() bool {
for _, c := range r.Checks {
if !c.OK {
return false
}
}
return true
}
// Run executes all checks against targetDir (where images and build
// workspaces will live).
func Run(targetDir string, selfUID int, run Runner) *Report {
r := &Report{}
sandboxChecks(r, selfUID, run)
toolChecks(r)
diskChecks(r, targetDir, run)
return r
}
// sandboxProbe is the command run inside the sandbox; its three output
// lines are the signals evaluated below.
const sandboxProbe = "id -u && cat /proc/self/uid_map && (touch /usr/ro-test 2>&1 || true)"
func sandboxChecks(r *Report, selfUID int, run Runner) {
if _, err := exec.LookPath("bwrap"); err != nil {
r.fail("bwrap is not installed on this host")
return
}
version, err := run("bwrap", "--version")
if err != nil {
r.fail("bwrap --version failed: %v", err)
return
}
r.pass("bwrap version: %s", strings.TrimSpace(version))
out, err := run("bwrap",
"--unshare-user", "--unshare-pid", "--die-with-parent",
"--uid", "0", "--gid", "0",
"--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc", "--tmpfs", "/tmp",
"sh", "-c", sandboxProbe)
if err != nil {
r.fail("bwrap invocation failed (no user namespace support?): %s", strings.TrimSpace(out))
return
}
EvaluateSandbox(r, out, selfUID)
}
// EvaluateSandbox checks the three signals of the sandbox probe output:
// uid 0 inside, a uid_map back to the unprivileged user, and an
// enforced read-only root bind.
func EvaluateSandbox(r *Report, output string, selfUID int) {
lines := strings.Split(strings.TrimRight(output, "\n"), "\n")
line := func(i int) string {
if i < len(lines) {
return strings.TrimSpace(lines[i])
}
return ""
}
if line(0) == "0" {
r.pass("build runs as root inside the namespace (uid 0)")
} else {
r.fail("expected uid 0 inside the namespace, got: %s", line(0))
}
mapRe := regexp.MustCompile(`^\s*0\s+` + strconv.Itoa(selfUID) + `\s+1`)
if mapRe.MatchString(line(1)) {
r.pass("uid_map maps root back to the unprivileged user (uid %d)", selfUID)
} else {
r.fail("expected uid_map '0 %d 1', got: %s", selfUID, line(1))
}
if strings.Contains(strings.ToLower(output), "read-only file system") {
r.pass("read-only root bind is enforced")
} else {
r.fail("the read-only root bind did not reject a write to /usr")
}
}
func toolChecks(r *Report) {
if _, err := exec.LookPath("tar"); err != nil {
r.fail("tar is not installed — required to unpack images")
} else {
r.pass("tar is available")
}
if _, err := exec.LookPath("zstd"); err != nil {
r.warn("zstd is not installed — .tar.zst images cannot be unpacked")
}
}
func diskChecks(r *Report, targetDir string, run Runner) {
minGiB := MinFreeKiB / 1024 / 1024
homeFS := ""
if home, err := os.UserHomeDir(); err == nil {
if out, err := run("df", "-Pk", home); err == nil {
homeFS, _, _ = ParseDF(out)
}
}
out, err := run("df", "-Pk", targetDir)
if err != nil {
r.warn("could not measure free space on %s — only the quota check applies", targetDir)
return
}
device, availKiB, mount := ParseDF(out)
if device == "" {
r.warn("could not measure free space on %s — only the quota check applies", targetDir)
} else {
if homeFS != "" && device != homeFS {
r.warn("target dir is on %s (mounted at %s), not the home filesystem (%s) — builds will run on slower storage", device, mount, homeFS)
}
if availKiB < MinFreeKiB {
r.fail("less than %d GiB free space on the build working filesystem (%s)", minGiB, mount)
} else {
r.pass("at least %d GiB free space on the build working filesystem (%s, device %s)", minGiB, mount, device)
}
}
quotaOut, err := run("quota", "-g")
if err != nil || strings.TrimSpace(quotaOut) == "" {
r.warn("no readable group quota tooling on this host — only free space was checked")
return
}
lines := ParseQuota(quotaOut)
if len(lines) == 0 {
r.warn("quota tooling present but no group quota lines could be parsed — only free space was checked")
return
}
ok := true
detail := ""
for _, q := range lines {
// Only the quota of the target filesystem counts — other
// volumes may legitimately be full without affecting builds.
if device != "" && filepath.Base(q.FS) != filepath.Base(device) && q.FS != device {
continue
}
headroom := q.Limit - q.Blocks
if headroom < MinFreeKiB {
ok = false
detail += fmt.Sprintf(" %s: %.1f GiB free of quota;", filepath.Base(q.FS), float64(headroom)/1024/1024)
}
}
if ok {
r.pass("group quota headroom covers the %d GiB build footprint", minGiB)
} else {
r.fail("group quota headroom below the %d GiB build footprint; raise the quota before building.%s", minGiB, detail)
}
}
// ParseDF extracts device, available KiB, and mount point from
// `df -Pk DIR` output.
func ParseDF(output string) (device string, availKiB int64, mount string) {
lines := strings.Split(strings.TrimSpace(output), "\n")
if len(lines) < 2 {
return "", 0, ""
}
fields := strings.Fields(lines[1])
if len(fields) < 6 {
return "", 0, ""
}
avail, err := strconv.ParseInt(fields[3], 10, 64)
if err != nil {
return "", 0, ""
}
return fields[0], avail, fields[5]
}
// QuotaLine is one filesystem's group quota: used blocks and the hard
// limit, both in KiB.
type QuotaLine struct {
FS string
Blocks int64
Limit int64
}
// ParseQuota parses `quota -g` output, including the wrapped form where
// a long device name stands alone on its own line and the numbers
// follow on the next. A '*' suffix on the blocks value (over soft
// quota) is ignored.
func ParseQuota(output string) []QuotaLine {
var result []QuotaLine
pendingFS := ""
for _, raw := range strings.Split(output, "\n") {
fields := strings.Fields(raw)
if len(fields) == 0 {
continue
}
if len(fields) == 1 && strings.HasPrefix(fields[0], "/") {
pendingFS = fields[0]
continue
}
if strings.HasPrefix(fields[0], "/") && len(fields) >= 4 {
if blocks, limit, ok := quotaNumbers(fields[1], fields[3]); ok {
result = append(result, QuotaLine{FS: fields[0], Blocks: blocks, Limit: limit})
pendingFS = ""
}
continue
}
if pendingFS != "" && len(fields) >= 3 {
if blocks, limit, ok := quotaNumbers(fields[0], fields[2]); ok {
result = append(result, QuotaLine{FS: pendingFS, Blocks: blocks, Limit: limit})
pendingFS = ""
}
}
}
return result
}
func quotaNumbers(blocksField, limitField string) (int64, int64, bool) {
blocks, err := strconv.ParseInt(strings.TrimSuffix(blocksField, "*"), 10, 64)
if err != nil {
return 0, 0, false
}
limit, err := strconv.ParseInt(limitField, 10, 64)
if err != nil {
return 0, 0, false
}
return blocks, limit, true
}
// Render writes the report in the PASS/FAIL format of the original
// prerequisites script, ending with a RESULT line.
func (r *Report) Render(w io.Writer) {
for _, c := range r.Checks {
status := "PASS"
if !c.OK {
status = "FAIL"
}
fmt.Fprintf(w, "%s: %s\n", status, c.Msg)
}
for _, warning := range r.Warnings {
fmt.Fprintf(w, "WARNING: %s\n", warning)
}
passed := 0
for _, c := range r.Checks {
if c.OK {
passed++
}
}
fmt.Fprintln(w)
if r.OK() {
fmt.Fprintf(w, "RESULT: PASS (%d/%d) — werkdock sandboxes are usable on this host.\n", passed, len(r.Checks))
} else {
fmt.Fprintf(w, "RESULT: FAIL (%d/%d) — werkdock sandboxes are not usable on this host.\n", passed, len(r.Checks))
}
}