milestone 20: declarative configuration for iac

This commit is contained in:
2026-08-11 23:31:40 +02:00
parent 2f29121e27
commit d76afc147a
74 changed files with 6722 additions and 1949 deletions
+443 -70
View File
@@ -32,7 +32,6 @@ const tls = std.crypto.tls;
const api_limiter = @import("web/api_limiter.zig");
const auth = @import("web/auth.zig");
const bootstrap = @import("config/bootstrap.zig");
const cert_store = @import("server/cert_store.zig");
const cli = @import("cli.zig");
const clients = @import("server/clients.zig");
@@ -49,6 +48,7 @@ const fetcher = @import("filter/fetcher.zig");
const forward_zones = @import("local/forward_zones.zig");
const handler = @import("server/handler.zig");
const http_util = @import("web/http_util.zig");
const loader = @import("config/loader.zig");
const local_records = @import("local/records.zig");
const local_tables = @import("server/local_tables.zig");
const logger_mod = @import("storage/logger.zig");
@@ -60,6 +60,7 @@ const pause = @import("server/pause.zig");
const pool_mod = @import("upstream/pool.zig");
const query_sink = @import("server/query_sink.zig");
const rate_limiter = @import("server/rate_limiter.zig");
const reconcile = @import("config/reconcile.zig");
const retention_mod = @import("storage/retention.zig");
const safe_url = @import("safe_url.zig");
const shutdown = @import("server/shutdown.zig");
@@ -95,6 +96,12 @@ pub fn run(runner: cli.Runner, args: cli.RunArgs) u8 {
const mapped = failureExitCode(err);
if (mapped == cli.exit_check) {
runner.err.writeAll("run `nxdns check` to see the configuration in full\n") catch {};
// Ruling 6: after bootstrap seeding died, a fresh database fails
// validation naturally (`NoUsableUpstreams`) and the operator needs
// to be told how a database gets a configuration at all. In file
// mode they already have a file, and the diagnostics above name what
// is wrong with it.
if (args.config == null) cli.writeDbSourceHint(runner.err);
}
break :code mapped;
};
@@ -113,70 +120,201 @@ fn failureExitCode(err: anyerror) u8 {
return if (faults.isConfigFault(err)) cli.exit_check else cli.exit_runtime;
}
/// First run only: the file seeds an empty database and is ignored forever
/// after. Its diagnostics are the operator's one chance to see what the file
/// said, so they are printed the way `check` and `import` print them.
/// File authority (ruling 2): read the file the operator named, validate it, and
/// converge the database onto it — on this start and on every start after it.
/// Returns the wall clock the reconcile ran at, which becomes the settings
/// envelope's `reconciled_at`.
///
/// Printed on the way out either way. A seed file can be accepted and still
/// carry warnings — a blocklist source in no group is downloaded and compiled
/// into nothing — and a warning that only appears when the start fails is a
/// warning nobody ever reads: the start it describes is the one that worked.
/// `check` reported it and `run` did not, which left the same file graded two
/// ways.
/// A missing, unreadable or invalid file fails the start. There is no fallback
/// to the database under any failure: a fallback turns a deploy typo into a
/// silently stale configuration, which is the failure mode the whole mode exists
/// to prevent.
///
/// The runner's error writer, not `std.log`: this runs before
/// `logging.install`, and one rendering of a diagnostic across `run`, `check`
/// and `import` is the point of `Diagnostics.writeAll`.
/// Diagnostics are printed on the way out either way. A file can be accepted and
/// still carry warnings — a blocklist source in no group is downloaded and
/// compiled into nothing — and a warning that only appears when the start fails
/// is a warning nobody ever reads: the start it describes is the one that
/// worked.
///
/// Flushed here rather than left to `run`'s exit flush. That writer is buffered
/// (`main` gives it 4 KiB) and `serve` does not return for as long as the
/// service runs, so a line left in the buffer reaches the operator when the
/// The runner's writers, not `std.log`: this runs before `logging.install`, and
/// one rendering of a diagnostic across `run`, `check` and `import` is the point
/// of `Diagnostics.writeAll`.
///
/// Flushed here rather than left to `run`'s exit flush. Both writers are
/// buffered (`main` gives them 4 KiB) and `serve` does not return for as long as
/// the service runs, so a line left in the buffer reaches the operator when the
/// process stops — days after the start it describes. A failure path flushes
/// anyway because it returns immediately; the successful start is the one that
/// needs this.
fn seedFromFile(
fn reconcileFromFile(
r: cli.Runner,
config_db: *db.Db,
dir: std.Io.Dir,
config_path: []const u8,
) bootstrap.Error!bootstrap.Outcome {
) !i64 {
return reconcileFromFileAt(
r,
config_db,
dir,
config_path,
std.Io.Clock.real.now(r.io).toSeconds(),
);
}
/// `pass_now` is what the engine stamps into the runtime columns of the rows it
/// inserts (`first_seen`, `last_seen`, `created_at`), and it is deliberately not
/// the value this returns.
///
/// The two clocks answer different questions, and conflating them was a real
/// defect: `reconciled_at` means "this process loaded the file at T" and is
/// compared against the file's mtime to detect a restart-pending state
/// (ruling 7). A stamp taken *before* the read makes a file written during the
/// read look newer than the process that loaded it — a false "restart pending"
/// in the UI for a file that is fully applied. So this returns a clock read
/// taken immediately after the commit, and `pass_now` never leaves the engine.
///
/// Split from `reconcileFromFile` so the two are separable in a test: pin
/// `pass_now` and the returned stamp must still be the real clock.
fn reconcileFromFileAt(
r: cli.Runner,
config_db: *db.Db,
dir: std.Io.Dir,
config_path: []const u8,
pass_now: i64,
) !i64 {
var arena_state: std.heap.ArenaAllocator = .init(r.gpa);
defer arena_state.deinit();
var diags: validate.Diagnostics = .init(r.gpa);
defer diags.deinit();
const result = bootstrap.bootstrap(r.io, r.gpa, config_db, dir, config_path, &diags);
const result = applyManagedFile(r, config_db, dir, config_path, arena_state.allocator(), &diags, pass_now);
// Neither discard is an oversight, and the two answer different questions.
//
// On a rejected seed file, `result` is returned untouched: the operator gets
// the reason the start failed, never a writer error standing in front of it.
// A broken stderr is not why the configuration was refused.
//
// On a seed that worked, a failure here does not stop the start. The trade
// is one lost warning line against a household with no name resolution, and
// `run` before this point is the only stretch of this program where an
// output failure could take DNS down at all — ruling 4 already says nothing
// after it is fatal. Nor could the failure be reported: this writer *is* the
// error channel, and `logging.install` has not run yet, so `std.log` resolves
// to the same stderr a diagnostic about it would have to travel down.
//
// It is not lost from the process either. A failed drain consumes nothing,
// so whatever the buffer held it still holds — that half is observed, in
// "a broken error writer does not stop a first start that succeeded" below,
// which reads the retained warning back out of the same writer.
//
// What happens to those bytes afterwards is derived, not watched, and is
// labelled so deliberately. `Io.Writer.defaultFlush` drains while `end != 0`
// and `run`'s exit flush maps a failure to exit 1, so a stderr still broken
// at shutdown should carry the condition out in the exit code, and one that
// recovered should deliver the line late. No test drives `run` that far.
// Two limits come with the derivation: an empty buffer flushes clean and
// reports nothing at all, and a failure that recovers ends at exit 0 with a
// line the operator reads days after the start it describes.
// Both discards are deliberate, and they answer different questions. On a
// rejected file the operator gets the reason the start failed, never a
// writer error standing in front of it — a broken stderr is not why the
// configuration was refused. On a file that applied, a failure here does not
// stop the start: the trade is one lost warning line against a household
// with no name resolution, and this writer *is* the error channel, so the
// failure has nowhere to be reported anyway.
diags.writeAll(r.err) catch {};
r.err.flush() catch {};
return result;
}
/// Returns the moment the transaction committed, which is what the settings
/// envelope reports as `reconciled_at`.
fn applyManagedFile(
r: cli.Runner,
config_db: *db.Db,
dir: std.Io.Dir,
config_path: []const u8,
arena: Allocator,
diags: *validate.Diagnostics,
now: i64,
) !i64 {
const cfg = try loader.load(r.io, arena, dir, config_path, diags);
try validate.validate(cfg, diags);
// The keys this pass wrote, never their values (ruling 8). Duplicated into
// `gpa` by the engine, so this frame frees them.
var changed: std.ArrayList([]const u8) = .empty;
defer {
for (changed.items) |key| r.gpa.free(key);
changed.deinit(r.gpa);
}
var pass = reconcile.begin(r.io, r.gpa, config_db, cfg, now, .{
.changed_settings = &changed,
}) catch |err| {
// `begin` has already rolled its own transaction back. What the operator
// needs is the cause: a full SD card must read as "disk", not as a bare
// exit 1, so the SQLite condition is named.
reportReconcileFailure(r, config_db, config_path, err);
return err;
};
errdefer pass.rollback();
pass.commit() catch |err| {
reportReconcileFailure(r, config_db, config_path, err);
return err;
};
// Read here and nowhere earlier: the file is loaded once this line runs, and
// not one statement before it.
const reconciled_at = std.Io.Clock.real.now(r.io).toSeconds();
printSummary(r, config_path, pass.summary, changed.items) catch {};
return reconciled_at;
}
/// SQLite conditions an operator acts on differently. `@errorName` alone would
/// say `Full`, which is not a word anyone can search for; the primary result
/// code's own name is.
fn sqliteCodeName(err: anyerror) ?[]const u8 {
return switch (err) {
error.Full => "SQLITE_FULL",
error.Busy => "SQLITE_BUSY",
error.IoErr => "SQLITE_IOERR",
error.ReadOnly => "SQLITE_READONLY",
error.Corrupt => "SQLITE_CORRUPT",
error.Constraint => "SQLITE_CONSTRAINT",
else => null,
};
}
fn reportReconcileFailure(r: cli.Runner, config_db: *db.Db, config_path: []const u8, err: anyerror) void {
var buf: [256]u8 = undefined;
const detail = config_db.lastError(&buf);
const code = sqliteCodeName(err) orelse @errorName(err);
r.err.print("reconciling '{s}' failed: {s}: {s}\n", .{ config_path, code, detail }) catch {};
r.err.flush() catch {};
}
/// What that restart changed, without opening sqlite (ruling 8): per-table
/// counts, the settings keys that moved — never their values — and an
/// authentication change, which is never a silent line item in a count.
fn printSummary(
r: cli.Runner,
config_path: []const u8,
summary: reconcile.Summary,
changed_settings: []const []const u8,
) !void {
try r.out.print("reconciled '{s}':", .{config_path});
if (summary.isNoOp()) {
try r.out.writeAll(" no changes\n");
} else {
inline for (@typeInfo(reconcile.Summary).@"struct".fields) |field| {
if (field.type == reconcile.TableCounts) {
const counts = @field(summary, field.name);
if (counts.total() != 0) {
try r.out.print(" {s} +{d} ~{d} -{d};", .{
field.name,
counts.inserted,
counts.updated,
counts.deleted,
});
}
}
}
try r.out.writeAll("\n");
if (changed_settings.len != 0) {
try r.out.writeAll("settings keys changed:");
for (changed_settings) |key| try r.out.print(" {s}", .{key});
try r.out.writeAll("\n");
}
switch (summary.auth_transition) {
.none => {},
.enabled => try r.out.writeAll("web authentication is now enabled\n"),
.disabled => try r.out.writeAll("web authentication is now disabled\n"),
.rotated => try r.out.writeAll("the web password changed\n"),
}
}
try r.out.flush();
}
fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
const io = r.io;
const gpa = r.gpa;
@@ -193,7 +331,15 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
defer config_db.close();
_ = try migrations.migrate(&config_db);
_ = try seedFromFile(r, &config_db, std.Io.Dir.cwd(), paths.config);
// Ruling 1: the presence of `--config` is the whole authority decision. With
// it, the file is the sole declarative source and the database is converged
// onto it here, before anything reads the database. Without it the database
// is authority and this step does not exist — a file on disk that no flag
// names changes nothing.
const reconciled_at: ?i64 = if (args.config) |config_path|
try reconcileFromFile(r, &config_db, std.Io.Dir.cwd(), config_path)
else
null;
// Every string in `cfg` points into this arena, and the pool's endpoints,
// the handler's records and the monitor's paths all keep such strings. It
@@ -203,6 +349,17 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
defer arena_state.deinit();
const arena = arena_state.allocator();
// The web layer reads the managed path out of `WebState` for the whole life
// of the process, so it takes a copy from the arena that outlives it rather
// than borrowing `argv`.
const authority: web_server.Authority = if (args.config) |config_path|
.{ .managed_file = try arena.dupe(u8, config_path) }
else
.database;
// The database stays the runtime substrate and the effective-config read
// path in both modes: in file mode the reconcile above has just made it
// agree with the file.
const cfg = try config_export.readConfig(&config_db, arena);
// From here on `std.log` goes wherever the operator asked. Before this call
@@ -472,7 +629,9 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
if (cfg.web.enabled) web_state = .{
.gpa = gpa,
.web = cfg.web,
.live_hash = .init(cfg.web.password_hash),
.authority = authority,
.reconciled_at = reconciled_at,
.live_hash = .init(cfg.web.password_hash orelse ""),
.handler = &h,
.pause = &paused,
.tracker = &tracker,
@@ -602,7 +761,7 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
// this box exists for — keeps serving.
if (cfg.web.enabled) try group.concurrent(io, web_server.serve, .{ &web_state, io });
logStartup(io, &manager, upstreams.active().len, .{
logStartup(io, authority, &manager, upstreams.active().len, .{
.udp6 = if (udp6) |*s| s.boundAddress() else null,
.udp4 = if (udp4) |*s| s.boundAddress() else null,
.tcp6 = if (tcp6) |*s| s.boundAddress() else null,
@@ -1005,8 +1164,8 @@ test "run, check and import agree on a seed file with no default group" {
\\}
;
// `run`: `serve` seeds through `bootstrap`, which is a wrapper over this
// exact call, so this is the error `run` classifies.
// `run --config`: `serve` validates through this exact call before it
// reconciles, so this is the error `run` classifies.
var database = try db.Db.open(":memory:", .{ .mode = .memory });
defer database.close();
try db.applyPragmas(&database, .{});
@@ -1082,11 +1241,11 @@ test "a configuration whose blocklist source is in no group imports and checks c
try std.testing.expectEqual(@as(usize, 1), check_diags.warningCount());
}
test "a first start that seeds from a file prints the warnings the file earned" {
// D5, second half. The seed file is read once in the life of a database, so
// a warning it earns is printed on that start or never. `run` printed
// diagnostics only when the file was rejected, which made a successful first
// start the one place the finding could not surface.
test "a start in file mode prints the warnings the file earned" {
// D5, second half. `run` printed diagnostics only when the file was
// rejected, which made a successful start the one place a finding could not
// surface. Under file authority the file is read on every start, so this is
// the line an operator sees after every restart, not only the first.
var threaded: std.Io.Threaded = .init(std.testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
@@ -1119,11 +1278,10 @@ test "a first start that seeds from a file prints the warnings the file earned"
var err_writer = err_file.writer(io, &err_buf);
const r: cli.Runner = .{ .io = io, .gpa = gpa, .out = &out, .err = &err_writer.interface };
// The file is valid, so the start succeeds and the database is seeded.
try std.testing.expectEqual(
bootstrap.Outcome.seeded,
try seedFromFile(r, &database, tmp.dir, "config.zon"),
);
// The file is valid, so the start succeeds and the database converges onto
// it. The returned stamp is what the settings envelope reports.
const reconciled_at = try reconcileFromFile(r, &database, tmp.dir, "config.zon");
try std.testing.expect(reconciled_at > 0);
try std.testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM upstreams"));
// Nothing flushes here on purpose. In production `serve` runs from this
@@ -1138,6 +1296,164 @@ test "a first start that seeds from a file prints the warnings the file earned"
try std.testing.expectEqual(@as(usize, 0), std.mem.count(u8, printed, "FAIL"));
}
test "run with a missing managed file exits 2 with the path, and never serves from the database" {
// Ruling 2: file mode fails closed. The database below is a perfectly good
// one — migrated, and the run would have reached the listeners on it in db
// mode — so a fallback would show up here as exit 0.
var threaded: std.Io.Threaded = .init(std.testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
const gpa = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var data_buf: [160]u8 = undefined;
const data_dir = try std.fmt.bufPrint(&data_buf, ".zig-cache/tmp/{s}/data", .{tmp.sub_path});
var missing_buf: [160]u8 = undefined;
const missing = try std.fmt.bufPrint(&missing_buf, ".zig-cache/tmp/{s}/nope.zon", .{tmp.sub_path});
// Real buffered `File.Writer`s, not `Writer.fixed`: this asserts the
// operator received the lines, and a fixed writer's flush is a no-op that
// counts a buffered line as delivered.
var out_file = try tmp.dir.createFile(io, "stdout.txt", .{});
defer out_file.close(io);
var err_file = try tmp.dir.createFile(io, "stderr.txt", .{});
defer err_file.close(io);
var out_buf: [4096]u8 = undefined;
var err_buf: [4096]u8 = undefined;
var out_writer = out_file.writer(io, &out_buf);
var err_writer = err_file.writer(io, &err_buf);
const r: cli.Runner = .{
.io = io,
.gpa = gpa,
.out = &out_writer.interface,
.err = &err_writer.interface,
};
try std.testing.expectEqual(cli.exit_check, run(r, .{
.paths = .{ .data_dir = data_dir },
.config = missing,
}));
const printed = try tmp.dir.readFileAlloc(io, "stderr.txt", gpa, .limited(8192));
defer gpa.free(printed);
// The path is in the message: an error name alone tells the operator nothing
// about which file the deploy got wrong.
try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, missing));
try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "no such file"));
try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "nxdns check"));
// The db-mode remediation hint belongs to db mode: in file mode the operator
// has a file, and the diagnostic above says what is wrong with it.
try std.testing.expectEqual(@as(usize, 0), std.mem.count(u8, printed, "make a file the source of truth"));
}
test "reconciled_at is stamped after the commit, not from the clock the pass wrote with" {
// Ruling 7: `reconciled_at` means "this process loaded the file at T", and
// the UI compares it against the file's mtime to say whether a restart is
// pending. A stamp taken before the read makes a file written while the read
// ran look newer than the process that loaded it — a restart-pending banner
// over a configuration that is fully applied.
//
// The pass clock is pinned to 1970 here, which the engine really does use:
// the inserted client below carries it. If the two were one value, the
// returned stamp would be 1970 too.
var threaded: std.Io.Threaded = .init(std.testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
const gpa = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.writeFile(io, .{ .sub_path = "config.zon", .data =
\\.{
\\ .groups = .{ .{ .name = "default" } },
\\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
\\ .clients = .{ .{ .ip = "192.168.1.5", .name = "tablet" } },
\\}
});
var database = try db.Db.open(":memory:", .{ .mode = .memory });
defer database.close();
try db.applyPragmas(&database, .{});
_ = try migrations.migrate(&database);
var out_buf: [4096]u8 = undefined;
var err_buf: [1024]u8 = undefined;
var out: Writer = .fixed(&out_buf);
var err: Writer = .fixed(&err_buf);
const r: cli.Runner = .{ .io = io, .gpa = gpa, .out = &out, .err = &err };
const pass_now: i64 = 42;
const before = std.Io.Clock.real.now(io).toSeconds();
const reconciled_at = try reconcileFromFileAt(r, &database, tmp.dir, "config.zon", pass_now);
// The pinned clock reached the engine, so the two values really are separate
// inputs rather than the same read twice.
try std.testing.expectEqual(pass_now, try database.queryInt(
"SELECT first_seen FROM clients WHERE ip = '192.168.1.5'",
));
try std.testing.expect(reconciled_at != pass_now);
try std.testing.expect(reconciled_at >= before);
}
test "the startup summary reports what the reconcile changed, then that nothing changed" {
// Ruling 8: the answer to "what did that restart change" without opening
// sqlite. Also ruling 5 from the operator's side — the second start of an
// unchanged file says so.
var threaded: std.Io.Threaded = .init(std.testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
const gpa = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.writeFile(io, .{ .sub_path = "config.zon", .data =
\\.{
\\ .groups = .{ .{ .name = "default" } },
\\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
\\ .web = .{ .password_hash = "$argon2id$v=19$m=19456,t=2,p=1$abc$def" },
\\}
});
var database = try db.Db.open(":memory:", .{ .mode = .memory });
defer database.close();
try db.applyPragmas(&database, .{});
_ = try migrations.migrate(&database);
var out_file = try tmp.dir.createFile(io, "stdout.txt", .{});
defer out_file.close(io);
var out_buf: [4096]u8 = undefined;
var out_writer = out_file.writer(io, &out_buf);
var err_buf: [1024]u8 = undefined;
var err: Writer = .fixed(&err_buf);
const r: cli.Runner = .{ .io = io, .gpa = gpa, .out = &out_writer.interface, .err = &err };
_ = try reconcileFromFile(r, &database, tmp.dir, "config.zon");
{
// Read back through the file: `serve` does not return for as long as the
// service runs, so a summary still in the buffer is a summary nobody
// reads.
const printed = try tmp.dir.readFileAlloc(io, "stdout.txt", gpa, .limited(8192));
defer gpa.free(printed);
try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "reconciled 'config.zon':"));
try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "upstreams +1 ~0 -0"));
try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "settings keys changed:"));
try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "web.password_hash"));
try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "authentication is now enabled"));
// The keys, never the values: the hash the file set must not be echoed.
try std.testing.expectEqual(@as(usize, 0), std.mem.count(u8, printed, "$argon2id$"));
}
try tmp.dir.writeFile(io, .{ .sub_path = "stdout.txt", .data = "" });
_ = try reconcileFromFile(r, &database, tmp.dir, "config.zon");
{
const printed = try tmp.dir.readFileAlloc(io, "stdout.txt", gpa, .limited(8192));
defer gpa.free(printed);
try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "no changes"));
}
}
/// A broken stderr, in the shape `main` builds: a buffered `File.Writer`, with
/// its drain switched to the mode that fails. `Writer.fixed` cannot stand in —
/// its flush is `noopFlush`, so it has no failure to report and its `written()`
@@ -1151,8 +1467,8 @@ fn brokenErrWriter(io: std.Io, file: std.Io.File, buffer: []u8) std.Io.File.Writ
return w;
}
test "a broken error writer does not replace the reason a seed file was rejected" {
// The operator has to see why seeding failed, and a broken stderr is not
test "a broken error writer does not replace the reason a managed file was rejected" {
// The operator has to see why the start failed, and a broken stderr is not
// that reason.
var threaded: std.Io.Threaded = .init(std.testing.allocator, .{});
defer threaded.deinit();
@@ -1187,7 +1503,7 @@ test "a broken error writer does not replace the reason a seed file was rejected
try std.testing.expectError(
error.MissingDefaultGroup,
seedFromFile(r, &database, tmp.dir, "config.zon"),
reconcileFromFile(r, &database, tmp.dir, "config.zon"),
);
// Empty, so the writer did fail — without this the assertion above would
@@ -1197,7 +1513,7 @@ test "a broken error writer does not replace the reason a seed file was rejected
try std.testing.expectEqual(@as(usize, 0), printed.len);
}
test "a broken error writer does not stop a first start that succeeded" {
test "a broken error writer does not stop a start whose file applied" {
// The call this file makes: a DNS server for a household does not refuse to
// resolve because stderr is broken. What it must not do is drop the warning
// on the floor, so the second half checks the buffer still holds it.
@@ -1233,10 +1549,7 @@ test "a broken error writer does not stop a first start that succeeded" {
var err_writer = brokenErrWriter(io, err_file, &err_buf);
const r: cli.Runner = .{ .io = io, .gpa = gpa, .out = &out, .err = &err_writer.interface };
try std.testing.expectEqual(
bootstrap.Outcome.seeded,
try seedFromFile(r, &database, tmp.dir, "config.zon"),
);
_ = try reconcileFromFile(r, &database, tmp.dir, "config.zon");
try std.testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM upstreams"));
// Nothing reached the file, so the flush really did fail.
@@ -1289,7 +1602,20 @@ fn isWildcard(addr: net.IpAddress) bool {
/// One line, at info, naming what an operator needs to see in `journalctl`
/// right after a restart: where it listens, how many upstreams it has, and
/// whether filtering is live.
fn logStartup(io: std.Io, manager: *manager_mod.Manager, upstream_count: usize, bound: Listeners) void {
///
/// Preceded by the authority (ruling 8), because every other question about a
/// restart — why a UI edit vanished, why a file edit did not apply — starts with
/// which of the two governs this process, and the journal is where an operator
/// looks for it.
fn logStartup(
io: std.Io,
authority: web_server.Authority,
manager: *manager_mod.Manager,
upstream_count: usize,
bound: Listeners,
) void {
log.info("authority: {f}", .{AuthorityText{ .authority = authority }});
var buf: [256]u8 = undefined;
var w: Writer = .fixed(&buf);
appendBind(&w, "udp", bound.udp6);
@@ -1316,6 +1642,25 @@ fn logStartup(io: std.Io, manager: *manager_mod.Manager, upstream_count: usize,
}
}
/// Which source governs this process, in the words the journal carries.
///
/// A formatter rather than a rendering into a buffer of this file's own: a
/// managed path is bounded only by `Dir.max_path_bytes`, and nested bind mounts
/// make long ones ordinary, so a fixed buffer here would silently drop exactly
/// the half of the line an operator came for. Writing straight to the log sink's
/// writer leaves the one documented, counted truncation in `platform/logging.zig`
/// as the only limit.
const AuthorityText = struct {
authority: web_server.Authority,
pub fn format(self: AuthorityText, w: *Writer) Writer.Error!void {
switch (self.authority) {
.database => try w.writeAll("database"),
.managed_file => |path| try w.print("file ({s})", .{path}),
}
}
};
/// Silent on overflow: a truncated startup line is not worth a failure path,
/// and 256 bytes hold four addresses.
fn appendBind(w: *Writer, which: []const u8, addr: ?net.IpAddress) void {
@@ -1323,6 +1668,34 @@ fn appendBind(w: *Writer, which: []const u8, addr: ?net.IpAddress) void {
w.print(" {s} {f}", .{ which, value }) catch {};
}
test "the startup line names which source governs this process, path and all" {
// Ruling 8. Every other question about a restart starts here, so the answer
// is in the journal rather than derived from the unit file by whoever is
// reading at 2am.
const gpa = std.testing.allocator;
var short: Writer.Allocating = .init(gpa);
defer short.deinit();
try short.writer.print("{f}", .{AuthorityText{ .authority = .database }});
try std.testing.expectEqualStrings("database", short.written());
var named: Writer.Allocating = .init(gpa);
defer named.deinit();
try named.writer.print("{f}", .{AuthorityText{ .authority = .{ .managed_file = "/etc/nxdns/config.zon" } }});
try std.testing.expectEqualStrings("file (/etc/nxdns/config.zon)", named.written());
// A path past any buffer this file could reasonably have picked. Nested bind
// mounts produce paths like this, and the path is the half of the line the
// operator came for — dropping it to keep the line short is the wrong trade.
const long_path = "/mnt/" ++ ("deeply-nested-mount/" ** 20) ++ "config.zon";
try std.testing.expect(long_path.len > 256);
var long: Writer.Allocating = .init(gpa);
defer long.deinit();
try long.writer.print("{f}", .{AuthorityText{ .authority = .{ .managed_file = long_path } }});
try std.testing.expect(std.mem.containsAtLeast(u8, long.written(), 1, long_path));
}
const test_address = @import("platform/address.zig");
test "one maintenance pass drops the api limiter's stale buckets" {