176 lines
5.7 KiB
Python
176 lines
5.7 KiB
Python
"""PostgreSQL database backup and restore helpers for the admin panel.
|
|
|
|
Unlike the original SQLite-based script, this module works against the
|
|
PostgreSQL database configured via DATABASE_URL. It shells out to the
|
|
standard ``pg_dump`` / ``pg_restore`` / ``psql`` command-line utilities.
|
|
|
|
Configuration via environment variables:
|
|
BACKUP_DIR: Directory to store backups (default: ./backups)
|
|
BACKUP_RETENTION_DAYS: Number of days to keep backups (default: 30)
|
|
DATABASE_URL: PostgreSQL connection string (required for connectivity)
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
import subprocess
|
|
from datetime import datetime, timedelta
|
|
from urllib.parse import urlparse
|
|
|
|
BACKUP_DIR = os.getenv('BACKUP_DIR', os.path.join(os.getcwd(), 'backups'))
|
|
BACKUP_RETENTION_DAYS = int(os.getenv('BACKUP_RETENTION_DAYS', 30))
|
|
|
|
|
|
def _database_url():
|
|
"""Return the DATABASE_URL, normalizing to the psycopg2 SQLAlchemy form."""
|
|
url = os.getenv('DATABASE_URL')
|
|
if not url:
|
|
raise RuntimeError('DATABASE_URL environment variable must be set')
|
|
# SQLAlchemy may prefix driver; strip it for command-line tools.
|
|
return re.sub(r'^postgresql\+[^:]+://', 'postgresql://', url)
|
|
|
|
|
|
def _pg_env():
|
|
"""Return the dict of PG* variables required by pg_dump / psql."""
|
|
parsed = urlparse(_database_url())
|
|
env = os.environ.copy()
|
|
# PGPASSWORD avoids interactive password prompts.
|
|
if parsed.password is not None:
|
|
env['PGPASSWORD'] = parsed.password
|
|
env.pop('PGHOST', None)
|
|
env.pop('PGPORT', None)
|
|
env.pop('PGUSER', None)
|
|
env.pop('PGDATABASE', None)
|
|
return env
|
|
|
|
|
|
def _pg_args():
|
|
"""Return [host, port, user, dbname] positional args for pg tools."""
|
|
parsed = urlparse(_database_url())
|
|
args = []
|
|
if parsed.hostname:
|
|
args += ['--host', parsed.hostname]
|
|
if parsed.port:
|
|
args += ['--port', str(parsed.port)]
|
|
if parsed.username:
|
|
args += ['--username', parsed.username]
|
|
if parsed.path and parsed.path.lstrip('/'):
|
|
args += ['--dbname', parsed.path.lstrip('/')]
|
|
return args
|
|
|
|
|
|
def create_backup_dir():
|
|
"""Create the backup directory if it doesn't exist."""
|
|
os.makedirs(BACKUP_DIR, exist_ok=True)
|
|
|
|
|
|
def create_backup(backup_type='manual', notes=None):
|
|
"""Create a PostgreSQL dump backup.
|
|
|
|
Returns:
|
|
dict: {'filename', 'file_path', 'size_bytes'} or None on failure.
|
|
"""
|
|
create_backup_dir()
|
|
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
|
filename = f'db_backup_{backup_type}_{timestamp}.sql'
|
|
file_path = os.path.join(BACKUP_DIR, filename)
|
|
|
|
cmd = ['pg_dump'] + _pg_args() + ['--format', 'plain', '--no-owner', '--file', file_path]
|
|
try:
|
|
result = subprocess.run(cmd, env=_pg_env(), capture_output=True, text=True)
|
|
if result.returncode != 0:
|
|
_cleanup_failed_file(file_path)
|
|
raise RuntimeError(result.stderr.strip() or 'pg_dump failed')
|
|
except FileNotFoundError:
|
|
_cleanup_failed_file(file_path)
|
|
raise RuntimeError('pg_dump executable not found on PATH')
|
|
except Exception:
|
|
_cleanup_failed_file(file_path)
|
|
raise
|
|
|
|
if not os.path.exists(file_path):
|
|
raise RuntimeError('Backup file was not created')
|
|
|
|
size = os.path.getsize(file_path)
|
|
return {'filename': filename, 'file_path': file_path, 'size_bytes': size}
|
|
|
|
|
|
def _cleanup_failed_file(file_path):
|
|
"""Remove a partially-written dump file, ignoring errors."""
|
|
if file_path and os.path.exists(file_path):
|
|
try:
|
|
os.remove(file_path)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def restore_backup(file_path):
|
|
"""Restore a PostgreSQL dump file using psql.
|
|
|
|
Args:
|
|
file_path: Path to a plain-text .sql dump produced by pg_dump.
|
|
|
|
Raises:
|
|
RuntimeError: If psql fails or is unavailable.
|
|
"""
|
|
if not file_path or not os.path.exists(file_path):
|
|
raise RuntimeError('Backup file does not exist')
|
|
|
|
# The dump was produced with --no-owner + plain format, so we can
|
|
# pipe it into psql. Use --set ON_ERROR_STOP=1 to fail fast.
|
|
cmd = ['psql'] + _pg_args() + ['--set', 'ON_ERROR_STOP=1', '--file', file_path]
|
|
try:
|
|
result = subprocess.run(cmd, env=_pg_env(), capture_output=True, text=True)
|
|
if result.returncode != 0:
|
|
raise RuntimeError(result.stderr.strip() or 'psql restore failed')
|
|
except FileNotFoundError:
|
|
raise RuntimeError('psql executable not found on PATH')
|
|
|
|
|
|
def cleanup_old_backups():
|
|
"""Remove backup files older than BACKUP_RETENTION_DAYS."""
|
|
if not os.path.exists(BACKUP_DIR):
|
|
return 0
|
|
|
|
cutoff = datetime.now() - timedelta(days=BACKUP_RETENTION_DAYS)
|
|
removed_count = 0
|
|
for filename in os.listdir(BACKUP_DIR):
|
|
file_path = os.path.join(BACKUP_DIR, filename)
|
|
if not os.path.isfile(file_path):
|
|
continue
|
|
file_time = datetime.fromtimestamp(os.path.getmtime(file_path))
|
|
if file_time < cutoff:
|
|
try:
|
|
os.remove(file_path)
|
|
removed_count += 1
|
|
except OSError:
|
|
pass
|
|
return removed_count
|
|
|
|
|
|
def main():
|
|
"""Run a manual backup from the CLI (for cron/Task Scheduler)."""
|
|
print('=== Team Tryouts PostgreSQL Backup ===')
|
|
print(f'Started at: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}')
|
|
print(f'Backup directory: {BACKUP_DIR}')
|
|
print(f'Retention period: {BACKUP_RETENTION_DAYS} days')
|
|
print()
|
|
|
|
try:
|
|
result = create_backup(backup_type='scheduled')
|
|
except RuntimeError as e:
|
|
print(f'[ERROR] {e}')
|
|
return 1
|
|
|
|
print(f'[OK] Database backed up to: {result["file_path"]}')
|
|
|
|
removed = cleanup_old_backups()
|
|
if removed:
|
|
print(f'[CLEANUP] Removed {removed} old backup(s).')
|
|
|
|
print()
|
|
print('=== Backup completed successfully ===')
|
|
return 0
|
|
|
|
|
|
if __name__ == '__main__':
|
|
exit(main()) |