"""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)