milestone 20: declarative configuration for iac
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user