- Drop --init so the image supervisor stays namespace PID 1, as the runtime handoff requires; add --memory-swap=1g and nosuid,nodev tmpfs options to match the runtime's tested launch flags. - Mount only frontend css/, js/ and vendor/ at the root origin so index.html's relative asset paths resolve; dev/tests are not exposed. - Add X-Content-Type-Options: nosniff to every response. - Add CONFLUENCE_WEB_CONFLUENCE_PROXY (socks5/http, Confluence only) and CONFLUENCE_WEB_MODEL_TIMEOUT_SECONDS; disable environment proxy inheritance for both upstream clients; add socksio dependency.
194 lines
6.7 KiB
Python
194 lines
6.7 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"]["MemorySwap"] == 1024 * 1024 * 1024 # no additional swap
|
|
assert info["HostConfig"]["Tmpfs"] == {
|
|
"/work": "rw,nosuid,nodev,size=256m,uid=10001,gid=10001",
|
|
"/tmp": "rw,nosuid,nodev,size=64m,uid=10001,gid=10001",
|
|
"/home/agent": "rw,nosuid,nodev,size=32m,uid=10001,gid=10001",
|
|
}
|
|
# The image entrypoint (supervisor) must be namespace PID 1: no docker-init.
|
|
assert info["HostConfig"].get("Init") is not True
|
|
assert info["Path"] != "/sbin/docker-init"
|
|
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)
|
|
|
|
|