"""Volledige codebase sync naar Gitea main โ€” SysOps.""" from __future__ import annotations import json import os import shutil import subprocess from datetime import datetime, timezone from pathlib import Path from typing import Any from app.db import execute, fetch_all, fetch_one from app.middleware import log_agent_event REPO_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") SKIP_NAMES = { ".git", "__pycache__", ".env", "node_modules", ".cursor", ".venv", "venv", "*.pyc", } def _iso_now() -> str: return datetime.now(timezone.utc).isoformat() def _run(cmd: list[str], cwd: Path | None = None, timeout: int = 180) -> 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_activity( action_type: str, title: str, body: str = "", commit_ref: str | None = None, files_changed: int = 0, status: str = "success", metadata: dict | None = None, ) -> dict[str, Any] | None: if not _table_exists("sysops_activity"): return None row = fetch_one( """ INSERT INTO sysops_activity (action_type, title, body, commit_ref, files_changed, status, metadata) VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb) RETURNING id, created_at """, (action_type, title, body, commit_ref, files_changed, status, json.dumps(metadata or {})), ) return dict(row) if row else None def list_activity(limit: int = 30) -> list[dict[str, Any]]: if not _table_exists("sysops_activity"): return [] rows = fetch_all( "SELECT * FROM sysops_activity 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 _should_skip(name: str) -> bool: if name in SKIP_NAMES: return True if name.endswith(".pyc") or name.endswith(".pyo"): return True return False def _copy_tree(src: Path, dest: Path) -> int: """Kopieer repo-inhoud naar git worktree; retour aantal bestanden.""" count = 0 if not src.exists(): return 0 for item in src.iterdir(): if _should_skip(item.name): continue target = dest / item.name if item.is_file(): target.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(item, target) count += 1 elif item.is_dir(): if target.exists(): shutil.rmtree(target, ignore_errors=True) shutil.copytree( item, target, ignore=shutil.ignore_patterns("__pycache__", "*.pyc", ".env", "node_modules", ".git"), ) count += sum(1 for _ in target.rglob("*") if _.is_file()) return count def run_gitea_repo_sync(reason: str = "deploy", approval_request_id: int | None = None) -> dict[str, Any]: """Push volledige codebase naar Gitea main branch.""" work = Path("/tmp/foodlinkk-gitea-sync") if work.exists(): shutil.rmtree(work, ignore_errors=True) work.mkdir(parents=True, exist_ok=True) if not REPO_ROOT.exists(): msg = f"Repo root niet gevonden: {REPO_ROOT}" _record_activity("gitea_sync", "Gitea sync mislukt", msg, status="failed") return {"ok": False, "message": msg} repo = work / "repo" clone = _run(["git", "clone", "--depth", "1", "-b", GITEA_BRANCH, GITEA_REMOTE, str(repo)]) if not clone.get("ok"): repo = work _run(["git", "init", "-b", GITEA_BRANCH], cwd=repo) _run(["git", "remote", "add", "origin", GITEA_REMOTE], cwd=repo) _run(["git", "config", "user.email", GITEA_GIT_EMAIL], cwd=repo) _run(["git", "config", "user.name", GITEA_GIT_NAME], cwd=repo) copied = _copy_tree(REPO_ROOT, repo) stamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") commit_msg = f"SysOps: {reason} โ€” {stamp}" _run(["git", "add", "-A"], cwd=repo) commit = _run(["git", "commit", "-m", commit_msg], cwd=repo) nothing = "nothing to commit" in (commit.get("stdout", "") + commit.get("stderr", "")) if not commit.get("ok") and not nothing: msg = "Git commit mislukt: " + (commit.get("stderr") or "") _record_activity("gitea_sync", "Gitea commit mislukt", msg, files_changed=copied, status="failed") return {"ok": False, "message": msg, "copied": copied} commit_ref = None if not nothing: push = _run(["git", "push", "-u", "origin", GITEA_BRANCH], cwd=repo, timeout=300) if not push.get("ok"): msg = "Git push mislukt: " + (push.get("stderr") or "") _record_activity("gitea_sync", "Gitea push mislukt", msg, files_changed=copied, status="failed") return {"ok": False, "message": msg, "copied": copied} rev = _run(["git", "rev-parse", "--short", "HEAD"], cwd=repo) commit_ref = rev.get("stdout") or None summary = ( f"Volledige codebase gesynchroniseerd naar Gitea ({reason}). " f"{copied} bestanden ยท commit {commit_ref or 'geen wijzigingen'}" ) activity = _record_activity( "gitea_sync", f"Gitea push: {reason}", summary, commit_ref=commit_ref, files_changed=copied, status="success" if (commit_ref or nothing) else "skipped", metadata={"reason": reason, "approval_request_id": approval_request_id, "nothing_to_commit": nothing}, ) try: log_agent_event( agent_name="sysops", agent_type="sysops", event_type="gitea_sync", title=f"Gitea sync: {reason}" + (f" ({commit_ref})" if commit_ref else " โ€” geen wijzigingen"), body=summary, metadata={ "commit_ref": commit_ref, "files_changed": copied, "reason": reason, "for_herman": True, }, channel="sysops", ) except Exception: pass if _table_exists("config_backups"): 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 """, ( approval_request_id, commit_ref, copied, "success" if commit_ref else "unchanged", summary, json.dumps({"reason": reason, "full_repo": True}), ), ) return { "ok": True, "message": summary, "commit_ref": commit_ref, "files_count": copied, "nothing_to_commit": nothing, "activity_id": (activity or {}).get("id"), }