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:
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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" {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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