milestone 27: diagnostics — operational failures land in one curated log, resolved history purgeable
Gates / frontend (push) Successful in 1m33s
Gates / test (push) Successful in 1m48s
Gates / test-aarch64 (push) Successful in 7m10s
Gates / package (push) Successful in 5m31s
Gates / container (push) Successful in 15s
CI / gates (push) Successful in 14m51s
Gates / frontend (push) Successful in 1m33s
Gates / test (push) Successful in 1m48s
Gates / test-aarch64 (push) Successful in 7m10s
Gates / package (push) Successful in 5m31s
Gates / container (push) Successful in 15s
CI / gates (push) Successful in 14m51s
This commit is contained in:
+113
-4
@@ -55,7 +55,24 @@ fn assertOrdered(list: []const Step) void {
|
||||
/// foreign_keys` is a no-op inside a transaction, so applying it afterwards
|
||||
/// would silently leave referential integrity off.
|
||||
pub fn migrate(database: *db.Db) Error!u32 {
|
||||
return migrateSteps(database, &steps);
|
||||
const version = try migrateSteps(database, &steps);
|
||||
try bridgeOperationalEvents(database);
|
||||
return version;
|
||||
}
|
||||
|
||||
/// **Removable when the v0.1 adoption gate lands.**
|
||||
///
|
||||
/// `operational_events` joined `config_schema.ddl_v1` after databases stamped
|
||||
/// version 1 already existed, and a stamped database never runs step 1 again —
|
||||
/// so on those installs nothing would ever create the table, silently, and the
|
||||
/// diagnostics store would fail to open forever. This runs after the stamp,
|
||||
/// with every statement conditional, and touches no other table.
|
||||
///
|
||||
/// It belongs here and not in `cli.openConfigDb`: that runs *before* migration
|
||||
/// everywhere (`app.zig`, `cli.zig`), so creating the table there would make a
|
||||
/// fresh database's unconditional `CREATE TABLE` in `ddl_v1` fail.
|
||||
fn bridgeOperationalEvents(database: *db.Db) db.Error!void {
|
||||
return database.exec(config_schema.operational_events_bridge);
|
||||
}
|
||||
|
||||
/// Same logic against an injected step list. The seam exists for the rollback
|
||||
@@ -159,7 +176,7 @@ test "migrate on a fresh database creates every table and seeds the default grou
|
||||
const expected = [_][]const u8{
|
||||
"schema_version", "groups", "clients", "client_prefixes",
|
||||
"upstreams", "rules", "local_records", "forward_zones",
|
||||
"blocklist_sources", "group_sources", "settings",
|
||||
"blocklist_sources", "group_sources", "settings", "operational_events",
|
||||
};
|
||||
for (expected) |name| {
|
||||
try testing.expect(try tableExists(&database, name));
|
||||
@@ -373,9 +390,101 @@ test "delete_order and table_names name exactly the tables the schema creates" {
|
||||
for (config_schema.table_names) |name| {
|
||||
try testing.expect(try tableExists(&database, name));
|
||||
}
|
||||
// delete_order covers every table except `schema_version`.
|
||||
// delete_order covers every table except two: `schema_version`, which is
|
||||
// the migration's own, and `operational_events`, which is runtime state an
|
||||
// import must never wipe.
|
||||
try testing.expect(!try tableExists(&database, "no_such_table"));
|
||||
try testing.expect(try tableExists(&database, "operational_events"));
|
||||
try testing.expectEqual(
|
||||
@as(i64, config_schema.delete_order.len + 1),
|
||||
@as(i64, config_schema.delete_order.len + 2),
|
||||
try database.queryInt("SELECT count(*) FROM sqlite_schema WHERE type='table'"),
|
||||
);
|
||||
}
|
||||
|
||||
test "a version-1 database created without operational_events gains exactly it" {
|
||||
// The silent divergence the bridge exists for: this is what the Pi's
|
||||
// `config.db` looks like — stamped 1, so step 1 never runs again.
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
try database.exec(config_schema.ddl_v1);
|
||||
try database.exec("DROP TABLE operational_events;");
|
||||
try database.exec("INSERT INTO schema_version (version) VALUES (1);");
|
||||
try testing.expect(!try tableExists(&database, "operational_events"));
|
||||
|
||||
const tables_before = try database.queryInt("SELECT count(*) FROM sqlite_schema WHERE type='table'");
|
||||
const groups_before = try database.queryInt("SELECT count(*) FROM groups");
|
||||
|
||||
try testing.expectEqual(@as(u32, 1), try migrate(&database));
|
||||
|
||||
try testing.expect(try tableExists(&database, "operational_events"));
|
||||
try testing.expectEqual(
|
||||
tables_before + 1,
|
||||
try database.queryInt("SELECT count(*) FROM sqlite_schema WHERE type='table'"),
|
||||
);
|
||||
// Both indexes came with it, and no other table moved.
|
||||
try testing.expectEqual(
|
||||
@as(i64, 2),
|
||||
try database.queryInt(
|
||||
"SELECT count(*) FROM sqlite_schema WHERE type='index' AND tbl_name='operational_events'",
|
||||
),
|
||||
);
|
||||
try testing.expectEqual(groups_before, try database.queryInt("SELECT count(*) FROM groups"));
|
||||
}
|
||||
|
||||
test "the bridge does not double-create on a fresh database or on a second run" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
// `ddl_v1` creates the table unconditionally, so a bridge that ran as part
|
||||
// of the step list would fail here rather than be a no-op.
|
||||
try testing.expectEqual(@as(u32, 1), try migrate(&database));
|
||||
const schema_rows = try database.queryInt("SELECT count(*) FROM sqlite_schema");
|
||||
|
||||
try database.exec(
|
||||
\\INSERT INTO operational_events
|
||||
\\ (code, subject_key, subject_label, severity, first_seen, last_seen, occurrences)
|
||||
\\VALUES ('disk.space', 'data', 'data', 'warning', 100, 100, 1);
|
||||
);
|
||||
try testing.expectEqual(@as(u32, 1), try migrate(&database));
|
||||
try testing.expectEqual(schema_rows, try database.queryInt("SELECT count(*) FROM sqlite_schema"));
|
||||
// A `CREATE TABLE IF NOT EXISTS` that had somehow replaced the table would
|
||||
// show up as a lost row, not as a schema difference.
|
||||
try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM operational_events"));
|
||||
}
|
||||
|
||||
test "the partial unique index allows one active row per key and any number of resolved ones" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
_ = try migrate(&database);
|
||||
|
||||
const insert =
|
||||
\\INSERT INTO operational_events
|
||||
\\ (code, subject_key, subject_label, severity, first_seen, last_seen, occurrences, resolved_at)
|
||||
\\VALUES ('blocklist.refresh', 'https://a.example', 'a', 'warning', 100, 100, 1, ?1)
|
||||
;
|
||||
{
|
||||
var stmt = try database.prepare(insert);
|
||||
defer stmt.deinit();
|
||||
try stmt.bindNull(1);
|
||||
try stmt.exec();
|
||||
}
|
||||
{
|
||||
// A second active row for the same (code, subject_key) is what
|
||||
// `report`'s overflow probe exists to avoid, and the index proves it.
|
||||
// Its own statement: `Stmt.reset` re-reports the code of a failed step,
|
||||
// so a reused one would answer `error.Constraint` a second time.
|
||||
var stmt = try database.prepare(insert);
|
||||
defer stmt.deinit();
|
||||
try stmt.bindNull(1);
|
||||
try testing.expectError(error.Constraint, stmt.step());
|
||||
}
|
||||
|
||||
var resolved = try database.prepare(insert);
|
||||
defer resolved.deinit();
|
||||
for ([_]i64{ 200, 300 }) |resolved_at| {
|
||||
try resolved.reset();
|
||||
try resolved.bindInt(1, resolved_at);
|
||||
try resolved.exec();
|
||||
}
|
||||
try testing.expectEqual(@as(i64, 3), try database.queryInt("SELECT count(*) FROM operational_events"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user