Files
LedgerLine/tests/harness/containers.py
T

105 lines
3.5 KiB
Python
Raw Normal View History

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