gabriel / musehub public
test_deployment.py python
408 lines 19.6 KB
Raw
sha256:8e5bf0f59b0dbd014580dec546aafd5eac90ea0915244fda362261505ebafc8f fix(#194): deploy.sh's ECR_REGISTRY now derives from ECR_IM… Sonnet 5 minor ⚠ breaking 3 hours 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 import subprocess
18 from pathlib import Path
19 from unittest.mock import AsyncMock, MagicMock, patch
20
21 import pytest
22 from httpx import AsyncClient
23
24 _ROOT = Path(__file__).resolve().parents[1]
25 _DOCKERFILE = _ROOT / "Dockerfile"
26 _COMPOSE = _ROOT / "docker-compose.yml"
27 _DEPLOY_SH = _ROOT / "deploy" / "deploy.sh"
28
29
30 # ═══════════════════════════════════════════════════════════════════════════════
31 # Zero-downtime deploy
32 # ═══════════════════════════════════════════════════════════════════════════════
33
34 class TestZeroDowntimeDeploy:
35 _src = _DEPLOY_SH.read_text()
36
37 def test_two_slots_defined(self) -> None:
38 """deploy.sh must define both blue and green slots."""
39 assert "blue" in self._src and "green" in self._src
40
41 def test_nginx_flip_present(self) -> None:
42 """deploy.sh must reload nginx after health check passes (atomic flip)."""
43 assert "nginx -s reload" in self._src or "nginx_point_to" in self._src
44
45 def test_health_check_before_nginx_flip(self) -> None:
46 """Health check must happen before the nginx flip — never flip a sick slot."""
47 src = self._src
48 health_pos = src.find("health_check")
49 nginx_pos = src.find("nginx_point_to")
50 assert health_pos != -1, "health_check function not found in deploy.sh"
51 assert nginx_pos != -1, "nginx_point_to not found in deploy.sh"
52 assert health_pos < nginx_pos, (
53 "nginx flip happens before health check — would route to an unhealthy slot"
54 )
55
56 def test_health_urls_point_to_healthz(self) -> None:
57 """deploy.sh must use /healthz not a UI page as the readiness signal."""
58 assert "/healthz" in self._src, (
59 "deploy.sh does not use /healthz — health check may pass even when "
60 "DB or storage is down (UI pages don't probe dependencies)"
61 )
62 assert "/explore" not in self._src, (
63 "deploy.sh still references /explore as a health URL — update to /healthz"
64 )
65
66 def test_old_slot_stopped_after_flip(self) -> None:
67 """deploy.sh must stop the old slot after the nginx flip to free resources."""
68 src = self._src
69 nginx_pos = src.find("nginx_point_to")
70 stop_pos = src.find("docker rm -f", nginx_pos)
71 assert stop_pos != -1, (
72 "deploy.sh does not stop the old slot after the nginx flip"
73 )
74
75 def test_dockerfile_healthcheck_uses_healthz(self) -> None:
76 """Dockerfile HEALTHCHECK must probe /healthz."""
77 src = _DOCKERFILE.read_text()
78 hc_lines = [l for l in src.splitlines() if "HEALTHCHECK" in l or l.strip().startswith("CMD")]
79 combined = " ".join(hc_lines)
80 assert "/healthz" in combined, (
81 "Dockerfile HEALTHCHECK does not probe /healthz — "
82 "docker will report 'healthy' even when DB is down"
83 )
84
85
86 # ═══════════════════════════════════════════════════════════════════════════════
87 # /healthz endpoint
88 # ═══════════════════════════════════════════════════════════════════════════════
89
90 class TestHealthzEndpoint:
91 async def test_healthz_returns_200_when_healthy(self, client: AsyncClient) -> None:
92 """GET /healthz must return 200 when DB and storage are reachable."""
93 resp = await client.get("/healthz")
94 assert resp.status_code == 200
95 body = resp.json()
96 assert body["status"] == "ok"
97 assert body["db"] is True
98 assert body["storage"] is True
99
100 async def test_healthz_returns_json(self, client: AsyncClient) -> None:
101 resp = await client.get("/healthz")
102 assert resp.headers["content-type"].startswith("application/json")
103
104 async def test_healthz_no_auth_required(self, client: AsyncClient) -> None:
105 """Healthz must be reachable without any Authorization header."""
106 resp = await client.get("/healthz")
107 # Must not be 401 or 403
108 assert resp.status_code not in (401, 403), (
109 f"/healthz returned {resp.status_code} — health check must be unauthenticated"
110 )
111
112 async def test_healthz_503_when_db_down(self, client: AsyncClient) -> None:
113 """GET /healthz must return 503 when the DB probe fails."""
114 from sqlalchemy.exc import OperationalError
115
116 # Patch the DB execute to simulate a broken connection
117 with patch(
118 "musehub.main.AsyncSession.execute",
119 new_callable=AsyncMock,
120 side_effect=OperationalError("connection refused", None, None),
121 ):
122 resp = await client.get("/healthz")
123
124 assert resp.status_code == 503
125 body = resp.json()
126 assert body["status"] == "unhealthy"
127 assert body["db"] is False
128
129 async def test_healthz_503_when_storage_down(self, client: AsyncClient) -> None:
130 """GET /healthz must return 503 when the storage probe fails."""
131 from musehub.storage.backends import BlobBackend
132
133 # Backend pointing at an unreachable endpoint — head_bucket will fail.
134 bad_backend = BlobBackend(
135 bucket="muse-objects",
136 endpoint_url="http://127.0.0.1:19999", # nothing listening here
137 access_key_id="x",
138 secret_access_key="x",
139 region="us-east-1",
140 )
141
142 with patch("musehub.storage.backends.get_backend", return_value=bad_backend):
143 resp = await client.get("/healthz")
144
145 assert resp.status_code == 503
146 body = resp.json()
147 assert body["status"] == "unhealthy"
148 assert body["storage"] is False
149
150 async def test_healthz_body_has_db_and_storage_keys(self, client: AsyncClient) -> None:
151 """Response body must expose both db and storage status for monitoring."""
152 resp = await client.get("/healthz")
153 body = resp.json()
154 assert "db" in body, "healthz response missing 'db' key"
155 assert "storage" in body, "healthz response missing 'storage' key"
156
157 async def test_healthz_fast(self, client: AsyncClient) -> None:
158 """Healthz must respond in under 2 s (load balancer timeout is typically 5 s)."""
159 import time
160 start = time.monotonic()
161 await client.get("/healthz")
162 elapsed = time.monotonic() - start
163 assert elapsed < 2.0, f"/healthz took {elapsed:.2f}s — too slow for a probe"
164
165 def test_healthz_route_registered(self) -> None:
166 """The /healthz route must be registered in the FastAPI app."""
167 from musehub.main import app
168 paths = [route.path for route in app.routes]
169 assert "/healthz" in paths, "/healthz route not registered in app"
170
171
172 # ═══════════════════════════════════════════════════════════════════════════════
173 # Non-root container user
174 # ═══════════════════════════════════════════════════════════════════════════════
175
176 class TestNonRootUser:
177 _src = _DOCKERFILE.read_text()
178
179 def test_dockerfile_has_user_instruction(self) -> None:
180 """Dockerfile must have a USER instruction."""
181 user_lines = [l.strip() for l in self._src.splitlines()
182 if l.strip().upper().startswith("USER ")]
183 assert user_lines, "Dockerfile has no USER instruction — container runs as root"
184
185 def test_dockerfile_user_is_not_root(self) -> None:
186 """Dockerfile USER must not be root or UID 0."""
187 user_lines = [l.strip() for l in self._src.splitlines()
188 if l.strip().upper().startswith("USER ")]
189 for line in user_lines:
190 user = line.split()[1].lower()
191 assert user not in ("root", "0"), (
192 f"Dockerfile sets USER to {user!r} — container must run as non-root"
193 )
194
195 def test_dockerfile_creates_system_user(self) -> None:
196 """Dockerfile must create a dedicated system user (groupadd + useradd)."""
197 assert "groupadd" in self._src and "useradd" in self._src, (
198 "Dockerfile does not create a dedicated system user"
199 )
200
201 def test_dockerfile_user_applied_after_installs(self) -> None:
202 """USER instruction must come after RUN pip install (installs need root)."""
203 lines = self._src.splitlines()
204 user_idx = next(
205 (i for i, l in enumerate(lines) if l.strip().upper().startswith("USER ")), None
206 )
207 pip_idx = max(
208 (i for i, l in enumerate(lines) if "pip install" in l), default=None
209 )
210 assert user_idx is not None and pip_idx is not None
211 assert user_idx > pip_idx, (
212 "USER instruction appears before pip install — "
213 "package installation would fail without root"
214 )
215
216
217 # ═══════════════════════════════════════════════════════════════════════════════
218 # Read-only filesystem
219 # ═══════════════════════════════════════════════════════════════════════════════
220
221 class TestReadOnlyFilesystem:
222 def _parse_yaml(self) -> None:
223 import yaml
224 return yaml.safe_load(_COMPOSE.read_text())
225
226 def test_musehub_service_read_only(self) -> None:
227 """musehub service must have read_only: true."""
228 src = _COMPOSE.read_text()
229 # Structural check: read_only appears in the musehub service block
230 # Find musehub service block (between 'musehub:' and the next top-level key)
231 in_musehub = False
232 for line in src.splitlines():
233 if re.match(r'^ musehub:', line):
234 in_musehub = True
235 elif re.match(r'^ \w', line) and in_musehub:
236 in_musehub = False
237 if in_musehub and "read_only: true" in line:
238 return
239 pytest.fail(
240 "musehub service in docker-compose.yml does not have read_only: true"
241 )
242
243 def test_tmp_is_tmpfs_or_volume(self) -> None:
244 """/tmp must be writable (tmpfs or volume) so uvicorn can write temp files."""
245 src = _COMPOSE.read_text()
246 assert "tmpfs" in src or "/tmp" in src, (
247 "No tmpfs mount for /tmp — uvicorn and Python will fail to write temp files "
248 "when the root filesystem is read-only"
249 )
250
251 def test_data_volume_is_explicit(self) -> None:
252 """/data object store must be an explicit named volume (not read-only)."""
253 src = _COMPOSE.read_text()
254 assert "musehub_data:/data" in src, (
255 "/data is not mounted as an explicit volume — objects cannot be written "
256 "when the root filesystem is read-only"
257 )
258
259
260 # ═══════════════════════════════════════════════════════════════════════════════
261 # Resource limits
262 # ═══════════════════════════════════════════════════════════════════════════════
263
264 class TestResourceLimits:
265 _src = _COMPOSE.read_text()
266
267 def _service_limits(self, service_name: str) -> str:
268 """Extract the text block for a given service."""
269 lines = self._src.splitlines()
270 in_service = False
271 block_lines = []
272 for line in lines:
273 if re.match(rf'^ {re.escape(service_name)}:', line):
274 in_service = True
275 elif re.match(r'^ \w', line) and in_service:
276 break
277 if in_service:
278 block_lines.append(line)
279 return "\n".join(block_lines)
280
281 def test_musehub_has_cpu_limit(self) -> None:
282 block = self._service_limits("musehub")
283 assert "cpus:" in block, "musehub service has no CPU limit"
284
285 def test_musehub_has_memory_limit(self) -> None:
286 block = self._service_limits("musehub")
287 assert "memory:" in block, "musehub service has no memory limit"
288
289 def test_musehub_memory_limit_sane(self) -> None:
290 """musehub memory limit must be ≥ 256 MiB (app needs headroom)."""
291 block = self._service_limits("musehub")
292 m = re.search(r'memory:\s*(\d+)([MmGg])', block)
293 if m:
294 amount = int(m.group(1))
295 unit = m.group(2).upper()
296 mb = amount * 1024 if unit == "G" else amount
297 assert mb >= 256, f"musehub memory limit {mb}M is below 256M minimum"
298
299 def test_postgres_has_cpu_limit(self) -> None:
300 block = self._service_limits("postgres")
301 assert "cpus:" in block, "postgres service has no CPU limit"
302
303 def test_postgres_has_memory_limit(self) -> None:
304 block = self._service_limits("postgres")
305 assert "memory:" in block, "postgres service has no memory limit"
306
307 def test_runner_has_cpu_limit(self) -> None:
308 block = self._service_limits("musehub-runner")
309 assert "cpus:" in block, "musehub-runner service has no CPU limit"
310
311 def test_runner_has_memory_limit(self) -> None:
312 block = self._service_limits("musehub-runner")
313 assert "memory:" in block, "musehub-runner service has no memory limit"
314
315 def test_all_services_have_deploy_block(self) -> None:
316 """All three services must have a deploy: block (where limits live)."""
317 for svc in ("musehub", "postgres", "musehub-runner"):
318 block = self._service_limits(svc)
319 assert "deploy:" in block, f"{svc} service has no deploy: block"
320
321
322 # ═══════════════════════════════════════════════════════════════════════════════
323 # musehub#194 — ECR_REGISTRY must derive from ECR_IMAGE, never be independently
324 # hardcoded. A "prod" deploy passes ECR_IMAGE pointing at Production's registry
325 # (see push.sh's per-environment ECR_REGISTRY map) — deploy.sh's docker
326 # login/logout must authenticate against that SAME registry, not a hardcoded
327 # Nonproduction one, or `docker pull` fails with an auth mismatch.
328 # ═══════════════════════════════════════════════════════════════════════════════
329
330 class TestEcrRegistryDerivedFromImage:
331 """Executes the real variable-derivation lines from deploy.sh in a
332 subprocess (not just a text/regex check) — this is a bash variable
333 scoping bug, so the test proves actual runtime behavior."""
334
335 _STAGING_IMAGE = "992382692655.dkr.ecr.us-east-1.amazonaws.com/musehub/musehub"
336 _PROD_IMAGE = "672469410277.dkr.ecr.us-east-1.amazonaws.com/musehub/musehub"
337
338 @staticmethod
339 def _extract_var_block() -> str:
340 """Return the exact source lines from ECR_IMAGE's definition through
341 FULL_IMAGE's definition — the minimal slice needed to resolve
342 ECR_REGISTRY/ECR_IMAGE/FULL_IMAGE, without deploy.sh's logging setup,
343 `cd`, or anything requiring a real /opt/musehub."""
344 src = _DEPLOY_SH.read_text()
345 start_marker = 'ECR_IMAGE="${ECR_IMAGE:-'
346 end_marker = 'FULL_IMAGE="${ECR_IMAGE}:${IMAGE_TAG}"'
347 start = src.index(start_marker)
348 # Back up to the start of that line (the marker may not be at col 0).
349 line_start = src.rfind("\n", 0, start) + 1
350 end = src.index(end_marker) + len(end_marker)
351 return src[line_start:end]
352
353 def _resolve(self, ecr_image: str | None) -> dict[str, str]:
354 block = self._extract_var_block()
355 script = (
356 f"{block}\n"
357 'echo "ECR_REGISTRY=$ECR_REGISTRY"\n'
358 'echo "ECR_IMAGE=$ECR_IMAGE"\n'
359 'echo "FULL_IMAGE=$FULL_IMAGE"\n'
360 )
361 env: dict[str, str] = {"PATH": "/usr/bin:/bin", "IMAGE_TAG": "abc123"}
362 if ecr_image is not None:
363 env["ECR_IMAGE"] = ecr_image
364 result = subprocess.run(
365 ["bash", "-c", script],
366 env=env,
367 capture_output=True,
368 text=True,
369 timeout=5,
370 )
371 assert result.returncode == 0, result.stderr
372 out: dict[str, str] = {}
373 for line in result.stdout.splitlines():
374 key, _, value = line.partition("=")
375 out[key] = value
376 return out
377
378 def test_staging_registry_derived_from_ecr_image(self) -> None:
379 resolved = self._resolve(self._STAGING_IMAGE)
380 assert resolved["ECR_REGISTRY"] == "992382692655.dkr.ecr.us-east-1.amazonaws.com"
381
382 def test_production_registry_derived_from_ecr_image(self) -> None:
383 """musehub#194: the exact regression — a prod ECR_IMAGE must resolve
384 to Production's registry, never the hardcoded staging one."""
385 resolved = self._resolve(self._PROD_IMAGE)
386 assert resolved["ECR_REGISTRY"] == "672469410277.dkr.ecr.us-east-1.amazonaws.com"
387 assert resolved["ECR_REGISTRY"] != "992382692655.dkr.ecr.us-east-1.amazonaws.com"
388
389 def test_full_image_matches_passed_ecr_image_and_tag(self) -> None:
390 resolved = self._resolve(self._PROD_IMAGE)
391 assert resolved["FULL_IMAGE"] == f"{self._PROD_IMAGE}:abc123"
392
393 def test_default_ecr_image_falls_back_to_staging_registry(self) -> None:
394 """No ECR_IMAGE set (manual on-instance invocation) defaults to
395 staging — matches the script's documented manual-use fallback."""
396 resolved = self._resolve(None)
397 assert resolved["ECR_REGISTRY"] == "992382692655.dkr.ecr.us-east-1.amazonaws.com"
398
399 def test_no_independently_hardcoded_production_or_staging_registry_constant(self) -> None:
400 """Guards against reintroducing a second, independent ECR_REGISTRY
401 source of truth — the account ID must only ever appear as part of
402 ECR_IMAGE's default value, never as a bare ECR_REGISTRY="..." literal."""
403 src = _DEPLOY_SH.read_text()
404 assert not re.search(r'^ECR_REGISTRY="\d', src, re.MULTILINE), (
405 "ECR_REGISTRY is hardcoded to a literal account ID again — it must "
406 "be derived from ECR_IMAGE so docker login and docker pull always "
407 "target the same registry."
408 )
File History 1 commit
sha256:8e5bf0f59b0dbd014580dec546aafd5eac90ea0915244fda362261505ebafc8f fix(#194): deploy.sh's ECR_REGISTRY now derives from ECR_IM… Sonnet 5 minor 3 hours ago