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:
@@ -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(©, "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);
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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" {
|
||||
|
||||
@@ -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)).?,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user