diff --git a/.github/workflows/windows-release.yml b/.github/workflows/windows-release.yml new file mode 100644 index 0000000..c5e5cf3 --- /dev/null +++ b/.github/workflows/windows-release.yml @@ -0,0 +1,96 @@ +name: Build Windows release + + +on: + workflow_dispatch: + push: + tags: + - '[0-9]+.[0-9]+.[0-9]+' + branches: + - main + - master + +jobs: + build-windows: + runs-on: windows-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Rust toolchain (MSVC) + uses: actions-rs/toolchain@v1 + with: + toolchain: stable-x86_64-pc-windows-msvc + profile: minimal + override: true + + - name: Install vcpkg and OpenSSL (x64) + shell: pwsh + run: | + # Install vcpkg and the prebuilt OpenSSL package + git clone https://github.com/microsoft/vcpkg C:\vcpkg + C:\vcpkg\bootstrap-vcpkg.bat + C:\vcpkg\vcpkg install openssl:x64-windows + # Export variables for subsequent steps + 'VCPKG_ROOT=C:\vcpkg' | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + 'OPENSSL_DIR=C:\vcpkg\installed\x64-windows' | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Build (cargo release) + shell: pwsh + env: + # Cargo / openssl-sys will pick up OPENSSL_DIR from the environment + OPENSSL_DIR: ${{ env.OPENSSL_DIR }} + run: | + # Ensure the environment variable is present for this step + if (-Not $env:OPENSSL_DIR) { Write-Host "OPENSSL_DIR not set, printing env for debugging"; Get-ChildItem Env: | ForEach-Object { Write-Host $_ } } + # Build the declared bin target explicitly (Cargo.toml [[bin]] name = "app") + cargo build --release --bin app + + - name: Prepare artifact zip + id: prepare_artifact + shell: pwsh + env: + RELEASE_TAG: ${{ github.ref_name }} + run: | + $tag = $env:RELEASE_TAG + if (-not $tag) { $tag = $env:GITHUB_SHA } + + # Package the declared bin target deterministically (Cargo.toml [[bin]] name = "app") + $exe = "target\release\app.exe" + if (-not (Test-Path $exe)) { Write-Error "Built binary $exe not found in target/release"; exit 1 } + + $outDir = "artifact" + New-Item -ItemType Directory -Path $outDir -Force | Out-Null + # Ship under the package name, not the internal bin name "app" + Copy-Item -Path $exe -Destination "$outDir\portabase-agent.exe" + + $zipName = "windows-release-$tag.zip" + if (Test-Path $zipName) { Remove-Item $zipName } + Compress-Archive -Path "$outDir\*" -DestinationPath $zipName -Force + Write-Host "ZIP=$zipName" + Write-Output "zip=$zipName" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + + - name: Upload build artifact + uses: actions/upload-artifact@v4 + with: + name: windows-release + path: windows-release-*.zip + + - name: Create GitHub Release + if: startsWith(github.ref, 'refs/tags/') + id: create_release + uses: softprops/action-gh-release@v1 + with: + tag_name: ${{ github.ref_name }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload release asset + if: startsWith(github.ref, 'refs/tags/') + uses: actions/upload-release-asset@v1 + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: windows-release-${{ github.ref_name }}.zip + asset_name: windows-release-${{ github.ref_name }}.zip + asset_content_type: application/zip \ No newline at end of file diff --git a/.gitignore b/.gitignore index 12bbacf..4e7fdb7 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ .env .claude -/docs \ No newline at end of file + +/docs diff --git a/docker-compose.yml b/docker-compose.yml index 2ad103b..37fb676 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,7 +19,7 @@ services: APP_ENV: development LOG: debug TZ: "Europe/Paris" - EDGE_KEY: "eyJzZXJ2ZXJVcmwiOiJodHRwOi8vbG9jYWxob3N0Ojg4ODciLCJhZ2VudElkIjoiNmNiMmQwODMtZDQ4MS00MWY3LTk5NjItZjNhMzU2ZTJiMzllIiwibWFzdGVyS2V5QjY0IjoiMUh0djdtWCtYVkJxL0IzUEV2WDlZZjlQeUdVZW5oRHlXemo5THRqNW90WT0ifQ==" + EDGE_KEY: "eyJzZXJ2ZXJVcmwiOiJodHRwOi8vbG9jYWxob3N0Ojg4ODciLCJhZ2VudElkIjoiNWRkZTE1NTctZWQ1ZC00MjUxLThiZDMtMDE0MjkxOTg2OGZjIiwibWFzdGVyS2V5QjY0IjoiQlhWM1hvbEM2NTZTVjdkTmdjV1BHUWxrKytycExJNmxHRGk3Q1BCNWllbz0ifQ==" #CHUNK_SIZE_MB: "1" #POOLING: 1 #DATABASES_CONFIG_FILE: "config.toml" diff --git a/src/domain/postgres/connection.rs b/src/domain/postgres/connection.rs index 358b354..a8bb098 100644 --- a/src/domain/postgres/connection.rs +++ b/src/domain/postgres/connection.rs @@ -1,5 +1,6 @@ use crate::domain::postgres::format::PostgresDumpFormat; use crate::services::config::DatabaseConfig; +use crate::settings::CONFIG; use anyhow::Result; use std::path::Path; use tokio_postgres::{Client, Config, NoTls}; @@ -32,11 +33,91 @@ pub async fn server_version(cfg: &DatabaseConfig) -> Result { Ok(version) } +/// Resolves the `bin` directory of a PostgreSQL installation for the given +/// major version, in a cross-platform way. +/// +/// Resolution order: +/// 1. The `PG_BIN_DIR` environment variable, if set, is used as-is. This +/// allows users/CI to override detection for non-standard installs +/// (e.g. portable PostgreSQL distributions, custom install locations). +/// 2. Platform-specific default install locations (Debian/Ubuntu packages, +/// the official Windows installer, Homebrew/Postgres.app on macOS, and +/// common RPM-based layouts on other Linux distros). +/// 3. A `PATH` lookup for `pg_dump` (`pg_dump.exe` on Windows), returning +/// its parent directory. +/// 4. The historical Debian/Ubuntu path as a last-resort fallback, so the +/// function keeps returning a `PathBuf` (never panics) even when nothing +/// was found, preserving the previous behavior for callers. +/// +/// The override is sourced from `CONFIG.pg_bin_dir` (the `PG_BIN_DIR` +/// environment variable). An empty value means "unset" and falls through to +/// detection. pub fn select_pg_path(version: &str) -> std::path::PathBuf { + select_pg_path_with(version, &CONFIG.pg_bin_dir) +} + +/// Inner resolver behind [`select_pg_path`], parameterized over the +/// `PG_BIN_DIR` override. Kept pure (no env / no `CONFIG` access) so it is +/// unit-testable without mutating process-global state. +pub(crate) fn select_pg_path_with(version: &str, pg_bin_dir: &str) -> std::path::PathBuf { let major = version.split('.').next().unwrap_or("17"); + + if !pg_bin_dir.is_empty() { + return pg_bin_dir.into(); + } + + let candidates: Vec = if cfg!(target_os = "windows") { + vec![ + // Default install path used by the official EDB Windows installer + format!(r"C:\Program Files\PostgreSQL\{major}\bin").into(), + format!(r"C:\Program Files (x86)\PostgreSQL\{major}\bin").into(), + ] + } else if cfg!(target_os = "macos") { + vec![ + // Homebrew on Apple Silicon + format!("/opt/homebrew/opt/postgresql@{major}/bin").into(), + // Homebrew on Intel + format!("/usr/local/opt/postgresql@{major}/bin").into(), + // Postgres.app + format!("/Applications/Postgres.app/Contents/Versions/{major}/bin").into(), + ] + } else { + vec![ + // Debian/Ubuntu packages + format!("/usr/lib/postgresql/{major}/bin").into(), + // Common RPM-based distro layout + format!("/usr/pgsql-{major}/bin").into(), + ] + }; + + if let Some(found) = candidates.into_iter().find(|p| pg_dump_exists_in(p)) { + return found; + } + + if let Some(dir) = find_pg_dump_in_path() { + return dir; + } + format!("/usr/lib/postgresql/{}/bin", major).into() } +pub(crate) fn pg_dump_binary_name() -> &'static str { + if cfg!(target_os = "windows") { + "pg_dump.exe" + } else { + "pg_dump" + } +} + +pub(crate) fn pg_dump_exists_in(dir: &std::path::Path) -> bool { + dir.join(pg_dump_binary_name()).is_file() +} + +fn find_pg_dump_in_path() -> Option { + let path_var = std::env::var_os("PATH")?; + std::env::split_paths(&path_var).find(|dir| pg_dump_exists_in(dir)) +} + pub async fn terminate_connections(cfg: &DatabaseConfig) -> Result<()> { let mut admin = cfg.clone(); admin.database = "postgres".to_string().into(); @@ -72,6 +153,7 @@ pub async fn detect_format_from_size(cfg: &DatabaseConfig) -> PostgresDumpFormat "Detecting database format {:?} - {:?}", cfg.name, cfg.generated_id ); + let client = match connect(cfg).await { Ok(c) => c, Err(_) => return PostgresDumpFormat::Fc, diff --git a/src/domain/postgres/database.rs b/src/domain/postgres/database.rs index c10b114..af18f81 100644 --- a/src/domain/postgres/database.rs +++ b/src/domain/postgres/database.rs @@ -23,7 +23,6 @@ impl PostgresDatabase { fn build_env(&self) -> HashMap { let mut envs = std::env::vars().collect::>(); envs.insert("PGPASSWORD".to_string(), self.cfg.password.to_string()); - info!("envs: {:?}", envs); envs } } diff --git a/src/domain/postgres/mod.rs b/src/domain/postgres/mod.rs index cd42ace..c7cd4cb 100644 --- a/src/domain/postgres/mod.rs +++ b/src/domain/postgres/mod.rs @@ -1,5 +1,5 @@ pub mod backup; -mod connection; +pub(crate) mod connection; pub mod database; mod format; mod ping; diff --git a/src/settings.rs b/src/settings.rs index 039ea70..b4076d1 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -11,6 +11,7 @@ pub struct Settings { pub edge_key: String, pub databases_config_file: String, pub data_path: String, + pub pg_bin_dir: String, pub pooling: usize, pub timezone: String, pub log: String, @@ -59,6 +60,7 @@ impl Settings { databases_config_file: env::var("DATABASES_CONFIG_FILE") .unwrap_or_else(|_| "config.json".into()), data_path: env::var("DATA_PATH").unwrap_or_else(|_| "/config".into()), + pg_bin_dir: env::var("PG_BIN_DIR").unwrap_or_default(), pooling: pooling_seconds, timezone: tz, log: env::var("LOG").unwrap_or_else(|_| "info".into()), diff --git a/src/tests/domain/postgres.rs b/src/tests/domain/postgres.rs index bd4a5a5..04d4cde 100644 --- a/src/tests/domain/postgres.rs +++ b/src/tests/domain/postgres.rs @@ -147,3 +147,64 @@ async fn postgres_password_with_slash_test() { assert_eq!(reachable, true); } + +mod select_pg_path_tests { + use crate::domain::postgres::connection::{ + pg_dump_binary_name, pg_dump_exists_in, select_pg_path_with, + }; + + // `select_pg_path_with` takes the `PG_BIN_DIR` override as a plain + // argument, so these tests never touch process-global env state or the + // cached `CONFIG`. They stay deterministic regardless of whether — or at + // which version — a real PostgreSQL install exists on the host. + + #[test] + fn respects_pg_bin_dir_override() { + let custom = if cfg!(target_os = "windows") { + r"C:\custom\pg\bin" + } else { + "/custom/pg/bin" + }; + let path = select_pg_path_with("16.4", custom); + assert_eq!(path, std::path::PathBuf::from(custom)); + } + + #[test] + fn pg_bin_dir_override_ignores_requested_version() { + // The override is taken as-is, regardless of which version was + // requested — this documents/locks in that behavior. + let custom = if cfg!(target_os = "windows") { + r"C:\custom\pg\bin" + } else { + "/custom/pg/bin" + }; + let path = select_pg_path_with("not-a-version", custom); + assert_eq!(path, std::path::PathBuf::from(custom)); + } + + #[test] + fn empty_pg_bin_dir_falls_through_to_detection() { + // An empty override means "unset" (matches `CONFIG.pg_bin_dir` when + // `PG_BIN_DIR` is absent). It must not be returned as a literal empty + // path — resolution falls through to platform defaults / PATH lookup + // and yields a non-empty path. + let path = select_pg_path_with("17", ""); + assert_ne!(path, std::path::PathBuf::from("")); + } + + #[test] + fn pg_dump_binary_name_is_platform_specific() { + let name = pg_dump_binary_name(); + if cfg!(target_os = "windows") { + assert_eq!(name, "pg_dump.exe"); + } else { + assert_eq!(name, "pg_dump"); + } + } + + #[test] + fn pg_dump_exists_in_is_false_for_nonexistent_dir() { + let dir = std::path::Path::new("this/path/almost-certainly/does-not-exist-12345"); + assert!(!pg_dump_exists_in(dir)); + } +}