Commit graph Heimdall/deployment/idunn
Author SHA1 Message Date
Meta Vens
3eaa9ef265 Declare Joystick in the Idunn recipe and document the locked refresh
Recipe optional_environment gains the Joystick client id and secret file;
service-contract documents the row lock, rotation fail-closed and the 410/502
answers; the StreamPixels profile names Heimdall as sole holder of provider
refresh tokens (F5).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:15:42 +02:00
Meta Vens
2800d88d10 Pin the recipe's secret declarations, and declare every app secret file
Soul S2: no test read deployment/idunn/recipe.toml, so a plaintext secret
name could be declared again (mutant ME) or a published _FILE name dropped
(mutant MF) with the suite green.

tests/recipe-secrets.test.ts reads the service's declared names and asserts
that no secret-shaped name is declared except as _FILE, that none of the
secrets Heimdall reads is declared under its plaintext name, and that every
provider client-secret file the witness publishes, every published app
profile's shared-secret file, and the database URL, token key and patron
secret files are declared.

The app-profile rule found two gaps: the witness publishes the repixelizer
and streampixels profiles and Heimdall reads their shared-secret files, but
the recipe declared neither, so an Idunn binding could not configure them.
Both are declared as optional, as GitHub and Spotify were for Soul F5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 14:40:21 +02:00
Meta Vens
f8ff2b26ba Declare the GitHub and Spotify client-secret files the witness publishes
Soul F5: the provider witness names GC_ACCESS_PROVIDER_GITHUB_CLIENT_SECRET_FILE
and GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_SECRET_FILE, but the recipe declared
neither, so an Idunn binding could not configure either provider. Both
providers are live code (the provider list, the catalog and the OAuth start
route serve them), so both names are declared as optional.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 13:51:18 +02:00
Meta Vens
ac86352972 Read every secret input from a credential file named by NAME_FILE
The recipe declared Heimdall's secrets as plain environment, so its admit
contract made the operator binding the only place their values could live,
and the binding was printed into a transcript. The recipe now declares each
secret only as its _FILE twin (GC_ACCESS_DATABASE_URL_FILE required, the rest
optional). A binding binds those under [workload.secret_files] to root:root
0400 credential files, and Idunn's rule that a binding binds only declared
names leaves a plaintext secret nowhere to go. GC_ACCESS_APP_SHARED_SECRET is
deleted outright: nothing reads it.

One reader, readSecretInput, serves every secret: it reads NAME_FILE and strips
one trailing newline, refuses NAME and NAME_FILE set together, and its errors
name the variable and path, never a value or file contents. Storage-backend
selection keys off the resolved database URL. The published provider witness
and the provider_not_configured body name the _FILE variables, and custody's
errors name GC_ACCESS_TOKEN_ENCRYPTION_KEY_BASE64_FILE.

The plaintext fallback (NAME read while NAME_FILE is absent) is a named shim
for the legacy EnvironmentFile unit only; secret-files Cut 5 deletes it.

Idunn secret-files cut, Cut 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:30:57 +02:00
Meta Vens
d5acc94fb9 Declare the Odin dependency Heimdall publishes to
Idunn no longer infers how a target proves readiness. Heimdall is routed
but never answers a route challenge; it publishes its presence to Odin, so
its recipe declares the odin.verse-rendezvous dependency and is admitted as
Odin-correlated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 12:51:56 +02:00
Meta Vens
6a923d525e Follow CultLib's package rename and select workspaces by path
Moving the CultLib pin forward to pick up the presence contract also brought
ten unrelated commits, one of which republished the TypeScript packages under
the @gamecult scope. Two things broke on that rename.

The recipe selected build workspaces by package name, so `--workspace=
cultcache-ts` no longer matched anything. Selecting by path instead says what
is actually meant and survives the next rename.

