db-mode config changes apply live in-process
Gates / frontend (push) Successful in 1m43s
Gates / test (push) Successful in 2m14s
Gates / test-aarch64 (push) Successful in 8m3s
Gates / package (push) Successful in 5m42s
Gates / container (push) Successful in 54s
CI / gates (push) Successful in 50m24s

settings and upstream writes now follow a prepare, commit, publish, retire
contract: candidates are built and validated before the database transaction,
published as infallible pointer swaps, and old generations retire after their
readers drain. per-query policy values snapshot once per query; upstream pool,
cache, rate limiter, sessions, api limiter, log sink, blocklist scheduler and
the query-log queue each gained one named live operation. restart_required
shrinks from every scalar key to the bind keys and web.enabled; the admin ui
drops its restart notices for everything else. file mode is unchanged.
This commit is contained in:
2026-08-24 00:04:28 +02:00
parent f7f4c8be09
commit ce143d1d87
47 changed files with 7698 additions and 926 deletions
@@ -1,16 +1,16 @@
import { act, fireEvent, screen, waitFor, within } from "@testing-library/react";
import { queryKeys } from "@/lib/queries";
import type { Settings, SettingsPatch } from "@/lib/types";
import { DATABASE, MANAGED_FILE, baseSettings, renderPage, stubApi } from "./testFixtures";
import { DATABASE, MANAGED_FILE, RESTART_REQUIRED_KEYS, baseSettings, renderPage, stubApi } from "./testFixtures";
/**
* System in database mode: the settings form, its diff contract, and the
* certificate reload that is a runtime action under both authorities.
*/
// `logging.level` is enum-backed, so the list covers both field renderings: an
// input whose label carries the mark, and a `Select` that cannot.
const RESTART_KEYS = ["dns.port", "web.port", "logging.level"];
// What the server actually reports: the listener binds and `web.enabled`.
// Every other key applies live, so it carries no mark at all.
const RESTART_KEYS = RESTART_REQUIRED_KEYS;
let stored: Settings;
let putBodies: SettingsPatch[];
@@ -32,10 +32,10 @@ function applyPatch(patch: SettingsPatch): void {
}
}
/** Mirrors settings.zig: a patch touching only `web.password` applies live. */
function needsRestart(patch: SettingsPatch): boolean {
/** Mirrors apply.zig's table: only a listed key leaves the server owing a restart. */
function needsRestart(patch: SettingsPatch, keys: readonly string[]): boolean {
return Object.entries(patch).some(([section, fields]) =>
Object.keys(fields as Record<string, unknown>).some((key) => !(section === "web" && key === "password")),
Object.keys(fields as Record<string, unknown>).some((key) => keys.includes(`${section}.${key}`)),
);
}
@@ -50,11 +50,11 @@ afterEach(() => {
vi.unstubAllGlobals();
});
async function openSystem() {
async function openSystem(restartKeys: readonly string[] = RESTART_KEYS) {
stubApi(DATABASE, {
responses: {
"GET /api/config/status": () => ({ ...DATABASE, restart_pending: restartPending }),
"GET /api/settings": () => ({ settings: stored, restart_required: RESTART_KEYS }),
"GET /api/settings": () => ({ settings: stored, restart_required: restartKeys }),
},
onWrite: (call) => {
if (call.url !== "/api/settings") return null;
@@ -62,8 +62,8 @@ async function openSystem() {
putBodies.push(patch);
if (putResponse !== null) return putResponse();
applyPatch(patch);
if (needsRestart(patch)) restartPending = true;
return json({ settings: stored, restart_required: RESTART_KEYS });
if (needsRestart(patch, restartKeys)) restartPending = true;
return json({ settings: stored, restart_required: restartKeys });
},
});
const router = await renderPage("/configuration/system", "System");
@@ -103,15 +103,50 @@ test("a changed field enables Save and the PUT body is exactly the diff", async
test("a restart-required key is marked as one, from the envelope's list", async () => {
await openSystem();
expect(within(screen.getByRole("group", { name: "DNS" })).getByText("needs restart")).toBeTruthy();
// `rate_limit` is not on the list, so it carries no mark.
// The DNS binds and the port are the section's whole share of the list;
// `rate_limit` and `rate_window_seconds` apply live and carry no mark.
const dns = screen.getByRole("group", { name: "DNS" });
expect(within(dns).getAllByText("needs restart")).toHaveLength(3);
const cache = screen.getByRole("group", { name: "Cache" });
expect(within(cache).queryByText("needs restart")).toBeNull();
});
test("an enum-backed key on the list is marked too, not only text and number fields", async () => {
test("every key the server applies live is drawn without restart messaging", async () => {
await openSystem();
// Silence is the report for a live key: no mark on the field, and editing
// one owes nothing afterwards either.
for (const title of ["Upstream", "Blocking", "Cache", "EDNS", "Logging", "Disk", "Blocklist Update"]) {
const section = screen.getByRole("group", { name: title });
expect(within(section).queryByText("needs restart")).toBeNull();
}
const logging = screen.getByRole("group", { name: "Logging" });
fireEvent.change(within(logging).getByLabelText("retention_days"), { target: { value: "14" } });
fireEvent.click(saveButton());
await waitFor(() => expect(putBodies).toHaveLength(1));
expect(putBodies[0]).toEqual({ logging: { retention_days: 14 } });
await waitFor(() => expect(saveButton().disabled).toBe(true));
expect(restartNotice()).toBeNull();
});
test("a port edit still owes a restart, and the shell says so", async () => {
await openSystem();
const dns = screen.getByRole("group", { name: "DNS" });
fireEvent.change(within(dns).getByLabelText(/^port/), { target: { value: "5353" } });
fireEvent.click(saveButton());
await waitFor(() => expect(putBodies).toHaveLength(1));
await screen.findByText(/Saved changes are not running yet/);
});
test("an enum-backed key on the list is marked too, not only text and number fields", async () => {
// No shipped restart-required key is enum-backed, but the list is the
// server's to change, so the `Select` rendering is pinned against one.
await openSystem(["logging.level"]);
const logging = screen.getByRole("group", { name: "Logging" });
// `logging.level` is a Select and `logging.output` is not on the list, so
// exactly one mark belongs to this section.
@@ -176,7 +211,7 @@ test("password flow: note shown, confirm required, PUT sends web.password, no re
expect(restartNotice()).toBeNull();
});
test("a mixed patch makes the server owe a restart, and the shell says so", async () => {
test("a patch of live keys alone leaves the server owing nothing", async () => {
await openSystem();
const web = screen.getByRole("group", { name: "Web" });
@@ -187,7 +222,8 @@ test("a mixed patch makes the server owe a restart, and the shell says so", asyn
await waitFor(() => expect(putBodies).toHaveLength(1));
expect(putBodies[0]).toEqual({ web: { session_ttl_hours: 48, password: "hunter2" } });
await screen.findByText(/Saved changes are not running yet/);
await waitFor(() => expect(saveButton().disabled).toBe(true));
expect(restartNotice()).toBeNull();
});
test("the form is disabled while the PUT is pending and re-enabled after success", async () => {