confluence_web/tests/backend/test_docker_live.py
Artur Mukhamadiev e65fbf4b67 backend: FastAPI backend track handoff (contract revision 1)
FastAPI app, upstream Confluence/model adapters, authoritative history,
rootless container lifecycle, artifact storage and downloads, fake peers
under backend/dev, tests under tests/backend. Root pytest.ini deselects
the live marker by default; requirements gain the backend dependencies.
2026-09-14 21:57:54 +03:00

190 lines
6.4 KiB
Python

"""Real rootless Docker lifecycle, isolation, and reconciliation tests using fake image."""
import asyncio
import json
import shutil
import time
from pathlib import Path
import pytest
import httpx
from backend.artifacts import ArtifactStore
from backend.confluence import ConfluenceClient
from backend.containers import DockerContainerManager
from backend.model import FakeModelAdapter
from backend.runner import QueryRunner
from backend.settings import Settings
import subprocess
from tests.backend.conftest import make_test_confluence_client_factory
FAKE_IMAGE = "confluence-fake-agent:test"
def has_docker() -> bool:
if shutil.which("docker") is None:
return False
try:
res = subprocess.run(["docker", "info"], capture_output=True, timeout=3.0)
return res.returncode == 0
except Exception:
return False
pytestmark = pytest.mark.skipif(not has_docker(), reason="Docker daemon not available or responsive")
@pytest.mark.asyncio
async def test_docker_verify_rootless():
settings = Settings(runtime_image=FAKE_IMAGE)
mgr = DockerContainerManager(settings)
await mgr.verify_rootless()
@pytest.mark.asyncio
async def test_docker_real_container_runner_lifecycle(tmp_path: Path):
settings = Settings(
runtime_image=FAKE_IMAGE,
approved_confluence_origins=["https://approved.example.com"],
query_timeout_seconds=60.0,
cleanup_timeout_seconds=10.0,
)
artifact_store = ArtifactStore(tmp_path / "artifacts")
container_mgr = DockerContainerManager(settings)
model_adapter = FakeModelAdapter()
conf_factory = make_test_confluence_client_factory()
runner = QueryRunner(
settings=settings,
container_manager=container_mgr,
artifact_store=artifact_store,
model_adapter=model_adapter,
confluence_client_factory=conf_factory,
)
result = await runner.run(
prompt="How do I deploy service X?",
confluence_url="https://approved.example.com",
confluence_pat="test-pat-12345678901234567890",
session_id="cw_sess_docker",
)
# 1. Output verification
assert result["session_id"]
assert "Deployment Guide" in result["markdown"]
assert len(result["pages_accessed"]) == 1
assert result["pages_accessed"][0]["page_id"] == "847291"
assert len(result["tool_history"]) == 2
assert len(result["artifacts"]) == 1
art = result["artifacts"][0]
assert art["name"] == "checklist.md"
assert art["size_bytes"] == 32
# 2. Verify container was killed and removed from Docker
code, out, _ = await container_mgr._exec_docker(
["ps", "-a", "-q", "--filter", f"label={settings.container_label_key}.query_id={result['session_id']}"]
)
assert code == 0
assert out.strip() == "", "Container should be completely removed after query completion"
# 3. Verify artifact download
dl = artifact_store.get_artifact_for_download(art["id"], session_id="cw_sess_docker")
assert dl is not None
fpath, fname, fsize = dl
assert fname == "checklist.md"
assert fsize == 32
assert fpath.read_bytes() == b"# Checklist\n\n- Deploy service X\n"
@pytest.mark.asyncio
async def test_docker_orphan_reconciliation():
settings = Settings(
runtime_image=FAKE_IMAGE,
container_label_key="com.confluence_web.app",
container_label_value="query-runner-orphan-test",
)
mgr = DockerContainerManager(settings)
# Create an orphan container in the background
cmd = [
"docker", "run", "-d",
"--label", f"{settings.container_label_key}={settings.container_label_value}",
"--label", "com.confluence_web.created_at=1000", # timestamp in past
FAKE_IMAGE,
]
proc = await asyncio.create_subprocess_exec(*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
stdout, _ = await proc.communicate()
cid = stdout.decode("utf-8").strip()
assert cid, "Failed to launch orphan test container"
try:
# Run reconciliation with max_age_seconds=10
removed = await mgr.reconcile_orphans(max_age_seconds=10.0)
assert removed >= 1
# Check container is gone
code, out, _ = await mgr._exec_docker(["ps", "-a", "-q", "--filter", f"id={cid}"])
assert code == 0
assert out.strip() == ""
finally:
# Cleanup safety
await mgr._exec_docker(["rm", "-f", cid])
@pytest.mark.asyncio
async def test_docker_isolation_flags():
import uuid
settings = Settings(runtime_image=FAKE_IMAGE)
mgr = DockerContainerManager(settings)
query_id = f"iso-{uuid.uuid4().hex[:8]}"
handle = await mgr.create_and_run(query_id)
try:
# Wait briefly for Docker daemon to register container
for _ in range(30):
code, stdout, _ = await mgr._exec_docker([
"inspect",
"--format",
"{{json .}}",
f"cw-{query_id}",
])
if code == 0:
break
await asyncio.sleep(0.1)
assert code == 0
info = json.loads(stdout)
# 1. Network isolation
assert info["HostConfig"]["NetworkMode"] == "none"
# 2. Filesystem isolation
assert info["HostConfig"]["ReadonlyRootfs"] is True
# No host bind mounts
binds = info["HostConfig"].get("Binds") or []
assert len(binds) == 0
# 3. User & privilege escalation
assert info["Config"]["User"] == "10001:10001"
assert "ALL" in (info["HostConfig"].get("CapDrop") or [])
sec_opts = info["HostConfig"].get("SecurityOpt") or []
assert any("no-new-privileges" in opt for opt in sec_opts)
# 4. Resource limits
assert info["HostConfig"]["Memory"] == 1024 * 1024 * 1024
assert info["HostConfig"]["PidsLimit"] == 128
assert info["HostConfig"]["NanoCpus"] == 1_000_000_000
assert info["HostConfig"]["LogConfig"]["Type"] == "none"
assert info["HostConfig"]["Tmpfs"] == {
"/work": "size=256m,uid=10001,gid=10001",
"/tmp": "size=64m,uid=10001,gid=10001",
"/home/agent": "size=32m,uid=10001,gid=10001",
}
assert info["Config"]["Labels"][settings.container_label_key + ".query_id"] == query_id
assert "HOME=/home/agent" in info["Config"]["Env"]
assert not any("MODEL_API_KEY=" in v or "CONFLUENCE_PAT=" in v for v in info["Config"]["Env"])
finally:
await mgr.kill_and_remove(query_id)