cultnet-ts depends on cultcache-ts by registry range, not by workspace
protocol, so the vendored copy is only used because Heimdall overrides that
name to the vendored path. The override still named the old one, and pnpm went
looking for @gamecult/cultcache-ts on a registry that does not have it.
Heimdall now uses the package's real name in its dependency, its override and
its imports.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 22:26:24 +02:00
Meta Vens
7c17902b1b Ship the interpreter inside the sealed release
Idunn compares the running process's executable inode against the sealed
artifact. A shebang script can never satisfy that: the kernel execs the
interpreter, so the process's executable is /usr/bin/node and the check
correctly reports an executable inode outside the sealed release.

That is an integrity property worth keeping, not a check to relax, and it
explains why every target with a recipe so far has been Rust. The fix that
preserves it is to make the interpreter part of the release: a build step
copies the runner image's own node into dist/, it ships as the executable
artifact, and the bundle becomes its first argument. The executed inode is
then sealed like everything else, and the node version is pinned to the
runner image digest rather than being whatever the host happens to have.

Costs a ~110 MB binary per release against retain_releases = 3. Cheaper
than Node's single-executable tooling and it needs no postject.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 22:07:47 +02:00
Meta Vens
549bbdb6a1 Declare the environment Heimdall actually needs
The first workload launch reached Heimdall.s own config validation and
failed on GC_ACCESS_TOKEN_ENCRYPTION_KEY_BASE64, which the incumbent
supplies from service.env and the binding did not. Rather than discover
one variable per deployment, the recipe now declares the whole set the
incumbent sets -- 32 names -- so the binding may supply them and admit()
stops rejecting each in turn.

Deliberately excluded: the Spotiverse app secrets (that profile is
retired), GC_ACCESS_CULTCACHE_PATH and GC_ACCESS_PROVIDER_HEALTH_IDENTITY_PATH.
Idunn owns both now -- state location arrives as --state-root, and the
signing identity arrives as an inherited descriptor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 19:57:04 +02:00
Meta Vens
c931775ad1 Bundle the service; ship one executable instead of a dependency tree
Idunn refuses to seal a release containing a symlink with parent traversal,
and pnpm's node_modules is exactly that -- a forest of relative links into
.pnpm. Negotiating with that layout failed twice: --node-linker=hoisted was
silently ignored (staging trees still had .pnpm), and pnpm 10's
--config.node-linker=hoisted did not resolve it either.

Bundling removes the question. `pnpm bundle` runs esbuild over src/index.ts
into a single 2.5 MB dist/heimdall with a shebang and a createRequire shim
for the CJS dependencies, pg-native left external as the optional native
binding it is. node_modules is now a build input rather than an artifact.

That also fixes a defect the seal had not reached yet: launch_command does
`ensure!(executable.is_file())` and execs the artifact directly, so the old
recipe -- executable_artifact pointing at the dist *directory*, with
"dist/src/index.js" as the first argument -- would have failed there too.
The service is now one file that is genuinely executable, and its only
arguments are the state root Idunn assigns.

Shipping a dependency tree to run a service was the larger liability
anyway; this deletes it rather than making it sealable.

Recipe validates; 54 pass, 1 skipped, tsc clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 16:51:59 +02:00
Meta Vens
1eca89f6e5 Try the pnpm 10 config-flag form for the hoisted linker (still unresolved)
pnpm 10 takes rc settings as --config.<name>, so --node-linker=hoisted was
being ignored: staging trees written after that change still contained
node_modules/.pnpm. --config.node-linker=hoisted does not resolve it
either -- the seal still fails with "sealed release symlink has a parent
traversal", and fails faster than a fresh install would take, so something
earlier in the chain is being reused.

Recording the attempt rather than leaving the flag looking correct. The
next move is probably to stop shipping node_modules as an artifact at all
and bundle the service instead, which removes the question rather than
negotiating with pnpm.s layout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 16:48:35 +02:00
Meta Vens
ac98df4af9 Install with a hoisted node_modules so the release can be sealed
pnpm defaults to a symlinked node_modules -- a forest of relative links
into .pnpm -- and Idunn rejects sealing a release containing parent
traversal: "sealed release symlink has a parent traversal". That is not a
configuration slip, it is pnpm.s default layout being structurally
unsealable.

