diff --git a/CHANGELOG.md b/CHANGELOG.md index d5bb6dc..47dfaca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,75 @@ which records not just what changed but what each drill run proved. ## Unreleased +### Added + +- **A minted box records how it was minted, and `box info` reads it back** + (#103) — `cmd_new` knew a great deal at the moment it launched and wrote + three `user.*` keys, dropping the rest on the floor: the box version that + minted it, the base image (an *unpinned alias on a moving remote* — two + boxes minted a month apart from "the same template" are not the same box, + and nothing on either said so), the rig role, which rig repo and ref + converged it, the mint time, and whether a container was chosen or fallen + back into for want of `/dev/kvm`. There is no host-side per-box store — + the Incus instance config *is* the database — so every one of those facts + was simply gone the moment the mint returned. The same single write point + now carries them as `user.box.*`, plus `user.box.schema=1` naming the + stamp's *shape* (an integer, not the box version: it moves only when a key + is removed or repurposed, never when one is added). The alias's resolved + fingerprint is pinned in a second call after the launch, read from + `volatile.base_image` — best-effort by construction, because a box that + exists and boots must never be failed over a provenance field. Deliberately + not stamped: cpu/memory (`limits.*` hold them, and a duplicate drifts the + first time someone edits a limit by hand), disk (a VM's *is* the root + device size and a container's does not exist — its root rides the pool), + and the tier, which `box_tier()` derives from whoever is *asking*. +- **A clone re-stamps its own provenance instead of inheriting a lie** + (#103) — `incus copy` carries every `user.*` key forward (drill audit item + B2), which is what makes a clone know its template and user for free and is + exactly why the mint stamp could not ride along untouched: an inherited + stamp does not go stale, it goes **false**, claiming a mint time the clone + was not present for and a box version that never saw it. `box new --from` + now re-stamps the four keys that describe *this* instance's coming into + being — schema, version, created, `origin=clone` with `origin.from=` — + on the copied instance *before* it is started, so a clone is never + observable wearing its source's provenance. The lineage keys (template, + user, image, role, rig pin) are left alone on purpose: the clone's disk + genuinely did come from them, and re-deriving them from the cloning + process's own template lookup would be the actual lie. `origin.from` + records **one hop** — a clone of a clone names its parent and forgets its + grandparent, because the alternative is an unbounded chain in a config + value and the parent is the box an operator can go look at. + One key sits in **neither** column and is therefore *cleared*: + `user.box.mode.asked`. It records whether a container was asked for or + fallen back into, which makes it a mint-*event* fact — and the asker was + the **source's** operator. A clone refuses `--vm`/`--container` outright, + so nobody was asked anything about this instance, and there is no true + value to re-stamp it with; inheriting it made `box info` print + `MODE vm (asked: auto)` on a clone, describing a demand never made of it. + The clear lands with the re-stamp, before the start, and the read side + needs no special case: the `MODE` line is gated on `asked`, so absence + renders as silence while `TYPE` still reports VM or CT off the preserved + instance type. +- **`box info` grew a provenance block** (#103) — it printed + `NAME / STATE / TYPE / IPV4`, exposures and snapshots, and surfaced *none* + of the `user.box.*` keys, including the two that already existed. A stamp + nothing can read is not a stamp. Every key is read with absence tolerated: + `incus config get` on an unset key prints empty and exits 0 (audit item + B4), so "no stamp" and "the daemon said no" arrive identically, and both + must render as a box with blanks. **Boxes minted before this stamp existed + keep working under every verb** — the README's standing promise, and the + same one `user.claudebox` carries — showing `MINTED (not recorded — this + box predates the mint stamp)` rather than an invented time or an error; a + pre-rename box still reads as the claude template. A schema this box does + not recognise is treated as *newer than me*: it shows what it understands + and says so, because a box outlives the release that minted it and refusing + to describe one a later release minted perfectly well is the wrong answer. + `box info --json` needed no code — the keys ride `incus list --format json` + in `config`. `test/cli.sh` drives both halves against a fake incus that + logs the arguments box builds, including the absence assertions that keep + cpu/memory/disk/tier out of the namespace and the lineage keys out of the + clone's re-stamp. + ### Changed - **`state:needs-human` no longer waits on the cron to become true** (#141) diff --git a/README.md b/README.md index 68f5f93..d0c1afb 100644 --- a/README.md +++ b/README.md @@ -390,7 +390,7 @@ the door is per-port, punched and removable at runtime. box new --name [--template ] [--from [/]] [--cpu ] [--memory ] [--disk ] [--vm|--container] box templates # list the templates this install can mint box list # list your boxes -box info # one box: state, IP, exposures, snapshot labels +box info # one box: state, IP, exposures, provenance, snapshots box shell # enter as the template's user box exec -- # run a command in the box box tmux [session] # attach/create a tmux session — survives disconnects @@ -427,6 +427,49 @@ clones an existing box or snapshot. VM mode (`--vm`, the default where `security.nesting=true`) is for hosts without nested virt — weaker isolation, 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. + +**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 A box is an ordinary Incus instance tagged `user.box=1` (pre-0.4.0 boxes diff --git a/bin/box b/bin/box index d877ee9..f36ca3f 100755 --- a/bin/box +++ b/bin/box @@ -12,7 +12,18 @@ inst="" # the resolved Incus instance, set by the 'box' precondition 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 -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': # what the kernel will present when incus opens the socket), never from the @@ -73,7 +84,7 @@ CMDS=( "new^--name [--template ] [--from [/]] [--cpu ] [--memory ] [--disk ] [--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^" "list^[--json]^^List your boxes^fn:cmd_list^" - "info^ [--json]^box^One box: state, type, IP, and its snapshot labels^fn:cmd_info^" + "info^ [--json]^box^One box: state, type, IP, what minted it, snapshot labels^fn:cmd_info^" "shell^^box^Open a shell in a box, as its template's user^fn:cmd_shell^" "exec^ -- ^box^Run a command inside a box^fn:cmd_exec^" "tmux^ []^box^Attach or create a tmux session in a box — survives disconnects^fn:cmd_tmux^" @@ -1002,8 +1013,16 @@ load_template() { # 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 # 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() { - 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._-]+$ ]] \ || die "RIG_REPO must look like owner/repo: $repo" [[ "$ref" =~ ^[A-Za-z0-9._/-]+$ ]] \ @@ -1026,6 +1045,13 @@ cmd_templates() { echo "mint one: box new --name --template