//! Fetch the prod backup that `migration_dry_run` runs against. //! //! Sources supported in v0: //! - `file:///abs/path/to/dump.sql.gz` — local copy. Used for localhost dev. //! - `rsync://host/module/path` — shells out to `rsync`. Used when MM //! pulls from an astra/Hetzner replica. //! //! The fetch is command-driven: the operator triggers it via /backup/fetch, it //! is not implicit in promote. That keeps the slowest, most failure-prone step //! visible in the TUI rather than buried inside a deploy. use crate::config::Config; use crate::topology::Topology; use anyhow::{Context, Result}; use chrono::Utc; use sqlx::SqlitePool; use std::path::Path; use std::sync::Arc; use tokio::process::Command; #[derive(Debug, Clone)] pub struct FetchedBackup { pub source: String, pub local_path: String, pub byte_size: Option, } pub async fn fetch( pool: &SqlitePool, _cfg: &Arc, topo: &Arc, ) -> Result { let source = topo.backup.source.clone(); let local_path = topo.backup.local_path.clone(); if let Some(parent) = Path::new(&local_path).parent() { tokio::fs::create_dir_all(parent).await?; } if let Some(rest) = source.strip_prefix("file://") { tokio::fs::copy(rest, &local_path) .await .with_context(|| format!("copy {rest} -> {local_path}"))?; } else if source.starts_with("rsync://") { let out = Command::new("rsync") .args(["-az", "--inplace", &source, &local_path]) .output() .await .context("spawning rsync")?; anyhow::ensure!( out.status.success(), "rsync failed: {}", String::from_utf8_lossy(&out.stderr), ); } else { anyhow::bail!("unsupported backup source scheme: {source}"); } let meta = tokio::fs::metadata(&local_path).await?; let size = meta.len() as i64; sqlx::query( "INSERT INTO backups (fetched_at, source, local_path, byte_size) VALUES (?, ?, ?, ?)", ) .bind(Utc::now().to_rfc3339()) .bind(&source) .bind(&local_path) .bind(size) .execute(pool) .await?; Ok(FetchedBackup { source, local_path, byte_size: Some(size) }) }