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
+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 },