Idunn/deploy/idunn-yggdrasil.service
Meta Vens da7dcf4b2d Give each build container its own machine identity
A target whose tests enrol a service identity could not be built at all.
CultLib's Linux protector binds the seed to /etc/machine-id, the build image
carries none, and the runner is --read-only, so Odin's suite failed every
step with "Linux machine-id is unavailable".

Mounting the host's machine-id would let a build container protect a seed
that unwraps on the host, which is exactly the property the binding exists to
deny. So each frozen workspace now gets its own identity, derived from the
workspace path and mounted read-only: stable for the life of a build, and
useless anywhere else including this host.

Also moves Idunn's topology store to /var/lib/gamecult/idunn-projection. It
is Idunn's published surface -- every managed target must read it to verify
its own Expected incarnation -- and it was sitting inside Idunn's private
0750 root where no workload could traverse to it. Odin's own daemon already
named that directory in its tests.

Odin's three roots become writable for the same reason Heimdall's are: a
target Idunn cannot write is a target it cannot deploy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 00:24:59 +02:00

94 lines
4.7 KiB
Desktop File

[Unit]
Description=Idunn deployment admission and daemon-survival authority
After=network-online.target docker.service
Wants=network-online.target
# Deployment runners use Docker, but continuity actuation does not. Warm,
# fence, promote, drain and restart all run on systemd; only the build step
# is containerized, and worktree-tree recipes skip even that. A hard
# Requires= would let a Docker outage stop Idunn itself, killing daemon
# survival for every target. Want it, order after it, and let individual
# container-backed deploys fail on their own if it is absent.
Wants=docker.service
[Service]
Type=simple
# Idunn runs privileged, and this is deliberate rather than laziness. It is the
# actuation authority: it starts transient units with systemd-run, writes unit
# files and route configuration, reloads nginx, and drives Docker. It invokes
# those programs directly -- there is no sudo anywhere in the source -- so a
# confined User= cannot actuate anything.
#
# The privilege separation is *internal*, not at the process boundary. Source
# freezing runs as the unprivileged idunn user via --source-uid/--source-gid
# (both validated as non-zero); the privileged driver never opens Git. What
# stops a compromised target repository from using that privilege is that Idunn
# accepts no imperative input: a recipe declares capability, the operator
# binding grants affordances, and cli_exposes_only_declarative_commands asserts
# there is no way to hand the daemon a command string.
#
# The previous generation ran as User=idunn and reached root through a narrow
# sudoers grant onto a shell actuator. That inverted the safety story: the
# process was confined but the thing it invoked was an unaudited root shell.
User=root
Group=root
# Idunn discovers no authority implicitly. Every store is named here, and every
# one of them is Idunn's own -- no path below belongs to a managed target.
ExecStart=/usr/local/bin/idunn serve \
--state-store /var/lib/gamecult/idunn/control.cc \
--bindings-dir /etc/gamecult/idunn/bindings \
--source-root /var/lib/gamecult/idunn/sources \
--staging-root /var/lib/gamecult/idunn/staging \
--topology-store /var/lib/gamecult/idunn-projection/topology.cc \
--idunn-identity-store /var/lib/gamecult/idunn/idunn-service-identity.cc \
--odin-correlation-store /var/lib/gamecult/odin/topology.cc \
--odin-trust-anchor /etc/gamecult/idunn/odin-topology-anchor.cc \
--deployment-brake-operator-anchor /etc/gamecult/idunn/deployment-brake-operator-anchor.cc \
--source-uid 986 \
--source-gid 978
Restart=always
RestartSec=5
TimeoutStopSec=20
KillSignal=SIGINT
UMask=027
# /run/idunn, for the route driver's preflight staging. systemd creates and
# removes it with the unit, so it never survives as stale state across a
# restart.
RuntimeDirectory=idunn
RuntimeDirectoryMode=0750
PrivateTmp=yes
ProtectHome=yes
# ProtectSystem=full keeps /usr and /etc read-only, then the paths Idunn
# actually actuates are re-opened one at a time. This list is the blast radius,
# and it is the reason running as root is tolerable: root plus an enumerated
# write set is narrower than root everywhere.
ProtectSystem=full
ReadWritePaths=/etc/gamecult/idunn
ReadWritePaths=/etc/nginx/idunn-stream-routes
ReadWritePaths=/etc/systemd/system
# idunn-projection is deliberately outside Idunn's own 0750 directory: the
# topology store is Idunn's *published* surface, and every managed target must
# read it to verify its own Expected incarnation against the Idunn anchor. It
# was inside the private root, where no workload could traverse to it.
ReadWritePaths=/var/lib/gamecult/idunn /var/lib/gamecult/idunn-authority /var/lib/gamecult/idunn-projection
ReadWritePaths=/srv/build
# One entry per managed target: its runtime root, state root and release root.
# A target Idunn cannot write is a target it cannot deploy, so this list grows
# by exactly three paths per migration and is the honest blast radius.
ReadWritePaths=/etc/gamecult/heimdall /var/lib/gamecult/heimdall /srv/heimdall/app
# Odin is a managed target like any other now, so its three roots are writable
# on the same terms. This used to be ReadOnlyPaths, on the reasoning that Idunn
# only *reads* Odin's correlation store to gate promotion. That remains true of
# the correlation itself -- continuity never consults it, see
# stale_odin_provider_receipt_cannot_gate_continuity_restart, and an absent
# store yields None rather than an error, so Odin being down blocks promotions
# and leaves daemon survival untouched. But a target Idunn cannot write is a
# target it cannot deploy, and Odin has to be deployable: it is the bootstrap
# target every other target's warming presence is observed through.
ReadWritePaths=/etc/gamecult/odin /var/lib/gamecult/odin /srv/odin
[Install]
WantedBy=multi-user.target