Files
nxdns/src/config/faults.zig
T
mokhtar 272655f60c storage: version querylog.db and migrate it in place, never reset a healthy file
querylog.db carries a schema version; migrations run at startup as one transaction after a vacuumed 0600 backup, and every failure refuses startup (exit 2, no systemd restart loop) instead of starting empty. corruption is the only automatic recreate left. the cut gate now requires a fixture-proven migration or an explicit versioned break with restore instructions, and locks shipped migration files and fixtures byte-for-byte.
2026-08-28 17:56:19 +02:00

157 lines
7.3 KiB
Zig

//! One definition of "the operator's configuration is wrong".
//!
//! `run`, `check` and `import` all sort a failure into two buckets: the
//! configuration is wrong and the operator can fix it (exit 2, `nxdns check`
//! is the next step), or something else broke (exit 1). Each subcommand used to
//! carry its own list of which errors meant which, and the lists disagreed —
//! the same seed file exited 1 from `run` and 2 from `check`. There is one list
//! now, and it is this file. Nothing else may keep a second one.
const std = @import("std");
const validate = @import("validate.zig");
/// `ValidateError` enters as a whole set rather than variant by variant, so a
/// variant added to the validator cannot silently fall through to exit 1. The
/// first five extras are the configuration faults raised outside the validator: the
/// ZON reader (`ParseZon`), the file size limit (`ConfigTooLarge`), the managed
/// file the operator named and this process cannot open
/// (`ManagedConfigUnreadable`, milestone-20 ruling 2), the composition root's
/// upstream build (`NoUsableUpstreams`) and its certificate load
/// (`BadCertificate`).
///
/// `ManagedConfigUnreadable` is the only place a missing or unreadable path is a
/// configuration fault, and it is deliberately not `FileNotFound` itself: the
/// operator named that path on the command line, so it is theirs to fix, while a
/// missing file anywhere else stays a runtime failure. `config/loader.zig` owns
/// the conversion and the closed set of open errors that qualify.
///
/// The four querylog schema refusals are here for the exit code, not because a
/// `.zon` file is wrong: `SchemaTooNew` and `SchemaUnsupported` are a deliberate
/// refusal to touch a `querylog.db` this binary does not understand, and
/// `MigrationFailed` and `MigrationBackupFailed` are a deliberate refusal to run
/// on a database whose migration or pre-migration backup did not complete. All
/// four need an operator, and none of them will resolve on a retry — exit 1 puts
/// them under systemd's `Restart=on-failure` and restart-loops a server that is
/// refusing on purpose. The unit's `RestartPreventExitStatus=2 64` is what exit
/// 2 buys them.
///
/// Not here on purpose: `error.DestructiveImport`, which reports what an import
/// would do to the database rather than the content of a file, and is the one
/// config-shaped exit 2 `cli` decides for itself.
const ConfigFault = validate.ValidateError || error{
ParseZon,
ConfigTooLarge,
ManagedConfigUnreadable,
NoUsableUpstreams,
BadCertificate,
SchemaTooNew,
SchemaUnsupported,
MigrationFailed,
MigrationBackupFailed,
};
const faults: []const anyerror = blk: {
const set = @typeInfo(ConfigFault).error_set.?;
var list: [set.len]anyerror = undefined;
for (set, 0..) |member, i| list[i] = @field(anyerror, member.name);
const frozen = list;
break :blk &frozen;
};
/// True when `err` means the configuration the operator supplied is wrong.
/// Linear over a set of about forty errors, on failure paths only.
pub fn isConfigFault(err: anyerror) bool {
for (faults) |fault| {
if (err == fault) return true;
}
return false;
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
const testing = std.testing;
test "every ValidateError variant is a configuration fault, with no exceptions" {
// The guard on the derivation: a variant added to `ValidateError` and left
// out of the classification fails here rather than exiting 1 in the field.
//
// No member is excused. This file used to subtract `error.OutOfMemory`
// here, which made the rule "every ValidateError is a fault, except one" —
// a private exclusion list of exactly the kind this file exists to abolish.
// `validate.ValidateError` no longer carries an allocation failure, so the
// rule is literal again.
inline for (@typeInfo(validate.ValidateError).error_set.?) |member| {
const err = @field(anyerror, member.name);
if (!isConfigFault(err)) {
std.debug.print("isConfigFault(error.{s}) is false, expected true\n", .{member.name});
return error.TestUnexpectedResult;
}
}
}
test "an allocation failure is not a member of the validator's verdict" {
// The root of it: `faults.zig` can only be exception-free while
// `ValidateError` holds nothing that is not a verdict on the file.
inline for (@typeInfo(validate.ValidateError).error_set.?) |member| {
if (std.mem.eql(u8, member.name, "OutOfMemory")) {
std.debug.print("ValidateError carries error.OutOfMemory\n", .{});
return error.TestUnexpectedResult;
}
}
// It is still reachable from `validate`, just not as a finding: the
// allocator can fail and the caller has to handle it.
comptime var reachable = false;
inline for (@typeInfo(validate.Error).error_set.?) |member| {
if (comptime std.mem.eql(u8, member.name, "OutOfMemory")) reachable = true;
}
try testing.expect(reachable);
}
test "the faults raised outside the validator are configuration faults" {
try testing.expect(isConfigFault(error.ParseZon));
try testing.expect(isConfigFault(error.ConfigTooLarge));
try testing.expect(isConfigFault(error.NoUsableUpstreams));
try testing.expect(isConfigFault(error.BadCertificate));
}
test "a managed file the operator named and this process cannot open is exit 2" {
// Ruling 2. The general rule below still holds — a bare `FileNotFound` is a
// runtime failure — and this is the one converted form, produced only by
// `config/loader.zig` for a path `--config` named.
try testing.expect(isConfigFault(error.ManagedConfigUnreadable));
try testing.expect(!isConfigFault(error.FileNotFound));
}
test "the seed-file errors that used to exit 1 from run are configuration faults" {
// D1 verbatim: these three reached `run` from a rejected seed file and were
// classified as runtime failures.
try testing.expect(isConfigFault(error.ParseZon));
try testing.expect(isConfigFault(error.MissingDefaultGroup));
try testing.expect(isConfigFault(error.NoUpstreams));
}
test "the querylog schema refusals exit 2 so systemd does not restart-loop them" {
try testing.expect(isConfigFault(error.SchemaTooNew));
try testing.expect(isConfigFault(error.SchemaUnsupported));
try testing.expect(isConfigFault(error.MigrationFailed));
try testing.expect(isConfigFault(error.MigrationBackupFailed));
// The refusals are a closed set. A neighbouring schema error is a corrupt
// database, not a refusal, and stays a runtime failure.
try testing.expect(!isConfigFault(error.SchemaCorrupt));
}
test "a runtime failure is not a configuration fault" {
try testing.expect(!isConfigFault(error.OutOfMemory));
try testing.expect(!isConfigFault(error.AccessDenied));
try testing.expect(!isConfigFault(error.FileNotFound));
try testing.expect(!isConfigFault(error.AddressInUse));
// A verdict on the diff, not on the file: `import` refuses a run that would
// delete rows and decides that exit code itself.
try testing.expect(!isConfigFault(error.DestructiveImport));
// Only ever a warning, so it never reaches an exit code by this route.
try testing.expect(!isConfigFault(error.SourceInNoGroup));
}