milestone 20: declarative configuration for iac
This commit is contained in:
+165
-106
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user