milestone 20: declarative configuration for iac
This commit is contained in:
+443
-70
@@ -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" {
|
||||
|
||||
Reference in New Issue
Block a user