milestone 32: task-shaped configuration, file mode as a rendering, config status api
Gates / frontend (push) Successful in 1m22s
Gates / test (push) Successful in 1m54s
Gates / test-aarch64 (push) Successful in 7m57s
Gates / package (push) Successful in 5m29s
Gates / container (push) Successful in 10s
CI / gates (push) Successful in 32m51s

This commit is contained in:
2026-08-22 22:42:50 +02:00
parent 025edbb093
commit 7e0df5fd94
89 changed files with 6101 additions and 3750 deletions
+88
View File
@@ -0,0 +1,88 @@
//! `GET /api/config/status` — which source governs this process's
//! configuration, and whether a change already written needs a restart.
//!
//! One home for both facts. `authority`, `path` and `reconciled_at` are the
//! invocation's answer (milestone-20 ruling 1): the database carries no record
//! of who wrote it, so only the running process can say. `restart_pending` is
//! the running process's own memory of a mutation whose effect waits for the
//! next start — the upstream pool and the scalar settings are both built at
//! startup. Nothing clears it but process exit, so a client that sees it false
//! after a restart is seeing the truth rather than a cleared flag.
//!
//! The route needs a session: `path` is a filesystem path and must never reach
//! the open `/api/version` or `/api/health`.
const std = @import("std");
const http_util = @import("../http_util.zig");
const server = @import("../server.zig");
const Request = http_util.Request;
const HandlerError = http_util.HandlerError;
pub const View = struct {
authority: []const u8,
/// The managed file, or null in database mode.
path: ?[]const u8,
/// When this process loaded the managed file, in epoch seconds; null in
/// database mode, which never reconciles. It means exactly that: a file
/// whose mtime is newer has not been loaded by the running process.
reconciled_at: ?i64,
restart_pending: bool,
};
pub fn view(state: *const server.WebState) View {
return switch (state.authority) {
.database => .{
.authority = "database",
.path = null,
.reconciled_at = state.reconciled_at,
.restart_pending = state.restart_pending.load(.monotonic),
},
.managed_file => |path| .{
.authority = "managed_file",
.path = path,
.reconciled_at = state.reconciled_at,
.restart_pending = state.restart_pending.load(.monotonic),
},
};
}
pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
_ = io;
return http_util.respondJson(request, .ok, view(state), &.{});
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
const testing = std.testing;
test "database authority reports no file and no reconcile time" {
var state: server.WebState = .{ .gpa = testing.allocator };
const current = view(&state);
try testing.expectEqualStrings("database", current.authority);
try testing.expectEqual(@as(?[]const u8, null), current.path);
try testing.expectEqual(@as(?i64, null), current.reconciled_at);
try testing.expect(!current.restart_pending);
}
test "file authority reports the path and the load time" {
var state: server.WebState = .{
.gpa = testing.allocator,
.authority = .{ .managed_file = "/etc/nxdns/config.zon" },
.reconciled_at = 1_700_000_042,
};
const current = view(&state);
try testing.expectEqualStrings("managed_file", current.authority);
try testing.expectEqualStrings("/etc/nxdns/config.zon", current.path.?);
try testing.expectEqual(@as(?i64, 1_700_000_042), current.reconciled_at);
}
test "the flag the mutations raise is what the view reports" {
var state: server.WebState = .{ .gpa = testing.allocator };
try testing.expect(!view(&state).restart_pending);
state.restart_pending.store(true, .monotonic);
try testing.expect(view(&state).restart_pending);
}
+107 -39
View File
@@ -184,6 +184,32 @@ fn merge(cfg: *model.Config, patch: Patch, bad_key: *[]const u8) bool {
return true;
}
/// Whether the patch names a key whose change waits for a restart. The key
/// table above is the authority, so a key that stops being restart-required
/// stops raising the flag without anyone remembering this function exists.
/// A patch carrying `web.password` alone touches no such key: the new hash is
/// installed live (ruling 17 of milestone 16).
fn touchesRestartRequiredKey(patch: Patch) bool {
inline for (@typeInfo(Patch).@"struct".fields) |section_field| {
if (@field(patch, section_field.name)) |section| {
inline for (@typeInfo(@TypeOf(section)).@"struct".fields) |field| {
if (@field(section, field.name) != null) {
if (comptime isRestartRequired(section_field.name ++ "." ++ field.name)) return true;
}
}
}
}
return false;
}
fn isRestartRequired(comptime key: []const u8) bool {
@setEvalBranchQuota(10_000);
for (keys) |listed| {
if (std.mem.eql(u8, listed, key)) return true;
}
return false;
}
/// Whether the patch carries a new password.
fn newPassword(patch: Patch) ?[]const u8 {
const web = patch.web orelse return null;
@@ -346,6 +372,10 @@ pub fn applyPut(
return .{ .fail = .{ .internal = err } };
};
// After the commit, never before: a validation failure or a write that
// rolled back changed nothing, so it owes nobody a restart.
if (touchesRestartRequiredKey(patch)) state.restart_pending.store(true, .monotonic);
if (replacement) |hash| {
// Ruling 17, both halves: the running server must verify against the
// new hash at once — a restart-free credential change — and the
@@ -470,36 +500,6 @@ pub const hash_stall_control = if (builtin.is_test) struct {
// routes
// ---------------------------------------------------------------------------
/// Which source governs this process's configuration, and when it last read
/// it (milestone-20 ruling 7). This is how the UI learns that configuration is
/// read-only — declaratively, rather than by probing a route for a 403.
///
/// It rides `GET /api/settings` because that route needs a session: the
/// managed path is a filesystem path and must never reach the open
/// `/api/version` or `/api/health`.
///
/// `reconciled_at` means exactly "this process loaded the file at T". A file
/// whose mtime is newer has not been loaded by the running process. It cannot
/// answer "is the file what the server uses" — a stepped clock or a preserved
/// mtime defeats the comparison in either direction, and the database can move
/// under `nxdns import` without either timestamp moving.
const AuthorityView = struct {
mode: []const u8,
path: ?[]const u8,
reconciled_at: ?i64,
};
fn authorityView(state: *const server.WebState) AuthorityView {
return switch (state.authority) {
.database => .{ .mode = "database", .path = null, .reconciled_at = state.reconciled_at },
.managed_file => |path| .{
.mode = "managed_file",
.path = path,
.reconciled_at = state.reconciled_at,
},
};
}
pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
const database = mutations.requireConfigDb(state) catch
return mutations.respondFailure(request, mutations.no_config_db, "reading the settings");
@@ -515,7 +515,7 @@ pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!
const cfg = loaded catch |err|
return mutations.respondFailure(request, .{ .internal = err }, "reading the settings");
return respondSettings(request, state, .ok, cfg);
return respondSettings(request, .ok, cfg);
}
pub fn put(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
@@ -524,20 +524,18 @@ pub fn put(state: *server.WebState, io: std.Io, request: *Request) HandlerError!
return switch (try applyPut(state, io, request.arena, parsed.value)) {
.fail => |failure| mutations.respondFailure(request, failure, "writing the settings"),
.config => |cfg| respondSettings(request, state, .ok, cfg),
.config => |cfg| respondSettings(request, .ok, cfg),
};
}
fn respondSettings(
request: *Request,
state: *const server.WebState,
status: std.http.Status,
cfg: model.Config,
) HandlerError!void {
/// `restart_required` is field-level metadata about this form — which keys need
/// a restart to take effect. Whether one is *owed* right now is process state
/// and lives on `GET /api/config/status`, which is also the one home of the
/// configuration authority: two copies of the same fact drift.
fn respondSettings(request: *Request, status: std.http.Status, cfg: model.Config) HandlerError!void {
return http_util.respondJson(request, status, .{
.settings = view(cfg),
.restart_required = restart_required_keys,
.authority = authorityView(state),
}, &.{});
}
@@ -769,6 +767,76 @@ test "an empty password is not a password change" {
try testing.expectEqualStrings("", outcome.config.web.password_hash orelse "");
}
test "a write that fails at the SQL layer owes no restart" {
var bench: mutations.Bench = undefined;
try bench.init(testing.allocator);
defer bench.deinit(testing.allocator);
try seeded(&bench);
var patch: Patch = .{};
patch.dns = .{ .port = 5353 };
// The seam fails `writeSettings` after its transaction has begun, which is
// the ordering this pins: the flag is stored only once the commit lands, so
// a rolled-back write leaves the operator nothing to restart for.
write_fault.armed = true;
const outcome = try applyPut(&bench.state, bench.io(), bench.arena(), patch);
try testing.expect(outcome.fail == .internal);
try testing.expect(!bench.state.restart_pending.load(.monotonic));
// The same patch, written for real, does raise it.
try testing.expect(try applyPut(&bench.state, bench.io(), bench.arena(), patch) == .config);
try testing.expect(bench.state.restart_pending.load(.monotonic));
}
test "a rejected patch raises no restart flag" {
var bench: mutations.Bench = undefined;
try bench.init(testing.allocator);
defer bench.deinit(testing.allocator);
try seeded(&bench);
var invalid: Patch = .{};
invalid.dns = .{ .port = 0 };
try testing.expect((try applyPut(&bench.state, bench.io(), bench.arena(), invalid)).fail == .invalid);
try testing.expect(!bench.state.restart_pending.load(.monotonic));
var unknown_enum: Patch = .{};
unknown_enum.logging = .{ .level = "verbose" };
try testing.expect((try applyPut(&bench.state, bench.io(), bench.arena(), unknown_enum)).fail == .invalid);
try testing.expect(!bench.state.restart_pending.load(.monotonic));
}
test "only a patch naming a restart-required key raises the flag" {
var password_only: Patch = .{};
password_only.web = .{ .password = "correct horse battery staple" };
try testing.expect(!touchesRestartRequiredKey(password_only));
var mixed: Patch = .{};
mixed.web = .{ .password = "correct horse battery staple", .port = 9090 };
try testing.expect(touchesRestartRequiredKey(mixed));
var elsewhere: Patch = .{};
elsewhere.dns = .{ .port = 5353 };
try testing.expect(touchesRestartRequiredKey(elsewhere));
try testing.expect(!touchesRestartRequiredKey(.{}));
}
test "a password-only put leaves the restart flag alone" {
var bench: mutations.Bench = undefined;
try bench.init(testing.allocator);
defer bench.deinit(testing.allocator);
try seeded(&bench);
var sessions: auth.Sessions = .init(24);
bench.state.sessions = &sessions;
var patch: Patch = .{};
patch.web = .{ .password = "correct horse battery staple" };
try testing.expect(try applyPut(&bench.state, bench.io(), bench.arena(), patch) == .config);
try testing.expect(!bench.state.restart_pending.load(.monotonic));
}
test "reading the settings with no database is unavailable" {
var state: server.WebState = .{ .gpa = testing.allocator };
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
+26
View File
@@ -52,6 +52,10 @@ pub fn applyCreate(
state.config_lock.unlock(io);
const id = inserted catch |err| return .{ .fail = mutations.dbFailure(err, url_conflict) };
// Ruling 12: the pool is built at startup, so the row now stored governs
// nothing until the next one. Stored after the insert, never before — a
// rejected url or a conflict owes no restart.
state.restart_pending.store(true, .monotonic);
return .{ .id = id };
}
@@ -83,6 +87,7 @@ pub fn applyUpdate(
upstreams_repo.updateUpstream(database, id, item) catch |err|
return mutations.dbFailure(err, url_conflict);
state.restart_pending.store(true, .monotonic);
return null;
}
@@ -106,6 +111,7 @@ pub fn applyDelete(state: *server.WebState, io: std.Io, arena: Allocator, id: i6
upstreams_repo.deleteUpstream(database, id) catch |err|
return mutations.dbFailure(err, url_conflict);
state.restart_pending.store(true, .monotonic);
return null;
}
@@ -245,6 +251,26 @@ test "a url the validator refuses never reaches the database" {
try testing.expectEqual(@as(i64, 0), try bench.queryInt("SELECT count(*) FROM upstreams"));
}
test "a refused upstream write owes no restart, and an accepted one does" {
var bench: mutations.Bench = undefined;
try bench.init(testing.allocator);
defer bench.deinit(testing.allocator);
try testing.expect((try applyCreate(&bench.state, bench.io(), bench.arena(), .{
.url = "udp://1.1.1.1:53",
})).fail == .invalid);
try testing.expect(!bench.state.restart_pending.load(.monotonic));
try testing.expect((try applyUpdate(&bench.state, bench.io(), bench.arena(), 999, doh)).? == .not_found);
try testing.expect(!bench.state.restart_pending.load(.monotonic));
try testing.expect(applyDelete(&bench.state, bench.io(), bench.arena(), 999).? == .not_found);
try testing.expect(!bench.state.restart_pending.load(.monotonic));
_ = try applyCreate(&bench.state, bench.io(), bench.arena(), doh);
try testing.expect(bench.state.restart_pending.load(.monotonic));
}
test "a duplicate url is a conflict" {
var bench: mutations.Bench = undefined;
try bench.init(testing.allocator);
+41 -14
View File
@@ -35,8 +35,8 @@ info:
envelope, naming the file. Operations that change runtime state —
`/api/pause`, `POST /api/blocklists/update`, `/api/certs/reload`, the
login and the logout — stay live, as does `DELETE /api/clients/{id}`
for a client the file does not declare. `GET /api/settings` reports
the live authority, so a client reads the mode rather than
for a client the file does not declare. `GET /api/config/status`
reports the live authority, so a client reads the mode rather than
discovering it from a rejection.
servers:
@@ -1753,6 +1753,26 @@ paths:
"503":
$ref: "#/components/responses/Unavailable"
/api/config/status:
get:
summary: Read the configuration authority and restart state
description: |
Which source governs this process's configuration, and whether a
change already written waits for a restart. Both are per-process
facts the database cannot answer, and this is their one home: a
client reads the mode here rather than discovering it from a 403.
responses:
"200":
description: The live authority and the pending-restart flag.
content:
application/json:
schema:
$ref: "#/components/schemas/ConfigStatus"
"401":
$ref: "#/components/responses/Unauthorized"
"429":
$ref: "#/components/responses/RateLimited"
/api/certs/reload:
post:
summary: Reload the TLS certificates from disk
@@ -2910,7 +2930,7 @@ components:
SettingsEnvelope:
type: object
required: [settings, restart_required, authority]
required: [settings, restart_required]
properties:
settings:
$ref: "#/components/schemas/Settings"
@@ -2919,21 +2939,21 @@ components:
items: { type: string }
description: |
Every `section.field` key that needs a restart to take effect —
currently all of them.
authority:
$ref: "#/components/schemas/Authority"
currently all of them. Whether a restart is *owed* right now is
process state, and lives on `/api/config/status`.
Authority:
ConfigStatus:
type: object
description: |
Which source governs this process's configuration. This is how a
client learns that configuration is read-only; it never has to probe
a write route for a 403. The block rides this authenticated endpoint
because `path` is a filesystem path, and never appears on the open
`/api/version` or `/api/health`.
required: [mode, path, reconciled_at]
Which source governs this process's configuration, and whether a
committed change waits for a restart. This is how a client learns
that configuration is read-only; it never has to probe a write route
for a 403. It rides an authenticated endpoint because `path` is a
filesystem path, and never appears on the open `/api/version` or
`/api/health`.
required: [authority, path, reconciled_at, restart_pending]
properties:
mode:
authority:
type: string
enum: [database, managed_file]
description: |
@@ -2955,6 +2975,13 @@ components:
a stepped clock or a preserved mtime defeats the comparison
either way, and `nxdns import` can move the database without
moving either timestamp.
restart_pending:
type: boolean
description: |
True once this process has committed a configuration change that
takes effect only at the next start — an upstream write or a
settings key. Nothing clears it but process exit, and it is
never persisted, so a false after a restart is the truth.
SettingsPatch:
type: object
+5 -1
View File
@@ -36,6 +36,7 @@ const auth = @import("handlers/auth.zig");
const blocklists = @import("handlers/blocklists.zig");
const certs = @import("handlers/certs.zig");
const clients = @import("handlers/clients.zig");
const config = @import("handlers/config.zig");
const diagnostics = @import("handlers/diagnostics.zig");
const groups = @import("handlers/groups.zig");
const health = @import("handlers/health.zig");
@@ -145,6 +146,9 @@ pub const table: []const router.RouteInfo = &.{
.{ .method = .GET, .pattern = "/api/settings", .auth = .session, .policy = .read, .handler = settings.get },
.{ .method = .PUT, .pattern = "/api/settings", .auth = .session, .policy = .config_write, .handler = settings.put },
// Configuration status: the authority and whether a restart is pending.
.{ .method = .GET, .pattern = "/api/config/status", .auth = .session, .policy = .read, .handler = config.get },
// Certificates (milestone-10 ruling 8).
.{ .method = .POST, .pattern = "/api/certs/reload", .auth = .session, .policy = .runtime_action, .handler = certs.post },
};
@@ -157,7 +161,7 @@ const std = @import("std");
const testing = std.testing;
test "the table carries every endpoint of the milestone" {
try testing.expectEqual(@as(usize, 63), table.len);
try testing.expectEqual(@as(usize, 64), table.len);
}
test "no two entries claim the same method and pattern" {
+6
View File
@@ -128,6 +128,12 @@ pub const WebState = struct {
/// loaded the file at T" and nothing more: a file whose mtime is newer has
/// not been loaded by the running process.
reconciled_at: ?i64 = null,
/// Whether a configuration change this process already committed waits for
/// a restart to take effect: the upstream pool and the scalar settings are
/// both built at startup. Per-process state, never persisted — nothing
/// clears it but process exit, which is exactly what applies the change.
/// Only database-mode mutations raise it; file mode never reaches them.
restart_pending: std.atomic.Value(bool) = .init(false),
handler: ?*dns_handler.Handler = null,
pause: ?*pause_mod.Pause = null,
+249 -24
View File
@@ -65,6 +65,7 @@ const upstreams_repo = @import("../storage/repositories/upstreams_repo.zig");
const handlers_blocklists = @import("handlers/blocklists.zig");
const handlers_certs = @import("handlers/certs.zig");
const handlers_config = @import("handlers/config.zig");
const handlers_diagnostics = @import("handlers/diagnostics.zig");
const handlers_health = @import("handlers/health.zig");
const handlers_live = @import("handlers/live.zig");
@@ -783,7 +784,6 @@ const SettingsView = struct {
blocklist_update: struct { enabled: bool, interval_hours: u16 },
},
restart_required: []const []const u8,
authority: struct { mode: []const u8, path: ?[]const u8, reconciled_at: ?i64 },
};
const Contract = struct {
@@ -909,6 +909,7 @@ const contract = [_]Contract{
// Certificates. The walk's environment wires no cert store, so both
// endpoints report disabled — and the reload still answers 200 (m10
// ruling 8: the outcome is the payload).
.{ .method = .GET, .pattern = "/api/config/status", .auth = .session, .policy = .read, .target = "/api/config/status", .status = 200, .check = jsonShape(handlers_config.View) },
.{ .method = .POST, .pattern = "/api/certs/reload", .auth = .session, .policy = .runtime_action, .target = "/api/certs/reload", .status = 200, .check = jsonShape(handlers_certs.View) },
// The walk's last delete returns the groups table to its seeded shape.
@@ -1486,33 +1487,249 @@ test "W10 milestone 20: an unauthenticated configuration write is 401, never 403
try bounded(env.io(), default_budget, fileModeUnauthenticated, .{ env.io(), env });
}
fn authorityEnvelope(io: std.Io, env: *Env) anyerror!void {
// ---------------------------------------------------------------------------
// `GET /api/config/status`: the authority and the pending restart (milestone 32)
// ---------------------------------------------------------------------------
/// One probe of the status route. `extra_header` carries a session cookie once
/// a test has turned authentication on.
fn configStatus(
gpa: Allocator,
conn: *Conn,
body_buf: []u8,
extra_header: ?[]const u8,
) !std.json.Parsed(handlers_config.View) {
try conn.request("GET", "/api/config/status", extra_header, null);
const response = try conn.receive(body_buf);
try testing.expectEqual(@as(u16, 200), response.status);
return std.json.parseFromSlice(handlers_config.View, gpa, response.body, .{});
}
fn restartPending(gpa: Allocator, conn: *Conn, body_buf: []u8, extra_header: ?[]const u8) !bool {
const parsed = try configStatus(gpa, conn, body_buf, extra_header);
defer parsed.deinit();
return parsed.value.restart_pending;
}
/// Logs in with `test_password` and renders the session as a `cookie:` header
/// line. Needed by the two password patches below: setting a password revokes
/// every session and turns authentication on, so the probe that follows one
/// has to carry a fresh cookie.
fn loginHeader(conn: *Conn, header_buf: []u8, body_buf: []u8) ![]const u8 {
try conn.request("POST", "/api/auth/login", null, "{\"password\":\"" ++ test_password ++ "\"}");
const response = try conn.receive(body_buf);
try testing.expectEqual(@as(u16, 200), response.status);
const set_cookie = response.header("set-cookie") orelse return error.TestNoCookie;
const pair_end = std.mem.findScalar(u8, set_cookie, ';') orelse set_cookie.len;
return std.fmt.bufPrint(header_buf, "cookie: {s}", .{set_cookie[0..pair_end]});
}
/// One mutation that must move the flag, with whatever the fresh database owes
/// it beforehand.
const Setter = struct {
method: []const u8,
target: []const u8,
body: ?[]const u8 = null,
status: u16,
/// Rows the scenario needs, inserted into the fresh config database before
/// the server is probed.
seed: [:0]const u8 = "",
};
fn setterRaisesFlag(io: std.Io, env: *Env, setter: Setter) anyerror!void {
var body_buf: [16 * 1024]u8 = undefined;
var conn: Conn = undefined;
try conn.connect(io, env.addr);
defer conn.close(io);
// The false→true transition is the claim, so the "before" reading is part
// of the proof: a probe taken while the flag is already true says nothing.
try testing.expect(!try restartPending(env.gpa, &conn, &body_buf, null));
try conn.request(setter.method, setter.target, null, setter.body);
const response = try conn.receive(&body_buf);
errdefer std.debug.print("{s} {s}: {d} {s}\n", .{ setter.method, setter.target, response.status, response.body });
try testing.expectEqual(setter.status, response.status);
try testing.expect(try restartPending(env.gpa, &conn, &body_buf, null));
}
test "W10 milestone 32: every restart-required mutation raises the flag, each from its own boot" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
// A server per setter. The flag is process state nothing clears, so proving
// a second setter raises it needs a process that has never raised it — and
// the fresh boot is also the proof that a restart clears it.
const setters = [_]Setter{
.{ .method = "PUT", .target = "/api/settings", .body = "{\"dns\":{\"port\":5353}}", .status = 200 },
.{ .method = "POST", .target = "/api/upstreams", .body = "{\"url\":\"https://dns2.example/dns-query\"}", .status = 201 },
.{ .method = "PUT", .target = "/api/upstreams/1", .body = "{\"url\":\"https://dns.example/dns-query\",\"priority\":5}", .status = 200 },
.{
.method = "DELETE",
.target = "/api/upstreams/2",
.status = 204,
// The seeded row 1 stays enabled, so removing this one is not the
// last-enabled-upstream conflict.
.seed =
\\INSERT INTO upstreams (id, url, priority, enabled, tls_name)
\\VALUES (2, 'https://dns2.example/dns-query', 100, 1, '')
,
},
};
for (setters) |setter| {
var env = try Env.create(gpa, .{});
defer env.destroy();
if (setter.seed.len != 0) try env.config_db.exec(setter.seed);
try bounded(env.io(), default_budget, setterRaisesFlag, .{ env.io(), env, setter });
}
}
fn passwordOnlyPatch(io: std.Io, env: *Env) anyerror!void {
var body_buf: [16 * 1024]u8 = undefined;
var conn: Conn = undefined;
try conn.connect(io, env.addr);
defer conn.close(io);
try testing.expect(!try restartPending(env.gpa, &conn, &body_buf, null));
try conn.request("PUT", "/api/settings", null, "{\"web\":{\"password\":\"" ++ test_password ++ "\"}}");
const response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 200), response.status);
// The patch revoked every session and turned authentication on, so the
// probe needs a cookie of its own.
var header_buf: [256]u8 = undefined;
const cookie = try loginHeader(&conn, &header_buf, &body_buf);
// A password applies live (ruling 17 of milestone 16). Nothing is owed.
try testing.expect(!try restartPending(env.gpa, &conn, &body_buf, cookie));
}
test "W10 milestone 32: a password-only patch owes no restart" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var env = try Env.create(gpa, .{});
defer env.destroy();
try bounded(env.io(), default_budget, passwordOnlyPatch, .{ env.io(), env });
}
fn mixedPatch(io: std.Io, env: *Env) anyerror!void {
var body_buf: [16 * 1024]u8 = undefined;
var conn: Conn = undefined;
try conn.connect(io, env.addr);
defer conn.close(io);
try testing.expect(!try restartPending(env.gpa, &conn, &body_buf, null));
try conn.request(
"PUT",
"/api/settings",
null,
"{\"web\":{\"password\":\"" ++ test_password ++ "\"},\"dns\":{\"port\":5353}}",
);
const response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 200), response.status);
var header_buf: [256]u8 = undefined;
const cookie = try loginHeader(&conn, &header_buf, &body_buf);
// The password half applies live; the `dns.port` half does not, and one
// restart-required key in the patch is enough.
try testing.expect(try restartPending(env.gpa, &conn, &body_buf, cookie));
}
test "W10 milestone 32: a patch carrying a password and a restart-required key raises the flag" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var env = try Env.create(gpa, .{});
defer env.destroy();
try bounded(env.io(), default_budget, mixedPatch, .{ env.io(), env });
}
fn rejectedMutations(io: std.Io, env: *Env) anyerror!void {
var body_buf: [16 * 1024]u8 = undefined;
var conn: Conn = undefined;
try conn.connect(io, env.addr);
defer conn.close(io);
// Nothing was committed, so nothing is owed. The SQL-layer half of this —
// a write that begins its transaction and then fails — is pinned in
// handlers/settings.zig, where the write-fault seam lives.
const rejected = [_]Setter{
.{ .method = "PUT", .target = "/api/settings", .body = "{\"logging\":{\"level\":\"chatty\"}}", .status = 400 },
.{ .method = "POST", .target = "/api/upstreams", .body = "{\"url\":\"udp://1.1.1.1:53\"}", .status = 400 },
.{ .method = "POST", .target = "/api/upstreams", .body = "{\"url\":\"https://dns.example/dns-query\"}", .status = 409 },
.{ .method = "DELETE", .target = "/api/upstreams/1", .status = 409 },
.{ .method = "DELETE", .target = "/api/upstreams/999", .status = 404 },
};
for (rejected) |attempt| {
try conn.request(attempt.method, attempt.target, null, attempt.body);
const response = try conn.receive(&body_buf);
errdefer std.debug.print("{s} {s}: {d} {s}\n", .{ attempt.method, attempt.target, response.status, response.body });
try testing.expectEqual(attempt.status, response.status);
try testing.expect(!try restartPending(env.gpa, &conn, &body_buf, null));
}
}
test "W10 milestone 32: a refused mutation leaves the flag alone" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var env = try Env.create(gpa, .{});
defer env.destroy();
try bounded(env.io(), default_budget, rejectedMutations, .{ env.io(), env });
}
fn fileModeStatus(io: std.Io, env: *Env) anyerror!void {
var body_buf: [16384]u8 = undefined;
var conn: Conn = undefined;
try conn.connect(io, env.addr);
defer conn.close(io);
try conn.request("GET", "/api/settings", null, null);
var response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 200), response.status);
{
const parsed = try configStatus(env.gpa, &conn, &body_buf, null);
defer parsed.deinit();
try testing.expectEqualStrings("managed_file", parsed.value.authority);
try testing.expectEqualStrings(managed_path, parsed.value.path.?);
try testing.expectEqual(@as(?i64, 1_700_000_042), parsed.value.reconciled_at);
try testing.expect(!parsed.value.restart_pending);
}
const parsed = try std.json.parseFromSlice(SettingsView, env.gpa, response.body, .{});
defer parsed.deinit();
try testing.expectEqualStrings("managed_file", parsed.value.authority.mode);
try testing.expectEqualStrings(managed_path, parsed.value.authority.path.?);
try testing.expectEqual(@as(?i64, 1_700_000_042), parsed.value.authority.reconciled_at);
// The write the flag would follow never reaches its handler, so the flag
// cannot rise in file mode at all.
try conn.request("PUT", "/api/settings", null, "{\"dns\":{\"port\":5353}}");
const refused = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 403), refused.status);
try testing.expect(!try restartPending(env.gpa, &conn, &body_buf, null));
// The path is a filesystem path and must not reach the open routes.
for ([_][]const u8{ "/api/version", "/api/health" }) |target| {
try conn.request("GET", target, null, null);
response = try conn.receive(&body_buf);
const response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 200), response.status);
try testing.expect(!std.mem.containsAtLeast(u8, response.body, 1, managed_path));
try testing.expect(!std.mem.containsAtLeast(u8, response.body, 1, "authority"));
}
// And the settings envelope no longer carries a second copy of it.
try conn.request("GET", "/api/settings", null, null);
const settings_response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 200), settings_response.status);
try testing.expect(!std.mem.containsAtLeast(u8, settings_response.body, 1, "authority"));
const parsed = try std.json.parseFromSlice(SettingsView, env.gpa, settings_response.body, .{});
defer parsed.deinit();
}
test "W10 milestone 20: the settings envelope reports the authority and the open routes do not" {
test "W10 milestone 32: file mode reports the file, owes no restart, and keeps the path off the open routes" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
@@ -1522,39 +1739,39 @@ test "W10 milestone 20: the settings envelope reports the authority and the open
});
defer env.destroy();
try bounded(env.io(), default_budget, authorityEnvelope, .{ env.io(), env });
try bounded(env.io(), default_budget, fileModeStatus, .{ env.io(), env });
}
fn databaseAuthorityEnvelope(io: std.Io, env: *Env) anyerror!void {
fn databaseModeStatus(io: std.Io, env: *Env) anyerror!void {
var body_buf: [16384]u8 = undefined;
var conn: Conn = undefined;
try conn.connect(io, env.addr);
defer conn.close(io);
try conn.request("GET", "/api/settings", null, null);
const response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 200), response.status);
const parsed = try std.json.parseFromSlice(SettingsView, env.gpa, response.body, .{});
const parsed = try configStatus(env.gpa, &conn, &body_buf, null);
defer parsed.deinit();
try testing.expectEqualStrings("database", parsed.value.authority.mode);
try testing.expectEqual(@as(?[]const u8, null), parsed.value.authority.path);
try testing.expectEqual(@as(?i64, null), parsed.value.authority.reconciled_at);
try testing.expectEqualStrings("database", parsed.value.authority);
try testing.expectEqual(@as(?[]const u8, null), parsed.value.path);
try testing.expectEqual(@as(?i64, null), parsed.value.reconciled_at);
try testing.expect(!parsed.value.restart_pending);
// And nothing is rejected.
try conn.request("POST", "/api/groups", null, "{\"name\":\"kids\"}");
const created = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 201), created.status);
// A live-resource write is not a restart: groups take effect at once.
try testing.expect(!try restartPending(env.gpa, &conn, &body_buf, null));
}
test "W10 milestone 20: database authority reports null and writes normally" {
test "W10 milestone 32: a fresh database-mode boot reports database, nulls and no pending restart" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var env = try Env.create(gpa, .{});
defer env.destroy();
try bounded(env.io(), default_budget, databaseAuthorityEnvelope, .{ env.io(), env });
try bounded(env.io(), default_budget, databaseModeStatus, .{ env.io(), env });
}
// ---------------------------------------------------------------------------
@@ -3564,6 +3781,14 @@ const contract_sample_walk = [_]ContractSample{
.{ .name = "get_settings", .ts_type = "SettingsEnvelope", .method = "GET", .target = "/api/settings", .status = 200 },
.{ .name = "put_settings", .ts_type = "SettingsEnvelope", .method = "PUT", .target = "/api/settings", .body = "{\"dns\":{\"port\":5353}}", .status = 200 },
// Config status after the settings PUT above, so the sample witnesses a
// raised `restart_pending` rather than only the boot value.
.{ .name = "get_config_status", .ts_type = "ConfigStatus", .method = "GET", .target = "/api/config/status", .status = 200 },
// Certificate reload. Neither TLS endpoint is wired in this environment, so
// the sample carries the disabled outcome and nothing is read from disk.
.{ .name = "reload_certs", .ts_type = "CertsReload", .method = "POST", .target = "/api/certs/reload", .status = 200 },
// One sample per shared error class this environment can produce. 401 and
// 429 need their own environments and follow below.
.{ .name = "error_bad_request", .ts_type = "ErrorEnvelope", .method = "PUT", .target = "/api/settings", .body = "{\"logging\":{\"level\":\"chatty\"}}", .status = 400 },