Heimdall/tests/idunn-candidate-bind.test.ts

122 lines
4.4 KiB
TypeScript
Raw Normal View History

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/);
});
});
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);
});
});