generated from nholtware/service-template-express
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:
parent
f0596d1fd3
commit
43f10810d3
13 changed files with 511 additions and 10 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
24
README.md
24
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 `<app>-runtime-config` (key `runtime.yaml`) in gitops
|
||||
`manifests/<app>/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.
|
||||
|
|
|
|||
16
package-lock.json
generated
16
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
18
src/app.ts
18
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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
176
src/lib/runtime-config.test.ts
Normal file
176
src/lib/runtime-config.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
218
src/lib/runtime-config.ts
Normal file
218
src/lib/runtime-config.ts
Normal file
|
|
@ -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<LoadResult> {
|
||||
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<string, unknown>)[k]) !==
|
||||
JSON.stringify((b as Record<string, unknown>)[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<RuntimeConfig> {
|
||||
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`
|
||||
// / `..<timestamp>` 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
14
src/routes/admin.ts
Normal file
14
src/routes/admin.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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")) {
|
||||
|
|
|
|||
22
src/runtime-config.schema.ts
Normal file
22
src/runtime-config.schema.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Runtime config: values that change by a gitops commit, without a restart.
|
||||
* Source: ConfigMap `<app>-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<typeof RuntimeConfigSchema>;
|
||||
Loading…
Add table
Add a link
Reference in a new issue