Compare commits
109 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c33794ce70 | ||
|
|
7e05328a6d | ||
|
|
7247768465 | ||
|
|
f8ca59c22e | ||
|
|
e4d9005aca | ||
|
|
3bae42157e | ||
|
|
b0d4aff964 | ||
|
|
1c3d873bf2 | ||
|
|
c93e230396 | ||
|
|
a4684f7afa | ||
|
|
bf74744bda | ||
|
|
a17903f07c | ||
|
|
86af660818 | ||
|
|
2c3b329b8f | ||
|
|
991acd2ba6 | ||
|
|
0b5bedd6e0 | ||
|
|
1ffd20d35b | ||
|
|
f63fa952d4 | ||
|
|
115b9c026e | ||
|
|
877d9ded55 | ||
|
|
0678b22e01 | ||
|
|
92dc3a2bdc | ||
|
|
67d5890e2e | ||
|
|
683217ee42 | ||
|
|
f7058b2814 | ||
|
|
9fcd2e4a7f | ||
|
|
82c1888381 | ||
|
|
4c595eb945 | ||
|
|
050c90966e | ||
|
|
91349acac1 | ||
|
|
64127b34e6 | ||
|
|
b2afea580e | ||
|
|
24a05c3020 | ||
|
|
a091c6e496 | ||
|
|
f84e474549 | ||
|
|
8f1a281c20 | ||
|
|
256ded2e92 | ||
|
|
4fe91ef562 | ||
|
|
c3823e9a3f | ||
|
|
aed2f5bf1f | ||
|
|
be633aa5f8 | ||
|
|
aec22277cd | ||
|
|
239428bf88 | ||
|
|
9b06e29b47 | ||
|
|
1e3fb473c3 | ||
|
|
4b658b6fb0 | ||
|
|
94d830cdb3 | ||
|
|
b7954e3f9b | ||
|
|
94f6ed0047 | ||
|
|
243ccc3aa5 | ||
|
|
3155258cf5 | ||
|
|
81c44c8b02 | ||
|
|
b953d8b3c1 | ||
|
|
e6854ef62c | ||
|
|
f956a85a1c | ||
|
|
27cfc5b8c1 | ||
|
|
074aea31d8 | ||
|
|
9bf669dabc | ||
|
|
2d49a5ad42 | ||
|
|
e408b7a0ed | ||
|
|
b9527d3cc1 | ||
|
|
cbc8f4f7a8 | ||
|
|
e40d2df5f1 | ||
|
|
724f103908 | ||
|
|
aa5a6baed6 | ||
|
|
ecce72f025 | ||
|
|
b3fa94a7d4 | ||
|
|
a9e52325f4 | ||
|
|
b1968dabb7 | ||
|
|
add1c2a142 | ||
|
|
cb72c71c33 | ||
|
|
ce57070c14 | ||
|
|
cc0a1a9a79 | ||
|
|
c68c519558 | ||
|
|
0b7ad21eca | ||
|
|
4c34facefe | ||
|
|
9c78911563 | ||
|
|
86dbb0449e | ||
|
|
15b67d938e | ||
|
|
c4f62e5d87 | ||
|
|
8661067468 | ||
|
|
2c4f83814f | ||
|
|
bb51aeeca9 | ||
|
|
e27ab239f1 | ||
|
|
41bba74e10 | ||
|
|
67331ebecd | ||
|
|
1a9d96168d | ||
|
|
9ea50d21e2 | ||
|
|
531803a8c9 | ||
|
|
b38518ee54 | ||
|
|
12713c5012 | ||
|
|
90ca56cd3f | ||
|
|
57db9a56bb | ||
|
|
dc09d54cbe | ||
|
|
9649659aff | ||
|
|
7067fb03b0 | ||
|
|
8598cf96fe | ||
|
|
4a06c3ac00 | ||
|
|
f2f57cca7f | ||
|
|
c8150f2d5b | ||
|
|
b89ed144fe | ||
|
|
03e1a8c5b7 | ||
|
|
cdf4c043c9 | ||
|
|
0fbd60e6ff | ||
|
|
89345f7de3 | ||
|
|
00e36b9436 | ||
|
|
13aa499dad | ||
|
|
420e8e7f32 | ||
|
|
b58ce5524f |
47 changed files with 4109 additions and 1395 deletions
49
.ceremony/AGENTS.md
Normal file
49
.ceremony/AGENTS.md
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
# AGENTS.md — start here
|
||||||
|
|
||||||
|
You are an agent working in a repo governed by
|
||||||
|
[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony). This file is
|
||||||
|
the router: find your role below, read its file, then act. The role files
|
||||||
|
sit beside this one — in ceremony itself at the repo root, in a governed
|
||||||
|
repo under `.ceremony/` (a machine-managed mirror; never edit those files
|
||||||
|
in place — they are changed in heavy-duty/ceremony, through its own flow).
|
||||||
|
|
||||||
|
## Your role
|
||||||
|
|
||||||
|
You were told your role when you were pointed at this repo ("you are a
|
||||||
|
reviewer here"). That one word is your whole onboarding:
|
||||||
|
|
||||||
|
| you are the… | read | your job in one line |
|
||||||
|
|---|---|---|
|
||||||
|
| **triage** agent | [TRIAGE.md](TRIAGE.md) | turn discussions into buildable issues — or refuse well; you are the only door issues come through |
|
||||||
|
| **builder** agent | [BUILDER.md](BUILDER.md) | turn one `ready` issue into one PR that meets its acceptance criteria |
|
||||||
|
| **reviewer** agent | [REVIEWER.md](REVIEWER.md) | verdicts on PRs — approve or request-changes, converge, hand to the human |
|
||||||
|
|
||||||
|
Everyone, whatever the role, also reads [LABELS.md](LABELS.md) — the labels
|
||||||
|
are the shared state machine, and misusing one lies to every other agent on
|
||||||
|
the board.
|
||||||
|
|
||||||
|
**Not told a role?** Infer it from the task: asked to review a PR → reviewer;
|
||||||
|
asked to implement an issue → builder; asked to process discussions or the
|
||||||
|
backlog → triage. Still ambiguous → ask before acting. Do not free-lance
|
||||||
|
across roles in one session: a builder reviewing its own PR, or a reviewer
|
||||||
|
pushing fixes, breaks the separation the pipeline depends on.
|
||||||
|
|
||||||
|
## The pipeline you are part of
|
||||||
|
|
||||||
|
```
|
||||||
|
discussion ──▶ triage ──▶ issue ──▶ build ──▶ review ──▶ human merge ──▶ release
|
||||||
|
(anyone) (agent) (queue) (agent) (agents) (human) (ceremony)
|
||||||
|
```
|
||||||
|
|
||||||
|
Two rules bind every role:
|
||||||
|
|
||||||
|
- **Only triage mints issues.** Found work? Open or extend a discussion.
|
||||||
|
- **Only humans merge.** Convergence ends at `state:needs-human`, never at
|
||||||
|
a merge button.
|
||||||
|
|
||||||
|
## Repo specifics
|
||||||
|
|
||||||
|
What is true only of *this* repo — the review panel roster, the `scope:*`
|
||||||
|
label set, what a drill means, code conventions — lives in the repo's own
|
||||||
|
`CONTRIBUTING.md`. Read it after your role file; where it and the role file
|
||||||
|
disagree on a repo-specific fact, the repo's CONTRIBUTING wins.
|
||||||
76
.ceremony/BUILDER.md
Normal file
76
.ceremony/BUILDER.md
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
# BUILDER.md — the builder role
|
||||||
|
|
||||||
|
You turn one issue into one PR. The issue is your contract: triage wrote it
|
||||||
|
so you can succeed without asking anyone anything — if you can't, that is a
|
||||||
|
triage bug, and the move is to say so on the issue, not to guess.
|
||||||
|
|
||||||
|
## Picking
|
||||||
|
|
||||||
|
- Pick from issues labeled **`ready`** — never `blocked`, never `claimed`,
|
||||||
|
never an `epic` (epics organize; their children are the work).
|
||||||
|
- Respect dependency order: inside an epic, take the earliest unblocked
|
||||||
|
unclaimed child. Between epics and strays, prefer the issue that unblocks
|
||||||
|
the most other work.
|
||||||
|
- **One issue at a time.** Finish or release your claim before taking
|
||||||
|
another.
|
||||||
|
|
||||||
|
## Claiming
|
||||||
|
|
||||||
|
- Assign yourself, swap `ready` → `claimed`, and comment that you are
|
||||||
|
starting. The claim is a promise of a draft PR soon — a claim with no PR
|
||||||
|
and no activity is what the staleness sweep reclaims.
|
||||||
|
- **Abandoning is fine; ghosting is not.** If you stop, say where you got to,
|
||||||
|
push the branch if it holds anything useful, unassign, and restore
|
||||||
|
`ready`.
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
- Branch per issue; open the PR **as a draft early**, `Closes #N` in the
|
||||||
|
body. Drafts are invisible to the reviewer panel on purpose — the draft
|
||||||
|
phase is yours.
|
||||||
|
- **The issue's acceptance criteria are your definition of done.** Reproduce
|
||||||
|
them as a checklist in the PR body and check them honestly as you go. If
|
||||||
|
one turns out to be wrong or unreachable, say so on the issue and get it
|
||||||
|
amended by triage — do not silently ship less than the issue says.
|
||||||
|
- Every behavior change adds one line to `CHANGELOG.md` under
|
||||||
|
`## Unreleased` — insert **above** the heading below it, never over it
|
||||||
|
(the monotonic guard's whole reason to exist).
|
||||||
|
- Follow the repo's conventions file and match the code you touch. Tests are
|
||||||
|
not optional: the issue's test plan is the floor, not the ceiling.
|
||||||
|
- **Scope discipline: the PR does the issue — whole, and nothing else.**
|
||||||
|
Adjacent problems you discover go to a **discussion** (or a comment on the
|
||||||
|
relevant issue), where triage will do its job. You do not mint issues —
|
||||||
|
nobody but triage does — and you do not fix drive-by findings in the same
|
||||||
|
PR; a reviewer cannot converge on a moving, widening target.
|
||||||
|
|
||||||
|
## The review round
|
||||||
|
|
||||||
|
(If you are reading this as `.ceremony/BUILDER.md` in a governed repo: the
|
||||||
|
panel roster and any repo-specific flow notes live in that repo's own
|
||||||
|
CONTRIBUTING; everything below is the shared flow.)
|
||||||
|
|
||||||
|
1. Mark ready-for-review; request **the whole panel** (the roster is in the
|
||||||
|
repo's CONTRIBUTING).
|
||||||
|
2. **Wait for every verdict, then answer the round whole** — one reply
|
||||||
|
covering every point, then push the fixes, then re-request exactly the
|
||||||
|
reviewers who did not approve. Prefer verification over argument: when a
|
||||||
|
reviewer doubts behavior, add the test that settles it.
|
||||||
|
3. Never dismiss a review, never merge, never mark your own work as passed.
|
||||||
|
A blocking point you disagree with is answered with evidence or escalated
|
||||||
|
in the PR — a maintainer can be asked for a ruling; silence and
|
||||||
|
force-forward are not options.
|
||||||
|
|
||||||
|
## Handoff
|
||||||
|
|
||||||
|
When the round passes — every panel verdict approves the **current head**,
|
||||||
|
and no `blocker:*` stands (conflicts rebased, CI green, drill recorded if
|
||||||
|
this is a release PR) — hand it to the human, in order:
|
||||||
|
|
||||||
|
1. post the round summary (what changed per round, what was verified);
|
||||||
|
2. request the human's review;
|
||||||
|
3. set `state:needs-human` yourself.
|
||||||
|
|
||||||
|
The label write is optimistic — the reconciler validates it, and takes it
|
||||||
|
back if the PR is not actually mergeable-right-now. Then stop: the PR is the
|
||||||
|
human's. Address what comes back (`state:addressing`) and re-hand-off the
|
||||||
|
same way.
|
||||||
98
.ceremony/LABELS.md
Normal file
98
.ceremony/LABELS.md
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
# Labels
|
||||||
|
|
||||||
|
The taxonomy shared across the heavy-duty repos. Only the `scope:` set
|
||||||
|
differs per repo (each repo's `.github/labels.conf` names its actual
|
||||||
|
surfaces); everything else below is core and identical everywhere, created by
|
||||||
|
the labels workflow's bootstrap dispatch (issue #10).
|
||||||
|
|
||||||
|
Two state machines share the taxonomy: the **PR machine** (proven in
|
||||||
|
box/rig/cast, reconciled by machinery) and the **issue flow** (the
|
||||||
|
triage → build queue, doctrine-enforced today, machinery to follow —
|
||||||
|
issue #18). One rule joins everything: **states are machine-owned, intent
|
||||||
|
labels are hand-set** — a hand-moved state label is a lie waiting to happen,
|
||||||
|
and the reconciler recomputes it from GitHub's own facts.
|
||||||
|
|
||||||
|
## PR state — who is the ball with? (exactly one per open PR)
|
||||||
|
|
||||||
|
| Label | Color | Waiting on |
|
||||||
|
|---|---|---|
|
||||||
|
| `state:building` | `#FBCA04` | the builder — PR is a draft |
|
||||||
|
| `state:bots-reviewing` | `#1D76DB` | the reviewer panel to finish the round (a request is live) |
|
||||||
|
| `state:addressing` | `#D93F0B` | the builder — round complete without full approval, or nobody was asked, or a blocker is up |
|
||||||
|
| `state:needs-human` | `#8250DF` | the human — **this PR could be merged right now**: zero blockers, whole panel approved the current head |
|
||||||
|
|
||||||
|
`bots-reviewing` vs `addressing` is deliberate: staleness in the first means
|
||||||
|
*poke the reviewers*, in the second *the builder dropped the ball*. And
|
||||||
|
`state:needs-human` means exactly one thing — a human could merge this now —
|
||||||
|
so it requires zero blockers and head-current approvals; anything less and
|
||||||
|
the reconciler takes it back. The author sets it at handoff (the one
|
||||||
|
hand-set state); the `labeled` event fires the sweep that validates the
|
||||||
|
write within seconds.
|
||||||
|
|
||||||
|
## PR blockers — what is in the way? (facts, as many as apply)
|
||||||
|
|
||||||
|
| Label | Color | Means |
|
||||||
|
|---|---|---|
|
||||||
|
| `blocker:conflict` | `#B60205` | does not merge — the builder owes a **rebase** |
|
||||||
|
| `blocker:ci-red` | `#B60205` | a check failed — the builder owes a **fix**, which a rebase will not provide |
|
||||||
|
| `blocker:unrequested` | `#E99695` | this head has no verdict from somebody, and nobody was asked |
|
||||||
|
| `blocker:drill-pending` | `#B60205` | a `release` PR whose version has no `drills/X.Y.Z.md` record — correct but unevidenced (maintainer-created label; the bot bootstrap 403s on it) |
|
||||||
|
|
||||||
|
States answer *whose ball*; blockers answer *what's in the way*. They are
|
||||||
|
separate axes because the single-label version kept lying — independent facts
|
||||||
|
projected onto one totally-ordered label meant one always won and the losers
|
||||||
|
vanished off the board (box's `state:needs-rebase`, retired: the reconciler
|
||||||
|
strips it on sight).
|
||||||
|
|
||||||
|
## Issue flow — the work queue (exactly one per open, triaged, non-epic issue)
|
||||||
|
|
||||||
|
| Label | Color | Means | Set by |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `needs-triage` | `#FBCA04` | an issue that did not come through triage — it owes normalization or conversion back to a discussion | anyone who spots one; cleared by triage |
|
||||||
|
| `ready` | `#0E8A16` | triaged, spec complete, unblocked — a builder can start now and succeed | triage |
|
||||||
|
| `claimed` | `#1D76DB` | a builder owns it: assignee set, a draft PR expected shortly | the claiming builder |
|
||||||
|
| `blocked` | `#6A737D` | waiting on another issue or PR (`Blocked by #N` in the body names it) | triage; anyone may correct it |
|
||||||
|
| `epic` | `#5319E7` | organizes other issues via a dependency-ordered task list; **builders never pick an epic** | triage |
|
||||||
|
|
||||||
|
The invariant a board scan relies on: every open issue is either
|
||||||
|
`needs-triage`, `epic`, or carries exactly one of `ready` / `claimed` /
|
||||||
|
`blocked`. A `claimed` issue with no open PR and no activity is what the
|
||||||
|
staleness sweep will reclaim (issue #18); until that machinery exists,
|
||||||
|
[TRIAGE.md](TRIAGE.md) owns the hygiene by hand.
|
||||||
|
|
||||||
|
## Cross-cutting (PRs and issues)
|
||||||
|
|
||||||
|
| Label | Color | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `stale` | `#B60205` | no activity for 48h — sweep-managed, never hand-applied |
|
||||||
|
| `blocked` | `#6A737D` | (see above — same label serves PRs waiting on another PR/issue; legitimately quiet, the staleness sweep skips it) |
|
||||||
|
| `release` | `#0E8A16` | release flow, versioning, packaging work — and the ceremony PR itself |
|
||||||
|
| `merge-next` | `#0E8A16` | head of the merge queue — merge this one next. Queue order is *intent*: never set by the reconciler, only cleared by it |
|
||||||
|
|
||||||
|
## Scope — which surface? (PRs and issues, any number)
|
||||||
|
|
||||||
|
All scopes share one calm color, `#C5DEF5` — scopes locate, states alert. The
|
||||||
|
set is per-repo (`.github/labels.conf`); PRs get theirs from changed paths via
|
||||||
|
actions/labeler, issues get theirs from triage. This repo's set:
|
||||||
|
|
||||||
|
| Label | Covers |
|
||||||
|
|---|---|
|
||||||
|
| `scope:release-flow` | the reusable release workflow, decide, the doors |
|
||||||
|
| `scope:guards` | changelog-armed / changelog-monotonic / drill-recorded |
|
||||||
|
| `scope:labels` | the labels workflow, reconciler, this taxonomy |
|
||||||
|
| `scope:docs` | README doctrine, CONSUMERS.md, the role files |
|
||||||
|
|
||||||
|
## Issue types
|
||||||
|
|
||||||
|
`bug`, `enhancement`, `documentation` — issues only, set by triage. PRs carry
|
||||||
|
their type in the conventional title (`feat:`, `fix:`, `docs:`); a type label
|
||||||
|
on a PR would say the same thing twice and drift.
|
||||||
|
|
||||||
|
## Maintenance
|
||||||
|
|
||||||
|
The labels workflow (issue #10) recomputes PR state statelessly on PR events
|
||||||
|
plus a 15-minute advisory cron, and bootstraps this taxonomy idempotently on
|
||||||
|
manual dispatch. Issue-flow labels are doctrine-owned until #18 lands
|
||||||
|
machinery for them. Default GitHub labels (`duplicate`, `invalid`,
|
||||||
|
`question`, `wontfix`, `help wanted`, `good first issue`) are deleted at
|
||||||
|
bootstrap — a `question` is a discussion, not an issue.
|
||||||
14
.ceremony/README.md
Normal file
14
.ceremony/README.md
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
# .ceremony/ — the vendored doctrine mirror
|
||||||
|
|
||||||
|
Machine-managed by heavy-duty/ceremony's `actions/docs-sync`. Never edit
|
||||||
|
these files here: they are byte-identical copies of
|
||||||
|
[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony) at this
|
||||||
|
repository's pinned ref, and CI re-diffs them on every PR — a hand edit
|
||||||
|
goes red. They are changed in heavy-duty/ceremony, through its own flow,
|
||||||
|
and arrive here when the pin moves.
|
||||||
|
|
||||||
|
The pin lives in `.github/workflows/release.yml` — the single
|
||||||
|
`uses: heavy-duty/ceremony/.github/workflows/release.yml@<ref>` line. One
|
||||||
|
pin governs machinery and doctrine alike: bump it and re-sync this mirror
|
||||||
|
in the same PR (`docs-sync --fix`, or let the red check on the bump PR say
|
||||||
|
what is stale).
|
||||||
70
.ceremony/REVIEWER.md
Normal file
70
.ceremony/REVIEWER.md
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
# REVIEWER.md — the reviewer role
|
||||||
|
|
||||||
|
You are one voice on a panel. The panel's job is to converge — on an
|
||||||
|
approval the human can trust, or on a precise statement of what is wrong.
|
||||||
|
The machine reads only your **verdict**; humans read your reasons.
|
||||||
|
|
||||||
|
## The verdict doctrine
|
||||||
|
|
||||||
|
- **Every review ends in a verdict**: approve, or request changes. A
|
||||||
|
comment-only review is a non-verdict — it does not say whether the round
|
||||||
|
passed, the state machine treats it as not-approved, and the PR simply
|
||||||
|
stalls. If you have an opinion, you have a verdict; commenting without one
|
||||||
|
only wedges the flow.
|
||||||
|
- **The verdict carries blockingness only; the body carries the feedback.**
|
||||||
|
Non-blocking nits ride an **approval**, and the builder addresses them at
|
||||||
|
their discretion. Anything blocking — including a question whose answer
|
||||||
|
gates your approval — is **request changes**, saying exactly what
|
||||||
|
unblocks it.
|
||||||
|
- An approval you would not defend to the human is a defect. You are not
|
||||||
|
being asked to be agreeable; you are being asked to be right.
|
||||||
|
|
||||||
|
## What you review against
|
||||||
|
|
||||||
|
In order of authority:
|
||||||
|
|
||||||
|
1. **The issue's acceptance criteria** — the PR's `Closes #N` names your
|
||||||
|
spec. Check every criterion; a PR that ships less than the issue says is
|
||||||
|
a request-changes even if the code is beautiful.
|
||||||
|
2. **The repo's load-bearing constraints** — the rules bought with
|
||||||
|
incidents (in ceremony itself: issue #1's constraint list; in a governed
|
||||||
|
repo: its own CONTRIBUTING plus ceremony's README). A change that
|
||||||
|
"simplifies away" a constraint gets request-changes with a link to the
|
||||||
|
incident that made the rule.
|
||||||
|
3. **The code itself** — correctness first, then tests (does the test plan's
|
||||||
|
floor exist? do the failure cases actually fail?), then conventions.
|
||||||
|
Changelog line present for behavior changes; comments carry why, not
|
||||||
|
what.
|
||||||
|
|
||||||
|
**Verify over opine.** Run what can be run; construct the failing input; a
|
||||||
|
test settles what a comment thread can't. A review that says "I ran X and
|
||||||
|
saw Y" outranks one that says "this looks like it might".
|
||||||
|
|
||||||
|
## What you do not do
|
||||||
|
|
||||||
|
- **Re-litigate the spec.** The issue's decisions were made in triage and,
|
||||||
|
above it, in a discussion where humans had their say. If you think the
|
||||||
|
spec itself is wrong, say so with reasons — as a comment pointing at the
|
||||||
|
discussion, while still reviewing the implementation against the spec as
|
||||||
|
written. Spec changes go through triage, not through a review round.
|
||||||
|
- **Merge, or tell the builder to merge.** Convergence hands the PR to a
|
||||||
|
human; only humans merge.
|
||||||
|
- **Approve a moving target.** Your approval is of a specific head. If the
|
||||||
|
builder pushes after your approval, GitHub stales it — that is correct,
|
||||||
|
and the builder owes a re-request, not an assumption.
|
||||||
|
|
||||||
|
## The round rhythm
|
||||||
|
|
||||||
|
- Review the **whole PR at the current head** each round, not just the diff
|
||||||
|
since your last comments — the fix for someone else's point can break
|
||||||
|
yours.
|
||||||
|
- The builder answers rounds whole and re-requests you; until re-requested,
|
||||||
|
the ball is not yours (`state:addressing` is the builder working — pile-on
|
||||||
|
reviews mid-address just churn the target).
|
||||||
|
- Convergence = every panel verdict approves the current head, no
|
||||||
|
`blocker:*` standing. Then the builder hands off (`state:needs-human`) and
|
||||||
|
the panel's job is done.
|
||||||
|
- If a round exposes a disagreement **within the panel**, argue it in the PR
|
||||||
|
with evidence until one side concedes or the builder escalates to the
|
||||||
|
maintainer for a ruling. Two reviewers pulling a builder in opposite
|
||||||
|
directions without resolution is a panel failure, not a builder failure.
|
||||||
95
.ceremony/TRIAGE.md
Normal file
95
.ceremony/TRIAGE.md
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
# TRIAGE.md — the triage role
|
||||||
|
|
||||||
|
You are the only door issues come through. Humans and agents open
|
||||||
|
**discussions**; you decide what becomes work. The quality of every
|
||||||
|
downstream stage — a builder succeeding without asking, a reviewer having a
|
||||||
|
spec to review against — is set here, by you, and nowhere else.
|
||||||
|
|
||||||
|
## Why this door exists
|
||||||
|
|
||||||
|
Discussions are allowed to be ambiguous; issues are not. An issue is a work
|
||||||
|
order a builder must be able to execute **without asking anyone anything**.
|
||||||
|
Keeping one accountable role between the two is what keeps the bar from
|
||||||
|
eroding — the moment anyone can mint an issue, the backlog fills with
|
||||||
|
"improve X" entries nobody can build, and builders start guessing. Guessing
|
||||||
|
is the failure this whole flow exists to prevent.
|
||||||
|
|
||||||
|
## Your inputs
|
||||||
|
|
||||||
|
- **Every open discussion** in the repo you serve.
|
||||||
|
- **Stray issues** — anything filed directly, by anyone. Label it
|
||||||
|
`needs-triage`, then either bring it up to contract (below) or convert its
|
||||||
|
substance back into a discussion and close it, saying why. Do not shame the
|
||||||
|
filer; do route the work correctly.
|
||||||
|
|
||||||
|
## For each discussion, converge on exactly one outcome
|
||||||
|
|
||||||
|
1. **Answer.** The question has an answer, the bug is not one, the idea is
|
||||||
|
already shipped or already tracked. Reply with the answer (link the code,
|
||||||
|
the doc, the existing issue), mark answered.
|
||||||
|
2. **Ask.** Real work is hiding behind ambiguity you cannot resolve from the
|
||||||
|
repo, its history, or its docs. Ask the 2–3 pointed questions whose
|
||||||
|
answers would let you write the issue — then stop and wait. Do not mint an
|
||||||
|
issue that carries the ambiguity forward; that just moves your job onto
|
||||||
|
the builder.
|
||||||
|
3. **Escalate.** The blocker is a *decision* only a human owns — scope,
|
||||||
|
money, product direction, breaking a public contract. Say precisely what
|
||||||
|
the decision is, list the options with your recommendation, and name the
|
||||||
|
decider. The discussion is where humans decide; wait there.
|
||||||
|
4. **Decline.** Real idea, wrong repo or wrong time. Say why plainly, link
|
||||||
|
where it belongs if anywhere, close. A refusal with reasons is a good
|
||||||
|
outcome; a zombie discussion is not.
|
||||||
|
5. **Accept.** It justifies work → mint the issue(s). The contract below is
|
||||||
|
the bar.
|
||||||
|
|
||||||
|
## The issue contract
|
||||||
|
|
||||||
|
Every issue you mint carries, in this order:
|
||||||
|
|
||||||
|
- **A title that names the deliverable** — "lib/version.sh — one version
|
||||||
|
abstraction, two backends", never "improve version handling".
|
||||||
|
- **Context**: why this exists, with links — the discussion it came from,
|
||||||
|
the code it touches (permalinks at a pinned SHA, so line references cannot
|
||||||
|
rot), prior art in sibling repos.
|
||||||
|
- **The spec**: decisions made, not options listed. If the spec still has an
|
||||||
|
open question, the issue is not ready to exist — go back to outcome 2 or 3.
|
||||||
|
- **Tasks**: the steps, checkboxed, in order.
|
||||||
|
- **Acceptance criteria**: checkboxed, verifiable, and honest — these become
|
||||||
|
the builder's definition of done and the reviewer's review spec, verbatim.
|
||||||
|
- **Test plan**: what proves it, including the cases that must fail.
|
||||||
|
- **Dependencies**: `Blocked by #N` / `Blocks #N`, and `Part of #E` when an
|
||||||
|
epic organizes it.
|
||||||
|
- **Labels**: type (`bug`/`enhancement`/`documentation`), `scope:*`, and
|
||||||
|
exactly one of `ready` / `blocked` (see [LABELS.md](LABELS.md)).
|
||||||
|
|
||||||
|
The bar, stated once: **a competent builder who has read only this issue and
|
||||||
|
the repo can succeed.** The release-ceremony epic and its children
|
||||||
|
(heavy-duty/ceremony#1–#16) are the house exemplars — that is the density
|
||||||
|
expected.
|
||||||
|
|
||||||
|
## Multi-issue work
|
||||||
|
|
||||||
|
When an acceptance produces more than one issue, mint an **epic** (`epic`
|
||||||
|
label): the approach, the decisions, the constraint list, and a
|
||||||
|
dependency-ordered task list of child issues. Children reference the epic;
|
||||||
|
the epic's checklist is the progress view. Builders never pick the epic
|
||||||
|
itself. Keep the checklist current — a stale epic misleads every scan.
|
||||||
|
|
||||||
|
## Backlog hygiene (yours until #18 automates it)
|
||||||
|
|
||||||
|
- **Dedup before minting** — search issues *and* closed issues; extend or
|
||||||
|
reopen before duplicating.
|
||||||
|
- **Flip `blocked` → `ready`** when the named dependency lands.
|
||||||
|
- **Reclaim abandoned claims**: `claimed` + no open PR + no activity →
|
||||||
|
comment, unassign, restore `ready`.
|
||||||
|
- **Close obsolete issues** with the reason and a link to what obsoleted
|
||||||
|
them. Every label on every open issue stays true; the board is only worth
|
||||||
|
scanning if it does not lie.
|
||||||
|
|
||||||
|
## What you never do
|
||||||
|
|
||||||
|
- Write code, review code, or build the thing yourself.
|
||||||
|
- Assign a builder — builders pick and claim ([BUILDER.md](BUILDER.md)).
|
||||||
|
- Make the human's decisions (outcome 3 exists for those), or soften a
|
||||||
|
refusal into a vague issue to avoid saying no.
|
||||||
|
- Mint an issue to "discuss" something — that is a discussion.
|
||||||
2
.github/labeler.yml
vendored
2
.github/labeler.yml
vendored
|
|
@ -1,4 +1,4 @@
|
||||||
# path → scope:* map for actions/labeler — the PR half of LABELS.md's scope
|
# path → scope:* map for actions/labeler — the PR half of .ceremony/LABELS.md's scope
|
||||||
# story (issues are hand-scoped at triage; paths only exist on PRs). Additive
|
# story (issues are hand-scoped at triage; paths only exist on PRs). Additive
|
||||||
# only: sync-labels stays off in labels.yml, so a hand-applied scope survives.
|
# only: sync-labels stays off in labels.yml, so a hand-applied scope survives.
|
||||||
"scope:cli":
|
"scope:cli":
|
||||||
|
|
|
||||||
7
.github/labels.conf
vendored
Normal file
7
.github/labels.conf
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
panel=claude-bot-andresmgsl codex-bot-andresmgsl grok-bot-andresmgsl kimi-bot-andresmgsl
|
||||||
|
scope:cli|C5DEF5|bin/box — the command surface
|
||||||
|
scope:installer|C5DEF5|install.sh, versioned installs, upgrade/uninstall
|
||||||
|
scope:host|C5DEF5|host/ — setup, teardown, firewall, isolation stack
|
||||||
|
scope:tiers|C5DEF5|restricted tier — grant/revoke, multi-user
|
||||||
|
scope:templates|C5DEF5|templates/ — the box seeds
|
||||||
|
scope:drill|C5DEF5|drill/ — rehearsals, doctor, RUNS.md
|
||||||
228
.github/scripts/labels-reconcile.sh
vendored
228
.github/scripts/labels-reconcile.sh
vendored
|
|
@ -1,228 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# labels-reconcile.sh — the automation LABELS.md promises: state labels are
|
|
||||||
# written by machinery, never by hand. Every run derives each open PR's
|
|
||||||
# state:* from GitHub's own facts (draft flag, requested reviewers, submitted
|
|
||||||
# reviews) and converges the labels to it, so a killed run or a hand-moved
|
|
||||||
# label heals on the next pass. Stale is judged from real activity — commits,
|
|
||||||
# comments, reviews — never from label churn, or the sweep would un-stale its
|
|
||||||
# own mark every tick.
|
|
||||||
#
|
|
||||||
# The verdict contract (CONTRIBUTING.md): reviews end in approve or
|
|
||||||
# request-changes. Some live bots are comment-only and post agreement as a
|
|
||||||
# COMMENTED review — a non-verdict this machine refuses to guess about (body
|
|
||||||
# parsing is a heuristic, and a wrong guess promotes an unapproved PR). The
|
|
||||||
# judgment call belongs to the PR AUTHOR, who reads the round and escalates
|
|
||||||
# by requesting the human's review — an explicit request is a fact, and it is
|
|
||||||
# the one this machine trusts (see decide_state's top precedence). The
|
|
||||||
# machine auto-requests the human only in the no-judgment-needed case: three
|
|
||||||
# formal head-current approvals. Any approval that counts must be bound to
|
|
||||||
# the CURRENT head SHA: GitHub keeps approvals alive across pushes, and a
|
|
||||||
# stale approval must never promote unreviewed code to the human.
|
|
||||||
#
|
|
||||||
# DRY_RUN=1 narrates every mutation instead of performing it (how this script
|
|
||||||
# is rehearsed against the live repo). A workflow_dispatch run also bootstraps
|
|
||||||
# the taxonomy (label create --force) — that heal is dispatch-only; the cron
|
|
||||||
# sweep tolerates a missing label rather than recreating it.
|
|
||||||
#
|
|
||||||
# The state machine below is pure (globals in, state out) and covered by
|
|
||||||
# fixture tests in test/labels-reconcile.sh.
|
|
||||||
|
|
||||||
HUMAN="${HUMAN_REVIEWER:-danmt}"
|
|
||||||
BOTS=(claude-bot-andresmgsl codex-bot-andresmgsl grok-bot-andresmgsl)
|
|
||||||
STATES=(state:building state:bots-reviewing state:addressing state:needs-human)
|
|
||||||
STALE_AFTER=$((48 * 3600))
|
|
||||||
|
|
||||||
log() { printf 'labels: %s\n' "$*"; }
|
|
||||||
|
|
||||||
run() { # every mutation goes through here — DRY_RUN=1 logs instead of doing
|
|
||||||
if [ -n "${DRY_RUN:-}" ]; then log "DRY_RUN: $*"; else "$@"; fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# The state machine. Pure functions over four globals, set per PR:
|
|
||||||
# DRAFT true|false
|
|
||||||
# HEAD_SHA the PR's current head commit
|
|
||||||
# REQUESTED newline-separated logins with a review currently requested
|
|
||||||
# REVIEWS_JSON JSON array of submitted (non-PENDING) reviews
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
requested() { grep -qxF "$1" <<<"$REQUESTED"; }
|
|
||||||
|
|
||||||
bot_verdict() { # $1 = login → MISSING | BLOCK | APPROVE | STALE | FEEDBACK
|
|
||||||
local review state commit
|
|
||||||
review="$(jq -c --arg u "$1" \
|
|
||||||
'[.[] | select(.user.login == $u)] | sort_by(.submitted_at) | last // empty' \
|
|
||||||
<<<"$REVIEWS_JSON")"
|
|
||||||
if [ -z "$review" ]; then echo MISSING; return; fi
|
|
||||||
state="$(jq -r '.state' <<<"$review")"
|
|
||||||
commit="$(jq -r '.commit_id' <<<"$review")"
|
|
||||||
case "$state" in
|
|
||||||
CHANGES_REQUESTED)
|
|
||||||
# blocks at ANY head — GitHub's own semantic: only a newer review
|
|
||||||
# from the same reviewer clears it
|
|
||||||
echo BLOCK ;;
|
|
||||||
APPROVED)
|
|
||||||
if [ "$commit" = "$HEAD_SHA" ]; then echo APPROVE; else echo STALE; fi ;;
|
|
||||||
*)
|
|
||||||
# COMMENTED and anything else: a non-verdict. The machine does not
|
|
||||||
# read bodies — if the comment is really an agreement, the AUTHOR
|
|
||||||
# says so by requesting the human's review.
|
|
||||||
echo FEEDBACK ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
human_request_needed() { # 0 when needs-human requires a FRESH human request
|
|
||||||
# already requested → the handoff is live; head-current human approval →
|
|
||||||
# nothing left to ask. Anything else (never reviewed, an old comment, an
|
|
||||||
# approval of an older head) stalls the handoff unless we request —
|
|
||||||
# guarding on "has the human ever reviewed" wedged exactly that way.
|
|
||||||
if requested "$HUMAN"; then return 1; fi
|
|
||||||
if [ "$(bot_verdict "$HUMAN")" = APPROVE ]; then return 1; fi
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
decide_state() { # → the one state:* label this PR should carry
|
|
||||||
if [ "$DRAFT" = true ]; then echo state:building; return; fi
|
|
||||||
# an explicit human request outranks the bot rounds — it is the final
|
|
||||||
# gate, and a maintainer pulling a PR to themselves early counts too
|
|
||||||
if requested "$HUMAN"; then echo state:needs-human; return; fi
|
|
||||||
local b v verdicts=""
|
|
||||||
for b in "${BOTS[@]}"; do
|
|
||||||
if requested "$b"; then echo state:bots-reviewing; return; fi
|
|
||||||
done
|
|
||||||
for b in "${BOTS[@]}"; do
|
|
||||||
v="$(bot_verdict "$b")"
|
|
||||||
if [ "$v" = MISSING ]; then echo state:bots-reviewing; return; fi
|
|
||||||
verdicts="$verdicts $v"
|
|
||||||
done
|
|
||||||
case "$verdicts" in
|
|
||||||
# FEEDBACK = a comment with no verdict → the agent owes the round-reply.
|
|
||||||
# STALE = a verdict for an older head → the agent owes a re-request.
|
|
||||||
*BLOCK* | *FEEDBACK* | *STALE*) echo state:addressing; return ;;
|
|
||||||
esac
|
|
||||||
# the bots all approve — but if the human's standing word is
|
|
||||||
# changes-requested (and nobody re-requested them yet), the agent owes
|
|
||||||
# fixes, not the human a nag
|
|
||||||
if [ "$(bot_verdict "$HUMAN")" = BLOCK ]; then
|
|
||||||
echo state:addressing
|
|
||||||
else
|
|
||||||
echo state:needs-human
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# The sweep: fetch facts, decide, converge. One PR's failure never aborts the
|
|
||||||
# others — each PR reconciles in a subshell and a failure just logs.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
bootstrap_labels() { # dispatch-only: ~20 upserts is too chatty for every cron tick
|
|
||||||
while IFS='|' read -r name color desc; do
|
|
||||||
[ -n "$name" ] || continue
|
|
||||||
run gh label create "$name" -R "$REPO" --color "$color" --description "$desc" --force
|
|
||||||
done <<'EOF'
|
|
||||||
state:building|FBCA04|PR is a draft — the coding agent is still building
|
|
||||||
state:bots-reviewing|1D76DB|Waiting on the bot reviewers to finish the round
|
|
||||||
state:addressing|D93F0B|All bots reviewed — coding agent owes the single reply + fixes
|
|
||||||
state:needs-human|8250DF|All bots approve — waiting on the human reviewer
|
|
||||||
stale|B60205|No activity for 48h — needs a poke (sweep-managed)
|
|
||||||
blocked|6A737D|Waiting on another PR or issue to land first
|
|
||||||
release|0E8A16|Release flow and version/packaging work
|
|
||||||
scope:cli|C5DEF5|bin/box — the command surface
|
|
||||||
scope:installer|C5DEF5|install.sh, versioned installs, upgrade/uninstall
|
|
||||||
scope:host|C5DEF5|host/ — setup, teardown, firewall, isolation stack
|
|
||||||
scope:tiers|C5DEF5|restricted tier — grant/revoke, multi-user
|
|
||||||
scope:templates|C5DEF5|templates/ — the box seeds
|
|
||||||
scope:drill|C5DEF5|drill/ — rehearsals, doctor, RUNS.md
|
|
||||||
EOF
|
|
||||||
}
|
|
||||||
|
|
||||||
has_label() { grep -qxF "$1" <<<"$LABELS"; }
|
|
||||||
|
|
||||||
reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch
|
|
||||||
local n="$1" desired remove s args last_activity age
|
|
||||||
|
|
||||||
desired="$(decide_state)"
|
|
||||||
|
|
||||||
# encode the runbook's last step for the no-judgment case: three formal
|
|
||||||
# head-current approvals → the human is asked, once. The guard asks whether
|
|
||||||
# a FRESH human review is needed for THIS head — never "has the human ever
|
|
||||||
# reviewed", which wedged the handoff after any earlier human comment.
|
|
||||||
# Idempotent (a live request suppresses it); race-free via the shared
|
|
||||||
# concurrency group in labels.yml. With a comment-only bot on the panel
|
|
||||||
# this path stays cold and the AUTHOR requests the human.
|
|
||||||
if [ "$desired" = state:needs-human ] && human_request_needed; then
|
|
||||||
run gh api "repos/$REPO/pulls/$n/requested_reviewers" -f "reviewers[]=$HUMAN" --silent
|
|
||||||
log "#$n: requested $HUMAN (round passed)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---- converge the state:* labels ----
|
|
||||||
remove=""
|
|
||||||
for s in "${STATES[@]}"; do
|
|
||||||
if [ "$s" != "$desired" ] && has_label "$s"; then remove="$remove,$s"; fi
|
|
||||||
done
|
|
||||||
remove="${remove#,}"
|
|
||||||
if ! has_label "$desired" || [ -n "$remove" ]; then
|
|
||||||
args=(--add-label "$desired")
|
|
||||||
[ -n "$remove" ] && args+=(--remove-label "$remove")
|
|
||||||
if run gh issue edit "$n" -R "$REPO" "${args[@]}" >/dev/null; then
|
|
||||||
log "#$n: state -> $desired${remove:+ (cleared $remove)}"
|
|
||||||
else
|
|
||||||
# a deleted label must not wedge the sweep — dispatch heals the taxonomy
|
|
||||||
log "#$n: WARNING: label edit failed (missing label? run the workflow manually to bootstrap)"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---- stale: real activity only, and blocked is legitimately quiet ----
|
|
||||||
last_activity="$(
|
|
||||||
{
|
|
||||||
jq -r '.created_at' <<<"$PR_JSON"
|
|
||||||
jq -r '.[].submitted_at' <<<"$REVIEWS_JSON"
|
|
||||||
gh api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at'
|
|
||||||
gh api --paginate "repos/$REPO/pulls/$n/comments" --jq '.[].created_at'
|
|
||||||
gh api --paginate "repos/$REPO/pulls/$n/commits" --jq '.[].commit.committer.date'
|
|
||||||
} | sort | tail -n1
|
|
||||||
)"
|
|
||||||
age=$((NOW - $(date -d "$last_activity" +%s)))
|
|
||||||
if has_label blocked || [ "$age" -le "$STALE_AFTER" ]; then
|
|
||||||
if has_label stale; then
|
|
||||||
run gh issue edit "$n" -R "$REPO" --remove-label stale >/dev/null
|
|
||||||
log "#$n: unstale"
|
|
||||||
fi
|
|
||||||
elif ! has_label stale; then
|
|
||||||
run gh issue edit "$n" -R "$REPO" --add-label stale >/dev/null
|
|
||||||
log "#$n: stale ($((age / 3600))h quiet)"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
main() {
|
|
||||||
REPO="${REPO:?set REPO to owner/name}"
|
|
||||||
NOW="$(date +%s)"
|
|
||||||
|
|
||||||
if [ "${GITHUB_EVENT_NAME:-}" = workflow_dispatch ]; then
|
|
||||||
log "workflow_dispatch: bootstrapping the taxonomy"
|
|
||||||
bootstrap_labels
|
|
||||||
fi
|
|
||||||
|
|
||||||
local n
|
|
||||||
for n in $(gh pr list -R "$REPO" --state open --limit 100 --json number --jq '.[].number'); do
|
|
||||||
(
|
|
||||||
PR_JSON="$(gh api "repos/$REPO/pulls/$n")"
|
|
||||||
DRAFT="$(jq -r '.draft' <<<"$PR_JSON")"
|
|
||||||
HEAD_SHA="$(jq -r '.head.sha' <<<"$PR_JSON")"
|
|
||||||
LABELS="$(jq -r '.labels[].name' <<<"$PR_JSON")"
|
|
||||||
REQUESTED="$(jq -r '.requested_reviewers[].login' <<<"$PR_JSON")"
|
|
||||||
# PENDING reviews are unsubmitted drafts in someone's browser — not a verdict
|
|
||||||
REVIEWS_JSON="$(gh api --paginate "repos/$REPO/pulls/$n/reviews" --jq '.[]' \
|
|
||||||
| jq -s '[.[] | select(.state != "PENDING")]')"
|
|
||||||
reconcile_pr "$n"
|
|
||||||
) || log "#$n: reconcile failed — continuing with the remaining PRs"
|
|
||||||
done
|
|
||||||
log "reconciled."
|
|
||||||
}
|
|
||||||
|
|
||||||
# sourced by test/labels-reconcile.sh for the fixture tests; executed in CI
|
|
||||||
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
|
|
||||||
main "$@"
|
|
||||||
fi
|
|
||||||
30
.github/scripts/release-notes.sh
vendored
30
.github/scripts/release-notes.sh
vendored
|
|
@ -1,30 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# release-notes.sh <version> [<changelog>] — print exactly <version>'s
|
|
||||||
# section of the changelog: every line between its '## <version> — <date>'
|
|
||||||
# header and the next '## '. This is what release.yml hands to
|
|
||||||
# 'gh release create', so the release notes are the curated prose we wrote,
|
|
||||||
# not the PR list GitHub would generate (#83). Fails loudly when the section
|
|
||||||
# is missing or empty — a tag without its changelog section is a release
|
|
||||||
# ritual skipped, and an empty release body would paper over it.
|
|
||||||
#
|
|
||||||
# A file of its own (not inlined in release.yml) so test/release.sh drives
|
|
||||||
# the same extraction against fixtures and the real CHANGELOG.md.
|
|
||||||
|
|
||||||
ver="${1:-}"
|
|
||||||
changelog="${2:-CHANGELOG.md}"
|
|
||||||
[ -n "$ver" ] || { echo "usage: release-notes.sh <version> [<changelog>]" >&2; exit 2; }
|
|
||||||
[ -f "$changelog" ] || { echo "release-notes: no such file: $changelog" >&2; exit 1; }
|
|
||||||
|
|
||||||
# $2 of a section header ('## 0.6.0 — 2026-07-18') is the bare version —
|
|
||||||
# compared WHOLE, so 0.6.0 can never match a 0.6.0-rc1 section (or vice
|
|
||||||
# versa), and no regex-escaping of dots. sed drops the blank padding under
|
|
||||||
# the header; the command substitution eats the trailing blanks.
|
|
||||||
notes="$(awk -v ver="$ver" '
|
|
||||||
/^## / { grab = ($2 == ver); next }
|
|
||||||
grab { print }
|
|
||||||
' "$changelog" | sed '/./,$!d')"
|
|
||||||
|
|
||||||
[ -n "$notes" ] || { echo "release-notes: $changelog has no section for '$ver' — the release PR stamps the Unreleased section with version + date BEFORE the tag (#83)" >&2; exit 1; }
|
|
||||||
printf '%s\n' "$notes"
|
|
||||||
88
.github/workflows/ci.yml
vendored
88
.github/workflows/ci.yml
vendored
|
|
@ -8,23 +8,105 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# fetch-depth: 0, for the changelog-monotonic step below and only
|
||||||
|
# for it. That check is about a DIFF — which release headings the
|
||||||
|
# merge base had — so it needs the base branch's history present,
|
||||||
|
# and the default depth-1 checkout has none of it. An explicit
|
||||||
|
# `git fetch origin <base>` would be narrower, but it has to be
|
||||||
|
# right on both event types and on fork PRs, and getting it subtly
|
||||||
|
# wrong degrades to a SKIP (a guard that silently stops guarding —
|
||||||
|
# the exact failure this repo keeps refusing). Full history on a
|
||||||
|
# pure-bash tree costs a second; the STRICT flag below turns any
|
||||||
|
# remaining skip red rather than green.
|
||||||
|
fetch-depth: 0
|
||||||
- name: shellcheck
|
- name: shellcheck
|
||||||
# -x follows `source`/`.` directives; box has no lib split today, but the
|
# -x follows `source`/`.` directives; box has no lib split today, but the
|
||||||
# flag costs nothing and keeps the invocation identical to rig's.
|
# flag costs nothing and keeps the invocation identical to rig's.
|
||||||
# globstar so a script in a new subdirectory is linted without anyone
|
# globstar so a script in a new subdirectory is linted without anyone
|
||||||
# remembering to edit this list; bin/* covers the extensionless entrypoint
|
# remembering to edit this list; bin/* covers the extensionless entrypoint
|
||||||
# (bin/box). The file list is printed so under-coverage shows up in the log.
|
# (bin/box). The file list is printed so under-coverage shows up in the log.
|
||||||
|
#
|
||||||
|
# dotglob is not decoration (#116): globstar makes `**` descend, but a
|
||||||
|
# glob still does not MATCH a dot-prefixed name, so `**/` never entered
|
||||||
|
# `.github/` — and the whole release path (changelog-armed.sh, which
|
||||||
|
# gates every PR, release-notes.sh, labels-reconcile.sh) went unlinted
|
||||||
|
# while the comment above told the next author it was covered.
|
||||||
|
# Measured when this invariant was introduced: dotglob added exactly
|
||||||
|
# those three and nothing else — a checkout's `.git` carries no `*.sh`
|
||||||
|
# (its hooks ship as `*.sample`), so `**/*.sh` does not wander into it.
|
||||||
|
#
|
||||||
|
# The sweep below is the CLASS check, same shape as the eof_guard_sweep
|
||||||
|
# in test/cli.sh (#112): the one-time fix is `dotglob`, but what keeps
|
||||||
|
# the gap from reopening is asserting that every TRACKED script is in
|
||||||
|
# the set actually handed to shellcheck. `git ls-files` is the authority
|
||||||
|
# on what the repo contains; if the glob ever drifts from it again —
|
||||||
|
# another dot-directory, another shopt subtlety — CI says which files
|
||||||
|
# escaped instead of quietly linting a subset and passing.
|
||||||
run: |
|
run: |
|
||||||
shopt -s globstar
|
shopt -s globstar dotglob
|
||||||
files=(bin/* **/*.sh)
|
files=(bin/* **/*.sh)
|
||||||
printf 'shellcheck: %s\n' "${files[@]}"
|
printf 'shellcheck: %s\n' "${files[@]}"
|
||||||
|
missing="$(comm -13 \
|
||||||
|
<(printf '%s\n' "${files[@]}" | sort -u) \
|
||||||
|
<(git ls-files '*.sh' | sort -u))"
|
||||||
|
if [ -n "$missing" ]; then
|
||||||
|
echo "tracked scripts the shellcheck sweep does not cover (#116):"
|
||||||
|
printf '%s\n' "$missing" | sed 's/^/ /'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
shellcheck -x "${files[@]}"
|
shellcheck -x "${files[@]}"
|
||||||
- name: cli tests
|
- name: cli tests
|
||||||
run: bash test/cli.sh
|
run: bash test/cli.sh
|
||||||
- name: labels state-machine tests
|
|
||||||
run: bash test/labels-reconcile.sh
|
|
||||||
- name: release-flow tests
|
- name: release-flow tests
|
||||||
run: bash test/release.sh
|
run: bash test/release.sh
|
||||||
|
# The changelog is ARMED for the next entry (#108). Its own step rather
|
||||||
|
# than a line inside test/release.sh: this one asserts a fact about THIS
|
||||||
|
# tree, not about the release machinery, so when it goes red the log
|
||||||
|
# says which check found the drift without anyone reading a suite.
|
||||||
|
- name: changelog is armed for the next entry
|
||||||
|
uses: heavy-duty/ceremony/actions/changelog-armed@0.1.0
|
||||||
|
# ...and no SHIPPED release heading was deleted or DUPLICATED (#122, #143).
|
||||||
|
# Its own step for the same reason as the one above — when it goes red the
|
||||||
|
# log names the invariant that broke — but a DIFFERENT invariant: armed is
|
||||||
|
# a fact about this tree, monotonicity is a fact about this tree versus
|
||||||
|
# its merge base. STRICT=1 so a checkout that cannot reach the base ref
|
||||||
|
# fails here instead of skipping quietly forever.
|
||||||
|
#
|
||||||
|
# NOT pull-request-only, and that is the #143 fix at the workflow level.
|
||||||
|
# The two halves have different vacuity: DELETION is vacuous on a push to
|
||||||
|
# main (the merge base IS HEAD), but DUPLICATION is vacuous on no tree at
|
||||||
|
# all, so gating the whole script on `pull_request` left a duplicate that
|
||||||
|
# reached main by any other route unasserted forever.
|
||||||
|
#
|
||||||
|
# The `|| github.ref_name` fallback is load-bearing, not defensive. On a
|
||||||
|
# push event `github.base_ref` is EMPTY, so the argument would collapse to
|
||||||
|
# a bare `origin/`, which does not resolve — and STRICT=1 correctly
|
||||||
|
# promotes that to a hard failure, turning every push to main red. With
|
||||||
|
# the fallback it resolves to the pushed branch, whose merge base with
|
||||||
|
# HEAD is HEAD or its parent: containment passes vacuously, exactly as the
|
||||||
|
# old `if` intended, while uniqueness now runs on every push.
|
||||||
|
# ...and a RELEASE tree has a drill record. Its own step for the same
|
||||||
|
# reason as the two above — when it goes red the log names the invariant
|
||||||
|
# that broke — and a third invariant again: armed is about the shape of
|
||||||
|
# the changelog, monotonic about the diff, this one about whether the
|
||||||
|
# ritual CONTRIBUTING.md hangs on the release PR actually happened.
|
||||||
|
#
|
||||||
|
# NOT pull-request-only, and for the same reasoning the monotonic guard
|
||||||
|
# was un-gated in #143: the release does not finish at the PR. The merge
|
||||||
|
# that publishes it is a PUSH to main carrying the same bare VERSION,
|
||||||
|
# and it is release.yml's trigger — so a check that only ever ran on the
|
||||||
|
# PR would leave the tree that actually ships unasserted, and any bare
|
||||||
|
# VERSION reaching main by another route (a direct push, a backfill,
|
||||||
|
# the manual tag path) unasserted forever. On a -dev tree, which is
|
||||||
|
# every ordinary push and PR, the script no-ops green by design, so
|
||||||
|
# running it everywhere costs nothing and closes the route.
|
||||||
|
- name: a release tree has a drill record
|
||||||
|
uses: heavy-duty/ceremony/actions/drill-recorded@0.1.0
|
||||||
|
- name: no shipped changelog heading was deleted or duplicated
|
||||||
|
uses: heavy-duty/ceremony/actions/changelog-monotonic@0.1.0
|
||||||
|
- name: vendored ceremony doctrine matches the pin
|
||||||
|
uses: heavy-duty/ceremony/actions/docs-sync@0.1.0
|
||||||
|
|
||||||
# The multi-user rehearsal, on a REAL incus — a GitHub runner is root on a
|
# The multi-user rehearsal, on a REAL incus — a GitHub runner is root on a
|
||||||
# disposable VM, which is exactly the substrate the rehearsal needs. It runs
|
# disposable VM, which is exactly the substrate the rehearsal needs. It runs
|
||||||
|
|
|
||||||
53
.github/workflows/labels.yml
vendored
53
.github/workflows/labels.yml
vendored
|
|
@ -1,56 +1,13 @@
|
||||||
name: labels
|
name: labels
|
||||||
# The automation LABELS.md promises. Two halves:
|
|
||||||
# scope — path-derived scope:* labels on PRs (actions/labeler)
|
|
||||||
# reconcile — the state:* machine + the stale sweep (.github/scripts/labels-reconcile.sh)
|
|
||||||
#
|
|
||||||
# pull_request_target, not pull_request: every PR here arrives from a fork,
|
|
||||||
# where pull_request (and pull_request_review) run with a READ-ONLY token and
|
|
||||||
# cannot label anything. _target is safe in this workflow because no PR code
|
|
||||||
# is ever checked out or executed — labeler reads changed paths via the API,
|
|
||||||
# and reconcile checks out the BASE branch only. Keep it that way.
|
|
||||||
#
|
|
||||||
# Review-submitted transitions (bots finishing a round) ride the cron: there
|
|
||||||
# is no pull_request_review_target, so the 15-minute tick is the wake signal —
|
|
||||||
# the same cadence the reviewer bots poll at.
|
|
||||||
on:
|
on:
|
||||||
schedule:
|
schedule: [{cron: "*/15 * * * *"}] # advisory; the handoff label is the real wake
|
||||||
- cron: "*/15 * * * *"
|
workflow_dispatch: # bootstraps missing labels on a fresh repo
|
||||||
workflow_dispatch: # also bootstraps missing labels — run once on a fresh repo
|
|
||||||
pull_request_target:
|
pull_request_target:
|
||||||
types: [opened, reopened, ready_for_review, converted_to_draft, synchronize]
|
types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled]
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
issues: write
|
issues: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
scope:
|
labels:
|
||||||
if: github.event_name == 'pull_request_target'
|
uses: heavy-duty/ceremony/.github/workflows/labels.yml@0.1.0
|
||||||
runs-on: ubuntu-latest
|
|
||||||
concurrency:
|
|
||||||
group: labels-scope-${{ github.event.pull_request.number }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
steps:
|
|
||||||
- uses: actions/labeler@v5
|
|
||||||
with:
|
|
||||||
# additive only — a hand-applied scope must survive the machine
|
|
||||||
sync-labels: false
|
|
||||||
|
|
||||||
reconcile:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
# ONE shared group: every reconcile sweeps every open PR, so cron and
|
|
||||||
# PR-event runs must serialize or two sweeps race the same PR's labels
|
|
||||||
# and both pass the request-the-human-once guard. GitHub keeps at most
|
|
||||||
# one queued run per group (older queued runs are superseded), which
|
|
||||||
# coalesces bursts instead of piling them up.
|
|
||||||
concurrency:
|
|
||||||
group: labels-reconcile
|
|
||||||
cancel-in-progress: false
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4 # base branch only — never the PR's code
|
|
||||||
- name: reconcile state + stale
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
REPO: ${{ github.repository }}
|
|
||||||
run: bash .github/scripts/labels-reconcile.sh
|
|
||||||
|
|
|
||||||
48
.github/workflows/release.yml
vendored
48
.github/workflows/release.yml
vendored
|
|
@ -1,42 +1,18 @@
|
||||||
name: release
|
name: release
|
||||||
# The release publisher (#83), on a bare X.Y.Z tag push (the 0.6.0 tag set
|
# Triggers and permissions MUST live here (a called workflow cannot define them):
|
||||||
# the precedent — no 'v' prefix). Two facts, then one act: the tag must name
|
|
||||||
# the tree's own VERSION (a mismatch fails loudly and creates NOTHING — a
|
|
||||||
# wrong release is worse than a missing one), and the release body is that
|
|
||||||
# version's CHANGELOG.md section (.github/scripts/release-notes.sh, shared
|
|
||||||
# with test/release.sh) — the curated prose, not the generated PR list. No
|
|
||||||
# assets are uploaded: for a pure-bash tree, GitHub's source tarball for the
|
|
||||||
# tag IS the package, and install.sh downloads exactly that.
|
|
||||||
on:
|
on:
|
||||||
|
# ONE push key, both filters — YAML maps are last-key-wins; a second sibling
|
||||||
|
# `push:` silently replaces the first and kills a door (rig's review catch).
|
||||||
push:
|
push:
|
||||||
# Every tag, not a shape filter (rig's precedent): a tag that mismatches
|
tags: ["**"] # every tag — a wrong tag must FAIL the assert loudly,
|
||||||
# VERSION — a habitual v0.7.0, a typo — must fail the assert LOUDLY
|
# never be skipped by a shape filter that didn't match
|
||||||
# below, not be silently skipped by a pattern that didn't match.
|
branches: [main]
|
||||||
tags: ["**"]
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write # gh release create
|
contents: write # tag ref create + release create + the bump push
|
||||||
|
pull-requests: write # decide's label read; the bump-fallback `gh pr create`
|
||||||
|
issues: write # --label on that fallback PR rides the issues API
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
runs-on: ubuntu-latest
|
uses: heavy-duty/ceremony/.github/workflows/release.yml@0.1.0
|
||||||
steps:
|
with:
|
||||||
- uses: actions/checkout@v4
|
version-source: file
|
||||||
- name: the tag must name the tree's VERSION
|
|
||||||
run: |
|
|
||||||
ver="$(cat VERSION)"
|
|
||||||
if [ "$GITHUB_REF_NAME" != "$ver" ]; then
|
|
||||||
echo "tag '$GITHUB_REF_NAME' does not match VERSION '$ver' — creating nothing." >&2
|
|
||||||
echo "A release is a PR, then a tag (#83): the release PR bumps VERSION and stamps the changelog; the tag goes on its MERGE commit. Delete this tag and re-tag the right commit." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
- name: release notes — the version's own CHANGELOG.md section
|
|
||||||
# release-notes.sh fails loudly on a missing/empty section, which
|
|
||||||
# fails the release here — before anything is created.
|
|
||||||
run: |
|
|
||||||
bash .github/scripts/release-notes.sh "$GITHUB_REF_NAME" > "$RUNNER_TEMP/notes.md"
|
|
||||||
cat "$RUNNER_TEMP/notes.md"
|
|
||||||
- name: create the release
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
run: gh release create "$GITHUB_REF_NAME" --verify-tag --title "$GITHUB_REF_NAME" --notes-file "$RUNNER_TEMP/notes.md"
|
|
||||||
|
|
|
||||||
7
AGENTS.md
Normal file
7
AGENTS.md
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
# AGENTS.md — start at .ceremony/
|
||||||
|
|
||||||
|
This repository is governed by
|
||||||
|
[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony). Read
|
||||||
|
`.ceremony/AGENTS.md` first — it routes you to your role file, vendored
|
||||||
|
beside it. Repo specifics (the review panel roster, the scope labels, what
|
||||||
|
a drill means here, code conventions) live in CONTRIBUTING.md.
|
||||||
560
CHANGELOG.md
560
CHANGELOG.md
|
|
@ -3,381 +3,163 @@
|
||||||
History before 0.5.0 lives in git and in [drill/RUNS.md](drill/RUNS.md),
|
History before 0.5.0 lives in git and in [drill/RUNS.md](drill/RUNS.md),
|
||||||
which records not just what changed but what each drill run proved.
|
which records not just what changed but what each drill run proved.
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- `box exec` preserves newlines and command argv across its login-user boundary (#169)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Release and repository governance now use the shared ceremony pinned at `0.1.0` (heavy-duty/ceremony#14)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `kimi-box` template — the Moonshot Kimi CLI agent seed (#158; rig#109's tenant)
|
||||||
|
|
||||||
|
## 0.9.0 — 2026-07-21
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `box import` stamps the trip, leaving the artifact's own mint stamp intact
|
||||||
|
(#131)
|
||||||
|
- A minted box records how it was minted, and `box info` reads it back (#103)
|
||||||
|
- A clone re-stamps its own provenance instead of inheriting its source's
|
||||||
|
(#103)
|
||||||
|
- `box info` grew a provenance block, blank on boxes that predate the stamp
|
||||||
|
(#103)
|
||||||
|
- Every fresh mint marks a `pristine` snapshot, before rig converges anything
|
||||||
|
(#104, heavy-duty/rig#62)
|
||||||
|
- A mint that converges a tenant role marks a `bootstrapped` snapshot (#130)
|
||||||
|
- CI refuses a release PR with no drill record at `drills/<version>.md`
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `state:needs-human` is set at handoff, not by the cron (#141)
|
||||||
|
- PR labels split into two axes: `state:*` (whose ball) and `blocker:*` (what
|
||||||
|
is in the way); `state:needs-rebase` is retired
|
||||||
|
- BREAKING: the tenant templates carry rig's family suffix — `claude` →
|
||||||
|
`claude-box`, `codex` → `codex-box`, `grok` → `grok-box`, `staging` →
|
||||||
|
`staging-box` (#123, heavy-duty/rig#76)
|
||||||
|
- Changelog entries are one line each, and the whole file now follows the rule
|
||||||
|
(#147)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- `test/release.sh` is green on the release ceremony's own tree
|
||||||
|
- `changelog-monotonic.sh` no longer lets a duplicate heading through when it
|
||||||
|
cannot see the base (#143)
|
||||||
|
- An unreadable check rollup no longer reads as "nothing is failing"
|
||||||
|
- `state:needs-human` no longer appears on PRs a human cannot merge (#136)
|
||||||
|
- CI's shellcheck sweep now lints `.github/scripts/*.sh` (#116)
|
||||||
|
- A PR can no longer delete or duplicate a shipped changelog section and stay
|
||||||
|
green (#122)
|
||||||
|
- An upgrade over a pre-0.7.0 flat `/opt/box` no longer skips host setup (#115)
|
||||||
|
- Host setup runs the version it just installed, not whatever `current` points
|
||||||
|
at (#115)
|
||||||
|
- The pre-0.7.0 migration says what it left behind, and how to keep or reap it
|
||||||
|
(#117)
|
||||||
|
- `teardown-host.sh` refuses a terminal-less run instead of aborting mute
|
||||||
|
(#113)
|
||||||
|
- `drill/wipe.sh` no longer carries #102's SIGPIPE shape, and the pin sweeps
|
||||||
|
the class (#107)
|
||||||
|
- The racing-reader sweep guards the class, not one spelling, and names
|
||||||
|
`incus config trust list` as a second writer (#124)
|
||||||
|
|
||||||
|
## 0.8.0 — 2026-07-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Merging the release PR is the release, and the release re-arms main itself
|
||||||
|
(#96)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- The release ceremony re-arms `CHANGELOG.md`, and CI refuses to let main sit
|
||||||
|
disarmed (#108, heavy-duty/rig#67)
|
||||||
|
- Ctrl-D at a confirmation prompt aborts out loud instead of exiting in
|
||||||
|
silence (#111)
|
||||||
|
- `box restore` asks before it destroys, in the row's own words rather than
|
||||||
|
`rm`'s (#105)
|
||||||
|
- `box-firewall` could hand a UFW host the no-UFW firewall, ~2% of the time
|
||||||
|
(#102)
|
||||||
|
- A missing firewall log now diagnoses itself (#102)
|
||||||
|
- `box grant` provisions an `incus-admin` member instead of refusing them
|
||||||
|
(#99)
|
||||||
|
|
||||||
## 0.7.0 — 2026-07-19
|
## 0.7.0 — 2026-07-19
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **The installer defaults to the latest release, and releases publish
|
- The installer defaults to the latest release, and releases publish
|
||||||
themselves** (#83) — `curl | bash` used to hand out whatever `main` was at
|
themselves (#83)
|
||||||
that second: the 0.6.0 release was a bookmark, not a package, and two
|
- `setup-host` auto-picks a free subnet — nested box-in-box with zero flags
|
||||||
operators "on 0.6.0" could be running different trees. `install.sh` now
|
(#80)
|
||||||
resolves the latest release tag by following GitHub's `releases/latest`
|
- `setup-host` refuses a claimed subnet, and `BOX_SUBNET` picks another (#80)
|
||||||
redirect (one HEAD request — no API, no token, no rate-limit pain) and
|
- `box doctor` knows the #80 signature: a gateway held as a local address, and
|
||||||
downloads that tag's tarball; a failed resolution refuses loudly, naming
|
duplicate connected routes for the uplink subnet
|
||||||
`BOX_REF` as the way out — it never hangs and never silently falls back to
|
- The `staging` template — a server-class, creds-free seed (#81)
|
||||||
`main`. A set `BOX_REF` is tried as a tag first, then as a branch, so one
|
- The `BOX_BOOTSTRAP_ROLE` template key, auto-run at mint (#81)
|
||||||
knob yields three channels: default = latest release, `BOX_REF=0.6.0` =
|
- The rig pin point: `RIG_REPO` / `RIG_REF` (#81)
|
||||||
pinned, `BOX_REF=main` = dev. A new `release.yml` (on a bare `X.Y.Z` tag
|
- Server-posture template keys `BOX_REQUIRE_VM` and `BOX_AUTOSTART` (#81)
|
||||||
push — the `0.6.0` tag set the no-`v` precedent) asserts the tag names the
|
- The template test suite discovers `templates/*/` instead of hardcoding the
|
||||||
tree's own `VERSION` (a mismatch fails loudly and creates nothing) and
|
list (#81)
|
||||||
publishes the GitHub release with that version's `CHANGELOG.md` section as
|
- `box export` / `box import` — a box's state that survives the box and the
|
||||||
the body (`.github/scripts/release-notes.sh` — the curated prose, not the
|
host (#70)
|
||||||
generated PR list; no assets, the source tarball for the tag IS the
|
- Versioned installs at `<root>/versions/<v>`, with `box versions` and
|
||||||
package). And `main`'s `VERSION` now carries `-dev` between releases
|
`box use` (#66)
|
||||||
(this PR: `0.6.1-dev`): the versioned layout names install trees after
|
- A real uninstall: `box uninstall [<version>] [--all] [--purge-host]`, ending
|
||||||
`VERSION`, so a `main` install without the bump would land in
|
in an absence assert
|
||||||
`versions/0.6.0` and impersonate the released tree. `test/release.sh`
|
- `BOX_INSTALL_SOURCE=<dir-or-tarball>` installs from a local tree, and CI's
|
||||||
drives all of it offline — the extraction against fixtures and the real
|
rehearsal drills the uninstall to zero residue
|
||||||
changelog, the resolution and every channel against a shim curl.
|
- `test/cli.sh` drives real installs against throwaway roots and a fake incus
|
||||||
- **`setup-host` auto-picks a free subnet — nested box-in-box with zero
|
(154 checks)
|
||||||
flags** (#80, completing its fix #1: "refuse … or automatically select a
|
|
||||||
non-colliding subnet"). A bare `box setup-host` now decides the subnet
|
|
||||||
itself, in four deliberate cases: an explicit `BOX_SUBNET` is honored or
|
|
||||||
refused, never silently overridden (scripted hosts keep exact semantics);
|
|
||||||
an existing `boxnet` bridge is converged on as-is — the bridge IS the pin —
|
|
||||||
turning the old bare-re-run agree-gate refusal into plain convergence
|
|
||||||
(unless a foreigner *also* claims the bridge's subnet: that is #80's
|
|
||||||
poisoned state, and converging would rebuild on it, so it still refuses and
|
|
||||||
names the bridge move); a free `10.88.0.0/24` stays the default; and a
|
|
||||||
*claimed* default — the nested case: a drill or rehearsal running inside a
|
|
||||||
box, whose own uplink owns 10.88 — scans `10.89.0.0/24` … `10.127.0.0/24`
|
|
||||||
in order, takes the first free candidate, announces the pick and the
|
|
||||||
claimant loudly, and only refuses when every candidate is claimed. The
|
|
||||||
decision happens before any mutation, and everything downstream (the
|
|
||||||
bridge, `BOX_GW`, the ACL's gateway carve-out, the firewall, the doctor's
|
|
||||||
expectations) derives from it.
|
|
||||||
- **`setup-host` refuses a claimed subnet, and `BOX_SUBNET` picks another**
|
|
||||||
(#80) — run inside a box, `setup-host` used to build a nested `boxnet` on
|
|
||||||
the exact subnet and gateway of the guest's own uplink: the guest then held
|
|
||||||
its gateway's address as a *local* address, carried duplicate connected
|
|
||||||
routes for its uplink subnet, and suffered intermittent, self-recovering
|
|
||||||
egress blackouts that looked like flaky internet (measured live: ~24–36 s
|
|
||||||
outages, roughly hourly, with the host clean throughout). `setup-host` now
|
|
||||||
scans the target subnet **before any mutation** — the default route's
|
|
||||||
gateway inside it, or any non-`boxnet` interface holding an address in it —
|
|
||||||
and refuses, naming the way out. A prior `boxnet` owning the subnet is the
|
|
||||||
legitimate converge path and does not trip it. `BOX_SUBNET=<a.b.c.0/24>`
|
|
||||||
(validated, alongside the existing `BOX_DNS`) moves the whole stack: the
|
|
||||||
bridge address, the ACL's gateway carve-out (now converged via
|
|
||||||
`network acl edit`, so a bridge moved off a colliding subnet no longer
|
|
||||||
strands box DNS behind a stale `/32`), the firewall (`box-firewall` reads
|
|
||||||
the gateway off the live bridge), and every drill/migrate probe that used
|
|
||||||
to hardcode `10.88`.
|
|
||||||
- **`box doctor` knows the #80 signature** — a default gateway held as a
|
|
||||||
LOCAL address, and duplicate connected routes for the uplink subnet, judged
|
|
||||||
from `ip route`/`ip addr` on the machine doctor runs on (both tiers, before
|
|
||||||
any daemon check — the nested daemon answering could be the impostor) and
|
|
||||||
probed *inside* every box it examines. The existing "egress broken but DNS
|
|
||||||
fine" split now names itself as #80's fingerprint (the impostor dnsmasq on
|
|
||||||
a captured gateway keeps resolving while IP egress dies), and the admin ACL
|
|
||||||
section verifies the gateway carve-out matches `boxnet`'s actual gateway.
|
|
||||||
The agent-context guard for the templates (suggested fix 4) lands in
|
|
||||||
heavy-duty/rig#31's bootstrap roles per the thin-templates split (#81).
|
|
||||||
|
|
||||||
- **The `staging` template** (#81, the re-cut of #69's layering) — a
|
|
||||||
server-class, creds-free seed: Debian 13, user `ops`, tmux, rig,
|
|
||||||
`BOX_REQUIRE_VM=1` (the VM is its trust boundary), `BOX_AUTOSTART=1` (a
|
|
||||||
server returns from a host reboot without an operator), and
|
|
||||||
`BOX_BOOTSTRAP_ROLE="staging"` — the server posture (docker, sshd
|
|
||||||
hardening) converges via `rig bootstrap staging` after mint. The tailnet
|
|
||||||
workload join holds a pre-auth key and therefore **stays operator-run**
|
|
||||||
(`box shell` → `sudo rig bootstrap workload`), printed as a next step —
|
|
||||||
box never sees the key.
|
|
||||||
- **`BOX_BOOTSTRAP_ROLE` template key + mint-time auto-run** (#81) — a
|
|
||||||
template names the **creds-free** rig tenant role box runs inside the guest
|
|
||||||
after cloud-init settles (`incus exec … rig bootstrap <role>`); the value
|
|
||||||
is a role *name* by allowlist (anything shell-shaped dies at parse time, on
|
|
||||||
the host). A failed role leaves the box up and names the re-run — the roles
|
|
||||||
are convergent by contract (rig#31). `blank` names no role and auto-runs
|
|
||||||
nothing.
|
|
||||||
- **The rig pin point: `RIG_REPO` / `RIG_REF`** (#81) — the tenant seeds
|
|
||||||
preinstall rig, inverting the rig→box install edge (rig#28), and the new
|
|
||||||
edge gets the same honest treatment rig#29 gave box's unpinned install:
|
|
||||||
`@RIG_REPO@`/`@RIG_REF@` tokens in the seed resolve at mint from the
|
|
||||||
environment (default `heavy-duty/rig` @ `main` — unpinned, tracking main,
|
|
||||||
until a release flow exists, rig#32/#83). The pin covers both the installer
|
|
||||||
fetched and the tree it installs, so a rig branch under review is testable
|
|
||||||
end to end; values are allowlist-validated before touching the YAML.
|
|
||||||
- **Server-posture template keys** (#81, carved from #69) — two optional
|
|
||||||
`box.env` allowlist keys. `BOX_REQUIRE_VM=1` refuses both the silent
|
|
||||||
container fallback (no `/dev/kvm`, exit 1) and an explicit `--container`
|
|
||||||
(exit 2): such a template's trust boundary is the VM. `BOX_AUTOSTART=1`
|
|
||||||
stamps `boot.autostart=true` at launch, per-instance like `limits.*`, so
|
|
||||||
the box returns from a host reboot without an operator; clones inherit it
|
|
||||||
via `incus copy`. Still no key for a network or a `security.*` flag, on
|
|
||||||
purpose.
|
|
||||||
- **Dynamic template test suite** (#81, carved from #69) — `test/cli.sh`
|
|
||||||
discovers `templates/*/` instead of hardcoding the list, so a new template
|
|
||||||
cannot ship unseen. Per template: `box.env` is driven through the real,
|
|
||||||
extracted `load_template` (unknown keys and missing `BOX_IMAGE`/`BOX_USER`
|
|
||||||
fail, fixtures proving both dies); `user-data.yaml` exists, declares
|
|
||||||
`#cloud-config`, parses as YAML, and installs tmux (#65). Grep guards pin
|
|
||||||
the `cmd_new` half: the `REQUIRE_VM` refusal orders after `pick_mode`, and
|
|
||||||
`boot.autostart` is stamped only under the `T_AUTOSTART` guard.
|
|
||||||
- **`box export` / `box import`** (#70) — a box's state that survives the box
|
|
||||||
_and_ the host, unblocking #66's humane upgrade flow (down, export, rm,
|
|
||||||
upgrade, re-import). `box export <box> [<file>]` wraps `incus export` into
|
|
||||||
one portable backup tarball (default `<box>-<UTC stamp>.tar.gz`), snapshots
|
|
||||||
included by default (`--instance-only` opts out); the box must be stopped
|
|
||||||
first (`box down`) so the artifact is a settled disk, not a moving one. The
|
|
||||||
file is **shouted about, not scrubbed** — it carries the box's whole disk
|
|
||||||
(agent logins, git credentials, SSH keys), and scrubbing a disk image is a
|
|
||||||
promise tarball surgery cannot keep, so box says what is inside instead,
|
|
||||||
every time. `box import <file> [--name <box>]` mints the box back and
|
|
||||||
re-stamps what is the _current host's_ truth, not the artifact's: the
|
|
||||||
`user.box=1` boundary tag (legacy `user.claudebox=1` honored), the
|
|
||||||
`box-net` placement (re-assigned if the artifact's differs — the
|
|
||||||
migrate-host move), and a fresh machine identity: the NIC's MAC (imports
|
|
||||||
restore `volatile.*` verbatim, and a re-import beside its sibling collided
|
|
||||||
at start with "MAC address already defined on another NIC" — measured
|
|
||||||
live; `incus copy` regenerates it, `incus import` does not) plus
|
|
||||||
`reset_identity` (the clone trust boundary: no DHCP collision with the box
|
|
||||||
it was exported from).
|
|
||||||
Import refuses any name an existing instance holds — the `resolve_box`
|
|
||||||
boundary, seen from the other side. Works on both tiers: `box grant` now
|
|
||||||
also converges `restricted.backups allow` (incus-user blocks backups by
|
|
||||||
default exactly like snapshots, and an export _is_ a backup
|
|
||||||
create+download — measured against incus 6.0's `permissions.go`); re-run
|
|
||||||
`box grant <user>` after upgrading, as documented. CI's `rehearsal` job now
|
|
||||||
proves the round-trip on a live Incus: mint → write a file → snapshot →
|
|
||||||
down → export → `rm` → import → the file and the snapshot survived, the
|
|
||||||
agent answers, the tag is present, and a colliding re-import is refused.
|
|
||||||
- **Versioned installs** (#66's stance, made livable) — install.sh now lands
|
|
||||||
each version side by side at `<root>/versions/<v>` (its own `VERSION` +
|
|
||||||
`INSTALLED_FROM`), with a `current` symlink tracking the default and
|
|
||||||
`$BINDIR/box` riding the chain, the way plenty of CLIs manage theirs. New
|
|
||||||
verbs: `box versions` (lists installs, marks the current default and the
|
|
||||||
running tree), `box use <version>` (flips the default, converges the PATH
|
|
||||||
symlinks, and *asserts the effective result* — `current` must resolve to
|
|
||||||
the asked-for version and the chain's `box --version` must answer it).
|
|
||||||
Re-running the installer with an installed version is a converging no-op
|
|
||||||
(`BOX_REINSTALL=1` replaces that version's tree); a **new** version installs
|
|
||||||
side-by-side and flips `current` only when no boxes exist — under existing
|
|
||||||
boxes the flip is refused loudly, naming the boxes (#66: never change
|
|
||||||
versions under a user's boxes; `box use` keeps the same refusal). A
|
|
||||||
pre-0.7.0 **flat tree is migrated in place** (two renames, the operator's
|
|
||||||
tree preserved bit for bit), so upgrading from 0.6.0 is seamless; a stale
|
|
||||||
or dangling `$BINDIR/box` is healed instead of wedging the install; and the
|
|
||||||
installer warns when the *other* tier's install (/opt/box vs ~/.local)
|
|
||||||
coexists, since PATH order decides which wins.
|
|
||||||
- **A real uninstall** — `box uninstall [<version>] [--all] [--purge-host]`
|
|
||||||
replaces the "rm -rf two paths" prose. One version: refuses the current one
|
|
||||||
(`box use` off it first). Everything: runs in the safe order — refuses
|
|
||||||
while boxes exist (naming them) unless `--purge-host` runs teardown-host
|
|
||||||
first — then removes every version, the `current` and PATH symlinks, and
|
|
||||||
the legacy claudebox crumbs (both name generations), and **ends with an
|
|
||||||
absence assert**: every removed path is re-checked, and any survivor makes
|
|
||||||
it exit 1 as `uninstall INCOMPLETE` naming the leftovers (the
|
|
||||||
`revoke --purge` discipline). `teardown-host.sh` gains `--yes`/`BOX_YES=1`
|
|
||||||
for automation and now points at `box uninstall` when done.
|
|
||||||
- **`BOX_INSTALL_SOURCE=<dir-or-tarball>`** — installs from a local tree,
|
|
||||||
bypassing the download. CI's rehearsal job now installs via install.sh
|
|
||||||
itself (proving the installer under review, not a `cp -r` mimic of it), and
|
|
||||||
ends with an **uninstall drill**: grant + `revoke --purge` a throwaway
|
|
||||||
user, `teardown-host`, `box uninstall --all`, then assert **zero residue**
|
|
||||||
— no networks, profiles, ACLs, nft tables, systemd units, files or
|
|
||||||
symlinks.
|
|
||||||
- **test/cli.sh drives real installs** — still dependency-free, non-root, no
|
|
||||||
daemon: `BOX_INSTALL_SOURCE` + throwaway `BOX_HOME`/`BOX_BIN` roots and a
|
|
||||||
fake `incus` on PATH (`$FAKE_BOXES`) turn layout, chain, no-op/converge,
|
|
||||||
reinstall, side-by-side upgrade, the three #66 refusals (install flip,
|
|
||||||
`use`, `uninstall` — boxes named), flat-tree migration, symlink healing,
|
|
||||||
single-version and zero-residue uninstalls, and the `INCOMPLETE` scream
|
|
||||||
into *driven* tests instead of greps (154 checks).
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **Thin templates — box mints, rig converges** (#81, companion rig#31) —
|
- Thin templates — box mints a creds-free seed, rig's bootstrap roles converge
|
||||||
the tenant content that lived in `claude`/`codex`/`grok`'s cloud-init (the
|
the tenant content (#81, heavy-duty/rig#31)
|
||||||
agent CLI installs, docker, node, the per-template agent-context heredocs)
|
|
||||||
**moves to rig's bootstrap roles**, where it is convergent, idempotent and
|
|
||||||
testable end to end instead of parse-only YAML. What remains per template
|
|
||||||
is a thin, creds-free seed: the tenant user, tmux (#65), and rig
|
|
||||||
preinstalled — nothing that joins a tailnet or admits credentials. The #80
|
|
||||||
agent-context guard ("never run `box setup-host` or the drill inside a
|
|
||||||
box") now lives once, in rig's roles, not copy-pasted per template. The
|
|
||||||
template test sweep grew the contract's teeth: per-template seed asserts
|
|
||||||
(user matches, rig pinned via both tokens) and fail-closed **absence
|
|
||||||
greps** over effective cloud-init lines — no agent CLI, no docker, no
|
|
||||||
tailscale/authkey/ssh, no `write_files` heredocs — so tenant content
|
|
||||||
cannot quietly grow back.
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **A wedged `incus launch` fails loudly, not forever — the mint's launch
|
- A wedged `incus launch` fails loudly, not forever: the launch phase is
|
||||||
phase is narrated and time-boxed** (#93) — twice in the 2026-07-19
|
narrated and time-boxed (#93)
|
||||||
release drill (Debian 13, Incus 6.x, /dev/kvm present, images cached),
|
- UFW's gateway carve-out converges with the bridge, and the doctor can see it
|
||||||
the child `incus launch` under `box new` hung with *no server-side
|
(#86)
|
||||||
operation*: `incus operation list` empty, the instance never created, the
|
- The boot-time gateway fallback is gone — an unaddressed bridge leaves the
|
||||||
daemon journal quiet — one wedge ran 56 minutes before being killed by
|
persisted UFW rules alone (#86)
|
||||||
hand, and an immediate retry of the identical command succeeded in
|
- `revoke --purge` re-checks the incus-user state, and stats it through
|
||||||
minutes, both times. `box new` inherited that as an indefinite silent
|
`$SUDO`
|
||||||
hang, indistinguishable from a cold mint working. It now prints
|
- A wedged `$BINDIR/box` no longer blocks installing
|
||||||
`launching instance …` before the call, and the call rides
|
|
||||||
`timeout -k 5 $BOX_LAUNCH_TIMEOUT` (seconds, default 600 — generous: the
|
|
||||||
coldest measured mint is minutes, never an hour; the same scripting-knob
|
|
||||||
shape as `BOX_CPU`/`BOX_MEMORY`), with stdin pinned per the drill's own
|
|
||||||
trap list. On the budget firing it probes whether the instance was ever
|
|
||||||
registered and tells the two stories apart — the measured #93 wedge (no
|
|
||||||
server-side operation; an immediate retry has been observed to succeed)
|
|
||||||
vs a slow launch that overran the budget with the instance already
|
|
||||||
created — then best-effort deletes either way, so the retry advice is
|
|
||||||
clean in both worlds, and points at `box doctor` for the host. The
|
|
||||||
`--from` clone path is untouched: `incus copy` of a local instance is a
|
|
||||||
different operation and has never been observed to wedge this way.
|
|
||||||
- **UFW's gateway carve-out converges with the bridge, and the doctor can
|
|
||||||
see it** (the #86 review's blind spot) — `box-firewall` gated its whole
|
|
||||||
UFW block behind "a `DENY on boxnet` rule exists", pinning every UFW host
|
|
||||||
to the gateway of the *first* run: a bridge remapped off a colliding
|
|
||||||
subnet (#80's escape hatch) kept its stale `allow … to <old-gw> port 53`
|
|
||||||
and never gained the live gateway's, so box→gateway DNS died at box's own
|
|
||||||
deny — while the doctor's carve-out check read only the incus ACL (which
|
|
||||||
setup-host converges) and called the host clean. The UFW allows now
|
|
||||||
converge off the live bridge address on every run (stale DNS allows
|
|
||||||
deleted, the live set ensured — ufw skips existing rules, so a fresh host
|
|
||||||
gets the identical rule set and a re-run is a no-op), and `box doctor`
|
|
||||||
reads UFW's own table wherever UFW is active, flagging a DNS allow that
|
|
||||||
does not match `boxnet`'s gateway (and stale allows left beside a live
|
|
||||||
one). The no-UFW nft carve-out never had this failure mode: it is
|
|
||||||
interface-scoped, no gateway address to go stale.
|
|
||||||
- **The boot-time gateway fallback is gone — no rule beats a wrong one** —
|
|
||||||
with the bridge not yet addressed when `box-firewall.service` ran,
|
|
||||||
`box-firewall` guessed `GW=10.88.0.1`; on a `BOX_SUBNET` host that hit
|
|
||||||
that window the UFW carve-out was built for the wrong gateway, a latent
|
|
||||||
DNS drop (#86 review). It now fails closed: an unaddressed bridge leaves
|
|
||||||
the persisted UFW rules exactly as they are (they survive boots on their
|
|
||||||
own, and nothing else in the script needs the gateway) and says so on
|
|
||||||
stderr; the next setup-host run or service restart converges them once
|
|
||||||
the bridge is addressed.
|
|
||||||
- **`revoke --purge` re-checks the incus-user state** — the purge removed
|
|
||||||
`/var/lib/incus/users/<uid>` without ever asserting its absence, the one
|
|
||||||
path its own absence block did not cover; and the stat now rides
|
|
||||||
`$SUDO test -d` (`/var/lib/incus` is not traversable by a non-root admin,
|
|
||||||
so a bare `[ -d ]` answered "absent" for a directory that was there).
|
|
||||||
- **A wedged `$BINDIR/box` no longer blocks installing** — the old
|
|
||||||
no-op-if-installed check keyed off the symlink's existence OR the tree's,
|
|
||||||
so a stale symlink (or a half-removed tree) could fake "already installed"
|
|
||||||
forever. Installed-ness is now judged from `versions/<v>` itself; symlinks
|
|
||||||
are converged with `ln -sfn`, never trusted as the signal.
|
|
||||||
|
|
||||||
## 0.6.0 — 2026-07-18
|
## 0.6.0 — 2026-07-18
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **The restricted tier: multi-user hosts** (#74, redesigning #72) — an admin
|
- The restricted tier: `box grant` / `box revoke` give a user their own boxes
|
||||||
runs `box grant <user>` and that user gets their own boxes on the same
|
on the shared hardened `boxnet` (#74)
|
||||||
hardened `boxnet`, seeing nobody else's; `box revoke <user>` takes it back
|
- CI runs the multi-user rehearsal on a real Incus
|
||||||
(`--purge` deletes their world, and asserts the absence). The tier rides
|
- Global / root install — one world-readable tree at `/opt/box` (#71)
|
||||||
incus-user, whose defaults miss box's contract three measured ways (Debian
|
- CI and a test suite: `.github/workflows/ci.yml` and `test/cli.sh`
|
||||||
13 / Incus 6.0.4): a private _unhardened_ NAT bridge per user, snapshots
|
|
||||||
blocked, the `box-net` profile invisible — so grant is an idempotent
|
|
||||||
convergence: project narrowed to `boxnet` **and only boxnet** (listing the
|
|
||||||
private bridge too, the obvious fix, would keep an unhardened network one
|
|
||||||
`--network` flag away), snapshots allowed, the shipped profile installed
|
|
||||||
into their project. `box_tier()` (live credentials, argless `id -nG`)
|
|
||||||
drives the tier-aware surface: `expose` refuses honestly before any daemon
|
|
||||||
call, `setup-host` and `doctor` answer at the caller's tier. Rehearsed
|
|
||||||
end-to-end by `drill/multiuser.sh` (criteria a–n: confinement, lifecycle,
|
|
||||||
cross-user visibility, name collisions, the in-box isolation contract,
|
|
||||||
escape hatches, re-sync survival, revoke incl. the live-session case) —
|
|
||||||
54/54 on the design host (container and VM mode), including the raw-attach scoped-guarantee measurement and both grant-failure injections demanded by #75's review.
|
|
||||||
- **CI runs the multi-user rehearsal on a real Incus** — a second `rehearsal`
|
|
||||||
job stands up the full stack on the runner (setup-host, doctor, then
|
|
||||||
`multiuser.sh --container`), so every PR proves the tier's semantics
|
|
||||||
against a live daemon, not a mock. The VM trust boundary itself remains a
|
|
||||||
real-hardware ritual, like the full drill.
|
|
||||||
- **Global / root install** (#71) — run as root, box installs _once_ to
|
|
||||||
`/opt/box` (world-readable) with the `box` symlink on `/usr/local/bin`, so
|
|
||||||
every operator on a shared host runs the same tree. Per-user installs are
|
|
||||||
unchanged (`$HOME/.local`); `BOX_HOME`/`BOX_BIN` still override. A per-user
|
|
||||||
tree under `/root` is `0700` and unreadable to everyone else — the whole fleet
|
|
||||||
got `command not found` — so the root branch lands in a system location and
|
|
||||||
`chmod -R a+rX`'s it (read for files, +search on dirs), guarded on root. This
|
|
||||||
unblocks "rig installs box" (rig#24's `box` role).
|
|
||||||
- **CI + a test suite** — `.github/workflows/ci.yml` (a `check` job: globstar
|
|
||||||
`shellcheck -x` over `bin/* **/*.sh`, then `bash test/cli.sh`) and `test/cli.sh`,
|
|
||||||
dependency-free and runnable by a non-root user with no Incus. It exercises the
|
|
||||||
`install.sh` DEST/BINDIR branch functionally (both tiers + `BOX_HOME`/`BOX_BIN`
|
|
||||||
overrides), the CLI contract, and grep-guards the daemon-gated invariants and
|
|
||||||
tmux in every template — the box was the repo with "no tests and no CI".
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **`box restore` never worked against Incus 6** — the command table
|
- `box restore` never worked against Incus 6 — it dispatched `incus restore`,
|
||||||
dispatched `incus restore`, a subcommand that does not exist (Incus 6
|
which does not exist
|
||||||
spells it `incus snapshot restore`), so every restore died on "unknown
|
- `box tmux` works on every template — tmux is in each template's package list
|
||||||
command". Found by #74's rehearsal exercising the full lifecycle as a
|
(#65)
|
||||||
restricted user; fixed for every tier, and the rehearsal + a grep-guard in
|
- `box setup-host` finishes in one run, re-execing itself under
|
||||||
`test/cli.sh` now hold it.
|
`sg incus-admin` (#63)
|
||||||
- **`box tmux` works on every template** (#65) — `box tmux` runs
|
- `setup-host` works as root, with or without `sudo`
|
||||||
`tmux new-session` _inside_ the box, but the templates did not install tmux, so
|
- `setup-host` grants `incus-admin` to the human, not to root
|
||||||
it failed with `tmux: command not found`. `tmux` is now in each template's
|
- `box-firewall.service` reports its state honestly, via `RemainAfterExit=yes`
|
||||||
cloud-init package list (`blank`/`claude`/`codex`/`grok`).
|
- `setup-host`'s apt calls can no longer hang on the dpkg lock
|
||||||
|
|
||||||
- **`box setup-host` finishes in one run** (#63). When it had to add you to
|
|
||||||
`incus-admin` it stopped there and told you to re-login and re-run — an
|
|
||||||
`exit 0` that reported success having built none of the stack: no `boxnet`,
|
|
||||||
no ACL, no `box-net` profile, no firewall. It now re-execs itself under
|
|
||||||
`sg incus-admin` and completes in that one invocation. The membership check
|
|
||||||
was also asking the wrong question: `id -nG "$USER"` reads the group
|
|
||||||
database, which lists the group the moment `usermod` returns, so a
|
|
||||||
same-session re-run passed the check with credentials that still lacked the
|
|
||||||
group and died further down on a bare permission error from `incus`. Argless
|
|
||||||
`id -nG` asks the process what it actually holds.
|
|
||||||
|
|
||||||
- **`setup-host` works as root, with or without `sudo`** — every privileged
|
|
||||||
call was a hardcoded `sudo`, so on a minimal root image (no `sudo` package)
|
|
||||||
it died on `sudo: command not found` before doing anything. Privilege is now
|
|
||||||
resolved once: nothing at UID 0, `sudo` otherwise, and a clear error if
|
|
||||||
neither is possible. This is what made `install.sh`'s root path real rather
|
|
||||||
than nominal.
|
|
||||||
- **`setup-host` grants `incus-admin` to the human, not to root** — under
|
|
||||||
`sudo install.sh` it would have added `root` to the group: a no-op (UID 0
|
|
||||||
opens the socket regardless) that also left the actual user locked out of
|
|
||||||
their own boxes. It now derives the login user from `SUDO_USER`.
|
|
||||||
- **`box-firewall.service` now reports its state honestly** — the unit is
|
|
||||||
`Type=oneshot` and was missing `RemainAfterExit=yes`, so it went
|
|
||||||
`inactive (dead)` the instant it succeeded: a host whose isolation was
|
|
||||||
perfectly live read as one whose firewall unit had died. drill.sh sends you
|
|
||||||
to `systemctl status box-firewall` to diagnose exactly that, and
|
|
||||||
setup-host.sh's own comment already asserted the unit "is RemainAfterExit" —
|
|
||||||
it was not. Found by running the drill on a real host and mistrusting the
|
|
||||||
green: `nft list table bridge box` showed the drop live while the unit read
|
|
||||||
dead. `restart` was and remains correct either way.
|
|
||||||
- **`setup-host`'s apt calls can no longer hang** — a fresh cloud image has
|
|
||||||
`apt-daily`/`unattended-upgrades` holding the dpkg lock, and a plain
|
|
||||||
`apt-get install` waits on it silently and indefinitely. Now bounded
|
|
||||||
(`DPkg::Lock::Timeout=300`) and non-interactive, which matters because
|
|
||||||
`install.sh` runs it with nobody watching.
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **`drill.sh` proves the new contract instead of masking it** — the drill ran
|
- `drill.sh` asserts the post-install stack instead of building it itself
|
||||||
`setup-host` itself right after installing, so the stack existed by its own
|
- `install.sh` asks, sets up the host, and no-ops on re-run (#64)
|
||||||
hand and a run passed identically whether or not `install.sh` had done a
|
- `install.sh` never overwrites an existing install
|
||||||
thing; a fresh run converged the stack three times, while the messages still
|
|
||||||
described the pre-#63 "first pass may only add you to the group" behaviour.
|
|
||||||
It now asserts the post-install stack in-group before touching the host, and
|
|
||||||
runs `setup-host` exactly once more — after the clean, which deliberately
|
|
||||||
unsets `dns.mode` and so has to be converged back. `DRILL_OWNS_SETUP=1`
|
|
||||||
hands sequencing back to the drill. Pre-setup tripwires now read _before_
|
|
||||||
`install.sh`, since that is what triggers setup now.
|
|
||||||
- **`install.sh` asks, sets up the host, and no-ops on re-run** (#64) — it now
|
|
||||||
prompts _"Install box?"_, then on a fresh host installs the tree and asks a
|
|
||||||
second question, _"Set up this machine as a box host now?"_, running the whole
|
|
||||||
isolation stack if you say yes (previously it only printed a warning and left
|
|
||||||
you a command, so the install reported success and `box new` died on a host
|
|
||||||
with no Incus). Prompts read `/dev/tty`, since under `curl | bash` the script
|
|
||||||
itself is stdin; `BOX_YES=1` answers yes unattended (required where there is
|
|
||||||
no terminal), `BOX_SKIP_SETUP_HOST=1` declines the host-setup step.
|
|
||||||
- **`install.sh` never overwrites an existing install** — if box is already
|
|
||||||
installed it says so and changes nothing, so a stray re-run can no longer
|
|
||||||
clobber a working tree or rebuild the host stack under live boxes. Upgrading
|
|
||||||
is explicit: uninstall (`rm -rf ~/.local/share/box ~/.local/bin/box`, boxes
|
|
||||||
preserved first) and install fresh. This replaces the earlier version-diff
|
|
||||||
refusal with a simpler rule that dissolves the same class of errors. The
|
|
||||||
version-aware upgrade that migrates boxes instead is #67; a portable
|
|
||||||
`box export` so a box survives its own deletion is #70.
|
|
||||||
|
|
||||||
## 0.5.0 — 2026-07-15
|
## 0.5.0 — 2026-07-15
|
||||||
|
|
||||||
|
|
@ -388,61 +170,27 @@ installer retires — but nothing current carries the old name.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **`codex` and `grok` templates** — OpenAI Codex CLI and xAI Grok CLI boxes,
|
- `codex` and `grok` templates
|
||||||
creds-free like every template. The template mechanic (image + user +
|
- `box expose <box> <port> [<host-port>]` — a loopback-only door to a port
|
||||||
resources, never a network or a `security.*` key) now has three tenants
|
inside a box
|
||||||
beside `blank`, and the drill mints all of them cold.
|
- Inline resource overrides on `new`: `--cpu`, `--memory`, `--disk` (#57)
|
||||||
- **`box expose <box> <port> [<host-port>]`** — a deliberate, loopback-only
|
- Host lifecycle as verbs: `box setup-host`, `box teardown-host`,
|
||||||
door to a port inside a box, for seeing a dev server in your browser. The
|
`box migrate-host`
|
||||||
listen side is always the host's `127.0.0.1` (no flag to widen it), the door
|
- The `.box/` recipe convention, renamed from `.claudebox/` (both spellings
|
||||||
is per-port, `--list`/`--remove` manage it, and `box info` shows open
|
read)
|
||||||
exposures — a box with a hole says so.
|
|
||||||
- **Inline resource overrides on `new`** — `--cpu <n> --memory <size>
|
|
||||||
--disk <size>` (#57). Resolution most-specific-first: flag > `BOX_CPU` /
|
|
||||||
`BOX_MEMORY` / `BOX_DISK` environment (the scripting form) > template
|
|
||||||
`box.env` > defaults. Values pass to Incus verbatim; resources are all a
|
|
||||||
flag can touch. `--from` refuses them — a clone carries its source's
|
|
||||||
resources.
|
|
||||||
- **Host lifecycle as verbs** — `box setup-host`, `box teardown-host`, and
|
|
||||||
`box migrate-host`, which re-homes pre-0.4.0 boxes onto the current stack
|
|
||||||
(`--box <n>` / `--all-boxes`, authed state preserved) and retires the legacy
|
|
||||||
bridge once empty (`--retire-legacy`).
|
|
||||||
- **The `.box/` recipe convention** — the agent-facing runbook folder a repo
|
|
||||||
can ship, renamed from `.claudebox/` (both spellings read).
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **VM mints no longer hang at GRUB** — Incus defaults VMs to Secure Boot on,
|
- VM mints no longer hang at GRUB — boxes launch with
|
||||||
and a cloud image whose shim the host's OVMF doesn't trust dies with "bad
|
`security.secureboot=false`
|
||||||
shim signature" forever. Boxes now launch with `security.secureboot=false`;
|
- `box expose` actually delivers packets
|
||||||
the VM boundary, not boot attestation, is the box threat model.
|
- Firewall rules converge on upgrade instead of pinning a host to the release
|
||||||
- **`box expose` actually delivers packets** — a trilogy of drill-found
|
that first ran there
|
||||||
absences: the NAT proxy needs the box's boxnet lease pinned as a static
|
- Failed mints tell you why
|
||||||
`ipv4.address` (Incus resolves `connect=0.0.0.0` against device config, not
|
- `grok` installs the binary it actually ships
|
||||||
the lease); a loopback-sourced packet needs `route_localnet` plus a
|
|
||||||
masquerade on the bridge to leave the host and be answerable; and the box's
|
|
||||||
replies need a `ct state established,related` accept ahead of the host
|
|
||||||
firewall's input drop, which was eating them statelessly. Boxes still
|
|
||||||
cannot initiate toward the host — a box-originated SYN is a NEW flow.
|
|
||||||
- **Firewall rules now converge on upgrade** — `box-firewall.sh` rebuilds its
|
|
||||||
chains every run (add + flush + re-add) instead of skipping when they
|
|
||||||
exist, which had pinned every host to the rule set of the release that
|
|
||||||
first ran there.
|
|
||||||
- **Failed mints tell you why** — cloud-init failures print the box's own log
|
|
||||||
excerpts and leave the box up to inspect; a mint that never boots names the
|
|
||||||
likely cause (corrupt image, Secure Boot, GRUB hang) and ships a sanitized
|
|
||||||
console dump; the installer asserts it landed the ref it was asked for.
|
|
||||||
- **`grok` installs the binary it actually ships** — the installer was read,
|
|
||||||
not guessed at, and the CLI lands on the non-interactive PATH (same fix
|
|
||||||
class as codex).
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **Debrand complete** — env vars, install dir, docs, template descriptions
|
- Debrand complete — env vars, install dir, docs, template descriptions and
|
||||||
and the README all say `box`; the install URL is
|
the README all say `box`; the install URL is `heavy-duty/box`
|
||||||
`heavy-duty/box` (GitHub redirects the old one, `BOX_REPO` overrides).
|
- The drill grew from 47 to 84 checks
|
||||||
- **The drill grew from 47 to 84 checks** — the expose door opened, exercised
|
|
||||||
and shut (with the contract re-probed around it), every template minted
|
|
||||||
cold, a faithful pre-0.4.0 box re-homed through `migrate-host`, and the
|
|
||||||
inline resource flags asserted (including their precedence over the
|
|
||||||
environment).
|
|
||||||
|
|
|
||||||
144
CONTRIBUTING.md
144
CONTRIBUTING.md
|
|
@ -1,87 +1,83 @@
|
||||||
# Contributing
|
# Contributing
|
||||||
|
|
||||||
How change lands in this repo. The short version: PRs are born as drafts,
|
This repository is governed by
|
||||||
three reviewer bots take the first rounds, a human takes the last word — and
|
[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony). Agents read
|
||||||
labels tell you where everything is without opening anything.
|
[`.ceremony/AGENTS.md`](.ceremony/AGENTS.md) first, then the role file it
|
||||||
|
selects. The files under `.ceremony/` are machine-managed and must never be
|
||||||
|
edited in place.
|
||||||
|
|
||||||
## The PR loop
|
Only triage mints issues. Everyone else opens or extends a discussion when
|
||||||
|
they find work outside an existing issue contract. Only humans merge.
|
||||||
|
|
||||||
1. **Fork and branch.** Contributors work from forks; upstream branches are
|
## Review panel
|
||||||
for maintainers. Title the PR conventionally (`feat:`, `fix:`, `docs:`),
|
|
||||||
and include a `CHANGELOG.md` entry under `## Unreleased` when the change
|
The review panel is:
|
||||||
deserves one.
|
|
||||||
2. **Open as a draft** while you build. Drafts are invisible to the reviewer
|
- `claude-bot-andresmgsl`
|
||||||
bots on purpose.
|
- `codex-bot-andresmgsl`
|
||||||
3. **When it's ready**: mark ready-for-review and request all three bots —
|
- `grok-bot-andresmgsl`
|
||||||
`claude-bot-andresmgsl`, `codex-bot-andresmgsl`, `grok-bot-andresmgsl`.
|
- `kimi-bot-andresmgsl`
|
||||||
They poll roughly every 15 minutes.
|
|
||||||
4. **Rounds are answered whole.** Wait until all three have reviewed, then
|
Every PR needs a current-head verdict from the whole panel minus its author.
|
||||||
answer the entire round in a **single reply**, push the fixes, and
|
`dan-claude-bot` is triage-only and is never a reviewer. Draft PRs remain
|
||||||
re-request the bots that didn't approve. Prefer verification over
|
invisible to the panel; when ready, request every eligible reviewer.
|
||||||
argument: a test settles what a comment thread can't.
|
|
||||||
5. **Reviews end in a verdict.** A reviewer — bot or human — either
|
## Code and verification
|
||||||
**approves** or **requests changes**, never a bare comment. A
|
|
||||||
comment-only review is a non-verdict: it doesn't say whether the round
|
- Bash executables use `set -euo pipefail`; test harnesses use `set -u`
|
||||||
passed, and the state machine (and anyone scanning the board) has to
|
because they assert failing commands.
|
||||||
guess. The verdict carries *blockingness only*, the body carries the
|
- Keep shellcheck clean. Run `bash test/cli.sh` and `bash test/release.sh`;
|
||||||
feedback: non-blocking nits ride an **approval** and the author addresses
|
CI also runs the Incus multi-user rehearsal.
|
||||||
them at their discretion; anything blocking — including a question that
|
- Match whole versions: `0.7.0` must never match `0.7.0-rc1`.
|
||||||
gates the verdict — is **request changes**, saying what unblocks it. The
|
- Comments preserve the incident that bought a rule, including its issue
|
||||||
reconciler treats a comment-only review as not-approved, so commenting
|
number.
|
||||||
without a verdict only stalls the PR. The machine never reads review
|
|
||||||
bodies: when a comment-only reviewer's line is really an agreement, that
|
## Changelog
|
||||||
judgment belongs to the **author** — escalate by requesting the
|
|
||||||
maintainer's review (step 6), and the reconciler flips the label on that
|
Every behavior-changing PR adds one concise line under `## Unreleased`,
|
||||||
request, because an explicit request is a fact it can trust.
|
above the shipped heading below it. Cite the issue or PR. Never replace or
|
||||||
6. **When the round passes, the author hands the PR to the maintainer** by
|
duplicate a shipped heading; the shared armed and monotonic guards enforce
|
||||||
requesting their review — that request is what flips `state:needs-human`.
|
both halves of this rule.
|
||||||
With three formal head-current approvals the labels workflow requests it
|
|
||||||
automatically; when part of the panel is comment-only, reading their
|
|
||||||
agreement is the author's judgment, so the author makes the request.
|
|
||||||
7. **Checks must be green**: `shellcheck` and `bash test/cli.sh` locally
|
|
||||||
mirror what CI runs; the multi-user rehearsal runs in CI on a real Incus.
|
|
||||||
|
|
||||||
## Releases
|
## Releases
|
||||||
|
|
||||||
A release is a PR, then a tag ([#83](https://github.com/heavy-duty/box/issues/83)):
|
The release ceremony, merge and tag doors, version stamps, guard semantics,
|
||||||
|
and recovery paths are defined by
|
||||||
|
[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony/blob/0.1.0/README.md).
|
||||||
|
Box pins the shared machinery and doctrine at `0.1.0`.
|
||||||
|
|
||||||
1. **The release PR** — `release: X.Y.Z`, labeled `release` — bumps `VERSION`
|
Box uses the `file` version backend and has no artifact hook: for this
|
||||||
from `X.Y.Z-dev` and stamps the `## Unreleased` section with version +
|
pure-Bash tree, GitHub’s source tarball for the tag is the package, and
|
||||||
date (feature PRs land their changelog entry as part of the PR, so the
|
`install.sh` downloads exactly that. `VERSION`, `CHANGELOG.md`, and
|
||||||
section is already written). This PR is where the release ritual hangs:
|
`drills/<version>.md` remain box-owned release inputs.
|
||||||
the full drill on real hardware, recorded in
|
|
||||||
[drill/RUNS.md](drill/RUNS.md) — CI proves the tier's semantics on every
|
|
||||||
PR, a release still proves the boundary.
|
|
||||||
2. **Merge, then tag the merge commit** bare `X.Y.Z` — no `v` prefix, the
|
|
||||||
`0.6.0` tag set the precedent — and push the tag.
|
|
||||||
[release.yml](.github/workflows/release.yml) takes it from there: it
|
|
||||||
asserts the tag names the tree's own `VERSION` (a mismatch fails loudly
|
|
||||||
and creates nothing) and publishes the GitHub release with that version's
|
|
||||||
`CHANGELOG.md` section as the body. No assets — the source tarball for
|
|
||||||
the tag is the package, and `install.sh` downloads exactly that.
|
|
||||||
3. **Immediately after: bump `main`'s `VERSION` to `X.Y.(Z+1)-dev`.** Not
|
|
||||||
cosmetic — the versioned layout names install trees after `VERSION`, so a
|
|
||||||
`main` install without the bump would land in `versions/X.Y.Z` and
|
|
||||||
impersonate the release you just cut.
|
|
||||||
|
|
||||||
## Labels — who sets what
|
### What a box drill proves
|
||||||
|
|
||||||
The full taxonomy lives in [LABELS.md](LABELS.md). What matters day to day is
|
The box drill is the 85-probe VM isolation contract: it exercises the trust
|
||||||
who sets each kind — most of it is machinery, and hand-moving a
|
boundary on real hardware. The lighter Incus container rehearsal in CI proves
|
||||||
machine-owned label just gets corrected on the next pass:
|
the tier mechanics but cannot substitute for that boundary measurement. The
|
||||||
|
record format and operating procedure live in [drills/README.md](drills/README.md).
|
||||||
|
|
||||||
| Labels | Set by |
|
`drills/<version>.md` and [`drill/RUNS.md`](drill/RUNS.md) are deliberately
|
||||||
|---|---|
|
different artifacts. The former is per-release evidence read by the release
|
||||||
| `state:*` | the labels workflow ([.github/workflows/labels.yml](.github/workflows/labels.yml)) — recomputed from GitHub's own facts every 15 minutes and on PR events. Never by hand. |
|
guard; the latter is the harness’s ongoing run log and lore. Updating one
|
||||||
| `stale` | the same workflow — 48h without commits, comments, or reviews. `blocked` PRs are exempt: they are quiet legitimately. |
|
never satisfies the purpose of the other.
|
||||||
| `scope:*` on PRs | actions/labeler, from the changed paths ([.github/labeler.yml](.github/labeler.yml)). Additive — you may add more, the machine won't remove them. |
|
|
||||||
| `scope:*` on issues | you, when opening or triaging — issues have no paths to derive from. |
|
|
||||||
| `blocked`, `release` | you — automation never guesses intent. |
|
|
||||||
| `bug` / `enhancement` / `documentation` | you, on issues only — a PR's type already lives in its title. |
|
|
||||||
|
|
||||||
## Issues
|
The family drills are independent and may run in any order. Each pins the
|
||||||
|
same fixed candidate refs: rig’s drill uses the candidate box ref, while
|
||||||
|
box’s drill mints with the candidate rig ref. Static refs dissolve the
|
||||||
|
box↔rig runtime recursion; no repository needs to release first.
|
||||||
|
|
||||||
Give issues the same care as PR titles: say the surface in the title, apply a
|
A known gap remains from box#81: released box templates still default
|
||||||
`scope:` label and a type label (`bug` / `enhancement` / `documentation`) when
|
`RIG_REF` to `main`, so a later mint may consume a rig revision other than
|
||||||
you open one, and `blocked` when it waits on something — that is what keeps
|
the one drilled. This conversion does not change that behavior or claim the
|
||||||
the board navigable as the issue count grows.
|
gap is closed.
|
||||||
|
|
||||||
|
## Scope labels
|
||||||
|
|
||||||
|
- `scope:cli` — `bin/box`, the command surface
|
||||||
|
- `scope:installer` — `install.sh`, versioned installs, upgrade/uninstall
|
||||||
|
- `scope:host` — host setup, teardown, firewall, and isolation stack
|
||||||
|
- `scope:tiers` — grant/revoke and multi-user boundaries
|
||||||
|
- `scope:templates` — template and profile seeds
|
||||||
|
- `scope:drill` — rehearsals, doctor, and run evidence
|
||||||
|
|
|
||||||
87
LABELS.md
87
LABELS.md
|
|
@ -1,87 +0,0 @@
|
||||||
# Labels
|
|
||||||
|
|
||||||
How this repo uses GitHub labels. The taxonomy is shared across the
|
|
||||||
heavy-duty repos (box, rig, cast) — only the `scope:` set differs per repo,
|
|
||||||
because it names this repo's actual surfaces.
|
|
||||||
|
|
||||||
## State — who is the ball with? (PRs, exactly one)
|
|
||||||
|
|
||||||
Every open PR carries exactly one `state:` label, and it answers the only
|
|
||||||
question a board scan actually asks: *who is this PR waiting on?* The states
|
|
||||||
mirror the review loop this repo runs — PRs open as drafts, three reviewer
|
|
||||||
bots pick up ready PRs with reviews requested, each round is answered in a
|
|
||||||
single reply, and a human takes the final review.
|
|
||||||
|
|
||||||
| Label | Color | Waiting on | Enters when | Leaves when |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `state:building` | `#FBCA04` | the coding agent, still building | PR opened as draft | marked ready + bot reviews requested |
|
|
||||||
| `state:bots-reviewing` | `#1D76DB` | the reviewer bots to finish the round | ready with reviews requested, or fixes pushed and reviews re-requested | all three bots have reviewed the round |
|
|
||||||
| `state:addressing` | `#D93F0B` | the coding agent to reply and push fixes | all bots reviewed the round, not all approved | the single round-reply is posted and fixes pushed |
|
|
||||||
| `state:needs-human` | `#8250DF` | the human reviewer | the human review is requested — by the author when the round passes, or automatically on three formal head-current approvals | merged — or changes requested, which cycles back to `state:addressing` |
|
|
||||||
|
|
||||||
`bots-reviewing` and `addressing` are deliberately distinct: staleness in the
|
|
||||||
first means *poke the bots*, staleness in the second means *the agent dropped
|
|
||||||
the ball*. Collapsing them loses exactly the information a sweep needs.
|
|
||||||
|
|
||||||
## Cross-cutting (PRs and issues)
|
|
||||||
|
|
||||||
| Label | Color | Meaning |
|
|
||||||
|---|---|---|
|
|
||||||
| `stale` | `#B60205` | No activity for 48h. Sweep-managed, never hand-applied. `state:building` + `stale` is precisely a forgotten draft. |
|
|
||||||
| `blocked` | `#6A737D` | Waiting on another PR or issue to land first. Quiet *legitimately* — the staleness sweep skips it. |
|
|
||||||
| `release` | `#0E8A16` | Release flow, versioning, and packaging work. |
|
|
||||||
|
|
||||||
## Scope — which surface? (PRs and issues, any number)
|
|
||||||
|
|
||||||
All scopes share one calm color, `#C5DEF5` — scopes locate, states alert.
|
|
||||||
|
|
||||||
| Label | Covers |
|
|
||||||
|---|---|
|
|
||||||
| `scope:cli` | `bin/box` — the command surface itself |
|
|
||||||
| `scope:installer` | `install.sh`, the versioned install layout, upgrade/uninstall |
|
|
||||||
| `scope:host` | `host/` — setup-host, teardown, the firewall and isolation stack |
|
|
||||||
| `scope:tiers` | the restricted tier — grant/revoke, multi-user semantics |
|
|
||||||
| `scope:templates` | `templates/` — the box seeds |
|
|
||||||
| `scope:drill` | `drill/` — the rehearsals, doctor, RUNS.md |
|
|
||||||
|
|
||||||
## Issue types
|
|
||||||
|
|
||||||
`bug`, `enhancement`, `documentation` — issues only. PRs carry their type in
|
|
||||||
the conventional title (`feat:`, `fix:`, `docs:`), so typing a PR with a label
|
|
||||||
would just say the same thing twice, drifting apart eventually.
|
|
||||||
|
|
||||||
## Maintenance
|
|
||||||
|
|
||||||
State labels are written by automation, never by hand. Every state above is
|
|
||||||
derivable from GitHub's own facts — the draft flag, requested reviewers,
|
|
||||||
review states, push timestamps — so the labels workflow
|
|
||||||
([.github/workflows/labels.yml](.github/workflows/labels.yml)) recomputes the
|
|
||||||
state and reconciles labels statelessly, on a 15-minute cron plus PR events.
|
|
||||||
A hand-moved label is a lie waiting to happen; the workflow asserts the
|
|
||||||
effective state instead. `scope:` labels on PRs are applied from the changed
|
|
||||||
paths by actions/labeler ([.github/labeler.yml](.github/labeler.yml));
|
|
||||||
[CONTRIBUTING.md](CONTRIBUTING.md) says who sets what.
|
|
||||||
|
|
||||||
The same workflow bootstraps the taxonomy: a manual dispatch creates any
|
|
||||||
missing label idempotently. To create them by hand (needs push access):
|
|
||||||
|
|
||||||
```sh
|
|
||||||
gh label create "state:building" --color FBCA04 --description "PR is a draft — the coding agent is still building" --force
|
|
||||||
gh label create "state:bots-reviewing" --color 1D76DB --description "Waiting on the bot reviewers to finish the round" --force
|
|
||||||
gh label create "state:addressing" --color D93F0B --description "All bots reviewed — coding agent owes the single reply + fixes" --force
|
|
||||||
gh label create "state:needs-human" --color 8250DF --description "All bots approve — waiting on the human reviewer" --force
|
|
||||||
gh label create "stale" --color B60205 --description "No activity for 48h — needs a poke (sweep-managed)" --force
|
|
||||||
gh label create "blocked" --color 6A737D --description "Waiting on another PR or issue to land first" --force
|
|
||||||
gh label create "release" --color 0E8A16 --description "Release flow and version/packaging work" --force
|
|
||||||
gh label create "scope:cli" --color C5DEF5 --description "bin/box — the command surface" --force
|
|
||||||
gh label create "scope:installer" --color C5DEF5 --description "install.sh, versioned installs, upgrade/uninstall" --force
|
|
||||||
gh label create "scope:host" --color C5DEF5 --description "host/ — setup, teardown, firewall, isolation stack" --force
|
|
||||||
gh label create "scope:tiers" --color C5DEF5 --description "restricted tier — grant/revoke, multi-user" --force
|
|
||||||
gh label create "scope:templates" --color C5DEF5 --description "templates/ — the box seeds" --force
|
|
||||||
gh label create "scope:drill" --color C5DEF5 --description "drill/ — rehearsals, doctor, RUNS.md" --force
|
|
||||||
# delete is not an upsert: a label that is already gone exits non-zero. Swallow
|
|
||||||
# that, so this block converges on re-run instead of erroring after first success.
|
|
||||||
for L in duplicate invalid question wontfix "help wanted" "good first issue"; do
|
|
||||||
gh label delete "$L" --yes 2>/dev/null || true
|
|
||||||
done
|
|
||||||
```
|
|
||||||
209
README.md
209
README.md
|
|
@ -2,8 +2,8 @@
|
||||||
|
|
||||||
**Headless, trust-less, throwaway dev VMs.** One command mints a fresh,
|
**Headless, trust-less, throwaway dev VMs.** One command mints a fresh,
|
||||||
network-isolated Incus box from a **template**; the coding-agent templates
|
network-isolated Incus box from a **template**; the coding-agent templates
|
||||||
hand you a CLI agent on Debian 13 — `claude` (Claude Code), `codex` (OpenAI
|
hand you a CLI agent on Debian 13 — `claude-box` (Claude Code), `codex-box`
|
||||||
Codex), `grok` (xAI Grok) — **box mints, [rig](https://github.com/heavy-duty/rig)
|
(OpenAI Codex), `grok-box` (xAI Grok), `kimi-box` (Moonshot Kimi) — **box mints, [rig](https://github.com/heavy-duty/rig)
|
||||||
converges**: the template is a thin seed, and the agent tooling lands via a
|
converges**: the template is a thin seed, and the agent tooling lands via a
|
||||||
creds-free `rig bootstrap` role auto-run at mint
|
creds-free `rig bootstrap` role auto-run at mint
|
||||||
([#81](https://github.com/heavy-duty/box/issues/81)). The box is the product
|
([#81](https://github.com/heavy-duty/box/issues/81)). The box is the product
|
||||||
|
|
@ -217,13 +217,14 @@ VM boundary itself is proven on real hardware, like the rest of the drill).
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
box new --name work --template claude # a creds-free coding-agent box (~10 min cold)
|
box new --name work --template claude-box # a creds-free coding-agent box (~10 min cold)
|
||||||
box shell work # enter as the template's user
|
box shell work # enter as the template's user
|
||||||
```
|
```
|
||||||
|
|
||||||
Pick whichever coding-agent template you like — `claude`, `codex`, `grok` — or
|
Pick whichever coding-agent template you like — `claude-box`, `codex-box`,
|
||||||
`blank` for none. Inside the box, authenticate as needed. The `claude` template
|
`grok-box`, `kimi-box` — or `blank` for none. Inside the box, authenticate as needed. The
|
||||||
looks like this; the others follow the same shape with their own login step:
|
`claude-box` template looks like this; the others follow the same shape with
|
||||||
|
their own login step:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
claude # then run /login — copy the URL (press c), open it
|
claude # then run /login — copy the URL (press c), open it
|
||||||
|
|
@ -239,12 +240,13 @@ No coding agent is special — each is one template among several, and adding
|
||||||
another is just another directory. What ships today:
|
another is just another directory. What ships today:
|
||||||
|
|
||||||
| Template | What it becomes |
|
| Template | What it becomes |
|
||||||
| --------- | ---------------------------------------------------------------------- |
|
| ------------- | ------------------------------------------------------------------ |
|
||||||
| `blank` | Bare Debian 13 — same isolation, no tooling. The default. |
|
| `blank` | Bare Debian 13 — same isolation, no tooling. The default. |
|
||||||
| `claude` | Claude Code, creds-free — where this project started |
|
| `claude-box` | Claude Code, creds-free — where this project started |
|
||||||
| `codex` | OpenAI Codex CLI, creds-free |
|
| `codex-box` | OpenAI Codex CLI, creds-free |
|
||||||
| `grok` | xAI Grok CLI, creds-free |
|
| `grok-box` | xAI Grok CLI, creds-free |
|
||||||
| `staging` | Server-class: docker + sshd hardening via rig; VM-only, autostarts |
|
| `kimi-box` | Moonshot Kimi CLI, creds-free |
|
||||||
|
| `staging-box` | Server-class: docker + sshd hardening via rig; VM-only, autostarts |
|
||||||
|
|
||||||
**Templates are thin seeds; rig does the becoming**
|
**Templates are thin seeds; rig does the becoming**
|
||||||
([#81](https://github.com/heavy-duty/box/issues/81)). A template is a
|
([#81](https://github.com/heavy-duty/box/issues/81)). A template is a
|
||||||
|
|
@ -255,8 +257,11 @@ pin tokens below). The seed is deliberately small — the tenant user, tmux,
|
||||||
and [rig](https://github.com/heavy-duty/rig) preinstalled, nothing that
|
and [rig](https://github.com/heavy-duty/rig) preinstalled, nothing that
|
||||||
joins a tailnet or admits credentials — and after cloud-init settles, box
|
joins a tailnet or admits credentials — and after cloud-init settles, box
|
||||||
auto-runs the template's **creds-free** tenant role inside the guest
|
auto-runs the template's **creds-free** tenant role inside the guest
|
||||||
(`rig bootstrap claude` / `codex` / `grok` / `staging`,
|
(`rig bootstrap claude-box` / `codex-box` / `grok-box` / `kimi-box` / `staging-box`,
|
||||||
[rig#31](https://github.com/heavy-duty/rig/issues/31)). The agent CLI,
|
[rig#31](https://github.com/heavy-duty/rig/issues/31); the roles carry a
|
||||||
|
family suffix — `-box` for box tenants, `-server` for fleet machines — and a
|
||||||
|
template is named for the role it converges,
|
||||||
|
[rig#76](https://github.com/heavy-duty/rig/issues/76)). The agent CLI,
|
||||||
docker, the server posture and the agent-context file all come from that
|
docker, the server posture and the agent-context file all come from that
|
||||||
role — convergent and idempotent, so the same command re-run later converges
|
role — convergent and idempotent, so the same command re-run later converges
|
||||||
an *existing* box to a newer spec (`box shell <box>` →
|
an *existing* box to a newer spec (`box shell <box>` →
|
||||||
|
|
@ -265,9 +270,9 @@ an *existing* box to a newer spec (`box shell <box>` →
|
||||||
`box setup-host`, `box teardown-host` or the drill *inside* a box — once,
|
`box setup-host`, `box teardown-host` or the drill *inside* a box — once,
|
||||||
from rig's roles, instead of copy-pasted per template.
|
from rig's roles, instead of copy-pasted per template.
|
||||||
|
|
||||||
**Anything that joins or admits stays operator-run.** The `staging` box's
|
**Anything that joins or admits stays operator-run.** The `staging-box`
|
||||||
tailnet workload join holds a pre-auth key, so box only prints it as the
|
tenant's tailnet workload join holds a pre-auth key, so box only prints it as the
|
||||||
next step — `box shell <name>`, then `sudo rig bootstrap workload` — and
|
next step — `box shell <name>`, then `sudo rig bootstrap workload-server` — and
|
||||||
never sees the key ([#69](https://github.com/heavy-duty/box/issues/69)'s
|
never sees the key ([#69](https://github.com/heavy-duty/box/issues/69)'s
|
||||||
split, kept).
|
split, kept).
|
||||||
|
|
||||||
|
|
@ -279,9 +284,9 @@ carries `@RIG_REPO@`/`@RIG_REF@` tokens that box resolves at mint from the
|
||||||
environment:
|
environment:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
box new --name work --template claude # heavy-duty/rig @ main
|
box new --name work --template claude-box # heavy-duty/rig @ main
|
||||||
RIG_REPO=you/rig RIG_REF=my-branch \
|
RIG_REPO=you/rig RIG_REF=my-branch \
|
||||||
box new --name trial --template claude # a rig branch under review
|
box new --name trial --template claude-box # a rig branch under review
|
||||||
```
|
```
|
||||||
|
|
||||||
Both directions of that edge track `main` unpinned today — said honestly,
|
Both directions of that edge track `main` unpinned today — said honestly,
|
||||||
|
|
@ -320,11 +325,92 @@ box new --name feature --from work/authed # clone the authed state into a new
|
||||||
|
|
||||||
`--from` copies the whole box (agent login, git creds, clones and all) while
|
`--from` copies the whole box (agent login, git creds, clones and all) while
|
||||||
preserving isolation. You can also `box new --name x --from work` to clone
|
preserving isolation. You can also `box new --name x --from work` to clone
|
||||||
a box's live state, or roll a box back with `box restore work authed`.
|
a box's live state, or roll a box back with `box restore work authed` — which
|
||||||
|
asks first, since a rollback discards everything since the snapshot (`--force`
|
||||||
|
skips the prompt, and scripts must pass it: with no terminal to ask on, box
|
||||||
|
refuses rather than assuming yes).
|
||||||
|
|
||||||
Forgotten what you called a checkpoint? `box info work` prints the box's
|
Forgotten what you called a checkpoint? `box info work` prints the box's
|
||||||
snapshot labels and the `--from` line to clone one.
|
snapshot labels and the `--from` line to clone one.
|
||||||
|
|
||||||
|
### `pristine` — the one checkpoint box takes for you
|
||||||
|
|
||||||
|
Every fresh mint marks a snapshot called `pristine`
|
||||||
|
([#104](https://github.com/heavy-duty/box/issues/104)) at the one moment it
|
||||||
|
is true: **after cloud-init, before `rig bootstrap` converges the tenant
|
||||||
|
role.** At that instant the guest is pristine Debian plus box's thin seed
|
||||||
|
(the user, tmux, rig) and nothing else — the state
|
||||||
|
[heavy-duty/rig#62](https://github.com/heavy-duty/rig/issues/62) calls "back
|
||||||
|
to pristine Debian". It exists for a few seconds on every mint, so box
|
||||||
|
captures it rather than asking you to be quick.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
box restore work pristine # undo the tenant role and everything since
|
||||||
|
```
|
||||||
|
|
||||||
|
That is a complete undo for every tenant role: everything `rig bootstrap
|
||||||
|
claude|codex|grok|staging` does — docker, node, the agent CLI, the
|
||||||
|
agent-context file, the role marker — is box-local and file-shaped, so a
|
||||||
|
filesystem rollback reaches all of it, without paying a ~10-minute re-mint.
|
||||||
|
|
||||||
|
Three things it deliberately does not do:
|
||||||
|
|
||||||
|
- **It is an undo, not a backup.** Snapshots die with their box: `box rm`
|
||||||
|
deletes a box _and_ every snapshot it has. `box export` is the only state
|
||||||
|
that outlives the box — see below.
|
||||||
|
- **It cannot reach off-box state.** A tailnet join, a GitHub runner
|
||||||
|
registration, a pushed commit: those are records held somewhere else, and
|
||||||
|
no filesystem rollback undoes them (rig#62 covers those separately).
|
||||||
|
- **A `--from` clone gets no `pristine` of its own.** A clone skips
|
||||||
|
cloud-init and rig entirely, so it has no pristine moment to capture, and
|
||||||
|
box will not label a source's worked-in state as one. Cloning a _box_
|
||||||
|
inherits the source's snapshots (a real `pristine` among them, if the
|
||||||
|
source had one); cloning a _snapshot_ starts with none. `box new` says
|
||||||
|
which of the two you got.
|
||||||
|
|
||||||
|
On a host whose storage pool uses the `dir` driver, a snapshot is a full
|
||||||
|
multi-GB copy rather than a near-free copy-on-write mark, so the mint
|
||||||
|
**skips** `pristine` and says so loudly — take it by hand with `box snapshot
|
||||||
|
<box> pristine` if you want it anyway. btrfs is what `box setup-host`
|
||||||
|
installs by default precisely so snapshots are cheap. `BOX_SNAPSHOT_PRISTINE=0`
|
||||||
|
skips the mark on any host.
|
||||||
|
|
||||||
|
### `bootstrapped` — the same undo, one step later
|
||||||
|
|
||||||
|
A mint whose template names a bootstrap role marks a second snapshot,
|
||||||
|
`bootstrapped` ([#130](https://github.com/heavy-duty/box/issues/130)), once
|
||||||
|
`rig bootstrap` has run and box has **watched it succeed**. That is the box
|
||||||
|
converged and not yet touched — the state you actually wanted back most of
|
||||||
|
the time.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
box restore work bootstrapped # keep the tenant role, undo what you did to it
|
||||||
|
box restore work pristine # throw the tenant role away too
|
||||||
|
```
|
||||||
|
|
||||||
|
Same rules as `pristine`: default on, never fatal, skipped loudly on a `dir`
|
||||||
|
pool (with two marks that disk cost is twice the size, so a CoW-less host is
|
||||||
|
not asked to pay it), and `BOX_SNAPSHOT_BOOTSTRAPPED=0` skips it anywhere.
|
||||||
|
The same two caveats apply, unchanged: it **dies with the box** on `box rm`
|
||||||
|
(`box export` is the durable path), and it **cannot reach off-box state** —
|
||||||
|
a tailnet device record, a runner registration (rig#62).
|
||||||
|
|
||||||
|
**Read the label in one direction only.** Its _presence_ means the mint-time
|
||||||
|
hook converged and nothing has touched the box since. Its _absence_ means
|
||||||
|
nothing at all, because box only marks a hook it watched:
|
||||||
|
|
||||||
|
- A **blank** box runs no hook, so there is no convergence to mark. It gets
|
||||||
|
none — `pristine` and `bootstrapped` would be the same disk state at twice
|
||||||
|
the cost, and a label claiming a convergence that never happened is worse
|
||||||
|
than no label.
|
||||||
|
- A box whose hook **failed** gets none either. box tells you to re-run the
|
||||||
|
role by hand through `box shell`, and a by-hand run happens in a shell box
|
||||||
|
does not watch — so box hands you `box snapshot <box> bootstrapped` to take
|
||||||
|
at the moment it is true, rather than inventing a fact.
|
||||||
|
- A `dir`-pool host and `BOX_SNAPSHOT_BOOTSTRAPPED=0` both skip it.
|
||||||
|
|
||||||
|
`box info <box>` is what actually tells you which labels a box has.
|
||||||
|
|
||||||
## Survive the host: `box export` / `box import`
|
## Survive the host: `box export` / `box import`
|
||||||
|
|
||||||
Snapshots live _inside_ a box, and `box rm` deletes the box **and** its
|
Snapshots live _inside_ a box, and `box rm` deletes the box **and** its
|
||||||
|
|
@ -383,12 +469,17 @@ the door is per-port, punched and removable at runtime.
|
||||||
box new --name <box> [--template <t>] [--from <src>[/<snap>]] [--cpu <n>] [--memory <size>] [--disk <size>] [--vm|--container]
|
box new --name <box> [--template <t>] [--from <src>[/<snap>]] [--cpu <n>] [--memory <size>] [--disk <size>] [--vm|--container]
|
||||||
box templates # list the templates this install can mint
|
box templates # list the templates this install can mint
|
||||||
box list # list your boxes
|
box list # list your boxes
|
||||||
box info <box> # one box: state, IP, exposures, snapshot labels
|
box info <box> # one box: state, IP, exposures, provenance, snapshots
|
||||||
box shell <box> # enter as the template's user
|
box shell <box> # enter as the template's user
|
||||||
box exec <box> -- <cmd...> # run a command in the box
|
box exec <box> -- <cmd...> # run a command in the box
|
||||||
box tmux <box> [session] # attach/create a tmux session — survives disconnects
|
box tmux <box> [session] # attach/create a tmux session — survives disconnects
|
||||||
box snapshot <box> [label] # checkpoint (label defaults to manual-<epoch>)
|
box snapshot <box> [label] # checkpoint (label defaults to manual-<epoch>)
|
||||||
box restore <box> <snap> # roll back to a snapshot
|
box restore <box> <snap> [--force]
|
||||||
|
# roll back to a snapshot — destructive, asks first
|
||||||
|
# 'pristine' is auto-marked at mint: back to
|
||||||
|
# pristine Debian + box's seed, before rig ran
|
||||||
|
# 'bootstrapped' too, if a rig hook converged:
|
||||||
|
# the role kept, everything since undone
|
||||||
box export <box> [<file>] [--instance-only]
|
box export <box> [<file>] [--instance-only]
|
||||||
# one portable file (snapshots incl.) — survives rm & host
|
# one portable file (snapshots incl.) — survives rm & host
|
||||||
box import <file> [--name <box>]
|
box import <file> [--name <box>]
|
||||||
|
|
@ -419,6 +510,82 @@ clones an existing box or snapshot. VM mode (`--vm`, the default where
|
||||||
`security.nesting=true`) is for hosts without nested virt — weaker isolation,
|
`security.nesting=true`) is for hosts without nested virt — weaker isolation,
|
||||||
dev/test only.
|
dev/test only.
|
||||||
|
|
||||||
|
## What minted this box: `box info`
|
||||||
|
|
||||||
|
A box outlives the release that minted it, the template that shaped it and the
|
||||||
|
image build it came from — and until
|
||||||
|
[#103](https://github.com/heavy-duty/box/issues/103) it recorded none of them.
|
||||||
|
There is no host-side per-box store; the Incus instance config _is_ the
|
||||||
|
database, so a fact not written at mint time is simply gone. `box new` now
|
||||||
|
stamps what it knew, and `box info` reads it back:
|
||||||
|
|
||||||
|
```
|
||||||
|
NAME work
|
||||||
|
STATE RUNNING
|
||||||
|
TYPE VM
|
||||||
|
IPV4 10.x.x.x
|
||||||
|
|
||||||
|
MINTED 2026-07-19T14:22:07Z by box 0.8.1
|
||||||
|
TEMPLATE claude (user claude, role claude)
|
||||||
|
IMAGE images:debian/13/cloud @ 8a2f1c9d4e5b…
|
||||||
|
MODE vm (asked: auto)
|
||||||
|
RIG heavy-duty/rig@main
|
||||||
|
ORIGIN mint
|
||||||
|
```
|
||||||
|
|
||||||
|
The image line carries both halves on purpose: the template names an
|
||||||
|
_unpinned alias on a moving remote_, so what it resolved to at that mint is the
|
||||||
|
only reproducible fact. `box info --json` carries every key verbatim — they
|
||||||
|
ride `incus list --format json` in `config`.
|
||||||
|
|
||||||
|
**A clone re-stamps.** `incus copy` preserves `user.*` keys, so a clone inherits
|
||||||
|
its source's template and user for free — but inheriting the mint stamp would
|
||||||
|
not make it stale, it would make it **false**: the clone was not present at that
|
||||||
|
mint. `box new --from` therefore re-stamps the four keys that describe _this_
|
||||||
|
instance's coming into being (`ORIGIN clone of work/authed`, a fresh time, the
|
||||||
|
box version that cloned it) and leaves the lineage keys alone, because the
|
||||||
|
clone's disk genuinely did come from that image, template and role. `origin.from`
|
||||||
|
records one hop: a clone of a clone names its parent, not its grandparent.
|
||||||
|
|
||||||
|
**An import records the trip, and rewrites nothing**
|
||||||
|
([#131](https://github.com/heavy-duty/box/issues/131)). Everything `incus
|
||||||
|
import` restores is the _artifact's_ truth, so an imported box keeps its mint
|
||||||
|
stamp verbatim — the mint time, the box version, the image and the origin
|
||||||
|
belong to the originating host and survive the trip on purpose. What `box
|
||||||
|
import` adds is the one fact the artifact cannot carry: that the trip happened.
|
||||||
|
|
||||||
|
```
|
||||||
|
MINTED 2026-06-01T10:00:00Z by box 0.7.0
|
||||||
|
IMPORTED 2026-07-20T09:14:03Z by box 0.8.1 (the mint above predates it)
|
||||||
|
ORIGIN clone of work/authed
|
||||||
|
```
|
||||||
|
|
||||||
|
It is **not** `origin=import`, and the difference is the whole point. `origin`
|
||||||
|
answers how the instance came into _being_ — mint or clone — and overwriting it
|
||||||
|
would destroy that: the clone above would come back claiming to be an import,
|
||||||
|
with nothing left saying it was ever a clone and an `origin.from` naming a
|
||||||
|
lineage no key explains. The import is a _third_ fact, orthogonal to the first
|
||||||
|
two, so it takes its own keys and leaves every other one alone.
|
||||||
|
|
||||||
|
The `IMPORTED` line sits directly under `MINTED` because that adjacency is what
|
||||||
|
stops the mint time being misread as this host's. Note what it does not claim:
|
||||||
|
box has no record of _which_ host minted the box, and a box can be exported and
|
||||||
|
re-imported onto the same host (that is the upgrade flow above), so the line
|
||||||
|
states only the ordering — the one thing box actually knows.
|
||||||
|
|
||||||
|
**A box can make the trip more than once**, and both ends are kept: the first
|
||||||
|
import is pinned forever, the latest is refreshed on every arrival, and a count
|
||||||
|
says how many. Last-wins alone would erase the evidence of the earlier trips,
|
||||||
|
which is the same mistake `origin=import` makes one level up. (The shape
|
||||||
|
follows [heavy-duty/rig#61](https://github.com/heavy-duty/rig/issues/61)'s
|
||||||
|
manifest: a birth pair plus a latest pair.)
|
||||||
|
|
||||||
|
**Boxes minted before this stamp existed keep working**, under this verb and
|
||||||
|
every other — they render as a box with blanks and say `MINTED (not recorded)`
|
||||||
|
rather than erroring. `user.box.schema` names the stamp's _shape_ (an integer,
|
||||||
|
not the box version) so a box minted by a later release reads back on an older
|
||||||
|
box as "here is what I understand, and there is more I don't".
|
||||||
|
|
||||||
## Boxes are just Incus instances
|
## Boxes are just Incus instances
|
||||||
|
|
||||||
A box is an ordinary Incus instance tagged `user.box=1` (pre-0.4.0 boxes
|
A box is an ordinary Incus instance tagged `user.box=1` (pre-0.4.0 boxes
|
||||||
|
|
|
||||||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.7.0
|
0.9.1-dev
|
||||||
|
|
|
||||||
709
bin/box
709
bin/box
|
|
@ -9,10 +9,27 @@ root="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)"
|
||||||
remote=""; mode="auto"; name=""; from=""; template=""; force=0; json=0; want_help=0
|
remote=""; mode="auto"; name=""; from=""; template=""; force=0; json=0; want_help=0
|
||||||
cpu=""; memory=""; disk=""; instance_only=0
|
cpu=""; memory=""; disk=""; instance_only=0
|
||||||
inst="" # the resolved Incus instance, set by the 'box' precondition
|
inst="" # the resolved Incus instance, set by the 'box' precondition
|
||||||
|
# Labels snapshot_mark actually CREATED this run, space-separated. Every path
|
||||||
|
# through snapshot_mark returns 0 on purpose — the never-fatal contract — so
|
||||||
|
# the exit status cannot tell a mark that was taken from one that was skipped
|
||||||
|
# (dir pool, knob=0) or refused (incus said no). Anything that offers the
|
||||||
|
# operator a mark must ask this, not assume the call happened.
|
||||||
|
marks=""
|
||||||
|
|
||||||
die() { echo "box: $*" >&2; exit 1; } # 1 = it went wrong
|
die() { echo "box: $*" >&2; exit 1; } # 1 = it went wrong
|
||||||
usage_error() { echo "box: $*" >&2; echo "try 'box help'." >&2; exit 2; } # 2 = you asked wrong
|
usage_error() { echo "box: $*" >&2; echo "try 'box help'." >&2; exit 2; } # 2 = you asked wrong
|
||||||
version() { echo "box $(cat "$root/VERSION" 2>/dev/null || echo unknown) ($root)"; }
|
# The tree's own version, read in ONE place. 'box --version' says it out loud;
|
||||||
|
# the mint stamp (#103) writes it onto every instance box creates, so a box can
|
||||||
|
# still name the release that made it long after that release is history.
|
||||||
|
box_version() { cat "$root/VERSION" 2>/dev/null || echo unknown; }
|
||||||
|
version() { echo "box $(box_version) ($root)"; }
|
||||||
|
|
||||||
|
# The SHAPE of the mint stamp, not the box version — an integer that changes
|
||||||
|
# only when a key is removed or repurposed, never when one is added (a reader
|
||||||
|
# that does not know a key simply does not print it). Absent means pre-stamp:
|
||||||
|
# every box minted before #103 has no schema key at all, and must keep working
|
||||||
|
# under every verb, which is the same promise 'user.claudebox' carries.
|
||||||
|
BOX_STAMP_SCHEMA=1
|
||||||
|
|
||||||
# Which tier is THIS PROCESS? Decided from live credentials (argless 'id -nG':
|
# Which tier is THIS PROCESS? Decided from live credentials (argless 'id -nG':
|
||||||
# what the kernel will present when incus opens the socket), never from the
|
# what the kernel will present when incus opens the socket), never from the
|
||||||
|
|
@ -35,7 +52,7 @@ box_tier() {
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# The command table.
|
# The command table.
|
||||||
#
|
#
|
||||||
# verb ^ synopsis args ^ preconditions ^ one-line summary ^ action ^ ok message
|
# verb ^ synopsis args ^ preconditions ^ one-line summary ^ action ^ ok message ^ confirm prompt
|
||||||
#
|
#
|
||||||
# Fields are ^-separated because a synopsis may contain '|' ([--vm|--container]).
|
# Fields are ^-separated because a synopsis may contain '|' ([--vm|--container]).
|
||||||
#
|
#
|
||||||
|
|
@ -44,7 +61,8 @@ box_tier() {
|
||||||
# isn't tagged user.box=1 (or the legacy user.claudebox=1) — the boundary, enforced, not assumed
|
# isn't tagged user.box=1 (or the legacy user.claudebox=1) — the boundary, enforced, not assumed
|
||||||
# arg2 a second positional is required
|
# arg2 a second positional is required
|
||||||
# stopped the box must not be running
|
# stopped the box must not be running
|
||||||
# confirm destructive: prompt unless --force
|
# confirm destructive: prompt unless --force. The row supplies the words
|
||||||
|
# (last field) — see 'confirm prompt' below.
|
||||||
#
|
#
|
||||||
# action:
|
# action:
|
||||||
# incus:<subcommand> run `incus <subcommand> <instance> [rest...]`
|
# incus:<subcommand> run `incus <subcommand> <instance> [rest...]`
|
||||||
|
|
@ -52,6 +70,19 @@ box_tier() {
|
||||||
#
|
#
|
||||||
# ok message: printed on success; {} = the box, {1} = the second positional.
|
# ok message: printed on success; {} = the box, {1} = the second positional.
|
||||||
#
|
#
|
||||||
|
# confirm prompt: the words the 'confirm' precondition asks with, phrased as
|
||||||
|
# the act ("delete X", "roll X back to Y") — confirm() wraps it into
|
||||||
|
# "box: <prompt>? this cannot be undone." and into the no-TTY refusal
|
||||||
|
# ("refusing to <prompt> without --force"). It is a per-row field and not a
|
||||||
|
# shared string on purpose: the prompt was hardcoded to rm's wording once, and
|
||||||
|
# the whole reason 'restore' shipped ungated for four releases is that adding
|
||||||
|
# the token to its row would have asked the operator to confirm DELETING the
|
||||||
|
# box they were trying to rescue (#105). A gate that names the wrong act is
|
||||||
|
# worse than no gate — it teaches people to answer 'y' without reading. Same
|
||||||
|
# substitutions as the ok message, except {} is the RESOLVED instance: the
|
||||||
|
# prompt names the thing about to be destroyed, so under --remote it should
|
||||||
|
# say 'lab:work', not 'work'.
|
||||||
|
#
|
||||||
# Adding a thin verb is one row. If a request can't be expressed as a row and
|
# Adding a thin verb is one row. If a request can't be expressed as a row and
|
||||||
# doesn't enforce a box invariant, it is incus's job, not ours — that is
|
# doesn't enforce a box invariant, it is incus's job, not ours — that is
|
||||||
# what `box incus` is for.
|
# what `box incus` is for.
|
||||||
|
|
@ -59,18 +90,18 @@ CMDS=(
|
||||||
"new^--name <box> [--template <t>] [--from <src>[/<snap>]] [--cpu <n>] [--memory <size>] [--disk <size>] [--vm|--container]^^Mint a box from a template (default: blank), or --from an existing box/snapshot^fn:cmd_new^"
|
"new^--name <box> [--template <t>] [--from <src>[/<snap>]] [--cpu <n>] [--memory <size>] [--disk <size>] [--vm|--container]^^Mint a box from a template (default: blank), or --from an existing box/snapshot^fn:cmd_new^"
|
||||||
"templates^^^List the templates this install can mint^fn:cmd_templates^"
|
"templates^^^List the templates this install can mint^fn:cmd_templates^"
|
||||||
"list^[--json]^^List your boxes^fn:cmd_list^"
|
"list^[--json]^^List your boxes^fn:cmd_list^"
|
||||||
"info^<box> [--json]^box^One box: state, type, IP, and its snapshot labels^fn:cmd_info^"
|
"info^<box> [--json]^box^One box: state, type, IP, what minted it, snapshot labels^fn:cmd_info^"
|
||||||
"shell^<box>^box^Open a shell in a box, as its template's user^fn:cmd_shell^"
|
"shell^<box>^box^Open a shell in a box, as its template's user^fn:cmd_shell^"
|
||||||
"exec^<box> -- <cmd...>^box^Run a command inside a box^fn:cmd_exec^"
|
"exec^<box> -- <cmd...>^box^Run a command inside a box^fn:cmd_exec^"
|
||||||
"tmux^<box> [<session>]^box^Attach or create a tmux session in a box — survives disconnects^fn:cmd_tmux^"
|
"tmux^<box> [<session>]^box^Attach or create a tmux session in a box — survives disconnects^fn:cmd_tmux^"
|
||||||
"snapshot^<box> [<label>]^box^Checkpoint a box (label defaults to manual-<epoch>)^fn:cmd_snapshot^"
|
"snapshot^<box> [<label>]^box^Checkpoint a box (label defaults to manual-<epoch>)^fn:cmd_snapshot^"
|
||||||
"restore^<box> <snapshot>^box,arg2^Roll a box back to one of its snapshots^incus:snapshot restore^restored {} to {1}"
|
"restore^<box> <snapshot> [--force]^box,arg2,confirm^Roll a box back to one of its snapshots — irreversible, and it asks first^incus:snapshot restore^restored {} to {1}^roll {} back to snapshot '{1}' and discard everything in the box since it was taken"
|
||||||
"export^<box> [<file>] [--instance-only]^box^Export a stopped box to one portable file — it survives 'box rm' and this host^fn:cmd_export^"
|
"export^<box> [<file>] [--instance-only]^box^Export a stopped box to one portable file — it survives 'box rm' and this host^fn:cmd_export^"
|
||||||
"import^<file> [--name <box>]^^Mint a box from an exported file, re-stamped onto this host's stack^fn:cmd_import^"
|
"import^<file> [--name <box>]^^Mint a box from an exported file, re-stamped onto this host's stack^fn:cmd_import^"
|
||||||
"rename^<box> <new-name>^box,arg2,stopped^Rename a box (it must be stopped first)^incus:rename^renamed {} to {1}"
|
"rename^<box> <new-name>^box,arg2,stopped^Rename a box (it must be stopped first)^incus:rename^renamed {} to {1}"
|
||||||
"down^<box>^box^Stop a box, keeping its state ('start' resumes it)^incus:stop^stopped {}"
|
"down^<box>^box^Stop a box, keeping its state ('start' resumes it)^incus:stop^stopped {}"
|
||||||
"start^<box>^box^Start a stopped box^incus:start^started {}"
|
"start^<box>^box^Start a stopped box^incus:start^started {}"
|
||||||
"rm^<box> [--force]^box,confirm^Delete a box and its snapshots — irreversible, and it asks first^incus:delete -f^removed {}"
|
"rm^<box> [--force]^box,confirm^Delete a box and its snapshots — irreversible, and it asks first^incus:delete -f^removed {}^delete {} and all its snapshots"
|
||||||
"expose^<box> <port> [<host-port>] | --list | --remove <port>^box^Forward a box port to the host's loopback — see a dev server^fn:cmd_expose^"
|
"expose^<box> <port> [<host-port>] | --list | --remove <port>^box^Forward a box port to the host's loopback — see a dev server^fn:cmd_expose^"
|
||||||
"incus^<box> -- <args...>^box^Escape hatch: run any incus command against a box^fn:cmd_incus^"
|
"incus^<box> -- <args...>^box^Escape hatch: run any incus command against a box^fn:cmd_incus^"
|
||||||
"doctor^[--fix | --pin-dns]^^Is this host fit to mint boxes? Diagnose the daemon, network, DNS, isolation^fn:cmd_doctor^"
|
"doctor^[--fix | --pin-dns]^^Is this host fit to mint boxes? Diagnose the daemon, network, DNS, isolation^fn:cmd_doctor^"
|
||||||
|
|
@ -92,12 +123,12 @@ is_command() { cmd_row "$1" >/dev/null 2>&1; }
|
||||||
# locals matter here: dispatch holds $pre/$action/$ok, and field() is called from
|
# locals matter here: dispatch holds $pre/$action/$ok, and field() is called from
|
||||||
# error paths inside it — a global read would clobber the row being dispatched.
|
# error paths inside it — a global read would clobber the row being dispatched.
|
||||||
field() {
|
field() {
|
||||||
local r f_syn f_pre f_sum f_act f_ok
|
local r f_syn f_pre f_sum f_act f_ok f_cnf
|
||||||
r="$(cmd_row "$1")" || return 1
|
r="$(cmd_row "$1")" || return 1
|
||||||
IFS='^' read -r _ f_syn f_pre f_sum f_act f_ok <<<"$r"
|
IFS='^' read -r _ f_syn f_pre f_sum f_act f_ok f_cnf <<<"$r"
|
||||||
case "$2" in
|
case "$2" in
|
||||||
syn) echo "$f_syn" ;; pre) echo "$f_pre" ;; sum) echo "$f_sum" ;;
|
syn) echo "$f_syn" ;; pre) echo "$f_pre" ;; sum) echo "$f_sum" ;;
|
||||||
act) echo "$f_act" ;; ok) echo "$f_ok" ;;
|
act) echo "$f_act" ;; ok) echo "$f_ok" ;; cnf) echo "$f_cnf" ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
synopsis_of() { local s; s="$(field "$1" syn)"; echo "box $1${s:+ $s}"; }
|
synopsis_of() { local s; s="$(field "$1" syn)"; echo "box $1${s:+ $s}"; }
|
||||||
|
|
@ -147,7 +178,7 @@ COMMANDS
|
||||||
EOF
|
EOF
|
||||||
local r v sum
|
local r v sum
|
||||||
for r in "${CMDS[@]}"; do
|
for r in "${CMDS[@]}"; do
|
||||||
IFS='^' read -r v _ _ sum _ _ <<<"$r"
|
IFS='^' read -r v _ _ sum _ _ _ <<<"$r"
|
||||||
printf ' %-13s %s\n' "$v" "$sum"
|
printf ' %-13s %s\n' "$v" "$sum"
|
||||||
done
|
done
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
|
|
@ -161,7 +192,8 @@ OPTIONS
|
||||||
dev/test only. Default where /dev/kvm is absent.
|
dev/test only. Default where /dev/kvm is absent.
|
||||||
--json Emit Incus JSON instead of a table (list, info)
|
--json Emit Incus JSON instead of a table (list, info)
|
||||||
--instance-only Export the live state only, no snapshots (export)
|
--instance-only Export the live state only, no snapshots (export)
|
||||||
--force, -f Delete without asking (rm); overwrite the file (export)
|
--force, -f Destroy without asking (rm, restore); overwrite the
|
||||||
|
file (export)
|
||||||
--remote <r> Act on Incus remote <r> (any)
|
--remote <r> Act on Incus remote <r> (any)
|
||||||
--help, -h Help; after a command, help for that command
|
--help, -h Help; after a command, help for that command
|
||||||
--version, -V Print the box version
|
--version, -V Print the box version
|
||||||
|
|
@ -170,7 +202,7 @@ OPTIONS
|
||||||
|
|
||||||
EXAMPLES
|
EXAMPLES
|
||||||
# mint a claude box and log in inside it — the tool never handles your token
|
# mint a claude box and log in inside it — the tool never handles your token
|
||||||
box new --name work --template claude
|
box new --name work --template claude-box
|
||||||
box shell work # then: run 'claude', then /login
|
box shell work # then: run 'claude', then /login
|
||||||
|
|
||||||
# log in once, reuse forever: checkpoint the authed box, clone from it
|
# log in once, reuse forever: checkpoint the authed box, clone from it
|
||||||
|
|
@ -227,13 +259,13 @@ help_cmd() {
|
||||||
case "$1" in
|
case "$1" in
|
||||||
new) cat <<'EOF'
|
new) cat <<'EOF'
|
||||||
Mint a box. Without --from, launches a fresh box from a template (default:
|
Mint a box. Without --from, launches a fresh box from a template (default:
|
||||||
blank — bare Debian 13, nobody home; --template claude gets Claude Code
|
blank — bare Debian 13, nobody home; --template claude-box gets Claude Code
|
||||||
installed, creds-free, ~10 min cold). With --from, clones an existing box or
|
installed, creds-free, ~10 min cold). With --from, clones an existing box or
|
||||||
one of its snapshots — login state, git creds and clones carry over,
|
one of its snapshots — login state, git creds and clones carry over,
|
||||||
isolation is preserved, and the clone knows its template's user without
|
isolation is preserved, and the clone knows its template's user without
|
||||||
being told.
|
being told.
|
||||||
|
|
||||||
A tenant template (claude, codex, grok, staging) is a THIN seed — the user,
|
A tenant template (claude-box, codex-box, grok-box, kimi-box, staging-box) is a THIN seed — the user,
|
||||||
tmux, rig (#81) — and after cloud-init box auto-runs the creds-free tenant
|
tmux, rig (#81) — and after cloud-init box auto-runs the creds-free tenant
|
||||||
role inside it ('rig bootstrap <role>', rig#31): that role installs the
|
role inside it ('rig bootstrap <role>', rig#31): that role installs the
|
||||||
agent CLI / server posture and the agent-context file. rig is preinstalled
|
agent CLI / server posture and the agent-context file. rig is preinstalled
|
||||||
|
|
@ -270,9 +302,40 @@ there is no flag for a network or a security key, on purpose.
|
||||||
BOX_LAUNCH_TIMEOUT=<seconds> (default 600) bounds the 'incus launch' call —
|
BOX_LAUNCH_TIMEOUT=<seconds> (default 600) bounds the 'incus launch' call —
|
||||||
a launch that overruns it fails loudly instead of hanging forever (#93).
|
a launch that overruns it fails loudly instead of hanging forever (#93).
|
||||||
|
|
||||||
|
Every fresh mint marks a snapshot called 'pristine' (#104) at the one moment
|
||||||
|
it is true: after cloud-init, before rig converges anything. That is the
|
||||||
|
guest as pristine Debian plus box's thin seed, and it is what
|
||||||
|
|
||||||
|
box restore <box> pristine
|
||||||
|
|
||||||
|
rolls back to — undoing the tenant role and everything done since, without
|
||||||
|
paying a re-mint. It is an UNDO, not a backup: 'box rm' deletes a box and
|
||||||
|
every snapshot it has, so 'box export' remains the only state that outlives
|
||||||
|
the box. A --from clone takes no 'pristine' of its own — it has no pristine
|
||||||
|
moment; it inherits the source's snapshots (copying a box) or starts with
|
||||||
|
none (copying a snapshot). On a 'dir'-backend host, where a snapshot is a
|
||||||
|
full copy rather than a CoW mark, the mint skips it and says so.
|
||||||
|
BOX_SNAPSHOT_PRISTINE=0 skips it anywhere.
|
||||||
|
|
||||||
|
A mint whose template names a bootstrap role marks a second snapshot,
|
||||||
|
'bootstrapped' (#130), once the rig hook has run and box has watched it
|
||||||
|
SUCCEED — the box converged and not yet touched. It is the cheaper undo:
|
||||||
|
'pristine' throws the tenant role away, 'bootstrapped' keeps it and throws
|
||||||
|
away only what you did afterwards. Same rules: never fatal, skipped on a
|
||||||
|
'dir' pool, and BOX_SNAPSHOT_BOOTSTRAPPED=0 skips it anywhere.
|
||||||
|
|
||||||
|
It is deliberately NOT unconditional the way 'pristine' is. A blank box
|
||||||
|
runs no hook, so there is no convergence to mark and it gets none — the
|
||||||
|
label marks an event, not a mint. And if the hook FAILS, box tells you to
|
||||||
|
re-run it by hand and takes no mark afterwards: a by-hand run happens in a
|
||||||
|
shell box does not watch, so box will not label a convergence it never saw.
|
||||||
|
It names 'box snapshot <box> bootstrapped' at that moment instead. So read
|
||||||
|
the label one way only: its PRESENCE means the hook converged untouched;
|
||||||
|
its ABSENCE means nothing at all.
|
||||||
|
|
||||||
box new --name scratch # blank, the default
|
box new --name scratch # blank, the default
|
||||||
box new --name work --template claude
|
box new --name work --template claude-box
|
||||||
box new --name lean --template claude --cpu 2 --memory 3GiB
|
box new --name lean --template claude-box --cpu 2 --memory 3GiB
|
||||||
box new --name feature --from work/authed
|
box new --name feature --from work/authed
|
||||||
EOF
|
EOF
|
||||||
;;
|
;;
|
||||||
|
|
@ -351,6 +414,19 @@ label defaults to manual-<epoch>; 'box info <box>' shows the labels you
|
||||||
have.
|
have.
|
||||||
|
|
||||||
box snapshot work authed
|
box snapshot work authed
|
||||||
|
|
||||||
|
Two labels are taken for you (see 'box help restore'): every fresh mint
|
||||||
|
marks 'pristine' after cloud-init and before rig's tenant role runs (#104),
|
||||||
|
and a mint with a bootstrap role marks 'bootstrapped' after box has watched
|
||||||
|
that role converge (#130). 'bootstrapped' is skipped where there was no hook
|
||||||
|
to watch — a blank box, or a hook box told you to re-run by hand — so its
|
||||||
|
absence proves nothing. Take it yourself in that case:
|
||||||
|
|
||||||
|
box snapshot work bootstrapped
|
||||||
|
|
||||||
|
Snapshots do not outlive their box: 'box rm' deletes a box and every
|
||||||
|
snapshot it has. They are an undo, not a backup — 'box export' is the
|
||||||
|
durable path.
|
||||||
EOF
|
EOF
|
||||||
;;
|
;;
|
||||||
restore) cat <<'EOF'
|
restore) cat <<'EOF'
|
||||||
|
|
@ -358,6 +434,51 @@ Roll a box back to one of its snapshots, in place. Anything in the box since
|
||||||
that snapshot is lost. 'box info <box>' lists the labels.
|
that snapshot is lost. 'box info <box>' lists the labels.
|
||||||
|
|
||||||
box restore work authed
|
box restore work authed
|
||||||
|
box restore work authed --force
|
||||||
|
box restore work pristine
|
||||||
|
|
||||||
|
'pristine' is the one label box takes for you (#104): every fresh mint marks
|
||||||
|
it after cloud-init and before rig converges the tenant role, so it is the
|
||||||
|
guest as pristine Debian plus box's thin seed. Restoring it is the answer to
|
||||||
|
"I broke this box" for every tenant role — everything 'rig bootstrap
|
||||||
|
<tenant>' does is box-local and file-shaped, so a filesystem rollback undoes
|
||||||
|
all of it, without a ~10-minute re-mint.
|
||||||
|
|
||||||
|
What it does NOT undo is anything that left the box: a tailnet join, a
|
||||||
|
GitHub runner registration, a pushed commit. Those are off-box records a
|
||||||
|
filesystem rollback cannot reach (heavy-duty/rig#62). And 'pristine' is not
|
||||||
|
a backup — it dies with the box on 'box rm'; 'box export' is what survives.
|
||||||
|
|
||||||
|
A box minted with --from has no 'pristine' of its own unless it inherited
|
||||||
|
one from its source, because a clone has no pristine moment to capture.
|
||||||
|
|
||||||
|
'bootstrapped' is the other label box takes for you (#130), and it is the
|
||||||
|
one you probably want more often: it is the box AFTER the tenant role
|
||||||
|
converged and before you touched it.
|
||||||
|
|
||||||
|
box restore work bootstrapped # keep the role, undo what you did to it
|
||||||
|
box restore work pristine # throw the role away too
|
||||||
|
|
||||||
|
The same two caveats apply to it exactly as written above — it dies with the
|
||||||
|
box on 'box rm' ('box export' is what survives), and it cannot undo anything
|
||||||
|
that left the box, a tailnet join or a runner registration among them
|
||||||
|
(heavy-duty/rig#62).
|
||||||
|
|
||||||
|
Its absence proves NOTHING. box takes it only after a rig hook it ran and
|
||||||
|
watched succeed, so a blank box has none, a 'dir'-pool host has none, and a
|
||||||
|
box whose hook failed and was re-run by hand through 'box shell' has none
|
||||||
|
either — box will not label a convergence it did not watch. 'box info <box>'
|
||||||
|
tells you which labels a box actually has; take the mark yourself any time
|
||||||
|
with 'box snapshot <box> bootstrapped'.
|
||||||
|
|
||||||
|
Destructive, so it asks first — naming the snapshot it is rolling back to,
|
||||||
|
because the whole risk is picking the wrong label. --force (-f) skips the
|
||||||
|
prompt; with no TTY to ask on it refuses rather than assuming yes.
|
||||||
|
|
||||||
|
box does not require the box to be stopped for this. Snapshots here are
|
||||||
|
stateless — no live memory is captured — so a rollback is crash-consistent:
|
||||||
|
the box comes back the way a machine comes back from losing power. 'box down
|
||||||
|
<box>' first if that matters.
|
||||||
EOF
|
EOF
|
||||||
;;
|
;;
|
||||||
export) cat <<'EOF'
|
export) cat <<'EOF'
|
||||||
|
|
@ -402,6 +523,17 @@ snapshots). What box then re-stamps is THIS host's truth:
|
||||||
differs, the same move migrate-host makes re-homing a legacy box
|
differs, the same move migrate-host makes re-homing a legacy box
|
||||||
· a fresh machine identity (reset_identity, exactly like a clone), so its
|
· a fresh machine identity (reset_identity, exactly like a clone), so its
|
||||||
DHCP lease can never collide with the box it was exported from
|
DHCP lease can never collide with the box it was exported from
|
||||||
|
· the import EVENT — when the box landed here, and by which box version
|
||||||
|
|
||||||
|
The mint stamp is NOT rewritten. The artifact's mint time, box version, image,
|
||||||
|
template and origin (mint or clone) are the originating host's truth and
|
||||||
|
survive the trip intact — 'box info' shows them unchanged, with an IMPORTED
|
||||||
|
line under them saying the mint predates the arrival. In particular the import
|
||||||
|
does not overwrite 'origin': a clone that was exported and imported still
|
||||||
|
reads as a clone, and still names the box it was cloned from.
|
||||||
|
|
||||||
|
A box that makes the trip more than once keeps both ends: the first import is
|
||||||
|
pinned forever, the latest is refreshed each time, and a count says how many.
|
||||||
|
|
||||||
Auth state rides along by design — the artifact carries the box's whole disk,
|
Auth state rides along by design — the artifact carries the box's whole disk,
|
||||||
logins included. That is the point (log in once, keep the file), and the same
|
logins included. That is the point (log in once, keep the file), and the same
|
||||||
|
|
@ -448,7 +580,7 @@ The in-box server must listen on 0.0.0.0:<port>, not only its own loopback —
|
||||||
a VM's forwarder connects to the box over the network. Inside an isolated box
|
a VM's forwarder connects to the box over the network. Inside an isolated box
|
||||||
that is safe: nothing but this proxy can reach the port.
|
that is safe: nothing but this proxy can reach the port.
|
||||||
|
|
||||||
box new --name web --template claude
|
box new --name web --template claude-box
|
||||||
box shell web # inside: run a dev server on 0.0.0.0:3000
|
box shell web # inside: run a dev server on 0.0.0.0:3000
|
||||||
box expose web 3000 # then open http://127.0.0.1:3000 in your browser
|
box expose web 3000 # then open http://127.0.0.1:3000 in your browser
|
||||||
EOF
|
EOF
|
||||||
|
|
@ -542,6 +674,17 @@ What it converges, idempotently (safe to re-run, and re-run after upgrades):
|
||||||
rides snapshots, 'box export' rides backups — #70)
|
rides snapshots, 'box export' rides backups — #70)
|
||||||
· installs the box-net profile into their project
|
· installs the box-net profile into their project
|
||||||
|
|
||||||
|
An incus-admin member is provisioned too, not refused (#99): they are added
|
||||||
|
to 'incus' like anyone else — not a new privilege, since incus-admin already
|
||||||
|
opens the daemon, but the key to a FILE, because incus-user's socket is group
|
||||||
|
'incus' mode 0660 and nothing below can provision them without it. Everything
|
||||||
|
else converges, so they finally have a project of their own. What it is not
|
||||||
|
is a confinement:
|
||||||
|
incus-admin wins at the socket, so the restrictions are a default placement
|
||||||
|
they can step outside at will, and their own client keeps resolving to the
|
||||||
|
admin socket (and the default project) until incus-admin is taken away. The
|
||||||
|
grant says all of that out loud when it lands.
|
||||||
|
|
||||||
The user's surface: new/list/info/shell/exec/tmux/snapshot/restore/export/
|
The user's surface: new/list/info/shell/exec/tmux/snapshot/restore/export/
|
||||||
import/rm on their own boxes. Not theirs: expose (edits daemon-global
|
import/rm on their own boxes. Not theirs: expose (edits daemon-global
|
||||||
state), setup-host, grant. Admin boxes and other users' boxes are invisible to them, and the
|
state), setup-host, grant. Admin boxes and other users' boxes are invisible to them, and the
|
||||||
|
|
@ -561,6 +704,15 @@ recreate the project, unhardened, afterwards — measured, not theoretical),
|
||||||
then their boxes, images, project, private bridge and trust-store
|
then their boxes, images, project, private bridge and trust-store
|
||||||
certificate are removed — irreversible, so it asks first.
|
certificate are removed — irreversible, so it asks first.
|
||||||
|
|
||||||
|
On an incus-admin member a bare revoke takes back the 'incus' membership that
|
||||||
|
grant added — reported as 'partial:', because it ends no access: incus-admin
|
||||||
|
still opens every project on this host. (One who was never granted is a named
|
||||||
|
no-op instead.) Mind what that leaves behind: with 'incus' gone, a later
|
||||||
|
'gpasswd -d <user> incus-admin' drops them into NEITHER group and their ready
|
||||||
|
project becomes unreachable — grant's "no re-grant needed" holds only while
|
||||||
|
they still hold 'incus'. --purge unmakes the provisioning the same way. Only
|
||||||
|
'gpasswd -d <user> incus-admin' ends their access, and revoke says so.
|
||||||
|
|
||||||
box revoke dev1 # take the tier; their boxes keep running
|
box revoke dev1 # take the tier; their boxes keep running
|
||||||
box revoke dev1 --purge # ...or end their sessions and delete everything
|
box revoke dev1 --purge # ...or end their sessions and delete everything
|
||||||
EOF
|
EOF
|
||||||
|
|
@ -764,12 +916,23 @@ need_arg2() {
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Row templates: {} -> $2, {1} -> the second positional. The caller picks what
|
||||||
|
# {} means because the two uses differ on purpose — the ok message reports on
|
||||||
|
# the box the operator NAMED, the confirm prompt names the RESOLVED instance,
|
||||||
|
# because a prompt about to destroy something should say which machine.
|
||||||
|
fill() { # $1 = template, $2 = what {} stands for
|
||||||
|
local t="${1//\{\}/$2}"
|
||||||
|
printf '%s\n' "${t//\{1\}/${args[1]:-}}"
|
||||||
|
}
|
||||||
|
|
||||||
confirm() { # $1 = prompt. --force, or a TTY to ask on, or we refuse.
|
confirm() { # $1 = prompt. --force, or a TTY to ask on, or we refuse.
|
||||||
if [ "$force" -eq 1 ]; then return 0; fi
|
if [ "$force" -eq 1 ]; then return 0; fi
|
||||||
[ -t 0 ] || usage_error "refusing to $1 without --force (no terminal to confirm on)"
|
[ -t 0 ] || usage_error "refusing to $1 without --force (no terminal to confirm on)"
|
||||||
local reply
|
local reply
|
||||||
printf 'box: %s? this cannot be undone. [y/N] ' "$1"
|
printf 'box: %s? this cannot be undone. [y/N] ' "$1"
|
||||||
read -r reply
|
# EOF (Ctrl-D) is an answer, and it means no. Unguarded, 'read' returns
|
||||||
|
# non-zero and 'set -e' ends the run in silence — heavy-duty/rig#43.
|
||||||
|
read -r reply || die "aborted."
|
||||||
case "$reply" in y|Y|yes|YES|Yes) return 0 ;; *) die "aborted." ;; esac
|
case "$reply" in y|Y|yes|YES|Yes) return 0 ;; *) die "aborted." ;; esac
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -946,8 +1109,16 @@ load_template() {
|
||||||
# environment must die on the host, never execute in the guest. bash's =~
|
# environment must die on the host, never execute in the guest. bash's =~
|
||||||
# anchors to the whole string — a multi-line value cannot sneak one clean
|
# anchors to the whole string — a multi-line value cannot sneak one clean
|
||||||
# line past it the way a line-oriented grep would.
|
# line past it the way a line-oriented grep would.
|
||||||
|
# The rig pin, resolved from the mint environment, in ONE place: render_userdata
|
||||||
|
# substitutes it into the seed, and the mint stamp (#103) records it onto the
|
||||||
|
# instance. Two spellings of the same default would eventually disagree, and a
|
||||||
|
# stamp that disagrees with the seed is worse than no stamp at all.
|
||||||
|
rig_repo() { printf '%s\n' "${RIG_REPO:-heavy-duty/rig}"; }
|
||||||
|
rig_ref() { printf '%s\n' "${RIG_REF:-main}"; }
|
||||||
|
|
||||||
render_userdata() {
|
render_userdata() {
|
||||||
local f="$1" repo="${RIG_REPO:-heavy-duty/rig}" ref="${RIG_REF:-main}" data
|
local f="$1" repo data ref
|
||||||
|
repo="$(rig_repo)"; ref="$(rig_ref)"
|
||||||
[[ "$repo" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]] \
|
[[ "$repo" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]] \
|
||||||
|| die "RIG_REPO must look like owner/repo: $repo"
|
|| die "RIG_REPO must look like owner/repo: $repo"
|
||||||
[[ "$ref" =~ ^[A-Za-z0-9._/-]+$ ]] \
|
[[ "$ref" =~ ^[A-Za-z0-9._/-]+$ ]] \
|
||||||
|
|
@ -970,6 +1141,144 @@ cmd_templates() {
|
||||||
echo "mint one: box new --name <box> --template <template>"
|
echo "mint one: box new --name <box> --template <template>"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# When this instance came into being. A timestamp in a CONVERGENT file would be
|
||||||
|
# churn — the same run writing a different byte every time — but a mint is not
|
||||||
|
# convergent: it happens exactly once, to exactly one instance, and is never
|
||||||
|
# re-run against it. UTC and ISO 8601 so it sorts as a string and means the same
|
||||||
|
# thing on every host that reads it back.
|
||||||
|
mint_time() { date -u +%Y-%m-%dT%H:%M:%SZ; }
|
||||||
|
|
||||||
|
# Which storage driver is under this box? The placement contract answers it:
|
||||||
|
# every box launches with --profile box-net, and that profile hardcodes the
|
||||||
|
# root disk's pool (profiles/box-net.yaml), so the pool is not a guess — it is
|
||||||
|
# read back from the profile that actually placed the instance, and only falls
|
||||||
|
# back to the name setup-host.sh creates. Two probes for the driver because
|
||||||
|
# the tiers see storage differently: 'incus storage show' is the admin read
|
||||||
|
# (the same line setup-host.sh:297 prints), 'incus storage list --format csv'
|
||||||
|
# is the one a restricted user's incus-user proxy is likelier to answer.
|
||||||
|
# Prints the driver, or nothing when neither probe answers.
|
||||||
|
storage_driver() {
|
||||||
|
local pool driver
|
||||||
|
pool="$(incus profile device get box-net root pool 2>/dev/null || true)"
|
||||||
|
[ -n "$pool" ] || pool=default
|
||||||
|
# '|| true' on all three probes, not just the first. A refusal here is an
|
||||||
|
# ANSWER ("this tier cannot read the pool"), never an error — this function
|
||||||
|
# must never be the thing that kills a mint. Today the two pipelines are
|
||||||
|
# safe only by accident: command substitution strips errexit, so a failing
|
||||||
|
# 'incus storage show' under pipefail falls through to the fallback instead
|
||||||
|
# of aborting. Add 'shopt -s inherit_errexit' to this file — exactly the
|
||||||
|
# robustness tweak #107 describes sailing through review — and that accident
|
||||||
|
# reverses into a fatal abort mid-mint, inside the function whose contract
|
||||||
|
# is NEVER fatal. Make the three probes read alike and depend on nothing.
|
||||||
|
driver="$(incus storage show "$pool" 2>/dev/null | awk '/^driver:/ {print $2; exit}' || true)"
|
||||||
|
[ -n "$driver" ] || driver="$(incus storage list --format csv 2>/dev/null \
|
||||||
|
| awk -F, -v p="$pool" '$1 == p {print $2; exit}' || true)"
|
||||||
|
printf '%s' "$driver"
|
||||||
|
}
|
||||||
|
|
||||||
|
# The shared policy behind every mark box takes for you (#104, #130):
|
||||||
|
# read the opt-out, read the storage driver, refuse to double the disk cost on
|
||||||
|
# a pool with no copy-on-write, and NEVER fail a mint over a checkpoint.
|
||||||
|
#
|
||||||
|
# It lives once on purpose. What generalises here is the MECHANISM, and the
|
||||||
|
# never-fatal contract inside it is exactly the property that must not be got
|
||||||
|
# subtly different in two places — one function, one place to review it, one
|
||||||
|
# place a future third mark inherits it from. What does NOT generalise is the
|
||||||
|
# PROSE: 'pristine' and 'bootstrapped' name different moments and are worth
|
||||||
|
# different sentences, so each wrapper below owns its own narration and passes
|
||||||
|
# it in. Two thin wrappers over one policy is the honest split; a single
|
||||||
|
# stringly-parameterised function taking five sentences would not be.
|
||||||
|
#
|
||||||
|
# The opt-out variable name is DERIVED from the label (BOX_SNAPSHOT_PRISTINE,
|
||||||
|
# BOX_SNAPSHOT_BOOTSTRAPPED) so the message can never drift from the knob the
|
||||||
|
# operator actually has to set.
|
||||||
|
#
|
||||||
|
# snapshot_mark <instance> <name> <label> <enabled> <what-this-state-is>
|
||||||
|
snapshot_mark() {
|
||||||
|
local instance="$1" name="$2" label="$3" enabled="$4" what="$5" driver knob
|
||||||
|
knob="BOX_SNAPSHOT_$(printf '%s' "$label" | tr '[:lower:]' '[:upper:]')"
|
||||||
|
if [ "$enabled" = 0 ]; then
|
||||||
|
echo "box: skipping the '$label' snapshot ($knob=0)."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
driver="$(storage_driver)"
|
||||||
|
# On btrfs (the deliberate default, host/setup-host.sh:261-298) this costs
|
||||||
|
# kilobytes until the guest diverges. On the documented 'dir' fallback
|
||||||
|
# (:294) there is no CoW at all: the snapshot is a full copy of the root,
|
||||||
|
# several GB and minutes, on EVERY mint. Skip it there — but loudly, and
|
||||||
|
# naming the by-hand command, because a silent skip teaches an operator to
|
||||||
|
# expect a mark that will not be there. With two marks the objection is
|
||||||
|
# twice the size, so the skip covers both: a 'dir' host is not asked to pay
|
||||||
|
# for one full root copy per mint, let alone two.
|
||||||
|
if [ "$driver" = dir ]; then
|
||||||
|
echo "box: NOT taking the '$label' snapshot — this host's storage pool is the 'dir'" >&2
|
||||||
|
echo "box: driver, which has no copy-on-write, so the mark would be a full multi-GB copy" >&2
|
||||||
|
echo "box: of the root disk on every mint rather than the near-free btrfs one." >&2
|
||||||
|
echo "box: take it by hand if you want it here: box snapshot $name $label" >&2
|
||||||
|
echo "box: or fix the host (btrfs is what 'box setup-host' installs by default)." >&2
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
# Neither probe answered — an unusual host, or a tier that cannot read the
|
||||||
|
# pool. Proceed, and say what was assumed. The two mistakes are not
|
||||||
|
# symmetric: a snapshot taken on a 'dir' host wastes disk the operator can
|
||||||
|
# see in 'box info' and delete, while a snapshot NOT taken is the moment
|
||||||
|
# gone for good, which is the whole thing this exists to prevent.
|
||||||
|
[ -n "$driver" ] || echo "box: could not read the storage driver — taking '$label' anyway," \
|
||||||
|
"assuming copy-on-write ($knob=0 to skip)." >&2
|
||||||
|
echo "box: marking '$label' — $what"
|
||||||
|
if ! incus snapshot create "$instance" "$label" </dev/null; then
|
||||||
|
# An undo is not the mint's product: a mint that worked must not be
|
||||||
|
# failed by a checkpoint that didn't.
|
||||||
|
echo "box: WARNING — could not take the '$label' snapshot (incus said why above)." >&2
|
||||||
|
echo "box: the box is fine; it just has no $label mark. The mint continues." >&2
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
marks="$marks $label"
|
||||||
|
echo "box: roll back to it any time with: box restore $name $label"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Did THIS run actually create <label>? The only honest source for a message
|
||||||
|
# that offers a rollback: see 'marks' above for why the return status cannot
|
||||||
|
# answer it.
|
||||||
|
mark_taken() { case " $marks " in *" $1 "*) return 0 ;; *) return 1 ;; esac; }
|
||||||
|
|
||||||
|
# Take the 'pristine' snapshot, or say loudly why not. Default ON: the value
|
||||||
|
# of this mark only exists if it is already there on the bad day, and nobody
|
||||||
|
# takes it by hand at the one moment it is true. The escape hatch is the
|
||||||
|
# BOX_LAUNCH_TIMEOUT shape — an environment knob, not another flag on 'new'.
|
||||||
|
snapshot_pristine() {
|
||||||
|
snapshot_mark "$1" "$2" pristine "${BOX_SNAPSHOT_PRISTINE:-1}" \
|
||||||
|
"the guest after cloud-init, before rig converges anything."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Take the 'bootstrapped' snapshot (#130), after the rig hook box RAN and
|
||||||
|
# WATCHED SUCCEED. Same default, same opt-out shape, same never-fatal
|
||||||
|
# contract as 'pristine' — it is the same policy, one function up.
|
||||||
|
#
|
||||||
|
# The label is deliberately CONDITIONAL where 'pristine' is unconditional, and
|
||||||
|
# the asymmetry is the point rather than an oversight. 'pristine' marks a
|
||||||
|
# MOMENT that every fresh mint has, so it is taken on every fresh mint.
|
||||||
|
# 'bootstrapped' marks an EVENT — "a tenant role converged, and nothing has
|
||||||
|
# happened since" — and a blank box has no such event. Minting the label there
|
||||||
|
# anyway would either duplicate 'pristine' byte for byte (nothing ran between
|
||||||
|
# them, so it is the same disk state at twice the cost, which is precisely the
|
||||||
|
# doubling the 'dir' skip above exists to refuse) or, worse, assert a
|
||||||
|
# convergence that never happened. So a hookless box gets nothing, and the
|
||||||
|
# label's presence is itself information: this box had a role and box saw it
|
||||||
|
# converge.
|
||||||
|
#
|
||||||
|
# Read only in that direction. PRESENCE means the mint-time hook succeeded and
|
||||||
|
# the box has not been touched since; ABSENCE means nothing at all — a blank
|
||||||
|
# template, a 'dir' pool, an opt-out, or a role converged by hand afterwards
|
||||||
|
# (see cmd_new's hook-failure path, which hands the operator the command
|
||||||
|
# because box cannot honestly take a mark for a run it did not watch). Nothing
|
||||||
|
# in box may treat a missing 'bootstrapped' as evidence a box is unconverged.
|
||||||
|
snapshot_bootstrapped() {
|
||||||
|
snapshot_mark "$1" "$2" bootstrapped \
|
||||||
|
"${BOX_SNAPSHOT_BOOTSTRAPPED:-1}" \
|
||||||
|
"the tenant role converged, and nothing has touched the box since."
|
||||||
|
}
|
||||||
|
|
||||||
cmd_new() {
|
cmd_new() {
|
||||||
[ -n "$name" ] || usage_error "usage: $(synopsis_of new)"
|
[ -n "$name" ] || usage_error "usage: $(synopsis_of new)"
|
||||||
require_stack
|
require_stack
|
||||||
|
|
@ -981,10 +1290,78 @@ cmd_new() {
|
||||||
case "$from" in */*) snap="${from#*/}" ;; esac
|
case "$from" in */*) snap="${from#*/}" ;; esac
|
||||||
srcref="$(iname_of "$src")"; [ -n "$snap" ] && srcref="$srcref/$snap"
|
srcref="$(iname_of "$src")"; [ -n "$snap" ] && srcref="$srcref/$snap"
|
||||||
incus copy "$srcref" "$instance"
|
incus copy "$srcref" "$instance"
|
||||||
|
# 'incus copy' carries every user.* key forward (audit B2) — which is what
|
||||||
|
# makes a clone know its template and user for free, and is also why the
|
||||||
|
# mint stamp (#103) cannot simply ride along. A clone that inherited the
|
||||||
|
# stamp verbatim would claim to have been minted at the source's mint time,
|
||||||
|
# by the box version that minted the SOURCE, in a mint that never touched
|
||||||
|
# this instance. That is not a stale field, it is a false one.
|
||||||
|
#
|
||||||
|
# So re-stamp exactly the keys that describe THIS instance's coming into
|
||||||
|
# being, and leave the rest alone:
|
||||||
|
# · version / created / schema — the clone was made HERE, NOW, by THIS box
|
||||||
|
# · origin=clone, origin.from=<srcref> — how, and from what
|
||||||
|
# Deliberately NOT re-stamped, because they are lineage and stay true: the
|
||||||
|
# clone's disk really did come from that image, that template, that user and
|
||||||
|
# that rig role — reading them off the source is the whole point of a clone.
|
||||||
|
# ('incus copy' preserves the instance type too, so mode stays true as well.)
|
||||||
|
#
|
||||||
|
# 'mode.asked' is the one key that sits in NEITHER column, and so it is
|
||||||
|
# CLEARED rather than re-stamped or inherited. It is a mint-event fact —
|
||||||
|
# only the mint knew whether a container was asked for or fallen back into
|
||||||
|
# for want of /dev/kvm — and the asker was the SOURCE's operator. A clone
|
||||||
|
# refuses --vm/--container outright (nobody was asked anything here), so an
|
||||||
|
# inherited 'asked' makes 'box info' print a demand that was never made of
|
||||||
|
# this instance. There is no true value to re-stamp it with: the honest
|
||||||
|
# answer is absence, and absence is already how the whole block renders
|
||||||
|
# what it does not know — the MODE line simply does not print, while TYPE
|
||||||
|
# above still says VM or CT off the preserved instance type.
|
||||||
|
#
|
||||||
|
# origin.from records ONE hop. A clone of a clone names its parent and
|
||||||
|
# forgets its grandparent: the alternative is an unbounded chain in a config
|
||||||
|
# value, and the parent is the box an operator can actually go look at.
|
||||||
|
incus config set "$instance" \
|
||||||
|
user.box.schema="$BOX_STAMP_SCHEMA" \
|
||||||
|
user.box.version="$(box_version)" \
|
||||||
|
user.box.created="$(mint_time)" \
|
||||||
|
user.box.origin=clone \
|
||||||
|
user.box.origin.from="$srcref"
|
||||||
|
# Cleared, not set-to-empty: an empty value is still a key, and a reader
|
||||||
|
# that greps the config would find it. Tolerated failure because the source
|
||||||
|
# may predate the stamp and never have carried the key at all — a clone
|
||||||
|
# must not die over a key that was already absent.
|
||||||
|
incus config unset "$instance" user.box.mode.asked >/dev/null 2>&1 || true
|
||||||
incus start "$instance"
|
incus start "$instance"
|
||||||
wait_agent "$instance"
|
wait_agent "$instance"
|
||||||
reset_identity "$instance"
|
reset_identity "$instance"
|
||||||
echo "box: cloned $srcref — isolation and auth state carry over from the source."
|
echo "box: cloned $srcref — isolation and auth state carry over from the source."
|
||||||
|
# A clone gets NO 'pristine' of its own (#104). It skips cloud-init and
|
||||||
|
# the rig hook entirely, so the pristine MOMENT never happens here —
|
||||||
|
# anything box marked at clone time would be "whatever the source was",
|
||||||
|
# converged and worked-in, wearing a label that promises pristine Debian.
|
||||||
|
# That is worse than no mark at all, so the mint-path snapshot lives
|
||||||
|
# strictly in the fresh-mint branch below and never runs here.
|
||||||
|
#
|
||||||
|
# What a clone gets instead is INHERITANCE, and it is already correct:
|
||||||
|
# 'incus copy <box>' carries the source's snapshots, so a source that has
|
||||||
|
# a 'pristine' hands the clone a real one — the true pristine state of
|
||||||
|
# the disk this clone descends from. Copying a SNAPSHOT ('--from box/snap')
|
||||||
|
# carries no snapshot list, so that clone starts with none. Both are
|
||||||
|
# honest; neither is invented. Say which one this is rather than leaving
|
||||||
|
# the operator to run 'box info' to find out.
|
||||||
|
# Capture first, THEN read (#124's class). Piping a multi-line incus
|
||||||
|
# writer straight into an early-exit reader lets grep close the pipe on
|
||||||
|
# the first match, SIGPIPE incus, and hand pipefail a 141 — which here
|
||||||
|
# would read as "no pristine" and narrate the WRONG inheritance shape on a
|
||||||
|
# clone that actually has one. Un-racy today at this writer's size, but
|
||||||
|
# the assignment costs nothing and does not depend on that staying true.
|
||||||
|
local snaps; snaps="$(incus snapshot list "$instance" --format csv 2>/dev/null || true)"
|
||||||
|
if printf '%s\n' "$snaps" | grep -q '^pristine,'; then
|
||||||
|
echo "box: it inherited the source's snapshots, 'pristine' among them (box restore $name pristine)."
|
||||||
|
else
|
||||||
|
echo "box: no 'pristine' mark here — a clone has no pristine moment to capture, and box"
|
||||||
|
echo "box: will not label the source's state as one. 'box info $name' lists what it did inherit."
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
local t="${template:-blank}" m extra=()
|
local t="${template:-blank}" m extra=()
|
||||||
load_template "$t"
|
load_template "$t"
|
||||||
|
|
@ -1021,6 +1398,47 @@ cmd_new() {
|
||||||
# which user. 'incus copy' preserves user.* keys (audit B2), so a clone
|
# which user. 'incus copy' preserves user.* keys (audit B2), so a clone
|
||||||
# knows what it is without ever consulting the template again.
|
# knows what it is without ever consulting the template again.
|
||||||
#
|
#
|
||||||
|
# And the rest of what this line knows and used to drop on the floor (#103).
|
||||||
|
# There is no host-side per-box store — the Incus instance config IS the
|
||||||
|
# database — so a fact not written here is simply gone the moment the mint
|
||||||
|
# returns. The stamp describes the MINT, not the outcome: it lands before
|
||||||
|
# cloud-init and before rig, and nothing later edits it.
|
||||||
|
#
|
||||||
|
# schema the stamp's shape, so a future reader knows what it is holding
|
||||||
|
# version the box that minted it — 'box --version' is a fact about the
|
||||||
|
# binary in front of you, never about the box you are looking at
|
||||||
|
# image the alias asked for. It is an UNPINNED alias on a moving
|
||||||
|
# remote: two boxes minted a month apart from "the same
|
||||||
|
# template" are not the same box, and the alias alone cannot
|
||||||
|
# say so. What it resolved to is pinned after the launch below.
|
||||||
|
# mode what it minted as, and what was ASKED — a container that fell
|
||||||
|
# back for want of /dev/kvm and one the operator asked for read
|
||||||
|
# identically afterwards, and only the mint knew which
|
||||||
|
# role the rig role box auto-runs at the hook below
|
||||||
|
# rig.* WHICH rig converged it, stamped only for a seed that actually
|
||||||
|
# installs rig from the pin ('blank' seeds none, so it gets none)
|
||||||
|
# created when. See mint_time() for why a timestamp belongs here.
|
||||||
|
# origin mint. A clone re-stamps it (see the --from branch above).
|
||||||
|
#
|
||||||
|
# NOT stamped, on purpose: cpu/memory (limits.* already hold them, and a
|
||||||
|
# duplicate drifts the first time someone edits the limit by hand); disk
|
||||||
|
# (a VM's is the root device size, and a container's does not exist — its
|
||||||
|
# root rides the pool, so a stamped value would be fiction); and tier,
|
||||||
|
# which box_tier() derives from whoever is ASKING, not from the box.
|
||||||
|
local stamp=(
|
||||||
|
--config user.box.schema="$BOX_STAMP_SCHEMA"
|
||||||
|
--config user.box.version="$(box_version)"
|
||||||
|
--config user.box.image="$T_IMAGE"
|
||||||
|
--config user.box.mode="$m"
|
||||||
|
--config user.box.mode.asked="$mode"
|
||||||
|
--config user.box.created="$(mint_time)"
|
||||||
|
--config user.box.origin=mint
|
||||||
|
)
|
||||||
|
[ -z "$T_BOOTSTRAP_ROLE" ] || stamp+=(--config user.box.role="$T_BOOTSTRAP_ROLE")
|
||||||
|
if grep -q '@RIG_REPO@' "$root/templates/$t/user-data.yaml" 2>/dev/null; then
|
||||||
|
stamp+=(--config user.box.rig.repo="$(rig_repo)" --config user.box.rig.ref="$(rig_ref)")
|
||||||
|
fi
|
||||||
|
#
|
||||||
# The launch is narrated and TIME-BOXED (#93). Twice in the 2026-07-19
|
# The launch is narrated and TIME-BOXED (#93). Twice in the 2026-07-19
|
||||||
# release drill the child 'incus launch' wedged before the create was
|
# release drill the child 'incus launch' wedged before the create was
|
||||||
# even accepted — 'incus operation list' empty, the instance never
|
# even accepted — 'incus operation list' empty, the instance never
|
||||||
|
|
@ -1039,6 +1457,7 @@ cmd_new() {
|
||||||
--config user.box=1 \
|
--config user.box=1 \
|
||||||
--config user.box.template="$t" \
|
--config user.box.template="$t" \
|
||||||
--config user.box.user="$T_USER" \
|
--config user.box.user="$T_USER" \
|
||||||
|
"${stamp[@]}" \
|
||||||
--config limits.cpu="$T_CPU" \
|
--config limits.cpu="$T_CPU" \
|
||||||
--config limits.memory="$T_MEMORY" \
|
--config limits.memory="$T_MEMORY" \
|
||||||
--config cloud-init.user-data="$(render_userdata "$root/templates/$t/user-data.yaml")" \
|
--config cloud-init.user-data="$(render_userdata "$root/templates/$t/user-data.yaml")" \
|
||||||
|
|
@ -1076,6 +1495,20 @@ cmd_new() {
|
||||||
# Not a wedge: incus refused and said why on stderr, right above.
|
# Not a wedge: incus refused and said why on stderr, right above.
|
||||||
die "incus launch failed (exit $rc)"
|
die "incus launch failed (exit $rc)"
|
||||||
fi
|
fi
|
||||||
|
# The one field the launch line could not know: 'user.box.image' above is
|
||||||
|
# the ALIAS, and an alias on a moving remote is not a reproducible fact.
|
||||||
|
# Incus resolves it during the launch and records what it landed on in
|
||||||
|
# volatile.base_image — read it back and pin it into the stamp, so an
|
||||||
|
# incident six months from now can ask "was this box built on the image
|
||||||
|
# that broke?" and get an answer instead of a template name.
|
||||||
|
#
|
||||||
|
# Best-effort BY CONSTRUCTION, and that is the whole design of this line:
|
||||||
|
# it runs only after a launch that already succeeded, and a box that exists
|
||||||
|
# and boots must never be failed over a provenance field. Every failure
|
||||||
|
# here is silent and leaves the alias standing as the honest partial answer
|
||||||
|
# — a stamp with no fingerprint, which is exactly how cmd_info renders it.
|
||||||
|
local fp; fp="$(incus config get "$instance" volatile.base_image 2>/dev/null || true)"
|
||||||
|
[ -z "$fp" ] || incus config set "$instance" user.box.image.fingerprint="$fp" >/dev/null 2>&1 || true
|
||||||
wait_agent "$instance"
|
wait_agent "$instance"
|
||||||
echo "box: waiting for phase-1 (cloud-init)..."
|
echo "box: waiting for phase-1 (cloud-init)..."
|
||||||
echo "box: (its full narration, live: incus exec $name -- tail -f /var/log/cloud-init-output.log)"
|
echo "box: (its full narration, live: incus exec $name -- tail -f /var/log/cloud-init-output.log)"
|
||||||
|
|
@ -1108,6 +1541,19 @@ cmd_new() {
|
||||||
echo " not serving, a VPN resolver the box inherits). Diagnose it: box doctor" >&2
|
echo " not serving, a VPN resolver the box inherits). Diagnose it: box doctor" >&2
|
||||||
die "cloud-init failed — the box is incomplete, so refusing to hand it over"
|
die "cloud-init failed — the box is incomplete, so refusing to hand it over"
|
||||||
fi
|
fi
|
||||||
|
# The pristine mark (#104, child of rig#62). RIGHT HERE — cloud-init has
|
||||||
|
# finished and rig has not started — the guest is pristine Debian plus
|
||||||
|
# box's seed, and nothing else. That state exists for a few seconds on
|
||||||
|
# every mint and has never been captured; rig#62's "back to pristine
|
||||||
|
# Debian" is exactly this state, so take it while it is true.
|
||||||
|
#
|
||||||
|
# Unconditional within a fresh mint, not gated on T_BOOTSTRAP_ROLE: a
|
||||||
|
# blank box has no rig hook but it does have the same pristine moment,
|
||||||
|
# and 'box restore <box> pristine' should mean one thing on every box.
|
||||||
|
#
|
||||||
|
# NEVER fatal. This is an undo, not the mint's product — a mint that
|
||||||
|
# worked must not be failed by a checkpoint that didn't.
|
||||||
|
snapshot_pristine "$instance" "$name"
|
||||||
# The tenant convergence (#81): the seed above is thin — the user, tmux,
|
# The tenant convergence (#81): the seed above is thin — the user, tmux,
|
||||||
# rig — and what the box BECOMES is rig's job. A template that names a
|
# rig — and what the box BECOMES is rig's job. A template that names a
|
||||||
# bootstrap role gets it auto-run here, as root inside the guest, because
|
# bootstrap role gets it auto-run here, as root inside the guest, because
|
||||||
|
|
@ -1123,20 +1569,57 @@ cmd_new() {
|
||||||
echo "box: rig bootstrap $T_BOOTSTRAP_ROLE FAILED in $name." >&2
|
echo "box: rig bootstrap $T_BOOTSTRAP_ROLE FAILED in $name." >&2
|
||||||
echo "box: the box is up and the seed is intact — the role converges, so re-run it:" >&2
|
echo "box: the box is up and the seed is intact — the role converges, so re-run it:" >&2
|
||||||
echo " box shell $name # then: sudo rig bootstrap $T_BOOTSTRAP_ROLE" >&2
|
echo " box shell $name # then: sudo rig bootstrap $T_BOOTSTRAP_ROLE" >&2
|
||||||
|
# The mark box cannot take for you (#130), said HERE because here is
|
||||||
|
# the only moment the operator is looking. 'bootstrapped' means "box
|
||||||
|
# ran the hook and watched it succeed" — a by-hand re-run happens in a
|
||||||
|
# shell box does not watch, so box cannot know when (or whether) it
|
||||||
|
# converged, and a mark taken on a guess would be a label asserting a
|
||||||
|
# convergence box never saw. That is the same lie #104 refused on the
|
||||||
|
# clone path. So: hand over the command instead of inventing the fact.
|
||||||
|
echo "box: no 'bootstrapped' mark was taken — box only marks a hook it watched succeed," >&2
|
||||||
|
echo " and a by-hand re-run happens in a shell it does not watch. Take it yourself once" >&2
|
||||||
|
echo " the role converges, at the moment it is true:" >&2
|
||||||
|
echo " box snapshot $name bootstrapped" >&2
|
||||||
|
# Only offer the rollback that EXISTS. Three mints reach this line
|
||||||
|
# with no pristine mark, all by this stack's own design: a 'dir' pool
|
||||||
|
# (skipped), BOX_SNAPSHOT_PRISTINE=0, and a refused create (warned,
|
||||||
|
# mint continued). On a 'dir' host that is EVERY hook failure, and an
|
||||||
|
# operator standing at the one moment this message exists for would
|
||||||
|
# copy-paste a restore that errors. Offering a mark the disk does not
|
||||||
|
# have is the same lie this PR refuses one message earlier, when it
|
||||||
|
# declines to invent a 'bootstrapped' it never watched.
|
||||||
|
if mark_taken pristine; then
|
||||||
|
echo "box: 'box restore $name pristine' is still there if you would rather start the role over." >&2
|
||||||
|
fi
|
||||||
die "the tenant role did not converge — the box is incomplete, so refusing to call it ready"
|
die "the tenant role did not converge — the box is incomplete, so refusing to call it ready"
|
||||||
fi
|
fi
|
||||||
|
# The hook RAN and box WATCHED it succeed — so right here the box is
|
||||||
|
# converged and untouched, the state an operator re-mints ten minutes
|
||||||
|
# for when they break a tenant role but not the OS under it. Never
|
||||||
|
# fatal, same as 'pristine': a mint that worked is not failed by a
|
||||||
|
# checkpoint that didn't.
|
||||||
|
snapshot_bootstrapped "$instance" "$name"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
# The login hint belongs to the claude template — read the EFFECTIVE
|
# The login hint belongs to the claude-box template — read the EFFECTIVE
|
||||||
# template off the instance, so a clone of a claude box gets it too and a
|
# template off the instance, so a clone of a claude-box box gets it too and
|
||||||
# blank box is not told to run a binary it doesn't have.
|
# a blank box is not told to run a binary it doesn't have.
|
||||||
|
#
|
||||||
|
# Both spellings match, and that is not an alias for the ROLE. The role
|
||||||
|
# names are a hard cut (rig#76) — 'rig bootstrap claude' is gone, so the
|
||||||
|
# seeds ask for 'claude-box' and nothing here softens that. What these arms
|
||||||
|
# read is user.box.template, a stamp left on an INSTANCE at its own mint
|
||||||
|
# time: every box minted before the rename carries the bare name forever,
|
||||||
|
# and a clone carries it forward. Refusing the old spelling here would not
|
||||||
|
# cut anything over, it would only drop the hint on boxes that predate the
|
||||||
|
# rename — the same reason user.claudebox is honored everywhere else.
|
||||||
local eff; eff="$(incus config get "$instance" user.box.template 2>/dev/null || true)"
|
local eff; eff="$(incus config get "$instance" user.box.template 2>/dev/null || true)"
|
||||||
[ -z "$eff" ] && [ "$(incus config get "$instance" user.claudebox 2>/dev/null || true)" = 1 ] && eff=claude
|
[ -z "$eff" ] && [ "$(incus config get "$instance" user.claudebox 2>/dev/null || true)" = 1 ] && eff=claude-box
|
||||||
if [ "$eff" = claude ]; then
|
if [ "$eff" = claude-box ] || [ "$eff" = claude ]; then
|
||||||
echo "box: ready — 'box shell $name'. Log into Claude inside: run 'claude' then /login."
|
echo "box: ready — 'box shell $name'. Log into Claude inside: run 'claude' then /login."
|
||||||
elif [ "$eff" = staging ]; then
|
elif [ "$eff" = staging-box ] || [ "$eff" = staging ]; then
|
||||||
echo "box: ready — 'box shell $name'. The tailnet join stays operator-run (it holds a key box must never see):"
|
echo "box: ready — 'box shell $name'. The tailnet join stays operator-run (it holds a key box must never see):"
|
||||||
echo " box shell $name # then: sudo rig bootstrap workload --hostname $name"
|
echo " box shell $name # then: sudo rig bootstrap workload-server --hostname $name"
|
||||||
else
|
else
|
||||||
echo "box: ready — 'box shell $name'."
|
echo "box: ready — 'box shell $name'."
|
||||||
fi
|
fi
|
||||||
|
|
@ -1219,6 +1702,85 @@ cmd_list() {
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# One instance config key. 'incus config get' on an UNSET key prints empty and
|
||||||
|
# exits 0 (audit B4) — so the '|| true' here covers the daemon refusing, not the
|
||||||
|
# key being absent, and every caller below reads absence as an empty string.
|
||||||
|
box_cfg() { incus config get "$1" "$2" 2>/dev/null || true; }
|
||||||
|
|
||||||
|
# The mint stamp (#103), read back for 'box info'. Every key is optional and so
|
||||||
|
# is the whole block: a box minted before the stamp existed carries none of it
|
||||||
|
# and must render as a box with blanks — never as an error, and never as a box
|
||||||
|
# wearing a mint time it does not have. Legacy boxes are not a transitional
|
||||||
|
# case: a box outlives the release that minted it, which is exactly what the
|
||||||
|
# legacy 'user.claudebox' tag already says out loud at resolve_box.
|
||||||
|
box_provenance() {
|
||||||
|
local i="$1" schema created ver img fp m asked tpl u role rrepo rref origin from
|
||||||
|
local imp impby impl implby impn
|
||||||
|
schema="$(box_cfg "$i" user.box.schema)"
|
||||||
|
created="$(box_cfg "$i" user.box.created)"; ver="$(box_cfg "$i" user.box.version)"
|
||||||
|
img="$(box_cfg "$i" user.box.image)"; fp="$(box_cfg "$i" user.box.image.fingerprint)"
|
||||||
|
m="$(box_cfg "$i" user.box.mode)"; asked="$(box_cfg "$i" user.box.mode.asked)"
|
||||||
|
tpl="$(box_cfg "$i" user.box.template)"; u="$(box_cfg "$i" user.box.user)"
|
||||||
|
role="$(box_cfg "$i" user.box.role)"
|
||||||
|
rrepo="$(box_cfg "$i" user.box.rig.repo)"; rref="$(box_cfg "$i" user.box.rig.ref)"
|
||||||
|
origin="$(box_cfg "$i" user.box.origin)"; from="$(box_cfg "$i" user.box.origin.from)"
|
||||||
|
# The import event (#131) — a THIRD fact, read from its own keys. 'origin'
|
||||||
|
# above still says mint or clone, which is what it has always meant.
|
||||||
|
imp="$(box_cfg "$i" user.box.imported)"; impby="$(box_cfg "$i" user.box.imported.by)"
|
||||||
|
impl="$(box_cfg "$i" user.box.imported.last)"
|
||||||
|
implby="$(box_cfg "$i" user.box.imported.last.by)"
|
||||||
|
impn="$(box_cfg "$i" user.box.imported.count)"
|
||||||
|
# A pre-rename box has no metadata at all but is always a Claude box — the
|
||||||
|
# same mapping box_user() makes, for the same reason.
|
||||||
|
[ -n "$tpl" ] || [ "$(box_cfg "$i" user.claudebox)" != 1 ] || { tpl=claude; u="${u:-claude}"; }
|
||||||
|
|
||||||
|
echo
|
||||||
|
if [ -n "$created" ] || [ -n "$ver" ]; then
|
||||||
|
printf '%-11s%s\n' MINTED "${created:-(time not recorded)} by box ${ver:-unknown}"
|
||||||
|
else
|
||||||
|
printf '%-11s%s\n' MINTED "(not recorded — this box predates the mint stamp)"
|
||||||
|
fi
|
||||||
|
# Directly under MINTED, because adjacency is what defeats the misreading:
|
||||||
|
# the mint time above is the ARTIFACT's, carried across the trip on purpose,
|
||||||
|
# and a reader who meets it alone will take it for this host's.
|
||||||
|
#
|
||||||
|
# The qualifier states the one thing box actually knows — the ORDERING. It
|
||||||
|
# does not say "on another host": a box can be exported and re-imported onto
|
||||||
|
# the SAME host (that is the installer's own upgrade advice, #66), and
|
||||||
|
# nothing on the box records which host minted it. Inventing a location here
|
||||||
|
# would be precisely the fabricated fact the stamp exists to avoid.
|
||||||
|
if [ -n "$impl" ] || [ -n "$imp" ]; then
|
||||||
|
printf '%-11s%s\n' IMPORTED \
|
||||||
|
"${impl:-$imp} by box ${implby:-${impby:-unknown}} (the mint above predates it)"
|
||||||
|
# The birth of the import history, shown only when it is a DIFFERENT trip
|
||||||
|
# from the latest — on a single import both pairs hold the same values and
|
||||||
|
# a second line would be noise. Continuation indent, no second label.
|
||||||
|
if [ -n "$imp" ] && [ "$imp" != "${impl:-$imp}" ]; then
|
||||||
|
printf '%-11s%s\n' '' "import ${impn:-?} — the first was $imp by box ${impby:-unknown}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [ -n "$tpl" ]; then
|
||||||
|
local paren=""
|
||||||
|
[ -z "$u" ] || paren="user $u"
|
||||||
|
[ -z "$role" ] || paren="${paren:+$paren, }role $role"
|
||||||
|
printf '%-11s%s\n' TEMPLATE "$tpl${paren:+ ($paren)}"
|
||||||
|
fi
|
||||||
|
[ -z "$img" ] || printf '%-11s%s\n' IMAGE "$img${fp:+ @ ${fp:0:12}…}"
|
||||||
|
# The mode is only worth a line alongside what was ASKED: TYPE above already
|
||||||
|
# says VM or CT, but only the mint knew whether a container was chosen or
|
||||||
|
# fallen back into for want of /dev/kvm.
|
||||||
|
[ -z "$asked" ] || printf '%-11s%s\n' MODE "${m:-?} (asked: $asked)"
|
||||||
|
[ -z "$rrepo" ] || printf '%-11s%s\n' RIG "$rrepo@${rref:-?}"
|
||||||
|
[ -z "$origin" ] || printf '%-11s%s\n' ORIGIN "$origin${from:+ of $from}"
|
||||||
|
# A schema box does not recognise is NEWER than box, not broken: show what is
|
||||||
|
# understood and say so, rather than refusing to describe a box that a later
|
||||||
|
# release minted perfectly well. (A non-integer lands here too, which is the
|
||||||
|
# right side to fail on.)
|
||||||
|
if [ -n "$schema" ] && { ! [ "$schema" -eq "$schema" ] 2>/dev/null || [ "$schema" -gt "$BOX_STAMP_SCHEMA" ]; }; then
|
||||||
|
printf '%-11s%s\n' NOTE "stamp schema '$schema' is newer than this box ($(box_version)) reads ($BOX_STAMP_SCHEMA) — showing what it understands"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
cmd_info() {
|
cmd_info() {
|
||||||
local box="${args[0]}" row
|
local box="${args[0]}" row
|
||||||
if [ "$json" -eq 1 ]; then incus list "$inst" --format json; return; fi
|
if [ "$json" -eq 1 ]; then incus list "$inst" --format json; return; fi
|
||||||
|
|
@ -1240,6 +1802,12 @@ cmd_info() {
|
||||||
printf '%-11s%s → port %s\n' EXPOSED "${listen#tcp:}" "${d#expose-}"
|
printf '%-11s%s → port %s\n' EXPOSED "${listen#tcp:}" "${d#expose-}"
|
||||||
done < <(incus config device list "$inst" 2>/dev/null)
|
done < <(incus config device list "$inst" 2>/dev/null)
|
||||||
|
|
||||||
|
# What built this box, from what, when, with which box (#103). Nothing else
|
||||||
|
# on the host records it — the instance config IS the store — so a stamp
|
||||||
|
# nothing surfaces is a stamp nobody has. 'box info --json' carries the keys
|
||||||
|
# for free: 'incus list --format json' includes config verbatim.
|
||||||
|
box_provenance "$inst"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
case "${snaps:-0}" in
|
case "${snaps:-0}" in
|
||||||
''|0)
|
''|0)
|
||||||
|
|
@ -1277,13 +1845,18 @@ box_user() {
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd_shell() { incus exec "$inst" -- sudo -u "$(box_user "$inst")" -i; }
|
cmd_shell() { incus exec "$inst" -- sudo -u "$(box_user "$inst")" -i; }
|
||||||
cmd_exec() { incus exec "$inst" -- sudo -u "$(box_user "$inst")" -i "${args[@]:1}"; }
|
# sudo -i joins its command argv into one shell string. In that join, a
|
||||||
|
# backslash-newline becomes a shell continuation and silently deletes the
|
||||||
|
# newline from a multi-line `box exec` payload (#169). Keep the login
|
||||||
|
# environment explicitly, but let the inner shell exec the original argv.
|
||||||
|
cmd_exec() { incus exec "$inst" -- sudo -u "$(box_user "$inst")" -H bash -lc 'cd ~ && exec "$@"' _ "${args[@]:1}"; }
|
||||||
|
|
||||||
# A shell is a child of the exec connection: drop the terminal and everything
|
# A shell is a child of the exec connection: drop the terminal and everything
|
||||||
# in it is SIGHUP'd — a long Claude run dies with it. tmux 'new-session -A'
|
# in it is SIGHUP'd — a long Claude run dies with it. tmux 'new-session -A'
|
||||||
# attaches when the session exists and creates it when it doesn't, so starting
|
# attaches when the session exists and creates it when it doesn't, so starting
|
||||||
# work and reattaching after a disconnect are the same command. 'shell' stays
|
# work and reattaching after a disconnect are the same command. 'shell' stays
|
||||||
# bare on purpose — two verbs, two contracts.
|
# bare on purpose — two verbs, two contracts. Unlike cmd_exec, tmux has no
|
||||||
|
# caller-supplied command payload; its session name is validated below.
|
||||||
cmd_tmux() {
|
cmd_tmux() {
|
||||||
local session="${args[1]:-main}"
|
local session="${args[1]:-main}"
|
||||||
case "$session" in
|
case "$session" in
|
||||||
|
|
@ -1389,6 +1962,69 @@ cmd_import() {
|
||||||
tag="$(incus config get "$target" user.box 2>/dev/null || true)"
|
tag="$(incus config get "$target" user.box 2>/dev/null || true)"
|
||||||
[ "$tag" = 1 ] || tag="$(incus config get "$target" user.claudebox 2>/dev/null || true)"
|
[ "$tag" = 1 ] || tag="$(incus config get "$target" user.claudebox 2>/dev/null || true)"
|
||||||
[ "$tag" = 1 ] || incus config set "$target" user.box=1
|
[ "$tag" = 1 ] || incus config set "$target" user.box=1
|
||||||
|
# The import EVENT (#131). Everything above either restores the ARTIFACT's
|
||||||
|
# truth or re-establishes THIS host's boundary; none of it records that the
|
||||||
|
# trip happened. The mint stamp (#103) rides inside the artifact verbatim —
|
||||||
|
# which is correct, mint facts belong to the originating host — and that is
|
||||||
|
# exactly what creates the gap: an imported box is otherwise indistinguishable
|
||||||
|
# from one minted here at the artifact's mint time.
|
||||||
|
#
|
||||||
|
# Deliberately NOT 'origin=import'. 'origin' answers how this instance came
|
||||||
|
# into BEING — mint or clone — and overwriting it destroys that answer: a
|
||||||
|
# clone that was exported and imported would come back claiming to be an
|
||||||
|
# import, with nothing left saying it was ever a clone and an 'origin.from'
|
||||||
|
# naming a lineage no key explains any more. The import is a THIRD fact,
|
||||||
|
# orthogonal to the first two, so it takes its OWN keys, and every key the
|
||||||
|
# artifact carried — origin, origin.from, created, version, template, user,
|
||||||
|
# image, role, rig.* — is left exactly as it arrived.
|
||||||
|
#
|
||||||
|
# Birth pair + latest pair, the shape heavy-duty/rig#61 settled on for the
|
||||||
|
# same repeated-event question. It transfers: a box can make the trip more
|
||||||
|
# than once, and these keys ride inside the artifact on every subsequent one.
|
||||||
|
# · imported / imported.by FIRST-wins. Pinned once, never rewritten.
|
||||||
|
# · imported.last / .last.by LAST-wins. When it landed HERE.
|
||||||
|
# · imported.count how many trips — first and last alone
|
||||||
|
# cannot tell 2 apart from 5, and nothing
|
||||||
|
# else on the box can reconstruct it.
|
||||||
|
# Last-wins alone would repeat, one level down, the very mistake this key
|
||||||
|
# exists to avoid: the second import would erase the evidence of the first.
|
||||||
|
# First-wins alone cannot answer "when did this land on THIS host", which is
|
||||||
|
# the question an operator actually asks.
|
||||||
|
local first_at prev_n n now
|
||||||
|
first_at="$(incus config get "$target" user.box.imported 2>/dev/null || true)"
|
||||||
|
prev_n="$(incus config get "$target" user.box.imported.count 2>/dev/null || true)"
|
||||||
|
# A count that is absent (the first trip) or not an integer (a hand-edited
|
||||||
|
# config, a foreign user.box.imported.count) must never fail an import that
|
||||||
|
# has already happened — arithmetic on it under 'set -e' would. Same side the
|
||||||
|
# schema check in box_provenance() falls on: degrade, never die.
|
||||||
|
# Two holes, and they have to close together. `[ 08 -eq 08 ]` PASSES — test
|
||||||
|
# parses decimal — and then `$((08 + 1))` aborts with "value too great for
|
||||||
|
# base", because arithmetic reads a leading zero as octal. That abort lands
|
||||||
|
# after the physical 'incus import' and before the stamp, the placement fix
|
||||||
|
# and the start: precisely the window this guard exists to protect. So the
|
||||||
|
# digits-only case closes sign and garbage, and 10# forces base ten.
|
||||||
|
case "$prev_n" in ''|*[!0-9]*) prev_n=0 ;; esac
|
||||||
|
n=$((10#$prev_n + 1)); now="$(mint_time)"
|
||||||
|
local istamp=(
|
||||||
|
user.box.imported.last="$now"
|
||||||
|
user.box.imported.last.by="$(box_version)"
|
||||||
|
user.box.imported.count="$n"
|
||||||
|
)
|
||||||
|
# The birth of this box's import history: pinned on the first trip and
|
||||||
|
# carried by the artifact forever after, so a box on its third host still
|
||||||
|
# names the trip that started it.
|
||||||
|
[ -n "$first_at" ] || istamp+=(user.box.imported="$now" user.box.imported.by="$(box_version)")
|
||||||
|
# No 'user.box.schema' is written here, on either path. "Absent schema means
|
||||||
|
# pre-stamp" is load-bearing for the MINTED line, and stamping schema=1 onto
|
||||||
|
# a legacy artifact that carries no mint stamp would claim a shape it does
|
||||||
|
# not have. These keys need no schema to be read — every reader tolerates
|
||||||
|
# absence by construction. Nor does the schema BUMP: #103's contract is that
|
||||||
|
# it moves on removal or repurposing, never on addition, and this only adds.
|
||||||
|
#
|
||||||
|
# Before the start, like the clone re-stamp and for the same reason: an
|
||||||
|
# imported box is never observable — not for a second, not by a concurrent
|
||||||
|
# 'box info' — without the record of how it got here.
|
||||||
|
incus config set "$target" "${istamp[@]}"
|
||||||
# Placement: the artifact carries its profile list, but the isolation
|
# Placement: the artifact carries its profile list, but the isolation
|
||||||
# contract is THIS host's box-net profile. A box export already says
|
# contract is THIS host's box-net profile. A box export already says
|
||||||
# box-net; anything else (a pre-0.4.0 artifact, a hand-rolled export) gets
|
# box-net; anything else (a pre-0.4.0 artifact, a hand-rolled export) gets
|
||||||
|
|
@ -1597,7 +2233,8 @@ uninstall_confirm() { # $1 = question. --force, or BOX_YES=1, or a TTY.
|
||||||
[ -t 0 ] || usage_error "refusing to $1 without --force (no terminal to confirm on; BOX_YES=1 also means yes)"
|
[ -t 0 ] || usage_error "refusing to $1 without --force (no terminal to confirm on; BOX_YES=1 also means yes)"
|
||||||
local reply
|
local reply
|
||||||
printf 'box: %s? [y/N] ' "$1"
|
printf 'box: %s? [y/N] ' "$1"
|
||||||
read -r reply
|
# Same EOF cure as confirm() above — Ctrl-D must abort out loud.
|
||||||
|
read -r reply || die "aborted."
|
||||||
case "$reply" in y|Y|yes|YES|Yes) return 0 ;; *) die "aborted." ;; esac
|
case "$reply" in y|Y|yes|YES|Yes) return 0 ;; *) die "aborted." ;; esac
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1888,12 +2525,19 @@ cmd_expose() {
|
||||||
# --- dispatch: driven by the table, not by a hand-written case --------------
|
# --- dispatch: driven by the table, not by a hand-written case --------------
|
||||||
|
|
||||||
row="$(cmd_row "$cmd")" || unknown_command "$cmd"
|
row="$(cmd_row "$cmd")" || unknown_command "$cmd"
|
||||||
IFS='^' read -r _ _ pre _ action ok <<<"$row"
|
IFS='^' read -r _ _ pre _ action ok cnf <<<"$row"
|
||||||
|
|
||||||
case ",$pre," in *,box,*) need_name; inst="$(resolve_box "${args[0]}")" ;; esac
|
case ",$pre," in *,box,*) need_name; inst="$(resolve_box "${args[0]}")" ;; esac
|
||||||
case ",$pre," in *,arg2,*) need_arg2 ;; esac
|
case ",$pre," in *,arg2,*) need_arg2 ;; esac
|
||||||
case ",$pre," in *,stopped,*) require_stopped "$inst" "${args[0]}" ;; esac
|
case ",$pre," in *,stopped,*) require_stopped "$inst" "${args[0]}" ;; esac
|
||||||
case ",$pre," in *,confirm,*) confirm "delete $inst and all its snapshots" ;; esac
|
# The prompt comes from the ROW, never from here. A shared string can only be
|
||||||
|
# right for one verb, and it was rm's — which is why 'restore' could not be
|
||||||
|
# gated by adding the token alone (#105). A row with 'confirm' and no words is
|
||||||
|
# a table bug, and it dies as one rather than asking a blank question.
|
||||||
|
case ",$pre," in *,confirm,*)
|
||||||
|
[ -n "$cnf" ] || die "internal: '$cmd' is marked confirm but its row carries no prompt"
|
||||||
|
confirm "$(fill "$cnf" "$inst")" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
case "$action" in
|
case "$action" in
|
||||||
fn:*)
|
fn:*)
|
||||||
|
|
@ -1905,8 +2549,7 @@ case "$action" in
|
||||||
# shellcheck disable=SC2086
|
# shellcheck disable=SC2086
|
||||||
incus $sub "$inst" "${args[@]:1}"
|
incus $sub "$inst" "${args[@]:1}"
|
||||||
if [ -n "$ok" ]; then
|
if [ -n "$ok" ]; then
|
||||||
msg="${ok//\{\}/${args[0]}}"; msg="${msg//\{1\}/${args[1]:-}}"
|
echo "box: $(fill "$ok" "${args[0]}")"
|
||||||
echo "box: $msg"
|
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
# box design
|
# box design
|
||||||
|
|
||||||
`box` is a CLI that mints and manages **trust-less, network-isolated VMs
|
`box` is a CLI that mints and manages **trust-less, network-isolated VMs
|
||||||
with a coding agent installed** (`claude`, `codex`, `grok`, or `blank` for
|
with a coding agent installed** (`claude-box`, `codex-box`, `grok-box`,
|
||||||
none). It is infrastructure, not a project provisioner.
|
`kimi-box`, or `blank` for none). It is infrastructure, not a project provisioner.
|
||||||
|
|
||||||
See issue #3 for the full reframe and rationale. This doc captures the durable
|
See issue #3 for the full reframe and rationale. This doc captures the durable
|
||||||
design decisions.
|
design decisions.
|
||||||
|
|
@ -43,6 +43,33 @@ snapshots, not a secrets store:
|
||||||
|
|
||||||
Log in once → snapshot → spin up authed boxes from it.
|
Log in once → snapshot → spin up authed boxes from it.
|
||||||
|
|
||||||
|
One checkpoint box takes itself: **`pristine`**, marked on every fresh mint
|
||||||
|
after cloud-init and before the `rig bootstrap` hook (#104, child of
|
||||||
|
heavy-duty/rig#62). That instant — pristine Debian plus box's thin seed, rig
|
||||||
|
installed but not yet run — is the state "back to pristine Debian" names, it
|
||||||
|
lasts a few seconds, and nobody is standing there to snapshot it. `box
|
||||||
|
restore <n> pristine` undoes a tenant role wholesale, because everything the
|
||||||
|
creds-free roles do is box-local and file-shaped. A `--from` clone takes no
|
||||||
|
`pristine`: it never has the moment, so it inherits its source's snapshots or
|
||||||
|
has none, and box refuses to label a worked-in state as pristine. On a `dir`
|
||||||
|
pool the mark would be a full copy rather than a CoW one, so the mint skips
|
||||||
|
it loudly.
|
||||||
|
|
||||||
|
A second, **`bootstrapped`**, is marked one step later (#130) — after the rig
|
||||||
|
hook has run and box has *watched it succeed*. Same policy, literally the same
|
||||||
|
function: default on, never fatal, skipped on `dir`, opt-out via
|
||||||
|
`BOX_SNAPSHOT_BOOTSTRAPPED=0`. Where the two differ is deliberate.
|
||||||
|
`pristine` marks a **moment** every fresh mint has, so it is unconditional;
|
||||||
|
`bootstrapped` marks an **event**, so it is not. A blank box has no hook and
|
||||||
|
therefore no event — marking it anyway would duplicate `pristine` byte for
|
||||||
|
byte at twice the disk cost, or assert a convergence that never happened. A
|
||||||
|
failed hook gets no mark either: box tells the operator to re-run the role
|
||||||
|
through `box shell`, which is a run box does not watch, so it hands over `box
|
||||||
|
snapshot <n> bootstrapped` instead of guessing. The consequence is a label
|
||||||
|
that is only ever read one way — **presence** means "converged and untouched";
|
||||||
|
**absence** means nothing, and nothing in box may treat a missing
|
||||||
|
`bootstrapped` as evidence a box is unconverged.
|
||||||
|
|
||||||
Snapshots are in-box state: `box rm` deletes a box *and* its snapshots, and a
|
Snapshots are in-box state: `box rm` deletes a box *and* its snapshots, and a
|
||||||
clone still lives on the same host. The off-host mechanism is `box export` /
|
clone still lives on the same host. The off-host mechanism is `box export` /
|
||||||
`box import` (#70) — one portable backup tarball, snapshots included by
|
`box import` (#70) — one portable backup tarball, snapshots included by
|
||||||
|
|
@ -50,7 +77,13 @@ default, that survives `rm`, a host teardown, an upgrade, a move. The split
|
||||||
of truths is the design: everything `incus import` restores is the artifact's
|
of truths is the design: everything `incus import` restores is the artifact's
|
||||||
(disk, config, snapshots); everything box re-stamps on import is the current
|
(disk, config, snapshots); everything box re-stamps on import is the current
|
||||||
host's (the `user.box=1` boundary tag, the `box-net` placement, a fresh
|
host's (the `user.box=1` boundary tag, the `box-net` placement, a fresh
|
||||||
machine identity via the same `reset_identity` a clone gets). Auth state
|
machine identity via the same `reset_identity` a clone gets, and the record
|
||||||
|
that the trip happened). That last one is #131, and it is deliberately *not*
|
||||||
|
`origin=import`: `origin` says how the instance came into **being** — mint or
|
||||||
|
clone — and the import is a third, orthogonal fact. Overwriting `origin` would
|
||||||
|
make an exported clone come back claiming to be an import, with its
|
||||||
|
`origin.from` lineage left unreadable, so the import gets its own keys and the
|
||||||
|
artifact's mint stamp survives the trip untouched. Auth state
|
||||||
rides along deliberately — and because scrubbing a disk image is a promise
|
rides along deliberately — and because scrubbing a disk image is a promise
|
||||||
tarball surgery cannot keep, export shouts that the file is a credential
|
tarball surgery cannot keep, export shouts that the file is a credential
|
||||||
instead of pretending to sanitize it.
|
instead of pretending to sanitize it.
|
||||||
|
|
@ -60,15 +93,17 @@ instead of pretending to sanitize it.
|
||||||
A template is a **thin, creds-free seed** — base image, the tenant user,
|
A template is a **thin, creds-free seed** — base image, the tenant user,
|
||||||
tmux, and [rig](https://github.com/heavy-duty/rig) preinstalled — and what
|
tmux, and [rig](https://github.com/heavy-duty/rig) preinstalled — and what
|
||||||
the box *becomes* lives in rig's bootstrap roles (rig#31): box auto-runs the
|
the box *becomes* lives in rig's bootstrap roles (rig#31): box auto-runs the
|
||||||
template's creds-free tenant role after cloud-init (`rig bootstrap claude` /
|
template's creds-free tenant role after cloud-init (`rig bootstrap claude-box`
|
||||||
`codex` / `grok` / `staging`), which installs the agent CLI or server
|
/ `codex-box` / `grok-box` / `kimi-box` / `staging-box` — the roles carry a family suffix,
|
||||||
posture. The split is deliberate: cloud-init is a first-boot one-shot —
|
`-box` for box tenants and `-server` for fleet machines, and the templates are
|
||||||
|
named for the roles they converge, rig#76), which installs the agent CLI or
|
||||||
|
server posture. The split is deliberate: cloud-init is a first-boot one-shot —
|
||||||
not convergent, not re-runnable, only parse-and-grep testable — while a rig
|
not convergent, not re-runnable, only parse-and-grep testable — while a rig
|
||||||
role is an idempotent script with effective-state asserts that can also
|
role is an idempotent script with effective-state asserts that can also
|
||||||
converge an *existing* box to a newer spec. Anything that joins a tailnet or
|
converge an *existing* box to a newer spec. Anything that joins a tailnet or
|
||||||
holds a key (staging's workload join) stays operator-run through
|
holds a key (the staging-box tenant's workload join) stays operator-run
|
||||||
`box shell`; box prints it as a next step and never sees the key. The seed's
|
through `box shell`; box prints it as a next step and never sees the key. The
|
||||||
rig install is pinned by `RIG_REPO`/`RIG_REF` at mint (default
|
seed's rig install is pinned by `RIG_REPO`/`RIG_REF` at mint (default
|
||||||
`heavy-duty/rig@main`, unpinned — the honest edge until rig#32's releases),
|
`heavy-duty/rig@main`, unpinned — the honest edge until rig#32's releases),
|
||||||
and box's template suite holds the line with fail-closed absence greps: no
|
and box's template suite holds the line with fail-closed absence greps: no
|
||||||
agent CLI, no docker, no tailscale, no context-file heredocs in any
|
agent CLI, no docker, no tailscale, no context-file heredocs in any
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,9 @@
|
||||||
|
|
||||||
`box` mints trust-less, creds-free, isolated VMs with a coding agent already
|
`box` mints trust-less, creds-free, isolated VMs with a coding agent already
|
||||||
installed (`box new/shell/snapshot/restore/exec/down/start/rm/status`) — the
|
installed (`box new/shell/snapshot/restore/exec/down/start/rm/status`) — the
|
||||||
`claude`, `codex`, and `grok` templates each ship a CLI agent. The tool knows
|
`claude-box`, `codex-box`, `grok-box`, and `kimi-box` templates each ship a
|
||||||
**nothing** about your project. There is no `install` step and no host-run
|
CLI agent. The tool knows **nothing** about your project. There is no `install` step and no
|
||||||
setup script.
|
host-run setup script.
|
||||||
|
|
||||||
A project makes itself easy to stand up inside a box by shipping an optional
|
A project makes itself easy to stand up inside a box by shipping an optional
|
||||||
`.box/` folder. This folder is **agent-facing documentation** — read and
|
`.box/` folder. This folder is **agent-facing documentation** — read and
|
||||||
|
|
|
||||||
|
|
@ -127,11 +127,13 @@ It had never worked.
|
||||||
## Rehearsal and CI
|
## Rehearsal and CI
|
||||||
|
|
||||||
`drill/multiuser.sh` (root, opt-in via `BOX_MULTIUSER_REHEARSAL=1`) proves
|
`drill/multiuser.sh` (root, opt-in via `BOX_MULTIUSER_REHEARSAL=1`) proves
|
||||||
criteria (a)–(f) from #74 plus the measured extensions (g)–(n): the in-box
|
criteria (a)–(f) from #74 plus the measured extensions (g)–(o): the in-box
|
||||||
isolation contract (egress, DNS, box→host, RFC1918, cross-user sibling drop,
|
isolation contract (egress, DNS, box→host, RFC1918, cross-user sibling drop,
|
||||||
name enumeration, IPv6-off), the closed escape hatches, re-sync survival, and
|
name enumeration, IPv6-off), the closed escape hatches, re-sync survival, and
|
||||||
scoped revoke, the raw-attach scoped guarantee (m) and the grant-failure
|
scoped revoke, the raw-attach scoped guarantee (m), the grant-failure
|
||||||
injections (n). Real users, real grants, real mints, probes from inside;
|
injections (n) and the `incus-admin`-only grant (o) — the last of these
|
||||||
|
because the shim suite models neither `INCUS_SOCKET` nor socket permissions,
|
||||||
|
so the group gate on `unix.socket.user` can only be measured live (#99, #101). Real users, real grants, real mints, probes from inside;
|
||||||
`--container` for CI, VM mode on real hardware; cleanup deletes everything it
|
`--container` for CI, VM mode on real hardware; cleanup deletes everything it
|
||||||
made.
|
made.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -71,8 +71,8 @@ wait_box() { # poll until exec answers (the VM agent can take a while), ~4 min
|
||||||
# Read from inside a box WITHOUT ever hanging the drill.
|
# Read from inside a box WITHOUT ever hanging the drill.
|
||||||
#
|
#
|
||||||
# Two traps, both hit for real:
|
# Two traps, both hit for real:
|
||||||
# · 'box exec' becomes 'sudo -u <template user> -i' — a LOGIN zsh (oh-my-zsh and
|
# · 'box exec' crosses a login-user shell boundary. Fine for a person,
|
||||||
# all). Fine for a person, needless machinery for a probe.
|
# needless machinery for a probe.
|
||||||
# · $( ) waits for stdout to CLOSE, not for the command to exit. A grandchild
|
# · $( ) waits for stdout to CLOSE, not for the command to exit. A grandchild
|
||||||
# inheriting the exec session's stdout keeps the substitution open forever,
|
# inheriting the exec session's stdout keeps the substitution open forever,
|
||||||
# and 'timeout' does not save you: it kills the wrapper, not the holder of
|
# and 'timeout' does not save you: it kills the wrapper, not the holder of
|
||||||
|
|
@ -378,7 +378,7 @@ if ! timeout -k 10 300 ~/.local/share/box/current/host/setup-host.sh; then
|
||||||
fi
|
fi
|
||||||
inf "host setup complete"
|
inf "host setup complete"
|
||||||
|
|
||||||
# A real server has room for the claude template's resources (8GiB/4cpu), and
|
# A real server has room for the claude-box template's resources (8GiB/4cpu), and
|
||||||
# drilling the real numbers is worth more than drilling shrunken ones. Only
|
# drilling the real numbers is worth more than drilling shrunken ones. Only
|
||||||
# shrink if we must. Since 0.4.0 resources are per-box, stamped from the
|
# shrink if we must. Since 0.4.0 resources are per-box, stamped from the
|
||||||
# template at mint — a profile edit no longer reaches them; the supported
|
# template at mint — a profile edit no longer reaches them; the supported
|
||||||
|
|
@ -386,9 +386,9 @@ inf "host setup complete"
|
||||||
ram="$(awk '/MemTotal/{print int($2/1024/1024)}' /proc/meminfo)"
|
ram="$(awk '/MemTotal/{print int($2/1024/1024)}' /proc/meminfo)"
|
||||||
if [ "$ram" -lt 20 ]; then
|
if [ "$ram" -lt 20 ]; then
|
||||||
export BOX_MEMORY=3GiB BOX_CPU=2
|
export BOX_MEMORY=3GiB BOX_CPU=2
|
||||||
note "host has ${ram}GiB RAM — minting at 3GiB/2cpu via BOX_MEMORY/BOX_CPU (the claude template's 8GiB/4cpu is what was NOT drilled)"
|
note "host has ${ram}GiB RAM — minting at 3GiB/2cpu via BOX_MEMORY/BOX_CPU (the claude-box template's 8GiB/4cpu is what was NOT drilled)"
|
||||||
else
|
else
|
||||||
inf "host has ${ram}GiB RAM — drilling the claude template's resources (8GiB/4cpu) unchanged"
|
inf "host has ${ram}GiB RAM — drilling the claude-box template's resources (8GiB/4cpu) unchanged"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
KVM=0; [ -e /dev/kvm ] && KVM=1
|
KVM=0; [ -e /dev/kvm ] && KVM=1
|
||||||
|
|
@ -492,10 +492,10 @@ fi
|
||||||
|
|
||||||
# --- templates: the mint surface is itself a surface to test ----------------
|
# --- templates: the mint surface is itself a surface to test ----------------
|
||||||
tpl_missing=""
|
tpl_missing=""
|
||||||
for t in blank claude codex grok; do
|
for t in blank claude-box codex-box grok-box kimi-box; do
|
||||||
box templates 2>/dev/null | grep -q "^ $t" || tpl_missing="$tpl_missing $t"
|
box templates 2>/dev/null | grep -q "^ $t" || tpl_missing="$tpl_missing $t"
|
||||||
done
|
done
|
||||||
[ -z "$tpl_missing" ] && ok "templates: lists blank, claude, codex, grok" \
|
[ -z "$tpl_missing" ] && ok "templates: lists blank, claude-box, codex-box, grok-box, kimi-box" \
|
||||||
|| no "templates listing is missing:$tpl_missing"
|
|| no "templates listing is missing:$tpl_missing"
|
||||||
box new --name tpl --template nosuch 2>&1 | grep -q 'no such template' \
|
box new --name tpl --template nosuch 2>&1 | grep -q 'no such template' \
|
||||||
&& ok "unknown template refused, points at 'box templates'" || no "an unknown template was not refused"
|
&& ok "unknown template refused, points at 'box templates'" || no "an unknown template was not refused"
|
||||||
|
|
@ -551,19 +551,22 @@ else
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# The generic mechanic (metadata, placement, user, isolation parity) is proven
|
# The generic mechanic (metadata, placement, user, isolation parity) is proven
|
||||||
# once by blank+claude and needs no per-template repeat. What a NEW template
|
# once by blank+claude-box and needs no per-template repeat. What a NEW template
|
||||||
# still has to prove is its own payload: the CLI installs, lands on the
|
# still has to prove is its own payload: the CLI installs, lands on the
|
||||||
# non-interactive exec PATH, and answers --version. One mint each.
|
# non-interactive exec PATH, and answers --version. One mint each.
|
||||||
|
# The box NAME stays the bare agent name — it is what the pre-flight banner
|
||||||
|
# announces and what teardown deletes — while the TEMPLATE carries rig#76's
|
||||||
|
# family suffix. They are two different namespaces and only one of them moved.
|
||||||
for t in codex grok; do
|
for t in codex grok; do
|
||||||
case "$t" in codex) bin=codex; user=codex ;; grok) bin=grok; user=grok ;; esac
|
case "$t" in codex) bin=codex; user=codex ;; grok) bin=grok; user=grok ;; esac
|
||||||
printf '\n minting a %s box (cold — validates the template install)…\n' "$t"
|
printf '\n minting a %s box (cold — validates the template install)…\n' "$t"
|
||||||
if mint_box "/tmp/mint-$t.log" --name "$t" --template "$t"; then
|
if mint_box "/tmp/mint-$t.log" --name "$t" --template "$t-box"; then
|
||||||
[ "$(incus config get "$t" user.box.user 2>/dev/null)" = "$user" ] \
|
[ "$(incus config get "$t" user.box.user 2>/dev/null)" = "$user" ] \
|
||||||
&& ok "$t: template user stamped ($user)" || no "$t: user.box.user not $user"
|
&& ok "$t: template user stamped ($user)" || no "$t: user.box.user not $user"
|
||||||
if timeout -k 5 30 box exec "$t" -- "$bin" --version </dev/null >/dev/null 2>&1; then
|
if timeout -k 5 30 box exec "$t" -- "$bin" --version </dev/null >/dev/null 2>&1; then
|
||||||
ok "$t: '$bin --version' answers via box exec — installed and on the non-interactive PATH"
|
ok "$t: '$bin --version' answers via box exec — installed and on the non-interactive PATH"
|
||||||
else
|
else
|
||||||
no "$t: '$bin --version' FAILED via exec — not installed, or not on exec's PATH (the claude template's #15 bug)"
|
no "$t: '$bin --version' FAILED via exec — not installed, or not on exec's PATH (the claude-box template's #15 bug)"
|
||||||
inf "PATH as exec sees it: $(timeout -k 5 20 box exec "$t" -- printenv PATH </dev/null 2>/dev/null)"
|
inf "PATH as exec sees it: $(timeout -k 5 20 box exec "$t" -- printenv PATH </dev/null 2>/dev/null)"
|
||||||
# Do not throw the evidence away — say WHAT the installer actually left.
|
# Do not throw the evidence away — say WHAT the installer actually left.
|
||||||
# Do NOT throw the evidence away — say what the installer actually left
|
# Do NOT throw the evidence away — say what the installer actually left
|
||||||
|
|
@ -583,10 +586,10 @@ for t in codex grok; do
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
printf '\n minting a claude box (cold, ~10 min)…\n'
|
printf '\n minting a claude-box box (cold, ~10 min)…\n'
|
||||||
t0=$SECONDS
|
t0=$SECONDS
|
||||||
if mint_box /tmp/mint-drill.log --name drill --template claude; then
|
if mint_box /tmp/mint-drill.log --name drill --template claude-box; then
|
||||||
ok "box new --name drill --template claude ($((SECONDS - t0))s)"
|
ok "box new --name drill --template claude-box ($((SECONDS - t0))s)"
|
||||||
else
|
else
|
||||||
no "box new FAILED — tail: $(tail -3 /tmp/mint-drill.log | tr '\n' ' ')"
|
no "box new FAILED — tail: $(tail -3 /tmp/mint-drill.log | tr '\n' ' ')"
|
||||||
timeout -k 5 60 incus delete -f drill >/dev/null 2>&1
|
timeout -k 5 60 incus delete -f drill >/dev/null 2>&1
|
||||||
|
|
@ -799,7 +802,7 @@ fi
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
phase "E. box expose — a deliberate loopback door (#55)"
|
phase "E. box expose — a deliberate loopback door (#55)"
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
# archive is a running claude box (node is installed). Start a DETACHED
|
# archive is a running claude-box box (node is installed). Start a DETACHED
|
||||||
# listener on 0.0.0.0 inside it, expose the port, and prove the door works
|
# listener on 0.0.0.0 inside it, expose the port, and prove the door works
|
||||||
# from the HOST's loopback. Then prove removing it closes the door, and that a
|
# from the HOST's loopback. Then prove removing it closes the door, and that a
|
||||||
# NON-exposed port still obeys the ingress drop — the feature must not
|
# NON-exposed port still obeys the ingress drop — the feature must not
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,9 @@
|
||||||
# host-owned control — the scoped guarantee, measured (#75 review)
|
# host-owned control — the scoped guarantee, measured (#75 review)
|
||||||
# n. a grant that fails is fail-closed: fresh user backed out (verified),
|
# n. a grant that fails is fail-closed: fresh user backed out (verified),
|
||||||
# pre-existing member warned loudly, re-run converges (#75 review)
|
# pre-existing member warned loudly, re-run converges (#75 review)
|
||||||
|
# o. an incus-admin-ONLY member is provisioned for real: the group step
|
||||||
|
# opens incus-user's socket, the lazy project appears, and dropping
|
||||||
|
# incus-admin lands them in it with no re-grant (#99, #101 review)
|
||||||
#
|
#
|
||||||
# ok/no/note return 0 by design — the 'A && ok || no' idiom below is the
|
# ok/no/note return 0 by design — the 'A && ok || no' idiom below is the
|
||||||
# same one drill.sh is built on (and the reason for the SC2015 disable).
|
# same one drill.sh is built on (and the reason for the SC2015 disable).
|
||||||
|
|
@ -114,7 +117,7 @@ cleanup() {
|
||||||
[ "$KEEP" = 1 ] && { echo "(--keep: users and boxes left for inspection)"; return; }
|
[ "$KEEP" = 1 ] && { echo "(--keep: users and boxes left for inspection)"; return; }
|
||||||
echo
|
echo
|
||||||
echo "── cleanup"
|
echo "── cleanup"
|
||||||
for u in "$U1" "$U2" boxdrill3 boxdrill4; do
|
for u in "$U1" "$U2" boxdrill3 boxdrill4 boxdrill5; do
|
||||||
id "$u" >/dev/null 2>&1 || continue
|
id "$u" >/dev/null 2>&1 || continue
|
||||||
# A half-failed purge followed by userdel leaves a project owned by
|
# A half-failed purge followed by userdel leaves a project owned by
|
||||||
# nobody — and doctor's leftover check keys on the USER existing. Keep
|
# nobody — and doctor's leftover check keys on the USER existing. Keep
|
||||||
|
|
@ -199,11 +202,42 @@ fi
|
||||||
rm -f "$mintlog"
|
rm -f "$mintlog"
|
||||||
as_u "$U1" box list 2>/dev/null | grep -q '^mine ' && ok "(b) box list shows mine" || no "(b) box list does not show mine"
|
as_u "$U1" box list 2>/dev/null | grep -q '^mine ' && ok "(b) box list shows mine" || no "(b) box list does not show mine"
|
||||||
as_u "$U1" box exec mine -- true >/dev/null 2>&1 && ok "(b) box exec mine -- true" || no "(b) box exec failed"
|
as_u "$U1" box exec mine -- true >/dev/null 2>&1 && ok "(b) box exec mine -- true" || no "(b) box exec failed"
|
||||||
|
|
||||||
|
# #169: compare the new explicit login-user boundary with sudo -i while the
|
||||||
|
# latter is still safe (one fixed `env` argv), then exercise the exact
|
||||||
|
# silent-success multiline shape that sudo -i used to corrupt.
|
||||||
|
target_user="$(as_u "$U1" incus config get mine user.box.user)"
|
||||||
|
legacy_env="$(as_u "$U1" incus exec mine -- sudo -u "$target_user" -i env \
|
||||||
|
| grep -E '^(USER|LOGNAME|SHELL|HOME|PWD|PATH)=' | sort)"
|
||||||
|
exec_env="$(as_u "$U1" box exec mine -- env \
|
||||||
|
| grep -E '^(USER|LOGNAME|SHELL|HOME|PWD|PATH)=' | sort)"
|
||||||
|
[ "$exec_env" = "$legacy_env" ] \
|
||||||
|
&& ok "(b) box exec preserves cross-user login environment (USER/LOGNAME/SHELL/HOME/PWD/PATH)" \
|
||||||
|
|| {
|
||||||
|
no "(b) box exec changed the cross-user login environment (#169)"
|
||||||
|
diff -u <(printf '%s\n' "$legacy_env") <(printf '%s\n' "$exec_env") | sed 's/^/ /' || true
|
||||||
|
}
|
||||||
|
as_u "$U1" box exec mine -- bash -lc '
|
||||||
|
set -o errexit -o nounset -o pipefail
|
||||||
|
touch /tmp/box-169-step-one
|
||||||
|
touch /tmp/box-169-step-two
|
||||||
|
' >/dev/null 2>&1 \
|
||||||
|
&& as_u "$U1" box exec mine -- test -f /tmp/box-169-step-one \
|
||||||
|
&& as_u "$U1" box exec mine -- test -f /tmp/box-169-step-two \
|
||||||
|
&& ok "(b) box exec preserves multiline commands, including the silent-success set shape" \
|
||||||
|
|| no "(b) box exec corrupted a multiline command (#169)"
|
||||||
|
|
||||||
as_u "$U1" box info mine 2>/dev/null | grep -qF "$(boxnet_pfx)" \
|
as_u "$U1" box info mine 2>/dev/null | grep -qF "$(boxnet_pfx)" \
|
||||||
&& ok "(g) box info shows a boxnet ($(boxnet_pfx)x) address — placed on the hardened network" \
|
&& ok "(g) box info shows a boxnet ($(boxnet_pfx)x) address — placed on the hardened network" \
|
||||||
|| no "(g) mine has no boxnet address in box info"
|
|| no "(g) mine has no boxnet address in box info"
|
||||||
as_u "$U1" box snapshot mine s1 >/dev/null 2>&1 && ok "(b) box snapshot mine s1" || no "(b) snapshot refused"
|
as_u "$U1" box snapshot mine s1 >/dev/null 2>&1 && ok "(b) box snapshot mine s1" || no "(b) snapshot refused"
|
||||||
as_u "$U1" box restore mine s1 >/dev/null 2>&1 && ok "(b) box restore mine s1 (the incus 6 'snapshot restore' spelling)" || no "(b) restore failed"
|
# --force, and it is the assertion as much as the fix: restore is destructive
|
||||||
|
# and now asks (#105), so an unattended drill MUST consent explicitly. Without
|
||||||
|
# the flag this line fails — correctly — because confirm() refuses rather than
|
||||||
|
# assuming yes when there is no terminal to ask on. Adding --force here is not
|
||||||
|
# working around the gate; it is the drill proving the gate is real, and that
|
||||||
|
# the documented non-interactive path through it works on real Incus.
|
||||||
|
as_u "$U1" box restore mine s1 --force >/dev/null 2>&1 && ok "(b) box restore mine s1 --force (the incus 6 'snapshot restore' spelling, through the #105 gate)" || no "(b) restore failed"
|
||||||
as_u "$U1" box new --name c1 --from mine/s1 >/dev/null 2>&1 && ok "(b) box new --from mine/s1 — the clone workflow" || no "(b) clone failed"
|
as_u "$U1" box new --name c1 --from mine/s1 >/dev/null 2>&1 && ok "(b) box new --from mine/s1 — the clone workflow" || no "(b) clone failed"
|
||||||
# c1 stays alive through phase g: it is the distinctly-NAMED sibling the
|
# c1 stays alive through phase g: it is the distinctly-NAMED sibling the
|
||||||
# enumeration probe needs (both users' primaries are 'mine' by design of d).
|
# enumeration probe needs (both users' primaries are 'mine' by design of d).
|
||||||
|
|
@ -469,6 +503,65 @@ else
|
||||||
fi
|
fi
|
||||||
aud "n. fail-closed injections: fresh-user backout verified; pre-existing member warned, not stripped; re-runs converge"
|
aud "n. fail-closed injections: fresh-user backout verified; pre-existing member warned, not stripped; re-runs converge"
|
||||||
|
|
||||||
|
phase "o. an incus-admin-ONLY member — #99's canonical user, on real Incus"
|
||||||
|
# The case the shim suite structurally cannot reach: the fake 'incus' in
|
||||||
|
# test/cli.sh ignores INCUS_SOCKET and file permissions, so a grant that could
|
||||||
|
# never connect() still logged a clean run there. This is the same path over
|
||||||
|
# the real daemon, where the socket is a real file with a real owning group.
|
||||||
|
#
|
||||||
|
# The blocker it exists to catch (#101 review): incus-user's socket is
|
||||||
|
# /var/lib/incus/unix.socket.user, group 'incus', mode 0660. incus-admin opens
|
||||||
|
# the ADMIN socket and not that one, so an incus-admin-only member without an
|
||||||
|
# 'incus' membership takes EACCES on grant's pinned touch — swallowed by its
|
||||||
|
# '|| true' — no project is created, and the grant dies blaming a perfectly
|
||||||
|
# healthy incus-user. Every assertion below is dead under that implementation.
|
||||||
|
U5=boxdrill5
|
||||||
|
useradd -m -s /bin/bash "$U5" 2>/dev/null
|
||||||
|
usermod -aG incus-admin "$U5"
|
||||||
|
gpasswd -d "$U5" incus >/dev/null 2>&1 || true # stage the ONLY, exactly
|
||||||
|
uid5="$(id -u "$U5")"; p5="user-$uid5"
|
||||||
|
id -nG "$U5" | tr ' ' '\n' | grep -qx incus \
|
||||||
|
&& no "(o) $U5 is already in 'incus' — the admin-ONLY precondition is not staged, so this phase proves nothing" \
|
||||||
|
|| ok "(o) $U5 staged in 'incus-admin' only (the precondition the blocker needed)"
|
||||||
|
|
||||||
|
out="$(box grant "$U5" 2>&1)"; rc=$?
|
||||||
|
if [ "$rc" -eq 0 ]; then
|
||||||
|
ok "(o) box grant converges an incus-admin-only member (rc=0)"
|
||||||
|
else
|
||||||
|
no "(o) box grant FAILED for the admin-only member (rc=$rc) — #99 is still closed:"
|
||||||
|
printf '%s\n' "$out" | tail -4 | sed 's/^/ /'
|
||||||
|
fi
|
||||||
|
id -nG "$U5" | tr ' ' '\n' | grep -qx incus \
|
||||||
|
&& ok "(o) the grant put them in 'incus' — the group that owns unix.socket.user" \
|
||||||
|
|| no "(o) still not in 'incus': the pinned touch cannot connect() to incus-user's socket"
|
||||||
|
incus project show "$p5" >/dev/null 2>&1 \
|
||||||
|
&& ok "(o) $p5 exists — the lazy touch really reached incus-user AS them" \
|
||||||
|
|| no "(o) $p5 was never created — the touch never reached incus-user (the EACCES this phase is for)"
|
||||||
|
|
||||||
|
# The socket, directly: the connect() that used to fail, measured as them.
|
||||||
|
# Resolved by incus's own directory rule, not hardcoded.
|
||||||
|
sockdir=/var/lib/incus; [ -e /run/incus/unix.socket ] && sockdir=/run/incus
|
||||||
|
as_u "$U5" env INCUS_SOCKET="$sockdir/unix.socket.user" incus --project "$p5" profile show box-net >/dev/null 2>&1 \
|
||||||
|
&& ok "(o) they can open unix.socket.user and read $p5's box-net profile" \
|
||||||
|
|| no "(o) EACCES/unreachable on $sockdir/unix.socket.user — the #101 blocker is back"
|
||||||
|
acc5="$(incus project get "$p5" restricted.networks.access 2>/dev/null)"
|
||||||
|
[ "$acc5" = boxnet ] \
|
||||||
|
&& ok "(o) $p5 is narrowed to boxnet like any other granted project" \
|
||||||
|
|| no "(o) $p5 restricted.networks.access = '$acc5' — the admin-only grant converged half a project"
|
||||||
|
|
||||||
|
# Grant's own closing promise, measured: "gpasswd -d <user> incus-admin (no
|
||||||
|
# re-grant needed; the project is ready)". True only because they were left in
|
||||||
|
# 'incus' — under the old no-op this drop left them in NEITHER group, box_tier
|
||||||
|
# 'none', and a ready project they could not open. So drop it and look.
|
||||||
|
gpasswd -d "$U5" incus-admin >/dev/null 2>&1
|
||||||
|
projects5="$(as_u "$U5" incus project list --format csv 2>/dev/null | cut -d, -f1)"
|
||||||
|
if [ "$(printf '%s\n' "$projects5" | grep -c .)" = 1 ] && printf '%s' "$projects5" | grep -q "$p5"; then
|
||||||
|
ok "(o) dropping incus-admin lands them in $p5 with NO re-grant — the promise holds"
|
||||||
|
else
|
||||||
|
no "(o) after dropping incus-admin they see: '$(printf '%s' "$projects5" | tr '\n' ' ')' — grant's no-re-grant promise is false"
|
||||||
|
fi
|
||||||
|
aud "o. incus-admin-only grant: in-'incus'=$(id -nG "$U5" 2>/dev/null | tr ' ' '\n' | grep -cx incus), project '$p5' access='$acc5', post-drop projects='$(printf '%s' "$projects5" | tr '\n' ' ')'"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "════════════════════════════════════════════"
|
echo "════════════════════════════════════════════"
|
||||||
echo " $pass passed, $fail failed"
|
echo " $pass passed, $fail failed"
|
||||||
|
|
|
||||||
|
|
@ -117,10 +117,39 @@ for t in "inet box" "bridge box" "inet claudebox" "bridge claudebox"; do
|
||||||
# shellcheck disable=SC2086 # the table spec is two words by design
|
# shellcheck disable=SC2086 # the table spec is two words by design
|
||||||
sudo nft delete table $t >/dev/null 2>&1 && say "deleted nft table $t"
|
sudo nft delete table $t >/dev/null 2>&1 && say "deleted nft table $t"
|
||||||
done
|
done
|
||||||
if command -v ufw >/dev/null && sudo ufw status 2>/dev/null | grep -q "Status: active"; then
|
# Every ufw read is CAPTURED before it is matched, never piped into a reader
|
||||||
|
# that exits on its first match (#102, #107).
|
||||||
|
#
|
||||||
|
# `ufw status | grep -q "Status: active"` returns the WRITER's exit: grep
|
||||||
|
# matches on the first line ufw prints, closes the pipe, ufw takes SIGPIPE,
|
||||||
|
# and the pipeline yields 141. This file is 'set -u' with no pipefail, so
|
||||||
|
# that 141 is discarded, grep's 0 is the pipeline's result, and the branch
|
||||||
|
# held — the defect was latent here, never live. It was also one line from
|
||||||
|
# live: adding 'set -o pipefail' for unrelated robustness would silently turn
|
||||||
|
# this into #102, skipping every UFW removal on a host the operator was told
|
||||||
|
# is wiped, with no error and no red X to see. Captured and matched with
|
||||||
|
# [[ ]], it is correct under any future 'set' line.
|
||||||
|
#
|
||||||
|
# The numbered loop had the same defect for a different reason: its condition
|
||||||
|
# was also an early-exit reader, so it could end while rules remained, and it
|
||||||
|
# re-read un-captured to get the number. It now reads one capture per
|
||||||
|
# iteration and breaks on absence — the re-scan is still per-delete (numbers
|
||||||
|
# shift after each removal), just no longer racing.
|
||||||
|
ufw_status=""
|
||||||
|
if command -v ufw >/dev/null; then
|
||||||
|
# '|| true': ufw exits non-zero when it cannot read its config, and "no
|
||||||
|
# usable ufw here" is nothing-to-clean, not a failure to report.
|
||||||
|
ufw_status="$(sudo ufw status 2>/dev/null || true)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$ufw_status" == *"Status: active"* ]]; then
|
||||||
for net in boxnet claudenet; do
|
for net in boxnet claudenet; do
|
||||||
while sudo ufw status numbered | grep -q "on $net"; do
|
while :; do
|
||||||
n="$(sudo ufw status numbered | grep -m1 "on $net" | sed -E 's/^\[ *([0-9]+)\].*/\1/')"
|
numbered="$(sudo ufw status numbered 2>/dev/null || true)"
|
||||||
|
line="$(printf '%s\n' "$numbered" | grep -m1 "on $net" || true)"
|
||||||
|
[ -n "$line" ] || break
|
||||||
|
n="$(printf '%s\n' "$line" | sed -E 's/^\[ *([0-9]+)\].*/\1/')"
|
||||||
|
[ -n "$n" ] || break
|
||||||
sudo ufw --force delete "$n" >/dev/null && say "deleted UFW rule on $net"
|
sudo ufw --force delete "$n" >/dev/null && say "deleted UFW rule on $net"
|
||||||
done
|
done
|
||||||
done
|
done
|
||||||
|
|
|
||||||
57
drills/0.9.0.md
Normal file
57
drills/0.9.0.md
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
# box 0.9.0 — release drill
|
||||||
|
|
||||||
|
**WAIVED. No drill was run for this release.**
|
||||||
|
|
||||||
|
Waived by the maintainer (@danmt) on 2026-07-21. This file exists because the
|
||||||
|
gate requires a *record*, not a passing result — so a skip is a line in a diff
|
||||||
|
somebody reviewed, rather than the silence that let 0.7.0, 0.8.0 and every
|
||||||
|
release before them ship unproven.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
The drill harness is not in a state to produce a trustworthy run. box has the
|
||||||
|
only real harness in the family (`drill/drill.sh`, ~85 probes), but the
|
||||||
|
procedure around it — standing up the substrate, pinning the release
|
||||||
|
candidates, capturing the result — was undocumented and assembled by hand.
|
||||||
|
rig and cast have no harness at all. A run improvised under those conditions
|
||||||
|
produces numbers nobody should rely on, and `drills/` is worth nothing if it
|
||||||
|
fills with records like that.
|
||||||
|
|
||||||
|
Deferred deliberately, and tracked: #155.
|
||||||
|
|
||||||
|
## What this release therefore does NOT prove
|
||||||
|
|
||||||
|
Stated plainly so it is not rediscovered as a surprise:
|
||||||
|
|
||||||
|
- **The VM trust boundary.** `ci.yml` says it in its own words: *"What
|
||||||
|
container mode canNOT validate is the VM trust boundary itself; that stays a
|
||||||
|
real-hardware ritual."* That is exactly the delta a release drill adds, and
|
||||||
|
it is exactly what is missing here.
|
||||||
|
- **The isolation contract on real hardware** — egress, the sibling drop, DNS
|
||||||
|
enumeration, IPv6 off, inbound drop — as measured by `drill/drill.sh` against
|
||||||
|
real VMs rather than containers.
|
||||||
|
- **The combination users receive.** A released box still resolves `RIG_REF` to
|
||||||
|
`main` (#150), so even a passing drill would have proven a pair that drifts
|
||||||
|
the moment rig's main moves.
|
||||||
|
|
||||||
|
## What CI did prove on this tree
|
||||||
|
|
||||||
|
Not nothing, and worth recording so the gap is precise rather than total:
|
||||||
|
|
||||||
|
| Job | Result |
|
||||||
|
|---|---|
|
||||||
|
| `rehearsal` — multi-user tier on a real Incus | pass |
|
||||||
|
| `scope` | pass |
|
||||||
|
| `reconcile` | pass |
|
||||||
|
| `test/cli.sh` | 653 passed, 0 failed |
|
||||||
|
| `test/release.sh` | 173 passed, 0 failed |
|
||||||
|
| `test/labels-reconcile.sh` | 72 passed, 0 failed |
|
||||||
|
| `shellcheck -x bin/* **/*.sh` | clean |
|
||||||
|
|
||||||
|
The tier's semantics are covered. The boundary is not.
|
||||||
|
|
||||||
|
## The commitment
|
||||||
|
|
||||||
|
The next release carries a real drill record at `drills/<version>.md` — what
|
||||||
|
ran, on what host, the pinned refs and SHAs, the numbers, and what failed. A
|
||||||
|
failed drill is a valid record; another waiver is not. Tracked in #155.
|
||||||
82
drills/README.md
Normal file
82
drills/README.md
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
# drills/ — release evidence, one file per version
|
||||||
|
|
||||||
|
This directory holds the **evidence that a release was proven on real
|
||||||
|
hardware**. One file per shipped version, named exactly for the version:
|
||||||
|
|
||||||
|
```
|
||||||
|
drills/0.9.0.md
|
||||||
|
drills/0.9.0-rc1.md
|
||||||
|
```
|
||||||
|
|
||||||
|
The name must match the contents of `VERSION` exactly.
|
||||||
|
[the pinned ceremony drill-recorded action](https://github.com/heavy-duty/ceremony/tree/0.1.0/actions/drill-recorded)
|
||||||
|
refuses any tree with a bare `VERSION` that has no such file, or whose file is
|
||||||
|
blank. A `-dev` tree passes with nothing to assert.
|
||||||
|
|
||||||
|
Because each version owns a file, `0.9.0` and `0.9.0-rc1` can never be
|
||||||
|
confused for one another — they are simply different paths. That used to take
|
||||||
|
careful whole-version field matching inside one shared file; now it is free.
|
||||||
|
|
||||||
|
## This is not `drill/RUNS.md`
|
||||||
|
|
||||||
|
Two different artifacts, and the distinction is load-bearing:
|
||||||
|
|
||||||
|
| | what it is |
|
||||||
|
|---|---|
|
||||||
|
| [`drill/RUNS.md`](../drill/RUNS.md) | the **harness's own history** — every run of `drill/drill.sh`, the traps table, the lore about what broke and why. It is not release-scoped and it is not going anywhere. |
|
||||||
|
| `drills/<version>.md` | **release evidence** — the record that *this version* was drilled before it shipped. Release-scoped, one file, gated by CI. |
|
||||||
|
|
||||||
|
Appending to `drill/RUNS.md` does not satisfy the release gate, and is not
|
||||||
|
meant to. Keep using it for what it has always been for.
|
||||||
|
|
||||||
|
## What a record should contain
|
||||||
|
|
||||||
|
- **What ran** — which drill, how many probes, `drill/drill.sh` invocation.
|
||||||
|
- **On what host** — the machine, the OS, the Incus version. "Real hardware"
|
||||||
|
is the claim; name the hardware.
|
||||||
|
- **The pinned candidate refs** — the exact `BOX_REF` / `RIG_REF` /
|
||||||
|
`CAST_REF` under test, and the other repos' commit SHAs. A drill that does
|
||||||
|
not say what it drilled proves nothing later.
|
||||||
|
- **The shared run ID**, so this record reconciles with the sibling repos'.
|
||||||
|
- **The numbers** — passed, failed, how long it took.
|
||||||
|
- **What failed**, plainly.
|
||||||
|
|
||||||
|
**A failed drill is still a valid record.** The gate wants *evidence*, not
|
||||||
|
success. A record saying "83/85, criterion (m) regressed, here is the issue"
|
||||||
|
is a good record. So is a maintainer's written waiver explaining why this
|
||||||
|
release shipped without a full drill. What the gate refuses is silence — #95,
|
||||||
|
#114 and #148 all shipped unproven because a skip left no trace.
|
||||||
|
|
||||||
|
## Worked example
|
||||||
|
|
||||||
|
The version below is a **placeholder that can never be a real release**.
|
||||||
|
Copy the shape, not the number.
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Release drill — 9.9.9
|
||||||
|
|
||||||
|
- **Run ID:** `drill-9.9.9-20260721-01` (shared with rig, cast)
|
||||||
|
- **Host:** bare Debian 13, Ryzen 7 5800X / 64 GB, Incus 6.0.2
|
||||||
|
- **Date:** 2026-07-21
|
||||||
|
- **Candidate refs:**
|
||||||
|
- box `release/9.9.9` @ `abc1234`
|
||||||
|
- rig `release/4.4.4` @ `def5678` (minted with `RIG_REF=release/4.4.4`)
|
||||||
|
- cast `release/2.2.2` @ `9abcdef`
|
||||||
|
|
||||||
|
## What ran
|
||||||
|
|
||||||
|
`bash drill/drill.sh --ref release/9.9.9` — the full end-to-end: install the
|
||||||
|
stack, mint every template cold, snapshot and restore, uninstall to zero
|
||||||
|
residue. Then `drill/multiuser.sh` for the two-user grant matrix.
|
||||||
|
|
||||||
|
## Result
|
||||||
|
|
||||||
|
**84/85 passed, 1 failed.** 41 minutes wall clock.
|
||||||
|
|
||||||
|
- Failed: `multiuser.sh` criterion (m) — the raw instance kept a stale route
|
||||||
|
after teardown. Filed as #999. Judged not release-blocking: it affects
|
||||||
|
teardown residue on a host that is about to be wiped, not the trust
|
||||||
|
boundary itself.
|
||||||
|
- The VM boundary probes (the 85-probe isolation contract) passed clean,
|
||||||
|
which is the assertion this repo's drill exists to make.
|
||||||
|
```
|
||||||
|
|
@ -19,7 +19,35 @@ NET=boxnet
|
||||||
# ('|| true': under pipefail an absent bridge would kill the script here.)
|
# ('|| true': under pipefail an absent bridge would kill the script here.)
|
||||||
GW="$(ip -4 -o addr show dev "$NET" 2>/dev/null | awk '{ split($4, a, "/"); print a[1]; exit }' || true)"
|
GW="$(ip -4 -o addr show dev "$NET" 2>/dev/null | awk '{ split($4, a, "/"); print a[1]; exit }' || true)"
|
||||||
|
|
||||||
if command -v ufw >/dev/null && ufw status 2>/dev/null | grep -q "Status: active"; then
|
# Read `ufw status` ONCE, into a variable, instead of piping it at a matcher.
|
||||||
|
# The pipe it replaces — `ufw status | grep -q "Status: active"` — was a latent
|
||||||
|
# branch-flipper, and the branch it flips is the whole firewall. "Status:
|
||||||
|
# active" is the FIRST line ufw prints, so `grep -q` matches it and exits
|
||||||
|
# immediately, closing the read end while ufw is still writing the rest of the
|
||||||
|
# table; ufw then dies of SIGPIPE (141). `grep` reported 0, but under the
|
||||||
|
# `set -o pipefail` at the top of this file the PIPELINE reports 141, so the
|
||||||
|
# `if` reads false and a host with UFW plainly active takes the no-UFW branch
|
||||||
|
# below — installing the nft fallback table and never building the DNS
|
||||||
|
# carve-out its persisted rules are counting on. It is a pure scheduling race
|
||||||
|
# between two processes, which is the worst possible property for a decision
|
||||||
|
# this load-bearing: measured at ~2% per invocation under test/cli.sh's shims
|
||||||
|
# (#102, where it surfaced as an intermittent four-assertions-red test and got
|
||||||
|
# read as flakiness for exactly as long as it was cheaper to re-run than to
|
||||||
|
# diagnose). Real ufw is a Python program with a slower, longer write than the
|
||||||
|
# shim's single printf, so there is no reason to think production is safer.
|
||||||
|
# A variable has no reader that can exit early, so the race cannot exist.
|
||||||
|
# The capture doubles as the snapshot the converge loop below reads, so the
|
||||||
|
# branch decision and the stale-rule scan are made against the same text
|
||||||
|
# rather than two reads that could disagree across an intervening change.
|
||||||
|
# ('|| true': ufw exits non-zero when it cannot read its config, and under
|
||||||
|
# pipefail+errexit that would kill the script instead of falling through to
|
||||||
|
# the nft branch, which is the correct answer for "ufw is not usable here".)
|
||||||
|
UFW_STATUS=""
|
||||||
|
if command -v ufw >/dev/null; then
|
||||||
|
UFW_STATUS="$(ufw status 2>/dev/null || true)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$UFW_STATUS" == *"Status: active"* ]]; then
|
||||||
if [ -z "$GW" ]; then
|
if [ -z "$GW" ]; then
|
||||||
echo "box-firewall: $NET has no address yet — UFW DNS carve-out left as-is (no rule beats a wrong one; the persisted rules survive boots, and setup-host or a service restart converges them once the bridge is addressed)" >&2
|
echo "box-firewall: $NET has no address yet — UFW DNS carve-out left as-is (no rule beats a wrong one; the persisted rules survive boots, and setup-host or a service restart converges them once the bridge is addressed)" >&2
|
||||||
else
|
else
|
||||||
|
|
@ -32,7 +60,9 @@ if command -v ufw >/dev/null && ufw status 2>/dev/null | grep -q "Status: active
|
||||||
# anywhere else, then ensure the live set: ufw skips a rule that already
|
# anywhere else, then ensure the live set: ufw skips a rule that already
|
||||||
# exists, so the re-run is a no-op and a fresh host gets exactly the
|
# exists, so the re-run is a no-op and a fresh host gets exactly the
|
||||||
# rules it always did.
|
# rules it always did.
|
||||||
for stale in $(ufw status | awk -v net="$NET" -v gw="$GW" '
|
# Scanned off the same $UFW_STATUS snapshot the branch was decided from —
|
||||||
|
# see the capture above for why this is not a second `ufw status` call.
|
||||||
|
for stale in $(printf '%s\n' "$UFW_STATUS" | awk -v net="$NET" -v gw="$GW" '
|
||||||
$2 ~ /^53\// && $3 == "on" && $4 == net && $1 != gw { print $1 }' | sort -u); do
|
$2 ~ /^53\// && $3 == "on" && $4 == net && $1 != gw { print $1 }' | sort -u); do
|
||||||
ufw delete allow in on "$NET" to "$stale" port 53 proto tcp || true
|
ufw delete allow in on "$NET" to "$stale" port 53 proto tcp || true
|
||||||
ufw delete allow in on "$NET" to "$stale" port 53 proto udp || true
|
ufw delete allow in on "$NET" to "$stale" port 53 proto udp || true
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,10 @@
|
||||||
# Incus 6.0.4; the full write-up is in docs/plans/2026-07-18-restricted-tier.md.
|
# Incus 6.0.4; the full write-up is in docs/plans/2026-07-18-restricted-tier.md.
|
||||||
#
|
#
|
||||||
# So granting is a per-user CONVERGENCE, and it must be run by an admin:
|
# So granting is a per-user CONVERGENCE, and it must be run by an admin:
|
||||||
# 1. put the user in the 'incus' group (not incus-admin — that is the tier)
|
# 1. put the user in the 'incus' group (not incus-admin — that is the tier).
|
||||||
|
# An incus-admin member goes in too, and NOT for privilege: incus-user's
|
||||||
|
# socket is a FILE, group 'incus', mode 0660, so the membership is the
|
||||||
|
# only thing that lets step 2 connect() at all (#99, #101 review)
|
||||||
# 2. touch incus-user AS the user, so the lazy project exists to converge
|
# 2. touch incus-user AS the user, so the lazy project exists to converge
|
||||||
# 3. unpin the private bridge (drop eth0 from the project's default profile)
|
# 3. unpin the private bridge (drop eth0 from the project's default profile)
|
||||||
# 4. restrict the project's network access to boxnet and ONLY boxnet —
|
# 4. restrict the project's network access to boxnet and ONLY boxnet —
|
||||||
|
|
@ -63,12 +66,34 @@ getent passwd "$user" >/dev/null || { echo "box grant: no such user: $user" >&2;
|
||||||
uid="$(id -u "$user")"
|
uid="$(id -u "$user")"
|
||||||
[ "$uid" -eq 0 ] && { echo "box grant: root does not need a tier — UID 0 owns the daemon socket outright." >&2; exit 1; }
|
[ "$uid" -eq 0 ] && { echo "box grant: root does not need a tier — UID 0 owns the daemon socket outright." >&2; exit 1; }
|
||||||
|
|
||||||
# An incus-admin member already holds the full socket; "granting" them the
|
# An incus-admin member gets the full convergence anyway (#99). This used to
|
||||||
# restricted tier would not restrict anything (admin membership wins at the
|
# be a hard refusal, on the reasoning that admin membership wins at the socket
|
||||||
# socket), it would only mislead whoever reads the group list later.
|
# so nothing here could restrict them. True — and beside the point, because it
|
||||||
|
# conflates the two separate things a grant hands over:
|
||||||
|
# · PERMISSION — the 'incus' group. At the DAEMON API they already hold
|
||||||
|
# strictly more through incus-admin, so this group adds no privilege. It
|
||||||
|
# is still required, because the two sockets are two FILES with two
|
||||||
|
# different owning groups (Debian 13 / Incus 6.0.4, measured):
|
||||||
|
# /var/lib/incus/unix.socket group incus-admin 0660
|
||||||
|
# /var/lib/incus/unix.socket.user group incus 0660
|
||||||
|
# incus-admin opens the first and not the second, and the second is the
|
||||||
|
# only one that provisions a user-<uid> project. An earlier revision of
|
||||||
|
# this script skipped the usermod for an admin member, reasoning that
|
||||||
|
# 'incus' is a subset of incus-admin — true of the API, false of the
|
||||||
|
# filesystem: the pinned touch below took EACCES, the '|| true' swallowed
|
||||||
|
# it, no project appeared, and the grant died blaming a healthy
|
||||||
|
# incus-user. So the group step is a real convergence for everyone.
|
||||||
|
# · PROVISIONING — the user-<uid> project, the boxnet narrowing, the
|
||||||
|
# snapshot and backup allowances, the box-net profile installed INTO that
|
||||||
|
# project. An incus-admin member has none of it: box_tier() resolves them
|
||||||
|
# to 'admin' (bin/box), so they work in the SHARED default project next to
|
||||||
|
# root and every other admin, with no world of their own. This script is
|
||||||
|
# the only thing that provisions one, and refusing left them unable to get
|
||||||
|
# it without first being taken out of incus-admin.
|
||||||
|
# So provision, and say plainly at the end what the provisioning does not do.
|
||||||
|
admin_member=0
|
||||||
if id -nG "$user" | tr ' ' '\n' | grep -qx incus-admin; then
|
if id -nG "$user" | tr ' ' '\n' | grep -qx incus-admin; then
|
||||||
echo "box grant: $user is in incus-admin — they already have the admin tier; there is nothing tighter to grant." >&2
|
admin_member=1
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# The stack the tier converges ONTO must exist first. Checked via the daemon,
|
# The stack the tier converges ONTO must exist first. Checked via the daemon,
|
||||||
|
|
@ -93,7 +118,11 @@ fi
|
||||||
# admin re-runs. Backing out the group closes that window completely for a
|
# admin re-runs. Backing out the group closes that window completely for a
|
||||||
# fresh grant (their existing sessions predate the membership, so no process
|
# fresh grant (their existing sessions predate the membership, so no process
|
||||||
# holds it yet). A user who was already in the group keeps it: not ours to
|
# holds it yet). A user who was already in the group keeps it: not ours to
|
||||||
# take on a re-run's failure.
|
# take on a re-run's failure. An incus-admin member now takes those same two
|
||||||
|
# paths (#101): the membership IS added for them and so IS backed out, with
|
||||||
|
# one thing extra to say either way — the rollback closes incus-user's socket
|
||||||
|
# and never their daemon access, which outlives this failure by a route the
|
||||||
|
# script never granted and must not pretend to control.
|
||||||
added_group=0; was_member=0
|
added_group=0; was_member=0
|
||||||
backout() {
|
backout() {
|
||||||
if [ "$added_group" -eq 1 ]; then
|
if [ "$added_group" -eq 1 ]; then
|
||||||
|
|
@ -106,6 +135,16 @@ backout() {
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "box grant: FAILED — removed $user from 'incus' again (verified against the group database); fix the cause and re-run" >&2
|
echo "box grant: FAILED — removed $user from 'incus' again (verified against the group database); fix the cause and re-run" >&2
|
||||||
|
if [ "$admin_member" -eq 1 ]; then
|
||||||
|
# The rollback is real and verified, but for an admin member it is not a
|
||||||
|
# lockout and must not read as one: what came back was incus-user's
|
||||||
|
# socket key, not the daemon. Say what survives, and what would end it.
|
||||||
|
echo "box grant: NOTE — that rollback closed incus-user's socket, NOT $user's access." >&2
|
||||||
|
echo " They keep full admin socket access throughout via 'incus-admin', which this run" >&2
|
||||||
|
echo " neither granted nor removed, so every project on this host stays open to them." >&2
|
||||||
|
echo " Their project may be part-converged; a re-run converges the rest. To close their" >&2
|
||||||
|
echo " access you must take the admin group itself: gpasswd -d $user incus-admin" >&2
|
||||||
|
fi
|
||||||
# The one window the database cannot close: a login STARTED between our
|
# The one window the database cannot close: a login STARTED between our
|
||||||
# usermod and this backout keeps the group in its session credentials.
|
# usermod and this backout keeps the group in its session credentials.
|
||||||
# For a fresh grant that is a rare race, but rare is not never — name it
|
# For a fresh grant that is a rare race, but rare is not never — name it
|
||||||
|
|
@ -122,6 +161,14 @@ backout() {
|
||||||
echo "box grant: FAILED with $user still holding socket access (their membership predates this run)." >&2
|
echo "box grant: FAILED with $user still holding socket access (their membership predates this run)." >&2
|
||||||
echo " their project may be part-converged — harmless in itself, and a re-run converges the rest." >&2
|
echo " their project may be part-converged — harmless in itself, and a re-run converges the rest." >&2
|
||||||
echo " if their access is not acceptable while you fix the cause: box revoke $user" >&2
|
echo " if their access is not acceptable while you fix the cause: box revoke $user" >&2
|
||||||
|
if [ "$admin_member" -eq 1 ]; then
|
||||||
|
# Same correction as above, for the member who was in BOTH groups before
|
||||||
|
# this run: 'box revoke' takes the 'incus' key back, and still leaves
|
||||||
|
# them the whole daemon.
|
||||||
|
echo "box grant: NOTE — $user is also in 'incus-admin', which this run neither granted nor removed:" >&2
|
||||||
|
echo " 'box revoke' takes back incus-user's socket key and nothing more. To close their" >&2
|
||||||
|
echo " access: gpasswd -d $user incus-admin" >&2
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
trap backout EXIT
|
trap backout EXIT
|
||||||
|
|
@ -132,17 +179,63 @@ if id -nG "$user" | tr ' ' '\n' | grep -qx incus; then
|
||||||
else
|
else
|
||||||
$SUDO usermod -aG incus "$user"
|
$SUDO usermod -aG incus "$user"
|
||||||
added_group=1
|
added_group=1
|
||||||
|
if [ "$admin_member" -eq 1 ]; then
|
||||||
|
# Say why, because the group list alone would imply a restriction that is
|
||||||
|
# not in force — the concern the old no-op was built around. It was a
|
||||||
|
# cosmetic concern and this is where it gets carried: in output, not in a
|
||||||
|
# skipped mutation that broke the mechanism.
|
||||||
|
echo "group: added $user to 'incus' — NOT a new privilege ('incus-admin' already opens the daemon,"
|
||||||
|
echo " and box_tier still reads them as 'admin'), but the key to a FILE: incus-user's socket is"
|
||||||
|
echo " group 'incus' mode 0660, and nothing below can provision $user without it"
|
||||||
|
else
|
||||||
echo "group: added $user to 'incus' (their next login picks it up; the grant does not wait)"
|
echo "group: added $user to 'incus' (their next login picks it up; the grant does not wait)"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
project="user-$uid"
|
project="user-$uid"
|
||||||
|
|
||||||
|
# The incus CLI picks its socket by WRITABILITY, not by intent: with no
|
||||||
|
# INCUS_SOCKET set it takes $INCUS_DIR/unix.socket when that is writable and
|
||||||
|
# only falls back to unix.socket.user when it is not (client/connection.go,
|
||||||
|
# stable-6.0 — the same branch that then defaults the project to user-<uid>).
|
||||||
|
# For a plain 'incus' member the fallback fires and every command below lands
|
||||||
|
# where we want it. For an incus-admin member the daemon socket IS writable,
|
||||||
|
# so an unpinned client sails straight past incus-user — the touch would not
|
||||||
|
# provision anything and the grant would die claiming incus-user was
|
||||||
|
# unhealthy. Pin the socket for them, by incus's own directory rule.
|
||||||
|
# INCUS_DIR first, then /run/incus if the daemon socket lives there, else
|
||||||
|
# /var/lib/incus: incus's resolution order, not an approximation of it — the
|
||||||
|
# pinned path has to name the same directory the client would have chosen.
|
||||||
|
user_socket=""
|
||||||
|
if [ "$admin_member" -eq 1 ]; then
|
||||||
|
incus_dir="${INCUS_DIR:-}"
|
||||||
|
if [ -z "$incus_dir" ]; then
|
||||||
|
incus_dir="/var/lib/incus"; [ -e /run/incus/unix.socket ] && incus_dir="/run/incus"
|
||||||
|
fi
|
||||||
|
user_socket="$incus_dir/unix.socket.user"
|
||||||
|
# $SUDO test, not a bare [ -e ]: revoke-user.sh documents from measurement
|
||||||
|
# that /var/lib/incus is not traversable by a non-root admin, so an
|
||||||
|
# unprivileged stat answers "absent" for a socket that is very much there —
|
||||||
|
# and this check EXITS on absent. Same discipline, same reason (#101 review).
|
||||||
|
$SUDO test -e "$user_socket" \
|
||||||
|
|| { echo "box grant: incus-user is active but $user_socket is not there — nothing can provision $project (journalctl -u incus-user)" >&2; exit 1; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Run the incus CLI as the granted user, on the socket that will actually
|
||||||
|
# serve them: pinned to incus-user for an admin member, left to the CLI's own
|
||||||
|
# resolution for everyone else (whose fallback already gets it right).
|
||||||
|
run_as_incus() {
|
||||||
|
if [ -n "$user_socket" ]; then run_as "$user" env INCUS_SOCKET="$user_socket" "$@"
|
||||||
|
else run_as "$user" "$@"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
# 2. The project is created LAZILY, on the user's first contact with
|
# 2. The project is created LAZILY, on the user's first contact with
|
||||||
# incus-user — an admin cannot pre-create it (incus-user would fight over
|
# incus-user — an admin cannot pre-create it (incus-user would fight over
|
||||||
# it), so make that first contact happen now, as the user.
|
# it), so make that first contact happen now, as the user.
|
||||||
if ! incus project show "$project" >/dev/null 2>&1 </dev/null; then
|
if ! incus project show "$project" >/dev/null 2>&1 </dev/null; then
|
||||||
echo "project: touching incus-user as $user to create $project..."
|
echo "project: touching incus-user as $user to create $project..."
|
||||||
run_as "$user" timeout 60 incus project list >/dev/null 2>&1 || true
|
run_as_incus timeout 60 incus project list >/dev/null 2>&1 || true
|
||||||
incus project show "$project" >/dev/null 2>&1 </dev/null \
|
incus project show "$project" >/dev/null 2>&1 </dev/null \
|
||||||
|| { echo "box grant: incus-user did not create $project — is incus-user.socket healthy? (journalctl -u incus-user)" >&2; exit 1; }
|
|| { echo "box grant: incus-user did not create $project — is incus-user.socket healthy? (journalctl -u incus-user)" >&2; exit 1; }
|
||||||
echo "project: $project created"
|
echo "project: $project created"
|
||||||
|
|
@ -205,10 +298,53 @@ echo "profile: box-net installed in $project"
|
||||||
|
|
||||||
# Prove the grant from the USER's side of the socket — the only side that
|
# Prove the grant from the USER's side of the socket — the only side that
|
||||||
# matters. This catches the failure the steps above cannot see one at a time:
|
# matters. This catches the failure the steps above cannot see one at a time:
|
||||||
# a converged project the user still cannot reach.
|
# a converged project the user still cannot reach. For an admin member the
|
||||||
run_as "$user" timeout 30 incus profile show box-net >/dev/null 2>&1 \
|
# project is named explicitly: an unqualified profile show over the pinned
|
||||||
|
# incus-user socket asks about 'default', and over their own admin socket it
|
||||||
|
# would answer from the shared default project — a green that proves the
|
||||||
|
# convergence nothing at all.
|
||||||
|
if [ -n "$user_socket" ]; then
|
||||||
|
run_as_incus timeout 30 incus --project "$project" profile show box-net >/dev/null 2>&1 \
|
||||||
|
|| { echo "box grant: converged, but $user cannot reach $project's box-net profile through incus-user — check journalctl -u incus-user" >&2; exit 1; }
|
||||||
|
else
|
||||||
|
run_as "$user" timeout 30 incus profile show box-net >/dev/null 2>&1 \
|
||||||
|| { echo "box grant: converged, but $user cannot see the box-net profile through incus-user — check journalctl -u incus-user" >&2; exit 1; }
|
|| { echo "box grant: converged, but $user cannot see the box-net profile through incus-user — check journalctl -u incus-user" >&2; exit 1; }
|
||||||
|
fi
|
||||||
|
|
||||||
trap - EXIT # converged and verified: the grant stands
|
trap - EXIT # converged and verified: the grant stands
|
||||||
echo "granted: $user has the restricted tier — their 'box new' lands on the hardened boxnet."
|
if [ "$admin_member" -eq 1 ]; then
|
||||||
echo " (their boxes are theirs alone; 'box revoke $user' takes the tier back)"
|
# The honest version of what the old refusal was gesturing at. The project
|
||||||
|
# is real, converged and theirs — that is what they were missing and what
|
||||||
|
# this run supplied. What it is NOT is confinement, in two distinct ways
|
||||||
|
# that both come from incus-admin winning at the socket, and both belong in
|
||||||
|
# the output rather than in a hard exit:
|
||||||
|
# · nothing here binds them. Every restriction converged above describes
|
||||||
|
# project $project; the default project and every other user's instances
|
||||||
|
# stay one flag away, and no setting inside $project can say otherwise
|
||||||
|
# while they hold that group.
|
||||||
|
# · nothing here even routes them, yet. Their unpinned CLI resolves to the
|
||||||
|
# writable daemon socket and so to the DEFAULT project (the socket rule
|
||||||
|
# cited at step 2), so their 'box new' still lands beside the other
|
||||||
|
# admins' until they either drop incus-admin — at which point this
|
||||||
|
# project becomes their automatic home, no re-run needed — or pin
|
||||||
|
# INCUS_SOCKET at incus-user by hand.
|
||||||
|
# That "no re-run needed" is a real promise only because the group step
|
||||||
|
# above put them in 'incus' (#101): dropping incus-admin leaves them a
|
||||||
|
# plain 'incus' member, which is exactly the tier whose client falls back
|
||||||
|
# to unix.socket.user and lands in $project. Under the old no-op they would
|
||||||
|
# have been left in NEITHER group — box_tier 'none', no socket at all, and
|
||||||
|
# a converged project they could not open.
|
||||||
|
echo "granted: $user has their own converged project $project (boxnet-only, snapshots, backups, box-net)."
|
||||||
|
echo " CAVEAT — $user is in 'incus-admin', which wins at the socket: this is a"
|
||||||
|
echo " DEFAULT PLACEMENT, not a confinement. They can reach the default project and"
|
||||||
|
echo " every other user's instances whenever they choose to."
|
||||||
|
echo " And until incus-admin goes, their own 'box' commands keep landing in the DEFAULT"
|
||||||
|
echo " project — the admin socket is the one their client picks. To make $project theirs"
|
||||||
|
echo " for real: gpasswd -d $user incus-admin (no re-grant needed: they keep 'incus', so"
|
||||||
|
echo " their client falls straight back to incus-user and $project is already ready)."
|
||||||
|
echo " 'box revoke $user' unwinds this provisioning and takes the 'incus' membership back;"
|
||||||
|
echo " it cannot touch their admin access."
|
||||||
|
else
|
||||||
|
echo "granted: $user has the restricted tier — their 'box new' lands on the hardened boxnet."
|
||||||
|
echo " (their boxes are theirs alone; 'box revoke $user' takes the tier back)"
|
||||||
|
fi
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,11 @@ legacy_boxes() {
|
||||||
}
|
}
|
||||||
|
|
||||||
# Re-home one box. Legacy boxes are all claude boxes (the only template the old
|
# Re-home one box. Legacy boxes are all claude boxes (the only template the old
|
||||||
# tool minted), so the new metadata is the claude template's.
|
# tool minted), so the new metadata is the claude-box template's. The stamp
|
||||||
|
# names the template as it is called TODAY, not as it was called when the box
|
||||||
|
# was minted: it is what 'box shell' and the mint hints read, so a re-homed
|
||||||
|
# box should look like a fresh claude-box mint, not like a fossil (rig#76's
|
||||||
|
# family suffix — the template is named for the role it converges).
|
||||||
rehome_one() {
|
rehome_one() {
|
||||||
local b="$1" st
|
local b="$1" st
|
||||||
incus config get "$b" user.claudebox >/dev/null 2>&1 || { warn "$b is not a legacy box (no user.claudebox tag) — skipping"; return 1; }
|
incus config get "$b" user.claudebox >/dev/null 2>&1 || { warn "$b is not a legacy box (no user.claudebox tag) — skipping"; return 1; }
|
||||||
|
|
@ -75,7 +79,7 @@ rehome_one() {
|
||||||
|
|
||||||
# 1. TAG FIRST — additive and reversible. A box that stops here is still a
|
# 1. TAG FIRST — additive and reversible. A box that stops here is still a
|
||||||
# valid legacy box (the old tag is untouched) AND now a new one.
|
# valid legacy box (the old tag is untouched) AND now a new one.
|
||||||
incus config set "$b" user.box=1 user.box.template=claude user.box.user=claude \
|
incus config set "$b" user.box=1 user.box.template=claude-box user.box.user=claude \
|
||||||
|| { warn "$b: could not set new metadata — left untouched"; return 1; }
|
|| { warn "$b: could not set new metadata — left untouched"; return 1; }
|
||||||
|
|
||||||
# 2. Stop, reassign the profile (this is the network move), restart. Incus
|
# 2. Stop, reassign the profile (this is the network move), restart. Incus
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,11 @@ if [ "$purge" -eq 1 ]; then
|
||||||
if [ -z "${BOX_YES:-}" ]; then
|
if [ -z "${BOX_YES:-}" ]; then
|
||||||
if [ -t 0 ]; then
|
if [ -t 0 ]; then
|
||||||
printf 'box revoke: delete ALL of %s'\''s boxes, images and their project %s? this cannot be undone. [y/N] ' "$user" "$project"
|
printf 'box revoke: delete ALL of %s'\''s boxes, images and their project %s? this cannot be undone. [y/N] ' "$user" "$project"
|
||||||
read -r reply
|
# EOF (Ctrl-D) is an answer too, and it is a refusal. Unguarded, 'read'
|
||||||
|
# returns non-zero and errexit ends the run right here — before the
|
||||||
|
# 'case' below can name the abort, so the most destructive prompt box
|
||||||
|
# has would go silent at the moment it asked (#111).
|
||||||
|
read -r reply || { echo "box revoke: aborted." >&2; exit 1; }
|
||||||
case "$reply" in y|Y|yes|YES|Yes) : ;; *) echo "box revoke: aborted." >&2; exit 1 ;; esac
|
case "$reply" in y|Y|yes|YES|Yes) : ;; *) echo "box revoke: aborted." >&2; exit 1 ;; esac
|
||||||
else
|
else
|
||||||
echo "box revoke: refusing to --purge without a terminal to confirm on. BOX_YES=1 means yes." >&2
|
echo "box revoke: refusing to --purge without a terminal to confirm on. BOX_YES=1 means yes." >&2
|
||||||
|
|
@ -56,14 +60,42 @@ if [ "$purge" -eq 1 ]; then
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# An incus-admin member is the mirror of grant's #99 case. 'box grant' DOES
|
||||||
|
# put them in 'incus' — not for privilege, but because incus-user's socket is
|
||||||
|
# a file owned by that group (#101) — so revoke has a real membership to take
|
||||||
|
# back here. What it does not have is a lockout: 'incus-admin' opens the
|
||||||
|
# daemon and is not this script's to remove, so "revoked" would still be the
|
||||||
|
# script claiming something it did not perform. Recorded here, spoken below.
|
||||||
|
admin_member=0
|
||||||
|
if id -nG "$user" | tr ' ' '\n' | grep -qx incus-admin; then
|
||||||
|
admin_member=1
|
||||||
|
fi
|
||||||
|
|
||||||
# The group, first — access ends even if a purge step below trips.
|
# The group, first — access ends even if a purge step below trips.
|
||||||
|
dropped_group=0
|
||||||
if id -nG "$user" | tr ' ' '\n' | grep -qx incus; then
|
if id -nG "$user" | tr ' ' '\n' | grep -qx incus; then
|
||||||
$SUDO gpasswd -d "$user" incus >/dev/null
|
$SUDO gpasswd -d "$user" incus >/dev/null
|
||||||
|
dropped_group=1
|
||||||
|
if [ "$admin_member" -eq 1 ]; then
|
||||||
|
echo "group: removed $user from 'incus' — that membership was incus-user's socket key, which"
|
||||||
|
echo " 'box grant' added. It is NOT their daemon access: 'incus-admin' is untouched here"
|
||||||
|
else
|
||||||
echo "group: removed $user from 'incus'"
|
echo "group: removed $user from 'incus'"
|
||||||
|
fi
|
||||||
|
elif [ "$admin_member" -eq 1 ]; then
|
||||||
|
echo "group: $user is not in 'incus' — nothing here to take; their socket access is 'incus-admin', which this does not touch"
|
||||||
else
|
else
|
||||||
echo "group: $user was not in 'incus'"
|
echo "group: $user was not in 'incus'"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Say it once, plainly, before either path's summary: for an admin member the
|
||||||
|
# group step above closed nothing, so nothing this script does — including
|
||||||
|
# --purge — ends their access to the daemon. Only the admin group itself does.
|
||||||
|
if [ "$admin_member" -eq 1 ]; then
|
||||||
|
echo "NOTE: $user is in 'incus-admin' and keeps FULL access to every project on this host"
|
||||||
|
echo " regardless of what follows. To actually lock them out: gpasswd -d $user incus-admin"
|
||||||
|
fi
|
||||||
|
|
||||||
# Supplementary groups are fixed AT LOGIN: the database change above does
|
# Supplementary groups are fixed AT LOGIN: the database change above does
|
||||||
# nothing to a session the user already holds — a leftover tmux keeps the
|
# nothing to a session the user already holds — a leftover tmux keeps the
|
||||||
# socket until it dies. For a bare revoke that is an honest warning. For
|
# socket until it dies. For a bare revoke that is an honest warning. For
|
||||||
|
|
@ -95,8 +127,28 @@ fi
|
||||||
if [ "$purge" -eq 0 ]; then
|
if [ "$purge" -eq 0 ]; then
|
||||||
if incus project show "$project" >/dev/null 2>&1 </dev/null; then
|
if incus project show "$project" >/dev/null 2>&1 </dev/null; then
|
||||||
echo "kept: project $project and its boxes (still running — revoking a person does not kill their workloads)"
|
echo "kept: project $project and its boxes (still running — revoking a person does not kill their workloads)"
|
||||||
|
if [ "$admin_member" -eq 1 ]; then
|
||||||
|
# "restores access" would be the wrong promise here: no access was lost.
|
||||||
|
echo " 'box revoke $user --purge' deletes them; 'box grant $user' re-converges the project and the membership"
|
||||||
|
else
|
||||||
echo " 'box revoke $user --purge' deletes them; 'box grant $user' restores access"
|
echo " 'box revoke $user --purge' deletes them; 'box grant $user' restores access"
|
||||||
fi
|
fi
|
||||||
|
fi
|
||||||
|
if [ "$admin_member" -eq 1 ]; then
|
||||||
|
# Not "revoked", still: what came back is the 'incus' membership grant
|
||||||
|
# added for incus-user's socket, and their access to this host was never
|
||||||
|
# riding on it. The project is kept. So say exactly what was taken and
|
||||||
|
# name the two real options, instead of a summary that reads as a lockout.
|
||||||
|
if [ "$dropped_group" -eq 1 ]; then
|
||||||
|
echo "partial: took $user out of 'incus' — incus-user's socket key, which 'box grant' added."
|
||||||
|
echo " $user is NOT locked out: 'incus-admin' still opens every project on this host."
|
||||||
|
else
|
||||||
|
echo "no-op: $user was not in 'incus' and holds the admin tier via 'incus-admin' — nothing was taken."
|
||||||
|
fi
|
||||||
|
echo " to remove their access: gpasswd -d $user incus-admin"
|
||||||
|
echo " to remove the project 'box grant' provisioned for them: box revoke $user --purge"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
echo "revoked: $user no longer has the restricted tier."
|
echo "revoked: $user no longer has the restricted tier."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
@ -148,10 +200,23 @@ fi
|
||||||
# and a promise the header makes is a promise this block checks. The
|
# and a promise the header makes is a promise this block checks. The
|
||||||
# incus-user state directory too — it was purged for releases without being
|
# incus-user state directory too — it was purged for releases without being
|
||||||
# re-checked, which is exactly the gap this block exists to close.
|
# re-checked, which is exactly the gap this block exists to close.
|
||||||
|
# The trust store is read into a capture rather than piped into a reader that
|
||||||
|
# stops at its first match — #102's shape, and this file is `set -euo pipefail`
|
||||||
|
# already, so unlike drill/wipe.sh (#107) nothing but the writer's size is
|
||||||
|
# holding it. A reader that exits early SIGPIPEs incus mid-table and the
|
||||||
|
# pipeline yields 141; sitting left of `&&` that is also set -e-exempt, so it
|
||||||
|
# would read as "no leftover cert" on a host that still trusts the revoked
|
||||||
|
# user's certificate and the purge would report success. Fail-open, on the
|
||||||
|
# cleanup path whose entire job is to prove access is gone.
|
||||||
|
# Un-racy in practice today — the trust store is small and likely one write —
|
||||||
|
# so this is defensive, not a live defect. Captured so it cannot become one.
|
||||||
|
trust_csv="$(incus config trust list --format csv --columns nf 2>/dev/null || true)"
|
||||||
|
|
||||||
leftover=""
|
leftover=""
|
||||||
incus project show "$project" >/dev/null 2>&1 </dev/null && leftover="$leftover $project"
|
incus project show "$project" >/dev/null 2>&1 </dev/null && leftover="$leftover $project"
|
||||||
incus network show "$bridge" >/dev/null 2>&1 </dev/null && leftover="$leftover $bridge"
|
incus network show "$bridge" >/dev/null 2>&1 </dev/null && leftover="$leftover $bridge"
|
||||||
incus config trust list --format csv --columns nf 2>/dev/null | grep -q "^incus-user-$uid," \
|
# Leading newline so the first CSV row anchors like the `^` this replaces.
|
||||||
|
[[ $'\n'"$trust_csv" == *$'\n'"incus-user-$uid,"* ]] \
|
||||||
&& leftover="$leftover cert:incus-user-$uid"
|
&& leftover="$leftover cert:incus-user-$uid"
|
||||||
$SUDO test -d "/var/lib/incus/users/$uid" 2>/dev/null \
|
$SUDO test -d "/var/lib/incus/users/$uid" 2>/dev/null \
|
||||||
&& leftover="$leftover /var/lib/incus/users/$uid"
|
&& leftover="$leftover /var/lib/incus/users/$uid"
|
||||||
|
|
@ -160,4 +225,13 @@ if [ -n "$leftover" ]; then
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "revoked: $user is out, and everything the tier created is gone."
|
if [ "$admin_member" -eq 1 ]; then
|
||||||
|
# "is out" would be false: the purge unmade the provisioning, and their
|
||||||
|
# incus-admin membership — untouched by design, it is not this script's to
|
||||||
|
# take — still opens every project on the host, including a fresh one they
|
||||||
|
# can build by hand a second from now.
|
||||||
|
echo "purged: everything 'box grant' created for $user is gone — but $user is NOT out."
|
||||||
|
echo " 'incus-admin' still gives them the whole daemon: gpasswd -d $user incus-admin"
|
||||||
|
else
|
||||||
|
echo "revoked: $user is out, and everything the tier created is gone."
|
||||||
|
fi
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,21 @@ $purge && echo "Incus itself will also be uninstalled (--purge-incus)."
|
||||||
if [ "$yes" -eq 1 ]; then
|
if [ "$yes" -eq 1 ]; then
|
||||||
echo "(confirmed non-interactively: --yes/BOX_YES)"
|
echo "(confirmed non-interactively: --yes/BOX_YES)"
|
||||||
else
|
else
|
||||||
read -rp "Continue? [y/N] " a
|
# No terminal to ask on, and no consent given: refuse and say how to proceed,
|
||||||
|
# rather than fall into 'read', hit instant EOF and abort with nothing but
|
||||||
|
# "aborted" (#113). This must stay BELOW the --yes/BOX_YES arm above — the
|
||||||
|
# order is the contract: consent given non-interactively still runs headless
|
||||||
|
# (CI's uninstall drill and 'box uninstall --purge-host --force' depend on
|
||||||
|
# it), consent NOT given without a terminal is a usage error, exit 2, the
|
||||||
|
# same shape as host/revoke-user.sh and install.sh. It also lands before the
|
||||||
|
# first 'incus' call below, so the refusal needs no daemon.
|
||||||
|
if [ ! -t 0 ]; then
|
||||||
|
echo "teardown-host: refusing to run without a terminal to confirm on. --yes (or BOX_YES=1) means yes." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
# EOF (Ctrl-D) refuses, out loud: unguarded, errexit would end the run on
|
||||||
|
# this line and the 'aborted' below would never print (#111).
|
||||||
|
read -rp "Continue? [y/N] " a || { echo "aborted"; exit 1; }
|
||||||
case "$a" in y|Y) ;; *) echo "aborted"; exit 1 ;; esac
|
case "$a" in y|Y) ;; *) echo "aborted"; exit 1 ;; esac
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -57,10 +71,37 @@ sudo systemctl daemon-reload
|
||||||
|
|
||||||
# Firewall crumbs — UFW rules mentioning either network (numbers shift after
|
# Firewall crumbs — UFW rules mentioning either network (numbers shift after
|
||||||
# each delete, so re-scan and remove the first match until none remain)
|
# each delete, so re-scan and remove the first match until none remain)
|
||||||
if command -v ufw >/dev/null && sudo ufw status 2>/dev/null | grep -q "Status: active"; then
|
# Every ufw read is CAPTURED before it is matched, never piped into a reader
|
||||||
|
# that can exit early — the same discipline box-firewall.sh now uses, and for
|
||||||
|
# the same measured reason (#102). This file sets `pipefail` (line 12), so
|
||||||
|
# `ufw status | grep -q "Status: active"` returns the WRITER's exit: grep
|
||||||
|
# matches on the first line ufw prints, closes the pipe, ufw takes SIGPIPE,
|
||||||
|
# and the pipeline yields 141. A plainly-active UFW then reads as inactive
|
||||||
|
# and this entire block silently skips, leaving stale boxnet/claudenet rules
|
||||||
|
# on a host the operator was told is clean. It is a branch condition, so
|
||||||
|
# errexit never fires — there is no error to see, which is exactly why it
|
||||||
|
# went unnoticed here while the same shape was being measured next door.
|
||||||
|
#
|
||||||
|
# The numbered loop had the same defect for a different reason: its condition
|
||||||
|
# was also an early-exit reader, so it could end while rules remained. It now
|
||||||
|
# reads one capture per iteration and breaks on absence — the re-scan is still
|
||||||
|
# per-delete (numbers shift after each removal), just no longer racing.
|
||||||
|
ufw_status=""
|
||||||
|
if command -v ufw >/dev/null; then
|
||||||
|
# '|| true': ufw exits non-zero when it cannot read its config, and under
|
||||||
|
# pipefail+errexit that would kill a teardown instead of correctly deciding
|
||||||
|
# "no usable ufw here, nothing to clean".
|
||||||
|
ufw_status="$(sudo ufw status 2>/dev/null || true)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$ufw_status" == *"Status: active"* ]]; then
|
||||||
for net in boxnet claudenet; do
|
for net in boxnet claudenet; do
|
||||||
while sudo ufw status numbered | grep -q "on $net"; do
|
while :; do
|
||||||
n="$(sudo ufw status numbered | grep -m1 "on $net" | sed -E 's/^\[ *([0-9]+)\].*/\1/')"
|
numbered="$(sudo ufw status numbered 2>/dev/null || true)"
|
||||||
|
line="$(printf '%s\n' "$numbered" | grep -m1 "on $net" || true)"
|
||||||
|
[ -n "$line" ] || break
|
||||||
|
n="$(printf '%s\n' "$line" | sed -E 's/^\[ *([0-9]+)\].*/\1/')"
|
||||||
|
[ -n "$n" ] || break
|
||||||
sudo ufw --force delete "$n"
|
sudo ufw --force delete "$n"
|
||||||
done
|
done
|
||||||
done
|
done
|
||||||
|
|
|
||||||
61
install.sh
61
install.sh
|
|
@ -143,12 +143,39 @@ flip_current() {
|
||||||
mv -Tf "$DEST/current.new.$$" "$DEST/current"
|
mv -Tf "$DEST/current.new.$$" "$DEST/current"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Whether ANY version was installed before this run — computed BEFORE the
|
||||||
|
# migration below, which is the whole point. It gates the host-setup offer: a
|
||||||
|
# host that already ran box has made that decision (and may have live boxes the
|
||||||
|
# stack must not be rebuilt under, #66); after an upgrade, 'box setup-host'
|
||||||
|
# re-applies stack changes on purpose.
|
||||||
|
#
|
||||||
|
# Order is load-bearing (#115). The migration converts a pre-0.7.0 flat tree
|
||||||
|
# into versions/<flat_ver>, so computing this AFTER it made the test true by
|
||||||
|
# its own doing: a flat host looked "already installed", setup-host was
|
||||||
|
# skipped, and every host-side artifact stayed at the old version while
|
||||||
|
# 'box --version' reported the new one. A tree that needs migrating has by
|
||||||
|
# definition never been converged by THIS version's setup-host, so it must
|
||||||
|
# read as had_install=0. A genuinely versioned tree still reads 1 — the
|
||||||
|
# directory it is testing predates this run.
|
||||||
|
had_install=0
|
||||||
|
if [ -d "$DEST/versions" ] && [ -n "$(ls -A "$DEST/versions" 2>/dev/null)" ]; then
|
||||||
|
had_install=1
|
||||||
|
fi
|
||||||
|
|
||||||
# --- migrate a pre-0.7.0 flat install --------------------------------------
|
# --- migrate a pre-0.7.0 flat install --------------------------------------
|
||||||
# 0.6.0 and earlier installed the tree FLAT at $DEST (bin/box directly under
|
# 0.6.0 and earlier installed the tree FLAT at $DEST (bin/box directly under
|
||||||
# it). Move such a tree to versions/<its-VERSION> BEFORE anything else, so an
|
# it). Move such a tree to versions/<its-VERSION> BEFORE anything else, so an
|
||||||
# upgrade from 0.6.0 is seamless and the version comparison below sees the
|
# upgrade from 0.6.0 is seamless and the version comparison below sees the
|
||||||
# truth. The move is two renames inside one parent directory — no copying, no
|
# truth. The move is two renames inside one parent directory — no copying, no
|
||||||
# window with no install — and the operator's tree is preserved bit for bit.
|
# window with no install — and the operator's tree is preserved bit for bit.
|
||||||
|
#
|
||||||
|
# What the migration LEAVES is the operator's to decide (#117): the old tree
|
||||||
|
# becomes a first-class 'box versions' entry — a rollback target if the new
|
||||||
|
# version misbehaves, garbage otherwise. Deleting it here is the wrong default
|
||||||
|
# (it is the only thing to roll back TO, at exactly the moment that matters),
|
||||||
|
# so name it instead — and name it AGAIN in the closing summary, because a
|
||||||
|
# line ~250 lines of output above 'done' is a line the operator scrolled past.
|
||||||
|
migrated_from=""
|
||||||
if [ -e "$DEST/bin/box" ] && [ ! -d "$DEST/versions" ]; then
|
if [ -e "$DEST/bin/box" ] && [ ! -d "$DEST/versions" ]; then
|
||||||
flat_ver="$(cat "$DEST/VERSION" 2>/dev/null || echo 0.0.0-unknown)"
|
flat_ver="$(cat "$DEST/VERSION" 2>/dev/null || echo 0.0.0-unknown)"
|
||||||
# The flat tree's VERSION is data from disk, not from this installer — the
|
# The flat tree's VERSION is data from disk, not from this installer — the
|
||||||
|
|
@ -164,15 +191,9 @@ if [ -e "$DEST/bin/box" ] && [ ! -d "$DEST/versions" ]; then
|
||||||
mkdir -p "$BINDIR"
|
mkdir -p "$BINDIR"
|
||||||
ln -sfn "$DEST/current/bin/box" "$BINDIR/box"
|
ln -sfn "$DEST/current/bin/box" "$BINDIR/box"
|
||||||
log "migrated: it now lives at $DEST/versions/$flat_ver (still current; your boxes are untouched)"
|
log "migrated: it now lives at $DEST/versions/$flat_ver (still current; your boxes are untouched)"
|
||||||
fi
|
log " it is a normal version entry now — 'box versions' lists it. Keep it as a"
|
||||||
|
log " rollback target ('box use $flat_ver'), or reap it: box uninstall $flat_ver"
|
||||||
# Whether ANY version was installed before this run — read before we add one.
|
migrated_from="$flat_ver"
|
||||||
# It gates the host-setup offer below: a host that already ran box has made
|
|
||||||
# that decision (and may have live boxes the stack must not be rebuilt under,
|
|
||||||
# #66); after an upgrade, 'box setup-host' re-applies stack changes on purpose.
|
|
||||||
had_install=0
|
|
||||||
if [ -d "$DEST/versions" ] && [ -n "$(ls -A "$DEST/versions" 2>/dev/null)" ]; then
|
|
||||||
had_install=1
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- temp workspace --------------------------------------------------------
|
# --- temp workspace --------------------------------------------------------
|
||||||
|
|
@ -386,6 +407,15 @@ esac
|
||||||
# 'box setup-host' re-applies stack changes deliberately, after an upgrade.
|
# 'box setup-host' re-applies stack changes deliberately, after an upgrade.
|
||||||
# BOX_SKIP_SETUP_HOST=1 answers "no" without prompting (image builds, a host set
|
# BOX_SKIP_SETUP_HOST=1 answers "no" without prompting (image builds, a host set
|
||||||
# up by hand); BOX_YES answers "yes".
|
# up by hand); BOX_YES answers "yes".
|
||||||
|
#
|
||||||
|
# It runs $VDIR's script, NOT $DEST/current's. They are usually the same tree,
|
||||||
|
# but 'current' does not always flip: the #66 guard above keeps the default
|
||||||
|
# where it is when the host has existing boxes, so on such a host 'current'
|
||||||
|
# still names the OLD version. Going through it would converge the host with
|
||||||
|
# the old release's host-side scripts — reinstating exactly the stale artifacts
|
||||||
|
# #115 is about, in the one case where the operator's boxes make it costly.
|
||||||
|
# $VDIR is unambiguously the version this run installed, which is the version
|
||||||
|
# whose host contract we are being asked to satisfy.
|
||||||
setup_ok=""
|
setup_ok=""
|
||||||
setup_declined=""
|
setup_declined=""
|
||||||
if [ "$had_install" -eq 1 ]; then
|
if [ "$had_install" -eq 1 ]; then
|
||||||
|
|
@ -396,7 +426,7 @@ elif [ -n "${BOX_SKIP_SETUP_HOST:-}" ]; then
|
||||||
setup_declined=1
|
setup_declined=1
|
||||||
elif [ "$(id -u)" -ne 0 ] && ! command -v sudo >/dev/null 2>&1; then
|
elif [ "$(id -u)" -ne 0 ] && ! command -v sudo >/dev/null 2>&1; then
|
||||||
warn "cannot set up the host: it needs root and sudo was not found."
|
warn "cannot set up the host: it needs root and sudo was not found."
|
||||||
warn " run this as root to finish: $DEST/current/host/setup-host.sh"
|
warn " run this as root to finish: $VDIR/host/setup-host.sh"
|
||||||
setup_declined=1
|
setup_declined=1
|
||||||
elif confirm "Set up this machine as a box host now? (installs Incus + the isolation stack; needs sudo)"; then
|
elif confirm "Set up this machine as a box host now? (installs Incus + the isolation stack; needs sudo)"; then
|
||||||
# </dev/null because under 'curl … | bash' this script IS stdin: a child that
|
# </dev/null because under 'curl … | bash' this script IS stdin: a child that
|
||||||
|
|
@ -404,7 +434,7 @@ elif confirm "Set up this machine as a box host now? (installs Incus + the isola
|
||||||
# it prompts on /dev/tty, so an interactive host can still authenticate.
|
# it prompts on /dev/tty, so an interactive host can still authenticate.
|
||||||
# setup-host re-execs itself under sg incus-admin if it must add you to the
|
# setup-host re-execs itself under sg incus-admin if it must add you to the
|
||||||
# group; that re-exec is a child here and completes the whole setup in one go.
|
# group; that re-exec is a child here and completes the whole setup in one go.
|
||||||
if bash "$DEST/current/host/setup-host.sh" </dev/null; then
|
if bash "$VDIR/host/setup-host.sh" </dev/null; then
|
||||||
setup_ok=1
|
setup_ok=1
|
||||||
else
|
else
|
||||||
warn "host setup did not complete — box is installed, the host is not ready."
|
warn "host setup did not complete — box is installed, the host is not ready."
|
||||||
|
|
@ -422,3 +452,12 @@ elif [ -n "$setup_declined" ]; then
|
||||||
else
|
else
|
||||||
log "done ($SRCDESC, version $new_ver) — finish with 'box setup-host', then: box new --name test"
|
log "done ($SRCDESC, version $new_ver) — finish with 'box setup-host', then: box new --name test"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Re-state the migration where the operator is actually looking (#117). The
|
||||||
|
# line at the migration itself is true but ~250 lines of output ago; this is
|
||||||
|
# the last thing printed, and it is where an unexplained 'box versions' entry
|
||||||
|
# stops being a surprise.
|
||||||
|
if [ -n "$migrated_from" ]; then
|
||||||
|
log "note: your pre-0.7.0 install was migrated to versions/$migrated_from and 'box versions' now lists it."
|
||||||
|
log " keep it to roll back ('box use $migrated_from'), or remove it: box uninstall $migrated_from"
|
||||||
|
fi
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
# The claude template — a thin, creds-free seed (#81): Debian 13, the
|
# The claude-box template — a thin, creds-free seed (#81): Debian 13, the
|
||||||
# 'claude' user, tmux and rig. What the box BECOMES — the Claude Code CLI,
|
# 'claude' user, tmux and rig. What the box BECOMES — the Claude Code CLI,
|
||||||
# docker, node, the agent-context file with its #80 guard — is rig's job:
|
# docker, node, the agent-context file with its #80 guard — is rig's job:
|
||||||
# box auto-runs 'rig bootstrap claude' after mint (heavy-duty/rig#31).
|
# box auto-runs 'rig bootstrap claude-box' after mint (heavy-duty/rig#31).
|
||||||
|
# The template is named for the role it converges, suffix and all
|
||||||
|
# (heavy-duty/rig#76): rig's roles carry a family suffix — '-server' for
|
||||||
|
# fleet machines, '-box' for box tenants — and a seed that named the bare
|
||||||
|
# 'claude' would ask a post-rename rig for a role that no longer exists.
|
||||||
# KEY="value" only. Parsed against an allowlist, never sourced; there is no
|
# KEY="value" only. Parsed against an allowlist, never sourced; there is no
|
||||||
# key for a network or a security flag, on purpose — the shared box-net
|
# key for a network or a security flag, on purpose — the shared box-net
|
||||||
# profile is the placement contract and no template can weaken it.
|
# profile is the placement contract and no template can weaken it.
|
||||||
|
|
@ -14,4 +18,4 @@ BOX_USER="claude"
|
||||||
BOX_CPU="4"
|
BOX_CPU="4"
|
||||||
BOX_MEMORY="8GiB"
|
BOX_MEMORY="8GiB"
|
||||||
BOX_DISK="60GiB"
|
BOX_DISK="60GiB"
|
||||||
BOX_BOOTSTRAP_ROLE="claude"
|
BOX_BOOTSTRAP_ROLE="claude-box"
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
# A thin, creds-free seed (#81): the tenant user, tmux (#65), and rig —
|
# A thin, creds-free seed (#81): the tenant user, tmux (#65), and rig —
|
||||||
# nothing that joins a tailnet or admits credentials, no agent CLI, no
|
# nothing that joins a tailnet or admits credentials, no agent CLI, no
|
||||||
# docker, no context-file heredoc. Everything this box becomes comes from
|
# docker, no context-file heredoc. Everything this box becomes comes from
|
||||||
# 'rig bootstrap claude' (heavy-duty/rig#31), which box auto-runs after
|
# 'rig bootstrap claude-box' (heavy-duty/rig#31), which box auto-runs after
|
||||||
# mint; the agent-context file — including the #80 guard (never run
|
# mint; the agent-context file — including the #80 guard (never run
|
||||||
# 'box setup-host' or the drill inside a box) — is rendered by that role,
|
# 'box setup-host' or the drill inside a box) — is rendered by that role,
|
||||||
# once, instead of being copy-pasted per template.
|
# once, instead of being copy-pasted per template.
|
||||||
|
|
@ -21,7 +21,7 @@ packages:
|
||||||
- ca-certificates
|
- ca-certificates
|
||||||
runcmd:
|
runcmd:
|
||||||
# Preinstall rig so the box can converge — and re-converge — via
|
# Preinstall rig so the box can converge — and re-converge — via
|
||||||
# 'rig bootstrap claude'. @RIG_REPO@/@RIG_REF@ are the pin point (#81):
|
# 'rig bootstrap claude-box'. @RIG_REPO@/@RIG_REF@ are the pin point (#81):
|
||||||
# box substitutes them at mint from the RIG_REPO/RIG_REF environment
|
# box substitutes them at mint from the RIG_REPO/RIG_REF environment
|
||||||
# (default heavy-duty/rig @ main — unpinned, tracking main, the same
|
# (default heavy-duty/rig @ main — unpinned, tracking main, the same
|
||||||
# honest edge as rig's own unpinned box install, until rig#32 ships a
|
# honest edge as rig's own unpinned box install, until rig#32 ships a
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
# The codex template — a thin, creds-free seed (#81): Debian 13, the
|
# The codex-box template — a thin, creds-free seed (#81): Debian 13, the
|
||||||
# 'codex' user, tmux and rig. What the box BECOMES — the OpenAI Codex CLI,
|
# 'codex' user, tmux and rig. What the box BECOMES — the OpenAI Codex CLI,
|
||||||
# docker, node, the agent-context file with its #80 guard — is rig's job:
|
# docker, node, the agent-context file with its #80 guard — is rig's job:
|
||||||
# box auto-runs 'rig bootstrap codex' after mint (heavy-duty/rig#31).
|
# box auto-runs 'rig bootstrap codex-box' after mint (heavy-duty/rig#31).
|
||||||
|
# The template is named for the role it converges, suffix and all
|
||||||
|
# (heavy-duty/rig#76): rig's roles carry a family suffix — '-server' for
|
||||||
|
# fleet machines, '-box' for box tenants — and a seed that named the bare
|
||||||
|
# 'codex' would ask a post-rename rig for a role that no longer exists.
|
||||||
# KEY="value" only. Parsed against an allowlist, never sourced; there is no
|
# KEY="value" only. Parsed against an allowlist, never sourced; there is no
|
||||||
# key for a network or a security flag, on purpose — the shared box-net
|
# key for a network or a security flag, on purpose — the shared box-net
|
||||||
# profile is the placement contract and no template can weaken it.
|
# profile is the placement contract and no template can weaken it.
|
||||||
|
|
@ -14,4 +18,4 @@ BOX_USER="codex"
|
||||||
BOX_CPU="4"
|
BOX_CPU="4"
|
||||||
BOX_MEMORY="8GiB"
|
BOX_MEMORY="8GiB"
|
||||||
BOX_DISK="60GiB"
|
BOX_DISK="60GiB"
|
||||||
BOX_BOOTSTRAP_ROLE="codex"
|
BOX_BOOTSTRAP_ROLE="codex-box"
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
# A thin, creds-free seed (#81): the tenant user, tmux (#65), and rig —
|
# A thin, creds-free seed (#81): the tenant user, tmux (#65), and rig —
|
||||||
# nothing that joins a tailnet or admits credentials, no agent CLI, no
|
# nothing that joins a tailnet or admits credentials, no agent CLI, no
|
||||||
# docker, no context-file heredoc. Everything this box becomes comes from
|
# docker, no context-file heredoc. Everything this box becomes comes from
|
||||||
# 'rig bootstrap codex' (heavy-duty/rig#31), which box auto-runs after
|
# 'rig bootstrap codex-box' (heavy-duty/rig#31), which box auto-runs after
|
||||||
# mint; the agent-context file — including the #80 guard (never run
|
# mint; the agent-context file — including the #80 guard (never run
|
||||||
# 'box setup-host' or the drill inside a box) — is rendered by that role,
|
# 'box setup-host' or the drill inside a box) — is rendered by that role,
|
||||||
# once, instead of being copy-pasted per template.
|
# once, instead of being copy-pasted per template.
|
||||||
|
|
@ -21,7 +21,7 @@ packages:
|
||||||
- ca-certificates
|
- ca-certificates
|
||||||
runcmd:
|
runcmd:
|
||||||
# Preinstall rig so the box can converge — and re-converge — via
|
# Preinstall rig so the box can converge — and re-converge — via
|
||||||
# 'rig bootstrap codex'. @RIG_REPO@/@RIG_REF@ are the pin point (#81):
|
# 'rig bootstrap codex-box'. @RIG_REPO@/@RIG_REF@ are the pin point (#81):
|
||||||
# box substitutes them at mint from the RIG_REPO/RIG_REF environment
|
# box substitutes them at mint from the RIG_REPO/RIG_REF environment
|
||||||
# (default heavy-duty/rig @ main — unpinned, tracking main, the same
|
# (default heavy-duty/rig @ main — unpinned, tracking main, the same
|
||||||
# honest edge as rig's own unpinned box install, until rig#32 ships a
|
# honest edge as rig's own unpinned box install, until rig#32 ships a
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
# The grok template — a thin, creds-free seed (#81): Debian 13, the
|
# The grok-box template — a thin, creds-free seed (#81): Debian 13, the
|
||||||
# 'grok' user, tmux and rig. What the box BECOMES — the xAI Grok CLI,
|
# 'grok' user, tmux and rig. What the box BECOMES — the xAI Grok CLI,
|
||||||
# docker, the agent-context file with its #80 guard — is rig's job:
|
# docker, the agent-context file with its #80 guard — is rig's job:
|
||||||
# box auto-runs 'rig bootstrap grok' after mint (heavy-duty/rig#31).
|
# box auto-runs 'rig bootstrap grok-box' after mint (heavy-duty/rig#31).
|
||||||
|
# The template is named for the role it converges, suffix and all
|
||||||
|
# (heavy-duty/rig#76): rig's roles carry a family suffix — '-server' for
|
||||||
|
# fleet machines, '-box' for box tenants — and a seed that named the bare
|
||||||
|
# 'grok' would ask a post-rename rig for a role that no longer exists.
|
||||||
# KEY="value" only. Parsed against an allowlist, never sourced; there is no
|
# KEY="value" only. Parsed against an allowlist, never sourced; there is no
|
||||||
# key for a network or a security flag, on purpose — the shared box-net
|
# key for a network or a security flag, on purpose — the shared box-net
|
||||||
# profile is the placement contract and no template can weaken it.
|
# profile is the placement contract and no template can weaken it.
|
||||||
|
|
@ -14,4 +18,4 @@ BOX_USER="grok"
|
||||||
BOX_CPU="4"
|
BOX_CPU="4"
|
||||||
BOX_MEMORY="8GiB"
|
BOX_MEMORY="8GiB"
|
||||||
BOX_DISK="60GiB"
|
BOX_DISK="60GiB"
|
||||||
BOX_BOOTSTRAP_ROLE="grok"
|
BOX_BOOTSTRAP_ROLE="grok-box"
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
# A thin, creds-free seed (#81): the tenant user, tmux (#65), and rig —
|
# A thin, creds-free seed (#81): the tenant user, tmux (#65), and rig —
|
||||||
# nothing that joins a tailnet or admits credentials, no agent CLI, no
|
# nothing that joins a tailnet or admits credentials, no agent CLI, no
|
||||||
# docker, no context-file heredoc. Everything this box becomes comes from
|
# docker, no context-file heredoc. Everything this box becomes comes from
|
||||||
# 'rig bootstrap grok' (heavy-duty/rig#31), which box auto-runs after
|
# 'rig bootstrap grok-box' (heavy-duty/rig#31), which box auto-runs after
|
||||||
# mint; the agent-context file — including the #80 guard (never run
|
# mint; the agent-context file — including the #80 guard (never run
|
||||||
# 'box setup-host' or the drill inside a box) — is rendered by that role,
|
# 'box setup-host' or the drill inside a box) — is rendered by that role,
|
||||||
# once, instead of being copy-pasted per template.
|
# once, instead of being copy-pasted per template.
|
||||||
|
|
@ -21,7 +21,7 @@ packages:
|
||||||
- ca-certificates
|
- ca-certificates
|
||||||
runcmd:
|
runcmd:
|
||||||
# Preinstall rig so the box can converge — and re-converge — via
|
# Preinstall rig so the box can converge — and re-converge — via
|
||||||
# 'rig bootstrap grok'. @RIG_REPO@/@RIG_REF@ are the pin point (#81):
|
# 'rig bootstrap grok-box'. @RIG_REPO@/@RIG_REF@ are the pin point (#81):
|
||||||
# box substitutes them at mint from the RIG_REPO/RIG_REF environment
|
# box substitutes them at mint from the RIG_REPO/RIG_REF environment
|
||||||
# (default heavy-duty/rig @ main — unpinned, tracking main, the same
|
# (default heavy-duty/rig @ main — unpinned, tracking main, the same
|
||||||
# honest edge as rig's own unpinned box install, until rig#32 ships a
|
# honest edge as rig's own unpinned box install, until rig#32 ships a
|
||||||
22
templates/kimi-box/box.env
Normal file
22
templates/kimi-box/box.env
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
# The kimi-box template — a thin, creds-free seed (#81): Debian 13, the
|
||||||
|
# 'kimi' user, tmux and rig. What the box BECOMES — the Moonshot Kimi CLI,
|
||||||
|
# docker, the agent-context file with its #80 guard — is rig's job:
|
||||||
|
# box auto-runs 'rig bootstrap kimi-box' after mint (heavy-duty/rig#31,
|
||||||
|
# the role itself: heavy-duty/rig#109).
|
||||||
|
# The template is named for the role it converges, suffix and all
|
||||||
|
# (heavy-duty/rig#76): rig's roles carry a family suffix — '-server' for
|
||||||
|
# fleet machines, '-box' for box tenants — and a seed that named the bare
|
||||||
|
# 'kimi' would ask a post-rename rig for a role that no longer exists.
|
||||||
|
# KEY="value" only. Parsed against an allowlist, never sourced; there is no
|
||||||
|
# key for a network or a security flag, on purpose — the shared box-net
|
||||||
|
# profile is the placement contract and no template can weaken it.
|
||||||
|
# BOX_USER must match the user user-data.yaml creates (the duplication is
|
||||||
|
# deliberate and by hand) — and it is the tenant user the rig role converges
|
||||||
|
# (rig dies loudly if the seed did not create it).
|
||||||
|
BOX_DESCRIPTION="Moonshot Kimi CLI on Debian 13, creds-free — box mints, rig converges"
|
||||||
|
BOX_IMAGE="images:debian/13/cloud"
|
||||||
|
BOX_USER="kimi"
|
||||||
|
BOX_CPU="4"
|
||||||
|
BOX_MEMORY="8GiB"
|
||||||
|
BOX_DISK="60GiB"
|
||||||
|
BOX_BOOTSTRAP_ROLE="kimi-box"
|
||||||
33
templates/kimi-box/user-data.yaml
Normal file
33
templates/kimi-box/user-data.yaml
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
#cloud-config
|
||||||
|
# A thin, creds-free seed (#81): the tenant user, tmux (#65), and rig —
|
||||||
|
# nothing that joins a tailnet or admits credentials, no agent CLI, no
|
||||||
|
# docker, no context-file heredoc. Everything this box becomes comes from
|
||||||
|
# 'rig bootstrap kimi-box' (heavy-duty/rig#31), which box auto-runs after
|
||||||
|
# mint; the agent-context file — including the #80 guard (never run
|
||||||
|
# 'box setup-host' or the drill inside a box) — is rendered by that role,
|
||||||
|
# once, instead of being copy-pasted per template.
|
||||||
|
users:
|
||||||
|
- name: kimi
|
||||||
|
shell: /bin/bash
|
||||||
|
sudo: "ALL=(ALL) NOPASSWD:ALL"
|
||||||
|
lock_passwd: true
|
||||||
|
package_update: true
|
||||||
|
# tmux: 'box tmux' runs 'tmux new-session' INSIDE the box (#65).
|
||||||
|
# curl + ca-certificates: the rig installer below rides them, and a bare
|
||||||
|
# cloud image is not guaranteed to ship either.
|
||||||
|
packages:
|
||||||
|
- tmux
|
||||||
|
- curl
|
||||||
|
- ca-certificates
|
||||||
|
runcmd:
|
||||||
|
# Preinstall rig so the box can converge — and re-converge — via
|
||||||
|
# 'rig bootstrap kimi-box'. @RIG_REPO@/@RIG_REF@ are the pin point (#81):
|
||||||
|
# box substitutes them at mint from the RIG_REPO/RIG_REF environment
|
||||||
|
# (default heavy-duty/rig @ main — unpinned, tracking main, the same
|
||||||
|
# honest edge as rig's own unpinned box install, until rig#32 ships a
|
||||||
|
# release flow). The pin covers both the installer fetched AND the tree
|
||||||
|
# it installs, so a branch under review is testable end to end.
|
||||||
|
# HOME=/root: cloud-init runs runcmd as root but with NO $HOME in the
|
||||||
|
# environment, and the rig installer (set -u) reads $HOME for its DEST —
|
||||||
|
# measured live: the mint died with "HOME: unbound variable" without it.
|
||||||
|
- curl -fsSL https://raw.githubusercontent.com/@RIG_REPO@/@RIG_REF@/install.sh | HOME=/root RIG_REPO="@RIG_REPO@" RIG_REF="@RIG_REF@" bash
|
||||||
|
|
@ -1,9 +1,14 @@
|
||||||
# The staging template — a thin, creds-free, server-class seed (#81, the
|
# The staging-box template — a thin, creds-free, server-class seed (#81, the
|
||||||
# re-cut of #69's layering): Debian 13, the 'ops' user, tmux and rig. The
|
# re-cut of #69's layering): Debian 13, the 'ops' user, tmux and rig. The
|
||||||
# server posture — docker, sshd hardening — is rig's job: box auto-runs
|
# server posture — docker, sshd hardening — is rig's job: box auto-runs
|
||||||
# 'rig bootstrap staging' after mint (heavy-duty/rig#31). The tailnet
|
# 'rig bootstrap staging-box' after mint (heavy-duty/rig#31). The template is
|
||||||
|
# named for the role it converges, suffix and all (heavy-duty/rig#76): rig's
|
||||||
|
# roles carry a family suffix — '-server' for fleet machines, '-box' for box
|
||||||
|
# tenants — and a seed that named the bare 'staging' would ask a post-rename
|
||||||
|
# rig for a role that no longer exists. Server-CLASS, not a fleet machine:
|
||||||
|
# this is still a box tenant, so it takes '-box', not '-server'. The tailnet
|
||||||
# workload join holds a key and therefore STAYS operator-run:
|
# workload join holds a key and therefore STAYS operator-run:
|
||||||
# box shell <name> # then: sudo rig bootstrap workload --hostname <name>
|
# box shell <name> # then: sudo rig bootstrap workload-server --hostname <name>
|
||||||
# KEY="value" only. Parsed against an allowlist, never sourced; there is no
|
# KEY="value" only. Parsed against an allowlist, never sourced; there is no
|
||||||
# key for a network or a security flag, on purpose — the shared box-net
|
# key for a network or a security flag, on purpose — the shared box-net
|
||||||
# profile is the placement contract and no template can weaken it.
|
# profile is the placement contract and no template can weaken it.
|
||||||
|
|
@ -21,4 +26,4 @@ BOX_MEMORY="8GiB"
|
||||||
BOX_DISK="60GiB"
|
BOX_DISK="60GiB"
|
||||||
BOX_REQUIRE_VM="1"
|
BOX_REQUIRE_VM="1"
|
||||||
BOX_AUTOSTART="1"
|
BOX_AUTOSTART="1"
|
||||||
BOX_BOOTSTRAP_ROLE="staging"
|
BOX_BOOTSTRAP_ROLE="staging-box"
|
||||||
|
|
@ -2,9 +2,9 @@
|
||||||
# A thin, creds-free, server-class seed (#81): the 'ops' user, tmux (#65),
|
# A thin, creds-free, server-class seed (#81): the 'ops' user, tmux (#65),
|
||||||
# and rig — nothing that joins a tailnet or admits credentials, no docker,
|
# and rig — nothing that joins a tailnet or admits credentials, no docker,
|
||||||
# no sshd config, no keys. The server posture comes from
|
# no sshd config, no keys. The server posture comes from
|
||||||
# 'rig bootstrap staging' (heavy-duty/rig#31), which box auto-runs after
|
# 'rig bootstrap staging-box' (heavy-duty/rig#31), which box auto-runs after
|
||||||
# mint; the tailnet workload join holds a pre-auth key and stays
|
# mint; the tailnet workload join holds a pre-auth key and stays
|
||||||
# operator-run ('box shell' → 'sudo rig bootstrap workload'), exactly as
|
# operator-run ('box shell' → 'sudo rig bootstrap workload-server'), exactly as
|
||||||
# #69 designed it — box never sees the key.
|
# #69 designed it — box never sees the key.
|
||||||
users:
|
users:
|
||||||
- name: ops
|
- name: ops
|
||||||
|
|
@ -22,7 +22,7 @@ packages:
|
||||||
- ca-certificates
|
- ca-certificates
|
||||||
runcmd:
|
runcmd:
|
||||||
# Preinstall rig so the box can converge — and re-converge — via
|
# Preinstall rig so the box can converge — and re-converge — via
|
||||||
# 'rig bootstrap staging'. @RIG_REPO@/@RIG_REF@ are the pin point (#81):
|
# 'rig bootstrap staging-box'. @RIG_REPO@/@RIG_REF@ are the pin point (#81):
|
||||||
# box substitutes them at mint from the RIG_REPO/RIG_REF environment
|
# box substitutes them at mint from the RIG_REPO/RIG_REF environment
|
||||||
# (default heavy-duty/rig @ main — unpinned, tracking main, the same
|
# (default heavy-duty/rig @ main — unpinned, tracking main, the same
|
||||||
# honest edge as rig's own unpinned box install, until rig#32 ships a
|
# honest edge as rig's own unpinned box install, until rig#32 ships a
|
||||||
1695
test/cli.sh
1695
test/cli.sh
File diff suppressed because it is too large
Load diff
|
|
@ -1,150 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# Fixture tests for the labels-reconcile state machine: a comment is a
|
|
||||||
# non-verdict whatever its body says (the AUTHOR escalates by requesting the
|
|
||||||
# human), a stale approval does not promote unreviewed code, and an explicit
|
|
||||||
# human request outranks everything.
|
|
||||||
# Dependency-free beyond jq; no network, no daemon — pure decide_state.
|
|
||||||
|
|
||||||
cd "$(dirname "$0")/.."
|
|
||||||
# shellcheck source=.github/scripts/labels-reconcile.sh
|
|
||||||
. .github/scripts/labels-reconcile.sh
|
|
||||||
|
|
||||||
# The DRAFT/HEAD_SHA/REQUESTED/REVIEWS_JSON assignments below are the state
|
|
||||||
# machine's inputs, consumed inside the sourced decide_state — not unused.
|
|
||||||
# shellcheck disable=SC2034
|
|
||||||
BOT1="${BOTS[0]}" BOT2="${BOTS[1]}" BOT3="${BOTS[2]}"
|
|
||||||
pass=0 fail=0
|
|
||||||
|
|
||||||
expect() { # $1 = description, $2 = want, $3 = got
|
|
||||||
if [ "$2" = "$3" ]; then
|
|
||||||
pass=$((pass + 1))
|
|
||||||
else
|
|
||||||
fail=$((fail + 1))
|
|
||||||
printf 'FAIL: %s — want %s, got %s\n' "$1" "$2" "$3"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
rev() { # $1=login $2=state $3=commit $4=body $5=submitted_at → one review object
|
|
||||||
jq -n --arg u "$1" --arg s "$2" --arg c "$3" --arg b "$4" --arg t "$5" \
|
|
||||||
'{user: {login: $u}, state: $s, commit_id: $c, body: $b, submitted_at: $t}'
|
|
||||||
}
|
|
||||||
|
|
||||||
reviews() { jq -s '.' <<<"$*"; } # collect review objects into an array
|
|
||||||
|
|
||||||
# -- drafts are building, whoever is requested --------------------------------
|
|
||||||
DRAFT=true HEAD_SHA=head1 REQUESTED="" REVIEWS_JSON='[]'
|
|
||||||
expect "draft PR is building" state:building "$(decide_state)"
|
|
||||||
|
|
||||||
# -- fresh ready PR with bots requested ---------------------------------------
|
|
||||||
DRAFT=false REQUESTED="$BOT1
|
|
||||||
$BOT2
|
|
||||||
$BOT3" REVIEWS_JSON='[]'
|
|
||||||
expect "requested bots mean bots-reviewing" state:bots-reviewing "$(decide_state)"
|
|
||||||
|
|
||||||
# -- a bot that never reviewed keeps the round open ---------------------------
|
|
||||||
REQUESTED="" REVIEWS_JSON="$(reviews \
|
|
||||||
"$(rev "$BOT1" APPROVED head1 "" t1)" \
|
|
||||||
"$(rev "$BOT2" APPROVED head1 "" t2)")"
|
|
||||||
expect "missing bot review means bots-reviewing" state:bots-reviewing "$(decide_state)"
|
|
||||||
|
|
||||||
# -- a comment is a non-verdict, agreement body or not: the author escalates --
|
|
||||||
REVIEWS_JSON="$(reviews \
|
|
||||||
"$(rev "$BOT1" COMMENTED head1 "✅ **Reviewed — I agree with everything.**" t1)" \
|
|
||||||
"$(rev "$BOT2" APPROVED head1 "" t2)" \
|
|
||||||
"$(rev "$BOT3" APPROVED head1 "" t3)")"
|
|
||||||
expect "comment-only agreement still parks on the author" state:addressing "$(decide_state)"
|
|
||||||
# ...and the author's escalation — requesting the human — flips it
|
|
||||||
REQUESTED="$HUMAN"
|
|
||||||
expect "author escalation flips to needs-human" state:needs-human "$(decide_state)"
|
|
||||||
REQUESTED=""
|
|
||||||
|
|
||||||
# -- three formal approvals need no author judgment ---------------------------
|
|
||||||
REVIEWS_JSON="$(reviews \
|
|
||||||
"$(rev "$BOT1" APPROVED head1 "" t1)" \
|
|
||||||
"$(rev "$BOT2" APPROVED head1 "" t2)" \
|
|
||||||
"$(rev "$BOT3" APPROVED head1 "" t3)")"
|
|
||||||
expect "three formal approvals reach needs-human" state:needs-human "$(decide_state)"
|
|
||||||
|
|
||||||
# -- a comment WITHOUT a verdict parks the PR on the agent --------------------
|
|
||||||
REVIEWS_JSON="$(reviews \
|
|
||||||
"$(rev "$BOT1" COMMENTED head1 "🔧 Reviewed — I agree with most; feedback below." t1)" \
|
|
||||||
"$(rev "$BOT2" APPROVED head1 "" t2)" \
|
|
||||||
"$(rev "$BOT3" APPROVED head1 "" t3)")"
|
|
||||||
expect "comment without verdict is addressing" state:addressing "$(decide_state)"
|
|
||||||
|
|
||||||
# -- changes requested blocks, at any head ------------------------------------
|
|
||||||
REVIEWS_JSON="$(reviews \
|
|
||||||
"$(rev "$BOT1" CHANGES_REQUESTED old1 "blockers below" t1)" \
|
|
||||||
"$(rev "$BOT2" APPROVED head1 "" t2)" \
|
|
||||||
"$(rev "$BOT3" APPROVED head1 "" t3)")"
|
|
||||||
expect "changes-requested blocks even from an old head" state:addressing "$(decide_state)"
|
|
||||||
|
|
||||||
# -- a stale approval must not promote unreviewed code ------------------------
|
|
||||||
REVIEWS_JSON="$(reviews \
|
|
||||||
"$(rev "$BOT1" APPROVED old1 "" t1)" \
|
|
||||||
"$(rev "$BOT2" APPROVED head1 "" t2)" \
|
|
||||||
"$(rev "$BOT3" APPROVED head1 "" t3)")"
|
|
||||||
expect "stale approval is addressing (agent owes re-request)" state:addressing "$(decide_state)"
|
|
||||||
|
|
||||||
# -- a re-requested bot reopens the round even with an old approval on file ---
|
|
||||||
REQUESTED="$BOT1"
|
|
||||||
expect "re-requested bot means bots-reviewing" state:bots-reviewing "$(decide_state)"
|
|
||||||
REQUESTED=""
|
|
||||||
|
|
||||||
# -- only the LATEST review per bot counts ------------------------------------
|
|
||||||
REVIEWS_JSON="$(reviews \
|
|
||||||
"$(rev "$BOT1" CHANGES_REQUESTED head1 "blockers" t1)" \
|
|
||||||
"$(rev "$BOT1" APPROVED head1 "" t2)" \
|
|
||||||
"$(rev "$BOT2" APPROVED head1 "" t3)" \
|
|
||||||
"$(rev "$BOT3" APPROVED head1 "" t4)")"
|
|
||||||
expect "later approval supersedes earlier block" state:needs-human "$(decide_state)"
|
|
||||||
|
|
||||||
# -- an explicit human request outranks the bot rounds ------------------------
|
|
||||||
REQUESTED="$HUMAN" REVIEWS_JSON="$(reviews \
|
|
||||||
"$(rev "$BOT1" COMMENTED head1 "feedback, no verdict" t1)")"
|
|
||||||
expect "human requested outranks bots" state:needs-human "$(decide_state)"
|
|
||||||
REQUESTED=""
|
|
||||||
|
|
||||||
# -- human CHANGES_REQUESTED puts the ball back on the agent ------------------
|
|
||||||
REVIEWS_JSON="$(reviews \
|
|
||||||
"$(rev "$BOT1" APPROVED head1 "" t1)" \
|
|
||||||
"$(rev "$BOT2" APPROVED head1 "" t2)" \
|
|
||||||
"$(rev "$BOT3" APPROVED head1 "" t3)" \
|
|
||||||
"$(rev "$HUMAN" CHANGES_REQUESTED head1 "not yet" t4)")"
|
|
||||||
expect "human block with bots approving is addressing" state:addressing "$(decide_state)"
|
|
||||||
# ...and re-requesting the human hands it back to them
|
|
||||||
REQUESTED="$HUMAN"
|
|
||||||
expect "re-requested human is needs-human again" state:needs-human "$(decide_state)"
|
|
||||||
REQUESTED=""
|
|
||||||
|
|
||||||
# -- an old human comment must not wedge the handoff (codex, #85 round 3) -----
|
|
||||||
REVIEWS_JSON="$(reviews \
|
|
||||||
"$(rev "$HUMAN" COMMENTED old1 "early thoughts" t0)" \
|
|
||||||
"$(rev "$BOT1" APPROVED head1 "" t1)" \
|
|
||||||
"$(rev "$BOT2" APPROVED head1 "" t2)" \
|
|
||||||
"$(rev "$BOT3" APPROVED head1 "" t3)")"
|
|
||||||
expect "old human comment + three approvals is needs-human" state:needs-human "$(decide_state)"
|
|
||||||
expect "old human comment still needs a fresh request" needed "$(human_request_needed && echo needed || echo not-needed)"
|
|
||||||
# ...a stale human APPROVAL likewise needs a re-request for the new head
|
|
||||||
REVIEWS_JSON="$(reviews \
|
|
||||||
"$(rev "$HUMAN" APPROVED old1 "" t0)" \
|
|
||||||
"$(rev "$BOT1" APPROVED head1 "" t1)" \
|
|
||||||
"$(rev "$BOT2" APPROVED head1 "" t2)" \
|
|
||||||
"$(rev "$BOT3" APPROVED head1 "" t3)")"
|
|
||||||
expect "stale human approval needs a fresh request" needed "$(human_request_needed && echo needed || echo not-needed)"
|
|
||||||
# ...a HEAD-CURRENT human approval needs nothing more
|
|
||||||
REVIEWS_JSON="$(reviews \
|
|
||||||
"$(rev "$HUMAN" APPROVED head1 "" t0)" \
|
|
||||||
"$(rev "$BOT1" APPROVED head1 "" t1)" \
|
|
||||||
"$(rev "$BOT2" APPROVED head1 "" t2)" \
|
|
||||||
"$(rev "$BOT3" APPROVED head1 "" t3)")"
|
|
||||||
expect "head-current human approval needs no request" not-needed "$(human_request_needed && echo needed || echo not-needed)"
|
|
||||||
# ...and a live request suppresses re-requesting
|
|
||||||
REQUESTED="$HUMAN"
|
|
||||||
expect "live human request suppresses re-request" not-needed "$(human_request_needed && echo needed || echo not-needed)"
|
|
||||||
REQUESTED=""
|
|
||||||
|
|
||||||
printf 'labels-reconcile tests: %d passed, %d failed\n' "$pass" "$fail"
|
|
||||||
[ "$fail" -eq 0 ]
|
|
||||||
186
test/release.sh
186
test/release.sh
|
|
@ -1,22 +1,10 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# The release flow (#83), proven offline. Run: bash test/release.sh
|
# Box-specific release-channel coverage. Shared release/guard machinery lives
|
||||||
#
|
# in heavy-duty/ceremony and is tested there; this file drives real install.sh.
|
||||||
# Three surfaces: the changelog-section extraction release.yml publishes
|
|
||||||
# (.github/scripts/release-notes.sh, driven against fixtures AND the real
|
|
||||||
# CHANGELOG.md so the header format cannot drift under it), the
|
|
||||||
# latest-release tag resolution install.sh defaults to (the extracted
|
|
||||||
# function, driven against a shim curl serving canned redirects), and the
|
|
||||||
# three install channels — REAL install.sh runs against throwaway roots,
|
|
||||||
# with the shim curl standing in for GitHub. Nothing here touches the
|
|
||||||
# network; the same discipline as test/cli.sh. Deliberately no `set -e` —
|
|
||||||
# the harness asserts on failing commands.
|
|
||||||
set -u
|
set -u
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
PASS=0 FAIL=0
|
PASS=0 FAIL=0
|
||||||
|
|
||||||
# check <desc> <want_exit> <want_substr> <cmd...>
|
|
||||||
# Runs cmd, asserts exit code and (if non-empty) that combined output
|
|
||||||
# contains want_substr.
|
|
||||||
check() {
|
check() {
|
||||||
local desc="$1" want="$2" substr="$3"; shift 3
|
local desc="$1" want="$2" substr="$3"; shift 3
|
||||||
local out rc
|
local out rc
|
||||||
|
|
@ -34,98 +22,10 @@ check() {
|
||||||
echo "ok: $desc"; PASS=$((PASS + 1))
|
echo "ok: $desc"; PASS=$((PASS + 1))
|
||||||
}
|
}
|
||||||
|
|
||||||
NOTES="$ROOT/.github/scripts/release-notes.sh"
|
|
||||||
WORK="$(mktemp -d)"
|
WORK="$(mktemp -d)"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# release-notes.sh — the extraction, against a fixture changelog that carries
|
|
||||||
# every boundary: an Unreleased section that must never leak into a release,
|
|
||||||
# two adjacent versions, a version that prefixes another (0.7.0 vs
|
|
||||||
# 0.7.0-rc1), and a stamped-but-empty section that must refuse.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
check "release-notes: runnable bash" 0 "" bash -n "$NOTES"
|
|
||||||
|
|
||||||
FIX="$WORK/CHANGELOG.md"
|
|
||||||
cat > "$FIX" <<'EOF'
|
|
||||||
# Changelog
|
|
||||||
|
|
||||||
Intro prose that belongs to no section.
|
|
||||||
|
|
||||||
## Unreleased
|
|
||||||
|
|
||||||
- **Not yet released** — must never appear in a release body.
|
|
||||||
|
|
||||||
## 0.7.0 — 2026-07-20
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- **The seven-oh entry** — prose for 0.7.0, and only 0.7.0.
|
|
||||||
|
|
||||||
## 0.7.0-rc1 — 2026-07-19
|
|
||||||
|
|
||||||
- **The rc entry** — must not ride along with 0.7.0.
|
|
||||||
|
|
||||||
## 0.6.0 — 2026-07-18
|
|
||||||
|
|
||||||
- **The six-oh entry** — the previous release's prose.
|
|
||||||
|
|
||||||
## 0.5.0 — 2026-07-15
|
|
||||||
|
|
||||||
EOF
|
|
||||||
|
|
||||||
check "extract: prints the asked-for version's prose" 0 "The seven-oh entry" bash "$NOTES" 0.7.0 "$FIX"
|
|
||||||
check "extract: keeps the section's own subheaders" 0 "### Added" bash "$NOTES" 0.7.0 "$FIX"
|
|
||||||
# shellcheck disable=SC2016 # $1/$2 expand in the child shell, by design
|
|
||||||
check "extract: stops at the NEXT section" 1 "" bash -c 'bash "$1" 0.7.0 "$2" | grep -q "rc entry"' _ "$NOTES" "$FIX"
|
|
||||||
# shellcheck disable=SC2016 # $1/$2 expand in the child shell, by design
|
|
||||||
check "extract: never leaks Unreleased into a release" 1 "" bash -c 'bash "$1" 0.7.0 "$2" | grep -q "Not yet released"' _ "$NOTES" "$FIX"
|
|
||||||
# shellcheck disable=SC2016 # $1/$2 expand in the child shell, by design
|
|
||||||
check "extract: never prints the header itself" 1 "" bash -c 'bash "$1" 0.7.0 "$2" | grep -q "^## "' _ "$NOTES" "$FIX"
|
|
||||||
check "extract: the version is matched WHOLE (rc1 is its own section)" \
|
|
||||||
0 "The rc entry" bash "$NOTES" 0.7.0-rc1 "$FIX"
|
|
||||||
check "extract: an adjacent older version still resolves" 0 "six-oh" bash "$NOTES" 0.6.0 "$FIX"
|
|
||||||
check "extract: a missing version refuses by name" 1 "no section for '9.9.9'" bash "$NOTES" 9.9.9 "$FIX"
|
|
||||||
check "extract: ...and names the ritual that was skipped" 1 "#83" bash "$NOTES" 9.9.9 "$FIX"
|
|
||||||
check "extract: a stamped-but-EMPTY section refuses" 1 "no section for '0.5.0'" bash "$NOTES" 0.5.0 "$FIX"
|
|
||||||
check "extract: no version argument is a usage error" 2 "usage:" bash "$NOTES"
|
|
||||||
check "extract: a missing changelog refuses by path" 1 "no such file" bash "$NOTES" 1.0.0 "$WORK/nope.md"
|
|
||||||
|
|
||||||
# The REAL changelog: released sections must keep extracting, or release.yml
|
|
||||||
# breaks the day it runs — this is the guard against header-format drift.
|
|
||||||
check "extract: the real 0.6.0 section extracts" 0 "restricted tier" bash "$NOTES" 0.6.0 "$ROOT/CHANGELOG.md"
|
|
||||||
check "extract: the real 0.5.0 section extracts" 0 "" bash "$NOTES" 0.5.0 "$ROOT/CHANGELOG.md"
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# release.yml — a daemon-free run cannot push a tag, so the wiring is
|
|
||||||
# grepped, fail-closed (the house discipline): the VERSION assertion, the
|
|
||||||
# shared extraction script, and that the tag is verified before creation.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
RY="$ROOT/.github/workflows/release.yml"
|
|
||||||
check "release.yml: exists" 0 "" test -f "$RY"
|
|
||||||
# shellcheck disable=SC2016 # the $-string is a literal in the target file
|
|
||||||
check "release.yml: asserts tag == VERSION before creating anything" 0 "" \
|
|
||||||
grep -qF 'GITHUB_REF_NAME" != "$ver"' "$RY"
|
|
||||||
check "release.yml: the mismatch creates NOTHING (exit 1)" 0 "" \
|
|
||||||
grep -qF 'creating nothing' "$RY"
|
|
||||||
check "release.yml: the body comes from the shared extraction script" 0 "" \
|
|
||||||
grep -qF '.github/scripts/release-notes.sh' "$RY"
|
|
||||||
check "release.yml: the release is bound to the pushed tag (--verify-tag)" 0 "" \
|
|
||||||
grep -qF -- '--verify-tag' "$RY"
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# latest_release_tag — extracted from install.sh (the source-the-pure-function
|
|
||||||
# trick) and driven against a shim curl. The shim serves the ONE seam the
|
|
||||||
# function uses: -w '%{redirect_url}' on the releases/latest probe.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
SHIMDIR="$WORK/shim"; mkdir -p "$SHIMDIR"
|
SHIMDIR="$WORK/shim"; mkdir -p "$SHIMDIR"
|
||||||
cat > "$SHIMDIR/curl" <<'SHIM'
|
cat > "$SHIMDIR/curl" <<'SHIM'
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Fake curl for the release drills: answers the releases/latest probe with
|
|
||||||
# $FAKE_REDIRECT on stdout (the -w '%{redirect_url}' seam) — or fails with
|
|
||||||
# $FAKE_CURL_RC (network down) — and serves downloads (-o <file>) by copying
|
|
||||||
# $FAKE_TARBALL when the URL is $FAKE_SERVE_URL, else exit 22 (curl's own
|
|
||||||
# 404-under--f code). Every URL is appended to $FAKE_CURL_LOG so a test can
|
|
||||||
# assert exactly what was asked for, and in what order.
|
|
||||||
url="" out=""
|
url="" out=""
|
||||||
while [ $# -gt 0 ]; do
|
while [ $# -gt 0 ]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
|
|
@ -151,105 +51,77 @@ chmod +x "$SHIMDIR/curl"
|
||||||
|
|
||||||
TAGFN="$(mktemp)"
|
TAGFN="$(mktemp)"
|
||||||
awk '/^latest_release_tag\(\) \{/,/^\}/' "$ROOT/install.sh" > "$TAGFN"
|
awk '/^latest_release_tag\(\) \{/,/^\}/' "$ROOT/install.sh" > "$TAGFN"
|
||||||
check "latest_release_tag: extracted from install.sh (guards the awk)" 0 "releases/latest" cat "$TAGFN"
|
check "latest_release_tag: extracted from install.sh" 0 "releases/latest" cat "$TAGFN"
|
||||||
check "latest_release_tag: the extracted function is valid bash" 0 "" bash -n "$TAGFN"
|
check "latest_release_tag: extracted function is valid bash" 0 "" bash -n "$TAGFN"
|
||||||
|
|
||||||
ltag() { # ltag <redirect_url> [curl_rc]
|
ltag() {
|
||||||
FAKE_REDIRECT="$1" FAKE_CURL_RC="${2:-0}" REPO=heavy-duty/box \
|
FAKE_REDIRECT="$1" FAKE_CURL_RC="${2:-0}" REPO=heavy-duty/box \
|
||||||
PATH="$SHIMDIR:$PATH" bash -c ". '$TAGFN'; latest_release_tag"
|
PATH="$SHIMDIR:$PATH" bash -c ". '$TAGFN'; latest_release_tag"
|
||||||
}
|
}
|
||||||
check "resolve: reads the tag off the redirect" 0 "0.6.0" \
|
check "resolve: reads the tag off the redirect" 0 "0.6.0" \
|
||||||
ltag "https://github.com/heavy-duty/box/releases/tag/0.6.0"
|
ltag "https://github.com/heavy-duty/box/releases/tag/0.6.0"
|
||||||
check "resolve: a -dev-style tag survives verbatim" 0 "0.7.0-rc1" \
|
check "resolve: a pre-release tag survives verbatim" 0 "0.7.0-rc1" \
|
||||||
ltag "https://github.com/heavy-duty/box/releases/tag/0.7.0-rc1"
|
ltag "https://github.com/heavy-duty/box/releases/tag/0.7.0-rc1"
|
||||||
check "resolve: a repo with NO releases (redirect to /releases) fails" 1 "" \
|
check "resolve: a repo with no releases fails" 1 "" \
|
||||||
ltag "https://github.com/heavy-duty/box/releases"
|
ltag "https://github.com/heavy-duty/box/releases"
|
||||||
check "resolve: no redirect at all fails" 1 "" ltag ""
|
check "resolve: no redirect fails" 1 "" ltag ""
|
||||||
check "resolve: a curl failure (network down) fails, never hangs on prose" 1 "" \
|
check "resolve: a curl failure fails" 1 "" \
|
||||||
ltag "https://github.com/heavy-duty/box/releases/tag/0.6.0" 6
|
ltag "https://github.com/heavy-duty/box/releases/tag/0.6.0" 6
|
||||||
rm -f "$TAGFN"
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# The three channels, driven through REAL install.sh runs (#83): default =
|
|
||||||
# latest release, BOX_REF=<tag> = pinned, BOX_REF=<branch> = dev. The shim
|
|
||||||
# curl serves a fabricated release tarball shaped exactly like GitHub's (one
|
|
||||||
# top-level directory), and its log proves WHICH URLs the installer asked
|
|
||||||
# for. FAKE_TARBALL carries VERSION 9.9.9 so nothing collides with the tree
|
|
||||||
# under test.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
FAKEHOME="$WORK/home"; mkdir -p "$FAKEHOME"
|
FAKEHOME="$WORK/home"; mkdir -p "$FAKEHOME"
|
||||||
SRC="$WORK/box-9.9.9"; mkdir -p "$SRC/bin"
|
SRC="$WORK/box-9.9.9"; mkdir -p "$SRC/bin"
|
||||||
cp "$ROOT/bin/box" "$SRC/bin/box"; chmod +x "$SRC/bin/box"
|
cp "$ROOT/bin/box" "$SRC/bin/box"; chmod +x "$SRC/bin/box"
|
||||||
echo "9.9.9" > "$SRC/VERSION"
|
printf '9.9.9\n' > "$SRC/VERSION"
|
||||||
tar -C "$WORK" -czf "$WORK/gh.tar.gz" box-9.9.9
|
tar -C "$WORK" -czf "$WORK/gh.tar.gz" box-9.9.9
|
||||||
|
|
||||||
ninst() { # ninst <box_home> <box_bin> [VAR=val ...] — install.sh, shim network
|
ninst() {
|
||||||
local h="$1" b="$2"; shift 2
|
local h="$1" b="$2"; shift 2
|
||||||
env HOME="$FAKEHOME" PATH="$SHIMDIR:$PATH" \
|
env HOME="$FAKEHOME" PATH="$SHIMDIR:$PATH" \
|
||||||
BOX_HOME="$h" BOX_BIN="$b" BOX_YES=1 BOX_SKIP_SETUP_HOST=1 \
|
BOX_HOME="$h" BOX_BIN="$b" BOX_YES=1 BOX_SKIP_SETUP_HOST=1 \
|
||||||
FAKE_TARBALL="$WORK/gh.tar.gz" "$@" bash "$ROOT/install.sh"
|
FAKE_TARBALL="$WORK/gh.tar.gz" "$@" bash "$ROOT/install.sh"
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- channel 1: the default is the latest RELEASE ---------------------------
|
|
||||||
H1="$WORK/h1"; B1="$WORK/b1"; L1="$WORK/c1.log"
|
H1="$WORK/h1"; B1="$WORK/b1"; L1="$WORK/c1.log"
|
||||||
check "default channel: resolves and installs the latest release" 0 "latest release: 9.9.9" \
|
check "default channel: installs the latest release" 0 "latest release: 9.9.9" \
|
||||||
ninst "$H1" "$B1" FAKE_CURL_LOG="$L1" \
|
ninst "$H1" "$B1" FAKE_CURL_LOG="$L1" \
|
||||||
FAKE_REDIRECT="https://github.com/heavy-duty/box/releases/tag/9.9.9" \
|
FAKE_REDIRECT="https://github.com/heavy-duty/box/releases/tag/9.9.9" \
|
||||||
FAKE_SERVE_URL="https://github.com/heavy-duty/box/archive/refs/tags/9.9.9.tar.gz"
|
FAKE_SERVE_URL="https://github.com/heavy-duty/box/archive/refs/tags/9.9.9.tar.gz"
|
||||||
check "default channel: the download is the TAG tarball" 0 "" \
|
check "default channel: downloads the tag tarball" 0 "" \
|
||||||
grep -qF "archive/refs/tags/9.9.9.tar.gz" "$L1"
|
grep -qF "archive/refs/tags/9.9.9.tar.gz" "$L1"
|
||||||
check "default channel: it never asked for a branch" 1 "" \
|
check "default channel: never asks for a branch" 1 "" grep -q "refs/heads" "$L1"
|
||||||
grep -q "refs/heads" "$L1"
|
check "default channel: records the resolved tag" 0 "heavy-duty/box@9.9.9" \
|
||||||
check "default channel: INSTALLED_FROM records the RESOLVED tag" 0 "heavy-duty/box@9.9.9" \
|
|
||||||
cat "$H1/versions/9.9.9/INSTALLED_FROM"
|
cat "$H1/versions/9.9.9/INSTALLED_FROM"
|
||||||
check "default channel: the install answers through the chain" 0 "box 9.9.9" \
|
check "default channel: installed binary answers" 0 "box 9.9.9" \
|
||||||
env HOME="$FAKEHOME" "$B1/box" --version
|
env HOME="$FAKEHOME" "$B1/box" --version
|
||||||
|
|
||||||
# --- channel 2: BOX_REF=<tag> pins a release --------------------------------
|
|
||||||
H2="$WORK/h2"; B2="$WORK/b2"; L2="$WORK/c2.log"
|
H2="$WORK/h2"; B2="$WORK/b2"; L2="$WORK/c2.log"
|
||||||
check "pinned channel: BOX_REF=<tag> installs that tag" 0 "done" \
|
check "pinned channel: installs the requested tag" 0 "done" \
|
||||||
ninst "$H2" "$B2" BOX_REF=9.9.9 FAKE_CURL_LOG="$L2" \
|
ninst "$H2" "$B2" BOX_REF=9.9.9 FAKE_CURL_LOG="$L2" \
|
||||||
FAKE_SERVE_URL="https://github.com/heavy-duty/box/archive/refs/tags/9.9.9.tar.gz"
|
FAKE_SERVE_URL="https://github.com/heavy-duty/box/archive/refs/tags/9.9.9.tar.gz"
|
||||||
check "pinned channel: no releases/latest probe (a pin resolves nothing)" 1 "" \
|
check "pinned channel: skips latest-release resolution" 1 "" \
|
||||||
grep -q "releases/latest" "$L2"
|
grep -q "releases/latest" "$L2"
|
||||||
|
|
||||||
# --- channel 3: BOX_REF=<branch> is the dev channel -------------------------
|
|
||||||
H3="$WORK/h3"; B3="$WORK/b3"; L3="$WORK/c3.log"
|
H3="$WORK/h3"; B3="$WORK/b3"; L3="$WORK/c3.log"
|
||||||
check "dev channel: BOX_REF=main falls back tag -> branch" 0 "trying it as a branch" \
|
check "dev channel: falls back from tag to branch" 0 "trying it as a branch" \
|
||||||
ninst "$H3" "$B3" BOX_REF=main FAKE_CURL_LOG="$L3" \
|
ninst "$H3" "$B3" BOX_REF=main FAKE_CURL_LOG="$L3" \
|
||||||
FAKE_SERVE_URL="https://github.com/heavy-duty/box/archive/refs/heads/main.tar.gz"
|
FAKE_SERVE_URL="https://github.com/heavy-duty/box/archive/refs/heads/main.tar.gz"
|
||||||
check "dev channel: the tag was tried FIRST" 0 "refs/tags/main.tar.gz" \
|
check "dev channel: tries the tag first" 0 "refs/tags/main.tar.gz" head -1 "$L3"
|
||||||
head -1 "$L3"
|
check "dev channel: then downloads the branch" 0 "" \
|
||||||
check "dev channel: then the branch" 0 "" \
|
|
||||||
grep -qF "archive/refs/heads/main.tar.gz" "$L3"
|
grep -qF "archive/refs/heads/main.tar.gz" "$L3"
|
||||||
|
|
||||||
# --- the failure is LOUD, never a silent fall-through to main ---------------
|
|
||||||
H4="$WORK/h4"; B4="$WORK/b4"; L4="$WORK/c4.log"
|
H4="$WORK/h4"; B4="$WORK/b4"; L4="$WORK/c4.log"
|
||||||
check "resolution failure: REFUSES, naming the probe URL" 1 "could not resolve the latest release" \
|
check "resolution failure: names the latest-release probe" 1 "could not resolve the latest release" \
|
||||||
ninst "$H4" "$B4" FAKE_CURL_RC=6 FAKE_CURL_LOG="$L4"
|
ninst "$H4" "$B4" FAKE_CURL_RC=6 FAKE_CURL_LOG="$L4"
|
||||||
check "resolution failure: ...and the way out (BOX_REF)" 1 "BOX_REF" \
|
check "resolution failure: names BOX_REF as the override" 1 "BOX_REF" \
|
||||||
ninst "$H4" "$B4" FAKE_CURL_RC=6
|
ninst "$H4" "$B4" FAKE_CURL_RC=6
|
||||||
check "resolution failure: downloaded NOTHING (no silent main)" 1 "" \
|
check "resolution failure: downloads nothing" 1 "" grep -q "archive/" "$L4"
|
||||||
grep -q "archive/" "$L4"
|
check "resolution failure: installs nothing" 1 "" test -e "$H4/versions"
|
||||||
check "resolution failure: nothing was installed" 1 "" test -e "$H4/versions"
|
check "unknown ref names both attempted channels" 1 "neither a tag nor a branch" \
|
||||||
check "a ref that is neither tag nor branch dies naming both" 1 "neither a tag nor a branch" \
|
|
||||||
ninst "$H4" "$B4" BOX_REF=no-such-ref
|
ninst "$H4" "$B4" BOX_REF=no-such-ref
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
check "README documents the latest-release channel" 0 "" grep -qF 'latest release' "$ROOT/README.md"
|
||||||
# The -dev convention (#83): main's VERSION carries -dev between releases, so
|
check "README documents the pinned channel" 0 "" grep -qF 'BOX_REF=0.6.0' "$ROOT/README.md"
|
||||||
# a dev install lands beside releases in versions/ instead of impersonating
|
check "README documents the dev channel" 0 "" grep -qF 'BOX_REF=main' "$ROOT/README.md"
|
||||||
# one — and the docs keep the promises this PR makes.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
check "CONTRIBUTING documents the post-release -dev bump" 0 "" \
|
|
||||||
grep -q -- '-dev' "$ROOT/CONTRIBUTING.md"
|
|
||||||
check "CONTRIBUTING documents the release ritual (tag == VERSION)" 0 "" \
|
|
||||||
grep -qi 'release' "$ROOT/CONTRIBUTING.md"
|
|
||||||
check "README documents the default (latest release) channel" 0 "" \
|
|
||||||
grep -qF 'latest release' "$ROOT/README.md"
|
|
||||||
check "README documents the pinned channel" 0 "" \
|
|
||||||
grep -qF 'BOX_REF=0.6.0' "$ROOT/README.md"
|
|
||||||
check "README documents the dev channel" 0 "" \
|
|
||||||
grep -qF 'BOX_REF=main' "$ROOT/README.md"
|
|
||||||
|
|
||||||
echo "---"
|
echo "---"
|
||||||
echo "$PASS passed, $FAIL failed"
|
echo "$PASS passed, $FAIL failed"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue