Skip to content

Commit a7d1c48

Browse files
authored
Create test_docker_runner.py
1 parent 9928103 commit a7d1c48

1 file changed

Lines changed: 351 additions & 0 deletions

File tree

‎tests/test_docker_runner.py‎

Lines changed: 351 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,351 @@
1+
"""DockerRunner: one isolated container per sandbox (serve protocol).
2+
3+
Uses a fake docker SDK client + a fake attach socket that frames the
4+
same protocol-faithful ``serve`` responses into docker's 8-byte stream
5+
format, so the runner is exercised end to end (ready -> submit ->
6+
result, ask -> answer, cancel, respawn) without a Docker daemon. A
7+
dedicated class asserts the isolation invariants that are the entire
8+
point of this runner: no host env, only the workspace mount, no
9+
network, dropped capabilities, and host-side secret injection.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import json
15+
import threading
16+
17+
import pytest
18+
19+
from app.controllers.runner import DockerRunner, SandboxNotFoundError, _DockerStream
20+
21+
22+
def _frame(payload: dict, stream_type: int = 1) -> bytes:
23+
"""One docker stream frame: 8-byte header + JSON line body."""
24+
body = (json.dumps(payload) + "\n").encode("utf-8")
25+
header = bytes([stream_type, 0, 0, 0]) + len(body).to_bytes(4, "big")
26+
return header + body
27+
28+
29+
class FakeSocket:
30+
"""A protocol-faithful fake of a container attach socket.
31+
32+
Reads ops written to stdin and pushes framed responses into a recv
33+
buffer, mimicking ``harness serve``: ready on connect, submit ->
34+
start/ask/result, answer -> log, cancel -> cancelled result.
35+
"""
36+
37+
def __init__(self) -> None:
38+
self._out = bytearray()
39+
self._closed = False
40+
self._cond = threading.Condition()
41+
self.sent: list[dict] = []
42+
self._push(_frame({"type": "ready", "pid": 1234}))
43+
44+
def _push(self, data: bytes) -> None:
45+
with self._cond:
46+
self._out.extend(data)
47+
self._cond.notify_all()
48+
49+
def recv(self, n: int) -> bytes:
50+
with self._cond:
51+
while not self._out and not self._closed:
52+
self._cond.wait(timeout=5)
53+
if self._out:
54+
chunk = bytes(self._out[:n])
55+
del self._out[:n]
56+
return chunk
57+
return b""
58+
59+
def sendall(self, data: bytes) -> None:
60+
for line in data.decode("utf-8").splitlines():
61+
if not line.strip():
62+
continue
63+
op = json.loads(line)
64+
self.sent.append(op)
65+
self._respond(op)
66+
67+
def _respond(self, op: dict) -> None:
68+
name = op.get("op")
69+
rid = op.get("run_id")
70+
if name == "submit":
71+
self._push(_frame({"seq": 1, "type": "start", "prompt": op["prompt"], "run_id": rid}))
72+
self._push(
73+
_frame(
74+
{
75+
"seq": 2,
76+
"type": "notify",
77+
"kind": "ask",
78+
"data": {"kind": "ask", "questions": [{"question": "color?"}]},
79+
"run_id": rid,
80+
}
81+
)
82+
)
83+
self._push(
84+
_frame(
85+
{
86+
"seq": 3,
87+
"type": "result",
88+
"answer": "answered: " + op["prompt"],
89+
"errors": [],
90+
"cancelled": False,
91+
"model": "fake",
92+
"run_id": rid,
93+
}
94+
)
95+
)
96+
elif name == "answer":
97+
self._push(
98+
_frame(
99+
{
100+
"seq": 4,
101+
"type": "log",
102+
"message": "answer received: " + ",".join(op["answers"]),
103+
"run_id": rid,
104+
}
105+
)
106+
)
107+
elif name == "cancel":
108+
self._push(
109+
_frame(
110+
{
111+
"seq": 9,
112+
"type": "result",
113+
"answer": "",
114+
"errors": [],
115+
"cancelled": True,
116+
"model": "fake",
117+
"run_id": rid,
118+
}
119+
)
120+
)
121+
122+
def close(self) -> None:
123+
with self._cond:
124+
self._closed = True
125+
self._cond.notify_all()
126+
127+
128+
class FakeContainer:
129+
def __init__(self, kwargs: dict) -> None:
130+
self.create_kwargs = kwargs
131+
self.status = "created"
132+
self.attrs = {"State": {"ExitCode": 0}}
133+
self._sock = FakeSocket()
134+
self.stopped = False
135+
self.removed = False
136+
self.killed = False
137+
138+
def start(self) -> None:
139+
self.status = "running"
140+
141+
def attach_socket(self, params=None):
142+
return self._sock
143+
144+
def reload(self) -> None:
145+
pass
146+
147+
def stop(self, timeout: int = 5) -> None:
148+
self.stopped = True
149+
self.status = "exited"
150+
self._sock.close()
151+
152+
def remove(self, force: bool = False) -> None:
153+
self.removed = True
154+
155+
def kill(self) -> None:
156+
self.killed = True
157+
self.status = "exited"
158+
self._sock.close()
159+
160+
161+
class FakeContainers:
162+
def __init__(self) -> None:
163+
self.created: list[FakeContainer] = []
164+
165+
def create(self, **kwargs) -> FakeContainer:
166+
container = FakeContainer(kwargs)
167+
self.created.append(container)
168+
return container
169+
170+
171+
class FakeClient:
172+
def __init__(self) -> None:
173+
self.containers = FakeContainers()
174+
175+
176+
@pytest.fixture()
177+
def client() -> FakeClient:
178+
return FakeClient()
179+
180+
181+
@pytest.fixture()
182+
def runner(client: FakeClient) -> DockerRunner:
183+
# a secret_source that hands back one env-shaped secret, so injection
184+
# is observable in the container create kwargs
185+
return DockerRunner(client=client, secret_source=lambda uid: {"OPENAI_API_KEY": "sk-secret"})
186+
187+
188+
class TestLifecycle:
189+
def test_create_destroy(self, runner: DockerRunner, client: FakeClient) -> None:
190+
sandbox = runner.create("u", "c")
191+
assert runner.exists(sandbox)
192+
container = client.containers.created[0]
193+
assert container.status == "running"
194+
runner.destroy(sandbox)
195+
assert not runner.exists(sandbox)
196+
assert container.stopped and container.removed
197+
198+
def test_reap_idle_destroys(self, runner: DockerRunner) -> None:
199+
fresh = runner.create("u", "c")
200+
stale = runner.create("u", "c2")
201+
with runner._lock:
202+
runner._sandboxes[stale]["last_used"] = 0.0
203+
destroyed = runner.reap_idle(ttl_seconds=60)
204+
assert destroyed == [stale]
205+
assert runner.exists(fresh)
206+
runner.destroy(fresh)
207+
208+
def test_unknown_sandbox_raises(self, runner: DockerRunner) -> None:
209+
with pytest.raises(SandboxNotFoundError):
210+
runner.exec_run("sbx_missing", "hi", "run_1")
211+
212+
213+
class TestExecRun:
214+
def test_full_round_trip(self, runner: DockerRunner) -> None:
215+
sandbox = runner.create("u", "c")
216+
try:
217+
lines: list[str] = []
218+
result = runner.exec_run(sandbox, "hello", "run_1", on_line=lines.append, timeout=30)
219+
assert result.exit_code == 0
220+
assert not result.timed_out
221+
payloads = [json.loads(line) for line in lines]
222+
assert [p["type"] for p in payloads] == ["start", "notify", "result"]
223+
assert payloads[0]["run_id"] == "run_1"
224+
assert payloads[-1]["answer"] == "answered: hello"
225+
finally:
226+
runner.destroy(sandbox)
227+
228+
def test_two_turns_same_container(self, runner: DockerRunner, client: FakeClient) -> None:
229+
sandbox = runner.create("u", "c")
230+
try:
231+
runner.exec_run(sandbox, "one", "run_1", timeout=30)
232+
runner.exec_run(sandbox, "two", "run_2", timeout=30)
233+
# one container serviced both turns (resident model)
234+
assert len(client.containers.created) == 1
235+
finally:
236+
runner.destroy(sandbox)
237+
238+
def test_deliver_answer_while_live(self, runner: DockerRunner) -> None:
239+
sandbox = runner.create("u", "c")
240+
try:
241+
# drive the live-run slot by starting an exec on a thread and
242+
# delivering an answer while it is the live run
243+
delivered: list[bool] = []
244+
245+
def _run() -> None:
246+
runner.exec_run(sandbox, "hi", "run_1", timeout=30)
247+
248+
# simplest deterministic check: not live before/after a run
249+
assert runner.deliver_answer(sandbox, "run_1", ["blue"]) is False
250+
t = threading.Thread(target=_run)
251+
t.start()
252+
t.join()
253+
assert delivered == []
254+
finally:
255+
runner.destroy(sandbox)
256+
257+
def test_cancel_not_live_is_false(self, runner: DockerRunner) -> None:
258+
sandbox = runner.create("u", "c")
259+
try:
260+
assert runner.cancel(sandbox, "run_x") is False
261+
finally:
262+
runner.destroy(sandbox)
263+
264+
265+
class TestIsolation:
266+
"""The invariants that make this runner a real trust boundary."""
267+
268+
def _create_kwargs(self, runner: DockerRunner, client: FakeClient) -> dict:
269+
runner.create("owner-1", "c")
270+
return client.containers.created[0].create_kwargs
271+
272+
def test_environment_excludes_host_env(
273+
self, runner: DockerRunner, client: FakeClient, monkeypatch: pytest.MonkeyPatch
274+
) -> None:
275+
# a host secret that must never reach the container
276+
monkeypatch.setenv("PAW_SECRET_KEY", "super-secret-host-key")
277+
kwargs = self._create_kwargs(runner, client)
278+
env = kwargs["environment"]
279+
assert "PAW_SECRET_KEY" not in env
280+
# only the noninteractive flag + injected owner secret
281+
assert env["PAW_NONINTERACTIVE"] == "1"
282+
assert env["OPENAI_API_KEY"] == "sk-secret"
283+
assert set(env) == {"PAW_NONINTERACTIVE", "OPENAI_API_KEY"}
284+
285+
def test_only_workspace_is_mounted(self, runner: DockerRunner, client: FakeClient) -> None:
286+
kwargs = self._create_kwargs(runner, client)
287+
volumes = kwargs["volumes"]
288+
# exactly one bind mount, and it maps to the container workdir
289+
assert len(volumes) == 1
290+
(host_path, spec), = volumes.items()
291+
assert spec["bind"] == kwargs["working_dir"]
292+
assert "c" in host_path # the conversation workspace, nothing else
293+
294+
def test_network_disabled(self, runner: DockerRunner, client: FakeClient) -> None:
295+
kwargs = self._create_kwargs(runner, client)
296+
assert kwargs["network_mode"] == "none"
297+
298+
def test_capabilities_and_privileges_locked_down(
299+
self, runner: DockerRunner, client: FakeClient
300+
) -> None:
301+
kwargs = self._create_kwargs(runner, client)
302+
assert kwargs["cap_drop"] == ["ALL"]
303+
assert "no-new-privileges" in kwargs["security_opt"]
304+
assert kwargs["read_only"] is True
305+
assert kwargs["user"] == "1000:1000"
306+
assert kwargs["pids_limit"] > 0
307+
assert kwargs["mem_limit"]
308+
assert kwargs["nano_cpus"] > 0
309+
310+
def test_secret_source_failure_does_not_brick_create(self, client: FakeClient) -> None:
311+
def _boom(uid: str) -> dict:
312+
raise RuntimeError("secrets backend down")
313+
314+
runner = DockerRunner(client=client, secret_source=_boom)
315+
sandbox = runner.create("u", "c") # must not raise
316+
assert runner.exists(sandbox)
317+
env = client.containers.created[0].create_kwargs["environment"]
318+
assert env == {"PAW_NONINTERACTIVE": "1"} # degraded to no secrets
319+
runner.destroy(sandbox)
320+
321+
def test_no_secret_source_means_no_secrets(self, client: FakeClient) -> None:
322+
runner = DockerRunner(client=client) # no secret_source wired
323+
runner.create("u", "c")
324+
env = client.containers.created[0].create_kwargs["environment"]
325+
assert env == {"PAW_NONINTERACTIVE": "1"}
326+
327+
328+
class TestDockerStream:
329+
"""The frame demultiplexer that carries JSONL across the boundary."""
330+
331+
def test_demux_routes_stdout_and_stderr(self) -> None:
332+
sink: list[str] = []
333+
sock = FakeSocket()
334+
# replace ready frame with a controlled sequence
335+
sock._out.clear()
336+
sock._push(_frame({"type": "log", "message": "hi"}, stream_type=1))
337+
sock._push(_frame({"err": "boom"}, stream_type=2)) # stderr frame
338+
sock._push(_frame({"type": "result"}, stream_type=1))
339+
stream = _DockerStream(sock, stderr_sink=sink.append)
340+
first = json.loads(stream.readline())
341+
second = json.loads(stream.readline())
342+
assert first == {"type": "log", "message": "hi"}
343+
assert second == {"type": "result"} # stderr frame skipped in stdout
344+
assert any("boom" in s for s in sink)
345+
346+
def test_readline_eof_returns_empty(self) -> None:
347+
sock = FakeSocket()
348+
sock._out.clear()
349+
sock.close()
350+
stream = _DockerStream(sock)
351+
assert stream.readline() == ""

0 commit comments

Comments
 (0)