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
+165 -106
View File
@@ -22,6 +22,7 @@ const app = @import("app.zig");
const config_export = @import("config/export.zig");
const faults = @import("config/faults.zig");
const import = @import("config/import.zig");
const loader = @import("config/loader.zig");
const model = @import("config/model.zig");
const validate = @import("config/validate.zig");
const cert_store = @import("server/cert_store.zig");
@@ -50,19 +51,31 @@ pub const querylog_db_name = "querylog.db";
pub const Paths = struct {
/// PLAN §3.13.
data_dir: []const u8 = "/var/lib/nxdns",
config: []const u8 = "/etc/nxdns/config.zon",
};
/// `config_explicit` records whether `--config` was given, because `check` has
/// to tell "the operator named a file" from "the default path happens to
/// exist".
pub const CheckArgs = struct { paths: Paths = .{}, config_explicit: bool = false };
/// Milestone-20 ruling 1: `--config` has no default path, and its presence is
/// the whole of the authority decision. Null means the database is authority —
/// for `run` that is today's appliance behaviour, for `check` it is the database
/// that gets graded. A file sitting at a well-known path that no flag names
/// changes nothing.
pub const CheckArgs = struct { paths: Paths = .{}, config: ?[]const u8 = null };
pub const ExportArgs = struct { paths: Paths = .{}, out: ?[]const u8 = null };
pub const ImportArgs = struct { paths: Paths = .{}, file: []const u8, force: bool = false };
/// `allow_delete` is `--allow-delete`: it permits an import whose diff removes
/// declarative rows (ruling 6).
pub const ImportArgs = struct { paths: Paths = .{}, file: []const u8, allow_delete: bool = false };
/// `config` names the managed configuration file and, by being present at all,
/// makes that file the sole declarative source of truth: it is read, validated
/// and reconciled into the database on every start.
///
/// `web_dev` is milestone-8 ruling 24's `--web-dev <dir>`: serve the web
/// interface from that directory instead of the embedded assets.
pub const RunArgs = struct { paths: Paths = .{}, web_dev: ?[]const u8 = null };
pub const RunArgs = struct {
paths: Paths = .{},
config: ?[]const u8 = null,
web_dev: ?[]const u8 = null,
};
pub const Command = union(enum) {
run: RunArgs,
@@ -176,7 +189,7 @@ fn parseRunArgs(argv: []const []const u8) ParseError!RunArgs {
if (eql(flag.name, "data-dir")) {
args.paths.data_dir = try flagValue(flag, argv, &i);
} else if (eql(flag.name, "config")) {
args.paths.config = try flagValue(flag, argv, &i);
args.config = try flagValue(flag, argv, &i);
} else if (eql(flag.name, "web-dev")) {
args.web_dev = try flagValue(flag, argv, &i);
} else return error.UnknownFlag;
@@ -192,8 +205,7 @@ fn parseCheckArgs(argv: []const []const u8) ParseError!CheckArgs {
if (eql(flag.name, "data-dir")) {
args.paths.data_dir = try flagValue(flag, argv, &i);
} else if (eql(flag.name, "config")) {
args.paths.config = try flagValue(flag, argv, &i);
args.config_explicit = true;
args.config = try flagValue(flag, argv, &i);
} else return error.UnknownFlag;
}
return args;
@@ -215,7 +227,7 @@ fn parseExportArgs(argv: []const []const u8) ParseError!ExportArgs {
fn parseImportArgs(argv: []const []const u8) ParseError!ImportArgs {
var paths: Paths = .{};
var force = false;
var allow_delete = false;
var file: ?[]const u8 = null;
var i: usize = 0;
@@ -227,18 +239,18 @@ fn parseImportArgs(argv: []const []const u8) ParseError!ImportArgs {
};
if (eql(flag.name, "data-dir")) {
paths.data_dir = try flagValue(flag, argv, &i);
} else if (eql(flag.name, "force")) {
// A boolean flag takes no value, so `--force=1` is not a spelling of
// any flag this program has.
} else if (eql(flag.name, "allow-delete")) {
// A boolean flag takes no value, so `--allow-delete=1` is not a
// spelling of any flag this program has.
if (flag.attached != null) return error.UnknownFlag;
force = true;
allow_delete = true;
} else return error.UnknownFlag;
}
return .{
.paths = paths,
.file = file orelse return error.MissingArgument,
.force = force,
.allow_delete = allow_delete,
};
}
@@ -379,14 +391,30 @@ const usage_text =
\\
\\options:
\\ --data-dir DIR data directory (default /var/lib/nxdns)
\\ --config FILE configuration file (default /etc/nxdns/config.zon)
\\ --config FILE run: make FILE the sole source of configuration and
\\ reconcile the database onto it at every start;
\\ check: grade FILE instead of the database
\\ --out FILE write the export to FILE instead of stdout
\\ --force let import replace a database that already has content
\\ --allow-delete let import apply a file whose diff deletes rows
\\ --web-dev DIR run only: serve the web interface from DIR instead of
\\ the embedded assets
\\
;
/// The one remediation line for a database that holds no usable configuration.
/// Both routes to that state print it: `run` refusing an unconfigured database
/// (`NoUsableUpstreams`) and `check` finding no `config.db` at all.
///
/// It lives here, beside the exit-code mapping, because a Zig error carries no
/// text and `config/validate.zig` must stay blind to which source it is grading
/// — the same file validates a database reading and a managed file.
pub const db_source_hint =
"load one with `nxdns import <file>`, or make a file the source of truth with `nxdns run --config <file>`\n";
pub fn writeDbSourceHint(w: *Writer) void {
w.writeAll(db_source_hint) catch {};
}
/// Returns nothing, so a writer failure here has nowhere to go. Every caller
/// flushes afterwards and reports that failure instead.
pub fn usage(w: *Writer) void {
@@ -493,7 +521,7 @@ fn importImpl(r: Runner, args: ImportArgs, diags: *validate.Diagnostics) !void {
&database,
std.Io.Dir.cwd(),
args.file,
.{ .force = args.force },
.{ .allow_delete = args.allow_delete },
diags,
);
@@ -514,9 +542,10 @@ fn importImpl(r: Runner, args: ImportArgs, diags: *validate.Diagnostics) !void {
/// That file is the only list — this function keeps none of its own, which is
/// what stops `run`, `check` and `import` drifting apart again (D1).
///
/// `error.DatabaseNotEmpty` is the one exception, and it is deliberate: it
/// reports the state of the database rather than the content of a file, so it
/// is not a configuration fault, and `import` alone decides it is exit 2.
/// `error.DestructiveImport` is the one exception, and it is deliberate: it
/// reports what the diff would do to the database rather than the content of a
/// file, so it is not a configuration fault, and `import` alone decides it is
/// exit 2.
///
/// `error.OutOfMemory` is matched first, before anything else is consulted.
/// Both recording paths — `validate` and import's per-line rendering of a ZON
@@ -530,7 +559,7 @@ fn importImpl(r: Runner, args: ImportArgs, diags: *validate.Diagnostics) !void {
fn failureExitCode(e: anyerror, failures: usize) u8 {
if (e == error.OutOfMemory) return exit_runtime;
if (failures != 0) return exit_check;
if (e == error.DatabaseNotEmpty) return exit_check;
if (e == error.DestructiveImport) return exit_check;
return if (faults.isConfigFault(e)) exit_check else exit_runtime;
}
@@ -563,28 +592,31 @@ fn checkImpl(r: Runner, args: CheckArgs, probe: bool) !u8 {
// Which source was used is printed in every branch, so the answer is never
// ambiguous about what it checked.
if (args.config_explicit) {
try r.out.print("checking configuration file {s}\n", .{args.paths.config});
return checkFile(r, arena, args.paths.config, probe);
//
// Ruling 1: the invocation decides, and nothing else. The heuristic that
// used to live here — probe for `config.db`, fall back to probing
// `/etc/nxdns/config.zon`, grade whichever exists — made the answer a
// function of what happened to be on disk, which is the ambient inference
// that made seed-once bootstrap a source of documentation lies. A file no
// flag names is not graded.
if (args.config) |path| {
try r.out.print("checking configuration file {s}\n", .{path});
return checkFile(r, arena, path, probe);
}
const config_db_path = try std.fs.path.joinZ(arena, &.{ args.paths.data_dir, config_db_name });
if (try pathExists(r.io, config_db_path)) {
try r.out.print("checking database {s}\n", .{config_db_path});
return checkDatabase(r, arena, config_db_path, probe);
if (!try pathExists(r.io, config_db_path)) {
// The deleted heuristic's "nothing to check" branch, replaced rather
// than dropped: an operator running `check` on a box that has never
// been configured gets the same exit code and one line saying what to
// do about it.
try r.out.print("no config database at {s}\n", .{config_db_path});
try r.out.writeAll(db_source_hint);
return exit_check;
}
if (try pathExists(r.io, args.paths.config)) {
try r.out.print("checking configuration file {s}\n", .{args.paths.config});
return checkFile(r, arena, args.paths.config, probe);
}
try r.out.print("nothing to check: no {s} in {s} and no {s}\n", .{
config_db_name,
args.paths.data_dir,
args.paths.config,
});
return exit_check;
try r.out.print("checking database {s}\n", .{config_db_path});
return checkDatabase(r, arena, config_db_path, probe);
}
/// `check` reads `config.db` and writes nothing to it (F-c): no create, no
@@ -722,52 +754,31 @@ fn pathReadable(io: std.Io, path: []const u8) std.Io.Dir.AccessError!bool {
return true;
}
/// Grades the file `--config` named, through `config/loader.zig` — the same
/// read and the same classification `nxdns run --config` uses. That shared
/// helper is what makes the scoped agreement of ruling 2 true: `check` reaches
/// exactly the read, size and parse faults `run` would reach, and validation
/// below is the same call on the same `Config`.
///
/// D4: a named file that is missing or unreadable is the same operator-fixable
/// condition as one that fails to parse, so it is reported as a finding rather
/// than escaping as a runtime failure.
fn checkFile(r: Runner, arena: Allocator, path: []const u8, probe: bool) !u8 {
const source = std.Io.Dir.cwd().readFileAllocOptions(
r.io,
path,
arena,
.limited(import.max_config_bytes),
.of(u8),
0,
) catch |e| switch (e) {
error.StreamTooLong => {
try r.out.print("FAIL {s}: larger than {d} bytes\n", .{ path, import.max_config_bytes });
return exit_check;
},
// D4: a named file that is missing or unreadable is the same
// operator-fixable condition as one that fails to parse, so it is
// reported as a finding rather than escaping as a runtime failure. The
// implicit path already exits 2 when it finds nothing to check; naming
// the file must not change the code.
error.FileNotFound => {
try r.out.print("FAIL {s}: no such file\n", .{path});
return exit_check;
},
error.AccessDenied, error.PermissionDenied => {
try r.out.print("FAIL {s}: not readable\n", .{path});
return exit_check;
},
else => |other| return other,
};
var diags: validate.Diagnostics = .init(r.gpa);
defer diags.deinit();
// Arena-owned and never handed to `std.zon.parse.free`; see the rule and its
// `parse.zig:874` citation in `config/import.zig`.
var zon_diag: std.zon.parse.Diagnostics = .{};
const cfg = std.zon.parse.fromSliceAlloc(model.Config, arena, source, &zon_diag, .{}) catch |e| switch (e) {
const cfg = loader.load(r.io, arena, std.Io.Dir.cwd(), path, &diags) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
// The rendering carries the line and column, which is the whole value of
// running `check` against a file the operator just edited. It is
// multi-line, and `check` promises one line per problem, so it goes
// through the same `Diagnostics` channel `nxdns import` uses rather than
// into one `FAIL` record with newlines inside it.
error.ParseZon => {
var diags: validate.Diagnostics = .init(r.gpa);
defer diags.deinit();
try import.reportParseFailure(&diags, &zon_diag);
error.ManagedConfigUnreadable, error.ConfigTooLarge, error.ParseZon => {
// One line per problem, the promise the rest of `check` keeps: a
// multi-line ZON rendering is several problems, not one `FAIL`
// record with newlines inside it.
try diags.writeAll(r.out);
return exit_check;
},
// A box fault — fd exhaustion, an I/O error — is not a verdict on the
// configuration and keeps its own name at exit 1.
else => |other| return other,
};
return checkConfig(r, cfg, probe);
@@ -1013,17 +1024,22 @@ fn probeUpstreams(r: Runner, cfg: model.Config) !usize {
const testing = std.testing;
test "parseArgs accepts run with no flags" {
test "run without --config selects database authority" {
// Ruling 1: authority is the invocation. No default path, so nothing on
// disk can make a bare `run` read a file.
const command = try parseArgs(&.{"run"});
try testing.expectEqualStrings("/var/lib/nxdns", command.run.paths.data_dir);
try testing.expectEqualStrings("/etc/nxdns/config.zon", command.run.paths.config);
try testing.expectEqual(@as(?[]const u8, null), command.run.config);
try testing.expectEqual(@as(?[]const u8, null), command.run.web_dev);
}
test "parseArgs accepts run with --data-dir and --config" {
test "run --config selects file authority and the path lands in the run args" {
const attached = try parseArgs(&.{ "run", "--config=/etc/nxdns/config.zon" });
try testing.expectEqualStrings("/etc/nxdns/config.zon", attached.run.config.?);
const command = try parseArgs(&.{ "run", "--data-dir", "/srv/nx", "--config", "/tmp/c.zon" });
try testing.expectEqualStrings("/srv/nx", command.run.paths.data_dir);
try testing.expectEqualStrings("/tmp/c.zon", command.run.paths.config);
try testing.expectEqualStrings("/tmp/c.zon", command.run.config.?);
}
test "parseArgs accepts run with --web-dev in both spellings" {
@@ -1049,13 +1065,12 @@ test "parseArgs accepts --data-dir with and without an equals sign" {
try testing.expectEqualStrings("/srv/nx", separate.check.paths.data_dir);
}
test "parseArgs records whether check was given an explicit --config" {
test "bare check grades the database and check --config grades the file" {
const implicit = try parseArgs(&.{"check"});
try testing.expect(!implicit.check.config_explicit);
try testing.expectEqual(@as(?[]const u8, null), implicit.check.config);
const explicit = try parseArgs(&.{ "check", "--config=/tmp/c.zon" });
try testing.expect(explicit.check.config_explicit);
try testing.expectEqualStrings("/tmp/c.zon", explicit.check.paths.config);
try testing.expectEqualStrings("/tmp/c.zon", explicit.check.config.?);
}
test "parseArgs accepts export with --out" {
@@ -1066,17 +1081,21 @@ test "parseArgs accepts export with --out" {
try testing.expectEqual(@as(?[]const u8, null), bare.export_.out);
}
test "parseArgs accepts import with a file, --force and --data-dir" {
const command = try parseArgs(&.{ "import", "c.zon", "--force", "--data-dir=/srv/nx" });
test "parseArgs accepts import with a file, --allow-delete and --data-dir" {
const command = try parseArgs(&.{ "import", "c.zon", "--allow-delete", "--data-dir=/srv/nx" });
try testing.expectEqualStrings("c.zon", command.import_.file);
try testing.expect(command.import_.force);
try testing.expect(command.import_.allow_delete);
try testing.expectEqualStrings("/srv/nx", command.import_.paths.data_dir);
}
test "parseArgs accepts import with the file after the flags" {
const command = try parseArgs(&.{ "import", "--data-dir", "/srv/nx", "c.zon" });
try testing.expectEqualStrings("c.zon", command.import_.file);
try testing.expect(!command.import_.force);
try testing.expect(!command.import_.allow_delete);
}
test "the renamed import flag replaces --force rather than joining it" {
try testing.expectError(error.UnknownFlag, parseArgs(&.{ "import", "c.zon", "--force" }));
}
test "parseArgs accepts version" {
@@ -1091,7 +1110,7 @@ test "parseArgs accepts help, --help and -h" {
test "parseArgs rejects import without a file" {
try testing.expectError(error.MissingArgument, parseArgs(&.{"import"}));
try testing.expectError(error.MissingArgument, parseArgs(&.{ "import", "--force" }));
try testing.expectError(error.MissingArgument, parseArgs(&.{ "import", "--allow-delete" }));
}
test "parseArgs rejects --out without a value" {
@@ -1101,7 +1120,7 @@ test "parseArgs rejects --out without a value" {
test "parseArgs rejects an unknown flag" {
try testing.expectError(error.UnknownFlag, parseArgs(&.{ "check", "--nope" }));
try testing.expectError(error.UnknownFlag, parseArgs(&.{ "import", "c.zon", "--force=1" }));
try testing.expectError(error.UnknownFlag, parseArgs(&.{ "import", "c.zon", "--allow-delete=1" }));
}
test "parseArgs rejects an unknown command" {
@@ -1134,6 +1153,17 @@ test "usage_text lists every command in command_names" {
}
}
test "usage_text names the flags this milestone renamed and describes --config" {
// The flag an operator reaches for is the one the help text names. `--force`
// is gone rather than aliased (greenfield rules), and `--config` no longer
// advertises a default path, because there is none: its presence is the
// whole authority decision.
try testing.expect(std.mem.containsAtLeast(u8, usage_text, 1, " --allow-delete "));
try testing.expectEqual(@as(usize, 0), std.mem.count(u8, usage_text, "--force"));
try testing.expectEqual(@as(usize, 0), std.mem.count(u8, usage_text, "default /etc/nxdns/config.zon"));
try testing.expect(std.mem.containsAtLeast(u8, usage_text, 1, "sole source of configuration"));
}
test "usage writes non-empty text" {
var out: Writer.Allocating = .init(testing.allocator);
defer out.deinit();
@@ -1246,7 +1276,7 @@ test "runUsageError names the fault and prints the usage text" {
}
test "failureExitCode separates a fixable configuration from a runtime failure" {
try testing.expectEqual(exit_check, failureExitCode(error.DatabaseNotEmpty, 0));
try testing.expectEqual(exit_check, failureExitCode(error.DestructiveImport, 0));
try testing.expectEqual(exit_check, failureExitCode(error.ParseZon, 0));
try testing.expectEqual(exit_check, failureExitCode(error.NoUpstreams, 1));
try testing.expectEqual(exit_runtime, failureExitCode(error.IoErr, 0));
@@ -1302,9 +1332,13 @@ test "failureExitCode keeps no list of its own and classifies through config/fau
}
// The one config-shaped exit 2 `cli` still decides for itself: it reports
// the state of the database, not the content of a file.
try testing.expect(!faults.isConfigFault(error.DatabaseNotEmpty));
try testing.expectEqual(exit_check, failureExitCode(error.DatabaseNotEmpty, 0));
// what the diff would do to the database, not the content of a file.
try testing.expect(!faults.isConfigFault(error.DestructiveImport));
try testing.expectEqual(exit_check, failureExitCode(error.DestructiveImport, 0));
// The managed file goes the other way: `config/loader.zig` converts the
// path class, so the classification — not this function — carries it.
try testing.expectEqual(exit_check, failureExitCode(error.ManagedConfigUnreadable, 0));
}
const fixtures = @import("test_fixtures");
@@ -1457,10 +1491,7 @@ test "check --config naming a missing file is a reported failure at exit 2" {
defer captured.deinit();
const r = captured.runner();
const code = runCheck(r, .{
.paths = .{ .config = env.missing_path },
.config_explicit = true,
}, false);
const code = runCheck(r, .{ .config = env.missing_path }, false);
try testing.expectEqual(exit_check, code);
const text = captured.out.written();
@@ -1469,6 +1500,37 @@ test "check --config naming a missing file is a reported failure at exit 2" {
try testing.expectEqualStrings("", captured.err.written());
}
test "bare check with no config database exits 2 and says how to make one" {
// The deleted heuristic's "nothing to check" branch, replaced. The valid
// file sitting in the same directory is the other half of ruling 1: bare
// `check` grades the database, and a file no flag named is not consulted —
// if it were, this run would print "OK: no problems found" instead.
var env: CheckEnv = undefined;
try env.init();
defer env.deinit();
var captured: Captured = .init(testing.allocator);
defer captured.deinit();
const r = captured.runner();
try env.tmp.dir.writeFile(r.io, .{ .sub_path = "config.zon", .data =
\\.{
\\ .groups = .{ .{ .name = "default" } },
\\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
\\}
});
const code = runCheck(r, .{ .paths = .{ .data_dir = env.data_dir } }, false);
try testing.expectEqual(exit_check, code);
const text = captured.out.written();
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "no config database at "));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, config_db_name));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, db_source_hint));
try testing.expectEqual(@as(usize, 0), std.mem.count(u8, text, "OK"));
try testing.expectEqualStrings("", captured.err.written());
}
test "check renders a multi-line ZON failure as one FAIL line per message" {
// The rendering used to go inline into a single `FAIL` record, which put
// newlines mid-line and broke the one-line-per-problem promise the rest of
@@ -1486,10 +1548,7 @@ test "check renders a multi-line ZON failure as one FAIL line per message" {
var path_buf: [160]u8 = undefined;
const config_path = try env.path(&path_buf, "config.zon");
const code = runCheck(r, .{
.paths = .{ .config = config_path },
.config_explicit = true,
}, false);
const code = runCheck(r, .{ .config = config_path }, false);
try testing.expectEqual(exit_check, code);
const text = captured.out.written();