feat: hot-reloaded runtime config (src/lib/runtime-config.ts), greeting on GET /, GET /admin/runtime-config

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
nholtware 2026-11-05 11:36:32 -06:00
parent cff850544d
commit bd2d5978a2
13 changed files with 511 additions and 10 deletions

View file

@ -0,0 +1,176 @@
import {
mkdtemp,
mkdir,
rename,
rm,
symlink,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import pino from "pino";
import request from "supertest";
import { createApp } from "../app.js";
import { parseConfig } from "./config.js";
import { createRuntimeConfig, type RuntimeConfig } from "./runtime-config.js";
type Line = { level: number; msg: string; [k: string]: unknown };
function capture() {
const lines: Line[] = [];
const logger = pino({}, { write: (l: string) => lines.push(JSON.parse(l)) });
return { lines, logger };
}
const waitFor = async (pred: () => boolean, ms = 3000) => {
const end = Date.now() + ms;
while (!pred()) {
if (Date.now() > end) throw new Error("timed out waiting for condition");
await new Promise((r) => setTimeout(r, 20));
}
};
describe("runtime config", () => {
let dir: string;
let path: string;
let rc: RuntimeConfig | undefined;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), "rc-"));
path = join(dir, "runtime.yaml");
});
afterEach(async () => {
rc?.close();
rc = undefined;
await rm(dir, { recursive: true, force: true });
});
it("no path: defaults, source defaults", async () => {
const { logger } = capture();
rc = await createRuntimeConfig({ logger });
expect(rc.get()).toEqual({
greeting: "hello",
maxItems: 50,
featureHints: [],
});
expect(rc.snapshot().source).toBe("defaults");
});
it("missing file: defaults and warns once", async () => {
const { lines, logger } = capture();
rc = await createRuntimeConfig({ path, logger, debounceMs: 10 });
expect(rc.snapshot().source).toBe("defaults");
expect(rc.get().greeting).toBe("hello");
expect(lines.filter((l) => /missing/.test(l.msg))).toHaveLength(1);
});
it("loads the file, fills defaults for absent keys", async () => {
await writeFile(path, "greeting: hi\n");
const { logger } = capture();
rc = await createRuntimeConfig({ path, logger, debounceMs: 10 });
expect(rc.snapshot().source).toBe("file");
expect(rc.get()).toEqual({
greeting: "hi",
maxItems: 50,
featureHints: [],
});
});
it("reloads on change, notifies onChange with changed keys, logs info", async () => {
await writeFile(path, "greeting: one\nmaxItems: 5\n");
const { lines, logger } = capture();
rc = await createRuntimeConfig({ path, logger, debounceMs: 10 });
const seen: string[][] = [];
rc.onChange((_v, changed) => seen.push(changed));
await writeFile(path, "greeting: two\nmaxItems: 5\nfeatureHints: [a, b]\n");
await waitFor(() => rc!.get().greeting === "two");
expect(rc.get()).toEqual({
greeting: "two",
maxItems: 5,
featureHints: ["a", "b"],
});
expect(seen.at(-1)?.sort()).toEqual(["featureHints", "greeting"]);
const reloaded = lines.find((l) => l.msg === "runtime config reloaded");
expect(reloaded).toMatchObject({
level: 30,
changedKeys: expect.arrayContaining(["greeting"]),
});
});
it("bad YAML keeps last good values and logs error", async () => {
await writeFile(path, "greeting: good\n");
const { lines, logger } = capture();
rc = await createRuntimeConfig({ path, logger, debounceMs: 10 });
await writeFile(path, "greeting: [unclosed\n");
await waitFor(() => lines.some((l) => l.level === 50));
expect(rc.get().greeting).toBe("good");
expect(lines.find((l) => l.level === 50)?.error).toMatch(/invalid YAML/);
});
it("schema violation keeps last good values and logs zod issues", async () => {
await writeFile(path, "maxItems: 10\n");
const { lines, logger } = capture();
rc = await createRuntimeConfig({ path, logger, debounceMs: 10 });
await writeFile(path, 'maxItems: "lots"\n');
await waitFor(() => lines.some((l) => l.level === 50));
expect(rc.get().maxItems).toBe(10);
const err = lines.find((l) => l.level === 50)!;
expect(err.msg).toMatch(/keeping last good/);
expect(JSON.stringify(err.issues)).toMatch(/maxItems/);
// and recovers on the next good commit
await writeFile(path, "maxItems: 20\n");
await waitFor(() => rc!.get().maxItems === 20);
});
it("sees a kubelet-style ..data symlink swap", async () => {
// Mimic a ConfigMap volume: runtime.yaml -> ..data/runtime.yaml,
// ..data -> ..v1; an update writes ..v2 and atomically renames ..data.
await mkdir(join(dir, "..v1"));
await writeFile(join(dir, "..v1", "runtime.yaml"), "greeting: v1\n");
await symlink("..v1", join(dir, "..data"));
await symlink("..data/runtime.yaml", path);
const { logger } = capture();
rc = await createRuntimeConfig({ path, logger, debounceMs: 10 });
expect(rc.get().greeting).toBe("v1");
await mkdir(join(dir, "..v2"));
await writeFile(join(dir, "..v2", "runtime.yaml"), "greeting: v2\n");
await symlink("..v2", join(dir, "..data_tmp"));
await rename(join(dir, "..data_tmp"), join(dir, "..data"));
await rm(join(dir, "..v1"), { recursive: true });
await waitFor(() => rc!.get().greeting === "v2");
});
it("GET / uses greeting; GET /admin/runtime-config serves the snapshot", async () => {
await writeFile(path, "greeting: howdy\n");
const { logger } = capture();
rc = await createRuntimeConfig({ path, logger, debounceMs: 10 });
const app = createApp({
config: parseConfig({ SERVICE_NAME: "test-svc" }),
logger: pino({ level: "silent" }),
runtimeConfig: rc,
});
expect((await request(app).get("/")).body.greeting).toBe("howdy");
const admin = await request(app).get("/admin/runtime-config");
expect(admin.status).toBe(200);
expect(admin.body).toMatchObject({
source: "file",
values: { greeting: "howdy", maxItems: 50, featureHints: [] },
});
expect(Date.parse(admin.body.loadedAt)).not.toBeNaN();
await writeFile(path, "greeting: changed\n");
await waitFor(() => rc!.get().greeting === "changed");
expect((await request(app).get("/")).body.greeting).toBe("changed");
});
});