Files
lore-engine-poc-v3/tests/test_mcp/test_dockerfile.py
Lore Engine Dev ca5b1976ee docker: containerize the HTTP MCP server + compose (slice 11.4 + 11.5)
* Dockerfile: python:3.12-slim, layer-cached requirements first,
  COPY lore_engine_poc/scripts, bake .graph.pkl, EXPOSE 8765,
  HEALTHCHECK via stdlib urllib against POST /mcp initialize.
  CMD runs scripts/06_mcp_http_server.py --host 0.0.0.0.
* .dockerignore: exclude __pycache__, tests/, .git/, data/raw/, etc.
* docker-compose.yml: one service lore-engine-mcp, port 8765:8765
  (overridable via $LORE_HTTP_PORT), bind mount for graph
  override (commented), healthcheck. Image tag overridable via
  $TAG.
* tests/test_mcp/test_dockerfile.py: 4 tests gated on docker
  availability. Build, run + round-trip, compose up + round-trip,
  healthcheck reaches 'healthy'. All 4 pass on this host.
  550 -> 554 green.
2026-06-18 14:29:02 -04:00

218 lines
8.0 KiB
Python

"""Tests for the Docker image (slice 11.4).
These tests are gated on docker availability. They:
1. Build the image with ``docker build``.
2. Run it, hit the /mcp endpoint, and tear it down.
3. Repeat the round-trip via ``docker compose up``.
4. Verify the healthcheck reaches ``healthy`` within the start period.
If docker is not on PATH, all tests are skipped (so CI without
docker still passes). If a test fails mid-run, it cleans up any
container or compose stack it started.
"""
from __future__ import annotations
import os
import shutil
import subprocess
import time
from pathlib import Path
import httpx
import pytest
ROOT = Path(__file__).resolve().parents[2]
IMAGE_TAG = "lore-engine-mcp:test"
HAS_DOCKER = shutil.which("docker") is not None
HAS_COMPOSE = HAS_DOCKER and subprocess.run(
["docker", "compose", "version"], capture_output=True, text=True
).returncode == 0
pytestmark_docker = pytest.mark.skipif(not HAS_DOCKER, reason="docker not on PATH")
pytestmark_compose = pytest.mark.skipif(not HAS_COMPOSE, reason="docker compose not available")
def _run(args, **kwargs):
"""Run a docker command, raise on non-zero exit."""
return subprocess.run(
["docker", *args],
cwd=str(ROOT), check=True, capture_output=True, text=True, **kwargs,
)
def _rm_container(name: str):
subprocess.run(
["docker", "rm", "-f", name],
cwd=str(ROOT), capture_output=True, text=True,
)
# ---------------------------------------------------------------------------
# Test 1 — docker build
# ---------------------------------------------------------------------------
@pytest.mark.skipif(not HAS_DOCKER, reason="docker not on PATH")
def test_dockerfile_builds():
try:
proc = subprocess.run(
["docker", "build", "-t", IMAGE_TAG, "."],
cwd=str(ROOT), capture_output=True, text=True, timeout=180,
)
assert proc.returncode == 0, (
f"docker build failed:\nstdout={proc.stdout}\nstderr={proc.stderr}"
)
finally:
# Best-effort cleanup; the image is small (~150 MB) and is reused
# by later tests. Don't rmi on success.
pass
# ---------------------------------------------------------------------------
# Test 2 — docker run + round-trip
# ---------------------------------------------------------------------------
@pytest.mark.skipif(not HAS_DOCKER, reason="docker not on PATH")
def test_docker_run_initialize_round_trip():
container = "lore-engine-mcp-test-run"
_rm_container(container)
port = 18765
try:
# -d (detached) + --rm alternative: we manage teardown ourselves.
_run([
"run", "-d",
"--name", container,
"-p", f"{port}:8765",
IMAGE_TAG,
])
# Poll /mcp until ready (uvicorn cold-start ~1-2s).
deadline = time.time() + 20
last_err = None
while time.time() < deadline:
try:
resp = httpx.post(
f"http://127.0.0.1:{port}/mcp",
json={"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}},
headers={"Accept": "application/json"},
timeout=2.0,
)
if resp.status_code == 200:
body = resp.json()
assert body["result"]["protocolVersion"] == "2024-11-05"
return
except (httpx.TransportError, httpx.TimeoutException) as exc:
# ConnectError before the port is bound; ReadError / RemoteProtocolError
# when uvicorn's keep-alive is reset; anything in the TransportError
# family is "not ready yet" at this stage.
last_err = exc
time.sleep(0.3)
raise AssertionError(f"container never became ready: last_err={last_err!r}")
finally:
_rm_container(container)
# ---------------------------------------------------------------------------
# Test 3 — docker compose up + round-trip
# ---------------------------------------------------------------------------
@pytest.mark.skipif(not HAS_COMPOSE, reason="docker compose not available")
def test_docker_compose_up_and_round_trip(tmp_path):
# Compose requires the build context (Dockerfile + source tree) at
# the cwd's location. Run from the repo root to avoid symlink
# resolution issues inside Docker's mount namespace. Use a unique
# project name + port to allow parallel CI runs.
project = "lore-engine-mcp-test-compose"
port = 18766
env = {**os.environ, "COMPOSE_PROJECT_NAME": project,
"LORE_HTTP_PORT": str(port)}
try:
# The compose file references image: lore-engine-mcp:slice11.
# Ensure that tag exists (it may not, if the build test was
# skipped). Re-tag from :test if so. Idempotent.
subprocess.run(
["docker", "tag", IMAGE_TAG, "lore-engine-mcp:slice11"],
cwd=str(ROOT), capture_output=True, text=True,
)
proc = subprocess.run(
["docker", "compose", "up", "-d"],
cwd=str(ROOT), env=env,
capture_output=True, text=True, timeout=120,
)
if proc.returncode != 0:
raise AssertionError(
f"docker compose up failed (rc={proc.returncode}):\n"
f"stdout={proc.stdout}\nstderr={proc.stderr}"
)
deadline = time.time() + 25
last_err = None
while time.time() < deadline:
try:
resp = httpx.post(
f"http://127.0.0.1:{port}/mcp",
json={"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}},
headers={"Accept": "application/json"},
timeout=2.0,
)
if resp.status_code == 200:
body = resp.json()
assert body["result"]["protocolVersion"] == "2024-11-05"
return
except (httpx.TransportError, httpx.TimeoutException) as exc:
last_err = exc
time.sleep(0.3)
raise AssertionError(f"compose stack never became ready: last_err={last_err!r}")
finally:
subprocess.run(
["docker", "compose", "down", "-v", "--remove-orphans"],
cwd=str(ROOT), env=env, capture_output=True, text=True,
)
# ---------------------------------------------------------------------------
# Test 4 — healthcheck reaches 'healthy'
# ---------------------------------------------------------------------------
@pytest.mark.skipif(not HAS_DOCKER, reason="docker not on PATH")
def test_docker_healthcheck_reaches_healthy():
container = "lore-engine-mcp-test-health"
_rm_container(container)
port = 18767
try:
_run([
"run", "-d",
"--name", container,
"-p", f"{port}:8765",
IMAGE_TAG,
])
# Healthcheck schedule: interval 30s, start-period 5s, retries 3.
# That means worst-case wait is ~5s + 3*30s = 95s. In practice
# the first probe fires after the start-period and passes in <1s.
# We poll for up to 20s — enough for the first healthy probe.
deadline = time.time() + 20
while time.time() < deadline:
proc = subprocess.run(
["docker", "inspect", "--format",
"{{.State.Health.Status}}", container],
cwd=str(ROOT), capture_output=True, text=True,
)
status = proc.stdout.strip()
if status == "healthy":
return
time.sleep(1.0)
# One more read for the failure message.
proc = subprocess.run(
["docker", "inspect", "--format",
"{{.State.Health.Status}}", container],
cwd=str(ROOT), capture_output=True, text=True,
)
raise AssertionError(
f"container never reached 'healthy' (last status: {proc.stdout.strip()!r})"
)
finally:
_rm_container(container)