--node-linker=hoisted produces a flat npm-style tree with no symlinks, at
the cost of some disk. The unit runs node dist/src/index.js and resolves
imports at runtime, so the release genuinely needs real modules rather
than a bundle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 16:44:17 +02:00
Meta Vens
9b727abe4d Run pnpm through corepack in the build runner
The pinned node image provides npm but not pnpm, and the image entrypoint
execs node for an unrecognised first argument, so a pnpm step failed with
"Cannot find module /workspace/pnpm". Heimdall declares
packageManager: pnpm@10.33.0, and corepack ships with node and honours
that declaration, so it is the toolchain the image already has rather than
a second thing to install.

corepack needs a writable home; COREPACK_HOME joins the step environment
alongside NPM_CONFIG_CACHE and HOME, pointing into the bind-mounted cache.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 16:43:10 +02:00
Meta Vens
ab18e8428d Give the build runner a writable cache
The Docker runner starts the container with --read-only; only /workspace
and a noexec /tmp are writable. Without a cache_root there is nowhere for
npm to cache, and the partial writes surface as "tarball data ... seems to
be corrupted" across most packages, then exit 254. Isolating it proved the
network was fine: the same image on the same network installs
playwright-core cleanly outside the runner sandbox.

I caused this by omitting cache_root when writing the binding, reasoning
that fewer paths meant less to debug. It meant more.

Build and test steps now declare NPM_CONFIG_CACHE and HOME; the binding
supplies both pointing at /cache, where Idunn bind-mounts the cache root
read-write when the build-cache affordance is present. Lowercase env names
are rejected by the recipe validator, which is why these are uppercase.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 16:30:21 +02:00
Meta Vens
0610683295 Let Idunn assign the state root, and make the routed transport http
Writing the operator binding surfaced two recipe defects that only the
real admit() could catch.

The recipe declared transport = "rudp", copied from Odin's. Heimdall's
routed surface is HTTP -- the CultNet private command plane is separate and
Idunn does not route it -- and the transport decides the scheme used to
derive the candidate endpoint, so this would have produced a nonsense bind
address. Now http.

Second, a recipe with state slots is a stateful workload, and admit()
requires the state root to arrive as a launch argument rather than an
environment variable: "stateful workload has no state_root argument
binding". That is the better ownership anyway. Idunn owns where a target's
state lives, the recipe declares slots relative to it, and the binding
supplies the absolute path. Heimdall now takes --state-root, requires it to
be absolute, and prefers it over GC_ACCESS_DATA_ROOT, which survives for
runs outside Idunn. GC_ACCESS_DATA_ROOT is correspondingly gone from the
binding's environment, so there is one owner for that path instead of two
spellings of it.

Verified against a real Linux Idunn rather than assumed: `idunn validate`
on Windows cannot check a Linux binding at all, because Path::is_absolute()
wants a drive prefix and /srv/build/Heimdall fails as "checkout must be
absolute". Built Idunn on yggdrasil in a rust:1.95-bookworm container --
the cached rust:1.89 image is too old, redb 4.2 requires 1.90 -- and
validated there: recipe ok, binding admits.

52 tests across 11 files; tsc clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 15:16:18 +02:00
Meta Vens
726dd56e7d Honour the Idunn process write lease, and stop claiming active while warming
During a deployment two Heimdall processes exist at once under distinct
UIDs and namespaces, and both are configured with the same CultCache store
path. Exactly one may write it. Idunn decides which by publishing a lease
record naming an incarnation; nothing in Heimdall read it, so both
generations would have written heimdall.service.cc.

src/process-write-lease.ts answers one question -- may this process write
state right now -- by reading the lease and the runtime bundle's
expected.cc, and comparing incarnation identity. It grants nothing and
throws nothing outward: every failure is a decision of mayWrite:false with
a reason, because for a warming candidate "not yet my turn" is the
expected condition and crashing would turn it into a failed deployment.
Records are decoded as the canonical positional MessagePack Idunn writes
(14 fields for the lease, schema-checked), and the tests build the same
envelope bytes rather than convenient objects.

