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 hashlib
import tarfile import tarfile
import subprocess import subprocess
import shutil
import fcntl import fcntl
from pathlib import Path from pathlib import Path
from datetime import datetime, timedelta from datetime import datetime, timedelta
@ -51,6 +52,29 @@ class BackupService:
self.files_dir = self.backup_dir / "files" self.files_dir = self.backup_dir / "files"
self.db_dir.mkdir(exist_ok=True) self.db_dir.mkdir(exist_ok=True)
self.files_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]: async def create_database_backup(self, is_monthly: bool = False) -> Optional[int]:
""" """
@ -83,6 +107,8 @@ class BackupService:
job_id, backup_format, is_monthly) job_id, backup_format, is_monthly)
try: try:
pg_dump_bin = self._resolve_pg_binary('pg_dump')
# Build pg_dump command - connect via network to postgres service # Build pg_dump command - connect via network to postgres service
env = os.environ.copy() env = os.environ.copy()
env['PGPASSWORD'] = settings.DATABASE_URL.split(':')[2].split('@')[0] # Extract password env['PGPASSWORD'] = settings.DATABASE_URL.split(':')[2].split('@')[0] # Extract password
@ -102,10 +128,10 @@ class BackupService:
if backup_format == 'dump': if backup_format == 'dump':
# Compressed custom format (-Fc) # Compressed custom format (-Fc)
cmd = ['pg_dump', '-h', host, '-U', user, '-Fc', dbname] cmd = [pg_dump_bin, '-h', host, '-U', user, '-Fc', dbname]
else: else:
# Plain SQL format # 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 # Execute pg_dump and write to file
logger.info("📦 Executing: %s > %s", ' '.join(cmd), backup_path) logger.info("📦 Executing: %s > %s", ' '.join(cmd), backup_path)
@ -154,6 +180,21 @@ class BackupService:
backup_path.unlink() backup_path.unlink()
return None 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]: 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' dbname = host_db[1] if len(host_db) > 1 else 'bmc_hub'
env['PGPASSWORD'] = password 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 # Step 1: Create new empty database
logger.info("📦 Creating new database: %s", new_dbname) 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};"] f"CREATE DATABASE {new_dbname} OWNER {user};"]
result = subprocess.run(create_cmd, stderr=subprocess.PIPE, stdout=subprocess.PIPE, result = subprocess.run(create_cmd, stderr=subprocess.PIPE, stdout=subprocess.PIPE,
text=True, env=env) text=True, env=env)
@ -430,7 +474,7 @@ class BackupService:
# Build restore command based on format # Build restore command based on format
if backup['backup_format'] == 'dump': if backup['backup_format'] == 'dump':
# Restore from compressed custom format # 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) 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: if has_real_errors and not is_harmless:
logger.error("❌ pg_restore had REAL errors: %s", result.stderr[:1000]) logger.error("❌ pg_restore had REAL errors: %s", result.stderr[:1000])
# Try to drop the failed database # 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) f"DROP DATABASE IF EXISTS {new_dbname};"], env=env)
raise RuntimeError(f"pg_restore failed with errors") raise RuntimeError(f"pg_restore failed with errors")
else: else:
@ -469,7 +513,7 @@ class BackupService:
else: else:
# Restore from plain SQL # 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) logger.info("📥 Executing: %s < %s", ' '.join(cmd), backup_path)