Traits (class/host/join) under the existing roles with dev/workstation/custom presets and the /etc/rig/role marker; rig users apply/status/close-root under the hybrid access model decided in #26's comments: operators on every class, root SSH closed on class=human, kept as the control plane's automation identity on class=server. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
22 KiB
Machine Traits + rig users Implementation Plan (issues #26 + #24, one release)
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Ship the trait model (#26) and declarative fleet users (#24) as a
single release. Bootstrap's role list becomes presets over three orthogonal
traits (class, host, join), recorded in a convergent marker
/etc/rig/role. A new rig users command family makes operator accounts a
first-class, declarative concern on every class — the hybrid access model:
humans always enter as themselves and elevate via sudo; root SSH is what
class decides after users exist. class=human machines close it
(rig users close-root); class=server machines keep it as the automation
identity the control plane (Coolify) SSHes in as. The tailnet is network-only
(no Tailscale SSH), so named users are the only attribution at the door.
Why this shape: #25's server/host binary bundled three questions that
merely correlated (who lives there / hosts VMs / how it joins). #26 unbundles
them; roles stay as presets so the usual shapes remain one command. The
earlier "server is humanless — rig users refuses" was superseded in #26's
comments: a shared root login is unattributable, so operators belong
everywhere; what stays class-specific is the fate of root SSH. Root's key
hygiene on servers (from=-locking Coolify's key line) is README guidance,
not automation — Coolify owns its key material on the servers it registers,
and two tools converging one file is drift by construction.
Architecture:
commands/bootstrap.shgains a role→traits map — the single place a role's shape is declared — plus--class/--host/--joinoverrides and rolesdev,workstation,custom. All per-class behavior keys off traits:/dev/kvmadvisory (host=yes), tag policy (derived), next-steps log. The marker is written post-join, convergently.join=login(workstation): a setTS_AUTHKEYis a usage error (exit 2, before the root check — testable); interactivetailscale up; the tag assertion inverts — any effective tag is a refusal, backed out withtailscale logout, mirroring the untagged-key refusal on the authkey path.- tag:server policy is derived, not a trait: only
control-planeandworkloadmay carry it; every other role refuses it on the effective tags (generalizes today's runner + staging checks into one rule). - New
commands/users-apply.sh,commands/users-status.sh,commands/users-close-root.sh, with parsing/marker helpers incommands/lib/users-config.shso the harness can exercise refusals via sourced functions against fixtures (repo precedent:assert_runner_repo,json_string_array). close-rootinstalls/etc/ssh/sshd_config.d/00-rig-users.conf(PermitRootLogin no). The name is load-bearing: sshd_config is first-wins, the Include glob expands lexically, and-(0x2D) <.(0x2E), so00-rig-users.confis read before bootstrap's00-rig.confand wins. Validate-then-apply exactly like bootstrap (sshd -t, rollback,sshd -Tassertion). Bootstrap's effective-config assertion learns to acceptpermitrootlogin no(strictly harder) so a re-run never reopens root.- Managed-user bookkeeping:
applymaintains/etc/rig/users(one username per line) so a user removed from the input file is found and locked (never deleted) on the next run.
Tech Stack: bash only, shellcheck, existing ci.yml (globstar shellcheck
bash test/cli.sh) — no workflow change needed.
Non-Goals
- No user deletion; no passwords (all locked, always); no LDAP/SSO/PAM; no per-user quotas (Incus project limits are a box concern).
- No management of root's
authorized_keys(Coolify owns its key on the servers it manages; thefrom=lock is README guidance, not code). - No Coolify application-account (web UI) management.
- No Incus / box installation (box's
setup-hostowns the daemon; theboxrole asserts theincusgroup exists and refuses with a pointer otherwise). - No behavior change for control-plane/workload/runner beyond the marker write and the generalized (identical-in-effect) tag policy.
- Cross-repo box work (restricted-tier verification, project-awareness, global install) is referenced in #24 and tracked in heavy-duty/box.
Global Constraints
#!/usr/bin/env bash+set -euo pipefail; per-command log prefix vialog/warn/diehelpers (rig-users:for the new family).- Exit codes:
2= usage/argument error,1= runtime refusal. All argument validation runs BEFORE the root check so error paths are testable as non-root. File parsing counts as validation: a bad users file exits 2 with all errors reported at once. - Convergent everywhere: a second identical run is a no-op, says so, exits 0.
- Validate-then-apply for anything that can lock the door or break sudo:
sshd -tbefore restart (rollback on failure),visudo -cbefore the sudoers drop-in lands. - shellcheck-clean exactly as CI runs it (
shopt -s globstar; shellcheck -x bin/* **/*.sh);bash test/cli.shgreen as non-root. - Keep the diff minimal — no drive-by refactors.
Task 1: role→traits map, trait flags, marker, login join path (bootstrap)
Files:
- Modify:
commands/bootstrap.sh(usage heredoc, role case + trait map + override flags, TS_AUTHKEY/login validation, kvm advisory keyed on traits, generalized tag policy, login-path inverted assertion, marker write, next-steps log) - Modify:
bin/rig(bootstrap usage lines: roles + trait flags) - Modify:
test/cli.sh(bootstrap section additions)
Behavior contract, in file order:
- Usage heredoc: roles
<control-plane|workload|runner|staging|dev|workstation|custom>; flags--hostname <name>,--class <human|server>,--host <yes|no>,--join <authkey|login>. Document: roles are presets over traits, any trait overridable;customrequires--hostnameand all three traits;join=loginneeds no pre-auth key and refuses a setTS_AUTHKEY(unset it or pass--join authkey); the preset table in one compact block. - Role case: the seven roles shift;
role required/unknown rolemessages name them. Then the role→traits map — onecase "$ROLE"assigningCLASS/HOST/JOINper the preset table in issue #26;customleaves all three empty. - Flag loop gains
--class/--host/--join, each validating its value set (bad value → exit 2 naming the valid values). After the loop:custommissing--hostname→ exit 2;custommissing any trait → exit 2.TS_HOSTNAMEdefault stays the role name (custom has none). - Post-parse validation (still pre-root-check):
JOIN=loginwith a setTS_AUTHKEY→dieexit 2: "join=login is interactive: unset TS_AUTHKEY or pass --join authkey". - Guards: the
/dev/kvmadvisory keys onHOST=yes(message unchanged in spirit; drops the staging-only wording). verify_effective_taggeneralizes the runner/staging refusals into the derived policy: if role is notcontrol-plane/workloadand the effective tags containtag:server→ die (message keeps the role-specific repair pointers for runner/staging; a generic message for other shapes). Keep the existing two die-message strings greppable — the harness greps them (role staging joined with tag:server, runner equivalent).- login path: when
JOIN=login, skip the pre-auth key acquisition; runtailscale up --hostname="$TS_HOSTNAME"(interactive, no--authkey); then the inverted assertion — poll asverify_effective_tagdoes, but any non-empty effective tag →tailscale logout+ die: "joined TAGGED (…) but join=login expects a user-owned, untagged node — a tag here means control granted this device fleet identity; use a pre-auth key path (--join authkey) for fleet machines." Empty tags +Running→ OK, log "user-owned join verified". The already-joined path runs the same class of check (tags present → refusal; no logout on a box that was already joined — detect, refuse, name the repair by hand). - Marker: after tag verification, write
/etc/rig/role(mkdir -p/etc/rig) with exactly one line:role=$ROLE class=$CLASS host=$HOST join=$JOIN— cmp-guarded (write+log only on change; "marker already current" otherwise). - Next-steps log keys off traits + role:
control-plane→ coolify install pointer (as today);runner→ runner install pointer (as today);HOST=yes→ box setup-host pointer (replaces the staging-only branch); all classes →rig users applypointer, with the class-specific tail:class=humanadds "thenrig users close-rootonce your admin key works";class=serveradds "root SSH stays — it is the control plane's automation door". bin/rigusage: bootstrap line gains the three roles and trait flags, one sentence for the trait model.
- Step 1: Append failing tests (
test/cli.sh, bootstrap section)
# --- traits: roles are presets, every trait individually settable (#26) -----
check "bootstrap: unknown role still exits 2" 2 "unknown role" "$ROOT/commands/bootstrap.sh" potato
check "bootstrap: bad --class value exits 2" 2 "human|server" "$ROOT/commands/bootstrap.sh" workload --class potato
check "bootstrap: bad --host value exits 2" 2 "yes|no" "$ROOT/commands/bootstrap.sh" workload --host maybe
check "bootstrap: bad --join value exits 2" 2 "authkey|login" "$ROOT/commands/bootstrap.sh" workload --join carrier-pigeon
check "bootstrap: custom without --hostname exits 2" 2 "--hostname" \
"$ROOT/commands/bootstrap.sh" custom --class server --host no --join authkey
check "bootstrap: custom without traits exits 2" 2 "--class" "$ROOT/commands/bootstrap.sh" custom --hostname box1
# workstation is join=login by preset: a set TS_AUTHKEY is a usage error, and it
# must die BEFORE the root check — provable non-root, which also proves the
# preset actually landed.
check "bootstrap: workstation + TS_AUTHKEY exits 2" 2 "unset TS_AUTHKEY" \
env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" workstation
# A trait override changes derived behavior, provable non-root: dev is
# join=authkey (TS_AUTHKEY fine → falls through to the root check), but
# --join login flips it into the TS_AUTHKEY refusal.
check "bootstrap: dev --join login + TS_AUTHKEY exits 2" 2 "unset TS_AUTHKEY" \
env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" dev --join login
# The login-path inverted assertion needs a real tailnet; grep the refusal so a
# deleted guard cannot ship green (repo precedent: staging/runner tag greps).
check "bootstrap: login-path tagged refusal is present" 0 "" \
grep -q "join=login expects a user-owned, untagged node" "$ROOT/commands/bootstrap.sh"
# The marker is the traits' ground truth for rig users; assert the write exists.
check "bootstrap: role marker write is present" 0 "" \
grep -q "/etc/rig/role" "$ROOT/commands/bootstrap.sh"
and in the existing non-root block:
check "bootstrap: dev role parses, refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" dev
check "bootstrap: workstation parses, refuses non-root" 1 "must run as root" env -u TS_AUTHKEY "$ROOT/commands/bootstrap.sh" workstation
check "bootstrap: custom parses, refuses non-root" 1 "must run as root" \
env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" custom --hostname b --class server --host no --join authkey
- Step 2: Run
bash test/cli.sh— new checks FAIL (unknown role / unknown flag / missing messages), existing stay green, harness exits 1. - Step 3: Implement per the behavior contract.
- Step 4:
bash test/cli.sh— all green, exit 0. - Step 5: shellcheck + syntax —
shopt -s globstar; shellcheck -x bin/* **/*.sh;bash -neach edited script. - Step 6: Commit
git add commands/bootstrap.sh bin/rig test/cli.sh
git commit -m "feat(bootstrap): traits under the roles — class/host/join, dev/workstation/custom, /etc/rig/role marker"
Task 2: rig users apply + rig users status
Files:
- Create:
commands/lib/users-config.sh(users-file parser, marker reader, role→group mapping — pure functions, no side effects, sourceable by the harness) - Create:
commands/users-apply.sh,commands/users-status.sh - Modify:
bin/rig(users subcommand dispatch + usage) - Modify:
test/cli.sh(users section)
Behavior contract:
commands/lib/users-config.sh:parse_users_file <path>: emits normalizeduser|roles|keylines on stdout, or all validation errors on stderr and returns 1. Refusals: unknown role (naming the valid setadmin rig box), differing roles across one user's lines,rootas username, malformed line (fewer than 3 fields / key not startingssh-orecdsa-), duplicate identical key line.#comments and blanks skipped.read_role_marker <path>: printsclass=<v>etc. from the marker; empty output when absent. No policy here — callers decide.
users-apply.sh(rig-users:prefix):- Args:
--file <path>required (-= stdin, read once into a temp);--help; unknown flag exit 2. Parse+validate the whole file (exit 2 on any error, all reported) before the root check. - Root check. Then marker note (never a refusal):
class=serveror no marker → log that root SSH stays the automation door / advise bootstrapping a marker, respectively. - Install
sudoif missing and any parsed role needs it (admin,rig). groupadd -f rig-admin rig; if any user carriesbox, assert groupincusexists else die 1 pointing at boxsetup-host.- Converge each user:
useradd -m -s /bin/bashif absent, then alwaysusermod -L; membership in the three rig-managed groups exactly (add and remove; other groups untouched);~/.ssh/authorized_keyswritten 0700/0600, user-owned, to exactly the file's keys (cmp-guarded). - Previously managed users absent from the file (diff against
/etc/rig/users):usermod -L, strip the three rig groups, keep home,warneach. Then rewrite/etc/rig/usersto the file's users. - Sudoers: write the two
%rig-admin/%rigrules to a temp,visudo -cagainst it, then install atomically to/etc/sudoers.d/rig-roles0440 (cmp-guarded; die 1 with the temp preserved for inspection on avisudofailure, sudoers untouched). - Converged-no-change run says "already converged; no changes".
- Args:
users-status.sh:--help; root check; reads/etc/rig/users, prints per user: roles (derived from actual group membership), key count fromauthorized_keys, locked/active. Exits 0 with "no rig-managed users" when the ledger is absent. Reads only — no network, no writes.bin/rig:usersdispatch (apply/status/close-root→ their scripts; bare or unknown sub → usage exit 2); usage block documents the three subcommands in the existing voice.
- Step 1: Append failing tests (
test/cli.sh; fixtures via mktemp, parser exercised through the sourced lib — precedent:guard()/tags()): bareusersexit 2;users frobnicateexit 2; apply--help0;--filerequired 2;--fileneeds value 2; missing file 2; unknown flag 2; parser fixtures: unknown role (message lists valid set), role mismatch across lines,rootrefused, malformed line, a valid two-user file parses (and a multi-error file reports both errors in one run); status--help0; non-root refusals for apply (valid fixture file) and status; ordering grep:visudo -cline precedes thesudoers.d/rig-rolesinstall line inusers-apply.sh. - Step 2:
bash test/cli.sh— new checks fail. - Step 3: Implement per contract.
- Step 4:
bash test/cli.sh— green. - Step 5: shellcheck + syntax (CI invocation).
- Step 6: Commit
git add commands/lib/users-config.sh commands/users-apply.sh commands/users-status.sh bin/rig test/cli.sh
git commit -m "feat(users): declarative operators — apply/status over a users file, every class"
Task 3: rig users close-root + bootstrap accepts the closed state
Files:
- Create:
commands/users-close-root.sh - Modify:
commands/bootstrap.sh(effective-config assertion acceptspermitrootlogin no) - Modify:
test/cli.sh
Behavior contract:
users-close-root.sh:--helpdocuments the model (human-class only; verify your admin login in a separate session first) ; unknown flag 2; root check; then, in order:- Marker gate via
read_role_marker(path overridable for tests viaRIG_ROLE_MARKER, default/etc/rig/role): absent marker → die 1 "no /etc/rig/role marker: re-run rig bootstrap so this box knows what it is; refusing to shut the root door blind";class=server→ die 1 "class=server: root here is the control plane's automation identity — closing it severs fleet management"; onlyclass=humanproceeds. - Admin-door gate: at least one member of
rig-adminwith a non-empty~/.ssh/authorized_keys→ else die 1 "no admin user with a key on this box — run rig users apply first; never close the only door". - Install
/etc/ssh/sshd_config.d/00-rig-users.confcontaining exactlyPermitRootLogin no, cmp-guarded;sshd -ton the merged config BEFOREsystemctl restart ssh, rollback (remove/restore) on failure — the bootstrap shape verbatim. Then assertsshd -Tresolvespermitrootlogin noor die. - Second run: "root already closed; nothing to do", exit 0.
- Marker gate via
bootstrap.sh: thepermitrootloginassertion regex becomes(no|prohibit-password|without-password)with a comment:nois the post-close-rootstate, strictly harder; bootstrap must never read a closed door as a broken one (or reopen it — its drop-in loses to00-rig-users.confby first-wins, which is the point).
- Step 1: Append failing tests
check "users close-root: --help exits 0" 0 "usage:" "$ROOT/commands/users-close-root.sh" --help
check "users close-root: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/users-close-root.sh" --nope
# The whole command rests on first-wins + lexical include order: '-' < '.', so
# 00-rig-users.conf is read before 00-rig.conf. Assert the actual comparison the
# glob makes, so a renamed drop-in cannot silently lose the fight.
check "users close-root: drop-in name sorts before bootstrap's" 0 "" \
bash -c '[ "00-rig-users.conf" \< "00-rig.conf" ]'
check "users close-root: drop-in name is the load-bearing one" 0 "" \
grep -q "00-rig-users.conf" "$ROOT/commands/users-close-root.sh"
# Validate-then-apply ordering, greppable (repo precedent: repo-guard ordering).
# sshd -t must precede the restart in file order.
# marker refusals via the sourced lib against fixture markers:
# class=server marker → refusal names the control plane
# absent marker → refusal names bootstrap as the repair
# class=human marker → gate passes (function returns 0)
# non-root: close-root refuses non-root (exit 1) after arg validation.
(Exact harness lines mirror the guard() fixture pattern; ordering check
mirrors the guard_at/start_at line-number comparison.)
- Step 2:
bash test/cli.sh— new checks fail. - Step 3: Implement per contract.
- Step 4:
bash test/cli.sh— green. - Step 5: shellcheck + syntax (CI invocation).
- Step 6: Commit
git add commands/users-close-root.sh commands/bootstrap.sh test/cli.sh
git commit -m "feat(users): close-root — shut the human-class root door once an admin key works"
Task 4: README — identity model, trait tables, users commands
Files:
-
Modify:
README.md -
Step 1: Write it (in the README's existing voice):
- Bootstrap section: role list/examples gain
dev,workstation,custom; the trait table and roles-as-presets table from #26; the marker; thejoin=loginstory (untagged/user-owned is the assertion, a tag is the refusal). - New identity model subsection: the hybrid access model — operators
on every class, humans never enter as root,
classdecides root SSH's fate afterrig users apply; the attribution rationale (network-only tailnet, no identity broker at the door); the detection side benefit (any root login that isn't the control plane is anomalous by definition); the honest caveat (attribution, not privilege reduction — sudo on a Docker box is root-equivalent). rig userssection: file format, roles table (admin/rig/box, incus-admin deliberately not a role), apply/status/close-root, the locked-not-deleted convergence rule, close-root's gates, and the README-only guidance: onclass=server, lock root'sauthorized_keysto the control plane with afrom="<control-plane-addr>"clause on Coolify's key line (rig will not write that file — Coolify owns it).
- Bootstrap section: role list/examples gain
-
Step 2: Full local gate — CI shellcheck invocation +
bash test/cli.sh. -
Step 3: Commit
git add README.md
git commit -m "docs: README identity model — traits, presets, fleet users, root's two fates"
Test Plan
- Harness (
bash test/cli.sh, non-root, network-free): everything in the per-task steps — trait/flag validation, preset-driven TS_AUTHKEY refusals (proving both presets and overrides), users-file refusal matrix through the sourced parser, marker-gate refusals through fixtures, the lexical drop-in-name assertion, and the two validate-then-apply ordering greps (visudo -cbefore sudoers install,sshd -tbefore restart). - CI: unchanged
ci.yml(globstar shellcheck + harness) covers all new files. - Rehearsal (manual, out of harness): Incus container pair —
- human-class:
bootstrap dev(realtag:localkey) → marker saysclass=human host=yes join=authkey;users applya two-user file → users/groups/keys/sudoers as specified; re-apply no-ops; remove a user → locked, home intact;statustruthful;close-root→sshd -Tresolvespermitrootlogin no; re-run bootstrap → green, root stays closed; drop a user's key,sshas them fails, as the other succeeds. - server-class:
bootstrap workload→ markerclass=server;users applyproceeds (operators exist);close-rootrefuses naming the control plane. - workstation:
bootstrap workstationwith no key → interactive login join, untagged asserted; with a tagged key's identity → refused and backed out.
- human-class: