refactor: remove duplicate app/app/ phantom directory
deleted file mode 100644| .. | .. |
|---|
| 1 | | -import os |
|---|
| 2 | | -from fastapi import HTTPException, Security, Query |
|---|
| 3 | | -from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials |
|---|
| 4 | | -from typing import Optional |
|---|
| 5 | | - |
|---|
| 6 | | -_AUTH_TOKEN = os.environ.get("AUTH_TOKEN", "changeme") |
|---|
| 7 | | - |
|---|
| 8 | | -_bearer_scheme = HTTPBearer(auto_error=False) |
|---|
| 9 | | - |
|---|
| 10 | | - |
|---|
| 11 | | -async def verify_token( |
|---|
| 12 | | - credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer_scheme), |
|---|
| 13 | | - token: Optional[str] = Query(default=None), |
|---|
| 14 | | -) -> str: |
|---|
| 15 | | - """ |
|---|
| 16 | | - Verify the bearer token from Authorization header or ?token= query param. |
|---|
| 17 | | - Raises 401 if missing or invalid. |
|---|
| 18 | | - """ |
|---|
| 19 | | - provided: Optional[str] = None |
|---|
| 20 | | - |
|---|
| 21 | | - if credentials is not None: |
|---|
| 22 | | - provided = credentials.credentials |
|---|
| 23 | | - elif token is not None: |
|---|
| 24 | | - provided = token |
|---|
| 25 | | - |
|---|
| 26 | | - if provided is None or provided != _AUTH_TOKEN: |
|---|
| 27 | | - raise HTTPException( |
|---|
| 28 | | - status_code=401, |
|---|
| 29 | | - detail="Invalid or missing authentication token", |
|---|
| 30 | | - headers={"WWW-Authenticate": "Bearer"}, |
|---|
| 31 | | - ) |
|---|
| 32 | | - |
|---|
| 33 | | - return provided |
|---|
deleted file mode 100644| .. | .. |
|---|
| 1 | | -import logging |
|---|
| 2 | | -from contextlib import asynccontextmanager |
|---|
| 3 | | -from pathlib import Path |
|---|
| 4 | | - |
|---|
| 5 | | -from fastapi import FastAPI |
|---|
| 6 | | -from fastapi.middleware.cors import CORSMiddleware |
|---|
| 7 | | -from fastapi.staticfiles import StaticFiles |
|---|
| 8 | | - |
|---|
| 9 | | -from app.routers import backups, restore, services, status, system |
|---|
| 10 | | - |
|---|
| 11 | | -logging.basicConfig( |
|---|
| 12 | | - level=logging.INFO, |
|---|
| 13 | | - format="%(asctime)s %(levelname)s %(name)s: %(message)s", |
|---|
| 14 | | -) |
|---|
| 15 | | -logger = logging.getLogger(__name__) |
|---|
| 16 | | - |
|---|
| 17 | | -_STATIC_DIR = Path(__file__).parent.parent / "static" |
|---|
| 18 | | - |
|---|
| 19 | | - |
|---|
| 20 | | -@asynccontextmanager |
|---|
| 21 | | -async def lifespan(app: FastAPI): |
|---|
| 22 | | - logger.info("Ops WebUI server is running") |
|---|
| 23 | | - yield |
|---|
| 24 | | - |
|---|
| 25 | | - |
|---|
| 26 | | -app = FastAPI( |
|---|
| 27 | | - title="Ops WebUI API", |
|---|
| 28 | | - description="Backend API for the ops web dashboard", |
|---|
| 29 | | - version="1.0.0", |
|---|
| 30 | | - lifespan=lifespan, |
|---|
| 31 | | -) |
|---|
| 32 | | - |
|---|
| 33 | | -# --------------------------------------------------------------------------- |
|---|
| 34 | | -# CORS – open for development; restrict in production via env/reverse proxy |
|---|
| 35 | | -# --------------------------------------------------------------------------- |
|---|
| 36 | | -app.add_middleware( |
|---|
| 37 | | - CORSMiddleware, |
|---|
| 38 | | - allow_origins=["*"], |
|---|
| 39 | | - allow_credentials=True, |
|---|
| 40 | | - allow_methods=["*"], |
|---|
| 41 | | - allow_headers=["*"], |
|---|
| 42 | | -) |
|---|
| 43 | | - |
|---|
| 44 | | -# --------------------------------------------------------------------------- |
|---|
| 45 | | -# API routers |
|---|
| 46 | | -# --------------------------------------------------------------------------- |
|---|
| 47 | | -app.include_router(status.router, prefix="/api/status", tags=["status"]) |
|---|
| 48 | | -app.include_router(backups.router, prefix="/api/backups", tags=["backups"]) |
|---|
| 49 | | -app.include_router(restore.router, prefix="/api/restore", tags=["restore"]) |
|---|
| 50 | | -app.include_router(services.router, prefix="/api/services", tags=["services"]) |
|---|
| 51 | | -app.include_router(system.router, prefix="/api/system", tags=["system"]) |
|---|
| 52 | | - |
|---|
| 53 | | -# --------------------------------------------------------------------------- |
|---|
| 54 | | -# Static files – serve the frontend SPA at / |
|---|
| 55 | | -# Mount last so API routes take precedence |
|---|
| 56 | | -# --------------------------------------------------------------------------- |
|---|
| 57 | | -if _STATIC_DIR.exists(): |
|---|
| 58 | | - app.mount("/", StaticFiles(directory=str(_STATIC_DIR), html=True), name="static") |
|---|
| 59 | | -else: |
|---|
| 60 | | - logger.warning("Static directory not found at %s – frontend will not be served", _STATIC_DIR) |
|---|
deleted file mode 100644| .. | .. |
|---|
| 1 | | -import asyncio |
|---|
| 2 | | -import json |
|---|
| 3 | | -import os |
|---|
| 4 | | -from typing import AsyncGenerator |
|---|
| 5 | | - |
|---|
| 6 | | -OPS_CLI = os.environ.get("OPS_CLI", "/opt/infrastructure/ops") |
|---|
| 7 | | -OFFSITE_PYTHON = os.environ.get("OFFSITE_PYTHON", "/opt/data/π/bin/python3") |
|---|
| 8 | | -OFFSITE_SCRIPT = os.environ.get("OFFSITE_SCRIPT", "/opt/data/scripts/offsite.py") |
|---|
| 9 | | - |
|---|
| 10 | | -_DEFAULT_TIMEOUT = 300 |
|---|
| 11 | | -_BACKUP_TIMEOUT = 3600 |
|---|
| 12 | | - |
|---|
| 13 | | -# nsenter via Docker: run commands on the host from inside the container. |
|---|
| 14 | | -# Required because ops backup/restore delegate to host Python venvs (3.12) |
|---|
| 15 | | -# that are incompatible with the container's Python (3.11). |
|---|
| 16 | | -_NSENTER_PREFIX = [ |
|---|
| 17 | | - "docker", "run", "--rm", "-i", |
|---|
| 18 | | - "--privileged", "--pid=host", "--network=host", |
|---|
| 19 | | - "alpine", |
|---|
| 20 | | - "nsenter", "-t", "1", "-m", "-u", "-i", "-n", "-p", "--", |
|---|
| 21 | | -] |
|---|
| 22 | | - |
|---|
| 23 | | - |
|---|
| 24 | | -# --------------------------------------------------------------------------- |
|---|
| 25 | | -# In-container execution (status, disk, health, docker commands) |
|---|
| 26 | | -# --------------------------------------------------------------------------- |
|---|
| 27 | | - |
|---|
| 28 | | -async def run_ops(args: list[str], timeout: int = _DEFAULT_TIMEOUT) -> dict: |
|---|
| 29 | | - """Run the ops CLI inside the container.""" |
|---|
| 30 | | - return await _run_exec([OPS_CLI] + args, timeout=timeout) |
|---|
| 31 | | - |
|---|
| 32 | | - |
|---|
| 33 | | -async def run_ops_json(args: list[str], timeout: int = _DEFAULT_TIMEOUT) -> dict: |
|---|
| 34 | | - """Run the ops CLI with --json and return parsed JSON.""" |
|---|
| 35 | | - result = await run_ops(args + ["--json"], timeout=timeout) |
|---|
| 36 | | - if not result["success"]: |
|---|
| 37 | | - return {"success": False, "data": None, "error": result["error"] or result["output"]} |
|---|
| 38 | | - try: |
|---|
| 39 | | - data = json.loads(result["output"]) |
|---|
| 40 | | - return {"success": True, "data": data, "error": ""} |
|---|
| 41 | | - except json.JSONDecodeError as exc: |
|---|
| 42 | | - return { |
|---|
| 43 | | - "success": False, |
|---|
| 44 | | - "data": None, |
|---|
| 45 | | - "error": f"Failed to parse JSON: {exc}\nRaw: {result['output'][:500]}", |
|---|
| 46 | | - } |
|---|
| 47 | | - |
|---|
| 48 | | - |
|---|
| 49 | | -async def stream_ops(args: list[str], timeout: int = _DEFAULT_TIMEOUT) -> AsyncGenerator[str, None]: |
|---|
| 50 | | - """Stream ops CLI output (in-container).""" |
|---|
| 51 | | - async for line in _stream_exec([OPS_CLI] + args, timeout=timeout): |
|---|
| 52 | | - yield line |
|---|
| 53 | | - |
|---|
| 54 | | - |
|---|
| 55 | | -async def run_command(args: list[str], timeout: int = _DEFAULT_TIMEOUT) -> dict: |
|---|
| 56 | | - """Generic command runner (in-container).""" |
|---|
| 57 | | - return await _run_exec(args, timeout=timeout) |
|---|
| 58 | | - |
|---|
| 59 | | - |
|---|
| 60 | | -async def stream_command(args: list[str], timeout: int = _DEFAULT_TIMEOUT) -> AsyncGenerator[str, None]: |
|---|
| 61 | | - """Stream generic command output (in-container).""" |
|---|
| 62 | | - async for line in _stream_exec(args, timeout=timeout): |
|---|
| 63 | | - yield line |
|---|
| 64 | | - |
|---|
| 65 | | - |
|---|
| 66 | | -# --------------------------------------------------------------------------- |
|---|
| 67 | | -# Host execution (backup, restore — needs host Python venvs) |
|---|
| 68 | | -# --------------------------------------------------------------------------- |
|---|
| 69 | | - |
|---|
| 70 | | -async def run_ops_host(args: list[str], timeout: int = _DEFAULT_TIMEOUT) -> dict: |
|---|
| 71 | | - """Run the ops CLI on the host via nsenter.""" |
|---|
| 72 | | - return await _run_exec(_NSENTER_PREFIX + [OPS_CLI] + args, timeout=timeout) |
|---|
| 73 | | - |
|---|
| 74 | | - |
|---|
| 75 | | -async def stream_ops_host(args: list[str], timeout: int = _DEFAULT_TIMEOUT) -> AsyncGenerator[str, None]: |
|---|
| 76 | | - """Stream ops CLI output from the host via nsenter.""" |
|---|
| 77 | | - async for line in _stream_exec(_NSENTER_PREFIX + [OPS_CLI] + args, timeout=timeout): |
|---|
| 78 | | - yield line |
|---|
| 79 | | - |
|---|
| 80 | | - |
|---|
| 81 | | -# --------------------------------------------------------------------------- |
|---|
| 82 | | -# Internal helpers |
|---|
| 83 | | -# --------------------------------------------------------------------------- |
|---|
| 84 | | - |
|---|
| 85 | | -async def _run_exec(args: list[str], timeout: int = _DEFAULT_TIMEOUT) -> dict: |
|---|
| 86 | | - """Execute a command and capture output.""" |
|---|
| 87 | | - try: |
|---|
| 88 | | - proc = await asyncio.create_subprocess_exec( |
|---|
| 89 | | - *args, |
|---|
| 90 | | - stdout=asyncio.subprocess.PIPE, |
|---|
| 91 | | - stderr=asyncio.subprocess.PIPE, |
|---|
| 92 | | - ) |
|---|
| 93 | | - try: |
|---|
| 94 | | - stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) |
|---|
| 95 | | - except asyncio.TimeoutError: |
|---|
| 96 | | - proc.kill() |
|---|
| 97 | | - await proc.communicate() |
|---|
| 98 | | - return {"success": False, "output": "", "error": f"Command timed out after {timeout}s"} |
|---|
| 99 | | - |
|---|
| 100 | | - return { |
|---|
| 101 | | - "success": proc.returncode == 0, |
|---|
| 102 | | - "output": stdout.decode("utf-8", errors="replace"), |
|---|
| 103 | | - "error": stderr.decode("utf-8", errors="replace"), |
|---|
| 104 | | - } |
|---|
| 105 | | - except FileNotFoundError as exc: |
|---|
| 106 | | - return {"success": False, "output": "", "error": f"Executable not found: {exc}"} |
|---|
| 107 | | - except Exception as exc: |
|---|
| 108 | | - return {"success": False, "output": "", "error": str(exc)} |
|---|
| 109 | | - |
|---|
| 110 | | - |
|---|
| 111 | | -async def _stream_exec(args: list[str], timeout: int = _DEFAULT_TIMEOUT) -> AsyncGenerator[str, None]: |
|---|
| 112 | | - """Execute a command and yield interleaved stdout/stderr lines.""" |
|---|
| 113 | | - try: |
|---|
| 114 | | - proc = await asyncio.create_subprocess_exec( |
|---|
| 115 | | - *args, |
|---|
| 116 | | - stdout=asyncio.subprocess.PIPE, |
|---|
| 117 | | - stderr=asyncio.subprocess.PIPE, |
|---|
| 118 | | - ) |
|---|
| 119 | | - except FileNotFoundError as exc: |
|---|
| 120 | | - yield f"[error] Executable not found: {exc}" |
|---|
| 121 | | - return |
|---|
| 122 | | - except Exception as exc: |
|---|
| 123 | | - yield f"[error] Failed to start process: {exc}" |
|---|
| 124 | | - return |
|---|
| 125 | | - |
|---|
| 126 | | - async def _readline(stream, prefix=""): |
|---|
| 127 | | - while True: |
|---|
| 128 | | - try: |
|---|
| 129 | | - line = await asyncio.wait_for(stream.readline(), timeout=timeout) |
|---|
| 130 | | - except asyncio.TimeoutError: |
|---|
| 131 | | - yield f"{prefix}[timeout] Command exceeded {timeout}s" |
|---|
| 132 | | - break |
|---|
| 133 | | - if not line: |
|---|
| 134 | | - break |
|---|
| 135 | | - yield prefix + line.decode("utf-8", errors="replace").rstrip("\n") |
|---|
| 136 | | - |
|---|
| 137 | | - stdout_gen = _readline(proc.stdout).__aiter__() |
|---|
| 138 | | - stderr_gen = _readline(proc.stderr, "[stderr] ").__aiter__() |
|---|
| 139 | | - |
|---|
| 140 | | - stdout_done = stderr_done = False |
|---|
| 141 | | - pending_out = pending_err = None |
|---|
| 142 | | - |
|---|
| 143 | | - async def _next(it): |
|---|
| 144 | | - try: |
|---|
| 145 | | - return await it.__anext__() |
|---|
| 146 | | - except StopAsyncIteration: |
|---|
| 147 | | - return None |
|---|
| 148 | | - |
|---|
| 149 | | - pending_out = asyncio.create_task(_next(stdout_gen)) |
|---|
| 150 | | - pending_err = asyncio.create_task(_next(stderr_gen)) |
|---|
| 151 | | - |
|---|
| 152 | | - while not (stdout_done and stderr_done): |
|---|
| 153 | | - tasks = [t for t in (pending_out, pending_err) if t is not None] |
|---|
| 154 | | - if not tasks: |
|---|
| 155 | | - break |
|---|
| 156 | | - done, _ = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) |
|---|
| 157 | | - |
|---|
| 158 | | - for task in done: |
|---|
| 159 | | - val = task.result() |
|---|
| 160 | | - if task is pending_out: |
|---|
| 161 | | - if val is None: |
|---|
| 162 | | - stdout_done = True |
|---|
| 163 | | - pending_out = None |
|---|
| 164 | | - else: |
|---|
| 165 | | - yield val |
|---|
| 166 | | - pending_out = asyncio.create_task(_next(stdout_gen)) |
|---|
| 167 | | - elif task is pending_err: |
|---|
| 168 | | - if val is None: |
|---|
| 169 | | - stderr_done = True |
|---|
| 170 | | - pending_err = None |
|---|
| 171 | | - else: |
|---|
| 172 | | - yield val |
|---|
| 173 | | - pending_err = asyncio.create_task(_next(stderr_gen)) |
|---|
| 174 | | - |
|---|
| 175 | | - await proc.wait() |
|---|
deleted file mode 100644| .. | .. |
|---|
| 1 | | -from typing import Any |
|---|
| 2 | | - |
|---|
| 3 | | -from fastapi import APIRouter, Depends, HTTPException |
|---|
| 4 | | - |
|---|
| 5 | | -from app.auth import verify_token |
|---|
| 6 | | -from app.ops_runner import run_ops, run_ops_json, run_ops_host, _BACKUP_TIMEOUT |
|---|
| 7 | | - |
|---|
| 8 | | -router = APIRouter() |
|---|
| 9 | | - |
|---|
| 10 | | - |
|---|
| 11 | | -@router.get("/", summary="List local backups") |
|---|
| 12 | | -async def list_backups( |
|---|
| 13 | | - _: str = Depends(verify_token), |
|---|
| 14 | | -) -> list[dict[str, Any]]: |
|---|
| 15 | | - """Returns a list of local backup records from `ops backups --json`.""" |
|---|
| 16 | | - result = await run_ops_json(["backups"]) |
|---|
| 17 | | - if not result["success"]: |
|---|
| 18 | | - raise HTTPException(status_code=500, detail=f"Failed to list backups: {result['error']}") |
|---|
| 19 | | - |
|---|
| 20 | | - data = result["data"] |
|---|
| 21 | | - if isinstance(data, list): |
|---|
| 22 | | - return data |
|---|
| 23 | | - if isinstance(data, dict): |
|---|
| 24 | | - for key in ("backups", "data", "items"): |
|---|
| 25 | | - if key in data and isinstance(data[key], list): |
|---|
| 26 | | - return data[key] |
|---|
| 27 | | - return [data] |
|---|
| 28 | | - return [] |
|---|
| 29 | | - |
|---|
| 30 | | - |
|---|
| 31 | | -@router.get("/offsite", summary="List offsite backups") |
|---|
| 32 | | -async def list_offsite_backups( |
|---|
| 33 | | - _: str = Depends(verify_token), |
|---|
| 34 | | -) -> list[dict[str, Any]]: |
|---|
| 35 | | - """Returns a list of offsite backup records.""" |
|---|
| 36 | | - all_backups = [] |
|---|
| 37 | | - for project in ["mdf", "seriousletter"]: |
|---|
| 38 | | - result = await run_ops_json(["offsite", "list", project]) |
|---|
| 39 | | - if result["success"] and isinstance(result["data"], list): |
|---|
| 40 | | - for b in result["data"]: |
|---|
| 41 | | - b["project"] = project |
|---|
| 42 | | - all_backups.extend(result["data"]) |
|---|
| 43 | | - return all_backups |
|---|
| 44 | | - |
|---|
| 45 | | - |
|---|
| 46 | | -@router.post("/{project}/{env}", summary="Create a local backup") |
|---|
| 47 | | -async def create_backup( |
|---|
| 48 | | - project: str, |
|---|
| 49 | | - env: str, |
|---|
| 50 | | - _: str = Depends(verify_token), |
|---|
| 51 | | -) -> dict[str, Any]: |
|---|
| 52 | | - """ |
|---|
| 53 | | - Runs `ops backup {project} {env}` on the host. |
|---|
| 54 | | - |
|---|
| 55 | | - Runs via nsenter because ops backup delegates to project CLIs |
|---|
| 56 | | - that use host Python venvs. |
|---|
| 57 | | - """ |
|---|
| 58 | | - result = await run_ops_host(["backup", project, env], timeout=_BACKUP_TIMEOUT) |
|---|
| 59 | | - if not result["success"]: |
|---|
| 60 | | - raise HTTPException( |
|---|
| 61 | | - status_code=500, |
|---|
| 62 | | - detail=f"Backup failed: {result['error'] or result['output']}", |
|---|
| 63 | | - ) |
|---|
| 64 | | - return { |
|---|
| 65 | | - "success": True, |
|---|
| 66 | | - "output": result["output"], |
|---|
| 67 | | - "project": project, |
|---|
| 68 | | - "env": env, |
|---|
| 69 | | - } |
|---|
| 70 | | - |
|---|
| 71 | | - |
|---|
| 72 | | -@router.post("/offsite/upload/{project}/{env}", summary="Upload backup to offsite") |
|---|
| 73 | | -async def upload_offsite( |
|---|
| 74 | | - project: str, |
|---|
| 75 | | - env: str, |
|---|
| 76 | | - _: str = Depends(verify_token), |
|---|
| 77 | | -) -> dict[str, Any]: |
|---|
| 78 | | - """Runs `ops offsite upload {project} {env}` on the host.""" |
|---|
| 79 | | - result = await run_ops_host( |
|---|
| 80 | | - ["offsite", "upload", project, env], timeout=_BACKUP_TIMEOUT |
|---|
| 81 | | - ) |
|---|
| 82 | | - if not result["success"]: |
|---|
| 83 | | - raise HTTPException( |
|---|
| 84 | | - status_code=500, |
|---|
| 85 | | - detail=f"Offsite upload failed: {result['error'] or result['output']}", |
|---|
| 86 | | - ) |
|---|
| 87 | | - return {"success": True, "output": result["output"], "project": project, "env": env} |
|---|
| 88 | | - |
|---|
| 89 | | - |
|---|
| 90 | | -@router.post("/offsite/retention", summary="Apply offsite retention policy") |
|---|
| 91 | | -async def apply_retention( |
|---|
| 92 | | - _: str = Depends(verify_token), |
|---|
| 93 | | -) -> dict[str, Any]: |
|---|
| 94 | | - """Runs `ops offsite retention` on the host.""" |
|---|
| 95 | | - result = await run_ops_host(["offsite", "retention"], timeout=_BACKUP_TIMEOUT) |
|---|
| 96 | | - if not result["success"]: |
|---|
| 97 | | - raise HTTPException( |
|---|
| 98 | | - status_code=500, |
|---|
| 99 | | - detail=f"Retention policy failed: {result['error'] or result['output']}", |
|---|
| 100 | | - ) |
|---|
| 101 | | - return {"success": True, "output": result["output"]} |
|---|
deleted file mode 100644| .. | .. |
|---|
| 1 | | -import json |
|---|
| 2 | | -from datetime import datetime, timezone |
|---|
| 3 | | -from typing import AsyncGenerator, Literal |
|---|
| 4 | | - |
|---|
| 5 | | -from fastapi import APIRouter, Depends, Query |
|---|
| 6 | | -from fastapi.responses import StreamingResponse |
|---|
| 7 | | - |
|---|
| 8 | | -from app.auth import verify_token |
|---|
| 9 | | -from app.ops_runner import _BACKUP_TIMEOUT, stream_ops_host |
|---|
| 10 | | - |
|---|
| 11 | | -router = APIRouter() |
|---|
| 12 | | - |
|---|
| 13 | | - |
|---|
| 14 | | -def _sse_line(payload: dict) -> str: |
|---|
| 15 | | - """Format a dict as a single SSE data line.""" |
|---|
| 16 | | - return f"data: {json.dumps(payload)}\n\n" |
|---|
| 17 | | - |
|---|
| 18 | | - |
|---|
| 19 | | -async def _restore_generator( |
|---|
| 20 | | - project: str, |
|---|
| 21 | | - env: str, |
|---|
| 22 | | - source: str, |
|---|
| 23 | | - dry_run: bool, |
|---|
| 24 | | -) -> AsyncGenerator[str, None]: |
|---|
| 25 | | - """Async generator that drives the restore workflow and yields SSE events. |
|---|
| 26 | | - |
|---|
| 27 | | - Runs on the host via nsenter because ops restore delegates to project CLIs |
|---|
| 28 | | - that use host Python venvs incompatible with the container's Python. |
|---|
| 29 | | - """ |
|---|
| 30 | | - base_args = ["restore", project, env] |
|---|
| 31 | | - if dry_run: |
|---|
| 32 | | - base_args.append("--dry-run") |
|---|
| 33 | | - |
|---|
| 34 | | - if source == "offsite": |
|---|
| 35 | | - # ops offsite restore <project> <env> |
|---|
| 36 | | - download_args = ["offsite", "restore", project, env] |
|---|
| 37 | | - yield _sse_line({"line": f"Downloading {project}/{env} from offsite...", "timestamp": _now()}) |
|---|
| 38 | | - |
|---|
| 39 | | - download_ok = True |
|---|
| 40 | | - async for line in stream_ops_host(download_args, timeout=_BACKUP_TIMEOUT): |
|---|
| 41 | | - yield _sse_line({"line": line, "timestamp": _now()}) |
|---|
| 42 | | - if line.startswith("[error]"): |
|---|
| 43 | | - download_ok = False |
|---|
| 44 | | - |
|---|
| 45 | | - if not download_ok: |
|---|
| 46 | | - yield _sse_line({"done": True, "success": False}) |
|---|
| 47 | | - return |
|---|
| 48 | | - |
|---|
| 49 | | - yield _sse_line({"line": "Download complete. Starting restore...", "timestamp": _now()}) |
|---|
| 50 | | - |
|---|
| 51 | | - success = True |
|---|
| 52 | | - async for line in stream_ops_host(base_args, timeout=_BACKUP_TIMEOUT): |
|---|
| 53 | | - yield _sse_line({"line": line, "timestamp": _now()}) |
|---|
| 54 | | - if line.startswith("[error]"): |
|---|
| 55 | | - success = False |
|---|
| 56 | | - |
|---|
| 57 | | - yield _sse_line({"done": True, "success": success}) |
|---|
| 58 | | - |
|---|
| 59 | | - |
|---|
| 60 | | -def _now() -> str: |
|---|
| 61 | | - return datetime.now(timezone.utc).isoformat() |
|---|
| 62 | | - |
|---|
| 63 | | - |
|---|
| 64 | | -@router.get("/{project}/{env}", summary="Restore a backup with real-time output") |
|---|
| 65 | | -async def restore_backup( |
|---|
| 66 | | - project: str, |
|---|
| 67 | | - env: str, |
|---|
| 68 | | - source: Literal["local", "offsite"] = Query(default="local"), |
|---|
| 69 | | - dry_run: bool = Query(default=False, alias="dry_run"), |
|---|
| 70 | | - _: str = Depends(verify_token), |
|---|
| 71 | | -) -> StreamingResponse: |
|---|
| 72 | | - """ |
|---|
| 73 | | - Restore a backup for the given project/env. |
|---|
| 74 | | - |
|---|
| 75 | | - Uses Server-Sent Events (SSE) to stream real-time progress. |
|---|
| 76 | | - Runs on the host via nsenter for Python venv compatibility. |
|---|
| 77 | | - """ |
|---|
| 78 | | - return StreamingResponse( |
|---|
| 79 | | - _restore_generator(project, env, source, dry_run), |
|---|
| 80 | | - media_type="text/event-stream", |
|---|
| 81 | | - headers={ |
|---|
| 82 | | - "Cache-Control": "no-cache", |
|---|
| 83 | | - "X-Accel-Buffering": "no", |
|---|
| 84 | | - }, |
|---|
| 85 | | - ) |
|---|
deleted file mode 100644| .. | .. |
|---|
| 1 | | -import os |
|---|
| 2 | | -from typing import Any |
|---|
| 3 | | - |
|---|
| 4 | | -import yaml |
|---|
| 5 | | -from fastapi import APIRouter, Depends, HTTPException, Query |
|---|
| 6 | | - |
|---|
| 7 | | -from app.auth import verify_token |
|---|
| 8 | | -from app.ops_runner import run_command |
|---|
| 9 | | - |
|---|
| 10 | | -router = APIRouter() |
|---|
| 11 | | - |
|---|
| 12 | | -_DOCKER = "docker" |
|---|
| 13 | | -_REGISTRY_PATH = os.environ.get( |
|---|
| 14 | | - "REGISTRY_PATH", |
|---|
| 15 | | - "/opt/infrastructure/servers/hetzner-vps/registry.yaml", |
|---|
| 16 | | -) |
|---|
| 17 | | - |
|---|
| 18 | | -# --------------------------------------------------------------------------- |
|---|
| 19 | | -# Registry-based name prefix lookup (cached) |
|---|
| 20 | | -# --------------------------------------------------------------------------- |
|---|
| 21 | | -_prefix_cache: dict[str, str] | None = None |
|---|
| 22 | | - |
|---|
| 23 | | - |
|---|
| 24 | | -def _load_prefixes() -> dict[str, str]: |
|---|
| 25 | | - """Load project -> name_prefix mapping from the ops registry.""" |
|---|
| 26 | | - global _prefix_cache |
|---|
| 27 | | - if _prefix_cache is not None: |
|---|
| 28 | | - return _prefix_cache |
|---|
| 29 | | - |
|---|
| 30 | | - try: |
|---|
| 31 | | - with open(_REGISTRY_PATH) as f: |
|---|
| 32 | | - data = yaml.safe_load(f) |
|---|
| 33 | | - _prefix_cache = {} |
|---|
| 34 | | - for proj_name, cfg in data.get("projects", {}).items(): |
|---|
| 35 | | - _prefix_cache[proj_name] = cfg.get("name_prefix", proj_name) |
|---|
| 36 | | - return _prefix_cache |
|---|
| 37 | | - except Exception: |
|---|
| 38 | | - return {} |
|---|
| 39 | | - |
|---|
| 40 | | - |
|---|
| 41 | | -# --------------------------------------------------------------------------- |
|---|
| 42 | | -# Container name resolution |
|---|
| 43 | | -# --------------------------------------------------------------------------- |
|---|
| 44 | | - |
|---|
| 45 | | - |
|---|
| 46 | | -async def _find_by_prefix(pattern: str) -> str | None: |
|---|
| 47 | | - """Find first running container whose name starts with `pattern`.""" |
|---|
| 48 | | - result = await run_command( |
|---|
| 49 | | - [_DOCKER, "ps", "--filter", f"name={pattern}", "--format", "{{.Names}}"], |
|---|
| 50 | | - timeout=10, |
|---|
| 51 | | - ) |
|---|
| 52 | | - if not result["success"]: |
|---|
| 53 | | - return None |
|---|
| 54 | | - for name in result["output"].strip().splitlines(): |
|---|
| 55 | | - name = name.strip() |
|---|
| 56 | | - if name and name.startswith(pattern): |
|---|
| 57 | | - return name |
|---|
| 58 | | - return None |
|---|
| 59 | | - |
|---|
| 60 | | - |
|---|
| 61 | | -async def _find_exact(name: str) -> str | None: |
|---|
| 62 | | - """Find a running container with exactly this name.""" |
|---|
| 63 | | - result = await run_command( |
|---|
| 64 | | - [_DOCKER, "ps", "--filter", f"name={name}", "--format", "{{.Names}}"], |
|---|
| 65 | | - timeout=10, |
|---|
| 66 | | - ) |
|---|
| 67 | | - if not result["success"]: |
|---|
| 68 | | - return None |
|---|
| 69 | | - for n in result["output"].strip().splitlines(): |
|---|
| 70 | | - if n.strip() == name: |
|---|
| 71 | | - return name |
|---|
| 72 | | - return None |
|---|
| 73 | | - |
|---|
| 74 | | - |
|---|
| 75 | | -async def _resolve_container(project: str, env: str, service: str) -> str: |
|---|
| 76 | | - """ |
|---|
| 77 | | - Resolve the actual Docker container name from project/env/service. |
|---|
| 78 | | - |
|---|
| 79 | | - Uses the ops registry name_prefix mapping and tries patterns in order: |
|---|
| 80 | | - 1. {env}-{prefix}-{service} (mdf, seriousletter: dev-mdf-mysql-UUID) |
|---|
| 81 | | - 2. {prefix}-{service} (ringsaday: ringsaday-website-UUID, coolify: coolify-db) |
|---|
| 82 | | - 3. {prefix}-{env} (ringsaday: ringsaday-dev-UUID) |
|---|
| 83 | | - 4. exact {prefix} (coolify infra: coolify) |
|---|
| 84 | | - """ |
|---|
| 85 | | - prefixes = _load_prefixes() |
|---|
| 86 | | - prefix = prefixes.get(project, project) |
|---|
| 87 | | - |
|---|
| 88 | | - # Pattern 1: {env}-{prefix}-{service} |
|---|
| 89 | | - hit = await _find_by_prefix(f"{env}-{prefix}-{service}") |
|---|
| 90 | | - if hit: |
|---|
| 91 | | - return hit |
|---|
| 92 | | - |
|---|
| 93 | | - # Pattern 2: {prefix}-{service} |
|---|
| 94 | | - hit = await _find_by_prefix(f"{prefix}-{service}") |
|---|
| 95 | | - if hit: |
|---|
| 96 | | - return hit |
|---|
| 97 | | - |
|---|
| 98 | | - # Pattern 3: {prefix}-{env} |
|---|
| 99 | | - hit = await _find_by_prefix(f"{prefix}-{env}") |
|---|
| 100 | | - if hit: |
|---|
| 101 | | - return hit |
|---|
| 102 | | - |
|---|
| 103 | | - # Pattern 4: exact match when service == prefix (e.g., coolify) |
|---|
| 104 | | - if service == prefix: |
|---|
| 105 | | - hit = await _find_exact(prefix) |
|---|
| 106 | | - if hit: |
|---|
| 107 | | - return hit |
|---|
| 108 | | - |
|---|
| 109 | | - raise HTTPException( |
|---|
| 110 | | - status_code=404, |
|---|
| 111 | | - detail=f"Container not found for {project}/{env}/{service}", |
|---|
| 112 | | - ) |
|---|
| 113 | | - |
|---|
| 114 | | - |
|---|
| 115 | | -# --------------------------------------------------------------------------- |
|---|
| 116 | | -# Endpoints |
|---|
| 117 | | -# --------------------------------------------------------------------------- |
|---|
| 118 | | - |
|---|
| 119 | | - |
|---|
| 120 | | -@router.get("/logs/{project}/{env}/{service}", summary="Get container logs") |
|---|
| 121 | | -async def get_logs( |
|---|
| 122 | | - project: str, |
|---|
| 123 | | - env: str, |
|---|
| 124 | | - service: str, |
|---|
| 125 | | - lines: int = Query( |
|---|
| 126 | | - default=100, ge=1, le=10000, description="Number of log lines to return" |
|---|
| 127 | | - ), |
|---|
| 128 | | - _: str = Depends(verify_token), |
|---|
| 129 | | -) -> dict[str, Any]: |
|---|
| 130 | | - """Fetch the last N lines of logs from a container.""" |
|---|
| 131 | | - container = await _resolve_container(project, env, service) |
|---|
| 132 | | - result = await run_command( |
|---|
| 133 | | - [_DOCKER, "logs", "--tail", str(lines), container], |
|---|
| 134 | | - timeout=30, |
|---|
| 135 | | - ) |
|---|
| 136 | | - |
|---|
| 137 | | - # docker logs writes to stderr by default; combine both streams |
|---|
| 138 | | - combined = result["output"] + result["error"] |
|---|
| 139 | | - |
|---|
| 140 | | - if not result["success"] and not combined.strip(): |
|---|
| 141 | | - raise HTTPException( |
|---|
| 142 | | - status_code=500, |
|---|
| 143 | | - detail=f"Failed to retrieve logs for container '{container}'", |
|---|
| 144 | | - ) |
|---|
| 145 | | - |
|---|
| 146 | | - return { |
|---|
| 147 | | - "container": container, |
|---|
| 148 | | - "lines": lines, |
|---|
| 149 | | - "logs": combined, |
|---|
| 150 | | - } |
|---|
| 151 | | - |
|---|
| 152 | | - |
|---|
| 153 | | -@router.post("/restart/{project}/{env}/{service}", summary="Restart a container") |
|---|
| 154 | | -async def restart_service( |
|---|
| 155 | | - project: str, |
|---|
| 156 | | - env: str, |
|---|
| 157 | | - service: str, |
|---|
| 158 | | - _: str = Depends(verify_token), |
|---|
| 159 | | -) -> dict[str, Any]: |
|---|
| 160 | | - """Restart a Docker container.""" |
|---|
| 161 | | - container = await _resolve_container(project, env, service) |
|---|
| 162 | | - result = await run_command( |
|---|
| 163 | | - [_DOCKER, "restart", container], |
|---|
| 164 | | - timeout=60, |
|---|
| 165 | | - ) |
|---|
| 166 | | - |
|---|
| 167 | | - if not result["success"]: |
|---|
| 168 | | - raise HTTPException( |
|---|
| 169 | | - status_code=500, |
|---|
| 170 | | - detail=f"Failed to restart container '{container}': {result['error'] or result['output']}", |
|---|
| 171 | | - ) |
|---|
| 172 | | - |
|---|
| 173 | | - return { |
|---|
| 174 | | - "success": True, |
|---|
| 175 | | - "container": container, |
|---|
| 176 | | - "message": f"Container '{container}' restarted successfully", |
|---|
| 177 | | - } |
|---|
deleted file mode 100644| .. | .. |
|---|
| 1 | | -from typing import Any |
|---|
| 2 | | - |
|---|
| 3 | | -from fastapi import APIRouter, Depends, HTTPException |
|---|
| 4 | | - |
|---|
| 5 | | -from app.auth import verify_token |
|---|
| 6 | | -from app.ops_runner import run_ops_json |
|---|
| 7 | | - |
|---|
| 8 | | -router = APIRouter() |
|---|
| 9 | | - |
|---|
| 10 | | - |
|---|
| 11 | | -@router.get("/", summary="Get all container statuses") |
|---|
| 12 | | -async def get_status( |
|---|
| 13 | | - _: str = Depends(verify_token), |
|---|
| 14 | | -) -> list[dict[str, Any]]: |
|---|
| 15 | | - """ |
|---|
| 16 | | - Returns a list of container status objects from `ops status --json`. |
|---|
| 17 | | - |
|---|
| 18 | | - Each item contains: project, service, status, health, uptime. |
|---|
| 19 | | - """ |
|---|
| 20 | | - result = await run_ops_json(["status"]) |
|---|
| 21 | | - if not result["success"]: |
|---|
| 22 | | - raise HTTPException( |
|---|
| 23 | | - status_code=500, |
|---|
| 24 | | - detail=f"Failed to retrieve status: {result['error']}", |
|---|
| 25 | | - ) |
|---|
| 26 | | - |
|---|
| 27 | | - data = result["data"] |
|---|
| 28 | | - # Normalise to list regardless of what ops returns |
|---|
| 29 | | - if isinstance(data, list): |
|---|
| 30 | | - return data |
|---|
| 31 | | - if isinstance(data, dict): |
|---|
| 32 | | - # Some ops implementations wrap the list in a key |
|---|
| 33 | | - for key in ("services", "containers", "status", "data"): |
|---|
| 34 | | - if key in data and isinstance(data[key], list): |
|---|
| 35 | | - return data[key] |
|---|
| 36 | | - return [data] |
|---|
| 37 | | - return [] |
|---|
deleted file mode 100644| .. | .. |
|---|
| 1 | | -import asyncio |
|---|
| 2 | | -import os |
|---|
| 3 | | -import re |
|---|
| 4 | | -from typing import Any |
|---|
| 5 | | - |
|---|
| 6 | | -from fastapi import APIRouter, Depends, HTTPException |
|---|
| 7 | | - |
|---|
| 8 | | -from app.auth import verify_token |
|---|
| 9 | | -from app.ops_runner import run_command, run_ops |
|---|
| 10 | | - |
|---|
| 11 | | -router = APIRouter() |
|---|
| 12 | | - |
|---|
| 13 | | - |
|---|
| 14 | | -# --------------------------------------------------------------------------- |
|---|
| 15 | | -# Helpers |
|---|
| 16 | | -# --------------------------------------------------------------------------- |
|---|
| 17 | | - |
|---|
| 18 | | -def _parse_disk_output(raw: str) -> list[dict[str, str]]: |
|---|
| 19 | | - """Parse df-style output into a list of filesystem dicts.""" |
|---|
| 20 | | - filesystems: list[dict[str, str]] = [] |
|---|
| 21 | | - lines = raw.strip().splitlines() |
|---|
| 22 | | - if not lines: |
|---|
| 23 | | - return filesystems |
|---|
| 24 | | - |
|---|
| 25 | | - data_lines = lines[1:] if re.match(r"(?i)filesystem", lines[0]) else lines |
|---|
| 26 | | - |
|---|
| 27 | | - for line in data_lines: |
|---|
| 28 | | - parts = line.split() |
|---|
| 29 | | - if len(parts) >= 5: |
|---|
| 30 | | - filesystems.append({ |
|---|
| 31 | | - "filesystem": parts[0], |
|---|
| 32 | | - "size": parts[1], |
|---|
| 33 | | - "used": parts[2], |
|---|
| 34 | | - "available": parts[3], |
|---|
| 35 | | - "use_percent": parts[4], |
|---|
| 36 | | - "mount": parts[5] if len(parts) > 5 else "", |
|---|
| 37 | | - }) |
|---|
| 38 | | - return filesystems |
|---|
| 39 | | - |
|---|
| 40 | | - |
|---|
| 41 | | -def _parse_health_output(raw: str) -> list[dict[str, str]]: |
|---|
| 42 | | - """Parse health check output into check result dicts.""" |
|---|
| 43 | | - checks: list[dict[str, str]] = [] |
|---|
| 44 | | - for line in raw.strip().splitlines(): |
|---|
| 45 | | - line = line.strip() |
|---|
| 46 | | - if not line: |
|---|
| 47 | | - continue |
|---|
| 48 | | - match = re.match(r"^\[(\w+)\]\s*(.+)$", line) |
|---|
| 49 | | - if match: |
|---|
| 50 | | - checks.append({"status": match.group(1), "check": match.group(2)}) |
|---|
| 51 | | - else: |
|---|
| 52 | | - checks.append({"status": "INFO", "check": line}) |
|---|
| 53 | | - return checks |
|---|
| 54 | | - |
|---|
| 55 | | - |
|---|
| 56 | | -def _parse_timers_output(raw: str) -> list[dict[str, str]]: |
|---|
| 57 | | - """Parse `systemctl list-timers` output into timer dicts.""" |
|---|
| 58 | | - timers: list[dict[str, str]] = [] |
|---|
| 59 | | - lines = raw.strip().splitlines() |
|---|
| 60 | | - if not lines: |
|---|
| 61 | | - return timers |
|---|
| 62 | | - |
|---|
| 63 | | - header_idx = 0 |
|---|
| 64 | | - for i, line in enumerate(lines): |
|---|
| 65 | | - if re.match(r"(?i)next\s+left", line): |
|---|
| 66 | | - header_idx = i |
|---|
| 67 | | - break |
|---|
| 68 | | - |
|---|
| 69 | | - for line in lines[header_idx + 1:]: |
|---|
| 70 | | - line = line.strip() |
|---|
| 71 | | - if not line or line.startswith("timers listed") or line.startswith("To show"): |
|---|
| 72 | | - continue |
|---|
| 73 | | - parts = re.split(r"\s{2,}", line) |
|---|
| 74 | | - if len(parts) >= 5: |
|---|
| 75 | | - timers.append({ |
|---|
| 76 | | - "next": parts[0], "left": parts[1], "last": parts[2], |
|---|
| 77 | | - "passed": parts[3], "unit": parts[4], |
|---|
| 78 | | - "activates": parts[5] if len(parts) > 5 else "", |
|---|
| 79 | | - }) |
|---|
| 80 | | - elif parts: |
|---|
| 81 | | - timers.append({"unit": parts[0], "next": "", "left": "", "last": "", "passed": "", "activates": ""}) |
|---|
| 82 | | - return timers |
|---|
| 83 | | - |
|---|
| 84 | | - |
|---|
| 85 | | -def _read_memory() -> dict[str, Any]: |
|---|
| 86 | | - """Read memory and swap from /proc/meminfo.""" |
|---|
| 87 | | - info: dict[str, int] = {} |
|---|
| 88 | | - try: |
|---|
| 89 | | - with open("/proc/meminfo") as f: |
|---|
| 90 | | - for line in f: |
|---|
| 91 | | - parts = line.split() |
|---|
| 92 | | - if len(parts) >= 2: |
|---|
| 93 | | - key = parts[0].rstrip(":") |
|---|
| 94 | | - info[key] = int(parts[1]) * 1024 # kB → bytes |
|---|
| 95 | | - except Exception: |
|---|
| 96 | | - return {} |
|---|
| 97 | | - |
|---|
| 98 | | - mem_total = info.get("MemTotal", 0) |
|---|
| 99 | | - mem_available = info.get("MemAvailable", 0) |
|---|
| 100 | | - mem_used = mem_total - mem_available |
|---|
| 101 | | - swap_total = info.get("SwapTotal", 0) |
|---|
| 102 | | - swap_free = info.get("SwapFree", 0) |
|---|
| 103 | | - swap_used = swap_total - swap_free |
|---|
| 104 | | - |
|---|
| 105 | | - return { |
|---|
| 106 | | - "memory": { |
|---|
| 107 | | - "total": mem_total, |
|---|
| 108 | | - "used": mem_used, |
|---|
| 109 | | - "available": mem_available, |
|---|
| 110 | | - "percent": round(mem_used / mem_total * 100, 1) if mem_total else 0, |
|---|
| 111 | | - }, |
|---|
| 112 | | - "swap": { |
|---|
| 113 | | - "total": swap_total, |
|---|
| 114 | | - "used": swap_used, |
|---|
| 115 | | - "free": swap_free, |
|---|
| 116 | | - "percent": round(swap_used / swap_total * 100, 1) if swap_total else 0, |
|---|
| 117 | | - }, |
|---|
| 118 | | - } |
|---|
| 119 | | - |
|---|
| 120 | | - |
|---|
| 121 | | -def _read_cpu_stat() -> tuple[int, int]: |
|---|
| 122 | | - """Read idle and total jiffies from /proc/stat.""" |
|---|
| 123 | | - with open("/proc/stat") as f: |
|---|
| 124 | | - line = f.readline() |
|---|
| 125 | | - parts = line.split() |
|---|
| 126 | | - values = [int(x) for x in parts[1:]] |
|---|
| 127 | | - idle = values[3] + (values[4] if len(values) > 4 else 0) # idle + iowait |
|---|
| 128 | | - return idle, sum(values) |
|---|
| 129 | | - |
|---|
| 130 | | - |
|---|
| 131 | | -# --------------------------------------------------------------------------- |
|---|
| 132 | | -# Endpoints |
|---|
| 133 | | -# --------------------------------------------------------------------------- |
|---|
| 134 | | - |
|---|
| 135 | | -@router.get("/disk", summary="Disk usage") |
|---|
| 136 | | -async def disk_usage( |
|---|
| 137 | | - _: str = Depends(verify_token), |
|---|
| 138 | | -) -> dict[str, Any]: |
|---|
| 139 | | - """Returns disk usage via `ops disk` (fallback: `df -h`).""" |
|---|
| 140 | | - result = await run_ops(["disk"]) |
|---|
| 141 | | - raw = result["output"] |
|---|
| 142 | | - |
|---|
| 143 | | - if not result["success"] or not raw.strip(): |
|---|
| 144 | | - fallback = await run_command(["df", "-h"]) |
|---|
| 145 | | - raw = fallback["output"] |
|---|
| 146 | | - if not fallback["success"]: |
|---|
| 147 | | - raise HTTPException(status_code=500, detail=f"Failed to get disk usage: {result['error']}") |
|---|
| 148 | | - |
|---|
| 149 | | - return { |
|---|
| 150 | | - "filesystems": _parse_disk_output(raw), |
|---|
| 151 | | - "raw": raw, |
|---|
| 152 | | - } |
|---|
| 153 | | - |
|---|
| 154 | | - |
|---|
| 155 | | -@router.get("/health", summary="System health checks") |
|---|
| 156 | | -async def health_check( |
|---|
| 157 | | - _: str = Depends(verify_token), |
|---|
| 158 | | -) -> dict[str, Any]: |
|---|
| 159 | | - """Returns health check results via `ops health`.""" |
|---|
| 160 | | - result = await run_ops(["health"]) |
|---|
| 161 | | - if not result["success"] and not result["output"].strip(): |
|---|
| 162 | | - raise HTTPException(status_code=500, detail=f"Failed to run health checks: {result['error']}") |
|---|
| 163 | | - return { |
|---|
| 164 | | - "checks": _parse_health_output(result["output"]), |
|---|
| 165 | | - "raw": result["output"], |
|---|
| 166 | | - } |
|---|
| 167 | | - |
|---|
| 168 | | - |
|---|
| 169 | | -@router.get("/timers", summary="Systemd timers") |
|---|
| 170 | | -async def list_timers( |
|---|
| 171 | | - _: str = Depends(verify_token), |
|---|
| 172 | | -) -> dict[str, Any]: |
|---|
| 173 | | - """Lists systemd timers.""" |
|---|
| 174 | | - result = await run_command(["systemctl", "list-timers", "--no-pager"]) |
|---|
| 175 | | - if not result["success"] and not result["output"].strip(): |
|---|
| 176 | | - raise HTTPException(status_code=500, detail=f"Failed to list timers: {result['error']}") |
|---|
| 177 | | - return { |
|---|
| 178 | | - "timers": _parse_timers_output(result["output"]), |
|---|
| 179 | | - "raw": result["output"], |
|---|
| 180 | | - } |
|---|
| 181 | | - |
|---|
| 182 | | - |
|---|
| 183 | | -@router.get("/info", summary="System information with CPU/memory") |
|---|
| 184 | | -async def system_info( |
|---|
| 185 | | - _: str = Depends(verify_token), |
|---|
| 186 | | -) -> dict[str, Any]: |
|---|
| 187 | | - """ |
|---|
| 188 | | - Returns system uptime, load average, CPU usage, memory, and swap. |
|---|
| 189 | | - |
|---|
| 190 | | - CPU usage is measured over a 0.5s window from /proc/stat. |
|---|
| 191 | | - Memory/swap are read from /proc/meminfo. |
|---|
| 192 | | - """ |
|---|
| 193 | | - uptime_str = "" |
|---|
| 194 | | - load_str = "" |
|---|
| 195 | | - |
|---|
| 196 | | - # Uptime |
|---|
| 197 | | - try: |
|---|
| 198 | | - with open("/proc/uptime") as f: |
|---|
| 199 | | - seconds_up = float(f.read().split()[0]) |
|---|
| 200 | | - days = int(seconds_up // 86400) |
|---|
| 201 | | - hours = int((seconds_up % 86400) // 3600) |
|---|
| 202 | | - minutes = int((seconds_up % 3600) // 60) |
|---|
| 203 | | - uptime_str = f"{days}d {hours}h {minutes}m" |
|---|
| 204 | | - except Exception: |
|---|
| 205 | | - pass |
|---|
| 206 | | - |
|---|
| 207 | | - # Load average |
|---|
| 208 | | - try: |
|---|
| 209 | | - with open("/proc/loadavg") as f: |
|---|
| 210 | | - parts = f.read().split() |
|---|
| 211 | | - load_str = f"{parts[0]}, {parts[1]}, {parts[2]}" |
|---|
| 212 | | - except Exception: |
|---|
| 213 | | - pass |
|---|
| 214 | | - |
|---|
| 215 | | - # CPU usage (two samples, 0.5s apart) |
|---|
| 216 | | - cpu_info: dict[str, Any] = {} |
|---|
| 217 | | - try: |
|---|
| 218 | | - idle1, total1 = _read_cpu_stat() |
|---|
| 219 | | - await asyncio.sleep(0.5) |
|---|
| 220 | | - idle2, total2 = _read_cpu_stat() |
|---|
| 221 | | - total_delta = total2 - total1 |
|---|
| 222 | | - if total_delta > 0: |
|---|
| 223 | | - usage = round((1 - (idle2 - idle1) / total_delta) * 100, 1) |
|---|
| 224 | | - else: |
|---|
| 225 | | - usage = 0.0 |
|---|
| 226 | | - cpu_info = { |
|---|
| 227 | | - "usage_percent": usage, |
|---|
| 228 | | - "cores": os.cpu_count() or 1, |
|---|
| 229 | | - } |
|---|
| 230 | | - except Exception: |
|---|
| 231 | | - pass |
|---|
| 232 | | - |
|---|
| 233 | | - # Memory + Swap |
|---|
| 234 | | - mem_info = _read_memory() |
|---|
| 235 | | - |
|---|
| 236 | | - # Fallback for uptime/load if /proc wasn't available |
|---|
| 237 | | - if not uptime_str or not load_str: |
|---|
| 238 | | - result = await run_command(["uptime"]) |
|---|
| 239 | | - if result["success"]: |
|---|
| 240 | | - raw = result["output"].strip() |
|---|
| 241 | | - up_match = re.search(r"up\s+(.+?),\s+\d+\s+user", raw) |
|---|
| 242 | | - if up_match: |
|---|
| 243 | | - uptime_str = uptime_str or up_match.group(1).strip() |
|---|
| 244 | | - load_match = re.search(r"load average[s]?:\s*(.+)$", raw, re.IGNORECASE) |
|---|
| 245 | | - if load_match: |
|---|
| 246 | | - load_str = load_str or load_match.group(1).strip() |
|---|
| 247 | | - |
|---|
| 248 | | - return { |
|---|
| 249 | | - "uptime": uptime_str or "unavailable", |
|---|
| 250 | | - "load": load_str or "unavailable", |
|---|
| 251 | | - "cpu": cpu_info or None, |
|---|
| 252 | | - **mem_info, |
|---|
| 253 | | - } |
|---|