Merge pull request #79 from dan-claude-bot/feat/machine-role-suffixes

feat(bootstrap)!: machine roles carry a `-server` suffix; `staging-server` restored
This commit is contained in:
Daniel Marin 2026-07-20 13:05:06 +01:00 committed by GitHub
commit e2a8a647e4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 326 additions and 142 deletions

View file

@ -6,6 +6,65 @@ on the way to cutting its first release, and this file starts there.
## Unreleased ## 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.
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
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 ## 0.2.0 — 2026-07-19
### Added ### Added

120
README.md
View file

@ -90,7 +90,7 @@ itself is untouched — what bootstrap converged stays converged.
## Commands ## Commands
### `rig bootstrap <control-plane|workload|runner|dev|workstation|custom>` ### `rig bootstrap <control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom>`
Run as root on the fresh box (over SSH). Convergent — safe to re-run; a 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`, 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.) tenants* below.)
```sh ```sh
rig bootstrap control-plane --hostname my-coolify-box --users ./users rig bootstrap control-plane-server --hostname my-coolify-box --users ./users
rig bootstrap workload --hostname my-prod-box --users ./users rig bootstrap workload-server --hostname my-prod-box --users ./users
rig bootstrap runner --hostname my-ci-box --users ./users rig bootstrap runner-server --hostname my-ci-box --users ./users
rig bootstrap dev --hostname my-dev-box --users ./users rig bootstrap dev-server --hostname my-dev-box --users ./users
rig bootstrap workstation --hostname my-laptop --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 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: **requires** it:
```sh ```sh
rig bootstrap dev --hostname my-dev-box --users ./users # one command, people included rig bootstrap dev-server --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 --no-users # deliberately root-only
``` ```
`--users <path>` runs exactly what `rig users apply --file <path>` runs, as `--users <path>` runs exactly what `rig users apply --file <path>` 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` | | `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) | | `join` | `authkey`, `login` | tagged pre-auth key (fleet identity) vs interactive browser login (user-owned device) |
| role | class | host | join | tailnet tag | | role | class | host | join | tailnet tag |
|-----------------|--------|------|---------|-------------| |------------------------|--------|------|---------|-------------|
| `control-plane` | server | no | authkey | `tag:server` | | `control-plane-server` | server | no | authkey | `tag:server` |
| `workload` | server | no | authkey | `tag:server` | | `workload-server` | server | no | authkey | `tag:server` |
| `runner` | server | no | authkey | `tag:ci` — refuses `tag:server` | | `runner-server` | server | no | authkey | `tag:ci` — refuses `tag:server` |
| `dev` | human | yes | authkey | `tag:local` — refuses `tag:server` | | `staging-server` | server | yes | authkey | `tag:local` — refuses `tag:server` |
| `workstation` | human | yes | login | untagged — any tag refused | | `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 > **The suffix names the family, not the class** (#76). rig builds two kinds
> preset (`class=server host=yes join=authkey`). The name now belongs to the > of thing on opposite sides of a trust boundary — tailnet **machines** it
> box TENANT family — the *guest*, not the host under it — because that is > converges, and **guests** a box mints — and for a while nothing in a role
> what "staging" mostly names in practice (the box the control plane will > name said which you were asking for. `staging` made that concrete: the word
> manage), and two meanings on one word was the worse bug. The host shape > named both the metal that hosts guests and the guests on it, and only one of
> lost nothing: it is one flag away — `rig bootstrap dev --class server > them could have it. So `-server` marks a fleet machine and `-box` marks a
> --hostname my-vm-host` — or fully spelled as `custom --class server --host > box tenant, everywhere, and `staging-server` / `staging-box` are simply the
> yes --join authkey`. Tag policy is unchanged: mint the host's key with > two halves spelled out. `staging-server` restores the VM-host preset #31
> `tag:local`; an effective `tag:server` is refused on every role but > retired, under a name that cannot be confused with its own guests.
> `control-plane` and `workload`. >
> 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 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 the only shapes it manages — every other role refuses an effective
`tag:server` after join, one rule instead of per-role exceptions. `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 > 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. > 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 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 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 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 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 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 the [`box`](https://github.com/heavy-duty/box) CLI, each converged from inside
with the tenant roles and (for staging guests) `rig bootstrap workload` — with the tenant roles and (for staging guests) `rig bootstrap workload-server` —
rides the traits since #31 (`--class server --host yes --join authkey`; see is the `staging-server` role (`--class server --host yes --join authkey`; see
the note above). It is `class=server`: an unattended VM appliance — operators 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 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* 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 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 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 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 > fork); `RIG_SKIP_BOX_INSTALL=1` opts out entirely for a host whose box you
> manage by hand. > 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 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 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 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 — 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 prompt, in the shipped script). The one creds-holding step a staging guest
eventually needs — the tailnet workload join — stays **operator-run**, exactly 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
<name> --users <path>` (or `--no-users` — a guest's door is `box shell`, gated <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 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 `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 Per role, the whole identity picture at a glance — issue #25's class
comparison, translated onto the traits that replaced the class binary: comparison, translated onto the traits that replaced the class binary:
| role | class | host | join | who lives here | root SSH after `rig users apply` | | 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 | | `control-plane-server` | server | no | authkey | nobody — Coolify runs here | open — the automation door |
| `workload` | server | no | authkey | nobody — deployed services run here | open — the automation door | | `workload-server` | 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 | | `runner-server` | 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` | | `staging-server` | server | yes | authkey | nobody — it mints and hosts guests | open — the automation door |
| `workstation` | human | yes | login | its owner | closed by `rig users close-root` | | `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 (`staging-server` is that unattended VM-host appliance, and the row above is
`class=server host=yes join=authkey` shape: nobody lives there, root SSH stays the whole of it: nobody lives there, root SSH stays open as the automation
open as the automation door. The box TENANT roles sit outside this table on door. The box TENANT roles sit outside this table on
purpose: a guest is not a tailnet machine, and its marker carries no `class=`, purpose: a guest is not a tailnet machine, and its marker carries no `class=`,
so `rig users close-root` fails closed on it.) 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 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 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, 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.** **`class` decides root SSH's fate — after `rig users apply`, never before.**
On `class=human`, root SSH closes entirely (`rig users close-root`, below). On `class=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. in as. It is a machine door, never a human one.
**Where this diverges from #17's original table:** that table let the **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 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 person's box — and on every server-class machine root SSH is the management
plane rig itself converges through, so `close-root` refuses there plane rig itself converges through, so `close-root` refuses there
@ -640,11 +664,11 @@ throwaway containers on every push.
### `rig runner install --repo <owner/repo>` ### `rig runner install --repo <owner/repo>`
Runner box only, run after `rig bootstrap runner` (the same two-step rhythm Runner box only, run after `rig bootstrap runner-server` (the same two-step rhythm
as `bootstrap control-plane` → `coolify install`): as `bootstrap control-plane-server` → `coolify install`):
```sh ```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 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 fixture markers), the pure parameter table, and the rendered agent-context
file — guard note included — plus absence-greps for the creds-free contract; 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 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 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 second run → destroy, remove the node from the tailnet. The tenant rehearsal
is the same shape, creds-free: container + seed user → `rig bootstrap claude` is the same shape, creds-free: container + seed user → `rig bootstrap claude`

View file

@ -13,7 +13,8 @@ usage() {
usage: rig <command> [args] usage: rig <command> [args]
commands: commands:
bootstrap <control-plane|workload|runner|dev|workstation|custom> bootstrap <control-plane-server|workload-server|runner-server|staging-server|
dev-server|workstation|custom>
(--users <path> | --no-users) (--users <path> | --no-users)
[--hostname <name>] [--class <human|server>] [--host <yes|no>] [--hostname <name>] [--class <human|server>] [--host <yes|no>]
[--join <authkey|login>] [--join <authkey|login>]
@ -27,7 +28,9 @@ commands:
overrides its trait, and custom states all of them. Prompts for a overrides its trait, and custom states all of them. Prompts for a
single-use TAGGED tailnet pre-auth key (TS_AUTHKEY env overrides the single-use TAGGED tailnet pre-auth key (TS_AUTHKEY env overrides the
prompt); the key's tags are the tailnet tag, verified after join — 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 (workstation) needs no key: interactive login, node must come up
untagged. Run as root. untagged. Run as root.
bootstrap <claude|codex|grok|staging> [--user <name>] bootstrap <claude|codex|grok|staging> [--user <name>]
@ -36,7 +39,7 @@ commands:
the system PATH, and the agent-context file (with the box#80 guard: 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 never run box setup-host inside a box). staging lands box#69's server
posture — docker + sshd hardening; its tailnet join stays operator-run 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. auto-runs these at mint. Run as root, inside the box.
coolify install --version <pin> coolify install --version <pin>
Pinned Coolify install (AUTOUPDATE=false). Control-plane box only. Pinned Coolify install (AUTOUPDATE=false). Control-plane box only.

View file

@ -10,7 +10,7 @@
# Creds-free BY CONTRACT: box auto-runs these at mint ('box exec … rig # 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 # 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 # 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. # creds split box#69 designed.
# Convergent: safe to re-run; a second run changes nothing. # Convergent: safe to re-run; a second run changes nothing.
set -euo pipefail 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 hardening. The tailnet workload join is deliberately
NOT here — it holds a credential, so it stays NOT here — it holds a credential, so it stays
operator-run: `box shell``sudo rig bootstrap operator-run: `box shell``sudo rig bootstrap
workload` with a tagged pre-auth key. workload-server` with a tagged pre-auth key.
--user <name> the tenant user the box seed created (default: the --user <name> the tenant user the box seed created (default: the
role's name; staging defaults to `ops`) 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 # from the box seed, and the one trait-shaped thing a staging guest
# eventually does (join the tailnet as a workload) is deliberately not # eventually does (join the tailnet as a workload) is deliberately not
# here: it holds a credential, so it stays operator-run. # 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) --ts-tag)
[ $# -ge 2 ] && shift [ $# -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 ;; *) die "unknown flag: $1" 2 ;;
esac esac
done done
@ -384,7 +384,7 @@ fi
log "done — tenant ${ROLE}, user ${TENANT_USER}" log "done — tenant ${ROLE}, user ${TENANT_USER}"
if [ "$ROLE" = "staging" ]; then if [ "$ROLE" = "staging" ]; then
log "next (operator-run, holds a credential): box shell → sudo rig bootstrap workload --hostname <name> with a tagged pre-auth key" log "next (operator-run, holds a credential): box shell → sudo rig bootstrap workload-server --hostname <name> with a tagged pre-auth key"
else else
log "next: creds stay with the operator — ${CLI} authenticates through its own interactive login when a human decides" log "next: creds stay with the operator — ${CLI} authenticates through its own interactive login when a human decides"
fi fi

View file

@ -22,7 +22,8 @@ die() { printf 'rig-bootstrap: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; }
usage() { usage() {
cat <<'EOF' cat <<'EOF'
usage: rig bootstrap <control-plane|workload|runner|dev|workstation|custom> usage: rig bootstrap <control-plane-server|workload-server|runner-server|
staging-server|dev-server|workstation|custom>
(--users <path> | --no-users) (--users <path> | --no-users)
[--hostname <name>] [--class <human|server>] [--hostname <name>] [--class <human|server>]
[--host <yes|no>] [--join <authkey|login>] [--host <yes|no>] [--join <authkey|login>]
@ -60,23 +61,39 @@ operator file has nothing to converge in there.
Roles are presets over the three traits; any flag overrides its trait. Roles are presets over the three traits; any flag overrides its trait.
custom presets nothing and requires --hostname plus all three traits. custom presets nothing and requires --hostname plus all three traits.
role class host join role class host join
control-plane server no authkey control-plane-server server no authkey
workload server no authkey workload-server server no authkey
runner server no authkey runner-server server no authkey
dev human yes authkey staging-server server yes authkey
workstation human yes login dev-server human yes authkey
workstation human yes login
The former staging VM-host preset is now spelled through the traits: THE SUFFIX NAMES THE FAMILY, not the class. '-server' means this role builds a
'custom --class server --host yes --join authkey' (or 'dev --class server'). fleet MACHINE — a tailnet node rig converges; '-box' (the tenant roles) means a
'staging' names the box TENANT role today — the guest, not the host. 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, 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 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 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 (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 outright, and only control-plane-server and workload-server may carry
the only shapes the control plane manages). Mint a correctly-tagged key. 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 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. 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) --------- # --- args (validated before the root check, so errors are testable) ---------
ROLE="${1:-}" ROLE="${1:-}"
case "$ROLE" in 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) claude|codex|grok|staging)
# The box TENANT roles (#31) are a different family — guests a box mints, # The box TENANT roles (#31) are a different family — guests a box mints,
# never tailnet machines — and live in their own mechanism, one script # 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. # stays the single entrypoint for both families.
exec "$HERE/bootstrap-tenant.sh" "$@" ;; exec "$HERE/bootstrap-tenant.sh" "$@" ;;
-h|--help) usage; exit 0 ;; -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 ;; "") 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|workload|runner|dev|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 esac
# Role→traits map — the single place a role's shape is declared (issue #26). # 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. # for the shape nobody foresaw — it declares nothing and must state all three.
CLASS="" HOST="" JOIN="" CLASS="" HOST="" JOIN=""
case "$ROLE" in case "$ROLE" in
control-plane) CLASS=server HOST=no JOIN=authkey ;; control-plane-server) CLASS=server HOST=no JOIN=authkey ;;
workload) CLASS=server HOST=no JOIN=authkey ;; workload-server) CLASS=server HOST=no JOIN=authkey ;;
runner) CLASS=server HOST=no JOIN=authkey ;; runner-server) CLASS=server HOST=no JOIN=authkey ;;
dev) CLASS=human HOST=yes JOIN=authkey ;; # The unattended VM host — the shape #31 retired when 'staging' moved to the
workstation) CLASS=human HOST=yes JOIN=login ;; # 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) ;; custom) ;;
esac esac
@ -430,25 +452,26 @@ verify_effective_tag() {
fi fi
# tag:server policy is DERIVED, not a trait: it means "the control plane # 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 — # control plane manages. Everything else refuses it on the EFFECTIVE tag —
# strictly stronger than the old request-time check, which only guarded the # 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 # 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 # 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, # even know. Refused, never warned; rig can DETECT this but cannot FIX it,
# so each refusal names its repair. # so each refusal names its repair.
if printf '%s\n' "$tags" | grep -qx 'tag:server'; then if printf '%s\n' "$tags" | grep -qx 'tag:server'; then
case "$ROLE" in case "$ROLE" in
control-plane|workload) ;; control-plane-server|workload-server) ;;
runner) runner-server)
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)." ;; 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 # This arm owns the VM-host shape too — 'staging-server' by name now,
# covered (custom/dev --class server): a host is never managed by the # 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 # control plane — its guest VMs are — so tag:server is refused there
# like everywhere else outside control-plane|workload. # like everywhere else outside the two control-plane-managed shapes.
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)." ;; # 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 esac
fi fi
@ -703,9 +726,9 @@ if [ -n "$USERS_FILE" ]; then
fi fi
log "done — role ${ROLE}, hostname ${TS_HOSTNAME}" log "done — role ${ROLE}, hostname ${TS_HOSTNAME}"
if [ "$ROLE" = "control-plane" ]; then if [ "$ROLE" = "control-plane-server" ]; then
log "next: rig coolify install --version <pin>" log "next: rig coolify install --version <pin>"
elif [ "$ROLE" = "runner" ]; then elif [ "$ROLE" = "runner-server" ]; then
log "next: rig runner install --repo <owner/repo> --version <pin>" log "next: rig runner install --repo <owner/repo> --version <pin>"
fi fi
# Every class gets operators: humans always enter as themselves and elevate via # Every class gets operators: humans always enter as themselves and elevate via

View file

@ -77,10 +77,13 @@ done
# certainly means the wrong SSH session — but the marker is advisory and may be # 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 # 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). # 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}")" MARKER_LINE="$(read_role_marker "${RIG_ROLE_MARKER:-/etc/rig/role}")"
case "$MARKER_LINE" in case "$MARKER_LINE" in
""|"role=control-plane"|"role=control-plane "*) ;; ""|"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; if this is the wrong box, stop here and re-check your SSH session." ;; *) 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 esac
# --- guards ---------------------------------------------------------------- # --- guards ----------------------------------------------------------------

View file

@ -51,10 +51,17 @@ fi
# are: the harness proves it non-root, and reading a 0644 file needs no # 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 # privilege. RIG_ROLE_MARKER overrides the path so tests point it at fixtures
# (repo precedent: users-apply, users-close-root). # (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}")" MARKER_LINE="$(read_role_marker "${RIG_ROLE_MARKER:-/etc/rig/role}")"
case "$MARKER_LINE" in case "$MARKER_LINE" in
""|"role=control-plane"|"role=control-plane "*) ;; ""|"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; 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." ;; *) 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 esac
[ "$(id -u)" -eq 0 ] || die "must run as root" [ "$(id -u)" -eq 0 ] || die "must run as root"

View file

@ -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: 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: --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 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: 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 --hostname 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 # --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 # 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 # asserted the request-time refusal THROUGH this flag; that policy now lives on
@ -44,25 +44,73 @@ 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 # 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. # 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" \ 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" \ 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 # 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, # 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 # 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. # 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" \ check "bootstrap: staging + removed --ts-tag exits 2" 2 "never join the tailnet" \
"$ROOT/commands/bootstrap.sh" staging --ts-tag tag:server "$ROOT/commands/bootstrap.sh" staging --ts-tag tag:server
# The old staging effective-tag refusal guarded the VM-HOST shape, which now # The VM-HOST shape has a named role again (staging-server, #76), but it is
# rides the traits (custom/dev --class server) — the catch-all tag:server # still not one of the two the control plane manages, so the catch-all
# refusal must still own that shape, so grep the general die instead. # 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 "" \ 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
# 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) ----- # --- 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: 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 --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 --host maybe 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 --join carrier-pigeon 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" \ check "bootstrap: custom without --hostname exits 2" 2 "--hostname" \
"$ROOT/commands/bootstrap.sh" custom --class server --host no --join authkey "$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 check "bootstrap: custom without traits exits 2" 2 "--class" "$ROOT/commands/bootstrap.sh" custom --hostname box1
@ -75,7 +123,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=authkey (TS_AUTHKEY fine → falls through to the root check), but
# --join login flips it into the TS_AUTHKEY refusal. # --join login flips it into the TS_AUTHKEY refusal.
check "bootstrap: dev --join login + TS_AUTHKEY exits 2" 2 "unset TS_AUTHKEY" \ 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 # 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). # deleted guard cannot ship green (repo precedent: staging/runner tag greps).
check "bootstrap: login-path tagged refusal is present" 0 "" \ check "bootstrap: login-path tagged refusal is present" 0 "" \
@ -186,33 +234,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 # to skip it type the identical command — the error is the only place rig can
# tell them apart. # tell them apart.
check "bootstrap: omitting --users and --no-users exits 2" 2 "one of --users <path> or --no-users is required" \ check "bootstrap: omitting --users and --no-users exits 2" 2 "one of --users <path> 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" \ 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" \ 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" \ 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 # 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 # precedence rule decide who may enter the box. Both orders, because a
# "last flag wins" implementation would pass one of them silently. # "last flag wins" implementation would pass one of them silently.
check "bootstrap: --users with --no-users exits 2" 2 "contradictory" \ 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" \ 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, # 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. # 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" \ 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" \ 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" \ 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 # '-' 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 # bootstrap: stdin here is the pre-auth key prompt's. Refused, with the split
# ('--no-users' then apply by hand) named. # ('--no-users' then apply by hand) named.
check "bootstrap: --users - is refused, naming the pre-auth key prompt" 2 "pre-auth key prompt" \ 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 # 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 # right to accept empty, comments-only and whitespace-only files — but it walks
# straight through #51's requirement: `--users ./empty` and `--no-users` # straight through #51's requirement: `--users ./empty` and `--no-users`
@ -227,23 +275,23 @@ cat > "$BOOT_USERS/comments" <<'USERS'
USERS USERS
printf ' \n\t\n\n' > "$BOOT_USERS/blank" printf ' \n\t\n\n' > "$BOOT_USERS/blank"
check "bootstrap: an empty users file exits 2" 2 "names no users" \ 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" \ 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" \ 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 # 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 # 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 # error that only reported "no users" would leave the operator who genuinely
# wants root-only with no named way to ask for it. # 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" \ 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 # 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 # untouched. Reaching the root check (exit 1) is the proof — same idiom as the
# incus precondition's negative cases below. # incus precondition's negative cases below.
if [ "$(id -u)" -ne 0 ]; then if [ "$(id -u)" -ne 0 ]; then
check "bootstrap: a users file naming operators still passes pre-flight" 1 "must run as root" \ 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 fi
# Scope guard (#57): the refusal is BOOTSTRAP's contract, not the parser's and # 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 # not apply's. A standalone `rig users apply` against an emptied file is a real
@ -295,19 +343,19 @@ chmod +x "$INCUS_SHIM_NO/getent" "$INCUS_SHIM_YES/getent" \
"$BOXLESS_SHIM/getent" "$INCUS_SHIM_YES/box" "$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" \ 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" \ 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" \ 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" \ 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 # 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 # 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. # 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" \ 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" \ 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'" \ 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" \ 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 if [ "$(id -u)" -ne 0 ]; then
# It must NOT fire in the three shapes that are not doomed. A users file with # 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 # no box-role user converges fine on a host that never saw Incus (refusing it
@ -318,18 +366,18 @@ if [ "$(id -u)" -ne 0 ]; then
# root check (exit 1) is the proof each passed the precondition. # 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" \ 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" \ 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" \ 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" \ 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" \ 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" \ 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 # 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. # 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" \ 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" \ 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 fi
# rig does NOT resolve the open "should rig install box" question here: the # 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 # precondition refuses, it never calls setup-host itself. A grep that finds
@ -399,10 +447,10 @@ if [ "$(id -u)" -ne 0 ]; then
# required (#51), so reaching the root check at all proves it was accepted. # 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 # --no-users here keeps these asserts about the ROOT CHECK; the --users path
# gets its own root-check assert below, against a valid fixture. # 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" \ 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" 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 --no-users 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 # 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. # 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 # RIG_ROLE_MARKER points at an absent fixture: the tenant marker guard runs
@ -410,7 +458,7 @@ if [ "$(id -u)" -ne 0 ]; then
# a real /etc/rig/role of its own. # a real /etc/rig/role of its own.
check "bootstrap: staging dispatches to the tenant mechanism, refuses non-root" 1 "must run as root" \ 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 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: 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" \ 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 env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" custom --hostname b --class server --host no --join authkey --no-users
@ -457,8 +505,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 # 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. # hardening would die at it with server-specific messaging — refuse instead.
TEN_FIX="$(mktemp -d)" TEN_FIX="$(mktemp -d)"
printf 'role=dev class=human host=yes join=authkey\n' > "$TEN_FIX/host" printf 'role=dev-server 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=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=custom class=human host=no join=login\n' > "$TEN_FIX/human"
printf 'role=claude tenant=yes host=no\n' > "$TEN_FIX/tenant" 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" \ check "tenant: staging refuses a non-server machine box" 1 "non-server machine role" \
@ -586,9 +634,15 @@ marker_warns() { # marker_warns <marker_path> <cmd...> — how many warnings fir
env RIG_ROLE_MARKER="$marker" "$@" 2>&1 | grep -c "not a control-plane box" || true env RIG_ROLE_MARKER="$marker" "$@" 2>&1 | grep -c "not a control-plane box" || true
} }
MARKER_FIX="$(mktemp -d)" MARKER_FIX="$(mktemp -d)"
printf 'role=workload class=server host=no join=authkey\n' > "$MARKER_FIX/workload" printf 'role=workload-server 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-server class=server host=no join=authkey\n' > "$MARKER_FIX/control-plane"
printf 'role=control-plane\n' > "$MARKER_FIX/bare-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 if [ "$(id -u)" -ne 0 ]; then
check "coolify: warns on a non-control-plane marker" 0 "1" \ 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 marker_warns "$MARKER_FIX/workload" "$ROOT/commands/coolify-install.sh" --version 4.1.2
@ -596,10 +650,21 @@ if [ "$(id -u)" -ne 0 ]; then
marker_warns "$MARKER_FIX/control-plane" "$ROOT/commands/coolify-install.sh" --version 4.1.2 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 # 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. # 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 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" \ 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 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 # 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. # root check (exit 1), never turned into a marker refusal.
check "coolify: the marker warns but never refuses" 1 "must run as root" \ check "coolify: the marker warns but never refuses" 1 "must run as root" \
@ -992,11 +1057,11 @@ hostvm_gate() { # hostvm_gate <marker_path>
assert_marker_hosts_vms "$2"' _ "$ROOT" "$1" assert_marker_hosts_vms "$2"' _ "$ROOT" "$1"
} }
HOSTVM_FIX="$(mktemp -d)" HOSTVM_FIX="$(mktemp -d)"
printf 'role=dev class=human host=yes join=authkey\n' > "$HOSTVM_FIX/yes" printf 'role=dev-server 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=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 # 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. # 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" \ check "users apply: host=yes passes the box-role gate" \
0 "" hostvm_gate "$HOSTVM_FIX/yes" 0 "" hostvm_gate "$HOSTVM_FIX/yes"
check "users apply: host=no fails the box-role gate" \ check "users apply: host=no fails the box-role gate" \
@ -1384,8 +1449,8 @@ marker_gate() { # marker_gate <marker_path>
assert_marker_human "$2"' _ "$ROOT" "$1" assert_marker_human "$2"' _ "$ROOT" "$1"
} }
MARKER_DIR="$(mktemp -d)" MARKER_DIR="$(mktemp -d)"
printf 'role=workload class=server host=no join=authkey\n' > "$MARKER_DIR/server" printf 'role=workload-server 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=dev-server class=human host=yes join=authkey\n' > "$MARKER_DIR/human"
check "users close-root: absent marker refuses, names bootstrap as the repair" \ check "users close-root: absent marker refuses, names bootstrap as the repair" \
1 "no /etc/rig/role marker" marker_gate "$MARKER_DIR/absent" 1 "no /etc/rig/role marker" marker_gate "$MARKER_DIR/absent"
check "users close-root: class=server refuses, names the control plane" \ check "users close-root: class=server refuses, names the control plane" \
@ -1549,7 +1614,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. # host itself — /etc/rig/role. The deliberate decision: warn and proceed.
# Driven against a fixture marker; counting fires proves silence too. # Driven against a fixture marker; counting fires proves silence too.
MARK="$WORK/role-marker" 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" H2="$WORK/h2"; B2="$WORK/b2"
check "flip gate: baseline install" 0 "done" inst "$H2" "$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" \ check "flip gate: an upgrade on a bootstrapped host WARNS" 0 "this host is bootstrapped" \