mirror of
https://github.com/Portabase/agent.git
synced 2026-09-12 06:23:42 +00:00
Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e504d09cb3 | |||
| 0363b300a4 | |||
| bf6e7d41ab | |||
| 2c7065ae95 | |||
| 1fece577cc | |||
| 273475fa3b | |||
| 628c017584 | |||
| f02218708a | |||
| 16152328b0 | |||
| 7328827435 | |||
| 348eaac81b | |||
| 0f6c93ecd0 | |||
| befec0deae | |||
| 2052ab0ff5 | |||
| 461e92d67e | |||
| 23678bf2d6 | |||
| 02105a8171 | |||
| f94656a39b | |||
| b94ff4e987 | |||
| dc32c442f3 | |||
| 1453851555 | |||
| 531a25f292 | |||
| 740ee43038 | |||
| 1fccc0bc19 | |||
| 2c15ea64ae | |||
| ac8f7fd8d8 | |||
| 6edf2890f1 | |||
| 5f579690ff | |||
| 5c35375df7 | |||
| 61c7224104 | |||
| d7958e03b9 | |||
| b269d98d3c | |||
| 2d44b62844 | |||
| 092f760431 | |||
| 2c5c805308 | |||
| 38a3274c43 | |||
| bb45c1961f | |||
| ee7016d1fa | |||
| c46c301371 |
@@ -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
|
||||
+2
-1
@@ -6,4 +6,5 @@
|
||||
.env
|
||||
|
||||
.claude
|
||||
/docs
|
||||
|
||||
/docs
|
||||
|
||||
+1
-1
@@ -27,5 +27,5 @@ keywords:
|
||||
- self-hosted
|
||||
- portabase
|
||||
license: Apache-2.0
|
||||
version: 1.12.0
|
||||
version: 1.12.3
|
||||
date-released: '2026-02-24'
|
||||
|
||||
Generated
+1
-1
@@ -3083,7 +3083,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "portabase-agent"
|
||||
version = "1.12.0"
|
||||
version = "1.12.3"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "portabase-agent"
|
||||
version = "1.12.0"
|
||||
version = "1.12.3"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
+9
-1
@@ -19,7 +19,7 @@ services:
|
||||
APP_ENV: development
|
||||
LOG: debug
|
||||
TZ: "Europe/Paris"
|
||||
EDGE_KEY: "eyJzZXJ2ZXJVcmwiOiJodHRwOi8vbG9jYWxob3N0Ojg4ODciLCJhZ2VudElkIjoiMGZiNDYyMmUtMTMxNS00MzMxLTlkMTMtZWMzMjAyZjZiNTIwIiwibWFzdGVyS2V5QjY0IjoiMUh0djdtWCtYVkJxL0IzUEV2WDlZZjlQeUdVZW5oRHlXemo5THRqNW90WT0ifQ=="
|
||||
EDGE_KEY: "eyJzZXJ2ZXJVcmwiOiJodHRwOi8vbG9jYWxob3N0Ojg4ODciLCJhZ2VudElkIjoiNWRkZTE1NTctZWQ1ZC00MjUxLThiZDMtMDE0MjkxOTg2OGZjIiwibWFzdGVyS2V5QjY0IjoiQlhWM1hvbEM2NTZTVjdkTmdjV1BHUWxrKytycExJNmxHRGk3Q1BCNWllbz0ifQ=="
|
||||
#CHUNK_SIZE_MB: "1"
|
||||
#POOLING: 1
|
||||
#DATABASES_CONFIG_FILE: "config.toml"
|
||||
@@ -28,6 +28,14 @@ services:
|
||||
networks:
|
||||
- portabase
|
||||
|
||||
cpus: "1.50"
|
||||
|
||||
mem_limit: 4g
|
||||
memswap_limit: 4g
|
||||
|
||||
pids_limit: 512
|
||||
|
||||
|
||||
volumes:
|
||||
cargo-registry:
|
||||
cargo-git:
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
# =========================
|
||||
# MySQL client tools
|
||||
# =========================
|
||||
FROM mysql:8.4 AS mysql-client-tools
|
||||
RUN mkdir -p /mysql-exports/bin /mysql-exports/lib \
|
||||
&& cp /usr/bin/mysqldump /mysql-exports/bin/ \
|
||||
&& find /usr/lib -name "libmysqlclient.so.21*" -exec cp {} /mysql-exports/lib/ \;
|
||||
|
||||
# =========================
|
||||
# Base image (shared)
|
||||
# =========================
|
||||
@@ -68,6 +76,13 @@ RUN if [ "$TARGETARCH" = "amd64" ]; then \
|
||||
COPY assets/tools/${TARGETARCH}/mongodb/ /usr/local/mongodb/
|
||||
RUN chmod +x /usr/local/mongodb/bin/*
|
||||
|
||||
# =========================
|
||||
# MySQL real mysqldump binary
|
||||
# =========================
|
||||
COPY --from=mysql-client-tools /mysql-exports/bin/mysqldump /usr/local/bin/mysqldump
|
||||
COPY --from=mysql-client-tools /mysql-exports/lib/ /usr/local/lib/
|
||||
RUN chmod +x /usr/local/bin/mysqldump && ldconfig
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# =========================
|
||||
@@ -135,6 +150,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/
|
||||
COPY --from=mysql-client-tools /mysql-exports/bin/mysqldump /usr/local/bin/mysqldump
|
||||
COPY --from=mysql-client-tools /mysql-exports/lib/ /usr/local/lib/
|
||||
RUN chmod +x /usr/local/bin/mysqldump && ldconfig
|
||||
|
||||
ENV PATH="$PATH:/usr/local/dotnet:/root/.dotnet/tools"
|
||||
ENV APP_ENV=production
|
||||
|
||||
@@ -33,6 +33,11 @@ pub async fn run(
|
||||
let _mariadb_dump = select_mariadb_path(&version).join("mariadb-dump");
|
||||
|
||||
logger.log("debug", format!("Using mariadb-dump at {}", _mariadb_dump.display()));
|
||||
|
||||
if let Ok(out) = Command::new("mariadb-dump").arg("--version").output() {
|
||||
logger.log("debug", format!("mariadb-dump client: {}", String::from_utf8_lossy(&out.stdout).trim()));
|
||||
}
|
||||
|
||||
logger.log("info", format!("Running mariadb-dump for {}", cfg.name));
|
||||
|
||||
let start = Instant::now();
|
||||
@@ -50,7 +55,7 @@ pub async fn run(
|
||||
.arg("--skip-add-drop-table")
|
||||
.arg("--compress")
|
||||
.arg("--verbose")
|
||||
.arg("--max-allowed-packet=512M")
|
||||
.arg(format!("--max-allowed-packet={}", cfg.max_packet_size))
|
||||
.arg("--net-buffer-length=16K")
|
||||
.arg("--default-character-set=utf8mb4")
|
||||
.arg(&cfg.database)
|
||||
|
||||
@@ -2,7 +2,6 @@ use crate::services::backup::logger::JobLogger;
|
||||
use crate::services::config::DatabaseConfig;
|
||||
use anyhow::{Context, Result};
|
||||
use std::fs::File;
|
||||
use std::io::{Read, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
@@ -12,11 +11,8 @@ pub async fn run(cfg: DatabaseConfig, restore_file: PathBuf, logger: Arc<JobLogg
|
||||
let handle = tokio::task::spawn_blocking(move || -> Result<()> {
|
||||
logger.log("info", format!("Starting restore for database {}", cfg.name));
|
||||
|
||||
let mut sql_content = String::new();
|
||||
let mut file = File::open(&restore_file)
|
||||
.with_context(|| format!("Failed to open restore file {}", restore_file.display()))?;
|
||||
file.read_to_string(&mut sql_content)
|
||||
.with_context(|| format!("Failed to read restore file {}", restore_file.display()))?;
|
||||
|
||||
let drop_create_cmd = format!(
|
||||
"DROP DATABASE IF EXISTS `{0}`; CREATE DATABASE `{0}`;",
|
||||
@@ -66,10 +62,8 @@ pub async fn run(cfg: DatabaseConfig, restore_file: PathBuf, logger: Arc<JobLogg
|
||||
.with_context(|| format!("Failed to start MariaDB restore for {}", cfg.name))?;
|
||||
|
||||
let mut stdin = child.stdin.take().context("Failed to open child stdin")?;
|
||||
stdin
|
||||
.write_all(sql_content.as_bytes())
|
||||
.context("Failed to write SQL content to MariaDB stdin")?;
|
||||
stdin.flush()?;
|
||||
std::io::copy(&mut file, &mut stdin)
|
||||
.context("Failed to stream SQL content to MariaDB stdin")?;
|
||||
drop(stdin);
|
||||
|
||||
let output = child
|
||||
|
||||
@@ -31,6 +31,10 @@ pub async fn run(
|
||||
|
||||
let file_path = backup_dir.join(format!("{}{}", cfg.generated_id, file_extension));
|
||||
|
||||
if let Ok(out) = Command::new("mysqldump").arg("--version").output() {
|
||||
logger.log("debug", format!("mysqldump client: {}", String::from_utf8_lossy(&out.stdout).trim()));
|
||||
}
|
||||
|
||||
logger.log("info", format!("Running mysqldump for {}", cfg.name));
|
||||
|
||||
let start = Instant::now();
|
||||
@@ -43,11 +47,15 @@ pub async fn run(
|
||||
.arg("--triggers")
|
||||
.arg("--verbose")
|
||||
.arg("--single-transaction")
|
||||
.arg("--set-gtid-purged=OFF")
|
||||
.arg("--no-tablespaces")
|
||||
.arg("--quick")
|
||||
.arg("--skip-lock-tables")
|
||||
.arg("--skip-add-drop-table")
|
||||
.arg("--no-create-db")
|
||||
.arg("--default-character-set=utf8mb4")
|
||||
.arg("--network-timeout")
|
||||
.arg(format!("--max-allowed-packet={}", cfg.max_packet_size))
|
||||
.arg(&cfg.database)
|
||||
.arg("-r").arg(&file_path)
|
||||
.envs(env)
|
||||
|
||||
@@ -2,7 +2,6 @@ use crate::services::backup::logger::JobLogger;
|
||||
use crate::services::config::DatabaseConfig;
|
||||
use anyhow::{Context, Result};
|
||||
use std::fs::File;
|
||||
use std::io::{Read, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
@@ -12,11 +11,8 @@ pub async fn run(cfg: DatabaseConfig, restore_file: PathBuf, logger: Arc<JobLogg
|
||||
let handle = tokio::task::spawn_blocking(move || -> Result<()> {
|
||||
logger.log("info", format!("Starting restore for database {}", cfg.name));
|
||||
|
||||
let mut sql_content = String::new();
|
||||
let mut file = File::open(&restore_file)
|
||||
.with_context(|| format!("Failed to open restore file {}", restore_file.display()))?;
|
||||
file.read_to_string(&mut sql_content)
|
||||
.with_context(|| format!("Failed to read restore file {}", restore_file.display()))?;
|
||||
|
||||
let drop_create_cmd = format!(
|
||||
"DROP DATABASE IF EXISTS `{0}`; CREATE DATABASE `{0}`;",
|
||||
@@ -66,10 +62,8 @@ pub async fn run(cfg: DatabaseConfig, restore_file: PathBuf, logger: Arc<JobLogg
|
||||
.with_context(|| format!("Failed to start mysql restore for {}", cfg.name))?;
|
||||
|
||||
let mut stdin = child.stdin.take().context("Failed to open child stdin")?;
|
||||
stdin
|
||||
.write_all(sql_content.as_bytes())
|
||||
.context("Failed to write SQL content to mysql stdin")?;
|
||||
stdin.flush()?;
|
||||
std::io::copy(&mut file, &mut stdin)
|
||||
.context("Failed to stream SQL content to mysql stdin")?;
|
||||
drop(stdin);
|
||||
|
||||
let output = child
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use anyhow::Result;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
@@ -13,6 +14,7 @@ pub async fn run(
|
||||
cfg: DatabaseConfig,
|
||||
format: PostgresDumpFormat,
|
||||
backup_dir: PathBuf,
|
||||
env: HashMap<String, String>,
|
||||
logger: Arc<JobLogger>,
|
||||
) -> Result<PathBuf> {
|
||||
tokio::task::spawn_blocking(move || -> Result<PathBuf> {
|
||||
@@ -37,18 +39,18 @@ pub async fn run(
|
||||
logger.log("info", format!("Running FC backup for {}", cfg.name));
|
||||
|
||||
let file_path = backup_dir.join(format!("{}.dump", cfg.generated_id));
|
||||
let url = format!(
|
||||
"postgresql://{}:{}@{}:{}/{}",
|
||||
cfg.username, cfg.password, cfg.host, cfg.port, cfg.database
|
||||
);
|
||||
|
||||
let start = Instant::now();
|
||||
let output = Command::new(&pg_dump)
|
||||
.arg("--dbname").arg(&url)
|
||||
.arg("--host").arg(&cfg.host)
|
||||
.arg("--port").arg(cfg.port.to_string())
|
||||
.arg("--username").arg(&cfg.username)
|
||||
.arg("--dbname").arg(&cfg.database)
|
||||
.arg("-Fc")
|
||||
.arg("-f").arg(&file_path)
|
||||
.arg("-v")
|
||||
.arg("--compress=3")
|
||||
.envs(env)
|
||||
.output();
|
||||
let duration_ms = start.elapsed().as_millis() as f64;
|
||||
|
||||
@@ -87,19 +89,19 @@ pub async fn run(
|
||||
return Err(e.into());
|
||||
}
|
||||
|
||||
let url = format!(
|
||||
"postgresql://{}:{}@{}:{}/{}",
|
||||
cfg.username, cfg.password, cfg.host, cfg.port, cfg.database
|
||||
);
|
||||
let cmd_label = format!("pg_dump -Fd {}", url);
|
||||
let cmd_label = format!("pg_dump -Fd {}@{}:{}/{}", cfg.username, cfg.host, cfg.port, cfg.database);
|
||||
|
||||
let start = Instant::now();
|
||||
let output = Command::new(&pg_dump)
|
||||
.arg("--dbname").arg(&url)
|
||||
.arg("--host").arg(&cfg.host)
|
||||
.arg("--port").arg(cfg.port.to_string())
|
||||
.arg("--username").arg(&cfg.username)
|
||||
.arg("--dbname").arg(&cfg.database)
|
||||
.arg("-Fd")
|
||||
.arg("-j").arg("4")
|
||||
.arg("-f").arg(&dump_dir)
|
||||
.arg("-v")
|
||||
.envs(env)
|
||||
.output();
|
||||
let duration_ms = start.elapsed().as_millis() as f64;
|
||||
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
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, NoTls};
|
||||
use tokio_postgres::{Client, Config, NoTls};
|
||||
use tracing::{error, info};
|
||||
|
||||
pub async fn connect(cfg: &DatabaseConfig) -> Result<Client> {
|
||||
info!("Connecting to postgres database {}:{}", cfg.host, cfg.port);
|
||||
let dsn = format!(
|
||||
"host={} port={} user={} password={} dbname={}",
|
||||
cfg.host, cfg.port, cfg.username, cfg.password, cfg.database
|
||||
);
|
||||
|
||||
let (client, connection) = tokio_postgres::connect(&dsn, NoTls).await?;
|
||||
let mut config = Config::new();
|
||||
config
|
||||
.host(&cfg.host)
|
||||
.port(cfg.port)
|
||||
.user(&cfg.username)
|
||||
.password(&cfg.password)
|
||||
.dbname(&cfg.database);
|
||||
|
||||
let (client, connection) = config.connect(NoTls).await?;
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = connection.await {
|
||||
error!("Postgres connection error: {}", e);
|
||||
@@ -28,11 +33,91 @@ pub async fn server_version(cfg: &DatabaseConfig) -> Result<String> {
|
||||
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<std::path::PathBuf> = 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<std::path::PathBuf> {
|
||||
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();
|
||||
@@ -68,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,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::{backup, format::PostgresDumpFormat, ping, restore};
|
||||
use crate::domain::factory::Database;
|
||||
use crate::services::backup::logger::JobLogger;
|
||||
@@ -18,6 +18,12 @@ impl PostgresDatabase {
|
||||
pub fn new(cfg: DatabaseConfig, format: PostgresDumpFormat) -> Self {
|
||||
Self { cfg, format }
|
||||
}
|
||||
|
||||
fn build_env(&self) -> HashMap<String, String> {
|
||||
let mut envs = std::env::vars().collect::<HashMap<_, _>>();
|
||||
envs.insert("PGPASSWORD".to_string(), self.cfg.password.to_string());
|
||||
envs
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -35,14 +41,28 @@ impl Database for PostgresDatabase {
|
||||
|
||||
async fn backup(&self, dir: &Path, logger: Arc<JobLogger>) -> Result<PathBuf> {
|
||||
FileLock::acquire(&self.cfg.generated_id, DbOpLock::Backup.as_str()).await?;
|
||||
let res = backup::run(self.cfg.clone(), self.format, dir.to_path_buf(), logger).await;
|
||||
let res = backup::run(
|
||||
self.cfg.clone(),
|
||||
self.format,
|
||||
dir.to_path_buf(),
|
||||
self.build_env(),
|
||||
logger,
|
||||
)
|
||||
.await;
|
||||
FileLock::release(&self.cfg.generated_id).await?;
|
||||
res
|
||||
}
|
||||
|
||||
async fn restore(&self, file: &Path, logger: Arc<JobLogger>) -> Result<()> {
|
||||
FileLock::acquire(&self.cfg.generated_id, DbOpLock::Restore.as_str()).await?;
|
||||
let res = restore::run(self.cfg.clone(), self.format, file.to_path_buf(), logger).await;
|
||||
let res = restore::run(
|
||||
self.cfg.clone(),
|
||||
self.format,
|
||||
file.to_path_buf(),
|
||||
self.build_env(),
|
||||
logger,
|
||||
)
|
||||
.await;
|
||||
FileLock::release(&self.cfg.generated_id).await?;
|
||||
res
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
pub mod backup;
|
||||
mod connection;
|
||||
pub(crate) mod connection;
|
||||
pub mod database;
|
||||
mod format;
|
||||
mod ping;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use anyhow::Result;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
@@ -13,6 +14,7 @@ pub async fn run(
|
||||
cfg: DatabaseConfig,
|
||||
format: PostgresDumpFormat,
|
||||
restore_file: PathBuf,
|
||||
env: HashMap<String, String>,
|
||||
logger: Arc<JobLogger>,
|
||||
) -> Result<()> {
|
||||
tokio::task::spawn_blocking(move || -> Result<()> {
|
||||
@@ -39,11 +41,6 @@ pub async fn run(
|
||||
}
|
||||
logger.log("info", format!("Connections terminated for database {}", cfg.name));
|
||||
|
||||
let url = format!(
|
||||
"postgresql://{}:{}@{}:{}/{}",
|
||||
cfg.username, cfg.password, cfg.host, cfg.port, cfg.database
|
||||
);
|
||||
|
||||
match format {
|
||||
PostgresDumpFormat::Fc => {
|
||||
logger.log("info", format!("Running FC restore for {}", cfg.name));
|
||||
@@ -54,11 +51,13 @@ pub async fn run(
|
||||
.arg("--clean")
|
||||
.arg("--if-exists")
|
||||
// .arg("--create")
|
||||
.arg("--dbname")
|
||||
.arg(&url)
|
||||
.arg("--host").arg(&cfg.host)
|
||||
.arg("--port").arg(cfg.port.to_string())
|
||||
.arg("--username").arg(&cfg.username)
|
||||
.arg("--dbname").arg(&cfg.database)
|
||||
.arg("-v")
|
||||
.arg(&restore_file)
|
||||
.env("PGPASSWORD", &cfg.password)
|
||||
.envs(env)
|
||||
.output();
|
||||
|
||||
let duration_ms = start.elapsed().as_millis() as f64;
|
||||
@@ -154,13 +153,15 @@ pub async fn run(
|
||||
.arg("--clean")
|
||||
.arg("--if-exists")
|
||||
// .arg("--create")
|
||||
.arg("--dbname")
|
||||
.arg(&url)
|
||||
.arg("--host").arg(&cfg.host)
|
||||
.arg("--port").arg(cfg.port.to_string())
|
||||
.arg("--username").arg(&cfg.username)
|
||||
.arg("--dbname").arg(&cfg.database)
|
||||
.arg("-v")
|
||||
.arg("-j")
|
||||
.arg("4")
|
||||
.arg(dump_dir)
|
||||
.env("PGPASSWORD", &cfg.password)
|
||||
.envs(env)
|
||||
.output();
|
||||
|
||||
let duration_ms = start.elapsed().as_millis() as f64;
|
||||
|
||||
@@ -54,6 +54,7 @@ pub struct DatabaseConfig {
|
||||
pub host: String,
|
||||
pub generated_id: String,
|
||||
pub path: String,
|
||||
pub max_packet_size: String,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
@@ -75,6 +76,7 @@ pub struct InputDatabaseConfig {
|
||||
pub host: Option<String>,
|
||||
pub generated_id: String,
|
||||
pub path: Option<String>,
|
||||
pub max_packet_size: Option<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
@@ -214,6 +216,13 @@ impl ConfigService {
|
||||
_ => optional(&db.path),
|
||||
};
|
||||
|
||||
let max_packet_size = match db.db_type {
|
||||
DbType::Mysql | DbType::Mariadb => {
|
||||
db.max_packet_size.unwrap_or_else(|| "512M".to_string())
|
||||
}
|
||||
_ => String::new(),
|
||||
};
|
||||
|
||||
databases.push(DatabaseConfig {
|
||||
name: db.name,
|
||||
database: database_name,
|
||||
@@ -224,6 +233,7 @@ impl ConfigService {
|
||||
port,
|
||||
generated_id: db.generated_id,
|
||||
path: path_val,
|
||||
max_packet_size,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -39,6 +39,7 @@ async fn create_config() -> (ContainerAsync<GenericImage>, DatabaseConfig) {
|
||||
host,
|
||||
generated_id: "3c445eb4-c2c6-4bde-a423-ee1385dcf6d2".to_string(),
|
||||
path: "".to_string(),
|
||||
max_packet_size: "".to_string(),
|
||||
};
|
||||
|
||||
(container, config)
|
||||
|
||||
@@ -31,6 +31,7 @@ async fn create_config() -> (ContainerAsync<Mariadb>, DatabaseConfig) {
|
||||
host: host.to_string(),
|
||||
generated_id: "3c4b4eb4-c2c6-4bde-a423-ee1385dcf6d2".to_string(),
|
||||
path: "".to_string(),
|
||||
max_packet_size: "512M".to_string(),
|
||||
};
|
||||
|
||||
(container, config)
|
||||
|
||||
@@ -29,6 +29,7 @@ async fn create_config() -> (ContainerAsync<Mongo>, DatabaseConfig) {
|
||||
host: host.to_string(),
|
||||
generated_id: "96d30a9f-ff4b-47c9-aaab-f3147bb34f16".to_string(),
|
||||
path: "".to_string(),
|
||||
max_packet_size: "".to_string(),
|
||||
};
|
||||
|
||||
(container, config)
|
||||
|
||||
@@ -54,6 +54,7 @@ fn make_config(host: String, port: u16, database: &str, generated_id: &str) -> D
|
||||
host,
|
||||
generated_id: generated_id.to_string(),
|
||||
path: "".to_string(),
|
||||
max_packet_size: "".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ async fn create_config() -> (ContainerAsync<Mysql>, DatabaseConfig) {
|
||||
host: host.to_string(),
|
||||
generated_id: "0f1bb8f2-35a0-4c91-8098-e36873d3ce31".to_string(),
|
||||
path: "".to_string(),
|
||||
max_packet_size: "512M".to_string(),
|
||||
};
|
||||
|
||||
(container, config)
|
||||
|
||||
@@ -38,6 +38,7 @@ async fn create_config() -> (ContainerAsync<Postgres>, DatabaseConfig) {
|
||||
host: host.to_string(),
|
||||
generated_id: "40875631-e3d2-4dfe-a26b-2a347ecc64fd".to_string(),
|
||||
path: "".to_string(),
|
||||
max_packet_size: "".to_string(),
|
||||
};
|
||||
|
||||
(container, config)
|
||||
@@ -107,3 +108,105 @@ async fn postgres_backup_restore_test() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn postgres_password_with_slash_test() {
|
||||
init_tracing_for_test();
|
||||
|
||||
let special_password = "ch/ange:me@1";
|
||||
|
||||
let container = Postgres::default()
|
||||
.with_env_var("POSTGRES_DB", "testdb")
|
||||
.with_env_var("POSTGRES_USER", "testuser")
|
||||
.with_env_var("POSTGRES_PASSWORD", special_password)
|
||||
.with_tag("17")
|
||||
.start()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let host = container
|
||||
.get_host()
|
||||
.await
|
||||
.unwrap_or(Host::parse("127.0.0.1").unwrap());
|
||||
|
||||
let port = container.get_host_port_ipv4(5432).await.unwrap_or(5432);
|
||||
|
||||
let config = DatabaseConfig {
|
||||
name: "My test Postgres Database with slash password".to_string(),
|
||||
database: "testdb".to_string(),
|
||||
db_type: DbType::Postgresql,
|
||||
username: "testuser".to_string(),
|
||||
password: special_password.to_string(),
|
||||
port,
|
||||
host: host.to_string(),
|
||||
generated_id: "5a1f0e3c-9b8a-4a8e-9b1b-0a1c2d3e4f5a".to_string(),
|
||||
path: "".to_string(),
|
||||
max_packet_size: "".to_string(),
|
||||
};
|
||||
|
||||
let db = DatabaseFactory::create_for_backup(config.clone()).await;
|
||||
let reachable = db.ping().await.unwrap_or(false);
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ async fn create_config() -> (ContainerAsync<Redis>, DatabaseConfig) {
|
||||
host: host.to_string(),
|
||||
generated_id: "40875631-e3d2-4dfe-a26b-2a347ecc64fd".to_string(),
|
||||
path: "".to_string(),
|
||||
max_packet_size: "".to_string(),
|
||||
};
|
||||
|
||||
(container, config)
|
||||
|
||||
@@ -27,6 +27,7 @@ async fn create_config() -> (ContainerAsync<Valkey>, DatabaseConfig) {
|
||||
host: host.to_string(),
|
||||
generated_id: "40875485-e3d2-4dfe-a26b-2a347ecc64fd".to_string(),
|
||||
path: "".to_string(),
|
||||
max_packet_size: "".to_string(),
|
||||
};
|
||||
|
||||
(container, config)
|
||||
|
||||
Reference in New Issue
Block a user