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>
122 lines
4.4 KiB
TypeScript
122 lines
4.4 KiB
TypeScript
import path from "node:path";
|
|
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);
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|