//! The `config.db` migration runner. //! //! Steps are compiled into the binary in ascending order and applied inside //! **one** transaction, then the reached version is stamped. SQLite runs DDL //! transactionally, so a step that fails leaves the file exactly as it was. //! //! A database stamped *newer* than this binary is never silently accepted and //! never downgraded: it is `error.SchemaTooNew`, distinct from every other //! error, so the CLI can tell the operator to install a newer nxdns. const std = @import("std"); const assert = std.debug.assert; const db = @import("db.zig"); const config_schema = @import("config_schema.zig"); const log = std.log.scoped(.migrations); pub const Step = struct { version: u32, sql: [:0]const u8 }; /// One baseline, no steps. Until v0.1 a schema change edits /// `config_schema.ddl_v1` in place, because nxdns has no installs and there is /// no database in the world for a step to reconcile. /// /// At v0.1 the baseline freezes and this list becomes append-only: editing a /// released step would make a fresh database and an upgraded one disagree, and /// nothing would detect it. `migrateSteps` already implements that discipline /// and its tests already pin it against injected step lists. pub const steps = [_]Step{ .{ .version = 1, .sql = config_schema.ddl_v1 }, }; /// The schema version this binary expects. A database `readVersion` reports /// below this needs `nxdns run` to migrate it; above it is `error.SchemaTooNew` /// and needs a newer nxdns. pub const target_version: u32 = steps[steps.len - 1].version; comptime { assertOrdered(&steps); } pub const Error = db.Error || error{ SchemaTooNew, SchemaCorrupt }; /// Versions must be `1, 2, 3, …` with no gaps. A gap would make "apply every /// step newer than the stamped version" ambiguous about what the stamp means. fn assertOrdered(list: []const Step) void { assert(list.len > 0); for (list, 0..) |step, i| assert(@as(usize, step.version) == i + 1); } /// Reads the stamped version, applies every newer step in one transaction and /// stamps the result. Returns the version now in the file. /// /// `database` must already have had `db.applyPragmas` called: `PRAGMA /// 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); } /// Same logic against an injected step list. The seam exists for the rollback /// and stepwise-upgrade tests, which need a second step that `steps` does not /// yet have. pub fn migrateSteps(database: *db.Db, list: []const Step) Error!u32 { assertOrdered(list); const target = list[list.len - 1].version; const current = try readVersion(database); if (current > target) { log.warn("config.db is at schema version {d}; this nxdns binary supports {d}", .{ current, target }); return error.SchemaTooNew; } if (current == target) return current; var tx = try db.Tx.begin(database); errdefer tx.rollback(); // Re-read under BEGIN IMMEDIATE. Two processes starting at the same moment // both saw `current` above; the one that loses the write lock arrives here // after the other committed and finds nothing to do. const stamped = try readVersion(database); if (stamped > target) { log.warn("config.db is at schema version {d}; this nxdns binary supports {d}", .{ stamped, target }); return error.SchemaTooNew; } if (stamped == target) { try tx.commit(); return stamped; } for (list) |step| { if (step.version <= stamped) continue; try database.exec(step.sql); } try database.exec("DELETE FROM schema_version;"); var stmt = try database.prepare("INSERT INTO schema_version (version) VALUES (?1)"); defer stmt.deinit(); try stmt.bindInt(1, target); try stmt.exec(); try tx.commit(); log.info("config.db migrated from schema version {d} to {d}", .{ stamped, target }); return target; } /// The schema version stamped in `database`, compared against `target_version`. /// /// `0` when `schema_version` does not exist yet. Zero rows or more than one row /// is `error.SchemaCorrupt` — the version of a database is never guessed. /// /// Reads only, so it works on a connection opened `.read_only` or /// `.immutable`. That is what it is public for: `nxdns check` may not migrate /// (ruling F-c), and "at version 0, this binary expects 1" tells an operator /// what to do where a bare SQLite error message does not. pub fn readVersion(database: *db.Db) Error!u32 { const present = try database.queryInt( "SELECT count(*) FROM sqlite_schema WHERE type='table' AND name='schema_version'", ); if (present == 0) return 0; const rows = try database.queryInt("SELECT count(*) FROM schema_version"); if (rows != 1) { log.warn("schema_version holds {d} rows; exactly one is required", .{rows}); return error.SchemaCorrupt; } const version = try database.queryInt("SELECT version FROM schema_version"); if (version < 0 or version > std.math.maxInt(u32)) { log.warn("schema_version holds an out-of-range version {d}", .{version}); return error.SchemaCorrupt; } return @intCast(version); } fn tableExists(database: *db.Db, name: []const u8) db.Error!bool { var stmt = try database.prepare("SELECT count(*) FROM sqlite_schema WHERE type='table' AND name = ?1"); defer stmt.deinit(); try stmt.bindText(1, name); if (!try stmt.step()) return error.SqliteError; return stmt.columnInt(0) != 0; } const testing = std.testing; fn openMigrated() !db.Db { var database = try db.Db.open(":memory:", .{ .mode = .memory }); errdefer database.close(); try db.applyPragmas(&database, .{}); return database; } test "migrate on a fresh database creates every table and seeds the default group" { var database = try openMigrated(); defer database.close(); try testing.expectEqual(target_version, try migrate(&database)); const expected = [_][]const u8{ "schema_version", "groups", "clients", "client_prefixes", "upstreams", "rules", "local_records", "forward_zones", "blocklist_sources", "group_sources", "settings", }; for (expected) |name| { try testing.expect(try tableExists(&database, name)); } try testing.expectEqual( @as(i64, expected.len), try database.queryInt("SELECT count(*) FROM sqlite_schema WHERE type='table'"), ); try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM groups")); try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT id FROM groups")); var stmt = try database.prepare("SELECT name, safe_search FROM groups"); defer stmt.deinit(); try testing.expect(try stmt.step()); try testing.expectEqualStrings("default", stmt.columnText(0)); try testing.expect(!stmt.columnBool(1)); } test "migrate is idempotent" { var database = try openMigrated(); defer database.close(); try testing.expectEqual(target_version, try migrate(&database)); const before = try database.queryInt("SELECT count(*) FROM sqlite_schema"); const rowid_before = database.lastInsertRowid(); try testing.expectEqual(target_version, try migrate(&database)); try testing.expectEqual(before, try database.queryInt("SELECT count(*) FROM sqlite_schema")); try testing.expectEqual(rowid_before, database.lastInsertRowid()); try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM schema_version")); try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM groups")); } test "a database stamped newer than the binary is error.SchemaTooNew" { var database = try openMigrated(); defer database.close(); _ = try migrate(&database); const future: i64 = @as(i64, target_version) + 1; var stmt = try database.prepare("UPDATE schema_version SET version = ?1"); defer stmt.deinit(); try stmt.bindInt(1, future); try stmt.exec(); try testing.expectError(error.SchemaTooNew, migrate(&database)); try testing.expectEqual(future, try database.queryInt("SELECT version FROM schema_version")); } test "schema_version holding two rows is error.SchemaCorrupt" { var database = try openMigrated(); defer database.close(); _ = try migrate(&database); try database.exec("INSERT INTO schema_version (version) VALUES (1);"); try testing.expectError(error.SchemaCorrupt, migrate(&database)); } test "a failing step rolls the whole migration back" { var database = try openMigrated(); defer database.close(); const broken = [_]Step{ .{ .version = 1, .sql = config_schema.ddl_v1 }, .{ .version = 2, .sql = "CREATE TABLE second (" }, }; // db.zig maps SQLITE_ERROR — the generic "SQL error" — to error.Unexpected. try testing.expectError(error.Unexpected, migrateSteps(&database, &broken)); try testing.expect(!try tableExists(&database, "schema_version")); try testing.expect(!try tableExists(&database, "groups")); try testing.expect(!try tableExists(&database, "second")); try testing.expectEqual(@as(u32, 0), try readVersion(&database)); } test "a stepwise upgrade applies only the new steps" { var database = try openMigrated(); defer database.close(); const first = [_]Step{.{ .version = 1, .sql = config_schema.ddl_v1 }}; try testing.expectEqual(@as(u32, 1), try migrateSteps(&database, &first)); try testing.expect(try tableExists(&database, "groups")); try testing.expect(!try tableExists(&database, "extra")); const second = [_]Step{ .{ .version = 1, .sql = config_schema.ddl_v1 }, .{ .version = 2, .sql = "CREATE TABLE extra (id INTEGER PRIMARY KEY);" }, }; try testing.expectEqual(@as(u32, 2), try migrateSteps(&database, &second)); try testing.expect(try tableExists(&database, "extra")); try testing.expectEqual(@as(u32, 2), try readVersion(&database)); // Step 1 did not run a second time: `groups` still holds one seeded row. try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM groups")); } fn columnExists(database: *db.Db, table: []const u8, column: []const u8) !bool { var stmt = try database.prepare("SELECT count(*) FROM pragma_table_info(?1) WHERE name = ?2"); defer stmt.deinit(); try stmt.bindText(1, table); try stmt.bindText(2, column); if (!try stmt.step()) return error.SqliteError; return stmt.columnInt(0) != 0; } test "a fresh database reaches the baseline with every v1 column and rule kind" { var database = try openMigrated(); defer database.close(); try testing.expectEqual(@as(u32, 1), try migrate(&database)); try testing.expectEqual(@as(u32, 1), target_version); try testing.expect(try columnExists(&database, "upstreams", "tls_name")); try testing.expect(try columnExists(&database, "blocklist_sources", "exception_count")); try testing.expect(try columnExists(&database, "blocklist_sources", "skipped_unsupported_count")); try database.exec( \\INSERT INTO rules (group_id, pattern, kind, action, created_at) \\VALUES (1, '^ad[0-9]+-', 'regex', 'block', 100); ); try testing.expectError(error.Constraint, database.exec( \\INSERT INTO rules (group_id, pattern, kind, action, created_at) \\VALUES (1, 'x', 'glob', 'block', 100); )); } test "the baseline rules table cascades from its group" { var database = try openMigrated(); defer database.close(); _ = try migrate(&database); try database.exec( \\INSERT INTO groups (id, name) VALUES (2, 'kids'); \\INSERT INTO rules (id, group_id, pattern, kind, action, created_at) VALUES \\ (9, 2, '*.tracker.net', 'wildcard', 'allow', 2000); ); try database.exec("DELETE FROM groups WHERE id = 2;"); try testing.expectEqual( @as(i64, 0), try database.queryInt("SELECT count(*) FROM rules WHERE group_id = 2"), ); } test "a failing step rolls back an upgrade of a populated database" { var database = try openMigrated(); defer database.close(); _ = try migrate(&database); try database.exec("INSERT INTO upstreams (url, priority, enabled) VALUES ('tls://1.1.1.1:853', 10, 1);"); // Rollback of an *upgrade* is a different case from rollback of the initial // creation ("a failing step rolls the whole migration back"): here a // populated database must come back untouched, not cease to exist. const broken = steps ++ [_]Step{ .{ .version = target_version + 1, .sql = "CREATE TABLE second (id INTEGER PRIMARY KEY);" }, .{ .version = target_version + 2, .sql = "CREATE TABLE third (" }, }; try testing.expectError(error.Unexpected, migrateSteps(&database, &broken)); // One transaction: the step that did succeed went back with the one that did not. try testing.expect(!try tableExists(&database, "second")); try testing.expectEqual(target_version, try readVersion(&database)); try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM upstreams")); } test "readVersion reports 0 before a migration and target_version after it" { var database = try openMigrated(); defer database.close(); try testing.expectEqual(@as(u32, 0), try readVersion(&database)); _ = try migrate(&database); try testing.expectEqual(target_version, try readVersion(&database)); } /// `.zig-cache/tmp/` is where `std.testing.tmpDir` puts its directories, and /// SQLite's VFS resolves filenames against the same working directory /// (`db.zig`'s immutable tests). const tmp_prefix = ".zig-cache/tmp/"; const sub_path_len = @typeInfo(@FieldType(testing.TmpDir, "sub_path")).array.len; test "readVersion reads a file database through an immutable open, writing nothing" { var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); var path_buf: [tmp_prefix.len + sub_path_len + 32]u8 = undefined; const path = try std.fmt.bufPrintZ(&path_buf, "{s}{s}/config.db", .{ tmp_prefix, &tmp.sub_path }); // `check` reads the stamped version without migrating (ruling F-c), so the // read has to work through a connection that cannot write at all. { var database = try db.Db.open(path, .{ .mode = .read_write_create }); defer database.close(); try db.applyPragmas(&database, .{}); try testing.expectEqual(target_version, try migrate(&database)); } var database = try db.Db.open(path, .{ .mode = .{ .immutable = testing.io } }); defer database.close(); try testing.expectEqual(target_version, try readVersion(&database)); // A write through this connection is refused by SQLite, not by convention. try testing.expectError(error.ReadOnly, database.exec("DELETE FROM schema_version;")); try testing.expectEqual(@as(u32, 1), try readVersion(&database)); } test "delete_order and table_names name exactly the tables the schema creates" { var database = try openMigrated(); defer database.close(); _ = try migrate(&database); for (config_schema.delete_order) |name| { try testing.expect(try tableExists(&database, name)); } for (config_schema.table_names) |name| { try testing.expect(try tableExists(&database, name)); } // delete_order covers every table except `schema_version`. try testing.expectEqual( @as(i64, config_schema.delete_order.len + 1), try database.queryInt("SELECT count(*) FROM sqlite_schema WHERE type='table'"), ); }