"""Tests for docker-compose.yml port exposure. Covers issue #50 acceptance criteria: - ``docker-compose.yml`` exposes port 4096 on ``0.0.0.0`` (open to the network) so that NPM (Nginx Proxy Manager) on a separate host can reach the opencode web UI via ``193.3.168.35:4096``. - The previous ``127.0.0.1:4096:4096`` binding (localhost-only) is gone — it blocked cross-host NPM proxying. PyYAML is not a direct project dependency, so the compose file is parsed manually by tracking indentation of the ``ports:`` block (mirrors the frontmatter parser in ``test_agent_frontmatter.py``). A raw-text assertion is also included as a belt-and-suspenders check. """ from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent COMPOSE_FILE = REPO_ROOT / "docker-compose.yml" EXPECTED_BINDING = "0.0.0.0:4096:4096" OLD_BINDING = "127.0.0.1:4096:4096" def _compose_text() -> str: """Read docker-compose.yml text (asserts the file exists).""" assert COMPOSE_FILE.exists(), f"docker-compose.yml missing: {COMPOSE_FILE}" return COMPOSE_FILE.read_text() def _parse_ports_bindings(text: str) -> list[str]: """Extract port binding strings from the ``ports:`` blocks of compose YAML. Manual parse (no pyyaml): walk lines, detect any ``ports:`` key (it sits under a service at indent 4), remember its indent, then collect every following list entry at a strictly greater indent until the indent drops back to or below the ``ports:`` indent. A list entry is a line whose stripped form starts with ``-``. """ bindings: list[str] = [] in_ports = False ports_indent = -1 for line in text.split("\n"): if not line.strip() or line.strip().startswith("#"): continue stripped = line.lstrip() indent = len(line) - len(stripped) if stripped.startswith("ports:") and stripped.endswith(":"): in_ports = True ports_indent = indent continue if in_ports: if indent <= ports_indent: in_ports = False continue if stripped.startswith("-"): value = stripped.lstrip("-").strip() value = value.strip('"').strip("'") bindings.append(value) return bindings # ── file exists ───────────────────────────────────────────────────────────── def test_docker_compose_exists(): """docker-compose.yml exists at repo root.""" assert COMPOSE_FILE.exists(), f"docker-compose.yml missing: {COMPOSE_FILE}" # ── port 4096 exposed on 0.0.0.0 (issue #50 — the required binding) ────────── def test_port_exposed_on_all_interfaces(): """docker-compose.yml exposes 4096 on ``0.0.0.0`` (open to the network). NPM on a separate host must reach the opencode web UI; ``0.0.0.0:4096:4096`` binds to all interfaces, ``127.0.0.1`` would block cross-host access. """ bindings = _parse_ports_bindings(_compose_text()) assert EXPECTED_BINDING in bindings, ( f"docker-compose.yml must bind {EXPECTED_BINDING!r} (open to network) — " f"found port bindings: {bindings}" ) def test_port_exposed_on_all_interfaces_raw(): """Raw text check: the compose file contains ``0.0.0.0:4096:4096``. Belt-and-suspenders alongside the parsed check — catches edge cases where the manual parser might miss a quoted or unquoted binding. """ content = _compose_text() assert EXPECTED_BINDING in content, ( f"docker-compose.yml must contain {EXPECTED_BINDING!r} so NPM can reach " "the web UI from a separate host" ) # ── old localhost-only binding is gone (issue #50 — must not regress) ─────── def test_no_localhost_only_binding(): """docker-compose.yml does NOT bind ``127.0.0.1:4096:4096``. The localhost-only binding blocks NPM proxying from another host; it must not be present (and must not come back via a future regression). """ bindings = _parse_ports_bindings(_compose_text()) assert OLD_BINDING not in bindings, ( f"docker-compose.yml must not bind {OLD_BINDING!r} (localhost-only) — " "NPM on a separate host cannot reach it" ) def test_no_localhost_only_binding_raw(): """Raw text check: ``127.0.0.1:4096:4096`` is absent from the compose file.""" content = _compose_text() assert OLD_BINDING not in content, ( f"docker-compose.yml must not contain {OLD_BINDING!r} — the localhost-only " "binding blocks cross-host NPM proxying" ) # ── opencode resource limits increased (issue #131 — anti-regression) ─────── def test_opencode_memory_limit_is_8g(): """opencode service memory limit is 8G (was 6G; peak hit 95% of 6G).""" content = _compose_text() assert "memory: 8G" in content, ( "docker-compose.yml opencode limits.memory must be 8G — 6G caused " "direct-reclaim stalls blocking the serve event loop" ) assert "memory: 6G" not in content, ( "docker-compose.yml must not retain the old 6G opencode memory limit" ) def test_opencode_cpu_limit_is_4(): """opencode service cpu limit is 4 cores (was 3; 879 throttle events observed).""" content = _compose_text() assert "cpus: '4'" in content, ( "docker-compose.yml opencode limits.cpus must be '4' — '3' caused " "879 throttle events and sustained CPU pressure" ) assert "cpus: '3'" not in content, ( "docker-compose.yml must not retain the old '3' opencode cpu limit" ) def test_opencode_pids_limit_is_2048(): """opencode service pids limit is 2048 (was 1024; >120 zombies observed).""" content = _compose_text() assert "pids: 2048" in content, ( "docker-compose.yml opencode limits.pids must be 2048 — 1024 left no " "buffer for accumulated zombie processes" ) assert "pids: 1024" not in content, ( "docker-compose.yml must not retain the old 1024 opencode pids limit" ) # ── opencode init: true for zombie reaping (issue #131) ────────────────────── def test_opencode_has_init_true(): """opencode service enables ``init: true`` so tini reaps orphaned zombies. opencode runs as PID 1 and never calls ``wait()``; without an init process zombies accumulate (>120 observed). Docker's ``init: true`` injects tini as PID 1 to reap orphans. """ content = _compose_text() assert "\n init: true\n" in content, ( "docker-compose.yml opencode service must set init: true — without it " "orphaned zombie processes accumulate (opencode is PID 1 and does not " "reap children)" ) # ── opencode healthcheck for hang auto-restart (issue #131) ────────────────── def test_opencode_has_healthcheck_block(): """opencode service defines a healthcheck probing localhost:4096. ``opencode serve`` requires basic auth and returns 401 without credentials — that is healthy. The check accepts 200 or 401 (server responds) and fails only when serve hangs (no HTTP response). Docker restarts unhealthy containers via the existing ``restart: unless-stopped``. """ content = _compose_text() assert "healthcheck:" in content, ( "docker-compose.yml opencode service must define a healthcheck — " "without it Docker cannot detect serve hangs to auto-restart" ) assert "curl" in content and "localhost:4096" in content, ( "healthcheck must probe opencode serve on localhost:4096 via curl" ) assert "200" in content and "401" in content, ( "healthcheck must accept both 200 and 401 as healthy — serve returns " "401 without basic-auth credentials" ) def test_opencode_healthcheck_timing(): """healthcheck timing matches issue #131 spec (interval/timeout/retries/start).""" content = _compose_text() assert "interval: 30s" in content assert "timeout: 10s" in content assert "retries: 3" in content assert "start_period: 30s" in content # ── restart not duplicated (issue #131 — must not regress) ─────────────────── def test_opencode_restart_not_duplicated(): """``restart: unless-stopped`` appears exactly once for opencode. It already exists; the healthcheck relies on it for auto-restart. A duplicate key would be a YAML error or a silent override. """ content = _compose_text() opencode_block = content.split(" opencode:", 1)[1] assert opencode_block.count("restart: unless-stopped") == 1, ( "docker-compose.yml opencode service must have exactly one " "'restart: unless-stopped' (already present, must not duplicate)" ) # ── dind service unchanged (issue #131 — must not regress) ────────────────── def test_dind_limits_unchanged(): """dind service keeps its original limits (4G/2cpu/512pids) — not in scope.""" content = _compose_text() dind_block = content.split(" dind:", 1)[1].split(" opencode:", 1)[0] assert "cpus: '2'" in dind_block assert "memory: 4G" in dind_block assert "pids: 512" in dind_block if __name__ == "__main__": import pytest pytest.main([__file__, "-v"])