From d174d86d2d2106ce2663e21a25341edb8df7ed87 Mon Sep 17 00:00:00 2001 From: Charles Gauthereau Date: Fri, 9 Oct 2026 16:39:40 +0200 Subject: [PATCH] feat(mssql): back up as dacpac with a backup_format option --- src/domain/mssql/backup.rs | 20 +++++- src/domain/mssql/database.rs | 3 +- src/domain/mssql/format.rs | 33 +++++++++ src/domain/mssql/mod.rs | 1 + src/tests/domain/mssql.rs | 131 ++++++++++++++++++++++++++++++++++- 5 files changed, 182 insertions(+), 6 deletions(-) create mode 100644 src/domain/mssql/format.rs diff --git a/src/domain/mssql/backup.rs b/src/domain/mssql/backup.rs index be564e1..0c6c90f 100644 --- a/src/domain/mssql/backup.rs +++ b/src/domain/mssql/backup.rs @@ -1,3 +1,4 @@ +use super::format::MssqlFormat; use crate::services::backup::logger::JobLogger; use crate::services::config::DatabaseConfig; use anyhow::{Context, Result}; @@ -15,6 +16,11 @@ pub async fn run( tokio::task::spawn_blocking(move || -> Result { logger.log("info", format!("Starting MSSQL backup for database {}", cfg.name)); + let (format, unknown_format) = MssqlFormat::from_config(&cfg); + if let Some(v) = unknown_format { + logger.log("warn", format!("Unknown backup_format '{}' for {}, falling back to 'dacpac'", v, cfg.name)); + } + let file_path = backup_dir.join(format!("{}{}", cfg.generated_id, file_extension)); let connection_string = format!( "Server=tcp:{},{};Database={};User Id={};Password={};TrustServerCertificate=True;Encrypt=False", @@ -24,8 +30,18 @@ pub async fn run( logger.log("info", format!("MSSQL backup: {}:{}/{} → {}", cfg.host, cfg.port, cfg.database, file_path.display())); let start = Instant::now(); - let output = Command::new("sqlpackage") - .arg("/a:Export") + let mut command = Command::new("sqlpackage"); + match format { + MssqlFormat::Dacpac => command.args([ + "/a:Extract", + "/p:ExtractAllTableData=True", + "/p:ExtractReferencedServerScopedElements=False", + "/p:IgnorePermissions=False", + "/p:IgnoreUserLoginMappings=False", + ]), + MssqlFormat::Bacpac => command.arg("/a:Export"), + }; + let output = command .arg(format!("/scs:{}", connection_string)) .arg(format!("/tf:{}", file_path.display())) .output() diff --git a/src/domain/mssql/database.rs b/src/domain/mssql/database.rs index fda0652..b15d69b 100644 --- a/src/domain/mssql/database.rs +++ b/src/domain/mssql/database.rs @@ -1,3 +1,4 @@ +use super::format::MssqlFormat; use super::{backup, ping, restore}; use crate::domain::factory::Database; use crate::services::backup::logger::JobLogger; @@ -21,7 +22,7 @@ impl MssqlDatabase { #[async_trait] impl Database for MssqlDatabase { fn file_extension(&self) -> &'static str { - ".bacpac" + MssqlFormat::from_config(&self.cfg).0.extension() } async fn ping(&self) -> Result { diff --git a/src/domain/mssql/format.rs b/src/domain/mssql/format.rs new file mode 100644 index 0000000..dedb542 --- /dev/null +++ b/src/domain/mssql/format.rs @@ -0,0 +1,33 @@ +use crate::services::config::DatabaseConfig; +use std::path::Path; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MssqlFormat { + Dacpac, + Bacpac, +} + +impl MssqlFormat { + pub fn from_config(cfg: &DatabaseConfig) -> (Self, Option) { + match cfg.options.get("backup_format").and_then(|v| v.as_str()) { + None | Some("dacpac") => (Self::Dacpac, None), + Some("bacpac") => (Self::Bacpac, None), + Some(other) => (Self::Dacpac, Some(other.to_string())), + } + } + + pub fn from_path(path: &Path) -> Option { + match path.extension()?.to_str()?.to_ascii_lowercase().as_str() { + "dacpac" => Some(Self::Dacpac), + "bacpac" => Some(Self::Bacpac), + _ => None, + } + } + + pub fn extension(self) -> &'static str { + match self { + Self::Dacpac => ".dacpac", + Self::Bacpac => ".bacpac", + } + } +} diff --git a/src/domain/mssql/mod.rs b/src/domain/mssql/mod.rs index 143be20..c518aaf 100644 --- a/src/domain/mssql/mod.rs +++ b/src/domain/mssql/mod.rs @@ -1,4 +1,5 @@ pub mod database; +pub(crate) mod format; mod connection; mod ping; mod backup; diff --git a/src/tests/domain/mssql.rs b/src/tests/domain/mssql.rs index 6022834..40ced10 100644 --- a/src/tests/domain/mssql.rs +++ b/src/tests/domain/mssql.rs @@ -1,8 +1,10 @@ use crate::domain::factory::DatabaseFactory; +use crate::services::backup::logger::JobLogger; use crate::services::config::{DatabaseConfig, DbType}; use crate::tests::init_tracing_for_test; use crate::utils::compress::{compress_to_tar_gz_large, decompress_large_tar_gz}; use std::path::PathBuf; +use std::sync::Arc; use std::time::Duration; use tempfile::TempDir; use tiberius::{AuthMethod, Client, Config}; @@ -10,7 +12,7 @@ use testcontainers::runners::AsyncRunner; use testcontainers::{ContainerAsync, GenericImage, ImageExt}; use testcontainers::core::IntoContainerPort; use tokio::net::TcpStream; -use tokio_util::compat::TokioAsyncWriteCompatExt; +use tokio_util::compat::{Compat, TokioAsyncWriteCompatExt}; use tracing::{error, info}; const SA_PASSWORD: &str = "Test!Str0ng1"; @@ -61,6 +63,43 @@ fn make_config(host: String, port: u16, database: &str, generated_id: &str) -> D } } +async fn start() -> (ContainerAsync, String, u16) { + let container = start_container().await; + tokio::time::sleep(Duration::from_secs(30)).await; + let host = container.get_host().await.unwrap().to_string(); + let port = container.get_host_port_ipv4(1433).await.unwrap(); + (container, host, port) +} + +async fn connect_as(host: &str, port: u16, database: &str, user: &str, password: &str) -> Client> { + let mut config = Config::new(); + config.host(host); + config.port(port); + config.database(database); + config.authentication(AuthMethod::sql_server(user, password)); + config.trust_cert(); + + let tcp = TcpStream::connect(config.get_addr()).await.unwrap(); + tcp.set_nodelay(true).unwrap(); + Client::connect(config, tcp.compat_write()).await.unwrap() +} + +async fn exec(host: &str, port: u16, database: &str, sql: &str) { + let mut client = connect_as(host, port, database, "sa", SA_PASSWORD).await; + client.simple_query(sql).await.unwrap().into_results().await.unwrap(); +} + +async fn scalar(host: &str, port: u16, database: &str, user: &str, password: &str, sql: &str) -> i32 { + let mut client = connect_as(host, port, database, user, password).await; + let row = client.simple_query(sql).await.unwrap().into_row().await.unwrap().unwrap(); + row.get::(0).unwrap() +} + +fn with_backup_format(mut config: DatabaseConfig, format: &str) -> DatabaseConfig { + config.options.insert("backup_format".to_string(), serde_json::json!(format)); + config +} + #[tokio::test] async fn mssql_ping_test() { init_tracing_for_test(); @@ -102,8 +141,8 @@ async fn mssql_backup_test() { "backup file should be non-empty" ); assert!( - file_path.extension().and_then(|e| e.to_str()) == Some("bacpac"), - "backup file should have .bacpac extension" + file_path.extension().and_then(|e| e.to_str()) == Some("dacpac"), + "backup file should have .dacpac extension" ); } @@ -153,3 +192,89 @@ async fn mssql_backup_restore_test() { } } } + +#[tokio::test] +async fn mssql_backup_bacpac_test() { + init_tracing_for_test(); + let (_container, host, port) = start().await; + create_user_database(&host, port, "bacpacdb").await; + + let config = with_backup_format( + make_config(host, port, "bacpacdb", "5a445eb4-c2c6-4bde-a423-ee1385dcf6d6"), + "bacpac", + ); + let temp_dir = TempDir::new().unwrap(); + let file_path = DatabaseFactory::create_for_backup(config) + .await + .backup(temp_dir.path(), Arc::new(JobLogger::new())) + .await + .unwrap(); + + assert!(file_path.metadata().unwrap().len() > 0); + assert_eq!(file_path.extension().and_then(|e| e.to_str()), Some("bacpac")); +} + +#[tokio::test] +async fn mssql_backup_windows_principal_test() { + init_tracing_for_test(); + let (_container, host, port) = start().await; + create_user_database(&host, port, "windowsdb").await; + exec( + &host, + port, + "windowsdb", + "CREATE TABLE dbo.t (id INT PRIMARY KEY); INSERT dbo.t VALUES (1); \ + CREATE USER [NT AUTHORITY\\SYSTEM] FOR LOGIN [NT AUTHORITY\\SYSTEM];", + ) + .await; + + let config = make_config(host, port, "windowsdb", "5a445eb4-c2c6-4bde-a423-ee1385dcf6d7"); + let temp_dir = TempDir::new().unwrap(); + + let bacpac = DatabaseFactory::create_for_backup(with_backup_format(config.clone(), "bacpac")) + .await + .backup(temp_dir.path(), Arc::new(JobLogger::new())) + .await; + assert!(bacpac.unwrap_err().to_string().contains("SQL71627")); + + let file_path = DatabaseFactory::create_for_backup(config) + .await + .backup(temp_dir.path(), Arc::new(JobLogger::new())) + .await + .unwrap(); + assert_eq!(file_path.extension().and_then(|e| e.to_str()), Some("dacpac")); +} + +mod format_tests { + use super::{make_config, with_backup_format}; + use crate::domain::mssql::format::MssqlFormat; + use std::path::Path; + + #[test] + fn from_config_defaults_to_dacpac() { + let cfg = make_config("localhost".into(), 1433, "db", "00000000-0000-0000-0000-000000000000"); + assert_eq!(MssqlFormat::from_config(&cfg), (MssqlFormat::Dacpac, None)); + assert_eq!( + MssqlFormat::from_config(&with_backup_format(cfg.clone(), "dacpac")), + (MssqlFormat::Dacpac, None) + ); + assert_eq!( + MssqlFormat::from_config(&with_backup_format(cfg.clone(), "bacpac")), + (MssqlFormat::Bacpac, None) + ); + assert_eq!( + MssqlFormat::from_config(&with_backup_format(cfg, "zip")), + (MssqlFormat::Dacpac, Some("zip".to_string())) + ); + } + + #[test] + fn from_path_reads_extension() { + assert_eq!(MssqlFormat::from_path(Path::new("/tmp/a.dacpac")), Some(MssqlFormat::Dacpac)); + assert_eq!(MssqlFormat::from_path(Path::new("/tmp/a.BACPAC")), Some(MssqlFormat::Bacpac)); + assert_eq!(MssqlFormat::from_path(Path::new("/tmp/backup.dump")), None); + assert_eq!(MssqlFormat::from_path(Path::new("/tmp/noext")), None); + assert_eq!(MssqlFormat::Dacpac.extension(), ".dacpac"); + assert_eq!(MssqlFormat::Bacpac.extension(), ".bacpac"); + } +}