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
+76 -11
View File
@@ -87,10 +87,16 @@ pub const Web = struct {
enabled: bool = true,
bind: []const u8 = "0.0.0.0",
port: u16 = 8080,
/// Operator input only. Never a settings row, always exported as "".
password: []const u8 = "",
/// argon2id PHC string; "" disables authentication.
password_hash: []const u8 = "",
/// Operator input only. Never a settings row, never exported.
///
/// Optional because absence and emptiness are different declarations: null
/// means "the file says nothing about the password, keep the stored hash",
/// while a present value is an instruction to set one.
password: ?[]const u8 = null,
/// argon2id PHC string. Null means "the file says nothing, keep what is
/// stored"; an explicit `""` is the documented way to disable
/// authentication.
password_hash: ?[]const u8 = null,
session_ttl_hours: u16 = 24,
api_rate_limit_per_min: u32 = 300,
/// Requests from the box itself skip the API rate limit. On by default: a
@@ -387,9 +393,23 @@ fn isScalarSection(comptime T: type) bool {
return @typeInfo(T) == .@"struct";
}
/// `web.password` is operator input, never a settings row: it is hashed into
/// `web.password_hash` at import time and discarded (S2.5).
fn isSkipped(comptime section: []const u8, comptime field: []const u8) bool {
/// The skip policy splits by direction, because encode and decode need
/// different sets.
///
/// `web.password` is operator input and is skipped both ways: it is hashed into
/// `web.password_hash` and discarded (S2.5).
///
/// `web.password_hash` is skipped on **encode only**. The reconcile engine owns
/// that settings row directly — ruling 4 of milestone 20 makes absence mean
/// "keep the stored hash", which a general encode pass cannot express. Skipping
/// it on decode as well would leave `cfg.web.password_hash` null on every read
/// path, turn `auth.authEnabled` false, and silently open the admin UI.
fn isEncodeSkipped(comptime section: []const u8, comptime field: []const u8) bool {
if (!std.mem.eql(u8, section, "web")) return false;
return std.mem.eql(u8, field, "password") or std.mem.eql(u8, field, "password_hash");
}
fn isDecodeSkipped(comptime section: []const u8, comptime field: []const u8) bool {
return std.mem.eql(u8, section, "web") and std.mem.eql(u8, field, "password");
}
@@ -416,6 +436,10 @@ fn decodeValue(comptime T: type, text: []const u8) error{BadSettingValue}!T {
.int => std.fmt.parseInt(T, text, 10) catch error.BadSettingValue,
.@"enum" => T.fromDb(text) orelse error.BadSettingValue,
.pointer => text,
// A stored key is a present value, so an optional field decodes to a
// non-null one; the null stays reserved for the absent key, which never
// reaches this function at all.
.optional => |info| try decodeValue(info.child, text),
else => @compileError("unsupported setting field type " ++ @typeName(T)),
};
}
@@ -441,7 +465,7 @@ pub fn toSettings(cfg: Config, gpa: Allocator, out: *std.ArrayList(SettingPair))
if (comptime isScalarSection(section_field.type)) {
const section = @field(cfg, section_field.name);
inline for (@typeInfo(section_field.type).@"struct".fields) |field| {
if (comptime !isSkipped(section_field.name, field.name)) {
if (comptime !isEncodeSkipped(section_field.name, field.name)) {
const value = try encodeValue(field.type, @field(section, field.name), gpa);
errdefer gpa.free(value);
try out.append(gpa, .{ .key = section_field.name ++ "." ++ field.name, .value = value });
@@ -465,7 +489,7 @@ pub fn fromSettings(pairs: []const SettingPair, cfg: *Config, unknown_keys: *usi
inline for (@typeInfo(Config).@"struct".fields) |section_field| {
if (comptime isScalarSection(section_field.type)) {
inline for (@typeInfo(section_field.type).@"struct".fields) |field| {
if (comptime !isSkipped(section_field.name, field.name)) {
if (comptime !isDecodeSkipped(section_field.name, field.name)) {
if (std.mem.eql(u8, pair.key, section_field.name ++ "." ++ field.name)) {
@field(@field(cfg, section_field.name), field.name) =
try decodeValue(field.type, pair.value);
@@ -531,7 +555,6 @@ const expected_keys = [_][]const u8{
"web.api_rate_limit_per_min",
"web.bind",
"web.enabled",
"web.password_hash",
"web.port",
"web.session_ttl_hours",
"web.sse_max_connections_per_ip",
@@ -642,7 +665,7 @@ test "toSettings and fromSettings round-trip a non-default config" {
inline for (@typeInfo(Config).@"struct".fields) |section_field| {
if (comptime isScalarSection(section_field.type)) {
inline for (@typeInfo(section_field.type).@"struct".fields) |field| {
if (comptime !isSkipped(section_field.name, field.name)) {
if (comptime !isEncodeSkipped(section_field.name, field.name)) {
const a = @field(@field(original, section_field.name), field.name);
const b = @field(@field(restored, section_field.name), field.name);
if (comptime @typeInfo(field.type) == .pointer) {
@@ -671,6 +694,48 @@ test "an unknown settings key is counted and not an error" {
try testing.expectEqual(@as(usize, 2), unknown);
}
test "web.password_hash decodes from the settings table but is never encoded" {
const gpa = testing.allocator;
var pairs: std.ArrayList(SettingPair) = .empty;
defer {
freeSettings(gpa, pairs.items);
pairs.deinit(gpa);
}
// Encode: the reconciler owns that row, so no pass over the model emits it.
const hash = "$argon2id$v=19$m=19456,t=2,p=1$abc$def";
try toSettings(.{ .web = .{ .password_hash = hash } }, gpa, &pairs);
for (pairs.items) |pair| {
try testing.expect(!std.mem.eql(u8, pair.key, "web.password_hash"));
try testing.expect(!std.mem.eql(u8, pair.key, "web.password"));
}
// Decode: every read path still sees the stored hash, or `authEnabled`
// would read false on a box that has a password set.
var cfg: Config = .{};
var unknown: usize = 0;
const stored = [_]SettingPair{.{ .key = "web.password_hash", .value = hash }};
try fromSettings(&stored, &cfg, &unknown);
try testing.expectEqual(@as(usize, 0), unknown);
try testing.expectEqualStrings(hash, cfg.web.password_hash.?);
}
test "an optional settings field is null when absent and non-null when present" {
var absent: Config = .{};
var unknown: usize = 0;
const other = [_]SettingPair{.{ .key = "dns.port", .value = "5300" }};
try fromSettings(&other, &absent, &unknown);
try testing.expectEqual(@as(?[]const u8, null), absent.web.password_hash);
// An explicit empty string is a present value, not an absent key: it is how
// a config file disables authentication.
var empty: Config = .{};
const disabled = [_]SettingPair{.{ .key = "web.password_hash", .value = "" }};
try fromSettings(&disabled, &empty, &unknown);
try testing.expect(empty.web.password_hash != null);
try testing.expectEqualStrings("", empty.web.password_hash.?);
}
test "a malformed settings value is BadSettingValue" {
var cfg: Config = .{};
var unknown: usize = 0;