milestone 20: declarative configuration for iac
This commit is contained in:
@@ -0,0 +1,323 @@
|
||||
//! The one way a configuration file becomes a `model.Config`.
|
||||
//!
|
||||
//! `nxdns run --config <file>` and `nxdns check --config <file>` must grade the
|
||||
//! same file the same way, so the read, the error classification and the parse
|
||||
//! live here rather than once per subcommand. `config/faults.zig` exists for the
|
||||
//! same reason one layer up: two copies of a rule are two rules.
|
||||
//!
|
||||
//! **The classification.** `faults.isConfigFault` deliberately excludes
|
||||
//! `FileNotFound` and `AccessDenied` in general — a missing file is usually a
|
||||
//! broken box, not a wrong configuration. The managed file is the one place
|
||||
//! where the opposite holds: the operator named that path, so a path that does
|
||||
//! not resolve is a configuration fault (exit 2, `nxdns check` is the next
|
||||
//! step). Only the path class converts:
|
||||
//!
|
||||
//! `FileNotFound`, `AccessDenied`, `PermissionDenied`, `NotDir`, `IsDir`,
|
||||
//! `SymLinkLoop`, `NameTooLong`, `BadPathName` → `ManagedConfigUnreadable`
|
||||
//!
|
||||
//! Everything else `readFileAllocOptions` can return — `SystemResources`, the
|
||||
//! two fd-quota errors, I/O failures, `OutOfMemory` — propagates unmapped and
|
||||
//! exits 1. Those are box faults a retry can clear, and the shipped unit carries
|
||||
//! `RestartPreventExitStatus=2 64`: mapping a transient failure to exit 2 would
|
||||
//! stop the service permanently on a fault that would have cleared itself.
|
||||
//!
|
||||
//! The mapping is a named error set switched exhaustively with
|
||||
//! `else => |other| return other`, so an error a Zig upgrade adds to
|
||||
//! `ReadFileAllocError` defaults to exit 1 rather than silently to exit 2.
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const model = @import("model.zig");
|
||||
const validate = @import("validate.zig");
|
||||
|
||||
/// The ceiling on a configuration file. A file above it is a configuration
|
||||
/// fault, not a resource failure: nothing an operator writes by hand comes near
|
||||
/// 4 MiB, so this is a typo or a wrong path rather than a real config.
|
||||
pub const max_config_bytes = 4 * 1024 * 1024;
|
||||
|
||||
/// Every error `readFileAllocOptions` can hand back, plus the parse.
|
||||
pub const ReadError = std.Io.Dir.ReadFileAllocError;
|
||||
|
||||
/// The open failures that mean the operator's path is wrong rather than the box
|
||||
/// being broken. Spelled out as a set rather than as switch prongs so that the
|
||||
/// list is one thing a reader can find and a test can enumerate.
|
||||
pub const PathFault = error{
|
||||
FileNotFound,
|
||||
AccessDenied,
|
||||
PermissionDenied,
|
||||
NotDir,
|
||||
IsDir,
|
||||
SymLinkLoop,
|
||||
NameTooLong,
|
||||
BadPathName,
|
||||
};
|
||||
|
||||
pub const Error = ReadError || error{ ManagedConfigUnreadable, ConfigTooLarge, ParseZon };
|
||||
|
||||
/// The classification itself, pure and testable on its own: a path-class
|
||||
/// failure becomes `ManagedConfigUnreadable`, the size limit becomes
|
||||
/// `ConfigTooLarge`, and every other member travels unchanged.
|
||||
pub fn mapReadError(e: ReadError) Error {
|
||||
return switch (e) {
|
||||
error.StreamTooLong => error.ConfigTooLarge,
|
||||
error.FileNotFound,
|
||||
error.AccessDenied,
|
||||
error.PermissionDenied,
|
||||
error.NotDir,
|
||||
error.IsDir,
|
||||
error.SymLinkLoop,
|
||||
error.NameTooLong,
|
||||
error.BadPathName,
|
||||
=> error.ManagedConfigUnreadable,
|
||||
else => |other| other,
|
||||
};
|
||||
}
|
||||
|
||||
/// The file, NUL-terminated because `std.zon.parse` needs a sentinel and
|
||||
/// `readFileAlloc` cannot supply one. Errors travel exactly as the filesystem
|
||||
/// returned them: `nxdns import` reads an operator-supplied argument, not a
|
||||
/// managed file, and its exit codes are its own.
|
||||
pub fn readSource(
|
||||
io: std.Io,
|
||||
gpa: Allocator,
|
||||
dir: std.Io.Dir,
|
||||
path: []const u8,
|
||||
) ReadError![:0]u8 {
|
||||
return dir.readFileAllocOptions(io, path, gpa, .limited(max_config_bytes), .of(u8), 0);
|
||||
}
|
||||
|
||||
/// `readSource` under the managed-file classification, with the reason recorded
|
||||
/// as a diagnostic. A Zig error carries no text, so the path an operator has to
|
||||
/// go and fix reaches them through `Diagnostics` — the same channel every other
|
||||
/// configuration problem travels down, and the reason `check` and `run` print
|
||||
/// these in one shape.
|
||||
pub fn readManaged(
|
||||
io: std.Io,
|
||||
gpa: Allocator,
|
||||
dir: std.Io.Dir,
|
||||
path: []const u8,
|
||||
diags: *validate.Diagnostics,
|
||||
) Error![:0]u8 {
|
||||
return readSource(io, gpa, dir, path) catch |e| {
|
||||
switch (e) {
|
||||
error.StreamTooLong => try diags.add(
|
||||
error.ConfigTooLarge,
|
||||
"{s}",
|
||||
.{path},
|
||||
"larger than {d} bytes",
|
||||
.{max_config_bytes},
|
||||
),
|
||||
error.FileNotFound => try diags.add(
|
||||
error.ManagedConfigUnreadable,
|
||||
"{s}",
|
||||
.{path},
|
||||
"no such file",
|
||||
.{},
|
||||
),
|
||||
error.AccessDenied, error.PermissionDenied => try diags.add(
|
||||
error.ManagedConfigUnreadable,
|
||||
"{s}",
|
||||
.{path},
|
||||
"not readable",
|
||||
.{},
|
||||
),
|
||||
error.IsDir => try diags.add(
|
||||
error.ManagedConfigUnreadable,
|
||||
"{s}",
|
||||
.{path},
|
||||
"is a directory, not a configuration file",
|
||||
.{},
|
||||
),
|
||||
error.NotDir, error.SymLinkLoop, error.NameTooLong, error.BadPathName => try diags.add(
|
||||
error.ManagedConfigUnreadable,
|
||||
"{s}",
|
||||
.{path},
|
||||
"cannot be opened ({s})",
|
||||
.{@errorName(e)},
|
||||
),
|
||||
// A box fault. It exits 1 with its own name and records nothing: a
|
||||
// diagnostic would file it under "the configuration is wrong".
|
||||
else => {},
|
||||
}
|
||||
return mapReadError(e);
|
||||
};
|
||||
}
|
||||
|
||||
/// The line and column of a ZON syntax error are the only thing the operator can
|
||||
/// act on, so they travel the same channel as every other config problem: the
|
||||
/// caller's `Diagnostics`, which `run`, `check` and `import` all render. The
|
||||
/// global log is not that channel — an operator reading command output would see
|
||||
/// a bare `ParseZon` and nothing else.
|
||||
///
|
||||
/// `std.zon.parse.Diagnostics` renders one "line:column: error: text" line per
|
||||
/// problem, plus a "note:" line each, so each rendered line becomes one
|
||||
/// `Problem` and the list keeps the parser's order. Rendering them inline would
|
||||
/// put newlines inside a single `FAIL` record.
|
||||
pub fn reportParseFailure(
|
||||
diags: *validate.Diagnostics,
|
||||
zon_diag: *const std.zon.parse.Diagnostics,
|
||||
) error{OutOfMemory}!void {
|
||||
const rendered = try std.fmt.allocPrint(diags.gpa, "{f}", .{zon_diag});
|
||||
defer diags.gpa.free(rendered);
|
||||
|
||||
var lines = std.mem.splitScalar(u8, rendered, '\n');
|
||||
while (lines.next()) |line| {
|
||||
if (line.len == 0) continue;
|
||||
try diags.add(error.ParseZon, "config", .{}, "{s}", .{line});
|
||||
}
|
||||
}
|
||||
|
||||
/// The parse, with its failure rendered. The result is arena-owned and
|
||||
/// `std.zon.parse.free` is NEVER called on it: `Parser.parseStruct` fills an
|
||||
/// absent field by copying the struct's default straight through
|
||||
/// (parse.zig:874), so a defaulted `[]const u8` — and this model has many
|
||||
/// non-empty string defaults — points into the binary's read-only data.
|
||||
/// `parse.free` keeps no record of which fields were parsed and which were
|
||||
/// defaulted, so it would `@memset` and free rodata. Freeing the arena is the
|
||||
/// only correct release.
|
||||
pub fn parse(
|
||||
arena: Allocator,
|
||||
source: [:0]const u8,
|
||||
diags: *validate.Diagnostics,
|
||||
) error{ ParseZon, OutOfMemory }!model.Config {
|
||||
var zon_diag: std.zon.parse.Diagnostics = .{};
|
||||
return std.zon.parse.fromSliceAlloc(model.Config, arena, source, &zon_diag, .{}) catch |e| switch (e) {
|
||||
error.OutOfMemory => error.OutOfMemory,
|
||||
error.ParseZon => {
|
||||
try reportParseFailure(diags, &zon_diag);
|
||||
return error.ParseZon;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/// Read and parse the managed file, everything a caller needs before
|
||||
/// `validate.validate`. Validation is deliberately left to the caller: `check`
|
||||
/// runs it beside its certificate and upstream probes, `run` runs it alone, and
|
||||
/// both call the same validator on the same `Config`, which is what makes the
|
||||
/// two agree.
|
||||
///
|
||||
/// `arena` owns both the source text and the returned configuration.
|
||||
pub fn load(
|
||||
io: std.Io,
|
||||
arena: Allocator,
|
||||
dir: std.Io.Dir,
|
||||
path: []const u8,
|
||||
diags: *validate.Diagnostics,
|
||||
) Error!model.Config {
|
||||
const source = try readManaged(io, arena, dir, path, diags);
|
||||
return parse(arena, source, diags);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
test "every path-class open failure is a managed-config fault" {
|
||||
inline for (@typeInfo(PathFault).error_set.?) |member| {
|
||||
const e = @field(ReadError, member.name);
|
||||
try testing.expectEqual(error.ManagedConfigUnreadable, mapReadError(e));
|
||||
}
|
||||
}
|
||||
|
||||
test "a box fault outside the path class propagates unmapped" {
|
||||
// Each of these exits 1: a retry can clear them, and the shipped unit's
|
||||
// `RestartPreventExitStatus=2 64` would make exit 2 permanent.
|
||||
try testing.expectEqual(error.SystemResources, mapReadError(error.SystemResources));
|
||||
try testing.expectEqual(error.ProcessFdQuotaExceeded, mapReadError(error.ProcessFdQuotaExceeded));
|
||||
try testing.expectEqual(error.SystemFdQuotaExceeded, mapReadError(error.SystemFdQuotaExceeded));
|
||||
try testing.expectEqual(error.OutOfMemory, mapReadError(error.OutOfMemory));
|
||||
try testing.expectEqual(error.InputOutput, mapReadError(error.InputOutput));
|
||||
}
|
||||
|
||||
test "the size limit is its own fault, not an unreadable path" {
|
||||
try testing.expectEqual(error.ConfigTooLarge, mapReadError(error.StreamTooLong));
|
||||
}
|
||||
|
||||
test "the path class is exactly the eight members the ruling names" {
|
||||
// A member added to `PathFault` without a decision recorded in the spec
|
||||
// fails here rather than quietly moving an exit code from 1 to 2.
|
||||
const expected = [_][]const u8{
|
||||
"FileNotFound", "AccessDenied", "PermissionDenied", "NotDir",
|
||||
"IsDir", "SymLinkLoop", "NameTooLong", "BadPathName",
|
||||
};
|
||||
const members = @typeInfo(PathFault).error_set.?;
|
||||
try testing.expectEqual(expected.len, members.len);
|
||||
inline for (members) |member| {
|
||||
var found = false;
|
||||
for (expected) |name| {
|
||||
if (std.mem.eql(u8, name, member.name)) found = true;
|
||||
}
|
||||
try testing.expect(found);
|
||||
}
|
||||
}
|
||||
|
||||
test "a missing managed file records the path and the reason" {
|
||||
var tmp = testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
|
||||
var diags: validate.Diagnostics = .init(testing.allocator);
|
||||
defer diags.deinit();
|
||||
|
||||
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
|
||||
try testing.expectError(
|
||||
error.ManagedConfigUnreadable,
|
||||
load(testing.io, arena_state.allocator(), tmp.dir, "nope.zon", &diags),
|
||||
);
|
||||
try testing.expectEqual(@as(usize, 1), diags.failureCount());
|
||||
try testing.expectEqualStrings("nope.zon", diags.problems.items[0].path);
|
||||
try testing.expectEqualStrings("no such file", diags.problems.items[0].message);
|
||||
}
|
||||
|
||||
test "a directory named as the managed file is a configuration fault, not a crash" {
|
||||
var tmp = testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
try tmp.dir.createDirPath(testing.io, "sub");
|
||||
|
||||
var diags: validate.Diagnostics = .init(testing.allocator);
|
||||
defer diags.deinit();
|
||||
|
||||
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
|
||||
try testing.expectError(
|
||||
error.ManagedConfigUnreadable,
|
||||
load(testing.io, arena_state.allocator(), tmp.dir, "sub", &diags),
|
||||
);
|
||||
try testing.expectEqual(@as(usize, 1), diags.failureCount());
|
||||
}
|
||||
|
||||
test "load parses a valid file and renders a syntax error line by line" {
|
||||
var tmp = testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
try tmp.dir.writeFile(testing.io, .{ .sub_path = "good.zon", .data =
|
||||
\\.{
|
||||
\\ .groups = .{ .{ .name = "default" } },
|
||||
\\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
|
||||
\\}
|
||||
});
|
||||
try tmp.dir.writeFile(testing.io, .{ .sub_path = "bad.zon", .data = ".{ .groups = " });
|
||||
|
||||
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
|
||||
var good_diags: validate.Diagnostics = .init(testing.allocator);
|
||||
defer good_diags.deinit();
|
||||
const cfg = try load(testing.io, arena, tmp.dir, "good.zon", &good_diags);
|
||||
try testing.expectEqual(@as(usize, 0), good_diags.problems.items.len);
|
||||
try testing.expectEqual(@as(usize, 1), cfg.upstreams.len);
|
||||
|
||||
var bad_diags: validate.Diagnostics = .init(testing.allocator);
|
||||
defer bad_diags.deinit();
|
||||
try testing.expectError(
|
||||
error.ParseZon,
|
||||
load(testing.io, arena, tmp.dir, "bad.zon", &bad_diags),
|
||||
);
|
||||
try testing.expect(bad_diags.failureCount() >= 1);
|
||||
try testing.expectEqualStrings("config", bad_diags.problems.items[0].path);
|
||||
}
|
||||
Reference in New Issue
Block a user