Idunn/docs/host-actuator.md
Meta Vens 6e2a31cf31 Body map after reincarnation cut 2: two slots, take_target, forward retry, operator-required
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 02:40:55 +02:00

155 lines
9.2 KiB
Markdown

# Host actuator: Idunn on a Windows workstation
Idunn on yggdrasil is a Linux control plane whose every driver is a local
actuator: git, docker, `systemd-run`, nginx, a file lock. It has no remote
execution. Muninn runs on Raven, a Windows workstation, inside the logged-on
user's desktop session because it captures that desktop and loops its audio.
Windows `sshd` spawns in session 0, so every remote path into the desktop
session is a hop through a scheduled task, and that hop is where deaths hide.
This document is the authority map for the cut that lets Idunn manage a
target on a host it cannot actuate locally. It is written before the code and
maintained with it.
## Objective
A Muninn deployment to Raven is one Idunn transaction: the same brake, the
same sealed release, the same Expected/activation/presence chain, the same
continuity, as odin on yggdrasil. Nothing on Raven is started by an operator
running a script, and a dead `muninn serve` is seen the tick it dies.
## Authority map
- **Owner:** the yggdrasil Idunn daemon stays the single deployment and
continuity authority. It seals, brakes, publishes Expected, issues
activations, admits Odin's correlation, fences, leases and retires. The
host actuator decides nothing.
- **Host actuator (`idunn-host`):** one process per managed Windows host,
running in the user session, started at logon by one scheduled task. It
dials Idunn over CultNet RUDP on the WireGuard mesh and executes the
consequences of two ports, runner and workload, for that host. It holds
the host's service identity and signs every report with it. It keeps no
transaction state; a restarted actuator re-adopts a running workload by
pid, creation time and executable digest.
- **Inputs to the actuator:** signed requests from Idunn carrying the
compiled plan, sealed release, Expected, activation and its credential.
The exact source is fetched by the actuator from the origin at the exact
revision Idunn froze; the plan's recipe bytes must match the tree.
- **Outputs from the actuator:** `SealedRelease`, installed root, and a
`HostWorkloadObservation` (pid, creation time, executable path and
sha256, command line sha256, environment names, runtime bundle, session
id, user sid, exit code once exited), each signed by the host identity.
- **Transport:** the actuator dials; the host opens no inbound port and
holds no credential for yggdrasil. Idunn binds its actuator hub on the
mesh address only. Confidentiality is WireGuard's; authenticity is the
two service identities (Idunn's, already enrolled; one new profile for
host actuators). A request Idunn did not sign is dropped; a report the
bound host anchor did not sign is dropped.
- **Derived state:** the actuator's own logs, the scheduled task's state,
and Task Manager are evidence, never admission.
- **Forbidden writers:** `restart-muninn.ps1` and its VBS/`schtasks`
launcher no longer start `serve` on a host with an actuator. The
actuator install unregisters the Muninn serve task. Operator scripts on
other Muninn hosts (starfire, nightwing) are untouched until those hosts
get an actuator.
- **Shared paths:** deploy, continuity restart, and cancel all reach Raven
through the same two ports the systemd and docker drivers implement.
There is no second launch path.
- **Deletion line:** `WorkloadDriver` and `RunnerDriver` as single-variant
enums are gone; the driver is the tag of the binding. The observation is
a variant per workload kind, not a systemd struct that a Windows process
would have to counterfeit. The Muninn serve scheduled task, its VBS and
cmd launchers, and `Register-HiddenVbsTask` for serve leave
`restart-muninn.ps1` once Raven is admitted.
## Current mechanism versus intended
| Concern | Today | After |
|---|---|---|
| Runner | `DockerRunnerDriver`, concrete field on `Engine` | `RunnerPort` chosen per plan from the binding: docker on yggdrasil, `host-native` through the actuator |
| Workload | `SystemdTransientWorkloadDriver`, concrete field | `WorkloadPort` chosen per plan: `systemd-transient` or `host-actuator` |
| Observation | 47-field systemd/`/proc` struct | `WorkloadObservation::{Systemd, Host}`, untagged on the wire so persisted records decode unchanged |
| Isolation proof | uid, pid ns, mount ns | per variant: Linux as before; host by pid and creation time, which a workstation cannot fake for two live processes |
| Liveness | `systemctl show` per tick | `Observe` request per tick over the session; a closed session is an error, and the error is "dead" |
| Route | nginx stream fragment | none for Muninn: consumers discover its endpoint through Odin's advertisement |
| Presence | Muninn publishes none | Muninn publishes a dual-signed `gamecult.runtime_presence_health.v2` to Odin from the runtime bundle and activation credential the actuator hands it |
## Invariants that must hold across the cut
1. A persisted transaction or admitted generation written by the previous
Idunn decodes unchanged. Verified by decoding a systemd observation
encoded alone as the enum.
2. No path on yggdrasil can start, stop or observe a host workload except
through the actuator session bound to that host's anchor.
3. A host with no attached actuator is a failed observation, which
continuity counts as a charged attempt and retries on its capped backoff
(it never gives up; six in an hour only pages), exactly like a unit that
will not start.
4. The actuator cannot be told to run a program the recipe did not declare;
`allowed_programs` on the host-native runner is enforced on the host.
## What the cut taught, same day
Each of these was found by running the pipe against Raven, not by reading.
- **Fixed host resources cannot overlap.** Muninn owns the desktop, the
capture device and two advertised ports; candidate-then-promote failed at
bind time. `rollout.strategy = "stop-then-start"` exists for exactly a
route-less host-actuator workload: the admitted incumbent is stopped at
Starting, before the candidate starts, and supervision leaves the target
alone from Starting on: the transaction takes the target there (one
`take_target` CAS) rather than at Fencing. A systemd-transient workload may not declare it.
- **An unobservable host is silence.** The hub returns a typed
`HostUnobservable` for not-attached, detached, reconnected and unanswered.
Supervision skips that tick and logs it. Before this, an Idunn restart
burned all three continuity attempts inside the actuator's 15-second
reattach window and demoted a healthy Muninn.
- **The hub is serviced on its own thread.** A tick that blocked on odin's
docker build for a minute starved every attached host into a timeout.
- **A binding path is absolute by its own shape.** The actuator re-parses
the Linux binding on Windows; `/var/...` must not fail there.
- **The actuator's git never rewrites line endings.** The frozen tree is
byte-exact; Windows git's autocrlf made the recipe bytes differ from the
plan's.
- **The actuator must be rebuilt when the binding vocabulary grows.** It
parses the same TOML the daemon does, from the same crate; an older
actuator refuses a plan whose binding it cannot decode, which is the
right refusal and a deploy step to remember.
- **A provider's presence sequence must survive its restart.** Odin admits
only above the sequence it last stored for a signer; Muninn now uses the
observation clock.
- **Odin (not this repo) died on a stale provider presence.** A continuity
restart of raven-muninn left the previous presence in Odin's store; Odin
treated a stored presence that no longer authenticated as tampering and
exited, on every restart, until Idunn gave up on it. Fixed in Odin
`aaaa8ed`: stale, logged, not fatal. Recorded here because this cut is
what made a provider restart under Idunn a routine event.
Measured on Raven: cold build 1 m 20 s, warm 50 s; seal to admitted about
90 s including the brake release; a killed `serve` is observed on the next
tick and restarted by continuity in 6 s; reattach after an Idunn restart
about 20 s, during which the target is logged unobservable and untouched.
## Subtraction budget
Removed: `WorkloadDriver`, `RunnerDriver` enums; the Muninn serve launcher
body in `restart-muninn.ps1` and its `repair-raven-muninn-task-actions.ps1`
once Raven is admitted. Added: one binary target (`idunn-host`, Windows),
one module of wire records shared by both binaries, one identity profile,
`windows-sys` behind `cfg(windows)`. Net additive, bought by the capability
the operator ordered: a Windows host inside Idunn's authority.
## Build budget
`idunn` and `idunn-provision` are built for yggdrasil in docker
(`rust:latest`, `CARGO_TARGET_DIR=target/linux`; the test suite needs an
`/etc/machine-id` in the container) and installed with
`gamecult-ops/scripts/install-idunn-yggdrasil-release.sh`. `idunn-host`
builds on a Windows host with the stable toolchain and runs on Raven; it is
not built on yggdrasil. The library compiles on both; unix-only code is
`cfg(unix)`, and the Linux-shaped test fixtures pass on a Windows checkout
except the one parent-only-descriptor test.
Invoke Cargo directly as the container command or through a non-login shell.
A login shell can reset `PATH` and hide the Rust toolchain installed in the
build image.