From f3cfa7c358bd9a0edaf4aca891c7b96d42c866db Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Fri, 17 Jul 2026 22:45:40 +0000 Subject: [PATCH] feat(bootstrap): host-class installs box + runs setup-host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A host=yes box exists to run guest boxes, so bootstrap finishes the job instead of printing "next: install the box CLI and run 'box setup-host'". After the role marker is written, on host=yes it installs the box CLI globally and lets box's OWN setup-host build the Incus stack. rig DELEGATES to box; it never touches Incus itself — the same design law `rig users apply` enforces ("rig NEVER installs Incus: box's setup-host owns the daemon and its group"). rig does not apt-install incus, does not configure the daemon, does not create the incus group. It runs box's global installer as root with BOX_YES=1 (non-interactive AND keeps setup-host); box installs Incus. Two tools converging one daemon is drift by construction. - Convergent: box's installer is a no-op once box is installed, so re-running bootstrap changes nothing. - Opt-out: RIG_SKIP_BOX_INSTALL=1 skips; also skips gracefully (with a manual pointer) when curl or the network is missing — box is the host EXTRA, so a failed box install never aborts a bootstrap that otherwise succeeded. - Pinnable: BOX_REPO / BOX_REF (default heavy-duty/box@main). - Runs only AFTER the role marker write, so a box that failed to become what it claims never installs box on a half-built host. The world-readable global install path (box under /opt/box, readable by every non-root user) depends on box PR #71; until it merges box's root install lands in /root. Noted in a comment and the plan doc. Completes rig#12 (the dev role — the Incus claudebox host) and rig#25 (machine classes: host-class installs box + rig users). Tests: 8 new bootstrap checks (guard on host=yes, BOX_YES install, pin defaults, RIG_SKIP_BOX_INSTALL opt-out, negative-grep that rig never apt-installs incus, box-after-marker ordering, manual-pointer on skip). 154 passed, 0 failed; shellcheck -x clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 36 ++-- commands/bootstrap.sh | 66 +++++++- docs/plans/2026-07-17-host-installs-box.md | 186 +++++++++++++++++++++ test/cli.sh | 43 +++++ 4 files changed, 317 insertions(+), 14 deletions(-) create mode 100644 docs/plans/2026-07-17-host-installs-box.md diff --git a/README.md b/README.md index e15f3f7..ec1cd00 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ presets nothing and requires `--hostname` plus all three traits. | trait | values | what it drives | |---------|--------------------|----------------| | `class` | `human`, `server` | root SSH's fate once operators exist — human closes it, server keeps it as the control plane's automation door | -| `host` | `yes`, `no` | whether the box exists to run VMs — the `/dev/kvm` advisory and the `box setup-host` pointer | +| `host` | `yes`, `no` | whether the box exists to run VMs — the `/dev/kvm` advisory and, on `yes`, installing the `box` CLI + running box's `setup-host` | | `join` | `authkey`, `login` | tagged pre-auth key (fleet identity) vs interactive browser login (user-owned device) | | role | class | host | join | tailnet tag | @@ -167,18 +167,32 @@ It is `class=server`: an unattended VM appliance — operators converge it and leave; nobody lives there. Mint its key with `tag:local`: the host and its guests sit on opposite sides of a trust boundary, and the *host* is never managed by the control plane — so the role **refuses an effective -`tag:server`**, same mechanism as `runner`. rig deliberately installs no Incus -and no box here — box's own `setup-host` is the single owner of the Incus -daemon's configuration, and two tools converging one daemon is drift by -construction. The closing log points you at it: install box, run -`box setup-host`, then `box new --template staging`. If `/dev/kvm` is absent, -rig warns (a host that exists to run VMs should have it) but does not fail — -the shape is rehearsed in containers, which legitimately lack it. +`tag:server`**, same mechanism as `runner`. + +On a host-class box (`host=yes`), bootstrap finishes the job instead of leaving +a to-do: after the role marker is written it **installs the `box` CLI globally +and runs box's own `setup-host`**, so the Incus stack is ready for +`box new --template staging` when bootstrap returns. rig **delegates to box; it +never touches Incus itself** — it does not `apt-get install incus`, does not +configure the daemon, does not create the `incus` group. It runs box's global +installer (`curl … | BOX_YES=1 bash`) as root, and box installs Incus via its +`setup-host`; two tools converging one daemon is drift by construction, and box +is the single owner. The step is **convergent** (box's installer is a no-op once +box is present) and **opt-out** (`RIG_SKIP_BOX_INSTALL=1`, plus a graceful skip +with a manual-command pointer when curl or the network is missing — box is the +host *extra*, so a failed box install never aborts a bootstrap that otherwise +succeeded). Source is pinnable with `BOX_REPO` / `BOX_REF` (default +`heavy-duty/box@main`). If `/dev/kvm` is absent, rig warns (a host that exists to +run VMs should have it) but does not fail — the shape is rehearsed in containers, +which legitimately lack it. (The world-readable global install path — box under +`/opt/box` readable by every non-root user — depends on box PR #71; until that +merges box's root install lands in `/root`.) `dev` is `staging`'s human-class sibling — the same VM-hosting, `tag:local` -shape with a person living on it — and `workstation` is the machine at the -keyboard end of all the SSH connections: human-class, `join=login`, entering -the tailnet as *your* device rather than the fleet's. +shape with a person living on it, box CLI installed the same way — and +`workstation` is the machine at the keyboard end of all the SSH connections: +human-class, `join=login`, entering the tailnet as *your* device rather than the +fleet's. ### The identity model diff --git a/commands/bootstrap.sh b/commands/bootstrap.sh index a47bec1..c2197f8 100755 --- a/commands/bootstrap.sh +++ b/commands/bootstrap.sh @@ -475,15 +475,75 @@ else fi rm -f "$MARKER_TMP" +# --- box install (host-class only) ------------------------------------------- +# A host=yes box exists to run guest boxes, so bootstrap finishes the job rather +# than printing a to-do: it installs the box CLI globally and lets box's OWN +# setup-host build the Incus stack. Placed AFTER the role marker write on +# purpose — a box that failed to become what it claims (tag refused, join backed +# out) dies above and never reaches here, so box is never installed on top of a +# half-built host. +# +# rig DELEGATES to box; it never touches Incus itself. This is the same design +# law `rig users apply` enforces — "rig NEVER installs Incus: box's setup-host +# owns the daemon and its group." rig does not apt-install incus, does not +# configure the daemon, does not create the incus group. It runs BOX'S global +# installer as root, and box installs Incus via its setup-host. Two tools +# converging one daemon is drift by construction; box is the single owner. +# +# CONVERGENT: box's installer is a no-op when box is already installed — it says +# so and changes nothing — so re-running bootstrap is safe and cheap. +# +# OPT-OUT: RIG_SKIP_BOX_INSTALL=1 skips the whole step (a container rehearsal +# with no /dev/kvm, an offline box, or a host whose box you manage by hand). The +# step ALSO skips gracefully — with a warning pointing at the manual command — +# when curl is missing or the network is down: bootstrap's core job is OS +# hardening + the tailnet, and box is the host EXTRA, so a failed box install +# must never abort a bootstrap that otherwise fully succeeded. +# +# PIN POINTS: BOX_REPO / BOX_REF override the source (default heavy-duty/box@main). +# BOX_YES=1 makes box's installer non-interactive AND keeps setup-host (so the +# Incus stack is actually built, not just the CLI dropped on PATH). +# +# rig#12's hard constraints hold here: the HOST joined the tailnet above; the +# guest boxes never do (box does not join the tailnet — fine), and there are no +# credentials on the host (box is creds-free — fine). +# +# DEPENDENCY (box#71): the GLOBAL, world-readable install path — box under +# /opt/box with a /usr/local/bin shim that every non-root user can read — depends +# on box PR #71. Until that merges, box's root install lands in /root and non-root +# users cannot reach it, so this step is only fully correct once box#71 is merged. +if [ "$HOST" = "yes" ]; then + BOX_REPO="${BOX_REPO:-heavy-duty/box}" + BOX_REF="${BOX_REF:-main}" + BOX_INSTALL_URL="https://raw.githubusercontent.com/${BOX_REPO}/${BOX_REF}/install.sh" + BOX_MANUAL="curl -fsSL ${BOX_INSTALL_URL} | BOX_YES=1 bash" + if [ "${RIG_SKIP_BOX_INSTALL:-}" = "1" ]; then + log "RIG_SKIP_BOX_INSTALL=1 — skipping box install; to prepare Incus by hand later: ${BOX_MANUAL}" + elif ! command -v curl >/dev/null 2>&1; then + warn "curl not found — skipping box install; once curl is present, prepare Incus with: ${BOX_MANUAL}" + else + log "installing box (${BOX_REPO}@${BOX_REF}) and running its host setup — box owns Incus, not rig" + # BOX_YES=1 in the environment: non-interactive AND keeps setup-host, so box + # builds the Incus stack rather than only dropping the CLI on PATH. Running as + # root, box installs globally (/opt/box + /usr/local/bin). No-op if box is + # already installed, so re-running bootstrap converges instead of reinstalling. + # A curl failure (no network) fails the pipe under pipefail and lands in the + # else — a warning, never an abort: box is the host extra, the OS+tailnet core + # is already done. + if curl -fsSL "$BOX_INSTALL_URL" | BOX_YES=1 bash; then + log "box installed and host set up — mint guest boxes with 'box new'" + else + warn "box install did not complete (no network, or box's installer failed); bootstrap's core work is done. Finish the host by hand: ${BOX_MANUAL}" + fi + fi +fi + log "done — role ${ROLE}, hostname ${TS_HOSTNAME}" if [ "$ROLE" = "control-plane" ]; then log "next: rig coolify install --version " elif [ "$ROLE" = "runner" ]; then log "next: rig runner install --repo --version " fi -if [ "$HOST" = "yes" ]; then - log "next: install the box CLI and run 'box setup-host' to prepare Incus for guest boxes" -fi # Every class gets operators: humans always enter as themselves and elevate via # sudo — a shared root login is unattributable. What differs by class is root # SSH's fate once named users exist. diff --git a/docs/plans/2026-07-17-host-installs-box.md b/docs/plans/2026-07-17-host-installs-box.md new file mode 100644 index 0000000..7d3c548 --- /dev/null +++ b/docs/plans/2026-07-17-host-installs-box.md @@ -0,0 +1,186 @@ +# rig `bootstrap host=yes` installs box + runs setup-host — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** When `rig bootstrap` converges a VM-host machine (trait `host=yes` — +roles `dev`, `staging`, `workstation`, and `custom --host yes`), it should +**install the `box` CLI globally and run box's own `setup-host`** automatically, +instead of printing `next: install the box CLI and run 'box setup-host'`. This +turns a documented to-do into a real, convergent install step, completing rig#12 +(the `dev` role — the Incus claudebox host) and rig#25 (machine classes: the +host class installs box + rig users). + +**Why this shape:** a `host=yes` box exists to run guest boxes; a bootstrap that +hardens the OS and joins the tailnet but then hands the operator a manual +checklist has left the machine half-built. The box CLI *is* the host's reason +for existing, so bootstrap finishes the job. But rig must finish it **the same +way `rig users apply` already assumes it happens** — by delegating to box, never +by touching Incus itself. + +## The design law this respects + +`commands/users-apply.sh` carries the law verbatim: *"rig NEVER installs Incus: +box's setup-host owns the daemon and its group."* Two tools converging one +daemon is drift by construction, and box is the single owner. This change does +**not** weaken that law — it *fulfils* it. rig does not `apt-get install incus`, +does not configure the daemon, does not create the `incus` group. It runs +**box's own global installer** as root; box installs Incus via its `setup-host`. +rig delegates; box owns. + +The symmetry is the point: `users-apply` dies on a `host=yes` box whose `incus` +group is absent, pointing at `box setup-host`. After this change, an ordinary +`rig bootstrap dev` is what *makes that group exist* — so the two commands now +close the loop instead of one pointing at a manual step the other assumed. + +## Architecture + +One block appended to `commands/bootstrap.sh`, after the role-marker write; no +new files, no new flags. It keys off the existing `HOST` trait (so every +`host=yes` shape — preset or `--host yes` override — gets it, and `--host no` +opts a laptop out with zero role logic). The install runs box's raw `install.sh` +piped to `bash` with `BOX_YES=1` in the environment (non-interactive **and** +keeps `setup-host`). Source is pinnable via `BOX_REPO` / `BOX_REF` +(default `heavy-duty/box@main`). + +**Tech Stack:** bash only, shellcheck, existing `ci.yml` (globstar shellcheck + +`bash test/cli.sh`) — no workflow change needed; the new code is a `.sh` file +already covered by both jobs. + +## Non-Goals + +- **No Incus install by rig, no daemon config, no `incus` group creation** — + box's `setup-host` owns all of it. rig runs box's installer; box owns Incus. +- **No guest provisioning** — minting boxes is box's job (`box new`). +- **No tailnet for guests** — rig#12's constraint: the *host* joins the tailnet + (done above in the tailscale block); guest boxes never do. box does not join + the tailnet, so nothing here needs to enforce it. +- **No credentials on the host** — rig#12's constraint: box is creds-free, so + the delegation introduces none. + +## Global Constraints + +- `#!/usr/bin/env bash` + `set -euo pipefail`; log prefix `rig-bootstrap:` via + the existing `log` / `warn` / `die` helpers. +- Runs **only as root**, only after the full bootstrap (root check, sshd + hardening, tailnet join + tag verification, role-marker write) has succeeded — + so a box that failed to become what it claims never installs box. +- **Never aborts the bootstrap.** box is the host *extra*; the OS + tailnet core + is already done and asserted. A missing curl, a dead network, or a box + installer that errors is a `warn` with a manual-command pointer, never a `die`. +- **Convergent:** box's installer is a no-op once box is installed, so a re-run + changes nothing. +- shellcheck-clean exactly as CI runs it (`shopt -s globstar; shellcheck -x + bin/* **/*.sh`); `bash test/cli.sh` green as non-root. +- Keep the diff minimal — no drive-by refactors. + +--- + +## Behavior contract (in file order, `commands/bootstrap.sh`) + +After the role-marker write (`install -m 0644 "$MARKER_TMP" "$MARKER"`), before +the closing `log "done — role …"`: + +1. **Guard:** `if [ "$HOST" = "yes" ]; then` — the exact guard line, no `&&` + (distinguishing it from the `/dev/kvm` advisory `if [ "$HOST" = "yes" ] && …` + up in the guards section). +2. **Pin points:** `BOX_REPO="${BOX_REPO:-heavy-duty/box}"`, + `BOX_REF="${BOX_REF:-main}"`, and a `BOX_INSTALL_URL` built from them; a + `BOX_MANUAL` string (`curl -fsSL … | BOX_YES=1 bash`) reused in every pointer. +3. **Opt-out first:** `RIG_SKIP_BOX_INSTALL=1` → `log` the skip with the manual + pointer, do nothing else. (Rehearsals in containers, offline boxes, + hand-managed hosts.) +4. **No-curl:** `command -v curl` absent → `warn` with the manual pointer, skip. +5. **Install:** `log` the intent (naming the pinned `BOX_REPO@BOX_REF` and that + box owns Incus), then `if curl -fsSL "$BOX_INSTALL_URL" | BOX_YES=1 bash;`: + - success → `log` box installed + host set up, pointer to `box new`; + - failure (no network, installer error — the pipe fails under `pipefail`) → + `warn` with the manual pointer. **Never `die`.** + +`BOX_YES=1` in the *environment* (not a flag) is load-bearing: it makes box's +installer non-interactive **and** keeps `setup-host`, so the Incus stack is +actually built rather than the CLI merely dropped on PATH. Running as root, box +installs globally to `/opt/box` + `/usr/local/bin`. + +The old `if [ "$HOST" = "yes" ]; then log "next: install the box CLI …"` block is +**replaced** by this — the message it printed is now the thing rig does. + +--- + +## The box#71 dependency (ordering / correctness) + +The **global, world-readable** install path — box under `/opt/box` with a +`/usr/local/bin` shim readable by every non-root user — depends on **box PR +#71**. Until #71 merges, box's root install lands in `/root`, and non-root users +(the `dev` box's human operator; any `box`-role rig user) cannot reach it. So +this rig step is **correct once box#71 is merged**; before then it installs box +for root but not for the humans who need it. + +This is a comment in `bootstrap.sh` and is called out in the PR body. The rig +step itself is right and ships now (it is convergent and delegates correctly); +the *effective* multi-user outcome is gated on box#71. No rig code changes when +#71 lands — box's installer changes where it writes. + +--- + +## Test Plan + +### Harness (`bash test/cli.sh`, non-root, network-free) + +The install itself needs root, the network, and a real host — none of which the +harness can fabricate — so, exactly like the effective-tag refusals and the +runner repo guard, the shipped script is proven by grepping its load-bearing +pieces: + +- **Guarded on host=yes** — the exact guard line (`grep -qxE`) belongs to the + box block alone. +- **Runs box's installer non-interactively** — `BOX_YES=1 bash` present. +- **Pinnable, default `heavy-duty/box@main`** — `BOX_REPO:-heavy-duty/box`. +- **Opt-out honored** — `RIG_SKIP_BOX_INSTALL` present. +- **rig never apt-installs incus** — a *negative* grep (`grep -nE 'apt-get + install.* incus'` exits 1 = pass), so the design law cannot silently erode. +- **Ordering** — box install (`BOX_YES=1 bash`) sits *after* the role-marker + write (`install -m 0644 "$MARKER_TMP"`); compare line numbers, same idiom as + the `visudo -c` / `sshd -t` ordering asserts, defaults fail closed. +- **Skip/failure keeps a manual pointer** — `prepare Incus` present. +- Existing bootstrap tests unchanged: `dev` / `staging` parse and refuse + non-root (they reach the root check long before the box block); unknown roles + still exit 2. + +### CI + +Unchanged `ci.yml` covers the edits (globstar `shellcheck -x` + `bash +test/cli.sh`). No workflow change. + +### Rehearsal (manual, out of harness — the effective-state proof) + +Unit tests stop at the script's text; only a real host proves the daemon came +up. On a pristine Debian `host=yes` box (real hardware with `/dev/kvm`, or a +nested-virt VM): + +1. `rig bootstrap dev --hostname dev-rehearsal` with a real single-use + `tag:local` key → hardened sshd drop-in, tailnet join as `dev-rehearsal`, + role marker `host=yes`, then **box installed and `setup-host` run**. +2. Assert **effective** state, not file existence: + - `incus info` returns the daemon's info (setup-host built the stack); + - `getent group incus` exists; + - as a **fresh user added to the `incus` group** (not root): `box templates` + lists templates and `incus list` works — the world-readable path (box#71) + is what makes this succeed for a non-root human. +3. **Convergence:** a second `rig bootstrap dev` run reports box already + installed and changes nothing; `incus info` unchanged. +4. **Opt-out:** `RIG_SKIP_BOX_INSTALL=1 rig bootstrap dev` skips the install and + logs the manual pointer; nothing Incus-related is touched. +5. **box#71 gate:** before box#71 merges, step 2's *fresh incus-group user* + check fails (box lives in `/root`); after it merges, it passes with no rig + change. This is the acceptance line that must be re-run once box#71 lands. + +The `staging` shape is the same box block; rehearsing `dev` exercises it. + +## Notes + +- rig#12's two hard constraints hold and are commented at the step: the host + joins the tailnet (guests never do — box doesn't join, fine); no credentials + on the host (box is creds-free, fine). +- The `users-apply` "absent incus group on host=yes → die pointing at + setup-host" path stays valid as the fallback for the opt-out / failed-install + cases; ordinary bootstrap now makes that group exist, closing the loop. diff --git a/test/cli.sh b/test/cli.sh index 2af3456..ef069e2 100644 --- a/test/cli.sh +++ b/test/cli.sh @@ -93,6 +93,49 @@ check "bootstrap: login verify fails closed on a stalled backend" 0 "" \ # The marker is the traits' ground truth for rig users; assert the write exists. check "bootstrap: role marker write is present" 0 "" \ grep -q "/etc/rig/role" "$ROOT/commands/bootstrap.sh" +# --- host-class box install (issues #12, #25) -------------------------------- +# A host=yes box finishes the job: bootstrap installs the box CLI globally and +# lets box's own setup-host build the Incus stack. The install itself runs as +# root, over the network, against a real host — none of which this harness can +# fabricate — so, exactly like the tag refusals and the runner repo guard, prove +# the shipped script by grepping its load-bearing pieces. +# The step is guarded on host=yes: the exact guard line (no `&&`, unlike the +# /dev/kvm advisory) belongs to the box block alone. The `\$HOST` is a literal +# we grep for in the script — single quotes are the point, as in the db checks. +# shellcheck disable=SC2016 +check "bootstrap: box install is guarded on host=yes" 0 "" \ + grep -qxE 'if \[ "\$HOST" = "yes" \]; then' "$ROOT/commands/bootstrap.sh" +# It runs box's OWN global installer with BOX_YES=1 (non-interactive AND keeps +# setup-host, so box builds Incus rather than only dropping the CLI on PATH). +check "bootstrap: box install runs box's installer non-interactively" 0 "" \ + grep -q "BOX_YES=1 bash" "$ROOT/commands/bootstrap.sh" +# Pin points: BOX_REPO / BOX_REF override the source, default heavy-duty/box@main. +check "bootstrap: box source is pinnable, defaults to heavy-duty/box@main" 0 "" \ + grep -qF 'BOX_REPO:-heavy-duty/box' "$ROOT/commands/bootstrap.sh" +# Opt-out for rehearsals / offline / hand-managed hosts. +check "bootstrap: box install honors RIG_SKIP_BOX_INSTALL opt-out" 0 "" \ + grep -q "RIG_SKIP_BOX_INSTALL" "$ROOT/commands/bootstrap.sh" +# The DESIGN LAW rig users apply also enforces: rig NEVER apt-installs Incus — +# box's setup-host is the single owner of the daemon and its group. A grep that +# finds nothing (exit 1) is the pass; a stray `apt-get install ... incus` would +# make it exit 0 and fail the check, so the law cannot silently erode. +check "bootstrap: rig never apt-installs incus (box owns the daemon)" 1 "" \ + grep -nE 'apt-get install.* incus' "$ROOT/commands/bootstrap.sh" +# Ordering is the safety property: box must be installed only AFTER the role +# marker is written, so a box that failed to become what it claims (tag refused, +# join backed out — all of which die above) never installs box on a half-built +# host. Compare line numbers, same idiom as the visudo/sshd -t ordering asserts. +# Defaults fail closed (marker missing -> huge, box missing -> 0 -> fails). +# $MARKER_TMP is a literal we grep for in the script — single quotes intended. +# shellcheck disable=SC2016 +box_marker_at="$(grep -n 'install -m 0644 "$MARKER_TMP"' "$ROOT/commands/bootstrap.sh" | head -n1 | cut -d: -f1)" +box_install_at="$(grep -n 'BOX_YES=1 bash' "$ROOT/commands/bootstrap.sh" | grep -v 'BOX_MANUAL=' | tail -n1 | cut -d: -f1)" +check "bootstrap: box install runs after the role marker write" \ + 0 "" test "${box_marker_at:-999999}" -lt "${box_install_at:-0}" +# On the skip/failure paths, keep pointing operators at the manual command so a +# host whose box did not install is never left without the next move. +check "bootstrap: box skip/failure keeps a pointer to the manual install" 0 "" \ + grep -q "prepare Incus" "$ROOT/commands/bootstrap.sh" if [ "$(id -u)" -ne 0 ]; then check "bootstrap: refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" workload check "bootstrap: runner role parses, refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" runner -- 2.45.2