From bfe63d7fd355fd68ad0f1f41b472a66045799d8c Mon Sep 17 00:00:00 2001 From: Charles GTE Date: Sun, 26 Apr 2026 17:42:30 +0200 Subject: [PATCH 01/13] fix: tests and refactoring, add justfile instead of makefile --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index d161be9..260eb2c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,7 +19,7 @@ services: APP_ENV: development LOG: debug TZ: "Europe/Paris" - EDGE_KEY: "eyJzZXJ2ZXJVcmwiOiJodHRwOi8vbG9jYWxob3N0Ojg4ODciLCJhZ2VudElkIjoiNzM0NjU3Y2YtMGQzYy00Y2UwLTkyODQtZDJmOGYyMjI2MzgzIiwibWFzdGVyS2V5QjY0IjoiMUh0djdtWCtYVkJxL0IzUEV2WDlZZjlQeUdVZW5oRHlXemo5THRqNW90WT0ifQ==" + EDGE_KEY: "eyJzZXJ2ZXJVcmwiOiJodHRwOi8vbG9jYWxob3N0Ojg4ODciLCJhZ2VudElkIjoiNjIyNWFlMzMtODQwMy00NmE3LWEyNDEtMjU4MTI2MjVlYTA4IiwibWFzdGVyS2V5QjY0IjoiQlhWM1hvbEM2NTZTVjdkTmdjV1BHUWxrKytycExJNmxHRGk3Q1BCNWllbz0ifQ==" #CHUNK_SIZE_MB: "1" #POOLING: 1 #DATABASES_CONFIG_FILE: "config.toml" From 90eea7d9519b6b4276307389000e8dd5b0f17ea6 Mon Sep 17 00:00:00 2001 From: Charles GTE Date: Mon, 11 May 2026 20:16:09 +0200 Subject: [PATCH 02/13] chore: add tiberius dependency for MSSQL support --- Cargo.lock | 129 ++++++++++++++++++++++++++++++++++++++++++++++++++--- Cargo.toml | 3 +- 2 files changed, 124 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7e1e082..640446c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -169,6 +169,19 @@ dependencies = [ "syn", ] +[[package]] +name = "asynchronous-codec" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057f2c32adbb2fc158e22fb38433c8e9bbf76b75a4732c7c0cbaf695fb65568" +dependencies = [ + "bytes", + "futures-sink", + "futures-util", + "memchr", + "pin-project-lite", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -496,7 +509,7 @@ dependencies = [ "pin-project-lite", "rustls 0.21.12", "rustls 0.23.37", - "rustls-native-certs", + "rustls-native-certs 0.8.3", "rustls-pki-types", "tokio", "tokio-rustls 0.26.4", @@ -770,7 +783,7 @@ dependencies = [ "pin-project-lite", "rand 0.9.2", "rustls 0.23.37", - "rustls-native-certs", + "rustls-native-certs 0.8.3", "rustls-pki-types", "serde", "serde_derive", @@ -968,6 +981,12 @@ version = "0.4.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" +[[package]] +name = "connection-string" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "510ca239cf13b7f8d16a2b48f263de7b4f8c566f0af58d901031473c76afb1e3" + [[package]] name = "const-oid" version = "0.9.6" @@ -1436,6 +1455,26 @@ dependencies = [ "syn", ] +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -2050,7 +2089,7 @@ dependencies = [ "hyper 1.8.1", "hyper-util", "rustls 0.23.37", - "rustls-native-certs", + "rustls-native-certs 0.8.3", "rustls-pki-types", "tokio", "tokio-rustls 0.26.4", @@ -2854,6 +2893,12 @@ dependencies = [ "syn", ] +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + [[package]] name = "openssl-probe" version = "0.2.1" @@ -3076,6 +3121,7 @@ dependencies = [ "testcontainers", "testcontainers-modules", "thiserror 2.0.18", + "tiberius", "time", "tokio", "tokio-postgres", @@ -3191,6 +3237,12 @@ dependencies = [ "termtree", ] +[[package]] +name = "pretty-hex" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6fa0831dd7cc608c38a5e323422a0077678fa5744aa2be4ad91c4ece8eec8d5" + [[package]] name = "prettyplease" version = "0.2.37" @@ -3677,16 +3729,37 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00" +dependencies = [ + "openssl-probe 0.1.6", + "rustls-pemfile", + "schannel", + "security-framework 2.11.1", +] + [[package]] name = "rustls-native-certs" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ - "openssl-probe", + "openssl-probe 0.2.1", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 3.7.0", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", ] [[package]] @@ -3711,10 +3784,10 @@ dependencies = [ "log", "once_cell", "rustls 0.23.37", - "rustls-native-certs", + "rustls-native-certs 0.8.3", "rustls-platform-verifier-android", "rustls-webpki 0.103.9", - "security-framework", + "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", "windows-sys 0.61.2", @@ -3832,6 +3905,19 @@ dependencies = [ "zeroize", ] +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -4353,6 +4439,34 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tiberius" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1446cb4198848d1562301a3340424b4f425ef79f35ef9ee034769a9dd92c10d" +dependencies = [ + "async-trait", + "asynchronous-codec", + "byteorder", + "bytes", + "chrono", + "connection-string", + "encoding_rs", + "enumflags2", + "futures-util", + "num-traits", + "once_cell", + "pin-project-lite", + "pretty-hex", + "rustls-native-certs 0.6.3", + "rustls-pemfile", + "thiserror 1.0.69", + "tokio-rustls 0.24.1", + "tokio-util", + "tracing", + "uuid", +] + [[package]] name = "time" version = "0.3.47" @@ -4668,6 +4782,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", diff --git a/Cargo.toml b/Cargo.toml index d4c43c5..bf80905 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,7 +35,8 @@ rand = "0.9.2" bytes = "1.11.0" async-stream = "0.3.6" uuid = { version = "1.20.0", features = ["v4"] } -tokio-util = "0.7.18" +tokio-util = { version = "0.7.18", features = ["compat"] } +tiberius = { version = "0.12", default-features = false, features = ["rustls", "chrono"] } aws-config = "1.8.13" aws-sdk-s3 = { version = "1.122.0", features = ["behavior-version-latest"] } async-compression = { version = "0.4.37", features = ["tokio", "gzip"] } From 30b5cc976ebcab8902ab78550b03025f6c4d3e61 Mon Sep 17 00:00:00 2001 From: Charles GTE Date: Mon, 11 May 2026 20:16:41 +0200 Subject: [PATCH 03/13] feat: add Mssql variant to DbType enum and config validation --- src/services/config.rs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/services/config.rs b/src/services/config.rs index c16cc6c..2a9d350 100644 --- a/src/services/config.rs +++ b/src/services/config.rs @@ -21,7 +21,8 @@ pub enum DbType { Sqlite, Redis, Valkey, - Firebird + Firebird, + Mssql, } impl DbType { @@ -35,6 +36,7 @@ impl DbType { DbType::Redis => "redis", DbType::Valkey => "valkey", DbType::Firebird => "firebird", + DbType::Mssql => "mssql", } } } @@ -165,14 +167,14 @@ impl ConfigService { } let username = match db.db_type { - DbType::Postgresql | DbType::Mysql | DbType::Mariadb => { + DbType::Postgresql | DbType::Mysql | DbType::Mariadb | DbType::Mssql => { required(&db.username, &db.name, "username")? } _ => optional(&db.username), }; let password = match db.db_type { - DbType::Postgresql | DbType::Mysql | DbType::Mariadb => { + DbType::Postgresql | DbType::Mysql | DbType::Mariadb | DbType::Mssql => { required(&db.password, &db.name, "password")? } _ => optional(&db.password), @@ -185,7 +187,8 @@ impl ConfigService { | DbType::MongoDB | DbType::Redis | DbType::Firebird - | DbType::Valkey => required(&db.host, &db.name, "host")?, + | DbType::Valkey + | DbType::Mssql => required(&db.host, &db.name, "host")?, DbType::Sqlite => optional(&db.host), }; @@ -196,7 +199,8 @@ impl ConfigService { | DbType::MongoDB | DbType::Redis | DbType::Firebird - | DbType::Valkey => required(&db.port, &db.name, "port")?, + | DbType::Valkey + | DbType::Mssql => required(&db.port, &db.name, "port")?, DbType::Sqlite => db.port.unwrap_or(0), }; From 6c229497b6525fea9096d99cbdd837c43ea05d74 Mon Sep 17 00:00:00 2001 From: Charles GTE Date: Mon, 11 May 2026 20:18:54 +0200 Subject: [PATCH 04/13] feat: add MSSQL domain adapter with tiberius ping and sqlpackage backup/restore --- src/domain/factory.rs | 3 + src/domain/mod.rs | 1 + src/domain/mssql/backup.rs | 47 ++++++++++ src/domain/mssql/connection.rs | 18 ++++ src/domain/mssql/database.rs | 47 ++++++++++ src/domain/mssql/mod.rs | 5 ++ src/domain/mssql/ping.rs | 24 ++++++ src/domain/mssql/restore.rs | 45 ++++++++++ src/tests/domain/mod.rs | 1 + src/tests/domain/mssql.rs | 151 +++++++++++++++++++++++++++++++++ 10 files changed, 342 insertions(+) create mode 100644 src/domain/mssql/backup.rs create mode 100644 src/domain/mssql/connection.rs create mode 100644 src/domain/mssql/database.rs create mode 100644 src/domain/mssql/mod.rs create mode 100644 src/domain/mssql/ping.rs create mode 100644 src/domain/mssql/restore.rs create mode 100644 src/tests/domain/mssql.rs diff --git a/src/domain/factory.rs b/src/domain/factory.rs index c32fda8..e4907ff 100644 --- a/src/domain/factory.rs +++ b/src/domain/factory.rs @@ -11,6 +11,7 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use crate::domain::firebird::database::FirebirdDatabase; use crate::domain::mariadb::database::MariaDBDatabase; +use crate::domain::mssql::database::MssqlDatabase; #[async_trait::async_trait] pub trait Database: Send + Sync { @@ -36,6 +37,7 @@ impl DatabaseFactory { DbType::Redis => Arc::new(RedisDatabase::new(cfg)), DbType::Valkey => Arc::new(ValkeyDatabase::new(cfg)), DbType::Firebird => Arc::new(FirebirdDatabase::new(cfg)), + DbType::Mssql => Arc::new(MssqlDatabase::new(cfg)), } } @@ -52,6 +54,7 @@ impl DatabaseFactory { DbType::Redis => Arc::new(RedisDatabase::new(cfg)), DbType::Valkey => Arc::new(ValkeyDatabase::new(cfg)), DbType::Firebird => Arc::new(FirebirdDatabase::new(cfg)), + DbType::Mssql => Arc::new(MssqlDatabase::new(cfg)), } } } diff --git a/src/domain/mod.rs b/src/domain/mod.rs index b24891d..9c4e64b 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -7,3 +7,4 @@ mod sqlite; mod valkey; mod mariadb; mod firebird; +pub mod mssql; diff --git a/src/domain/mssql/backup.rs b/src/domain/mssql/backup.rs new file mode 100644 index 0000000..65828a3 --- /dev/null +++ b/src/domain/mssql/backup.rs @@ -0,0 +1,47 @@ +use crate::services::config::DatabaseConfig; +use anyhow::{Context, Result}; +use std::path::PathBuf; +use std::process::Command; +use tracing::{debug, error, info}; + +pub async fn run( + cfg: DatabaseConfig, + backup_dir: PathBuf, + file_extension: &'static str, +) -> Result { + tokio::task::spawn_blocking(move || -> Result { + debug!("Starting MSSQL backup for database {}", cfg.name); + + let file_path = backup_dir.join(format!("{}{}", cfg.generated_id, file_extension)); + let server = format!("tcp:{},{}", cfg.host, cfg.port); + + info!( + "MSSQL backup: {}:{}/{} → {}", + cfg.host, + cfg.port, + cfg.database, + file_path.display() + ); + + let output = Command::new("sqlpackage") + .arg("/a:Export") + .arg(format!("/ssn:{}", server)) + .arg(format!("/su:{}", cfg.username)) + .arg(format!("/sp:{}", cfg.password)) + .arg(format!("/sdn:{}", cfg.database)) + .arg(format!("/tf:{}", file_path.display())) + .output() + .with_context(|| format!("Failed to run sqlpackage for {}", cfg.name))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + error!("MSSQL backup failed — stderr: {} stdout: {}", stderr, stdout); + anyhow::bail!("MSSQL backup failed for {}: {}", cfg.name, stderr); + } + + info!("MSSQL backup completed: {}", file_path.display()); + Ok(file_path) + }) + .await? +} diff --git a/src/domain/mssql/connection.rs b/src/domain/mssql/connection.rs new file mode 100644 index 0000000..aad6fad --- /dev/null +++ b/src/domain/mssql/connection.rs @@ -0,0 +1,18 @@ +use crate::services::config::DatabaseConfig; +use anyhow::Result; +use tiberius::{AuthMethod, Client, Config}; +use tokio::net::TcpStream; +use tokio_util::compat::{Compat, TokioAsyncWriteCompatExt}; + +pub async fn build_client(cfg: &DatabaseConfig) -> Result>> { + let mut config = Config::new(); + config.host(&cfg.host); + config.port(cfg.port); + config.authentication(AuthMethod::sql_server(&cfg.username, &cfg.password)); + config.trust_cert(); + + let tcp = TcpStream::connect(config.get_addr()).await?; + tcp.set_nodelay(true)?; + let client = Client::connect(config, tcp.compat_write()).await?; + Ok(client) +} diff --git a/src/domain/mssql/database.rs b/src/domain/mssql/database.rs new file mode 100644 index 0000000..673cb95 --- /dev/null +++ b/src/domain/mssql/database.rs @@ -0,0 +1,47 @@ +use super::{backup, ping, restore}; +use crate::domain::factory::Database; +use crate::services::config::DatabaseConfig; +use crate::utils::locks::{DbOpLock, FileLock}; +use anyhow::Result; +use async_trait::async_trait; +use std::path::{Path, PathBuf}; + +pub struct MssqlDatabase { + cfg: DatabaseConfig, +} + +impl MssqlDatabase { + pub fn new(cfg: DatabaseConfig) -> Self { + Self { cfg } + } +} + +#[async_trait] +impl Database for MssqlDatabase { + fn file_extension(&self) -> &'static str { + ".bacpac" + } + + async fn ping(&self) -> Result { + ping::run(self.cfg.clone()).await + } + + async fn backup(&self, dir: &Path) -> Result { + FileLock::acquire(&self.cfg.generated_id, DbOpLock::Backup.as_str()).await?; + let res = backup::run( + self.cfg.clone(), + dir.to_path_buf(), + self.file_extension(), + ) + .await; + FileLock::release(&self.cfg.generated_id).await?; + res + } + + async fn restore(&self, file: &Path) -> Result<()> { + FileLock::acquire(&self.cfg.generated_id, DbOpLock::Restore.as_str()).await?; + let res = restore::run(self.cfg.clone(), file.to_path_buf()).await; + FileLock::release(&self.cfg.generated_id).await?; + res + } +} diff --git a/src/domain/mssql/mod.rs b/src/domain/mssql/mod.rs new file mode 100644 index 0000000..143be20 --- /dev/null +++ b/src/domain/mssql/mod.rs @@ -0,0 +1,5 @@ +pub mod database; +mod connection; +mod ping; +mod backup; +mod restore; diff --git a/src/domain/mssql/ping.rs b/src/domain/mssql/ping.rs new file mode 100644 index 0000000..34c33a5 --- /dev/null +++ b/src/domain/mssql/ping.rs @@ -0,0 +1,24 @@ +use crate::services::config::DatabaseConfig; +use anyhow::Result; +use tracing::{error, info}; + +pub async fn run(cfg: DatabaseConfig) -> Result { + info!("Running ping for MSSQL database {}", cfg.name); + + match super::connection::build_client(&cfg).await { + Ok(mut client) => match client.simple_query("SELECT 1").await { + Ok(_) => { + info!("MSSQL ping succeeded for {}", cfg.name); + Ok(true) + } + Err(e) => { + error!("MSSQL ping query failed for {}: {:?}", cfg.name, e); + Ok(false) + } + }, + Err(e) => { + error!("MSSQL connection failed for {}: {:?}", cfg.name, e); + Ok(false) + } + } +} diff --git a/src/domain/mssql/restore.rs b/src/domain/mssql/restore.rs new file mode 100644 index 0000000..7149948 --- /dev/null +++ b/src/domain/mssql/restore.rs @@ -0,0 +1,45 @@ +use crate::services::config::DatabaseConfig; +use anyhow::{Context, Result}; +use std::path::PathBuf; +use std::process::Command; +use tracing::{debug, error, info}; + +pub async fn run(cfg: DatabaseConfig, restore_file: PathBuf) -> Result<()> { + tokio::task::spawn_blocking(move || -> Result<()> { + debug!("Starting MSSQL restore for database {}", cfg.name); + + let server = format!("tcp:{},{}", cfg.host, cfg.port); + + info!( + "MSSQL restore: {} → {}:{}/{}", + restore_file.display(), + cfg.host, + cfg.port, + cfg.database + ); + + let output = Command::new("sqlpackage") + .arg("/a:Import") + .arg(format!("/tsn:{}", server)) + .arg(format!("/tu:{}", cfg.username)) + .arg(format!("/tp:{}", cfg.password)) + .arg(format!("/tdn:{}", cfg.database)) + .arg(format!("/sf:{}", restore_file.display())) + .output() + .with_context(|| format!("Failed to run sqlpackage restore for {}", cfg.name))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + error!( + "MSSQL restore failed for {} — stderr: {} stdout: {}", + cfg.name, stderr, stdout + ); + anyhow::bail!("MSSQL restore failed for {}: {}", cfg.name, stderr); + } + + info!("MSSQL restore completed for {}", cfg.name); + Ok(()) + }) + .await? +} diff --git a/src/tests/domain/mod.rs b/src/tests/domain/mod.rs index 868ff33..641e097 100644 --- a/src/tests/domain/mod.rs +++ b/src/tests/domain/mod.rs @@ -5,3 +5,4 @@ mod postgres; mod redis; mod valkey; mod firebird; +mod mssql; diff --git a/src/tests/domain/mssql.rs b/src/tests/domain/mssql.rs new file mode 100644 index 0000000..61781be --- /dev/null +++ b/src/tests/domain/mssql.rs @@ -0,0 +1,151 @@ +use crate::domain::factory::DatabaseFactory; +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::time::Duration; +use tempfile::TempDir; +use tiberius::{AuthMethod, Client, Config}; +use testcontainers::runners::AsyncRunner; +use testcontainers::{ContainerAsync, GenericImage, ImageExt}; +use testcontainers::core::IntoContainerPort; +use tokio::net::TcpStream; +use tokio_util::compat::TokioAsyncWriteCompatExt; +use tracing::{error, info}; + +const SA_PASSWORD: &str = "Test!Str0ng1"; + +async fn start_container() -> ContainerAsync { + GenericImage::new("mcr.microsoft.com/azure-sql-edge", "latest") + .with_exposed_port(1433.tcp()) + .with_env_var("ACCEPT_EULA", "Y") + .with_env_var("MSSQL_SA_PASSWORD", SA_PASSWORD) + .start() + .await + .expect("azure-sql-edge container started") +} + +async fn create_user_database(host: &str, port: u16, db_name: &str) { + let mut config = Config::new(); + config.host(host); + config.port(port); + config.authentication(AuthMethod::sql_server("sa", SA_PASSWORD)); + config.trust_cert(); + + let tcp = TcpStream::connect(config.get_addr()).await.unwrap(); + tcp.set_nodelay(true).unwrap(); + let mut client = Client::connect(config, tcp.compat_write()).await.unwrap(); + + let sql = format!( + "IF NOT EXISTS (SELECT name FROM sys.databases WHERE name = N'{}') CREATE DATABASE [{}]", + db_name, db_name + ); + client.simple_query(sql.as_str()).await.unwrap(); +} + +fn make_config(host: String, port: u16, database: &str) -> DatabaseConfig { + DatabaseConfig { + name: "Test MSSQL".to_string(), + database: database.to_string(), + db_type: DbType::Mssql, + username: "sa".to_string(), + password: SA_PASSWORD.to_string(), + port, + host, + generated_id: "5a445eb4-c2c6-4bde-a423-ee1385dcf6d3".to_string(), + path: "".to_string(), + } +} + +#[tokio::test] +async fn mssql_ping_test() { + init_tracing_for_test(); + + 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(); + let config = make_config(host, port, "master"); + + let db = DatabaseFactory::create_for_backup(config).await; + let reachable = db.ping().await.unwrap_or(false); + + assert!(reachable, "MSSQL ping should succeed"); +} + +#[tokio::test] +async fn mssql_backup_test() { + init_tracing_for_test(); + + 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(); + + create_user_database(&host, port, "backupdb").await; + + let config = make_config(host, port, "backupdb"); + let temp_dir = TempDir::new().unwrap(); + + let db = DatabaseFactory::create_for_backup(config).await; + let file_path = db.backup(temp_dir.path()).await.unwrap(); + + assert!(file_path.is_file(), "backup file should exist"); + assert!( + file_path.metadata().unwrap().len() > 0, + "backup file should be non-empty" + ); + assert!( + file_path.extension().and_then(|e| e.to_str()) == Some("bacpac"), + "backup file should have .bacpac extension" + ); +} + +#[tokio::test] +async fn mssql_backup_restore_test() { + init_tracing_for_test(); + + 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(); + + create_user_database(&host, port, "sourcedb").await; + + let backup_config = make_config(host.clone(), port, "sourcedb"); + let temp_dir = TempDir::new().unwrap(); + + let db = DatabaseFactory::create_for_backup(backup_config).await; + let file_path = db.backup(temp_dir.path()).await.unwrap(); + assert!(file_path.is_file()); + + let compression = compress_to_tar_gz_large(&file_path).await.unwrap(); + assert!(compression.compressed_path.is_file()); + + let files = decompress_large_tar_gz( + compression.compressed_path.as_path(), + temp_dir.path(), + ) + .await + .unwrap(); + + let backup_file: PathBuf = if files.len() == 1 { + files[0].clone() + } else { + panic!("Unexpected number of files after decompression: {}", files.len()); + }; + + let restore_config = make_config(host, port, "restoreddb"); + let db_restore = DatabaseFactory::create_for_restore(restore_config, &backup_file).await; + + match db_restore.restore(&backup_file).await { + Ok(_) => info!("MSSQL restore succeeded"), + Err(e) => { + error!("MSSQL restore failed: {:?}", e); + panic!("Restore failed: {:?}", e); + } + } +} From 32b46d6832ac853a8fa5aba4528bfc109e8926c6 Mon Sep 17 00:00:00 2001 From: Charles GTE Date: Mon, 11 May 2026 20:21:40 +0200 Subject: [PATCH 05/13] feat: install sqlpackage in Docker base and prod stages --- docker/Dockerfile | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 975185e..0d74fa7 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,6 +22,17 @@ RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* +RUN wget -q https://packages.microsoft.com/config/debian/12/packages-microsoft-prod.deb \ + -O /tmp/packages-microsoft-prod.deb \ + && dpkg -i /tmp/packages-microsoft-prod.deb \ + && rm /tmp/packages-microsoft-prod.deb \ + && apt-get update && apt-get install -y dotnet-sdk-8.0 \ + && dotnet tool install --global microsoft.sqlpackage \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +ENV PATH="$PATH:/root/.dotnet/tools" + ARG TARGETARCH # ========================= @@ -101,6 +112,7 @@ RUN apt-get update && apt-get install -y \ sqlite3 \ redis-tools \ valkey \ + dotnet-runtime-8.0 \ && rm -rf /var/lib/apt/lists/* @@ -113,8 +125,9 @@ RUN chmod +x /entrypoint.sh COPY --from=base /usr/lib/postgresql/ /usr/lib/postgresql/ COPY --from=base /usr/local/mongodb/bin/ /usr/local/mongodb/bin/ +COPY --from=base /root/.dotnet/tools/ /root/.dotnet/tools/ - +ENV PATH="$PATH:/root/.dotnet/tools" ENV APP_ENV=production CMD ["/entrypoint.sh"] From 86564d50208eb2228b730c752ea2d74f523f90c3 Mon Sep 17 00:00:00 2001 From: Charles GTE Date: Mon, 11 May 2026 20:22:04 +0200 Subject: [PATCH 06/13] feat: add MSSQL service to docker-compose and config examples --- databases.toml | 10 ++++++++++ docker-compose.databases.yml | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/databases.toml b/databases.toml index a28cff9..4612278 100644 --- a/databases.toml +++ b/databases.toml @@ -100,4 +100,14 @@ port = 3050 host = "db-firebird" generated_id = "16706124-ff7e-4c97-8c83-0adeff214681" +[[databases]] +name = "Test database 13 - MSSQL" +database = "master" +type = "mssql" +username = "sa" +password = "Databasement!Strong1" +port = 1433 +host = "db-mssql" +generated_id = "16706125-ff7e-4c97-8c83-0adeff214682" + diff --git a/docker-compose.databases.yml b/docker-compose.databases.yml index 92c2d6a..6030c23 100644 --- a/docker-compose.databases.yml +++ b/docker-compose.databases.yml @@ -159,6 +159,24 @@ services: networks: - portabase + db-mssql: + container_name: db-mssql + image: mcr.microsoft.com/azure-sql-edge:latest + ports: + - "1433:1433" + environment: + ACCEPT_EULA: "Y" + MSSQL_SA_PASSWORD: "Databasement!Strong1" + networks: + - portabase + healthcheck: + test: >- + /opt/mssql-tools/bin/sqlcmd -S localhost -U sa + -P "Databasement!Strong1" -Q "SELECT 1" || exit 0 + interval: 10s + timeout: 5s + retries: 20 + volumes: postgres-data: mariadb-data: From 525c07b394c3c747fdfa31089ddd7847525e05ed Mon Sep 17 00:00:00 2001 From: Charles GTE Date: Mon, 11 May 2026 20:38:54 +0200 Subject: [PATCH 07/13] fix: log backup errors instead of silently discarding them --- src/services/backup/runner.rs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/services/backup/runner.rs b/src/services/backup/runner.rs index e2bacd9..73ff871 100644 --- a/src/services/backup/runner.rs +++ b/src/services/backup/runner.rs @@ -52,13 +52,16 @@ impl BackupService { code: Some("backup_already_in_progress".into()), }), - Err(_) => Ok(BackupResult { - generated_id, - db_type, - status: "failed".into(), - backup_file: None, - code: None, - }), + Err(e) => { + error!("Backup failed for {}: {:?}", generated_id, e); + Ok(BackupResult { + generated_id, + db_type, + status: "failed".into(), + backup_file: None, + code: None, + }) + } } } } From a6e005ced1efe130643ca59f6bb30a0f63f5559e Mon Sep 17 00:00:00 2001 From: Charles GTE Date: Mon, 11 May 2026 20:42:15 +0200 Subject: [PATCH 08/13] fix: use dotnet-install.sh instead of Microsoft apt feed (SHA1 key rejected by Debian Trixie) --- docker/Dockerfile | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 0d74fa7..6f11dcf 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,16 +22,14 @@ RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* -RUN wget -q https://packages.microsoft.com/config/debian/12/packages-microsoft-prod.deb \ - -O /tmp/packages-microsoft-prod.deb \ - && dpkg -i /tmp/packages-microsoft-prod.deb \ - && rm /tmp/packages-microsoft-prod.deb \ - && apt-get update && apt-get install -y dotnet-sdk-8.0 \ - && dotnet tool install --global microsoft.sqlpackage \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* +ENV DOTNET_ROOT=/usr/local/dotnet +RUN curl -sSL https://dot.net/v1/dotnet-install.sh -o /tmp/dotnet-install.sh \ + && chmod +x /tmp/dotnet-install.sh \ + && /tmp/dotnet-install.sh --channel 8.0 --install-dir /usr/local/dotnet \ + && rm /tmp/dotnet-install.sh \ + && /usr/local/dotnet/dotnet tool install --global microsoft.sqlpackage -ENV PATH="$PATH:/root/.dotnet/tools" +ENV PATH="$PATH:/usr/local/dotnet:/root/.dotnet/tools" ARG TARGETARCH @@ -108,13 +106,18 @@ RUN apt-get update && apt-get install -y \ libreadline8 \ libncurses6 \ zlib1g \ + curl \ mariadb-client \ sqlite3 \ redis-tools \ valkey \ - dotnet-runtime-8.0 \ && rm -rf /var/lib/apt/lists/* +ENV DOTNET_ROOT=/usr/local/dotnet +RUN curl -sSL https://dot.net/v1/dotnet-install.sh -o /tmp/dotnet-install.sh \ + && chmod +x /tmp/dotnet-install.sh \ + && /tmp/dotnet-install.sh --channel 8.0 --runtime dotnet --install-dir /usr/local/dotnet \ + && rm /tmp/dotnet-install.sh WORKDIR /app @@ -127,7 +130,7 @@ COPY --from=base /usr/lib/postgresql/ /usr/lib/postgresql/ COPY --from=base /usr/local/mongodb/bin/ /usr/local/mongodb/bin/ COPY --from=base /root/.dotnet/tools/ /root/.dotnet/tools/ -ENV PATH="$PATH:/root/.dotnet/tools" +ENV PATH="$PATH:/usr/local/dotnet:/root/.dotnet/tools" ENV APP_ENV=production CMD ["/entrypoint.sh"] From d2f3aec41aee1f32ebb5357e8240f83a2f80ef9c Mon Sep 17 00:00:00 2001 From: Charles GTE Date: Mon, 11 May 2026 21:02:02 +0200 Subject: [PATCH 09/13] fix: add /TrustServerCertificate:true to sqlpackage commands for self-signed certs --- src/domain/mssql/backup.rs | 1 + src/domain/mssql/restore.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/src/domain/mssql/backup.rs b/src/domain/mssql/backup.rs index 65828a3..918e15a 100644 --- a/src/domain/mssql/backup.rs +++ b/src/domain/mssql/backup.rs @@ -30,6 +30,7 @@ pub async fn run( .arg(format!("/sp:{}", cfg.password)) .arg(format!("/sdn:{}", cfg.database)) .arg(format!("/tf:{}", file_path.display())) + .arg("/TrustServerCertificate:true") .output() .with_context(|| format!("Failed to run sqlpackage for {}", cfg.name))?; diff --git a/src/domain/mssql/restore.rs b/src/domain/mssql/restore.rs index 7149948..ef1b7bb 100644 --- a/src/domain/mssql/restore.rs +++ b/src/domain/mssql/restore.rs @@ -25,6 +25,7 @@ pub async fn run(cfg: DatabaseConfig, restore_file: PathBuf) -> Result<()> { .arg(format!("/tp:{}", cfg.password)) .arg(format!("/tdn:{}", cfg.database)) .arg(format!("/sf:{}", restore_file.display())) + .arg("/TrustServerCertificate:true") .output() .with_context(|| format!("Failed to run sqlpackage restore for {}", cfg.name))?; From 55145321b9a5079c61233b9da494e1bdb9e5a517 Mon Sep 17 00:00:00 2001 From: Charles GTE Date: Mon, 11 May 2026 21:03:58 +0200 Subject: [PATCH 10/13] fix: use connection string with TrustServerCertificate for sqlpackage --- src/domain/mssql/backup.rs | 11 +++++------ src/domain/mssql/restore.rs | 11 +++++------ 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/src/domain/mssql/backup.rs b/src/domain/mssql/backup.rs index 918e15a..698cf26 100644 --- a/src/domain/mssql/backup.rs +++ b/src/domain/mssql/backup.rs @@ -13,7 +13,10 @@ pub async fn run( debug!("Starting MSSQL backup for database {}", cfg.name); let file_path = backup_dir.join(format!("{}{}", cfg.generated_id, file_extension)); - let server = format!("tcp:{},{}", cfg.host, cfg.port); + let connection_string = format!( + "Server=tcp:{},{};Database={};User Id={};Password={};TrustServerCertificate=True;Encrypt=True", + cfg.host, cfg.port, cfg.database, cfg.username, cfg.password + ); info!( "MSSQL backup: {}:{}/{} → {}", @@ -25,12 +28,8 @@ pub async fn run( let output = Command::new("sqlpackage") .arg("/a:Export") - .arg(format!("/ssn:{}", server)) - .arg(format!("/su:{}", cfg.username)) - .arg(format!("/sp:{}", cfg.password)) - .arg(format!("/sdn:{}", cfg.database)) + .arg(format!("/scs:{}", connection_string)) .arg(format!("/tf:{}", file_path.display())) - .arg("/TrustServerCertificate:true") .output() .with_context(|| format!("Failed to run sqlpackage for {}", cfg.name))?; diff --git a/src/domain/mssql/restore.rs b/src/domain/mssql/restore.rs index ef1b7bb..a484e1f 100644 --- a/src/domain/mssql/restore.rs +++ b/src/domain/mssql/restore.rs @@ -8,7 +8,10 @@ pub async fn run(cfg: DatabaseConfig, restore_file: PathBuf) -> Result<()> { tokio::task::spawn_blocking(move || -> Result<()> { debug!("Starting MSSQL restore for database {}", cfg.name); - let server = format!("tcp:{},{}", cfg.host, cfg.port); + let connection_string = format!( + "Server=tcp:{},{};Database={};User Id={};Password={};TrustServerCertificate=True;Encrypt=True", + cfg.host, cfg.port, cfg.database, cfg.username, cfg.password + ); info!( "MSSQL restore: {} → {}:{}/{}", @@ -20,12 +23,8 @@ pub async fn run(cfg: DatabaseConfig, restore_file: PathBuf) -> Result<()> { let output = Command::new("sqlpackage") .arg("/a:Import") - .arg(format!("/tsn:{}", server)) - .arg(format!("/tu:{}", cfg.username)) - .arg(format!("/tp:{}", cfg.password)) - .arg(format!("/tdn:{}", cfg.database)) + .arg(format!("/tcs:{}", connection_string)) .arg(format!("/sf:{}", restore_file.display())) - .arg("/TrustServerCertificate:true") .output() .with_context(|| format!("Failed to run sqlpackage restore for {}", cfg.name))?; From de0e91893d77f50b6b2ec1d9fe49057c3dceb3d6 Mon Sep 17 00:00:00 2001 From: Charles GTE Date: Mon, 11 May 2026 21:07:42 +0200 Subject: [PATCH 11/13] fix: use user database in MSSQL config example (system databases cannot be exported) --- databases.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/databases.toml b/databases.toml index 4612278..455e3e0 100644 --- a/databases.toml +++ b/databases.toml @@ -102,10 +102,10 @@ generated_id = "16706124-ff7e-4c97-8c83-0adeff214681" [[databases]] name = "Test database 13 - MSSQL" -database = "master" +database = "myappdb" type = "mssql" username = "sa" -password = "Databasement!Strong1" +password = "Portabase!Strong1" port = 1433 host = "db-mssql" generated_id = "16706125-ff7e-4c97-8c83-0adeff214682" From 8910ae7aa9facf803658a183e63fe3cc7ff98bcd Mon Sep 17 00:00:00 2001 From: Charles GTE Date: Mon, 11 May 2026 21:10:30 +0200 Subject: [PATCH 12/13] feat: add seed-mssql task and seed script --- justfile | 9 ++++++++- scripts/mssql/seed.sql | 20 ++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 scripts/mssql/seed.sql diff --git a/justfile b/justfile index 47a04d5..1804dc1 100644 --- a/justfile +++ b/justfile @@ -45,6 +45,12 @@ seed-firebird: echo "SELECT RDB\$RELATION_NAME FROM RDB\$RELATIONS WHERE RDB\$SYSTEM_FLAG = 0 AND RDB\$VIEW_BLR IS NULL;" \ | docker exec -i db-firebird isql -user alice -password fake_password /var/lib/firebird/data/mirror.fdb +seed-mssql: + echo "Seeding MSSQL..." + docker exec -i "$MSSQL_CONTAINER" /opt/mssql-tools/bin/sqlcmd \ + -S localhost -U sa -P "$MSSQL_SA_PASSWORD" < ./scripts/mssql/seed.sql + echo "Done" + seed-all: just seed-mongo just seed-mysql @@ -52,4 +58,5 @@ seed-all: just seed-postgres-1gb just seed-sqlite just seed-mongo - just seed-firebird \ No newline at end of file + just seed-firebird + just seed-mssql \ No newline at end of file diff --git a/scripts/mssql/seed.sql b/scripts/mssql/seed.sql new file mode 100644 index 0000000..b7c9e04 --- /dev/null +++ b/scripts/mssql/seed.sql @@ -0,0 +1,20 @@ +IF NOT EXISTS (SELECT name FROM sys.databases WHERE name = N'myappdb') + CREATE DATABASE [myappdb]; +GO + +USE [myappdb]; +GO + +IF OBJECT_ID('users', 'U') IS NULL + CREATE TABLE users ( + id INT IDENTITY(1,1) PRIMARY KEY, + email NVARCHAR(255) NOT NULL UNIQUE, + name NVARCHAR(255), + created_at DATETIME DEFAULT GETDATE() + ); +GO + +INSERT INTO users (email, name) VALUES ('alice@example.com', 'Alice'); +INSERT INTO users (email, name) VALUES ('bob@example.com', 'Bob'); +INSERT INTO users (email, name) VALUES ('charlie@example.com', 'Charlie'); +GO From 84a596e69d30d70930ef9f43e55922b91bcba5da Mon Sep 17 00:00:00 2001 From: Charles GTE Date: Mon, 11 May 2026 21:43:09 +0200 Subject: [PATCH 13/13] fix: docker-compose.yml --- .gitignore | 5 ++++- databases.json | 10 ++++++++++ docker-compose.databases.yml | 9 +++++---- docker/Dockerfile | 5 +++++ justfile | 3 +-- src/domain/mssql/backup.rs | 2 +- src/domain/mssql/restore.rs | 2 +- 7 files changed, 27 insertions(+), 9 deletions(-) diff --git a/.gitignore b/.gitignore index d47aac6..12bbacf 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,7 @@ /src/data/ .DS_Store -.env \ No newline at end of file +.env + +.claude +/docs \ No newline at end of file diff --git a/databases.json b/databases.json index 83b750d..7566da2 100644 --- a/databases.json +++ b/databases.json @@ -101,6 +101,16 @@ "port": 3050, "host": "db-firebird", "generated_id": "16706124-ff7e-4c97-8c83-0adeff214681" + }, + { + "name": "Test database 13 - MysSQL", + "database": "myappdb", + "type": "mssql", + "username": "sa", + "password": "Portabase!Strong1", + "port": 1433, + "host": "db-mssql", + "generated_id": "16706125-ff7e-4c97-8c83-0adeff214682" } ] } diff --git a/docker-compose.databases.yml b/docker-compose.databases.yml index 6030c23..0d551c9 100644 --- a/docker-compose.databases.yml +++ b/docker-compose.databases.yml @@ -166,13 +166,13 @@ services: - "1433:1433" environment: ACCEPT_EULA: "Y" - MSSQL_SA_PASSWORD: "Databasement!Strong1" + MSSQL_SA_PASSWORD: "Portabase!Strong1" + volumes: + - mssql-data:/var/opt/mssql networks: - portabase healthcheck: - test: >- - /opt/mssql-tools/bin/sqlcmd -S localhost -U sa - -P "Databasement!Strong1" -Q "SELECT 1" || exit 0 + test: ["CMD-SHELL", "cat /proc/net/tcp6 | grep -q '059901' || exit 1"] interval: 10s timeout: 5s retries: 20 @@ -189,6 +189,7 @@ volumes: valkey-data: valkey-data-auth: firebird-data: + mssql-data: networks: portabase: diff --git a/docker/Dockerfile b/docker/Dockerfile index 6f11dcf..61cdffa 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -31,6 +31,11 @@ RUN curl -sSL https://dot.net/v1/dotnet-install.sh -o /tmp/dotnet-install.sh \ ENV PATH="$PATH:/usr/local/dotnet:/root/.dotnet/tools" +RUN ARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/') \ + && curl -sSL "https://github.com/microsoft/go-sqlcmd/releases/latest/download/sqlcmd-linux-${ARCH}.tar.bz2" \ + | tar -xjf - -C /usr/local/bin sqlcmd \ + && chmod +x /usr/local/bin/sqlcmd + ARG TARGETARCH # ========================= diff --git a/justfile b/justfile index 1804dc1..45b7b7c 100644 --- a/justfile +++ b/justfile @@ -47,8 +47,7 @@ seed-firebird: seed-mssql: echo "Seeding MSSQL..." - docker exec -i "$MSSQL_CONTAINER" /opt/mssql-tools/bin/sqlcmd \ - -S localhost -U sa -P "$MSSQL_SA_PASSWORD" < ./scripts/mssql/seed.sql + docker exec -i rust-dev sqlcmd -S "db-mssql,1433" -U sa -P "$MSSQL_SA_PASSWORD" -N disable -i /app/scripts/mssql/seed.sql echo "Done" seed-all: diff --git a/src/domain/mssql/backup.rs b/src/domain/mssql/backup.rs index 698cf26..5edb941 100644 --- a/src/domain/mssql/backup.rs +++ b/src/domain/mssql/backup.rs @@ -14,7 +14,7 @@ pub async fn run( 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=True", + "Server=tcp:{},{};Database={};User Id={};Password={};TrustServerCertificate=True;Encrypt=False", cfg.host, cfg.port, cfg.database, cfg.username, cfg.password ); diff --git a/src/domain/mssql/restore.rs b/src/domain/mssql/restore.rs index a484e1f..181313d 100644 --- a/src/domain/mssql/restore.rs +++ b/src/domain/mssql/restore.rs @@ -9,7 +9,7 @@ pub async fn run(cfg: DatabaseConfig, restore_file: PathBuf) -> Result<()> { debug!("Starting MSSQL restore for database {}", cfg.name); let connection_string = format!( - "Server=tcp:{},{};Database={};User Id={};Password={};TrustServerCertificate=True;Encrypt=True", + "Server=tcp:{},{};Database={};User Id={};Password={};TrustServerCertificate=True;Encrypt=False", cfg.host, cfg.port, cfg.database, cfg.username, cfg.password );