81 tests across 11 files plus a 5-module harness (load, pg, streams, containers, payloads), covering all eleven Phase 0 goals and all 13 named chaos-flag pathologies. Talks to the system only over HTTP, Postgres, Redis, and the Docker CLI -- no imports of application code. RED-BASELINE.md records the suite failing red against the empty repo before any implementation existed; README.md documents the request-contract fields invented to fill gaps in the spec, measurement methodology, and known-flaky tests. This suite is a frozen contract from this point forward per CLAUDE.md.
105 lines
3.5 KiB
Python
105 lines
3.5 KiB
Python
"""docker stats / docker compose logs / container lifecycle helpers.
|
|
|
|
Per phase-0a-test-suite-spec.md §7. All `docker compose` invocations run with
|
|
`cwd=REPO_ROOT` so they work regardless of the pytest invocation's own cwd.
|
|
"""
|
|
import json
|
|
import re
|
|
import subprocess
|
|
import time
|
|
from datetime import datetime
|
|
|
|
from .config import REPO_ROOT
|
|
|
|
|
|
def _run(cmd, timeout=30):
|
|
return subprocess.run(cmd, cwd=REPO_ROOT, capture_output=True, text=True, timeout=timeout)
|
|
|
|
|
|
def _compose_ps(service):
|
|
result = _run(["docker", "compose", "ps", service, "--format", "json"])
|
|
lines = [l for l in result.stdout.strip().splitlines() if l.strip()]
|
|
if not lines:
|
|
return None
|
|
return json.loads(lines[0])
|
|
|
|
|
|
def stop(service):
|
|
result = _run(["docker", "compose", "stop", service])
|
|
if result.returncode != 0:
|
|
raise RuntimeError(f"docker compose stop {service} failed: {result.stderr}")
|
|
|
|
|
|
def start(service):
|
|
result = _run(["docker", "compose", "start", service])
|
|
if result.returncode != 0:
|
|
raise RuntimeError(f"docker compose start {service} failed: {result.stderr}")
|
|
|
|
|
|
def wait_ready(service, timeout=30.0):
|
|
"""Poll `docker compose ps` until the container reports healthy (or, absent
|
|
a healthcheck, running). Raises TimeoutError otherwise."""
|
|
deadline = time.monotonic() + timeout
|
|
last_state = None
|
|
while time.monotonic() < deadline:
|
|
info = _compose_ps(service)
|
|
if info:
|
|
health = info.get("Health", "")
|
|
state = info.get("State", "")
|
|
last_state = (state, health)
|
|
if health == "healthy" or (health == "" and state == "running"):
|
|
return
|
|
time.sleep(1)
|
|
raise TimeoutError(f"{service} not ready within {timeout}s (last state={last_state})")
|
|
|
|
|
|
_MEM_UNITS = {
|
|
"B": 1 / (1024 * 1024),
|
|
"KIB": 1 / 1024,
|
|
"KB": 1 / 1024,
|
|
"MIB": 1,
|
|
"MB": 1,
|
|
"GIB": 1024,
|
|
"GB": 1024,
|
|
"TIB": 1024 * 1024,
|
|
"TB": 1024 * 1024,
|
|
}
|
|
|
|
|
|
def _parse_mem_to_mb(s):
|
|
m = re.match(r"([\d.]+)\s*([A-Za-z]+)", s.strip())
|
|
if not m:
|
|
raise ValueError(f"cannot parse memory string: {s!r}")
|
|
value, unit = float(m.group(1)), m.group(2).upper()
|
|
if unit not in _MEM_UNITS:
|
|
raise ValueError(f"unknown memory unit {unit!r} in {s!r}")
|
|
return value * _MEM_UNITS[unit]
|
|
|
|
|
|
def rss_mb(service):
|
|
"""Current RSS (approximated by `docker stats` MemUsage) of `service`'s container, in MB."""
|
|
info = _compose_ps(service)
|
|
if not info:
|
|
raise RuntimeError(f"container for {service!r} not found -- is the stack up?")
|
|
container_id = info.get("ID") or info.get("Id")
|
|
result = _run(["docker", "stats", container_id, "--no-stream", "--format", "{{.MemUsage}}"])
|
|
mem_str = result.stdout.strip().split("/")[0].strip()
|
|
return _parse_mem_to_mb(mem_str)
|
|
|
|
|
|
def count_log_lines(service, pattern, since):
|
|
"""Count lines in `docker compose logs <service>` since `since` matching `pattern`.
|
|
|
|
`since` is a timezone-aware datetime or a docker-compatible string (RFC3339
|
|
or a relative duration like "30s"). This is deliberately the only place
|
|
that shells out to `docker compose logs` for counting -- see
|
|
tests/README.md, "Log-counting migration", for why and what replaces it
|
|
from Phase 3 onward.
|
|
"""
|
|
since_arg = since.isoformat() if isinstance(since, datetime) else since
|
|
result = _run(
|
|
["docker", "compose", "logs", service, "--since", since_arg, "--no-color", "--no-log-prefix"]
|
|
)
|
|
regex = re.compile(pattern)
|
|
return sum(1 for line in result.stdout.splitlines() if regex.search(line))
|