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>
293 lines
8.6 KiB
Go
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))
|
|
}
|
|
}
|