milestone 8: web server, rest api, sse, auth, metrics and static assets
This commit is contained in:
+52
-7
@@ -7,9 +7,11 @@
|
||||
//! the moment the query finishes. That is the whole reason this file has fixed
|
||||
//! buffers instead of slices.
|
||||
//!
|
||||
//! The privacy transforms of §11.4 run inside `log`, before the entry is
|
||||
//! enqueued, so nothing downstream — the database now, Phase 8's event stream
|
||||
//! later — can observe a value the operator asked to hide.
|
||||
//! The privacy transforms of §11.4 run in `transformed`, before the entry is
|
||||
//! enqueued, so nothing downstream — the database or the event stream — can
|
||||
//! observe a value the operator asked to hide. `log` is the two halves in
|
||||
//! order; `QuerySink` calls them separately so both of its consumers see the
|
||||
//! one transformed entry.
|
||||
//!
|
||||
//! Log rows are expendable. A full queue drops the oldest unflushed entry, a
|
||||
//! failed batch is dropped whole, and a disk that crossed the critical
|
||||
@@ -191,10 +193,23 @@ pub const Logger = struct {
|
||||
/// Applies the privacy transforms and enqueues without ever blocking the
|
||||
/// query path. A full queue loses its oldest unflushed entry (§11.4).
|
||||
pub fn log(self: *Logger, io: std.Io, entry: Entry) void {
|
||||
var transformed = entry;
|
||||
if (self.cfg.hide_domains) transformed.setDomain(hidden_marker);
|
||||
if (self.cfg.hide_client_ips) transformed.setClientIp(hidden_marker);
|
||||
self.enqueue(io, transformed);
|
||||
self.logTransformed(io, self.transformed(entry));
|
||||
}
|
||||
|
||||
/// The §11.4 privacy transforms, on their own. `QuerySink` runs them once
|
||||
/// and hands the result to every consumer, so nothing downstream — the
|
||||
/// database or the event stream — can observe a value the operator asked
|
||||
/// to hide.
|
||||
pub fn transformed(self: *const Logger, entry: Entry) Entry {
|
||||
var out = entry;
|
||||
if (self.cfg.hide_domains) out.setDomain(hidden_marker);
|
||||
if (self.cfg.hide_client_ips) out.setClientIp(hidden_marker);
|
||||
return out;
|
||||
}
|
||||
|
||||
/// `log` without the transforms, for a caller that already applied them.
|
||||
pub fn logTransformed(self: *Logger, io: std.Io, entry: Entry) void {
|
||||
self.enqueue(io, entry);
|
||||
}
|
||||
|
||||
/// Retries until the put succeeds, and each failed attempt drops exactly
|
||||
@@ -556,6 +571,36 @@ test "log hides only the field its switch names" {
|
||||
try testing.expectEqualStrings("192.0.2.10", untouched.clientIp());
|
||||
}
|
||||
|
||||
test "the split halves reproduce log byte for byte" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
const configs = [_]model.Logging{
|
||||
.{},
|
||||
.{ .hide_domains = true },
|
||||
.{ .hide_client_ips = true },
|
||||
.{ .hide_domains = true, .hide_client_ips = true },
|
||||
};
|
||||
|
||||
for (configs) |cfg| {
|
||||
var buf: [4]Entry = undefined;
|
||||
var logger: Logger = .init(cfg, &buf);
|
||||
const source = sampleEntry(100, "tracker.example");
|
||||
|
||||
logger.log(io, source);
|
||||
logger.logTransformed(io, logger.transformed(source));
|
||||
|
||||
const from_log = try logger.queue.getOne(io);
|
||||
const from_halves = try logger.queue.getOne(io);
|
||||
try testing.expectEqualSlices(
|
||||
u8,
|
||||
std.mem.asBytes(&from_log),
|
||||
std.mem.asBytes(&from_halves),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
test "a full queue drops the oldest entry and counts it" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
|
||||
@@ -362,10 +362,10 @@ test "S8 case 5: a retention pass prunes the old rows and truncates the write-ah
|
||||
var pass: retention.Retention = .init(.{ .retention_days = 30 });
|
||||
pass.runOnce(io, log_db.database());
|
||||
|
||||
try testing.expectEqual(@as(u64, 1), pass.stats.passes);
|
||||
try testing.expectEqual(@as(u64, 2), pass.stats.rows_pruned);
|
||||
try testing.expectEqual(@as(u64, 1), pass.stats.checkpoints);
|
||||
try testing.expectEqual(@as(u64, 0), pass.stats.vacuums);
|
||||
try testing.expectEqual(@as(u64, 1), pass.snapshotStats().passes);
|
||||
try testing.expectEqual(@as(u64, 2), pass.snapshotStats().rows_pruned);
|
||||
try testing.expectEqual(@as(u64, 1), pass.snapshotStats().checkpoints);
|
||||
try testing.expectEqual(@as(u64, 0), pass.snapshotStats().vacuums);
|
||||
try testing.expectEqual(@as(i64, 2), try queries_repo.countRows(log_db.database()));
|
||||
// Both names stay: the dimension table is not collected.
|
||||
try testing.expectEqual(@as(i64, 2), try queries_repo.countDomains(log_db.database()));
|
||||
|
||||
@@ -5,8 +5,10 @@
|
||||
//! not appear in an export. `countClients` counts **all** rows, because S5's
|
||||
//! "has this database ever been configured" predicate needs the true count.
|
||||
//!
|
||||
//! Only list / insert / deleteAll / count, plus the two runtime calls
|
||||
//! `upsertSeen` and `pruneStale` that the Phase 7 client tracker owns.
|
||||
//! The import path is list / insert / deleteAll / count, plus the two runtime
|
||||
//! calls `upsertSeen` and `pruneStale` that the Phase 7 client tracker owns.
|
||||
//! Phase 8's REST surface is the third section: it speaks row ids and shows
|
||||
//! every client, materialised ones included.
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
@@ -15,6 +17,7 @@ const db = @import("../db.zig");
|
||||
const migrations = @import("../migrations.zig");
|
||||
const model = @import("../../config/model.zig");
|
||||
const context = @import("context.zig");
|
||||
const crud = @import("crud.zig");
|
||||
|
||||
const IdMap = context.IdMap;
|
||||
const InsertContext = context.InsertContext;
|
||||
@@ -193,6 +196,255 @@ pub fn countClientPrefixes(database: *db.Db) db.Error!i64 {
|
||||
return database.queryInt("SELECT count(*) FROM client_prefixes");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// REST surface (milestone 8)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// `/api/clients` shows every row — a device the server materialised from live
|
||||
// traffic is exactly what the operator wants to name — so these reads carry no
|
||||
// `hand_edited` filter and report the flag instead. The write shapes take
|
||||
// `group_id`, not a group name: the REST layer identifies every resource by row
|
||||
// id, and a `group_id` no group holds must surface as the foreign-key violation
|
||||
// it is.
|
||||
|
||||
pub const ClientRow = struct {
|
||||
id: i64,
|
||||
ip: []const u8,
|
||||
/// `clients.name` is nullable; a NULL reads as `""`, as it does on the
|
||||
/// import path.
|
||||
name: []const u8,
|
||||
group_id: i64,
|
||||
group: []const u8,
|
||||
hand_edited: bool,
|
||||
first_seen: i64,
|
||||
last_seen: i64,
|
||||
};
|
||||
|
||||
/// What creating a client by hand needs. `first_seen` and `last_seen` are the
|
||||
/// caller's clock, so this shape does not carry them.
|
||||
pub const ClientInput = struct {
|
||||
ip: []const u8,
|
||||
name: []const u8 = "",
|
||||
group_id: i64,
|
||||
};
|
||||
|
||||
/// What editing a client may change (ruling 9). `ip` is absent on purpose: it is
|
||||
/// the identity live traffic matches a row by, and rewriting it would collide
|
||||
/// with the row the tracker materialises for the device that still holds it.
|
||||
pub const ClientEdit = struct {
|
||||
name: []const u8 = "",
|
||||
group_id: i64,
|
||||
};
|
||||
|
||||
const list_client_rows_sql =
|
||||
\\SELECT c.id, c.ip, c.name, c.group_id, g.name, c.hand_edited, c.first_seen, c.last_seen
|
||||
\\ FROM clients c
|
||||
\\ JOIN groups g ON g.id = c.group_id
|
||||
\\ ORDER BY c.ip
|
||||
;
|
||||
|
||||
const get_client_sql =
|
||||
\\SELECT c.id, c.ip, c.name, c.group_id, g.name, c.hand_edited, c.first_seen, c.last_seen
|
||||
\\ FROM clients c
|
||||
\\ JOIN groups g ON g.id = c.group_id
|
||||
\\ WHERE c.id = ?1
|
||||
;
|
||||
|
||||
/// Every client, materialised ones included. Every string is a heap copy owned
|
||||
/// by `gpa`.
|
||||
pub fn listClientRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(ClientRow) {
|
||||
var stmt = try database.prepare(list_client_rows_sql);
|
||||
defer stmt.deinit();
|
||||
|
||||
var out: std.ArrayList(ClientRow) = .empty;
|
||||
errdefer out.deinit(gpa);
|
||||
errdefer freeClientRows(gpa, out.items);
|
||||
|
||||
while (try stmt.step()) {
|
||||
const row = try readClientRow(&stmt, gpa);
|
||||
errdefer freeClientRow(gpa, row);
|
||||
try out.append(gpa, row);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
pub fn freeClientRow(gpa: Allocator, row: ClientRow) void {
|
||||
gpa.free(row.ip);
|
||||
gpa.free(row.name);
|
||||
gpa.free(row.group);
|
||||
}
|
||||
|
||||
pub fn freeClientRows(gpa: Allocator, items: []const ClientRow) void {
|
||||
for (items) |item| freeClientRow(gpa, item);
|
||||
}
|
||||
|
||||
pub fn getClient(database: *db.Db, gpa: Allocator, id: i64) db.Error!?ClientRow {
|
||||
var stmt = try database.prepare(get_client_sql);
|
||||
defer stmt.deinit();
|
||||
try stmt.bindInt(1, id);
|
||||
if (!try stmt.step()) return null;
|
||||
return try readClientRow(&stmt, gpa);
|
||||
}
|
||||
|
||||
fn readClientRow(stmt: *db.Stmt, gpa: Allocator) db.Error!ClientRow {
|
||||
const ip = try stmt.columnTextAlloc(gpa, 1);
|
||||
errdefer gpa.free(ip);
|
||||
const name = try stmt.columnTextAlloc(gpa, 2);
|
||||
errdefer gpa.free(name);
|
||||
const group = try stmt.columnTextAlloc(gpa, 4);
|
||||
errdefer gpa.free(group);
|
||||
return .{
|
||||
.id = stmt.columnInt(0),
|
||||
.ip = ip,
|
||||
.name = name,
|
||||
.group_id = stmt.columnInt(3),
|
||||
.group = group,
|
||||
.hand_edited = stmt.columnBool(5),
|
||||
.first_seen = stmt.columnInt(6),
|
||||
.last_seen = stmt.columnInt(7),
|
||||
};
|
||||
}
|
||||
|
||||
const insert_client_row_sql =
|
||||
\\INSERT INTO clients (ip, name, group_id, hand_edited, first_seen, last_seen)
|
||||
\\VALUES (?1, ?2, ?3, 1, ?4, ?4)
|
||||
;
|
||||
|
||||
/// Creates a client the operator typed, so `hand_edited` is 1 — the difference
|
||||
/// from `upsertSeen`, which materialises what the DNS path saw and never claims
|
||||
/// a row is configuration.
|
||||
///
|
||||
/// `now_s` is unix epoch seconds, from `std.Io.Clock.real`; it seeds both
|
||||
/// timestamps, exactly as `insertClient` does on the import path.
|
||||
///
|
||||
/// `error.Constraint`: `clients.ip` is UNIQUE, or `group_id` names no group.
|
||||
pub fn insertClientRow(database: *db.Db, item: ClientInput, now_s: i64) db.Error!i64 {
|
||||
var stmt = try database.prepare(insert_client_row_sql);
|
||||
defer stmt.deinit();
|
||||
try stmt.bindText(1, item.ip);
|
||||
try stmt.bindText(2, item.name);
|
||||
try stmt.bindInt(3, item.group_id);
|
||||
try stmt.bindInt(4, now_s);
|
||||
try stmt.exec();
|
||||
return database.lastInsertRowid();
|
||||
}
|
||||
|
||||
/// An edit is what makes a client configuration, so this sets `hand_edited` to
|
||||
/// 1 on every call (ruling 9) and `pruneStale` stops considering the row.
|
||||
/// `first_seen` and `last_seen` stay the tracker's.
|
||||
///
|
||||
/// `error.NotFound`: no client holds `id`. `error.Constraint`: `group_id` names
|
||||
/// no group.
|
||||
pub fn updateClient(database: *db.Db, id: i64, item: ClientEdit) db.Error!void {
|
||||
var stmt = try database.prepare(
|
||||
"UPDATE clients SET name = ?2, group_id = ?3, hand_edited = 1 WHERE id = ?1",
|
||||
);
|
||||
defer stmt.deinit();
|
||||
try stmt.bindInt(1, id);
|
||||
try stmt.bindText(2, item.name);
|
||||
try stmt.bindInt(3, item.group_id);
|
||||
return crud.execStrict(database, &stmt);
|
||||
}
|
||||
|
||||
/// `error.NotFound`: no client holds `id`. Nothing references `clients`, so a
|
||||
/// delete cannot violate a constraint — and a device that keeps querying
|
||||
/// re-materialises through `upsertSeen`.
|
||||
pub fn deleteClient(database: *db.Db, id: i64) db.Error!void {
|
||||
var stmt = try database.prepare("DELETE FROM clients WHERE id = ?1");
|
||||
defer stmt.deinit();
|
||||
try stmt.bindInt(1, id);
|
||||
return crud.execStrict(database, &stmt);
|
||||
}
|
||||
|
||||
pub const ClientPrefixRow = struct {
|
||||
id: i64,
|
||||
prefix: []const u8,
|
||||
group_id: i64,
|
||||
group: []const u8,
|
||||
priority: i32,
|
||||
};
|
||||
|
||||
pub const ClientPrefixInput = struct {
|
||||
prefix: []const u8,
|
||||
group_id: i64,
|
||||
priority: i32 = 100,
|
||||
};
|
||||
|
||||
const list_client_prefix_rows_sql =
|
||||
\\SELECT p.id, p.prefix, p.group_id, g.name, p.priority FROM client_prefixes p
|
||||
\\ JOIN groups g ON g.id = p.group_id
|
||||
\\ ORDER BY p.prefix
|
||||
;
|
||||
|
||||
/// Same order as `listClientPrefixes`; every string is a heap copy owned by
|
||||
/// `gpa`.
|
||||
pub fn listClientPrefixRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(ClientPrefixRow) {
|
||||
var stmt = try database.prepare(list_client_prefix_rows_sql);
|
||||
defer stmt.deinit();
|
||||
|
||||
var out: std.ArrayList(ClientPrefixRow) = .empty;
|
||||
errdefer out.deinit(gpa);
|
||||
errdefer freeClientPrefixRows(gpa, out.items);
|
||||
|
||||
while (try stmt.step()) {
|
||||
const prefix = try stmt.columnTextAlloc(gpa, 1);
|
||||
errdefer gpa.free(prefix);
|
||||
const group = try stmt.columnTextAlloc(gpa, 3);
|
||||
errdefer gpa.free(group);
|
||||
// The column is a 64-bit integer; the row field is `i32`. A value
|
||||
// outside that range means something other than nxdns wrote the row.
|
||||
const priority = std.math.cast(i32, stmt.columnInt(4)) orelse return error.Mismatch;
|
||||
try out.append(gpa, .{
|
||||
.id = stmt.columnInt(0),
|
||||
.prefix = prefix,
|
||||
.group_id = stmt.columnInt(2),
|
||||
.group = group,
|
||||
.priority = priority,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
pub fn freeClientPrefixRow(gpa: Allocator, row: ClientPrefixRow) void {
|
||||
gpa.free(row.prefix);
|
||||
gpa.free(row.group);
|
||||
}
|
||||
|
||||
pub fn freeClientPrefixRows(gpa: Allocator, items: []const ClientPrefixRow) void {
|
||||
for (items) |item| freeClientPrefixRow(gpa, item);
|
||||
}
|
||||
|
||||
/// Replaces the whole prefix table inside a transaction (ruling 9 makes
|
||||
/// `/api/client-prefixes` one atomic list resource). Row ids do not survive the
|
||||
/// call: every row is written fresh.
|
||||
///
|
||||
/// `error.Constraint`: `client_prefixes.prefix` is UNIQUE, so a prefix repeated
|
||||
/// in `items` is rejected rather than collapsed — two rows for one prefix with
|
||||
/// different groups or priorities is a contradiction, not a set. Also fires when
|
||||
/// a `group_id` names no group. Either way the old table survives untouched.
|
||||
pub fn replaceClientPrefixes(database: *db.Db, items: []const ClientPrefixInput) db.Error!void {
|
||||
var tx = try db.Tx.begin(database);
|
||||
errdefer tx.rollback();
|
||||
|
||||
try deleteAllClientPrefixes(database);
|
||||
|
||||
var stmt = try database.prepare(
|
||||
"INSERT INTO client_prefixes (prefix, group_id, priority) VALUES (?1, ?2, ?3)",
|
||||
);
|
||||
defer stmt.deinit();
|
||||
for (items) |item| {
|
||||
// `reset` clears the bindings too, so all three are bound again on
|
||||
// every pass.
|
||||
try stmt.reset();
|
||||
try stmt.bindText(1, item.prefix);
|
||||
try stmt.bindInt(2, item.group_id);
|
||||
try stmt.bindInt(3, item.priority);
|
||||
try stmt.exec();
|
||||
}
|
||||
|
||||
try tx.commit();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -498,3 +750,187 @@ test "listClientPrefixes is leak-safe under allocation failure" {
|
||||
try ids.put(testing.allocator, "kids", 2);
|
||||
try testing.checkAllAllocationFailures(testing.allocator, listClientPrefixesUnderFailure, .{&ids});
|
||||
}
|
||||
|
||||
// --- REST surface ----------------------------------------------------------
|
||||
|
||||
test "a client round-trips through insert, get, list, update and delete" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
var ids = try seedGroups(&database);
|
||||
defer ids.deinit(testing.allocator);
|
||||
|
||||
const id = try insertClientRow(&database, .{
|
||||
.ip = "192.168.1.7",
|
||||
.name = "printer",
|
||||
.group_id = 2,
|
||||
}, 1700000000);
|
||||
|
||||
const fetched = (try getClient(&database, testing.allocator, id)).?;
|
||||
defer freeClientRow(testing.allocator, fetched);
|
||||
try testing.expectEqual(id, fetched.id);
|
||||
try testing.expectEqualStrings("192.168.1.7", fetched.ip);
|
||||
try testing.expectEqualStrings("printer", fetched.name);
|
||||
try testing.expectEqual(@as(i64, 2), fetched.group_id);
|
||||
try testing.expectEqualStrings("kids", fetched.group);
|
||||
try testing.expect(fetched.hand_edited);
|
||||
try testing.expectEqual(@as(i64, 1700000000), fetched.first_seen);
|
||||
try testing.expectEqual(@as(i64, 1700000000), fetched.last_seen);
|
||||
|
||||
try updateClient(&database, id, .{ .name = "label printer", .group_id = 1 });
|
||||
const updated = (try getClient(&database, testing.allocator, id)).?;
|
||||
defer freeClientRow(testing.allocator, updated);
|
||||
try testing.expectEqualStrings("label printer", updated.name);
|
||||
try testing.expectEqualStrings("default", updated.group);
|
||||
try testing.expectEqualStrings("192.168.1.7", updated.ip);
|
||||
// The tracker's timestamps are not the editor's to move.
|
||||
try testing.expectEqual(@as(i64, 1700000000), updated.first_seen);
|
||||
|
||||
try deleteClient(&database, id);
|
||||
try testing.expectEqual(@as(?ClientRow, null), try getClient(&database, testing.allocator, id));
|
||||
try testing.expectEqual(@as(i64, 0), try countClients(&database));
|
||||
}
|
||||
|
||||
test "listClientRows shows materialised clients with hand_edited false" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
var ids = try seedGroups(&database);
|
||||
defer ids.deinit(testing.allocator);
|
||||
|
||||
_ = try insertClientRow(&database, .{ .ip = "192.168.1.10", .name = "desk", .group_id = 1 }, 1700000000);
|
||||
try upsertSeen(&database, "192.168.1.99", 1700000500);
|
||||
|
||||
var rows = try listClientRows(&database, testing.allocator);
|
||||
defer rows.deinit(testing.allocator);
|
||||
defer freeClientRows(testing.allocator, rows.items);
|
||||
|
||||
try testing.expectEqual(@as(usize, 2), rows.items.len);
|
||||
try testing.expectEqualStrings("192.168.1.10", rows.items[0].ip);
|
||||
try testing.expect(rows.items[0].hand_edited);
|
||||
try testing.expectEqualStrings("192.168.1.99", rows.items[1].ip);
|
||||
try testing.expect(!rows.items[1].hand_edited);
|
||||
// A materialised row carries no name; NULL reads as the empty string.
|
||||
try testing.expectEqualStrings("", rows.items[1].name);
|
||||
try testing.expectEqualStrings("default", rows.items[1].group);
|
||||
}
|
||||
|
||||
test "an edited client stops being a candidate for pruneStale" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
try upsertSeen(&database, "192.168.1.99", 1700000000);
|
||||
const id = (try database.queryInt("SELECT id FROM clients WHERE ip = '192.168.1.99'"));
|
||||
try updateClient(&database, id, .{ .name = "tv", .group_id = 1 });
|
||||
|
||||
try testing.expectEqual(@as(u32, 0), try pruneStale(&database, 1800000000));
|
||||
const row = (try getClient(&database, testing.allocator, id)).?;
|
||||
defer freeClientRow(testing.allocator, row);
|
||||
try testing.expect(row.hand_edited);
|
||||
}
|
||||
|
||||
test "client update and delete report NotFound for an id no row holds" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
try testing.expectError(error.NotFound, updateClient(&database, 404, .{ .group_id = 1 }));
|
||||
try testing.expectError(error.NotFound, deleteClient(&database, 404));
|
||||
try testing.expectEqual(@as(?ClientRow, null), try getClient(&database, testing.allocator, 404));
|
||||
}
|
||||
|
||||
test "a duplicate ip and an unknown group both surface as error.Constraint" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
const id = try insertClientRow(&database, .{ .ip = "192.168.1.7", .group_id = 1 }, 1);
|
||||
try testing.expectError(
|
||||
error.Constraint,
|
||||
insertClientRow(&database, .{ .ip = "192.168.1.7", .group_id = 1 }, 1),
|
||||
);
|
||||
try testing.expectError(
|
||||
error.Constraint,
|
||||
insertClientRow(&database, .{ .ip = "192.168.1.8", .group_id = 404 }, 1),
|
||||
);
|
||||
try testing.expectError(error.Constraint, updateClient(&database, id, .{ .group_id = 404 }));
|
||||
try testing.expectEqual(@as(i64, 1), try countClients(&database));
|
||||
}
|
||||
|
||||
test "client prefixes replace as one atomic list" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
var ids = try seedGroups(&database);
|
||||
defer ids.deinit(testing.allocator);
|
||||
|
||||
try replaceClientPrefixes(&database, &.{
|
||||
.{ .prefix = "192.168.2.0/24", .group_id = 2, .priority = 10 },
|
||||
.{ .prefix = "192.168.1.0/24", .group_id = 1, .priority = 50 },
|
||||
});
|
||||
|
||||
var rows = try listClientPrefixRows(&database, testing.allocator);
|
||||
defer rows.deinit(testing.allocator);
|
||||
defer freeClientPrefixRows(testing.allocator, rows.items);
|
||||
try testing.expectEqual(@as(usize, 2), rows.items.len);
|
||||
try testing.expectEqualStrings("192.168.1.0/24", rows.items[0].prefix);
|
||||
try testing.expectEqual(@as(i64, 1), rows.items[0].group_id);
|
||||
try testing.expectEqualStrings("default", rows.items[0].group);
|
||||
try testing.expectEqual(@as(i32, 50), rows.items[0].priority);
|
||||
try testing.expectEqualStrings("192.168.2.0/24", rows.items[1].prefix);
|
||||
try testing.expectEqualStrings("kids", rows.items[1].group);
|
||||
try testing.expectEqual(@as(i32, 10), rows.items[1].priority);
|
||||
try testing.expect(rows.items[0].id != rows.items[1].id);
|
||||
|
||||
// The replacement is total, and the empty list clears the table.
|
||||
try replaceClientPrefixes(&database, &.{.{ .prefix = "fd00::/48", .group_id = 2 }});
|
||||
try testing.expectEqual(@as(i64, 1), try countClientPrefixes(&database));
|
||||
try replaceClientPrefixes(&database, &.{});
|
||||
try testing.expectEqual(@as(i64, 0), try countClientPrefixes(&database));
|
||||
}
|
||||
|
||||
test "replaceClientPrefixes rolls back on a duplicate prefix or an unknown group" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
var ids = try seedGroups(&database);
|
||||
defer ids.deinit(testing.allocator);
|
||||
|
||||
try replaceClientPrefixes(&database, &.{.{ .prefix = "192.168.1.0/24", .group_id = 1 }});
|
||||
|
||||
try testing.expectError(error.Constraint, replaceClientPrefixes(&database, &.{
|
||||
.{ .prefix = "10.0.0.0/8", .group_id = 2 },
|
||||
.{ .prefix = "10.0.0.0/8", .group_id = 1 },
|
||||
}));
|
||||
try testing.expectError(error.Constraint, replaceClientPrefixes(&database, &.{
|
||||
.{ .prefix = "10.0.0.0/8", .group_id = 404 },
|
||||
}));
|
||||
|
||||
// Both failures left the previous list in place.
|
||||
var rows = try listClientPrefixRows(&database, testing.allocator);
|
||||
defer rows.deinit(testing.allocator);
|
||||
defer freeClientPrefixRows(testing.allocator, rows.items);
|
||||
try testing.expectEqual(@as(usize, 1), rows.items.len);
|
||||
try testing.expectEqualStrings("192.168.1.0/24", rows.items[0].prefix);
|
||||
}
|
||||
|
||||
fn clientRowsUnderFailure(gpa: Allocator, ids: *const IdMap) !void {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
try database.exec("INSERT INTO groups (id, name) VALUES (2, 'kids');");
|
||||
try seedClients(&database, ids);
|
||||
try seedClientPrefixes(&database, ids);
|
||||
|
||||
var rows = try listClientRows(&database, gpa);
|
||||
defer rows.deinit(gpa);
|
||||
defer freeClientRows(gpa, rows.items);
|
||||
|
||||
const one = (try getClient(&database, gpa, rows.items[0].id)).?;
|
||||
defer freeClientRow(gpa, one);
|
||||
|
||||
var prefixes = try listClientPrefixRows(&database, gpa);
|
||||
defer prefixes.deinit(gpa);
|
||||
defer freeClientPrefixRows(gpa, prefixes.items);
|
||||
}
|
||||
|
||||
test "the client read surface is leak-safe under allocation failure" {
|
||||
var ids: IdMap = .empty;
|
||||
defer ids.deinit(testing.allocator);
|
||||
try ids.put(testing.allocator, "default", 1);
|
||||
try ids.put(testing.allocator, "kids", 2);
|
||||
try testing.checkAllAllocationFailures(testing.allocator, clientRowsUnderFailure, .{&ids});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
//! What every by-id mutation in this directory shares.
|
||||
//!
|
||||
//! `UPDATE ... WHERE id = ?1` and `DELETE ... WHERE id = ?1` are silent about a
|
||||
//! row that is not there: SQLite reports success and touches nothing. The REST
|
||||
//! layer must answer 404 instead, so every mutation runs its statement through
|
||||
//! `execStrict`, which turns "touched no row" into `error.NotFound`.
|
||||
//!
|
||||
//! `error.Constraint` needs no helper — `Stmt.exec` already reports it, and the
|
||||
//! handler layer maps it to 409. Each mutation documents which constraint of
|
||||
//! `config_schema.ddl_v1` can fire.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
const db = @import("../db.zig");
|
||||
const migrations = @import("../migrations.zig");
|
||||
|
||||
/// Runs a statement that must touch exactly one row.
|
||||
///
|
||||
/// `Db.changes` counts the rows the *last completed* statement wrote, so it
|
||||
/// must be read immediately after `exec`. SQLite counts a row an `UPDATE`
|
||||
/// rewrote with identical values, so a no-op edit is not mistaken for a missing
|
||||
/// row.
|
||||
pub fn execStrict(database: *db.Db, stmt: *db.Stmt) db.Error!void {
|
||||
try stmt.exec();
|
||||
if (database.changes() == 0) return error.NotFound;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
fn openTable() !db.Db {
|
||||
var database = try db.Db.open(":memory:", .{ .mode = .memory });
|
||||
errdefer database.close();
|
||||
try db.applyPragmas(&database, .{});
|
||||
_ = try migrations.migrate(&database);
|
||||
try database.exec("INSERT INTO forward_zones (id, zone, resolver) VALUES (1, 'home.arpa', 'udp://10.0.0.1:53');");
|
||||
return database;
|
||||
}
|
||||
|
||||
test "execStrict passes an update that touches a row" {
|
||||
var database = try openTable();
|
||||
defer database.close();
|
||||
|
||||
var stmt = try database.prepare("UPDATE forward_zones SET resolver = ?2 WHERE id = ?1");
|
||||
defer stmt.deinit();
|
||||
try stmt.bindInt(1, 1);
|
||||
try stmt.bindText(2, "udp://10.0.0.2:53");
|
||||
try execStrict(&database, &stmt);
|
||||
}
|
||||
|
||||
test "execStrict passes an update that rewrites the same value" {
|
||||
var database = try openTable();
|
||||
defer database.close();
|
||||
|
||||
var stmt = try database.prepare("UPDATE forward_zones SET resolver = ?2 WHERE id = ?1");
|
||||
defer stmt.deinit();
|
||||
try stmt.bindInt(1, 1);
|
||||
try stmt.bindText(2, "udp://10.0.0.1:53");
|
||||
try execStrict(&database, &stmt);
|
||||
}
|
||||
|
||||
test "execStrict reports NotFound for an id no row holds" {
|
||||
var database = try openTable();
|
||||
defer database.close();
|
||||
|
||||
var update = try database.prepare("UPDATE forward_zones SET resolver = 'x' WHERE id = ?1");
|
||||
defer update.deinit();
|
||||
try update.bindInt(1, 404);
|
||||
try testing.expectError(error.NotFound, execStrict(&database, &update));
|
||||
|
||||
var delete = try database.prepare("DELETE FROM forward_zones WHERE id = ?1");
|
||||
defer delete.deinit();
|
||||
try delete.bindInt(1, 404);
|
||||
try testing.expectError(error.NotFound, execStrict(&database, &delete));
|
||||
|
||||
try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM forward_zones"));
|
||||
}
|
||||
@@ -4,9 +4,9 @@
|
||||
//! stable across an import, so an export carrying them would not re-import into
|
||||
//! the same shape.
|
||||
//!
|
||||
//! Only list / insert / deleteAll / count exist. Update-by-id, delete-by-id and
|
||||
//! paged reads are Phase 8's REST surface; adding them now would be untested,
|
||||
//! unused generality.
|
||||
//! The import path is list / insert / deleteAll / count. Phase 8's REST surface
|
||||
//! is the second half of this file: it speaks row ids, because that is what a
|
||||
//! `/api/groups/{id}` request names.
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
@@ -15,6 +15,7 @@ const db = @import("../db.zig");
|
||||
const migrations = @import("../migrations.zig");
|
||||
const model = @import("../../config/model.zig");
|
||||
const context = @import("context.zig");
|
||||
const crud = @import("crud.zig");
|
||||
|
||||
const IdMap = context.IdMap;
|
||||
const InsertContext = context.InsertContext;
|
||||
@@ -133,6 +134,148 @@ pub fn countGroupSources(database: *db.Db) db.Error!i64 {
|
||||
return database.queryInt("SELECT count(*) FROM group_sources");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// REST surface (milestone 8)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// The write shape is `model.Group`: its two fields are exactly the columns an
|
||||
// operator may set, so the REST layer needs no third shape for them.
|
||||
|
||||
pub const GroupRow = struct { id: i64, name: []const u8, safe_search: bool };
|
||||
|
||||
/// Same order as `listGroups`; every string is a heap copy owned by `gpa`.
|
||||
pub fn listGroupRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(GroupRow) {
|
||||
var stmt = try database.prepare("SELECT id, name, safe_search FROM groups ORDER BY name");
|
||||
defer stmt.deinit();
|
||||
|
||||
var out: std.ArrayList(GroupRow) = .empty;
|
||||
errdefer out.deinit(gpa);
|
||||
errdefer freeGroupRows(gpa, out.items);
|
||||
|
||||
while (try stmt.step()) {
|
||||
const name = try stmt.columnTextAlloc(gpa, 1);
|
||||
errdefer gpa.free(name);
|
||||
try out.append(gpa, .{
|
||||
.id = stmt.columnInt(0),
|
||||
.name = name,
|
||||
.safe_search = stmt.columnBool(2),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
pub fn freeGroupRow(gpa: Allocator, row: GroupRow) void {
|
||||
gpa.free(row.name);
|
||||
}
|
||||
|
||||
pub fn freeGroupRows(gpa: Allocator, items: []const GroupRow) void {
|
||||
for (items) |item| freeGroupRow(gpa, item);
|
||||
}
|
||||
|
||||
pub fn getGroup(database: *db.Db, gpa: Allocator, id: i64) db.Error!?GroupRow {
|
||||
var stmt = try database.prepare("SELECT id, name, safe_search FROM groups WHERE id = ?1");
|
||||
defer stmt.deinit();
|
||||
try stmt.bindInt(1, id);
|
||||
if (!try stmt.step()) return null;
|
||||
return .{
|
||||
.id = stmt.columnInt(0),
|
||||
.name = try stmt.columnTextAlloc(gpa, 1),
|
||||
.safe_search = stmt.columnBool(2),
|
||||
};
|
||||
}
|
||||
|
||||
/// `error.Constraint`: `groups.name` is UNIQUE.
|
||||
pub fn insertGroupRow(database: *db.Db, item: model.Group) db.Error!i64 {
|
||||
var stmt = try database.prepare("INSERT INTO groups (name, safe_search) VALUES (?1, ?2)");
|
||||
defer stmt.deinit();
|
||||
try stmt.bindText(1, item.name);
|
||||
try stmt.bindBool(2, item.safe_search);
|
||||
try stmt.exec();
|
||||
return database.lastInsertRowid();
|
||||
}
|
||||
|
||||
/// `error.NotFound`: no group holds `id`. `error.Constraint`: `groups.name` is
|
||||
/// UNIQUE.
|
||||
pub fn updateGroup(database: *db.Db, id: i64, item: model.Group) db.Error!void {
|
||||
var stmt = try database.prepare("UPDATE groups SET name = ?2, safe_search = ?3 WHERE id = ?1");
|
||||
defer stmt.deinit();
|
||||
try stmt.bindInt(1, id);
|
||||
try stmt.bindText(2, item.name);
|
||||
try stmt.bindBool(3, item.safe_search);
|
||||
return crud.execStrict(database, &stmt);
|
||||
}
|
||||
|
||||
/// `error.NotFound`: no group holds `id`. `error.Constraint`: `clients.group_id`
|
||||
/// references it and carries no `ON DELETE` action, so a group any client sits
|
||||
/// in cannot go. `client_prefixes`, `group_sources` and `rules` cascade and
|
||||
/// disappear with it.
|
||||
pub fn deleteGroup(database: *db.Db, id: i64) db.Error!void {
|
||||
var stmt = try database.prepare("DELETE FROM groups WHERE id = ?1");
|
||||
defer stmt.deinit();
|
||||
try stmt.bindInt(1, id);
|
||||
return crud.execStrict(database, &stmt);
|
||||
}
|
||||
|
||||
/// The blocklist sources assigned to one group, ascending. An unknown
|
||||
/// `group_id` yields an empty list, not an error: the caller that needs the
|
||||
/// distinction reads the group itself.
|
||||
pub fn listGroupSourceIds(database: *db.Db, gpa: Allocator, group_id: i64) db.Error!std.ArrayList(i64) {
|
||||
var stmt = try database.prepare("SELECT source_id FROM group_sources WHERE group_id = ?1 ORDER BY source_id");
|
||||
defer stmt.deinit();
|
||||
try stmt.bindInt(1, group_id);
|
||||
|
||||
var out: std.ArrayList(i64) = .empty;
|
||||
errdefer out.deinit(gpa);
|
||||
while (try stmt.step()) try out.append(gpa, stmt.columnInt(0));
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Replaces one group's whole source assignment inside a transaction, so a
|
||||
/// caller never observes the group with half a set.
|
||||
///
|
||||
/// The assignment is a set: an id repeated in `source_ids` is written once.
|
||||
/// Order does not survive, and re-running the call with the same ids is a no-op
|
||||
/// as far as any reader can tell.
|
||||
///
|
||||
/// `error.NotFound`: no group holds `group_id` — checked explicitly, because an
|
||||
/// empty `source_ids` writes nothing and would otherwise report success for a
|
||||
/// group that does not exist. `error.Constraint`: an id in `source_ids` names no
|
||||
/// `blocklist_sources` row.
|
||||
pub fn setGroupSources(database: *db.Db, group_id: i64, source_ids: []const i64) db.Error!void {
|
||||
var tx = try db.Tx.begin(database);
|
||||
errdefer tx.rollback();
|
||||
|
||||
if (!try groupExists(database, group_id)) return error.NotFound;
|
||||
|
||||
{
|
||||
var delete = try database.prepare("DELETE FROM group_sources WHERE group_id = ?1");
|
||||
defer delete.deinit();
|
||||
try delete.bindInt(1, group_id);
|
||||
try delete.exec();
|
||||
}
|
||||
|
||||
var insert = try database.prepare("INSERT INTO group_sources (group_id, source_id) VALUES (?1, ?2)");
|
||||
defer insert.deinit();
|
||||
for (source_ids, 0..) |source_id, i| {
|
||||
if (std.mem.indexOfScalar(i64, source_ids[0..i], source_id) != null) continue;
|
||||
// `reset` clears the bindings too, so both parameters are bound again
|
||||
// on every pass.
|
||||
try insert.reset();
|
||||
try insert.bindInt(1, group_id);
|
||||
try insert.bindInt(2, source_id);
|
||||
try insert.exec();
|
||||
}
|
||||
|
||||
try tx.commit();
|
||||
}
|
||||
|
||||
fn groupExists(database: *db.Db, id: i64) db.Error!bool {
|
||||
var stmt = try database.prepare("SELECT 1 FROM groups WHERE id = ?1");
|
||||
defer stmt.deinit();
|
||||
try stmt.bindInt(1, id);
|
||||
return stmt.step();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -284,3 +427,207 @@ test "listGroupSources is leak-safe under allocation failure" {
|
||||
defer ids.deinit(testing.allocator);
|
||||
try testing.checkAllAllocationFailures(testing.allocator, listGroupSourcesUnderFailure, .{&ids});
|
||||
}
|
||||
|
||||
// --- REST surface ----------------------------------------------------------
|
||||
|
||||
/// Two sources with known ids, for the group-source assignment tests.
|
||||
fn seedSourceRows(database: *db.Db) !void {
|
||||
try database.exec(
|
||||
\\INSERT INTO blocklist_sources (id, url, name) VALUES
|
||||
\\ (10, 'https://a.example/list.txt', 'A'),
|
||||
\\ (20, 'https://b.example/list.txt', 'B'),
|
||||
\\ (30, 'https://c.example/list.txt', 'C');
|
||||
);
|
||||
}
|
||||
|
||||
test "a group round-trips through insert, get, list, update and delete" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
const id = try insertGroupRow(&database, .{ .name = "kids", .safe_search = true });
|
||||
try testing.expect(id > 1);
|
||||
|
||||
const fetched = (try getGroup(&database, testing.allocator, id)).?;
|
||||
defer freeGroupRow(testing.allocator, fetched);
|
||||
try testing.expectEqual(id, fetched.id);
|
||||
try testing.expectEqualStrings("kids", fetched.name);
|
||||
try testing.expect(fetched.safe_search);
|
||||
|
||||
var rows = try listGroupRows(&database, testing.allocator);
|
||||
defer rows.deinit(testing.allocator);
|
||||
defer freeGroupRows(testing.allocator, rows.items);
|
||||
try testing.expectEqual(@as(usize, 2), rows.items.len);
|
||||
try testing.expectEqualStrings("default", rows.items[0].name);
|
||||
try testing.expectEqual(@as(i64, 1), rows.items[0].id);
|
||||
try testing.expectEqualStrings("kids", rows.items[1].name);
|
||||
try testing.expectEqual(id, rows.items[1].id);
|
||||
|
||||
try updateGroup(&database, id, .{ .name = "teens", .safe_search = false });
|
||||
const updated = (try getGroup(&database, testing.allocator, id)).?;
|
||||
defer freeGroupRow(testing.allocator, updated);
|
||||
try testing.expectEqualStrings("teens", updated.name);
|
||||
try testing.expect(!updated.safe_search);
|
||||
|
||||
try deleteGroup(&database, id);
|
||||
try testing.expectEqual(@as(?GroupRow, null), try getGroup(&database, testing.allocator, id));
|
||||
try testing.expectEqual(@as(i64, 1), try countGroups(&database));
|
||||
}
|
||||
|
||||
test "update and delete report NotFound for an id no group holds" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
try testing.expectError(error.NotFound, updateGroup(&database, 404, .{ .name = "ghost" }));
|
||||
try testing.expectError(error.NotFound, deleteGroup(&database, 404));
|
||||
try testing.expectEqual(@as(?GroupRow, null), try getGroup(&database, testing.allocator, 404));
|
||||
}
|
||||
|
||||
test "a duplicate group name surfaces as error.Constraint on insert and on update" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
const id = try insertGroupRow(&database, .{ .name = "kids" });
|
||||
try testing.expectError(error.Constraint, insertGroupRow(&database, .{ .name = "kids" }));
|
||||
try testing.expectError(error.Constraint, updateGroup(&database, id, .{ .name = "default" }));
|
||||
}
|
||||
|
||||
test "deleting a group a client sits in surfaces as error.Constraint" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
const id = try insertGroupRow(&database, .{ .name = "kids" });
|
||||
try database.exec(
|
||||
\\INSERT INTO clients (ip, group_id, first_seen, last_seen)
|
||||
\\VALUES ('192.168.1.9', 2, 1, 1);
|
||||
);
|
||||
try testing.expectError(error.Constraint, deleteGroup(&database, id));
|
||||
try testing.expectEqual(@as(i64, 2), try countGroups(&database));
|
||||
}
|
||||
|
||||
test "deleting a group takes its rules, prefixes and source assignment with it" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
try seedSourceRows(&database);
|
||||
|
||||
const id = try insertGroupRow(&database, .{ .name = "kids" });
|
||||
try setGroupSources(&database, id, &.{ 10, 20 });
|
||||
try database.exec("INSERT INTO rules (group_id, pattern, kind, action, created_at) VALUES (2, 'x.example', 'exact', 'block', 1);");
|
||||
try database.exec("INSERT INTO client_prefixes (prefix, group_id) VALUES ('10.0.0.0/8', 2);");
|
||||
|
||||
try deleteGroup(&database, id);
|
||||
try testing.expectEqual(@as(i64, 0), try countGroupSources(&database));
|
||||
try testing.expectEqual(@as(i64, 0), try database.queryInt("SELECT count(*) FROM rules"));
|
||||
try testing.expectEqual(@as(i64, 0), try database.queryInt("SELECT count(*) FROM client_prefixes"));
|
||||
}
|
||||
|
||||
fn sourceIds(database: *db.Db, group_id: i64) ![]i64 {
|
||||
var list = try listGroupSourceIds(database, testing.allocator, group_id);
|
||||
return list.toOwnedSlice(testing.allocator);
|
||||
}
|
||||
|
||||
test "setGroupSources replaces the whole set and repeats without effect" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
try seedSourceRows(&database);
|
||||
|
||||
try setGroupSources(&database, 1, &.{ 20, 10 });
|
||||
{
|
||||
const ids = try sourceIds(&database, 1);
|
||||
defer testing.allocator.free(ids);
|
||||
try testing.expectEqualSlices(i64, &.{ 10, 20 }, ids);
|
||||
}
|
||||
|
||||
// Same set again: the rows are rewritten, the observable state is not.
|
||||
try setGroupSources(&database, 1, &.{ 10, 20 });
|
||||
{
|
||||
const ids = try sourceIds(&database, 1);
|
||||
defer testing.allocator.free(ids);
|
||||
try testing.expectEqualSlices(i64, &.{ 10, 20 }, ids);
|
||||
}
|
||||
try testing.expectEqual(@as(i64, 2), try countGroupSources(&database));
|
||||
|
||||
// A different set replaces, it does not merge.
|
||||
try setGroupSources(&database, 1, &.{30});
|
||||
{
|
||||
const ids = try sourceIds(&database, 1);
|
||||
defer testing.allocator.free(ids);
|
||||
try testing.expectEqualSlices(i64, &.{30}, ids);
|
||||
}
|
||||
|
||||
// The empty set clears it.
|
||||
try setGroupSources(&database, 1, &.{});
|
||||
try testing.expectEqual(@as(i64, 0), try countGroupSources(&database));
|
||||
}
|
||||
|
||||
test "setGroupSources writes a repeated id once" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
try seedSourceRows(&database);
|
||||
|
||||
try setGroupSources(&database, 1, &.{ 10, 10, 20, 10 });
|
||||
const ids = try sourceIds(&database, 1);
|
||||
defer testing.allocator.free(ids);
|
||||
try testing.expectEqualSlices(i64, &.{ 10, 20 }, ids);
|
||||
}
|
||||
|
||||
test "setGroupSources leaves other groups alone" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
try seedSourceRows(&database);
|
||||
const kids = try insertGroupRow(&database, .{ .name = "kids" });
|
||||
|
||||
try setGroupSources(&database, 1, &.{10});
|
||||
try setGroupSources(&database, kids, &.{ 20, 30 });
|
||||
try setGroupSources(&database, kids, &.{20});
|
||||
|
||||
const default_ids = try sourceIds(&database, 1);
|
||||
defer testing.allocator.free(default_ids);
|
||||
try testing.expectEqualSlices(i64, &.{10}, default_ids);
|
||||
const kids_ids = try sourceIds(&database, kids);
|
||||
defer testing.allocator.free(kids_ids);
|
||||
try testing.expectEqualSlices(i64, &.{20}, kids_ids);
|
||||
}
|
||||
|
||||
test "setGroupSources reports NotFound for a group that does not exist" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
try seedSourceRows(&database);
|
||||
|
||||
try testing.expectError(error.NotFound, setGroupSources(&database, 404, &.{10}));
|
||||
// Including the case where the empty set writes nothing at all.
|
||||
try testing.expectError(error.NotFound, setGroupSources(&database, 404, &.{}));
|
||||
}
|
||||
|
||||
test "setGroupSources rolls back and reports Constraint for an unknown source id" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
try seedSourceRows(&database);
|
||||
try setGroupSources(&database, 1, &.{10});
|
||||
|
||||
try testing.expectError(error.Constraint, setGroupSources(&database, 1, &.{ 20, 999 }));
|
||||
|
||||
// The prior assignment survived: the failed call wrote nothing.
|
||||
const ids = try sourceIds(&database, 1);
|
||||
defer testing.allocator.free(ids);
|
||||
try testing.expectEqualSlices(i64, &.{10}, ids);
|
||||
}
|
||||
|
||||
fn groupRowsUnderFailure(gpa: Allocator) !void {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
try seedGroups(&database);
|
||||
|
||||
var rows = try listGroupRows(&database, gpa);
|
||||
defer rows.deinit(gpa);
|
||||
defer freeGroupRows(gpa, rows.items);
|
||||
|
||||
const one = (try getGroup(&database, gpa, 1)).?;
|
||||
defer freeGroupRow(gpa, one);
|
||||
|
||||
var ids = try listGroupSourceIds(&database, gpa, 1);
|
||||
defer ids.deinit(gpa);
|
||||
}
|
||||
|
||||
test "the group read surface is leak-safe under allocation failure" {
|
||||
try testing.checkAllAllocationFailures(testing.allocator, groupRowsUnderFailure, .{});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! `local_records` and `forward_zones`.
|
||||
//!
|
||||
//! Only list / insert / deleteAll / count exist.
|
||||
//! The import path is list / insert / deleteAll / count. Phase 8's REST surface
|
||||
//! follows each table's section: it speaks row ids, because that is what an
|
||||
//! `/api/local-records/{id}` or `/api/forward-zones/{id}` request names.
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
@@ -9,6 +11,7 @@ const db = @import("../db.zig");
|
||||
const migrations = @import("../migrations.zig");
|
||||
const model = @import("../../config/model.zig");
|
||||
const context = @import("context.zig");
|
||||
const crud = @import("crud.zig");
|
||||
|
||||
const InsertContext = context.InsertContext;
|
||||
|
||||
@@ -121,6 +124,185 @@ pub fn countForwardZones(database: *db.Db) db.Error!i64 {
|
||||
return database.queryInt("SELECT count(*) FROM forward_zones");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// REST surface (milestone 8)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Neither table references another, so the write shapes are `model.LocalRecord`
|
||||
// and `model.ForwardZone` unchanged: their fields are exactly the columns.
|
||||
|
||||
pub const LocalRecordRow = struct {
|
||||
id: i64,
|
||||
name: []const u8,
|
||||
rtype: model.RecordType,
|
||||
value: []const u8,
|
||||
ttl: u32,
|
||||
};
|
||||
|
||||
const list_local_record_rows_sql =
|
||||
\\SELECT id, name, rtype, value, ttl FROM local_records ORDER BY name, rtype, value
|
||||
;
|
||||
|
||||
/// Same order as `listLocalRecords`; every string is a heap copy owned by `gpa`.
|
||||
pub fn listLocalRecordRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(LocalRecordRow) {
|
||||
var stmt = try database.prepare(list_local_record_rows_sql);
|
||||
defer stmt.deinit();
|
||||
|
||||
var out: std.ArrayList(LocalRecordRow) = .empty;
|
||||
errdefer out.deinit(gpa);
|
||||
errdefer freeLocalRecordRows(gpa, out.items);
|
||||
|
||||
while (try stmt.step()) {
|
||||
const row = try readLocalRecordRow(&stmt, gpa);
|
||||
errdefer freeLocalRecordRow(gpa, row);
|
||||
try out.append(gpa, row);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
pub fn freeLocalRecordRow(gpa: Allocator, row: LocalRecordRow) void {
|
||||
gpa.free(row.name);
|
||||
gpa.free(row.value);
|
||||
}
|
||||
|
||||
pub fn freeLocalRecordRows(gpa: Allocator, items: []const LocalRecordRow) void {
|
||||
for (items) |item| freeLocalRecordRow(gpa, item);
|
||||
}
|
||||
|
||||
pub fn getLocalRecord(database: *db.Db, gpa: Allocator, id: i64) db.Error!?LocalRecordRow {
|
||||
var stmt = try database.prepare("SELECT id, name, rtype, value, ttl FROM local_records WHERE id = ?1");
|
||||
defer stmt.deinit();
|
||||
try stmt.bindInt(1, id);
|
||||
if (!try stmt.step()) return null;
|
||||
return try readLocalRecordRow(&stmt, gpa);
|
||||
}
|
||||
|
||||
fn readLocalRecordRow(stmt: *db.Stmt, gpa: Allocator) db.Error!LocalRecordRow {
|
||||
// The DDL's CHECK constraint makes the decode total for any row nxdns
|
||||
// wrote; `error.Mismatch` covers a row that something else wrote, and the
|
||||
// same goes for a `ttl` outside `u32`.
|
||||
const rtype = model.RecordType.fromDb(stmt.columnText(2)) orelse return error.Mismatch;
|
||||
const ttl = std.math.cast(u32, stmt.columnInt(4)) orelse return error.Mismatch;
|
||||
const name = try stmt.columnTextAlloc(gpa, 1);
|
||||
errdefer gpa.free(name);
|
||||
const value = try stmt.columnTextAlloc(gpa, 3);
|
||||
errdefer gpa.free(value);
|
||||
return .{ .id = stmt.columnInt(0), .name = name, .rtype = rtype, .value = value, .ttl = ttl };
|
||||
}
|
||||
|
||||
/// `error.Constraint`: `local_records` declares `UNIQUE(name, rtype, value)`, so
|
||||
/// the same answer cannot be stored twice — a second TTL for one record would be
|
||||
/// two truths.
|
||||
pub fn insertLocalRecordRow(database: *db.Db, item: model.LocalRecord) db.Error!i64 {
|
||||
var stmt = try database.prepare(insert_local_record_sql);
|
||||
defer stmt.deinit();
|
||||
try stmt.bindText(1, item.name);
|
||||
try stmt.bindText(2, item.rtype.toDb());
|
||||
try stmt.bindText(3, item.value);
|
||||
try stmt.bindInt(4, item.ttl);
|
||||
try stmt.exec();
|
||||
return database.lastInsertRowid();
|
||||
}
|
||||
|
||||
/// `error.NotFound`: no record holds `id`. `error.Constraint`:
|
||||
/// `UNIQUE(name, rtype, value)`.
|
||||
pub fn updateLocalRecord(database: *db.Db, id: i64, item: model.LocalRecord) db.Error!void {
|
||||
var stmt = try database.prepare(
|
||||
"UPDATE local_records SET name = ?2, rtype = ?3, value = ?4, ttl = ?5 WHERE id = ?1",
|
||||
);
|
||||
defer stmt.deinit();
|
||||
try stmt.bindInt(1, id);
|
||||
try stmt.bindText(2, item.name);
|
||||
try stmt.bindText(3, item.rtype.toDb());
|
||||
try stmt.bindText(4, item.value);
|
||||
try stmt.bindInt(5, item.ttl);
|
||||
return crud.execStrict(database, &stmt);
|
||||
}
|
||||
|
||||
/// `error.NotFound`: no record holds `id`. Nothing references `local_records`,
|
||||
/// so a delete cannot violate a constraint.
|
||||
pub fn deleteLocalRecord(database: *db.Db, id: i64) db.Error!void {
|
||||
var stmt = try database.prepare("DELETE FROM local_records WHERE id = ?1");
|
||||
defer stmt.deinit();
|
||||
try stmt.bindInt(1, id);
|
||||
return crud.execStrict(database, &stmt);
|
||||
}
|
||||
|
||||
pub const ForwardZoneRow = struct { id: i64, zone: []const u8, resolver: []const u8 };
|
||||
|
||||
/// Same order as `listForwardZones`; every string is a heap copy owned by `gpa`.
|
||||
pub fn listForwardZoneRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(ForwardZoneRow) {
|
||||
var stmt = try database.prepare("SELECT id, zone, resolver FROM forward_zones ORDER BY zone");
|
||||
defer stmt.deinit();
|
||||
|
||||
var out: std.ArrayList(ForwardZoneRow) = .empty;
|
||||
errdefer out.deinit(gpa);
|
||||
errdefer freeForwardZoneRows(gpa, out.items);
|
||||
|
||||
while (try stmt.step()) {
|
||||
const row = try readForwardZoneRow(&stmt, gpa);
|
||||
errdefer freeForwardZoneRow(gpa, row);
|
||||
try out.append(gpa, row);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
pub fn freeForwardZoneRow(gpa: Allocator, row: ForwardZoneRow) void {
|
||||
gpa.free(row.zone);
|
||||
gpa.free(row.resolver);
|
||||
}
|
||||
|
||||
pub fn freeForwardZoneRows(gpa: Allocator, items: []const ForwardZoneRow) void {
|
||||
for (items) |item| freeForwardZoneRow(gpa, item);
|
||||
}
|
||||
|
||||
pub fn getForwardZone(database: *db.Db, gpa: Allocator, id: i64) db.Error!?ForwardZoneRow {
|
||||
var stmt = try database.prepare("SELECT id, zone, resolver FROM forward_zones WHERE id = ?1");
|
||||
defer stmt.deinit();
|
||||
try stmt.bindInt(1, id);
|
||||
if (!try stmt.step()) return null;
|
||||
return try readForwardZoneRow(&stmt, gpa);
|
||||
}
|
||||
|
||||
fn readForwardZoneRow(stmt: *db.Stmt, gpa: Allocator) db.Error!ForwardZoneRow {
|
||||
const zone = try stmt.columnTextAlloc(gpa, 1);
|
||||
errdefer gpa.free(zone);
|
||||
const resolver = try stmt.columnTextAlloc(gpa, 2);
|
||||
errdefer gpa.free(resolver);
|
||||
return .{ .id = stmt.columnInt(0), .zone = zone, .resolver = resolver };
|
||||
}
|
||||
|
||||
/// `error.Constraint`: `forward_zones.zone` is UNIQUE — one zone has one
|
||||
/// resolver.
|
||||
pub fn insertForwardZoneRow(database: *db.Db, item: model.ForwardZone) db.Error!i64 {
|
||||
var stmt = try database.prepare("INSERT INTO forward_zones (zone, resolver) VALUES (?1, ?2)");
|
||||
defer stmt.deinit();
|
||||
try stmt.bindText(1, item.zone);
|
||||
try stmt.bindText(2, item.resolver);
|
||||
try stmt.exec();
|
||||
return database.lastInsertRowid();
|
||||
}
|
||||
|
||||
/// `error.NotFound`: no zone holds `id`. `error.Constraint`:
|
||||
/// `forward_zones.zone` is UNIQUE.
|
||||
pub fn updateForwardZone(database: *db.Db, id: i64, item: model.ForwardZone) db.Error!void {
|
||||
var stmt = try database.prepare("UPDATE forward_zones SET zone = ?2, resolver = ?3 WHERE id = ?1");
|
||||
defer stmt.deinit();
|
||||
try stmt.bindInt(1, id);
|
||||
try stmt.bindText(2, item.zone);
|
||||
try stmt.bindText(3, item.resolver);
|
||||
return crud.execStrict(database, &stmt);
|
||||
}
|
||||
|
||||
/// `error.NotFound`: no zone holds `id`. Nothing references `forward_zones`, so
|
||||
/// a delete cannot violate a constraint.
|
||||
pub fn deleteForwardZone(database: *db.Db, id: i64) db.Error!void {
|
||||
var stmt = try database.prepare("DELETE FROM forward_zones WHERE id = ?1");
|
||||
defer stmt.deinit();
|
||||
try stmt.bindInt(1, id);
|
||||
return crud.execStrict(database, &stmt);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -253,3 +435,161 @@ fn listForwardZonesUnderFailure(gpa: Allocator) !void {
|
||||
test "listForwardZones is leak-safe under allocation failure" {
|
||||
try testing.checkAllAllocationFailures(testing.allocator, listForwardZonesUnderFailure, .{});
|
||||
}
|
||||
|
||||
// --- REST surface ----------------------------------------------------------
|
||||
|
||||
test "a local record round-trips through insert, get, list, update and delete" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
const id = try insertLocalRecordRow(&database, .{
|
||||
.name = "nas.home.arpa",
|
||||
.rtype = .a,
|
||||
.value = "192.168.1.5",
|
||||
});
|
||||
|
||||
const fetched = (try getLocalRecord(&database, testing.allocator, id)).?;
|
||||
defer freeLocalRecordRow(testing.allocator, fetched);
|
||||
try testing.expectEqual(id, fetched.id);
|
||||
try testing.expectEqualStrings("nas.home.arpa", fetched.name);
|
||||
try testing.expectEqual(model.RecordType.a, fetched.rtype);
|
||||
try testing.expectEqualStrings("192.168.1.5", fetched.value);
|
||||
try testing.expectEqual(@as(u32, 300), fetched.ttl);
|
||||
|
||||
try updateLocalRecord(&database, id, .{
|
||||
.name = "nas.home.arpa",
|
||||
.rtype = .a,
|
||||
.value = "192.168.1.6",
|
||||
.ttl = 60,
|
||||
});
|
||||
const updated = (try getLocalRecord(&database, testing.allocator, id)).?;
|
||||
defer freeLocalRecordRow(testing.allocator, updated);
|
||||
try testing.expectEqualStrings("192.168.1.6", updated.value);
|
||||
try testing.expectEqual(@as(u32, 60), updated.ttl);
|
||||
|
||||
var rows = try listLocalRecordRows(&database, testing.allocator);
|
||||
defer rows.deinit(testing.allocator);
|
||||
defer freeLocalRecordRows(testing.allocator, rows.items);
|
||||
try testing.expectEqual(@as(usize, 1), rows.items.len);
|
||||
try testing.expectEqual(id, rows.items[0].id);
|
||||
|
||||
try deleteLocalRecord(&database, id);
|
||||
try testing.expectEqual(@as(?LocalRecordRow, null), try getLocalRecord(&database, testing.allocator, id));
|
||||
try testing.expectEqual(@as(i64, 0), try countLocalRecords(&database));
|
||||
}
|
||||
|
||||
test "local record update and delete report NotFound for an id no row holds" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
const item: model.LocalRecord = .{ .name = "x.home.arpa", .rtype = .a, .value = "10.0.0.1" };
|
||||
try testing.expectError(error.NotFound, updateLocalRecord(&database, 404, item));
|
||||
try testing.expectError(error.NotFound, deleteLocalRecord(&database, 404));
|
||||
try testing.expectEqual(@as(?LocalRecordRow, null), try getLocalRecord(&database, testing.allocator, 404));
|
||||
}
|
||||
|
||||
test "a duplicate name, rtype and value surfaces as error.Constraint" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
const first: model.LocalRecord = .{ .name = "nas.home.arpa", .rtype = .a, .value = "192.168.1.5" };
|
||||
_ = try insertLocalRecordRow(&database, first);
|
||||
// The TTL is not part of the UNIQUE key, so a second TTL is still a clash.
|
||||
try testing.expectError(error.Constraint, insertLocalRecordRow(&database, .{
|
||||
.name = "nas.home.arpa",
|
||||
.rtype = .a,
|
||||
.value = "192.168.1.5",
|
||||
.ttl = 60,
|
||||
}));
|
||||
|
||||
const other = try insertLocalRecordRow(&database, .{
|
||||
.name = "nas.home.arpa",
|
||||
.rtype = .aaaa,
|
||||
.value = "fd00::5",
|
||||
});
|
||||
try testing.expectError(error.Constraint, updateLocalRecord(&database, other, first));
|
||||
try testing.expectEqual(@as(i64, 2), try countLocalRecords(&database));
|
||||
}
|
||||
|
||||
test "a forward zone round-trips through insert, get, list, update and delete" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
const id = try insertForwardZoneRow(&database, .{
|
||||
.zone = "home.arpa",
|
||||
.resolver = "udp://192.168.1.1:53",
|
||||
});
|
||||
|
||||
const fetched = (try getForwardZone(&database, testing.allocator, id)).?;
|
||||
defer freeForwardZoneRow(testing.allocator, fetched);
|
||||
try testing.expectEqual(id, fetched.id);
|
||||
try testing.expectEqualStrings("home.arpa", fetched.zone);
|
||||
try testing.expectEqualStrings("udp://192.168.1.1:53", fetched.resolver);
|
||||
|
||||
try updateForwardZone(&database, id, .{ .zone = "lab.example", .resolver = "tcp://[fd00::1]:53" });
|
||||
const updated = (try getForwardZone(&database, testing.allocator, id)).?;
|
||||
defer freeForwardZoneRow(testing.allocator, updated);
|
||||
try testing.expectEqualStrings("lab.example", updated.zone);
|
||||
try testing.expectEqualStrings("tcp://[fd00::1]:53", updated.resolver);
|
||||
|
||||
var rows = try listForwardZoneRows(&database, testing.allocator);
|
||||
defer rows.deinit(testing.allocator);
|
||||
defer freeForwardZoneRows(testing.allocator, rows.items);
|
||||
try testing.expectEqual(@as(usize, 1), rows.items.len);
|
||||
try testing.expectEqual(id, rows.items[0].id);
|
||||
|
||||
try deleteForwardZone(&database, id);
|
||||
try testing.expectEqual(@as(?ForwardZoneRow, null), try getForwardZone(&database, testing.allocator, id));
|
||||
try testing.expectEqual(@as(i64, 0), try countForwardZones(&database));
|
||||
}
|
||||
|
||||
test "forward zone update and delete report NotFound for an id no row holds" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
const item: model.ForwardZone = .{ .zone = "home.arpa", .resolver = "udp://10.0.0.1:53" };
|
||||
try testing.expectError(error.NotFound, updateForwardZone(&database, 404, item));
|
||||
try testing.expectError(error.NotFound, deleteForwardZone(&database, 404));
|
||||
try testing.expectEqual(@as(?ForwardZoneRow, null), try getForwardZone(&database, testing.allocator, 404));
|
||||
}
|
||||
|
||||
test "a duplicate zone surfaces as error.Constraint on insert and on update" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
_ = try insertForwardZoneRow(&database, .{ .zone = "home.arpa", .resolver = "udp://10.0.0.1:53" });
|
||||
const other = try insertForwardZoneRow(&database, .{ .zone = "lab.example", .resolver = "udp://10.0.0.2:53" });
|
||||
|
||||
try testing.expectError(error.Constraint, insertForwardZoneRow(&database, .{
|
||||
.zone = "home.arpa",
|
||||
.resolver = "udp://10.0.0.3:53",
|
||||
}));
|
||||
try testing.expectError(error.Constraint, updateForwardZone(&database, other, .{
|
||||
.zone = "home.arpa",
|
||||
.resolver = "udp://10.0.0.2:53",
|
||||
}));
|
||||
try testing.expectEqual(@as(i64, 2), try countForwardZones(&database));
|
||||
}
|
||||
|
||||
fn localRowsUnderFailure(gpa: Allocator) !void {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
try seedLocalRecords(&database);
|
||||
try seedForwardZones(&database);
|
||||
|
||||
var records = try listLocalRecordRows(&database, gpa);
|
||||
defer records.deinit(gpa);
|
||||
defer freeLocalRecordRows(gpa, records.items);
|
||||
const record = (try getLocalRecord(&database, gpa, records.items[0].id)).?;
|
||||
defer freeLocalRecordRow(gpa, record);
|
||||
|
||||
var zones = try listForwardZoneRows(&database, gpa);
|
||||
defer zones.deinit(gpa);
|
||||
defer freeForwardZoneRows(gpa, zones.items);
|
||||
const zone = (try getForwardZone(&database, gpa, zones.items[0].id)).?;
|
||||
defer freeForwardZoneRow(gpa, zone);
|
||||
}
|
||||
|
||||
test "the local read surface is leak-safe under allocation failure" {
|
||||
try testing.checkAllAllocationFailures(testing.allocator, localRowsUnderFailure, .{});
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
//!
|
||||
//! Two shapes live here. The free functions follow the milestone-4 repository
|
||||
//! idiom — prepare, use, finalize — because retention runs them a handful of
|
||||
//! times per day. The flush loop is the one hot path in the program, so it gets
|
||||
//! `BatchWriter`, which owns its three statements for its whole life
|
||||
//! times per day, and the API read layer at the bottom of the file runs once
|
||||
//! per HTTP request. The flush loop is the one hot path in the program, so it
|
||||
//! gets `BatchWriter`, which owns its three statements for its whole life
|
||||
//! (`db.zig:360` names this file as the reason `db.zig` carries no statement
|
||||
//! cache).
|
||||
//!
|
||||
@@ -15,6 +16,7 @@
|
||||
//! decides what a failed batch means.
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const db = @import("../db.zig");
|
||||
|
||||
@@ -181,6 +183,294 @@ pub fn countDomains(database: *db.Db) db.Error!i64 {
|
||||
return database.queryInt("SELECT count(*) FROM domains");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// the API read layer (`GET /api/queries`, `/api/stats`, `/api/stats/timeseries`)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// One row of `GET /api/queries`, joined back through the `domains` dimension.
|
||||
///
|
||||
/// `block_reason` and `upstream` are nullable columns, and a NULL reads as `""`
|
||||
/// — the same convention `Stmt.columnText` already uses. Neither column is ever
|
||||
/// written as an empty string (a reason is a word, an upstream is a URL), so the
|
||||
/// mapping loses nothing and the API layer can treat `""` as "absent".
|
||||
pub const QueryRow = struct {
|
||||
id: i64,
|
||||
ts: i64,
|
||||
domain: []const u8,
|
||||
client_ip: []const u8,
|
||||
qtype: ?u16,
|
||||
blocked: bool,
|
||||
block_reason: []const u8,
|
||||
response_time_us: ?i64,
|
||||
cache_hit: ?bool,
|
||||
upstream: []const u8,
|
||||
};
|
||||
|
||||
/// Every field is an independent narrowing; `null` means "do not filter on it".
|
||||
///
|
||||
/// `since` is inclusive and `until` is exclusive, so adjacent windows tile
|
||||
/// without double-counting a row on the boundary.
|
||||
pub const QueryFilter = struct {
|
||||
limit: u32 = 100,
|
||||
/// Keyset cursor: only rows with a strictly smaller `id`. Rows come back
|
||||
/// newest-first, so this is the id of the last row of the previous page.
|
||||
before: ?i64 = null,
|
||||
/// Matched case-insensitively for ASCII, which is what SQLite's `LIKE`
|
||||
/// does and what a domain search wants.
|
||||
domain_substring: ?[]const u8 = null,
|
||||
client: ?[]const u8 = null,
|
||||
blocked: ?bool = null,
|
||||
since: ?i64 = null,
|
||||
until: ?i64 = null,
|
||||
};
|
||||
|
||||
/// Ruling 11 caps the page at 1000; the repository enforces it too, so a caller
|
||||
/// that forgets cannot ask this connection for the whole table.
|
||||
pub const max_limit: u32 = 1000;
|
||||
|
||||
const select_head =
|
||||
\\SELECT q.id, q.timestamp, d.domain, q.client_ip, q.qtype, q.blocked,
|
||||
\\ q.block_reason, q.response_time_us, q.cache_hit, q.upstream
|
||||
\\ FROM query_log q JOIN domains d ON d.id = q.domain_id
|
||||
;
|
||||
|
||||
/// The escape character of `where_domain`. SQLite does not give string literals
|
||||
/// C escapes, so `'\'` in the SQL text is one backslash.
|
||||
const like_escape = '\\';
|
||||
|
||||
const where_before = " q.id < ?";
|
||||
const where_domain = " d.domain LIKE ? ESCAPE '\\'";
|
||||
const where_client = " q.client_ip = ?";
|
||||
const where_blocked = " q.blocked = ?";
|
||||
const where_since = " q.timestamp >= ?";
|
||||
const where_until = " q.timestamp < ?";
|
||||
const select_tail = " ORDER BY q.id DESC LIMIT ?";
|
||||
|
||||
const where_keyword = " WHERE";
|
||||
const and_keyword = " AND";
|
||||
|
||||
/// Assembles the statement from the fixed fragments above and nothing else.
|
||||
///
|
||||
/// **No value ever reaches this buffer.** Every filter contributes a `?` and is
|
||||
/// bound afterwards, in the order the predicates were appended: an unnumbered
|
||||
/// parameter takes the next free index, so append order and bind order are the
|
||||
/// same single contract.
|
||||
const Sql = struct {
|
||||
/// `where_keyword` is longer than `and_keyword` and is used at most once,
|
||||
/// so counting six of it bounds every reachable combination.
|
||||
const capacity = select_head.len + 6 * where_keyword.len + select_tail.len +
|
||||
where_before.len + where_domain.len + where_client.len +
|
||||
where_blocked.len + where_since.len + where_until.len;
|
||||
|
||||
buf: [capacity]u8 = undefined,
|
||||
len: usize = 0,
|
||||
has_where: bool = false,
|
||||
|
||||
fn put(self: *Sql, fragment: []const u8) void {
|
||||
@memcpy(self.buf[self.len..][0..fragment.len], fragment);
|
||||
self.len += fragment.len;
|
||||
}
|
||||
|
||||
fn predicate(self: *Sql, fragment: []const u8) void {
|
||||
self.put(if (self.has_where) and_keyword else where_keyword);
|
||||
self.has_where = true;
|
||||
self.put(fragment);
|
||||
}
|
||||
|
||||
fn text(self: *const Sql) []const u8 {
|
||||
return self.buf[0..self.len];
|
||||
}
|
||||
};
|
||||
|
||||
/// Rows come back newest-first (`id DESC`). Every string is allocated from
|
||||
/// `arena`, including the list's own storage, so the caller frees the whole
|
||||
/// result by resetting the arena — there is nothing to unwind on failure.
|
||||
pub fn selectQueries(database: *db.Db, arena: Allocator, filter: QueryFilter) db.Error!std.ArrayList(QueryRow) {
|
||||
var sql: Sql = .{};
|
||||
sql.put(select_head);
|
||||
if (filter.before != null) sql.predicate(where_before);
|
||||
if (filter.domain_substring != null) sql.predicate(where_domain);
|
||||
if (filter.client != null) sql.predicate(where_client);
|
||||
if (filter.blocked != null) sql.predicate(where_blocked);
|
||||
if (filter.since != null) sql.predicate(where_since);
|
||||
if (filter.until != null) sql.predicate(where_until);
|
||||
sql.put(select_tail);
|
||||
|
||||
var stmt = try database.prepare(sql.text());
|
||||
defer stmt.deinit();
|
||||
|
||||
var idx: c_int = 0;
|
||||
if (filter.before) |v| {
|
||||
idx += 1;
|
||||
try stmt.bindInt(idx, v);
|
||||
}
|
||||
if (filter.domain_substring) |v| {
|
||||
idx += 1;
|
||||
try stmt.bindText(idx, try likePattern(arena, v));
|
||||
}
|
||||
if (filter.client) |v| {
|
||||
idx += 1;
|
||||
try stmt.bindText(idx, v);
|
||||
}
|
||||
if (filter.blocked) |v| {
|
||||
idx += 1;
|
||||
try stmt.bindBool(idx, v);
|
||||
}
|
||||
if (filter.since) |v| {
|
||||
idx += 1;
|
||||
try stmt.bindInt(idx, v);
|
||||
}
|
||||
if (filter.until) |v| {
|
||||
idx += 1;
|
||||
try stmt.bindInt(idx, v);
|
||||
}
|
||||
idx += 1;
|
||||
try stmt.bindInt(idx, @min(filter.limit, max_limit));
|
||||
|
||||
var out: std.ArrayList(QueryRow) = .empty;
|
||||
while (try stmt.step()) {
|
||||
try out.append(arena, .{
|
||||
.id = stmt.columnInt(0),
|
||||
.ts = stmt.columnInt(1),
|
||||
.domain = try stmt.columnTextAlloc(arena, 2),
|
||||
.client_ip = try stmt.columnTextAlloc(arena, 3),
|
||||
.qtype = if (stmt.isNull(4)) null else std.math.cast(u16, stmt.columnInt(4)) orelse
|
||||
return error.Mismatch,
|
||||
.blocked = stmt.columnBool(5),
|
||||
.block_reason = try stmt.columnTextAlloc(arena, 6),
|
||||
.response_time_us = if (stmt.isNull(7)) null else stmt.columnInt(7),
|
||||
.cache_hit = if (stmt.isNull(8)) null else stmt.columnBool(8),
|
||||
.upstream = try stmt.columnTextAlloc(arena, 9),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Wraps `needle` in `%` and neutralises the two `LIKE` metacharacters, so a
|
||||
/// user searching for `a_b` gets domains containing `a_b` and not domains
|
||||
/// containing `axb`. The escape character escapes itself.
|
||||
fn likePattern(arena: Allocator, needle: []const u8) Allocator.Error![]const u8 {
|
||||
var out: std.ArrayList(u8) = try .initCapacity(arena, needle.len * 2 + 2);
|
||||
out.appendAssumeCapacity('%');
|
||||
for (needle) |ch| {
|
||||
if (ch == '%' or ch == '_' or ch == like_escape) out.appendAssumeCapacity(like_escape);
|
||||
out.appendAssumeCapacity(ch);
|
||||
}
|
||||
out.appendAssumeCapacity('%');
|
||||
return out.items;
|
||||
}
|
||||
|
||||
/// The `/api/stats` rollup for one period. `avg_response_time_us` is `null` when
|
||||
/// no row in the window recorded a response time.
|
||||
pub const StatsTotals = struct {
|
||||
queries: u64,
|
||||
blocked: u64,
|
||||
cached: u64,
|
||||
distinct_clients: u64,
|
||||
avg_response_time_us: ?i64,
|
||||
};
|
||||
|
||||
/// The mean is derived from a sum and a count rather than SQL's `avg`, which
|
||||
/// returns REAL: `Stmt` reads integers, and integer microseconds are exact.
|
||||
const stats_totals_sql =
|
||||
\\SELECT count(*),
|
||||
\\ coalesce(sum(blocked <> 0), 0),
|
||||
\\ coalesce(sum(cache_hit = 1), 0),
|
||||
\\ count(DISTINCT client_ip),
|
||||
\\ coalesce(sum(response_time_us), 0),
|
||||
\\ count(response_time_us)
|
||||
\\ FROM query_log
|
||||
\\ WHERE timestamp >= ?1 AND timestamp < ?2
|
||||
;
|
||||
|
||||
/// Aggregates `[since, until)`. An empty window is all zeros with a null mean,
|
||||
/// not an error.
|
||||
pub fn statsTotals(database: *db.Db, since: i64, until: i64) db.Error!StatsTotals {
|
||||
var stmt = try database.prepare(stats_totals_sql);
|
||||
defer stmt.deinit();
|
||||
try stmt.bindInt(1, since);
|
||||
try stmt.bindInt(2, until);
|
||||
|
||||
// A bare aggregate always produces exactly one row; no row means the
|
||||
// statement is not the one this function prepared.
|
||||
if (!try stmt.step()) return error.Misuse;
|
||||
|
||||
const timed = stmt.columnInt(5);
|
||||
return .{
|
||||
.queries = try countOf(stmt.columnInt(0)),
|
||||
.blocked = try countOf(stmt.columnInt(1)),
|
||||
.cached = try countOf(stmt.columnInt(2)),
|
||||
.distinct_clients = try countOf(stmt.columnInt(3)),
|
||||
.avg_response_time_us = if (timed == 0) null else @divTrunc(stmt.columnInt(4), timed),
|
||||
};
|
||||
}
|
||||
|
||||
/// `count` and `sum` over non-negative columns cannot go negative; a negative
|
||||
/// value means the row came from something other than this schema.
|
||||
fn countOf(value: i64) db.Error!u64 {
|
||||
if (value < 0) return error.Mismatch;
|
||||
return @intCast(value);
|
||||
}
|
||||
|
||||
/// One bucket of `/api/stats/timeseries`. `ts` is the bucket's inclusive start.
|
||||
pub const Bucket = struct {
|
||||
ts: i64,
|
||||
queries: u64,
|
||||
blocked: u64,
|
||||
cached: u64,
|
||||
};
|
||||
|
||||
const timeseries_sql =
|
||||
\\SELECT (timestamp - ?1) / ?2,
|
||||
\\ count(*),
|
||||
\\ coalesce(sum(blocked <> 0), 0),
|
||||
\\ coalesce(sum(cache_hit = 1), 0)
|
||||
\\ FROM query_log
|
||||
\\ WHERE timestamp >= ?1 AND timestamp < ?3
|
||||
\\ GROUP BY 1
|
||||
;
|
||||
|
||||
/// Fills `out` with `out.len` buckets of `bucket_seconds` each, covering
|
||||
/// `[since, since + bucket_seconds * out.len)`, and returns how many it wrote.
|
||||
///
|
||||
/// Every bucket is present: a window with no rows in it is written with zeros
|
||||
/// rather than skipped, so the caller charts a contiguous axis without
|
||||
/// reconstructing the gaps. Buckets are aligned to `since`, so the caller —
|
||||
/// which knows the period grammar of ruling 13 — owns UTC alignment by choosing
|
||||
/// `since`.
|
||||
pub fn timeseries(database: *db.Db, since: i64, bucket_seconds: u32, out: []Bucket) db.Error!usize {
|
||||
if (out.len == 0) return 0;
|
||||
// Both are caller bugs, not runtime conditions: a zero width would make the
|
||||
// SQL divide by zero (SQLite yields NULL, silently emptying the chart), and
|
||||
// a window that does not fit i64 cannot be asked about.
|
||||
if (bucket_seconds == 0) return error.Misuse;
|
||||
const width: i64 = bucket_seconds;
|
||||
const span = std.math.mul(i64, width, std.math.cast(i64, out.len) orelse
|
||||
return error.Misuse) catch return error.Misuse;
|
||||
const until = std.math.add(i64, since, span) catch return error.Misuse;
|
||||
|
||||
for (out, 0..) |*bucket, i| {
|
||||
bucket.* = .{ .ts = since + width * @as(i64, @intCast(i)), .queries = 0, .blocked = 0, .cached = 0 };
|
||||
}
|
||||
|
||||
var stmt = try database.prepare(timeseries_sql);
|
||||
defer stmt.deinit();
|
||||
try stmt.bindInt(1, since);
|
||||
try stmt.bindInt(2, width);
|
||||
try stmt.bindInt(3, until);
|
||||
|
||||
while (try stmt.step()) {
|
||||
// The WHERE clause already bounds the index to `out`; the check is
|
||||
// cheap and keeps a schema surprise from writing past the slice.
|
||||
const index = std.math.cast(usize, stmt.columnInt(0)) orelse return error.Mismatch;
|
||||
if (index >= out.len) return error.Mismatch;
|
||||
out[index].queries = try countOf(stmt.columnInt(1));
|
||||
out[index].blocked = try countOf(stmt.columnInt(2));
|
||||
out[index].cached = try countOf(stmt.columnInt(3));
|
||||
}
|
||||
return out.len;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -466,3 +756,379 @@ test "checkpointTruncate and vacuum run against a WAL file database" {
|
||||
try testing.expectEqual(@as(i64, 1), try countRows(&database));
|
||||
try testing.expectEqual(@as(i64, 2), try countDomains(&database));
|
||||
}
|
||||
|
||||
// --- the read layer -------------------------------------------------------
|
||||
|
||||
/// `BatchWriter` assigns `query_log.id` in the order it is handed the rows, so
|
||||
/// every test below knows the id of each seeded row: the nth row of the nth
|
||||
/// batch has id n.
|
||||
fn seed(database: *db.Db, rows: []const Row) !void {
|
||||
var writer = try BatchWriter.init(database);
|
||||
defer writer.deinit();
|
||||
try writer.writeBatch(rows);
|
||||
}
|
||||
|
||||
fn ids(rows: []const QueryRow, out: []i64) []const i64 {
|
||||
for (rows, 0..) |row, i| out[i] = row.id;
|
||||
return out[0..rows.len];
|
||||
}
|
||||
|
||||
test "selectQueries returns the newest row first and reads every column" {
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
|
||||
try seed(&database, &.{
|
||||
.{
|
||||
.timestamp = 10,
|
||||
.domain = "ads.example.net",
|
||||
.client_ip = "192.0.2.10",
|
||||
.qtype = 28,
|
||||
.blocked = true,
|
||||
.block_reason = "blocklist",
|
||||
.response_time_us = 4200,
|
||||
.cache_hit = true,
|
||||
.upstream = "https://dns.example/dns-query",
|
||||
},
|
||||
.{
|
||||
.timestamp = 20,
|
||||
.domain = "quiet.example",
|
||||
.client_ip = "hidden",
|
||||
.qtype = null,
|
||||
.blocked = false,
|
||||
.block_reason = null,
|
||||
.response_time_us = null,
|
||||
.cache_hit = null,
|
||||
.upstream = null,
|
||||
},
|
||||
});
|
||||
|
||||
const rows = try selectQueries(&database, arena_state.allocator(), .{});
|
||||
try testing.expectEqual(@as(usize, 2), rows.items.len);
|
||||
|
||||
const newest = rows.items[0];
|
||||
try testing.expectEqual(@as(i64, 2), newest.id);
|
||||
try testing.expectEqual(@as(i64, 20), newest.ts);
|
||||
try testing.expectEqualStrings("quiet.example", newest.domain);
|
||||
try testing.expectEqualStrings("hidden", newest.client_ip);
|
||||
try testing.expectEqual(@as(?u16, null), newest.qtype);
|
||||
try testing.expect(!newest.blocked);
|
||||
// A NULL text column reads as the empty string, by documented convention.
|
||||
try testing.expectEqualStrings("", newest.block_reason);
|
||||
try testing.expectEqual(@as(?i64, null), newest.response_time_us);
|
||||
try testing.expectEqual(@as(?bool, null), newest.cache_hit);
|
||||
try testing.expectEqualStrings("", newest.upstream);
|
||||
|
||||
const oldest = rows.items[1];
|
||||
try testing.expectEqual(@as(i64, 1), oldest.id);
|
||||
try testing.expectEqual(@as(i64, 10), oldest.ts);
|
||||
try testing.expectEqualStrings("ads.example.net", oldest.domain);
|
||||
try testing.expectEqualStrings("192.0.2.10", oldest.client_ip);
|
||||
try testing.expectEqual(@as(?u16, 28), oldest.qtype);
|
||||
try testing.expect(oldest.blocked);
|
||||
try testing.expectEqualStrings("blocklist", oldest.block_reason);
|
||||
try testing.expectEqual(@as(?i64, 4200), oldest.response_time_us);
|
||||
try testing.expectEqual(@as(?bool, true), oldest.cache_hit);
|
||||
try testing.expectEqualStrings("https://dns.example/dns-query", oldest.upstream);
|
||||
}
|
||||
|
||||
test "selectQueries honours the limit and caps it at max_limit" {
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
|
||||
var rows: [1005]Row = undefined;
|
||||
for (&rows, 0..) |*row, i| row.* = plainRow(@intCast(i), "example.com");
|
||||
try seed(&database, &rows);
|
||||
|
||||
const few = try selectQueries(&database, arena, .{ .limit = 3 });
|
||||
try testing.expectEqual(@as(usize, 3), few.items.len);
|
||||
|
||||
// Asked for more than the cap, and for more rows than the cap, so the cap
|
||||
// is what bounds the answer rather than the table.
|
||||
const capped = try selectQueries(&database, arena, .{ .limit = 5000 });
|
||||
try testing.expectEqual(@as(usize, max_limit), capped.items.len);
|
||||
|
||||
const none = try selectQueries(&database, arena, .{ .limit = 0 });
|
||||
try testing.expectEqual(@as(usize, 0), none.items.len);
|
||||
}
|
||||
|
||||
test "keyset paging walks every row exactly once across the page boundaries" {
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
|
||||
var seeded: [7]Row = undefined;
|
||||
for (&seeded, 0..) |*row, i| row.* = plainRow(@intCast(i), "example.com");
|
||||
try seed(&database, &seeded);
|
||||
|
||||
var seen: std.ArrayList(i64) = .empty;
|
||||
defer seen.deinit(testing.allocator);
|
||||
|
||||
var before: ?i64 = null;
|
||||
var pages: usize = 0;
|
||||
while (pages < 10) : (pages += 1) {
|
||||
const page = try selectQueries(&database, arena, .{ .limit = 3, .before = before });
|
||||
if (page.items.len == 0) break;
|
||||
for (page.items) |row| try seen.append(testing.allocator, row.id);
|
||||
before = page.items[page.items.len - 1].id;
|
||||
}
|
||||
|
||||
// Two full pages and one short page; the fourth call returns nothing and
|
||||
// breaks before the counter, which is how the walk knows it is done.
|
||||
try testing.expectEqual(@as(usize, 3), pages);
|
||||
try testing.expectEqualSlices(i64, &.{ 7, 6, 5, 4, 3, 2, 1 }, seen.items);
|
||||
}
|
||||
|
||||
test "each filter narrows the result on its own" {
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
|
||||
var blocked_row = plainRow(200, "ads.example.net");
|
||||
blocked_row.client_ip = "192.0.2.20";
|
||||
blocked_row.blocked = true;
|
||||
blocked_row.block_reason = "blocklist";
|
||||
try seed(&database, &.{
|
||||
plainRow(100, "one.example.com"),
|
||||
blocked_row,
|
||||
plainRow(300, "two.example.com"),
|
||||
});
|
||||
|
||||
var buf: [8]i64 = undefined;
|
||||
|
||||
const by_domain = try selectQueries(&database, arena, .{ .domain_substring = "example.com" });
|
||||
try testing.expectEqualSlices(i64, &.{ 3, 1 }, ids(by_domain.items, &buf));
|
||||
|
||||
const by_client = try selectQueries(&database, arena, .{ .client = "192.0.2.20" });
|
||||
try testing.expectEqualSlices(i64, &.{2}, ids(by_client.items, &buf));
|
||||
|
||||
// An exact match, not a prefix: the seeded clients share the first octets.
|
||||
const no_client = try selectQueries(&database, arena, .{ .client = "192.0.2" });
|
||||
try testing.expectEqual(@as(usize, 0), no_client.items.len);
|
||||
|
||||
const only_blocked = try selectQueries(&database, arena, .{ .blocked = true });
|
||||
try testing.expectEqualSlices(i64, &.{2}, ids(only_blocked.items, &buf));
|
||||
|
||||
const only_allowed = try selectQueries(&database, arena, .{ .blocked = false });
|
||||
try testing.expectEqualSlices(i64, &.{ 3, 1 }, ids(only_allowed.items, &buf));
|
||||
|
||||
// Every filter at once, all satisfied by the one blocked row.
|
||||
const combined = try selectQueries(&database, arena, .{
|
||||
.limit = 10,
|
||||
.before = 3,
|
||||
.domain_substring = "ads",
|
||||
.client = "192.0.2.20",
|
||||
.blocked = true,
|
||||
.since = 200,
|
||||
.until = 300,
|
||||
});
|
||||
try testing.expectEqualSlices(i64, &.{2}, ids(combined.items, &buf));
|
||||
}
|
||||
|
||||
test "since is inclusive, until is exclusive, and an empty range selects nothing" {
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
|
||||
try seed(&database, &.{
|
||||
plainRow(100, "a.example"),
|
||||
plainRow(200, "b.example"),
|
||||
plainRow(300, "c.example"),
|
||||
});
|
||||
|
||||
var buf: [8]i64 = undefined;
|
||||
|
||||
const window = try selectQueries(&database, arena, .{ .since = 100, .until = 300 });
|
||||
try testing.expectEqualSlices(i64, &.{ 2, 1 }, ids(window.items, &buf));
|
||||
|
||||
const after = try selectQueries(&database, arena, .{ .since = 300 });
|
||||
try testing.expectEqualSlices(i64, &.{3}, ids(after.items, &buf));
|
||||
|
||||
const empty = try selectQueries(&database, arena, .{ .since = 300, .until = 300 });
|
||||
try testing.expectEqual(@as(usize, 0), empty.items.len);
|
||||
|
||||
const beyond = try selectQueries(&database, arena, .{ .since = 1000 });
|
||||
try testing.expectEqual(@as(usize, 0), beyond.items.len);
|
||||
}
|
||||
|
||||
test "a domain substring matches % and _ as literal characters" {
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
|
||||
try seed(&database, &.{
|
||||
plainRow(10, "a_b.example"),
|
||||
plainRow(20, "axb.example"),
|
||||
plainRow(30, "a%b.example"),
|
||||
plainRow(40, "azzb.example"),
|
||||
plainRow(50, "back\\slash.example"),
|
||||
});
|
||||
|
||||
var buf: [8]i64 = undefined;
|
||||
|
||||
// Unescaped, `_` is LIKE's single-character wildcard and would also match
|
||||
// "axb"; escaped, it matches only the underscore.
|
||||
const underscore = try selectQueries(&database, arena, .{ .domain_substring = "a_b" });
|
||||
try testing.expectEqualSlices(i64, &.{1}, ids(underscore.items, &buf));
|
||||
|
||||
// Unescaped, `%` would match everything from "a" to "b", so "azzb" too.
|
||||
const percent = try selectQueries(&database, arena, .{ .domain_substring = "a%b" });
|
||||
try testing.expectEqualSlices(i64, &.{3}, ids(percent.items, &buf));
|
||||
|
||||
// The escape character escapes itself, so it is searchable as well.
|
||||
const backslash = try selectQueries(&database, arena, .{ .domain_substring = "k\\s" });
|
||||
try testing.expectEqualSlices(i64, &.{5}, ids(backslash.items, &buf));
|
||||
|
||||
// An empty needle is `%%`, which matches every row rather than none.
|
||||
const all = try selectQueries(&database, arena, .{ .domain_substring = "" });
|
||||
try testing.expectEqual(@as(usize, 5), all.items.len);
|
||||
}
|
||||
|
||||
test "statsTotals aggregates the window and averages only the timed rows" {
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
|
||||
var timed = plainRow(100, "a.example");
|
||||
timed.response_time_us = 100;
|
||||
var blocked_row = plainRow(150, "ads.example");
|
||||
blocked_row.blocked = true;
|
||||
blocked_row.block_reason = "blocklist";
|
||||
blocked_row.response_time_us = 200;
|
||||
var cached = plainRow(199, "b.example");
|
||||
cached.client_ip = "192.0.2.99";
|
||||
cached.cache_hit = true;
|
||||
cached.response_time_us = null;
|
||||
try seed(&database, &.{ timed, blocked_row, cached, plainRow(200, "outside.example") });
|
||||
|
||||
const totals = try statsTotals(&database, 100, 200);
|
||||
try testing.expectEqual(@as(u64, 3), totals.queries);
|
||||
try testing.expectEqual(@as(u64, 1), totals.blocked);
|
||||
try testing.expectEqual(@as(u64, 1), totals.cached);
|
||||
try testing.expectEqual(@as(u64, 2), totals.distinct_clients);
|
||||
// (100 + 200) / 2 — the untimed row is not in the divisor.
|
||||
try testing.expectEqual(@as(?i64, 150), totals.avg_response_time_us);
|
||||
}
|
||||
|
||||
test "statsTotals over an empty window is zeros with a null average" {
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
try seed(&database, &.{plainRow(100, "a.example")});
|
||||
|
||||
for ([_][2]i64{ .{ 500, 600 }, .{ 100, 100 } }) |window| {
|
||||
const totals = try statsTotals(&database, window[0], window[1]);
|
||||
try testing.expectEqual(@as(u64, 0), totals.queries);
|
||||
try testing.expectEqual(@as(u64, 0), totals.blocked);
|
||||
try testing.expectEqual(@as(u64, 0), totals.cached);
|
||||
try testing.expectEqual(@as(u64, 0), totals.distinct_clients);
|
||||
try testing.expectEqual(@as(?i64, null), totals.avg_response_time_us);
|
||||
}
|
||||
}
|
||||
|
||||
test "timeseries writes every bucket, including the ones with no rows" {
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
|
||||
var blocked_row = plainRow(1020, "ads.example");
|
||||
blocked_row.blocked = true;
|
||||
blocked_row.block_reason = "blocklist";
|
||||
var cached = plainRow(1035, "b.example");
|
||||
cached.cache_hit = true;
|
||||
try seed(&database, &.{
|
||||
plainRow(995, "before.example"),
|
||||
plainRow(1000, "a.example"),
|
||||
plainRow(1009, "a.example"),
|
||||
blocked_row,
|
||||
cached,
|
||||
plainRow(1040, "after.example"),
|
||||
});
|
||||
|
||||
var buckets: [4]Bucket = undefined;
|
||||
try testing.expectEqual(@as(usize, 4), try timeseries(&database, 1000, 10, &buckets));
|
||||
|
||||
// The row at 995 is before the window and the row at 1040 is past its end;
|
||||
// neither lands in a bucket.
|
||||
try testing.expectEqualSlices(Bucket, &.{
|
||||
.{ .ts = 1000, .queries = 2, .blocked = 0, .cached = 0 },
|
||||
.{ .ts = 1010, .queries = 0, .blocked = 0, .cached = 0 },
|
||||
.{ .ts = 1020, .queries = 1, .blocked = 1, .cached = 0 },
|
||||
.{ .ts = 1030, .queries = 1, .blocked = 0, .cached = 1 },
|
||||
}, &buckets);
|
||||
}
|
||||
|
||||
test "timeseries over an empty table still writes the whole axis" {
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
|
||||
var buckets: [3]Bucket = undefined;
|
||||
try testing.expectEqual(@as(usize, 3), try timeseries(&database, 0, 60, &buckets));
|
||||
try testing.expectEqualSlices(Bucket, &.{
|
||||
.{ .ts = 0, .queries = 0, .blocked = 0, .cached = 0 },
|
||||
.{ .ts = 60, .queries = 0, .blocked = 0, .cached = 0 },
|
||||
.{ .ts = 120, .queries = 0, .blocked = 0, .cached = 0 },
|
||||
}, &buckets);
|
||||
}
|
||||
|
||||
test "timeseries rejects a zero-width bucket and accepts an empty slice" {
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
|
||||
var buckets: [2]Bucket = undefined;
|
||||
try testing.expectError(error.Misuse, timeseries(&database, 0, 0, &buckets));
|
||||
|
||||
var none: [0]Bucket = undefined;
|
||||
try testing.expectEqual(@as(usize, 0), try timeseries(&database, 0, 0, &none));
|
||||
}
|
||||
|
||||
test "timeseries reports a window that does not fit an i64 rather than wrapping" {
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
|
||||
var buckets: [4]Bucket = undefined;
|
||||
try testing.expectError(
|
||||
error.Misuse,
|
||||
timeseries(&database, std.math.maxInt(i64) - 1, 3600, &buckets),
|
||||
);
|
||||
}
|
||||
|
||||
test "the built SQL never carries a filter value and fits its buffer" {
|
||||
var sql: Sql = .{};
|
||||
sql.put(select_head);
|
||||
sql.predicate(where_before);
|
||||
sql.predicate(where_domain);
|
||||
sql.predicate(where_client);
|
||||
sql.predicate(where_blocked);
|
||||
sql.predicate(where_since);
|
||||
sql.predicate(where_until);
|
||||
sql.put(select_tail);
|
||||
|
||||
// Every predicate present is the longest reachable statement.
|
||||
try testing.expect(sql.len <= Sql.capacity);
|
||||
try testing.expectEqual(@as(usize, 1), std.mem.count(u8, sql.text(), " WHERE"));
|
||||
try testing.expectEqual(@as(usize, 5), std.mem.count(u8, sql.text(), " AND"));
|
||||
// Six filters plus the LIMIT, each a bare parameter.
|
||||
try testing.expectEqual(@as(usize, 7), std.mem.count(u8, sql.text(), "?"));
|
||||
}
|
||||
|
||||
test "likePattern wraps the needle and neutralises every metacharacter" {
|
||||
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
|
||||
try testing.expectEqualStrings("%plain%", try likePattern(arena, "plain"));
|
||||
try testing.expectEqualStrings("%a\\_b%", try likePattern(arena, "a_b"));
|
||||
try testing.expectEqualStrings("%a\\%b%", try likePattern(arena, "a%b"));
|
||||
try testing.expectEqualStrings("%a\\\\b%", try likePattern(arena, "a\\b"));
|
||||
try testing.expectEqualStrings("%%", try likePattern(arena, ""));
|
||||
}
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
//! order as a whole is: `import` inserts the rules in export order, so the new
|
||||
//! ids ascend in exactly the order this statement produced.
|
||||
//!
|
||||
//! Only list / insert / deleteAll / count exist.
|
||||
//! The import path is list / insert / deleteAll / count. Phase 8's REST surface
|
||||
//! is the second half of this file: it speaks row ids, because that is what an
|
||||
//! `/api/rules/{id}` request names.
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
@@ -22,6 +24,7 @@ const migrations = @import("../migrations.zig");
|
||||
const model = @import("../../config/model.zig");
|
||||
const context = @import("context.zig");
|
||||
const groups_repo = @import("groups_repo.zig");
|
||||
const crud = @import("crud.zig");
|
||||
|
||||
const IdMap = context.IdMap;
|
||||
const InsertContext = context.InsertContext;
|
||||
@@ -89,6 +92,145 @@ pub fn countRules(database: *db.Db) db.Error!i64 {
|
||||
return database.queryInt("SELECT count(*) FROM rules");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// REST surface (milestone 8)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// The read shape carries both `group_id` and the group name: the UI groups the
|
||||
// rules it lists, and the client that edits one sends an id back. The write
|
||||
// shape carries only the id, so a group that does not exist surfaces as the
|
||||
// foreign-key violation it is instead of a lookup miss.
|
||||
|
||||
pub const RuleRow = struct {
|
||||
id: i64,
|
||||
group_id: i64,
|
||||
group: []const u8,
|
||||
pattern: []const u8,
|
||||
kind: model.RuleKind,
|
||||
action: model.RuleAction,
|
||||
created_at: i64,
|
||||
};
|
||||
|
||||
pub const RuleInput = struct {
|
||||
group_id: i64,
|
||||
pattern: []const u8,
|
||||
kind: model.RuleKind,
|
||||
action: model.RuleAction,
|
||||
};
|
||||
|
||||
const list_rule_rows_sql =
|
||||
\\SELECT r.id, r.group_id, g.name, r.pattern, r.kind, r.action, r.created_at FROM rules r
|
||||
\\ JOIN groups g ON g.id = r.group_id
|
||||
\\ ORDER BY g.name, r.kind, r.action, r.pattern, r.id
|
||||
;
|
||||
|
||||
const get_rule_sql =
|
||||
\\SELECT r.id, r.group_id, g.name, r.pattern, r.kind, r.action, r.created_at FROM rules r
|
||||
\\ JOIN groups g ON g.id = r.group_id
|
||||
\\ WHERE r.id = ?1
|
||||
;
|
||||
|
||||
/// Same order as `listRules`; every string is a heap copy owned by `gpa`.
|
||||
pub fn listRuleRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(RuleRow) {
|
||||
var stmt = try database.prepare(list_rule_rows_sql);
|
||||
defer stmt.deinit();
|
||||
|
||||
var out: std.ArrayList(RuleRow) = .empty;
|
||||
errdefer out.deinit(gpa);
|
||||
errdefer freeRuleRows(gpa, out.items);
|
||||
|
||||
while (try stmt.step()) {
|
||||
const row = try readRuleRow(&stmt, gpa);
|
||||
errdefer freeRuleRow(gpa, row);
|
||||
try out.append(gpa, row);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
pub fn freeRuleRow(gpa: Allocator, row: RuleRow) void {
|
||||
gpa.free(row.group);
|
||||
gpa.free(row.pattern);
|
||||
}
|
||||
|
||||
pub fn freeRuleRows(gpa: Allocator, items: []const RuleRow) void {
|
||||
for (items) |item| freeRuleRow(gpa, item);
|
||||
}
|
||||
|
||||
pub fn getRule(database: *db.Db, gpa: Allocator, id: i64) db.Error!?RuleRow {
|
||||
var stmt = try database.prepare(get_rule_sql);
|
||||
defer stmt.deinit();
|
||||
try stmt.bindInt(1, id);
|
||||
if (!try stmt.step()) return null;
|
||||
return try readRuleRow(&stmt, gpa);
|
||||
}
|
||||
|
||||
fn readRuleRow(stmt: *db.Stmt, gpa: Allocator) db.Error!RuleRow {
|
||||
// The DDL's CHECK constraints make both decodes total for any row nxdns
|
||||
// wrote; `error.Mismatch` covers a row that something else wrote.
|
||||
const kind = model.RuleKind.fromDb(stmt.columnText(4)) orelse return error.Mismatch;
|
||||
const action = model.RuleAction.fromDb(stmt.columnText(5)) orelse return error.Mismatch;
|
||||
const group = try stmt.columnTextAlloc(gpa, 2);
|
||||
errdefer gpa.free(group);
|
||||
const pattern = try stmt.columnTextAlloc(gpa, 3);
|
||||
errdefer gpa.free(pattern);
|
||||
return .{
|
||||
.id = stmt.columnInt(0),
|
||||
.group_id = stmt.columnInt(1),
|
||||
.group = group,
|
||||
.pattern = pattern,
|
||||
.kind = kind,
|
||||
.action = action,
|
||||
.created_at = stmt.columnInt(6),
|
||||
};
|
||||
}
|
||||
|
||||
/// `now_s` is unix epoch seconds, from `std.Io.Clock.real`; it becomes
|
||||
/// `created_at`, the column that dates a rule for the operator.
|
||||
///
|
||||
/// `error.Constraint`: `group_id` names no group. `rules` has no UNIQUE
|
||||
/// constraint, so a rule identical to one already stored is accepted — the
|
||||
/// table has always allowed that.
|
||||
pub fn insertRuleRow(database: *db.Db, item: RuleInput, now_s: i64) db.Error!i64 {
|
||||
var stmt = try database.prepare(insert_sql);
|
||||
defer stmt.deinit();
|
||||
try stmt.bindInt(1, item.group_id);
|
||||
try stmt.bindText(2, item.pattern);
|
||||
try stmt.bindText(3, item.kind.toDb());
|
||||
try stmt.bindText(4, item.action.toDb());
|
||||
try stmt.bindInt(5, now_s);
|
||||
try stmt.exec();
|
||||
return database.lastInsertRowid();
|
||||
}
|
||||
|
||||
const update_rule_sql =
|
||||
\\UPDATE rules SET group_id = ?2, pattern = ?3, kind = ?4, action = ?5 WHERE id = ?1
|
||||
;
|
||||
|
||||
/// `created_at` is when the rule was written, not when it was last touched, so
|
||||
/// an edit leaves it alone.
|
||||
///
|
||||
/// `error.NotFound`: no rule holds `id`. `error.Constraint`: `group_id` names no
|
||||
/// group.
|
||||
pub fn updateRule(database: *db.Db, id: i64, item: RuleInput) db.Error!void {
|
||||
var stmt = try database.prepare(update_rule_sql);
|
||||
defer stmt.deinit();
|
||||
try stmt.bindInt(1, id);
|
||||
try stmt.bindInt(2, item.group_id);
|
||||
try stmt.bindText(3, item.pattern);
|
||||
try stmt.bindText(4, item.kind.toDb());
|
||||
try stmt.bindText(5, item.action.toDb());
|
||||
return crud.execStrict(database, &stmt);
|
||||
}
|
||||
|
||||
/// `error.NotFound`: no rule holds `id`. Nothing references `rules`, so a delete
|
||||
/// cannot violate a constraint.
|
||||
pub fn deleteRule(database: *db.Db, id: i64) db.Error!void {
|
||||
var stmt = try database.prepare("DELETE FROM rules WHERE id = ?1");
|
||||
defer stmt.deinit();
|
||||
try stmt.bindInt(1, id);
|
||||
return crud.execStrict(database, &stmt);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -292,3 +434,100 @@ test "listRules is leak-safe under allocation failure" {
|
||||
defer ids.deinit(testing.allocator);
|
||||
try testing.checkAllAllocationFailures(testing.allocator, listRulesUnderFailure, .{&ids});
|
||||
}
|
||||
|
||||
// --- REST surface ----------------------------------------------------------
|
||||
|
||||
test "a rule round-trips through insert, get, list, update and delete" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
const kids = try groups_repo.insertGroupRow(&database, .{ .name = "kids" });
|
||||
|
||||
const id = try insertRuleRow(&database, .{
|
||||
.group_id = 1,
|
||||
.pattern = "tracker.example",
|
||||
.kind = .exact,
|
||||
.action = .block,
|
||||
}, 1700000000);
|
||||
|
||||
const fetched = (try getRule(&database, testing.allocator, id)).?;
|
||||
defer freeRuleRow(testing.allocator, fetched);
|
||||
try testing.expectEqual(id, fetched.id);
|
||||
try testing.expectEqual(@as(i64, 1), fetched.group_id);
|
||||
try testing.expectEqualStrings("default", fetched.group);
|
||||
try testing.expectEqualStrings("tracker.example", fetched.pattern);
|
||||
try testing.expectEqual(model.RuleKind.exact, fetched.kind);
|
||||
try testing.expectEqual(model.RuleAction.block, fetched.action);
|
||||
try testing.expectEqual(@as(i64, 1700000000), fetched.created_at);
|
||||
|
||||
try updateRule(&database, id, .{
|
||||
.group_id = kids,
|
||||
.pattern = "*.ads.example",
|
||||
.kind = .wildcard,
|
||||
.action = .allow,
|
||||
});
|
||||
const updated = (try getRule(&database, testing.allocator, id)).?;
|
||||
defer freeRuleRow(testing.allocator, updated);
|
||||
try testing.expectEqual(kids, updated.group_id);
|
||||
try testing.expectEqualStrings("kids", updated.group);
|
||||
try testing.expectEqualStrings("*.ads.example", updated.pattern);
|
||||
try testing.expectEqual(model.RuleKind.wildcard, updated.kind);
|
||||
try testing.expectEqual(model.RuleAction.allow, updated.action);
|
||||
// An edit is not a creation, so the date stands.
|
||||
try testing.expectEqual(@as(i64, 1700000000), updated.created_at);
|
||||
|
||||
var rows = try listRuleRows(&database, testing.allocator);
|
||||
defer rows.deinit(testing.allocator);
|
||||
defer freeRuleRows(testing.allocator, rows.items);
|
||||
try testing.expectEqual(@as(usize, 1), rows.items.len);
|
||||
try testing.expectEqual(id, rows.items[0].id);
|
||||
|
||||
try deleteRule(&database, id);
|
||||
try testing.expectEqual(@as(?RuleRow, null), try getRule(&database, testing.allocator, id));
|
||||
try testing.expectEqual(@as(i64, 0), try countRules(&database));
|
||||
}
|
||||
|
||||
test "rule update and delete report NotFound for an id no row holds" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
const item: RuleInput = .{ .group_id = 1, .pattern = "x.example", .kind = .exact, .action = .block };
|
||||
try testing.expectError(error.NotFound, updateRule(&database, 404, item));
|
||||
try testing.expectError(error.NotFound, deleteRule(&database, 404));
|
||||
try testing.expectEqual(@as(?RuleRow, null), try getRule(&database, testing.allocator, 404));
|
||||
}
|
||||
|
||||
test "a rule in a group that does not exist surfaces as error.Constraint" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
const item: RuleInput = .{ .group_id = 404, .pattern = "x.example", .kind = .exact, .action = .block };
|
||||
try testing.expectError(error.Constraint, insertRuleRow(&database, item, 1));
|
||||
|
||||
const id = try insertRuleRow(&database, .{
|
||||
.group_id = 1,
|
||||
.pattern = "x.example",
|
||||
.kind = .exact,
|
||||
.action = .block,
|
||||
}, 1);
|
||||
try testing.expectError(error.Constraint, updateRule(&database, id, item));
|
||||
try testing.expectEqual(@as(i64, 1), try countRules(&database));
|
||||
}
|
||||
|
||||
fn ruleRowsUnderFailure(gpa: Allocator, ids: *const IdMap) !void {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
try seedRules(&database, ids);
|
||||
|
||||
var rows = try listRuleRows(&database, gpa);
|
||||
defer rows.deinit(gpa);
|
||||
defer freeRuleRows(gpa, rows.items);
|
||||
|
||||
const one = (try getRule(&database, gpa, rows.items[0].id)).?;
|
||||
defer freeRuleRow(gpa, one);
|
||||
}
|
||||
|
||||
test "the rule read surface is leak-safe under allocation failure" {
|
||||
var ids = try seedGroupIds();
|
||||
defer ids.deinit(testing.allocator);
|
||||
try testing.checkAllAllocationFailures(testing.allocator, ruleRowsUnderFailure, .{&ids});
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
//! `model.fromSettings` speak, so the scalar sections cross the storage boundary
|
||||
//! without a second shape.
|
||||
//!
|
||||
//! Only list / insert / deleteAll / count exist.
|
||||
//! The import path is list / insert / deleteAll / count; `putSetting` is Phase
|
||||
//! 8's single-key write. `settings` has no row ids — the key is the identity —
|
||||
//! so it gains no by-id surface.
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
@@ -64,6 +66,32 @@ pub fn countSettings(database: *db.Db) db.Error!i64 {
|
||||
return database.queryInt("SELECT count(*) FROM settings");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// REST surface (milestone 8)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const put_setting_sql =
|
||||
\\INSERT INTO settings (key, value) VALUES (?1, ?2)
|
||||
\\ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
||||
;
|
||||
|
||||
/// Writes one key, whether or not it is already stored.
|
||||
///
|
||||
/// `PUT /api/settings` is a partial update over a table whose rows the import
|
||||
/// path writes once and never revisits, so a plain `INSERT` would fail on every
|
||||
/// key the config already carries and a plain `UPDATE` would drop every key it
|
||||
/// does not. The conflict target is `settings.key`, the table's PRIMARY KEY.
|
||||
///
|
||||
/// No constraint can fire: the table has one key column and one `NOT NULL`
|
||||
/// value, and both are bound.
|
||||
pub fn putSetting(database: *db.Db, key: []const u8, value: []const u8) db.Error!void {
|
||||
var stmt = try database.prepare(put_setting_sql);
|
||||
defer stmt.deinit();
|
||||
try stmt.bindText(1, key);
|
||||
try stmt.bindText(2, value);
|
||||
try stmt.exec();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -143,3 +171,41 @@ fn listSettingsUnderFailure(gpa: Allocator) !void {
|
||||
test "listSettings is leak-safe under allocation failure" {
|
||||
try testing.checkAllAllocationFailures(testing.allocator, listSettingsUnderFailure, .{});
|
||||
}
|
||||
|
||||
// --- REST surface ----------------------------------------------------------
|
||||
|
||||
test "putSetting writes a key that is absent and overwrites one that is present" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
try seedSettings(&database);
|
||||
|
||||
try putSetting(&database, "web.port", "9090");
|
||||
try putSetting(&database, "cache.max_entries", "20000");
|
||||
|
||||
var items = try listSettings(&database, testing.allocator);
|
||||
defer items.deinit(testing.allocator);
|
||||
defer freeSettings(testing.allocator, items.items);
|
||||
|
||||
try testing.expectEqual(@as(usize, 4), items.items.len);
|
||||
try testing.expectEqualStrings("cache.max_entries", items.items[0].key);
|
||||
try testing.expectEqualStrings("20000", items.items[0].value);
|
||||
try testing.expectEqualStrings("web.port", items.items[3].key);
|
||||
try testing.expectEqualStrings("9090", items.items[3].value);
|
||||
// The keys it did not name are untouched.
|
||||
try testing.expectEqualStrings("dns.port", items.items[1].key);
|
||||
try testing.expectEqualStrings("53", items.items[1].value);
|
||||
}
|
||||
|
||||
test "putSetting is idempotent and leaves the row count alone" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
try putSetting(&database, "web.password_hash", "$argon2id$v=19$m=19456,t=2,p=1$c2FsdA$aGFzaA");
|
||||
try putSetting(&database, "web.password_hash", "$argon2id$v=19$m=19456,t=2,p=1$c2FsdA$aGFzaA");
|
||||
try testing.expectEqual(@as(i64, 1), try countSettings(&database));
|
||||
|
||||
var items = try listSettings(&database, testing.allocator);
|
||||
defer items.deinit(testing.allocator);
|
||||
defer freeSettings(testing.allocator, items.items);
|
||||
try testing.expectEqualStrings("$argon2id$v=19$m=19456,t=2,p=1$c2FsdA$aGFzaA", items.items[0].value);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
//! facts a running server produces; an insert leaves them at their column
|
||||
//! defaults so two exports taken minutes apart stay identical.
|
||||
//!
|
||||
//! Only list / insert / deleteAll / count exist.
|
||||
//! The import path is list / insert / deleteAll / count; the runtime columns and
|
||||
//! Phase 8's REST surface follow it, both keyed by row id.
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
@@ -14,6 +15,7 @@ const db = @import("../db.zig");
|
||||
const migrations = @import("../migrations.zig");
|
||||
const model = @import("../../config/model.zig");
|
||||
const context = @import("context.zig");
|
||||
const crud = @import("crud.zig");
|
||||
|
||||
const InsertContext = context.InsertContext;
|
||||
|
||||
@@ -91,6 +93,9 @@ pub const SourceRow = struct {
|
||||
url: []const u8,
|
||||
name: []const u8,
|
||||
enabled: bool,
|
||||
/// Defaulted because the blocklist manager builds `SourceRow` values from
|
||||
/// the refresh columns alone; the REST layer is what reads this one.
|
||||
is_suggested: bool = false,
|
||||
last_updated: ?i64,
|
||||
domain_count: i64,
|
||||
wildcard_count: i64,
|
||||
@@ -107,12 +112,15 @@ pub const SourceStats = struct {
|
||||
checksum: []const u8,
|
||||
};
|
||||
|
||||
const list_rows_sql =
|
||||
const row_columns_sql =
|
||||
\\SELECT id, url, name, enabled, last_updated,
|
||||
\\ domain_count, wildcard_count, skipped_regex_count, checksum
|
||||
\\ FROM blocklist_sources ORDER BY url
|
||||
\\ domain_count, wildcard_count, skipped_regex_count, checksum,
|
||||
\\ is_suggested
|
||||
\\ FROM blocklist_sources
|
||||
;
|
||||
|
||||
const list_rows_sql = row_columns_sql ++ " ORDER BY url";
|
||||
|
||||
/// Every source with its row id and its runtime columns, in the same `url`
|
||||
/// order `listBlocklistSources` uses. Every string is a heap copy owned by
|
||||
/// `gpa`; free the whole list with `freeSourceRows` and then `deinit` the list.
|
||||
@@ -127,33 +135,42 @@ pub fn listSourceRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(S
|
||||
errdefer freeSourceRows(gpa, out.items);
|
||||
|
||||
while (try stmt.step()) {
|
||||
const url = try stmt.columnTextAlloc(gpa, 1);
|
||||
errdefer gpa.free(url);
|
||||
const name = try stmt.columnTextAlloc(gpa, 2);
|
||||
errdefer gpa.free(name);
|
||||
const checksum = try stmt.columnTextAllocOrNull(gpa, 8);
|
||||
errdefer if (checksum) |value| gpa.free(value);
|
||||
try out.append(gpa, .{
|
||||
.id = stmt.columnInt(0),
|
||||
.url = url,
|
||||
.name = name,
|
||||
.enabled = stmt.columnBool(3),
|
||||
.last_updated = if (stmt.isNull(4)) null else stmt.columnInt(4),
|
||||
.domain_count = stmt.columnInt(5),
|
||||
.wildcard_count = stmt.columnInt(6),
|
||||
.skipped_regex_count = stmt.columnInt(7),
|
||||
.checksum = checksum,
|
||||
});
|
||||
const row = try readSourceRow(&stmt, gpa);
|
||||
errdefer freeSourceRow(gpa, row);
|
||||
try out.append(gpa, row);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
fn readSourceRow(stmt: *db.Stmt, gpa: Allocator) db.Error!SourceRow {
|
||||
const url = try stmt.columnTextAlloc(gpa, 1);
|
||||
errdefer gpa.free(url);
|
||||
const name = try stmt.columnTextAlloc(gpa, 2);
|
||||
errdefer gpa.free(name);
|
||||
const checksum = try stmt.columnTextAllocOrNull(gpa, 8);
|
||||
errdefer if (checksum) |value| gpa.free(value);
|
||||
return .{
|
||||
.id = stmt.columnInt(0),
|
||||
.url = url,
|
||||
.name = name,
|
||||
.enabled = stmt.columnBool(3),
|
||||
.is_suggested = stmt.columnBool(9),
|
||||
.last_updated = if (stmt.isNull(4)) null else stmt.columnInt(4),
|
||||
.domain_count = stmt.columnInt(5),
|
||||
.wildcard_count = stmt.columnInt(6),
|
||||
.skipped_regex_count = stmt.columnInt(7),
|
||||
.checksum = checksum,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn freeSourceRow(gpa: Allocator, row: SourceRow) void {
|
||||
gpa.free(row.url);
|
||||
gpa.free(row.name);
|
||||
if (row.checksum) |value| gpa.free(value);
|
||||
}
|
||||
|
||||
pub fn freeSourceRows(gpa: Allocator, items: []const SourceRow) void {
|
||||
for (items) |item| {
|
||||
gpa.free(item.url);
|
||||
gpa.free(item.name);
|
||||
if (item.checksum) |value| gpa.free(value);
|
||||
}
|
||||
for (items) |item| freeSourceRow(gpa, item);
|
||||
}
|
||||
|
||||
const update_stats_sql =
|
||||
@@ -177,6 +194,71 @@ pub fn updateSourceStats(database: *db.Db, id: i64, stats: SourceStats) db.Error
|
||||
try stmt.exec();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// REST surface (milestone 8)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// `/api/blocklists` is this table. The read shape is `SourceRow` above — the
|
||||
// UI wants the counters next to the configuration — and the write shape is
|
||||
// `model.BlocklistSource`, whose four fields are the four columns an operator
|
||||
// may set.
|
||||
|
||||
pub fn getSource(database: *db.Db, gpa: Allocator, id: i64) db.Error!?SourceRow {
|
||||
var stmt = try database.prepare(row_columns_sql ++ " WHERE id = ?1");
|
||||
defer stmt.deinit();
|
||||
try stmt.bindInt(1, id);
|
||||
if (!try stmt.step()) return null;
|
||||
return try readSourceRow(&stmt, gpa);
|
||||
}
|
||||
|
||||
/// The runtime columns stay at their defaults, so a source added through the
|
||||
/// API looks exactly like an imported one until the first refresh.
|
||||
///
|
||||
/// `error.Constraint`: `blocklist_sources.url` is UNIQUE.
|
||||
pub fn insertSourceRow(database: *db.Db, item: model.BlocklistSource) db.Error!i64 {
|
||||
var stmt = try database.prepare(insert_sql);
|
||||
defer stmt.deinit();
|
||||
try stmt.bindText(1, item.url);
|
||||
try stmt.bindText(2, item.name);
|
||||
try stmt.bindBool(3, item.enabled);
|
||||
try stmt.bindBool(4, item.is_suggested);
|
||||
try stmt.exec();
|
||||
return database.lastInsertRowid();
|
||||
}
|
||||
|
||||
const update_source_sql =
|
||||
\\UPDATE blocklist_sources
|
||||
\\ SET url = ?2, name = ?3, enabled = ?4, is_suggested = ?5
|
||||
\\ WHERE id = ?1
|
||||
;
|
||||
|
||||
/// Writes the four configuration columns. The runtime columns are the refresh
|
||||
/// path's and stay as they are — even when `url` changes, because the next
|
||||
/// refresh compares checksums and replaces them anyway.
|
||||
///
|
||||
/// `error.NotFound`: no source holds `id`. `error.Constraint`:
|
||||
/// `blocklist_sources.url` is UNIQUE.
|
||||
pub fn updateSource(database: *db.Db, id: i64, item: model.BlocklistSource) db.Error!void {
|
||||
var stmt = try database.prepare(update_source_sql);
|
||||
defer stmt.deinit();
|
||||
try stmt.bindInt(1, id);
|
||||
try stmt.bindText(2, item.url);
|
||||
try stmt.bindText(3, item.name);
|
||||
try stmt.bindBool(4, item.enabled);
|
||||
try stmt.bindBool(5, item.is_suggested);
|
||||
return crud.execStrict(database, &stmt);
|
||||
}
|
||||
|
||||
/// `error.NotFound`: no source holds `id`. `group_sources` references it
|
||||
/// `ON DELETE CASCADE`, so every group's assignment loses it silently and no
|
||||
/// constraint can fire.
|
||||
pub fn deleteSource(database: *db.Db, id: i64) db.Error!void {
|
||||
var stmt = try database.prepare("DELETE FROM blocklist_sources WHERE id = ?1");
|
||||
defer stmt.deinit();
|
||||
try stmt.bindInt(1, id);
|
||||
return crud.execStrict(database, &stmt);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -361,3 +443,126 @@ fn listSourceRowsUnderFailure(gpa: Allocator) !void {
|
||||
test "listSourceRows is leak-safe under allocation failure" {
|
||||
try testing.checkAllAllocationFailures(testing.allocator, listSourceRowsUnderFailure, .{});
|
||||
}
|
||||
|
||||
// --- REST surface ----------------------------------------------------------
|
||||
|
||||
test "a blocklist source round-trips through insert, get, list, update and delete" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
const id = try insertSourceRow(&database, .{
|
||||
.url = "https://lists.example/hosts.txt",
|
||||
.name = "Example",
|
||||
.is_suggested = true,
|
||||
});
|
||||
|
||||
const fetched = (try getSource(&database, testing.allocator, id)).?;
|
||||
defer freeSourceRow(testing.allocator, fetched);
|
||||
try testing.expectEqual(id, fetched.id);
|
||||
try testing.expectEqualStrings("https://lists.example/hosts.txt", fetched.url);
|
||||
try testing.expectEqualStrings("Example", fetched.name);
|
||||
try testing.expect(fetched.enabled);
|
||||
try testing.expect(fetched.is_suggested);
|
||||
try testing.expectEqual(@as(?i64, null), fetched.last_updated);
|
||||
|
||||
try updateSource(&database, id, .{
|
||||
.url = "https://lists.example/hosts.txt",
|
||||
.name = "Example list",
|
||||
.enabled = false,
|
||||
});
|
||||
const updated = (try getSource(&database, testing.allocator, id)).?;
|
||||
defer freeSourceRow(testing.allocator, updated);
|
||||
try testing.expectEqualStrings("Example list", updated.name);
|
||||
try testing.expect(!updated.enabled);
|
||||
try testing.expect(!updated.is_suggested);
|
||||
|
||||
try deleteSource(&database, id);
|
||||
try testing.expectEqual(@as(?SourceRow, null), try getSource(&database, testing.allocator, id));
|
||||
try testing.expectEqual(@as(i64, 0), try countBlocklistSources(&database));
|
||||
}
|
||||
|
||||
test "updateSource leaves the runtime columns where the refresh path left them" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
const id = try insertSourceRow(&database, .{ .url = "https://lists.example/a.txt", .name = "A" });
|
||||
try updateSourceStats(&database, id, .{
|
||||
.last_updated = 1_700_000_000,
|
||||
.domain_count = 12,
|
||||
.wildcard_count = 3,
|
||||
.skipped_regex_count = 1,
|
||||
.checksum = "c" ** 64,
|
||||
});
|
||||
|
||||
try updateSource(&database, id, .{ .url = "https://lists.example/b.txt", .name = "B" });
|
||||
|
||||
const row = (try getSource(&database, testing.allocator, id)).?;
|
||||
defer freeSourceRow(testing.allocator, row);
|
||||
try testing.expectEqualStrings("https://lists.example/b.txt", row.url);
|
||||
try testing.expectEqual(@as(?i64, 1_700_000_000), row.last_updated);
|
||||
try testing.expectEqual(@as(i64, 12), row.domain_count);
|
||||
try testing.expectEqualStrings("c" ** 64, row.checksum.?);
|
||||
}
|
||||
|
||||
test "source update and delete report NotFound for an id no row holds" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
try testing.expectError(
|
||||
error.NotFound,
|
||||
updateSource(&database, 404, .{ .url = "https://lists.example/a.txt", .name = "A" }),
|
||||
);
|
||||
try testing.expectError(error.NotFound, deleteSource(&database, 404));
|
||||
try testing.expectEqual(@as(?SourceRow, null), try getSource(&database, testing.allocator, 404));
|
||||
}
|
||||
|
||||
test "a duplicate source url surfaces as error.Constraint on insert and on update" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
_ = try insertSourceRow(&database, .{ .url = "https://lists.example/a.txt", .name = "A" });
|
||||
const other = try insertSourceRow(&database, .{ .url = "https://lists.example/b.txt", .name = "B" });
|
||||
|
||||
try testing.expectError(
|
||||
error.Constraint,
|
||||
insertSourceRow(&database, .{ .url = "https://lists.example/a.txt", .name = "again" }),
|
||||
);
|
||||
try testing.expectError(
|
||||
error.Constraint,
|
||||
updateSource(&database, other, .{ .url = "https://lists.example/a.txt", .name = "B" }),
|
||||
);
|
||||
try testing.expectEqual(@as(i64, 2), try countBlocklistSources(&database));
|
||||
}
|
||||
|
||||
test "deleting a source drops it from every group assignment" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
const id = try insertSourceRow(&database, .{ .url = "https://lists.example/a.txt", .name = "A" });
|
||||
const keep = try insertSourceRow(&database, .{ .url = "https://lists.example/b.txt", .name = "B" });
|
||||
try database.exec("INSERT INTO group_sources (group_id, source_id) VALUES (1, 1), (1, 2);");
|
||||
|
||||
try deleteSource(&database, id);
|
||||
try testing.expectEqual(@as(i64, 1), try countGroupSources(&database));
|
||||
try testing.expectEqual(
|
||||
keep,
|
||||
try database.queryInt("SELECT source_id FROM group_sources"),
|
||||
);
|
||||
}
|
||||
|
||||
fn countGroupSources(database: *db.Db) db.Error!i64 {
|
||||
return database.queryInt("SELECT count(*) FROM group_sources");
|
||||
}
|
||||
|
||||
fn sourceRowUnderFailure(gpa: Allocator) !void {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
try seedSources(&database);
|
||||
|
||||
const one = (try getSource(&database, gpa, 1)).?;
|
||||
defer freeSourceRow(gpa, one);
|
||||
}
|
||||
|
||||
test "getSource is leak-safe under allocation failure" {
|
||||
try testing.checkAllAllocationFailures(testing.allocator, sourceRowUnderFailure, .{});
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
//! meaningful order — it matches what `Pool.init` expects — and `url` breaks
|
||||
//! ties uniquely, which is what makes an export byte-stable.
|
||||
//!
|
||||
//! Only list / insert / deleteAll / count exist.
|
||||
//! The import path is list / insert / deleteAll / count. Phase 8's REST surface
|
||||
//! is the second half of this file: it speaks row ids, because that is what an
|
||||
//! `/api/upstreams/{id}` request names.
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
@@ -13,6 +15,7 @@ const db = @import("../db.zig");
|
||||
const migrations = @import("../migrations.zig");
|
||||
const model = @import("../../config/model.zig");
|
||||
const context = @import("context.zig");
|
||||
const crud = @import("crud.zig");
|
||||
|
||||
const InsertContext = context.InsertContext;
|
||||
|
||||
@@ -73,6 +76,118 @@ pub fn countUpstreams(database: *db.Db) db.Error!i64 {
|
||||
return database.queryInt("SELECT count(*) FROM upstreams");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// REST surface (milestone 8)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// The write shape is `model.UpstreamServer`: its four fields are exactly the
|
||||
// columns of the table, so the REST layer needs no second shape for them.
|
||||
|
||||
pub const UpstreamRow = struct {
|
||||
id: i64,
|
||||
url: []const u8,
|
||||
priority: i32,
|
||||
enabled: bool,
|
||||
tls_name: []const u8,
|
||||
};
|
||||
|
||||
const list_upstream_rows_sql =
|
||||
\\SELECT id, url, priority, enabled, tls_name FROM upstreams ORDER BY priority, url
|
||||
;
|
||||
|
||||
const get_upstream_sql =
|
||||
\\SELECT id, url, priority, enabled, tls_name FROM upstreams WHERE id = ?1
|
||||
;
|
||||
|
||||
/// Same order as `listUpstreams`; every string is a heap copy owned by `gpa`.
|
||||
pub fn listUpstreamRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(UpstreamRow) {
|
||||
var stmt = try database.prepare(list_upstream_rows_sql);
|
||||
defer stmt.deinit();
|
||||
|
||||
var out: std.ArrayList(UpstreamRow) = .empty;
|
||||
errdefer out.deinit(gpa);
|
||||
errdefer freeUpstreamRows(gpa, out.items);
|
||||
|
||||
while (try stmt.step()) {
|
||||
const row = try readUpstreamRow(&stmt, gpa);
|
||||
errdefer freeUpstreamRow(gpa, row);
|
||||
try out.append(gpa, row);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
pub fn freeUpstreamRow(gpa: Allocator, row: UpstreamRow) void {
|
||||
gpa.free(row.url);
|
||||
gpa.free(row.tls_name);
|
||||
}
|
||||
|
||||
pub fn freeUpstreamRows(gpa: Allocator, items: []const UpstreamRow) void {
|
||||
for (items) |item| freeUpstreamRow(gpa, item);
|
||||
}
|
||||
|
||||
pub fn getUpstream(database: *db.Db, gpa: Allocator, id: i64) db.Error!?UpstreamRow {
|
||||
var stmt = try database.prepare(get_upstream_sql);
|
||||
defer stmt.deinit();
|
||||
try stmt.bindInt(1, id);
|
||||
if (!try stmt.step()) return null;
|
||||
return try readUpstreamRow(&stmt, gpa);
|
||||
}
|
||||
|
||||
fn readUpstreamRow(stmt: *db.Stmt, gpa: Allocator) db.Error!UpstreamRow {
|
||||
const url = try stmt.columnTextAlloc(gpa, 1);
|
||||
errdefer gpa.free(url);
|
||||
const tls_name = try stmt.columnTextAlloc(gpa, 4);
|
||||
errdefer gpa.free(tls_name);
|
||||
// The column is a 64-bit integer; the row field is `i32`. A value outside
|
||||
// that range means something other than nxdns wrote the row.
|
||||
const priority = std.math.cast(i32, stmt.columnInt(2)) orelse return error.Mismatch;
|
||||
return .{
|
||||
.id = stmt.columnInt(0),
|
||||
.url = url,
|
||||
.priority = priority,
|
||||
.enabled = stmt.columnBool(3),
|
||||
.tls_name = tls_name,
|
||||
};
|
||||
}
|
||||
|
||||
/// `error.Constraint`: `upstreams.url` is UNIQUE.
|
||||
pub fn insertUpstreamRow(database: *db.Db, item: model.UpstreamServer) db.Error!i64 {
|
||||
var stmt = try database.prepare(
|
||||
"INSERT INTO upstreams (url, priority, enabled, tls_name) VALUES (?1, ?2, ?3, ?4)",
|
||||
);
|
||||
defer stmt.deinit();
|
||||
try stmt.bindText(1, item.url);
|
||||
try stmt.bindInt(2, item.priority);
|
||||
try stmt.bindBool(3, item.enabled);
|
||||
try stmt.bindText(4, item.tls_name);
|
||||
try stmt.exec();
|
||||
return database.lastInsertRowid();
|
||||
}
|
||||
|
||||
/// `error.NotFound`: no upstream holds `id`. `error.Constraint`:
|
||||
/// `upstreams.url` is UNIQUE.
|
||||
pub fn updateUpstream(database: *db.Db, id: i64, item: model.UpstreamServer) db.Error!void {
|
||||
var stmt = try database.prepare(
|
||||
"UPDATE upstreams SET url = ?2, priority = ?3, enabled = ?4, tls_name = ?5 WHERE id = ?1",
|
||||
);
|
||||
defer stmt.deinit();
|
||||
try stmt.bindInt(1, id);
|
||||
try stmt.bindText(2, item.url);
|
||||
try stmt.bindInt(3, item.priority);
|
||||
try stmt.bindBool(4, item.enabled);
|
||||
try stmt.bindText(5, item.tls_name);
|
||||
return crud.execStrict(database, &stmt);
|
||||
}
|
||||
|
||||
/// `error.NotFound`: no upstream holds `id`. Nothing references `upstreams`, so
|
||||
/// a delete cannot violate a constraint.
|
||||
pub fn deleteUpstream(database: *db.Db, id: i64) db.Error!void {
|
||||
var stmt = try database.prepare("DELETE FROM upstreams WHERE id = ?1");
|
||||
defer stmt.deinit();
|
||||
try stmt.bindInt(1, id);
|
||||
return crud.execStrict(database, &stmt);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -151,3 +266,89 @@ fn listUpstreamsUnderFailure(gpa: Allocator) !void {
|
||||
test "listUpstreams is leak-safe under allocation failure" {
|
||||
try testing.checkAllAllocationFailures(testing.allocator, listUpstreamsUnderFailure, .{});
|
||||
}
|
||||
|
||||
// --- REST surface ----------------------------------------------------------
|
||||
|
||||
test "an upstream round-trips through insert, get, list, update and delete" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
const id = try insertUpstreamRow(&database, .{
|
||||
.url = "tls://1.1.1.1:853",
|
||||
.priority = 10,
|
||||
.tls_name = "one.one.one.one",
|
||||
});
|
||||
|
||||
const fetched = (try getUpstream(&database, testing.allocator, id)).?;
|
||||
defer freeUpstreamRow(testing.allocator, fetched);
|
||||
try testing.expectEqual(id, fetched.id);
|
||||
try testing.expectEqualStrings("tls://1.1.1.1:853", fetched.url);
|
||||
try testing.expectEqual(@as(i32, 10), fetched.priority);
|
||||
try testing.expect(fetched.enabled);
|
||||
try testing.expectEqualStrings("one.one.one.one", fetched.tls_name);
|
||||
|
||||
const second = try insertUpstreamRow(&database, .{ .url = "udp://9.9.9.9:53", .priority = 20 });
|
||||
|
||||
var rows = try listUpstreamRows(&database, testing.allocator);
|
||||
defer rows.deinit(testing.allocator);
|
||||
defer freeUpstreamRows(testing.allocator, rows.items);
|
||||
try testing.expectEqual(@as(usize, 2), rows.items.len);
|
||||
try testing.expectEqual(id, rows.items[0].id);
|
||||
try testing.expectEqual(second, rows.items[1].id);
|
||||
try testing.expectEqualStrings("", rows.items[1].tls_name);
|
||||
|
||||
try updateUpstream(&database, id, .{
|
||||
.url = "tls://1.0.0.1:853",
|
||||
.priority = 5,
|
||||
.enabled = false,
|
||||
.tls_name = "",
|
||||
});
|
||||
const updated = (try getUpstream(&database, testing.allocator, id)).?;
|
||||
defer freeUpstreamRow(testing.allocator, updated);
|
||||
try testing.expectEqualStrings("tls://1.0.0.1:853", updated.url);
|
||||
try testing.expectEqual(@as(i32, 5), updated.priority);
|
||||
try testing.expect(!updated.enabled);
|
||||
try testing.expectEqualStrings("", updated.tls_name);
|
||||
|
||||
try deleteUpstream(&database, id);
|
||||
try testing.expectEqual(@as(?UpstreamRow, null), try getUpstream(&database, testing.allocator, id));
|
||||
try testing.expectEqual(@as(i64, 1), try countUpstreams(&database));
|
||||
}
|
||||
|
||||
test "upstream update and delete report NotFound for an id no row holds" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
try testing.expectError(error.NotFound, updateUpstream(&database, 404, .{ .url = "udp://9.9.9.9:53" }));
|
||||
try testing.expectError(error.NotFound, deleteUpstream(&database, 404));
|
||||
try testing.expectEqual(@as(?UpstreamRow, null), try getUpstream(&database, testing.allocator, 404));
|
||||
}
|
||||
|
||||
test "a duplicate upstream url surfaces as error.Constraint on insert and on update" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
_ = try insertUpstreamRow(&database, .{ .url = "udp://9.9.9.9:53" });
|
||||
const other = try insertUpstreamRow(&database, .{ .url = "udp://1.1.1.1:53" });
|
||||
|
||||
try testing.expectError(error.Constraint, insertUpstreamRow(&database, .{ .url = "udp://9.9.9.9:53" }));
|
||||
try testing.expectError(error.Constraint, updateUpstream(&database, other, .{ .url = "udp://9.9.9.9:53" }));
|
||||
try testing.expectEqual(@as(i64, 2), try countUpstreams(&database));
|
||||
}
|
||||
|
||||
fn upstreamRowsUnderFailure(gpa: Allocator) !void {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
try seedUpstreams(&database);
|
||||
|
||||
var rows = try listUpstreamRows(&database, gpa);
|
||||
defer rows.deinit(gpa);
|
||||
defer freeUpstreamRows(gpa, rows.items);
|
||||
|
||||
const one = (try getUpstream(&database, gpa, rows.items[0].id)).?;
|
||||
defer freeUpstreamRow(gpa, one);
|
||||
}
|
||||
|
||||
test "the upstream read surface is leak-safe under allocation failure" {
|
||||
try testing.checkAllAllocationFailures(testing.allocator, upstreamRowsUnderFailure, .{});
|
||||
}
|
||||
|
||||
+57
-27
@@ -26,6 +26,7 @@ pub const vacuum_every_passes = 7;
|
||||
/// expresses, so a finer schedule would prune nothing new.
|
||||
pub const pass_interval_s = 86_400;
|
||||
|
||||
/// A consistent copy of the counters, for `/metrics` and the health rollup.
|
||||
pub const Stats = struct {
|
||||
passes: u64 = 0,
|
||||
rows_pruned: u64 = 0,
|
||||
@@ -33,12 +34,34 @@ pub const Stats = struct {
|
||||
vacuums: u64 = 0,
|
||||
};
|
||||
|
||||
/// The live counters. Atomic because the retention task writes them and the web
|
||||
/// task reads them, on different threads, with no lock between the two — the
|
||||
/// same shape the query logger uses for its own counters.
|
||||
const Counters = struct {
|
||||
passes: std.atomic.Value(u64) = .init(0),
|
||||
rows_pruned: std.atomic.Value(u64) = .init(0),
|
||||
checkpoints: std.atomic.Value(u64) = .init(0),
|
||||
vacuums: std.atomic.Value(u64) = .init(0),
|
||||
};
|
||||
|
||||
pub const Retention = struct {
|
||||
cfg: model.Logging,
|
||||
stats: Stats,
|
||||
counters: Counters,
|
||||
|
||||
pub fn init(cfg: model.Logging) Retention {
|
||||
return .{ .cfg = cfg, .stats = .{} };
|
||||
return .{ .cfg = cfg, .counters = .{} };
|
||||
}
|
||||
|
||||
/// The four counters, read one at a time. A scrape that lands mid-pass can
|
||||
/// see a pass counted before the rows it pruned are; the alternative is a
|
||||
/// lock on the pass itself, which buys a consistency no consumer needs.
|
||||
pub fn snapshotStats(self: *const Retention) Stats {
|
||||
return .{
|
||||
.passes = self.counters.passes.load(.monotonic),
|
||||
.rows_pruned = self.counters.rows_pruned.load(.monotonic),
|
||||
.checkpoints = self.counters.checkpoints.load(.monotonic),
|
||||
.vacuums = self.counters.vacuums.load(.monotonic),
|
||||
};
|
||||
}
|
||||
|
||||
/// One pass: prune, checkpoint, and on every seventh pass vacuum.
|
||||
@@ -53,29 +76,36 @@ pub const Retention = struct {
|
||||
///
|
||||
/// `database` must be a connection no other task uses; see `run`.
|
||||
pub fn runOnce(self: *Retention, io: std.Io, database: *db.Db) void {
|
||||
self.stats.passes += 1;
|
||||
const pass = add(&self.counters.passes, 1) + 1;
|
||||
const cutoff = std.Io.Clock.real.now(io).toSeconds() - model.retentionSeconds(self.cfg);
|
||||
|
||||
if (queries_repo.pruneOlderThan(database, cutoff)) |deleted| {
|
||||
self.stats.rows_pruned += @intCast(deleted);
|
||||
_ = add(&self.counters.rows_pruned, @intCast(deleted));
|
||||
} else |err| {
|
||||
log.warn("retention prune before {d} failed: {s}", .{ cutoff, @errorName(err) });
|
||||
}
|
||||
|
||||
if (queries_repo.checkpointTruncate(database)) {
|
||||
self.stats.checkpoints += 1;
|
||||
_ = add(&self.counters.checkpoints, 1);
|
||||
} else |err| {
|
||||
log.warn("retention checkpoint failed: {s}", .{@errorName(err)});
|
||||
}
|
||||
|
||||
if (self.stats.passes % vacuum_every_passes != 0) return;
|
||||
if (pass % vacuum_every_passes != 0) return;
|
||||
if (queries_repo.vacuum(database)) {
|
||||
self.stats.vacuums += 1;
|
||||
_ = add(&self.counters.vacuums, 1);
|
||||
} else |err| {
|
||||
log.warn("retention vacuum failed: {s}", .{@errorName(err)});
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the value before the addition, which is what the pass counter
|
||||
/// needs: only this task increments it, so `previous + 1` is this pass's
|
||||
/// number.
|
||||
fn add(counter: *std.atomic.Value(u64), delta: u64) u64 {
|
||||
return counter.fetchAdd(delta, .monotonic);
|
||||
}
|
||||
|
||||
/// Daily loop, first pass immediately. Phase 7 starts it.
|
||||
///
|
||||
/// `boot` rather than `awake`: a box that suspends overnight must still see
|
||||
@@ -159,10 +189,10 @@ test "a pass prunes the rows past the retention window and keeps the rest" {
|
||||
retention.runOnce(io, &database);
|
||||
|
||||
try testing.expectEqual(@as(i64, 2), try queries_repo.countRows(&database));
|
||||
try testing.expectEqual(@as(u64, 1), retention.stats.passes);
|
||||
try testing.expectEqual(@as(u64, 2), retention.stats.rows_pruned);
|
||||
try testing.expectEqual(@as(u64, 1), retention.stats.checkpoints);
|
||||
try testing.expectEqual(@as(u64, 0), retention.stats.vacuums);
|
||||
try testing.expectEqual(@as(u64, 1), retention.snapshotStats().passes);
|
||||
try testing.expectEqual(@as(u64, 2), retention.snapshotStats().rows_pruned);
|
||||
try testing.expectEqual(@as(u64, 1), retention.snapshotStats().checkpoints);
|
||||
try testing.expectEqual(@as(u64, 0), retention.snapshotStats().vacuums);
|
||||
}
|
||||
|
||||
test "the cutoff follows retention_days" {
|
||||
@@ -182,12 +212,12 @@ test "the cutoff follows retention_days" {
|
||||
var keeps: Retention = .init(.{ .retention_days = 7 });
|
||||
keeps.runOnce(io, &database);
|
||||
try testing.expectEqual(@as(i64, 1), try queries_repo.countRows(&database));
|
||||
try testing.expectEqual(@as(u64, 0), keeps.stats.rows_pruned);
|
||||
try testing.expectEqual(@as(u64, 0), keeps.snapshotStats().rows_pruned);
|
||||
|
||||
var prunes: Retention = .init(.{ .retention_days = 1 });
|
||||
prunes.runOnce(io, &database);
|
||||
try testing.expectEqual(@as(i64, 0), try queries_repo.countRows(&database));
|
||||
try testing.expectEqual(@as(u64, 1), prunes.stats.rows_pruned);
|
||||
try testing.expectEqual(@as(u64, 1), prunes.snapshotStats().rows_pruned);
|
||||
}
|
||||
|
||||
test "the seventh pass vacuums and the six before it do not" {
|
||||
@@ -201,17 +231,17 @@ test "the seventh pass vacuums and the six before it do not" {
|
||||
var retention: Retention = .init(.{});
|
||||
for (0..6) |_| {
|
||||
retention.runOnce(io, &database);
|
||||
try testing.expectEqual(@as(u64, 0), retention.stats.vacuums);
|
||||
try testing.expectEqual(@as(u64, 0), retention.snapshotStats().vacuums);
|
||||
}
|
||||
retention.runOnce(io, &database);
|
||||
|
||||
try testing.expectEqual(@as(u64, 7), retention.stats.passes);
|
||||
try testing.expectEqual(@as(u64, 1), retention.stats.vacuums);
|
||||
try testing.expectEqual(@as(u64, 7), retention.stats.checkpoints);
|
||||
try testing.expectEqual(@as(u64, 7), retention.snapshotStats().passes);
|
||||
try testing.expectEqual(@as(u64, 1), retention.snapshotStats().vacuums);
|
||||
try testing.expectEqual(@as(u64, 7), retention.snapshotStats().checkpoints);
|
||||
|
||||
for (0..7) |_| retention.runOnce(io, &database);
|
||||
try testing.expectEqual(@as(u64, 14), retention.stats.passes);
|
||||
try testing.expectEqual(@as(u64, 2), retention.stats.vacuums);
|
||||
try testing.expectEqual(@as(u64, 14), retention.snapshotStats().passes);
|
||||
try testing.expectEqual(@as(u64, 2), retention.snapshotStats().vacuums);
|
||||
}
|
||||
|
||||
test "a pass over an empty database still counts" {
|
||||
@@ -225,9 +255,9 @@ test "a pass over an empty database still counts" {
|
||||
var retention: Retention = .init(.{});
|
||||
retention.runOnce(io, &database);
|
||||
|
||||
try testing.expectEqual(@as(u64, 1), retention.stats.passes);
|
||||
try testing.expectEqual(@as(u64, 0), retention.stats.rows_pruned);
|
||||
try testing.expectEqual(@as(u64, 1), retention.stats.checkpoints);
|
||||
try testing.expectEqual(@as(u64, 1), retention.snapshotStats().passes);
|
||||
try testing.expectEqual(@as(u64, 0), retention.snapshotStats().rows_pruned);
|
||||
try testing.expectEqual(@as(u64, 1), retention.snapshotStats().checkpoints);
|
||||
try testing.expectEqual(@as(i64, 0), try queries_repo.countRows(&database));
|
||||
}
|
||||
|
||||
@@ -250,10 +280,10 @@ test "a failing prune counts the pass and leaves the rows alone" {
|
||||
retention.runOnce(io, &database);
|
||||
|
||||
try testing.expectEqual(@as(i64, 1), try queries_repo.countRows(&database));
|
||||
try testing.expectEqual(@as(u64, 1), retention.stats.passes);
|
||||
try testing.expectEqual(@as(u64, 0), retention.stats.rows_pruned);
|
||||
try testing.expectEqual(@as(u64, 1), retention.snapshotStats().passes);
|
||||
try testing.expectEqual(@as(u64, 0), retention.snapshotStats().rows_pruned);
|
||||
// The checkpoint runs whether or not the prune did.
|
||||
try testing.expectEqual(@as(u64, 1), retention.stats.checkpoints);
|
||||
try testing.expectEqual(@as(u64, 1), retention.snapshotStats().checkpoints);
|
||||
}
|
||||
|
||||
test "the next pass retries what the failed one could not do" {
|
||||
@@ -279,6 +309,6 @@ test "the next pass retries what the failed one could not do" {
|
||||
retention.runOnce(io, &database);
|
||||
|
||||
try testing.expectEqual(@as(i64, 0), try queries_repo.countRows(&database));
|
||||
try testing.expectEqual(@as(u64, 2), retention.stats.passes);
|
||||
try testing.expectEqual(@as(u64, 2), retention.stats.rows_pruned);
|
||||
try testing.expectEqual(@as(u64, 2), retention.snapshotStats().passes);
|
||||
try testing.expectEqual(@as(u64, 2), retention.snapshotStats().rows_pruned);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user