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
+110 -1
View File
@@ -24,6 +24,7 @@ const Allocator = std.mem.Allocator;
const address = @import("../../platform/address.zig");
const clients_repo = @import("../../storage/repositories/clients_repo.zig");
const db = @import("../../storage/db.zig");
const http_util = @import("../http_util.zig");
const model = @import("../../config/model.zig");
const mutations = @import("mutations.zig");
@@ -155,7 +156,76 @@ const resource = mutations.Resource(.{
pub const list = resource.list;
pub const get = resource.get;
pub const remove = resource.remove;
/// What file authority found when it went to delete a row.
pub const ObservedDelete = enum { deleted, declared, absent };
/// Reads `hand_edited` and acts on it inside one `BEGIN IMMEDIATE`, because the
/// two halves are a single decision. Split across two statements, a concurrent
/// `nxdns import` — which takes the same write lock for its own reconcile — can
/// promote the row between the read and the DELETE, and file authority would
/// delete a client the file had just declared. Holding the write lock across
/// both makes the promotion wait, and it then sees the row already gone or
/// still there, never half of each.
///
/// A read-only outcome commits an empty transaction, which costs nothing and
/// keeps the one exit path.
fn deleteIfObserved(database: *db.Db, arena: Allocator, id: i64) db.Error!ObservedDelete {
var tx = try db.Tx.begin(database);
errdefer tx.rollback();
const row = try clients_repo.getClient(database, arena, id);
const verdict: ObservedDelete = if (row) |found|
(if (found.hand_edited) .declared else .deleted)
else
.absent;
if (verdict == .deleted) try clients_repo.deleteClient(database, id);
try tx.commit();
return verdict;
}
/// DELETE is a `runtime_action` in the route table (milestone-20 ruling 7), so
/// file authority lets it through: an observed row is runtime state the file
/// never declared, and without a way to remove it a mis-identified or departed
/// device would be immortal — the file can promote an IP, never forget one.
/// A row the file *declares* is configuration, and deleting it would contradict
/// the file, so it answers the same 403 the router answers elsewhere. This is
/// the one policy decision that needs a row read, which is why it is here and
/// not a table column.
///
/// A row that is not there is a 404, exactly as in database mode: file
/// authority must not turn a missing row into a policy verdict.
pub fn remove(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
const path = switch (state.authority) {
.database => return resource.remove(state, io, request),
.managed_file => |managed| managed,
};
const database = mutations.requireConfigDb(state) catch
return mutations.respondFailure(request, mutations.no_config_db, delete_what);
state.config_lock.lockUncancelable(io);
const outcome = deleteIfObserved(database, request.arena, request.id.?);
state.config_lock.unlock(io);
switch (outcome catch |err| return mutations.respondFailure(
request,
mutations.dbFailure(err, group_conflict),
delete_what,
)) {
.absent => return mutations.respondFailure(request, .not_found, ""),
.declared => return http_util.respondManagedByFile(request, path),
.deleted => {},
}
if (mutations.reload(state, io)) |failure| {
return mutations.respondFailure(request, failure, delete_what);
}
return http_util.respondEmpty(request, .no_content);
}
const delete_what = "deleting a client";
/// The prefixes are one list resource with no `/{id}` route: the whole set is
/// read and replaced (ruling 9), so there is nothing to get or delete by id.
@@ -280,6 +350,45 @@ test "deleting a client removes the row and announces the change" {
try testing.expectEqual(@as(usize, 1), bench.reloads);
}
test "file authority deletes an observed client and refuses a declared one" {
var bench: mutations.Bench = undefined;
try bench.init(testing.allocator);
defer bench.deinit(testing.allocator);
try seedClient(&bench);
try bench.exec(
\\INSERT INTO clients (id, ip, group_id, hand_edited, first_seen, last_seen)
\\VALUES (2, '192.168.1.11', 1, 1, 100, 200);
);
// The declared row is configuration; it survives, and nothing is written.
try testing.expectEqual(ObservedDelete.declared, try deleteIfObserved(&bench.database, bench.arena(), 2));
try testing.expectEqual(@as(i64, 1), try bench.queryInt("SELECT count(*) FROM clients WHERE id = 2"));
try testing.expectEqual(ObservedDelete.deleted, try deleteIfObserved(&bench.database, bench.arena(), 1));
try testing.expectEqual(@as(i64, 0), try bench.queryInt("SELECT count(*) FROM clients WHERE id = 1"));
try testing.expectEqual(ObservedDelete.absent, try deleteIfObserved(&bench.database, bench.arena(), 999));
}
test "the observed check and the delete are one transaction" {
var bench: mutations.Bench = undefined;
try bench.init(testing.allocator);
defer bench.deinit(testing.allocator);
try seedClient(&bench);
// SQLite refuses a `BEGIN IMMEDIATE` inside an open transaction, so a held
// transaction is what proves this takes the write lock rather than reading
// and deleting through two unsynchronised statements — the window a
// concurrent `nxdns import` would promote the row in. Without the
// transaction both statements run and the row is gone.
var tx = try db.Tx.begin(&bench.database);
try testing.expectError(error.Unexpected, deleteIfObserved(&bench.database, bench.arena(), 1));
tx.rollback();
// The row is untouched: the refusal happened before any statement ran.
try testing.expectEqual(@as(i64, 1), try bench.queryInt("SELECT count(*) FROM clients WHERE id = 1"));
}
test "the prefix list is replaced whole" {
var bench: mutations.Bench = undefined;
try bench.init(testing.allocator);