mcp-virtual-computer 0.2.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kilntainers/__init__.py +3 -0
- kilntainers/__main__.py +6 -0
- kilntainers/auth.py +34 -0
- kilntainers/backends/__init__.py +17 -0
- kilntainers/backends/base.py +380 -0
- kilntainers/backends/docker.py +1498 -0
- kilntainers/backends/test___init__.py +37 -0
- kilntainers/backends/test_base.py +355 -0
- kilntainers/backends/test_docker.py +841 -0
- kilntainers/backends/test_docker_integration.py +310 -0
- kilntainers/backends/test_docker_management.py +122 -0
- kilntainers/backends/test_utils.py +152 -0
- kilntainers/backends/test_virtual_docker.py +387 -0
- kilntainers/cli.py +375 -0
- kilntainers/computers.py +284 -0
- kilntainers/config.py +71 -0
- kilntainers/dashboard.html +4263 -0
- kilntainers/dashboard.py +23 -0
- kilntainers/desktop.py +47 -0
- kilntainers/desktop_control.py +105 -0
- kilntainers/desktop_image/Dockerfile +67 -0
- kilntainers/desktop_image/desktop-control.py +661 -0
- kilntainers/desktop_image/start-desktop.sh +206 -0
- kilntainers/desktop_image/visual-action.py +185 -0
- kilntainers/desktop_image/wsproxy.py +218 -0
- kilntainers/errors.py +36 -0
- kilntainers/file_tools.py +320 -0
- kilntainers/server.py +1760 -0
- kilntainers/test_cli.py +704 -0
- kilntainers/test_cli_integration.py +213 -0
- kilntainers/test_computers.py +122 -0
- kilntainers/test_config.py +135 -0
- kilntainers/test_dashboard.py +113 -0
- kilntainers/test_desktop_control.py +109 -0
- kilntainers/test_e2e_mcp.py +310 -0
- kilntainers/test_errors.py +62 -0
- kilntainers/test_file_tools.py +175 -0
- kilntainers/test_http_lifecycle.py +353 -0
- kilntainers/test_lifecycle_integration.py +389 -0
- kilntainers/test_server.py +849 -0
- kilntainers/test_windows_docker.py +300 -0
- kilntainers/windows_docker.py +565 -0
- mcp_virtual_computer-0.2.3.dist-info/METADATA +179 -0
- mcp_virtual_computer-0.2.3.dist-info/RECORD +48 -0
- mcp_virtual_computer-0.2.3.dist-info/WHEEL +5 -0
- mcp_virtual_computer-0.2.3.dist-info/entry_points.txt +5 -0
- mcp_virtual_computer-0.2.3.dist-info/licenses/LICENSE +7 -0
- mcp_virtual_computer-0.2.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,841 @@
|
|
|
1
|
+
"""Unit tests for Docker backend.
|
|
2
|
+
|
|
3
|
+
Tests mock subprocess calls to simulate Docker CLI responses without
|
|
4
|
+
requiring a real Docker daemon.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from kilntainers.backends.base import ExecRequest
|
|
12
|
+
from kilntainers.backends.docker import (
|
|
13
|
+
DEFAULT_DESKTOP_IMAGE,
|
|
14
|
+
DockerBackend,
|
|
15
|
+
DockerBackendConfig,
|
|
16
|
+
DockerSandbox,
|
|
17
|
+
_DockerSandboxState,
|
|
18
|
+
)
|
|
19
|
+
from kilntainers.errors import BackendError, SandboxDiedError
|
|
20
|
+
|
|
21
|
+
# --- Mock subprocess utilities ---
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class MockStdin:
|
|
25
|
+
"""Mock stdin for subprocess."""
|
|
26
|
+
|
|
27
|
+
def __init__(self):
|
|
28
|
+
self.closed = False
|
|
29
|
+
|
|
30
|
+
def write(self, data):
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
async def drain(self):
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
def close(self):
|
|
37
|
+
self.closed = True
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class MockStreamReader:
|
|
41
|
+
"""Mock asyncio.StreamReader for subprocess output."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, data: bytes, delay: float = 0, block_forever: bool = False):
|
|
44
|
+
self._data = data
|
|
45
|
+
self._pos = 0
|
|
46
|
+
self._delay = delay
|
|
47
|
+
self._block_forever = block_forever
|
|
48
|
+
|
|
49
|
+
async def read(self, n: int = -1) -> bytes:
|
|
50
|
+
if self._block_forever:
|
|
51
|
+
# Block forever until cancelled
|
|
52
|
+
await asyncio.Future()
|
|
53
|
+
return b"" # Never reached
|
|
54
|
+
if self._delay > 0:
|
|
55
|
+
await asyncio.sleep(self._delay)
|
|
56
|
+
if n == -1:
|
|
57
|
+
result = self._data[self._pos :]
|
|
58
|
+
self._pos = len(self._data)
|
|
59
|
+
return result
|
|
60
|
+
result = self._data[self._pos : self._pos + n]
|
|
61
|
+
self._pos += len(result)
|
|
62
|
+
return result
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class MockProcess:
|
|
66
|
+
"""Mock asyncio.subprocess.Process."""
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
returncode: int,
|
|
71
|
+
stdout: bytes = b"",
|
|
72
|
+
stderr: bytes = b"",
|
|
73
|
+
stdin_data: bytes | None = None,
|
|
74
|
+
stdout_delay: float = 0,
|
|
75
|
+
stderr_delay: float = 0,
|
|
76
|
+
block_stdout: bool = False,
|
|
77
|
+
block_stderr: bool = False,
|
|
78
|
+
):
|
|
79
|
+
self.returncode = returncode
|
|
80
|
+
self._stdout = stdout
|
|
81
|
+
self._stderr = stderr
|
|
82
|
+
self._stdin_data = stdin_data
|
|
83
|
+
self._stdout_delay = stdout_delay
|
|
84
|
+
self._stderr_delay = stderr_delay
|
|
85
|
+
self.pid = 12345
|
|
86
|
+
self.stdin = MockStdin() if stdin_data else None
|
|
87
|
+
self.stdout = (
|
|
88
|
+
MockStreamReader(stdout, stdout_delay, block_stdout)
|
|
89
|
+
if (stdout or block_stdout)
|
|
90
|
+
else None
|
|
91
|
+
)
|
|
92
|
+
self.stderr = (
|
|
93
|
+
MockStreamReader(stderr, stderr_delay, block_stderr)
|
|
94
|
+
if (stderr or block_stderr)
|
|
95
|
+
else None
|
|
96
|
+
)
|
|
97
|
+
self._killed = False
|
|
98
|
+
|
|
99
|
+
async def wait(self):
|
|
100
|
+
return self.returncode
|
|
101
|
+
|
|
102
|
+
async def communicate(self, input_data=None):
|
|
103
|
+
return self._stdout, self._stderr
|
|
104
|
+
|
|
105
|
+
def kill(self):
|
|
106
|
+
self._killed = True
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@pytest.fixture
|
|
110
|
+
def mock_subprocess(monkeypatch):
|
|
111
|
+
"""Mock asyncio.create_subprocess_exec with configurable responses."""
|
|
112
|
+
|
|
113
|
+
responses: list[MockProcess] = []
|
|
114
|
+
|
|
115
|
+
async def create_mock(*args, **kwargs):
|
|
116
|
+
if responses:
|
|
117
|
+
return responses.pop(0)
|
|
118
|
+
return MockProcess(0, b"", b"")
|
|
119
|
+
|
|
120
|
+
monkeypatch.setattr(asyncio, "create_subprocess_exec", create_mock)
|
|
121
|
+
|
|
122
|
+
# Return a function to add responses
|
|
123
|
+
def add_response(*, returncode=0, stdout=b"", stderr=b"", stdin_data=None):
|
|
124
|
+
responses.insert(0, MockProcess(returncode, stdout, stderr, stdin_data))
|
|
125
|
+
|
|
126
|
+
return add_response
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@pytest.fixture
|
|
130
|
+
def default_config():
|
|
131
|
+
"""Return a default DockerBackendConfig."""
|
|
132
|
+
return DockerBackendConfig()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# --- DockerBackend tests ---
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class TestDockerBackendValidation:
|
|
139
|
+
"""Tests for DockerBackend._validate method."""
|
|
140
|
+
|
|
141
|
+
@pytest.mark.asyncio
|
|
142
|
+
async def test_validate_success(self, mock_subprocess, default_config):
|
|
143
|
+
"""Validation passes when docker info succeeds."""
|
|
144
|
+
mock_subprocess(returncode=0, stdout=b"Docker info output")
|
|
145
|
+
|
|
146
|
+
backend = DockerBackend(default_config)
|
|
147
|
+
await backend.validate()
|
|
148
|
+
|
|
149
|
+
# Second call should be cached (no-op)
|
|
150
|
+
await backend.validate()
|
|
151
|
+
|
|
152
|
+
@pytest.mark.asyncio
|
|
153
|
+
async def test_validate_failure(self, mock_subprocess, default_config):
|
|
154
|
+
"""Validation fails when docker info fails."""
|
|
155
|
+
mock_subprocess(returncode=1, stderr=b"Cannot connect to daemon")
|
|
156
|
+
|
|
157
|
+
backend = DockerBackend(default_config)
|
|
158
|
+
|
|
159
|
+
with pytest.raises(BackendError) as exc_info:
|
|
160
|
+
await backend.validate()
|
|
161
|
+
|
|
162
|
+
assert "Cannot connect to" in str(exc_info.value)
|
|
163
|
+
assert "docker" in str(exc_info.value)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class TestDockerBackendImageManagement:
|
|
167
|
+
"""Tests for DockerBackend._ensure_image method."""
|
|
168
|
+
|
|
169
|
+
@pytest.mark.asyncio
|
|
170
|
+
async def test_image_exists_locally(self, mock_subprocess, default_config):
|
|
171
|
+
"""Image is available locally, no pull needed."""
|
|
172
|
+
mock_subprocess(returncode=0, stdout=b'{"Id": "abc123"}')
|
|
173
|
+
|
|
174
|
+
backend = DockerBackend(default_config)
|
|
175
|
+
await backend._ensure_image()
|
|
176
|
+
|
|
177
|
+
# Should have called image inspect only
|
|
178
|
+
# (mock responses are LIFO, so we can't verify order easily here)
|
|
179
|
+
|
|
180
|
+
@pytest.mark.asyncio
|
|
181
|
+
async def test_stale_bundled_desktop_image_is_rebuilt(self, monkeypatch):
|
|
182
|
+
"""A changed bundled image must not be hidden by its stable local tag."""
|
|
183
|
+
calls: list[tuple[str, ...]] = []
|
|
184
|
+
|
|
185
|
+
async def run_docker(*args, **kwargs):
|
|
186
|
+
calls.append(args)
|
|
187
|
+
if "inspect" in args:
|
|
188
|
+
return (
|
|
189
|
+
0,
|
|
190
|
+
b'[{"Config":{"Labels":{"mcp-virtual-computer.image-version":"1"}}}]',
|
|
191
|
+
b"",
|
|
192
|
+
)
|
|
193
|
+
return 0, b"", b""
|
|
194
|
+
|
|
195
|
+
backend = DockerBackend(DockerBackendConfig(image=DEFAULT_DESKTOP_IMAGE))
|
|
196
|
+
monkeypatch.setattr(backend, "_run_docker", run_docker)
|
|
197
|
+
|
|
198
|
+
await backend._ensure_image()
|
|
199
|
+
|
|
200
|
+
assert any("build" in call for call in calls)
|
|
201
|
+
|
|
202
|
+
@pytest.mark.asyncio
|
|
203
|
+
async def test_image_not_local_pull_success(self, monkeypatch, default_config):
|
|
204
|
+
"""Image not local, pull succeeds."""
|
|
205
|
+
|
|
206
|
+
async def create_mock(*args, **kwargs):
|
|
207
|
+
if "inspect" in args:
|
|
208
|
+
return MockProcess(1, stderr=b"Error: No such image")
|
|
209
|
+
elif "pull" in args:
|
|
210
|
+
return MockProcess(0)
|
|
211
|
+
return MockProcess(0)
|
|
212
|
+
|
|
213
|
+
monkeypatch.setattr(asyncio, "create_subprocess_exec", create_mock)
|
|
214
|
+
|
|
215
|
+
backend = DockerBackend(default_config)
|
|
216
|
+
await backend._ensure_image() # Should not raise
|
|
217
|
+
|
|
218
|
+
@pytest.mark.asyncio
|
|
219
|
+
async def test_image_pull_failure(self, monkeypatch, default_config):
|
|
220
|
+
"""Image not local, pull fails."""
|
|
221
|
+
|
|
222
|
+
async def create_mock(*args, **kwargs):
|
|
223
|
+
if "inspect" in args:
|
|
224
|
+
return MockProcess(1, stderr=b"Error: No such image")
|
|
225
|
+
elif "pull" in args:
|
|
226
|
+
return MockProcess(1, stderr=b"Error: pull access denied")
|
|
227
|
+
return MockProcess(0)
|
|
228
|
+
|
|
229
|
+
monkeypatch.setattr(asyncio, "create_subprocess_exec", create_mock)
|
|
230
|
+
|
|
231
|
+
backend = DockerBackend(default_config)
|
|
232
|
+
|
|
233
|
+
with pytest.raises(BackendError) as exc_info:
|
|
234
|
+
await backend._ensure_image()
|
|
235
|
+
|
|
236
|
+
assert "Failed to pull image" in str(exc_info.value)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class TestDockerBackendSandboxCreation:
|
|
240
|
+
"""Tests for DockerBackend._create_sandbox method."""
|
|
241
|
+
|
|
242
|
+
@pytest.mark.asyncio
|
|
243
|
+
async def test_create_sandbox_success(self, monkeypatch, default_config):
|
|
244
|
+
"""Full sandbox creation sequence succeeds."""
|
|
245
|
+
|
|
246
|
+
call_count = [0]
|
|
247
|
+
calls_log = []
|
|
248
|
+
|
|
249
|
+
async def create_mock(*args, **kwargs):
|
|
250
|
+
call_count[0] += 1
|
|
251
|
+
calls_log.append(args)
|
|
252
|
+
|
|
253
|
+
if "inspect" in args:
|
|
254
|
+
return MockProcess(0, stdout=b'{"Id": "abc123"}')
|
|
255
|
+
elif "run" in args:
|
|
256
|
+
return MockProcess(0, stdout=b"a" * 64)
|
|
257
|
+
elif "exec" in args:
|
|
258
|
+
return MockProcess(0, stdout=b"kilntainers-ready\n")
|
|
259
|
+
return MockProcess(0)
|
|
260
|
+
|
|
261
|
+
monkeypatch.setattr(asyncio, "create_subprocess_exec", create_mock)
|
|
262
|
+
|
|
263
|
+
backend = DockerBackend(default_config)
|
|
264
|
+
sandbox = await backend.create_sandbox()
|
|
265
|
+
|
|
266
|
+
assert isinstance(sandbox, DockerSandbox)
|
|
267
|
+
assert sandbox.sandbox_id == "a" * 12, (
|
|
268
|
+
f"Expected 'aaaaaaaaaaaa', got '{sandbox.sandbox_id}'. Calls: {calls_log}"
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
@pytest.mark.asyncio
|
|
272
|
+
async def test_create_sandbox_readiness_failure(self, monkeypatch, default_config):
|
|
273
|
+
"""Readiness check fails, container is cleaned up."""
|
|
274
|
+
|
|
275
|
+
call_count = [0]
|
|
276
|
+
stop_called = [False]
|
|
277
|
+
|
|
278
|
+
async def create_mock(*args, **kwargs):
|
|
279
|
+
call_count[0] += 1
|
|
280
|
+
|
|
281
|
+
if call_count[0] == 1: # image inspect
|
|
282
|
+
return MockProcess(0, stdout=b'{"Id": "abc123"}')
|
|
283
|
+
elif call_count[0] == 2: # docker run
|
|
284
|
+
return MockProcess(0, stdout=b"a" * 64)
|
|
285
|
+
elif call_count[0] == 3: # readiness check - wrong output
|
|
286
|
+
return MockProcess(0, stdout=b"wrong output\n")
|
|
287
|
+
elif "stop" in args:
|
|
288
|
+
stop_called[0] = True
|
|
289
|
+
return MockProcess(0)
|
|
290
|
+
return MockProcess(0)
|
|
291
|
+
|
|
292
|
+
monkeypatch.setattr(asyncio, "create_subprocess_exec", create_mock)
|
|
293
|
+
|
|
294
|
+
backend = DockerBackend(default_config)
|
|
295
|
+
|
|
296
|
+
with pytest.raises(BackendError) as exc_info:
|
|
297
|
+
await backend.create_sandbox()
|
|
298
|
+
|
|
299
|
+
assert "readiness check failed" in str(exc_info.value)
|
|
300
|
+
assert stop_called[0], "Container should be stopped after readiness failure"
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
class TestDockerBackendToolInstructions:
|
|
304
|
+
"""Tests for DockerBackend.tool_instructions method."""
|
|
305
|
+
|
|
306
|
+
def test_tool_instructions_default_image(self, default_config):
|
|
307
|
+
"""Default image returns tool description."""
|
|
308
|
+
backend = DockerBackend(default_config)
|
|
309
|
+
instructions = backend.tool_instructions()
|
|
310
|
+
|
|
311
|
+
assert instructions is not None
|
|
312
|
+
assert "Debian" in instructions
|
|
313
|
+
assert "bash" in instructions
|
|
314
|
+
assert "120" in instructions # default timeout
|
|
315
|
+
|
|
316
|
+
def test_tool_instructions_custom_image(self):
|
|
317
|
+
"""Custom image returns None."""
|
|
318
|
+
config = DockerBackendConfig(image="alpine:latest")
|
|
319
|
+
backend = DockerBackend(config)
|
|
320
|
+
instructions = backend.tool_instructions()
|
|
321
|
+
|
|
322
|
+
assert instructions is None
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
class TestDockerBackendEnginePrefix:
|
|
326
|
+
"""Tests for DockerBackend._engine_prefix property."""
|
|
327
|
+
|
|
328
|
+
def test_engine_prefix_no_host(self, default_config):
|
|
329
|
+
"""No host returns engine only."""
|
|
330
|
+
backend = DockerBackend(default_config)
|
|
331
|
+
assert backend._engine_prefix == ["docker"]
|
|
332
|
+
|
|
333
|
+
def test_engine_prefix_with_host(self):
|
|
334
|
+
"""Host adds -H flag to prefix."""
|
|
335
|
+
config = DockerBackendConfig(host="tcp://remote:2375")
|
|
336
|
+
backend = DockerBackend(config)
|
|
337
|
+
assert backend._engine_prefix == ["docker", "-H", "tcp://remote:2375"]
|
|
338
|
+
|
|
339
|
+
def test_engine_prefix_with_custom_engine_and_host(self):
|
|
340
|
+
"""Custom engine and host both appear in prefix."""
|
|
341
|
+
config = DockerBackendConfig(engine="podman", host="unix:///custom.sock")
|
|
342
|
+
backend = DockerBackend(config)
|
|
343
|
+
assert backend._engine_prefix == ["podman", "-H", "unix:///custom.sock"]
|
|
344
|
+
|
|
345
|
+
@pytest.mark.asyncio
|
|
346
|
+
async def test_run_docker_includes_host(self, monkeypatch):
|
|
347
|
+
"""_run_docker passes -H to subprocess when host is configured."""
|
|
348
|
+
captured_args: list[tuple] = []
|
|
349
|
+
|
|
350
|
+
async def create_mock(*args, **kwargs):
|
|
351
|
+
captured_args.append(args)
|
|
352
|
+
return MockProcess(0, stdout=b"ok\n")
|
|
353
|
+
|
|
354
|
+
monkeypatch.setattr(asyncio, "create_subprocess_exec", create_mock)
|
|
355
|
+
|
|
356
|
+
config = DockerBackendConfig(host="tcp://remote:2375")
|
|
357
|
+
backend = DockerBackend(config)
|
|
358
|
+
await backend._run_docker("info")
|
|
359
|
+
|
|
360
|
+
assert len(captured_args) == 1
|
|
361
|
+
cmd = captured_args[0]
|
|
362
|
+
assert cmd[0] == "docker"
|
|
363
|
+
assert cmd[1] == "-H"
|
|
364
|
+
assert cmd[2] == "tcp://remote:2375"
|
|
365
|
+
assert cmd[3] == "info"
|
|
366
|
+
|
|
367
|
+
@pytest.mark.asyncio
|
|
368
|
+
async def test_run_docker_no_host(self, monkeypatch, default_config):
|
|
369
|
+
"""_run_docker omits -H when no host is configured."""
|
|
370
|
+
captured_args: list[tuple] = []
|
|
371
|
+
|
|
372
|
+
async def create_mock(*args, **kwargs):
|
|
373
|
+
captured_args.append(args)
|
|
374
|
+
return MockProcess(0, stdout=b"ok\n")
|
|
375
|
+
|
|
376
|
+
monkeypatch.setattr(asyncio, "create_subprocess_exec", create_mock)
|
|
377
|
+
|
|
378
|
+
backend = DockerBackend(default_config)
|
|
379
|
+
await backend._run_docker("info")
|
|
380
|
+
|
|
381
|
+
assert len(captured_args) == 1
|
|
382
|
+
cmd = captured_args[0]
|
|
383
|
+
assert cmd[0] == "docker"
|
|
384
|
+
assert cmd[1] == "info"
|
|
385
|
+
assert "-H" not in cmd
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
class TestDockerSandboxEnginePrefix:
|
|
389
|
+
"""Tests for DockerSandbox._engine_prefix property."""
|
|
390
|
+
|
|
391
|
+
def test_engine_prefix_no_host(self):
|
|
392
|
+
"""No host returns engine only."""
|
|
393
|
+
state = _DockerSandboxState(
|
|
394
|
+
engine="docker", host=None, shell="/bin/bash", container_id="a" * 64
|
|
395
|
+
)
|
|
396
|
+
sandbox = DockerSandbox(state)
|
|
397
|
+
assert sandbox._engine_prefix == ["docker"]
|
|
398
|
+
|
|
399
|
+
def test_engine_prefix_with_host(self):
|
|
400
|
+
"""Host adds -H flag to prefix."""
|
|
401
|
+
state = _DockerSandboxState(
|
|
402
|
+
engine="docker",
|
|
403
|
+
host="tcp://remote:2375",
|
|
404
|
+
shell="/bin/bash",
|
|
405
|
+
container_id="a" * 64,
|
|
406
|
+
)
|
|
407
|
+
sandbox = DockerSandbox(state)
|
|
408
|
+
assert sandbox._engine_prefix == ["docker", "-H", "tcp://remote:2375"]
|
|
409
|
+
|
|
410
|
+
def test_build_exec_command_includes_host(self):
|
|
411
|
+
"""_build_exec_command includes -H when host is configured."""
|
|
412
|
+
state = _DockerSandboxState(
|
|
413
|
+
engine="docker",
|
|
414
|
+
host="tcp://remote:2375",
|
|
415
|
+
shell="/bin/bash",
|
|
416
|
+
container_id="a" * 64,
|
|
417
|
+
)
|
|
418
|
+
sandbox = DockerSandbox(state)
|
|
419
|
+
request = ExecRequest(command="ls", timeout=30, output_limit=2_097_152)
|
|
420
|
+
cmd = sandbox._build_exec_command(request)
|
|
421
|
+
|
|
422
|
+
assert cmd[0] == "docker"
|
|
423
|
+
assert cmd[1] == "-H"
|
|
424
|
+
assert cmd[2] == "tcp://remote:2375"
|
|
425
|
+
assert "exec" in cmd
|
|
426
|
+
|
|
427
|
+
def test_build_exec_command_no_host(self):
|
|
428
|
+
"""_build_exec_command omits -H when no host is configured."""
|
|
429
|
+
state = _DockerSandboxState(
|
|
430
|
+
engine="docker", host=None, shell="/bin/bash", container_id="a" * 64
|
|
431
|
+
)
|
|
432
|
+
sandbox = DockerSandbox(state)
|
|
433
|
+
request = ExecRequest(command="ls", timeout=30, output_limit=2_097_152)
|
|
434
|
+
cmd = sandbox._build_exec_command(request)
|
|
435
|
+
|
|
436
|
+
assert cmd[0] == "docker"
|
|
437
|
+
assert cmd[1] == "exec"
|
|
438
|
+
assert "-H" not in cmd
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
class TestDockerBackendRunCommand:
|
|
442
|
+
"""Tests for DockerBackend._build_run_command method."""
|
|
443
|
+
|
|
444
|
+
def test_build_run_command_defaults(self, default_config):
|
|
445
|
+
"""Default config produces expected docker run command."""
|
|
446
|
+
backend = DockerBackend(default_config)
|
|
447
|
+
cmd = backend._build_run_command()
|
|
448
|
+
|
|
449
|
+
assert cmd[0] == "run"
|
|
450
|
+
assert "-d" in cmd
|
|
451
|
+
assert "--rm" in cmd
|
|
452
|
+
assert "--label" in cmd
|
|
453
|
+
assert "kilntainers=true" in cmd
|
|
454
|
+
assert "--network" not in cmd
|
|
455
|
+
assert "none" not in cmd
|
|
456
|
+
assert default_config.image in cmd
|
|
457
|
+
assert "tail" in cmd
|
|
458
|
+
assert "-f" in cmd
|
|
459
|
+
assert "/dev/null" in cmd
|
|
460
|
+
|
|
461
|
+
def test_build_run_command_network_enabled(self):
|
|
462
|
+
"""Network enabled omits --network none."""
|
|
463
|
+
config = DockerBackendConfig(network_enabled=True)
|
|
464
|
+
backend = DockerBackend(config)
|
|
465
|
+
cmd = backend._build_run_command()
|
|
466
|
+
|
|
467
|
+
assert "--network" not in cmd
|
|
468
|
+
assert "none" not in cmd
|
|
469
|
+
|
|
470
|
+
def test_build_run_command_with_resources(self):
|
|
471
|
+
"""CPU and memory limits are added."""
|
|
472
|
+
config = DockerBackendConfig(cpu="1.5", memory="512m")
|
|
473
|
+
backend = DockerBackend(config)
|
|
474
|
+
cmd = backend._build_run_command()
|
|
475
|
+
|
|
476
|
+
assert "--cpus" in cmd
|
|
477
|
+
assert "1.5" in cmd
|
|
478
|
+
assert "--memory" in cmd
|
|
479
|
+
assert "512m" in cmd
|
|
480
|
+
|
|
481
|
+
def test_build_run_command_with_custom_flags(self):
|
|
482
|
+
"""Custom flags are appended."""
|
|
483
|
+
config = DockerBackendConfig(
|
|
484
|
+
docker_run_flags=["--read-only", "--pids-limit=256"]
|
|
485
|
+
)
|
|
486
|
+
backend = DockerBackend(config)
|
|
487
|
+
cmd = backend._build_run_command()
|
|
488
|
+
|
|
489
|
+
assert "--read-only" in cmd
|
|
490
|
+
assert "--pids-limit=256" in cmd
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
# --- DockerSandbox tests ---
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
class TestDockerSandboxExecCommandConstruction:
|
|
497
|
+
"""Tests for DockerSandbox._build_exec_command method."""
|
|
498
|
+
|
|
499
|
+
@pytest.fixture
|
|
500
|
+
def sandbox(self, default_config):
|
|
501
|
+
"""Create a test sandbox."""
|
|
502
|
+
state = _DockerSandboxState(
|
|
503
|
+
engine="docker", host=None, shell="/bin/bash", container_id="a" * 64
|
|
504
|
+
)
|
|
505
|
+
return DockerSandbox(state)
|
|
506
|
+
|
|
507
|
+
def test_command_mode(self, sandbox):
|
|
508
|
+
"""Command mode wraps in shell."""
|
|
509
|
+
request = ExecRequest(command="ls -la", timeout=30, output_limit=2_097_152)
|
|
510
|
+
cmd = sandbox._build_exec_command(request)
|
|
511
|
+
|
|
512
|
+
assert "docker" in cmd
|
|
513
|
+
assert "exec" in cmd
|
|
514
|
+
assert "a" * 64 in cmd
|
|
515
|
+
assert "/bin/bash" in cmd
|
|
516
|
+
assert "-c" in cmd
|
|
517
|
+
assert "ls -la" in cmd
|
|
518
|
+
assert "-i" not in cmd
|
|
519
|
+
|
|
520
|
+
def test_args_mode(self, sandbox):
|
|
521
|
+
"""Args mode passes directly."""
|
|
522
|
+
request = ExecRequest(
|
|
523
|
+
args=["python3", "script.py"], timeout=30, output_limit=2_097_152
|
|
524
|
+
)
|
|
525
|
+
cmd = sandbox._build_exec_command(request)
|
|
526
|
+
|
|
527
|
+
assert "docker" in cmd
|
|
528
|
+
assert "exec" in cmd
|
|
529
|
+
assert "a" * 64 in cmd
|
|
530
|
+
assert "python3" in cmd
|
|
531
|
+
assert "script.py" in cmd
|
|
532
|
+
assert "/bin/bash" not in cmd
|
|
533
|
+
assert "-c" not in cmd
|
|
534
|
+
assert "-i" not in cmd
|
|
535
|
+
|
|
536
|
+
def test_with_stdin(self, sandbox):
|
|
537
|
+
"""Stdin adds -i flag."""
|
|
538
|
+
request = ExecRequest(
|
|
539
|
+
command="cat", stdin="hello", timeout=30, output_limit=2_097_152
|
|
540
|
+
)
|
|
541
|
+
cmd = sandbox._build_exec_command(request)
|
|
542
|
+
|
|
543
|
+
assert "-i" in cmd
|
|
544
|
+
|
|
545
|
+
def test_with_working_directory(self, sandbox):
|
|
546
|
+
"""Working directory adds -w flag."""
|
|
547
|
+
request = ExecRequest(
|
|
548
|
+
command="pwd", working_directory="/tmp", timeout=30, output_limit=2_097_152
|
|
549
|
+
)
|
|
550
|
+
cmd = sandbox._build_exec_command(request)
|
|
551
|
+
|
|
552
|
+
assert "-w" in cmd
|
|
553
|
+
assert "/tmp" in cmd
|
|
554
|
+
|
|
555
|
+
def test_all_options(self, sandbox):
|
|
556
|
+
"""All options combined."""
|
|
557
|
+
request = ExecRequest(
|
|
558
|
+
command="make",
|
|
559
|
+
stdin="",
|
|
560
|
+
working_directory="/app",
|
|
561
|
+
timeout=30,
|
|
562
|
+
output_limit=2_097_152,
|
|
563
|
+
)
|
|
564
|
+
cmd = sandbox._build_exec_command(request)
|
|
565
|
+
|
|
566
|
+
assert "-i" in cmd
|
|
567
|
+
assert "-w" in cmd
|
|
568
|
+
assert "/app" in cmd
|
|
569
|
+
assert "/bin/bash" in cmd
|
|
570
|
+
assert "-c" in cmd
|
|
571
|
+
assert "make" in cmd
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
class TestDockerSandboxExec:
|
|
575
|
+
"""Tests for DockerSandbox.exec method."""
|
|
576
|
+
|
|
577
|
+
@pytest.fixture
|
|
578
|
+
def sandbox(self, default_config):
|
|
579
|
+
"""Create a test sandbox."""
|
|
580
|
+
state = _DockerSandboxState(
|
|
581
|
+
engine="docker", host=None, shell="/bin/bash", container_id="a" * 64
|
|
582
|
+
)
|
|
583
|
+
return DockerSandbox(state)
|
|
584
|
+
|
|
585
|
+
@pytest.mark.asyncio
|
|
586
|
+
async def test_exec_success(self, monkeypatch, sandbox):
|
|
587
|
+
"""Successful command execution."""
|
|
588
|
+
|
|
589
|
+
async def create_mock(*args, **kwargs):
|
|
590
|
+
return MockProcess(0, stdout=b"hello\n", stderr=b"")
|
|
591
|
+
|
|
592
|
+
monkeypatch.setattr(asyncio, "create_subprocess_exec", create_mock)
|
|
593
|
+
|
|
594
|
+
request = ExecRequest(command="echo hello", timeout=30, output_limit=2_097_152)
|
|
595
|
+
result = await sandbox.exec(request)
|
|
596
|
+
|
|
597
|
+
assert result.stdout == "hello\n"
|
|
598
|
+
assert result.stderr == ""
|
|
599
|
+
assert result.exit_code == 0
|
|
600
|
+
assert result.exec_duration_ms >= 0
|
|
601
|
+
|
|
602
|
+
@pytest.mark.asyncio
|
|
603
|
+
async def test_exec_failure(self, monkeypatch, sandbox):
|
|
604
|
+
"""Command fails with non-zero exit."""
|
|
605
|
+
|
|
606
|
+
async def create_mock(*args, **kwargs):
|
|
607
|
+
if "inspect" in args:
|
|
608
|
+
# Container is still running
|
|
609
|
+
return MockProcess(0, stdout=b"true")
|
|
610
|
+
# Command execution fails
|
|
611
|
+
return MockProcess(1, stdout=b"", stderr=b"error: not found\n")
|
|
612
|
+
|
|
613
|
+
monkeypatch.setattr(asyncio, "create_subprocess_exec", create_mock)
|
|
614
|
+
|
|
615
|
+
request = ExecRequest(command="false", timeout=30, output_limit=2_097_152)
|
|
616
|
+
result = await sandbox.exec(request)
|
|
617
|
+
|
|
618
|
+
assert result.exit_code == 1
|
|
619
|
+
assert "error: not found" in result.stderr
|
|
620
|
+
|
|
621
|
+
@pytest.mark.asyncio
|
|
622
|
+
async def test_exec_timeout(self, monkeypatch, sandbox):
|
|
623
|
+
"""Command times out."""
|
|
624
|
+
|
|
625
|
+
async def create_mock(*args, **kwargs):
|
|
626
|
+
# Create a process with blocking stream readers
|
|
627
|
+
proc = MockProcess(0, block_stdout=True, block_stderr=True)
|
|
628
|
+
return proc
|
|
629
|
+
|
|
630
|
+
monkeypatch.setattr(asyncio, "create_subprocess_exec", create_mock)
|
|
631
|
+
|
|
632
|
+
request = ExecRequest(command="sleep 60", timeout=1, output_limit=2_097_152)
|
|
633
|
+
result = await sandbox.exec(request)
|
|
634
|
+
|
|
635
|
+
assert result.exit_code == 124
|
|
636
|
+
assert "timed out" in result.stderr
|
|
637
|
+
assert result.stdout == ""
|
|
638
|
+
|
|
639
|
+
@pytest.mark.asyncio
|
|
640
|
+
async def test_exec_output_limit(self, monkeypatch, sandbox):
|
|
641
|
+
"""Output limit exceeded."""
|
|
642
|
+
|
|
643
|
+
async def create_mock(*args, **kwargs):
|
|
644
|
+
# Create a process that generates lots of output
|
|
645
|
+
proc = MockProcess(0, stdout=b"x" * 10000, stderr=b"y" * 10000)
|
|
646
|
+
return proc
|
|
647
|
+
|
|
648
|
+
monkeypatch.setattr(asyncio, "create_subprocess_exec", create_mock)
|
|
649
|
+
|
|
650
|
+
request = ExecRequest(command="yes", timeout=30, output_limit=1000)
|
|
651
|
+
result = await sandbox.exec(request)
|
|
652
|
+
|
|
653
|
+
assert result.exit_code == 1
|
|
654
|
+
assert "output limit exceeded" in result.stderr
|
|
655
|
+
assert result.stdout == ""
|
|
656
|
+
|
|
657
|
+
@pytest.mark.asyncio
|
|
658
|
+
async def test_exec_after_stop(self, sandbox):
|
|
659
|
+
"""Exec after stop raises SandboxDiedError."""
|
|
660
|
+
sandbox._stopped = True
|
|
661
|
+
|
|
662
|
+
request = ExecRequest(command="echo", timeout=30, output_limit=2_097_152)
|
|
663
|
+
|
|
664
|
+
with pytest.raises(SandboxDiedError) as exc_info:
|
|
665
|
+
await sandbox.exec(request)
|
|
666
|
+
|
|
667
|
+
assert "stopped" in str(exc_info.value)
|
|
668
|
+
|
|
669
|
+
@pytest.mark.asyncio
|
|
670
|
+
async def test_exec_serialization(self, monkeypatch, sandbox):
|
|
671
|
+
"""Concurrent exec calls are serialized."""
|
|
672
|
+
|
|
673
|
+
exec_count = [0]
|
|
674
|
+
in_exec = asyncio.Event()
|
|
675
|
+
|
|
676
|
+
async def mock_wait(self):
|
|
677
|
+
exec_count[0] += 1
|
|
678
|
+
in_exec.set()
|
|
679
|
+
# Wait a bit to verify serialization
|
|
680
|
+
await asyncio.sleep(0.1)
|
|
681
|
+
return 0
|
|
682
|
+
|
|
683
|
+
async def create_mock(*args, **kwargs):
|
|
684
|
+
proc = MockProcess(0, stdout=b"done\n", stderr=b"")
|
|
685
|
+
proc.wait = mock_wait.__get__(proc, MockProcess) # type: ignore[method-assign]
|
|
686
|
+
return proc
|
|
687
|
+
|
|
688
|
+
monkeypatch.setattr(asyncio, "create_subprocess_exec", create_mock)
|
|
689
|
+
|
|
690
|
+
request = ExecRequest(command="echo test", timeout=30, output_limit=2_097_152)
|
|
691
|
+
|
|
692
|
+
# Start two exec calls concurrently
|
|
693
|
+
task1 = asyncio.create_task(sandbox.exec(request))
|
|
694
|
+
task2 = asyncio.create_task(sandbox.exec(request))
|
|
695
|
+
|
|
696
|
+
# Wait for first to start
|
|
697
|
+
await in_exec.wait()
|
|
698
|
+
|
|
699
|
+
# Both should complete
|
|
700
|
+
await task1
|
|
701
|
+
await task2
|
|
702
|
+
|
|
703
|
+
# Due to lock, they should have run serially
|
|
704
|
+
assert exec_count[0] == 2
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
class TestDockerSandboxStop:
|
|
708
|
+
"""Tests for DockerSandbox.stop method."""
|
|
709
|
+
|
|
710
|
+
@pytest.fixture
|
|
711
|
+
def sandbox(self, default_config):
|
|
712
|
+
"""Create a test sandbox."""
|
|
713
|
+
state = _DockerSandboxState(
|
|
714
|
+
engine="docker", host=None, shell="/bin/bash", container_id="a" * 64
|
|
715
|
+
)
|
|
716
|
+
return DockerSandbox(state)
|
|
717
|
+
|
|
718
|
+
@pytest.mark.asyncio
|
|
719
|
+
async def test_stop(self, monkeypatch, sandbox):
|
|
720
|
+
"""Stop calls docker stop."""
|
|
721
|
+
stop_called = [False]
|
|
722
|
+
|
|
723
|
+
async def create_mock(*args, **kwargs):
|
|
724
|
+
cmd = args[1] if len(args) > 1 else ""
|
|
725
|
+
if "stop" in cmd:
|
|
726
|
+
stop_called[0] = True
|
|
727
|
+
return MockProcess(0)
|
|
728
|
+
return MockProcess(0)
|
|
729
|
+
|
|
730
|
+
monkeypatch.setattr(asyncio, "create_subprocess_exec", create_mock)
|
|
731
|
+
|
|
732
|
+
await sandbox.stop()
|
|
733
|
+
|
|
734
|
+
assert sandbox._stopped
|
|
735
|
+
assert sandbox._stop_requested
|
|
736
|
+
assert stop_called[0]
|
|
737
|
+
|
|
738
|
+
@pytest.mark.asyncio
|
|
739
|
+
async def test_stop_idempotent(self, monkeypatch, sandbox):
|
|
740
|
+
"""Stop is idempotent."""
|
|
741
|
+
stop_count = [0]
|
|
742
|
+
|
|
743
|
+
async def create_mock(*args, **kwargs):
|
|
744
|
+
cmd = args[1] if len(args) > 1 else ""
|
|
745
|
+
if "stop" in cmd:
|
|
746
|
+
stop_count[0] += 1
|
|
747
|
+
return MockProcess(0)
|
|
748
|
+
return MockProcess(0)
|
|
749
|
+
|
|
750
|
+
monkeypatch.setattr(asyncio, "create_subprocess_exec", create_mock)
|
|
751
|
+
|
|
752
|
+
await sandbox.stop()
|
|
753
|
+
await sandbox.stop()
|
|
754
|
+
await sandbox.stop()
|
|
755
|
+
|
|
756
|
+
assert stop_count[0] == 1 # Only called once
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
class TestDockerSandboxDeathDetection:
|
|
760
|
+
"""Tests for DockerSandbox.wait_for_death method."""
|
|
761
|
+
|
|
762
|
+
@pytest.fixture
|
|
763
|
+
def sandbox(self, default_config):
|
|
764
|
+
"""Create a test sandbox."""
|
|
765
|
+
state = _DockerSandboxState(
|
|
766
|
+
engine="docker", host=None, shell="/bin/bash", container_id="a" * 64
|
|
767
|
+
)
|
|
768
|
+
return DockerSandbox(state)
|
|
769
|
+
|
|
770
|
+
@pytest.mark.asyncio
|
|
771
|
+
async def test_wait_for_death_unexpected_exit(self, monkeypatch, sandbox):
|
|
772
|
+
"""Container exits unexpectedly."""
|
|
773
|
+
|
|
774
|
+
async def create_mock(*args, **kwargs):
|
|
775
|
+
cmd = args[1] if len(args) > 1 else ""
|
|
776
|
+
if "wait" in cmd:
|
|
777
|
+
# Container exits immediately
|
|
778
|
+
return MockProcess(0)
|
|
779
|
+
return MockProcess(0)
|
|
780
|
+
|
|
781
|
+
monkeypatch.setattr(asyncio, "create_subprocess_exec", create_mock)
|
|
782
|
+
|
|
783
|
+
# Should return when container exits unexpectedly
|
|
784
|
+
await sandbox.wait_for_death()
|
|
785
|
+
|
|
786
|
+
@pytest.mark.asyncio
|
|
787
|
+
async def test_wait_for_death_with_stop_requested(self, monkeypatch, sandbox):
|
|
788
|
+
"""Container exits after stop is requested."""
|
|
789
|
+
|
|
790
|
+
async def create_mock(*args, **kwargs):
|
|
791
|
+
cmd = args[1] if len(args) > 1 else ""
|
|
792
|
+
if "wait" in cmd:
|
|
793
|
+
# Container exits
|
|
794
|
+
return MockProcess(0)
|
|
795
|
+
return MockProcess(0)
|
|
796
|
+
|
|
797
|
+
monkeypatch.setattr(asyncio, "create_subprocess_exec", create_mock)
|
|
798
|
+
|
|
799
|
+
sandbox._stop_requested = True
|
|
800
|
+
|
|
801
|
+
# Should block forever (we'll cancel it)
|
|
802
|
+
task = asyncio.create_task(sandbox.wait_for_death())
|
|
803
|
+
await asyncio.sleep(0.1)
|
|
804
|
+
|
|
805
|
+
# Task should still be running
|
|
806
|
+
assert not task.done()
|
|
807
|
+
|
|
808
|
+
# Cancel the task
|
|
809
|
+
task.cancel()
|
|
810
|
+
await task
|
|
811
|
+
|
|
812
|
+
@pytest.mark.asyncio
|
|
813
|
+
async def test_wait_for_death_cancellation(self, monkeypatch, sandbox):
|
|
814
|
+
"""Task is cancelled before container exits."""
|
|
815
|
+
|
|
816
|
+
async def create_mock(*args, **kwargs):
|
|
817
|
+
if "wait" in args:
|
|
818
|
+
# Create a process that completes immediately
|
|
819
|
+
return MockProcess(0)
|
|
820
|
+
return MockProcess(0)
|
|
821
|
+
|
|
822
|
+
monkeypatch.setattr(asyncio, "create_subprocess_exec", create_mock)
|
|
823
|
+
|
|
824
|
+
# When container exits immediately, wait_for_death should return
|
|
825
|
+
await sandbox.wait_for_death()
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
class TestDockerSandboxSandboxId:
|
|
829
|
+
"""Tests for DockerSandbox.sandbox_id property."""
|
|
830
|
+
|
|
831
|
+
def test_sandbox_id(self, default_config):
|
|
832
|
+
"""sandbox_id returns first 12 chars of container_id."""
|
|
833
|
+
state = _DockerSandboxState(
|
|
834
|
+
engine="docker",
|
|
835
|
+
host=None,
|
|
836
|
+
shell="/bin/bash",
|
|
837
|
+
container_id="abcd1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
|
|
838
|
+
)
|
|
839
|
+
sandbox = DockerSandbox(state)
|
|
840
|
+
|
|
841
|
+
assert sandbox.sandbox_id == "abcd12345678"
|