milestone 20: declarative configuration for iac
Gates / test-aarch64 (push) Successful in 6m45s
Gates / frontend (push) Successful in 51s
Gates / test (push) Successful in 1m37s
Gates / container (push) Failing after 7m31s
Gates / package (push) Failing after 15m14s
CI / gates (push) Failing after 24m27s
Gates / test-aarch64 (push) Successful in 6m45s
Gates / frontend (push) Successful in 51s
Gates / test (push) Successful in 1m37s
Gates / container (push) Failing after 7m31s
Gates / package (push) Failing after 15m14s
CI / gates (push) Failing after 24m27s
This commit is contained in:
+57
-10
@@ -34,7 +34,7 @@ pub const Error = ReadError || Writer.Error ||
|
||||
|
||||
const header =
|
||||
\\// nxdns configuration
|
||||
\\// generated by `nxdns export` — the database is the source of truth
|
||||
\\// generated by `nxdns export` from the running configuration
|
||||
\\
|
||||
;
|
||||
|
||||
@@ -64,11 +64,13 @@ pub fn readConfig(database: *db.Db, arena: Allocator) ReadError!model.Config {
|
||||
cfg.local_records = (try local_repo.listLocalRecords(database, arena)).items;
|
||||
cfg.forward_zones = (try local_repo.listForwardZones(database, arena)).items;
|
||||
|
||||
// `web.password` is operator input and is never stored; the exported file
|
||||
// always carries an empty one. This is exactly what makes the round trip
|
||||
// stable: re-importing takes the "password is empty" branch and stores the
|
||||
// same hash.
|
||||
cfg.web.password = "";
|
||||
// `web.password` is operator input and is never stored, so the exported
|
||||
// file always states it as absent. Absent rather than `""`: a present empty
|
||||
// password is refused by `validate` (ruling 4), so exporting one would make
|
||||
// every export fail its own rules. It is also what makes the round trip
|
||||
// stable — re-applying the file takes the "password_hash written verbatim"
|
||||
// branch and stores the same hash.
|
||||
cfg.web.password = null;
|
||||
return cfg;
|
||||
}
|
||||
|
||||
@@ -250,7 +252,7 @@ test "readConfig, writeConfig, import and readConfig again produce an equal conf
|
||||
|
||||
try testing.expectEqual(a.dns.port, b.dns.port);
|
||||
try testing.expectEqual(a.logging.level, b.logging.level);
|
||||
try testing.expectEqualStrings(a.web.password_hash, b.web.password_hash);
|
||||
try testing.expectEqualStrings(a.web.password_hash.?, b.web.password_hash.?);
|
||||
try testing.expectEqual(a.groups.len, b.groups.len);
|
||||
try testing.expectEqual(a.upstreams.len, b.upstreams.len);
|
||||
for (a.upstreams, b.upstreams) |left, right| {
|
||||
@@ -303,12 +305,57 @@ test "an exported password_hash survives a re-import unchanged" {
|
||||
.upstreams = &.{.{ .url = "https://dns.example/dns-query" }},
|
||||
.web = .{ .password = "correct horse battery staple" },
|
||||
};
|
||||
try import.applyToDb(io, gpa, &database, cfg, 42, .{});
|
||||
var diags: validate.Diagnostics = .init(gpa);
|
||||
defer diags.deinit();
|
||||
try import.apply(io, gpa, &database, cfg, 42, .{}, &diags);
|
||||
|
||||
var arena_state: std.heap.ArenaAllocator = .init(gpa);
|
||||
defer arena_state.deinit();
|
||||
const exported = try readConfig(&database, arena_state.allocator());
|
||||
|
||||
try testing.expectEqualStrings("", exported.web.password);
|
||||
try testing.expect(std.mem.startsWith(u8, exported.web.password_hash, "$argon2id$"));
|
||||
try testing.expectEqual(@as(?[]const u8, null), exported.web.password);
|
||||
try testing.expect(std.mem.startsWith(u8, exported.web.password_hash.?, "$argon2id$"));
|
||||
}
|
||||
|
||||
test "the exported password form is the one validate accepts" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
const gpa = testing.allocator;
|
||||
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
var apply_diags: validate.Diagnostics = .init(gpa);
|
||||
defer apply_diags.deinit();
|
||||
try import.apply(io, gpa, &database, .{
|
||||
.groups = &.{.{ .name = "default" }},
|
||||
.upstreams = &.{.{ .url = "https://dns.example/dns-query" }},
|
||||
.web = .{ .password = "correct horse battery staple" },
|
||||
}, 42, .{}, &apply_diags);
|
||||
|
||||
var out: Writer.Allocating = .init(gpa);
|
||||
defer out.deinit();
|
||||
try writeToWriter(gpa, &database, &out.writer);
|
||||
|
||||
// The literal form matters: an export carrying `password = ""` beside a
|
||||
// stored hash would trip `EmptyWebPassword` on the way back in, so export
|
||||
// would produce a file its own validator refuses.
|
||||
try testing.expect(std.mem.indexOf(u8, out.written(), ".password = null,") != null);
|
||||
|
||||
const source = try gpa.dupeZ(u8, out.written());
|
||||
defer gpa.free(source);
|
||||
var arena_state: std.heap.ArenaAllocator = .init(gpa);
|
||||
defer arena_state.deinit();
|
||||
const reparsed = try std.zon.parse.fromSliceAlloc(
|
||||
model.Config,
|
||||
arena_state.allocator(),
|
||||
source,
|
||||
null,
|
||||
.{},
|
||||
);
|
||||
var diags: validate.Diagnostics = .init(gpa);
|
||||
defer diags.deinit();
|
||||
try validate.validate(reparsed, &diags);
|
||||
try testing.expectEqual(@as(?[]const u8, null), reparsed.web.password);
|
||||
try testing.expect(std.mem.startsWith(u8, reparsed.web.password_hash.?, "$argon2id$"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user