import os from typing import Any import yaml from fastapi import APIRouter, Depends, HTTPException, Query from app.auth import verify_token from app.ops_runner import run_command router = APIRouter() _DOCKER = "docker" _REGISTRY_PATH = os.environ.get( "REGISTRY_PATH", "/opt/infrastructure/servers/hetzner-vps/registry.yaml", ) # --------------------------------------------------------------------------- # Registry-based name prefix lookup (cached) # --------------------------------------------------------------------------- _prefix_cache: dict[str, str] | None = None def _load_prefixes() -> dict[str, str]: """Load project -> name_prefix mapping from the ops registry.""" global _prefix_cache if _prefix_cache is not None: return _prefix_cache try: with open(_REGISTRY_PATH) as f: data = yaml.safe_load(f) _prefix_cache = {} for proj_name, cfg in data.get("projects", {}).items(): _prefix_cache[proj_name] = cfg.get("name_prefix", proj_name) return _prefix_cache except Exception: return {} # --------------------------------------------------------------------------- # Container name resolution # --------------------------------------------------------------------------- async def _find_by_prefix(pattern: str) -> str | None: """Find first running container whose name starts with `pattern`.""" result = await run_command( [_DOCKER, "ps", "--filter", f"name={pattern}", "--format", "{{.Names}}"], timeout=10, ) if not result["success"]: return None for name in result["output"].strip().splitlines(): name = name.strip() if name and name.startswith(pattern): return name return None async def _find_exact(name: str) -> str | None: """Find a running container with exactly this name.""" result = await run_command( [_DOCKER, "ps", "--filter", f"name={name}", "--format", "{{.Names}}"], timeout=10, ) if not result["success"]: return None for n in result["output"].strip().splitlines(): if n.strip() == name: return name return None async def _resolve_container(project: str, env: str, service: str) -> str: """ Resolve the actual Docker container name from project/env/service. Uses the ops registry name_prefix mapping and tries patterns in order: 1. {env}-{prefix}-{service} (mdf, seriousletter: dev-mdf-mysql-UUID) 2. {prefix}-{service} (ringsaday: ringsaday-website-UUID, coolify: coolify-db) 3. {prefix}-{env} (ringsaday: ringsaday-dev-UUID) 4. exact {prefix} (coolify infra: coolify) """ prefixes = _load_prefixes() prefix = prefixes.get(project, project) # Pattern 1: {env}-{prefix}-{service} hit = await _find_by_prefix(f"{env}-{prefix}-{service}") if hit: return hit # Pattern 2: {prefix}-{service} hit = await _find_by_prefix(f"{prefix}-{service}") if hit: return hit # Pattern 3: {prefix}-{env} hit = await _find_by_prefix(f"{prefix}-{env}") if hit: return hit # Pattern 4: exact match when service == prefix (e.g., coolify) if service == prefix: hit = await _find_exact(prefix) if hit: return hit raise HTTPException( status_code=404, detail=f"Container not found for {project}/{env}/{service}", ) # --------------------------------------------------------------------------- # Endpoints # --------------------------------------------------------------------------- @router.get("/logs/{project}/{env}/{service}", summary="Get container logs") async def get_logs( project: str, env: str, service: str, lines: int = Query( default=100, ge=1, le=10000, description="Number of log lines to return" ), _: str = Depends(verify_token), ) -> dict[str, Any]: """Fetch the last N lines of logs from a container.""" container = await _resolve_container(project, env, service) result = await run_command( [_DOCKER, "logs", "--tail", str(lines), container], timeout=30, ) # docker logs writes to stderr by default; combine both streams combined = result["output"] + result["error"] if not result["success"] and not combined.strip(): raise HTTPException( status_code=500, detail=f"Failed to retrieve logs for container '{container}'", ) return { "container": container, "lines": lines, "logs": combined, } @router.post("/restart/{project}/{env}/{service}", summary="Restart a container") async def restart_service( project: str, env: str, service: str, _: str = Depends(verify_token), ) -> dict[str, Any]: """Restart a Docker container.""" container = await _resolve_container(project, env, service) result = await run_command( [_DOCKER, "restart", container], timeout=60, ) if not result["success"]: raise HTTPException( status_code=500, detail=f"Failed to restart container '{container}': {result['error'] or result['output']}", ) return { "success": True, "container": container, "message": f"Container '{container}' restarted successfully", }