From 43f10810d35cb15a3bca7993df646da83fb00c20 Mon Sep 17 00:00:00 2001 From: nholtware Date: Thu, 5 Nov 2026 11:37:09 -0600 Subject: [PATCH] feat: hot-reloaded runtime config (src/lib/runtime-config.ts), greeting on GET /, GET /admin/runtime-config Co-Authored-By: Claude Opus 5.5 --- .env.example | 2 + CLAUDE.md | 1 + README.md | 24 +++- package-lock.json | 16 +++ package.json | 1 + src/app.ts | 18 ++- src/bin/server.ts | 22 +++- src/lib/config.ts | 3 + src/lib/runtime-config.test.ts | 176 ++++++++++++++++++++++++++ src/lib/runtime-config.ts | 218 +++++++++++++++++++++++++++++++++ src/routes/admin.ts | 14 +++ src/routes/root.ts | 4 + src/runtime-config.schema.ts | 22 ++++ 13 files changed, 511 insertions(+), 10 deletions(-) create mode 100644 src/lib/runtime-config.test.ts create mode 100644 src/lib/runtime-config.ts create mode 100644 src/routes/admin.ts create mode 100644 src/runtime-config.schema.ts diff --git a/.env.example b/.env.example index 508ff1e..e863e29 100644 --- a/.env.example +++ b/.env.example @@ -7,3 +7,5 @@ REDIS_URL=redis://localhost:6379 # Feature flags (optional; unset = all flags off) # UNLEASH_URL=http://unleash.192.168.1.83.nip.io/api # UNLEASH_API_TOKEN= +# Runtime config file, hot-reloaded (optional; unset = schema defaults) +# RUNTIME_CONFIG_PATH=./runtime.yaml diff --git a/CLAUDE.md b/CLAUDE.md index 60907a7..e82f72a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,5 +7,6 @@ - Health contract: `/healthz` liveness (always 200), `/readyz` readiness (checks configured Postgres/Redis, else 503 JSON). - Seams: cross-cutting modules in `src/lib/`, endpoints in `src/routes/` wired in `src/app.ts`. - Feature flags: only via `deps.flags` (`src/lib/flags.ts`, Unleash); never import `unleash-client` elsewhere. Unset `UNLEASH_URL` = all flags off. +- Runtime config: only via `deps.runtimeConfig.get()` (`src/lib/runtime-config.ts`); keys + defaults in `src/runtime-config.schema.ts`. Never secrets. Never mount the ConfigMap with `subPath` (breaks hot reload). - Migrations: `migrations/*.sql`, run in-cluster by an ArgoCD PreSync Job using this same image. - Deploy: push to `main` -> Forgejo CI -> image -> gitops bump -> ArgoCD. Never `kubectl apply` by hand. diff --git a/README.md b/README.md index 8a44f82..0dabc1f 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,8 @@ Express 5 + TypeScript backend, generated from `nholtware/service-template-expre - `GET /healthz` liveness, always 200 once listening - `GET /readyz` readiness: Postgres `SELECT 1` and Redis `PING` for whichever of `DATABASE_URL` / `REDIS_URL` are set; 503 + JSON reason otherwise -- `GET /` service name, version (git sha), pod, a `service_info` row, Redis hit counter, and `banner` while the `hello-banner` flag is on +- `GET /` service name, version (git sha), pod, `greeting` (runtime config), a `service_info` row, Redis hit counter, and `banner` while the `hello-banner` flag is on +- `GET /admin/runtime-config` current runtime config `values`, `loadedAt`, `source` (`file` | `defaults`) - `GET /log-demo?msg=...` logs `{ demo: true, text }` with message `log-demo` (logging-pipeline demo; delete when you don't need it) ## Local dev @@ -17,7 +18,7 @@ npm ci && npm run dev npm run lint && npm test ``` -Env is validated at boot (`src/lib/config.ts`): `PORT`, `SERVICE_NAME` (required), `LOG_LEVEL`, `DATABASE_URL?`, `REDIS_URL?`, `UNLEASH_URL?`, `UNLEASH_API_TOKEN?` (required with `UNLEASH_URL`), `UNLEASH_REFRESH_MS` (default 5000). +Env is validated at boot (`src/lib/config.ts`): `PORT`, `SERVICE_NAME` (required), `LOG_LEVEL`, `DATABASE_URL?`, `REDIS_URL?`, `UNLEASH_URL?`, `UNLEASH_API_TOKEN?` (required with `UNLEASH_URL`), `UNLEASH_REFRESH_MS` (default 5000), `RUNTIME_CONFIG_PATH?`. Migrations: SQL files in `migrations/`, applied with `npm run migrate`. Logs: one JSON object per line on stdout (`level,time,msg,service,version`, plus `req.*`/`res.statusCode`/`responseTime` per request). @@ -54,6 +55,25 @@ const v = deps.flags?.getVariant("my-experiment"); // v.name, v.enabled, v.paylo - Tests: pass a fake `Flags` to `createApp` (see `src/app.test.ts`); never hit a real Unleash. - `hello-banner` on `GET /` is the platform demo flag; delete it when you don't need it. +## Runtime config + +Typed values you change with a git commit, live in running pods without a restart +(timeouts, limits, copy; not on/off-for-whom, that's a feature flag; never secrets). + +- Schema + defaults: `src/runtime-config.schema.ts` (zod). Every key needs a default. +- Read it per request: `deps.runtimeConfig.get().maxItems`. React to changes with + `runtimeConfig.onChange((values, changedKeys) => ...)`. +- In the cluster: ConfigMap `-runtime-config` (key `runtime.yaml`) in gitops + `manifests//runtime-config.yaml`, mounted as a directory at `/etc/app/`, + `RUNTIME_CONFIG_PATH=/etc/app/runtime.yaml`. Edit, commit, push: live within ~4 min + (ArgoCD polls every 3 min, kubelet refreshes the volume within ~1 min). + Log line `runtime config reloaded` lists `changedKeys`. +- A bad value (invalid YAML, fails the schema) keeps the last good values and logs `error` + with the zod issues. The pod never crashes on a config commit. Fix it with another commit. +- Local dev: `RUNTIME_CONFIG_PATH=./runtime.yaml npm run dev`; edits reload immediately. + Unset = schema defaults (`source: "defaults"`). +- `GET /admin/runtime-config` is unauthenticated, which is why nothing secret goes in here. + ## Using this template 1. **Generate** a repo from this template in Forgejo (public). `hello-svc` is substituted into `package.json`, this README, `.env.example`, the CI workflow, and the first migration. diff --git a/package-lock.json b/package-lock.json index b414fae..3a80631 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "pino": "^9.5.0", "pino-http": "^10.3.0", "unleash-client": "^6.12.2", + "yaml": "^2.9.1", "zod": "^3.24.0" }, "devDependencies": { @@ -6290,6 +6291,21 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "license": "ISC" }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yargs": { "version": "17.7.3", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", diff --git a/package.json b/package.json index 2ce2c9e..b758ed1 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "pino": "^9.5.0", "pino-http": "^10.3.0", "unleash-client": "^6.12.2", + "yaml": "^2.9.1", "zod": "^3.24.0" }, "devDependencies": { diff --git a/src/app.ts b/src/app.ts index 9bf8582..46b66e4 100644 --- a/src/app.ts +++ b/src/app.ts @@ -6,6 +6,11 @@ import type { Config } from "./lib/config.js"; import type { Db } from "./lib/db.js"; import type { Flags } from "./lib/flags.js"; import type { Cache } from "./lib/redis.js"; +import { + staticRuntimeConfig, + type RuntimeConfig, +} from "./lib/runtime-config.js"; +import { adminRouter } from "./routes/admin.js"; import { healthRouter } from "./routes/health.js"; import { logDemoRouter } from "./routes/log-demo.js"; import { rootRouter } from "./routes/root.js"; @@ -16,9 +21,17 @@ export interface Deps { db?: Db; cache?: Cache; flags?: Flags; + runtimeConfig?: RuntimeConfig; } -export function createApp({ config, logger, db, cache, flags }: Deps) { +export function createApp({ + config, + logger, + db, + cache, + flags, + runtimeConfig = staticRuntimeConfig(), +}: Deps) { const app = express(); app.disable("x-powered-by"); @@ -45,7 +58,8 @@ export function createApp({ config, logger, db, cache, flags }: Deps) { ); app.use(healthRouter({ db, cache })); - app.use(rootRouter({ config, db, cache, flags })); + app.use(rootRouter({ config, db, cache, flags, runtimeConfig })); + app.use(adminRouter({ runtimeConfig })); app.use(logDemoRouter()); // Express identifies error handlers by arity; keep all four args. diff --git a/src/bin/server.ts b/src/bin/server.ts index 4adb3a7..50debfc 100644 --- a/src/bin/server.ts +++ b/src/bin/server.ts @@ -4,22 +4,32 @@ import { createDb } from "../lib/db.js"; import { createFlags } from "../lib/flags.js"; import { createLogger } from "../lib/logger.js"; import { createCache } from "../lib/redis.js"; +import { createRuntimeConfig } from "../lib/runtime-config.js"; const config = parseConfig(process.env); const logger = createLogger(config); const db = config.DATABASE_URL ? createDb(config.DATABASE_URL) : undefined; const cache = config.REDIS_URL ? createCache(config.REDIS_URL) : undefined; const flags = createFlags(config, logger); +const runtimeConfig = await createRuntimeConfig({ + path: config.RUNTIME_CONFIG_PATH, + logger, +}); -const server = createApp({ config, logger, db, cache, flags }).listen( - config.PORT, - () => { - logger.info({ port: config.PORT }, "listening"); - }, -); +const server = createApp({ + config, + logger, + db, + cache, + flags, + runtimeConfig, +}).listen(config.PORT, () => { + logger.info({ port: config.PORT }, "listening"); +}); function shutdown(signal: string) { logger.info({ signal }, "shutting down"); + runtimeConfig.close(); server.close(async () => { await Promise.allSettled([db?.close(), cache?.close(), flags.close()]); process.exit(0); diff --git a/src/lib/config.ts b/src/lib/config.ts index fa205ba..5864e83 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -14,6 +14,9 @@ const schema = z UNLEASH_URL: z.string().url().optional(), UNLEASH_API_TOKEN: z.string().min(1).optional(), UNLEASH_REFRESH_MS: z.coerce.number().int().positive().default(5000), + // Hot-reloaded runtime config file (src/lib/runtime-config.ts). + // Unset = schema defaults, no reload. + RUNTIME_CONFIG_PATH: z.string().min(1).optional(), }) .refine((c) => !c.UNLEASH_URL || c.UNLEASH_API_TOKEN, { message: "UNLEASH_API_TOKEN is required when UNLEASH_URL is set", diff --git a/src/lib/runtime-config.test.ts b/src/lib/runtime-config.test.ts new file mode 100644 index 0000000..afdeffe --- /dev/null +++ b/src/lib/runtime-config.test.ts @@ -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"); + }); +}); diff --git a/src/lib/runtime-config.ts b/src/lib/runtime-config.ts new file mode 100644 index 0000000..352e33f --- /dev/null +++ b/src/lib/runtime-config.ts @@ -0,0 +1,218 @@ +import { watch, type FSWatcher } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { basename, dirname } from "node:path"; +import type { Logger } from "pino"; +import { parse as parseYaml } from "yaml"; +import { + RuntimeConfigSchema, + type RuntimeConfigValues, +} from "../runtime-config.schema.js"; + +export type RuntimeConfigSource = "file" | "defaults"; + +export interface RuntimeConfigSnapshot { + values: RuntimeConfigValues; + loadedAt: string; + source: RuntimeConfigSource; +} + +export interface RuntimeConfig { + /** Current values. Cheap; call per request, don't cache the result. */ + get(): RuntimeConfigValues; + /** Values plus where/when they came from (served by /admin/runtime-config). */ + snapshot(): RuntimeConfigSnapshot; + /** Called after each successful reload that changed something. Returns an unsubscribe. */ + onChange( + cb: (values: RuntimeConfigValues, changedKeys: string[]) => void, + ): () => void; + close(): void; +} + +function defaults(): RuntimeConfigValues { + return RuntimeConfigSchema.parse({}); +} + +/** Schema defaults, never reloads. For tests and when RUNTIME_CONFIG_PATH is unset. */ +export function staticRuntimeConfig( + values: RuntimeConfigValues = defaults(), +): RuntimeConfig { + const snap: RuntimeConfigSnapshot = { + values, + loadedAt: new Date().toISOString(), + source: "defaults", + }; + return { + get: () => snap.values, + snapshot: () => snap, + onChange: () => () => {}, + close: () => {}, + }; +} + +type LoadResult = + | { ok: true; values: RuntimeConfigValues } + | { ok: false; missing: boolean; error: string; issues?: unknown }; + +async function load(path: string): Promise { + let text: string; + try { + text = await readFile(path, "utf8"); + } catch (err) { + const missing = (err as NodeJS.ErrnoException).code === "ENOENT"; + return { ok: false, missing, error: String(err) }; + } + let raw: unknown; + try { + raw = parseYaml(text) ?? {}; + } catch (err) { + return { ok: false, missing: false, error: `invalid YAML: ${String(err)}` }; + } + const result = RuntimeConfigSchema.safeParse(raw); + if (!result.success) { + return { + ok: false, + missing: false, + error: "schema validation failed", + issues: result.error.issues, + }; + } + return { ok: true, values: result.data }; +} + +function changedKeys(a: RuntimeConfigValues, b: RuntimeConfigValues): string[] { + const keys = new Set([...Object.keys(a), ...Object.keys(b)]); + return [...keys].filter( + (k) => + JSON.stringify((a as Record)[k]) !== + JSON.stringify((b as Record)[k]), + ); +} + +/** + * Loads the runtime config file at boot and hot-reloads it. + * + * Kubernetes updates a ConfigMap volume by swapping a `..data` symlink in the + * mount directory (never the file itself), so this watches the DIRECTORY and + * re-reads the file through its symlink. Never mount with `subPath`: those + * mounts are not updated. + * + * A bad commit (invalid YAML, schema failure, file gone) keeps the last good + * values and logs `error`; a running pod never crashes on config. + */ +export async function createRuntimeConfig(opts: { + path?: string; + logger: Logger; + debounceMs?: number; +}): Promise { + const { path, logger } = opts; + const debounceMs = opts.debounceMs ?? 200; + const log = logger.child({ component: "runtime-config" }); + + if (!path) { + log.info("runtime config: RUNTIME_CONFIG_PATH unset, using defaults"); + return staticRuntimeConfig(); + } + + let snap: RuntimeConfigSnapshot; + const listeners = new Set< + (values: RuntimeConfigValues, changed: string[]) => void + >(); + + const first = await load(path); + if (first.ok) { + snap = { + values: first.values, + loadedAt: new Date().toISOString(), + source: "file", + }; + log.info({ path, values: snap.values }, "runtime config loaded"); + } else { + snap = { + values: defaults(), + loadedAt: new Date().toISOString(), + source: "defaults", + }; + if (first.missing) { + log.warn({ path }, "runtime config file missing, using defaults"); + } else { + log.error( + { path, error: first.error, issues: first.issues }, + "runtime config invalid at boot, using defaults", + ); + } + } + + async function reload() { + const r = await load(path!); + if (!r.ok) { + log.error( + { path, error: r.error, issues: r.issues }, + "runtime config reload failed, keeping last good values", + ); + return; + } + const changed = changedKeys(snap.values, r.values); + if (changed.length === 0 && snap.source === "file") return; + snap = { + values: r.values, + loadedAt: new Date().toISOString(), + source: "file", + }; + log.info( + { path, changedKeys: changed, values: r.values }, + "runtime config reloaded", + ); + for (const cb of listeners) { + try { + cb(r.values, changed); + } catch (err) { + log.error({ err }, "runtime config onChange listener threw"); + } + } + } + + // The kubelet's swap fires several events in a burst; debounce them. + let timer: NodeJS.Timeout | undefined; + const schedule = () => { + clearTimeout(timer); + timer = setTimeout(() => void reload(), debounceMs); + }; + + let watcher: FSWatcher | undefined; + const dir = dirname(path); + try { + watcher = watch(dir, { persistent: false }, (_event, filename) => { + // Plain dirs report the file itself; ConfigMap mounts report `..data` + // / `..` entries. Anything else in the dir is ignored. + if ( + !filename || + filename === basename(path) || + filename.startsWith("..") + ) { + schedule(); + } + }); + watcher.on("error", (err) => + log.error({ err, dir }, "runtime config watcher error"), + ); + } catch (err) { + log.error( + { err, dir }, + "runtime config: cannot watch directory, no hot reload", + ); + } + + return { + get: () => snap.values, + snapshot: () => snap, + onChange(cb) { + listeners.add(cb); + return () => listeners.delete(cb); + }, + close() { + clearTimeout(timer); + watcher?.close(); + listeners.clear(); + }, + }; +} diff --git a/src/routes/admin.ts b/src/routes/admin.ts new file mode 100644 index 0000000..9911e3f --- /dev/null +++ b/src/routes/admin.ts @@ -0,0 +1,14 @@ +import { Router } from "express"; +import type { RuntimeConfig } from "../lib/runtime-config.js"; + +// Unauthenticated by design (LAN homelab). Runtime config holds no secrets +// by contract; see src/runtime-config.schema.ts. +export function adminRouter(deps: { runtimeConfig: RuntimeConfig }): Router { + const router = Router(); + + router.get("/admin/runtime-config", (_req, res) => { + res.json(deps.runtimeConfig.snapshot()); + }); + + return router; +} diff --git a/src/routes/root.ts b/src/routes/root.ts index 190962e..9270724 100644 --- a/src/routes/root.ts +++ b/src/routes/root.ts @@ -4,12 +4,14 @@ import type { Config } from "../lib/config.js"; import type { Db } from "../lib/db.js"; import type { Flags } from "../lib/flags.js"; import type { Cache } from "../lib/redis.js"; +import type { RuntimeConfig } from "../lib/runtime-config.js"; export function rootRouter(deps: { config: Config; db?: Db; cache?: Cache; flags?: Flags; + runtimeConfig: RuntimeConfig; }): Router { const router = Router(); @@ -19,6 +21,8 @@ export function rootRouter(deps: { service: deps.config.SERVICE_NAME, version: deps.config.GIT_SHA, pod: hostname(), + // Runtime config (Plan 08): edit the ConfigMap in gitops, no restart. + greeting: deps.runtimeConfig.get().greeting, }; // Demo flag (Plan 07): toggle "hello-banner" in Unleash, no redeploy. if (deps.flags?.isEnabled("hello-banner")) { diff --git a/src/runtime-config.schema.ts b/src/runtime-config.schema.ts new file mode 100644 index 0000000..da16d66 --- /dev/null +++ b/src/runtime-config.schema.ts @@ -0,0 +1,22 @@ +import { z } from "zod"; + +/** + * Runtime config: values that change by a gitops commit, without a restart. + * Source: ConfigMap `-runtime-config` (key `runtime.yaml`), mounted at + * RUNTIME_CONFIG_PATH. Edit this schema for your service. + * + * Rules: + * - Every key MUST have a default: a missing file, or a missing key, still + * yields a complete config. + * - Secrets never go here. `GET /admin/runtime-config` serves these values + * unauthenticated, and the ConfigMap is plain text in git. + * - Unknown keys are dropped, not rejected, so a config commit that adds a + * key can land before the code that reads it. + */ +export const RuntimeConfigSchema = z.object({ + greeting: z.string().min(1).default("hello"), + maxItems: z.number().int().positive().default(50), + featureHints: z.array(z.string()).default([]), +}); + +export type RuntimeConfigValues = z.infer;