2026-10-23 19:42:34 +02:00
|
|
|
# Deployment provenance: map
|
|
|
|
|
|
|
|
|
|
Status: map, Imagination, 2026-10-23 (campaign `idunn-deploy-provenance`,
|
|
|
|
|
session `self-2026-10-23-eureka-b`). The ends are in
|
|
|
|
|
[`deploy-provenance-target.md`](deploy-provenance-target.md). Questions,
|
|
|
|
|
rulings, cut specs and follow-ups live in Eureka's mind; this page holds what
|
2026-10-23 21:54:00 +02:00
|
|
|
has no kind: body facts, rationale and sequencing. Line numbers in T1-T12 are
|
|
|
|
|
at `d74ac8d` (origin/main on 2026-10-23); T4, T9 and T13-T16 are at `fd040f0`,
|
|
|
|
|
the head of Hands attempt 1, which revision 2 of the spec builds on.
|
2026-10-23 19:42:34 +02:00
|
|
|
|
|
|
|
|
## Cut `tree-executable-members`
|
|
|
|
|
|
2026-10-23 21:54:00 +02:00
|
|
|
Spec `idunn-deploy-provenance:cut_spec:cut-tree-executable-members.r2`
|
|
|
|
|
(revision 2, after Soul pass 1 found the real interpreter tree refused). It
|
2026-10-23 19:42:34 +02:00
|
|
|
serves `motion-pipeline:follow_up:idunn-tree-executable-members` (Vili's
|
|
|
|
|
admission waits on it) and will serve gamecult-chat C3 too: the presence
|
|
|
|
|
launcher runs a child such as `upstream/bin/daemon` from inside a tree artifact
|
|
|
|
|
(README, launcher fragment), and Synapse needs a Python interpreter there.
|
|
|
|
|
|
|
|
|
|
### Body facts
|
|
|
|
|
|
|
|
|
|
- T1. `ArtifactOutput` (`src/deployment.rs:106-117`) carries one `executable`
|
|
|
|
|
bool. `ArtifactReceipt` (`src/deployment_plan.rs:731-737`) copies it and
|
|
|
|
|
`validate` (`:740-760`) requires it to equal the declaration.
|
|
|
|
|
- T2. `harden_installed_release` (`src/drivers.rs:6848-6855`) builds its
|
|
|
|
|
executable set from `root.join(receipt.destination)` only. `harden_root_tree`
|
|
|
|
|
(`:6858-6892`) seals every directory 0555 and every file 0444 unless that
|
|
|
|
|
exact path is in the set. A directory artifact with `executable = true` is
|
|
|
|
|
therefore silently meaningless today: its files are all 0444. Nothing refuses
|
|
|
|
|
it. No live recipe declares one (CodexConnector, Ghostlight, Heimdall, Odin,
|
|
|
|
|
StreamPixels service and web surveyed): `executable = true` is on files only.
|
|
|
|
|
- T3. A tree output is copied by `copy_artifact`/`copy_tree`
|
|
|
|
|
(`src/drivers.rs:8053-8126`), which recreate symlinks verbatim. The digest
|
|
|
|
|
(`digest_tree`, `:8142-8175`) covers names, file bytes and link targets, not
|
|
|
|
|
mode bits. So runner or git exec bits never reach the release: harden is the
|
|
|
|
|
only mode authority.
|
2026-10-23 21:54:00 +02:00
|
|
|
- T4. `validate_release_symlink` (`src/drivers.rs:6897-6916`) keeps a symlink
|
|
|
|
|
only if its target is relative with no `..`. It runs only inside
|
|
|
|
|
`harden_root_tree`, at install, so a tree with a `..` or absolute link copies,
|
|
|
|
|
digests and seals, then fails at `install_release` (Soul s1: copying and
|
|
|
|
|
digesting the real tree twice took 160 s before harden refused it). The rule
|
|
|
|
|
is sound but too narrow: it refuses the real interpreter tree (T13). The
|
|
|
|
|
frozen-source path has a physical resolver (`resolve_frozen_source_symlink`,
|
|
|
|
|
`:6617`) that walks the filesystem with a 40-link budget and a memo. Its
|
|
|
|
|
first version was exponential (Soul S4-1, recorded in `d562a12`), and it needs
|
|
|
|
|
the tree on disk to answer. Revision 2 does not reuse it (T14).
|
2026-10-23 19:42:34 +02:00
|
|
|
- T5. The sealed release id hashes `(schema, plan_id, artifacts,
|
|
|
|
|
external_inputs)` (`src/deployment_plan.rs:1000-1011`), and `plan_id` hashes
|
|
|
|
|
the whole plan including `recipe_blob`. A changed member list is therefore
|
|
|
|
|
already a new plan and a new release. The receipt must still carry the list,
|
|
|
|
|
because `harden_installed_release` reads receipts only; its two callers
|
|
|
|
|
(`install_release` `:2788`, `validate_installed_release` `:2822`) re-harden
|
|
|
|
|
every installed release on each observation.
|
|
|
|
|
- T6. Encoding is `rmp_serde::to_vec`, positional. A trailing field with
|
|
|
|
|
`#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]` keeps every
|
|
|
|
|
existing receipt's bytes and release id: the plan's `deadlines` field
|
|
|
|
|
(`:574-577`) is the precedent.
|
|
|
|
|
- T7. `digest_artifact` and `digest_tree` (`:8128-8175`) `fs::read` each file
|
|
|
|
|
whole. A torch CPU tree's largest file (`libtorch_cpu.so`, hundreds of MB)
|
|
|
|
|
is held in Idunn's memory three times per install (seal, install, every
|
|
|
|
|
validate). `hash_frozen_source_tree` (`:6788-6840`) already streams in 64 KiB
|
|
|
|
|
chunks (S9). The cut collapses both onto one streaming helper.
|
|
|
|
|
- T8. `require_leaf_path` (`src/deployment.rs:1951-1958`) only keeps the
|
|
|
|
|
artifact destination a single filename. Members are paths inside that
|
|
|
|
|
destination, so it does not change. `require_relative_path` (`:1936-1949`)
|
|
|
|
|
with `allow_current = false` already refuses absolute, `..`, root and prefix
|
|
|
|
|
components.
|
2026-10-23 21:54:00 +02:00
|
|
|
- T9. The host actuator re-spells the seal (`src/host.rs:1050-1086`): copy,
|
|
|
|
|
mode check, digest, pinned-digest check and receipt, the same steps as
|
|
|
|
|
`collect_artifact` (`src/drivers.rs:2305-2326`). Soul s1: deleting its mode
|
|
|
|
|
check survives every test. Revision 2 collapses both onto one `seal_artifact`
|
|
|
|
|
in `drivers.rs`. Each caller keeps only how it finds the source path.
|
2026-10-23 19:42:34 +02:00
|
|
|
- T10. The transient unit runs with `ProtectSystem=strict` and the release in
|
|
|
|
|
`ReadOnlyPaths` (`src/drivers.rs:3675-3698`), `/tmp` mounted `noexec`, and no
|
|
|
|
|
`MemoryDenyWriteExecute`. Executing a 0555 file from the release works.
|
|
|
|
|
Shared libraries need no exec bit.
|
|
|
|
|
- T11. Live body on Yggdrasil: `/usr/local/bin/idunn` sha256 `44cf8a14...`,
|
|
|
|
|
installed 2026-10-20 02:34 CEST, before `6e2a31c` (02:40 CEST). `src/` is
|
|
|
|
|
identical between `8760a4f` and `6e2a31c`, so the live code is the
|
|
|
|
|
reincarnation c2 merge. `origin/main` is 26 commits ahead (+4194/-112 in
|
|
|
|
|
`src/`: the presence launcher and member route). Installing this cut ships all
|
|
|
|
|
of them. Idunn's own body changes only through gamecult-ops
|
|
|
|
|
`scripts/install-idunn-yggdrasil-release.sh` (staged binaries, unit,
|
|
|
|
|
bindings). The stage at `/home/gamecultadmin/idunn-stage` is stale (2026-09-11).
|
2026-10-23 21:54:00 +02:00
|
|
|
- T13. Link census of the real interpreter tree (Yggdrasil probe of
|
|
|
|
|
`cpython-3.12.15+20261021-x86_64-unknown-linux-gnu-install_only.tar.gz`,
|
|
|
|
|
sha256 `5ff9f060...77191124`; torch adds no links, as Soul s1 counted 1,049
|
|
|
|
|
after pip): 1,049 links. 0 are absolute, 749 have no `..`, and 300 have all
|
|
|
|
|
their `..` components before the first name (every one is
|
|
|
|
|
`share/terminfo/<c>/<name> -> ../<c2>/<name2>`, one `..` each). 0 have a `..`
|
|
|
|
|
after a name, 0 climb above the tree, 0 dangle and 0 resolve outside it.
|
|
|
|
|
Blender 5.2.2 has 79 links, none absolute or with `..` (Soul s1).
|
|
|
|
|
- T14. Lexically normalising `<link's directory>/<target>` does not check
|
|
|
|
|
containment. The kernel resolves a target one component at a time, so a `..`
|
|
|
|
|
after a symlinked name climbs from where that link points, not from where its
|
|
|
|
|
name sits. Probe on Yggdrasil: `root/a/s -> ..` and `root/a/L -> s/../x`
|
|
|
|
|
normalise to `root` and `root/a/x`, both inside, yet `L` reads the file `x`
|
|
|
|
|
beside `root`. `root/a/d -> .` with `root/a/M -> d/../../x` escapes the same
|
|
|
|
|
way. Idunn has met this before: verify-transaction-cut F5 (`D -> .`,
|
|
|
|
|
`L -> D/D/../../../etc/passwd` passed a lexical check).
|
|
|
|
|
The sound lexical rule constrains the target's shape: all its `..` components
|
|
|
|
|
come before its first name, and they number no more than the link's depth
|
|
|
|
|
below the artifact's top. The climb then starts from the link's own
|
|
|
|
|
directory, which the walk reached through real directories only (copy and
|
|
|
|
|
digest never descend through a link), so it lands on a real ancestor inside
|
|
|
|
|
the artifact. After that the target only descends, and any link it passes
|
|
|
|
|
through obeys the same rule. By induction on the kernel's hops (capped at 40,
|
|
|
|
|
then ELOOP), every hop stays inside. The check is one pass over the target's
|
|
|
|
|
components with no filesystem reads, so its cost is linear in the tree and no
|
|
|
|
|
resolver is needed. The shapes it refuses (`s/../x`) occur in neither real
|
|
|
|
|
tree (T13).
|
|
|
|
|
Prior art, from documentation as known to Imagination (high confidence for Go
|
|
|
|
|
and securejoin, medium for the tar details):
|
|
|
|
|
- Go `path/filepath.IsLocal` (1.20) is lexical and says so: it does not account
|
|
|
|
|
for symlinks. It would admit `s/../x`. Used alone, it is the T14 bug.
|
|
|
|
|
- Go `os.Root` (1.24) and `cyphar/filepath-securejoin` (used by runc,
|
|
|
|
|
containerd and umoci) resolve each component inside the root when a path is
|
|
|
|
|
opened, refusing or clamping an escape. They guard one later access, need
|
|
|
|
|
the tree on disk, and check again on every open. Idunn instead certifies a
|
|
|
|
|
sealed tree once, so that the kernel's own resolution stays inside on every
|
|
|
|
|
later access.
|
|
|
|
|
- Zip-slip defences and archive extractors check entry names lexically and
|
|
|
|
|
protect against link targets by refusing to write through links
|
|
|
|
|
(libarchive's `ARCHIVE_EXTRACT_SECURE_SYMLINKS`, and `SECURE_NODOTDOT` for
|
|
|
|
|
names; GNU tar creates such links last). They protect the extraction, not
|
|
|
|
|
what a kept link points at afterwards.
|
|
|
|
|
- OCI layer extraction keeps link targets verbatim, absolute ones included,
|
|
|
|
|
because the runtime resolves them inside the container's root. Idunn runs a
|
|
|
|
|
release in the host's mount namespace, so absolute targets stay refused.
|
|
|
|
|
- Nix keeps link targets verbatim and tracks dependencies by scanning for
|
|
|
|
|
references. The whole store is its root, so it has no per-artifact
|
|
|
|
|
containment to compare with.
|
|
|
|
|
None of these is a static per-link rule for a tree that runs unconfined. The
|
|
|
|
|
T14 shape rule fits that case.
|
|
|
|
|
- T15. Host targets: `idunn-host` observes, starts and stops processes on
|
|
|
|
|
Windows only (the non-Windows `platform` module in `src/host.rs` bails), and
|
|
|
|
|
`host.rs` sets no file modes. On Windows a file runs by its type, not by a
|
|
|
|
|
mode bit, so `executable` and `executable_members` have nothing to set there.
|
|
|
|
|
The file `executable` flag has always been carried and never applied on host
|
|
|
|
|
targets. A recipe does not know which workload kind its binding will choose.
|
|
|
|
|
- T16. The release root holds only artifact destinations. Runner workspaces,
|
|
|
|
|
where external inputs land, are removed before `SealedRelease::new`
|
|
|
|
|
(`src/drivers.rs:2421-2423`). `install_release` (`:2781-2788`) and
|
|
|
|
|
`validate_installed_release` (`:2815-2822`) digest every receipt's
|
|
|
|
|
destination before calling harden. So a rule in the digest walk runs at seal,
|
|
|
|
|
at install and at every validate, over every link harden would see.
|
2026-10-23 19:42:34 +02:00
|
|
|
- T12. Disk: `/` on Yggdrasil has 1.3 TB free. A tree release costs about three
|
|
|
|
|
copies (runner workspace, staging, installed). Nothing collects old releases or
|
|
|
|
|
staging (`ghostlight-verse:follow_up:idunn-generation-retention`), so every
|
|
|
|
|
Vili deploy leaves about 7 GiB behind.
|
|
|
|
|
|
|
|
|
|
### Contract, corrected against the body
|
|
|
|
|
|
|
|
|
|
- Any directory artifact may declare `executable_members` (a set of relative
|
|
|
|
|
paths). The follow-up limited it to runner-output; that limit protects no
|
|
|
|
|
invariant, and the launcher's worktree or fetched trees need the same feature.
|
|
|
|
|
- `executable = true` names a file artifact. On a directory output the release
|
|
|
|
|
is refused, and `executable_members` on a file output is refused (T2).
|
|
|
|
|
- A member is refused at recipe parse if it is empty, absolute, or holds `.`,
|
|
|
|
|
`..`, a root or a prefix. At seal it is refused if it is missing, is a
|
|
|
|
|
symlink, passes through a symlink or non-directory, or is not a regular file.
|
|
|
|
|
Refusal happens before `SealedRelease::new`, so no sealed release can name a
|
|
|
|
|
member it cannot seal.
|
|
|
|
|
- Harden seals each member 0555 and fails if any declared executable path was
|
|
|
|
|
not visited as a regular file. "Every declared executable is sealed 0555, or
|
|
|
|
|
the install fails" holds structurally, not by a lookup that can miss.
|
2026-10-23 21:54:00 +02:00
|
|
|
- Symlinks: a link in a tree artifact is kept when its target is relative, its
|
|
|
|
|
`..` components all come before its first name, and they number no more than
|
|
|
|
|
the link's depth below the artifact's top (T14). One function,
|
|
|
|
|
`check_release_link`, decides this. Only the digest walk calls it, so a bad
|
|
|
|
|
link refuses the release at seal, before `SealedRelease::new`, and again at
|
|
|
|
|
install and validate (T16). Harden no longer judges links. The climb is
|
|
|
|
|
bounded by the artifact's top, not the release root: no live release has a
|
|
|
|
|
`..` link (the old rule refused them all), so nothing installed is newly
|
|
|
|
|
refused, and each artifact's digest describes a closed tree.
|
|
|
|
|
- Host targets carry `executable` and `executable_members` in the receipt,
|
|
|
|
|
shape-checked at seal by the shared `seal_artifact`, and do not apply them
|
|
|
|
|
(T15). Refusing members there would make a recipe's validity depend on its
|
|
|
|
|
binding.
|
|
|
|
|
- Vili's trees must
|
2026-10-23 19:42:34 +02:00
|
|
|
not come from `python -m venv`: its `bin/python` is an absolute link and its
|
|
|
|
|
`pyvenv.cfg` names the build workspace. Install packages into the relocatable
|
|
|
|
|
interpreter tree instead (`motion-pipeline:follow_up:vili-relocatable-python-tree`).
|
|
|
|
|
|
|
|
|
|
### Authority
|
|
|
|
|
|
2026-10-23 21:54:00 +02:00
|
|
|
Owner: the seal (`seal_artifact`, called by `collect_artifact` for systemd
|
|
|
|
|
targets and by `materialize` in `host.rs` for host targets) decides
|
|
|
|
|
membership from the recipe and writes it into the receipt. Harden enforces the
|
|
|
|
|
receipt and decides nothing. Runner exec bits and git `100755` modes are dead
|
|
|
|
|
inputs for release modes. Link containment belongs to `check_release_link`,
|
|
|
|
|
which only `digest_tree` calls. `validate_release_symlink` is deleted, and
|
|
|
|
|
harden's symlink arm leaves links as they are.
|
2026-10-23 19:42:34 +02:00
|
|
|
|
|
|
|
|
### Acceptance sizing
|
|
|
|
|
|
|
|
|
|
The big-tree job (spec `verification.operator`) needs about 12 GiB of scratch
|
|
|
|
|
on Yggdrasil: a 3.5 GiB tree, its staging copy, downloads and pip cache. It
|
2026-10-23 21:54:00 +02:00
|
|
|
removes it at the end. Revision 2 runs it on the trees as shipped, with the 300
|
|
|
|
|
terminfo links (T13). If a tree holds a link the T14 rule refuses, the job
|
|
|
|
|
prints it and stops, and the link is reported to Self, not deleted.
|
2026-10-24 14:51:10 +02:00
|
|
|
|
|
|
|
|
## Cut `isolation-credential-evidence`
|
|
|
|
|
|
|
|
|
|
Spec `idunn-deploy-provenance:cut_spec:cut-cut-isolation-credential-evidence.r1`,
|
|
|
|
|
at `112a7a5` (origin/main, live on Yggdrasil since 2026-10-24 11:29 UTC). It
|
|
|
|
|
serves Vili's admission (`motion-pipeline:cut_report:cut-vili-admission.h4`,
|
|
|
|
|
first deviation): every `idunn up vili` is refused before fencing.
|
|
|
|
|
|
|
|
|
|
### Body facts (probed read-only on Yggdrasil, 2026-10-24)
|
|
|
|
|
|
|
|
|
|
- I1. `idunn up vili` runs 6 and 7 failed with
|
|
|
|
|
`systemd workload isolation properties differ from the Idunn contract`, the
|
|
|
|
|
message of `observe_unit` (`src/drivers.rs:2975-3005`). Of its conjuncts,
|
|
|
|
|
only one differs on the failed Vili unit: `LoadCredential`. Idunn expects
|
|
|
|
|
`""` because Vili has no service credential besides the presence identity
|
|
|
|
|
(`service_credential_names` empty); systemd shows `[unprintable]`. Every
|
|
|
|
|
other conjunct matches, InaccessiblePaths included (one value across all
|
|
|
|
|
five idunn-vili/odin units).
|
|
|
|
|
- I2. systemd 257.13 `systemctl show -p LoadCredential` prints
|
|
|
|
|
`LoadCredential=[unprintable]` for every unit, whatever the credentials:
|
|
|
|
|
`ssh.service` and `systemd-journald.service` (not DynamicUser, zero
|
|
|
|
|
LoadCredential), the four failed `idunn-vili-*` units (zero), and the Odin
|
|
|
|
|
unit (one). The display form carries no information, so the conjunct was
|
|
|
|
|
vacuous whenever credentials existed and always false when none did. Odin
|
|
|
|
|
and StreamPixels passed only because each carries a regular credential.
|
|
|
|
|
- I3. The exact value is readable: D-Bus
|
|
|
|
|
`org.freedesktop.systemd1.Service.LoadCredential` (`busctl get-property`)
|
|
|
|
|
answers `a(ss) 0` for the Vili units and ssh, `a(ss) 1
|
|
|
|
|
"ODIN_TOPOLOGY_IDENTITY" "<path>"` for Odin.
|
|
|
|
|
- I4. What the workload can actually read is the delivered directory. On the
|
|
|
|
|
host, `/run/credentials/` holds a directory only for units with credentials
|
|
|
|
|
(getty, journald, Odin, StreamPixels service and web). Inside Odin's MainPID
|
|
|
|
|
root, `/run/credentials/<unit>/` holds exactly `ODIN_TOPOLOGY_IDENTITY`
|
|
|
|
|
(`-r--r-----+ root root`). The presence identity is a parent-only `OpenFile`
|
|
|
|
|
descriptor, never a credential.
|
|
|
|
|
- I5. The rule arrived whole in `1fc0dad` (2026-09-03, "Rebuild Idunn
|
|
|
|
|
deployment transaction authority"), with its twin clause in
|
|
|
|
|
`validate_launch_observation` (`:3468-3472`, `:3513-3516`); no later commit
|
|
|
|
|
touched it. No ruling or finding in any campaign concerns workload isolation
|
|
|
|
|
verification; gamecult-chat's foreign-workloads cut touched
|
|
|
|
|
`CREDENTIALS_DIRECTORY` reservation, not this check.
|
|
|
|
|
- I6. `SystemdWorkloadObservation` is persisted by previous Idunns in both
|
|
|
|
|
MessagePack encodings, compact (positional) included
|
|
|
|
|
(`persisted_systemd_observations_decode_as_the_systemd_variant`,
|
|
|
|
|
`:9915-9930`), so deleting the `load_credential` field shifts every later
|
|
|
|
|
slot of a live incumbent's record. The field is also in the drift list
|
|
|
|
|
(`:2462`) that `stop` (`:4064-4070`) uses to refuse stopping a changed
|
|
|
|
|
workload: changing what the field holds without leaving that list would make
|
|
|
|
|
the next Idunn refuse to stop the live Odin and StreamPixels incumbents.
|
|
|
|
|
- I7. Fixtures: the verbatim 257.13 `systemctl show` output of base's 27
|
|
|
|
|
properties for a Vili unit (presence identity only) and the Odin unit
|
|
|
|
|
(presence identity plus one service credential), captured 2026-10-24 into
|
|
|
|
|
this Imagination's scratchpad `fixtures/`.
|
|
|
|
|
- I8. The new rule holds for every live systemd incumbent: inside each
|
|
|
|
|
MainPID root the delivered entries equal its binding's `secret_files` keys
|
|
|
|
|
minus `GAMECULT_RUNTIME_PRESENCE_IDENTITY` (Odin 1, StreamPixels service 11,
|
|
|
|
|
StreamPixels web 1; key names compared, no values read). An Idunn restart onto
|
|
|
|
|
this cut re-observes them (`:3955-3970`) and passes.
|
|
|
|
|
|
|
|
|
|
### Decision
|
|
|
|
|
|
|
|
|
|
A property systemd cannot display does not belong in the comparison. The
|
|
|
|
|
invariant it reached for, that the workload holds exactly the credentials Idunn
|
|
|
|
|
delivered, is observed where it is decided: the entries of
|
|
|
|
|
`/run/credentials/<unit>` in the MainPID's root equal Idunn's service
|
|
|
|
|
credential names, absent directory equal to none. That covers
|
|
|
|
|
`LoadCredential`, `SetCredential`, `ImportCredential` and drop-ins alike, and
|
|
|
|
|
needs no new program. The D-Bus read (I3) is exact but sees only one of those
|
|
|
|
|
properties and adds a second systemd client and parser; rejected.
|
|
|
|
|
|
|
|
|
|
### Authority
|
|
|
|
|
|
|
|
|
|
Owner: `observe_unit` decides the credential rule, from the delivered
|
|
|
|
|
directory. `LoadCredential`'s display form is no longer read or compared; the
|
|
|
|
|
`load_credential` field is a retired wire slot, written empty, out of the drift
|
|
|
|
|
list, kept only so persisted records decode. Forbidden writers: the
|
|
|
|
|
`observe_unit` conjunct and the `validate_launch_observation` clause. Dropping
|
|
|
|
|
the slot waits on the next schema revision of the observation.
|
|
|
|
|
|
|
|
|
|
### Deploy
|
|
|
|
|
|
|
|
|
|
Idunn's body changes only through gamecult-ops
|
|
|
|
|
`scripts/install-idunn-yggdrasil-release.sh` (stage `idunn` only; unit and
|
|
|
|
|
bindings unchanged). After it, Vili's Hands reruns from step 4 of
|
|
|
|
|
`motion-pipeline:cut_report:cut-vili-admission.h4`'s undone list.
|
2026-10-25 02:04:37 +01:00
|
|
|
|
|
|
|
|
## Cut `runner-failure-evidence`
|
|
|
|
|
|
|
|
|
|
Spec `idunn-deploy-provenance:cut_spec:cut-runner-failure-evidence.r1`, at
|
|
|
|
|
`804ed75` (origin/main, live on Yggdrasil). It serves the StreamPixels-area
|
|
|
|
|
Self: `idunn up vili` (command `up-4866467c-ee98-4a2b-b4a9-a008d7c44acd`,
|
|
|
|
|
transaction `tx-a04b68c7-c742-4d7d-8220-8aa08b9febae`, 2026-10-25 00:50-00:55
|
|
|
|
|
UTC) failed in Sealing with `Docker runner exited with exit status: 1: ` and
|
|
|
|
|
nobody could tell which recipe step failed.
|
|
|
|
|
|
|
|
|
|
### Body facts (source read at `804ed75`; host read-only, 2026-10-25)
|
|
|
|
|
|
|
|
|
|
- R1. The runner is `docker run --rm` (`src/drivers.rs:2088-2090`), launched by
|
|
|
|
|
`DockerRunnerDriver::docker` (`:2173-2195`) with stdin null and
|
|
|
|
|
`Command::output()`: stdout and stderr are each buffered whole in the
|
|
|
|
|
daemon's memory, unbounded. On a non-zero exit the error is
|
|
|
|
|
`"Docker runner exited with {status}: {stderr}"`; stdout is dropped. The
|
|
|
|
|
container is gone the moment it exits, so `docker ps -a` and `docker logs`
|
|
|
|
|
hold nothing.
|
|
|
|
|
- R2. The empty message: the failing process wrote nothing to stderr, and
|
|
|
|
|
stdout, the only stream that carried its report, is discarded at
|
|
|
|
|
`:2182-2193`. The same interpolation echoes whatever a step does write to
|
|
|
|
|
stderr into `last_error`, `pre_fencing_abort.error` and
|
|
|
|
|
`completion.error` (`bounded_detail`, 2048 bytes), and from there into
|
|
|
|
|
`idunn up`'s `deployment failed:` line and `idunn status`.
|
|
|
|
|
- R3. No step identity reaches the error. `materialize` (`:2368-2391`) loops
|
|
|
|
|
over `declaration.steps` and calls `run_in_workspace` with argv only;
|
|
|
|
|
`step.id` is used only in the environment precondition. External inputs
|
|
|
|
|
(`curl` in the runner, `:2247-2258`) fail through the same helper with the
|
|
|
|
|
same anonymous message.
|
|
|
|
|
- R4. Which step failed on 2026-10-25, inferred from the failed transaction's
|
|
|
|
|
staging root, which survives (926 MiB,
|
|
|
|
|
`/var/lib/gamecult/idunn/staging/tx-a04b68c7-.../`): in `.runner-node-build`,
|
|
|
|
|
`node_modules/.package-lock.json` was written at 00:51:24 (so
|
|
|
|
|
`install-vili-dependencies`, `npm ci`, finished), nothing outside
|
|
|
|
|
`node_modules` is newer, and `release/vili` (the next step's output) is
|
|
|
|
|
absent; the python workspace holds only fetched inputs. The failing step was
|
|
|
|
|
`test-daemon` (`node --test ... scripts/test-vili-daemon.mjs`), whose test
|
|
|
|
|
reporter writes to stdout: exactly the stream R1 discards. Inference from
|
|
|
|
|
residue, not from output.
|
|
|
|
|
- R5. The failure path: `resume_one_transaction` (`src/control_plane.rs:6291-6320`)
|
|
|
|
|
sends a pre-fence error to `begin_pre_fencing_abort` (`:9916-9935`), which
|
|
|
|
|
persists `bounded_detail(format!("{error:#}"))`. Nothing reaches the journal
|
|
|
|
|
on this path: the idunn-yggdrasil journal for 00:45-01:10 holds only
|
|
|
|
|
provider-exclusion lines.
|
|
|
|
|
- R6. Stores. `control.cc` (root 0640) holds commands, transactions
|
|
|
|
|
(`idunn.deployment_transaction.v4`, keys 0-36), generations, supervision and
|
|
|
|
|
expiry requests; `ControlSnapshot::read` refuses any other type
|
|
|
|
|
(`:3420`). Records must re-encode byte-exactly (`decode_record`,
|
|
|
|
|
`:3717-3727`), so a new transaction field is a schema bump and a v4 lift
|
|
|
|
|
(`:4100-4110`). Terminal transactions move verbatim to `history.cc`
|
|
|
|
|
(`:4366-4396`; 39.8 MB on the host), which status reads for `--command`.
|
|
|
|
|
`incidents.cc` lives in the published projection directory and by its own
|
|
|
|
|
doc (`src/incident.rs:1-11`) carries no free text and is never `control.cc`,
|
|
|
|
|
because a type added there stops continuity for every target on a rollback.
|
|
|
|
|
- R7. The daemon runs as root (`User=root`). Runner secrets reach the
|
|
|
|
|
container as read-only file mounts named by env (`:2041-2047`), so any text
|
|
|
|
|
a step derives from them can land in its output.
|
|
|
|
|
- R8. Staging residue: 475 transaction roots, 55 GiB under
|
|
|
|
|
`/var/lib/gamecult/idunn/staging`; a failed Sealing leaves its workspaces
|
|
|
|
|
because `materialize` returns before `remove_tree_inside`.
|
|
|
|
|
|
|
|
|
|
### Decision
|
|
|
|
|
|
|
|
|
|
The failure becomes a typed value at the runner, `RunnerStepFailure`: the
|
|
|
|
|
subject (recipe step or external input, by id), the runner id, the exit code or
|
|
|
|
|
signal, and the last 8 KiB of stdout and of stderr with each stream's total
|
|
|
|
|
byte count. Its `Display` carries the subject and status, never output.
|
|
|
|
|
Capture streams both pipes through bounded tails, so a verbose build no longer
|
|
|
|
|
buffers whole in the daemon.
|
|
|
|
|
|
|
|
|
|
The tail lives in a new private store, `runner-evidence.cc`, beside
|
|
|
|
|
`control.cc` (mode 0600), one record per transaction
|
|
|
|
|
(`idunn.runner_failure.v1`), newest 32 kept by the writer. Not in the
|
|
|
|
|
transaction record: that is a v5 bump whose rollback makes every older Idunn
|
|
|
|
|
refuse its control store (R6), the scar `incident.rs` already names. Not in
|
|
|
|
|
`incidents.cc`: published, and free text is barred there. Not in the journal:
|
|
|
|
|
journal readers are a wider set than root, so the journal line names the
|
|
|
|
|
transaction, the subject, the status and the store, and no output.
|
|
|
|
|
|
|
|
|
|
No redaction. Matching secret bytes would make the daemon read every secret,
|
|
|
|
|
and still misses any encoding, prefix or transform of one; a claimed redaction
|
|
|
|
|
that leaks is worse than a stated rule. The rule instead is the reader set: the
|
|
|
|
|
store's only readers (root) can already read every secret the runner was
|
|
|
|
|
given.
|
|
|
|
|
|
|
|
|
|
Keeping the failed container (dropping `--rm`) is worse: its output then lives
|
|
|
|
|
in Docker's json-file log, unbounded, untyped, outside Idunn's state and
|
|
|
|
|
readable by the docker group; containers accumulate with no owner to reap them;
|
|
|
|
|
`docker system prune` erases the evidence; and nothing ties a container to the
|
|
|
|
|
transaction in `idunn status`.
|
|
|
|
|
|
|
|
|
|
### Authority
|
|
|
|
|
|
|
|
|
|
Owner: the runner decides what failed and keeps the bounded tail; the engine's
|
|
|
|
|
pre-fence failure path is the one writer of `runner-evidence.cc`.
|
|
|
|
|
`last_error`, `pre_fencing_abort.error` and `completion.error` keep only the
|
|
|
|
|
failure's `Display`. Forbidden writers: the `stderr` interpolation in
|
|
|
|
|
`DockerRunnerDriver::docker`. Retention is by count inside the writer, so the
|
|
|
|
|
store needs no external owner; staging roots (R8) stay with
|
|
|
|
|
`ghostlight-verse:follow_up:idunn-generation-retention`.
|
|
|
|
|
|
|
|
|
|
### Deploy
|
|
|
|
|
|
|
|
|
|
Idunn's body changes only through gamecult-ops
|
|
|
|
|
`scripts/install-idunn-yggdrasil-release.sh` (stage `idunn` only; unit and
|
|
|
|
|
bindings unchanged). The next failed Sealing then shows its step and tail on
|
|
|
|
|
`sudo idunn status --command <id>`.
|