import sys from typing import Any from fastapi import APIRouter, Depends, HTTPException, Query from app.auth import verify_token from app.ops_runner import run_command sys.path.insert(0, "/opt/infrastructure") from toolkit.descriptor import find as find_project # noqa: E402 router = APIRouter() _DOCKER = "docker" # --------------------------------------------------------------------------- # 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. Loads the project descriptor and expands container_prefix for the given env (e.g. "{env}-mdf" -> "dev-mdf"), then tries: 1. {expanded_prefix}-{service} e.g. dev-mdf-wordpress 2. exact match on expanded_prefix (infra containers with no service suffix) """ desc = find_project(project) if desc is None: raise HTTPException( status_code=404, detail=f"Project '{project}' not found", ) expanded_prefix = desc.container_prefix_for(env) # Pattern 1: {expanded_prefix}-{service} hit = await _find_by_prefix(f"{expanded_prefix}-{service}") if hit: return hit # Pattern 2: exact match on prefix (infrastructure containers, e.g. "coolify") if service == expanded_prefix or service == desc.name: hit = await _find_exact(expanded_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", }