* 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.
218 lines
8.0 KiB
Python
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)
|