From 1845468765c5c1d1d9c5c880c97bc77711148674 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Mon, 20 Jul 2026 00:01:16 +0000 Subject: [PATCH 1/2] feat(bootstrap)!: machine roles carry a -server suffix; staging-server restored rig builds two kinds of thing on opposite sides of a trust boundary -- tailnet machines it converges, and guests a box mints -- and both families lived in one flat namespace with nothing in a role name saying which you meant. `staging` is where that stopped being cosmetic: the word names the metal that hosts guests and the guests on it, only one could have it, and #31 gave it to the guests. The VM-host shape was left nameless, spelled `custom --class server --host yes --join authkey`, which is what every refusal recited at an operator who had confused the two. The suffix now names the family: control-plane-server, workload-server, runner-server, dev-server, plus the restored staging-server (class=server host=yes join=authkey). host=yes already installs the box CLI and runs box's setup-host, so staging-server is a table row, not new machinery. It stays OUT of the tag:server allow-list deliberately -- a host is never managed by the control plane, its guests are -- so its key is minted tag:local. custom and workstation keep bare names as the rule, not an exception to it: custom presets nothing and can be any shape including a guest, so a family claim is one it cannot make; a workstation is somebody's own device, joined by interactive login, user-owned and untagged, never tailnet-managed. Hard cut, no aliases -- old names are refused as unknown. Two consequences this reaches beyond the CLI surface. TS_HOSTNAME defaults to the role name, so a box taking the default now comes up control-plane-server. And the two coolify commands match the ROLE NAME in /etc/rig/role, not the traits, so they now look for role=control-plane-server; a pre-rename control plane takes their warning branch, which is advisory and never a gate, so the run proceeds and the message names the repair. dev-server is class=human, which reads like a contradiction and is not: the suffix names the family, the class names the root-SSH door policy. The two axes share the word "server", which is a real wart -- #77 renames the class trait to what it controls, kept separate because it reaches markers on live machines that guard root SSH. Tests cover both directions of the cut: every new name resolves, every old name is refused as unknown, and the two deliberately-bare roles are proven NOT to have been swept up -- the inverse error, which would otherwise only surface at somebody's laptop. Closes #76 (machine-role half) Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 50 +++++++++++ README.md | 120 +++++++++++++++---------- bin/rig | 9 +- commands/bootstrap.sh | 85 +++++++++++------- commands/coolify-backup-install.sh | 7 +- commands/coolify-install.sh | 11 ++- test/cli.sh | 137 ++++++++++++++++++----------- 7 files changed, 282 insertions(+), 137 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7d7fb0..80b243c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,56 @@ on the way to cutting its first release, and this file starts there. ## Unreleased +### Changed + +- **BREAKING: machine roles carry a `-server` suffix, and the VM host gets its + name back** (#76) — rig builds two kinds of thing that sit on opposite sides + of a trust boundary: tailnet **machines** it converges, and **guests** a box + mints. Both families lived in one flat namespace, and no role name said which + one you were asking for. `staging` is where that stopped being cosmetic — the + word names the metal that hosts guests *and* the guests on it, only one of + them could have the name, and #31 gave it to the guests. The VM-host shape + was left with no name at all, spelled `custom --class server --host yes + --join authkey`, which is what every refusal in the tree recited at an + operator who had confused the two. + + So the suffix names the family: `control-plane-server`, `workload-server`, + `runner-server`, `dev-server`, and the restored `staging-server` + (`class=server host=yes join=authkey` — the preset #31 retired, back under a + name that cannot be mistaken for its own guests). `host=yes` already installs + the box CLI and runs box's `setup-host`, so `staging-server` is a table row + rather than new machinery, and it stays **out** of the `tag:server` + allow-list on purpose: a host is never managed by the control plane, its + guests are, so mint its key with `tag:local`. + + **`custom` and `workstation` keep bare names**, and that is the rule rather + than an exception to it. `custom` presets nothing and can be any shape — a + guest included — so a family claim is one it cannot make. `workstation` is + somebody's own device rather than fleet infrastructure: it joins by + interactive login, comes up user-owned and untagged, and the tailnet never + manages it. + + **Migration — this is a hard cut, with no aliases.** The old names are + refused as unknown roles; a box bootstrapped under one is re-bootstrapped + rather than migrated, which at this fleet size costs less than four + deprecation paths each quietly keeping an old name alive. Two consequences + worth knowing before you re-run anything. `TS_HOSTNAME` defaults to the role + name, so a box that took the default now comes up as `control-plane-server` + rather than `control-plane` — pass `--hostname` to hold a name steady, and + check anything pinning one (ACL entries, a `cast` `environments.yaml` server + name, host keys). And `rig coolify install` / `rig coolify backup install` + match the **role name** in `/etc/rig/role`, so they now look for + `role=control-plane-server`; a pre-rename control plane takes their warning + branch until it is re-bootstrapped. That check has always been advisory and + never a gate, so the run still proceeds and the warning names the repair. + + `dev-server` is `class=human`, which reads like a contradiction and is not: + the suffix names the family, the class names the root-SSH door policy, and + operators enter a dev box as themselves so `close-root` shuts its door. The + two axes genuinely share the word "server", which is a wart — #77 renames the + class trait to what it actually controls, and is kept separate because it + reaches markers on live machines that guard root SSH. + ## 0.2.0 — 2026-07-19 ### Added diff --git a/README.md b/README.md index cb9d443..23890ea 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ itself is untouched — what bootstrap converged stays converged. ## Commands -### `rig bootstrap ` +### `rig bootstrap ` Run as root on the fresh box (over SSH). Convergent — safe to re-run; a second run changes nothing. (The box TENANT roles — `claude`, `codex`, @@ -98,10 +98,10 @@ second run changes nothing. (The box TENANT roles — `claude`, `codex`, tenants* below.) ```sh -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 control-plane-server --hostname my-coolify-box --users ./users +rig bootstrap workload-server --hostname my-prod-box --users ./users +rig bootstrap runner-server --hostname my-ci-box --users ./users +rig bootstrap dev-server --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 ``` @@ -125,8 +125,8 @@ 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 +rig bootstrap dev-server --hostname my-dev-box --users ./users # one command, people included +rig bootstrap dev-server --hostname my-dev-box --no-users # deliberately root-only ``` `--users ` runs exactly what `rig users apply --file ` runs, as @@ -187,27 +187,50 @@ presets nothing and requires `--hostname` plus all three traits. | `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 | -|-----------------|--------|------|---------|-------------| -| `control-plane` | server | no | authkey | `tag:server` | -| `workload` | server | no | authkey | `tag:server` | -| `runner` | server | no | authkey | `tag:ci` — refuses `tag:server` | -| `dev` | human | yes | authkey | `tag:local` — refuses `tag:server` | -| `workstation` | human | yes | login | untagged — any tag refused | +| role | class | host | join | tailnet tag | +|------------------------|--------|------|---------|-------------| +| `control-plane-server` | server | no | authkey | `tag:server` | +| `workload-server` | server | no | authkey | `tag:server` | +| `runner-server` | server | no | authkey | `tag:ci` — refuses `tag:server` | +| `staging-server` | server | yes | authkey | `tag:local` — refuses `tag:server` | +| `dev-server` | human | yes | authkey | `tag:local` — refuses `tag:server` | +| `workstation` | human | yes | login | untagged — any tag refused | -> **Where the `staging` preset went.** Before #31, `staging` was the VM-host -> preset (`class=server host=yes join=authkey`). The name now belongs to the -> box TENANT family — the *guest*, not the host under it — because that is -> what "staging" mostly names in practice (the box the control plane will -> manage), and two meanings on one word was the worse bug. The host shape -> lost nothing: it is one flag away — `rig bootstrap dev --class server -> --hostname my-vm-host` — or fully spelled as `custom --class server --host -> yes --join authkey`. Tag policy is unchanged: mint the host's key with -> `tag:local`; an effective `tag:server` is refused on every role but -> `control-plane` and `workload`. +> **The suffix names the family, not the class** (#76). rig builds two kinds +> of thing on opposite sides of a trust boundary — tailnet **machines** it +> converges, and **guests** a box mints — and for a while nothing in a role +> name said which you were asking for. `staging` made that concrete: the word +> named both the metal that hosts guests and the guests on it, and only one of +> them could have it. So `-server` marks a fleet machine and `-box` marks a +> box tenant, everywhere, and `staging-server` / `staging-box` are simply the +> two halves spelled out. `staging-server` restores the VM-host preset #31 +> retired, under a name that cannot be confused with its own guests. +> +> Two roles take **no** suffix, on purpose. `custom` presets nothing and can +> be any shape — a guest included — so a family claim is one it cannot make. +> `workstation` is somebody's own device rather than fleet infrastructure: it +> joins by interactive login, comes up user-owned and untagged, and the +> tailnet never manages it. +> +> **`dev-server` is `class=human`, and that is not a contradiction** — though +> it is a wart. The suffix names the *family*; the class names the *root-SSH +> door policy*, and operators enter a dev box as themselves, so `close-root` +> shuts its door. Two orthogonal axes that happen to share the word "server". +> [#77](https://github.com/heavy-duty/rig/issues/77) renames the class trait +> to what it actually controls, which is the real fix; it touches markers on +> live machines, so it is deliberately not folded in here. +> +> **This was a hard cut — no aliases.** Old role names stop working, and a +> box bootstrapped under one is re-bootstrapped rather than migrated. Two +> things follow. The default tailnet hostname is the role name, so a box that +> took the default now comes up as `control-plane-server`; pass `--hostname` +> to hold a name steady. And `rig coolify install` / `rig coolify backup +> install` match `role=control-plane-server` in the marker, so a pre-rename +> control plane takes their (advisory, non-fatal) warning until it is +> re-bootstrapped. The tag column is **derived policy, not a fourth trait**: `tag:server` means -"the control plane manages this box", and `control-plane` and `workload` are +"the control plane manages this box", and `control-plane-server` and `workload-server` are the only shapes it manages — every other role refuses an effective `tag:server` after join, one rule instead of per-role exceptions. @@ -292,9 +315,9 @@ catches a box bootstrapped before this change, or retagged behind rig's back). > tag flag, re-tagging needs a fresh key via `up --force-reauth` — so rig detects > and refuses, and never claims a convergence it cannot perform. -`control-plane` and `workload` are identical today except the default +`control-plane-server` and `workload-server` are identical today except the default hostname; they exist because the boxes diverge over time, and because each -follow-up command applies to exactly one role. `runner` is the box a CI agent +follow-up command applies to exactly one role. `runner-server` is the box a CI agent will live on, and it differs behaviorally: it **refuses `tag:server`**. That refusal moved onto the *effective* tag and is strictly stronger for it — it is no longer "don't advertise `tag:server`" but "the key you actually used must not @@ -305,13 +328,13 @@ hard, post-join error. The VM-host shape — the box that *hosts* staging boxes: Incus VMs minted by the [`box`](https://github.com/heavy-duty/box) CLI, each converged from inside -with the tenant roles and (for staging guests) `rig bootstrap workload` — -rides the traits since #31 (`--class server --host yes --join authkey`; see +with the tenant roles and (for staging guests) `rig bootstrap workload-server` — +is the `staging-server` role (`--class server --host yes --join authkey`; see the note above). 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 an effective **`tag:server` is -refused**, same mechanism as `runner`. +refused**, same mechanism as `runner-server`. 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 @@ -344,7 +367,7 @@ merges box's root install lands in `/root`.) > fork); `RIG_SKIP_BOX_INSTALL=1` opts out entirely for a host whose box you > manage by hand. -`dev` is the human-class VM-hosting shape — `tag:local`, box CLI installed as +`dev-server` is the human-class VM-hosting shape — `tag:local`, box CLI installed as above, a person living on it (`--class server` turns it into the unattended VM-host appliance) — and `workstation` is the machine at the keyboard end of all the SSH connections: human-class, `join=login`, entering the tailnet as @@ -393,7 +416,7 @@ files, so a PATH export alone is invisible to it. — no tailnet, no keys (the harness pins this by *absence*: no `tailscale`, no 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 +as box#69 designed it: `box shell` → `sudo rig bootstrap workload-server --hostname --users ` (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 @@ -441,17 +464,18 @@ 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 | -| `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` | +| role | class | host | join | who lives here | root SSH after `rig users apply` | +|------------------------|--------|------|---------|--------------------------------------|----------------------------------| +| `control-plane-server` | server | no | authkey | nobody — Coolify runs here | open — the automation door | +| `workload-server` | server | no | authkey | nobody — deployed services run here | open — the automation door | +| `runner-server` | server | no | authkey | nobody — CI jobs as `github-runner` | open — the automation door | +| `staging-server` | server | yes | authkey | nobody — it mints and hosts guests | open — the automation door | +| `dev-server` | human | yes | authkey | operators, minting boxes | closed by `rig users close-root` | +| `workstation` | human | yes | login | its owner | closed by `rig users close-root` | -(The unattended VM-host appliance — formerly the `staging` preset — is the -`class=server host=yes join=authkey` shape: nobody lives there, root SSH stays -open as the automation door. The box TENANT roles sit outside this table on +(`staging-server` is that unattended VM-host appliance, and the row above is +the whole of it: nobody lives there, root SSH stays open as the automation +door. The box TENANT roles sit outside this table on purpose: a guest is not a tailnet machine, and its marker carries no `class=`, so `rig users close-root` fails closed on it.) @@ -463,7 +487,7 @@ operators land via `rig users apply` on every class and elevate through sudo 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`. +converged from inside by `rig bootstrap workload-server`. **`class` decides root SSH's fate — after `rig users apply`, never before.** On `class=human`, root SSH closes entirely (`rig users close-root`, below). @@ -472,7 +496,7 @@ root there is the **automation** identity the control plane (Coolify) SSHes in as. It is a machine door, never a human one. **Where this diverges from #17's original table:** that table let the -`runner` role close root ("no Coolify involved"). The class model supersedes +`runner-server` role close root ("no Coolify involved"). The class model supersedes the per-role call: runner is `class=server` — an automation identity, not a person's box — and on every server-class machine root SSH is the management plane rig itself converges through, so `close-root` refuses there @@ -640,11 +664,11 @@ throwaway containers on every push. ### `rig runner install --repo ` -Runner box only, run after `rig bootstrap runner` (the same two-step rhythm -as `bootstrap control-plane` → `coolify install`): +Runner box only, run after `rig bootstrap runner-server` (the same two-step rhythm +as `bootstrap control-plane-server` → `coolify install`): ```sh -rig bootstrap runner --hostname my-ci-box --users ./users +rig bootstrap runner-server --hostname my-ci-box --users ./users rig runner install --repo acme/widgets ``` @@ -1006,7 +1030,7 @@ split: the harness proves the arg/refusal surface, the marker guards (off fixture markers), the pure parameter table, and the rendered agent-context file — guard note included — plus absence-greps for the creds-free contract; the real converge belongs to the rehearsal. The end-to-end rehearsal is a -throwaway VM/container: pristine Debian → install → `bootstrap workload` with +throwaway VM/container: pristine Debian → install → `bootstrap workload-server` with a real single-use key → assert the sshd drop-in, tailnet join, and a no-op second run → destroy, remove the node from the tailnet. The tenant rehearsal is the same shape, creds-free: container + seed user → `rig bootstrap claude` diff --git a/bin/rig b/bin/rig index 0af2016..e0a2b02 100755 --- a/bin/rig +++ b/bin/rig @@ -13,7 +13,8 @@ usage() { usage: rig [args] commands: - bootstrap + bootstrap (--users | --no-users) [--hostname ] [--class ] [--host ] [--join ] @@ -27,7 +28,9 @@ commands: 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 — - only control-plane and workload may carry tag:server. join=login + only control-plane-server and workload-server may carry tag:server. + The '-server' suffix names the FAMILY (a fleet machine, vs the '-box' + tenant roles); custom and workstation take none. join=login (workstation) needs no key: interactive login, node must come up untagged. Run as root. bootstrap [--user ] @@ -36,7 +39,7 @@ commands: the system PATH, and the agent-context file (with the box#80 guard: never run box setup-host inside a box). staging lands box#69's server posture — docker + sshd hardening; its tailnet join stays operator-run - via 'rig bootstrap workload'. Creds-free and non-interactive: box + via 'rig bootstrap workload-server'. Creds-free and non-interactive: box auto-runs these at mint. Run as root, inside the box. coolify install --version Pinned Coolify install (AUTOUPDATE=false). Control-plane box only. diff --git a/commands/bootstrap.sh b/commands/bootstrap.sh index 2374dfe..1d9a7de 100755 --- a/commands/bootstrap.sh +++ b/commands/bootstrap.sh @@ -22,7 +22,8 @@ die() { printf 'rig-bootstrap: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; } usage() { cat <<'EOF' -usage: rig bootstrap +usage: rig bootstrap (--users | --no-users) [--hostname ] [--class ] [--host ] [--join ] @@ -60,23 +61,39 @@ 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. - role class host join - control-plane server no authkey - workload server no authkey - runner server no authkey - dev human yes authkey - workstation human yes login + role class host join + control-plane-server server no authkey + workload-server server no authkey + runner-server server no authkey + staging-server server yes authkey + dev-server human yes authkey + workstation human yes login -The former staging VM-host preset is now spelled through the traits: -'custom --class server --host yes --join authkey' (or 'dev --class server'). -'staging' names the box TENANT role today — the guest, not the host. +THE SUFFIX NAMES THE FAMILY, not the class. '-server' means this role builds a +fleet MACHINE — a tailnet node rig converges; '-box' (the tenant roles) means a +GUEST a box mints. Two families lived in one flat namespace and nothing in a +name said which you were asking for; 'staging' made that concrete by naming +both the metal and the guests on it. + + custom no suffix: it presets nothing and can be any shape, a guest + included, so a family claim would be one it cannot make. + workstation no suffix: somebody's own device, not fleet infrastructure — + it joins by interactive login and comes up user-owned and + untagged, and the tailnet never manages it. + +'dev-server' is class=human, and that is not a contradiction: the suffix names +the family, the CLASS names the root-SSH door policy (operators enter a dev box +as themselves, so 'users close-root' shuts its door). The two axes share the +word 'server' and that is a genuine wart — tracked in #77, which renames the +class trait to what it actually controls. 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 (tailscale status .Self.Tags) and asserts on THAT — an untagged key is refused -outright, and only control-plane and workload may carry tag:server (they are -the only shapes the control plane manages). Mint a correctly-tagged key. +outright, and only control-plane-server and workload-server may carry +tag:server (they are the only shapes the control plane manages). Mint a +correctly-tagged key. join=authkey: provide the single-use tailscale pre-auth key via the TS_AUTHKEY env var, or enter it at the interactive prompt. Used once, never written to disk. @@ -91,7 +108,7 @@ EOF # --- args (validated before the root check, so errors are testable) --------- ROLE="${1:-}" case "$ROLE" in - control-plane|workload|runner|dev|workstation|custom) shift ;; + control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom) shift ;; claude|codex|grok|staging) # The box TENANT roles (#31) are a different family — guests a box mints, # never tailnet machines — and live in their own mechanism, one script @@ -99,8 +116,8 @@ case "$ROLE" in # stays the single entrypoint for both families. exec "$HERE/bootstrap-tenant.sh" "$@" ;; -h|--help) usage; exit 0 ;; - "") usage >&2; die "role required (control-plane|workload|runner|dev|workstation|custom — or a tenant role: claude|codex|grok|staging)" 2 ;; - *) die "unknown role: $ROLE (want control-plane|workload|runner|dev|workstation|custom — or a tenant role: claude|codex|grok|staging)" 2 ;; + "") usage >&2; die "role required (control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom — or a tenant role: claude|codex|grok|staging)" 2 ;; + *) die "unknown role: $ROLE (want control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom — or a tenant role: claude|codex|grok|staging)" 2 ;; esac # Role→traits map — the single place a role's shape is declared (issue #26). @@ -109,11 +126,16 @@ esac # for the shape nobody foresaw — it declares nothing and must state all three. CLASS="" HOST="" JOIN="" case "$ROLE" in - control-plane) CLASS=server HOST=no JOIN=authkey ;; - workload) CLASS=server HOST=no JOIN=authkey ;; - runner) CLASS=server HOST=no JOIN=authkey ;; - dev) CLASS=human HOST=yes JOIN=authkey ;; - workstation) CLASS=human HOST=yes JOIN=login ;; + control-plane-server) CLASS=server HOST=no JOIN=authkey ;; + workload-server) CLASS=server HOST=no JOIN=authkey ;; + runner-server) CLASS=server HOST=no JOIN=authkey ;; + # The unattended VM host — the shape #31 retired when 'staging' moved to the + # tenant family, restored under a name that cannot be confused with its own + # guests. host=yes is the whole point: it is what installs the box CLI and + # runs box's setup-host further down, so this is a table row, not machinery. + staging-server) CLASS=server HOST=yes JOIN=authkey ;; + dev-server) CLASS=human HOST=yes JOIN=authkey ;; + workstation) CLASS=human HOST=yes JOIN=login ;; custom) ;; esac @@ -430,25 +452,26 @@ verify_effective_tag() { fi # tag:server policy is DERIVED, not a trait: it means "the control plane - # manages this box", and only control-plane and workload are shapes the + # manages this box", and only control-plane-server and workload-server are shapes the # control plane manages. Everything else refuses it on the EFFECTIVE tag — # strictly stronger than the old request-time check, which only guarded the - # tag rig HOPED for. The fleet has been bitten both ways: a runner carrying + # tag rig HOPED for. The fleet has been bitten both ways: a runner-server carrying # tag:server extends every server grant to repo-controlled code, and a # staging host carrying it extends them to a box the control plane does not # even know. Refused, never warned; rig can DETECT this but cannot FIX it, # so each refusal names its repair. if printf '%s\n' "$tags" | grep -qx 'tag:server'; then case "$ROLE" in - control-plane|workload) ;; - runner) - 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)." ;; + control-plane-server|workload-server) ;; + runner-server) + die "role runner-server 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)." ;; *) - # This arm now also owns the VM-host shape the old staging preset - # covered (custom/dev --class server): a host is never managed by the + # This arm owns the VM-host shape too — 'staging-server' by name now, + # plus custom/dev-server --class server: a host is never managed by the # control plane — its guest VMs are — so tag:server is refused there - # like everywhere else outside control-plane|workload. - die "role $ROLE joined with tag:server (effective tags: $(printf '%s' "$tags" | tr '\n' ' ')). Only control-plane and workload are managed by the control plane; tag:server on this box extends every server grant to it. Re-run bootstrap with a key minted for a non-server tag (e.g. tag:local)." ;; + # like everywhere else outside the two control-plane-managed shapes. + # Mint the metal's key with tag:local. + die "role $ROLE joined with tag:server (effective tags: $(printf '%s' "$tags" | tr '\n' ' ')). Only control-plane-server and workload-server are managed by the control plane; tag:server on this box extends every server grant to it. Re-run bootstrap with a key minted for a non-server tag (e.g. tag:local)." ;; esac fi @@ -703,9 +726,9 @@ if [ -n "$USERS_FILE" ]; then fi log "done — role ${ROLE}, hostname ${TS_HOSTNAME}" -if [ "$ROLE" = "control-plane" ]; then +if [ "$ROLE" = "control-plane-server" ]; then log "next: rig coolify install --version " -elif [ "$ROLE" = "runner" ]; then +elif [ "$ROLE" = "runner-server" ]; then log "next: rig runner install --repo --version " fi # Every class gets operators: humans always enter as themselves and elevate via diff --git a/commands/coolify-backup-install.sh b/commands/coolify-backup-install.sh index a656472..de17bca 100755 --- a/commands/coolify-backup-install.sh +++ b/commands/coolify-backup-install.sh @@ -77,10 +77,13 @@ done # 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). +# Matches the ROLE NAME, so #76's rename reaches it the same way it reaches +# `coolify install` — a pre-rename marker takes the warning branch, which is +# the hard cut behaving as designed rather than a regression. 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." ;; + ""|"role=control-plane-server"|"role=control-plane-server "*) ;; + *) 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-server; if this is the wrong box, stop here and re-check your SSH session." ;; esac # --- guards ---------------------------------------------------------------- diff --git a/commands/coolify-install.sh b/commands/coolify-install.sh index 888e838..616fb32 100755 --- a/commands/coolify-install.sh +++ b/commands/coolify-install.sh @@ -51,10 +51,17 @@ fi # 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). +# +# This match is on the ROLE NAME, which #76's rename therefore reaches: a box +# bootstrapped before the rename carries 'role=control-plane' and now takes the +# warning branch. That is the hard cut behaving as designed — the marker is +# advisory, the run still proceeds, and the warning names the re-bootstrap that +# makes the marker true again. Nothing here is load-bearing enough to justify +# carrying the old name forever. 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." ;; + ""|"role=control-plane-server"|"role=control-plane-server "*) ;; + *) warn "this box's role marker says '${MARKER_LINE}' — not a control-plane box. Coolify belongs on role control-plane-server; if this is the wrong box, stop here and re-check your SSH session. Repurposing it on purpose? Re-run 'rig bootstrap control-plane-server' first so the marker tells the truth." ;; esac [ "$(id -u)" -eq 0 ] || die "must run as root" diff --git a/test/cli.sh b/test/cli.sh index 70e2e8e..5ce8b10 100644 --- a/test/cli.sh +++ b/test/cli.sh @@ -34,8 +34,8 @@ check "bare coolify shows usage, exit 2" 2 "usage:" "$ROOT/bin/rig" coolify check "bootstrap: role required, exit 2" 2 "role required" "$ROOT/commands/bootstrap.sh" check "bootstrap: --help exits 0" 0 "usage:" "$ROOT/commands/bootstrap.sh" --help check "bootstrap: unknown role exits 2" 2 "unknown role" "$ROOT/commands/bootstrap.sh" potato -check "bootstrap: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/bootstrap.sh" workload --nope -check "bootstrap: hostname needs value" 2 "needs a value" "$ROOT/commands/bootstrap.sh" workload --hostname +check "bootstrap: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/bootstrap.sh" workload-server --nope +check "bootstrap: hostname needs value" 2 "needs a value" "$ROOT/commands/bootstrap.sh" workload-server --hostname # --ts-tag is REMOVED, not demoted: the tag now comes from the pre-auth key and # rig verifies the GRANTED tag after join. The old runner-refuses-tag:server test # asserted the request-time refusal THROUGH this flag; that policy now lives on @@ -44,25 +44,60 @@ check "bootstrap: hostname needs value" 2 "needs a value" "$ROOT/commands/bo # at the key (exit 2, a usage error), rather than an "unknown flag" that would # leave an operator guessing where the tag went — value present or absent. check "bootstrap: --ts-tag is removed (with value), exit 2" 2 "comes from the pre-auth key" \ - "$ROOT/commands/bootstrap.sh" runner --ts-tag tag:server + "$ROOT/commands/bootstrap.sh" runner-server --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 + "$ROOT/commands/bootstrap.sh" runner-server --ts-tag # staging is a box TENANT role since #31 (the guest, not the VM host), and it # never joins the tailnet — but --ts-tag on it must still die with a story, # not an "unknown flag": scripts from its trait-preset life may pass it, and # the message must say where both the tag AND the join went. check "bootstrap: staging + removed --ts-tag exits 2" 2 "never join the tailnet" \ "$ROOT/commands/bootstrap.sh" staging --ts-tag tag:server -# The old staging effective-tag refusal guarded the VM-HOST shape, which now -# rides the traits (custom/dev --class server) — the catch-all tag:server -# refusal must still own that shape, so grep the general die instead. +# The VM-HOST shape has a named role again (staging-server, #76), but it is +# still not one of the two the control plane manages, so the catch-all +# tag:server refusal must own it. Grep-pinned so a deleted guard cannot ship +# green (repo precedent: the login-path refusal below). check "bootstrap: the catch-all tag:server refusal is present" 0 "" \ - grep -q "Only control-plane and workload are managed by the control plane" "$ROOT/commands/bootstrap.sh" + grep -q "Only control-plane-server and workload-server are managed by the control plane" "$ROOT/commands/bootstrap.sh" +# ...and staging-server must NOT have slipped into the allow-list arm beside +# control-plane-server|workload-server. A new preset silently landing there +# would extend every server grant to a VM host, which is the exact shape the +# refusal exists to prevent — and nothing else in the suite would notice. +check "bootstrap: staging-server is not in the tag:server allow-list" 1 "" \ + grep -qE '^ *control-plane-server\|workload-server\)[^#]*staging-server' "$ROOT/commands/bootstrap.sh" + +# --- the role taxonomy (#76): -server names the family, and it was a hard cut - +# Every machine role carries the suffix; custom and workstation deliberately do +# not. Proven by reaching the ROOT CHECK, which is the last thing before the +# converge and therefore proof the name resolved to a preset. +if [ "$(id -u)" -ne 0 ]; then + for r in control-plane-server workload-server runner-server staging-server dev-server; do + check "bootstrap: role $r resolves" 1 "must run as root" \ + env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" "$r" --no-users + done +fi +# THE HARD CUT. No aliases: the pre-#76 names are gone, and must fail as +# UNKNOWN rather than quietly resolving to anything. Asserted per name because +# an alias accidentally left in for one role is exactly the shape that survives +# review — the taxonomy reads as complete while one old name still works. +# 'staging' is deliberately absent: it still routes to the TENANT mechanism at +# this point in the stack, and the tenant rename lands in its own change. +for r in control-plane workload runner dev; do + check "bootstrap: the pre-#76 name '$r' is gone (hard cut)" 2 "unknown role" \ + "$ROOT/commands/bootstrap.sh" "$r" +done +# ...but the two roles that legitimately carry no suffix must NOT have been +# swept up in the rename. This is the inverse error and it fails silently: a +# workstation that stopped resolving would only surface at someone's laptop. +check "bootstrap: workstation keeps its bare name" 2 "unset TS_AUTHKEY" \ + env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" workstation +check "bootstrap: custom keeps its bare name" 2 "--hostname" \ + "$ROOT/commands/bootstrap.sh" custom --class server --host no --join authkey # --- traits: roles are presets, every trait individually settable (#26) ----- check "bootstrap: unknown role still exits 2" 2 "unknown role" "$ROOT/commands/bootstrap.sh" potato -check "bootstrap: bad --class value exits 2" 2 "human|server" "$ROOT/commands/bootstrap.sh" workload --class potato -check "bootstrap: bad --host value exits 2" 2 "yes|no" "$ROOT/commands/bootstrap.sh" workload --host maybe -check "bootstrap: bad --join value exits 2" 2 "authkey|login" "$ROOT/commands/bootstrap.sh" workload --join carrier-pigeon +check "bootstrap: bad --class value exits 2" 2 "human|server" "$ROOT/commands/bootstrap.sh" workload-server --class potato +check "bootstrap: bad --host value exits 2" 2 "yes|no" "$ROOT/commands/bootstrap.sh" workload-server --host maybe +check "bootstrap: bad --join value exits 2" 2 "authkey|login" "$ROOT/commands/bootstrap.sh" workload-server --join carrier-pigeon check "bootstrap: custom without --hostname exits 2" 2 "--hostname" \ "$ROOT/commands/bootstrap.sh" custom --class server --host no --join authkey check "bootstrap: custom without traits exits 2" 2 "--class" "$ROOT/commands/bootstrap.sh" custom --hostname box1 @@ -75,7 +110,7 @@ check "bootstrap: workstation + TS_AUTHKEY exits 2" 2 "unset TS_AUTHKEY" \ # join=authkey (TS_AUTHKEY fine → falls through to the root check), but # --join login flips it into the TS_AUTHKEY refusal. check "bootstrap: dev --join login + TS_AUTHKEY exits 2" 2 "unset TS_AUTHKEY" \ - env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" dev --join login + env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" dev-server --join login # The login-path inverted assertion needs a real tailnet; grep the refusal so a # deleted guard cannot ship green (repo precedent: staging/runner tag greps). check "bootstrap: login-path tagged refusal is present" 0 "" \ @@ -186,33 +221,33 @@ printf '%s\n' 'maria ops ssh-ed25519 AAAA maria@mac' > "$BOOT_USER # 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 or --no-users is required" \ - "$ROOT/commands/bootstrap.sh" workload + "$ROOT/commands/bootstrap.sh" workload-server 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 + "$ROOT/commands/bootstrap.sh" dev-server --hostname b check "bootstrap: the requirement holds on class=server too" 2 "one of --users" \ - "$ROOT/commands/bootstrap.sh" control-plane --hostname cp + "$ROOT/commands/bootstrap.sh" control-plane-server --hostname cp check "bootstrap: --users needs a value" 2 "needs a value" \ - "$ROOT/commands/bootstrap.sh" workload --users + "$ROOT/commands/bootstrap.sh" workload-server --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 + "$ROOT/commands/bootstrap.sh" workload-server --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" + "$ROOT/commands/bootstrap.sh" workload-server --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" + "$ROOT/commands/bootstrap.sh" workload-server --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" + "$ROOT/commands/bootstrap.sh" workload-server --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" + "$ROOT/commands/bootstrap.sh" workload-server --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 - + "$ROOT/commands/bootstrap.sh" workload-server --users - # A file that parses to ZERO users (#57). Not a parse error — the parser is # right to accept empty, comments-only and whitespace-only files — but it walks # straight through #51's requirement: `--users ./empty` and `--no-users` @@ -227,23 +262,23 @@ cat > "$BOOT_USERS/comments" <<'USERS' USERS printf ' \n\t\n\n' > "$BOOT_USERS/blank" check "bootstrap: an empty users file exits 2" 2 "names no users" \ - "$ROOT/commands/bootstrap.sh" workload --users "$BOOT_USERS/empty" + "$ROOT/commands/bootstrap.sh" workload-server --users "$BOOT_USERS/empty" check "bootstrap: a comments-only users file exits 2" 2 "names no users" \ - "$ROOT/commands/bootstrap.sh" workload --users "$BOOT_USERS/comments" + "$ROOT/commands/bootstrap.sh" workload-server --users "$BOOT_USERS/comments" check "bootstrap: a whitespace-only users file exits 2" 2 "names no users" \ - "$ROOT/commands/bootstrap.sh" workload --users "$BOOT_USERS/blank" + "$ROOT/commands/bootstrap.sh" workload-server --users "$BOOT_USERS/blank" # The refusal must name --no-users, for the same reason the missing-flag one # does: the root-only box IS reachable, it just has to be said out loud. An # error that only reported "no users" would leave the operator who genuinely # wants root-only with no named way to ask for it. check "bootstrap: the zero-user refusal names --no-users as the way to say it" 2 "pass --no-users to leave this box root-only" \ - "$ROOT/commands/bootstrap.sh" workload --users "$BOOT_USERS/empty" + "$ROOT/commands/bootstrap.sh" workload-server --users "$BOOT_USERS/empty" # It must NOT over-refuse: a file that names even one operator passes pre-flight # untouched. Reaching the root check (exit 1) is the proof — same idiom as the # incus precondition's negative cases below. if [ "$(id -u)" -ne 0 ]; then check "bootstrap: a users file naming operators still passes pre-flight" 1 "must run as root" \ - env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" workload --users "$BOOT_USERS/ok" + env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" workload-server --users "$BOOT_USERS/ok" fi # Scope guard (#57): the refusal is BOOTSTRAP's contract, not the parser's and # not apply's. A standalone `rig users apply` against an emptied file is a real @@ -295,19 +330,19 @@ 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" + "$ROOT/commands/bootstrap.sh" dev-server --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" + "$ROOT/commands/bootstrap.sh" dev-server --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" + "$ROOT/commands/bootstrap.sh" dev-server --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" + "$ROOT/commands/bootstrap.sh" dev-server --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 @@ -318,18 +353,18 @@ if [ "$(id -u)" -ne 0 ]; then # 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" + "$ROOT/commands/bootstrap.sh" dev-server --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" + "$ROOT/commands/bootstrap.sh" dev-server --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" + "$ROOT/commands/bootstrap.sh" dev-server --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" + "$ROOT/commands/bootstrap.sh" workload-server --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 @@ -399,10 +434,10 @@ if [ "$(id -u)" -ne 0 ]; then # 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: refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" workload-server --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 + env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" workload-server --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-server --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 @@ -410,7 +445,7 @@ 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 --no-users + check "bootstrap: dev role parses, refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" dev-server --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 --no-users @@ -457,8 +492,8 @@ check "bootstrap: tenant roles dispatch through bootstrap.sh" 0 "claude|codex|gr # A non-server machine (class=human via custom) is NOT that guest, and server # hardening would die at it with server-specific messaging — refuse instead. TEN_FIX="$(mktemp -d)" -printf 'role=dev class=human host=yes join=authkey\n' > "$TEN_FIX/host" -printf 'role=workload class=server host=no join=authkey\n' > "$TEN_FIX/machine" +printf 'role=dev-server class=human host=yes join=authkey\n' > "$TEN_FIX/host" +printf 'role=workload-server class=server host=no join=authkey\n' > "$TEN_FIX/machine" printf 'role=custom class=human host=no join=login\n' > "$TEN_FIX/human" printf 'role=claude tenant=yes host=no\n' > "$TEN_FIX/tenant" check "tenant: staging refuses a non-server machine box" 1 "non-server machine role" \ @@ -586,9 +621,9 @@ marker_warns() { # marker_warns — how many warnings fir 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" +printf 'role=workload-server class=server host=no join=authkey\n' > "$MARKER_FIX/workload" +printf 'role=control-plane-server class=server host=no join=authkey\n' > "$MARKER_FIX/control-plane" +printf 'role=control-plane-server\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 @@ -596,7 +631,7 @@ if [ "$(id -u)" -ne 0 ]; then 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" \ + check "coolify: a bare 'role=control-plane-server' 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 @@ -992,11 +1027,11 @@ hostvm_gate() { # hostvm_gate assert_marker_hosts_vms "$2"' _ "$ROOT" "$1" } HOSTVM_FIX="$(mktemp -d)" -printf 'role=dev class=human host=yes join=authkey\n' > "$HOSTVM_FIX/yes" -printf 'role=workload class=server host=no join=authkey\n' > "$HOSTVM_FIX/no" +printf 'role=dev-server class=human host=yes join=authkey\n' > "$HOSTVM_FIX/yes" +printf 'role=workload-server class=server host=no join=authkey\n' > "$HOSTVM_FIX/no" # A marker that predates the host= trait (or was hand-edited): present, but it # names no host=. Distinct from an ABSENT marker and it must not read as yes. -printf 'role=workload class=server join=authkey\n' > "$HOSTVM_FIX/traitless" +printf 'role=workload-server class=server join=authkey\n' > "$HOSTVM_FIX/traitless" check "users apply: host=yes passes the box-role gate" \ 0 "" hostvm_gate "$HOSTVM_FIX/yes" check "users apply: host=no fails the box-role gate" \ @@ -1384,8 +1419,8 @@ marker_gate() { # marker_gate assert_marker_human "$2"' _ "$ROOT" "$1" } MARKER_DIR="$(mktemp -d)" -printf 'role=workload class=server host=no join=authkey\n' > "$MARKER_DIR/server" -printf 'role=dev class=human host=yes join=authkey\n' > "$MARKER_DIR/human" +printf 'role=workload-server class=server host=no join=authkey\n' > "$MARKER_DIR/server" +printf 'role=dev-server class=human host=yes join=authkey\n' > "$MARKER_DIR/human" check "users close-root: absent marker refuses, names bootstrap as the repair" \ 1 "no /etc/rig/role marker" marker_gate "$MARKER_DIR/absent" check "users close-root: class=server refuses, names the control plane" \ @@ -1549,7 +1584,7 @@ check "install: ...and does not move the default" 0 "rig $VER" irig "$B1/rig" -- # host itself — /etc/rig/role. The deliberate decision: warn and proceed. # Driven against a fixture marker; counting fires proves silence too. MARK="$WORK/role-marker" -printf 'role=workload class=server host=no join=authkey\n' > "$MARK" +printf 'role=workload-server class=server host=no join=authkey\n' > "$MARK" H2="$WORK/h2"; B2="$WORK/b2" check "flip gate: baseline install" 0 "done" inst "$H2" "$B2" check "flip gate: an upgrade on a bootstrapped host WARNS" 0 "this host is bootstrapped" \ From 00f6351e2815da596c9a51fcb49efb65c108d863 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Mon, 20 Jul 2026 00:21:35 +0000 Subject: [PATCH 2/2] fix(bootstrap): stop telling operators to run bare roles; pin the migration Two review findings from the bot round on this stack. BLOCKING (codex-bot, claude-bot -- both, independently). bootstrap-tenant.sh emits the staging guest's tailnet-join next step at the end of a converge ("box shell -> sudo rig bootstrap workload"), repeats it in usage, and two of its refusals recite the old machine-role list. Fixed here rather than on the stacked tenant PR because THIS is the branch that removes the `workload` role -- shipping it alone would print a next step naming a role that no longer exists. None of those four sites is code that ACCEPTS a role, which is why the rename missed them, and is also what makes them the worse failure. A stale flag dies immediately with a usage error. A stale next-step is copy-pasted by a human onto a DIFFERENT box, minutes after the run that printed it reported success, and dies there with no thread back to the cause. So test/cli.sh sweeps every shipped script under bin/ and commands/ for `rig bootstrap ` rather than pinning the four known sites: the next instance of this class will be somewhere else. Proven non-vacuous -- reintroducing the bare `workload` next-step turns the suite red (412/1), restoring it turns it green (413/0). NON-BLOCKING (claude-bot). The migration story was documented and untested: every marker fixture was renamed alongside the code, so nothing asserted what a real pre-rename box does. A `role=control-plane` fixture now pins both halves of the promise -- such a box WARNS on the coolify verbs (its marker no longer names a role that exists) and is never REFUSED. Both halves matter: a rename that turned this into a refusal would break the exact boxes the CHANGELOG promises keep working, on the command that installs the control plane. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 9 +++++++++ commands/bootstrap-tenant.sh | 10 +++++----- test/cli.sh | 30 ++++++++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80b243c..f7c1ad1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,15 @@ on the way to cutting its first release, and this file starts there. branch until it is re-bootstrapped. That check has always been advisory and never a gate, so the run still proceeds and the warning names the repair. + The rename also reaches every string that *tells an operator to run a role*, + not just the code that accepts one — `bootstrap-tenant.sh` emits the staging + guest's tailnet-join next step (`sudo rig bootstrap workload-server`), and + two of its refusals recite the machine-role list. A stale next-step is worse + than a stale flag: it fails when someone copy-pastes it, on a different box, + minutes after the run that printed it reported success. `test/cli.sh` sweeps + every shipped script for pre-rename role names rather than pinning the known + sites, because the next instance of this will be somewhere else. + `dev-server` is `class=human`, which reads like a contradiction and is not: the suffix names the family, the class names the root-SSH door policy, and operators enter a dev box as themselves so `close-root` shuts its door. The diff --git a/commands/bootstrap-tenant.sh b/commands/bootstrap-tenant.sh index a09f23b..f5ea140 100755 --- a/commands/bootstrap-tenant.sh +++ b/commands/bootstrap-tenant.sh @@ -10,7 +10,7 @@ # Creds-free BY CONTRACT: box auto-runs these at mint ('box exec … rig # bootstrap claude'), so every path here is non-interactive and nothing joins # or admits — no tailnet, no keys, no prompts. staging's tailnet join stays -# operator-run ('rig bootstrap workload' through 'box shell'), exactly the +# operator-run ('rig bootstrap workload-server' through 'box shell'), exactly the # creds split box#69 designed. # Convergent: safe to re-run; a second run changes nothing. set -euo pipefail @@ -43,7 +43,7 @@ tenant on top, and re-runs converge an existing box to a new spec. hardening. The tailnet workload join is deliberately NOT here — it holds a credential, so it stays operator-run: `box shell` → `sudo rig bootstrap - workload` with a tagged pre-auth key. + workload-server` with a tagged pre-auth key. --user the tenant user the box seed created (default: the role's name; staging defaults to `ops`) @@ -77,10 +77,10 @@ while [ $# -gt 0 ]; do # from the box seed, and the one trait-shaped thing a staging guest # eventually does (join the tailnet as a workload) is deliberately not # here: it holds a credential, so it stays operator-run. - die "tenant roles have no traits: $1 belongs to the machine roles (control-plane|workload|runner|dev|workstation|custom). A tenant box's shape comes from its seed; staging's tailnet join is operator-run via 'rig bootstrap workload'." 2 ;; + die "tenant roles have no traits: $1 belongs to the machine roles (control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom). A tenant box's shape comes from its seed; staging's tailnet join is operator-run via 'rig bootstrap workload-server'. The METAL that hosts these guests is 'rig bootstrap staging-server'." 2 ;; --ts-tag) [ $# -ge 2 ] && shift - die "--ts-tag is gone and tenant roles never join the tailnet anyway. staging's join is operator-run via 'rig bootstrap workload', where the tag comes from the pre-auth key." 2 ;; + die "--ts-tag is gone and tenant roles never join the tailnet anyway. staging's join is operator-run via 'rig bootstrap workload-server', where the tag comes from the pre-auth key." 2 ;; *) die "unknown flag: $1" 2 ;; esac done @@ -384,7 +384,7 @@ fi log "done — tenant ${ROLE}, user ${TENANT_USER}" if [ "$ROLE" = "staging" ]; then - log "next (operator-run, holds a credential): box shell → sudo rig bootstrap workload --hostname with a tagged pre-auth key" + log "next (operator-run, holds a credential): box shell → sudo rig bootstrap workload-server --hostname with a tagged pre-auth key" else log "next: creds stay with the operator — ${CLI} authenticates through its own interactive login when a human decides" fi diff --git a/test/cli.sh b/test/cli.sh index 5ce8b10..1ff95ac 100644 --- a/test/cli.sh +++ b/test/cli.sh @@ -93,6 +93,19 @@ check "bootstrap: workstation keeps its bare name" 2 "unset TS_AUTHKEY" \ env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" workstation check "bootstrap: custom keeps its bare name" 2 "--hostname" \ "$ROOT/commands/bootstrap.sh" custom --class server --host no --join authkey +# NOTHING may still TELL an operator to run a pre-#76 role. The rename is a +# hard cut, so a next-step string, a usage line or a refusal that still recites +# a bare role name is a command that fails when someone copy-pastes it — and it +# fails later and further from the cause than a broken flag would, because it +# fails on a different box, minutes after this run reported success. The tenant +# script is the one that emits the staging guest's workload-join next step, so +# it is where this bites first (caught in review on #80, fixed here where the +# rename actually happens). Swept across every shipped script rather than +# asserted at the one known site: the next instance of this will be somewhere +# else, and a site-specific check would not see it. +check "roles: no shipped script tells an operator to run a pre-#76 role name" 1 "" \ + grep -rnE "rig bootstrap (control-plane|workload|runner|dev)( |'|\"|$)" \ + "$ROOT/bin/rig" "$ROOT/commands/" # --- traits: roles are presets, every trait individually settable (#26) ----- check "bootstrap: unknown role still exits 2" 2 "unknown role" "$ROOT/commands/bootstrap.sh" potato check "bootstrap: bad --class value exits 2" 2 "human|server" "$ROOT/commands/bootstrap.sh" workload-server --class potato @@ -624,6 +637,12 @@ MARKER_FIX="$(mktemp -d)" printf 'role=workload-server class=server host=no join=authkey\n' > "$MARKER_FIX/workload" printf 'role=control-plane-server class=server host=no join=authkey\n' > "$MARKER_FIX/control-plane" printf 'role=control-plane-server\n' > "$MARKER_FIX/bare-control-plane" +# A PRE-#76 marker, verbatim as a real box bootstrapped before the rename +# carries it. This is the one fixture that must keep its old spelling: the +# CHANGELOG promises such a box takes the warning branch and keeps working, +# and until this existed nothing asserted it — every other fixture here was +# renamed with the code, so the migration story was documented and untested. +printf 'role=control-plane class=server host=no join=authkey\n' > "$MARKER_FIX/pre-rename-cp" 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 @@ -635,6 +654,17 @@ if [ "$(id -u)" -ne 0 ]; then 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 migration story, pinned in both halves: a pre-#76 control plane WARNS + # (its marker no longer names a role that exists) but is never refused. Both + # halves matter — a rename that turned this into a refusal would break the + # exact boxes the CHANGELOG promises keep working, and it would do it on the + # command that installs the control plane. + check "coolify: a PRE-#76 'role=control-plane' marker warns (migration)" 0 "1" \ + marker_warns "$MARKER_FIX/pre-rename-cp" "$ROOT/commands/coolify-install.sh" --version 4.1.2 + check "coolify: ...and is still never refused" 1 "must run as root" \ + env RIG_ROLE_MARKER="$MARKER_FIX/pre-rename-cp" "$ROOT/commands/coolify-install.sh" --version 4.1.2 + check "coolify backup: a PRE-#76 'role=control-plane' marker warns (migration)" 0 "1" \ + marker_warns "$MARKER_FIX/pre-rename-cp" "$ROOT/commands/coolify-backup-install.sh" # 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" \