max / makenotwork
15 files changed,
+1651 insertions,
-500 deletions
| @@ -1,1634 +0,0 @@ | |||
| 1 | - | //! SQLite persistence — schema, health checks, test runs, and peer data. | |
| 2 | - | //! | |
| 3 | - | //! Uses a migration versioning system: each schema change is a numbered migration | |
| 4 | - | //! stored in [`MIGRATIONS`]. On startup, [`run_migrations`] checks the current | |
| 5 | - | //! version and runs any pending migrations. Existing databases (pre-migration) | |
| 6 | - | //! are detected by the presence of the `health_checks` table and marked as v1. | |
| 7 | - | ||
| 8 | - | use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions}; | |
| 9 | - | use std::path::Path; | |
| 10 | - | use std::str::FromStr; | |
| 11 | - | use tracing::{info, instrument}; | |
| 12 | - | ||
| 13 | - | use crate::error::Result; | |
| 14 | - | use crate::types::{BackupCheckResult, CorsCheckResult, DnsCheckResult, HealthDetails, HealthSnapshot, HealthStatus, TestDetail, TestRun, TestRunId, TestSummary, TlsStatus, WhoisResult}; | |
| 15 | - | ||
| 16 | - | /// Each migration is a (version, description, SQL) tuple. Versions start at 1. | |
| 17 | - | /// The SQL may contain multiple statements separated by semicolons. | |
| 18 | - | const MIGRATIONS: &[(i64, &str, &str)] = &[ | |
| 19 | - | (1, "initial schema", r#" | |
| 20 | - | CREATE TABLE IF NOT EXISTS health_checks ( | |
| 21 | - | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 22 | - | target TEXT NOT NULL, | |
| 23 | - | status TEXT NOT NULL, | |
| 24 | - | checked_at TEXT NOT NULL, | |
| 25 | - | response_time_ms INTEGER NOT NULL, | |
| 26 | - | details_json TEXT, | |
| 27 | - | error TEXT | |
| 28 | - | ); | |
| 29 | - | CREATE TABLE IF NOT EXISTS test_runs ( | |
| 30 | - | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 31 | - | target TEXT NOT NULL, | |
| 32 | - | started_at TEXT NOT NULL, | |
| 33 | - | finished_at TEXT, | |
| 34 | - | duration_secs INTEGER, | |
| 35 | - | exit_code INTEGER, | |
| 36 | - | passed INTEGER NOT NULL, | |
| 37 | - | summary_json TEXT NOT NULL, | |
| 38 | - | raw_output TEXT NOT NULL, | |
| 39 | - | filter TEXT | |
| 40 | - | ); | |
| 41 | - | CREATE TABLE IF NOT EXISTS peer_identities ( | |
| 42 | - | peer_name TEXT PRIMARY KEY, | |
| 43 | - | instance_id TEXT NOT NULL, | |
| 44 | - | first_seen TEXT NOT NULL | |
| 45 | - | ); | |
| 46 | - | CREATE TABLE IF NOT EXISTS peer_heartbeats ( | |
| 47 | - | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 48 | - | peer_name TEXT NOT NULL, | |
| 49 | - | status TEXT NOT NULL, | |
| 50 | - | latency_ms INTEGER NOT NULL, | |
| 51 | - | checked_at TEXT NOT NULL | |
| 52 | - | ); | |
| 53 | - | CREATE INDEX IF NOT EXISTS idx_health_checks_target_id ON health_checks(target, id DESC); | |
| 54 | - | CREATE INDEX IF NOT EXISTS idx_health_checks_target_checked ON health_checks(target, checked_at); | |
| 55 | - | CREATE INDEX IF NOT EXISTS idx_test_runs_target_id ON test_runs(target, id DESC); | |
| 56 | - | CREATE INDEX IF NOT EXISTS idx_peer_heartbeats_peer_id ON peer_heartbeats(peer_name, id DESC); | |
| 57 | - | "#), | |
| 58 | - | (2, "add alerts table", r#" | |
| 59 | - | CREATE TABLE IF NOT EXISTS alerts ( | |
| 60 | - | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 61 | - | target TEXT NOT NULL, | |
| 62 | - | alert_type TEXT NOT NULL, | |
| 63 | - | from_status TEXT, | |
| 64 | - | to_status TEXT, | |
| 65 | - | sent_at TEXT NOT NULL, | |
| 66 | - | error TEXT | |
| 67 | - | ); | |
| 68 | - | CREATE INDEX IF NOT EXISTS idx_alerts_target_sent ON alerts(target, sent_at); | |
| 69 | - | "#), | |
| 70 | - | (3, "add tls_checks table", r#" | |
| 71 | - | CREATE TABLE IF NOT EXISTS tls_checks ( | |
| 72 | - | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 73 | - | target TEXT NOT NULL, | |
| 74 | - | host TEXT NOT NULL, | |
| 75 | - | valid INTEGER NOT NULL, | |
| 76 | - | days_remaining INTEGER NOT NULL, | |
| 77 | - | not_before TEXT NOT NULL, | |
| 78 | - | not_after TEXT NOT NULL, | |
| 79 | - | subject TEXT NOT NULL, | |
| 80 | - | issuer TEXT NOT NULL, | |
| 81 | - | checked_at TEXT NOT NULL, | |
| 82 | - | error TEXT | |
| 83 | - | ); | |
| 84 | - | CREATE INDEX IF NOT EXISTS idx_tls_checks_target_id ON tls_checks(target, id DESC); | |
| 85 | - | "#), | |
| 86 | - | (4, "add incidents table", r#" | |
| 87 | - | CREATE TABLE IF NOT EXISTS incidents ( | |
| 88 | - | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 89 | - | target TEXT NOT NULL, | |
| 90 | - | started_at TEXT NOT NULL, | |
| 91 | - | ended_at TEXT, | |
| 92 | - | duration_secs INTEGER, | |
| 93 | - | from_status TEXT NOT NULL, | |
| 94 | - | to_status TEXT NOT NULL | |
| 95 | - | ); | |
| 96 | - | CREATE INDEX IF NOT EXISTS idx_incidents_target_id ON incidents(target, id DESC); | |
| 97 | - | "#), | |
| 98 | - | (5, "add route_checks table", r#" | |
| 99 | - | CREATE TABLE IF NOT EXISTS route_checks ( | |
| 100 | - | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 101 | - | target TEXT NOT NULL, | |
| 102 | - | path TEXT NOT NULL, | |
| 103 | - | status_code INTEGER NOT NULL, | |
| 104 | - | ok INTEGER NOT NULL, | |
| 105 | - | response_time_ms INTEGER NOT NULL, | |
| 106 | - | checked_at TEXT NOT NULL, | |
| 107 | - | error TEXT | |
| 108 | - | ); | |
| 109 | - | CREATE INDEX IF NOT EXISTS idx_route_checks_target_path ON route_checks(target, path, id DESC); | |
| 110 | - | CREATE INDEX IF NOT EXISTS idx_route_checks_target ON route_checks(target, checked_at DESC); | |
| 111 | - | "#), | |
| 112 | - | (6, "add dns_checks and whois_checks tables", r#" | |
| 113 | - | CREATE TABLE IF NOT EXISTS dns_checks ( | |
| 114 | - | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 115 | - | target TEXT NOT NULL, | |
| 116 | - | name TEXT NOT NULL, | |
| 117 | - | record_type TEXT NOT NULL, | |
| 118 | - | expected TEXT NOT NULL, | |
| 119 | - | actual TEXT NOT NULL, | |
| 120 | - | matches INTEGER NOT NULL, | |
| 121 | - | checked_at TEXT NOT NULL, | |
| 122 | - | error TEXT | |
| 123 | - | ); | |
| 124 | - | CREATE INDEX IF NOT EXISTS idx_dns_checks_target ON dns_checks(target, name, id DESC); | |
| 125 | - | ||
| 126 | - | CREATE TABLE IF NOT EXISTS whois_checks ( | |
| 127 | - | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 128 | - | target TEXT NOT NULL, | |
| 129 | - | domain TEXT NOT NULL, | |
| 130 | - | registrar TEXT, | |
| 131 | - | expiry_date TEXT, | |
| 132 | - | days_remaining INTEGER, | |
| 133 | - | nameservers TEXT, | |
| 134 | - | checked_at TEXT NOT NULL, | |
| 135 | - | error TEXT | |
| 136 | - | ); | |
| 137 | - | CREATE INDEX IF NOT EXISTS idx_whois_checks_target ON whois_checks(target, id DESC); | |
| 138 | - | "#), | |
| 139 | - | (7, "add test_details table", r#" | |
| 140 | - | CREATE TABLE IF NOT EXISTS test_details ( | |
| 141 | - | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 142 | - | run_id INTEGER NOT NULL REFERENCES test_runs(id) ON DELETE CASCADE, | |
| 143 | - | test_name TEXT NOT NULL, | |
| 144 | - | passed INTEGER NOT NULL, | |
| 145 | - | duration_ms INTEGER | |
| 146 | - | ); | |
| 147 | - | CREATE INDEX IF NOT EXISTS idx_test_details_run_id ON test_details(run_id); | |
| 148 | - | CREATE INDEX IF NOT EXISTS idx_test_details_name ON test_details(test_name, run_id DESC); | |
| 149 | - | "#), | |
| 150 | - | (8, "add cors_checks table", r#" | |
| 151 | - | CREATE TABLE IF NOT EXISTS cors_checks ( | |
| 152 | - | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 153 | - | target TEXT NOT NULL, | |
| 154 | - | url TEXT NOT NULL, | |
| 155 | - | origin TEXT NOT NULL, | |
| 156 | - | method TEXT NOT NULL, | |
| 157 | - | passes INTEGER NOT NULL, | |
| 158 | - | checked_at TEXT NOT NULL, | |
| 159 | - | error TEXT | |
| 160 | - | ); | |
| 161 | - | CREATE INDEX IF NOT EXISTS idx_cors_target_id ON cors_checks(target, id DESC); | |
| 162 | - | "#), | |
| 163 | - | (9, "add backup_checks table", r#" | |
| 164 | - | CREATE TABLE IF NOT EXISTS backup_checks ( | |
| 165 | - | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 166 | - | target TEXT NOT NULL, | |
| 167 | - | database_name TEXT NOT NULL, | |
| 168 | - | status TEXT NOT NULL, | |
| 169 | - | last_backup_at TEXT, | |
| 170 | - | size_bytes INTEGER, | |
| 171 | - | age_hours INTEGER, | |
| 172 | - | checked_at TEXT NOT NULL, | |
| 173 | - | error TEXT | |
| 174 | - | ); | |
| 175 | - | CREATE INDEX IF NOT EXISTS idx_backup_checks_target ON backup_checks(target, id DESC); | |
| 176 | - | "#), | |
| 177 | - | (10, "add pending_alerts retry queue", r#" | |
| 178 | - | CREATE TABLE IF NOT EXISTS pending_alerts ( | |
| 179 | - | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 180 | - | alert_key TEXT NOT NULL, | |
| 181 | - | category TEXT NOT NULL, | |
| 182 | - | channel TEXT NOT NULL, -- 'wam' or 'email' | |
| 183 | - | subject TEXT NOT NULL, | |
| 184 | - | body TEXT NOT NULL, | |
| 185 | - | priority TEXT, -- WAM only | |
| 186 | - | source TEXT, -- WAM only | |
| 187 | - | source_ref TEXT, -- WAM only | |
| 188 | - | from_status TEXT, | |
| 189 | - | to_status TEXT, | |
| 190 | - | error TEXT, | |
| 191 | - | attempts INTEGER NOT NULL DEFAULT 0, | |
| 192 | - | created_at TEXT NOT NULL, | |
| 193 | - | next_retry_at TEXT NOT NULL | |
| 194 | - | ); | |
| 195 | - | CREATE INDEX IF NOT EXISTS idx_pending_alerts_due ON pending_alerts(next_retry_at, id); | |
| 196 | - | "#), | |
| 197 | - | ]; | |
| 198 | - | ||
| 199 | - | #[instrument(skip_all)] | |
| 200 | - | pub async fn connect(path: &Path) -> Result<SqlitePool> { | |
| 201 | - | let opts = SqliteConnectOptions::from_str(&format!("sqlite:{}", path.display()))? | |
| 202 | - | .create_if_missing(true) | |
| 203 | - | .foreign_keys(true) | |
| 204 | - | .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal); | |
| 205 | - | ||
| 206 | - | let pool = SqlitePoolOptions::new() | |
| 207 | - | .max_connections(5) | |
| 208 | - | .connect_with(opts) | |
| 209 | - | .await?; | |
| 210 | - | ||
| 211 | - | run_migrations(&pool).await?; | |
| 212 | - | Ok(pool) | |
| 213 | - | } | |
| 214 | - | ||
| 215 | - | #[instrument(skip_all)] | |
| 216 | - | pub async fn connect_in_memory() -> Result<SqlitePool> { | |
| 217 | - | let opts = SqliteConnectOptions::from_str("sqlite::memory:")?.foreign_keys(true); | |
| 218 | - | let pool = SqlitePoolOptions::new() | |
| 219 | - | .max_connections(1) | |
| 220 | - | .connect_with(opts) | |
| 221 | - | .await?; | |
| 222 | - | ||
| 223 | - | run_migrations(&pool).await?; | |
| 224 | - | Ok(pool) | |
| 225 | - | } | |
| 226 | - | ||
| 227 | - | /// Run pending schema migrations. Detects pre-migration databases by checking | |
| 228 | - | /// for existing tables and stamps them as version 1 without re-running. | |
| 229 | - | #[instrument(skip_all)] | |
| 230 | - | pub async fn run_migrations(pool: &SqlitePool) -> Result<()> { | |
| 231 | - | // Ensure the schema_version table exists | |
| 232 | - | sqlx::query( | |
| 233 | - | "CREATE TABLE IF NOT EXISTS schema_version ( | |
| 234 | - | version INTEGER NOT NULL, | |
| 235 | - | description TEXT NOT NULL, | |
| 236 | - | applied_at TEXT NOT NULL | |
| 237 | - | )", | |
| 238 | - | ) | |
| 239 | - | .execute(pool) | |
| 240 | - | .await?; | |
| 241 | - | ||
| 242 | - | let current_version = get_schema_version(pool).await?; | |
| 243 | - | ||
| 244 | - | // Detect pre-migration databases: if schema_version is empty but tables exist, | |
| 245 | - | // this is an existing database that predates the migration system. | |
| 246 | - | if current_version == 0 && has_existing_tables(pool).await? { | |
| 247 | - | info!("detected pre-migration database, stamping as version 1"); | |
| 248 | - | stamp_version(pool, 1, "initial schema (pre-existing)").await?; | |
| 249 | - | // Run remaining migrations (2+) if any | |
| 250 | - | for &(version, description, sql) in MIGRATIONS { | |
| 251 | - | if version > 1 { | |
| 252 | - | run_one_migration(pool, version, description, sql).await?; | |
| 253 | - | } | |
| 254 | - | } | |
| 255 | - | return Ok(()); | |
| 256 | - | } | |
| 257 | - | ||
| 258 | - | // Run all migrations newer than current version | |
| 259 | - | for &(version, description, sql) in MIGRATIONS { | |
| 260 | - | if version > current_version { | |
| 261 | - | run_one_migration(pool, version, description, sql).await?; | |
| 262 | - | } | |
| 263 | - | } | |
| 264 | - | ||
| 265 | - | Ok(()) | |
| 266 | - | } | |
| 267 | - | ||
| 268 | - | /// Get the current schema version (0 if no migrations have been applied). | |
| 269 | - | #[instrument(skip_all)] | |
| 270 | - | pub async fn get_schema_version(pool: &SqlitePool) -> Result<i64> { | |
| 271 | - | let row = sqlx::query_as::<_, (i64,)>( | |
| 272 | - | "SELECT COALESCE(MAX(version), 0) FROM schema_version", | |
| 273 | - | ) | |
| 274 | - | .fetch_one(pool) | |
| 275 | - | .await?; | |
| 276 | - | Ok(row.0) | |
| 277 | - | } | |
| 278 | - | ||
| 279 | - | /// Check whether the database has existing tables from before the migration system. | |
| 280 | - | async fn has_existing_tables(pool: &SqlitePool) -> Result<bool> { | |
| 281 | - | let row = sqlx::query_as::<_, (i64,)>( | |
| 282 | - | "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'health_checks'", | |
| 283 | - | ) | |
| 284 | - | .fetch_one(pool) | |
| 285 | - | .await?; | |
| 286 | - | Ok(row.0 > 0) | |
| 287 | - | } | |
| 288 | - | ||
| 289 | - | /// Execute a single migration's SQL and record it in schema_version. | |
| 290 | - | /// Wrapped in an explicit transaction so partial failures roll back cleanly. | |
| 291 | - | async fn run_one_migration( | |
| 292 | - | pool: &SqlitePool, | |
| 293 | - | version: i64, | |
| 294 | - | description: &str, | |
| 295 | - | sql: &str, | |
| 296 | - | ) -> Result<()> { | |
| 297 | - | info!(version, description, "running migration"); | |
| 298 | - | ||
| 299 | - | let mut tx = pool.begin().await?; | |
| 300 | - | ||
| 301 | - | // Execute the whole migration as a batch via `raw_sql`, which hands the | |
| 302 | - | // string to SQLite's own parser to split on statement boundaries. A naive | |
| 303 | - | // `split(';')` breaks on the first `;` inside a trigger body or string | |
| 304 | - | // literal (fuzz-2026-07-06 migration split trap); today's migrations have | |
| 305 | - | // none, but this makes the first such migration safe rather than a startup | |
| 306 | - | // failure. | |
| 307 | - | sqlx::raw_sql(sql).execute(&mut *tx).await?; | |
| 308 | - | ||
| 309 | - | // Record the version inside the same transaction | |
| 310 | - | let now = chrono::Utc::now().to_rfc3339(); | |
| 311 | - | sqlx::query( | |
| 312 | - | "INSERT INTO schema_version (version, description, applied_at) VALUES (?, ?, ?)", | |
| 313 | - | ) | |
| 314 | - | .bind(version) | |
| 315 | - | .bind(description) | |
| 316 | - | .bind(&now) | |
| 317 | - | .execute(&mut *tx) | |
| 318 | - | .await?; | |
| 319 | - | ||
| 320 | - | tx.commit().await?; | |
| 321 | - | Ok(()) | |
| 322 | - | } | |
| 323 | - | ||
| 324 | - | /// Record a version in the schema_version table. | |
| 325 | - | async fn stamp_version(pool: &SqlitePool, version: i64, description: &str) -> Result<()> { | |
| 326 | - | let now = chrono::Utc::now().to_rfc3339(); | |
| 327 | - | sqlx::query( | |
| 328 | - | "INSERT INTO schema_version (version, description, applied_at) VALUES (?, ?, ?)", | |
| 329 | - | ) | |
| 330 | - | .bind(version) | |
| 331 | - | .bind(description) | |
| 332 | - | .bind(&now) | |
| 333 | - | .execute(pool) | |
| 334 | - | .await?; | |
| 335 | - | Ok(()) | |
| 336 | - | } | |
| 337 | - | ||
| 338 | - | // --- Health check queries --- | |
| 339 | - | ||
| 340 | - | #[instrument(skip_all)] | |
| 341 | - | pub async fn insert_health_check( | |
| 342 | - | pool: &SqlitePool, | |
| 343 | - | snapshot: &HealthSnapshot, | |
| 344 | - | ) -> Result<i64> { | |
| 345 | - | let status = snapshot.status.to_string(); | |
| 346 | - | let details_json = snapshot | |
| 347 | - | .details | |
| 348 | - | .as_ref() | |
| 349 | - | .map(|d| serde_json::to_string(d).unwrap_or_default()); | |
| 350 | - | ||
| 351 | - | let result = sqlx::query( | |
| 352 | - | "INSERT INTO health_checks (target, status, checked_at, response_time_ms, details_json, error) | |
| 353 | - | VALUES (?, ?, ?, ?, ?, ?)", | |
| 354 | - | ) | |
| 355 | - | .bind(&snapshot.target) | |
| 356 | - | .bind(&status) | |
| 357 | - | .bind(&snapshot.checked_at) | |
| 358 | - | .bind(snapshot.response_time_ms) | |
| 359 | - | .bind(&details_json) | |
| 360 | - | .bind(&snapshot.error) | |
| 361 | - | .execute(pool) | |
| 362 | - | .await?; | |
| 363 | - | ||
| 364 | - | Ok(result.last_insert_rowid()) | |
| 365 | - | } | |
| 366 | - | ||
| 367 | - | #[instrument(skip_all)] | |
| 368 | - | pub async fn get_health_history( | |
| 369 | - | pool: &SqlitePool, | |
| 370 | - | target: Option<&str>, | |
| 371 | - | limit: i64, | |
| 372 | - | ) -> Result<Vec<HealthSnapshot>> { | |
| 373 | - | let rows = match target { | |
| 374 | - | Some(t) => { | |
| 375 | - | sqlx::query_as::<_, HealthCheckRow>( | |
| 376 | - | "SELECT id, target, status, checked_at, response_time_ms, details_json, error | |
| 377 | - | FROM health_checks WHERE target = ? ORDER BY id DESC LIMIT ?", | |
| 378 | - | ) | |
| 379 | - | .bind(t) | |
| 380 | - | .bind(limit) | |
| 381 | - | .fetch_all(pool) | |
| 382 | - | .await? | |
| 383 | - | } | |
| 384 | - | None => { | |
| 385 | - | sqlx::query_as::<_, HealthCheckRow>( | |
| 386 | - | "SELECT id, target, status, checked_at, response_time_ms, details_json, error | |
| 387 | - | FROM health_checks ORDER BY id DESC LIMIT ?", | |
| 388 | - | ) | |
| 389 | - | .bind(limit) | |
| 390 | - | .fetch_all(pool) | |
| 391 | - | .await? | |
| 392 | - | } | |
| 393 | - | }; | |
| 394 | - | ||
| 395 | - | Ok(rows.into_iter().map(|r| r.into_snapshot()).collect()) | |
| 396 | - | } | |
| 397 | - | ||
| 398 | - | #[instrument(skip_all)] | |
| 399 | - | pub async fn get_latest_health( | |
| 400 | - | pool: &SqlitePool, | |
| 401 | - | target: &str, | |
| 402 | - | ) -> Result<Option<HealthSnapshot>> { | |
| 403 | - | let row = sqlx::query_as::<_, HealthCheckRow>( | |
| 404 | - | "SELECT id, target, status, checked_at, response_time_ms, details_json, error | |
| 405 | - | FROM health_checks WHERE target = ? ORDER BY id DESC LIMIT 1", | |
| 406 | - | ) | |
| 407 | - | .bind(target) | |
| 408 | - | .fetch_optional(pool) | |
| 409 | - | .await?; | |
| 410 | - | ||
| 411 | - | Ok(row.map(|r| r.into_snapshot())) | |
| 412 | - | } | |
| 413 | - | ||
| 414 | - | // --- Test run queries --- | |
| 415 | - | ||
| 416 | - | #[instrument(skip_all)] | |
| 417 | - | pub async fn insert_test_run( | |
| 418 | - | pool: &SqlitePool, | |
| 419 | - | run: &TestRun, | |
| 420 | - | ) -> Result<TestRunId> { | |
| 421 | - | let summary_json = serde_json::to_string(&run.summary).unwrap_or_default(); | |
| 422 | - | ||
| 423 | - | let result = sqlx::query( | |
| 424 | - | "INSERT INTO test_runs (target, started_at, finished_at, duration_secs, exit_code, passed, summary_json, raw_output, filter) | |
| 425 | - | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 426 | - | ) | |
| 427 | - | .bind(&run.target) | |
| 428 | - | .bind(&run.started_at) | |
| 429 | - | .bind(&run.finished_at) | |
| 430 | - | .bind(run.duration_secs) | |
| 431 | - | .bind(run.exit_code) | |
| 432 | - | .bind(run.passed) | |
| 433 | - | .bind(&summary_json) | |
| 434 | - | .bind(&run.raw_output) | |
| 435 | - | .bind(&run.filter) | |
| 436 | - | .execute(pool) | |
| 437 | - | .await?; | |
| 438 | - | ||
| 439 | - | Ok(TestRunId(result.last_insert_rowid())) | |
| 440 | - | } | |
| 441 | - | ||
| 442 | - | #[instrument(skip_all)] | |
| 443 | - | pub async fn get_test_history( | |
| 444 | - | pool: &SqlitePool, | |
| 445 | - | target: Option<&str>, | |
| 446 | - | limit: i64, | |
| 447 | - | ) -> Result<Vec<TestRun>> { | |
| 448 | - | let rows = match target { | |
| 449 | - | Some(t) => { | |
| 450 | - | sqlx::query_as::<_, TestRunRow>( | |
| 451 | - | "SELECT id, target, started_at, finished_at, duration_secs, exit_code, passed, summary_json, raw_output, filter | |
| 452 | - | FROM test_runs WHERE target = ? ORDER BY id DESC LIMIT ?", | |
| 453 | - | ) | |
| 454 | - | .bind(t) | |
| 455 | - | .bind(limit) | |
| 456 | - | .fetch_all(pool) | |
| 457 | - | .await? | |
| 458 | - | } | |
| 459 | - | None => { | |
| 460 | - | sqlx::query_as::<_, TestRunRow>( | |
| 461 | - | "SELECT id, target, started_at, finished_at, duration_secs, exit_code, passed, summary_json, raw_output, filter | |
| 462 | - | FROM test_runs ORDER BY id DESC LIMIT ?", | |
| 463 | - | ) | |
| 464 | - | .bind(limit) | |
| 465 | - | .fetch_all(pool) | |
| 466 | - | .await? | |
| 467 | - | } | |
| 468 | - | }; | |
| 469 | - | ||
| 470 | - | Ok(rows.into_iter().map(|r| r.into_test_run()).collect()) | |
| 471 | - | } | |
| 472 | - | ||
| 473 | - | #[instrument(skip_all)] | |
| 474 | - | pub async fn get_latest_test_run( | |
| 475 | - | pool: &SqlitePool, | |
| 476 | - | target: &str, | |
| 477 | - | ) -> Result<Option<TestRun>> { | |
| 478 | - | let row = sqlx::query_as::<_, TestRunRow>( | |
| 479 | - | "SELECT id, target, started_at, finished_at, duration_secs, exit_code, passed, summary_json, raw_output, filter | |
| 480 | - | FROM test_runs WHERE target = ? ORDER BY id DESC LIMIT 1", | |
| 481 | - | ) | |
| 482 | - | .bind(target) | |
| 483 | - | .fetch_optional(pool) | |
| 484 | - | .await?; | |
| 485 | - | ||
| 486 | - | Ok(row.map(|r| r.into_test_run())) | |
| 487 | - | } | |
| 488 | - | ||
| 489 | - | // --- Test detail queries --- | |
| 490 | - | ||
| 491 | - | /// Insert per-test results for a given test run. | |
| 492 | - | #[instrument(skip_all)] | |
| 493 | - | pub async fn insert_test_details( | |
| 494 | - | pool: &SqlitePool, | |
| 495 | - | run_id: TestRunId, | |
| 496 | - | details: &[TestDetail], | |
| 497 | - | ) -> Result<()> { | |
| 498 | - | // One transaction for the whole detail set: a crash mid-loop otherwise left a | |
| 499 | - | // partial set, which skews get_test_regressions (fuzz-2026-07-06 crash | |
| 500 | - | // atomicity). All-or-nothing. |
Lines truncated
| @@ -0,0 +1,173 @@ | |||
| 1 | + | use super::*; | |
| 2 | + | use tracing::instrument; | |
| 3 | + | ||
| 4 | + | #[derive(Debug, sqlx::FromRow)] | |
| 5 | + | pub struct AlertRow { | |
| 6 | + | pub id: i64, | |
| 7 | + | pub target: String, | |
| 8 | + | pub alert_type: String, | |
| 9 | + | pub from_status: Option<String>, | |
| 10 | + | pub to_status: Option<String>, | |
| 11 | + | pub sent_at: String, | |
| 12 | + | pub error: Option<String>, | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | #[instrument(skip_all)] | |
| 16 | + | pub async fn insert_alert( | |
| 17 | + | pool: &SqlitePool, | |
| 18 | + | target: &str, | |
| 19 | + | alert_type: &str, | |
| 20 | + | from_status: Option<&str>, | |
| 21 | + | to_status: Option<&str>, | |
| 22 | + | error: Option<&str>, | |
| 23 | + | ) -> Result<i64> { | |
| 24 | + | let now = chrono::Utc::now().to_rfc3339(); | |
| 25 | + | let result = sqlx::query( | |
| 26 | + | "INSERT INTO alerts (target, alert_type, from_status, to_status, sent_at, error) | |
| 27 | + | VALUES (?, ?, ?, ?, ?, ?)", | |
| 28 | + | ) | |
| 29 | + | .bind(target) | |
| 30 | + | .bind(alert_type) | |
| 31 | + | .bind(from_status) | |
| 32 | + | .bind(to_status) | |
| 33 | + | .bind(&now) | |
| 34 | + | .bind(error) | |
| 35 | + | .execute(pool) | |
| 36 | + | .await?; | |
| 37 | + | Ok(result.last_insert_rowid()) | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | #[instrument(skip_all)] | |
| 41 | + | pub async fn get_latest_alert_for_target( | |
| 42 | + | pool: &SqlitePool, | |
| 43 | + | target: &str, | |
| 44 | + | ) -> Result<Option<AlertRow>> { | |
| 45 | + | Ok(sqlx::query_as::<_, AlertRow>( | |
| 46 | + | "SELECT id, target, alert_type, from_status, to_status, sent_at, error | |
| 47 | + | FROM alerts WHERE target = ? AND alert_type NOT LIKE '%recovery%' | |
| 48 | + | ORDER BY id DESC LIMIT 1", | |
| 49 | + | ) | |
| 50 | + | .bind(target) | |
| 51 | + | .fetch_optional(pool) | |
| 52 | + | .await?) | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | /// The most recent alert row for a target of a specific type-set. Unlike | |
| 56 | + | /// [`get_latest_alert_for_target`] (which excludes recoveries), this can target | |
| 57 | + | /// recovery rows so recoveries get their own cooldown, and so a recorded recovery | |
| 58 | + | /// can clear a prior failure's cooldown (fail→recover→fail). `like` is a SQL | |
| 59 | + | /// LIKE pattern matched against `alert_type` (e.g. `'%recovery%'`). | |
| 60 | + | #[instrument(skip_all)] | |
| 61 | + | pub async fn get_latest_alert_matching( | |
| 62 | + | pool: &SqlitePool, | |
| 63 | + | target: &str, | |
| 64 | + | like: &str, | |
| 65 | + | ) -> Result<Option<AlertRow>> { | |
| 66 | + | Ok(sqlx::query_as::<_, AlertRow>( | |
| 67 | + | "SELECT id, target, alert_type, from_status, to_status, sent_at, error | |
| 68 | + | FROM alerts WHERE target = ? AND alert_type LIKE ? | |
| 69 | + | ORDER BY id DESC LIMIT 1", | |
| 70 | + | ) | |
| 71 | + | .bind(target) | |
| 72 | + | .bind(like) | |
| 73 | + | .fetch_optional(pool) | |
| 74 | + | .await?) | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | /// A delivery that failed and must be retried, so a transient WAM/Postmark error | |
| 78 | + | /// at a status transition can't silence the whole outage (the transition fires | |
| 79 | + | /// once; this queue is the durable state that outlives it). | |
| 80 | + | #[derive(Debug, Clone, sqlx::FromRow)] | |
| 81 | + | pub struct PendingAlertRow { | |
| 82 | + | pub id: i64, | |
| 83 | + | pub alert_key: String, | |
| 84 | + | pub category: String, | |
| 85 | + | pub channel: String, | |
| 86 | + | pub subject: String, | |
| 87 | + | pub body: String, | |
| 88 | + | pub priority: Option<String>, | |
| 89 | + | pub source: Option<String>, | |
| 90 | + | pub source_ref: Option<String>, | |
| 91 | + | pub from_status: Option<String>, | |
| 92 | + | pub to_status: Option<String>, | |
| 93 | + | pub error: Option<String>, | |
| 94 | + | pub attempts: i64, | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | /// Fields needed to enqueue an undelivered alert for retry. | |
| 98 | + | #[derive(Debug, Clone)] | |
| 99 | + | pub struct NewPendingAlert<'a> { | |
| 100 | + | pub alert_key: &'a str, | |
| 101 | + | pub category: &'a str, | |
| 102 | + | pub channel: &'a str, | |
| 103 | + | pub subject: &'a str, | |
| 104 | + | pub body: &'a str, | |
| 105 | + | pub priority: Option<&'a str>, | |
| 106 | + | pub source: Option<&'a str>, | |
| 107 | + | pub source_ref: Option<&'a str>, | |
| 108 | + | pub from_status: Option<&'a str>, | |
| 109 | + | pub to_status: Option<&'a str>, | |
| 110 | + | pub error: Option<&'a str>, | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | #[instrument(skip_all)] | |
| 114 | + | pub async fn enqueue_pending_alert(pool: &SqlitePool, a: &NewPendingAlert<'_>) -> Result<()> { | |
| 115 | + | let now = chrono::Utc::now().to_rfc3339(); | |
| 116 | + | sqlx::query( | |
| 117 | + | "INSERT INTO pending_alerts | |
| 118 | + | (alert_key, category, channel, subject, body, priority, source, source_ref, | |
| 119 | + | from_status, to_status, error, attempts, created_at, next_retry_at) | |
| 120 | + | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)", | |
| 121 | + | ) | |
| 122 | + | .bind(a.alert_key) | |
| 123 | + | .bind(a.category) | |
| 124 | + | .bind(a.channel) | |
| 125 | + | .bind(a.subject) | |
| 126 | + | .bind(a.body) | |
| 127 | + | .bind(a.priority) | |
| 128 | + | .bind(a.source) | |
| 129 | + | .bind(a.source_ref) | |
| 130 | + | .bind(a.from_status) | |
| 131 | + | .bind(a.to_status) | |
| 132 | + | .bind(a.error) | |
| 133 | + | .bind(&now) | |
| 134 | + | .bind(&now) // due immediately for the first retry | |
| 135 | + | .execute(pool) | |
| 136 | + | .await?; | |
| 137 | + | Ok(()) | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | /// Pending alerts whose `next_retry_at` is due (<= now), oldest first. | |
| 141 | + | #[instrument(skip_all)] | |
| 142 | + | pub async fn due_pending_alerts(pool: &SqlitePool, limit: i64) -> Result<Vec<PendingAlertRow>> { | |
| 143 | + | let now = chrono::Utc::now().to_rfc3339(); | |
| 144 | + | Ok(sqlx::query_as::<_, PendingAlertRow>( | |
| 145 | + | "SELECT id, alert_key, category, channel, subject, body, priority, source, source_ref, | |
| 146 | + | from_status, to_status, error, attempts | |
| 147 | + | FROM pending_alerts WHERE next_retry_at <= ? ORDER BY id ASC LIMIT ?", | |
| 148 | + | ) | |
| 149 | + | .bind(&now) | |
| 150 | + | .bind(limit) | |
| 151 | + | .fetch_all(pool) | |
| 152 | + | .await?) | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | #[instrument(skip_all)] | |
| 156 | + | pub async fn delete_pending_alert(pool: &SqlitePool, id: i64) -> Result<()> { | |
| 157 | + | sqlx::query("DELETE FROM pending_alerts WHERE id = ?") | |
| 158 | + | .bind(id) | |
| 159 | + | .execute(pool) | |
| 160 | + | .await?; | |
| 161 | + | Ok(()) | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | /// Record a failed retry: bump the attempt count and push `next_retry_at` out. | |
| 165 | + | #[instrument(skip_all)] | |
| 166 | + | pub async fn bump_pending_alert(pool: &SqlitePool, id: i64, next_retry_at: &str) -> Result<()> { | |
| 167 | + | sqlx::query("UPDATE pending_alerts SET attempts = attempts + 1, next_retry_at = ? WHERE id = ?") | |
| 168 | + | .bind(next_retry_at) | |
| 169 | + | .bind(id) | |
| 170 | + | .execute(pool) | |
| 171 | + | .await?; | |
| 172 | + | Ok(()) | |
| 173 | + | } |
| @@ -0,0 +1,54 @@ | |||
| 1 | + | use super::*; | |
| 2 | + | use tracing::instrument; | |
| 3 | + | ||
| 4 | + | #[instrument(skip_all)] | |
| 5 | + | pub async fn insert_backup_check( | |
| 6 | + | pool: &SqlitePool, | |
| 7 | + | result: &BackupCheckResult, | |
| 8 | + | ) -> Result<i64> { | |
| 9 | + | let row = sqlx::query( | |
| 10 | + | "INSERT INTO backup_checks (target, database_name, status, last_backup_at, size_bytes, age_hours, checked_at, error) | |
| 11 | + | VALUES (?, ?, ?, ?, ?, ?, ?, ?)", | |
| 12 | + | ) | |
| 13 | + | .bind(&result.target) | |
| 14 | + | .bind(&result.database_name) | |
| 15 | + | .bind(&result.status) | |
| 16 | + | .bind(&result.last_backup_at) | |
| 17 | + | .bind(result.size_bytes) | |
| 18 | + | .bind(result.age_hours) | |
| 19 | + | .bind(&result.checked_at) | |
| 20 | + | .bind(&result.error) | |
| 21 | + | .execute(pool) | |
| 22 | + | .await?; | |
| 23 | + | Ok(row.last_insert_rowid()) | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | /// Get the latest backup check for a specific target and database. | |
| 27 | + | #[instrument(skip_all)] | |
| 28 | + | pub async fn get_latest_backup_check( | |
| 29 | + | pool: &SqlitePool, | |
| 30 | + | target: &str, | |
| 31 | + | database_name: &str, | |
| 32 | + | ) -> Result<Option<BackupCheckRow>> { | |
| 33 | + | Ok(sqlx::query_as::<_, BackupCheckRow>( | |
| 34 | + | "SELECT id, target, database_name, status, last_backup_at, size_bytes, age_hours, checked_at, error | |
| 35 | + | FROM backup_checks WHERE target = ? AND database_name = ? ORDER BY id DESC LIMIT 1", | |
| 36 | + | ) | |
| 37 | + | .bind(target) | |
| 38 | + | .bind(database_name) | |
| 39 | + | .fetch_optional(pool) | |
| 40 | + | .await?) | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | #[derive(Debug, Clone, sqlx::FromRow, serde::Serialize)] | |
| 44 | + | pub struct BackupCheckRow { | |
| 45 | + | pub id: i64, | |
| 46 | + | pub target: String, | |
| 47 | + | pub database_name: String, | |
| 48 | + | pub status: String, | |
| 49 | + | pub last_backup_at: Option<String>, | |
| 50 | + | pub size_bytes: Option<i64>, | |
| 51 | + | pub age_hours: Option<i64>, | |
| 52 | + | pub checked_at: String, | |
| 53 | + | pub error: Option<String>, | |
| 54 | + | } |
| @@ -0,0 +1,54 @@ | |||
| 1 | + | use super::*; | |
| 2 | + | use tracing::instrument; | |
| 3 | + | ||
| 4 | + | #[instrument(skip_all)] | |
| 5 | + | pub async fn insert_cors_check( | |
| 6 | + | pool: &SqlitePool, | |
| 7 | + | result: &CorsCheckResult, | |
| 8 | + | ) -> Result<i64> { | |
| 9 | + | let row = sqlx::query( | |
| 10 | + | "INSERT INTO cors_checks (target, url, origin, method, passes, checked_at, error) | |
| 11 | + | VALUES (?, ?, ?, ?, ?, ?, ?)", | |
| 12 | + | ) | |
| 13 | + | .bind(&result.target) | |
| 14 | + | .bind(&result.url) | |
| 15 | + | .bind(&result.origin) | |
| 16 | + | .bind(&result.method) | |
| 17 | + | .bind(result.passes) | |
| 18 | + | .bind(&result.checked_at) | |
| 19 | + | .bind(&result.error) | |
| 20 | + | .execute(pool) | |
| 21 | + | .await?; | |
| 22 | + | Ok(row.last_insert_rowid()) | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | /// Get the latest CORS check per URL for a target. | |
| 26 | + | #[instrument(skip_all)] | |
| 27 | + | pub async fn get_latest_cors_checks( | |
| 28 | + | pool: &SqlitePool, | |
| 29 | + | target: &str, | |
| 30 | + | ) -> Result<Vec<CorsCheckResult>> { | |
| 31 | + | let rows = sqlx::query_as::<_, (String, String, String, String, bool, String, Option<String>)>( | |
| 32 | + | "SELECT c.target, c.url, c.origin, c.method, c.passes, c.checked_at, c.error | |
| 33 | + | FROM cors_checks c | |
| 34 | + | INNER JOIN (SELECT url, MAX(id) as max_id FROM cors_checks WHERE target = ? GROUP BY url) latest | |
| 35 | + | ON c.id = latest.max_id | |
| 36 | + | ORDER BY c.url", | |
| 37 | + | ) | |
| 38 | + | .bind(target) | |
| 39 | + | .fetch_all(pool) | |
| 40 | + | .await?; | |
| 41 | + | ||
| 42 | + | Ok(rows | |
| 43 | + | .into_iter() | |
| 44 | + | .map(|(target, url, origin, method, passes, checked_at, error)| CorsCheckResult { | |
| 45 | + | target, | |
| 46 | + | url, | |
| 47 | + | origin, | |
| 48 | + | method, | |
| 49 | + | passes, | |
| 50 | + | checked_at, | |
| 51 | + | error, | |
| 52 | + | }) | |
| 53 | + | .collect()) | |
| 54 | + | } |
| @@ -0,0 +1,92 @@ | |||
| 1 | + | use super::*; | |
| 2 | + | use tracing::instrument; | |
| 3 | + | ||
| 4 | + | #[derive(Debug, Clone, sqlx::FromRow, serde::Serialize)] | |
| 5 | + | pub struct DnsCheckRow { | |
| 6 | + | pub id: i64, | |
| 7 | + | pub target: String, | |
| 8 | + | pub name: String, | |
| 9 | + | pub record_type: String, | |
| 10 | + | pub expected: String, | |
| 11 | + | pub actual: String, | |
| 12 | + | pub matches: bool, | |
| 13 | + | pub checked_at: String, | |
| 14 | + | pub error: Option<String>, | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | #[instrument(skip_all)] | |
| 18 | + | pub async fn insert_dns_check( | |
| 19 | + | pool: &SqlitePool, | |
| 20 | + | result: &DnsCheckResult, | |
| 21 | + | ) -> Result<i64> { | |
| 22 | + | let expected = serde_json::to_string(&result.expected).unwrap_or_default(); | |
| 23 | + | let actual = serde_json::to_string(&result.actual).unwrap_or_default(); | |
| 24 | + | let record_type_str = result.record_type.to_string(); | |
| 25 | + | ||
| 26 | + | let row = sqlx::query( | |
| 27 | + | "INSERT INTO dns_checks (target, name, record_type, expected, actual, matches, checked_at, error) | |
| 28 | + | VALUES (?, ?, ?, ?, ?, ?, ?, ?)", | |
| 29 | + | ) | |
| 30 | + | .bind(&result.target) | |
| 31 | + | .bind(&result.name) | |
| 32 | + | .bind(&record_type_str) | |
| 33 | + | .bind(&expected) | |
| 34 | + | .bind(&actual) | |
| 35 | + | .bind(result.matches) | |
| 36 | + | .bind(&result.checked_at) | |
| 37 | + | .bind(&result.error) | |
| 38 | + | .execute(pool) | |
| 39 | + | .await?; | |
| 40 | + | Ok(row.last_insert_rowid()) | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | /// Get the latest DNS check per (name, record_type) for a target. | |
| 44 | + | #[instrument(skip_all)] | |
| 45 | + | pub async fn get_latest_dns_checks( | |
| 46 | + | pool: &SqlitePool, | |
| 47 | + | target: &str, | |
| 48 | + | ) -> Result<Vec<DnsCheckRow>> { | |
| 49 | + | Ok(sqlx::query_as::<_, DnsCheckRow>( | |
| 50 | + | "SELECT d.id, d.target, d.name, d.record_type, d.expected, d.actual, d.matches, d.checked_at, d.error | |
| 51 | + | FROM dns_checks d | |
| 52 | + | INNER JOIN (SELECT name, record_type, MAX(id) as max_id FROM dns_checks WHERE target = ? GROUP BY name, record_type) latest | |
| 53 | + | ON d.id = latest.max_id | |
| 54 | + | ORDER BY d.name, d.record_type", | |
| 55 | + | ) | |
| 56 | + | .bind(target) | |
| 57 | + | .fetch_all(pool) | |
| 58 | + | .await?) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | /// Delete dns_checks for (name, record_type) pairs no longer in the config. | |
| 62 | + | #[instrument(skip_all)] | |
| 63 | + | pub async fn prune_stale_dns( | |
| 64 | + | pool: &SqlitePool, | |
| 65 | + | target: &str, | |
| 66 | + | expected_dns: &[(String, String)], | |
| 67 | + | ) -> Result<u64> { | |
| 68 | + | if expected_dns.is_empty() { | |
| 69 | + | // No configured DNS records — delete all DNS checks for this target | |
| 70 | + | let result = sqlx::query("DELETE FROM dns_checks WHERE target = ?") | |
| 71 | + | .bind(target) | |
| 72 | + | .execute(pool) | |
| 73 | + | .await?; | |
| 74 | + | return Ok(result.rows_affected()); | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // Build a compound condition: keep rows matching any configured (name, record_type) pair | |
| 78 | + | let conditions: Vec<String> = expected_dns | |
| 79 | + | .iter() | |
| 80 | + | .map(|_| "(name = ? AND record_type = ?)".to_string()) | |
| 81 | + | .collect(); | |
| 82 | + | let sql = format!( | |
| 83 | + | "DELETE FROM dns_checks WHERE target = ? AND NOT ({})", | |
| 84 | + | conditions.join(" OR ") | |
| 85 | + | ); | |
| 86 | + | let mut query = sqlx::query(&sql).bind(target); | |
| 87 | + | for (name, record_type) in expected_dns { | |
| 88 | + | query = query.bind(name).bind(record_type); | |
| 89 | + | } | |
| 90 | + | let result = query.execute(pool).await?; | |
| 91 | + | Ok(result.rows_affected()) | |
| 92 | + | } |
| @@ -0,0 +1,178 @@ | |||
| 1 | + | use super::*; | |
| 2 | + | use tracing::instrument; | |
| 3 | + | ||
| 4 | + | #[instrument(skip_all)] | |
| 5 | + | pub async fn insert_health_check( | |
| 6 | + | pool: &SqlitePool, | |
| 7 | + | snapshot: &HealthSnapshot, | |
| 8 | + | ) -> Result<i64> { | |
| 9 | + | let status = snapshot.status.to_string(); | |
| 10 | + | let details_json = snapshot | |
| 11 | + | .details | |
| 12 | + | .as_ref() | |
| 13 | + | .map(|d| serde_json::to_string(d).unwrap_or_default()); | |
| 14 | + | ||
| 15 | + | let result = sqlx::query( | |
| 16 | + | "INSERT INTO health_checks (target, status, checked_at, response_time_ms, details_json, error) | |
| 17 | + | VALUES (?, ?, ?, ?, ?, ?)", | |
| 18 | + | ) | |
| 19 | + | .bind(&snapshot.target) | |
| 20 | + | .bind(&status) | |
| 21 | + | .bind(&snapshot.checked_at) | |
| 22 | + | .bind(snapshot.response_time_ms) | |
| 23 | + | .bind(&details_json) | |
| 24 | + | .bind(&snapshot.error) | |
| 25 | + | .execute(pool) | |
| 26 | + | .await?; | |
| 27 | + | ||
| 28 | + | Ok(result.last_insert_rowid()) | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | #[instrument(skip_all)] | |
| 32 | + | pub async fn get_health_history( | |
| 33 | + | pool: &SqlitePool, | |
| 34 | + | target: Option<&str>, | |
| 35 | + | limit: i64, | |
| 36 | + | ) -> Result<Vec<HealthSnapshot>> { | |
| 37 | + | let rows = match target { | |
| 38 | + | Some(t) => { | |
| 39 | + | sqlx::query_as::<_, HealthCheckRow>( | |
| 40 | + | "SELECT id, target, status, checked_at, response_time_ms, details_json, error | |
| 41 | + | FROM health_checks WHERE target = ? ORDER BY id DESC LIMIT ?", | |
| 42 | + | ) | |
| 43 | + | .bind(t) | |
| 44 | + | .bind(limit) | |
| 45 | + | .fetch_all(pool) | |
| 46 | + | .await? | |
| 47 | + | } | |
| 48 | + | None => { | |
| 49 | + | sqlx::query_as::<_, HealthCheckRow>( | |
| 50 | + | "SELECT id, target, status, checked_at, response_time_ms, details_json, error | |
| 51 | + | FROM health_checks ORDER BY id DESC LIMIT ?", | |
| 52 | + | ) | |
| 53 | + | .bind(limit) | |
| 54 | + | .fetch_all(pool) | |
| 55 | + | .await? | |
| 56 | + | } | |
| 57 | + | }; | |
| 58 | + | ||
| 59 | + | Ok(rows.into_iter().map(|r| r.into_snapshot()).collect()) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | #[instrument(skip_all)] | |
| 63 | + | pub async fn get_latest_health( | |
| 64 | + | pool: &SqlitePool, | |
| 65 | + | target: &str, | |
| 66 | + | ) -> Result<Option<HealthSnapshot>> { | |
| 67 | + | let row = sqlx::query_as::<_, HealthCheckRow>( | |
| 68 | + | "SELECT id, target, status, checked_at, response_time_ms, details_json, error | |
| 69 | + | FROM health_checks WHERE target = ? ORDER BY id DESC LIMIT 1", | |
| 70 | + | ) | |
| 71 | + | .bind(target) | |
| 72 | + | .fetch_optional(pool) | |
| 73 | + | .await?; | |
| 74 | + | ||
| 75 | + | Ok(row.map(|r| r.into_snapshot())) | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | /// Calculate uptime percentage for a target over the given number of hours. | |
| 79 | + | /// Returns the percentage of health checks with "operational" status. | |
| 80 | + | #[instrument(skip_all)] | |
| 81 | + | pub async fn get_uptime_percent( | |
| 82 | + | pool: &SqlitePool, | |
| 83 | + | target: &str, | |
| 84 | + | hours: i64, | |
| 85 | + | ) -> Result<Option<f64>> { | |
| 86 | + | let cutoff = chrono::Utc::now() - chrono::Duration::hours(hours); | |
| 87 | + | let cutoff_str = cutoff.to_rfc3339(); | |
| 88 | + | ||
| 89 | + | let row = sqlx::query_as::<_, (i64, i64)>( | |
| 90 | + | "SELECT | |
| 91 | + | COUNT(*) as total, | |
| 92 | + | SUM(CASE WHEN status = 'operational' THEN 1 ELSE 0 END) as operational | |
| 93 | + | FROM health_checks | |
| 94 | + | WHERE target = ? AND checked_at >= ?", | |
| 95 | + | ) | |
| 96 | + | .bind(target) | |
| 97 | + | .bind(&cutoff_str) | |
| 98 | + | .fetch_one(pool) | |
| 99 | + | .await?; | |
| 100 | + | ||
| 101 | + | if row.0 == 0 { | |
| 102 | + | Ok(None) | |
| 103 | + | } else { | |
| 104 | + | Ok(Some(row.1 as f64 / row.0 as f64 * 100.0)) | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | /// Fetch all response times for a target since a given timestamp, ordered ASC. | |
| 109 | + | #[instrument(skip_all)] | |
| 110 | + | pub async fn get_response_times( | |
| 111 | + | pool: &SqlitePool, | |
| 112 | + | target: &str, | |
| 113 | + | since_rfc3339: &str, | |
| 114 | + | ) -> Result<Vec<(String, i64)>> { | |
| 115 | + | let rows = sqlx::query_as::<_, (String, i64)>( | |
| 116 | + | "SELECT checked_at, response_time_ms FROM health_checks | |
| 117 | + | WHERE target = ? AND checked_at >= ? | |
| 118 | + | ORDER BY checked_at ASC", | |
| 119 | + | ) | |
| 120 | + | .bind(target) | |
| 121 | + | .bind(since_rfc3339) | |
| 122 | + | .fetch_all(pool) | |
| 123 | + | .await?; | |
| 124 | + | Ok(rows) | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | /// Fetch the last N response times for **operational** checks only (most recent first). | |
| 128 | + | #[instrument(skip_all)] | |
| 129 | + | pub async fn get_recent_response_times( | |
| 130 | + | pool: &SqlitePool, | |
| 131 | + | target: &str, | |
| 132 | + | count: i64, | |
| 133 | + | ) -> Result<Vec<i64>> { | |
| 134 | + | let rows = sqlx::query_as::<_, (i64,)>( | |
| 135 | + | "SELECT response_time_ms FROM health_checks | |
| 136 | + | WHERE target = ? AND status = 'operational' | |
| 137 | + | ORDER BY id DESC LIMIT ?", | |
| 138 | + | ) | |
| 139 | + | .bind(target) | |
| 140 | + | .bind(count) | |
| 141 | + | .fetch_all(pool) | |
| 142 | + | .await?; | |
| 143 | + | Ok(rows.into_iter().map(|r| r.0).collect()) | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | #[derive(sqlx::FromRow)] | |
| 147 | + | struct HealthCheckRow { | |
| 148 | + | id: i64, | |
| 149 | + | target: String, | |
| 150 | + | status: String, | |
| 151 | + | checked_at: String, | |
| 152 | + | response_time_ms: i64, | |
| 153 | + | details_json: Option<String>, | |
| 154 | + | error: Option<String>, | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | impl HealthCheckRow { | |
| 158 | + | fn into_snapshot(self) -> HealthSnapshot { | |
| 159 | + | let status = self | |
| 160 | + | .status | |
| 161 | + | .parse::<HealthStatus>() | |
| 162 | + | .unwrap_or(HealthStatus::Error); | |
| 163 | + | let details = self | |
| 164 | + | .details_json | |
| 165 | + | .as_deref() | |
| 166 | + | .and_then(|s| serde_json::from_str::<HealthDetails>(s).ok()); | |
| 167 | + | ||
| 168 | + | HealthSnapshot { | |
| 169 | + | id: Some(self.id), | |
| 170 | + | target: self.target, | |
| 171 | + | status, | |
| 172 | + | checked_at: self.checked_at, | |
| 173 | + | response_time_ms: self.response_time_ms, | |
| 174 | + | details, | |
| 175 | + | error: self.error, | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | } |
| @@ -0,0 +1,117 @@ | |||
| 1 | + | use super::*; | |
| 2 | + | use tracing::instrument; | |
| 3 | + | ||
| 4 | + | #[derive(Debug, Clone, sqlx::FromRow, serde::Serialize)] | |
| 5 | + | pub struct IncidentRow { | |
| 6 | + | pub id: i64, | |
| 7 | + | pub target: String, | |
| 8 | + | pub started_at: String, | |
| 9 | + | pub ended_at: Option<String>, | |
| 10 | + | pub duration_secs: Option<i64>, | |
| 11 | + | pub from_status: String, | |
| 12 | + | pub to_status: String, | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | #[instrument(skip_all)] | |
| 16 | + | pub async fn insert_incident( | |
| 17 | + | pool: &SqlitePool, | |
| 18 | + | target: &str, | |
| 19 | + | from_status: &str, | |
| 20 | + | to_status: &str, | |
| 21 | + | ) -> Result<i64> { | |
| 22 | + | let now = chrono::Utc::now().to_rfc3339(); | |
| 23 | + | let result = sqlx::query( | |
| 24 | + | "INSERT INTO incidents (target, started_at, from_status, to_status) | |
| 25 | + | VALUES (?, ?, ?, ?)", | |
| 26 | + | ) | |
| 27 | + | .bind(target) | |
| 28 | + | .bind(&now) | |
| 29 | + | .bind(from_status) | |
| 30 | + | .bind(to_status) | |
| 31 | + | .execute(pool) | |
| 32 | + | .await?; | |
| 33 | + | Ok(result.last_insert_rowid()) | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | #[instrument(skip_all)] | |
| 37 | + | pub async fn close_open_incidents( | |
| 38 | + | pool: &SqlitePool, | |
| 39 | + | target: &str, | |
| 40 | + | ) -> Result<u64> { | |
| 41 | + | let now = chrono::Utc::now().to_rfc3339(); | |
| 42 | + | let result = sqlx::query( | |
| 43 | + | "UPDATE incidents SET ended_at = ?, duration_secs = CAST((julianday(?) - julianday(started_at)) * 86400 AS INTEGER) | |
| 44 | + | WHERE target = ? AND ended_at IS NULL", | |
| 45 | + | ) | |
| 46 | + | .bind(&now) | |
| 47 | + | .bind(&now) | |
| 48 | + | .bind(target) | |
| 49 | + | .execute(pool) | |
| 50 | + | .await?; | |
| 51 | + | Ok(result.rows_affected()) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | /// Close any open incidents for `target` and open a new one, atomically. A status | |
| 55 | + | /// change like `error -> unreachable` closes the old incident and opens the new | |
| 56 | + | /// one; done as two pool calls, a crash between them left the old incident closed | |
| 57 | + | /// and the new one never recorded (fuzz-2026-07-06 crash atomicity). One tx. | |
| 58 | + | #[instrument(skip_all)] | |
| 59 | + | pub async fn close_and_open_incident( | |
| 60 | + | pool: &SqlitePool, | |
| 61 | + | target: &str, | |
| 62 | + | from_status: &str, | |
| 63 | + | to_status: &str, | |
| 64 | + | ) -> Result<()> { | |
| 65 | + | let now = chrono::Utc::now().to_rfc3339(); | |
| 66 | + | let mut tx = pool.begin().await?; | |
| 67 | + | sqlx::query( | |
| 68 | + | "UPDATE incidents SET ended_at = ?, duration_secs = CAST((julianday(?) - julianday(started_at)) * 86400 AS INTEGER) | |
| 69 | + | WHERE target = ? AND ended_at IS NULL", | |
| 70 | + | ) | |
| 71 | + | .bind(&now) | |
| 72 | + | .bind(&now) | |
| 73 | + | .bind(target) | |
| 74 | + | .execute(&mut *tx) | |
| 75 | + | .await?; | |
| 76 | + | sqlx::query( | |
| 77 | + | "INSERT INTO incidents (target, started_at, from_status, to_status) VALUES (?, ?, ?, ?)", | |
| 78 | + | ) | |
| 79 | + | .bind(target) | |
| 80 | + | .bind(&now) | |
| 81 | + | .bind(from_status) | |
| 82 | + | .bind(to_status) | |
| 83 | + | .execute(&mut *tx) | |
| 84 | + | .await?; | |
| 85 | + | tx.commit().await?; | |
| 86 | + | Ok(()) | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | #[instrument(skip_all)] | |
| 90 | + | pub async fn get_open_incident( | |
| 91 | + | pool: &SqlitePool, | |
| 92 | + | target: &str, | |
| 93 | + | ) -> Result<Option<IncidentRow>> { | |
| 94 | + | Ok(sqlx::query_as::<_, IncidentRow>( | |
| 95 | + | "SELECT id, target, started_at, ended_at, duration_secs, from_status, to_status | |
| 96 | + | FROM incidents WHERE target = ? AND ended_at IS NULL ORDER BY id DESC LIMIT 1", | |
| 97 | + | ) | |
| 98 | + | .bind(target) | |
| 99 | + | .fetch_optional(pool) | |
| 100 | + | .await?) | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | #[instrument(skip_all)] | |
| 104 | + | pub async fn get_recent_incidents( | |
| 105 | + | pool: &SqlitePool, | |
| 106 | + | target: &str, | |
| 107 | + | limit: i64, | |
| 108 | + | ) -> Result<Vec<IncidentRow>> { | |
| 109 | + | Ok(sqlx::query_as::<_, IncidentRow>( | |
| 110 | + | "SELECT id, target, started_at, ended_at, duration_secs, from_status, to_status | |
| 111 | + | FROM incidents WHERE target = ? ORDER BY id DESC LIMIT ?", | |
| 112 | + | ) | |
| 113 | + | .bind(target) | |
| 114 | + | .bind(limit) | |
| 115 | + | .fetch_all(pool) | |
| 116 | + | .await?) | |
| 117 | + | } |
| @@ -0,0 +1,105 @@ | |||
| 1 | + | use super::*; | |
| 2 | + | use tracing::instrument; | |
| 3 | + | ||
| 4 | + | /// Prune result with counts for each table. | |
| 5 | + | pub struct PruneResult { | |
| 6 | + | pub health: u64, | |
| 7 | + | pub tests: u64, | |
| 8 | + | pub test_details: u64, | |
| 9 | + | pub heartbeats: u64, | |
| 10 | + | pub alerts: u64, | |
| 11 | + | pub tls: u64, | |
| 12 | + | pub incidents: u64, | |
| 13 | + | pub routes: u64, | |
| 14 | + | pub dns: u64, | |
| 15 | + | pub whois: u64, | |
| 16 | + | pub backups: u64, | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | /// Delete records older than `days` from all tables. | |
| 20 | + | /// Only closed incidents (with a non-NULL `ended_at`) are pruned. | |
| 21 | + | #[instrument(skip_all)] | |
| 22 | + | pub async fn prune_old_records( | |
| 23 | + | pool: &SqlitePool, | |
| 24 | + | days: i64, | |
| 25 | + | ) -> Result<PruneResult> { | |
| 26 | + | // Guard: days <= 0 would set cutoff to now (or the future), deleting | |
| 27 | + | // everything. Treat this as a no-op instead. | |
| 28 | + | if days <= 0 { | |
| 29 | + | return Ok(PruneResult { health: 0, tests: 0, test_details: 0, heartbeats: 0, alerts: 0, tls: 0, incidents: 0, routes: 0, dns: 0, whois: 0, backups: 0 }); | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | let cutoff = chrono::Utc::now() - chrono::Duration::days(days); | |
| 33 | + | let cutoff_str = cutoff.to_rfc3339(); | |
| 34 | + | ||
| 35 | + | let health_result = sqlx::query("DELETE FROM health_checks WHERE checked_at < ?") | |
| 36 | + | .bind(&cutoff_str) | |
| 37 | + | .execute(pool) | |
| 38 | + | .await?; | |
| 39 | + | ||
| 40 | + | let test_result = sqlx::query("DELETE FROM test_runs WHERE started_at < ?") | |
| 41 | + | .bind(&cutoff_str) | |
| 42 | + | .execute(pool) | |
| 43 | + | .await?; | |
| 44 | + | ||
| 45 | + | // Prune orphaned test_details (run was deleted above). | |
| 46 | + | let test_details_result = sqlx::query( | |
| 47 | + | "DELETE FROM test_details WHERE run_id NOT IN (SELECT id FROM test_runs)", | |
| 48 | + | ) | |
| 49 | + | .execute(pool) | |
| 50 | + | .await?; | |
| 51 | + | ||
| 52 | + | let peer_hb_result = sqlx::query("DELETE FROM peer_heartbeats WHERE checked_at < ?") | |
| 53 | + | .bind(&cutoff_str) | |
| 54 | + | .execute(pool) | |
| 55 | + | .await?; | |
| 56 | + | ||
| 57 | + | let alerts_result = sqlx::query("DELETE FROM alerts WHERE sent_at < ?") | |
| 58 | + | .bind(&cutoff_str) | |
| 59 | + | .execute(pool) | |
| 60 | + | .await?; | |
| 61 | + | ||
| 62 | + | let tls_result = sqlx::query("DELETE FROM tls_checks WHERE checked_at < ?") | |
| 63 | + | .bind(&cutoff_str) | |
| 64 | + | .execute(pool) | |
| 65 | + | .await?; | |
| 66 | + | ||
| 67 | + | let incidents_result = sqlx::query("DELETE FROM incidents WHERE ended_at IS NOT NULL AND ended_at < ?") | |
| 68 | + | .bind(&cutoff_str) | |
| 69 | + | .execute(pool) | |
| 70 | + | .await?; | |
| 71 | + | ||
| 72 | + | let routes_result = sqlx::query("DELETE FROM route_checks WHERE checked_at < ?") | |
| 73 | + | .bind(&cutoff_str) | |
| 74 | + | .execute(pool) | |
| 75 | + | .await?; | |
| 76 | + | ||
| 77 | + | let dns_result = sqlx::query("DELETE FROM dns_checks WHERE checked_at < ?") | |
| 78 | + | .bind(&cutoff_str) | |
| 79 | + | .execute(pool) | |
| 80 | + | .await?; | |
| 81 | + | ||
| 82 | + | let whois_result = sqlx::query("DELETE FROM whois_checks WHERE checked_at < ?") | |
| 83 | + | .bind(&cutoff_str) | |
| 84 | + | .execute(pool) | |
| 85 | + | .await?; | |
| 86 | + | ||
| 87 | + | let backups_result = sqlx::query("DELETE FROM backup_checks WHERE checked_at < ?") | |
| 88 | + | .bind(&cutoff_str) | |
| 89 | + | .execute(pool) | |
| 90 | + | .await?; | |
| 91 | + | ||
| 92 | + | Ok(PruneResult { | |
| 93 | + | health: health_result.rows_affected(), | |
| 94 | + | tests: test_result.rows_affected(), | |
| 95 | + | test_details: test_details_result.rows_affected(), | |
| 96 | + | heartbeats: peer_hb_result.rows_affected(), | |
| 97 | + | alerts: alerts_result.rows_affected(), | |
| 98 | + | tls: tls_result.rows_affected(), | |
| 99 | + | incidents: incidents_result.rows_affected(), | |
| 100 | + | routes: routes_result.rows_affected(), | |
| 101 | + | dns: dns_result.rows_affected(), | |
| 102 | + | whois: whois_result.rows_affected(), | |
| 103 | + | backups: backups_result.rows_affected(), | |
| 104 | + | }) | |
| 105 | + | } |
| @@ -0,0 +1,324 @@ | |||
| 1 | + | use super::*; | |
| 2 | + | use tracing::{info, instrument}; | |
| 3 | + | ||
| 4 | + | /// Each migration is a (version, description, SQL) tuple. Versions start at 1. | |
| 5 | + | /// The SQL may contain multiple statements separated by semicolons. | |
| 6 | + | const MIGRATIONS: &[(i64, &str, &str)] = &[ | |
| 7 | + | (1, "initial schema", r#" | |
| 8 | + | CREATE TABLE IF NOT EXISTS health_checks ( | |
| 9 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 10 | + | target TEXT NOT NULL, | |
| 11 | + | status TEXT NOT NULL, | |
| 12 | + | checked_at TEXT NOT NULL, | |
| 13 | + | response_time_ms INTEGER NOT NULL, | |
| 14 | + | details_json TEXT, | |
| 15 | + | error TEXT | |
| 16 | + | ); | |
| 17 | + | CREATE TABLE IF NOT EXISTS test_runs ( | |
| 18 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 19 | + | target TEXT NOT NULL, | |
| 20 | + | started_at TEXT NOT NULL, | |
| 21 | + | finished_at TEXT, | |
| 22 | + | duration_secs INTEGER, | |
| 23 | + | exit_code INTEGER, | |
| 24 | + | passed INTEGER NOT NULL, | |
| 25 | + | summary_json TEXT NOT NULL, | |
| 26 | + | raw_output TEXT NOT NULL, | |
| 27 | + | filter TEXT | |
| 28 | + | ); | |
| 29 | + | CREATE TABLE IF NOT EXISTS peer_identities ( | |
| 30 | + | peer_name TEXT PRIMARY KEY, | |
| 31 | + | instance_id TEXT NOT NULL, | |
| 32 | + | first_seen TEXT NOT NULL | |
| 33 | + | ); | |
| 34 | + | CREATE TABLE IF NOT EXISTS peer_heartbeats ( | |
| 35 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 36 | + | peer_name TEXT NOT NULL, | |
| 37 | + | status TEXT NOT NULL, | |
| 38 | + | latency_ms INTEGER NOT NULL, | |
| 39 | + | checked_at TEXT NOT NULL | |
| 40 | + | ); | |
| 41 | + | CREATE INDEX IF NOT EXISTS idx_health_checks_target_id ON health_checks(target, id DESC); | |
| 42 | + | CREATE INDEX IF NOT EXISTS idx_health_checks_target_checked ON health_checks(target, checked_at); | |
| 43 | + | CREATE INDEX IF NOT EXISTS idx_test_runs_target_id ON test_runs(target, id DESC); | |
| 44 | + | CREATE INDEX IF NOT EXISTS idx_peer_heartbeats_peer_id ON peer_heartbeats(peer_name, id DESC); | |
| 45 | + | "#), | |
| 46 | + | (2, "add alerts table", r#" | |
| 47 | + | CREATE TABLE IF NOT EXISTS alerts ( | |
| 48 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 49 | + | target TEXT NOT NULL, | |
| 50 | + | alert_type TEXT NOT NULL, | |
| 51 | + | from_status TEXT, | |
| 52 | + | to_status TEXT, | |
| 53 | + | sent_at TEXT NOT NULL, | |
| 54 | + | error TEXT | |
| 55 | + | ); | |
| 56 | + | CREATE INDEX IF NOT EXISTS idx_alerts_target_sent ON alerts(target, sent_at); | |
| 57 | + | "#), | |
| 58 | + | (3, "add tls_checks table", r#" | |
| 59 | + | CREATE TABLE IF NOT EXISTS tls_checks ( | |
| 60 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 61 | + | target TEXT NOT NULL, | |
| 62 | + | host TEXT NOT NULL, | |
| 63 | + | valid INTEGER NOT NULL, | |
| 64 | + | days_remaining INTEGER NOT NULL, | |
| 65 | + | not_before TEXT NOT NULL, | |
| 66 | + | not_after TEXT NOT NULL, | |
| 67 | + | subject TEXT NOT NULL, | |
| 68 | + | issuer TEXT NOT NULL, | |
| 69 | + | checked_at TEXT NOT NULL, | |
| 70 | + | error TEXT | |
| 71 | + | ); | |
| 72 | + | CREATE INDEX IF NOT EXISTS idx_tls_checks_target_id ON tls_checks(target, id DESC); | |
| 73 | + | "#), | |
| 74 | + | (4, "add incidents table", r#" | |
| 75 | + | CREATE TABLE IF NOT EXISTS incidents ( | |
| 76 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 77 | + | target TEXT NOT NULL, | |
| 78 | + | started_at TEXT NOT NULL, | |
| 79 | + | ended_at TEXT, | |
| 80 | + | duration_secs INTEGER, | |
| 81 | + | from_status TEXT NOT NULL, | |
| 82 | + | to_status TEXT NOT NULL | |
| 83 | + | ); | |
| 84 | + | CREATE INDEX IF NOT EXISTS idx_incidents_target_id ON incidents(target, id DESC); | |
| 85 | + | "#), | |
| 86 | + | (5, "add route_checks table", r#" | |
| 87 | + | CREATE TABLE IF NOT EXISTS route_checks ( | |
| 88 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 89 | + | target TEXT NOT NULL, | |
| 90 | + | path TEXT NOT NULL, | |
| 91 | + | status_code INTEGER NOT NULL, | |
| 92 | + | ok INTEGER NOT NULL, | |
| 93 | + | response_time_ms INTEGER NOT NULL, | |
| 94 | + | checked_at TEXT NOT NULL, | |
| 95 | + | error TEXT | |
| 96 | + | ); | |
| 97 | + | CREATE INDEX IF NOT EXISTS idx_route_checks_target_path ON route_checks(target, path, id DESC); | |
| 98 | + | CREATE INDEX IF NOT EXISTS idx_route_checks_target ON route_checks(target, checked_at DESC); | |
| 99 | + | "#), | |
| 100 | + | (6, "add dns_checks and whois_checks tables", r#" | |
| 101 | + | CREATE TABLE IF NOT EXISTS dns_checks ( | |
| 102 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 103 | + | target TEXT NOT NULL, | |
| 104 | + | name TEXT NOT NULL, | |
| 105 | + | record_type TEXT NOT NULL, | |
| 106 | + | expected TEXT NOT NULL, | |
| 107 | + | actual TEXT NOT NULL, | |
| 108 | + | matches INTEGER NOT NULL, | |
| 109 | + | checked_at TEXT NOT NULL, | |
| 110 | + | error TEXT | |
| 111 | + | ); | |
| 112 | + | CREATE INDEX IF NOT EXISTS idx_dns_checks_target ON dns_checks(target, name, id DESC); | |
| 113 | + | ||
| 114 | + | CREATE TABLE IF NOT EXISTS whois_checks ( | |
| 115 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 116 | + | target TEXT NOT NULL, | |
| 117 | + | domain TEXT NOT NULL, | |
| 118 | + | registrar TEXT, | |
| 119 | + | expiry_date TEXT, | |
| 120 | + | days_remaining INTEGER, | |
| 121 | + | nameservers TEXT, | |
| 122 | + | checked_at TEXT NOT NULL, | |
| 123 | + | error TEXT | |
| 124 | + | ); | |
| 125 | + | CREATE INDEX IF NOT EXISTS idx_whois_checks_target ON whois_checks(target, id DESC); | |
| 126 | + | "#), | |
| 127 | + | (7, "add test_details table", r#" | |
| 128 | + | CREATE TABLE IF NOT EXISTS test_details ( | |
| 129 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 130 | + | run_id INTEGER NOT NULL REFERENCES test_runs(id) ON DELETE CASCADE, | |
| 131 | + | test_name TEXT NOT NULL, | |
| 132 | + | passed INTEGER NOT NULL, | |
| 133 | + | duration_ms INTEGER | |
| 134 | + | ); | |
| 135 | + | CREATE INDEX IF NOT EXISTS idx_test_details_run_id ON test_details(run_id); | |
| 136 | + | CREATE INDEX IF NOT EXISTS idx_test_details_name ON test_details(test_name, run_id DESC); | |
| 137 | + | "#), | |
| 138 | + | (8, "add cors_checks table", r#" | |
| 139 | + | CREATE TABLE IF NOT EXISTS cors_checks ( | |
| 140 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 141 | + | target TEXT NOT NULL, | |
| 142 | + | url TEXT NOT NULL, | |
| 143 | + | origin TEXT NOT NULL, | |
| 144 | + | method TEXT NOT NULL, | |
| 145 | + | passes INTEGER NOT NULL, | |
| 146 | + | checked_at TEXT NOT NULL, | |
| 147 | + | error TEXT | |
| 148 | + | ); | |
| 149 | + | CREATE INDEX IF NOT EXISTS idx_cors_target_id ON cors_checks(target, id DESC); | |
| 150 | + | "#), | |
| 151 | + | (9, "add backup_checks table", r#" | |
| 152 | + | CREATE TABLE IF NOT EXISTS backup_checks ( | |
| 153 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 154 | + | target TEXT NOT NULL, | |
| 155 | + | database_name TEXT NOT NULL, | |
| 156 | + | status TEXT NOT NULL, | |
| 157 | + | last_backup_at TEXT, | |
| 158 | + | size_bytes INTEGER, | |
| 159 | + | age_hours INTEGER, | |
| 160 | + | checked_at TEXT NOT NULL, | |
| 161 | + | error TEXT | |
| 162 | + | ); | |
| 163 | + | CREATE INDEX IF NOT EXISTS idx_backup_checks_target ON backup_checks(target, id DESC); | |
| 164 | + | "#), | |
| 165 | + | (10, "add pending_alerts retry queue", r#" | |
| 166 | + | CREATE TABLE IF NOT EXISTS pending_alerts ( | |
| 167 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 168 | + | alert_key TEXT NOT NULL, | |
| 169 | + | category TEXT NOT NULL, | |
| 170 | + | channel TEXT NOT NULL, -- 'wam' or 'email' | |
| 171 | + | subject TEXT NOT NULL, | |
| 172 | + | body TEXT NOT NULL, | |
| 173 | + | priority TEXT, -- WAM only | |
| 174 | + | source TEXT, -- WAM only | |
| 175 | + | source_ref TEXT, -- WAM only | |
| 176 | + | from_status TEXT, | |
| 177 | + | to_status TEXT, | |
| 178 | + | error TEXT, | |
| 179 | + | attempts INTEGER NOT NULL DEFAULT 0, | |
| 180 | + | created_at TEXT NOT NULL, | |
| 181 | + | next_retry_at TEXT NOT NULL | |
| 182 | + | ); | |
| 183 | + | CREATE INDEX IF NOT EXISTS idx_pending_alerts_due ON pending_alerts(next_retry_at, id); | |
| 184 | + | "#), | |
| 185 | + | ]; | |
| 186 | + | ||
| 187 | + | #[instrument(skip_all)] | |
| 188 | + | pub async fn connect(path: &Path) -> Result<SqlitePool> { | |
| 189 | + | let opts = SqliteConnectOptions::from_str(&format!("sqlite:{}", path.display()))? | |
| 190 | + | .create_if_missing(true) | |
| 191 | + | .foreign_keys(true) | |
| 192 | + | .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal); | |
| 193 | + | ||
| 194 | + | let pool = SqlitePoolOptions::new() | |
| 195 | + | .max_connections(5) | |
| 196 | + | .connect_with(opts) | |
| 197 | + | .await?; | |
| 198 | + | ||
| 199 | + | run_migrations(&pool).await?; | |
| 200 | + | Ok(pool) | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | #[instrument(skip_all)] | |
| 204 | + | pub async fn connect_in_memory() -> Result<SqlitePool> { | |
| 205 | + | let opts = SqliteConnectOptions::from_str("sqlite::memory:")?.foreign_keys(true); | |
| 206 | + | let pool = SqlitePoolOptions::new() | |
| 207 | + | .max_connections(1) | |
| 208 | + | .connect_with(opts) | |
| 209 | + | .await?; | |
| 210 | + | ||
| 211 | + | run_migrations(&pool).await?; | |
| 212 | + | Ok(pool) | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | /// Run pending schema migrations. Detects pre-migration databases by checking | |
| 216 | + | /// for existing tables and stamps them as version 1 without re-running. | |
| 217 | + | #[instrument(skip_all)] | |
| 218 | + | pub async fn run_migrations(pool: &SqlitePool) -> Result<()> { | |
| 219 | + | // Ensure the schema_version table exists | |
| 220 | + | sqlx::query( | |
| 221 | + | "CREATE TABLE IF NOT EXISTS schema_version ( | |
| 222 | + | version INTEGER NOT NULL, | |
| 223 | + | description TEXT NOT NULL, | |
| 224 | + | applied_at TEXT NOT NULL | |
| 225 | + | )", | |
| 226 | + | ) | |
| 227 | + | .execute(pool) | |
| 228 | + | .await?; | |
| 229 | + | ||
| 230 | + | let current_version = get_schema_version(pool).await?; | |
| 231 | + | ||
| 232 | + | // Detect pre-migration databases: if schema_version is empty but tables exist, | |
| 233 | + | // this is an existing database that predates the migration system. | |
| 234 | + | if current_version == 0 && has_existing_tables(pool).await? { | |
| 235 | + | info!("detected pre-migration database, stamping as version 1"); | |
| 236 | + | stamp_version(pool, 1, "initial schema (pre-existing)").await?; | |
| 237 | + | // Run remaining migrations (2+) if any | |
| 238 | + | for &(version, description, sql) in MIGRATIONS { | |
| 239 | + | if version > 1 { | |
| 240 | + | run_one_migration(pool, version, description, sql).await?; | |
| 241 | + | } | |
| 242 | + | } | |
| 243 | + | return Ok(()); | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | // Run all migrations newer than current version | |
| 247 | + | for &(version, description, sql) in MIGRATIONS { | |
| 248 | + | if version > current_version { | |
| 249 | + | run_one_migration(pool, version, description, sql).await?; | |
| 250 | + | } | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | Ok(()) | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | /// Get the current schema version (0 if no migrations have been applied). | |
| 257 | + | #[instrument(skip_all)] | |
| 258 | + | pub async fn get_schema_version(pool: &SqlitePool) -> Result<i64> { | |
| 259 | + | let row = sqlx::query_as::<_, (i64,)>( | |
| 260 | + | "SELECT COALESCE(MAX(version), 0) FROM schema_version", | |
| 261 | + | ) | |
| 262 | + | .fetch_one(pool) | |
| 263 | + | .await?; | |
| 264 | + | Ok(row.0) | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | /// Check whether the database has existing tables from before the migration system. | |
| 268 | + | async fn has_existing_tables(pool: &SqlitePool) -> Result<bool> { | |
| 269 | + | let row = sqlx::query_as::<_, (i64,)>( | |
| 270 | + | "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'health_checks'", | |
| 271 | + | ) | |
| 272 | + | .fetch_one(pool) | |
| 273 | + | .await?; | |
| 274 | + | Ok(row.0 > 0) | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | /// Execute a single migration's SQL and record it in schema_version. | |
| 278 | + | /// Wrapped in an explicit transaction so partial failures roll back cleanly. | |
| 279 | + | async fn run_one_migration( | |
| 280 | + | pool: &SqlitePool, | |
| 281 | + | version: i64, | |
| 282 | + | description: &str, | |
| 283 | + | sql: &str, | |
| 284 | + | ) -> Result<()> { | |
| 285 | + | info!(version, description, "running migration"); | |
| 286 | + | ||
| 287 | + | let mut tx = pool.begin().await?; | |
| 288 | + | ||
| 289 | + | // Execute the whole migration as a batch via `raw_sql`, which hands the | |
| 290 | + | // string to SQLite's own parser to split on statement boundaries. A naive | |
| 291 | + | // `split(';')` breaks on the first `;` inside a trigger body or string | |
| 292 | + | // literal (fuzz-2026-07-06 migration split trap); today's migrations have | |
| 293 | + | // none, but this makes the first such migration safe rather than a startup | |
| 294 | + | // failure. | |
| 295 | + | sqlx::raw_sql(sql).execute(&mut *tx).await?; | |
| 296 | + | ||
| 297 | + | // Record the version inside the same transaction | |
| 298 | + | let now = chrono::Utc::now().to_rfc3339(); | |
| 299 | + | sqlx::query( | |
| 300 | + | "INSERT INTO schema_version (version, description, applied_at) VALUES (?, ?, ?)", | |
| 301 | + | ) | |
| 302 | + | .bind(version) | |
| 303 | + | .bind(description) | |
| 304 | + | .bind(&now) | |
| 305 | + | .execute(&mut *tx) | |
| 306 | + | .await?; | |
| 307 | + | ||
| 308 | + | tx.commit().await?; | |
| 309 | + | Ok(()) | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | /// Record a version in the schema_version table. | |
| 313 | + | async fn stamp_version(pool: &SqlitePool, version: i64, description: &str) -> Result<()> { | |
| 314 | + | let now = chrono::Utc::now().to_rfc3339(); | |
| 315 | + | sqlx::query( | |
| 316 | + | "INSERT INTO schema_version (version, description, applied_at) VALUES (?, ?, ?)", | |
| 317 | + | ) | |
| 318 | + | .bind(version) | |
| 319 | + | .bind(description) | |
| 320 | + | .bind(&now) | |
| 321 | + | .execute(pool) | |
| 322 | + | .await?; | |
| 323 | + | Ok(()) | |
| 324 | + | } |
| @@ -0,0 +1,41 @@ | |||
| 1 | + | //! SQLite persistence — schema, health checks, test runs, and peer data. | |
| 2 | + | //! | |
| 3 | + | //! Uses a migration versioning system: each schema change is a numbered migration | |
| 4 | + | //! stored in [`MIGRATIONS`]. On startup, [`run_migrations`] checks the current | |
| 5 | + | //! version and runs any pending migrations. Existing databases (pre-migration) | |
| 6 | + | //! are detected by the presence of the `health_checks` table and marked as v1. | |
| 7 | + | ||
| 8 | + | use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions}; | |
| 9 | + | use std::path::Path; | |
| 10 | + | use std::str::FromStr; | |
| 11 | + | ||
| 12 | + | use crate::error::Result; | |
| 13 | + | use crate::types::{BackupCheckResult, CorsCheckResult, DnsCheckResult, HealthDetails, HealthSnapshot, HealthStatus, TestDetail, TestRun, TestRunId, TestSummary, TlsStatus, WhoisResult}; | |
| 14 | + | ||
| 15 | + | mod migrations; | |
| 16 | + | mod health; | |
| 17 | + | mod test_runs; | |
| 18 | + | mod alerts; | |
| 19 | + | mod tls; | |
| 20 | + | mod incidents; | |
| 21 | + | mod routes; | |
| 22 | + | mod dns; | |
| 23 | + | mod whois; | |
| 24 | + | mod cors; | |
| 25 | + | mod backup; | |
| 26 | + | mod maintenance; | |
| 27 | + | mod peers; | |
| 28 | + | ||
| 29 | + | pub use migrations::*; | |
| 30 | + | pub use health::*; | |
| 31 | + | pub use test_runs::*; | |
| 32 | + | pub use alerts::*; | |
| 33 | + | pub use tls::*; | |
| 34 | + | pub use incidents::*; | |
| 35 | + | pub use routes::*; | |
| 36 | + | pub use dns::*; | |
| 37 | + | pub use whois::*; | |
| 38 | + | pub use cors::*; | |
| 39 | + | pub use backup::*; | |
| 40 | + | pub use maintenance::*; | |
| 41 | + | pub use peers::*; |
| @@ -0,0 +1,103 @@ | |||
| 1 | + | use super::*; | |
| 2 | + | use tracing::instrument; | |
| 3 | + | ||
| 4 | + | /// Store a peer's identity (first-seen UUID). INSERT OR IGNORE — first wins. | |
| 5 | + | #[instrument(skip_all)] | |
| 6 | + | pub async fn store_peer_identity( | |
| 7 | + | pool: &SqlitePool, | |
| 8 | + | peer_name: &str, | |
| 9 | + | instance_id: &str, | |
| 10 | + | ) -> Result<()> { | |
| 11 | + | let now = chrono::Utc::now().to_rfc3339(); | |
| 12 | + | sqlx::query( | |
| 13 | + | "INSERT OR IGNORE INTO peer_identities (peer_name, instance_id, first_seen) | |
| 14 | + | VALUES (?, ?, ?)", | |
| 15 | + | ) | |
| 16 | + | .bind(peer_name) | |
| 17 | + | .bind(instance_id) | |
| 18 | + | .bind(&now) | |
| 19 | + | .execute(pool) | |
| 20 | + | .await?; | |
| 21 | + | Ok(()) | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | /// Update a peer's identity (UUID changed, e.g. after reinstall). | |
| 25 | + | #[instrument(skip_all)] | |
| 26 | + | pub async fn update_peer_identity( | |
| 27 | + | pool: &SqlitePool, | |
| 28 | + | peer_name: &str, | |
| 29 | + | instance_id: &str, | |
| 30 | + | ) -> Result<()> { | |
| 31 | + | let now = chrono::Utc::now().to_rfc3339(); | |
| 32 | + | sqlx::query( | |
| 33 | + | "UPDATE peer_identities SET instance_id = ?, first_seen = ? | |
| 34 | + | WHERE peer_name = ?", | |
| 35 | + | ) | |
| 36 | + | .bind(instance_id) | |
| 37 | + | .bind(&now) | |
| 38 | + | .bind(peer_name) | |
| 39 | + | .execute(pool) | |
| 40 | + | .await?; | |
| 41 | + | Ok(()) | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | /// Get a peer's first-seen instance ID. | |
| 45 | + | #[instrument(skip_all)] | |
| 46 | + | pub async fn get_peer_identity( | |
| 47 | + | pool: &SqlitePool, | |
| 48 | + | peer_name: &str, | |
| 49 | + | ) -> Result<Option<String>> { | |
| 50 | + | let row = sqlx::query_as::<_, (String,)>( | |
| 51 | + | "SELECT instance_id FROM peer_identities WHERE peer_name = ?", | |
| 52 | + | ) | |
| 53 | + | .bind(peer_name) | |
| 54 | + | .fetch_optional(pool) | |
| 55 | + | .await?; | |
| 56 | + | Ok(row.map(|r| r.0)) | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | #[instrument(skip_all)] | |
| 60 | + | pub async fn insert_peer_heartbeat( | |
| 61 | + | pool: &SqlitePool, | |
| 62 | + | peer_name: &str, | |
| 63 | + | status: &str, | |
| 64 | + | latency_ms: i64, | |
| 65 | + | ) -> Result<i64> { | |
| 66 | + | let now = chrono::Utc::now().to_rfc3339(); | |
| 67 | + | let result = sqlx::query( | |
| 68 | + | "INSERT INTO peer_heartbeats (peer_name, status, latency_ms, checked_at) | |
| 69 | + | VALUES (?, ?, ?, ?)", | |
| 70 | + | ) | |
| 71 | + | .bind(peer_name) | |
| 72 | + | .bind(status) | |
| 73 | + | .bind(latency_ms) | |
| 74 | + | .bind(&now) | |
| 75 | + | .execute(pool) | |
| 76 | + | .await?; | |
| 77 | + | Ok(result.last_insert_rowid()) | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | #[instrument(skip_all)] | |
| 81 | + | pub async fn get_peer_heartbeat_history( | |
| 82 | + | pool: &SqlitePool, | |
| 83 | + | peer_name: &str, | |
| 84 | + | limit: i64, | |
| 85 | + | ) -> Result<Vec<PeerHeartbeatRow>> { | |
| 86 | + | Ok(sqlx::query_as::<_, PeerHeartbeatRow>( | |
| 87 | + | "SELECT id, peer_name, status, latency_ms, checked_at | |
| 88 | + | FROM peer_heartbeats WHERE peer_name = ? ORDER BY id DESC LIMIT ?", | |
| 89 | + | ) | |
| 90 | + | .bind(peer_name) | |
| 91 | + | .bind(limit) | |
| 92 | + | .fetch_all(pool) | |
| 93 | + | .await?) | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | #[derive(Debug, sqlx::FromRow, serde::Serialize)] | |
| 97 | + | pub struct PeerHeartbeatRow { | |
| 98 | + | pub id: i64, | |
| 99 | + | pub peer_name: String, | |
| 100 | + | pub status: String, | |
| 101 | + | pub latency_ms: i64, | |
| 102 | + | pub checked_at: String, | |
| 103 | + | } |
| @@ -0,0 +1,83 @@ | |||
| 1 | + | use super::*; | |
| 2 | + | use tracing::instrument; | |
| 3 | + | ||
| 4 | + | #[derive(Debug, Clone, sqlx::FromRow, serde::Serialize)] | |
| 5 | + | pub struct RouteCheckRow { | |
| 6 | + | pub id: i64, | |
| 7 | + | pub target: String, | |
| 8 | + | pub path: String, | |
| 9 | + | pub status_code: i64, | |
| 10 | + | pub ok: bool, | |
| 11 | + | pub response_time_ms: i64, | |
| 12 | + | pub checked_at: String, | |
| 13 | + | pub error: Option<String>, | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | #[instrument(skip_all)] | |
| 17 | + | pub async fn insert_route_check( | |
| 18 | + | pool: &SqlitePool, | |
| 19 | + | result: &crate::checks::routes::RouteCheckResult, | |
| 20 | + | ) -> Result<i64> { | |
| 21 | + | let row = sqlx::query( | |
| 22 | + | "INSERT INTO route_checks (target, path, status_code, ok, response_time_ms, checked_at, error) | |
| 23 | + | VALUES (?, ?, ?, ?, ?, ?, ?)", | |
| 24 | + | ) | |
| 25 | + | .bind(&result.target) | |
| 26 | + | .bind(&result.path) | |
| 27 | + | .bind(result.status_code as i64) | |
| 28 | + | .bind(result.ok) | |
| 29 | + | .bind(result.response_time_ms) | |
| 30 | + | .bind(&result.checked_at) | |
| 31 | + | .bind(&result.error) | |
| 32 | + | .execute(pool) | |
| 33 | + | .await?; | |
| 34 | + | Ok(row.last_insert_rowid()) | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | /// Get the latest route check per path for a target. | |
| 38 | + | #[instrument(skip_all)] | |
| 39 | + | pub async fn get_latest_route_checks( | |
| 40 | + | pool: &SqlitePool, | |
| 41 | + | target: &str, | |
| 42 | + | ) -> Result<Vec<RouteCheckRow>> { | |
| 43 | + | Ok(sqlx::query_as::<_, RouteCheckRow>( | |
| 44 | + | "SELECT r.id, r.target, r.path, r.status_code, r.ok, r.response_time_ms, r.checked_at, r.error | |
| 45 | + | FROM route_checks r | |
| 46 | + | INNER JOIN (SELECT path, MAX(id) as max_id FROM route_checks WHERE target = ? GROUP BY path) latest | |
| 47 | + | ON r.id = latest.max_id | |
| 48 | + | ORDER BY r.path", | |
| 49 | + | ) | |
| 50 | + | .bind(target) | |
| 51 | + | .fetch_all(pool) | |
| 52 | + | .await?) | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | /// Delete route_checks for paths no longer in the config. | |
| 56 | + | #[instrument(skip_all)] | |
| 57 | + | pub async fn prune_stale_routes( | |
| 58 | + | pool: &SqlitePool, | |
| 59 | + | target: &str, | |
| 60 | + | expected_routes: &[String], | |
| 61 | + | ) -> Result<u64> { | |
| 62 | + | if expected_routes.is_empty() { | |
| 63 | + | // No configured routes — delete all route checks for this target | |
| 64 | + | let result = sqlx::query("DELETE FROM route_checks WHERE target = ?") | |
| 65 | + | .bind(target) | |
| 66 | + | .execute(pool) | |
| 67 | + | .await?; | |
| 68 | + | return Ok(result.rows_affected()); | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // Build placeholders for the IN clause | |
| 72 | + | let placeholders: Vec<&str> = expected_routes.iter().map(|_| "?").collect(); | |
| 73 | + | let sql = format!( | |
| 74 | + | "DELETE FROM route_checks WHERE target = ? AND path NOT IN ({})", | |
| 75 | + | placeholders.join(", ") | |
| 76 | + | ); | |
| 77 | + | let mut query = sqlx::query(&sql).bind(target); | |
| 78 | + | for route in expected_routes { | |
| 79 | + | query = query.bind(route); | |
| 80 | + | } | |
| 81 | + | let result = query.execute(pool).await?; | |
| 82 | + | Ok(result.rows_affected()) | |
| 83 | + | } |
| @@ -0,0 +1,218 @@ | |||
| 1 | + | use super::*; | |
| 2 | + | use tracing::instrument; | |
| 3 | + | ||
| 4 | + | #[instrument(skip_all)] | |
| 5 | + | pub async fn insert_test_run( | |
| 6 | + | pool: &SqlitePool, | |
| 7 | + | run: &TestRun, | |
| 8 | + | ) -> Result<TestRunId> { | |
| 9 | + | let summary_json = serde_json::to_string(&run.summary).unwrap_or_default(); | |
| 10 | + | ||
| 11 | + | let result = sqlx::query( | |
| 12 | + | "INSERT INTO test_runs (target, started_at, finished_at, duration_secs, exit_code, passed, summary_json, raw_output, filter) | |
| 13 | + | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 14 | + | ) | |
| 15 | + | .bind(&run.target) | |
| 16 | + | .bind(&run.started_at) | |
| 17 | + | .bind(&run.finished_at) | |
| 18 | + | .bind(run.duration_secs) | |
| 19 | + | .bind(run.exit_code) | |
| 20 | + | .bind(run.passed) | |
| 21 | + | .bind(&summary_json) | |
| 22 | + | .bind(&run.raw_output) | |
| 23 | + | .bind(&run.filter) | |
| 24 | + | .execute(pool) | |
| 25 | + | .await?; | |
| 26 | + | ||
| 27 | + | Ok(TestRunId(result.last_insert_rowid())) | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | #[instrument(skip_all)] | |
| 31 | + | pub async fn get_test_history( | |
| 32 | + | pool: &SqlitePool, | |
| 33 | + | target: Option<&str>, | |
| 34 | + | limit: i64, | |
| 35 | + | ) -> Result<Vec<TestRun>> { | |
| 36 | + | let rows = match target { | |
| 37 | + | Some(t) => { | |
| 38 | + | sqlx::query_as::<_, TestRunRow>( | |
| 39 | + | "SELECT id, target, started_at, finished_at, duration_secs, exit_code, passed, summary_json, raw_output, filter | |
| 40 | + | FROM test_runs WHERE target = ? ORDER BY id DESC LIMIT ?", | |
| 41 | + | ) | |
| 42 | + | .bind(t) | |
| 43 | + | .bind(limit) | |
| 44 | + | .fetch_all(pool) | |
| 45 | + | .await? | |
| 46 | + | } | |
| 47 | + | None => { | |
| 48 | + | sqlx::query_as::<_, TestRunRow>( | |
| 49 | + | "SELECT id, target, started_at, finished_at, duration_secs, exit_code, passed, summary_json, raw_output, filter | |
| 50 | + | FROM test_runs ORDER BY id DESC LIMIT ?", | |
| 51 | + | ) | |
| 52 | + | .bind(limit) | |
| 53 | + | .fetch_all(pool) | |
| 54 | + | .await? | |
| 55 | + | } | |
| 56 | + | }; | |
| 57 | + | ||
| 58 | + | Ok(rows.into_iter().map(|r| r.into_test_run()).collect()) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | #[instrument(skip_all)] | |
| 62 | + | pub async fn get_latest_test_run( | |
| 63 | + | pool: &SqlitePool, | |
| 64 | + | target: &str, | |
| 65 | + | ) -> Result<Option<TestRun>> { | |
| 66 | + | let row = sqlx::query_as::<_, TestRunRow>( | |
| 67 | + | "SELECT id, target, started_at, finished_at, duration_secs, exit_code, passed, summary_json, raw_output, filter | |
| 68 | + | FROM test_runs WHERE target = ? ORDER BY id DESC LIMIT 1", | |
| 69 | + | ) | |
| 70 | + | .bind(target) | |
| 71 | + | .fetch_optional(pool) | |
| 72 | + | .await?; | |
| 73 | + | ||
| 74 | + | Ok(row.map(|r| r.into_test_run())) | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | /// Insert per-test results for a given test run. | |
| 78 | + | #[instrument(skip_all)] | |
| 79 | + | pub async fn insert_test_details( | |
| 80 | + | pool: &SqlitePool, | |
| 81 | + | run_id: TestRunId, | |
| 82 | + | details: &[TestDetail], | |
| 83 | + | ) -> Result<()> { | |
| 84 | + | // One transaction for the whole detail set: a crash mid-loop otherwise left a | |
| 85 | + | // partial set, which skews get_test_regressions (fuzz-2026-07-06 crash | |
| 86 | + | // atomicity). All-or-nothing. | |
| 87 | + | let mut tx = pool.begin().await?; | |
| 88 | + | for detail in details { | |
| 89 | + | sqlx::query( | |
| 90 | + | "INSERT INTO test_details (run_id, test_name, passed) VALUES (?, ?, ?)", | |
| 91 | + | ) | |
| 92 | + | .bind(run_id.0) | |
| 93 | + | .bind(&detail.test_name) | |
| 94 | + | .bind(detail.passed) | |
| 95 | + | .execute(&mut *tx) | |
| 96 | + | .await?; | |
| 97 | + | } | |
| 98 | + | tx.commit().await?; | |
| 99 | + | Ok(()) | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | /// Find tests that passed in the previous run but failed in this one (regressions). | |
| 103 | + | #[instrument(skip_all)] | |
| 104 | + | pub async fn get_test_regressions( | |
| 105 | + | pool: &SqlitePool, | |
| 106 | + | target: &str, | |
| 107 | + | current_run_id: TestRunId, | |
| 108 | + | ) -> Result<Vec<String>> { | |
| 109 | + | // Find the run immediately before this one for the same target | |
| 110 | + | let prev_run = sqlx::query_as::<_, (i64,)>( | |
| 111 | + | "SELECT id FROM test_runs | |
| 112 | + | WHERE target = ? AND id < ? | |
| 113 | + | ORDER BY id DESC LIMIT 1", | |
| 114 | + | ) | |
| 115 | + | .bind(target) | |
| 116 | + | .bind(current_run_id.0) | |
| 117 | + | .fetch_optional(pool) | |
| 118 | + | .await?; | |
| 119 | + | ||
| 120 | + | let Some((prev_id,)) = prev_run else { | |
| 121 | + | return Ok(vec![]); | |
| 122 | + | }; | |
| 123 | + | ||
| 124 | + | // Tests that passed in prev run but failed in current run | |
| 125 | + | let rows = sqlx::query_as::<_, (String,)>( | |
| 126 | + | "SELECT curr.test_name FROM test_details curr | |
| 127 | + | INNER JOIN test_details prev ON prev.test_name = curr.test_name AND prev.run_id = ? | |
| 128 | + | WHERE curr.run_id = ? AND curr.passed = 0 AND prev.passed = 1", | |
| 129 | + | ) | |
| 130 | + | .bind(prev_id) | |
| 131 | + | .bind(current_run_id.0) | |
| 132 | + | .fetch_all(pool) | |
| 133 | + | .await?; | |
| 134 | + | ||
| 135 | + | Ok(rows.into_iter().map(|r| r.0).collect()) | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | /// Get test duration history for a target (most recent first). | |
| 139 | + | #[instrument(skip_all)] | |
| 140 | + | pub async fn get_test_durations( | |
| 141 | + | pool: &SqlitePool, | |
| 142 | + | target: &str, | |
| 143 | + | limit: i64, | |
| 144 | + | ) -> Result<Vec<(String, i64)>> { | |
| 145 | + | let rows = sqlx::query_as::<_, (String, i64)>( | |
| 146 | + | "SELECT started_at, duration_secs FROM test_runs | |
| 147 | + | WHERE target = ? AND duration_secs IS NOT NULL | |
| 148 | + | ORDER BY id DESC LIMIT ?", | |
| 149 | + | ) | |
| 150 | + | .bind(target) | |
| 151 | + | .bind(limit) | |
| 152 | + | .fetch_all(pool) | |
| 153 | + | .await?; | |
| 154 | + | Ok(rows) | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | /// Get the version from the health check closest to (but before) a given timestamp. | |
| 158 | + | #[instrument(skip_all)] | |
| 159 | + | pub async fn get_version_at_time( | |
| 160 | + | pool: &SqlitePool, | |
| 161 | + | target: &str, | |
| 162 | + | before_rfc3339: &str, | |
| 163 | + | ) -> Result<Option<String>> { | |
| 164 | + | let row = sqlx::query_as::<_, (Option<String>,)>( | |
| 165 | + | "SELECT details_json FROM health_checks | |
| 166 | + | WHERE target = ? AND checked_at <= ? | |
| 167 | + | ORDER BY checked_at DESC LIMIT 1", | |
| 168 | + | ) | |
| 169 | + | .bind(target) | |
| 170 | + | .bind(before_rfc3339) | |
| 171 | + | .fetch_optional(pool) | |
| 172 | + | .await?; | |
| 173 | + | ||
| 174 | + | let version = row | |
| 175 | + | .and_then(|r| r.0) | |
| 176 | + | .and_then(|json_str| serde_json::from_str::<serde_json::Value>(&json_str).ok()) | |
| 177 | + | .and_then(|json| json.get("version").and_then(|v| v.as_str()).map(String::from)); | |
| 178 | + | ||
| 179 | + | Ok(version) | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | #[derive(sqlx::FromRow)] | |
| 183 | + | struct TestRunRow { | |
| 184 | + | id: i64, | |
| 185 | + | target: String, | |
| 186 | + | started_at: String, | |
| 187 | + | finished_at: Option<String>, | |
| 188 | + | duration_secs: Option<i64>, | |
| 189 | + | exit_code: Option<i32>, | |
| 190 | + | passed: bool, | |
| 191 | + | summary_json: String, | |
| 192 | + | raw_output: String, | |
| 193 | + | filter: Option<String>, | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | impl TestRunRow { | |
| 197 | + | fn into_test_run(self) -> TestRun { | |
| 198 | + | let summary = serde_json::from_str::<TestSummary>(&self.summary_json).unwrap_or(TestSummary { | |
| 199 | + | steps: vec![], | |
| 200 | + | total_passed: None, | |
| 201 | + | total_failed: None, | |
| 202 | + | details: vec![], | |
| 203 | + | }); | |
| 204 | + | ||
| 205 | + | TestRun { | |
| 206 | + | id: Some(self.id), | |
| 207 | + | target: self.target, | |
| 208 | + | started_at: self.started_at, | |
| 209 | + | finished_at: self.finished_at, | |
| 210 | + | duration_secs: self.duration_secs, | |
| 211 | + | exit_code: self.exit_code, | |
| 212 | + | passed: self.passed, | |
| 213 | + | summary, | |
| 214 | + | raw_output: self.raw_output, | |
| 215 | + | filter: self.filter, | |
| 216 | + | } | |
| 217 | + | } | |
| 218 | + | } |
| @@ -0,0 +1,56 @@ | |||
| 1 | + | use super::*; | |
| 2 | + | use tracing::instrument; | |
| 3 | + | ||
| 4 | + | #[derive(Debug, sqlx::FromRow, serde::Serialize)] | |
| 5 | + | pub struct TlsCheckRow { | |
| 6 | + | pub id: i64, | |
| 7 | + | pub target: String, | |
| 8 | + | pub host: String, | |
| 9 | + | pub valid: bool, | |
| 10 | + | pub days_remaining: i64, | |
| 11 | + | pub not_before: String, | |
| 12 | + | pub not_after: String, | |
| 13 | + | pub subject: String, | |
| 14 | + | pub issuer: String, | |
| 15 | + | pub checked_at: String, | |
| 16 | + | pub error: Option<String>, | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | #[instrument(skip_all)] | |
| 20 | + | pub async fn insert_tls_check( | |
| 21 | + | pool: &SqlitePool, | |
| 22 | + | status: &TlsStatus, | |
| 23 | + | ) -> Result<i64> { | |
| 24 | + | let result = sqlx::query( | |
| 25 | + | "INSERT INTO tls_checks (target, host, valid, days_remaining, not_before, not_after, subject, issuer, checked_at, error) | |
| 26 | + | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 27 | + | ) | |
| 28 | + | .bind(&status.target) | |
| 29 | + | .bind(&status.host) | |
| 30 | + | .bind(status.valid) | |
| 31 | + | .bind(status.days_remaining) | |
| 32 | + | .bind(&status.not_before) | |
| 33 | + | .bind(&status.not_after) | |
| 34 | + | .bind(&status.subject) | |
| 35 | + | .bind(&status.issuer) | |
| 36 | + | .bind(&status.checked_at) | |
| 37 | + | .bind(&status.error) | |
| 38 | + | .execute(pool) | |
| 39 | + | .await?; | |
| 40 | + | ||
| 41 | + | Ok(result.last_insert_rowid()) | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | #[instrument(skip_all)] | |
| 45 | + | pub async fn get_latest_tls_check( | |
| 46 | + | pool: &SqlitePool, | |
| 47 | + | target: &str, | |
| 48 | + | ) -> Result<Option<TlsCheckRow>> { | |
| 49 | + | Ok(sqlx::query_as::<_, TlsCheckRow>( | |
| 50 | + | "SELECT id, target, host, valid, days_remaining, not_before, not_after, subject, issuer, checked_at, error | |
| 51 | + | FROM tls_checks WHERE target = ? ORDER BY id DESC LIMIT 1", | |
| 52 | + | ) | |
| 53 | + | .bind(target) | |
| 54 | + | .fetch_optional(pool) | |
| 55 | + | .await?) | |
| 56 | + | } |
| @@ -0,0 +1,53 @@ | |||
| 1 | + | use super::*; | |
| 2 | + | use tracing::instrument; | |
| 3 | + | ||
| 4 | + | #[derive(Debug, Clone, sqlx::FromRow, serde::Serialize)] | |
| 5 | + | pub struct WhoisCheckRow { | |
| 6 | + | pub id: i64, | |
| 7 | + | pub target: String, | |
| 8 | + | pub domain: String, | |
| 9 | + | pub registrar: Option<String>, | |
| 10 | + | pub expiry_date: Option<String>, | |
| 11 | + | pub days_remaining: Option<i64>, | |
| 12 | + | pub nameservers: Option<String>, | |
| 13 | + | pub checked_at: String, | |
| 14 | + | pub error: Option<String>, | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | #[instrument(skip_all)] | |
| 18 | + | pub async fn insert_whois_check( | |
| 19 | + | pool: &SqlitePool, | |
| 20 | + | result: &WhoisResult, | |
| 21 | + | ) -> Result<i64> { | |
| 22 | + | let nameservers = serde_json::to_string(&result.nameservers).unwrap_or_default(); | |
| 23 | + | ||
| 24 | + | let row = sqlx::query( | |
| 25 | + | "INSERT INTO whois_checks (target, domain, registrar, expiry_date, days_remaining, nameservers, checked_at, error) | |
| 26 | + | VALUES (?, ?, ?, ?, ?, ?, ?, ?)", | |
| 27 | + | ) | |
| 28 | + | .bind(&result.target) | |
| 29 | + | .bind(&result.domain) | |
| 30 | + | .bind(&result.registrar) | |
| 31 | + | .bind(&result.expiry_date) | |
| 32 | + | .bind(result.days_remaining) | |
| 33 | + | .bind(&nameservers) | |
| 34 | + | .bind(&result.checked_at) | |
| 35 | + | .bind(&result.error) | |
| 36 | + | .execute(pool) | |
| 37 | + | .await?; | |
| 38 | + | Ok(row.last_insert_rowid()) | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | #[instrument(skip_all)] | |
| 42 | + | pub async fn get_latest_whois_check( | |
| 43 | + | pool: &SqlitePool, | |
| 44 | + | target: &str, | |
| 45 | + | ) -> Result<Option<WhoisCheckRow>> { | |
| 46 | + | Ok(sqlx::query_as::<_, WhoisCheckRow>( | |
| 47 | + | "SELECT id, target, domain, registrar, expiry_date, days_remaining, nameservers, checked_at, error | |
| 48 | + | FROM whois_checks WHERE target = ? ORDER BY id DESC LIMIT 1", | |
| 49 | + | ) | |
| 50 | + | .bind(target) | |
| 51 | + | .fetch_optional(pool) | |
| 52 | + | .await?) | |
| 53 | + | } |