diff --git a/README.md b/README.md index cdcdd43..d025a35 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ PATH (`/usr/local/bin` when root). Re-run any time to upgrade. ## Commands -### `rig bootstrap ` +### `rig bootstrap ` Run as root on the fresh box (over SSH). Convergent — safe to re-run; a second run changes nothing. @@ -29,6 +29,7 @@ second run changes nothing. rig bootstrap control-plane --hostname my-coolify-box rig bootstrap workload --hostname my-prod-box rig bootstrap runner --hostname my-ci-box +rig bootstrap staging --hostname my-vm-host ``` - `--hostname ` — tailnet hostname (default: the role name) @@ -106,6 +107,19 @@ grant `tag:server` to repo-controlled code." A runner executes that code, and the check turns the worst misconfiguration from a documentation warning into a hard, post-join error. +`staging` is the box that *hosts* staging boxes — Incus VMs minted by the +[`box`](https://github.com/heavy-duty/box) CLI, each converged from inside with +`rig bootstrap workload` and registered in the control plane as its own server. +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 role is rehearsed in containers, which legitimately lack it. + ### `rig coolify install --version ` Control-plane box only. Installs Coolify at exactly the pinned version with diff --git a/bin/rig b/bin/rig index 8301af6..767bfa3 100755 --- a/bin/rig +++ b/bin/rig @@ -8,11 +8,12 @@ usage() { usage: rig [args] commands: - bootstrap [--hostname ] [--ts-tag ] + bootstrap [--hostname ] OS plumbing on a pristine Debian box: hardening, unattended-upgrades, - tailscale join. Prompts for a single-use tailnet pre-auth key - (TS_AUTHKEY env overrides the prompt). Run as root. Role runner - defaults to tag:ci and refuses tag:server. + tailscale join. Prompts for a single-use TAGGED tailnet pre-auth key + (TS_AUTHKEY env overrides the prompt); the key's tags are the tailnet + tag, verified after join. Run as root. Roles runner and staging + refuse tag:server. coolify install --version Pinned Coolify install (AUTOUPDATE=false). Control-plane box only. coolify backup install [options] diff --git a/commands/bootstrap.sh b/commands/bootstrap.sh index 45f8092..6b5c06a 100755 --- a/commands/bootstrap.sh +++ b/commands/bootstrap.sh @@ -13,10 +13,15 @@ die() { printf 'rig-bootstrap: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; } usage() { cat <<'EOF' -usage: rig bootstrap [--hostname ] +usage: rig bootstrap [--hostname ] --hostname system + tailnet hostname (default: the role name) +Role staging is the host for box-minted staging VMs (Incus guests converged +from inside with `rig bootstrap workload`). Mint its key with tag:local: the +host is never managed by the control plane — its guest VMs are — so a staging +host may not carry tag:server. + The tailnet tag is NOT a rig argument. A pre-auth key is minted WITH its tags, so the key is the single source of truth: rig no longer requests a tag it might disagree with. After the box joins, rig reads the tag control actually GRANTED @@ -31,10 +36,10 @@ EOF # --- args (validated before the root check, so errors are testable) --------- ROLE="${1:-}" case "$ROLE" in - control-plane|workload|runner) shift ;; + control-plane|workload|runner|staging) shift ;; -h|--help) usage; exit 0 ;; - "") usage >&2; die "role required (control-plane|workload|runner)" 2 ;; - *) die "unknown role: $ROLE (want control-plane|workload|runner)" 2 ;; + "") usage >&2; die "role required (control-plane|workload|runner|staging)" 2 ;; + *) die "unknown role: $ROLE (want control-plane|workload|runner|staging)" 2 ;; esac TS_HOSTNAME="$ROLE" @@ -71,6 +76,12 @@ if [ -r /etc/os-release ]; then else warn "cannot read /etc/os-release; proceeding anyway" fi +# A staging host exists to run VMs, so no /dev/kvm deserves a loud note — but +# only a note: the role is rehearsed in containers, where /dev/kvm is +# legitimately absent, and rig cannot tell a rehearsal from a misconfigured box. +if [ "$ROLE" = "staging" ] && [ ! -e /dev/kvm ]; then + warn "/dev/kvm is absent — a staging host is expected to run VMs. Harmless in a container rehearsal; on real hardware, enable virtualization (VT-x/AMD-V) in firmware." +fi # The pre-auth key is acquired LATER, in the tailscale block — and only if the # box has not already joined. rig is convergent by contract, so re-running it to @@ -216,6 +227,15 @@ verify_effective_tag() { die "role runner joined with tag:server (effective tags: $(printf '%s' "$tags" | tr '\n' ' ')). The key you used grants tag:server to repo-controlled code; that must never happen. Re-run bootstrap with a key minted for a CI tag (e.g. tag:ci)." fi + # Same policy, staging flavor: a staging HOST is never managed by the control + # plane — its guest VMs are, each registered there as its own server. The + # fleet has already been bitten by a host wrongly carrying tag:server, which + # extends every server grant to a box the control plane does not even know. + # Refused, never warned; rig can DETECT this but not FIX it, so name the repair. + if [ "$ROLE" = "staging" ] && printf '%s\n' "$tags" | grep -qx 'tag:server'; then + die "role staging joined with tag:server (effective tags: $(printf '%s' "$tags" | tr '\n' ' ')). A staging host is never managed by the control plane — its guest VMs are. Re-run bootstrap with a key minted for tag:local." + fi + log "verified effective tailnet tag(s): $(printf '%s' "$tags" | tr '\n' ' ')" } @@ -272,4 +292,6 @@ if [ "$ROLE" = "control-plane" ]; then log "next: rig coolify install --version " elif [ "$ROLE" = "runner" ]; then log "next: rig runner install --repo --version " +elif [ "$ROLE" = "staging" ]; then + log "next: install the box CLI and run 'box setup-host' to prepare Incus, then mint staging boxes with 'box new --template staging'" fi diff --git a/docs/plans/2026-07-17-staging-role.md b/docs/plans/2026-07-17-staging-role.md new file mode 100644 index 0000000..9f42893 --- /dev/null +++ b/docs/plans/2026-07-17-staging-role.md @@ -0,0 +1,216 @@ +# rig `bootstrap staging` 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:** Add a `staging` bootstrap role alongside `control-plane` / `workload` / +`runner` — the host archetype for a machine whose job is to **host staging +VMs**: Incus VMs minted by the `box` CLI (heavy-duty/box) from its `staging` +template, each later converged from *inside* with `rig bootstrap workload` and +registered in the control plane as its own server. + +**Why this shape:** the host and its guests sit on opposite sides of a trust +boundary. The guests are servers the control plane manages; the **host is +not** — it must never carry `tag:server`, and the fleet has already been bitten +once by a host that wrongly did. The role is deliberately minimal host +plumbing: hardening, tailnet join (key minted with `tag:local`), hostname — +and **nothing about Incus or box**. box's own `setup-host` owns Incus +configuration; two tools converging the same daemon is drift by construction, +so rig only *points* at box's installer in its closing log. The guest side +needs zero rig changes — `rig bootstrap workload` already is the staging-box +role. + +**Architecture:** `staging` joins the existing role case in +`commands/bootstrap.sh`; no new files, no new flags. Since issue #16 / PR #20, +the tailnet tag is **not a rig argument** — the pre-auth key carries its tags +and rig asserts on the tag control actually *granted* (`.Self.Tags`), post-join +and on every re-run. So the role's tag policy lands in `verify_effective_tag`, +exactly where the `runner` policy lives: role `staging` **refuses an effective +`tag:server`** (die, exit 1 — a runtime refusal, not a usage error). The +`/dev/kvm` advisory and the box next-step pointer live in the execution path; +argument validation stays pure and root-free. + +**Tech Stack:** bash only, shellcheck, existing `ci.yml` (globstar shellcheck + +`bash test/cli.sh`) — no workflow change needed. + +## Non-Goals + +- **No Incus, no box install** — box's `setup-host` is the single owner of the + Incus daemon's configuration. rig prints a pointer, nothing more. +- **No VM provisioning** — minting boxes is box's job (`box new --template + staging`, companion issue heavy-duty/box#68). +- **No control-plane/Coolify API usage** — guests register themselves via the + existing workload flow. +- **No `dev` role** — a dev-box host role is anticipated (same plumbing, honest + name) but explicitly out of scope here. + +## Global Constraints + +- `#!/usr/bin/env bash` + `set -euo pipefail`; log prefix `rig-bootstrap:` + via the existing `log`/`warn`/`die` helpers. +- Exit codes: `2` = usage/argument error, `1` = runtime refusal. **All argument + validation runs BEFORE the root check** so error paths are testable as + non-root. +- The tag policy asserts the **effective** tag, never a requested one — there + is no `--ts-tag` to refuse anymore (it died in PR #20; passing it exits 2 + with a pointer at the key). The issue's original "refuse `--ts-tag + tag:server`" acceptance is therefore satisfied at the stronger, post-join + layer, same as `runner`. +- `/dev/kvm` absence is a **warning, not a failure** — the role is rehearsed in + containers where `/dev/kvm` legitimately isn't there. +- Convergent: a second run changes nothing and exits 0. +- 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. (One deliberate exception: + `bin/rig`'s bootstrap usage line still advertises the removed `--ts-tag` + flag and the old `tag:ci` default — stale since PR #20. It gets corrected in + the same breath as adding `staging` to the role list, because shipping a new + role into a help text that lies about the flag surface would be worse than + the drive-by.) + +--- + +### Task 1: role wiring in `commands/bootstrap.sh` + dispatcher usage + tests + +**Files:** +- Modify: `commands/bootstrap.sh` (role case, effective-tag refusal, `/dev/kvm` + advisory, closing next-step log, usage heredoc) +- Modify: `bin/rig` (bootstrap usage line: role list + stale-flag correction) +- Modify: `test/cli.sh` (bootstrap section additions) + +**Behavior contract, in file order:** + +1. Usage heredoc: role list becomes ``; + one added sentence: staging hosts box-minted staging VMs, its key should be + minted with `tag:local`, and it refuses `tag:server` — the host is never + managed by the control plane; its guest VMs are. +2. Role case arm: `control-plane|workload|runner|staging) shift ;;` and both + error messages (`role required`, `unknown role`) name the four roles. +3. `verify_effective_tag`: after the `runner` refusal, the `staging` one — same + shape (`grep -qx 'tag:server'` against the effective tags), message + `role staging joined with tag:server ...` naming the repair (mint a + `tag:local` key), rationale comment: hosts are never managed by the control + plane, their guest VMs are; the fleet has been bitten by a host wrongly + carrying `tag:server`. `die` with default status → exit 1. +4. Guards section (execution path, after the root check): when role is + `staging` and `/dev/kvm` is absent, `warn` — the host exists to run VMs, but + a container rehearsal legitimately has no `/dev/kvm`, so this must not fail. +5. Closing log: `staging` branch pointing at the box CLI — install box, run + `box setup-host` to prepare Incus, then `box new --template staging`. +6. `bin/rig` usage: `bootstrap + [--hostname ]`; drop the stale `[--ts-tag ]` and + `tag:ci`-default sentence; say the tag comes from the pre-auth key and that + roles `runner` and `staging` refuse `tag:server`. + +- [ ] **Step 1: Append failing tests** + +In `test/cli.sh`, bootstrap section: + +```bash +check "bootstrap: staging + removed --ts-tag exits 2" 2 "comes from the pre-auth key" \ + "$ROOT/commands/bootstrap.sh" staging --ts-tag tag:server +# The staging tag:server refusal rides the EFFECTIVE tag, inside +# verify_effective_tag — a path that needs a real tailnet, so it belongs to the +# rehearsal. What the harness CAN prove is that the refusal exists in the shipped +# script: grep the die message, so a deleted guard cannot ship green (the same +# reason the runner-install repo guard is grepped below). +check "bootstrap: staging effective-tag refusal is present" 0 "" \ + grep -q "role staging joined with tag:server" "$ROOT/commands/bootstrap.sh" +``` + +and in the existing non-root block: + +```bash +check "bootstrap: staging role parses, refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" staging +``` + +The existing `unknown role exits 2` (potato) check already covers the +still-fails-usage path and stays untouched. + +- [ ] **Step 2: Run tests to verify the new ones fail** + +Run: `bash test/cli.sh` +Expected: the staging checks FAIL (`unknown role: staging` → wrong exit/output +for the first and third; missing die message for the grep); everything existing +stays green; harness exits 1. + +- [ ] **Step 3: Implement the role** + +Per the behavior contract above. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bash test/cli.sh` +Expected: all checks pass, exit 0. + +- [ ] **Step 5: shellcheck + syntax** + +Run: `shopt -s globstar; shellcheck -x bin/* **/*.sh` (exactly CI's +invocation) and `bash -n` on each edited script. +Expected: exit 0, no findings. + +- [ ] **Step 6: Commit** + +```bash +git add commands/bootstrap.sh bin/rig test/cli.sh +git commit -m "feat(bootstrap): staging role — the host archetype for box-minted staging VMs" +``` + +--- + +### Task 2: README roles documentation + +**Files:** +- Modify: `README.md` (bootstrap section: heading role list, example block, the + roles paragraph) + +- [ ] **Step 1: Write it** + +Content requirements (in the README's existing voice): + +- Heading/example gain `staging` (`rig bootstrap staging --hostname my-vm-host`). +- One honest paragraph in the roles discussion: `staging` is the box that + *hosts* staging boxes — Incus VMs minted by the `box` CLI, each converged + from inside with `rig bootstrap workload` and registered in the control plane + as its own server. Mint its key with `tag:local`; the role **refuses an + effective `tag:server`** — the host is never managed by the control plane, + its guests are. rig deliberately installs no Incus and no box (box's + `setup-host` owns that); it points there when done. + +- [ ] **Step 2: Full local gate** + +Run: CI's shellcheck invocation + `bash test/cli.sh`. +Expected: silent shellcheck; all tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add README.md +git commit -m "docs: README section for the staging bootstrap role" +``` + +--- + +## Test Plan + +- **Harness (`bash test/cli.sh`, non-root, network-free):** staging parses and + reaches the root check (exit 1 `must run as root`); staging + the removed + `--ts-tag` dies at arg validation (exit 2, message points at the key — + proving validation precedes the root check); the effective-tag refusal + message is present in the script; unknown roles still exit 2. +- **CI:** unchanged `ci.yml` covers the edits (globstar shellcheck + harness). +- **Rehearsal (manual, out of harness):** pristine Debian box → `rig bootstrap + staging` with a real single-use `tag:local` key → hardened sshd drop-in, + tailnet join, hostname `staging`, `/dev/kvm` warning absent on real hardware, + closing log points at box; second run is a no-op. A `tag:server` key must + die post-join with the staging refusal. + +## Addendum (2026-07-17, written before implementation) + +Issue #22 predates the merge of PR #20 (issue #16: the tag comes from the key). +Its acceptance criterion "`rig bootstrap staging --ts-tag tag:server` exits 1 +with a refusal, before the root check" names a flag that no longer exists — +`--ts-tag` now dies (exit 2) for every role, before the root check, pointing at +the key. The staging `tag:server` policy therefore lands where the runner's +did: on the **effective** tag in `verify_effective_tag`, exit 1, which is the +strictly stronger check (it guards the tag the key actually granted, not the +one rig hoped for). This plan is the up-to-date statement of the work. diff --git a/test/cli.sh b/test/cli.sh index 6571de0..15aeae3 100644 --- a/test/cli.sh +++ b/test/cli.sh @@ -47,9 +47,19 @@ check "bootstrap: --ts-tag is removed (with value), exit 2" 2 "comes from the pr "$ROOT/commands/bootstrap.sh" runner --ts-tag tag:server check "bootstrap: --ts-tag is removed (no value), exit 2" 2 "comes from the pre-auth key" \ "$ROOT/commands/bootstrap.sh" runner --ts-tag +check "bootstrap: staging + removed --ts-tag exits 2" 2 "comes from the pre-auth key" \ + "$ROOT/commands/bootstrap.sh" staging --ts-tag tag:server +# The staging tag:server refusal rides the EFFECTIVE tag, inside +# verify_effective_tag — a path that needs a real tailnet, so it belongs to the +# rehearsal. What the harness CAN prove is that the refusal exists in the +# shipped script: grep the die message, so a deleted guard cannot ship green +# (the same reason the runner-install repo guard is grepped below). +check "bootstrap: staging effective-tag refusal is present" 0 "" \ + grep -q "role staging joined with tag:server" "$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 + check "bootstrap: staging role parses, refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" staging else echo "skip: bootstrap non-root refusals (running as root)" fi