"""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 ` 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))