systemd runs services with a minimal PATH that may not include /usr/bin or /usr/sbin. Add _safe_env() helpers that ensure standard paths are present, and expand apt-cache discovery to check common locations directly. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
177 lines
5.7 KiB
Python
177 lines
5.7 KiB
Python
"""Self-update — check Gitea repo for new commits and apply updates."""
|
|
|
|
import asyncio
|
|
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"])
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Guard
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_IS_GIT = (BASE_DIR / ".git").is_dir()
|
|
_HAS_GIT = bool(shutil.which("git"))
|
|
|
|
|
|
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
|
|
return env
|
|
|
|
|
|
async def _git(*args: str, timeout: float = 30) -> str:
|
|
"""Run a git command in the Atlus install directory."""
|
|
_require_git()
|
|
cmd = ["git", "-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()
|
|
raise HTTPException(504, "git operation timed out")
|
|
if proc.returncode != 0:
|
|
msg = stderr.decode().strip() or stdout.decode().strip()
|
|
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", "-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, ""
|
|
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:
|
|
# Fetch failed (no network, etc.) — report no update
|
|
return {
|
|
"available": False,
|
|
"local_hash": local_hash[:8],
|
|
"remote_hash": local_hash[:8],
|
|
"behind_count": 0,
|
|
"error": "Could not reach remote repository",
|
|
}
|
|
|
|
# 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()
|