publishHeimdallVerseState is the single writer, so that is the only place
gated. Outside Idunn no lease path is configured and writes proceed
unconditionally, which keeps developer runs and the current plain unit
working.

Second defect found while wiring it: health publication hardcoded
state "active". A candidate would have claimed active from its first
pulse, which is both untrue and structurally broken -- Idunn must observe
`warming` presence before it fences the incumbent, so a candidate that
never reports warming cannot be promoted. State is now derived from the
lease: warming until held, active after, with the reason carried in the
detail string.

49 tests across 11 files; tsc clean under exactOptionalPropertyTypes.

The recipe's status banner now records the contract as implemented, with
the honest caveat that none of it has been through a live `idunn up` --
the daemon on yggdrasil is still the previous generation, so the first
deployment remains the real verification.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 02:25:38 +02:00
Meta Vens
817d4970b9 Take the Idunn candidate socket, and keep it out of the advertised URL
Heimdall bound config.port unconditionally, so it could not warm alongside
itself: Idunn runs candidate and incumbent at once under distinct UIDs and
namespaces, and two generations on 4100 is EADDRINUSE. src/config.ts now
prefers GAMECULT_IDUNN_CANDIDATE_BIND, requiring loopback and a usable
port, and falls back to HOST/PORT so developer runs and the current plain
unit are unaffected.

The part worth the care: publicBaseUrl defaulted to `http://${host}:${port}`,
and it builds the OAuth callback URL registered with each provider. Left
alone, a candidate would have advertised its ephemeral port as that
callback -- a URL that stops resolving the moment the candidate is
promoted or discarded, breaking logins in a way that would look like a
provider problem. Listening address and advertised address are separate
authorities, so GC_ACCESS_BASE_URL is now mandatory whenever a candidate
bind is present, and startup fails loudly rather than advertising a
disappearing port.

Six tests cover it, including that regression by name and rejection of
non-loopback, zero-port and malformed values. 41 pass across 10 files.

Recipe corrected while here: required_environment named
HEIMDALL_DATABASE_URL, which does not exist. The real name is
GC_ACCESS_DATABASE_URL, confirmed in src/config.ts and in
/srv/heimdall/env/service.env; that would have failed at first deploy.
HOST and PORT are dropped, since the candidate bind supersedes them under
Idunn, and GC_ACCESS_BASE_URL added as required.

The recipe's status banner now lists what is done and what is pending
rather than saying the contract is unimplemented. The write lease is the
remaining blocker: the state slot is process-bound-single-writer and the
service does not read the lease, so two generations could both open
heimdall.service.cc.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 02:16:41 +02:00
Meta Vens
62a1560f2a Add Heimdall's Idunn recipe; it declares a contract the service must still implement
The recipe validates. It encodes the build sequence that previously lived
only in the host's shell manifest -- npm ci in vendor/CultLib, then build
cultcache-ts, cultnet-ts and cultmesh-ts, then pnpm install, test, build --
so the source seal now covers how Heimdall is built, not just what from.

Artifacts are dist/ plus the production node_modules, because the unit runs
dist/src/index.js and Node resolves imports at runtime; the release is not
the compiled output alone.

Marked NOT YET DEPLOYABLE in the file, because validation only proves the
declaration is well-formed, not that the service honours it. src/index.ts
binds config.port (4100, fixed), and `grep -r GAMECULT_IDUNN src/` returns
nothing. Warm-then-cutover needs a candidate to bind where Idunn tells it
via GAMECULT_IDUNN_CANDIDATE_BIND, because candidate and incumbent run at
once under distinct UIDs and namespaces -- a service pinned to one port
cannot warm alongside itself. The process write lease and runtime bundle
are declared and unread too.

Odin, Ghostlight and CodexConnector all read these variables; every target
with a recipe implements the contract. Heimdall publishes Idunn health
already via src/idunn-rudp-health.ts and stops short of the rest. That
implementation is the remaining work, and it is app code rather than
deployment configuration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 00:44:34 +02:00