| .. | .. |
|---|
| 1 | +import os |
|---|
| 1 | 2 | from typing import Any |
|---|
| 2 | 3 | |
|---|
| 4 | +import yaml |
|---|
| 3 | 5 | from fastapi import APIRouter, Depends, HTTPException, Query |
|---|
| 4 | 6 | |
|---|
| 5 | 7 | from app.auth import verify_token |
|---|
| .. | .. |
|---|
| 8 | 10 | router = APIRouter() |
|---|
| 9 | 11 | |
|---|
| 10 | 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 |
|---|
| 11 | 22 | |
|---|
| 12 | 23 | |
|---|
| 13 | | -def _container_name(project: str, env: str, service: str) -> str: |
|---|
| 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: |
|---|
| 14 | 76 | """ |
|---|
| 15 | | - Derive the Docker container name from project, env, and service. |
|---|
| 16 | | - Docker Compose v2 default: {project}-{env}-{service}-1 |
|---|
| 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) |
|---|
| 17 | 84 | """ |
|---|
| 18 | | - return f"{project}-{env}-{service}-1" |
|---|
| 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 | +# --------------------------------------------------------------------------- |
|---|
| 19 | 118 | |
|---|
| 20 | 119 | |
|---|
| 21 | 120 | @router.get("/logs/{project}/{env}/{service}", summary="Get container logs") |
|---|
| .. | .. |
|---|
| 23 | 122 | project: str, |
|---|
| 24 | 123 | env: str, |
|---|
| 25 | 124 | service: str, |
|---|
| 26 | | - lines: int = Query(default=100, ge=1, le=10000, description="Number of log lines to return"), |
|---|
| 125 | + lines: int = Query( |
|---|
| 126 | + default=100, ge=1, le=10000, description="Number of log lines to return" |
|---|
| 127 | + ), |
|---|
| 27 | 128 | _: str = Depends(verify_token), |
|---|
| 28 | 129 | ) -> dict[str, Any]: |
|---|
| 29 | | - """ |
|---|
| 30 | | - Fetch the last N lines of logs from a container. |
|---|
| 31 | | - Uses `docker logs --tail {lines} {container}`. |
|---|
| 32 | | - """ |
|---|
| 33 | | - container = _container_name(project, env, service) |
|---|
| 130 | + """Fetch the last N lines of logs from a container.""" |
|---|
| 131 | + container = await _resolve_container(project, env, service) |
|---|
| 34 | 132 | result = await run_command( |
|---|
| 35 | 133 | [_DOCKER, "logs", "--tail", str(lines), container], |
|---|
| 36 | 134 | timeout=30, |
|---|
| 37 | 135 | ) |
|---|
| 38 | 136 | |
|---|
| 39 | | - # docker logs writes to stderr by default; treat combined output as logs |
|---|
| 137 | + # docker logs writes to stderr by default; combine both streams |
|---|
| 40 | 138 | combined = result["output"] + result["error"] |
|---|
| 41 | 139 | |
|---|
| 42 | 140 | if not result["success"] and not combined.strip(): |
|---|
| .. | .. |
|---|
| 59 | 157 | service: str, |
|---|
| 60 | 158 | _: str = Depends(verify_token), |
|---|
| 61 | 159 | ) -> dict[str, Any]: |
|---|
| 62 | | - """ |
|---|
| 63 | | - Restart a Docker container via `docker restart {container}`. |
|---|
| 64 | | - """ |
|---|
| 65 | | - container = _container_name(project, env, service) |
|---|
| 160 | + """Restart a Docker container.""" |
|---|
| 161 | + container = await _resolve_container(project, env, service) |
|---|
| 66 | 162 | result = await run_command( |
|---|
| 67 | 163 | [_DOCKER, "restart", container], |
|---|
| 68 | 164 | timeout=60, |
|---|