249 lines
8.0 KiB
Python
249 lines
8.0 KiB
Python
"""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"),
|
|
}
|