diff --git a/README.md b/README.md index ec1cd00..efd0361 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A CLI that turns a **pristine Debian server into a hardened, tailnet-joined node** — one curl, one command. A second command installs a version-pinned Coolify on a control-plane box. -Philosophy (shared with [claudebox](https://github.com/heavy-duty/claudebox)): +Philosophy (shared with [box](https://github.com/heavy-duty/box)): **public tool, private state**. rig carries plumbing logic only — no hostnames, no bindings, no secrets, nothing about *your* infrastructure. It takes arguments, does its work, and stores no credential, ever. @@ -188,6 +188,18 @@ which legitimately lack it. (The world-readable global install path — box unde `/opt/box` readable by every non-root user — depends on box PR #71; until that merges box's root install lands in `/root`.) +> **The box install is unpinned — on purpose, and out loud.** `coolify install` +> demands a version pin; the box step tracks a moving `heavy-duty/box@main`. +> Not because box self-updates (it doesn't — it has Coolify's shape, not the +> runner's) but because there is nothing to pin *to*: box cuts no tags and no +> releases, and its installer resolves `refs/heads/` — branches only — so +> a `BOX_REF=v0.5.0` would 404 even if the tag existed. Issue #12's call was +> that silently tracking `main` on the box that runs the agents is the option +> not to pick — hence this paragraph. `BOX_REPO` / `BOX_REF` are the pin +> points the day box cuts a tag (or you point at a frozen branch of your own +> fork); `RIG_SKIP_BOX_INSTALL=1` opts out entirely for a host whose box you +> manage by hand. + `dev` is `staging`'s human-class sibling — the same VM-hosting, `tag:local` 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: @@ -203,6 +215,28 @@ root login is unattributable by construction. `rig users apply` puts named operators on every box, server-class included; a human always enters as themself and elevates via sudo. +Per role, the whole identity picture at a glance — issue #25's class +comparison, translated onto the traits that replaced the class binary: + +| role | class | host | join | who lives here | root SSH after `rig users apply` | +|-----------------|--------|------|---------|--------------------------------------|----------------------------------| +| `control-plane` | server | no | authkey | nobody — Coolify runs here | open — the automation door | +| `workload` | server | no | authkey | nobody — deployed services run here | open — the automation door | +| `runner` | server | no | authkey | nobody — CI jobs as `github-runner` | open — the automation door | +| `staging` | server | yes | authkey | nobody — an unattended VM appliance | open — the automation door | +| `dev` | human | yes | authkey | operators, minting boxes | closed by `rig users close-root` | +| `workstation` | human | yes | login | its owner | closed by `rig users close-root` | + +Who installs what, and who runs as what: **bootstrap is always root** and +installs everything a role needs — on `host=yes` that includes the box CLI +(globally) and box's own `setup-host`. **Humans always run as themselves**: +operators land via `rig users apply` on every class and elevate through sudo +(roles `admin`/`rig`) or the `incus` group (role `box`) — never by logging in +as root. **Machine identities stay machine-shaped**: Coolify's automation +SSHes in as root (that is what server-class root *is*), CI jobs run as the +unprivileged `github-runner`, and guest VMs are their own server-class boxes, +converged from inside by `rig bootstrap workload`. + **`class` decides root SSH's fate — after `rig users apply`, never before.** On `class=human`, root SSH closes entirely (`rig users close-root`, below). On `class=server` it stays open — key-only, as bootstrap left it — because diff --git a/commands/bootstrap.sh b/commands/bootstrap.sh index c2197f8..0146f04 100755 --- a/commands/bootstrap.sh +++ b/commands/bootstrap.sh @@ -531,7 +531,30 @@ if [ "$HOST" = "yes" ]; then # 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'" + # Don't trust the exit code — prove the effective state (issue #12). An + # installer can exit 0 having done less than it claims: box's setup-host + # is written for a sudo-capable user, and one of its paths exits 0 after + # only adding a group, asking for a re-login. That is the sshd first-wins + # bug's exact shape — asserting what was REQUESTED (here: the installer's + # claimed success) instead of what actually TOOK. + # Two proofs, one claim each. `command -v box` proves the CLI landed + # (box's root install symlinks into /usr/local/bin, already on this + # shell's PATH — no login shell needed). "Host set up" is a separate + # claim and gets box's OWN effective-state verdict, `box doctor` — + # the daemon, the pool, the network stay box's domain (the same + # delegation law as the install itself: box owns the daemon), rig + # just refuses to claim what that verdict does not answer. Both + # failures WARN, never die: box is the host EXTRA, and the OS+tailnet + # core above is already done and asserted. + if command -v box >/dev/null 2>&1; then + if box doctor >/dev/null 2>&1; then + log "box installed and host set up — 'box doctor' passed; mint guest boxes with 'box new'" + else + warn "box is on PATH but 'box doctor' does not pass — the CLI landed, the host stack is unproven. Run 'box doctor' for the verdict, then 'box setup-host' (or finish by hand: ${BOX_MANUAL})" + fi + else + warn "box's installer reported success but no 'box' is on PATH — the install did not take effect. Finish the host by hand: ${BOX_MANUAL}" + fi 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 diff --git a/commands/coolify-backup-install.sh b/commands/coolify-backup-install.sh index 4f9a64b..a656472 100755 --- a/commands/coolify-backup-install.sh +++ b/commands/coolify-backup-install.sh @@ -7,6 +7,10 @@ # already-filled bindings file is left untouched. set -euo pipefail +HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)" +# shellcheck source=SCRIPTDIR/lib/users-config.sh +. "$HERE/lib/users-config.sh" # read_role_marker — the traits line bootstrap wrote + log() { printf 'rig-coolify-backup: %s\n' "$*"; } warn() { printf 'rig-coolify-backup: WARNING: %s\n' "$*" >&2; } die() { printf 'rig-coolify-backup: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; } @@ -67,6 +71,18 @@ done [ -n "$PG_USER" ] || die "--pg-user must not be empty" 2 [ -n "$PG_DB" ] || die "--pg-db must not be empty" 2 +# --- role-marker sanity (issue #25) ------------------------------------------ +# Same advisory check as `rig coolify install`, same reasoning: this command +# dumps the CONTROL PLANE's database, so a marker naming any other role almost +# certainly means the wrong SSH session — but the marker is advisory and may be +# absent, so WARN, never die, and warn before the root check so the harness can +# prove it non-root (RIG_ROLE_MARKER points it at fixtures, repo precedent). +MARKER_LINE="$(read_role_marker "${RIG_ROLE_MARKER:-/etc/rig/role}")" +case "$MARKER_LINE" in + ""|"role=control-plane"|"role=control-plane "*) ;; + *) warn "this box's role marker says '${MARKER_LINE}' — not a control-plane box. The nightly dump targets Coolify's own database, which lives on role control-plane; if this is the wrong box, stop here and re-check your SSH session." ;; +esac + # --- guards ---------------------------------------------------------------- [ "$(id -u)" -eq 0 ] || die "must run as root" if [ -r /etc/os-release ]; then diff --git a/commands/coolify-install.sh b/commands/coolify-install.sh index 9c18cdd..888e838 100755 --- a/commands/coolify-install.sh +++ b/commands/coolify-install.sh @@ -4,8 +4,13 @@ # explicit act. set -euo pipefail -log() { printf 'rig-coolify: %s\n' "$*"; } -die() { printf 'rig-coolify: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; } +HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)" +# shellcheck source=SCRIPTDIR/lib/users-config.sh +. "$HERE/lib/users-config.sh" # read_role_marker — the traits line bootstrap wrote + +log() { printf 'rig-coolify: %s\n' "$*"; } +warn() { printf 'rig-coolify: WARNING: %s\n' "$*" >&2; } +die() { printf 'rig-coolify: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; } usage() { cat <<'EOF' @@ -32,6 +37,26 @@ if [ -z "$VERSION" ]; then die "--version is required" 2 fi +# --- role-marker sanity (issue #25) ------------------------------------------ +# Coolify belongs on the control-plane box and nowhere else — but the marker is +# ADVISORY, never a gate. It may legitimately be absent (a box bootstrapped +# before rig wrote markers, or a hand-built one), and rig refuses to guess from +# silence. When the marker EXISTS and names another role, the likeliest story +# is an operator in the wrong SSH session about to put a control plane on a +# workload box — so say it loudly. But WARN, never die: the operator may also +# be deliberately repurposing the box, and an advisory file must never outrank +# the human running the command (contrast close-root, where the marker IS the +# gate — shutting the root door blind is irreversible in a way an extra +# Coolify is not). Placed BEFORE the root check for the same reason arg errors +# are: the harness proves it non-root, and reading a 0644 file needs no +# privilege. RIG_ROLE_MARKER overrides the path so tests point it at fixtures +# (repo precedent: users-apply, users-close-root). +MARKER_LINE="$(read_role_marker "${RIG_ROLE_MARKER:-/etc/rig/role}")" +case "$MARKER_LINE" in + ""|"role=control-plane"|"role=control-plane "*) ;; + *) warn "this box's role marker says '${MARKER_LINE}' — not a control-plane box. Coolify belongs on role control-plane; if this is the wrong box, stop here and re-check your SSH session. Repurposing it on purpose? Re-run 'rig bootstrap control-plane' first so the marker tells the truth." ;; +esac + [ "$(id -u)" -eq 0 ] || die "must run as root" export AUTOUPDATE=false diff --git a/docs/plans/2026-07-18-class-model-remnants.md b/docs/plans/2026-07-18-class-model-remnants.md new file mode 100644 index 0000000..120ce68 --- /dev/null +++ b/docs/plans/2026-07-18-class-model-remnants.md @@ -0,0 +1,62 @@ +# Class-model remnants — closing issues #12 and #25 + +**Goal:** finish the four remnants that keep rig#12 (the `dev` role) and rig#25 +(machine classes) open. The bulk of both issues already landed on `main` via +PR #27 (traits model: role→class/host/join map, the `dev` and `workstation` +roles, `/etc/rig/role`, the effective-tag refusals, `rig users`) and PR #28 +(host-class bootstrap installs box + runs its `setup-host`). What remains is +documentation debt and two small behaviors both issues explicitly asked for. + +## Tasks + +- [x] **README: `claudebox` → `box`** (#12 comment, item 1). The philosophy + line still links `heavy-duty/claudebox`; the repo renamed to + `heavy-duty/box`. The redirect works today and is one squatted rename away + from not working. Negative-grep test pins the stale slug out. +- [x] **README: the per-role identity table** (#25, item 4). *The identity + model* section carries the prose but not #25's at-a-glance class comparison. + Add a compact table translated onto the current traits (class/host/join per + role, who lives there, root SSH's fate) plus a who-installs-what / + who-runs-as-what paragraph. No wholesale rewrite of the section. +- [x] **README: say out loud that the box install is unpinned** (#12 comment, + item 5). box has no tags/releases and its installer resolves `refs/heads` + only, so host-class bootstrap can only track a moving `heavy-duty/box@main`. + The issue's decision: install `main` **and the README says so and why** — + `BOX_REPO`/`BOX_REF` as the pin points, `RIG_SKIP_BOX_INSTALL=1` as the + opt-out. +- [x] **bootstrap: verify the box install took effect** (#12 comment, item 3 — + "rig trusting an exit code instead of checking effective state"). After the + installer claims success, prove `command -v box` resolves; a hollow success + WARNS (box is the host extra — never fatal) with the manual pointer. +- [x] **coolify verbs: role-marker sanity warnings** (#25, item 3's named + consumer — "`rig ` sanity warnings later (e.g. `coolify install` on a + non-control-plane box)"). Both `coolify install` and `coolify backup + install` read `/etc/rig/role` via the lib's `read_role_marker` and warn when + it names a non-control-plane role. +- [x] **Tests** in `test/cli.sh`, existing patterns only: live marker-warning + checks through `RIG_ROLE_MARKER` fixtures (non-root), grep-the-shipped-script + guards for the root-gated paths, line-number ordering assert for the + effective check, fail-closed defaults. + +## Behavior contract + +- **bootstrap, box block** (`commands/bootstrap.sh`): on the installer-success + path, `command -v box` decides the message — found → success log naming + `box new` and `box doctor` (box's own effective-state verdict; rig never + interrogates Incus, so the deeper verification is delegated, not + reimplemented); absent → `warn` with the `BOX_MANUAL` pointer. Never `die` + in either branch; exit codes and all skip/failure paths unchanged. +- **coolify install / coolify backup install**: the marker check is + ADVISORY — absent marker or `role=control-plane …` stays silent; any other + marker line warns and proceeds. It runs after arg validation and **before** + the root check (testable non-root; a 0644 file needs no privilege), reads + the path from `RIG_ROLE_MARKER` (default `/etc/rig/role`), and never + changes an exit code: usage errors stay 2, the root refusal stays 1. +- **README**: content-only edits; no command semantics described differently + from what ships. + +## Non-goals + +- No new roles, traits, flags, or marker consumers beyond the two coolify + verbs. No changes to box. No gating (warn-only) — `close-root` remains the + only command the marker can refuse. diff --git a/test/cli.sh b/test/cli.sh index ef069e2..877cfff 100644 --- a/test/cli.sh +++ b/test/cli.sh @@ -136,6 +136,45 @@ check "bootstrap: box install runs after the role marker write" \ # 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" +# "Don't trust exit codes" (#12): box's installer can exit 0 having done less +# than it claims (its setup-host has a path that exits 0 after only adding a +# group, asking for a re-login). After a claimed success bootstrap must prove +# the one artifact it asked for — box on PATH — and a hollow success WARNS, +# never dies: box is the host extra. Exercising it needs root + the network, +# so grep the shipped script (repo precedent: the tag-refusal greps). Match +# the CALL, not the word — the rationale comment says `command -v box` too. +check "bootstrap: a box-install success is verified, not trusted" 0 "" \ + grep -qE '^[[:space:]]*if command -v box' "$ROOT/commands/bootstrap.sh" +check "bootstrap: a hollow box-install success warns, never dies" 0 "" \ + grep -q "reported success but no 'box' is on PATH" "$ROOT/commands/bootstrap.sh" +# Ordering: the effective check must sit AFTER the installer run it verifies. +# Line-number compare, defaults fail closed (same idiom as the marker/install +# ordering assert above; box_install_at is computed there). +box_check_at="$(grep -nE '^[[:space:]]*if command -v box' "$ROOT/commands/bootstrap.sh" | head -n1 | cut -d: -f1)" +check "bootstrap: the effective check follows the installer run" \ + 0 "" test "${box_install_at:-999999}" -lt "${box_check_at:-0}" +# rig's delegation law caps the check's depth: rig never interrogates Incus — +# the host verdict is box's own verb, and the "host set up" CLAIM is gated on +# it. Two asserts: the gate exists as a call (not just prose naming the verb), +# and the claim line sits inside/after it (line order, fail-closed defaults — +# a claim that outruns its proof is exactly the overclaim this closes). +check "bootstrap: the host-set-up claim is gated on box doctor" 0 "" \ + grep -qE '^[[:space:]]*if box doctor' "$ROOT/commands/bootstrap.sh" +doctor_at="$(grep -nE '^[[:space:]]*if box doctor' "$ROOT/commands/bootstrap.sh" | head -n1 | cut -d: -f1)" +claim_at="$(grep -n 'box installed and host set up' "$ROOT/commands/bootstrap.sh" | head -n1 | cut -d: -f1)" +check "bootstrap: the claim follows the doctor gate" \ + 0 "" test "${doctor_at:-999999}" -lt "${claim_at:-0}" +check "bootstrap: a failed doctor warns without claiming the host" 0 "" \ + grep -q "the CLI landed, the host stack is unproven" "$ROOT/commands/bootstrap.sh" +# --- README: the box rename (#12) -------------------------------------------- +# The philosophy line must point at heavy-duty/box — the old claudebox slug +# only works through a GitHub redirect that one squatted rename away from +# breaking (box's own installer was already bitten by the rename once). A +# negative grep (exit 1 = pass) keeps the stale slug from creeping back. +check "README: no stale heavy-duty/claudebox links" 1 "" \ + grep -n "heavy-duty/claudebox" "$ROOT/README.md" +check "README: points at heavy-duty/box" 0 "" \ + grep -q "github.com/heavy-duty/box" "$ROOT/README.md" 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 @@ -170,6 +209,57 @@ else echo "skip: coolify backup non-root refusal (running as root)" fi +# --- role-marker sanity: coolify verbs off the control plane (#25) ----------- +# Both coolify commands read /etc/rig/role and WARN — never die — when the +# marker names a non-control-plane role: the likeliest story is the wrong SSH +# session, but the marker is advisory and must not outrank the operator. The +# warning fires BEFORE the root check (same testability rule as arg errors), +# so a non-root run prints it and then hits the root refusal — provable here +# with RIG_ROLE_MARKER pointed at fixtures (repo precedent: the close-root +# marker gate). Counting fires proves silence too: a control-plane marker, an +# absent marker, and a marker-less box must all stay quiet, because warning on +# absence would nag every pre-marker box on every legitimate run. +marker_warns() { # marker_warns — how many warnings fired + local marker="$1"; shift + env RIG_ROLE_MARKER="$marker" "$@" 2>&1 | grep -c "not a control-plane box" || true +} +MARKER_FIX="$(mktemp -d)" +printf 'role=workload class=server host=no join=authkey\n' > "$MARKER_FIX/workload" +printf 'role=control-plane class=server host=no join=authkey\n' > "$MARKER_FIX/control-plane" +printf 'role=control-plane\n' > "$MARKER_FIX/bare-control-plane" +if [ "$(id -u)" -ne 0 ]; then + check "coolify: warns on a non-control-plane marker" 0 "1" \ + marker_warns "$MARKER_FIX/workload" "$ROOT/commands/coolify-install.sh" --version 4.1.2 + check "coolify: control-plane marker stays silent" 0 "0" \ + marker_warns "$MARKER_FIX/control-plane" "$ROOT/commands/coolify-install.sh" --version 4.1.2 + # A bare marker line with no trailing traits must read the same as the full + # one — the guard must not couple to the marker's field formatting. + check "coolify: a bare 'role=control-plane' line (no traits) stays silent" 0 "0" \ + marker_warns "$MARKER_FIX/bare-control-plane" "$ROOT/commands/coolify-install.sh" --version 4.1.2 + check "coolify: absent marker stays silent (advisory, not a gate)" 0 "0" \ + marker_warns "$MARKER_FIX/absent" "$ROOT/commands/coolify-install.sh" --version 4.1.2 + # The warning must stay a warning: the run proceeds past it and stops at the + # root check (exit 1), never turned into a marker refusal. + check "coolify: the marker warns but never refuses" 1 "must run as root" \ + env RIG_ROLE_MARKER="$MARKER_FIX/workload" "$ROOT/commands/coolify-install.sh" --version 4.1.2 + check "coolify backup: warns on a non-control-plane marker" 0 "1" \ + marker_warns "$MARKER_FIX/workload" "$ROOT/commands/coolify-backup-install.sh" + check "coolify backup: control-plane marker stays silent" 0 "0" \ + marker_warns "$MARKER_FIX/control-plane" "$ROOT/commands/coolify-backup-install.sh" + check "coolify backup: the marker warns but never refuses" 1 "must run as root" \ + env RIG_ROLE_MARKER="$MARKER_FIX/workload" "$ROOT/commands/coolify-backup-install.sh" +else + echo "skip: coolify role-marker warning checks (running as root)" +fi +rm -rf "$MARKER_FIX" +# Root runs skip the live checks above, so also pin the warning's presence in +# both shipped scripts — a deleted advisory cannot ship green (repo precedent: +# the staging/runner tag greps). +check "coolify: marker warning present in the shipped script" 0 "" \ + grep -q "not a control-plane box" "$ROOT/commands/coolify-install.sh" +check "coolify backup: marker warning present in the shipped script" 0 "" \ + grep -q "not a control-plane box" "$ROOT/commands/coolify-backup-install.sh" + # --- rig db (ad-hoc dump/restore) ------------------------------------------- check "bare db shows usage, exit 2" 2 "usage:" "$ROOT/bin/rig" db check "db --help exits 0" 0 "usage:" "$ROOT/bin/rig" db --help