gabriel / musehub public

test_deployment.py file-level

at sha256:9 · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 πŸ’₯ blast risk
sha256:4 fix: publish_muse_release.sh failed against production on two counts D… · gabriel · Sep 12, 2026
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 _PUSH_SH = _ROOT / "deploy" / "push.sh"
29 _PUBLISH_MUSE_RELEASE_SH = _ROOT / "deploy" / "publish_muse_release.sh"
30
31
32 # ═══════════════════════════════════════════════════════════════════════════════
33 # Zero-downtime deploy
34 # ═══════════════════════════════════════════════════════════════════════════════
35
36 class TestZeroDowntimeDeploy:
37 _src = _DEPLOY_SH.read_text()
38
39 def test_two_slots_defined(self) -> None:
40 """deploy.sh must define both blue and green slots."""
41 assert "blue" in self._src and "green" in self._src
42
43 def test_nginx_flip_present(self) -> None:
44 """deploy.sh must reload nginx after health check passes (atomic flip)."""
45 assert "nginx -s reload" in self._src or "nginx_point_to" in self._src
46
47 def test_health_check_before_nginx_flip(self) -> None:
48 """Health check must happen before the nginx flip β€” never flip a sick slot."""
49 src = self._src
50 health_pos = src.find("health_check")
51 nginx_pos = src.find("nginx_point_to")
52 assert health_pos != -1, "health_check function not found in deploy.sh"
53 assert nginx_pos != -1, "nginx_point_to not found in deploy.sh"
54 assert health_pos < nginx_pos, (
55 "nginx flip happens before health check β€” would route to an unhealthy slot"
56 )
57
58 def test_health_urls_point_to_healthz(self) -> None:
59 """deploy.sh must use /healthz not a UI page as the readiness signal."""
60 assert "/healthz" in self._src, (
61 "deploy.sh does not use /healthz β€” health check may pass even when "
62 "DB or storage is down (UI pages don't probe dependencies)"
63 )
64 assert "/explore" not in self._src, (
65 "deploy.sh still references /explore as a health URL β€” update to /healthz"
66 )
67
68 def test_old_slot_stopped_after_flip(self) -> None:
69 """deploy.sh must stop the old slot after the nginx flip to free resources."""
70 src = self._src
71 nginx_pos = src.find("nginx_point_to")
72 stop_pos = src.find("docker rm -f", nginx_pos)
73 assert stop_pos != -1, (
74 "deploy.sh does not stop the old slot after the nginx flip"
75 )
76
77 def test_dockerfile_healthcheck_uses_healthz(self) -> None:
78 """Dockerfile HEALTHCHECK must probe /healthz."""
79 src = _DOCKERFILE.read_text()
80 hc_lines = [l for l in src.splitlines() if "HEALTHCHECK" in l or l.strip().startswith("CMD")]
81 combined = " ".join(hc_lines)
82 assert "/healthz" in combined, (
83 "Dockerfile HEALTHCHECK does not probe /healthz β€” "
84 "docker will report 'healthy' even when DB is down"
85 )
86
87
88 # ═══════════════════════════════════════════════════════════════════════════════
89 # /healthz endpoint
90 # ═══════════════════════════════════════════════════════════════════════════════
91
92 class TestHealthzEndpoint:
93 async def test_healthz_returns_200_when_healthy(self, client: AsyncClient) -> None:
94 """GET /healthz must return 200 when DB and storage are reachable."""
95 resp = await client.get("/healthz")
96 assert resp.status_code == 200
97 body = resp.json()
98 assert body["status"] == "ok"
99 assert body["db"] is True
100 assert body["storage"] is True
101
102 async def test_healthz_returns_json(self, client: AsyncClient) -> None:
103 resp = await client.get("/healthz")
104 assert resp.headers["content-type"].startswith("application/json")
105
106 async def test_healthz_no_auth_required(self, client: AsyncClient) -> None:
107 """Healthz must be reachable without any Authorization header."""
108 resp = await client.get("/healthz")
109 # Must not be 401 or 403
110 assert resp.status_code not in (401, 403), (
111 f"/healthz returned {resp.status_code} β€” health check must be unauthenticated"
112 )
113
114 async def test_healthz_503_when_db_down(self, client: AsyncClient) -> None:
115 """GET /healthz must return 503 when the DB probe fails."""
116 from sqlalchemy.exc import OperationalError
117
118 # Patch the DB execute to simulate a broken connection
119 with patch(
120 "musehub.main.AsyncSession.execute",
121 new_callable=AsyncMock,
122 side_effect=OperationalError("connection refused", None, None),
123 ):
124 resp = await client.get("/healthz")
125
126 assert resp.status_code == 503
127 body = resp.json()
128 assert body["status"] == "unhealthy"
129 assert body["db"] is False
130
131 async def test_healthz_503_when_storage_down(self, client: AsyncClient) -> None:
132 """GET /healthz must return 503 when the storage probe fails."""
133 from musehub.storage.backends import BlobBackend
134
135 # Backend pointing at an unreachable endpoint β€” head_bucket will fail.
136 bad_backend = BlobBackend(
137 bucket="muse-objects",
138 endpoint_url="http://127.0.0.1:19999", # nothing listening here
139 access_key_id="x",
140 secret_access_key="x",
141 region="us-east-1",
142 )
143
144 with patch("musehub.storage.backends.get_backend", return_value=bad_backend):
145 resp = await client.get("/healthz")
146
147 assert resp.status_code == 503
148 body = resp.json()
149 assert body["status"] == "unhealthy"
150 assert body["storage"] is False
151
152 async def test_healthz_body_has_db_and_storage_keys(self, client: AsyncClient) -> None:
153 """Response body must expose both db and storage status for monitoring."""
154 resp = await client.get("/healthz")
155 body = resp.json()
156 assert "db" in body, "healthz response missing 'db' key"
157 assert "storage" in body, "healthz response missing 'storage' key"
158
159 async def test_healthz_fast(self, client: AsyncClient) -> None:
160 """Healthz must respond in under 2 s (load balancer timeout is typically 5 s)."""
161 import time
162 start = time.monotonic()
163 await client.get("/healthz")
164 elapsed = time.monotonic() - start
165 assert elapsed < 2.0, f"/healthz took {elapsed:.2f}s β€” too slow for a probe"
166
167 def test_healthz_route_registered(self) -> None:
168 """The /healthz route must be registered in the FastAPI app."""
169 from musehub.main import app
170 paths = [route.path for route in app.routes]
171 assert "/healthz" in paths, "/healthz route not registered in app"
172
173
174 # ═══════════════════════════════════════════════════════════════════════════════
175 # Non-root container user
176 # ═══════════════════════════════════════════════════════════════════════════════
177
178 class TestNonRootUser:
179 _src = _DOCKERFILE.read_text()
180
181 def test_dockerfile_has_user_instruction(self) -> None:
182 """Dockerfile must have a USER instruction."""
183 user_lines = [l.strip() for l in self._src.splitlines()
184 if l.strip().upper().startswith("USER ")]
185 assert user_lines, "Dockerfile has no USER instruction β€” container runs as root"
186
187 def test_dockerfile_user_is_not_root(self) -> None:
188 """Dockerfile USER must not be root or UID 0."""
189 user_lines = [l.strip() for l in self._src.splitlines()
190 if l.strip().upper().startswith("USER ")]
191 for line in user_lines:
192 user = line.split()[1].lower()
193 assert user not in ("root", "0"), (
194 f"Dockerfile sets USER to {user!r} β€” container must run as non-root"
195 )
196
197 def test_dockerfile_creates_system_user(self) -> None:
198 """Dockerfile must create a dedicated system user (groupadd + useradd)."""
199 assert "groupadd" in self._src and "useradd" in self._src, (
200 "Dockerfile does not create a dedicated system user"
201 )
202
203 def test_dockerfile_user_applied_after_installs(self) -> None:
204 """USER instruction must come after RUN pip install (installs need root)."""
205 lines = self._src.splitlines()
206 user_idx = next(
207 (i for i, l in enumerate(lines) if l.strip().upper().startswith("USER ")), None
208 )
209 pip_idx = max(
210 (i for i, l in enumerate(lines) if "pip install" in l), default=None
211 )
212 assert user_idx is not None and pip_idx is not None
213 assert user_idx > pip_idx, (
214 "USER instruction appears before pip install β€” "
215 "package installation would fail without root"
216 )
217
218
219 # ═══════════════════════════════════════════════════════════════════════════════
220 # Read-only filesystem
221 # ═══════════════════════════════════════════════════════════════════════════════
222
223 class TestReadOnlyFilesystem:
224 def _parse_yaml(self) -> None:
225 import yaml
226 return yaml.safe_load(_COMPOSE.read_text())
227
228 def test_musehub_service_read_only(self) -> None:
229 """musehub service must have read_only: true."""
230 src = _COMPOSE.read_text()
231 # Structural check: read_only appears in the musehub service block
232 # Find musehub service block (between 'musehub:' and the next top-level key)
233 in_musehub = False
234 for line in src.splitlines():
235 if re.match(r'^ musehub:', line):
236 in_musehub = True
237 elif re.match(r'^ \w', line) and in_musehub:
238 in_musehub = False
239 if in_musehub and "read_only: true" in line:
240 return
241 pytest.fail(
242 "musehub service in docker-compose.yml does not have read_only: true"
243 )
244
245 def test_tmp_is_tmpfs_or_volume(self) -> None:
246 """/tmp must be writable (tmpfs or volume) so uvicorn can write temp files."""
247 src = _COMPOSE.read_text()
248 assert "tmpfs" in src or "/tmp" in src, (
249 "No tmpfs mount for /tmp β€” uvicorn and Python will fail to write temp files "
250 "when the root filesystem is read-only"
251 )
252
253 def test_data_volume_is_explicit(self) -> None:
254 """/data object store must be an explicit named volume (not read-only)."""
255 src = _COMPOSE.read_text()
256 assert "musehub_data:/data" in src, (
257 "/data is not mounted as an explicit volume β€” objects cannot be written "
258 "when the root filesystem is read-only"
259 )
260
261
262 # ═══════════════════════════════════════════════════════════════════════════════
263 # Resource limits
264 # ═══════════════════════════════════════════════════════════════════════════════
265
266 class TestResourceLimits:
267 _src = _COMPOSE.read_text()
268
269 def _service_limits(self, service_name: str) -> str:
270 """Extract the text block for a given service."""
271 lines = self._src.splitlines()
272 in_service = False
273 block_lines = []
274 for line in lines:
275 if re.match(rf'^ {re.escape(service_name)}:', line):
276 in_service = True
277 elif re.match(r'^ \w', line) and in_service:
278 break
279 if in_service:
280 block_lines.append(line)
281 return "\n".join(block_lines)
282
283 def test_musehub_has_cpu_limit(self) -> None:
284 block = self._service_limits("musehub")
285 assert "cpus:" in block, "musehub service has no CPU limit"
286
287 def test_musehub_has_memory_limit(self) -> None:
288 block = self._service_limits("musehub")
289 assert "memory:" in block, "musehub service has no memory limit"
290
291 def test_musehub_memory_limit_sane(self) -> None:
292 """musehub memory limit must be β‰₯ 256 MiB (app needs headroom)."""
293 block = self._service_limits("musehub")
294 m = re.search(r'memory:\s*(\d+)([MmGg])', block)
295 if m:
296 amount = int(m.group(1))
297 unit = m.group(2).upper()
298 mb = amount * 1024 if unit == "G" else amount
299 assert mb >= 256, f"musehub memory limit {mb}M is below 256M minimum"
300
301 def test_postgres_has_cpu_limit(self) -> None:
302 block = self._service_limits("postgres")
303 assert "cpus:" in block, "postgres service has no CPU limit"
304
305 def test_postgres_has_memory_limit(self) -> None:
306 block = self._service_limits("postgres")
307 assert "memory:" in block, "postgres service has no memory limit"
308
309 def test_runner_has_cpu_limit(self) -> None:
310 block = self._service_limits("musehub-runner")
311 assert "cpus:" in block, "musehub-runner service has no CPU limit"
312
313 def test_runner_has_memory_limit(self) -> None:
314 block = self._service_limits("musehub-runner")
315 assert "memory:" in block, "musehub-runner service has no memory limit"
316
317 def test_all_services_have_deploy_block(self) -> None:
318 """All three services must have a deploy: block (where limits live)."""
319 for svc in ("musehub", "postgres", "musehub-runner"):
320 block = self._service_limits(svc)
321 assert "deploy:" in block, f"{svc} service has no deploy: block"
322
323
324 # ═══════════════════════════════════════════════════════════════════════════════
325 # musehub#194 β€” ECR_REGISTRY must derive from ECR_IMAGE, never be independently
326 # hardcoded. A "prod" deploy passes ECR_IMAGE pointing at Production's registry
327 # (see push.sh's per-environment ECR_REGISTRY map) β€” deploy.sh's docker
328 # login/logout must authenticate against that SAME registry, not a hardcoded
329 # Nonproduction one, or `docker pull` fails with an auth mismatch.
330 # ═══════════════════════════════════════════════════════════════════════════════
331
332 class TestEcrRegistryDerivedFromImage:
333 """Executes the real variable-derivation lines from deploy.sh in a
334 subprocess (not just a text/regex check) β€” this is a bash variable
335 scoping bug, so the test proves actual runtime behavior."""
336
337 _STAGING_IMAGE = "992382692655.dkr.ecr.us-east-1.amazonaws.com/musehub/musehub"
338 _PROD_IMAGE = "672469410277.dkr.ecr.us-east-1.amazonaws.com/musehub/musehub"
339
340 @staticmethod
341 def _extract_var_block() -> str:
342 """Return the exact source lines from ECR_IMAGE's definition through
343 FULL_IMAGE's definition β€” the minimal slice needed to resolve
344 ECR_REGISTRY/ECR_IMAGE/FULL_IMAGE, without deploy.sh's logging setup,
345 `cd`, or anything requiring a real /opt/musehub."""
346 src = _DEPLOY_SH.read_text()
347 start_marker = 'ECR_IMAGE="${ECR_IMAGE:-'
348 end_marker = 'FULL_IMAGE="${ECR_IMAGE}:${IMAGE_TAG}"'
349 start = src.index(start_marker)
350 # Back up to the start of that line (the marker may not be at col 0).
351 line_start = src.rfind("\n", 0, start) + 1
352 end = src.index(end_marker) + len(end_marker)
353 return src[line_start:end]
354
355 def _resolve(self, ecr_image: str | None) -> dict[str, str]:
356 block = self._extract_var_block()
357 script = (
358 f"{block}\n"
359 'echo "ECR_REGISTRY=$ECR_REGISTRY"\n'
360 'echo "ECR_IMAGE=$ECR_IMAGE"\n'
361 'echo "FULL_IMAGE=$FULL_IMAGE"\n'
362 )
363 env: dict[str, str] = {"PATH": "/usr/bin:/bin", "IMAGE_TAG": "abc123"}
364 if ecr_image is not None:
365 env["ECR_IMAGE"] = ecr_image
366 result = subprocess.run(
367 ["bash", "-c", script],
368 env=env,
369 capture_output=True,
370 text=True,
371 timeout=5,
372 )
373 assert result.returncode == 0, result.stderr
374 out: dict[str, str] = {}
375 for line in result.stdout.splitlines():
376 key, _, value = line.partition("=")
377 out[key] = value
378 return out
379
380 def test_staging_registry_derived_from_ecr_image(self) -> None:
381 resolved = self._resolve(self._STAGING_IMAGE)
382 assert resolved["ECR_REGISTRY"] == "992382692655.dkr.ecr.us-east-1.amazonaws.com"
383
384 def test_production_registry_derived_from_ecr_image(self) -> None:
385 """musehub#194: the exact regression β€” a prod ECR_IMAGE must resolve
386 to Production's registry, never the hardcoded staging one."""
387 resolved = self._resolve(self._PROD_IMAGE)
388 assert resolved["ECR_REGISTRY"] == "672469410277.dkr.ecr.us-east-1.amazonaws.com"
389 assert resolved["ECR_REGISTRY"] != "992382692655.dkr.ecr.us-east-1.amazonaws.com"
390
391 def test_full_image_matches_passed_ecr_image_and_tag(self) -> None:
392 resolved = self._resolve(self._PROD_IMAGE)
393 assert resolved["FULL_IMAGE"] == f"{self._PROD_IMAGE}:abc123"
394
395 def test_default_ecr_image_falls_back_to_staging_registry(self) -> None:
396 """No ECR_IMAGE set (manual on-instance invocation) defaults to
397 staging β€” matches the script's documented manual-use fallback."""
398 resolved = self._resolve(None)
399 assert resolved["ECR_REGISTRY"] == "992382692655.dkr.ecr.us-east-1.amazonaws.com"
400
401 def test_no_independently_hardcoded_production_or_staging_registry_constant(self) -> None:
402 """Guards against reintroducing a second, independent ECR_REGISTRY
403 source of truth β€” the account ID must only ever appear as part of
404 ECR_IMAGE's default value, never as a bare ECR_REGISTRY="..." literal."""
405 src = _DEPLOY_SH.read_text()
406 assert not re.search(r'^ECR_REGISTRY="\d', src, re.MULTILINE), (
407 "ECR_REGISTRY is hardcoded to a literal account ID again β€” it must "
408 "be derived from ECR_IMAGE so docker login and docker pull always "
409 "target the same registry."
410 )
411
412
413 # ═══════════════════════════════════════════════════════════════════════════════
414 # musehub#199 β€” push.sh bash 3.2 (macOS default) compatibility
415 # ═══════════════════════════════════════════════════════════════════════════════
416
417 _SYSTEM_BASH = Path("/bin/bash")
418
419
420 def _system_bash_major_version() -> int | None:
421 """The major version of whatever /bin/bash actually is on this machine.
422 On macOS this is the real, un-upgradeable system bash 3.2 β€” the exact
423 binary the reported bug ran under. Returns None if /bin/bash is absent."""
424 if not _SYSTEM_BASH.exists():
425 return None
426 result = subprocess.run(
427 [str(_SYSTEM_BASH), "-c", "echo ${BASH_VERSINFO[0]}"],
428 capture_output=True,
429 text=True,
430 timeout=5,
431 )
432 try:
433 return int(result.stdout.strip())
434 except ValueError:
435 return None
436
437
438 def _extract_function(src: str, name: str) -> str:
439 """Return one `name() { ... }` function body verbatim from push.sh."""
440 start = src.index(f"{name}() {{")
441 end = src.index("\n}", start) + len("\n}")
442 return src[start:end]
443
444
445 class TestPushShBash32Compat:
446 """push.sh used `declare -A` (associative arrays, bash 4+ only) with no
447 version guard, so it crashed outright under macOS's default /bin/bash
448 3.2 rather than a Homebrew-installed bash 4/5 β€” 'unbound variable' at
449 the first array reference, before the script did anything useful.
450 Rewritten as `case` dispatch functions, which need no minimum bash
451 version at all."""
452
453 def test_no_declare_a_in_push_sh(self) -> None:
454 src = _PUSH_SH.read_text()
455 assert not re.search(r"^\s*declare -A", src, re.MULTILINE), (
456 "push.sh uses declare -A again, which requires bash 4+ and "
457 "crashes outright under macOS's default bash 3.2."
458 )
459
460 @pytest.mark.skipif(
461 _system_bash_major_version() is None or _system_bash_major_version() >= 4,
462 reason="requires a real bash < 4 (e.g. macOS's /bin/bash) to prove the fix",
463 )
464 def test_usage_message_prints_under_real_bash_32(self) -> None:
465 """The exact reported failure: `bash deploy/push.sh` with no args
466 used to crash at `declare -A` with 'unbound variable' before ever
467 reaching the usage message. Runs the real script end to end."""
468 result = subprocess.run(
469 [str(_SYSTEM_BASH), str(_PUSH_SH)],
470 capture_output=True,
471 text=True,
472 timeout=10,
473 )
474 assert result.returncode == 1
475 assert "Usage: bash deploy/push.sh" in result.stdout
476 assert "unbound variable" not in result.stderr
477
478 @pytest.mark.skipif(
479 _system_bash_major_version() is None or _system_bash_major_version() >= 4,
480 reason="requires a real bash < 4 (e.g. macOS's /bin/bash) to prove the fix",
481 )
482 def test_env_helper_functions_resolve_correctly_under_bash_32(self) -> None:
483 """The replacement case-dispatch helpers must resolve the exact same
484 values the old associative arrays held, not just avoid crashing."""
485 src = _PUSH_SH.read_text()
486 block = "\n".join(
487 _extract_function(src, name)
488 for name in ("instance_id_for", "ecr_registry_for", "aws_profile_for")
489 )
490 script = (
491 f"{block}\n"
492 'echo "INSTANCE_STAGING=$(instance_id_for staging)"\n'
493 'echo "INSTANCE_PROD=$(instance_id_for prod)"\n'
494 'echo "REGISTRY_STAGING=$(ecr_registry_for staging)"\n'
495 'echo "REGISTRY_PROD=$(ecr_registry_for prod)"\n'
496 'echo "PROFILE_STAGING=$(aws_profile_for staging)"\n'
497 'echo "PROFILE_PROD=$(aws_profile_for prod)"\n'
498 )
499 result = subprocess.run(
500 [str(_SYSTEM_BASH), "-c", script],
501 capture_output=True,
502 text=True,
503 timeout=5,
504 )
505 assert result.returncode == 0, result.stderr
506 out = dict(line.split("=", 1) for line in result.stdout.splitlines())
507 assert out["INSTANCE_STAGING"] == "i-07547cd20bee2dea5"
508 assert out["INSTANCE_PROD"] == "i-043aaed71bef11903"
509 assert out["REGISTRY_STAGING"] == "992382692655.dkr.ecr.us-east-1.amazonaws.com"
510 assert out["REGISTRY_PROD"] == "672469410277.dkr.ecr.us-east-1.amazonaws.com"
511 assert out["PROFILE_STAGING"] == "musehub-nonproduction"
512 assert out["PROFILE_PROD"] == "musehub-production"
513
514
515 # ═══════════════════════════════════════════════════════════════════════════════
516 # publish_muse_release.sh β€” production must use the musehub-production SSO
517 # profile for its SSM calls, not the ambient default credentials
518 # ═══════════════════════════════════════════════════════════════════════════════
519
520 class TestPublishMuseReleaseProfile:
521 """publish_muse_release.sh's SSM calls had no --profile at all, so they
522 silently ran under whatever the ambient default AWS credentials happened
523 to be (musehub-infra, the Nonproduction/staging account) regardless of
524 which environment was requested. Production's instance lives in a
525 separate AWS account with no static IAM user by design, so every SSM
526 call against production failed with a confusing
527 'InvalidInstanceId ... not in a valid state for account' error that
528 looks like a broken instance rather than a missing --profile."""
529
530 @staticmethod
531 def _extract_env_block() -> str:
532 """The exact source lines from the ENV case statement through the
533 PROFILE_ARGS assignment β€” the minimal slice needed to resolve
534 PROFILE_ARGS for a given $ENV, without anything past it (building,
535 S3, SSM, curl) that would require real AWS/network access."""
536 src = _PUBLISH_MUSE_RELEASE_SH.read_text()
537 start_marker = 'case "$ENV" in'
538 end_marker = "PROFILE_ARGS=(--profile \"$PROFILE\")\nfi"
539 start = src.index(start_marker)
540 end = src.index(end_marker) + len(end_marker)
541 return src[start:end]
542
543 def _resolve_profile_args(self, env: str) -> str:
544 block = self._extract_env_block()
545 script = f'ENV="{env}"\n{block}\necho "PROFILE_ARGS=${{PROFILE_ARGS[@]}}"\n'
546 result = subprocess.run(
547 ["bash", "-c", script],
548 capture_output=True,
549 text=True,
550 timeout=5,
551 )
552 assert result.returncode == 0, result.stderr
553 line = next(l for l in result.stdout.splitlines() if l.startswith("PROFILE_ARGS="))
554 return line.removeprefix("PROFILE_ARGS=")
555
556 def test_production_resolves_musehub_production_profile(self) -> None:
557 assert self._resolve_profile_args("production") == "--profile musehub-production"
558
559 def test_staging_resolves_no_profile(self) -> None:
560 """Staging's instance is in the same account as the ambient default
561 credentials (musehub-infra) β€” must stay unset, not regress to also
562 requiring an explicit profile."""
563 assert self._resolve_profile_args("staging") == ""
564
565 def test_every_ssm_call_site_passes_profile_args(self) -> None:
566 """Guards against reintroducing the exact regression this fixes: a
567 new `aws ssm send-command` or `aws ssm get-command-invocation` call
568 added later without ``"${PROFILE_ARGS[@]}"`` would silently run
569 under the wrong account for production again."""
570 src = _PUBLISH_MUSE_RELEASE_SH.read_text()
571 ssm_call_count = len(re.findall(r"aws ssm (?:send-command|get-command-invocation)", src))
572 profile_args_count = src.count('"${PROFILE_ARGS[@]}"')
573 assert ssm_call_count > 0, "expected to find aws ssm calls in publish_muse_release.sh"
574 assert profile_args_count == ssm_call_count, (
575 f"found {ssm_call_count} aws ssm call(s) but only {profile_args_count} "
576 '"${PROFILE_ARGS[@]}" usage(s) β€” every SSM call must pass it'
577 )
578
579 def test_s3_calls_do_not_use_profile_args(self) -> None:
580 """The release bucket is a single shared, public-read bucket (per
581 the script's own header comment) β€” S3 calls must keep using the
582 ambient default credentials for both environments, not be
583 accidentally scoped to $PROFILE_ARGS."""
584 src = _PUBLISH_MUSE_RELEASE_SH.read_text()
585 for line in src.splitlines():
586 if line.strip().startswith("aws s3 "):
587 assert "PROFILE_ARGS" not in line
588
589 def test_remote_download_uses_no_sign_request(self) -> None:
590 """musehub#(none yet): discovered live against production -- an
591 authenticated `aws s3 cp` using the instance's own IAM role got a
592 403 (an account-level guardrail denying s3:GetObject outright,
593 overriding the bucket's public Allow), while an anonymous request
594 succeeded. The remote instance-side download of the release
595 tarball must use --no-sign-request so it never depends on the
596 instance role having S3 permissions at all -- matching how
597 install.sh itself fetches releases with no credentials."""
598 src = _PUBLISH_MUSE_RELEASE_SH.read_text()
599 match = re.search(r'CMD="aws s3 cp ([^\n]*)', src)
600 assert match is not None, "expected to find the remote CMD's aws s3 cp invocation"
601 assert "--no-sign-request" in match.group(1)