diff --git a/app/backups/backend/service.py b/app/backups/backend/service.py index 17a7b06..ab4ac39 100644 --- a/app/backups/backend/service.py +++ b/app/backups/backend/service.py @@ -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)