storage and config: sqlite wrapper, migrations, querylog policy, repositories, zon config with import/export/check cli
This commit is contained in:
@@ -0,0 +1,336 @@
|
||||
//! `clients` and `client_prefixes`.
|
||||
//!
|
||||
//! `listClients` returns only `hand_edited = 1` rows. A client the server
|
||||
//! materialised from live traffic is runtime state, not configuration, and must
|
||||
//! 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 exist.
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const db = @import("../db.zig");
|
||||
const migrations = @import("../migrations.zig");
|
||||
const model = @import("../../config/model.zig");
|
||||
const context = @import("context.zig");
|
||||
|
||||
const IdMap = context.IdMap;
|
||||
const InsertContext = context.InsertContext;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// clients
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const list_clients_sql =
|
||||
\\SELECT c.ip, c.name, g.name FROM clients c
|
||||
\\ JOIN groups g ON g.id = c.group_id
|
||||
\\ WHERE c.hand_edited = 1
|
||||
\\ ORDER BY c.ip
|
||||
;
|
||||
|
||||
/// Every string in the result is a heap copy owned by `gpa`.
|
||||
pub fn listClients(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.Client) {
|
||||
var stmt = try database.prepare(list_clients_sql);
|
||||
defer stmt.deinit();
|
||||
|
||||
var out: std.ArrayList(model.Client) = .empty;
|
||||
// `errdefer`s run in reverse: `freeClients` is declared last so it runs
|
||||
// before the backing array is released.
|
||||
errdefer out.deinit(gpa);
|
||||
errdefer freeClients(gpa, out.items);
|
||||
|
||||
while (try stmt.step()) {
|
||||
const ip = try stmt.columnTextAlloc(gpa, 0);
|
||||
errdefer gpa.free(ip);
|
||||
// `clients.name` is nullable; `columnTextAlloc` reads NULL as "", which
|
||||
// is exactly the model's default.
|
||||
const name = try stmt.columnTextAlloc(gpa, 1);
|
||||
errdefer gpa.free(name);
|
||||
const group = try stmt.columnTextAlloc(gpa, 2);
|
||||
errdefer gpa.free(group);
|
||||
try out.append(gpa, .{ .ip = ip, .name = name, .group = group });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
pub fn freeClients(gpa: Allocator, items: []const model.Client) void {
|
||||
for (items) |item| {
|
||||
gpa.free(item.ip);
|
||||
gpa.free(item.name);
|
||||
gpa.free(item.group);
|
||||
}
|
||||
}
|
||||
|
||||
const insert_client_sql =
|
||||
\\INSERT INTO clients (ip, name, group_id, hand_edited, first_seen, last_seen)
|
||||
\\VALUES (?1, ?2, ?3, 1, ?4, ?4)
|
||||
;
|
||||
|
||||
/// `hand_edited` is 1: a client that reached a repository through the config
|
||||
/// model came from an operator's file, by definition.
|
||||
pub fn insertClient(database: *db.Db, item: model.Client, ctx: InsertContext) db.Error!void {
|
||||
const group_id = try ctx.groupId(item.group);
|
||||
|
||||
var stmt = try database.prepare(insert_client_sql);
|
||||
defer stmt.deinit();
|
||||
try stmt.bindText(1, item.ip);
|
||||
try stmt.bindText(2, item.name);
|
||||
try stmt.bindInt(3, group_id);
|
||||
try stmt.bindInt(4, ctx.now);
|
||||
try stmt.exec();
|
||||
}
|
||||
|
||||
pub fn deleteAllClients(database: *db.Db) db.Error!void {
|
||||
return database.exec("DELETE FROM clients;");
|
||||
}
|
||||
|
||||
/// Counts every row, including the ones `listClients` filters out.
|
||||
pub fn countClients(database: *db.Db) db.Error!i64 {
|
||||
return database.queryInt("SELECT count(*) FROM clients");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// client_prefixes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const list_client_prefixes_sql =
|
||||
\\SELECT p.prefix, g.name, p.priority FROM client_prefixes p
|
||||
\\ JOIN groups g ON g.id = p.group_id
|
||||
\\ ORDER BY p.prefix
|
||||
;
|
||||
|
||||
pub fn listClientPrefixes(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.ClientPrefix) {
|
||||
var stmt = try database.prepare(list_client_prefixes_sql);
|
||||
defer stmt.deinit();
|
||||
|
||||
var out: std.ArrayList(model.ClientPrefix) = .empty;
|
||||
errdefer out.deinit(gpa);
|
||||
errdefer freeClientPrefixes(gpa, out.items);
|
||||
|
||||
while (try stmt.step()) {
|
||||
const prefix = try stmt.columnTextAlloc(gpa, 0);
|
||||
errdefer gpa.free(prefix);
|
||||
const group = try stmt.columnTextAlloc(gpa, 1);
|
||||
errdefer gpa.free(group);
|
||||
// The column is a 64-bit integer; the model 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;
|
||||
try out.append(gpa, .{ .prefix = prefix, .group = group, .priority = priority });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
pub fn freeClientPrefixes(gpa: Allocator, items: []const model.ClientPrefix) void {
|
||||
for (items) |item| {
|
||||
gpa.free(item.prefix);
|
||||
gpa.free(item.group);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn insertClientPrefix(database: *db.Db, item: model.ClientPrefix, ctx: InsertContext) db.Error!void {
|
||||
const group_id = try ctx.groupId(item.group);
|
||||
|
||||
var stmt = try database.prepare("INSERT INTO client_prefixes (prefix, group_id, priority) VALUES (?1, ?2, ?3)");
|
||||
defer stmt.deinit();
|
||||
try stmt.bindText(1, item.prefix);
|
||||
try stmt.bindInt(2, group_id);
|
||||
try stmt.bindInt(3, item.priority);
|
||||
try stmt.exec();
|
||||
}
|
||||
|
||||
pub fn deleteAllClientPrefixes(database: *db.Db) db.Error!void {
|
||||
return database.exec("DELETE FROM client_prefixes;");
|
||||
}
|
||||
|
||||
pub fn countClientPrefixes(database: *db.Db) db.Error!i64 {
|
||||
return database.queryInt("SELECT count(*) FROM client_prefixes");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
fn openMigrated() !db.Db {
|
||||
var database = try db.Db.open(":memory:", .{ .mode = .memory });
|
||||
errdefer database.close();
|
||||
try db.applyPragmas(&database, .{});
|
||||
_ = try migrations.migrate(&database);
|
||||
return database;
|
||||
}
|
||||
|
||||
/// Migration step 1 seeds `(1, 'default')`; `kids` is added here so the join
|
||||
/// has two distinct groups to resolve.
|
||||
fn seedGroups(database: *db.Db) !IdMap {
|
||||
try database.exec("INSERT INTO groups (id, name) VALUES (2, 'kids');");
|
||||
var ids: IdMap = .empty;
|
||||
errdefer ids.deinit(testing.allocator);
|
||||
try ids.put(testing.allocator, "default", 1);
|
||||
try ids.put(testing.allocator, "kids", 2);
|
||||
return ids;
|
||||
}
|
||||
|
||||
fn seedClients(database: *db.Db, ids: *const IdMap) !void {
|
||||
const ctx: InsertContext = .{ .now = 1700000000, .group_ids = ids };
|
||||
try insertClient(database, .{ .ip = "192.168.1.20", .name = "laptop", .group = "kids" }, ctx);
|
||||
try insertClient(database, .{ .ip = "192.168.1.10", .name = "desk" }, ctx);
|
||||
try insertClient(database, .{ .ip = "fd00::1", .group = "kids" }, ctx);
|
||||
}
|
||||
|
||||
fn seedClientPrefixes(database: *db.Db, ids: *const IdMap) !void {
|
||||
const ctx: InsertContext = .{ .group_ids = ids };
|
||||
try insertClientPrefix(database, .{ .prefix = "192.168.2.0/24", .group = "kids", .priority = 10 }, ctx);
|
||||
try insertClientPrefix(database, .{ .prefix = "192.168.1.0/24", .priority = 50 }, ctx);
|
||||
try insertClientPrefix(database, .{ .prefix = "fd00::/48", .group = "kids" }, ctx);
|
||||
}
|
||||
|
||||
test "clients round-trip in ip order with group names resolved" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
var ids = try seedGroups(&database);
|
||||
defer ids.deinit(testing.allocator);
|
||||
try seedClients(&database, &ids);
|
||||
|
||||
var items = try listClients(&database, testing.allocator);
|
||||
defer items.deinit(testing.allocator);
|
||||
defer freeClients(testing.allocator, items.items);
|
||||
|
||||
try testing.expectEqual(@as(usize, 3), items.items.len);
|
||||
try testing.expectEqualStrings("192.168.1.10", items.items[0].ip);
|
||||
try testing.expectEqualStrings("desk", items.items[0].name);
|
||||
try testing.expectEqualStrings("default", items.items[0].group);
|
||||
try testing.expectEqualStrings("192.168.1.20", items.items[1].ip);
|
||||
try testing.expectEqualStrings("laptop", items.items[1].name);
|
||||
try testing.expectEqualStrings("kids", items.items[1].group);
|
||||
try testing.expectEqualStrings("fd00::1", items.items[2].ip);
|
||||
try testing.expectEqualStrings("", items.items[2].name);
|
||||
try testing.expectEqualStrings("kids", items.items[2].group);
|
||||
|
||||
try testing.expectEqual(
|
||||
@as(i64, 1700000000),
|
||||
try database.queryInt("SELECT first_seen FROM clients WHERE ip = '192.168.1.10'"),
|
||||
);
|
||||
try testing.expectEqual(
|
||||
@as(i64, 1700000000),
|
||||
try database.queryInt("SELECT last_seen FROM clients WHERE ip = '192.168.1.10'"),
|
||||
);
|
||||
}
|
||||
|
||||
test "a hand_edited = 0 client is absent from listClients but counted by countClients" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
var ids = try seedGroups(&database);
|
||||
defer ids.deinit(testing.allocator);
|
||||
try seedClients(&database, &ids);
|
||||
try database.exec(
|
||||
\\INSERT INTO clients (ip, name, group_id, hand_edited, first_seen, last_seen)
|
||||
\\VALUES ('10.0.0.5', 'auto', 1, 0, 1, 1);
|
||||
);
|
||||
|
||||
try testing.expectEqual(@as(i64, 4), try countClients(&database));
|
||||
|
||||
var items = try listClients(&database, testing.allocator);
|
||||
defer items.deinit(testing.allocator);
|
||||
defer freeClients(testing.allocator, items.items);
|
||||
try testing.expectEqual(@as(usize, 3), items.items.len);
|
||||
for (items.items) |item| {
|
||||
try testing.expect(!std.mem.eql(u8, item.ip, "10.0.0.5"));
|
||||
}
|
||||
}
|
||||
|
||||
test "deleteAllClients empties the table and countClients reflects it" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
var ids = try seedGroups(&database);
|
||||
defer ids.deinit(testing.allocator);
|
||||
try seedClients(&database, &ids);
|
||||
|
||||
try testing.expectEqual(@as(i64, 3), try countClients(&database));
|
||||
try deleteAllClients(&database);
|
||||
try testing.expectEqual(@as(i64, 0), try countClients(&database));
|
||||
}
|
||||
|
||||
test "insertClient reports a group the caller's map does not hold" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
const ctx: InsertContext = .{};
|
||||
try testing.expectError(
|
||||
error.NotFound,
|
||||
insertClient(&database, .{ .ip = "192.168.1.1" }, ctx),
|
||||
);
|
||||
}
|
||||
|
||||
fn listClientsUnderFailure(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);
|
||||
|
||||
var items = try listClients(&database, gpa);
|
||||
defer items.deinit(gpa);
|
||||
defer freeClients(gpa, items.items);
|
||||
}
|
||||
|
||||
test "listClients 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, listClientsUnderFailure, .{&ids});
|
||||
}
|
||||
|
||||
test "client_prefixes round-trip in prefix order with group names resolved" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
var ids = try seedGroups(&database);
|
||||
defer ids.deinit(testing.allocator);
|
||||
try seedClientPrefixes(&database, &ids);
|
||||
|
||||
var items = try listClientPrefixes(&database, testing.allocator);
|
||||
defer items.deinit(testing.allocator);
|
||||
defer freeClientPrefixes(testing.allocator, items.items);
|
||||
|
||||
try testing.expectEqual(@as(usize, 3), items.items.len);
|
||||
try testing.expectEqualStrings("192.168.1.0/24", items.items[0].prefix);
|
||||
try testing.expectEqualStrings("default", items.items[0].group);
|
||||
try testing.expectEqual(@as(i32, 50), items.items[0].priority);
|
||||
try testing.expectEqualStrings("192.168.2.0/24", items.items[1].prefix);
|
||||
try testing.expectEqualStrings("kids", items.items[1].group);
|
||||
try testing.expectEqual(@as(i32, 10), items.items[1].priority);
|
||||
try testing.expectEqualStrings("fd00::/48", items.items[2].prefix);
|
||||
try testing.expectEqualStrings("kids", items.items[2].group);
|
||||
try testing.expectEqual(@as(i32, 100), items.items[2].priority);
|
||||
}
|
||||
|
||||
test "deleteAllClientPrefixes empties the table and countClientPrefixes reflects it" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
var ids = try seedGroups(&database);
|
||||
defer ids.deinit(testing.allocator);
|
||||
try seedClientPrefixes(&database, &ids);
|
||||
|
||||
try testing.expectEqual(@as(i64, 3), try countClientPrefixes(&database));
|
||||
try deleteAllClientPrefixes(&database);
|
||||
try testing.expectEqual(@as(i64, 0), try countClientPrefixes(&database));
|
||||
}
|
||||
|
||||
fn listClientPrefixesUnderFailure(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 seedClientPrefixes(&database, ids);
|
||||
|
||||
var items = try listClientPrefixes(&database, gpa);
|
||||
defer items.deinit(gpa);
|
||||
defer freeClientPrefixes(gpa, items.items);
|
||||
}
|
||||
|
||||
test "listClientPrefixes 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, listClientPrefixesUnderFailure, .{&ids});
|
||||
}
|
||||
Reference in New Issue
Block a user