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
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:
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user