A unit with Upholds= is refused for start and restart (systemd would start the upheld unit in a full transaction). A wrapper with ConsistsOf= is refused for stop, restart and reload with a typed Refused::Wrapper naming the first member, so it cannot report ran while touching nothing. Tests pin empty BoundBy, a single show per refusal, no show for a refused name and each posted reason. README: the one-job rule's residual full transactions. Removes the unused BTreeMap import and the never_loop in the host actuator test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
355 lines
16 KiB
Markdown
355 lines
16 KiB
Markdown
# Idunn
|
||
|
||
**Deployment admission and daemon-survival authority for the GameCult swarm.**
|
||
|
||
Idunn decides two things and nothing else: whether an artifact may be
|
||
installed, and whether an already-installed body may be restarted. It keeps
|
||
daemons alive across a fleet, admits releases against operator-signed
|
||
authority, and reports typed health — while depending on none of the services
|
||
it manages.
|
||
|
||
Extracted from [Odin](https://github.com/GameCult/Odin) on 2026-09-05 with its
|
||
full 136-commit history. Idunn had lived as a crate inside the repository of a
|
||
service it manages, which inverted the authority it exists to hold.
|
||
|
||
---
|
||
|
||
## Quick start
|
||
|
||
Requires Rust 1.95+. Idunn is a **Linux daemon**; see [Platform](#platform).
|
||
|
||
```bash
|
||
git clone https://github.com/GameCult/Idunn.git
|
||
cd Idunn
|
||
cargo build --release
|
||
```
|
||
|
||
Two binaries land in `target/release/`:
|
||
|
||
| Binary | Purpose |
|
||
|---|---|
|
||
| `idunn` | the daemon — keepalive supervision, deployment actuation, health |
|
||
| `idunn-provision` | operator tooling — brake status, identity enrolment, provisioning |
|
||
|
||
Idunn's CLI is **declarative**. There are five commands and no imperative
|
||
escape hatch — you cannot hand it a shell string to run:
|
||
|
||
```bash
|
||
idunn serve [runtime options] # the control plane
|
||
idunn up <service|profile:name> [--no-wait] # request a deployment
|
||
idunn status [--command ID] # read what happened
|
||
idunn cancel <command-id> # cancel a pending command
|
||
idunn validate --recipe PATH [--binding PATH] # offline admission check
|
||
```
|
||
|
||
A test, `cli_exposes_only_declarative_commands`, asserts that
|
||
`--deploy-command`, `--restart-command` and `--swarm-profile` are **rejected**.
|
||
What a target is, how it is built, and what it is allowed to touch live in two
|
||
documents, never in a command line — see [Recipes and bindings](#recipes-and-bindings).
|
||
|
||
Request a deployment and watch it:
|
||
|
||
```bash
|
||
idunn up ghostlight --requested-by "$USER"
|
||
idunn status --command <id>
|
||
```
|
||
|
||
`idunn up` accepts a service name or `profile:<name>`; profiles are declared by
|
||
the bindings, so `idunn up profile:full-gamecult` brings up everything bound
|
||
into that profile.
|
||
|
||
### Steward orders
|
||
|
||
A second root unit from the same binary, `idunn-orders` (`idunn orders serve`),
|
||
takes typed orders from one steward Mind over `/run/idunn-orders/steward.sock`.
|
||
It has its own store, `orders.cc`, and its own binding, `steward.toml`; the
|
||
survival daemon takes no order. There is still no command string:
|
||
|
||
```bash
|
||
idunn order package-install <pkg>[=version]... --reason TEXT # apt-get update, install named packages, check them
|
||
idunn order unit <start|stop|restart|reload> <unit> --reason TEXT
|
||
idunn order deploy <service|profile:name> --reason TEXT # the same commit as `idunn up`
|
||
idunn order show <id> # status and output tails
|
||
idunn order list
|
||
```
|
||
|
||
A unit order submits exactly one job, for the unit it names: every verb runs
|
||
`systemctl <verb> --job-mode=ignore-requirements -- <unit>`, so systemd builds a
|
||
transaction of one job. It does not start what the unit requires or restart what
|
||
is part of it: order the dependency first, and order `postgresql@16-main.service`
|
||
rather than the `postgresql.service` wrapper. Targets are not orderable. The job is one; what the unit does afterwards is
|
||
not: systemd still runs full transactions of its own for `Restart=` after a
|
||
failure, for a `StopWhenUnneeded=` dependency left without a user, and for a
|
||
`Conflicts=` partner of a unit that fails, so an ordered stop of a unit
|
||
that something restarts does not last, and a start of a `Restart=always`
|
||
unit can report failure and be active seconds later. A unit
|
||
order may act on any unit except Idunn's own (`idunn-*`) and the minds' own
|
||
(`mind-self@*`, `mind-persona@*`), and the units that end or take the host
|
||
(`systemd-suspend`, `systemd-hibernate`, `systemd-hybrid-sleep`,
|
||
`systemd-suspend-then-hibernate`, `rescue`, `emergency`). That set is compiled
|
||
into the binary and read from no configuration. An order is refused, recorded
|
||
and posted when the ordered unit, or an alias it resolves from, is one of them;
|
||
when it has an emergency action (`SuccessAction=`, `FailureAction=`,
|
||
`StartLimitAction=`, `JobTimeoutAction=`) other than `none` or an `OnFailure=`
|
||
or `OnSuccess=` unit (not for a reload); or, for a stop or restart, when another
|
||
unit is `BoundBy=` it, because systemd stops that unit with it whatever the job
|
||
mode; for a start or restart, when it has an `Upholds=` unit, which systemd
|
||
starts in a full transaction whatever the job mode; or, for a stop, restart or
|
||
reload, when it has a `ConsistsOf=` member, a wrapper such as
|
||
`postgresql.service` whose verbs would exit 0 and touch nothing. A unit
|
||
`systemctl show` cannot resolve fails the order. Slices, scopes,
|
||
targets and globs are not units an order can name.
|
||
|
||
A package order installs the packages it names and nothing else: apt runs with
|
||
`--no-remove`, each name is passed as an exact-name pattern so `.` and `+`
|
||
match themselves, and a last step fails the order unless every named package
|
||
is installed.
|
||
|
||
An order runs once, after the steward's uid is checked and the instance's
|
||
brake check (`mind-control --instance <principal> check`) exits 0. The check
|
||
that the order's principal and uid are the bound steward's runs again at every
|
||
execution, so an order accepted before `steward.toml` changed is refused and
|
||
recorded, not run. The socket belongs to the steward user alone (mode 0600);
|
||
root may `show` and `list` orders but never issue one. An order that had
|
||
started when the service died is recorded as lost and never run again. No order releases a brake. Acceptance and result are posted to
|
||
`#operator` as a retried projection of `orders.cc`: a chat outage delays the
|
||
posts and not the orders. A refusal names the field and a code, never the value
|
||
it refused.
|
||
|
||
## Authority
|
||
|
||
**Owner.** Deployment admission, and the continuity of installed bodies.
|
||
|
||
**Inputs.** Its own typed state stores, its own credentials, and
|
||
operator-authored trust anchors and bindings. It does not read a managed
|
||
target's binaries, health, or availability to decide anything about itself.
|
||
|
||
The one deliberate exception, stated plainly rather than hidden: Idunn reads
|
||
Odin's published topology correlation to gate **deployment** promotion, so that
|
||
a promotion waits for semantic discovery. It is read-only, an absent store
|
||
yields `None` rather than an error, and it is structurally unreachable from the
|
||
continuity path — `validate_live_providers_for_deploy` never invokes the
|
||
provider check for `CommandKind::Continuity`. Odin being down blocks
|
||
promotions. It cannot stop daemon survival.
|
||
|
||
**The invariant.**
|
||
|
||
> Idunn must be able to start, recover, observe and report while any managed
|
||
> target is absent, broken, or deliberately braked.
|
||
|
||
A target's brake gates mutation *of that target only*. It can neither authorize
|
||
nor block Idunn's own startup, nor any other target's lifecycle.
|
||
|
||
**Two brakes, not one.** Deployment and continuity are separate authorities
|
||
with separate stores, declared per target in that target's binding. A binding
|
||
naming one store for both is rejected outright — *"deployment and lifecycle
|
||
brakes are identical"*.
|
||
|
||
- *Continuity* is restarting an already-admitted installed release. Physiology.
|
||
- *Deployment* is changing an artifact, revision, configuration, schema, unit,
|
||
or authority binding.
|
||
|
||
A deployment brake does not own the former. Suspending restart actuation
|
||
requires the separately named lifecycle brake.
|
||
|
||
**Forbidden.** Idunn does not depend on a managed target, and does not repair
|
||
one merely to satisfy a deploy. If recovery would require that, the target
|
||
dependency is the thing to cut.
|
||
|
||
## Recipes and bindings
|
||
|
||
Two documents describe a target, and the split is the security model.
|
||
|
||
The **recipe** lives in the target's own repository at
|
||
`deployment/idunn/recipe.toml` (`gamecult.idunn.target_declaration.v1`). It
|
||
declares *capability*: build and test steps, the artifacts they produce, the
|
||
service's argument shape, its health contract, its state slots, and what it
|
||
provides. It names no host paths, no images, no privileges.
|
||
|
||
The **operator binding** lives on the host under `--bindings-dir`
|
||
(`gamecult.idunn.operator_binding.v2`). It supplies everything privileged:
|
||
which origin and ref are admitted, the pinned runner image, the workload's
|
||
roots and hardening, the runtime trust anchor, the route, the two brake
|
||
stores, rollout strategy and placement. It also declares which profiles the
|
||
target belongs to.
|
||
|
||
```toml
|
||
# recipe — in the target's repo, capability only
|
||
[[steps]]
|
||
id = "build"
|
||
phase = "build"
|
||
runner = "rust-build"
|
||
argv = ["cargo", "build", "--locked", "--release", "-p", "thing"]
|
||
|
||
[service]
|
||
executable_artifact = "thing"
|
||
arguments = [
|
||
{ kind = "literal", value = "--store" },
|
||
{ kind = "binding", name = "state_root" },
|
||
]
|
||
```
|
||
|
||
```toml
|
||
# binding — on the host, privilege only
|
||
[repository]
|
||
origin = "https://github.com/GameCult/Thing.git"
|
||
admitted_ref = "refs/heads/main"
|
||
selection = "ref-head"
|
||
recipe_path = "deployment/idunn/recipe.toml"
|
||
|
||
[brakes]
|
||
deployment_store = "/var/lib/gamecult/idunn-authority/thing-deployment-brake.cc"
|
||
lifecycle_store = "/var/lib/gamecult/idunn-authority/thing-lifecycle-brake.cc"
|
||
```
|
||
|
||
A recipe cannot grant itself an affordance: the binding must name every
|
||
affordance the recipe's runners derive, and `admit()` rejects a binding that
|
||
omits one. Unknown fields are rejected on both sides. A repository that gets
|
||
compromised can therefore change what is built, but not what it is allowed to
|
||
touch on the host.
|
||
|
||
An upstream daemon that does not speak Idunn's runtime contract is deployed
|
||
with `idunn-launch` as its executable artifact and the upstream tree as a
|
||
required adjacent artifact; the launcher finds the child inside the release that
|
||
holds it. The launcher is the unit's process: it binds the
|
||
candidate endpoint and answers Idunn's challenge there, renders the child's
|
||
config, starts a stateful child only once it holds the write lease, and signs
|
||
presence. Members never pass through it: the binding's `[route.member]` makes
|
||
Idunn route a second listener straight to the child, which binds the address
|
||
Idunn gives it in `GAMECULT_IDUNN_MEMBER_BIND`.
|
||
|
||
```toml
|
||
# recipe fragment — the upstream tree is built or fetched as an artifact
|
||
[service]
|
||
executable_artifact = "idunn-launch"
|
||
arguments = [
|
||
{ kind = "literal", value = "--child" },
|
||
{ kind = "literal", value = "upstream/bin/daemon" },
|
||
{ kind = "literal", value = "--template" },
|
||
{ kind = "literal", value = "config.tmpl" },
|
||
{ kind = "literal", value = "--health-path" },
|
||
{ kind = "literal", value = "/health" },
|
||
{ kind = "literal", value = "--health-contract" },
|
||
{ kind = "literal", value = "example.presence-launcher.v1" },
|
||
]
|
||
required_environment = [
|
||
"GAMECULT_IDUNN_RUNTIME_BUNDLE",
|
||
"GAMECULT_IDUNN_CANDIDATE_BIND",
|
||
"GAMECULT_IDUNN_MEMBER_BIND",
|
||
]
|
||
```
|
||
|
||
A directory artifact is sealed read-only: its files are 0444 and its
|
||
directories 0555, whatever mode the build or git gave them. A file the tree
|
||
must run, such as the launcher's `upstream/bin/daemon` child, is declared in
|
||
`executable_members` and sealed 0555. The members are part of the sealed release
|
||
address, so changing the list makes a new release. Idunn refuses the release if a
|
||
member is absolute, climbs with `..`, is missing, is a symlink or sits behind
|
||
one, or is not a regular file; `executable = true` on a directory output and
|
||
`executable_members` on a file output are refused too.
|
||
|
||
A tree may hold relative symlinks whose `..` steps all come first and climb no
|
||
higher than the artifact's top (`share/terminfo/4/4025ex -> ../t/tek4025ex` is
|
||
kept). An absolute link, a `..` after a name, or a climb out of the artifact
|
||
refuses the release when it is sealed. Host targets (Windows) record
|
||
`executable` and `executable_members` in the receipt and do not apply them.
|
||
|
||
```toml
|
||
# recipe fragment — the upstream artifact declares the child it launches
|
||
[[artifacts]]
|
||
id = "upstream"
|
||
source_kind = "runner-output"
|
||
runner = "build"
|
||
source = "dist/upstream"
|
||
destination = "upstream"
|
||
executable_members = ["bin/daemon"]
|
||
```
|
||
|
||
```toml
|
||
# binding fragment <20> members connect to the member endpoint, which Idunn
|
||
# forwards to the private port derived from the candidate: member
|
||
# private_port_start + (candidate port - route private_port_start)
|
||
[route]
|
||
stable_endpoint = "tcp://10.77.0.1:8008"
|
||
private_port_start = 18831
|
||
private_port_end = 18839
|
||
# ...
|
||
|
||
[route.member]
|
||
stable_endpoint = "tcp://10.77.0.1:8448"
|
||
private_port_start = 18841
|
||
# max_connections = 128 # nginx limit_conn on the member listener (1-65535)
|
||
```
|
||
|
||
## Contracts
|
||
|
||
Idunn's typed contracts are published by
|
||
[CultLib](https://github.com/GameCult/CultLib) in `cultnet-rs` — deployment and
|
||
lifecycle brakes, service identity, runtime activation, expected incarnation,
|
||
and the provider-health family. Idunn consumes them and defines no private
|
||
copies.
|
||
|
||
State is CultCache (`.cc` typed documents) throughout. Health is published over
|
||
CultNet RUDP.
|
||
|
||
Odin keeps its own separate projection of Idunn state for aggregation. That is
|
||
the all-seer's view, not Idunn's contract, and the two sets do not overlap.
|
||
|
||
## Running as a service
|
||
|
||
[`deploy/`](deploy/) carries a worked systemd installation:
|
||
|
||
| File | |
|
||
|---|---|
|
||
| `idunn-yggdrasil.service` | the unit |
|
||
| `idunn-yggdrasil` | operator wrapper |
|
||
| `idunn-yggdrasil.sudoers` | the narrow sudo grant the wrapper needs |
|
||
|
||
The unit runs as root under `ProtectSystem=full`,
|
||
`ProtectHome=yes`, `PrivateTmp=yes`. `ReadWritePaths` contains Idunn's own state,
|
||
the build root, and only the runtime, state, and release roots explicitly
|
||
granted to each managed target. The operator anchor, bindings, and Odin's
|
||
correlation store remain read-only. Idunn admits against operator authority —
|
||
it does not author it.
|
||
|
||
Note `Wants=docker.service`, not `Requires=`. Only the build step is
|
||
containerized; warm, fence, promote, drain and restart run on systemd, and
|
||
worktree-tree recipes skip Docker entirely. A hard requirement would let a
|
||
Docker outage stop Idunn itself and take daemon survival down with it.
|
||
|
||
Adapt the store paths for your host. Every path in `ExecStart` is explicit on
|
||
purpose; Idunn discovers no authority implicitly.
|
||
|
||
## Documentation
|
||
|
||
| | |
|
||
|---|---|
|
||
| [`docs/migration.md`](docs/migration.md) | **start here if you run a swarm today** — moving a target from the previous generation, in order, and cutting over the authority itself |
|
||
| [`docs/authority-map.md`](docs/authority-map.md) | authority map, repository declaration, operator binding, release foundation, promotion and continuity |
|
||
| [`docs/deployment-authority.md`](docs/deployment-authority.md) | the deployment transaction in depth |
|
||
| [`docs/signed-daemon-health-authority.md`](docs/signed-daemon-health-authority.md) | signed health admission and trust bindings |
|
||
| [`docs/guide.md`](docs/guide.md) | the **previous** generation's keepalive daemon — no longer runs anywhere, kept for migration history; its commands no longer work |
|
||
| [`deploy/legacy/`](deploy/legacy/) | the shell actuator that generation used, no longer running, and what replaced each part of it |
|
||
|
||
## Platform
|
||
|
||
Idunn targets Linux and does not compile on Windows (`drivers.rs` uses
|
||
`std::os::unix` unconditionally). A local `cargo check` on a Windows
|
||
workstation proves nothing. `scripts/verify.sh` is the verification command:
|
||
the library tests plus a Windows-target check of `idunn-host`. Run it on Linux
|
||
with the `x86_64-pc-windows-gnu` std installed. Test and build where it runs, in the same image
|
||
Odin uses, with the working tree shipped to the host's build root:
|
||
|
||
```bash
|
||
git ls-files -z | tar --null -T - -cf - | ssh ygg 'sudo rm -rf /srv/build/idunn && sudo mkdir -p /srv/build/idunn && sudo tar -x -C /srv/build/idunn && sudo chown -R gamecultadmin:gamecultadmin /srv/build/idunn'
|
||
ssh ygg 'sudo docker run --rm -v /srv/build/idunn:/w -v /etc/machine-id:/etc/machine-id:ro -v /srv/build/cargo-registry:/usr/local/cargo/registry -v /srv/build/cargo-git:/usr/local/cargo/git -w /w rust:1.95-bookworm sh -c "cargo test --lib && cargo build --release"'
|
||
```
|
||
|
||
`/etc/machine-id` must be mounted or every identity-enrolling test fails.
|
||
`--lib` because the tests under `tests/` assume the host layout. The release
|
||
binary lands in `/srv/build/idunn/target/release/idunn`; install it over
|
||
`/usr/local/bin/idunn` and restart `idunn-yggdrasil.service`.
|
||
|
||
## Licence
|
||
|
||
MIT.
|