Commit graph Heimdall/tests/idunn-candidate-bind.test.ts
Author SHA1 Message Date
Meta Vens
5443ae7daf Move the private command port with the candidate
The first release that got far enough to report its own failure said
EADDRINUSE 127.0.0.1:4101: the CultNet private command plane. I gave the
HTTP listener a candidate bind and never noticed Heimdall has a second
listener on a fixed port, so the candidate collided with the incumbent and
died before it could warm.

A fixed private port makes coexistence impossible, which is the premise the
whole warm-then-cutover rests on. The private plane belongs to the same
generation as the HTTP listener, so under Idunn its port is derived from
the assigned candidate port with a fixed offset. Each generation gets its
own pair, and the published command-boundary endpoint stays truthful
because it reads the same config value. Outside Idunn the configured port
is unchanged.

The offset is large enough that a derived port cannot land inside the
route's candidate range and collide with another generation's HTTP
listener.

57 tests, 56 pass and the machine-id-bound one skipped on Windows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 22:15:34 +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
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