Matthias Nott
2026-02-21 ed26def7d76ac011075c11e8c1679ed1f7a08abc
app/routers/services.py
....@@ -1,5 +1,7 @@
1
+import os
12 from typing import Any
23
4
+import yaml
35 from fastapi import APIRouter, Depends, HTTPException, Query
46
57 from app.auth import verify_token
....@@ -8,14 +10,111 @@
810 router = APIRouter()
911
1012 _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
1122
1223
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:
1476 """
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)
1784 """
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
+# ---------------------------------------------------------------------------
19118
20119
21120 @router.get("/logs/{project}/{env}/{service}", summary="Get container logs")
....@@ -23,20 +122,19 @@
23122 project: str,
24123 env: str,
25124 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
+ ),
27128 _: str = Depends(verify_token),
28129 ) -> 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)
34132 result = await run_command(
35133 [_DOCKER, "logs", "--tail", str(lines), container],
36134 timeout=30,
37135 )
38136
39
- # docker logs writes to stderr by default; treat combined output as logs
137
+ # docker logs writes to stderr by default; combine both streams
40138 combined = result["output"] + result["error"]
41139
42140 if not result["success"] and not combined.strip():
....@@ -59,10 +157,8 @@
59157 service: str,
60158 _: str = Depends(verify_token),
61159 ) -> 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)
66162 result = await run_command(
67163 [_DOCKER, "restart", container],
68164 timeout=60,