207 lines
6.8 KiB
Python
207 lines
6.8 KiB
Python
"""Self-update — check Gitea repo for new commits and apply updates."""
|
|
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
import shutil
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
|
|
from backend.auth import get_current_user
|
|
from backend.config import BASE_DIR
|
|
|
|
router = APIRouter(prefix="/api/updates", tags=["updates"])
|
|
log = logging.getLogger("atlus.updates")
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Guard
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_IS_GIT = (BASE_DIR / ".git").is_dir()
|
|
|
|
|
|
def _find_git() -> str | None:
|
|
"""Find the git binary, even with systemd's minimal PATH."""
|
|
found = shutil.which("git")
|
|
if found:
|
|
return found
|
|
for p in ("/usr/bin/git", "/usr/local/bin/git", "/bin/git"):
|
|
if os.path.isfile(p) and os.access(p, os.X_OK):
|
|
return p
|
|
return None
|
|
|
|
|
|
_GIT_BIN = _find_git()
|
|
_HAS_GIT = _GIT_BIN is not None
|
|
|
|
|
|
def _require_git():
|
|
if not _HAS_GIT:
|
|
raise HTTPException(503, "git is not installed")
|
|
if not _IS_GIT:
|
|
raise HTTPException(503, "Atlus was not installed via git — updates unavailable")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _safe_env():
|
|
"""Build environment with full PATH for git commands."""
|
|
env = {**os.environ, "LC_ALL": "C"}
|
|
path = env.get("PATH", "")
|
|
for p in ("/usr/bin", "/usr/sbin", "/bin", "/sbin"):
|
|
if p not in path:
|
|
path = p + ":" + path
|
|
env["PATH"] = path
|
|
# Ensure HOME is set — systemd may strip it, git needs it for SSH keys
|
|
if "HOME" not in env:
|
|
import pwd
|
|
try:
|
|
env["HOME"] = pwd.getpwuid(os.getuid()).pw_dir
|
|
except KeyError:
|
|
env["HOME"] = "/root"
|
|
return env
|
|
|
|
|
|
async def _git(*args: str, timeout: float = 30) -> str:
|
|
"""Run a git command in the Atlus install directory."""
|
|
_require_git()
|
|
cmd = [_GIT_BIN, "-C", str(BASE_DIR)] + list(args)
|
|
log.debug("Running: %s", " ".join(cmd))
|
|
proc = await asyncio.create_subprocess_exec(
|
|
*cmd,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
env=_safe_env(),
|
|
)
|
|
try:
|
|
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
|
except asyncio.TimeoutError:
|
|
proc.kill()
|
|
await proc.wait()
|
|
raise HTTPException(504, "git operation timed out")
|
|
if proc.returncode != 0:
|
|
msg = stderr.decode().strip() or stdout.decode().strip()
|
|
log.warning("git %s failed (rc=%d): %s", args[0] if args else "?", proc.returncode, msg)
|
|
raise HTTPException(500, f"git error: {msg}")
|
|
return stdout.decode().strip()
|
|
|
|
|
|
async def _git_nofail(*args: str, timeout: float = 30) -> tuple[int, str]:
|
|
"""Run a git command, return (returncode, stdout) without raising."""
|
|
_require_git()
|
|
cmd = [_GIT_BIN, "-C", str(BASE_DIR)] + list(args)
|
|
proc = await asyncio.create_subprocess_exec(
|
|
*cmd,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
env=_safe_env(),
|
|
)
|
|
try:
|
|
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
|
except asyncio.TimeoutError:
|
|
proc.kill()
|
|
await proc.wait()
|
|
return 1, ""
|
|
if proc.returncode != 0:
|
|
log.debug("git %s returned %d: %s", args[0] if args else "?",
|
|
proc.returncode, stderr.decode().strip())
|
|
return proc.returncode, stdout.decode().strip()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Endpoints
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@router.get("/check")
|
|
async def check_for_updates(_user: str = Depends(get_current_user)):
|
|
"""Check if there are new commits on the remote."""
|
|
# Get current local HEAD
|
|
local_hash = await _git("rev-parse", "HEAD")
|
|
|
|
# Fetch latest from remote (may take a few seconds)
|
|
try:
|
|
await _git("fetch", "origin", timeout=30)
|
|
except HTTPException as e:
|
|
# Fetch failed (no network, no auth, etc.)
|
|
error_msg = str(e.detail) if hasattr(e, 'detail') else str(e)
|
|
log.warning("git fetch failed: %s", error_msg)
|
|
return {
|
|
"available": False,
|
|
"local_hash": local_hash[:8],
|
|
"remote_hash": local_hash[:8],
|
|
"behind_count": 0,
|
|
"error": f"Could not reach remote: {error_msg}",
|
|
}
|
|
|
|
# Get remote HEAD
|
|
rc, remote_hash = await _git_nofail("rev-parse", "origin/main")
|
|
if rc != 0:
|
|
# Try origin/master as fallback
|
|
rc, remote_hash = await _git_nofail("rev-parse", "origin/master")
|
|
if rc != 0:
|
|
return {
|
|
"available": False,
|
|
"local_hash": local_hash[:8],
|
|
"remote_hash": "",
|
|
"behind_count": 0,
|
|
"error": "Could not determine remote branch",
|
|
}
|
|
|
|
# Count commits behind
|
|
rc, count_str = await _git_nofail("rev-list", "--count", f"HEAD..{remote_hash}")
|
|
behind_count = int(count_str) if rc == 0 and count_str.isdigit() else 0
|
|
|
|
return {
|
|
"available": behind_count > 0,
|
|
"local_hash": local_hash[:8],
|
|
"remote_hash": remote_hash[:8],
|
|
"behind_count": behind_count,
|
|
}
|
|
|
|
|
|
@router.post("/apply")
|
|
async def apply_update(_user: str = Depends(get_current_user)):
|
|
"""Pull latest changes and schedule a service restart."""
|
|
# Pull latest
|
|
pull_output = await _git("pull", "--ff-only", timeout=60)
|
|
|
|
# Reinstall Python dependencies
|
|
pip_bin = str(BASE_DIR / "venv" / "bin" / "pip")
|
|
if not os.path.exists(pip_bin):
|
|
# Fallback: try system pip
|
|
pip_bin = shutil.which("pip3") or shutil.which("pip") or "pip3"
|
|
|
|
req_file = str(BASE_DIR / "backend" / "requirements.txt")
|
|
if os.path.exists(req_file):
|
|
proc = await asyncio.create_subprocess_exec(
|
|
pip_bin, "install", "-r", req_file, "-q",
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
try:
|
|
await asyncio.wait_for(proc.communicate(), timeout=120)
|
|
except asyncio.TimeoutError:
|
|
pass # Non-fatal — deps might already be satisfied
|
|
|
|
# Schedule restart after response is sent
|
|
if shutil.which("systemctl"):
|
|
asyncio.create_task(_delayed_restart())
|
|
|
|
return {
|
|
"success": True,
|
|
"message": "Update applied. Restarting service...",
|
|
"pull_output": pull_output[-500:],
|
|
}
|
|
|
|
|
|
async def _delayed_restart(delay: float = 2.0):
|
|
"""Wait briefly then restart the atlus systemd service."""
|
|
await asyncio.sleep(delay)
|
|
proc = await asyncio.create_subprocess_exec(
|
|
"systemctl", "restart", "atlus",
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
await proc.wait()
|