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
|
|
|
import path from "node:path";
|
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
|
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
|
|
|
|
|
|
import { loadConfig } from "../src/config.js";
|
|
|
|
|
|
|
|
|
|
describe("Idunn candidate bind", () => {
|
|
|
|
|
it("falls back to HOST/PORT when Idunn is not the launcher", () => {
|
|
|
|
|
const config = loadConfig({ HOST: "127.0.0.1", PORT: "4100" });
|
|
|
|
|
|
|
|
|
|
expect(config.host).toBe("127.0.0.1");
|
|
|
|
|
expect(config.port).toBe(4100);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("takes the candidate socket over HOST/PORT", () => {
|
|
|
|
|
// Idunn owns the listen address for a candidate incarnation, because the
|
|
|
|
|
// incumbent still holds the ordinary one. HOST/PORT are not consulted.
|
|
|
|
|
const config = loadConfig({
|
|
|
|
|
HOST: "127.0.0.1",
|
|
|
|
|
PORT: "4100",
|
|
|
|
|
GAMECULT_IDUNN_CANDIDATE_BIND: "127.0.0.1:18833",
|
|
|
|
|
GC_ACCESS_BASE_URL: "https://heimdall.gamecult.org",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(config.port).toBe(18833);
|
|
|
|
|
expect(config.host).toBe("127.0.0.1");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("never advertises the candidate socket as the public base URL", () => {
|
|
|
|
|
// The regression this guards: publicBaseUrl defaults to host:port, and it
|
|
|
|
|
// builds the OAuth callback registered with each provider. Deriving it
|
|
|
|
|
// from an ephemeral candidate port would hand providers a URL that stops
|
|
|
|
|
// resolving the moment the candidate is promoted or discarded.
|
|
|
|
|
const config = loadConfig({
|
|
|
|
|
GAMECULT_IDUNN_CANDIDATE_BIND: "127.0.0.1:18833",
|
|
|
|
|
GC_ACCESS_BASE_URL: "https://heimdall.gamecult.org",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(config.publicBaseUrl).toBe("https://heimdall.gamecult.org");
|
|
|
|
|
expect(config.publicBaseUrl).not.toContain("18833");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("refuses to start under Idunn without an explicit public base URL", () => {
|
|
|
|
|
expect(() =>
|
|
|
|
|
loadConfig({ GAMECULT_IDUNN_CANDIDATE_BIND: "127.0.0.1:18833" })
|
|
|
|
|
).toThrow(/GC_ACCESS_BASE_URL is required/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("rejects a non-loopback or unusable candidate socket", () => {
|
|
|
|
|
for (const value of ["0.0.0.0:18833", "10.77.0.1:18833", "127.0.0.1:0", "127.0.0.1", "127.0.0.1:not-a-port"]) {
|
|
|
|
|
expect(() =>
|
|
|
|
|
loadConfig({
|
|
|
|
|
GAMECULT_IDUNN_CANDIDATE_BIND: value,
|
|
|
|
|
GC_ACCESS_BASE_URL: "https://heimdall.gamecult.org",
|
|
|
|
|
})
|
|
|
|
|
).toThrow(/GAMECULT_IDUNN_CANDIDATE_BIND/);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("accepts a bracketed IPv6 loopback", () => {
|
|
|
|
|
const config = loadConfig({
|
|
|
|
|
GAMECULT_IDUNN_CANDIDATE_BIND: "[::1]:18833",
|
|
|
|
|
GC_ACCESS_BASE_URL: "https://heimdall.gamecult.org",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(config.host).toBe("::1");
|
|
|
|
|
expect(config.port).toBe(18833);
|
|
|
|
|
});
|
|
|
|
|
});
|
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
|
|
|
|
|
|
|
|
describe("Idunn state root argument", () => {
|
|
|
|
|
it("takes --state-root over GC_ACCESS_DATA_ROOT", () => {
|
|
|
|
|
// Idunn owns where state lives: the recipe declares slots relative to
|
|
|
|
|
// state_root and the binding supplies the absolute path as a launch
|
|
|
|
|
// argument. The env var remains for runs outside Idunn.
|
|
|
|
|
const config = loadConfig({ GC_ACCESS_DATA_ROOT: "/tmp/ignored" }, [
|
|
|
|
|
"--state-root",
|
|
|
|
|
"/var/lib/gamecult/heimdall",
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(config.dataRoot).toBe("/var/lib/gamecult/heimdall");
|
|
|
|
|
expect(config.cultCachePath).toContain("heimdall.service.cc");
|
|
|
|
|
expect(config.cultCachePath).toBe(
|
|
|
|
|
path.join(config.dataRoot, "cultcache", "heimdall.service.cc")
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("falls back to GC_ACCESS_DATA_ROOT when the argument is absent", () => {
|
|
|
|
|
expect(loadConfig({ GC_ACCESS_DATA_ROOT: "/srv/heimdall/state" }, []).dataRoot).toBe(
|
|
|
|
|
"/srv/heimdall/state"
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("rejects a missing or relative --state-root", () => {
|
|
|
|
|
expect(() => loadConfig({}, ["--state-root"])).toThrow(/requires a path/);
|
|
|
|
|
expect(() => loadConfig({}, ["--state-root", "--other"])).toThrow(/requires a path/);
|
|
|
|
|
expect(() => loadConfig({}, ["--state-root", "relative/path"])).toThrow(/must be absolute/);
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-09-06 22:15:34 +02:00
|
|
|
|
|
|
|
|
describe("private command plane port", () => {
|
|
|
|
|
it("moves with the candidate so two generations can coexist", () => {
|
|
|
|
|
// The incumbent holds the configured port. A candidate that reuses it dies
|
|
|
|
|
// on bind before it can warm, which is how the first sealed release failed.
|
|
|
|
|
const config = loadConfig(
|
|
|
|
|
{
|
|
|
|
|
GAMECULT_IDUNN_CANDIDATE_BIND: "127.0.0.1:14103",
|
|
|
|
|
GC_ACCESS_BASE_URL: "https://heimdall.gamecult.org",
|
|
|
|
|
GC_ACCESS_PRIVATE_COMMAND_PORT: "4101",
|
|
|
|
|
},
|
|
|
|
|
[]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(config.port).toBe(14103);
|
|
|
|
|
expect(config.privateCommandPort).toBe(15103);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("keeps the configured port when not launched by Idunn", () => {
|
|
|
|
|
expect(
|
|
|
|
|
loadConfig({ GC_ACCESS_PRIVATE_COMMAND_PORT: "4101" }, []).privateCommandPort
|
|
|
|
|
).toBe(4101);
|
|
|
|
|
});
|
|
|
|
|
});
|