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:
2026-08-24 00:04:28 +02:00
parent 17e6e93ce1
commit d961b152a3
47 changed files with 7698 additions and 926 deletions
+867
View File
@@ -0,0 +1,867 @@
//! The apply table and the prepare → commit → publish → retire machinery a
//! configuration write drives.
//!
//! Every settings key names one CONCRETE operation — the owner that has to be
//! told, not a class of change. `bind` and `web_lifecycle` are the two that
//! cannot be executed in-process: they create or destroy a socket, so they set
//! `restart_pending` and milestone 35 executes them. Everything else applies
//! live, and no response or document claims otherwise.
//!
//! The four phases are the write contract, and `Plan` is what carries state
//! between them:
//!
//! 1. `prepare` builds and validates ONE candidate per affected owner from the
//! FINAL MERGED configuration. It is the only fallible phase. A failure
//! leaves the database and the running server untouched, and the caller
//! calls `abandon`.
//! 2. The caller commits its transaction. A commit failure is also `abandon`.
//! 3. `publish` swaps handles and pointers and stores atomics. Infallible and
//! I/O-free: nothing here opens, closes, joins or frees.
//! 4. `retire` closes and frees what publish displaced, and reconciles the
//! upstream diagnostics — event rows are SQLite I/O and are banned from a
//! publish.
//!
//! An operation whose owner this `WebState` does not have is skipped: the web
//! layer must build without a whole running server, and a handler test wires
//! only what it asserts on. The database row is still written, because the row
//! is the configuration and the runtime is a consumer of it.
const std = @import("std");
const Allocator = std.mem.Allocator;
const cert_store = @import("../../server/cert_store.zig");
const dns_cache = @import("../../cache/dns_cache.zig");
const dns_handler = @import("../../server/handler.zig");
const disk_monitor = @import("../../storage/disk_monitor.zig");
const logger_controller = @import("../../storage/logger_controller.zig");
const logging = @import("../../platform/logging.zig");
const model = @import("../../config/model.zig");
const mutations = @import("mutations.zig");
const rate_limiter = @import("../../server/rate_limiter.zig");
const server = @import("../server.zig");
const upstream_owner = @import("../../upstream/owner.zig");
const upstreams_repo = @import("../../storage/repositories/upstreams_repo.zig");
const Failure = mutations.Failure;
const log = std.log.scoped(.web_api);
// ---------------------------------------------------------------------------
// the key table
// ---------------------------------------------------------------------------
/// The owner a settings key belongs to. One entry per owner, never one per key:
/// two keys of the same owner produce ONE candidate.
pub const Operation = enum {
dns_policy,
trusted_proxies,
logger_privacy,
logger_flush,
disk_thresholds,
upstream_generation,
cache,
rate_limiter,
sessions_ttl,
api_limiter,
retention,
certs_doh,
certs_dot,
log_sink,
scheduler,
logger_queue,
/// A listener's address, port, or existence. Milestone 35 executes these.
bind,
/// `web.enabled`. Milestone 35 executes it.
web_lifecycle,
/// The broad class, DERIVED rather than listed a second time: a second
/// table would be a second authority, and the two would drift.
pub fn class(self: Operation) Class {
return switch (self) {
.bind => .bind,
.web_lifecycle => .web_lifecycle,
// Everything a candidate has to be built for before it can be
// published: memory, a file, a connection, a task.
.upstream_generation,
.cache,
.rate_limiter,
.certs_doh,
.certs_dot,
.log_sink,
.logger_queue,
.trusted_proxies,
=> .subsystem,
.dns_policy,
.logger_privacy,
.logger_flush,
.disk_thresholds,
.sessions_ttl,
.api_limiter,
.retention,
.scheduler,
=> .live,
};
}
/// Whether a change to this owner waits for a restart. The two that do are
/// the two milestone 35 owns.
pub fn needsRestart(self: Operation) bool {
return switch (self.class()) {
.bind, .web_lifecycle => true,
.live, .subsystem => false,
};
}
};
pub const Class = enum { live, subsystem, bind, web_lifecycle };
pub const Set = std.EnumSet(Operation);
pub const Entry = struct { key: []const u8, operation: Operation };
/// Every settings key, in `model.Config` declaration order, with the owner it
/// belongs to. `web.password` is write-only and `web.password_hash` is hidden;
/// neither is a settings key, and the completeness test below spells out the
/// same two exclusions the read and write shapes use.
pub const table = [_]Entry{
.{ .key = "upstream.attempt_timeout_ms", .operation = .upstream_generation },
// The forward-zone client's read deadline, which the per-query policy
// carries — not one of the pool's two timeouts.
.{ .key = "upstream.read_timeout_ms", .operation = .dns_policy },
.{ .key = "upstream.total_timeout_ms", .operation = .upstream_generation },
.{ .key = "dns.bind_ipv4", .operation = .bind },
.{ .key = "dns.bind_ipv6", .operation = .bind },
.{ .key = "dns.port", .operation = .bind },
.{ .key = "dns.rate_limit", .operation = .rate_limiter },
.{ .key = "dns.rate_window_seconds", .operation = .rate_limiter },
.{ .key = "blocking.response", .operation = .dns_policy },
.{ .key = "blocking.ttl", .operation = .dns_policy },
.{ .key = "cache.size", .operation = .cache },
// The cache keeps no copy of its configuration: `classify` reads the
// negative ceiling off the per-query policy.
.{ .key = "cache.negative_ttl_max", .operation = .dns_policy },
.{ .key = "web.enabled", .operation = .web_lifecycle },
.{ .key = "web.bind", .operation = .bind },
.{ .key = "web.port", .operation = .bind },
.{ .key = "web.session_ttl_hours", .operation = .sessions_ttl },
.{ .key = "web.api_rate_limit_per_min", .operation = .api_limiter },
.{ .key = "web.api_localhost_exempt", .operation = .api_limiter },
.{ .key = "web.sse_max_connections_per_ip", .operation = .api_limiter },
.{ .key = "web.trusted_proxies", .operation = .trusted_proxies },
.{ .key = "doh_server.enabled", .operation = .bind },
.{ .key = "doh_server.bind", .operation = .bind },
.{ .key = "doh_server.port", .operation = .bind },
.{ .key = "doh_server.cert_path", .operation = .certs_doh },
.{ .key = "doh_server.key_path", .operation = .certs_doh },
.{ .key = "dot_server.enabled", .operation = .bind },
.{ .key = "dot_server.bind", .operation = .bind },
.{ .key = "dot_server.port", .operation = .bind },
.{ .key = "dot_server.cert_path", .operation = .certs_dot },
.{ .key = "dot_server.key_path", .operation = .certs_dot },
.{ .key = "edns.ecs_mode", .operation = .dns_policy },
.{ .key = "logging.level", .operation = .log_sink },
.{ .key = "logging.retention_days", .operation = .retention },
.{ .key = "logging.query_log_buffer_max", .operation = .logger_queue },
.{ .key = "logging.query_log_flush_interval_s", .operation = .logger_flush },
.{ .key = "logging.hide_domains", .operation = .logger_privacy },
.{ .key = "logging.hide_client_ips", .operation = .logger_privacy },
.{ .key = "logging.output", .operation = .log_sink },
.{ .key = "logging.file_path", .operation = .log_sink },
.{ .key = "logging.max_size_mb", .operation = .log_sink },
.{ .key = "logging.max_files", .operation = .log_sink },
.{ .key = "disk.min_free_mb", .operation = .disk_thresholds },
.{ .key = "disk.warn_free_mb", .operation = .disk_thresholds },
.{ .key = "blocklist_update.enabled", .operation = .scheduler },
.{ .key = "blocklist_update.interval_hours", .operation = .scheduler },
};
/// 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.
pub 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");
}
/// `web.password` is accepted on a PUT and never returned. It is not a settings
/// key: the hash it produces is, and its apply is the live-hash install.
pub 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");
}
pub fn isScalarSection(comptime T: type) bool {
return @typeInfo(T) == .@"struct";
}
fn find(comptime key: []const u8) ?Operation {
@setEvalBranchQuota(20_000);
for (table) |entry| {
if (std.mem.eql(u8, entry.key, key)) return entry.operation;
}
return null;
}
/// The keys whose change waits for a restart, which is exactly the two
/// milestone-35 operations. `/api/settings` reports this list as field-level
/// metadata about the form.
pub const restart_required_keys: []const []const u8 = &restart_keys;
const restart_keys = blk: {
var list: [table.len][]const u8 = undefined;
var count = 0;
for (table) |entry| {
if (!entry.operation.needsRestart()) continue;
list[count] = entry.key;
count += 1;
}
const final = list[0..count].*;
break :blk final;
};
/// Whether a scalar settings value differs between two configurations. Strings
/// compare by bytes; everything else a settings row can hold compares by value.
fn differs(comptime T: type, a: T, b: T) bool {
if (T == []const u8) return !std.mem.eql(u8, a, b);
return a != b;
}
/// The owners a change from `before` to `after` has to tell.
///
/// Derived from the values, not from the keys the patch named: a PUT that
/// rewrites a setting to what it already was changes nothing, so it must not
/// resize a queue, rebuild a pool, or owe a restart. The admin form submits
/// every field, and treating that as eighteen applies would be wrong as well as
/// wasteful.
pub fn changedOperations(before: model.Config, after: model.Config) Set {
var ops: Set = .initEmpty();
inline for (@typeInfo(model.Config).@"struct".fields) |section_field| {
if (comptime isScalarSection(section_field.type)) {
inline for (@typeInfo(section_field.type).@"struct".fields) |field| {
comptime if (isHidden(section_field.name, field.name)) continue;
comptime if (isWriteOnly(section_field.name, field.name)) continue;
const operation = comptime find(section_field.name ++ "." ++ field.name).?;
const old = @field(@field(before, section_field.name), field.name);
const new = @field(@field(after, section_field.name), field.name);
if (differs(@TypeOf(old), old, new)) ops.insert(operation);
}
}
}
return ops;
}
// ---------------------------------------------------------------------------
// the plan
// ---------------------------------------------------------------------------
/// Everything one configuration write prepared, published and still owes a
/// retire. Exactly one of `abandon` or `publish` consumes a prepared plan, and
/// a published one is always followed by `retire`.
pub const Plan = struct {
state: *server.WebState,
/// The per-request arena. Holds the copied upstream report keys, which
/// nothing outside this request reads.
arena: Allocator,
/// The final merged configuration every candidate is built from.
cfg: model.Config,
ops: Set,
// prepared candidates ---------------------------------------------------
proxies: ?[]u8 = null,
cache: ?*dns_cache.DnsCache = null,
limiter: ?*rate_limiter.RateLimiter = null,
upstream: ?*upstream_owner.Generation = null,
upstream_previous_keys: []const []const u8 = &.{},
doh_paths: ?cert_store.CertStore.PreparedPaths = null,
dot_paths: ?cert_store.CertStore.PreparedPaths = null,
sink: ?logging.PreparedApply = null,
log_dir: ?*disk_monitor.LogDir = null,
queue: ?logger_controller.Prepared = null,
/// The reading `setLimits` refills its buckets against. Taken at prepare
/// because publish reads no clock: a clock is I/O.
limiter_now: ?std.Io.Timestamp = null,
// what publish displaced, for retire ------------------------------------
retired_proxies: ?[]const u8 = null,
retired_cache: ?*dns_cache.DnsCache = null,
retired_limiter: ?*rate_limiter.RateLimiter = null,
retired_upstream: ?*upstream_owner.Generation = null,
detached_log_file: ?std.Io.File = null,
published_upstream: bool = false,
pub fn init(
state: *server.WebState,
arena: Allocator,
cfg: model.Config,
ops: Set,
) Plan {
return .{ .state = state, .arena = arena, .cfg = cfg, .ops = ops };
}
/// Builds every candidate the change needs. Returns the failure that
/// refused it, in which case the caller must still call `abandon`: an
/// earlier owner's candidate may already exist.
///
/// A propagated error abandons here instead, because the caller has no
/// plan left to abandon: an earlier owner may already hold memory, a file,
/// a connection, a parked writer or a lock — the cert store keeps
/// `reload_mutex` held between its prepare and its publish.
pub fn prepare(self: *Plan, io: std.Io) error{OutOfMemory}!?Failure {
errdefer self.abandon(io);
const state = self.state;
const gpa = state.gpa;
if (self.ops.contains(.trusted_proxies)) {
self.proxies = try gpa.dupe(u8, self.cfg.web.trusted_proxies);
}
if (self.ops.contains(.cache)) {
const candidate = try gpa.create(dns_cache.DnsCache);
candidate.* = dns_cache.DnsCache.init(gpa, self.cfg.cache) catch |err| {
gpa.destroy(candidate);
return err;
};
self.cache = candidate;
}
if (self.ops.contains(.rate_limiter)) {
const candidate = try gpa.create(rate_limiter.RateLimiter);
candidate.* = rate_limiter.RateLimiter.init(gpa, .{
.limit = self.cfg.dns.rate_limit,
.window_seconds = self.cfg.dns.rate_window_seconds,
}) catch |err| {
gpa.destroy(candidate);
return err;
};
self.limiter = candidate;
}
if (self.ops.contains(.upstream_generation)) {
if (try self.prepareUpstreams(io, self.cfg.upstreams)) |failure| return failure;
}
if (self.ops.contains(.certs_doh)) {
// A disabled endpoint has no store: the change is a database row
// and nothing else, and milestone 35 validates the paths when it
// implements enable.
if (state.doh_certs) |store| {
self.doh_paths = store.preparePathChange(
io,
self.cfg.doh_server.cert_path,
self.cfg.doh_server.key_path,
) catch |err| return certFailure("doh_server", err);
}
}
if (self.ops.contains(.certs_dot)) {
if (state.dot_certs) |store| {
self.dot_paths = store.preparePathChange(
io,
self.cfg.dot_server.cert_path,
self.cfg.dot_server.key_path,
) catch |err| return certFailure("dot_server", err);
}
}
if (self.ops.contains(.log_sink)) {
self.sink = logging.prepareApply(io, self.cfg.logging) catch |err| return switch (err) {
error.PathTooLong => Failure{ .invalid = "logging.file_path: too long for this system" },
error.TargetUnopenable => Failure{
.invalid = "logging.file_path: this file cannot be opened for writing",
},
};
// Derived from the final merged output AND file_path on every sink
// apply, in both directions: output `file` measures the file's
// directory, anything else measures nothing.
self.log_dir = try disk_monitor.Monitor.prepareLogDir(gpa, logging.logDirname(self.cfg.logging));
}
if (self.ops.contains(.logger_queue)) {
if (state.logger) |controller| {
// The merged configuration, not the live one: a PUT that
// changes the buffer size and a privacy flag together must not
// hand the replacement generation the privacy it replaced.
if (controller.prepare(io, self.cfg.logging)) |prepared| {
self.queue = prepared;
} else |err| {
const entries = self.cfg.logging.query_log_buffer_max;
if (try self.queueFailure(err, entries)) |failure| return failure;
}
}
}
if (self.ops.contains(.api_limiter)) self.limiter_now = std.Io.Clock.awake.now(io);
return null;
}
/// What a refused resize means to the client, or null when it means nothing
/// — a controller with no generation to replace is a test's borrowed one,
/// and the row is still the configuration.
fn queueFailure(
self: *Plan,
err: logger_controller.PrepareError,
entries: u32,
) error{OutOfMemory}!?Failure {
var buf: [96]u8 = undefined;
if (logger_controller.sizeMessage(err, entries, &buf)) |message| {
return Failure{ .invalid = try std.fmt.allocPrint(
self.arena,
"logging.query_log_buffer_max: {s}",
.{message},
) };
}
return switch (err) {
error.NotResizable => null,
error.OutOfMemory => error.OutOfMemory,
error.PreviousResizeDraining => Failure{
.conflict = "a previous query-log resize is still draining",
},
else => blk: {
log.warn("preparing the query-log resize failed: {s}", .{@errorName(err)});
break :blk Failure{ .unavailable = "the query logger could not be resized" };
},
};
}
/// The upstream half, shared with the upstream resource handlers: they
/// build their candidate from the HYPOTHETICAL post-mutation row set,
/// before the repository write.
pub fn prepareUpstreams(
self: *Plan,
io: std.Io,
servers: []const model.UpstreamServer,
) error{OutOfMemory}!?Failure {
const state = self.state;
const owner = state.upstreams orelse return null;
const inputs = state.upstream_build orelse return null;
self.upstream_previous_keys = try owner.copyLiveReportKeys(io, self.arena);
self.upstream = upstream_owner.build(.{
.gpa = state.gpa,
.io = io,
.servers = servers,
.http = inputs.http,
.bundle = inputs.bundle,
.bundle_lock = inputs.bundle_lock,
.timeouts = .{
.attempt = .{ .raw = model.attemptTimeout(self.cfg.upstream), .clock = .awake },
.total = .{ .raw = model.totalTimeout(self.cfg.upstream), .clock = .awake },
},
.seed = @truncate(@as(u96, @bitCast(std.Io.Clock.real.now(io).nanoseconds))),
.diagnostics = state.events,
}) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
error.NoUsableUpstreams => return Failure{
.conflict = "no usable upstream would be left",
},
};
self.ops.insert(.upstream_generation);
return null;
}
/// Frees every candidate. The change did not happen: nothing was published,
/// so nothing the running server holds is touched.
pub fn abandon(self: *Plan, io: std.Io) void {
const gpa = self.state.gpa;
if (self.proxies) |text| gpa.free(text);
if (self.cache) |candidate| {
candidate.deinit();
gpa.destroy(candidate);
}
if (self.limiter) |candidate| {
candidate.deinit();
gpa.destroy(candidate);
}
if (self.upstream) |candidate| candidate.retire(io);
if (self.doh_paths) |prepared| self.state.doh_certs.?.abortPathChange(io, prepared);
if (self.dot_paths) |prepared| self.state.dot_certs.?.abortPathChange(io, prepared);
if (self.sink) |prepared| logging.abortApply(io, prepared);
disk_monitor.Monitor.destroyPreparedLogDir(self.log_dir);
if (self.queue) |prepared| self.state.logger.?.abandon(io, prepared);
self.* = undefined;
}
/// Infallible and I/O-free. Every operation whose owner is wired is told,
/// and what it displaced is recorded for `retire`.
pub fn publish(self: *Plan, io: std.Io) void {
const state = self.state;
if (self.ops.contains(.dns_policy)) {
if (state.handler) |h| h.setPolicy(io, .{
.blocking = .{ .mode = self.cfg.blocking.response, .ttl = self.cfg.blocking.ttl },
.ecs_mode = self.cfg.edns.ecs_mode,
.forward_read_timeout = .{ .raw = model.readTimeout(self.cfg.upstream), .clock = .awake },
.negative_ttl_max = self.cfg.cache.negative_ttl_max,
});
}
if (self.proxies) |prepared| {
self.retired_proxies = state.proxies.install(io, prepared);
self.proxies = null;
}
if (self.ops.contains(.logger_privacy)) {
if (state.logger) |controller| controller.setPrivacy(io, .{
.hide_domains = self.cfg.logging.hide_domains,
.hide_client_ips = self.cfg.logging.hide_client_ips,
});
}
if (self.ops.contains(.logger_flush)) {
if (state.logger) |controller| {
controller.setFlushInterval(io, self.cfg.logging.query_log_flush_interval_s);
}
}
if (self.ops.contains(.disk_thresholds)) {
if (state.monitor) |monitor| monitor.setThresholds(self.cfg.disk);
}
if (self.upstream) |candidate| {
self.retired_upstream = state.upstreams.?.replace(io, candidate);
self.published_upstream = true;
self.upstream = null;
}
if (self.cache) |candidate| {
// A handler-less state still owns the candidate, and dropping it
// here would leak it: `replaceCache` is the only thing that can
// hand the old one back.
if (state.handler) |h| {
self.retired_cache = h.replaceCache(io, candidate);
} else {
self.retired_cache = candidate;
}
self.cache = null;
}
if (self.limiter) |candidate| {
if (state.handler) |h| {
self.retired_limiter = h.replaceRateLimiter(io, candidate);
} else {
self.retired_limiter = candidate;
}
self.limiter = null;
}
if (self.ops.contains(.sessions_ttl)) {
if (state.sessions) |sessions| sessions.setTtl(io, self.cfg.web.session_ttl_hours);
}
if (self.limiter_now) |now| {
if (state.limiter) |limiter| limiter.setLimits(io, now, .{
.rate_per_min = self.cfg.web.api_rate_limit_per_min,
.localhost_exempt = self.cfg.web.api_localhost_exempt,
.sse_max_per_ip = self.cfg.web.sse_max_connections_per_ip,
});
}
if (self.ops.contains(.retention)) {
if (state.retention_days) |days| days.setRetentionDays(self.cfg.logging.retention_days);
}
if (self.doh_paths) |prepared| {
state.doh_certs.?.publishPathChange(io, prepared);
self.doh_paths = null;
}
if (self.dot_paths) |prepared| {
state.dot_certs.?.publishPathChange(io, prepared);
self.dot_paths = null;
}
if (self.sink) |prepared| {
self.detached_log_file = logging.publishApply(prepared);
self.sink = null;
if (state.monitor) |monitor| {
monitor.setLogDir(io, self.log_dir);
} else {
disk_monitor.Monitor.destroyPreparedLogDir(self.log_dir);
}
self.log_dir = null;
}
if (self.ops.contains(.scheduler)) {
if (state.manager) |manager| manager.setSchedule(
io,
self.cfg.blocklist_update.enabled,
self.cfg.blocklist_update.interval_hours,
);
}
if (self.queue) |prepared| {
state.logger.?.publish(io, prepared);
self.queue = null;
}
}
/// Closes, frees and reconciles after the publish. Runs on the writing
/// task, never on a reader's release path.
pub fn retire(self: *Plan, io: std.Io) void {
const gpa = self.state.gpa;
if (self.retired_proxies) |text| gpa.free(text);
if (self.retired_cache) |old| {
old.deinit();
gpa.destroy(old);
}
if (self.retired_limiter) |old| {
old.deinit();
gpa.destroy(old);
}
logging.retireApply(io, self.detached_log_file);
if (self.published_upstream) {
if (self.state.events) |store| {
const owner = self.state.upstreams.?;
const generation = owner.acquire(io);
defer owner.release(io, generation);
upstream_owner.reconcileReport(
store,
io,
std.Io.Clock.real.now(io).toSeconds(),
generation.report().notes,
self.upstream_previous_keys,
);
}
// A generation no reader held at the swap has no release left to
// tear it down, so the publisher does — after the reconciliation,
// which reads only the copies taken at prepare.
if (self.retired_upstream) |old| old.retire(io);
}
self.* = undefined;
}
};
fn certFailure(comptime endpoint: []const u8, err: cert_store.ReloadError) Failure {
return switch (err) {
error.OutOfMemory => .{ .unavailable = "out of memory loading the certificate" },
error.CertUnreadable => .{ .invalid = endpoint ++ ".cert_path: no readable certificate there" },
error.KeyUnreadable => .{ .invalid = endpoint ++ ".key_path: no readable key there" },
error.KeyMismatch => .{ .invalid = endpoint ++ ": the key at that path does not match the certificate" },
// The remaining variants are parse, size and library failures. They name
// the pair rather than one of the two files, because which of them was
// at fault is exactly what the loader could not decide.
else => .{ .invalid = endpoint ++ ": the certificate and key at those paths could not be loaded" },
};
}
/// The row set an upstream create, update or delete would leave behind, built
/// before the repository write so the candidate generation is the one the
/// commit will make true.
pub const RowMutation = union(enum) {
add: model.UpstreamServer,
replace: struct { id: i64, item: model.UpstreamServer },
remove: i64,
};
pub fn hypotheticalRows(
arena: Allocator,
rows: []const upstreams_repo.UpstreamRow,
mutation: RowMutation,
) Allocator.Error![]const model.UpstreamServer {
var out: std.ArrayList(model.UpstreamServer) = .empty;
for (rows) |row| {
switch (mutation) {
.add => {},
.replace => |edit| if (row.id == edit.id) {
try out.append(arena, edit.item);
continue;
},
.remove => |id| if (row.id == id) continue,
}
try out.append(arena, .{
.url = row.url,
.priority = row.priority,
.enabled = row.enabled,
.tls_name = row.tls_name,
});
}
if (mutation == .add) try out.append(arena, mutation.add);
return out.items;
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
const testing = std.testing;
const fixtures = @import("test_fixtures");
test "a propagated error out of prepare abandons what earlier owners already built" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.writeFile(io, .{ .sub_path = "cert.pem", .data = fixtures.cert_pem });
try tmp.dir.writeFile(io, .{ .sub_path = "key.pem", .data = fixtures.key_pem });
var cert_buf: [128]u8 = undefined;
var key_buf: [128]u8 = undefined;
var log_buf: [128]u8 = undefined;
const cert_path = try std.fmt.bufPrint(&cert_buf, ".zig-cache/tmp/{s}/cert.pem", .{tmp.sub_path});
const key_path = try std.fmt.bufPrint(&key_buf, ".zig-cache/tmp/{s}/key.pem", .{tmp.sub_path});
const log_path = try std.fmt.bufPrint(&log_buf, ".zig-cache/tmp/{s}/nxdns.log", .{tmp.sub_path});
var store = try cert_store.CertStore.init(testing.allocator, io, cert_path, key_path, null);
defer store.deinit(io);
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
// The cert candidate is built from the store's own allocator and succeeds;
// the log-directory generation that follows it comes from the state's, and
// that one is out of memory.
var failing: std.testing.FailingAllocator = .init(testing.allocator, .{ .fail_index = 0 });
var state: server.WebState = .{ .gpa = failing.allocator(), .doh_certs = &store };
var cfg: model.Config = .{};
cfg.doh_server.cert_path = cert_path;
cfg.doh_server.key_path = key_path;
cfg.logging.output = .file;
cfg.logging.file_path = log_path;
var ops: Set = .initEmpty();
ops.insert(.certs_doh);
ops.insert(.log_sink);
var plan: Plan = .init(&state, arena_state.allocator(), cfg, ops);
try testing.expectError(error.OutOfMemory, plan.prepare(io));
// `preparePathChange` holds `reload_mutex` until its publish or its abort.
// A prepare that failed after it must have aborted, or every later cert
// change and every reload would block here forever.
const second = try store.preparePathChange(io, cert_path, key_path);
store.abortPathChange(io, second);
}
test "every scalar settings key appears in the table exactly once" {
// The old comptime generator walked `model.Config` to produce the key list;
// this walks it to prove the hand-written table covers it. A section added
// to the model fails to compile here until its keys have owners.
comptime {
@setEvalBranchQuota(50_000);
var counted = 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;
const key = section_field.name ++ "." ++ field.name;
var hits = 0;
for (table) |entry| {
if (std.mem.eql(u8, entry.key, key)) hits += 1;
}
if (hits != 1) @compileError("the apply table does not list " ++ key ++ " exactly once");
counted += 1;
}
}
if (counted != table.len) @compileError("the apply table lists a key the model does not have");
}
}
test "the table names no secret" {
for (table) |entry| {
try testing.expect(!std.mem.eql(u8, entry.key, "web.password"));
try testing.expect(!std.mem.eql(u8, entry.key, "web.password_hash"));
}
}
test "restart-required is bind and web lifecycle, and nothing else" {
for (table) |entry| {
var listed = false;
for (restart_required_keys) |key| {
if (std.mem.eql(u8, entry.key, key)) listed = true;
}
try testing.expectEqual(entry.operation.needsRestart(), listed);
}
// The full list, so a key silently joining or leaving it is a failure.
const expected = [_][]const u8{
"dns.bind_ipv4",
"dns.bind_ipv6",
"dns.port",
"web.enabled",
"web.bind",
"web.port",
"doh_server.bind",
"doh_server.enabled",
"doh_server.port",
"dot_server.bind",
"dot_server.enabled",
"dot_server.port",
};
try testing.expectEqual(expected.len, restart_required_keys.len);
for (expected) |key| {
var found = false;
for (restart_required_keys) |listed| {
if (std.mem.eql(u8, listed, key)) found = true;
}
try testing.expect(found);
}
}
test "changed operations follow the values, not the keys a patch named" {
const before: model.Config = .{};
try testing.expect(changedOperations(before, before).count() == 0);
var after = before;
after.dns.port = 5353;
try testing.expect(changedOperations(before, after).eql(Set.initOne(.bind)));
// Two keys of one owner are one operation.
var pair = before;
pair.disk = .{ .min_free_mb = 1, .warn_free_mb = 2 };
try testing.expect(changedOperations(before, pair).eql(Set.initOne(.disk_thresholds)));
// A string key compares by bytes.
var proxies = before;
proxies.web.trusted_proxies = "10.0.0.1";
try testing.expect(changedOperations(before, proxies).eql(Set.initOne(.trusted_proxies)));
// Equal bytes at a different address are not a change.
var same_bytes = before;
var copy: [7]u8 = undefined;
@memcpy(&copy, "0.0.0.0");
same_bytes.dns.bind_ipv4 = ©
try testing.expect(changedOperations(before, same_bytes).count() == 0);
}
test "the hypothetical row set is the one the commit will make true" {
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const rows = [_]upstreams_repo.UpstreamRow{
.{ .id = 1, .url = "https://a.example/dns-query", .priority = 1, .enabled = true, .tls_name = "" },
.{ .id = 2, .url = "tls://b.example:853", .priority = 2, .enabled = true, .tls_name = "b.example" },
};
const added = try hypotheticalRows(arena, &rows, .{ .add = .{ .url = "https://c.example/dns-query" } });
try testing.expectEqual(@as(usize, 3), added.len);
try testing.expectEqualStrings("https://c.example/dns-query", added[2].url);
const removed = try hypotheticalRows(arena, &rows, .{ .remove = 1 });
try testing.expectEqual(@as(usize, 1), removed.len);
try testing.expectEqualStrings("tls://b.example:853", removed[0].url);
const replaced = try hypotheticalRows(arena, &rows, .{
.replace = .{ .id = 2, .item = .{ .url = "tls://z.example:853", .tls_name = "z.example" } },
});
try testing.expectEqual(@as(usize, 2), replaced.len);
try testing.expectEqualStrings("tls://z.example:853", replaced[1].url);
}
+7 -1
View File
@@ -124,11 +124,17 @@ pub fn login(state: *server.WebState, io: std.Io, request: *Request) HandlerErro
.cookie => |cookie| {
log.info("web login accepted for {f}", .{request.client_addr});
var buf: [cookie_buf_len]u8 = undefined;
// The live table's TTL, not `state.web`'s boot snapshot: a
// settings apply changes the TTL in place.
const ttl_s = if (state.sessions) |sessions|
sessions.ttlSeconds(io)
else
model.sessionTtlSeconds(state.web);
const header = http_util.formatSetCookie(
&buf,
auth.cookie_name,
&cookie,
model.sessionTtlSeconds(state.web),
ttl_s,
) catch return error.OutOfMemory;
return http_util.respondJson(request, .ok, .{
.authenticated = true,
+19 -12
View File
@@ -20,6 +20,7 @@ const std = @import("std");
const disk_monitor = @import("../../storage/disk_monitor.zig");
const http_util = @import("../http_util.zig");
const logger_controller = @import("../../storage/logger_controller.zig");
const logger_mod = @import("../../storage/logger.zig");
const metrics = @import("../metrics.zig");
const pause_mod = @import("../../server/pause.zig");
@@ -229,20 +230,25 @@ pub fn collect(state: *server.WebState, io: std.Io) Input {
input.disk_free_bytes = monitor.gauges().free_bytes;
}
if (state.pool) |pool| {
var raw: [metrics.max_upstreams]pool_mod.Snapshot = undefined;
const count = metrics.poolSnapshot(pool, io, &raw);
input.upstreams_total = @intCast(count);
for (raw[0..count]) |entry| {
if (entry.available) input.upstreams_available += 1;
if (state.upstreams) |owner| {
const generation = owner.acquire(io);
defer owner.release(io, generation);
if (generation.pool) |pool| {
var raw: [metrics.max_upstreams]pool_mod.Snapshot = undefined;
const count = metrics.poolSnapshot(pool, io, &raw);
input.upstreams_total = @intCast(count);
for (raw[0..count]) |entry| {
if (entry.available) input.upstreams_available += 1;
}
}
}
if (state.logger) |logger| {
input.queries_dropped = logger.queries_dropped.load(.monotonic);
input.last_drop_s = logger.lastDropSeconds();
input.writer_failed = logger.writer_failed.load(.monotonic);
input.gate_episode = logger.gateEpisode();
if (state.logger) |controller| {
const reading = controller.sample(io);
input.queries_dropped = reading.queries_dropped;
input.last_drop_s = reading.last_drop_s;
input.writer_failed = reading.writer_failed;
input.gate_episode = reading.gate_episode;
}
if (state.pause) |paused| input.pause_until = paused.until.load(.monotonic);
@@ -492,13 +498,14 @@ test "collect reads the logger's counters and the pause flag" {
query_logger.queries_dropped.store(4, .monotonic);
query_logger.last_drop_s.store(1_700_000_000, .monotonic);
query_logger.writer_failed.store(true, .monotonic);
var log_owner: logger_controller.Borrowed = .{};
var paused: pause_mod.Pause = .{};
paused.pauseFor(0, null);
var state: server.WebState = .{
.gpa = testing.allocator,
.logger = &query_logger,
.logger = log_owner.over(&query_logger),
.pause = &paused,
};
const input = collect(&state, io);
+18 -4
View File
@@ -163,7 +163,11 @@ pub fn Resource(comptime desc: anytype) type {
if (!isNull(@TypeOf(desc.remove))) {
const Remove = @TypeOf(desc.remove);
if (removeTakesArena(Remove)) {
expectType("remove", Remove, fn (*server.WebState, std.Io, Allocator, i64) ?Failure);
if (removeIsFallible(Remove)) {
expectType("remove", Remove, fn (*server.WebState, std.Io, Allocator, i64) error{OutOfMemory}!?Failure);
} else {
expectType("remove", Remove, fn (*server.WebState, std.Io, Allocator, i64) ?Failure);
}
} else {
expectType("remove", Remove, fn (*server.WebState, std.Io, i64) ?Failure);
}
@@ -227,10 +231,12 @@ pub fn Resource(comptime desc: anytype) type {
}
fn removeRow(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
const failure = if (comptime removeTakesArena(@TypeOf(desc.remove)))
desc.remove(state, io, request.arena, request.id.?)
const failure = if (comptime !removeTakesArena(@TypeOf(desc.remove)))
desc.remove(state, io, request.id.?)
else if (comptime removeIsFallible(@TypeOf(desc.remove)))
try desc.remove(state, io, request.arena, request.id.?)
else
desc.remove(state, io, request.id.?);
desc.remove(state, io, request.arena, request.id.?);
if (failure) |value| return respondFailure(request, value, remove_what);
return http_util.respondEmpty(request, .no_content);
@@ -246,6 +252,14 @@ fn removeTakesArena(comptime T: type) bool {
return @typeInfo(T) == .@"fn" and @typeInfo(T).@"fn".params.len == 4;
}
/// A delete decision that builds a candidate for the running server allocates
/// while it does so, so it may run out of memory. The two shapes are checked
/// exactly, so a `remove` that returns some other error set is still a compile
/// error naming what it should have been.
fn removeIsFallible(comptime T: type) bool {
return @typeInfo(@typeInfo(T).@"fn".return_type.?) == .error_union;
}
fn expectType(comptime name: []const u8, comptime Actual: type, comptime Expected: type) void {
if (Actual != Expected) @compileError("resource descriptor `" ++ name ++ "` must be " ++
@typeName(Expected) ++ ", found " ++ @typeName(Actual));
+90 -104
View File
@@ -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" {
+90 -29
View File
@@ -1,11 +1,13 @@
//! `/api/upstreams` — the resolvers nxdns forwards to.
//!
//! Ruling 9 makes this a resource like any other; ruling 12 makes it the one
//! mutable resource that is NOT live. The pool builds its clients, its health
//! state and its TLS material at startup, so an upstream added, edited or
//! removed here takes effect at the next restart. The response says so through
//! `restart_required`, which is the same word `/api/settings` uses, so the UI
//! has one banner and one meaning for it.
//! Ruling 9 makes this a resource like any other, and milestone 34 makes it
//! live like the rest of them: an upstream added, edited or removed here is
//! applied in-process. The row set the write will leave behind is built into a
//! candidate generation BEFORE the write, so a set that cannot produce clients
//! is refused with nothing changed; the candidate is published after the commit
//! and the displaced generation retires once the exchanges holding it finish.
//! `restart_required` in the response is therefore `false`, and stays in the
//! shape so the UI has one field with one meaning across every mutation.
//!
//! `tls_name` is the DoT-only SNI and certificate name (migration v2). It is
//! empty for every other scheme, and the validator refuses it there.
@@ -13,6 +15,8 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const apply = @import("apply.zig");
const db = @import("../../storage/db.zig");
const http_util = @import("../http_util.zig");
const model = @import("../../config/model.zig");
const mutations = @import("mutations.zig");
@@ -38,6 +42,34 @@ const Created = union(enum) { id: i64, fail: Failure };
// decisions
// ---------------------------------------------------------------------------
/// Prepares the generation the row set after `mutation` would produce, on a
/// caller that already holds `config_lock`. Returns the plan for the caller to
/// publish or abandon.
fn planFor(
state: *server.WebState,
io: std.Io,
arena: Allocator,
database: *db.Db,
mutation: apply.RowMutation,
) error{OutOfMemory}!union(enum) { plan: apply.Plan, fail: Failure } {
const cfg = mutations.loadConfig(arena, database) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
else => return .{ .fail = .{ .internal = err } },
};
const rows = upstreams_repo.listUpstreamRows(database, arena) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
else => return .{ .fail = .{ .internal = err } },
};
const servers = try apply.hypotheticalRows(arena, rows.items, mutation);
var plan: apply.Plan = .init(state, arena, cfg, .initEmpty());
if (try plan.prepareUpstreams(io, servers)) |failure| {
plan.abandon(io);
return .{ .fail = failure };
}
return .{ .plan = plan };
}
fn applyCreate(
state: *server.WebState,
io: std.Io,
@@ -48,14 +80,20 @@ fn applyCreate(
if (try mutations.checkUpstream(arena, item)) |problem| return .{ .fail = .{ .invalid = problem } };
state.config_lock.lockUncancelable(io);
const inserted = upstreams_repo.insertUpstreamRow(database, item);
state.config_lock.unlock(io);
defer 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);
var plan = switch (try planFor(state, io, arena, database, .{ .add = item })) {
.fail => |failure| return .{ .fail = failure },
.plan => |p| p,
};
const id = upstreams_repo.insertUpstreamRow(database, item) catch |err| {
plan.abandon(io);
return .{ .fail = mutations.dbFailure(err, url_conflict) };
};
plan.publish(io);
plan.retire(io);
return .{ .id = id };
}
@@ -85,16 +123,27 @@ fn applyUpdate(
}
}
upstreams_repo.updateUpstream(database, id, item) catch |err|
var plan = switch (try planFor(state, io, arena, database, .{
.replace = .{ .id = id, .item = item },
})) {
.fail => |failure| return failure,
.plan => |p| p,
};
upstreams_repo.updateUpstream(database, id, item) catch |err| {
plan.abandon(io);
return mutations.dbFailure(err, url_conflict);
state.restart_pending.store(true, .monotonic);
};
plan.publish(io);
plan.retire(io);
return null;
}
/// The last enabled upstream cannot go: a resolver with nowhere to forward to
/// answers nothing, and `validate.validate` refuses that configuration at
/// startup — so allowing it here would only produce a box that will not boot.
fn applyDelete(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) ?Failure {
fn applyDelete(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) error{OutOfMemory}!?Failure {
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
state.config_lock.lockUncancelable(io);
@@ -109,19 +158,28 @@ fn applyDelete(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) ?
},
}
upstreams_repo.deleteUpstream(database, id) catch |err|
var plan = switch (try planFor(state, io, arena, database, .{ .remove = id })) {
.fail => |failure| return failure,
.plan => |p| p,
};
upstreams_repo.deleteUpstream(database, id) catch |err| {
plan.abandon(io);
return mutations.dbFailure(err, url_conflict);
state.restart_pending.store(true, .monotonic);
};
plan.publish(io);
plan.retire(io);
return null;
}
const Remaining = union(enum) { missing, count: usize };
fn countEnabledExcept(
database: *@import("../../storage/db.zig").Db,
database: *db.Db,
arena: Allocator,
id: i64,
) @import("../../storage/db.zig").Error!Remaining {
) db.Error!Remaining {
const rows = try upstreams_repo.listUpstreamRows(database, arena);
var found = false;
var left: usize = 0;
@@ -166,7 +224,7 @@ pub fn create(state: *server.WebState, io: std.Io, request: *Request) HandlerErr
.priority = item.priority,
.enabled = item.enabled,
.tls_name = item.tls_name,
.restart_required = true,
.restart_required = false,
}, &.{}),
};
}
@@ -186,7 +244,7 @@ pub fn update(state: *server.WebState, io: std.Io, request: *Request) HandlerErr
.priority = item.priority,
.enabled = item.enabled,
.tls_name = item.tls_name,
.restart_required = true,
.restart_required = false,
}, &.{});
}
@@ -219,7 +277,7 @@ test "a created upstream is stored" {
try testing.expectEqualStrings("", row.tls_name);
}
test "an upstream change never announces a reload" {
test "an upstream change never announces a reload or a restart" {
var bench: mutations.Bench = undefined;
try bench.init(testing.allocator);
defer bench.deinit(testing.allocator);
@@ -251,7 +309,7 @@ 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" {
test "no upstream write owes a restart, refused or accepted" {
var bench: mutations.Bench = undefined;
try bench.init(testing.allocator);
defer bench.deinit(testing.allocator);
@@ -264,11 +322,14 @@ test "a refused upstream write owes no restart, and an accepted one does" {
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((try applyDelete(&bench.state, bench.io(), bench.arena(), 999)).? == .not_found);
try testing.expect(!bench.state.restart_pending.load(.monotonic));
// Milestone 34: an accepted write is applied in-process, so it owes no
// restart either. `restart_pending` now has exactly two sources, and
// neither of them is here.
_ = try applyCreate(&bench.state, bench.io(), bench.arena(), doh);
try testing.expect(bench.state.restart_pending.load(.monotonic));
try testing.expect(!bench.state.restart_pending.load(.monotonic));
}
test "a duplicate url is a conflict" {
@@ -287,7 +348,7 @@ test "the last enabled upstream cannot be deleted" {
defer bench.deinit(testing.allocator);
const created = try applyCreate(&bench.state, bench.io(), bench.arena(), doh);
const failure = applyDelete(&bench.state, bench.io(), bench.arena(), created.id);
const failure = try applyDelete(&bench.state, bench.io(), bench.arena(), created.id);
try testing.expectEqualStrings("the last enabled upstream cannot be removed", failure.?.conflict);
const second = try applyCreate(&bench.state, bench.io(), bench.arena(), .{
@@ -296,7 +357,7 @@ test "the last enabled upstream cannot be deleted" {
});
try testing.expectEqual(
@as(?Failure, null),
applyDelete(&bench.state, bench.io(), bench.arena(), created.id),
try applyDelete(&bench.state, bench.io(), bench.arena(), created.id),
);
try testing.expectEqual(
@as(i64, 1),
@@ -357,6 +418,6 @@ test "an id no upstream holds is a 404 on both update and delete" {
);
try testing.expectEqual(
Failure.not_found,
applyDelete(&bench.state, bench.io(), bench.arena(), 999).?,
(try applyDelete(&bench.state, bench.io(), bench.arena(), 999)).?,
);
}