db-mode config changes apply live in-process
settings and upstream writes now follow a prepare, commit, publish, retire contract: candidates are built and validated before the database transaction, published as infallible pointer swaps, and old generations retire after their readers drain. per-query policy values snapshot once per query; upstream pool, cache, rate limiter, sessions, api limiter, log sink, blocklist scheduler and the query-log queue each gained one named live operation. restart_required shrinks from every scalar key to the bind keys and web.enabled; the admin ui drops its restart notices for everything else. file mode is unchanged.
This commit is contained in:
+90
-104
@@ -1,11 +1,14 @@
|
||||
//! `GET`/`PUT /api/settings` — the scalar configuration, the rows of the
|
||||
//! `settings` table (ruling 16).
|
||||
//!
|
||||
//! Everything here is restart-required this milestone, and the response says so
|
||||
//! for every key: what changes live is the resource endpoints and the pause,
|
||||
//! not a setting. The list is generated from `model.Config` itself, so a
|
||||
//! section added to the model appears here without anyone remembering to add
|
||||
//! it.
|
||||
//! Every key applies live except the two that create or destroy a socket —
|
||||
//! `bind` (a listener's address, port or existence) and `web_lifecycle`
|
||||
//! (`web.enabled`) — which set `restart_pending` for milestone 35 to execute.
|
||||
//! `restart_required` in the response is that pair of key groups and nothing
|
||||
//! else. `apply.zig` holds the table that decides which owner a key belongs to
|
||||
//! and drives the prepare → commit → publish → retire the write contract asks
|
||||
//! for; a section added to the model fails to compile until its keys have
|
||||
//! owners there.
|
||||
//!
|
||||
//! `web.password` is write-only and `web.password_hash` is neither readable nor
|
||||
//! directly writable. A PUT carrying `web.password` hashes it with the import
|
||||
@@ -23,6 +26,7 @@ const builtin = @import("builtin");
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const apply = @import("apply.zig");
|
||||
const auth = @import("../auth.zig");
|
||||
const db = @import("../../storage/db.zig");
|
||||
const http_util = @import("../http_util.zig");
|
||||
@@ -42,58 +46,13 @@ const log = std.log.scoped(.web_api);
|
||||
/// always fits the copy `applyLogin` takes.
|
||||
const hash_buf_len = auth.LiveHash.max_len;
|
||||
|
||||
/// Fields a client may neither read nor write directly. `password_hash` is
|
||||
/// derived from `password`; exposing it would let a client install a hash
|
||||
/// nxdns never computed.
|
||||
fn isHidden(comptime section: []const u8, comptime field: []const u8) bool {
|
||||
return std.mem.eql(u8, section, "web") and std.mem.eql(u8, field, "password_hash");
|
||||
}
|
||||
const isHidden = apply.isHidden;
|
||||
const isWriteOnly = apply.isWriteOnly;
|
||||
const isScalarSection = apply.isScalarSection;
|
||||
|
||||
/// `web.password` is accepted on a PUT and never returned.
|
||||
fn isWriteOnly(comptime section: []const u8, comptime field: []const u8) bool {
|
||||
return std.mem.eql(u8, section, "web") and std.mem.eql(u8, field, "password");
|
||||
}
|
||||
|
||||
fn isScalarSection(comptime T: type) bool {
|
||||
return @typeInfo(T) == .@"struct";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// the restart-required table (ruling 16)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Every settings key, in `model.Config` declaration order. Ruling 16: all of
|
||||
/// them are restart-required this milestone, so the table is the key list and
|
||||
/// the flag is implied by membership.
|
||||
pub const restart_required_keys: []const []const u8 = &keys;
|
||||
|
||||
const keys = blk: {
|
||||
var list: [countKeys()][]const u8 = undefined;
|
||||
var index = 0;
|
||||
for (@typeInfo(model.Config).@"struct".fields) |section_field| {
|
||||
if (!isScalarSection(section_field.type)) continue;
|
||||
for (@typeInfo(section_field.type).@"struct".fields) |field| {
|
||||
if (isHidden(section_field.name, field.name)) continue;
|
||||
if (isWriteOnly(section_field.name, field.name)) continue;
|
||||
list[index] = section_field.name ++ "." ++ field.name;
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
break :blk list;
|
||||
};
|
||||
|
||||
fn countKeys() usize {
|
||||
var count = 0;
|
||||
for (@typeInfo(model.Config).@"struct".fields) |section_field| {
|
||||
if (!isScalarSection(section_field.type)) continue;
|
||||
for (@typeInfo(section_field.type).@"struct".fields) |field| {
|
||||
if (isHidden(section_field.name, field.name)) continue;
|
||||
if (isWriteOnly(section_field.name, field.name)) continue;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
/// The keys whose change waits for a restart: `bind` and `web_lifecycle`, the
|
||||
/// two operations milestone 35 executes. The apply table is the authority.
|
||||
pub const restart_required_keys = apply.restart_required_keys;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// the patch a PUT carries
|
||||
@@ -184,32 +143,6 @@ 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;
|
||||
@@ -339,10 +272,11 @@ fn applyPut(
|
||||
state.config_lock.lockUncancelable(io);
|
||||
defer state.config_lock.unlock(io);
|
||||
|
||||
var cfg = mutations.loadConfig(arena, database) catch |err| switch (err) {
|
||||
const stored = mutations.loadConfig(arena, database) catch |err| switch (err) {
|
||||
error.OutOfMemory => return error.OutOfMemory,
|
||||
else => return .{ .fail = .{ .internal = err } },
|
||||
};
|
||||
var cfg = stored;
|
||||
|
||||
var bad_key: []const u8 = "";
|
||||
if (!merge(&cfg, patch, &bad_key)) {
|
||||
@@ -366,15 +300,38 @@ fn applyPut(
|
||||
const merged_hash = cfg.web.password_hash orelse "";
|
||||
const hash_changed = password != null and !std.mem.eql(u8, previous_hash, merged_hash);
|
||||
const replacement: ?[]u8 = if (hash_changed) try state.gpa.dupe(u8, merged_hash) else null;
|
||||
errdefer if (replacement) |hash| state.gpa.free(hash);
|
||||
|
||||
// Prepare: one candidate per owner whose value actually moved, built from
|
||||
// the merged configuration. Nothing is published and no row is written
|
||||
// until every one of them succeeded.
|
||||
const ops = apply.changedOperations(stored, cfg);
|
||||
var plan: apply.Plan = .init(state, arena, cfg, ops);
|
||||
if (try plan.prepare(io)) |failure| {
|
||||
plan.abandon(io);
|
||||
if (replacement) |hash| state.gpa.free(hash);
|
||||
return .{ .fail = failure };
|
||||
}
|
||||
|
||||
writeSettings(arena, database, cfg) catch |err| {
|
||||
plan.abandon(io);
|
||||
if (replacement) |hash| state.gpa.free(hash);
|
||||
return .{ .fail = .{ .internal = err } };
|
||||
};
|
||||
|
||||
plan.publish(io);
|
||||
plan.retire(io);
|
||||
|
||||
// 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);
|
||||
// rolled back changed nothing, so it owes nobody a restart. Only the two
|
||||
// operations milestone 35 executes raise it; everything else is already
|
||||
// live by the time this line runs.
|
||||
var restart_owed = false;
|
||||
var it = ops.iterator();
|
||||
while (it.next()) |operation| {
|
||||
if (operation.needsRestart()) restart_owed = true;
|
||||
}
|
||||
if (restart_owed) state.restart_pending.store(true, .monotonic);
|
||||
|
||||
if (replacement) |hash| {
|
||||
// Ruling 17, both halves: the running server must verify against the
|
||||
@@ -546,11 +503,10 @@ fn respondSettings(request: *Request, status: std.http.Status, cfg: model.Config
|
||||
const testing = std.testing;
|
||||
const auth_handlers = @import("auth.zig");
|
||||
|
||||
test "the restart-required table lists every settings key and no secret" {
|
||||
// `model.toSettings` is the other half of the same fact. The two lists are
|
||||
// now equal rather than off by one: `toSettings` stopped emitting
|
||||
// `web.password_hash` (milestone 20 ruling 4) and this table never listed
|
||||
// it, so both exclude the hash and the plaintext.
|
||||
test "the restart-required list is a strict subset of the settings keys" {
|
||||
// `model.toSettings` is the key list this form writes. The restart list is
|
||||
// now the small part of it that milestone 35 owns, so it must be shorter
|
||||
// than the key list and every entry must still be a real key.
|
||||
var pairs: std.ArrayList(model.SettingPair) = .empty;
|
||||
defer {
|
||||
model.freeSettings(testing.allocator, pairs.items);
|
||||
@@ -558,17 +514,24 @@ test "the restart-required table lists every settings key and no secret" {
|
||||
}
|
||||
try model.toSettings(.{}, testing.allocator, &pairs);
|
||||
|
||||
try testing.expectEqual(pairs.items.len, restart_required_keys.len);
|
||||
try testing.expect(restart_required_keys.len < pairs.items.len);
|
||||
for (restart_required_keys) |key| {
|
||||
try testing.expect(!std.mem.eql(u8, key, "web.password_hash"));
|
||||
try testing.expect(!std.mem.eql(u8, key, "web.password"));
|
||||
var is_a_setting = false;
|
||||
for (pairs.items) |pair| {
|
||||
if (std.mem.eql(u8, pair.key, key)) is_a_setting = true;
|
||||
}
|
||||
try testing.expect(is_a_setting);
|
||||
}
|
||||
|
||||
var found_port = false;
|
||||
var found_ttl = false;
|
||||
for (restart_required_keys) |key| {
|
||||
if (std.mem.eql(u8, key, "dns.port")) found_port = true;
|
||||
if (std.mem.eql(u8, key, "blocking.ttl")) found_ttl = true;
|
||||
}
|
||||
try testing.expect(found_port);
|
||||
// A live key must not appear: the UI renders no restart affordance for it.
|
||||
try testing.expect(!found_ttl);
|
||||
}
|
||||
|
||||
test "the read shape spells every enum the way the database does" {
|
||||
@@ -806,20 +769,43 @@ test "a rejected patch raises no restart flag" {
|
||||
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));
|
||||
test "only a bind or web-lifecycle change raises the flag" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
try seeded(&bench);
|
||||
|
||||
var mixed: Patch = .{};
|
||||
mixed.web = .{ .password = "correct horse battery staple", .port = 9090 };
|
||||
try testing.expect(touchesRestartRequiredKey(mixed));
|
||||
// A live key, however many of them: nothing to restart for.
|
||||
var live: Patch = .{};
|
||||
live.blocking = .{ .ttl = 30 };
|
||||
live.disk = .{ .min_free_mb = 10, .warn_free_mb = 20 };
|
||||
try testing.expect(try applyPut(&bench.state, bench.io(), bench.arena(), live) == .config);
|
||||
try testing.expect(!bench.state.restart_pending.load(.monotonic));
|
||||
|
||||
var elsewhere: Patch = .{};
|
||||
elsewhere.dns = .{ .port = 5353 };
|
||||
try testing.expect(touchesRestartRequiredKey(elsewhere));
|
||||
// `web.enabled` is the second of the two milestone-35 operations, and it
|
||||
// executes nothing: the row moves and the flag rises.
|
||||
var lifecycle: Patch = .{};
|
||||
lifecycle.web = .{ .enabled = false };
|
||||
try testing.expect(try applyPut(&bench.state, bench.io(), bench.arena(), lifecycle) == .config);
|
||||
try testing.expect(bench.state.restart_pending.load(.monotonic));
|
||||
try testing.expectEqual(
|
||||
@as(i64, 1),
|
||||
try bench.queryInt("SELECT count(*) FROM settings WHERE key = 'web.enabled' AND value = 'false'"),
|
||||
);
|
||||
}
|
||||
|
||||
try testing.expect(!touchesRestartRequiredKey(.{}));
|
||||
test "a patch that changes nothing applies nothing" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
try seeded(&bench);
|
||||
|
||||
// The stored port is 53, and the admin form submits every field it read.
|
||||
// Rewriting a value to itself is not a change and must not raise the flag.
|
||||
var same: Patch = .{};
|
||||
same.dns = .{ .port = 53 };
|
||||
try testing.expect(try applyPut(&bench.state, bench.io(), bench.arena(), same) == .config);
|
||||
try testing.expect(!bench.state.restart_pending.load(.monotonic));
|
||||
}
|
||||
|
||||
test "a password-only put leaves the restart flag alone" {
|
||||
|
||||
Reference in New Issue
Block a user