- Rust 99.1%
- Shell 0.9%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
digest's xattr and single-file blind spots Soul pass 8 found the artifact digest's single-file branch (digest_artifact) was a content-only sha256 -- no mode, gid or xattr -- and it is the branch host.rs actually takes for an installed executable, immediately before launch and while the process is running. All three existing digest_artifact tests wrapped the file in a directory so digest_tree ran instead, which is exactly why the fast path's own blindness survived three fix batches untested. - R-I20: extract digest_file_entry, the one place a file entry is hashed for the artifact digest, and delegate both digest_tree's file arm and digest_artifact's bare-file branch to it. Rewrite the three wrapped-file tests to call digest_artifact on a bare file, matching the shape production uses. - R-I21: give the artifact digest an xattr term on all three arms (dir/file/link), matching hash_frozen_source_tree's own coverage -- the asymmetry ran the wrong way, since the artifact is the binary that gets exec'd and re-verified before every launch, unlike the frozen source's 0555 root-owned tree nothing executes. Shared the framing writer (hash_xattr_list) between hash_frozen_source_xattrs and the new digest_tree_xattrs so the two terms can't drift apart; the artifact side reads xattrs through a platform-conditional reader since artifact digesting (unlike frozen-source digesting) runs on every target platform this daemon ships for. Add fixture tests for the dir, file and symlink arms. - R-I22: hash_xattr_list's framing was pinned by nothing -- all 193 tests stayed green while dropping the value length prefix, the count prefix, the name NUL terminator, folding names to lowercase, or hashing names only and dropping values entirely. Add five tests that call hash_xattr_list directly with hand-constructed attribute lists chosen so each specific framing removed is exactly what makes two different lists collide, not just assertions that some digest changed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
||
| .github/workflows | ||
| deploy | ||
| docs | ||
| src | ||
| tests/fixtures | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| clippy.toml | ||
| LICENSE | ||
| README.md | ||
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 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.
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:
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.
Request a deployment and watch it:
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.
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.
# 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" },
]
# 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.
Contracts
Idunn's typed contracts are published by
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/ 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 a dedicated idunn user under ProtectSystem=full,
ProtectHome=yes, PrivateTmp=yes. ReadWritePaths is Idunn's own state plus
the build root; the operator anchor, the bindings, and Odin's correlation store
are all ReadOnlyPaths. 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 |
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 |
authority map, repository declaration, operator binding, release foundation, promotion and continuity |
docs/deployment-authority.md |
the deployment transaction in depth |
docs/signed-daemon-health-authority.md |
signed health admission and trust bindings |
docs/guide.md |
the previous generation's keepalive daemon — no longer runs anywhere, kept for migration history; its commands no longer work |
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. Test and build where it runs, in the same image
Odin uses, with the working tree shipped to the host's build root:
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.