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