feat!: bootstrap takes the users file #54
Labels
No labels
attention
blocked
blocker:ci-red
blocker:conflict
blocker:drill-pending
blocker:unrequested
bug
claimed
documentation
enhancement
epic
merge-next
needs-ruling
needs-triage
offsite
post-merge
ready
release
scope:bootstrap
scope:coolify
scope:db
scope:docs
scope:drill
scope:installer
scope:labels
scope:platform
scope:runner
scope:users
stale
state:addressing
state:bots-reviewing
state:building
state:needs-human
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: heavy-duty/rig#54
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/bootstrap-takes-users"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
One command, box ready
rig bootstrapalready knew everything else about what a box is — class,host, join, hostname — and wrote
/etc/rig/roleto say so. The users file wasthe last piece of that answer it did not take, so bring-up was two commands and
the second one was the forgettable one:
Now:
--users <path>runs exactly whatrig users apply --file <path>runs, asbootstrap's final phase.
Required, with an explicit opt-out
--usersis required on every machine role;--no-usersis the deliberateopt-out. Omitting both is a usage error naming both flags; passing both is a
usage error too (rig refuses to pick a winner — a precedence rule deciding who
may enter a box would be wrong half the time).
Required on
class=serveras well asclass=human, per the issue: a machinenobody logs into routinely is exactly where shared-root access rots, and
per-human accounts keep attribution intact for the times someone does go in. So
the complete path is the default path, and skipping it is deliberate rather
than an omission that looks identical to forgetting — which is precisely what
the old optional flag could not distinguish.
Ordering — and why
host=yesis smaller than it looksThe phase order is a correctness property, not taste:
users applyreads the marker (class=picks its root-SSH note,host=decides what a missing
incusgroup means), so it must run after the marker iswritten. And on
host=yesit must run after the box install, becausebox's own installer already runs
setup-host— theincusgroup, boxnet,ACL and firewall are built by installing the box CLI. There is no phase to wait
on: the prerequisite is simply "box installed".
So this PR does not resolve the issue's open question ("should rig install
box on
host=yes?"). It does not callbox setup-host, and adds no boxinstall of its own; a test grep-pins that. What it does add is an early
refusal, but only where the outcome is already proven: with
RIG_SKIP_BOX_INSTALL=1the run has been told it will not touch box, so amissing
incusgroup can no longer be rescued and the run is doomed a hundredlines before it would notice. Every other failure shape (no network, box's
installer breaking) is unknowable that early and lands in
users apply'sexisting refusal at the end — the same message, one phase later.
The precondition fires only when someone in the file actually carries role
box; an admins-only file converges fine on a host that never saw Incus, andrefusing it there would be rig inventing a prerequisite apply does not have.
Decisions the issue left open
The tenant roles take no
--users.rig bootstrap <claude|codex|grok| staging>is a different family and stays that way. A tenant is a box-mintedguest: box auto-runs its bootstrap at mint, non-interactively, with no file
to hand it — requiring
--userswould break the mint-time path outright. Theguest never joins the tailnet and has no SSH door of its own; entry is
box shell, gated by the host'sincusgrants, which the host's own users filealready converged. A fleet-wide operator file has nothing to converge in there.
Mechanically this is free: tenant dispatch happens before the flag parser, so
--userslands in the tenant script's own unknown-flag refusal — pinned by atest, and explained in both
usage()and the README.--users -is refused. apply's stdin convenience cannot survive the trip:bootstrap's stdin belongs to the pre-auth key prompt, and the two
credentials-shaped reads would fight over one pipe. The refusal names the split
(
--no-users, then apply by hand).The file is pre-flighted, not just passed through. Bootstrap sources
lib/users-config.shand runs the same parser apply uses, beforeapt, beforethe hostname change, before a single-use pre-auth key is spent. A typo in the
users file must not be discovered after all of that was already paid for. The
lib is sourced for validation only —
users applystays the single owner ofwhat a users file does to a box.
Not persisted. rig takes the path, reads it once through apply, and copies
it nowhere;
README.md's "rig never persists it" still holds. A negative greptest enforces it.
apply is invoked as a child, not
exec'd. Bootstrap keeps the last word,a failing apply fails the bootstrap under
set -e, and apply's invoker gatestays intact:
SUDO_USERrides through, sosudo rig bootstrap --users <file-naming-me-admin>by a role-riguser refuses exactly assudo rig users applywould. Bootstrap must not become a laundering path around the one gatethat keeps rig's scoped sudo from being root-equivalent.
Breaking change — the migration
Every existing
rig bootstrapinvocation must add--users <path>or--no-users. Scripted bring-up that already ranrig users applyas aseparate step can either fold it in (
--users ./users, and drop the separatecall) or keep the old shape verbatim by adding
--no-users. Named in theCHANGELOG under
## Unreleased.Verification
shellcheck -xover the full CI sweep (bin/* **/*.sh, 22 files, the exactcommand from
.github/workflows/ci.yml) — clean.bash test/cli.sh— 352 passed, 0 failed (up from 345; the delta is thisPR's coverage, plus the pre-existing bootstrap invocations updated to state
their users answer).
bash test/release.sh— 41 passed, 0 failed.New coverage, following the existing patterns in
test/cli.sh:both flags; asserted on
class=servertoo, and--userswith no value.would pass one of them silently.
idiom, fail-closed defaults) that the apply call sits after both the role
marker write and the box install.
host=yesprecondition, both directions — driven with a shimgetent(precedent: theinstall.shgetent shim) so it is provable on anymachine, not skipped where the runner happens to have an
incusgroup. Fournegative cases prove it does not over-refuse: no box-role user, an existing
group, no
RIG_SKIP_BOX_INSTALL, andhost=no.per-line error),
--users -.$USERS_FILE.box setup-hostcall.
--userson a tenant role exits 2, and usage explainswhy.
Closes #51
🤖 Generated with Claude Code
Verdict: approve
Verified at head b4fdfbb:
bash test/cli.sh— 352 passed, 0 failed;bash test/release.sh— 41 passed, 0 failed;shellcheck -xover the CI sweep (22 files) — clean. All three match the PR body's claims.Breaking change — properly carried
The
feat!is fully documented and fails loudly, never silently:CHANGELOG.mdnames the break under## Unreleasedwith an explicit migration line (add--users <path>or--no-usersto every invocation).commands/bootstrap.sh:209-211), so a stale script cannot misbehave quietly — it gets told exactly what changed. Asserted attest/cli.sh:437-442.bin/rig --help, andbootstrap --helpall updated; usage documents both flags and the tenant exclusion (tests attest/cli.sh:543-554).Design points that hold up under scrutiny
bootstrap.sh:206-208), tested in both flag orders (test/cli.sh:448-451) — the both-orders test genuinely kills a "last flag wins" implementation.bootstrap.sh:218-237): unreadable file, invalid file (with the parser's own per-line errors), and--users -all die exit 2 before the root check, before apt/hostname/key-spend. The-refusal is correct — bootstrap's stdin really is the pre-auth key prompt's on the authkey path (bootstrap.sh:513-516).bootstrap.sh:658-661), and both orderings are pinned by fail-closed line-number asserts (test/cli.sh:528-534). The rationale is real: apply readsclass=/host=from the marker (users-apply.sh:150-154, 176-184), and onhost=yesthe incus group arrives via box's installer.--usersre-runs apply, which is convergent by construction (cmp-guarded writes, exact group membership, "already converged; no changes"). Re-running with--no-userson a box that has users touches nothing. The already-joined tailnet path skips the key prompt, so a repair re-run stays cheap.SUDO_USERrides through and apply's rig-admin gate (users-apply.sh:125-128) fires identically undersudo rig bootstrap --users <file-naming-me-admin>. I checked the escalation shape: a role-riguser's scoped sudo reachesrig bootstrap, but the file naming them admin still dies at apply's gate. (That refusal lands late — end of the run — but on any box where a rig-role user exists the tailnet is already joined, so no key is spent; acceptable.)host=yesprecondition is correctly narrow (bootstrap.sh:255-259): only box-role-in-file +host=yes+RIG_SKIP_BOX_INSTALL=1+ no group. The four negative tests (test/cli.sh:492-514) prove it does not over-refuse, and the getent shim makes it machine-independent. The role-box grep on the parseduser|roles|keystream (bootstrap.sh:234) handles comma lists correctly.test/cli.sh:518-519, 539-540).Series context
#53 and #55 both target
mainindependently; this PR is a single commit atopmain(219b0fb) — no stacking, no hidden inclusion. #53 rewrites apply's box-role internals, but bootstrap only invokes apply as a child, so there is no semantic conflict; expect at most textual merge friction intest/cli.sh.Non-blocking observations
--users— re-creating exactly the "looks identical to forgetting" ambiguity the required flag was built to kill. Worse on a re-run: a truncated file against a box with existing operators revokes them all (apply's documented drop-semantics, so it warns per user — but bootstrap could catch the zero-user parse atbootstrap.sh:227-229and require--no-usersto say root-only out loud). Worth a follow-up.--users <directory>dies exit 2 but noisily:[ -r ]passes for a directory (bootstrap.sh:226), so the parser aborts with bash noise (read: Is a directory,line: unbound variable) before thediemessage. An[ -f ]check would clean it up. Same pre-existing shape inusers-apply.sh:85, so this is parity, not a regression.host=yesthat the early check (group only) does not mirror. That is consistent with this PR's "early only where proven" rule, but whoever merges second may want to extend the precondition tocommand -v boxunder the same skip condition.None of these block: 1 and 2 are edge polish on top of behavior that still fails safe or loud, and 3 belongs to whichever PR lands second.
— automated review by
claude-bot-andresmgsl· heavy-duty-review-botVerdict: Approve — I agree with this as-is.
Why
--users/--no-usersrequired; omission and both-flags are usage errors (exit 2). Migration path clear.--users -refused (stdin is authkey); tenants excluded correctly.users apply(exec, not reimplemented). CI check/db green; labels reconcile cancelled (not a product fail).No blockers.
Verdict: I agree with everything and have no additional feedback. The required
--users/--no-userscontract is validated before costly bootstrap work, tenant roles remain separate, and users convergence runs after the role marker and host-side box installation while preservingusers applyas the single convergence owner. The migration is documented and the current CI checks are green.@danmt — handoff summary. This is the breaking one (
feat!) — read the migration note before merging.What this does
rig bootstraptook--hostname,--class,--host,--join— everything about what a box is except its people. It now takes--users <path>, required, with an explicit--no-usersopt-out. Closes #51.The rationale is yours, recorded on the issue: even on
class=serverboxes nobody logs into routinely, named human accounts beat shared root — that is exactly where root access rots, and per-human users keep attribution intact for the times someone does go in. Required-with-opt-out makes the complete path the default path, and skipping deliberate.Migration
Every existing
rig bootstrapinvocation gains a required flag. Omitting both exits 2 naming both ways out, so a stale script fails loudly rather than quietly bootstrapping a root-only box. CHANGELOG carries the migration line; README,bin/rig --helpandbootstrap --helpare all updated.Review history
One round, no fixes needed. 3/3 approved at head
b4fdfbb— claude-bot, codex-bot, grok-bot.Verification
test/cli.sh352 passed / 0 failed (baseline 345 — 7 net new, after updating pre-existing bootstrap invocations to state a users answer)test/release.sh41 passed / 0 failedshellcheck -xover the CI sweep, 22 files: cleancheck,scope,db-integrationgreenDecisions made along the way
Tenant roles take no
--users.rig bootstrap <claude|codex|grok|staging>are box-minted guests: box auto-runs their bootstrap at mint, non-interactively, with no file to hand it — requiring the flag would break the mint-time path outright. The guest never joins the tailnet and has no SSH door of its own; entry isbox shell, gated by the host'sincusgrants that the host's own users file already converged. Mechanically free, since tenant dispatch precedes the flag parser.Pre-flight before cost. Unreadable file, invalid file, and
--users -all die exit 2 before the root check, before apt / hostname / key spend. The-refusal is deliberate: bootstrap's stdin belongs to the pre-auth key prompt.apply is invoked as a child, not
exec'd, soSUDO_USERrides through and apply's rig-admin invoker gate still fires. claude-bot specifically checked the escalation shape: a role-riguser's scoped sudo reachesrig bootstrap, but a file naming them admin still dies at apply's gate. Bootstrap cannot become a laundering path around it.A correction to the issue, for the record
I filed #51 claiming the open question was "should rig install box on
host=yes" and called it the last thing between us and one-command bring-up. That was wrong —commands/bootstrap.sh:437-512already installs box and lets box's own installer runsetup-host, with the delegation boundary reasoned out in place. I've corrected the issue body and left a comment there. Consequence: the new earlyhost=yesprecondition fires only underRIG_SKIP_BOX_INSTALL=1, and it is gated on the file actually containing a role-boxuser so an admins-only file isn't refused on an Incus-less host.Flagged for your judgment
The one I'd genuinely like your call on, from claude-bot: an empty or comments-only users file passes pre-flight silently and converges a root-only box under
--users— re-creating the "looks identical to forgetting" ambiguity that requiring the flag was built to kill. Sharper on a re-run: a truncated file against a box with existing operators revokes them all. Apply's documented drop-semantics warn per user, so it is loud rather than silent — but bootstrap could catch the zero-user parse and require--no-usersto say root-only out loud. Given that this PR's entire premise is "make the complete path the default path", I lean toward tightening it. Not done here because it changes the contract the bots just approved. Say the word and it's a small follow-up.Two smaller ones, neither blocking:
--users <directory>dies exit 2 but noisily ([ -r ]passes for a directory, so the parser emits bash noise before thedie) —[ -f ]would clean it up, and the same shape pre-exists inusers-apply.sh:85, so it's parity not regression. And post-#53 the early precondition becomes a weaker proxy, since #53 adds a "box CLI not on PATH" die that the group-only early check doesn't mirror; whoever merges second may want to extend it under the same skip condition.Merge order
This PR only invokes apply and does not modify it, so there is no semantic conflict with #53 or #55 — expect at most textual friction in
test/cli.shand CHANGELOG's## Unreleased.🤖 Generated with Claude Code
Rebased onto
mainafter #60 merged — and the changelog needed hand-placing@danmt — sweep after #60. This branch is rebased onto
main;mergeable: true.The changelog needed more than conflict resolution, and the reason is worth knowing before you merge the rest of this batch.
maincut 0.1.0 at 18:03:45Z. #60 merged at 19:02:12Z — an hour later. Its entry had been written under## Unreleased, but by merge time that heading had been stamped to## 0.1.0, so git merged the entry cleanly, with no conflict, into the notes of an already-published release. Same thing happened to this branch on rebase.Every PR in this batch predates the release, so every one of them has this. It does not announce itself — #54's rebase produced zero conflicts and still put a BREAKING entry inside 0.1.0's notes.
Resolved here by rebuilding the section structure: a fresh
## Unreleasedholding this PR's entry and #60's (moved back out of 0.1.0, where it does not belong — it shipped after), with## 0.1.0left holding only what it actually released.Scope of the damage, checked rather than assumed: the published GitHub release body for 0.1.0 is clean — it was generated at release time, before #60 merged, so it never contained the stray entry. Only the
CHANGELOG.mdfile onmainwas wrong, and merging any PR from this batch corrects it.Filed as heavy-duty/rig#66 — the release flow has no guard against this, and it will recur on every release.
Verification at the rebased head
shellcheck -xover the CI sweep — cleantest/release.sh— 61 / 0Content is otherwise unchanged from what the bots approved; the delta is the rebase plus changelog placement.
Rebased after #53 merged — and picked up the deferred precondition work
@danmt — at
b8dc115,mergeable: true. This is the last of the batch, and it does one thing beyond the rebase, deliberately.The changelog conflict
Only
CHANGELOG.mdconflicted. Resolved so## Unreleasednow holds all four entries under correct headings —### Added(#49),### Changed(this PR's BREAKING entry),### Fixed(#58, #50) — with## 0.1.0left holding only what it actually shipped. A duplicated## 0.1.0heading from an earlier replay was also removed. See #66; this batch has hit that trap on every single rebase.The deferred item, now due
@claude-bot-andresmgsl's third non-blocking note on this PR said:
#53 has merged and this PR is second, so that is now. The early check tested only for the
incusgroup; underRIG_SKIP_BOX_INSTALL=1a missing box CLI is just as final and just as knowable at that point, and would otherwise fail a hundred lines later in apply. It now mirrors both refusals — either one alone dooms the run.A machine-dependence bug I introduced and then fixed
Adding that check quietly made an existing test depend on the runner.
bootstrap: an existing incus group satisfies the preconditionpassed here only because this machine happens to haveboxon PATH; on a CI runner without it, the new die would fire and the test would fail for a reason that has nothing to do with what it asserts.Fixed the same way this block already handles
getent:INCUS_SHIM_YESnow ships a stubbox, so the satisfied case is proven on its own terms rather than on the runner's luck. The two new negative tests buildPATHwithout the real one, so the absence they test is the test's, not the machine's.Verified by running the whole suite with
boxstripped fromPATH—command -v boxreturns nothing, suite still 393 passed, 0 failed.Verification at
b8dc115test/cli.sh— 393 passed, 0 failed (+2 new)boxhidden fromPATH— 393 passed, 0 failedtest/release.sh— 61 / 0shellcheck -xover the CI sweep — cleanOnce this merges, #59 unblocks — I will rebase it onto
main, dropblocked, and put it in front of the bots.