feat!: bootstrap takes the users file #54

Merged
dan-claude-bot merged 1 commit from feat/bootstrap-takes-users into main 2026-07-19 19:17:45 +00:00
5 changed files with 456 additions and 22 deletions
Showing only changes of commit b8dc1154c8 - Show all commits

View file

@ -26,6 +26,44 @@ on the way to cutting its first release, and this file starts there.
`incus-admin` member is warned, not fatal — `box grant` refuses them today,
which heavy-duty/box#99 fixes box-side with no rig change needed.
### Changed
- **BREAKING: `rig bootstrap` takes the users file, and requires it** (#51) —
bootstrap already knew everything else about what a box *is* (class, host,
join, hostname) and wrote `/etc/rig/role` to say so; the users file was the
last piece of that answer it did not take, so bring-up was two commands and
the second was the forgettable one. `--users <path>` now runs the `users
apply` convergence as bootstrap's **final phase** — after the traits, after
the verified tailnet join, after the role marker (apply *reads* that
marker), and after the `host=yes` box install (so box-role users find the
`incus` group box's own `setup-host` built). One command, and the box has
its people on it. The file is still passed per invocation and **never
persisted**; `--users -` is refused, because bootstrap's stdin belongs to
the pre-auth key prompt.
**Migration: every existing `rig bootstrap` invocation must add `--users
<path>` or `--no-users`.** Omitting both is now a usage error (exit 2)
naming both flags, and passing both is a usage error too. Scripted
bring-up that already ran `rig users apply` as a separate step can either
fold it in (`--users ./users`, and drop the separate call) or keep the old
shape verbatim by adding `--no-users`. Required on `class=server` as well
as `class=human`: a server nobody logs into routinely is exactly where
shared-root access rots, and per-human accounts keep attribution intact
for the times someone does go in — so the complete path is the default
path, and skipping it is deliberate rather than an omission that looks
identical to forgetting. The box TENANT roles (`claude|codex|grok|
staging`) take neither flag: a guest is minted non-interactively by box,
never joins the tailnet, and has no SSH door of its own — entry is `box
shell`, gated by the host's `incus` grants.
A bad users file is caught **up front** now (the same parser apply uses,
before `apt`, the hostname change, and any spent pre-auth key), and on
`host=yes` with `RIG_SKIP_BOX_INSTALL=1` a box-role user with no `incus`
group refuses immediately instead of a hundred lines later — the one case
where the outcome is already certain. rig still never installs Incus and
never calls `box setup-host` on its own account; every other way that step
can fail lands in `users apply`'s existing refusal, unchanged.
### Fixed
- **A `host=no` box with an `incus` group no longer hands out the bare

View file

@ -98,21 +98,76 @@ second run changes nothing. (The box TENANT roles — `claude`, `codex`,
tenants* below.)
```sh
rig bootstrap control-plane --hostname my-coolify-box
rig bootstrap workload --hostname my-prod-box
rig bootstrap runner --hostname my-ci-box
rig bootstrap dev --hostname my-dev-box
rig bootstrap workstation --hostname my-laptop
rig bootstrap custom --hostname my-vm-host --class server --host yes --join authkey
rig bootstrap control-plane --hostname my-coolify-box --users ./users
rig bootstrap workload --hostname my-prod-box --users ./users
rig bootstrap runner --hostname my-ci-box --users ./users
rig bootstrap dev --hostname my-dev-box --users ./users
rig bootstrap workstation --hostname my-laptop --users ./users
rig bootstrap custom --hostname my-vm-host --class server --host yes --join authkey --users ./users
```
- `--users <path>` / `--no-users`**required**, one or the other: the users
file this box's operators come from, converged as bootstrap's last phase
(see *One command, box ready* below)
- `--hostname <name>` — system + tailnet hostname (default: the role name;
`custom` has no default and requires it)
- `--class <human|server>` — who lives here; decides root SSH's fate after
`rig users apply` (see *The identity model* below)
the users phase (see *The identity model* below)
- `--host <yes|no>` — does this box host VMs (box/Incus)
- `--join <authkey|login>` — how it enters the tailnet
#### One command, box ready — `--users` is required
`rig bootstrap` already knows everything else about what a box *is* — class,
host, join, hostname — and writes `/etc/rig/role` to say so. The users file
was the last piece of that answer it did not take, so bring-up was two
commands and the second one was easy to forget. Now it takes it, and
**requires** it:
```sh
rig bootstrap dev --hostname my-dev-box --users ./users # one command, people included
rig bootstrap dev --hostname my-dev-box --no-users # deliberately root-only
```
`--users <path>` runs exactly what `rig users apply --file <path>` runs, as
bootstrap's **final phase** — after the traits are set, after the tailnet
join is verified, and after `/etc/rig/role` is written, because apply *reads*
that marker (`class=` picks its root-SSH note, `host=` decides what a missing
`incus` group means). On a `host=yes` box it also lands after the `box`
install, so box-role users find the `incus` group box's `setup-host` built.
The file is passed per invocation and **never persisted** — bootstrap reads
it through apply and keeps nothing; `--users -` is refused, because
bootstrap's stdin belongs to the pre-auth key prompt.
Required on **every** role, `class=server` included. A bootstrapped box with
no users converges to a box only root can enter — on `class=human` a
half-built machine, and on `class=server` something worse than half-built: a
machine nobody logs into routinely is exactly where shared-root access rots,
and per-human accounts keep attribution intact for the times someone does go
in. So the complete path is the default path, and skipping it is a deliberate
`--no-users` rather than an omission that looks identical to forgetting.
Omitting both is a usage error naming both flags; passing both is a usage
error too — rig will not silently pick a winner.
A bad users file is caught **up front**, in the same breath as a bad
`--class`: bootstrap pre-flights it with the same parser apply uses, before
`apt`, before the hostname change, and before a single-use pre-auth key is
spent. And on `host=yes` with `RIG_SKIP_BOX_INSTALL=1`, a box-role user with
no `incus` group refuses immediately rather than a hundred lines later —
that is the one case where the outcome is already certain, since the run has
been told it will not install box. rig still **never** installs Incus or runs
`box setup-host` on its own account; the box CLI's own installer does that
(see the `host` trait), and every other way that step can fail lands in
apply's existing refusal at the end.
`--users` does **not** reach the box TENANT roles (`claude`, `codex`, `grok`,
`staging`). A tenant is a box-minted *guest*: box auto-runs its bootstrap at
mint, non-interactively, with no file to hand it; the guest never joins the
tailnet and has no SSH door of its own — you enter with `box shell`, gated by
the **host's** `incus` grants, which the host's own users file already
converged. A fleet-wide operator file has nothing to converge in there, and
requiring one would break the mint-time path outright.
**Roles are presets over three orthogonal traits**, nothing more — every
per-role behavior keys off a trait, so any flag overrides its trait without
needing a new role (`rig bootstrap workstation --host no` for a laptop that
@ -332,7 +387,8 @@ files, so a PATH export alone is invisible to it.
prompt, in the shipped script). The one creds-holding step a staging guest
eventually needs — the tailnet workload join — stays **operator-run**, exactly
as box#69 designed it: `box shell` → `sudo rig bootstrap workload --hostname
<name>` with a single-use tagged pre-auth key. After that join, re-running
<name> --users <path>` (or `--no-users` — a guest's door is `box shell`, gated
by the host's grants) with a single-use tagged pre-auth key. After that join, re-running
`rig bootstrap staging` still converges docker + hardening and leaves the
workload marker alone — the machine role is the truer statement of what the
box became.
@ -581,7 +637,7 @@ Runner box only, run after `rig bootstrap runner` (the same two-step rhythm
as `bootstrap control-plane``coolify install`):
```sh
rig bootstrap runner --hostname my-ci-box
rig bootstrap runner --hostname my-ci-box --users ./users
rig runner install --repo acme/widgets
```
@ -712,6 +768,10 @@ Converges named operator accounts from a declarative users file — on **every**
class (see *The identity model*). Run as root. Convergent: a second identical
run says "already converged; no changes".
This is also what `rig bootstrap --users <path>` runs as its last phase, so on
a fresh box you rarely call it by hand — it is the *re-converge* verb (a key
added, an operator revoked, a `--no-users` box growing people later).
```
# user roles ssh public key
dan admin,box ssh-ed25519 AAAA... dan@laptop

View file

@ -14,10 +14,16 @@ usage: rig <command> [args]
commands:
bootstrap <control-plane|workload|runner|dev|workstation|custom>
(--users <path> | --no-users)
[--hostname <name>] [--class <human|server>] [--host <yes|no>]
[--join <authkey|login>]
OS plumbing on a pristine Debian box: hardening, unattended-upgrades,
tailscale join. Roles are presets over the three traits; any flag
tailscale join, then this box's operators. --users is REQUIRED on
every role — it converges the users file as bootstrap's last phase
(identical to 'rig users apply --file <path>', marker written first)
so one command leaves a box with its people on it; --no-users is the
deliberate opt-out and leaves root as the only door. The tenant roles
below take neither. Roles are presets over the three traits; any flag
overrides its trait, and custom states all of them. 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 —

View file

@ -8,6 +8,13 @@ HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
. "$HERE/lib/runner-config.sh" # json_field / json_string_array read the netmap
# shellcheck source=SCRIPTDIR/lib/sshd.sh
. "$HERE/lib/sshd.sh" # harden_sshd — shared with the staging tenant
# shellcheck source=SCRIPTDIR/lib/users-config.sh
. "$HERE/lib/users-config.sh" # parse_users_file — the --users PRE-FLIGHT only
# The users lib is sourced for validation, never for convergence: `users apply`
# stays the single owner of what a users file DOES to a box (#51). Bootstrap
# borrows the parser so a typo'd users file is caught in the same breath as a
# bad --class — before apt, before the tailnet join, before a pre-auth key is
# spent — instead of at the very end of a run the operator already paid for.
log() { printf 'rig-bootstrap: %s\n' "$*"; }
warn() { printf 'rig-bootstrap: WARNING: %s\n' "$*" >&2; }
@ -16,19 +23,40 @@ die() { printf 'rig-bootstrap: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; }
usage() {
cat <<'EOF'
usage: rig bootstrap <control-plane|workload|runner|dev|workstation|custom>
(--users <path> | --no-users)
[--hostname <name>] [--class <human|server>]
[--host <yes|no>] [--join <authkey|login>]
rig bootstrap <claude|codex|grok|staging> [--user <name>]
(the box TENANT roles — see their own --help)
(the box TENANT roles — see their own --help; they take
no --users, see below)
--users the users file this box's operators come from — REQUIRED. It is
applied as bootstrap's last phase, exactly as `rig users apply
--file <path>` would, so one command leaves a box with its
people on it. Passed per invocation and never persisted.
--no-users the deliberate opt-out — bootstrap converges the OS and the
tailnet and leaves the box with root as its only door.
--hostname system + tailnet hostname (default: the role name; custom has
no default and requires it)
--class who lives here — human|server. Decides root SSH's fate after
`rig users apply`: human closes it, server keeps it as the
control plane's automation door.
the users phase: human closes it (`rig users close-root`),
server keeps it as the control plane's automation door.
--host does this box host VMs (box/Incus) — yes|no
--join how it enters the tailnet — authkey|login
One of --users/--no-users is required on every role, class=server included:
a box nobody logs into routinely is exactly where shared-root access rots,
and per-human accounts keep attribution intact for the times someone does go
in. So the complete path is the default path and skipping it is a deliberate
--no-users, not an omission.
--users does NOT reach the box TENANT roles (claude|codex|grok|staging). A
tenant is a box-minted GUEST: box auto-runs its bootstrap at mint,
non-interactively, with no file to hand it; the guest never joins the tailnet
and has no SSH door of its own — entry is `box shell`, gated by the HOST's
incus grants, which the host's own users file already converged. A fleet-wide
operator file has nothing to converge in there.
Roles are presets over the three traits; any flag overrides its trait.
custom presets nothing and requires --hostname plus all three traits.
@ -92,8 +120,15 @@ esac
# custom has no hostname default: a made-up name on a made-up shape helps nobody.
TS_HOSTNAME="$ROLE"
[ "$ROLE" = "custom" ] && TS_HOSTNAME=""
USERS_FILE=""
NO_USERS=0
while [ $# -gt 0 ]; do
case "$1" in
--users)
[ $# -ge 2 ] || die "--users needs a value" 2
USERS_FILE="$2"; shift 2 ;;
--no-users)
NO_USERS=1; shift ;;
--hostname)
[ $# -ge 2 ] || die "--hostname needs a value" 2
TS_HOSTNAME="$2"; shift 2 ;;
@ -150,6 +185,90 @@ if [ "$JOIN" = "login" ] && [ -n "${TS_AUTHKEY:-}" ]; then
die "join=login is interactive: unset TS_AUTHKEY or pass --join authkey" 2
fi
# --- who lives here (#51) -----------------------------------------------------
# The users file is the last piece of "what this box is" that bootstrap did not
# take, and it is REQUIRED rather than optional: a bootstrapped box with no
# users converges to a box only root can enter, and on class=human that is a
# half-built machine waiting for a second command the operator has to remember
# (`rig users close-root` is itself gated behind "once your admin key works" —
# which needs an admin to exist). class=server gets the same requirement on
# purpose: a server nobody logs into routinely is exactly where shared-root
# access rots, and per-human accounts keep attribution intact for the times
# someone does go in.
#
# The opt-out is a FLAG, not a default. Both states are then something the
# operator said out loud, which is the whole point — an omitted --users used to
# be indistinguishable from "I meant to and forgot", and the box that resulted
# looked identical either way. Contradicting yourself is a usage error too:
# --users and --no-users together is not a precedence puzzle rig should silently
# resolve, because whichever way it resolved would be the wrong one half the
# time.
if [ -n "$USERS_FILE" ] && [ "$NO_USERS" -eq 1 ]; then
die "--users and --no-users are contradictory: pass the file, or say --no-users, not both" 2
fi
if [ -z "$USERS_FILE" ] && [ "$NO_USERS" -eq 0 ]; then
die "one of --users <path> or --no-users is required: bootstrap converges this box's operators as its last phase, and a box with no named users is one only root can enter. Pass --users <path>, or --no-users to leave it root-only deliberately" 2
fi
# The users file is PRE-FLIGHTED here and applied at the very end: everything
# below this point costs the operator something — apt, a hostname change, a
# single-use pre-auth key — and a users file with a typo in it must not be
# discovered after all of that was already spent. Same reason every other flag
# is validated before the root check: errors belong at the top of the run.
USERS_HAS_BOX_ROLE=0
if [ -n "$USERS_FILE" ]; then
# '-' (stdin) is apply's own convenience and cannot survive the trip through
# bootstrap: stdin here belongs to the pre-auth key prompt, and a users file
# piped in would either eat that prompt or be eaten by it. Refuse the token
# rather than let the two credentials-shaped reads fight over one pipe.
[ "$USERS_FILE" != "-" ] \
|| die "--users needs a real path: bootstrap's stdin is the pre-auth key prompt's, so it cannot also carry the users file. Write it to a file, or run 'rig users apply --file -' separately after --no-users" 2
[ -r "$USERS_FILE" ] || die "cannot read users file: $USERS_FILE" 2
if ! USERS_PARSED="$(parse_users_file "$USERS_FILE")"; then
die "invalid users file: $USERS_FILE — every error is listed above; nothing was changed" 2
fi
# Does anyone in the file carry role box? That single fact decides whether the
# incus precondition below applies at all — a users file naming only admins
# converges perfectly well on a host that has never seen Incus, and refusing
# it there would be rig inventing a prerequisite its own apply does not have.
if printf '%s\n' "$USERS_PARSED" | cut -d'|' -f2 | grep -qE '(^|,)box(,|$)'; then
USERS_HAS_BOX_ROLE=1
fi
fi
# host=yes + a box-role user + no incus group = the refusal `users apply` already
# owns ("rig NEVER installs Incus: box's setup-host owns the daemon and its
# group"). rig does not build that stack here and does not call setup-host —
# whether rig should install box on a VM host is an open boundary question, and
# resolving it by accident inside a users change would be the worst way to
# answer it. What bootstrap CAN do is stop early instead of late.
#
# Early only where the outcome is already PROVEN, though. The ordinary host=yes
# path installs box further down, and box's own installer runs setup-host — so
# the group that is missing now will exist by the time the users phase runs, and
# an unconditional refusal here would reject the exact bring-up this issue is
# about. RIG_SKIP_BOX_INSTALL=1 is the one case with no such rescue: the
# operator has said this run will not touch box, so the group's absence is final
# and the run is doomed a hundred lines before it notices. The other failure
# shapes (no network, box's installer breaking) are not knowable this early and
# land in apply's own refusal at the end — the same message, one phase later.
#
# #49 (merged) added a SECOND host-level refusal to apply: the box CLI itself
# missing on host=yes, because the group is only the socket and the tier is
# `box grant`, which apply calls rather than reimplements. Under
# RIG_SKIP_BOX_INSTALL=1 that absence is just as final and just as knowable
# now as the group's, so the early check mirrors both rather than being a
# weaker proxy for one of them. Either one alone dooms the run.
if [ "$USERS_HAS_BOX_ROLE" -eq 1 ] && [ "$HOST" = "yes" ] \
&& [ "${RIG_SKIP_BOX_INSTALL:-}" = "1" ]; then
if ! getent group incus >/dev/null 2>&1; then
die "a user carries role box and this box hosts VMs (host=yes) but group incus is absent and RIG_SKIP_BOX_INSTALL=1 means this run will not install box — install the box CLI and run 'box setup-host' first; rig never installs Incus. (Or drop RIG_SKIP_BOX_INSTALL and let bootstrap install box as it normally does.)" 2
fi
if ! command -v box >/dev/null 2>&1; then
die "a user carries role box and this box hosts VMs (host=yes) but the box CLI is not on PATH and RIG_SKIP_BOX_INSTALL=1 means this run will not install it — the incus group is only the socket; the restricted tier is 'box grant', which rig calls rather than reimplements. Install the box CLI first. (Or drop RIG_SKIP_BOX_INSTALL and let bootstrap install box as it normally does.)" 2
fi
fi
# --- guards ------------------------------------------------------------------
[ "$(id -u)" -eq 0 ] || die "must run as root"
if [ -r /etc/os-release ]; then
@ -520,6 +639,38 @@ if [ "$HOST" = "yes" ]; then
fi
fi
# --- users (the last phase, and it must be last) -------------------------------
# Ordering is a correctness property, not a preference. `users apply` READS
# /etc/rig/role: class= decides which root-SSH note it prints, and host= decides
# what an absent incus group means (refuse on yes, skip the box role with a
# warning on no). Run before the marker write, apply would see no marker at all
# and warn "re-run rig bootstrap so this box knows what it is" — in the middle
# of the very bootstrap that is teaching it. Run before the box install, a
# host=yes box would refuse its own box-role users for a group arriving twenty
# lines later. So: traits, then join, then marker, then box, then people.
#
# NOT persisted. rig takes the path, reads it once through apply, and keeps
# nothing — the users file lives in your private infra repo and is passed per
# invocation (README: "rig never persists it"). Taking it as a bootstrap flag
# must not quietly turn it into box state, so nothing here copies it anywhere,
# and /etc/rig keeps only the ledger of NAMES apply already wrote.
#
# Invoked as a child, not exec'd: bootstrap still owns the last word (the
# next-steps below), and a failing apply must fail the bootstrap — under
# `set -e` a non-zero apply ends the run right here, which is correct. The box
# is already hardened and joined at this point; what failed is one named phase,
# and it is re-runnable on its own with `rig users apply --file <path>`.
#
# A child also keeps apply's INVOKER gate intact, which is the point: SUDO_USER
# rides through, so `sudo rig bootstrap --users <file-naming-me-admin>` by a
# role-rig user refuses exactly as `sudo rig users apply` would. Bootstrap must
# not become a laundering path around the one gate that stops rig's scoped sudo
# from being root-equivalent. Bring-up runs as real root and is unaffected.
if [ -n "$USERS_FILE" ]; then
log "converging operators from ${USERS_FILE} (rig users apply)"
"$HERE/users-apply.sh" --file "$USERS_FILE"
fi
log "done — role ${ROLE}, hostname ${TS_HOSTNAME}"
if [ "$ROLE" = "control-plane" ]; then
log "next: rig coolify install --version <pin>"
@ -528,9 +679,19 @@ elif [ "$ROLE" = "runner" ]; then
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.
if [ "$CLASS" = "human" ]; then
log "next: rig users apply --file <users-file>, then 'rig users close-root' once your admin key works"
# SSH's fate once named users exist. With --users the accounts exist already, so
# the note that used to point at the missing command now points at what is left
# to do; --no-users still owes the box its people, and says so.
if [ -n "$USERS_FILE" ]; then
if [ "$CLASS" = "human" ]; then
log "next: 'rig users close-root' once your admin key works — verify you can SSH in as an admin FIRST"
else
log "operators are converged; root SSH stays — it is the control plane's automation door"
fi
else
log "next: rig users apply --file <users-file> for named operator logins; root SSH stays — it is the control plane's automation door"
if [ "$CLASS" = "human" ]; then
log "--no-users: this box has no named operators — root is its only door. When you want them: rig users apply --file <users-file>, then 'rig users close-root' once your admin key works"
else
log "--no-users: this box has no named operators — root SSH is its only door, and it stays (the control plane's automation door). For named logins: rig users apply --file <users-file>"
fi
fi

View file

@ -168,6 +168,169 @@ 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"
# --- the users phase (#51): --users is required, --no-users is the opt-out ----
# Bootstrap takes the users file and applies it as its LAST phase, so one
# command leaves a box with its people on it. Everything about the FLAG is
# provable here — the whole surface sits before the root check, deliberately,
# because a users file with a typo must not be discovered after apt, a hostname
# change and a spent pre-auth key.
BOOT_USERS="$(mktemp -d)"
cat > "$BOOT_USERS/ok" <<'USERS'
dan admin ssh-ed25519 AAAAC3fixture dan@laptop
maria rig ssh-ed25519 AAAAC3fixture maria@mac
USERS
printf '%s\n' 'dan admin,box ssh-ed25519 AAAAC3fixture dan@laptop' > "$BOOT_USERS/box"
printf '%s\n' 'maria ops ssh-ed25519 AAAA maria@mac' > "$BOOT_USERS/bad"
# The REQUIREMENT, and the message that carries it: omitting both flags must
# name BOTH ways out, because an operator who forgot the file and one who meant
# to skip it type the identical command — the error is the only place rig can
# tell them apart.
check "bootstrap: omitting --users and --no-users exits 2" 2 "one of --users <path> or --no-users is required" \
"$ROOT/commands/bootstrap.sh" workload
check "bootstrap: the requirement names --no-users as the way out" 2 "--no-users to leave it root-only" \
"$ROOT/commands/bootstrap.sh" dev --hostname b
check "bootstrap: the requirement holds on class=server too" 2 "one of --users" \
"$ROOT/commands/bootstrap.sh" control-plane --hostname cp
check "bootstrap: --users needs a value" 2 "needs a value" \
"$ROOT/commands/bootstrap.sh" workload --users
# MUTUAL EXCLUSION, both orders: rig refuses to pick a winner rather than let a
# precedence rule decide who may enter the box. Both orders, because a
# "last flag wins" implementation would pass one of them silently.
check "bootstrap: --users with --no-users exits 2" 2 "contradictory" \
"$ROOT/commands/bootstrap.sh" workload --users "$BOOT_USERS/ok" --no-users
check "bootstrap: --no-users with --users exits 2 (either order)" 2 "contradictory" \
"$ROOT/commands/bootstrap.sh" workload --no-users --users "$BOOT_USERS/ok"
# Pre-flight: an unreadable or invalid file dies at the top of the run, exit 2,
# before the root check — the same contract every other flag here has.
check "bootstrap: an unreadable users file exits 2" 2 "cannot read users file" \
"$ROOT/commands/bootstrap.sh" workload --users "$BOOT_USERS/nope"
check "bootstrap: an invalid users file exits 2 with the parser's errors" 2 "invalid users file" \
"$ROOT/commands/bootstrap.sh" workload --users "$BOOT_USERS/bad"
check "bootstrap: the invalid-file refusal carries the parser's own line error" 2 "valid roles: admin rig box" \
"$ROOT/commands/bootstrap.sh" workload --users "$BOOT_USERS/bad"
# '-' is apply's stdin convenience and cannot survive the trip through
# bootstrap: stdin here is the pre-auth key prompt's. Refused, with the split
# ('--no-users' then apply by hand) named.
check "bootstrap: --users - is refused, naming the pre-auth key prompt" 2 "pre-auth key prompt" \
"$ROOT/commands/bootstrap.sh" workload --users -
# The host=yes box-role precondition, surfaced EARLY — but only where the
# outcome is already proven: RIG_SKIP_BOX_INSTALL=1 means this run will not
# install box, so a missing incus group can no longer be rescued by the install
# further down. The group's presence is a property of whatever machine runs
# this harness, so it is driven with a shim `getent` instead — both directions,
# on any machine (repo precedent: the install.sh getent shim below).
#
# The box CLI's presence is the SECOND half of the precondition (#49 merged a
# matching die into apply), and it is a property of the runner in exactly the
# same way — this machine happens to have box on PATH, a CI runner may not. So
# it is shimmed in both directions too, and `box` is deliberately NOT inherited
# from the real PATH in these runs: a test that passes only where box happens
# to be installed proves nothing about the machine where it isn't.
INCUS_SHIM_NO="$BOOT_USERS/shim-no"; INCUS_SHIM_YES="$BOOT_USERS/shim-yes"
BOXLESS_SHIM="$BOOT_USERS/shim-nobox"
mkdir -p "$INCUS_SHIM_NO" "$INCUS_SHIM_YES" "$BOXLESS_SHIM"
# Answer only the `group incus` question; everything else falls through to the
# real getent, so the shim cannot quietly change some other lookup's answer.
cat > "$INCUS_SHIM_NO/getent" <<'SHIM'
#!/bin/sh
if [ "$1" = group ] && [ "$2" = incus ]; then exit 2; fi
exec /usr/bin/getent "$@"
SHIM
cat > "$INCUS_SHIM_YES/getent" <<'SHIM'
#!/bin/sh
if [ "$1" = group ] && [ "$2" = incus ]; then echo "incus:x:900:"; exit 0; fi
exec /usr/bin/getent "$@"
SHIM
# Group present, box absent — the shape #49's die now owns, and the one the
# old group-only precondition let through to fail a hundred lines later.
cat > "$BOXLESS_SHIM/getent" <<'SHIM'
#!/bin/sh
if [ "$1" = group ] && [ "$2" = incus ]; then echo "incus:x:900:"; exit 0; fi
exec /usr/bin/getent "$@"
SHIM
# A `box` that exists, for the satisfied case — so INCUS_SHIM_YES proves the
# precondition passes on its own terms rather than on the runner's luck.
printf '#!/bin/sh\nexit 0\n' > "$INCUS_SHIM_YES/box"
chmod +x "$INCUS_SHIM_NO/getent" "$INCUS_SHIM_YES/getent" \
"$BOXLESS_SHIM/getent" "$INCUS_SHIM_YES/box"
check "bootstrap: host=yes + box role + no incus + skipped box install exits 2" 2 "group incus is absent" \
env RIG_SKIP_BOX_INSTALL=1 PATH="$INCUS_SHIM_NO:$PATH" \
"$ROOT/commands/bootstrap.sh" dev --hostname h --users "$BOOT_USERS/box"
check "bootstrap: that refusal points at box setup-host, not at rig" 2 "rig never installs Incus" \
env RIG_SKIP_BOX_INSTALL=1 PATH="$INCUS_SHIM_NO:$PATH" \
"$ROOT/commands/bootstrap.sh" dev --hostname h --users "$BOOT_USERS/box"
# The group can be there while the CLI is not — #49's die owns that shape, and
# under the skip it is just as final and just as knowable now. PATH is built
# WITHOUT the real one so the absence is the test's, not the machine's.
check "bootstrap: host=yes + box role + incus group + no box CLI + skip exits 2" 2 "box CLI is not on PATH" \
env RIG_SKIP_BOX_INSTALL=1 PATH="$BOXLESS_SHIM:/usr/bin:/bin" \
"$ROOT/commands/bootstrap.sh" dev --hostname h --users "$BOOT_USERS/box"
check "bootstrap: that refusal names the tier, not just the socket" 2 "the restricted tier is 'box grant'" \
env RIG_SKIP_BOX_INSTALL=1 PATH="$BOXLESS_SHIM:/usr/bin:/bin" \
"$ROOT/commands/bootstrap.sh" dev --hostname h --users "$BOOT_USERS/box"
if [ "$(id -u)" -ne 0 ]; then
# It must NOT fire in the three shapes that are not doomed. A users file with
# no box-role user converges fine on a host that never saw Incus (refusing it
# would be rig inventing a prerequisite apply does not have); an incus group
# that exists satisfies it outright; and WITHOUT RIG_SKIP_BOX_INSTALL the
# missing group is the box install's to create further down — refusing there
# would reject the exact one-command bring-up this flag is for. Reaching the
# root check (exit 1) is the proof each passed the precondition.
check "bootstrap: no box-role user means no incus precondition" 1 "must run as root" \
env TS_AUTHKEY=x RIG_SKIP_BOX_INSTALL=1 PATH="$INCUS_SHIM_NO:$PATH" \
"$ROOT/commands/bootstrap.sh" dev --hostname h --users "$BOOT_USERS/ok"
check "bootstrap: an existing incus group satisfies the precondition" 1 "must run as root" \
env TS_AUTHKEY=x RIG_SKIP_BOX_INSTALL=1 PATH="$INCUS_SHIM_YES:$PATH" \
"$ROOT/commands/bootstrap.sh" dev --hostname h --users "$BOOT_USERS/box"
check "bootstrap: without the skip, the box install is left to create the group" 1 "must run as root" \
env TS_AUTHKEY=x PATH="$INCUS_SHIM_NO:$PATH" \
"$ROOT/commands/bootstrap.sh" dev --hostname h --users "$BOOT_USERS/box"
# host=no is the other side of apply's host= rule — the box role is skipped
# with a warning there, never refused, so bootstrap must not refuse it either.
check "bootstrap: host=no never gets the incus precondition" 1 "must run as root" \
env TS_AUTHKEY=x RIG_SKIP_BOX_INSTALL=1 PATH="$INCUS_SHIM_NO:$PATH" \
"$ROOT/commands/bootstrap.sh" workload --users "$BOOT_USERS/box"
fi
# rig does NOT resolve the open "should rig install box" question here: the
# precondition refuses, it never calls setup-host itself. A grep that finds
# nothing (exit 1) is the pass — same shape as the never-apt-install-incus law.
check "bootstrap: the users phase never runs box setup-host itself" 1 "" \
grep -nE '^[[:space:]]*box setup-host' "$ROOT/commands/bootstrap.sh"
# ORDERING is a correctness property, not taste: apply READS /etc/rig/role
# (class= picks its root-SSH note, host= decides what a missing incus group
# means), and on host=yes it needs the group box's installer built. So the
# users phase must sit after BOTH the marker write and the box install. Line
# numbers, same idiom as the marker/box-install ordering asserts above;
# defaults fail closed. The apply call is grepped as a literal — single quotes
# intended, $HERE/$USERS_FILE are the script's own.
# shellcheck disable=SC2016
users_apply_at="$(grep -n '"$HERE/users-apply.sh" --file "$USERS_FILE"' "$ROOT/commands/bootstrap.sh" | head -n1 | cut -d: -f1)"
check "bootstrap: the users phase invokes users apply" 0 "" \
test -n "$users_apply_at"
check "bootstrap: the users phase runs after the role marker write" \
0 "" test "${box_marker_at:-999999}" -lt "${users_apply_at:-0}"
check "bootstrap: the users phase runs after the box install" \
0 "" test "${box_install_at:-999999}" -lt "${users_apply_at:-0}"
# The users file is passed per invocation and NEVER persisted (README: "rig
# never persists it"). Taking it as a bootstrap flag must not quietly turn it
# into box state, so nothing may copy it anywhere. A grep that finds nothing
# (exit 1) is the pass — same shape as the never-apt-install-incus law.
check "bootstrap: the users file is never copied onto the box" 1 "" \
grep -nE '^[[:space:]]*(cp|install|mv|tee|cat)[[:space:]].*USERS_FILE' "$ROOT/commands/bootstrap.sh"
# Usage must carry both flags: an operator hitting the new requirement reads
# --help next, and finding only --users there would leave the opt-out a secret.
check "bootstrap: usage documents --users" 0 "--users" "$ROOT/commands/bootstrap.sh" --help
check "bootstrap: usage documents --no-users" 0 "--no-users" "$ROOT/commands/bootstrap.sh" --help
check "rig usage documents the bootstrap users flags" 0 "(--users <path> | --no-users)" \
"$ROOT/bin/rig" --help
# The TENANT family takes neither flag. Dispatch happens before this parser
# runs, so --users lands in the tenant script's own unknown-flag refusal — the
# decision (a box-minted guest has no SSH door of its own; entry is `box shell`,
# gated by the HOST's incus grants) is documented in usage and the README.
check "bootstrap: --users does not reach the tenant roles" 2 "unknown flag" \
"$ROOT/commands/bootstrap.sh" claude --users "$BOOT_USERS/ok"
check "bootstrap: usage explains why tenants take no --users" 0 "box-minted GUEST" \
"$ROOT/commands/bootstrap.sh" --help
# --- 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
@ -192,8 +355,14 @@ check "README: documents the mismatch strip on host=no" 0 "" \
check "README: no stale 'group absent decides' semantics" 1 "" \
grep -n "when the \`incus\` group is absent, the \`host=\` trait decides" "$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
# Every machine-role invocation now states its users answer — the flag is
# required (#51), so reaching the root check at all proves it was accepted.
# --no-users here keeps these asserts about the ROOT CHECK; the --users path
# gets its own root-check assert below, against a valid fixture.
check "bootstrap: refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" workload --no-users
check "bootstrap: --users file reaches the root check" 1 "must run as root" \
env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" workload --users "$BOOT_USERS/ok"
check "bootstrap: runner role parses, refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" runner --no-users
# staging dispatches to the tenant mechanism now; reaching ITS root check
# through bootstrap.sh proves the dispatch and the tenant arg pass in one go.
# RIG_ROLE_MARKER points at an absent fixture: the tenant marker guard runs
@ -201,10 +370,10 @@ if [ "$(id -u)" -ne 0 ]; then
# a real /etc/rig/role of its own.
check "bootstrap: staging dispatches to the tenant mechanism, refuses non-root" 1 "must run as root" \
env RIG_ROLE_MARKER=/nonexistent/rig-role "$ROOT/commands/bootstrap.sh" staging
check "bootstrap: dev role parses, refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" dev
check "bootstrap: workstation parses, refuses non-root" 1 "must run as root" env -u TS_AUTHKEY "$ROOT/commands/bootstrap.sh" workstation
check "bootstrap: dev role parses, refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" dev --no-users
check "bootstrap: workstation parses, refuses non-root" 1 "must run as root" env -u TS_AUTHKEY "$ROOT/commands/bootstrap.sh" workstation --no-users
check "bootstrap: custom parses, refuses non-root" 1 "must run as root" \
env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" custom --hostname b --class server --host no --join authkey
env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" custom --hostname b --class server --host no --join authkey --no-users
else
echo "skip: bootstrap non-root refusals (running as root)"
fi