Files
foodlinkk-command-center/tools-api/app/connectors/config_backup.py
T
2026-06-09 10:41:13 +00:00

145 lines
4.1 KiB
Python

"""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)