Fix backup pg_dump resolution across environments

This commit is contained in:
Christian 2026-05-02 11:13:18 +02:00
parent 5ee962fdb3
commit 2cef28ff3b

View File

@ -8,6 +8,7 @@ import logging
import hashlib
import tarfile
import subprocess
import shutil
import fcntl
from pathlib import Path
from datetime import datetime, timedelta
@ -51,6 +52,29 @@ class BackupService:
self.files_dir = self.backup_dir / "files"
self.db_dir.mkdir(exist_ok=True)
self.files_dir.mkdir(exist_ok=True)
def _resolve_pg_binary(self, binary_name: str) -> str:
"""Resolve PostgreSQL CLI binaries across container/host environments."""
found = shutil.which(binary_name)
if found:
return found
candidates = [
f"/usr/bin/{binary_name}",
f"/usr/local/bin/{binary_name}",
f"/opt/homebrew/bin/{binary_name}",
f"/usr/lib/postgresql/16/bin/{binary_name}",
f"/usr/lib/postgresql/15/bin/{binary_name}",
f"/usr/lib/postgresql/14/bin/{binary_name}",
]
for candidate in candidates:
if Path(candidate).exists():
return candidate
raise FileNotFoundError(
f"{binary_name} blev ikke fundet i PATH. Installer postgresql-client eller rebuild API image."
)
async def create_database_backup(self, is_monthly: bool = False) -> Optional[int]:
"""
@ -83,6 +107,8 @@ class BackupService:
job_id, backup_format, is_monthly)
try:
pg_dump_bin = self._resolve_pg_binary('pg_dump')
# Build pg_dump command - connect via network to postgres service
env = os.environ.copy()
env['PGPASSWORD'] = settings.DATABASE_URL.split(':')[2].split('@')[0] # Extract password
@ -102,10 +128,10 @@ class BackupService:
if backup_format == 'dump':
# Compressed custom format (-Fc)
cmd = ['pg_dump', '-h', host, '-U', user, '-Fc', dbname]
cmd = [pg_dump_bin, '-h', host, '-U', user, '-Fc', dbname]
else:
# Plain SQL format
cmd = ['pg_dump', '-h', host, '-U', user, dbname]
cmd = [pg_dump_bin, '-h', host, '-U', user, dbname]
# Execute pg_dump and write to file
logger.info("📦 Executing: %s > %s", ' '.join(cmd), backup_path)
@ -154,6 +180,21 @@ class BackupService:
backup_path.unlink()
return None
except FileNotFoundError as e:
error_msg = str(e)
logger.error("❌ Database backup failed: %s", error_msg)
execute_update(
"""UPDATE backup_jobs
SET status = %s, completed_at = %s, error_message = %s
WHERE id = %s""",
('failed', datetime.now(), error_msg, job_id)
)
if backup_path.exists():
backup_path.unlink()
return None
async def create_files_backup(self) -> Optional[int]:
"""
@ -411,10 +452,13 @@ class BackupService:
dbname = host_db[1] if len(host_db) > 1 else 'bmc_hub'
env['PGPASSWORD'] = password
psql_bin = self._resolve_pg_binary('psql')
pg_restore_bin = self._resolve_pg_binary('pg_restore')
# Step 1: Create new empty database
logger.info("📦 Creating new database: %s", new_dbname)
create_cmd = ['psql', '-h', host, '-U', user, '-d', 'postgres', '-c',
create_cmd = [psql_bin, '-h', host, '-U', user, '-d', 'postgres', '-c',
f"CREATE DATABASE {new_dbname} OWNER {user};"]
result = subprocess.run(create_cmd, stderr=subprocess.PIPE, stdout=subprocess.PIPE,
text=True, env=env)
@ -430,7 +474,7 @@ class BackupService:
# Build restore command based on format
if backup['backup_format'] == 'dump':
# Restore from compressed custom format
cmd = ['pg_restore', '-h', host, '-U', user, '-d', new_dbname]
cmd = [pg_restore_bin, '-h', host, '-U', user, '-d', new_dbname]
logger.info("📥 Restoring to %s: %s < %s", new_dbname, ' '.join(cmd), backup_path)
@ -461,7 +505,7 @@ class BackupService:
if has_real_errors and not is_harmless:
logger.error("❌ pg_restore had REAL errors: %s", result.stderr[:1000])
# Try to drop the failed database
subprocess.run(['psql', '-h', host, '-U', user, '-d', 'postgres', '-c',
subprocess.run([psql_bin, '-h', host, '-U', user, '-d', 'postgres', '-c',
f"DROP DATABASE IF EXISTS {new_dbname};"], env=env)
raise RuntimeError(f"pg_restore failed with errors")
else:
@ -469,7 +513,7 @@ class BackupService:
else:
# Restore from plain SQL
cmd = ['psql', '-h', host, '-U', user, '-d', new_dbname]
cmd = [psql_bin, '-h', host, '-U', user, '-d', new_dbname]
logger.info("📥 Executing: %s < %s", ' '.join(cmd), backup_path)