| |
| """ |
| Backup and Restore utility for Sydeco SQLite database. |
| Handles online backups, encryption with a separate key, and retention policy. |
| """ |
| import os |
| import sys |
| import time |
| import sqlite3 |
| import datetime |
| import shutil |
| from cryptography.fernet import Fernet |
|
|
| |
| sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) |
|
|
| def get_backup_dir() -> str: |
| backup_dir = os.getenv("LDV_BACKUP_DIR", os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "backups")) |
| os.makedirs(backup_dir, exist_ok=True) |
| return backup_dir |
|
|
| def get_backup_key() -> bytes: |
| key = os.getenv("LDV_BACKUP_ENCRYPTION_KEY", "").strip() |
| if not key: |
| if os.getenv("LDV_PRODUCTION") == "1": |
| raise ValueError("LDV_BACKUP_ENCRYPTION_KEY must be set in production mode") |
| |
| print("WARNING: LDV_BACKUP_ENCRYPTION_KEY not set. Using ephemeral/testing key.", file=sys.stderr) |
| key = Fernet.generate_key().decode() |
| return key.encode() |
|
|
| def run_backup() -> str: |
| """Perform online SQLite backup, encrypt it, save to backups/, and run retention cleanup.""" |
| import database |
| |
| db_path = database.get_db_path() |
| if not os.path.exists(db_path): |
| raise FileNotFoundError(f"Database not found at {db_path}") |
|
|
| now = datetime.datetime.now() |
| date_str = now.strftime("%Y-%m-%d_%H%M%S") |
| backup_dir = get_backup_dir() |
| |
| |
| |
| |
| |
| category = "daily" |
| if now.day == 1: |
| category = "monthly" |
| elif now.weekday() == 6: |
| category = "weekly" |
| |
| temp_db_path = os.path.join(backup_dir, f"temp_backup_{date_str}.db") |
| final_enc_path = os.path.join(backup_dir, f"db_backup_{category}_{date_str}.enc") |
|
|
| |
| src_conn = sqlite3.connect(db_path) |
| dst_conn = sqlite3.connect(temp_db_path) |
| try: |
| src_conn.backup(dst_conn) |
| finally: |
| dst_conn.close() |
| src_conn.close() |
|
|
| |
| key = get_backup_key() |
| fernet = Fernet(key) |
| with open(temp_db_path, "rb") as f: |
| plaintext_data = f.read() |
| |
| ciphertext = fernet.encrypt(plaintext_data) |
| with open(final_enc_path, "wb") as f: |
| f.write(ciphertext) |
| |
| |
| os.remove(temp_db_path) |
| |
| print(f"Encrypted backup saved to: {final_enc_path}") |
| |
| |
| try: |
| database.write_audit("backup.create", user_id=None, org_id=None, resource_id=category, detail=final_enc_path) |
| except Exception: |
| pass |
| |
| |
| enforce_retention(backup_dir) |
| |
| return final_enc_path |
|
|
| def enforce_retention(backup_dir: str): |
| """Keep 7 daily, 4 weekly, and 6 monthly backups. Remove older ones.""" |
| |
| files = [f for f in os.listdir(backup_dir) if f.startswith("db_backup_") and f.endswith(".enc")] |
| |
| dailies = [] |
| weeklies = [] |
| monthlies = [] |
| |
| for f in files: |
| path = os.path.join(backup_dir, f) |
| if "_daily_" in f: |
| dailies.append(path) |
| elif "_weekly_" in f: |
| weeklies.append(path) |
| elif "_monthly_" in f: |
| monthlies.append(path) |
| |
| |
| dailies.sort(key=os.path.getmtime) |
| weeklies.sort(key=os.path.getmtime) |
| monthlies.sort(key=os.path.getmtime) |
| |
| |
| to_delete = [] |
| if len(dailies) > 7: |
| to_delete.extend(dailies[:-7]) |
| if len(weeklies) > 4: |
| to_delete.extend(weeklies[:-4]) |
| if len(monthlies) > 6: |
| to_delete.extend(monthlies[:-6]) |
| |
| for p in to_delete: |
| try: |
| os.remove(p) |
| print(f"Retention: Deleted old backup {os.path.basename(p)}") |
| except Exception as e: |
| print(f"Error deleting {p}: {e}", file=sys.stderr) |
|
|
| def run_restore(backup_path: str, target_db_path: str, key_override: bytes = None) -> bool: |
| """Decrypt backup file, verify SQLite integrity, and copy to target database path.""" |
| if not os.path.exists(backup_path): |
| raise FileNotFoundError(f"Backup file not found at {backup_path}") |
| |
| key = key_override or get_backup_key() |
| fernet = Fernet(key) |
| |
| with open(backup_path, "rb") as f: |
| ciphertext = f.read() |
| |
| try: |
| plaintext_data = fernet.decrypt(ciphertext) |
| except Exception as e: |
| raise ValueError(f"Decryption failed: check backup encryption key. Error: {e}") |
| |
| |
| temp_restored_path = target_db_path + ".restore_temp" |
| with open(temp_restored_path, "wb") as f: |
| f.write(plaintext_data) |
| |
| |
| try: |
| conn = sqlite3.connect(temp_restored_path) |
| cursor = conn.cursor() |
| cursor.execute("PRAGMA integrity_check") |
| status = cursor.fetchone()[0] |
| conn.close() |
| |
| if status != "ok": |
| os.remove(temp_restored_path) |
| raise ValueError(f"SQLite integrity check failed: {status}") |
| |
| except Exception as e: |
| if os.path.exists(temp_restored_path): |
| os.remove(temp_restored_path) |
| raise ValueError(f"Integrity check failed: {e}") |
| |
| |
| |
| if os.path.exists(target_db_path): |
| shutil.move(target_db_path, target_db_path + ".bak") |
| |
| try: |
| shutil.move(temp_restored_path, target_db_path) |
| |
| if os.path.exists(target_db_path + ".bak"): |
| os.remove(target_db_path + ".bak") |
| print(f"Successfully restored database to {target_db_path}") |
| return True |
| except Exception as e: |
| |
| if os.path.exists(target_db_path + ".bak"): |
| shutil.move(target_db_path + ".bak", target_db_path) |
| raise e |
|
|
| if __name__ == "__main__": |
| if len(sys.argv) < 2: |
| print("Usage: python3 backup.py [backup|restore <backup_path> <target_path>]") |
| sys.exit(1) |
| |
| cmd = sys.argv[1] |
| if cmd == "backup": |
| try: |
| run_backup() |
| print("Backup completed successfully.") |
| except Exception as e: |
| print(f"Backup failed: {e}", file=sys.stderr) |
| sys.exit(1) |
| elif cmd == "restore": |
| if len(sys.argv) < 4: |
| print("Usage: python3 backup.py restore <backup_path> <target_path>") |
| sys.exit(1) |
| b_path = sys.argv[2] |
| t_path = sys.argv[3] |
| try: |
| run_restore(b_path, t_path) |
| print("Restore completed successfully.") |
| except Exception as e: |
| print(f"Restore failed: {e}", file=sys.stderr) |
| sys.exit(1) |
| else: |
| print(f"Unknown command: {cmd}") |
| sys.exit(1) |
|
|