"""Daily configuration backup to Gitea — executed after CEO approval.""" from __future__ import annotations import json import os import subprocess from datetime import datetime, timezone from pathlib import Path from typing import Any from app.db import execute, fetch_all, fetch_one BACKUP_ROOT = Path(os.getenv("BACKUP_ROOT", "/data/backup-root")) GITEA_REMOTE = os.getenv( "GITEA_BACKUP_REMOTE", "http://sysops:Foodlinkk%23SysOps2026@gitea:3001/aissa/foodlinkk-command-center.git", ) GITEA_GIT_USER = os.getenv("GITEA_SYSOPS_USER", "sysops") GITEA_GIT_EMAIL = os.getenv("GITEA_SYSOPS_EMAIL", "sysops@foodlinkk.local") GITEA_GIT_NAME = os.getenv("GITEA_SYSOPS_NAME", "sysops") GITEA_BRANCH = os.getenv("GITEA_BACKUP_BRANCH", "main") BACKUP_PATHS = [ "docker-compose.yml", ".env.example", "migrations", "monitoring", "cockpit/requirements.txt", "tools-api/requirements.txt", "deploy-all.sh", "README.md", ] def _iso_now() -> str: return datetime.now(timezone.utc).isoformat() def _run(cmd: list[str], cwd: Path | None = None, timeout: int = 120) -> dict[str, Any]: try: proc = subprocess.run( # noqa: S603 cmd, cwd=str(cwd) if cwd else None, capture_output=True, text=True, timeout=timeout, check=False, ) except subprocess.TimeoutExpired: return {"ok": False, "error": "timeout", "cmd": cmd} except FileNotFoundError as exc: return {"ok": False, "error": str(exc), "cmd": cmd} return { "ok": proc.returncode == 0, "code": proc.returncode, "stdout": (proc.stdout or "").strip(), "stderr": (proc.stderr or "").strip(), "cmd": cmd, } def _table_exists(name: str) -> bool: row = fetch_one( """ SELECT EXISTS( SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = %s ) AS ok """, (name,), ) return bool(row and row.get("ok")) def _record_backup( status: str, message: str, files_count: int = 0, commit_ref: str | None = None, approval_request_id: int | None = None, payload: dict | None = None, ) -> dict[str, Any] | None: if not _table_exists("config_backups"): return None row = fetch_one( """ INSERT INTO config_backups (approval_request_id, commit_ref, files_count, status, message, payload) VALUES (%s, %s, %s, %s, %s, %s::jsonb) RETURNING id, created_at """, ( approval_request_id, commit_ref, files_count, status, message, json.dumps(payload or {}), ), ) return dict(row) if row else None def list_backups(limit: int = 20) -> list[dict[str, Any]]: if not _table_exists("config_backups"): return [] rows = fetch_all( "SELECT * FROM config_backups ORDER BY created_at DESC LIMIT %s", (max(1, min(limit, 100)),), ) out = [] for r in rows: item = dict(r) if hasattr(item.get("created_at"), "isoformat"): item["created_at"] = item["created_at"].isoformat() out.append(item) return out def prepare_backup_manifest() -> dict[str, Any]: root = BACKUP_ROOT files: list[str] = [] if root.exists(): for rel in BACKUP_PATHS: p = root / rel if p.is_file(): files.append(rel) elif p.is_dir(): for fp in p.rglob("*"): if fp.is_file() and not any(x in str(fp) for x in [".git", "__pycache__", ".pyc"]): files.append(str(fp.relative_to(root))) return { "root": str(root), "root_exists": root.exists(), "files": sorted(files)[:500], "files_count": len(files), "paths_configured": BACKUP_PATHS, "generated_at": _iso_now(), } def run_gitea_backup(approval_request_id: int | None = None) -> dict[str, Any]: from app.connectors.gitea_repo_sync import run_gitea_repo_sync return run_gitea_repo_sync(reason="approved_backup", approval_request_id=approval_request_id)