rig/README.md

1283 lines
71 KiB
Markdown
Raw Normal View History

# rig
2026-07-10 20:37:08 +00:00
A CLI that turns a **pristine Debian server into a hardened, tailnet-joined
node** — one curl, one command. A second command installs a version-pinned
Coolify on a control-plane box. And inside a [box](https://github.com/heavy-duty/box)-minted
feat(bootstrap)!: box tenant roles carry a -box suffix The other half of #76. claude -> claude-box, codex -> codex-box, grok -> grok-box, staging -> staging-box, so a role name always says which family it belongs to: -server builds a fleet machine, -box converges a guest a box minted. With both halves in, the two families can no longer collide on a word the way `staging` did. The role carries the suffix; nothing inside the guest does. A tenant user is the account the box SEED created (BOX_USER) and each agent CLI reads its own dotdir, so claude-box still converges the `claude` user and still writes ~/.claude/CLAUDE.md. Every rename here is a $ROLE comparison or a case arm -- no CLI binary name, no dotdir path, and no account moved. README's tenant table now shows role and user in adjacent columns, because that distinction stopped being cosmetic the moment they differed. Hard cut, no aliases. The old names are refused as unknown at BOTH entrypoints -- `rig bootstrap <name>` and bootstrap-tenant.sh directly -- and the suite asserts each of the four at each, because bootstrap.sh keeps its own dispatch list and a name could survive in one and not the other. An alias left in for a single tenant is the shape that survives review: the taxonomy reads complete while one old name still quietly converges. The consequence is cross-repo. A seed carrying BOX_BOOTSTRAP_ROLE="claude" now fails its own mint-time bootstrap, so heavy-duty/box#123 updates the seeds and must land after this. Closes #76 (tenant half) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:05:19 +00:00
guest, the same verb converges the **box tenants** — claude-box, codex-box,
grok-box, kimi-box, staging-box — from thin, creds-free seeds (see *the box tenants*
feat(bootstrap)!: box tenant roles carry a -box suffix The other half of #76. claude -> claude-box, codex -> codex-box, grok -> grok-box, staging -> staging-box, so a role name always says which family it belongs to: -server builds a fleet machine, -box converges a guest a box minted. With both halves in, the two families can no longer collide on a word the way `staging` did. The role carries the suffix; nothing inside the guest does. A tenant user is the account the box SEED created (BOX_USER) and each agent CLI reads its own dotdir, so claude-box still converges the `claude` user and still writes ~/.claude/CLAUDE.md. Every rename here is a $ROLE comparison or a case arm -- no CLI binary name, no dotdir path, and no account moved. README's tenant table now shows role and user in adjacent columns, because that distinction stopped being cosmetic the moment they differed. Hard cut, no aliases. The old names are refused as unknown at BOTH entrypoints -- `rig bootstrap <name>` and bootstrap-tenant.sh directly -- and the suite asserts each of the four at each, because bootstrap.sh keeps its own dispatch list and a name could survive in one and not the other. An alias left in for a single tenant is the shape that survives review: the taxonomy reads complete while one old name still quietly converges. The consequence is cross-repo. A seed carrying BOX_BOOTSTRAP_ROLE="claude" now fails its own mint-time bootstrap, so heavy-duty/box#123 updates the seeds and must land after this. Closes #76 (tenant half) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:05:19 +00:00
below).
Philosophy (shared with [box](https://github.com/heavy-duty/box)):
**public tool, private state**. rig carries plumbing logic only — no
hostnames, no bindings, no secrets, nothing about *your* infrastructure. It
takes arguments, does its work, and stores no credential, ever.
## Install
```sh
curl -fsSL https://raw.githubusercontent.com/heavy-duty/rig/main/install.sh | RIG_REF=main bash
```
This README tracks `main`, so the quick start installs that same development
tree. To install a stable version instead, use the latest-release or pinned-tag
channel and read the documentation shipped with it at
`$RIG_HOME/current/README.md` (`~/.local/share/rig/current/README.md` with the
default install root). Three channels come from the same script; `RIG_REF`
picks:
```sh
curl -fsSL .../install.sh | bash # the latest release
curl -fsSL .../install.sh | RIG_REF=0.2.0 bash # pinned to a release
curl -fsSL .../install.sh | RIG_REF=main bash # the development tree
```
A tag outranks a branch of the same name (the pin must win); anything that
is not a tag falls back to `refs/heads/<ref>`.
feat(install): versioned installs and a real uninstall — box#79's layout, ported install.sh now lands every version at <root>/versions/<v> (each tree carrying its own VERSION + INSTALLED_FROM), tracks the default through an atomically-flipped 'current' symlink, and converges instead of clobbering: a same-version re-run is a no-op that says so, RIG_REINSTALL=1 replaces that version's tree by two renames (delete last), and a new version installs side by side. A pre-versioning flat tree is migrated in place — two renames, preserved bit for bit, VERSION-less trees as 0.0.0-unknown. bin/rig grows the table verbs: 'rig versions' (current + running marked), 'rig use <v>' (atomic flip, asserted effective through the PATH chain), 'rig uninstall [<v>|--all]' — which ENDS with an absence assert: every removed path re-checked, survivors exit 1 as 'uninstall INCOMPLETE' by name. One strict valid_version gate guards every place a version string becomes a path (byte-identical copies in bin/rig and install.sh, diffed by the suite so they cannot drift). Plus the VERSION file and 'rig --version' (rig#32's first item, folded in minimally — rig main had neither). The flip gate is rig's own shape, deliberately: box refuses flips under existing boxes; rig's stake is the converged host, so a flip (upgrade, 'rig use', full uninstall) on a host where /etc/rig/role exists WARNS and proceeds — no user state to strand, and upgrading a bootstrapped host is the normal case. The suite drives REAL installer runs (RIG_INSTALL_SOURCE against throwaway RIG_HOME/RIG_BIN roots): fresh install, converge, reinstall, side-by-side upgrade, use/rollback, both migrations, hostile flat VERSION, wedged- symlink healing, the marker warn gate (RIG_ROLE_MARKER fixtures), both uninstalls and the INCOMPLETE scream — driven, not grepped. Closes #35. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:33:17 +00:00
The layout, under the install root (`~/.local/share/rig`):
```
versions/<version>/ one full tree per installed version
current -> versions/<v> the tracked default
$BINDIR/rig -> current/bin/rig the PATH entry, riding the chain
```
`rig` lands on your PATH via `~/.local/bin` (`/usr/local/bin` when root).
**Re-running is a safe converge.** Installing a version you already have
changes nothing and says so (`RIG_REINSTALL=1` replaces that version's
tree); a **new** version installs side by side and becomes the default — so
"re-run any time to upgrade" stays true, and now means *upgrade to the
latest release*; every version you had stays installed as the way back:
feat(install): versioned installs and a real uninstall — box#79's layout, ported install.sh now lands every version at <root>/versions/<v> (each tree carrying its own VERSION + INSTALLED_FROM), tracks the default through an atomically-flipped 'current' symlink, and converges instead of clobbering: a same-version re-run is a no-op that says so, RIG_REINSTALL=1 replaces that version's tree by two renames (delete last), and a new version installs side by side. A pre-versioning flat tree is migrated in place — two renames, preserved bit for bit, VERSION-less trees as 0.0.0-unknown. bin/rig grows the table verbs: 'rig versions' (current + running marked), 'rig use <v>' (atomic flip, asserted effective through the PATH chain), 'rig uninstall [<v>|--all]' — which ENDS with an absence assert: every removed path re-checked, survivors exit 1 as 'uninstall INCOMPLETE' by name. One strict valid_version gate guards every place a version string becomes a path (byte-identical copies in bin/rig and install.sh, diffed by the suite so they cannot drift). Plus the VERSION file and 'rig --version' (rig#32's first item, folded in minimally — rig main had neither). The flip gate is rig's own shape, deliberately: box refuses flips under existing boxes; rig's stake is the converged host, so a flip (upgrade, 'rig use', full uninstall) on a host where /etc/rig/role exists WARNS and proceeds — no user state to strand, and upgrading a bootstrapped host is the normal case. The suite drives REAL installer runs (RIG_INSTALL_SOURCE against throwaway RIG_HOME/RIG_BIN roots): fresh install, converge, reinstall, side-by-side upgrade, use/rollback, both migrations, hostile flat VERSION, wedged- symlink healing, the marker warn gate (RIG_ROLE_MARKER fixtures), both uninstalls and the INCOMPLETE scream — driven, not grepped. Closes #35. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:33:17 +00:00
```sh
rig versions # what is installed, which is current, which is running
rig use <version> # flip the default (atomic; asserts the flip took)
```
On a **bootstrapped host** (one where `/etc/rig/role` exists) switching the
default — by upgrade or by `rig use` — prints a WARNING, because a
different rig under a converged host changes what a re-converge
(`rig bootstrap`, `rig users apply`) would do. It warns rather than
refuses: unlike box (which protects live boxes), rig holds no user state a
flip can strand, and upgrading a bootstrapped host is the normal case.
A pre-versioning flat install is migrated into `versions/` automatically on
the next installer run — the tree is moved, not re-downloaded, and
preserved bit for bit. For scripting: `RIG_HOME`/`RIG_BIN` override the
install root and bin dir, `RIG_INSTALL_SOURCE=<dir-or-tarball>` installs
from a local tree instead of downloading (how the test suite proves the
installer under review), and `RIG_YES=1` answers `rig uninstall`'s prompt
in automation.
### Uninstall
`rig uninstall` is the real uninstall — no more "rm -rf two paths" prose —
and it **ends with an absence assert**: every path it removed is
re-checked, and any survivor makes it exit 1 naming the leftovers instead
of reporting a clean uninstall that wasn't.
```sh
rig uninstall <version> # one non-current version (side-by-side cleanup)
rig uninstall --all # everything: every version, current, the PATH symlinks
```
Asks before removing; `--force` or `RIG_YES=1` skips the prompt. The host
itself is untouched — what bootstrap converged stays converged.
## Commands
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 <noreply@anthropic.com>
2026-07-20 00:01:16 +00:00
### `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
feat(bootstrap)!: box tenant roles carry a -box suffix The other half of #76. claude -> claude-box, codex -> codex-box, grok -> grok-box, staging -> staging-box, so a role name always says which family it belongs to: -server builds a fleet machine, -box converges a guest a box minted. With both halves in, the two families can no longer collide on a word the way `staging` did. The role carries the suffix; nothing inside the guest does. A tenant user is the account the box SEED created (BOX_USER) and each agent CLI reads its own dotdir, so claude-box still converges the `claude` user and still writes ~/.claude/CLAUDE.md. Every rename here is a $ROLE comparison or a case arm -- no CLI binary name, no dotdir path, and no account moved. README's tenant table now shows role and user in adjacent columns, because that distinction stopped being cosmetic the moment they differed. Hard cut, no aliases. The old names are refused as unknown at BOTH entrypoints -- `rig bootstrap <name>` and bootstrap-tenant.sh directly -- and the suite asserts each of the four at each, because bootstrap.sh keeps its own dispatch list and a name could survive in one and not the other. An alias left in for a single tenant is the shape that survives review: the taxonomy reads complete while one old name still quietly converges. The consequence is cross-repo. A seed carrying BOX_BOOTSTRAP_ROLE="claude" now fails its own mint-time bootstrap, so heavy-duty/box#123 updates the seeds and must land after this. Closes #76 (tenant half) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:05:19 +00:00
second run changes nothing. (The box TENANT roles — `claude-box`, `codex-box`,
`grok-box`, `kimi-box`, `staging-box` — share the verb but are their own family; the
feat(bootstrap)!: box tenant roles carry a -box suffix The other half of #76. claude -> claude-box, codex -> codex-box, grok -> grok-box, staging -> staging-box, so a role name always says which family it belongs to: -server builds a fleet machine, -box converges a guest a box minted. With both halves in, the two families can no longer collide on a word the way `staging` did. The role carries the suffix; nothing inside the guest does. A tenant user is the account the box SEED created (BOX_USER) and each agent CLI reads its own dotdir, so claude-box still converges the `claude` user and still writes ~/.claude/CLAUDE.md. Every rename here is a $ROLE comparison or a case arm -- no CLI binary name, no dotdir path, and no account moved. README's tenant table now shows role and user in adjacent columns, because that distinction stopped being cosmetic the moment they differed. Hard cut, no aliases. The old names are refused as unknown at BOTH entrypoints -- `rig bootstrap <name>` and bootstrap-tenant.sh directly -- and the suite asserts each of the four at each, because bootstrap.sh keeps its own dispatch list and a name could survive in one and not the other. An alias left in for a single tenant is the shape that survives review: the taxonomy reads complete while one old name still quietly converges. The consequence is cross-repo. A seed carrying BOX_BOOTSTRAP_ROLE="claude" now fails its own mint-time bootstrap, so heavy-duty/box#123 updates the seeds and must land after this. Closes #76 (tenant half) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:05:19 +00:00
`-box` suffix says so. See *the box tenants* below.)
```sh
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 <noreply@anthropic.com>
2026-07-20 00:01:16 +00:00
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
feat!: bootstrap takes the users file `rig bootstrap` already knew everything else about what a box is — class, host, join, hostname — and wrote /etc/rig/role to say so. The users file was the last piece of that answer it did not take, so bring-up was two commands and the second one was the forgettable one. --users <path> now runs the `users apply` convergence as bootstrap's final phase: after the traits, after the verified tailnet join, after the role marker (apply reads that marker), and after the host=yes box install (so box-role users find the incus group box's own setup-host built). One command, and the box has its people on it. BREAKING: --users is required on every machine role, with --no-users as the explicit opt-out. Omitting both is a usage error naming both flags; passing both is a usage error too. class=server is required as well: a machine nobody logs into routinely is exactly where shared-root access rots, and per-human accounts keep attribution intact for the times someone does go in. The file is never persisted — passed per invocation, read once through apply, copied nowhere. `--users -` is refused: bootstrap's stdin belongs to the pre-auth key prompt. The box TENANT roles take neither flag; a guest is minted non-interactively, never joins the tailnet, and has no SSH door of its own. rig still never installs Incus and never calls `box setup-host` itself. The host=yes box-role precondition refuses early only where the outcome is already proven (RIG_SKIP_BOX_INSTALL=1); every other way that step can fail lands in `users apply`'s existing refusal, unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:17:49 +00:00
rig bootstrap workstation --hostname my-laptop --users ./users
rig bootstrap custom --hostname my-vm-host --root-door open --host yes --join authkey --users ./users
```
feat!: bootstrap takes the users file `rig bootstrap` already knew everything else about what a box is — class, host, join, hostname — and wrote /etc/rig/role to say so. The users file was the last piece of that answer it did not take, so bring-up was two commands and the second one was the forgettable one. --users <path> now runs the `users apply` convergence as bootstrap's final phase: after the traits, after the verified tailnet join, after the role marker (apply reads that marker), and after the host=yes box install (so box-role users find the incus group box's own setup-host built). One command, and the box has its people on it. BREAKING: --users is required on every machine role, with --no-users as the explicit opt-out. Omitting both is a usage error naming both flags; passing both is a usage error too. class=server is required as well: a machine nobody logs into routinely is exactly where shared-root access rots, and per-human accounts keep attribution intact for the times someone does go in. The file is never persisted — passed per invocation, read once through apply, copied nowhere. `--users -` is refused: bootstrap's stdin belongs to the pre-auth key prompt. The box TENANT roles take neither flag; a guest is minted non-interactively, never joins the tailnet, and has no SSH door of its own. rig still never installs Incus and never calls `box setup-host` itself. The host=yes box-role precondition refuses early only where the outcome is already proven (RIG_SKIP_BOX_INSTALL=1); every other way that step can fail lands in `users apply`'s existing refusal, unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:17:49 +00:00
- `--users <path>` / `--no-users`**required**, one or the other: the users
file this box's operators come from, converged as bootstrap's last phase
(see *One command, box ready* below)
- `--hostname <name>` — system + tailnet hostname (default: the role name;
`custom` has no default and requires it)
- `--root-door <closed|open>` — what happens to root SSH after the users
phase: `closed` means `rig users close-root` shuts it once named operators
can get in, `open` means it stays as the control plane's automation door
(see *The identity model* below)
- `--host <yes|no>` — does this box host VMs (box/Incus)
- `--join <authkey|login>` — how it enters the tailnet
feat!: bootstrap takes the users file `rig bootstrap` already knew everything else about what a box is — class, host, join, hostname — and wrote /etc/rig/role to say so. The users file was the last piece of that answer it did not take, so bring-up was two commands and the second one was the forgettable one. --users <path> now runs the `users apply` convergence as bootstrap's final phase: after the traits, after the verified tailnet join, after the role marker (apply reads that marker), and after the host=yes box install (so box-role users find the incus group box's own setup-host built). One command, and the box has its people on it. BREAKING: --users is required on every machine role, with --no-users as the explicit opt-out. Omitting both is a usage error naming both flags; passing both is a usage error too. class=server is required as well: a machine nobody logs into routinely is exactly where shared-root access rots, and per-human accounts keep attribution intact for the times someone does go in. The file is never persisted — passed per invocation, read once through apply, copied nowhere. `--users -` is refused: bootstrap's stdin belongs to the pre-auth key prompt. The box TENANT roles take neither flag; a guest is minted non-interactively, never joins the tailnet, and has no SSH door of its own. rig still never installs Incus and never calls `box setup-host` itself. The host=yes box-role precondition refuses early only where the outcome is already proven (RIG_SKIP_BOX_INSTALL=1); every other way that step can fail lands in `users apply`'s existing refusal, unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:17:49 +00:00
#### One command, box ready — `--users` is required
`rig bootstrap` already knows everything else about what a box *is* — the
root-door policy, host, join, hostname — and writes `/etc/rig/role` to say so.
The users file was the last piece of that answer it did not take, so bring-up
was two commands and the second one was easy to forget. Now it takes it, and
feat!: bootstrap takes the users file `rig bootstrap` already knew everything else about what a box is — class, host, join, hostname — and wrote /etc/rig/role to say so. The users file was the last piece of that answer it did not take, so bring-up was two commands and the second one was the forgettable one. --users <path> now runs the `users apply` convergence as bootstrap's final phase: after the traits, after the verified tailnet join, after the role marker (apply reads that marker), and after the host=yes box install (so box-role users find the incus group box's own setup-host built). One command, and the box has its people on it. BREAKING: --users is required on every machine role, with --no-users as the explicit opt-out. Omitting both is a usage error naming both flags; passing both is a usage error too. class=server is required as well: a machine nobody logs into routinely is exactly where shared-root access rots, and per-human accounts keep attribution intact for the times someone does go in. The file is never persisted — passed per invocation, read once through apply, copied nowhere. `--users -` is refused: bootstrap's stdin belongs to the pre-auth key prompt. The box TENANT roles take neither flag; a guest is minted non-interactively, never joins the tailnet, and has no SSH door of its own. rig still never installs Incus and never calls `box setup-host` itself. The host=yes box-role precondition refuses early only where the outcome is already proven (RIG_SKIP_BOX_INSTALL=1); every other way that step can fail lands in `users apply`'s existing refusal, unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:17:49 +00:00
**requires** it:
```sh
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 <noreply@anthropic.com>
2026-07-20 00:01:16 +00:00
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
feat!: bootstrap takes the users file `rig bootstrap` already knew everything else about what a box is — class, host, join, hostname — and wrote /etc/rig/role to say so. The users file was the last piece of that answer it did not take, so bring-up was two commands and the second one was the forgettable one. --users <path> now runs the `users apply` convergence as bootstrap's final phase: after the traits, after the verified tailnet join, after the role marker (apply reads that marker), and after the host=yes box install (so box-role users find the incus group box's own setup-host built). One command, and the box has its people on it. BREAKING: --users is required on every machine role, with --no-users as the explicit opt-out. Omitting both is a usage error naming both flags; passing both is a usage error too. class=server is required as well: a machine nobody logs into routinely is exactly where shared-root access rots, and per-human accounts keep attribution intact for the times someone does go in. The file is never persisted — passed per invocation, read once through apply, copied nowhere. `--users -` is refused: bootstrap's stdin belongs to the pre-auth key prompt. The box TENANT roles take neither flag; a guest is minted non-interactively, never joins the tailnet, and has no SSH door of its own. rig still never installs Incus and never calls `box setup-host` itself. The host=yes box-role precondition refuses early only where the outcome is already proven (RIG_SKIP_BOX_INSTALL=1); every other way that step can fail lands in `users apply`'s existing refusal, unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:17:49 +00:00
```
`--users <path>` runs exactly what `rig users apply --file <path>` runs, as
bootstrap's **final phase** — after the traits are set, after the tailnet
join is verified, and after `/etc/rig/role` is written, because apply *reads*
that marker (`root-door=` picks its root-SSH note, `host=` decides what a missing
feat!: bootstrap takes the users file `rig bootstrap` already knew everything else about what a box is — class, host, join, hostname — and wrote /etc/rig/role to say so. The users file was the last piece of that answer it did not take, so bring-up was two commands and the second one was the forgettable one. --users <path> now runs the `users apply` convergence as bootstrap's final phase: after the traits, after the verified tailnet join, after the role marker (apply reads that marker), and after the host=yes box install (so box-role users find the incus group box's own setup-host built). One command, and the box has its people on it. BREAKING: --users is required on every machine role, with --no-users as the explicit opt-out. Omitting both is a usage error naming both flags; passing both is a usage error too. class=server is required as well: a machine nobody logs into routinely is exactly where shared-root access rots, and per-human accounts keep attribution intact for the times someone does go in. The file is never persisted — passed per invocation, read once through apply, copied nowhere. `--users -` is refused: bootstrap's stdin belongs to the pre-auth key prompt. The box TENANT roles take neither flag; a guest is minted non-interactively, never joins the tailnet, and has no SSH door of its own. rig still never installs Incus and never calls `box setup-host` itself. The host=yes box-role precondition refuses early only where the outcome is already proven (RIG_SKIP_BOX_INSTALL=1); every other way that step can fail lands in `users apply`'s existing refusal, unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:17:49 +00:00
`incus` group means). On a `host=yes` box it also lands after the `box`
install, so box-role users find the `incus` group box's `setup-host` built.
The file is passed per invocation and **never persisted** — bootstrap reads
it through apply and keeps nothing; `--users -` is refused, because
bootstrap's stdin belongs to the pre-auth key prompt.
Required on **every** role, `root-door=open` included. A bootstrapped box with
no users converges to a box only root can enter — on `root-door=closed` a
half-built machine, and on `root-door=open` something worse than half-built: a
feat!: bootstrap takes the users file `rig bootstrap` already knew everything else about what a box is — class, host, join, hostname — and wrote /etc/rig/role to say so. The users file was the last piece of that answer it did not take, so bring-up was two commands and the second one was the forgettable one. --users <path> now runs the `users apply` convergence as bootstrap's final phase: after the traits, after the verified tailnet join, after the role marker (apply reads that marker), and after the host=yes box install (so box-role users find the incus group box's own setup-host built). One command, and the box has its people on it. BREAKING: --users is required on every machine role, with --no-users as the explicit opt-out. Omitting both is a usage error naming both flags; passing both is a usage error too. class=server is required as well: a machine nobody logs into routinely is exactly where shared-root access rots, and per-human accounts keep attribution intact for the times someone does go in. The file is never persisted — passed per invocation, read once through apply, copied nowhere. `--users -` is refused: bootstrap's stdin belongs to the pre-auth key prompt. The box TENANT roles take neither flag; a guest is minted non-interactively, never joins the tailnet, and has no SSH door of its own. rig still never installs Incus and never calls `box setup-host` itself. The host=yes box-role precondition refuses early only where the outcome is already proven (RIG_SKIP_BOX_INSTALL=1); every other way that step can fail lands in `users apply`'s existing refusal, unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:17:49 +00:00
machine nobody logs into routinely is exactly where shared-root access rots,
and per-human accounts keep attribution intact for the times someone does go
in. So the complete path is the default path, and skipping it is a deliberate
`--no-users` rather than an omission that looks identical to forgetting.
Omitting both is a usage error naming both flags; passing both is a usage
error too — rig will not silently pick a winner.
A bad users file is caught **up front**, in the same breath as a bad
`--root-door`: bootstrap pre-flights it with the same parser apply uses, before
feat!: bootstrap takes the users file `rig bootstrap` already knew everything else about what a box is — class, host, join, hostname — and wrote /etc/rig/role to say so. The users file was the last piece of that answer it did not take, so bring-up was two commands and the second one was the forgettable one. --users <path> now runs the `users apply` convergence as bootstrap's final phase: after the traits, after the verified tailnet join, after the role marker (apply reads that marker), and after the host=yes box install (so box-role users find the incus group box's own setup-host built). One command, and the box has its people on it. BREAKING: --users is required on every machine role, with --no-users as the explicit opt-out. Omitting both is a usage error naming both flags; passing both is a usage error too. class=server is required as well: a machine nobody logs into routinely is exactly where shared-root access rots, and per-human accounts keep attribution intact for the times someone does go in. The file is never persisted — passed per invocation, read once through apply, copied nowhere. `--users -` is refused: bootstrap's stdin belongs to the pre-auth key prompt. The box TENANT roles take neither flag; a guest is minted non-interactively, never joins the tailnet, and has no SSH door of its own. rig still never installs Incus and never calls `box setup-host` itself. The host=yes box-role precondition refuses early only where the outcome is already proven (RIG_SKIP_BOX_INSTALL=1); every other way that step can fail lands in `users apply`'s existing refusal, unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:17:49 +00:00
`apt`, before the hostname change, and before a single-use pre-auth key is
spent. A file that names **no users** is refused there too — empty,
comments-only and whitespace-only files all parse fine, but bootstrapping
with one converges the same root-only box `--no-users` asks for, via the
flag that exists to guarantee the opposite. The refusal names `--no-users`,
because that box is reachable; it just has to be asked for out loud. This is
bootstrap's contract only: `rig users apply` against an emptied file is a
genuine de-provisioning operation and stays available. And on `host=yes`
with `RIG_SKIP_BOX_INSTALL=1`, a box-role user with no `incus` group refuses
immediately rather than a hundred lines later — that is the one case where
the outcome is already certain, since the run has been told it will not
install box. rig still **never** installs Incus or runs `box setup-host` on
its own account; the box CLI's own installer does that (see the `host`
trait), and every other way that step can fail lands in apply's existing
refusal at the end.
feat!: bootstrap takes the users file `rig bootstrap` already knew everything else about what a box is — class, host, join, hostname — and wrote /etc/rig/role to say so. The users file was the last piece of that answer it did not take, so bring-up was two commands and the second one was the forgettable one. --users <path> now runs the `users apply` convergence as bootstrap's final phase: after the traits, after the verified tailnet join, after the role marker (apply reads that marker), and after the host=yes box install (so box-role users find the incus group box's own setup-host built). One command, and the box has its people on it. BREAKING: --users is required on every machine role, with --no-users as the explicit opt-out. Omitting both is a usage error naming both flags; passing both is a usage error too. class=server is required as well: a machine nobody logs into routinely is exactly where shared-root access rots, and per-human accounts keep attribution intact for the times someone does go in. The file is never persisted — passed per invocation, read once through apply, copied nowhere. `--users -` is refused: bootstrap's stdin belongs to the pre-auth key prompt. The box TENANT roles take neither flag; a guest is minted non-interactively, never joins the tailnet, and has no SSH door of its own. rig still never installs Incus and never calls `box setup-host` itself. The host=yes box-role precondition refuses early only where the outcome is already proven (RIG_SKIP_BOX_INSTALL=1); every other way that step can fail lands in `users apply`'s existing refusal, unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:17:49 +00:00
feat(bootstrap)!: box tenant roles carry a -box suffix The other half of #76. claude -> claude-box, codex -> codex-box, grok -> grok-box, staging -> staging-box, so a role name always says which family it belongs to: -server builds a fleet machine, -box converges a guest a box minted. With both halves in, the two families can no longer collide on a word the way `staging` did. The role carries the suffix; nothing inside the guest does. A tenant user is the account the box SEED created (BOX_USER) and each agent CLI reads its own dotdir, so claude-box still converges the `claude` user and still writes ~/.claude/CLAUDE.md. Every rename here is a $ROLE comparison or a case arm -- no CLI binary name, no dotdir path, and no account moved. README's tenant table now shows role and user in adjacent columns, because that distinction stopped being cosmetic the moment they differed. Hard cut, no aliases. The old names are refused as unknown at BOTH entrypoints -- `rig bootstrap <name>` and bootstrap-tenant.sh directly -- and the suite asserts each of the four at each, because bootstrap.sh keeps its own dispatch list and a name could survive in one and not the other. An alias left in for a single tenant is the shape that survives review: the taxonomy reads complete while one old name still quietly converges. The consequence is cross-repo. A seed carrying BOX_BOOTSTRAP_ROLE="claude" now fails its own mint-time bootstrap, so heavy-duty/box#123 updates the seeds and must land after this. Closes #76 (tenant half) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:05:19 +00:00
`--users` does **not** reach the box TENANT roles (`claude-box`, `codex-box`,
`grok-box`, `kimi-box`, `staging-box`). A tenant is a box-minted *guest*: box auto-runs its bootstrap at
feat!: bootstrap takes the users file `rig bootstrap` already knew everything else about what a box is — class, host, join, hostname — and wrote /etc/rig/role to say so. The users file was the last piece of that answer it did not take, so bring-up was two commands and the second one was the forgettable one. --users <path> now runs the `users apply` convergence as bootstrap's final phase: after the traits, after the verified tailnet join, after the role marker (apply reads that marker), and after the host=yes box install (so box-role users find the incus group box's own setup-host built). One command, and the box has its people on it. BREAKING: --users is required on every machine role, with --no-users as the explicit opt-out. Omitting both is a usage error naming both flags; passing both is a usage error too. class=server is required as well: a machine nobody logs into routinely is exactly where shared-root access rots, and per-human accounts keep attribution intact for the times someone does go in. The file is never persisted — passed per invocation, read once through apply, copied nowhere. `--users -` is refused: bootstrap's stdin belongs to the pre-auth key prompt. The box TENANT roles take neither flag; a guest is minted non-interactively, never joins the tailnet, and has no SSH door of its own. rig still never installs Incus and never calls `box setup-host` itself. The host=yes box-role precondition refuses early only where the outcome is already proven (RIG_SKIP_BOX_INSTALL=1); every other way that step can fail lands in `users apply`'s existing refusal, unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:17:49 +00:00
mint, non-interactively, with no file to hand it; the guest never joins the
tailnet and has no SSH door of its own — you enter with `box shell`, gated by
the **host's** `incus` grants, which the host's own users file already
converged. A fleet-wide operator file has nothing to converge in there, and
requiring one would break the mint-time path outright.
**Roles are presets over three orthogonal traits**, nothing more — every
per-role behavior keys off a trait, so any flag overrides its trait without
needing a new role (`rig bootstrap workstation --host no` for a laptop that
will never run VMs), and `custom` exists for the shape nobody foresaw: it
presets nothing and requires `--hostname` plus all three traits.
| trait | values | what it drives |
|---------|--------------------|----------------|
| `root-door` | `closed`, `open` | root SSH's fate once operators exist — `closed` shuts it via `rig users close-root`, `open` keeps it as the control plane's automation door |
feat(bootstrap): host-class installs box + runs setup-host A host=yes box exists to run guest boxes, so bootstrap finishes the job instead of printing "next: install the box CLI and run 'box setup-host'". After the role marker is written, on host=yes it installs the box CLI globally and lets box's OWN setup-host build the Incus stack. rig DELEGATES to box; it never touches Incus itself — the same design law `rig users apply` enforces ("rig NEVER installs Incus: box's setup-host owns the daemon and its group"). rig does not apt-install incus, does not configure the daemon, does not create the incus group. It runs box's global installer as root with BOX_YES=1 (non-interactive AND keeps setup-host); box installs Incus. Two tools converging one daemon is drift by construction. - Convergent: box's installer is a no-op once box is installed, so re-running bootstrap changes nothing. - Opt-out: RIG_SKIP_BOX_INSTALL=1 skips; also skips gracefully (with a manual pointer) when curl or the network is missing — box is the host EXTRA, so a failed box install never aborts a bootstrap that otherwise succeeded. - Pinnable: BOX_REPO / BOX_REF (default heavy-duty/box@main). - Runs only AFTER the role marker write, so a box that failed to become what it claims never installs box on a half-built host. The world-readable global install path (box under /opt/box, readable by every non-root user) depends on box PR #71; until it merges box's root install lands in /root. Noted in a comment and the plan doc. Completes rig#12 (the dev role — the Incus claudebox host) and rig#25 (machine classes: host-class installs box + rig users). Tests: 8 new bootstrap checks (guard on host=yes, BOX_YES install, pin defaults, RIG_SKIP_BOX_INSTALL opt-out, negative-grep that rig never apt-installs incus, box-after-marker ordering, manual-pointer on skip). 154 passed, 0 failed; shellcheck -x clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 22:45:40 +00:00
| `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 | root-door | host | join | tailnet tag |
|------------------------|-----------|------|---------|-------------|
| `control-plane-server` | open | no | authkey | `tag:server` |
| `workload-server` | open | no | authkey | `tag:server` |
| `runner-server` | open | no | authkey | `tag:ci` — refuses `tag:server` |
| `staging-server` | open | yes | authkey | `tag:local` — refuses `tag:server` |
| `dev-server` | closed | yes | authkey | `tag:local` — refuses `tag:server` |
| `workstation` | closed | yes | login | untagged — any tag refused |
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 <noreply@anthropic.com>
2026-07-20 00:01:16 +00:00
> **The suffix names the family, not the door policy** (#76). rig builds two kinds
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 <noreply@anthropic.com>
2026-07-20 00:01:16 +00:00
> 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 --root-door closed` says what is true, and says it once**
> (#77). The suffix names the *family* — a fleet machine — and the trait names
> the *door*: operators enter a dev box as themselves, so `close-root` shuts
> its door. Until #77 this trait was `--class human|server`, which named the
> wrong axis (who lives on the box) and made `dev-server` read as a
> `class=human` contradiction: one word, "server", doing duty on two unrelated
> questions. Nobody *lives* on a dev box; what distinguishes it is that its
> root door closes. Markers written before the rename still say
> `class=human|server` and are still read — see *The root-door trait was
> renamed* below.
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 <noreply@anthropic.com>
2026-07-20 00:01:16 +00:00
>
> **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
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 <noreply@anthropic.com>
2026-07-20 00:01:16 +00:00
"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.
After the tag verification passes, bootstrap writes `/etc/rig/role` — one
line, `role=… root-door=… host=… join=…` — recording the **effective** traits,
overrides and all, so an overridden role never lies to the commands that read
the marker later (`rig users` keys root policy off `root-door=`). Written
post-join and cmp-guarded, so a marker never describes a box that failed to
become what it claims.
Immediately after it, bootstrap stamps `/etc/rig/manifest`**provenance**:
which rig converged this box and when (see [`rig
manifest`](#rig-manifest)). Same discipline, same guarantee, and the two files
stay consistent because they land together. The marker says what the box *is*;
the manifest says what *built* it. The tenant roles stamp it too — a
box-minted guest is a machine rig converged.
**`join=login` inverts the tag assertion.** A workstation joins as a
user-owned device: there is no pre-auth key — a set `TS_AUTHKEY` is a loud
usage error (exit 2; unset it, or pass `--join authkey`) — `tailscale up`
prints a login URL, and the human at the keyboard is the credential. After
join the assertion flips: **untagged** is what rig asserts, and any effective
tag is the refusal — a tag here means control granted this device fleet
identity, and on a first join the half-joined node is backed out with
`tailscale logout` (a box that was already joined is refused without backout;
rig never unwinds state it did not create). Same principle as the authkey
path, mirrored: verify what control **granted**, never what was requested.
There is **no `--ts-tag` flag**. A pre-auth key is minted *with* its tags, so
the key is the single source of truth for the tailnet tag — rig no longer states
a second one it might disagree with. It **verifies** the tag control actually
granted after join instead (see below). Passing `--ts-tag` now exits 2 with a
message pointing you at the key.
What it does: installs `curl ca-certificates unattended-upgrades` (and
enables periodic unattended upgrades); writes an sshd hardening drop-in
fix(bootstrap): sshd hardening never applied on cloud images rig wrote its hardening drop-in as 99-rig.conf. Cloud images ship 50-cloud-init.conf carrying `PasswordAuthentication yes`, and sshd_config is FIRST-wins ("for each keyword, the first obtained value will be used" — sshd_config(5)) with Include expanding its glob in lexical order. So 50- was read before 99- and every keyword rig set was silently discarded. Every Hetzner box rig has bootstrapped was still serving `passwordauthentication yes` — confirmed today on coolify-box (CX23) and ci-runner (CX43) by `sshd -T`, and from off-tailnet by `ssh -o PreferredAuthentications=none`. Root logins were never exposed (PermitRootLogin resolved to prohibit-password via Debian's stock config), but any password-bearing non-root account was reachable on a public port 22. Three fixes: 1. Name the drop-in 00-rig.conf so it is read first and actually wins; sweep a stale 99-rig.conf on re-run so existing boxes converge. 2. Assert the EFFECTIVE config with `sshd -T` and refuse to claim a hardened box otherwise. Asserting that the file existed is what let this ship green — the Incus rehearsal has no cloud-init drop-in to lose to, so the bug was invisible to the one gate that could have caught it. 3. Set the system hostname, not just the tailnet one. A box reached as `coolify-box` greeted the operator as `root@internal-tooling`; the shell prompt is the only "am I on the right box" signal before a destructive command, and it was lying on every box rig built. Also defer the pre-auth key prompt to the join path. rig is convergent by contract, but re-running it to pick up this fix demanded a Tailscale key it would never spend — friction on precisely the repair path that matters. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 15:26:34 +00:00
(`PermitRootLogin prohibit-password`, `PasswordAuthentication no`) and
**verifies it took effect** via `sshd -T`; sets the system hostname; installs
tailscale and joins your tailnet — then **verifies the tag the key granted**
(see *The tag comes from the key* below).
**`--hostname` converges both names.** On a box that has already joined,
`bootstrap` skips `tailscale up` (so a re-run needs no pre-auth key) — but it
still reconciles the **tailnet** hostname via `tailscale set --hostname`. Without
that, a box which joined under the wrong name — say `--hostname` was omitted, so
it defaulted to the *role* — stayed misnamed forever, and re-running rig, the
documented repair, could not fix it. A machine you deliberately renamed in the
admin console keeps that name; rig will not fight it.
fix(bootstrap): sshd hardening never applied on cloud images rig wrote its hardening drop-in as 99-rig.conf. Cloud images ship 50-cloud-init.conf carrying `PasswordAuthentication yes`, and sshd_config is FIRST-wins ("for each keyword, the first obtained value will be used" — sshd_config(5)) with Include expanding its glob in lexical order. So 50- was read before 99- and every keyword rig set was silently discarded. Every Hetzner box rig has bootstrapped was still serving `passwordauthentication yes` — confirmed today on coolify-box (CX23) and ci-runner (CX43) by `sshd -T`, and from off-tailnet by `ssh -o PreferredAuthentications=none`. Root logins were never exposed (PermitRootLogin resolved to prohibit-password via Debian's stock config), but any password-bearing non-root account was reachable on a public port 22. Three fixes: 1. Name the drop-in 00-rig.conf so it is read first and actually wins; sweep a stale 99-rig.conf on re-run so existing boxes converge. 2. Assert the EFFECTIVE config with `sshd -T` and refuse to claim a hardened box otherwise. Asserting that the file existed is what let this ship green — the Incus rehearsal has no cloud-init drop-in to lose to, so the bug was invisible to the one gate that could have caught it. 3. Set the system hostname, not just the tailnet one. A box reached as `coolify-box` greeted the operator as `root@internal-tooling`; the shell prompt is the only "am I on the right box" signal before a destructive command, and it was lying on every box rig built. Also defer the pre-auth key prompt to the join path. rig is convergent by contract, but re-running it to pick up this fix demanded a Tailscale key it would never spend — friction on precisely the repair path that matters. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 15:26:34 +00:00
> **Why the drop-in is `00-rig.conf` and not `99-`.** `sshd_config` is
> **first-wins** — *"for each keyword, the first obtained value will be used"*
> (`sshd_config(5)`) — and `Include` expands its glob in lexical order. Cloud
> images ship `/etc/ssh/sshd_config.d/50-cloud-init.conf` carrying
> `PasswordAuthentication yes`, so a `99-` drop-in is read **second** and every
> keyword in it is silently discarded. This is the opposite of the
> last-wins convention most config systems use, and it shipped green here for
> a month: rig asserted the *file existed* rather than what `sshd` actually
> resolved, and the Incus rehearsal container has no cloud-init drop-in to
> lose to. Every Hetzner box rig had bootstrapped was still serving
> `passwordauthentication yes`. `bootstrap` now sweeps a stale `99-rig.conf`
> on re-run, and refuses to claim success unless `sshd -T` agrees.
**The pre-auth key** (`join=authkey` roles — everything but `workstation`):
provide it via the `TS_AUTHKEY` env var or type it at
the interactive prompt. Use a **single-use, tagged, short-expiry** key — the
**tagged** part is now load-bearing, not advice (see below). It lives in process
memory only — rig never writes a credential to disk.
**The tag comes from the key, and rig verifies the one control granted.** rig
used to pass `--ts-tag` to `tailscale up --advertise-tags`, stating the tag a
*second* time — with no way to know whether its request and the key's own tags
agreed. It asserted the tag it **requested**, never the tag control **granted**;
this is the same shape as the sshd first-wins bug above, and it left the same
scar (both M900s joined carrying `tag:server` and had to be retagged by hand,
because nothing in rig ever read the effective tag back). So rig stops
overriding the key: `tailscale up` carries no `--advertise-tags`, the key's tags
apply, and after join rig polls `tailscale status --json` for `.Self.Tags` — the
netmap's ground truth, not `tailscale debug prefs`, which prints what was
*requested* — and asserts on that, on **first join and on every re-run** (which
catches a box bootstrapped before this change, or retagged behind rig's back).
> **An untagged key is a hard refusal.** Drop `--advertise-tags` and you also
> drop the accidental net that used to tag an untagged key's node anyway. An
> untagged node joins owned by the *key creator's user identity* — it inherits
> that human's ACL grants, expires with the key, and vanishes if the account is
> deleted. That is a fleet-shaped mistake, not a warning: rig runs `tailscale
> logout` to back the half-joined node out and dies telling you to mint a tagged
> key. A wrong tag **cannot** be fixed in place either — `tailscale set` has no
> 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.
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 <noreply@anthropic.com>
2026-07-20 00:01:16 +00:00
`control-plane-server` and `workload-server` are identical today except the default
hostname; they exist because the boxes diverge over time, and because each
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 <noreply@anthropic.com>
2026-07-20 00:01:16 +00:00
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
grant `tag:server` to repo-controlled code." A runner executes that code, and
`tag:server`'s grants (SSH between your servers, say) must never extend to it;
the check turns the worst misconfiguration from a documentation warning into a
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
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 <noreply@anthropic.com>
2026-07-20 00:01:16 +00:00
with the tenant roles and (for staging guests) `rig bootstrap workload-server`
is the `staging-server` role (`--root-door open --host yes --join authkey`; see
the note above). It is `root-door=open`: 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
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 <noreply@anthropic.com>
2026-07-20 00:01:16 +00:00
refused**, same mechanism as `runner-server`.
feat(bootstrap): host-class installs box + runs setup-host A host=yes box exists to run guest boxes, so bootstrap finishes the job instead of printing "next: install the box CLI and run 'box setup-host'". After the role marker is written, on host=yes it installs the box CLI globally and lets box's OWN setup-host build the Incus stack. rig DELEGATES to box; it never touches Incus itself — the same design law `rig users apply` enforces ("rig NEVER installs Incus: box's setup-host owns the daemon and its group"). rig does not apt-install incus, does not configure the daemon, does not create the incus group. It runs box's global installer as root with BOX_YES=1 (non-interactive AND keeps setup-host); box installs Incus. Two tools converging one daemon is drift by construction. - Convergent: box's installer is a no-op once box is installed, so re-running bootstrap changes nothing. - Opt-out: RIG_SKIP_BOX_INSTALL=1 skips; also skips gracefully (with a manual pointer) when curl or the network is missing — box is the host EXTRA, so a failed box install never aborts a bootstrap that otherwise succeeded. - Pinnable: BOX_REPO / BOX_REF (default heavy-duty/box@main). - Runs only AFTER the role marker write, so a box that failed to become what it claims never installs box on a half-built host. The world-readable global install path (box under /opt/box, readable by every non-root user) depends on box PR #71; until it merges box's root install lands in /root. Noted in a comment and the plan doc. Completes rig#12 (the dev role — the Incus claudebox host) and rig#25 (machine classes: host-class installs box + rig users). Tests: 8 new bootstrap checks (guard on host=yes, BOX_YES install, pin defaults, RIG_SKIP_BOX_INSTALL opt-out, negative-grep that rig never apt-installs incus, box-after-marker ordering, manual-pointer on skip). 154 passed, 0 failed; shellcheck -x clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 22:45:40 +00:00
On a VM-hosting box (`host=yes`), bootstrap finishes the job instead of leaving
feat(bootstrap): host-class installs box + runs setup-host A host=yes box exists to run guest boxes, so bootstrap finishes the job instead of printing "next: install the box CLI and run 'box setup-host'". After the role marker is written, on host=yes it installs the box CLI globally and lets box's OWN setup-host build the Incus stack. rig DELEGATES to box; it never touches Incus itself — the same design law `rig users apply` enforces ("rig NEVER installs Incus: box's setup-host owns the daemon and its group"). rig does not apt-install incus, does not configure the daemon, does not create the incus group. It runs box's global installer as root with BOX_YES=1 (non-interactive AND keeps setup-host); box installs Incus. Two tools converging one daemon is drift by construction. - Convergent: box's installer is a no-op once box is installed, so re-running bootstrap changes nothing. - Opt-out: RIG_SKIP_BOX_INSTALL=1 skips; also skips gracefully (with a manual pointer) when curl or the network is missing — box is the host EXTRA, so a failed box install never aborts a bootstrap that otherwise succeeded. - Pinnable: BOX_REPO / BOX_REF (default heavy-duty/box@main). - Runs only AFTER the role marker write, so a box that failed to become what it claims never installs box on a half-built host. The world-readable global install path (box under /opt/box, readable by every non-root user) depends on box PR #71; until it merges box's root install lands in /root. Noted in a comment and the plan doc. Completes rig#12 (the dev role — the Incus claudebox host) and rig#25 (machine classes: host-class installs box + rig users). Tests: 8 new bootstrap checks (guard on host=yes, BOX_YES install, pin defaults, RIG_SKIP_BOX_INSTALL opt-out, negative-grep that rig never apt-installs incus, box-after-marker ordering, manual-pointer on skip). 154 passed, 0 failed; shellcheck -x clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 22:45:40 +00:00
a to-do: after the role marker is written it **installs the `box` CLI globally
and runs box's own `setup-host`**, so the Incus stack is ready for
`box new` when bootstrap returns. rig **delegates to box; it
feat(bootstrap): host-class installs box + runs setup-host A host=yes box exists to run guest boxes, so bootstrap finishes the job instead of printing "next: install the box CLI and run 'box setup-host'". After the role marker is written, on host=yes it installs the box CLI globally and lets box's OWN setup-host build the Incus stack. rig DELEGATES to box; it never touches Incus itself — the same design law `rig users apply` enforces ("rig NEVER installs Incus: box's setup-host owns the daemon and its group"). rig does not apt-install incus, does not configure the daemon, does not create the incus group. It runs box's global installer as root with BOX_YES=1 (non-interactive AND keeps setup-host); box installs Incus. Two tools converging one daemon is drift by construction. - Convergent: box's installer is a no-op once box is installed, so re-running bootstrap changes nothing. - Opt-out: RIG_SKIP_BOX_INSTALL=1 skips; also skips gracefully (with a manual pointer) when curl or the network is missing — box is the host EXTRA, so a failed box install never aborts a bootstrap that otherwise succeeded. - Pinnable: BOX_REPO / BOX_REF (default heavy-duty/box@main). - Runs only AFTER the role marker write, so a box that failed to become what it claims never installs box on a half-built host. The world-readable global install path (box under /opt/box, readable by every non-root user) depends on box PR #71; until it merges box's root install lands in /root. Noted in a comment and the plan doc. Completes rig#12 (the dev role — the Incus claudebox host) and rig#25 (machine classes: host-class installs box + rig users). Tests: 8 new bootstrap checks (guard on host=yes, BOX_YES install, pin defaults, RIG_SKIP_BOX_INSTALL opt-out, negative-grep that rig never apt-installs incus, box-after-marker ordering, manual-pointer on skip). 154 passed, 0 failed; shellcheck -x clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 22:45:40 +00:00
never touches Incus itself** — it does not `apt-get install incus`, does not
configure the daemon, does not create the `incus` group. It runs box's global
installer (`curl … | BOX_YES=1 bash`) as root, and box installs Incus via its
`setup-host`; two tools converging one daemon is drift by construction, and box
is the single owner. The step is **convergent** (box's installer is a no-op once
box is present) and **opt-out** (`RIG_SKIP_BOX_INSTALL=1`, plus a graceful skip
with a manual-command pointer when curl or the network is missing — box is the
host *extra*, so a failed box install never aborts a bootstrap that otherwise
succeeded). Source is pinnable with `BOX_REPO` / `BOX_REF` (default
`heavy-duty/box@main`). If `/dev/kvm` is absent, rig warns (a host that exists to
run VMs should have it) but does not fail — the shape is rehearsed in containers,
which legitimately lack it. (The world-readable global install path — box under
`/opt/box` readable by every non-root user — depends on box PR #71; until that
merges box's root install lands in `/root`.)
> **The box install is unpinned — on purpose, and out loud.** `coolify install`
> demands a version pin; the box step tracks a moving `heavy-duty/box@main`.
> Not because box self-updates (it doesn't — it has Coolify's shape, not the
> runner's) but because there is nothing to pin *to*: box cuts no tags and no
> releases, and its installer resolves `refs/heads/<ref>` — branches only — so
> a `BOX_REF=v0.5.0` would 404 even if the tag existed. Issue #12's call was
> that silently tracking `main` on the box that runs the agents is the option
> not to pick — hence this paragraph. `BOX_REPO` / `BOX_REF` are the pin
> points the day box cuts a tag (or you point at a frozen branch of your own
> fork); `RIG_SKIP_BOX_INSTALL=1` opts out entirely for a host whose box you
> manage by hand.
`dev-server` is the closed-door VM-hosting shape — `tag:local`, box CLI installed as
above, operators entering as themselves (`--root-door open` turns it into the
unattended VM-host appliance) — and `workstation` is the machine at the keyboard
end of all the SSH connections: `root-door=closed`, `join=login`, entering the
tailnet as *your* device rather than the fleet's.
### `rig bootstrap <claude-box|codex-box|grok-box|kimi-box|staging-box>` — the box tenants
Run as root, **inside** a [box](https://github.com/heavy-duty/box)-minted
guest. Convergent — safe to re-run; a second run changes nothing.
```sh
rig bootstrap claude-box # or codex-box, grok-box, kimi-box — the agent tenants
feat(bootstrap)!: box tenant roles carry a -box suffix The other half of #76. claude -> claude-box, codex -> codex-box, grok -> grok-box, staging -> staging-box, so a role name always says which family it belongs to: -server builds a fleet machine, -box converges a guest a box minted. With both halves in, the two families can no longer collide on a word the way `staging` did. The role carries the suffix; nothing inside the guest does. A tenant user is the account the box SEED created (BOX_USER) and each agent CLI reads its own dotdir, so claude-box still converges the `claude` user and still writes ~/.claude/CLAUDE.md. Every rename here is a $ROLE comparison or a case arm -- no CLI binary name, no dotdir path, and no account moved. README's tenant table now shows role and user in adjacent columns, because that distinction stopped being cosmetic the moment they differed. Hard cut, no aliases. The old names are refused as unknown at BOTH entrypoints -- `rig bootstrap <name>` and bootstrap-tenant.sh directly -- and the suite asserts each of the four at each, because bootstrap.sh keeps its own dispatch list and a name could survive in one and not the other. An alias left in for a single tenant is the shape that survives review: the taxonomy reads complete while one old name still quietly converges. The consequence is cross-repo. A seed carrying BOX_BOOTSTRAP_ROLE="claude" now fails its own mint-time bootstrap, so heavy-duty/box#123 updates the seeds and must land after this. Closes #76 (tenant half) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:05:19 +00:00
rig bootstrap staging-box # the server tenant (docker + sshd hardening)
rig bootstrap claude-box --user dev # when the seed's BOX_USER differs
```
**The layering** (rig#31 ↔ box#81): a box template stops being where tenant
content lives. box mints a **thin, creds-free seed** — base image, the
`BOX_USER`, rig (+ tmux) preinstalled, and nothing that joins or admits — and
everything the guest *becomes* is a rig tenant role. cloud-init is a first-boot
one-shot: not convergent, not re-runnable, and only parse-and-grep testable.
rig roles are idempotent scripts with effective-state asserts, driven by the
same harness as everything else — and re-runnable on an *existing* box to
converge it to a new spec instead of re-minting it. One convergence engine;
the guests were the hole.
It is **one mechanism, parameterized per tenant** (`lib/tenant-config.sh`
holds the whole per-tenant table), not four hand-maintained scripts:
feat(bootstrap)!: box tenant roles carry a -box suffix The other half of #76. claude -> claude-box, codex -> codex-box, grok -> grok-box, staging -> staging-box, so a role name always says which family it belongs to: -server builds a fleet machine, -box converges a guest a box minted. With both halves in, the two families can no longer collide on a word the way `staging` did. The role carries the suffix; nothing inside the guest does. A tenant user is the account the box SEED created (BOX_USER) and each agent CLI reads its own dotdir, so claude-box still converges the `claude` user and still writes ~/.claude/CLAUDE.md. Every rename here is a $ROLE comparison or a case arm -- no CLI binary name, no dotdir path, and no account moved. README's tenant table now shows role and user in adjacent columns, because that distinction stopped being cosmetic the moment they differed. Hard cut, no aliases. The old names are refused as unknown at BOTH entrypoints -- `rig bootstrap <name>` and bootstrap-tenant.sh directly -- and the suite asserts each of the four at each, because bootstrap.sh keeps its own dispatch list and a name could survive in one and not the other. An alias left in for a single tenant is the shape that survives review: the taxonomy reads complete while one old name still quietly converges. The consequence is cross-repo. A seed carrying BOX_BOOTSTRAP_ROLE="claude" now fails its own mint-time bootstrap, so heavy-duty/box#123 updates the seeds and must land after this. Closes #76 (tenant half) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:05:19 +00:00
| tenant role | user | what lands |
|---------------|----------|------------|
| `claude-box` | `claude` | the agent toolbelt (git, gh, tmux, ripgrep, jq, age, unzip, build-essential), docker, node 22, the Claude Code CLI on the system PATH, zsh + oh-my-zsh, and `~/.claude/CLAUDE.md` |
| `codex-box` | `codex` | the toolbelt, docker, node 22, `@openai/codex` on the system PATH, and `~/.codex/AGENTS.md` |
| `grok-box` | `grok` | the toolbelt, docker, the grok CLI on the system PATH, and `~/.grok/AGENTS.md` |
| `kimi-box` | `kimi` | the toolbelt, docker, the kimi CLI (uv-managed) on the system PATH, and `~/.kimi/AGENTS.md` |
| `staging-box` | `ops` | box#69's server posture: docker + the same sshd hardening the machine roles get (shared `lib/sshd.sh`, `root-door=open` acceptance) |
feat(bootstrap)!: box tenant roles carry a -box suffix The other half of #76. claude -> claude-box, codex -> codex-box, grok -> grok-box, staging -> staging-box, so a role name always says which family it belongs to: -server builds a fleet machine, -box converges a guest a box minted. With both halves in, the two families can no longer collide on a word the way `staging` did. The role carries the suffix; nothing inside the guest does. A tenant user is the account the box SEED created (BOX_USER) and each agent CLI reads its own dotdir, so claude-box still converges the `claude` user and still writes ~/.claude/CLAUDE.md. Every rename here is a $ROLE comparison or a case arm -- no CLI binary name, no dotdir path, and no account moved. README's tenant table now shows role and user in adjacent columns, because that distinction stopped being cosmetic the moment they differed. Hard cut, no aliases. The old names are refused as unknown at BOTH entrypoints -- `rig bootstrap <name>` and bootstrap-tenant.sh directly -- and the suite asserts each of the four at each, because bootstrap.sh keeps its own dispatch list and a name could survive in one and not the other. An alias left in for a single tenant is the shape that survives review: the taxonomy reads complete while one old name still quietly converges. The consequence is cross-repo. A seed carrying BOX_BOOTSTRAP_ROLE="claude" now fails its own mint-time bootstrap, so heavy-duty/box#123 updates the seeds and must land after this. Closes #76 (tenant half) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:05:19 +00:00
**The role carries the suffix; the user does not.** A tenant user is the
account the box *seed* created (`BOX_USER`) and the agent CLI's own dotdir
hangs off it — `claude-box` converges the `claude` user and writes
`~/.claude/CLAUDE.md`. The suffix is rig's word for "this is a guest", not a
rename of anything inside the box, so nothing in the guest's filesystem moved.
Every install is **asserted on effective state**, not exit codes: the CLI must
*answer* (`--version`, run as the tenant user — a CLI that exists but cannot
run has already cost a drill), docker must answer, `sshd -T` must resolve the
hardening. The CLI also lands on the **system** PATH (`/usr/local/bin`):
`box exec <box> -- claude …` runs a non-interactive shell that reads no rc
files, so a PATH export alone is invisible to it.
**Creds-free and non-interactive, by contract.** box auto-runs these at mint
feat(bootstrap)!: box tenant roles carry a -box suffix The other half of #76. claude -> claude-box, codex -> codex-box, grok -> grok-box, staging -> staging-box, so a role name always says which family it belongs to: -server builds a fleet machine, -box converges a guest a box minted. With both halves in, the two families can no longer collide on a word the way `staging` did. The role carries the suffix; nothing inside the guest does. A tenant user is the account the box SEED created (BOX_USER) and each agent CLI reads its own dotdir, so claude-box still converges the `claude` user and still writes ~/.claude/CLAUDE.md. Every rename here is a $ROLE comparison or a case arm -- no CLI binary name, no dotdir path, and no account moved. README's tenant table now shows role and user in adjacent columns, because that distinction stopped being cosmetic the moment they differed. Hard cut, no aliases. The old names are refused as unknown at BOTH entrypoints -- `rig bootstrap <name>` and bootstrap-tenant.sh directly -- and the suite asserts each of the four at each, because bootstrap.sh keeps its own dispatch list and a name could survive in one and not the other. An alias left in for a single tenant is the shape that survives review: the taxonomy reads complete while one old name still quietly converges. The consequence is cross-repo. A seed carrying BOX_BOOTSTRAP_ROLE="claude" now fails its own mint-time bootstrap, so heavy-duty/box#123 updates the seeds and must land after this. Closes #76 (tenant half) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:05:19 +00:00
(`box exec … rig bootstrap claude-box`), so nothing here prompts, joins, or admits
— 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
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 <noreply@anthropic.com>
2026-07-20 00:01:16 +00:00
as box#69 designed it: `box shell` → `sudo rig bootstrap workload-server --hostname
feat!: bootstrap takes the users file `rig bootstrap` already knew everything else about what a box is — class, host, join, hostname — and wrote /etc/rig/role to say so. The users file was the last piece of that answer it did not take, so bring-up was two commands and the second one was the forgettable one. --users <path> now runs the `users apply` convergence as bootstrap's final phase: after the traits, after the verified tailnet join, after the role marker (apply reads that marker), and after the host=yes box install (so box-role users find the incus group box's own setup-host built). One command, and the box has its people on it. BREAKING: --users is required on every machine role, with --no-users as the explicit opt-out. Omitting both is a usage error naming both flags; passing both is a usage error too. class=server is required as well: a machine nobody logs into routinely is exactly where shared-root access rots, and per-human accounts keep attribution intact for the times someone does go in. The file is never persisted — passed per invocation, read once through apply, copied nowhere. `--users -` is refused: bootstrap's stdin belongs to the pre-auth key prompt. The box TENANT roles take neither flag; a guest is minted non-interactively, never joins the tailnet, and has no SSH door of its own. rig still never installs Incus and never calls `box setup-host` itself. The host=yes box-role precondition refuses early only where the outcome is already proven (RIG_SKIP_BOX_INSTALL=1); every other way that step can fail lands in `users apply`'s existing refusal, unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:17:49 +00:00
<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
feat(bootstrap)!: box tenant roles carry a -box suffix The other half of #76. claude -> claude-box, codex -> codex-box, grok -> grok-box, staging -> staging-box, so a role name always says which family it belongs to: -server builds a fleet machine, -box converges a guest a box minted. With both halves in, the two families can no longer collide on a word the way `staging` did. The role carries the suffix; nothing inside the guest does. A tenant user is the account the box SEED created (BOX_USER) and each agent CLI reads its own dotdir, so claude-box still converges the `claude` user and still writes ~/.claude/CLAUDE.md. Every rename here is a $ROLE comparison or a case arm -- no CLI binary name, no dotdir path, and no account moved. README's tenant table now shows role and user in adjacent columns, because that distinction stopped being cosmetic the moment they differed. Hard cut, no aliases. The old names are refused as unknown at BOTH entrypoints -- `rig bootstrap <name>` and bootstrap-tenant.sh directly -- and the suite asserts each of the four at each, because bootstrap.sh keeps its own dispatch list and a name could survive in one and not the other. An alias left in for a single tenant is the shape that survives review: the taxonomy reads complete while one old name still quietly converges. The consequence is cross-repo. A seed carrying BOX_BOOTSTRAP_ROLE="claude" now fails its own mint-time bootstrap, so heavy-duty/box#123 updates the seeds and must land after this. Closes #76 (tenant half) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:05:19 +00:00
`rig bootstrap staging-box` still converges docker + hardening and leaves the
workload marker alone — the machine role is the truer statement of what the
box became.
**The agent-context file carries the box#80 guard, once.** Every agent tenant
writes its agent's instructions file (`CLAUDE.md` / `AGENTS.md`), rendered
from one shared template: the creds-free contract, the isolation and
disposability facts, and the guard note — **never run `box setup-host`,
`box teardown-host`, or the drill inside a box; the box you are in is not a
host you own**. A nested box stack claims the guest's own uplink subnet and
silently breaks its networking (box#80). The note lives in
`lib/tenant-config.sh` exactly once, not copy-pasted per template — that was
the point of moving it here.
**Tenants and the role marker.** A tenant run writes `role=<tenant> tenant=yes
host=no` — no `root-door=`, because a guest has no root-door policy of its own
(`rig users close-root` fails closed on it, by design). The guard runs the
other way too: a box already carrying a **machine** role refuses the agent
tenants outright, and *any* tenant refuses a `host=yes` box — a VM host is
the opposite of a guest, and a pre-#31 staging *host* re-running its old
command is exactly who that refusal catches (it names the new spelling).
> **The rig install in the seed is unpinned — same honesty as the box note
> above.** The seed preinstalls rig via its curl installer, which resolves
> `RIG_REPO`/`RIG_REF` — and since rig#32 the installer defaults to the
> **latest release**, with `RIG_REF=<tag>` the pin and `RIG_REF=main` the
2026-07-20 17:32:43 +00:00
> dev channel. A seed that needs main must set `RIG_REF=main` explicitly;
> the default channel never silently falls back to a development branch.
> That inverts the install edge on this page:
> rig installs box on VM-hosting machines, and box guests now install rig.
> `RIG_REPO`/`RIG_REF` are the pin points, or point them at a frozen branch
> of your own fork. The seed side of this edge is box#81's to document.
### The identity model
**Named operators exist on every box, and humans never enter as root.** The
tailnet is network-only — no Tailscale SSH — so there is no identity broker at
the door: whoever holds a key to an account *is* that account, and a shared
root login is unattributable by construction. `rig users apply` puts named
operators on every box, `root-door=open` included; a human always enters as
themself and elevates via sudo.
Per role, the whole identity picture at a glance — issue #25's class
comparison, translated onto the traits that replaced the class binary. Note
that "who lives here" and the root-door trait are **different columns**: that
they were ever one word is exactly what #77 fixed.
| role | root-door | host | join | who lives here | root SSH after `rig users apply` |
|------------------------|-----------|------|---------|--------------------------------------|----------------------------------|
| `control-plane-server` | open | no | authkey | nobody — Coolify runs here | open — the automation door |
| `workload-server` | open | no | authkey | nobody — deployed services run here | open — the automation door |
| `runner-server` | open | no | authkey | nobody — CI jobs as `github-runner` | open — the automation door |
| `staging-server` | open | yes | authkey | nobody — it mints and hosts guests | open — the automation door |
| `dev-server` | closed | yes | authkey | operators, minting boxes | closed by `rig users close-root` |
| `workstation` | closed | yes | login | its owner | closed by `rig users close-root` |
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 <noreply@anthropic.com>
2026-07-20 00:01:16 +00:00
(`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 `root-door=`,
so `rig users close-root` fails closed on it.)
Who installs what, and who runs as what: **bootstrap is always root** and
installs everything a role needs — on `host=yes` that includes the box CLI
(globally) and box's own `setup-host`. **Humans always run as themselves**:
operators land via `rig users apply` on every box and elevate through sudo
(roles `admin`/`rig`) or the `incus` group (role `box`) — never by logging in
as root. **Machine identities stay machine-shaped**: Coolify's automation
SSHes in as root (that is what `root-door=open` root *is*), CI jobs run as the
unprivileged `github-runner`, and guest VMs are their own open-door boxes,
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 <noreply@anthropic.com>
2026-07-20 00:01:16 +00:00
converged from inside by `rig bootstrap workload-server`.
**`root-door` decides root SSH's fate — after `rig users apply`, never before.**
On `root-door=closed`, root SSH closes entirely (`rig users close-root`, below).
On `root-door=open` it stays open — key-only, as bootstrap left it — because
root there is the **automation** identity the control plane (Coolify) SSHes
in as. It is a machine door, never a human one.
#### The root-door trait was renamed, and old markers still resolve
This trait was `--class human|server` until
[#77](https://github.com/heavy-duty/rig/issues/77). `class` named who *lived
on* the box; what it actually decides is whether root SSH stays open as the
control plane's automation door. Those are different questions, and the roles
proved it: `dev-server` is an unattended VM-host appliance — nobody lives
there — yet it was `class=human`, correctly, because operators enter it as
themselves and its root door must close. After #76 gave `-server` a second
job (naming the machine *family*), `dev-server` carried a suffix saying server
and a trait saying human, and nothing in the name said which axis was which.
`--root-door closed|open` names the axis rig actually branches on.
**This is not a cosmetic rename, and it is not a hard cut.** Unlike role
names — which nothing reads back — this trait is written into `/etc/rig/role`
and read *from* there on live machines, where it gates `rig users close-root`.
Every box bootstrapped before #77 carries `class=human` or `class=server` and
carries it **forever**, until someone re-bootstraps it; nothing migrates a
fleet. So rig **reads both spellings, permanently**:
| marker says | resolves to | means |
|---|---|---|
| `root-door=closed` | closed | the current spelling |
| `root-door=open` | open | the current spelling |
| `class=human` | closed | pre-#77, still honored |
| `class=server` | open | pre-#77, still honored |
| both, agreeing | that value | one claim said twice |
| both, **disagreeing** | refusal | rig will not pick a winner — re-run bootstrap |
| neither | refusal | no door policy to act on — re-run bootstrap |
New markers are written in the **new vocabulary only**. Writing both would
keep an old rig reading a new marker, but it would also entrench the retired
spelling on every box rig ever converges and make the disagreement row
reachable from rig's own hand rather than only from a text editor. The compat
obligation runs the other way: new rig reads old markers, because those exist
in the field and nothing will rewrite them.
The two refusal rows are **fail-closed** on purpose. A marker that names no
door policy, or names two that disagree, leaves rig unable to say whether root
here is a human's bad habit or the fleet's management plane — and the safe
error is a door that stays open and a loud instruction to re-run bootstrap,
never a door welded shut on a machine whose only entrance it was.
**Where this diverges from #17's original table:** that table let the
`runner-server` role close root ("no Coolify involved"). The trait model supersedes
the per-role call: runner is `root-door=open` — an automation identity, not a
person's box — and on every open-door machine root SSH is the management
plane rig itself converges through, so `close-root` refuses there
deliberately, runner included. A CI box you mean to administer like a human
machine is `--root-door closed` at bootstrap, not an exception carved out of
the gate.
**The detection side benefit:** once humans never use root, any root login
that is not the control plane is anomalous *by definition* — a cheap,
high-signal alert that a shared root identity makes impossible to write.
**The honest caveat:** on a Docker-running box this buys attribution, not
privilege reduction — an operator with sudo is root-equivalent anyway.
Attribution is the goal: *who did what* survives, even where *what they could
do* is everything.
### `rig coolify install --version <pin>`
Control-plane box only. Installs Coolify at exactly the pinned version with
`AUTOUPDATE=false` — your deploy tooling is verified against an API surface;
the platform must never move underneath it on its own. Upgrading is an
explicit re-run with a new pin. The pin is required; there is no default.
feat(coolify): install the control-plane dump as a systemd timer The Coolify control-plane database holds the GitHub App private key, every registered server's SSH key, and every environment value for every environment it manages. Backing it up was a manual runbook step, and the dump script lived in cast — the off-box tool, whose src never references it. It runs on the box, as root, under a scheduler: that is rig's job description. It matters beyond tidiness. The dump is forensics, not a restore path — a lost control plane is rebuilt fresh and reconciled from the manifest. So there will be a next control-plane box, and as a runbook step it was born un-backed-up, depending on someone remembering mid-incident. Now it is backed up from birth. rig installs the machinery and templates /etc/coolify-dump.env empty at 0600, never reading it back — no credential passes through rig. The script's own guards make an unfilled file fail the unit loudly rather than ship plaintext. systemd timer over cron: EnvironmentFile is the right idiom for 0600 secrets, failures surface in systemctl status instead of being mailed into the void, and Persistent=true catches a run missed while the box was down. Two hazards the cast script missed, carried into the unit: - aws-cli >= 2.23 enables default upload checksums that S3-compatible backends reject; Debian 13 ships 2.23.6, so the unit defaults both checksum knobs to when_required. - A failed pg_dump piped into age still yields a valid, tiny, encrypted file that uploads cleanly every night and looks exactly like a working backup. The script now refuses to upload an empty artifact. Closes #8 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 19:14:07 +00:00
### `rig coolify backup install`
Control-plane box only. Installs a **nightly age-encrypted dump of Coolify's own
database** as a systemd timer.
```sh
rig coolify backup install
rig coolify backup install --schedule '*-*-* 02:30:00 UTC' --pg-container coolify-db
```
- `--schedule <OnCalendar>` — systemd calendar expression (default: `*-*-* 04:00:00 UTC`)
- `--pg-container` / `--pg-user` / `--pg-db` — Coolify's postgres (defaults: `coolify-db`,
`coolify`, `coolify`)
That database holds the GitHub App private key, every registered server's SSH key,
and every environment value for every environment the control plane manages. It is
`pg_dump`ed straight into `age` — encrypted **client-side, on the box** — and only
then shipped to S3. The bucket is never trusted with plaintext.
It is **forensics, not a restore path.** A lost control plane is rebuilt fresh and
reconciled from your manifest, never restored from this artifact. Which is exactly
why the plumbing belongs in rig: there *will* be a next control-plane box, and it
should be backed up from birth rather than depending on someone remembering a
runbook step mid-incident.
**rig installs the machinery; you supply the bindings.** rig writes
`/etc/coolify-dump.env` **empty**, `0600`, and never reads it back — no credential
ever passes through rig. You fill in the age recipient (a *public* key), the S3
bucket + endpoint, and the S3 credentials. Until you do, the unit **fails loudly on
every run**: a silent backup is worse than a missing one.
rig cannot verify that the upload works — that needs your credentials. So prove it
by hand once, rather than letting the timer discover it at 04:00:
```sh
systemctl start coolify-dump.service
journalctl -u coolify-dump.service -n 20 --no-pager
```
A backup you have never read back is not yet a backup.
feat(db): bring ad-hoc dump/restore on-box as `rig db` Add `rig db dump <container> [outfile]` and `rig db restore <artifact> <container> [db] [--yes]` — imperative on-box PostgreSQL tooling, the interactive counterpart to the scheduled, declarative `coolify backup install`. Key decisions: - Dumps carry `--clean --if-exists --no-owner --no-acl`. `--no-owner --no-acl` is mandatory for cross-instance restores: the target's superuser differs (Coolify randomizes it), so a plain dump aborts under ON_ERROR_STOP=1 on the first GRANT/ALTER OWNER for a missing role. - $POSTGRES_USER/$POSTGRES_DB are read INSIDE the container (single-quoted `sh -c`), never hardcoded to `postgres` on the host. - restore connects as the container's own superuser and runs with ON_ERROR_STOP=1; the optional [db] arg targets a NAMED database in a shared container, passed in via a container env var rather than string splicing. - restore overwrites the target, so it prompts y/N; --yes/--force is the automation bypass. Artifact existence/non-emptiness is checked before the confirm gate and before anything touches the DB. - dump uses pipefail + a sibling temp promoted only on success, and refuses to keep an empty artifact — a failed pg_dump must never leave a plausible-looking .gz behind. Args are validated before the root check (testable without root); guards are root, Debian-family warn, docker, and gzip/gunzip. Adds CLI tests and a `### rig db` README section. Closes #15 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:16:35 +00:00
### `rig db <dump|restore>`
Ad-hoc PostgreSQL dump/restore for a container running on this box. Run as
root.
```sh
rig db dump coolify-db # -> coolify-db-20260717T041500Z.sql.gz
rig db dump my-app-db /srv/snapshots/pre-migrate.sql.gz
rig db restore pre-migrate.sql.gz my-app-db # prompts before overwriting
rig db restore umami.sql.gz shared-pg umami --yes
```
This is **imperative on-box tooling** — the "give me a copy of that database
right now", "put this artifact back" verbs you reach for by hand. It is the
counterpart to [`rig coolify backup install`](#rig-coolify-backup-install),
which is the *scheduled, declarative, forensics-only* path; `db` is
interactive, targets any container, and (on restore) overwrites live data
behind a confirm gate. Declarative convergence lives elsewhere by design — this
verb exists precisely for the moments that are not convergent.
**`dump`** pipes `pg_dump` straight into `gzip`:
```sh
docker exec <container> sh -c \
'pg_dump -U "$POSTGRES_USER" --clean --if-exists --no-owner --no-acl "$POSTGRES_DB"' | gzip
```
- **`--no-owner --no-acl` is mandatory, not cosmetic.** A cross-instance restore
runs as the *target's* superuser, and Coolify randomizes that role per
database — so the source's `ALTER OWNER`/`GRANT` statements name a role that
does not exist on the target and, under `ON_ERROR_STOP=1`, abort the whole
restore on the first one. Stripping ownership and ACLs makes the dump describe
*data and schema*, portable onto any instance.
- **`$POSTGRES_USER` / `$POSTGRES_DB` are read inside the container** — that is
why the command is a *single-quoted* `sh -c`: it evaluates the container's own
environment, never the host's. The host never hardcodes `postgres`; on the
next container that role name is simply wrong.
- With no `[outfile]`, it writes `<container>-<UTC-timestamp>.sql.gz` in the
current directory (same timestamp shape as the nightly dump). `pipefail` is
load-bearing: without it a failing `pg_dump` still exits 0 through the pipe and
`gzip` compresses the truncated output into a valid `.gz` that looks exactly
like a good backup. rig dumps to a sibling temp, promotes it only on success,
and refuses to keep an empty artifact.
**`restore <artifact> <container> [db]`** streams the artifact back in:
```sh
gunzip -c <artifact> | docker exec -i <container> sh -c \
'psql -U "$POSTGRES_USER" -d "${db:-$POSTGRES_DB}" -v ON_ERROR_STOP=1'
```
- It connects as the container's **own superuser** (`$POSTGRES_USER`), again
never a hardcoded role, and runs with `ON_ERROR_STOP=1` so a bad restore fails
loudly instead of limping to a half-applied state and reporting success.
- **`[db]` targets a named database in a shared container** — e.g. a `umami`
database living in a Postgres that also hosts other apps. Omit it to restore
into the container's default `$POSTGRES_DB`. rig passes the name *into* the
container as an env var rather than splicing it into the command string.
- **Restore overwrites the target**, so it prompts `y/N` first. `--yes` (or
`--force`) is the automation bypass. The artifact is checked for existence and
non-emptiness *before* the prompt and before anything touches the database, so
a fat-fingered path fails cheaply.
#### Verifying a dump/restore actually works
A `.gz` that opens without error is not proof of a good backup: a `pg_dump`
truncated mid-stream still compresses into a perfectly valid gzip file that
*looks* exactly like a complete one. The same ethos as the nightly dump applies
here — **a backup you have never read back is not yet a backup.** The only
fully-trustworthy proof is to restore the artifact and read the rows back out.
On a real Coolify box you can do that **without touching prod data** by
restoring into a fresh *scratch* database rather than over the live one:
```sh
# 1. dump the live database (read-only; harmless)
rig db dump coolify-db /srv/snapshots/verify.sql.gz
# 2. create a throwaway database as the container's OWN superuser
docker exec coolify-db sh -c 'createdb -U "$POSTGRES_USER" rig_verify'
# 3. restore the artifact INTO the scratch db (not the live one)
rig db restore /srv/snapshots/verify.sql.gz coolify-db rig_verify --yes
# 4. spot-check a table you expect to see
docker exec coolify-db sh -c \
'psql -U "$POSTGRES_USER" -d rig_verify -c "\dt"'
docker exec coolify-db sh -c \
'psql -U "$POSTGRES_USER" -d rig_verify -c "SELECT count(*) FROM <some-table>"'
# 5. drop the scratch db — live data was never touched
docker exec coolify-db sh -c 'dropdb -U "$POSTGRES_USER" rig_verify'
```
If the counts and tables are there, the artifact is real. This is exactly the
round-trip `test/db-integration.sh` automates in CI (the `db-integration` job):
it seeds a known table in a source container whose superuser is *not* the
default, dumps it, restores into a second container whose superuser differs, and
asserts the rows and an ordered checksum survived — the same proof, done against
throwaway containers on every push.
feat: rig platform — what is this machine, computed not stored rig read no hardware at all. The single exception was `uname -m` in runner-install.sh, used to pick a runner tarball and then discarded — so "is this the 32GB one, or the M900?" was a question you answered by logging in and running free -h, nproc, df -h and uname -r by hand, four commands deep, on a machine you were already unsure about. `rig platform` prints hostname, OS, kernel, CPU, memory, disk and virtualization, then a provenance block: which rig, when, and the role marker's traits. It COMPUTES rather than stores, and that is the design rather than an implementation detail. Specs change without rig doing anything — RAM added, root disk resized, the unattended-upgrades bootstrap itself enables patching the kernel — so a stored spec is stale the moment the machine changes, and refreshing one on every run would collide with bootstrap's "safe to re-run; a second run changes nothing" contract. Nothing is written, so nothing can go stale. The corollary is deliberate: reading only /proc, uname, /etc/os-release, df and systemd-detect-virt means no root, no network, and it runs on a pristine Debian box rig has never bootstrapped — useful for deciding what to converge a machine into, not only for auditing it afterwards. That also makes it the rare rig command the harness can RUN for real rather than grep: the tests assert the answer describes the actual test machine (kernel and hostname compared against independently computed values), and assert it writes nothing. Both known traps are handled explicitly. /etc/os-release is sourced in a SUBSHELL — it defines VERSION, NAME and ID and would otherwise clobber same-named script variables, the form every other site in this tree uses and test/cli.sh already greps for. systemd-detect-virt exits non-zero on bare metal while printing 'none', a normal answer that set -e would otherwise turn into a failed run, so it is wrapped in `|| true`. Provenance is read, never written, and degrades per file. /etc/rig/manifest is #61 and does not exist yet, so that line reads 'not bootstrapped' on every machine today; the command ships complete without it and neither blocks the other. Named `platform` and not `status`: `users status` and `runner status` cross-check recorded against live state and print DRIFT, and a command that records nothing cannot drift, so calling it status would borrow a promise it structurally cannot make. It also leaves `rig status` free for the machine-wide roll-up it will eventually want to be. Refs #64
2026-07-19 23:35:58 +00:00
### `rig platform`
```sh
rig platform
```
What is this machine — computed at run time, **stored nowhere**:
```
PLATFORM
HOSTNAME hetzner-cp-1
OS Debian GNU/Linux 13 (trixie)
KERNEL 6.12.95+deb13-amd64 (x86_64)
CPU AMD Ryzen 7 3700X 8-Core Processor (16 cores)
MEMORY 31Gi total, 24Gi available
DISK 456Gi total, 201Gi free on /
VIRT kvm
PROVENANCE
CONVERGED 0.6.0, 2026-08-02T09:11:03Z
BOOTSTRAP 0.4.0, 2026-07-19T14:24:51Z
ROLE dev-server (root-door=closed host=yes join=authkey)
feat: rig platform — what is this machine, computed not stored rig read no hardware at all. The single exception was `uname -m` in runner-install.sh, used to pick a runner tarball and then discarded — so "is this the 32GB one, or the M900?" was a question you answered by logging in and running free -h, nproc, df -h and uname -r by hand, four commands deep, on a machine you were already unsure about. `rig platform` prints hostname, OS, kernel, CPU, memory, disk and virtualization, then a provenance block: which rig, when, and the role marker's traits. It COMPUTES rather than stores, and that is the design rather than an implementation detail. Specs change without rig doing anything — RAM added, root disk resized, the unattended-upgrades bootstrap itself enables patching the kernel — so a stored spec is stale the moment the machine changes, and refreshing one on every run would collide with bootstrap's "safe to re-run; a second run changes nothing" contract. Nothing is written, so nothing can go stale. The corollary is deliberate: reading only /proc, uname, /etc/os-release, df and systemd-detect-virt means no root, no network, and it runs on a pristine Debian box rig has never bootstrapped — useful for deciding what to converge a machine into, not only for auditing it afterwards. That also makes it the rare rig command the harness can RUN for real rather than grep: the tests assert the answer describes the actual test machine (kernel and hostname compared against independently computed values), and assert it writes nothing. Both known traps are handled explicitly. /etc/os-release is sourced in a SUBSHELL — it defines VERSION, NAME and ID and would otherwise clobber same-named script variables, the form every other site in this tree uses and test/cli.sh already greps for. systemd-detect-virt exits non-zero on bare metal while printing 'none', a normal answer that set -e would otherwise turn into a failed run, so it is wrapped in `|| true`. Provenance is read, never written, and degrades per file. /etc/rig/manifest is #61 and does not exist yet, so that line reads 'not bootstrapped' on every machine today; the command ships complete without it and neither blocks the other. Named `platform` and not `status`: `users status` and `runner status` cross-check recorded against live state and print DRIFT, and a command that records nothing cannot drift, so calling it status would borrow a promise it structurally cannot make. It also leaves `rig status` free for the machine-wide roll-up it will eventually want to be. Refs #64
2026-07-19 23:35:58 +00:00
```
"Is this the 32GB one, or the M900?" was previously a question you answered by
logging in and running `free -h`, `nproc`, `df -h` and `uname -r` by hand —
four commands deep, on a machine you were already unsure about.
**Why this computes instead of storing.** It would be easy to write the specs
into a file at bootstrap. That is the wrong shape: specs change without rig
doing anything — someone adds RAM, resizes the root disk, or the
unattended-upgrades that bootstrap itself enables patches the kernel. A stored
spec is stale the moment the machine changes, and refreshing it on every run
would collide with bootstrap's contract that a second run changes nothing.
Computing at run time removes the problem instead of managing it: the answer
is correct by construction because there is nothing to go stale.
The corollary is deliberate: **`rig platform` works on a machine rig has never
converged.** It reads only `/proc`, `uname`, `/etc/os-release`, `df` and
`systemd-detect-virt`, so it runs on bare Debian before bootstrap — useful for
deciding *what to converge this into*, not just for auditing afterwards. It
needs no root, makes no network call, and writes nothing, ever.
The `PROVENANCE` block is the complementary half — which rig, and when, which
is *decided* rather than observed, so it is stored. It is **read, never
written**: `CONVERGED`/`BOOTSTRAP` come from `/etc/rig/manifest` and `ROLE`
from `/etc/rig/role`. Neither file is required — a machine missing one reads
`not bootstrapped` for that line, which is itself the useful answer. The
manifest is #61 and is not implemented yet, so today those lines read `not
bootstrapped` on every machine; nothing else in the command depends on it.
**The two dates are deliberately separate**, matching #61's schema: `BOOTSTRAP`
is birth (`bootstrapped_by`/`bootstrapped_at`, first-write-wins, pinned
forever) and `CONVERGED` is latest (`converged_by`/`converged_at`, updated only
when the converging version actually differs). That distinction is what answers
"is this machine still converged by a rig that predates the fix?".
A **fresh machine writes both pairs with equal values**, so two identical lines
mean "bootstrapped and never re-converged since" — not a missing record. A
later re-converge by a different rig moves `CONVERGED` and leaves `BOOTSTRAP`
untouched, which is the whole point of keeping them apart.
`CONVERGED not recorded` therefore does **not** describe a freshly
bootstrapped box; no writer produces a manifest without the pair. It means the
file is partial or hand-edited, and the value is deliberately not backfilled
from `BOOTSTRAP` — inferring a convergence that never happened would be worse
than saying so. Likewise a manifest whose `schema=` this rig does not know is
named as such instead of being half-read in silence.
feat: rig platform — what is this machine, computed not stored rig read no hardware at all. The single exception was `uname -m` in runner-install.sh, used to pick a runner tarball and then discarded — so "is this the 32GB one, or the M900?" was a question you answered by logging in and running free -h, nproc, df -h and uname -r by hand, four commands deep, on a machine you were already unsure about. `rig platform` prints hostname, OS, kernel, CPU, memory, disk and virtualization, then a provenance block: which rig, when, and the role marker's traits. It COMPUTES rather than stores, and that is the design rather than an implementation detail. Specs change without rig doing anything — RAM added, root disk resized, the unattended-upgrades bootstrap itself enables patching the kernel — so a stored spec is stale the moment the machine changes, and refreshing one on every run would collide with bootstrap's "safe to re-run; a second run changes nothing" contract. Nothing is written, so nothing can go stale. The corollary is deliberate: reading only /proc, uname, /etc/os-release, df and systemd-detect-virt means no root, no network, and it runs on a pristine Debian box rig has never bootstrapped — useful for deciding what to converge a machine into, not only for auditing it afterwards. That also makes it the rare rig command the harness can RUN for real rather than grep: the tests assert the answer describes the actual test machine (kernel and hostname compared against independently computed values), and assert it writes nothing. Both known traps are handled explicitly. /etc/os-release is sourced in a SUBSHELL — it defines VERSION, NAME and ID and would otherwise clobber same-named script variables, the form every other site in this tree uses and test/cli.sh already greps for. systemd-detect-virt exits non-zero on bare metal while printing 'none', a normal answer that set -e would otherwise turn into a failed run, so it is wrapped in `|| true`. Provenance is read, never written, and degrades per file. /etc/rig/manifest is #61 and does not exist yet, so that line reads 'not bootstrapped' on every machine today; the command ships complete without it and neither blocks the other. Named `platform` and not `status`: `users status` and `runner status` cross-check recorded against live state and print DRIFT, and a command that records nothing cannot drift, so calling it status would borrow a promise it structurally cannot make. It also leaves `rig status` free for the machine-wide roll-up it will eventually want to be. Refs #64
2026-07-19 23:35:58 +00:00
**Known limitation — `CPU` and `MEMORY` inside a container-style guest are
unverified.** `CPU` and `MEMORY` are read straight from `/proc/cpuinfo` and
`/proc/meminfo`, with no cgroup awareness. Inside a box-minted guest (`VIRT`
says `lxc`) it is **not currently established** whether those files report the
instance's configured limits or the host's totals: neither file is namespaced
by the kernel, but `lxcfs` — when the guest has it — overmounts both with
limit-aware versions, so the answer depends on the guest's setup rather than
on anything rig controls. Until someone confirms it against a real guest,
treat those two lines as unreliable on `lxc` machines and check the instance
config if the number matters. Everything else (OS, kernel, disk, virt,
provenance) is the guest's own either way.
Deliberately not guessed at: cgroup-aware limit detection would be the fix if
the numbers do turn out to be the host's, but writing it against a *reasoned*
answer rather than an *observed* one risks correcting a bug that isn't there
and papering over one that is.
Deliberately **not** here: NIC names, MAC addresses, PCI inventory, mount
tables, sensors — this is a cheatsheet, not `inxi`, and the bar is "what would
I want to know before I SSH in". Nor any health judgement ("disk nearly
full"): that needs thresholds this command has no business owning. It is
called `platform` and not `status` on purpose — `rig users status` and `rig
runner status` cross-check recorded state against live state and print
`DRIFT`, and this command records nothing, so it cannot drift and must not
borrow a promise it structurally cannot make. That leaves `rig status` free
for the machine-wide roll-up it will eventually want to be.
### `rig runner install --repo <owner/repo>`
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 <noreply@anthropic.com>
2026-07-20 00:01:16 +00:00
Runner box only, run after `rig bootstrap runner-server` (the same two-step rhythm
as `bootstrap control-plane-server``coolify install`):
```sh
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 <noreply@anthropic.com>
2026-07-20 00:01:16 +00:00
rig bootstrap runner-server --hostname my-ci-box --users ./users
rig runner install --repo acme/widgets
```
Installs GitHub's official `actions/runner` as a systemd service under an
unprivileged user (default `github-runner`, created if absent, never root, no
supplementary groups). The runner is an agent, not a server: it long-polls
GitHub outbound and receives jobs down that already-established connection,
so it needs **zero inbound ports** and works fine behind a deny-all
firewall — it can even trigger deploys on hosts only it can reach, like a
tailnet-only control plane.
No Docker, deliberately: the Docker socket is a root API and `docker` group
membership is root-equivalent, which is a gratuitous path to root on a box
whose whole point is a narrow blast radius. Add Docker only once a job
genuinely needs it, and rethink the isolation model then.
- `--version <pin>` — actions/runner release to install (default: the
latest release, resolved at install time; e.g. `--version 2.335.1`
the latest as of this writing). Pin it when you need a deterministic,
auditable install.
- `--name <name>` — runner name (default: this host's hostname)
- `--labels <csv>` — runner labels, replacing the `ci-runner` default — keep
any label your workflows' `runs-on` needs (GitHub adds `self-hosted` itself)
- `--user <name>` — the unprivileged service user (default: `github-runner`)
**The registration token:** provide it via the `RUNNER_TOKEN` env var or type
it at the interactive prompt. It's short-lived, consumed at registration, and
never written to disk by rig.
Why latest-by-default here when `coolify install` demands a pin: the two
tools age differently. Coolify never self-updates (`AUTOUPDATE=false`), so
its version is a contract your deploy tooling is verified against — stating
it is the point. The runner **self-updates regardless**: GitHub refuses jobs
from stale runners, so freezing it would just make it silently stop taking
work. The install-time version is a starting point either way; `--version`
exists for when you want that starting point deterministic and auditable.
fix(runner): install refuses a box registered to another repo `rig runner install --repo <B>` on a box already registered to repo A treated the mere existence of .runner as "already registered", skipped configure, restarted the service still pointed at A, and reported success. --repo was accepted, validated, and then ignored — leaving B with zero runners and its `runs-on` jobs queued against one that will never come. This is the natural next command after a partial `repoint`, and the failure is worse than a no-op: moving a runner between repos is a trust-boundary act, so quietly putting it back on the old one defeats the point of the move. Gate install on the repo .runner actually names. Convergence — the property worth keeping — is untouched: re-running against the repo the box is already on still skips registration, never prompts for a token, and exits 0. Skipping when the repo *differs* was never convergence, only a silently ignored argument, so it now fails and names both repos, pointing at `runner repoint` (move) or `runner remove` (start over). An unreadable .runner is refused too — it is no licence to assume a match. The .runner reader that `status` and `repoint` each carried is lifted into commands/lib/runner-config.sh, which now also holds the guard. Its json_field no longer dies bare under `set -o pipefail` when a key is missing, which is what `status`'s own ${REPO_URL:-unknown} fallback always assumed. Tests: the guard is exercised against a fixture .runner (refuses another repo naming both, points at repoint, no-ops on the same repo, passes an unregistered box, refuses an unreadable one) plus an ordering assertion that it precedes svc.sh start — reaching it through the CLI would need root and a really-registered runner, which the dependency-free harness cannot fabricate. All three mutants (guard deleted, guard comparing nothing, guard moved below the service start) go red. Closes #13
2026-07-13 14:57:28 +00:00
Convergent **toward `--repo`** — re-running against the repo this box is
already on re-uses the binary, skips registration, and never asks for a token.
Pointed at a *different* repo it **refuses**, and names both: skipping there
would not be convergence, it would be ignoring the argument — restarting the
runner on the **old** repo while reporting success, leaving the repo you asked
for with no runner and its `runs-on` jobs queued forever. Moving a runner
between repos is a trust-boundary act; that verb is
[`rig runner repoint`](#rig-runner-repoint---repo-ownerrepo).
### `rig runner status`
```sh
rig runner status
```
What this box's runner is registered to — repo, runner name, labels,
install dir, systemd unit and its state. Reads the runner's own on-disk
config; no token, no network call. Exits 1 when no runner is installed.
The answer to "wait, which repo is this box wired to?" should not require
knowing that the config lives in a dotfile under an unprivileged user's home.
### `rig runner remove`
```sh
rig runner remove
rig runner remove --local # no token; leaves a stale entry to delete by hand
```
Stops and uninstalls the systemd service, then deregisters the runner from
GitHub. The binary and its user stay put, so a later `rig runner install`
re-registers without downloading anything.
**The token here is a *removal* token, not a registration token** — a
different endpoint, and mixing them up is the easy mistake:
```sh
gh api -X POST repos/<owner/repo>/actions/runners/remove-token
```
Supply it via `RUNNER_REMOVE_TOKEN` or the prompt; it never touches disk.
`--local` is the escape hatch for when the registration is already gone
server-side (or you can't mint a token): the box is cleaned, but a stale
offline runner stays listed in the repo, for you to delete from
Settings → Actions → Runners.
The service always comes down *first*, in both paths. GitHub's own removal
refuses to run while the service is installed ("Uninstall service first"),
and `--local` skips that check entirely — which would otherwise leave a
running service pointed at config that no longer exists.
Convergent — a box with no runner installed exits 0.
### `rig runner repoint --repo <owner/repo>`
```sh
rig runner repoint --repo acme/widgets
```
Moves an installed runner from one repository to another: deregister,
re-register, reusing the binary already on the box. It keeps the runner's
existing name unless you pass `--name`.
fix(runner): install refuses a box registered to another repo `rig runner install --repo <B>` on a box already registered to repo A treated the mere existence of .runner as "already registered", skipped configure, restarted the service still pointed at A, and reported success. --repo was accepted, validated, and then ignored — leaving B with zero runners and its `runs-on` jobs queued against one that will never come. This is the natural next command after a partial `repoint`, and the failure is worse than a no-op: moving a runner between repos is a trust-boundary act, so quietly putting it back on the old one defeats the point of the move. Gate install on the repo .runner actually names. Convergence — the property worth keeping — is untouched: re-running against the repo the box is already on still skips registration, never prompts for a token, and exits 0. Skipping when the repo *differs* was never convergence, only a silently ignored argument, so it now fails and names both repos, pointing at `runner repoint` (move) or `runner remove` (start over). An unreadable .runner is refused too — it is no licence to assume a match. The .runner reader that `status` and `repoint` each carried is lifted into commands/lib/runner-config.sh, which now also holds the guard. Its json_field no longer dies bare under `set -o pipefail` when a key is missing, which is what `status`'s own ${REPO_URL:-unknown} fallback always assumed. Tests: the guard is exercised against a fixture .runner (refuses another repo naming both, points at repoint, no-ops on the same repo, passes an unregistered box, refuses an unreadable one) plus an ordering assertion that it precedes svc.sh start — reaching it through the CLI would need root and a really-registered runner, which the dependency-free harness cannot fabricate. All three mutants (guard deleted, guard comparing nothing, guard moved below the service start) go red. Closes #13
2026-07-13 14:57:28 +00:00
This is the verb that was missing. `runner install` can create a runner but
never move one — pointed at a repo the box is not on, it fails and sends you
here — and re-pointing a box otherwise meant hand-rolled `config.sh`/`svc.sh`
incantations against an install path only rig knew.
Two short-lived tokens, each minted from **its own** repo — `RUNNER_REMOVE_TOKEN`
for the one it's leaving, `RUNNER_TOKEN` for the one it's joining. Both are
collected **before** anything is torn down: a token you turn out not to have
should fail while the runner is still registered and working, not halfway
through the move. If re-registration fails anyway, rig says so plainly and
prints the exact `runner install` line that finishes the job.
> **Labels do not survive a move on their own.** GitHub holds them; the runner
> does not persist them locally. rig now records what it registered with, so
> `repoint` and `status` can read it back — but a runner installed before rig
> did that has nothing to read, and `repoint` falls back to the `ci-runner`
> default and warns loudly before it touches anything. Labels are what
> `runs-on` matches, so a silent change there is a workflow that simply stops
> finding its runner. Pass `--labels` if yours differ.
Convergent — repointing to the repo it is already on changes nothing, exits 0,
and never asks for a token.
### `rig users apply --file <path>`
Converges named operator accounts from a declarative users file — on **every**
box, whatever its root-door policy (see *The identity model*). Run as root. Convergent: a second identical
run says "already converged; no changes".
feat!: bootstrap takes the users file `rig bootstrap` already knew everything else about what a box is — class, host, join, hostname — and wrote /etc/rig/role to say so. The users file was the last piece of that answer it did not take, so bring-up was two commands and the second one was the forgettable one. --users <path> now runs the `users apply` convergence as bootstrap's final phase: after the traits, after the verified tailnet join, after the role marker (apply reads that marker), and after the host=yes box install (so box-role users find the incus group box's own setup-host built). One command, and the box has its people on it. BREAKING: --users is required on every machine role, with --no-users as the explicit opt-out. Omitting both is a usage error naming both flags; passing both is a usage error too. class=server is required as well: a machine nobody logs into routinely is exactly where shared-root access rots, and per-human accounts keep attribution intact for the times someone does go in. The file is never persisted — passed per invocation, read once through apply, copied nowhere. `--users -` is refused: bootstrap's stdin belongs to the pre-auth key prompt. The box TENANT roles take neither flag; a guest is minted non-interactively, never joins the tailnet, and has no SSH door of its own. rig still never installs Incus and never calls `box setup-host` itself. The host=yes box-role precondition refuses early only where the outcome is already proven (RIG_SKIP_BOX_INSTALL=1); every other way that step can fail lands in `users apply`'s existing refusal, unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:17:49 +00:00
This is also what `rig bootstrap --users <path>` runs as its last phase, so on
a fresh box you rarely call it by hand — it is the *re-converge* verb (a key
added, an operator revoked, a `--no-users` box growing people later).
```
# user roles ssh public key
dan admin,box ssh-ed25519 AAAA... dan@laptop
dan admin,box ssh-ed25519 AAAA... dan@desktop
maria rig,box ssh-ed25519 AAAA... maria@mac
```
One line per key — user, comma-joined roles, then the SSH public key (the rest
of the line). The format is bash-parseable on purpose: a rig box has no YAML
parser and no jq, and gets neither for this. Repeated username lines add
authorized keys, and the roles must be identical on each — a repeated line
always means "another key", never a quiet role edit hiding mid-file. `root` is
refused as a username: this file names operators; root's fate is root-door policy.
`--file -` reads stdin. A bad file exits 2 with **every** error listed at
once, before anything changes — one fix cycle, not one round-trip per line.
**A file naming zero users is confirmed, not refused (#65).** "Revoke
everyone" and "I truncated the file" are the same instruction in this format,
and a stray `>` writes the second one. apply cannot read intent — but it can
read the `/etc/rig/users` ledger, which draws the only line worth drawing:
against an empty ledger an empty file is an unambiguous no-op, and against a
populated one it closes every named door on the box. So that second case, and
only it, stops and asks — naming how many operators are about to go:
```sh
rig users apply --file ./users # empty file + managed operators -> asks
rig users apply --file ./users --yes # ...or say yes up front
RIG_YES=1 rig users apply --file ./users # same, for automation
```
**Without a terminal and without consent it exits 2** rather than assume a
yes it cannot ask for — the contract `rig uninstall` already uses, and the
same `RIG_YES` the installer family reads. Consent that cannot be obtained is
not consent, and a prompt nothing can answer must not hang either.
This is a *confirmation*, deliberately unlike `rig bootstrap`'s flat refusal
of the same file (see *Bootstrap*): bootstrap **asserts** who lives on a box,
so an empty answer contradicts itself, while apply **converges** — and
converging to zero is a complete, legitimate de-provisioning that has to keep
working. Already-revoked ledger entries don't count toward the number, so a
second identical run of an emptied file stays the silent no-op convergence
promises. Dropping *most* users — nineteen of twenty — is not yet gated;
that needs a threshold, where "the file is empty" is a bright line that needs
none.
**`@root` — seed keys from the door you came in through (#17).** A key field
of exactly `@root` means "this user's `authorized_keys` becomes root's
CURRENT `/root/.ssh/authorized_keys`". The point is lockout-avoidance: you
provably hold a root private key — you SSHed in with it to run apply at all —
so the seeded key is the one key rig can *know* opens for you; any pasted
literal can be a key you do not hold. `@root` mixes with literal lines
(seeded keys land first, literals append after), re-runs re-seed from root's
then-current file — convergent to it, so a seeded key you hand-remove from
the admin returns until you switch the line to literal keys — and apply dies
if root has no `authorized_keys` to seed. Root's key lines are copied
verbatim, options included: a `from=`/`command=` restriction follows the key,
and on a Coolify-managed box root's file also carries *Coolify's* key — on
`root-door=open`, prefer literal keys.
**Public tool, private state, here too.** The users file lives in *your*
private infra repo and is passed per invocation — rig never persists it. It
holds nothing secret anyway: usernames, roles, and *public* keys.
| role | grants | via group |
|---------|----------------------------------------------|-------------|
| `admin` | full NOPASSWD sudo | `rig-admin` |
| `rig` | NOPASSWD sudo for `/usr/local/bin/rig` only | `rig` |
feat: users apply grants the box tier, not just the socket Role `box` resolved to exactly one action, `usermod -aG incus`. That is the socket — step 1 of the five `box grant` performs. Without the other four (the user-<uid> project, its narrowing to boxnet and only boxnet, the snapshot and backup allowances clone and `box export` ride, and the shipped box-net profile installed into that project) the user's first `box new` refuses for want of a box-net profile, so apply's promise — the users file is the fleet's source of truth — was not kept for this role. Worse, until an admin arrived by hand the user held an `incus` membership with no converged project, and incus-user would lazily hand them a stock unhardened NAT bridge: a state box's own contract forbids. On host=yes apply now calls `box grant <user>` per box-role user. rig calls box's grant rather than reimplementing four fifths of it — the "rig never installs Incus" boundary is about installation, not invocation, and grant is already script-callable: idempotent, root-or-sudo, stdin-pinned, with its own run-as-the-user touch. Three decisions the code carries in comment form: - Ordering. The call sits after `useradd` (grant opens with a getent passwd and refuses an unknown account) and after the other groups, so a user whose grant fails still lands with everything rig owns outright. - Failure granularity, split the way the host= guard beside it already splits. A missing box CLI on host=yes dies, like the missing incus group: a broken VM host, not a per-user accident. A per-user grant failure warns and continues — one box-role user somewhere in the fleet must not stop apply everywhere VMs don't live. host=no and marker-less boxes keep their existing skip-with-warning untouched. - The group ADD is deferred to grant, while `incus` stays in the wanted set so the exact-convergence loop never strips a box-role user's socket. Grant's rollback only reaches a membership that run added, so rig opening the socket first would leave a failed grant unable to close it. And grant is the authority on whether the group belongs at all: for an incus-admin member it deliberately does not add `incus`. An incus-admin member is warned, never fatal: box grant refuses them today, which heavy-duty/box#99 fixes box-side with no rig change needed. Closes #49 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:15:55 +00:00
| `box` | Incus **restricted** tier, no sudo | `incus` + `box grant` |
fix(users): review findings — invoker gate, real SSH revocation, StrictModes-shaped close-root gate, trait-aware box role Seven review findings on the users family, each with the harness check that would have caught it: - Invoker gate (apply + close-root): %rig's sudoers rule is binary-scoped but not argument-scoped, so `sudo rig users apply --file <me-as-admin>` made role rig silently root-equivalent through the very command that granted it. Identity management now refuses any sudo invoker outside rig-admin; direct root (bring-up, a root shell) proceeds. - Offboarding revokes SSH, not just the password: a '!'-locked password is not a closed door under UsePAM — Debian sshd still honors the pubkey. A dropped user's account is now expired (usermod -L -e 1, the switch PAM actually enforces) and authorized_keys is renamed to authorized_keys.revoked-by-rig — access revoked, data kept, convergence never destroys. Present users get their expiry cleared idempotently, so a re-added user comes back to life. - The ledger remembers: two-field lines ('name active' / 'name revoked', legacy bare names read as active), so dropped users no longer vanish from rig's memory on the next rewrite. status now reports the ledger state corroborated by the account's real expiry — passwd -S read L for everyone (apply locks all passwords always), so its locked/active was meaningless — and flags a mismatch loudly as drift. - Perms are part of the converged state: ~/.ssh and authorized_keys ownership and mode converge on every run, not only when content changes — StrictModes treats them as load-bearing, so drifted perms were a broken login that "already converged" lied about. Only the content write stays cmp-guarded. - close-root's admin-door gate checks the StrictModes shape per candidate — ownership, group/world-writability of home/.ssh/authorized_keys, a real login shell, an unexpired account — and names which check failed. It proves the door SHOULD open, not that it does; the separate-session advisory stays load-bearing. - Usernames are validated in the parser's one-pass refusal matrix (^[a-z_][a-z0-9_-]{0,31}$): 'fo|o' corrupted the parser's own '|'-delimited stream, and a leading '-' read as a useradd flag mid-convergence. - The box role is trait-aware: on a host=no box an absent incus group skips the role with a warning and converges everything else — one box-role user in a fleet-wide file must not abort apply everywhere VMs don't live. host=yes still dies pointing at box setup-host; a classless marker warns toward a bootstrap re-run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:01:19 +00:00
**The honest limit of the `rig` role:** its sudo grant is binary-scoped, not
argument-scoped — it trusts its holder with every rig verb *except* identity
management. The `rig users` commands gate their **invoker**: run under sudo
by anyone outside `rig-admin`, they refuse. Without that gate, `sudo rig
users apply` against a file naming yourself admin would make the scoped grant
silently root-equivalent through the very tool it scopes. Direct root — a
bring-up shell, before any admin exists — proceeds.
`box` binds where VMs live, and a users file is fleet-wide — its box grants
are not. **The `host=` trait decides whether the box role applies here, and
the `incus` group never overrides it.** On `host=no` — or a marker that names
no `host=` at all, or no marker — the role is **skipped with a warning** and
everything else, admins included, still converges: one box-role user
somewhere in the fleet must not stop apply everywhere VMs don't live. The
verdict is the same whether or not the group happens to exist.
That last part is the point. rig never installs Incus — box's `setup-host`
owns the daemon — so a `host=no` box can still carry a leftover `incus`
group from a previous life. Adding someone to it there would hand out the
socket with no tier behind it, and incus-user would lazily build them an
**unhardened** project on first contact: `incusbr-<uid>`, NAT on v4 *and*
v6, no ACL, no `dns.mode=none`, no port isolation. So on that mismatch apply
warns — naming the contradiction and `rig bootstrap --host yes` as the
repair — and **strips** box-role users out of `incus`, because an inherited
half-grant is the same defect as a fresh one.
The group's presence matters only once the trait already said yes: on
`host=yes` an absent `incus` group means the daemon was never set up, so
apply dies pointing at `box setup-host` rather than conjure a group nothing
would consult. `incus-admin` is deliberately **not** a role: that group is
fix(users): review findings — invoker gate, real SSH revocation, StrictModes-shaped close-root gate, trait-aware box role Seven review findings on the users family, each with the harness check that would have caught it: - Invoker gate (apply + close-root): %rig's sudoers rule is binary-scoped but not argument-scoped, so `sudo rig users apply --file <me-as-admin>` made role rig silently root-equivalent through the very command that granted it. Identity management now refuses any sudo invoker outside rig-admin; direct root (bring-up, a root shell) proceeds. - Offboarding revokes SSH, not just the password: a '!'-locked password is not a closed door under UsePAM — Debian sshd still honors the pubkey. A dropped user's account is now expired (usermod -L -e 1, the switch PAM actually enforces) and authorized_keys is renamed to authorized_keys.revoked-by-rig — access revoked, data kept, convergence never destroys. Present users get their expiry cleared idempotently, so a re-added user comes back to life. - The ledger remembers: two-field lines ('name active' / 'name revoked', legacy bare names read as active), so dropped users no longer vanish from rig's memory on the next rewrite. status now reports the ledger state corroborated by the account's real expiry — passwd -S read L for everyone (apply locks all passwords always), so its locked/active was meaningless — and flags a mismatch loudly as drift. - Perms are part of the converged state: ~/.ssh and authorized_keys ownership and mode converge on every run, not only when content changes — StrictModes treats them as load-bearing, so drifted perms were a broken login that "already converged" lied about. Only the content write stays cmp-guarded. - close-root's admin-door gate checks the StrictModes shape per candidate — ownership, group/world-writability of home/.ssh/authorized_keys, a real login shell, an unexpired account — and names which check failed. It proves the door SHOULD open, not that it does; the separate-session advisory stays load-bearing. - Usernames are validated in the parser's one-pass refusal matrix (^[a-z_][a-z0-9_-]{0,31}$): 'fo|o' corrupted the parser's own '|'-delimited stream, and a leading '-' read as a useradd flag mid-convergence. - The box role is trait-aware: on a host=no box an absent incus group skips the role with a warning and converges everything else — one box-role user in a fleet-wide file must not abort apply everywhere VMs don't live. host=yes still dies pointing at box setup-host; a classless marker warns toward a bootstrap re-run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:01:19 +00:00
host-root-equivalent, break-glass by hand only.
feat: users apply grants the box tier, not just the socket Role `box` resolved to exactly one action, `usermod -aG incus`. That is the socket — step 1 of the five `box grant` performs. Without the other four (the user-<uid> project, its narrowing to boxnet and only boxnet, the snapshot and backup allowances clone and `box export` ride, and the shipped box-net profile installed into that project) the user's first `box new` refuses for want of a box-net profile, so apply's promise — the users file is the fleet's source of truth — was not kept for this role. Worse, until an admin arrived by hand the user held an `incus` membership with no converged project, and incus-user would lazily hand them a stock unhardened NAT bridge: a state box's own contract forbids. On host=yes apply now calls `box grant <user>` per box-role user. rig calls box's grant rather than reimplementing four fifths of it — the "rig never installs Incus" boundary is about installation, not invocation, and grant is already script-callable: idempotent, root-or-sudo, stdin-pinned, with its own run-as-the-user touch. Three decisions the code carries in comment form: - Ordering. The call sits after `useradd` (grant opens with a getent passwd and refuses an unknown account) and after the other groups, so a user whose grant fails still lands with everything rig owns outright. - Failure granularity, split the way the host= guard beside it already splits. A missing box CLI on host=yes dies, like the missing incus group: a broken VM host, not a per-user accident. A per-user grant failure warns and continues — one box-role user somewhere in the fleet must not stop apply everywhere VMs don't live. host=no and marker-less boxes keep their existing skip-with-warning untouched. - The group ADD is deferred to grant, while `incus` stays in the wanted set so the exact-convergence loop never strips a box-role user's socket. Grant's rollback only reaches a membership that run added, so rig opening the socket first would leave a failed grant unable to close it. And grant is the authority on whether the group belongs at all: for an incus-admin member it deliberately does not add `incus`. An incus-admin member is warned, never fatal: box grant refuses them today, which heavy-duty/box#99 fixes box-side with no rig change needed. Closes #49 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:15:55 +00:00
**The group is the socket; the tier is `box grant`.** On `host=yes`, apply
calls `box grant <user>` for every box-role user — the `incus` group is only
the first of the five steps that grant performs (the `user-<uid>` project,
its narrowing to `boxnet` and *only* `boxnet`, the snapshot and backup
allowances `box clone` and `box export` ride, and the shipped `box-net`
profile installed into that project). rig calls box's own grant rather than
reimplementing four fifths of it: the "rig never installs Incus" boundary is
about installation, not invocation, and deferring here respects it harder
than a rig-side copy would. The group ADD is left to grant too, so a grant
that fails partway can take the socket back with it — rig opening the socket
first would leave a user with live access to an *un-narrowed* project, which
is worse than no grant at all. A grant that fails **warns and continues**:
one box-role user's project must not stop apply for the fleet. A missing
`box` CLI on `host=yes` **dies**, like the missing `incus` group — that is a
broken VM host, not a per-user accident.
fix: dropping the box role revokes through box, not behind its back `users apply` converged group `incus` with a bare `gpasswd -d`, the same move it makes for `rig-admin` and `rig`. Those two are rig's. `incus` is box's, and `box revoke` does strictly more with it: it says out loud that supplementary groups are read AT LOGIN, so a session the dropped operator already holds keeps the Incus socket until that session dies, and it hands over `loginctl terminate-user <user>` as the remedy. rig logged "removed <user> from incus" and moved on. An operator who dropped someone from the users file and watched apply succeed believed the VM access was gone — and was wrong for as long as that user held a session. Both removal paths — the per-user convergence loop and the dropped-user sweep — now route the incus group through one `drop_incus` helper that calls `box revoke`, keeping a single owner for the group. Never `--purge`: that deletes the user's boxes, images and project, and destroying someone's running machines is not a convergence step; it stays an explicit admin act. The exit code is not trusted (the #12 lesson bootstrap already applies to box's installer): a revoke that returns 0 with the membership still standing has not closed the socket, so the effective state is checked and rig falls back to removing the group itself — as it also does on a host where box is not installed. Every fallback path carries the session warning in rig's own voice, because the silence was the bug. The absent-group case needs no new guard: `id -nG` cannot report a group that does not exist, so the existing `in_group` test at both call sites is already false on a host=no box or one where `box setup-host` never ran. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:18:21 +00:00
Losing the `box` role goes back through box, too. `rig-admin` and `rig` are
rig's groups and a `gpasswd -d` is the whole story for them; `incus` is box's,
and `box revoke` does more with it than remove a membership — it says out loud
that supplementary groups are read **at login**, so a session the dropped
operator already holds keeps the Incus socket until it dies, and names
`loginctl terminate-user <user>` as the way to end it now. So apply calls
`box revoke` and lets box speak. Never `--purge`: that deletes the user's
boxes, images and project, and destroying someone's running machines is not a
convergence step — `box revoke <user> --purge` stays a deliberate admin act.
Where box is not installed (or the revoke returns success with the membership
still standing — an exit code is not effective state) rig removes the group
itself **and carries the session warning**, because a silent removal is what
lets an operator believe access ended when it has not.
**All passwords stay locked, always** — created or found. The SSH key at the
door is the authentication, and NOPASSWD sudo does not weaken it: there was
never a password to guess or rotate.
Convergence is exact. Membership in the three rig-managed groups is made to
match the file — added *and* removed — while every other group is left alone:
fix(users): review findings — invoker gate, real SSH revocation, StrictModes-shaped close-root gate, trait-aware box role Seven review findings on the users family, each with the harness check that would have caught it: - Invoker gate (apply + close-root): %rig's sudoers rule is binary-scoped but not argument-scoped, so `sudo rig users apply --file <me-as-admin>` made role rig silently root-equivalent through the very command that granted it. Identity management now refuses any sudo invoker outside rig-admin; direct root (bring-up, a root shell) proceeds. - Offboarding revokes SSH, not just the password: a '!'-locked password is not a closed door under UsePAM — Debian sshd still honors the pubkey. A dropped user's account is now expired (usermod -L -e 1, the switch PAM actually enforces) and authorized_keys is renamed to authorized_keys.revoked-by-rig — access revoked, data kept, convergence never destroys. Present users get their expiry cleared idempotently, so a re-added user comes back to life. - The ledger remembers: two-field lines ('name active' / 'name revoked', legacy bare names read as active), so dropped users no longer vanish from rig's memory on the next rewrite. status now reports the ledger state corroborated by the account's real expiry — passwd -S read L for everyone (apply locks all passwords always), so its locked/active was meaningless — and flags a mismatch loudly as drift. - Perms are part of the converged state: ~/.ssh and authorized_keys ownership and mode converge on every run, not only when content changes — StrictModes treats them as load-bearing, so drifted perms were a broken login that "already converged" lied about. Only the content write stays cmp-guarded. - close-root's admin-door gate checks the StrictModes shape per candidate — ownership, group/world-writability of home/.ssh/authorized_keys, a real login shell, an unexpired account — and names which check failed. It proves the door SHOULD open, not that it does; the separate-session advisory stays load-bearing. - Usernames are validated in the parser's one-pass refusal matrix (^[a-z_][a-z0-9_-]{0,31}$): 'fo|o' corrupted the parser's own '|'-delimited stream, and a leading '-' read as a useradd flag mid-convergence. - The box role is trait-aware: on a host=no box an absent incus group skips the role with a warning and converges everything else — one box-role user in a fleet-wide file must not abort apply everywhere VMs don't live. host=yes still dies pointing at box setup-host; a classless marker warns toward a bootstrap re-run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:01:19 +00:00
not rig's to converge. `authorized_keys` becomes exactly the file's keys, and
its ownership and mode (and `.ssh`'s) are converged on **every** run, not
just when content changes — sshd's `StrictModes` treats them as
load-bearing, so drifted perms are a broken login that "already converged"
would lie about. A user dropped from the file is found via the
`/etc/rig/users` ledger and **revoked, never deleted**: the account is
expired — the switch PAM actually enforces; a locked password alone still
lets a pubkey in under Debian's `UsePAM` — and `authorized_keys` is renamed
to `authorized_keys.revoked-by-rig`. Access revoked, data kept: deletion
frees the uid for reuse and orphans file ownership, so attribution would rot;
home stays for the same reason, and re-adding the user to the file brings
them back, fresh keys and all. And the sudoers rules land in
`/etc/sudoers.d/rig-roles` only after `visudo -c` passes on the candidate — a
bad file under `/etc/sudoers.d` can take down *all* of sudo, locking every
admin out of the very escalation path apply just granted.
### `rig manifest`
```sh
rig manifest # the whole provenance record
rig manifest converged_by # one value, for a shell caller
```
Prints `/etc/rig/manifest`**which rig converged this machine, and when**.
`rig bootstrap` writes it as its last durable act, beside the role marker;
this command only reads, needs no root (the file is `0644`), and works on a
machine whose rig has since been upgraded or removed.
```
schema=1
bootstrapped_by=0.4.0
bootstrapped_at=2026-07-19T14:24:51Z
converged_by=0.6.0
converged_at=2026-08-02T09:11:03Z
```
Two pairs: **birth** — the rig that *first* converged this machine, pinned
forever — and **latest** — the newest rig to have converged it. On a fresh
machine they are equal. The version recorded is the one that **ran**, captured
at run time; `rig --version` reports the tree installed *now*, which after an
upgrade answers a different question, because a machine outlives the rig that
built it.
Only **decided** facts live here, and that is what keeps bootstrap's
convergence contract intact. `bootstrapped_*` is first-write-wins;
`converged_*` moves **only when the version actually differs** — it records
the time the converging version last changed, not the time of the last run. A
re-run by the same rig therefore renders a byte-identical file and the
cmp-guard stays silent; a re-converge by a *different* rig is a real change and
the guard firing there is correct.
**Observed** facts — cores, RAM, disk, kernel — are deliberately absent: they
go stale on their own (someone adds RAM; unattended-upgrades patches the
kernel), so storing them would either lie or force a rewrite on every run. They
belong to `rig platform`, which computes them fresh and stores nothing.
`key=value`, one per line — never JSON, never YAML. This is the one file that
must stay readable on the most broken machine in the fleet, and a
rig-bootstrapped box has no YAML parser and no `jq`. Readers must ignore keys
they do not know, so `schema=` is bumped only when a key is removed or
repurposed; a manifest written by a newer rig stays readable to an older one,
and the writer preserves lines it does not own rather than eating them. Nothing
here is ever a credential.
Exits 1 when there is no manifest — a machine converged before rig wrote one,
or never converged at all. `RIG_MANIFEST` overrides the path.
The manifest does **not** replace `/etc/rig/role`. The marker holds *traits*
(what this box is) and has six readers; the manifest holds *provenance* (what
built it). Two files, two jobs.
### `rig users status`
```sh
rig users status
```
Read-only truth: per rig-managed user, the roles derived from the groups the
user is **actually** in — not the ledger's memory of an apply — plus the
fix(users): review findings — invoker gate, real SSH revocation, StrictModes-shaped close-root gate, trait-aware box role Seven review findings on the users family, each with the harness check that would have caught it: - Invoker gate (apply + close-root): %rig's sudoers rule is binary-scoped but not argument-scoped, so `sudo rig users apply --file <me-as-admin>` made role rig silently root-equivalent through the very command that granted it. Identity management now refuses any sudo invoker outside rig-admin; direct root (bring-up, a root shell) proceeds. - Offboarding revokes SSH, not just the password: a '!'-locked password is not a closed door under UsePAM — Debian sshd still honors the pubkey. A dropped user's account is now expired (usermod -L -e 1, the switch PAM actually enforces) and authorized_keys is renamed to authorized_keys.revoked-by-rig — access revoked, data kept, convergence never destroys. Present users get their expiry cleared idempotently, so a re-added user comes back to life. - The ledger remembers: two-field lines ('name active' / 'name revoked', legacy bare names read as active), so dropped users no longer vanish from rig's memory on the next rewrite. status now reports the ledger state corroborated by the account's real expiry — passwd -S read L for everyone (apply locks all passwords always), so its locked/active was meaningless — and flags a mismatch loudly as drift. - Perms are part of the converged state: ~/.ssh and authorized_keys ownership and mode converge on every run, not only when content changes — StrictModes treats them as load-bearing, so drifted perms were a broken login that "already converged" lied about. Only the content write stays cmp-guarded. - close-root's admin-door gate checks the StrictModes shape per candidate — ownership, group/world-writability of home/.ssh/authorized_keys, a real login shell, an unexpired account — and names which check failed. It proves the door SHOULD open, not that it does; the separate-session advisory stays load-bearing. - Usernames are validated in the parser's one-pass refusal matrix (^[a-z_][a-z0-9_-]{0,31}$): 'fo|o' corrupted the parser's own '|'-delimited stream, and a leading '-' read as a useradd flag mid-convergence. - The box role is trait-aware: on a host=no box an absent incus group skips the role with a warning and converges everything else — one box-role user in a fleet-wide file must not abort apply everywhere VMs don't live. host=yes still dies pointing at box setup-host; a classless marker warns toward a bootstrap re-run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:01:19 +00:00
`authorized_keys` count (`revoked` when only the `.revoked-by-rig` rename
remains) and the user's state, **active** or **revoked**. The state is the
ledger's word corroborated by the account's real expiry — the switch that
actually revokes — and a mismatch is flagged loudly as drift: a box someone
changed behind rig's back must never read as healthy. Reads the box only; no
network, no writes. Run as root (shadow is read).
### `rig users close-root`
```sh
rig users close-root
```
Shuts the root SSH door — `root-door=closed` boxes only, and only once a named
admin can already get in. The gates run in order: the `/etc/rig/role` marker
must resolve to `closed` — an absent marker refuses (never shut the root door
blind; re-run bootstrap so the box knows what it is), and `root-door=open`
refuses with no `--force`, because root there is the control plane's
automation identity and closing it severs fleet management. A marker written
before #77 says `class=human|server` and resolves to `closed|open`
respectively, so a box bootstrapped before the rename gates exactly as it
always did. Then at least one
fix(users): review findings — invoker gate, real SSH revocation, StrictModes-shaped close-root gate, trait-aware box role Seven review findings on the users family, each with the harness check that would have caught it: - Invoker gate (apply + close-root): %rig's sudoers rule is binary-scoped but not argument-scoped, so `sudo rig users apply --file <me-as-admin>` made role rig silently root-equivalent through the very command that granted it. Identity management now refuses any sudo invoker outside rig-admin; direct root (bring-up, a root shell) proceeds. - Offboarding revokes SSH, not just the password: a '!'-locked password is not a closed door under UsePAM — Debian sshd still honors the pubkey. A dropped user's account is now expired (usermod -L -e 1, the switch PAM actually enforces) and authorized_keys is renamed to authorized_keys.revoked-by-rig — access revoked, data kept, convergence never destroys. Present users get their expiry cleared idempotently, so a re-added user comes back to life. - The ledger remembers: two-field lines ('name active' / 'name revoked', legacy bare names read as active), so dropped users no longer vanish from rig's memory on the next rewrite. status now reports the ledger state corroborated by the account's real expiry — passwd -S read L for everyone (apply locks all passwords always), so its locked/active was meaningless — and flags a mismatch loudly as drift. - Perms are part of the converged state: ~/.ssh and authorized_keys ownership and mode converge on every run, not only when content changes — StrictModes treats them as load-bearing, so drifted perms were a broken login that "already converged" lied about. Only the content write stays cmp-guarded. - close-root's admin-door gate checks the StrictModes shape per candidate — ownership, group/world-writability of home/.ssh/authorized_keys, a real login shell, an unexpired account — and names which check failed. It proves the door SHOULD open, not that it does; the separate-session advisory stays load-bearing. - Usernames are validated in the parser's one-pass refusal matrix (^[a-z_][a-z0-9_-]{0,31}$): 'fo|o' corrupted the parser's own '|'-delimited stream, and a leading '-' read as a useradd flag mid-convergence. - The box role is trait-aware: on a host=no box an absent incus group skips the role with a warning and converges everything else — one box-role user in a fleet-wide file must not abort apply everywhere VMs don't live. host=yes still dies pointing at box setup-host; a classless marker warns toward a bootstrap re-run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:01:19 +00:00
`rig-admin` member must hold a login sshd would plausibly **accept** — a
non-empty `authorized_keys` alone proves a file, not a door: the gate checks
the `StrictModes` shape (home, `.ssh`, and `authorized_keys` owned by the
user and not group/world-writable), a real login shell, and an unexpired
account — and then two **reachability** proofs (#17): `sudo -n true` under
`runuser` must answer, so NOPASSWD sudo is effective rather than merely
written, and `sshd -T -C user=<admin>` must resolve a per-user effective
config that accepts the login (`pubkeyauthentication yes`, no `DenyUsers`
hit — where any pattern or `USER@HOST` entry counts as a hit, fail closed,
since `DenyUsers dan*` really denies admin `dan` and rig will not re-implement
sshd's pattern engine to prove a miss — `AllowUsers`, if set, names them
literally, and the same fail-closed pair for `DenyGroups`/`AllowGroups`
judged against the admin's actual groups), so a `Match` block elsewhere
cannot quietly exclude the admin
while every file looks right. The refusal names which check failed, per
candidate. What no local check can prove: that you *hold* the private key,
and how a `Match Address` rule treats your real client address (the probe
resolves against a synthetic `addr=127.0.0.1`) — which is why the
separate-session verification below stays load-bearing. Never close the only
door.
Before running it, prove the admin door in a **separate** session — `ssh
<admin>@<box>` while this one stays open. Root SSH is being welded shut; the
admin login must be proven, not presumed.
> **The drop-in's name is the entire mechanism.** close-root installs
> `/etc/ssh/sshd_config.d/00-rig-users.conf` carrying exactly
> `PermitRootLogin no`. sshd_config is first-wins, `Include` expands its glob
> lexically, and `-` (0x2D) sorts before `.` (0x2E) — so `00-rig-users.conf`
> is read *before* bootstrap's `00-rig.conf` and beats its
> `prohibit-password`. Bootstrap's effective-config assertion accepts the
> closed state (`no` is strictly harder than what it installs), and by the
> same first-wins order its own drop-in can never reopen it — a bootstrap
> re-run on a closed box leaves it closed. Validate-then-apply as everywhere:
> `sshd -t` before the restart, rollback on failure, and success is only
> claimed once `sshd -T` resolves `permitrootlogin no`.
Convergent — once root is closed, a re-run says "root already closed; nothing
to do" and exits 0.
> **On `root-door=open`, root stays — so lock its key instead.** This is README
> guidance, deliberately not automation: prefix Coolify's line in root's
> `authorized_keys` with a `from="<control-plane-addr>"` clause, so the
> automation identity only opens from the one address supposed to use it. rig
> will not write that file — Coolify owns its key material on the servers it
> registers, and two tools converging one file is drift by construction (the
> same argument that keeps rig's hands off Incus).
## What rig deliberately does NOT do
- **Provider firewalls** — Docker publishes ports past host firewalls, so
the real boundary is your cloud provider's firewall, configured outside
this tool.
- **Fetch your config** — boxes never receive repo credentials. Everything
rig needs arrives as arguments or an interactive prompt.
- **Manage deployments** — deploy manifests/executors are separate concerns.
(Planned: the `apply`/`diff` executor half joins rig as commands that
run on operator machines, never on boxes.)
## Testing
`bash test/cli.sh` (dependency-free assertions) + shellcheck run in CI. The
feat(install): versioned installs and a real uninstall — box#79's layout, ported install.sh now lands every version at <root>/versions/<v> (each tree carrying its own VERSION + INSTALLED_FROM), tracks the default through an atomically-flipped 'current' symlink, and converges instead of clobbering: a same-version re-run is a no-op that says so, RIG_REINSTALL=1 replaces that version's tree by two renames (delete last), and a new version installs side by side. A pre-versioning flat tree is migrated in place — two renames, preserved bit for bit, VERSION-less trees as 0.0.0-unknown. bin/rig grows the table verbs: 'rig versions' (current + running marked), 'rig use <v>' (atomic flip, asserted effective through the PATH chain), 'rig uninstall [<v>|--all]' — which ENDS with an absence assert: every removed path re-checked, survivors exit 1 as 'uninstall INCOMPLETE' by name. One strict valid_version gate guards every place a version string becomes a path (byte-identical copies in bin/rig and install.sh, diffed by the suite so they cannot drift). Plus the VERSION file and 'rig --version' (rig#32's first item, folded in minimally — rig main had neither). The flip gate is rig's own shape, deliberately: box refuses flips under existing boxes; rig's stake is the converged host, so a flip (upgrade, 'rig use', full uninstall) on a host where /etc/rig/role exists WARNS and proceeds — no user state to strand, and upgrading a bootstrapped host is the normal case. The suite drives REAL installer runs (RIG_INSTALL_SOURCE against throwaway RIG_HOME/RIG_BIN roots): fresh install, converge, reinstall, side-by-side upgrade, use/rollback, both migrations, hostile flat VERSION, wedged- symlink healing, the marker warn gate (RIG_ROLE_MARKER fixtures), both uninstalls and the INCOMPLETE scream — driven, not grepped. Closes #35. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:33:17 +00:00
versioned install is proven by REAL installer runs: `RIG_INSTALL_SOURCE`
points install.sh at the tree under review and the harness drives it against
throwaway `RIG_HOME`/`RIG_BIN` roots — fresh install, converge, reinstall,
side-by-side upgrade, `use`/rollback, flat-tree migration, symlink healing,
the bootstrapped-host warning (via `RIG_ROLE_MARKER` fixtures), and both
uninstalls with their absence asserts. The
`rig users` family is covered the same way: the harness drives its refusal
matrix — users-file parsing, the marker gates, the lexical drop-in-name
assertion, the validate-then-apply ordering — through the sourced lib
functions, non-root and network-free. The tenant family follows the same
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
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 <noreply@anthropic.com>
2026-07-20 00:01:16 +00:00
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
feat(bootstrap)!: box tenant roles carry a -box suffix The other half of #76. claude -> claude-box, codex -> codex-box, grok -> grok-box, staging -> staging-box, so a role name always says which family it belongs to: -server builds a fleet machine, -box converges a guest a box minted. With both halves in, the two families can no longer collide on a word the way `staging` did. The role carries the suffix; nothing inside the guest does. A tenant user is the account the box SEED created (BOX_USER) and each agent CLI reads its own dotdir, so claude-box still converges the `claude` user and still writes ~/.claude/CLAUDE.md. Every rename here is a $ROLE comparison or a case arm -- no CLI binary name, no dotdir path, and no account moved. README's tenant table now shows role and user in adjacent columns, because that distinction stopped being cosmetic the moment they differed. Hard cut, no aliases. The old names are refused as unknown at BOTH entrypoints -- `rig bootstrap <name>` and bootstrap-tenant.sh directly -- and the suite asserts each of the four at each, because bootstrap.sh keeps its own dispatch list and a name could survive in one and not the other. An alias left in for a single tenant is the shape that survives review: the taxonomy reads complete while one old name still quietly converges. The consequence is cross-repo. A seed carrying BOX_BOOTSTRAP_ROLE="claude" now fails its own mint-time bootstrap, so heavy-duty/box#123 updates the seeds and must land after this. Closes #76 (tenant half) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:05:19 +00:00
is the same shape, creds-free: container + seed user → `rig bootstrap claude-box`
/ `staging-box` → assert the CLI answers, docker answers, `sshd -T`, the context
file — then re-run and watch it no-op.