gabriel / musehub public
test_deployment.py python
314 lines 14.9 KB
Raw
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 121 days ago
1 """Section 7.3 — Deployment readiness tests.
2
3 Covers:
4 Zero-downtime : deploy.sh is blue-green (two slots, nginx flip); health
5 check URLs point to /healthz not a UI page.
6 /healthz : returns 200 when DB + storage healthy; 503 with JSON body
7 when either is down; exempt from auth; fast.
8 Non-root user : Dockerfile uses USER instruction (non-root); container
9 runs as the 'musehub' system user.
10 Read-only FS : docker-compose.yml sets read_only: true on musehub service;
11 /tmp is mounted as tmpfs; /data is a named volume.
12 Resource limits: CPU and memory limits set on musehub, postgres, runner.
13 """
14 from __future__ import annotations
15
16 import re
17 from pathlib import Path
18 from unittest.mock import AsyncMock, MagicMock, patch
19
20 import pytest
21 from httpx import AsyncClient
22
23 _ROOT = Path(__file__).resolve().parents[1]
24 _DOCKERFILE = _ROOT / "Dockerfile"
25 _COMPOSE = _ROOT / "docker-compose.yml"
26 _DEPLOY_SH = _ROOT / "deploy" / "deploy.sh"
27
28
29 # ═══════════════════════════════════════════════════════════════════════════════
30 # Zero-downtime deploy
31 # ═══════════════════════════════════════════════════════════════════════════════
32
33 class TestZeroDowntimeDeploy:
34 _src = _DEPLOY_SH.read_text()
35
36 def test_two_slots_defined(self) -> None:
37 """deploy.sh must define both blue and green slots."""
38 assert "blue" in self._src and "green" in self._src
39
40 def test_nginx_flip_present(self) -> None:
41 """deploy.sh must reload nginx after health check passes (atomic flip)."""
42 assert "nginx -s reload" in self._src or "nginx_point_to" in self._src
43
44 def test_health_check_before_nginx_flip(self) -> None:
45 """Health check must happen before the nginx flip — never flip a sick slot."""
46 src = self._src
47 health_pos = src.find("health_check")
48 nginx_pos = src.find("nginx_point_to")
49 assert health_pos != -1, "health_check function not found in deploy.sh"
50 assert nginx_pos != -1, "nginx_point_to not found in deploy.sh"
51 assert health_pos < nginx_pos, (
52 "nginx flip happens before health check — would route to an unhealthy slot"
53 )
54
55 def test_health_urls_point_to_healthz(self) -> None:
56 """deploy.sh must use /healthz not a UI page as the readiness signal."""
57 assert "/healthz" in self._src, (
58 "deploy.sh does not use /healthz — health check may pass even when "
59 "DB or storage is down (UI pages don't probe dependencies)"
60 )
61 assert "/explore" not in self._src, (
62 "deploy.sh still references /explore as a health URL — update to /healthz"
63 )
64
65 def test_old_slot_stopped_after_flip(self) -> None:
66 """deploy.sh must stop the old slot after the nginx flip to free resources."""
67 src = self._src
68 nginx_pos = src.find("nginx_point_to")
69 stop_pos = src.find("docker rm -f", nginx_pos)
70 assert stop_pos != -1, (
71 "deploy.sh does not stop the old slot after the nginx flip"
72 )
73
74 def test_dockerfile_healthcheck_uses_healthz(self) -> None:
75 """Dockerfile HEALTHCHECK must probe /healthz."""
76 src = _DOCKERFILE.read_text()
77 hc_lines = [l for l in src.splitlines() if "HEALTHCHECK" in l or l.strip().startswith("CMD")]
78 combined = " ".join(hc_lines)
79 assert "/healthz" in combined, (
80 "Dockerfile HEALTHCHECK does not probe /healthz — "
81 "docker will report 'healthy' even when DB is down"
82 )
83
84
85 # ═══════════════════════════════════════════════════════════════════════════════
86 # /healthz endpoint
87 # ═══════════════════════════════════════════════════════════════════════════════
88
89 class TestHealthzEndpoint:
90 async def test_healthz_returns_200_when_healthy(self, client: AsyncClient) -> None:
91 """GET /healthz must return 200 when DB and storage are reachable."""
92 resp = await client.get("/healthz")
93 assert resp.status_code == 200
94 body = resp.json()
95 assert body["status"] == "ok"
96 assert body["db"] is True
97 assert body["storage"] is True
98
99 async def test_healthz_returns_json(self, client: AsyncClient) -> None:
100 resp = await client.get("/healthz")
101 assert resp.headers["content-type"].startswith("application/json")
102
103 async def test_healthz_no_auth_required(self, client: AsyncClient) -> None:
104 """Healthz must be reachable without any Authorization header."""
105 resp = await client.get("/healthz")
106 # Must not be 401 or 403
107 assert resp.status_code not in (401, 403), (
108 f"/healthz returned {resp.status_code} — health check must be unauthenticated"
109 )
110
111 async def test_healthz_503_when_db_down(self, client: AsyncClient) -> None:
112 """GET /healthz must return 503 when the DB probe fails."""
113 from sqlalchemy.exc import OperationalError
114
115 # Patch the DB execute to simulate a broken connection
116 with patch(
117 "musehub.main.AsyncSession.execute",
118 new_callable=AsyncMock,
119 side_effect=OperationalError("connection refused", None, None),
120 ):
121 resp = await client.get("/healthz")
122
123 assert resp.status_code == 503
124 body = resp.json()
125 assert body["status"] == "unhealthy"
126 assert body["db"] is False
127
128 async def test_healthz_503_when_storage_down(self, client: AsyncClient) -> None:
129 """GET /healthz must return 503 when the storage probe fails."""
130 from musehub.storage.backends import LocalBackend
131
132 # Patch get_backend at the source module so the local import inside
133 # healthz() picks up the mock.
134 bad_backend = LocalBackend.__new__(LocalBackend)
135 bad_backend._root = Path("/nonexistent/path/that/does/not/exist")
136
137 with patch("musehub.storage.backends.get_backend", return_value=bad_backend):
138 resp = await client.get("/healthz")
139
140 assert resp.status_code == 503
141 body = resp.json()
142 assert body["status"] == "unhealthy"
143 assert body["storage"] is False
144
145 async def test_healthz_body_has_db_and_storage_keys(self, client: AsyncClient) -> None:
146 """Response body must expose both db and storage status for monitoring."""
147 resp = await client.get("/healthz")
148 body = resp.json()
149 assert "db" in body, "healthz response missing 'db' key"
150 assert "storage" in body, "healthz response missing 'storage' key"
151
152 async def test_healthz_fast(self, client: AsyncClient) -> None:
153 """Healthz must respond in under 2 s (load balancer timeout is typically 5 s)."""
154 import time
155 start = time.monotonic()
156 await client.get("/healthz")
157 elapsed = time.monotonic() - start
158 assert elapsed < 2.0, f"/healthz took {elapsed:.2f}s — too slow for a probe"
159
160 def test_healthz_route_registered(self) -> None:
161 """The /healthz route must be registered in the FastAPI app."""
162 from musehub.main import app
163 paths = [route.path for route in app.routes]
164 assert "/healthz" in paths, "/healthz route not registered in app"
165
166
167 # ═══════════════════════════════════════════════════════════════════════════════
168 # Non-root container user
169 # ═══════════════════════════════════════════════════════════════════════════════
170
171 class TestNonRootUser:
172 _src = _DOCKERFILE.read_text()
173
174 def test_dockerfile_has_user_instruction(self) -> None:
175 """Dockerfile must have a USER instruction."""
176 user_lines = [l.strip() for l in self._src.splitlines()
177 if l.strip().upper().startswith("USER ")]
178 assert user_lines, "Dockerfile has no USER instruction — container runs as root"
179
180 def test_dockerfile_user_is_not_root(self) -> None:
181 """Dockerfile USER must not be root or UID 0."""
182 user_lines = [l.strip() for l in self._src.splitlines()
183 if l.strip().upper().startswith("USER ")]
184 for line in user_lines:
185 user = line.split()[1].lower()
186 assert user not in ("root", "0"), (
187 f"Dockerfile sets USER to {user!r} — container must run as non-root"
188 )
189
190 def test_dockerfile_creates_system_user(self) -> None:
191 """Dockerfile must create a dedicated system user (groupadd + useradd)."""
192 assert "groupadd" in self._src and "useradd" in self._src, (
193 "Dockerfile does not create a dedicated system user"
194 )
195
196 def test_dockerfile_user_applied_after_installs(self) -> None:
197 """USER instruction must come after RUN pip install (installs need root)."""
198 lines = self._src.splitlines()
199 user_idx = next(
200 (i for i, l in enumerate(lines) if l.strip().upper().startswith("USER ")), None
201 )
202 pip_idx = max(
203 (i for i, l in enumerate(lines) if "pip install" in l), default=None
204 )
205 assert user_idx is not None and pip_idx is not None
206 assert user_idx > pip_idx, (
207 "USER instruction appears before pip install — "
208 "package installation would fail without root"
209 )
210
211
212 # ═══════════════════════════════════════════════════════════════════════════════
213 # Read-only filesystem
214 # ═══════════════════════════════════════════════════════════════════════════════
215
216 class TestReadOnlyFilesystem:
217 def _parse_yaml(self) -> None:
218 import yaml
219 return yaml.safe_load(_COMPOSE.read_text())
220
221 def test_musehub_service_read_only(self) -> None:
222 """musehub service must have read_only: true."""
223 src = _COMPOSE.read_text()
224 # Structural check: read_only appears in the musehub service block
225 # Find musehub service block (between 'musehub:' and the next top-level key)
226 in_musehub = False
227 for line in src.splitlines():
228 if re.match(r'^ musehub:', line):
229 in_musehub = True
230 elif re.match(r'^ \w', line) and in_musehub:
231 in_musehub = False
232 if in_musehub and "read_only: true" in line:
233 return
234 pytest.fail(
235 "musehub service in docker-compose.yml does not have read_only: true"
236 )
237
238 def test_tmp_is_tmpfs_or_volume(self) -> None:
239 """/tmp must be writable (tmpfs or volume) so uvicorn can write temp files."""
240 src = _COMPOSE.read_text()
241 assert "tmpfs" in src or "/tmp" in src, (
242 "No tmpfs mount for /tmp — uvicorn and Python will fail to write temp files "
243 "when the root filesystem is read-only"
244 )
245
246 def test_data_volume_is_explicit(self) -> None:
247 """/data object store must be an explicit named volume (not read-only)."""
248 src = _COMPOSE.read_text()
249 assert "musehub_data:/data" in src, (
250 "/data is not mounted as an explicit volume — objects cannot be written "
251 "when the root filesystem is read-only"
252 )
253
254
255 # ═══════════════════════════════════════════════════════════════════════════════
256 # Resource limits
257 # ═══════════════════════════════════════════════════════════════════════════════
258
259 class TestResourceLimits:
260 _src = _COMPOSE.read_text()
261
262 def _service_limits(self, service_name: str) -> str:
263 """Extract the text block for a given service."""
264 lines = self._src.splitlines()
265 in_service = False
266 block_lines = []
267 for line in lines:
268 if re.match(rf'^ {re.escape(service_name)}:', line):
269 in_service = True
270 elif re.match(r'^ \w', line) and in_service:
271 break
272 if in_service:
273 block_lines.append(line)
274 return "\n".join(block_lines)
275
276 def test_musehub_has_cpu_limit(self) -> None:
277 block = self._service_limits("musehub")
278 assert "cpus:" in block, "musehub service has no CPU limit"
279
280 def test_musehub_has_memory_limit(self) -> None:
281 block = self._service_limits("musehub")
282 assert "memory:" in block, "musehub service has no memory limit"
283
284 def test_musehub_memory_limit_sane(self) -> None:
285 """musehub memory limit must be ≥ 256 MiB (app needs headroom)."""
286 block = self._service_limits("musehub")
287 m = re.search(r'memory:\s*(\d+)([MmGg])', block)
288 if m:
289 amount = int(m.group(1))
290 unit = m.group(2).upper()
291 mb = amount * 1024 if unit == "G" else amount
292 assert mb >= 256, f"musehub memory limit {mb}M is below 256M minimum"
293
294 def test_postgres_has_cpu_limit(self) -> None:
295 block = self._service_limits("postgres")
296 assert "cpus:" in block, "postgres service has no CPU limit"
297
298 def test_postgres_has_memory_limit(self) -> None:
299 block = self._service_limits("postgres")
300 assert "memory:" in block, "postgres service has no memory limit"
301
302 def test_runner_has_cpu_limit(self) -> None:
303 block = self._service_limits("musehub-runner")
304 assert "cpus:" in block, "musehub-runner service has no CPU limit"
305
306 def test_runner_has_memory_limit(self) -> None:
307 block = self._service_limits("musehub-runner")
308 assert "memory:" in block, "musehub-runner service has no memory limit"
309
310 def test_all_services_have_deploy_block(self) -> None:
311 """All three services must have a deploy: block (where limits live)."""
312 for svc in ("musehub", "postgres", "musehub-runner"):
313 block = self._service_limits(svc)
314 assert "deploy:" in block, f"{svc} service has no deploy: block"
File History 1 commit
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 121 days ago