offwork 0.4.0__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.
- offwork/__init__.py +167 -0
- offwork/__main__.py +770 -0
- offwork/_venv.py +174 -0
- offwork/core/__init__.py +15 -0
- offwork/core/errors.py +83 -0
- offwork/core/models.py +174 -0
- offwork/core/pairing.py +389 -0
- offwork/core/progress.py +91 -0
- offwork/core/signing.py +91 -0
- offwork/core/task.py +520 -0
- offwork/core/token.py +184 -0
- offwork/core/version.py +10 -0
- offwork/graph/__init__.py +5 -0
- offwork/graph/analyzer.py +637 -0
- offwork/graph/decorator.py +87 -0
- offwork/graph/graph.py +995 -0
- offwork/graph/store.py +500 -0
- offwork/graph/tracing.py +429 -0
- offwork/py.typed +0 -0
- offwork/typing.py +48 -0
- offwork/worker/__init__.py +18 -0
- offwork/worker/backends/__init__.py +3 -0
- offwork/worker/backends/base.py +149 -0
- offwork/worker/backends/http.py +237 -0
- offwork/worker/backends/local.py +452 -0
- offwork/worker/backends/rabbitmq.py +410 -0
- offwork/worker/backends/redis.py +175 -0
- offwork/worker/deps.py +365 -0
- offwork/worker/remote.py +793 -0
- offwork/worker/result.py +276 -0
- offwork/worker/sandbox/Dockerfile +24 -0
- offwork/worker/sandbox/__init__.py +18 -0
- offwork/worker/sandbox/_protocol.py +50 -0
- offwork/worker/sandbox/docker.py +438 -0
- offwork/worker/sandbox/guest_agent.py +622 -0
- offwork/worker/schedule.py +26 -0
- offwork/worker/worker.py +263 -0
- offwork-0.4.0.dist-info/METADATA +143 -0
- offwork-0.4.0.dist-info/RECORD +42 -0
- offwork-0.4.0.dist-info/WHEEL +4 -0
- offwork-0.4.0.dist-info/entry_points.txt +3 -0
- offwork-0.4.0.dist-info/licenses/LICENSE +661 -0
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
"""Docker-based sandbox for isolated function execution.
|
|
2
|
+
|
|
3
|
+
Runs the guest agent (:mod:`~offwork.worker.sandbox.guest_agent`) inside
|
|
4
|
+
a Docker container, communicating over TCP with a length-prefixed JSON
|
|
5
|
+
protocol.
|
|
6
|
+
|
|
7
|
+
Requirements
|
|
8
|
+
~~~~~~~~~~~~
|
|
9
|
+
* Docker (or a compatible runtime such as colima / Podman) installed
|
|
10
|
+
and the ``docker`` CLI available on ``PATH``
|
|
11
|
+
* The current user must be able to run ``docker`` commands (i.e. be in
|
|
12
|
+
the ``docker`` group or use rootless Docker)
|
|
13
|
+
|
|
14
|
+
The image is built automatically from the bundled ``Dockerfile`` on
|
|
15
|
+
first use, so ``offwork sandbox setup`` is optional (but recommended in
|
|
16
|
+
CI to avoid a cold-start build).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
import shutil
|
|
21
|
+
import asyncio
|
|
22
|
+
import hashlib
|
|
23
|
+
import logging
|
|
24
|
+
import contextlib
|
|
25
|
+
from typing import Any
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from collections.abc import Callable
|
|
28
|
+
|
|
29
|
+
from offwork.core.errors import WorkerError
|
|
30
|
+
from offwork.core.task import _resolve, _to_jsonable
|
|
31
|
+
from offwork.core.progress import _progress_callback
|
|
32
|
+
from offwork.worker.sandbox._protocol import async_recv, async_send
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
_DOCKERFILE_DIR = Path(__file__).resolve().parent # contains Dockerfile + guest_agent.py
|
|
37
|
+
_IMAGE_ASSETS = ("Dockerfile", "guest_agent.py", "_protocol.py")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _assets_digest() -> str:
|
|
41
|
+
"""Short hash of the files baked into the sandbox image.
|
|
42
|
+
|
|
43
|
+
Used as the default image tag so a code change in the guest agent
|
|
44
|
+
or Dockerfile produces a fresh image instead of silently reusing a
|
|
45
|
+
stale one whose ``guest_agent.py`` no longer matches the host.
|
|
46
|
+
"""
|
|
47
|
+
h = hashlib.sha256()
|
|
48
|
+
for name in _IMAGE_ASSETS:
|
|
49
|
+
path = _DOCKERFILE_DIR / name
|
|
50
|
+
if path.exists():
|
|
51
|
+
h.update(name.encode())
|
|
52
|
+
h.update(b"\0")
|
|
53
|
+
h.update(path.read_bytes())
|
|
54
|
+
h.update(b"\0")
|
|
55
|
+
return h.hexdigest()[:12]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
_DEFAULT_IMAGE = f"offwork-sandbox:{_assets_digest()}"
|
|
59
|
+
_DEFAULT_CONTAINER = "offwork-sandbox"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class DockerSandbox:
|
|
63
|
+
"""Execute functions inside a Docker container.
|
|
64
|
+
|
|
65
|
+
The sandbox lazily starts a container on first use and keeps it
|
|
66
|
+
running for the lifetime of the worker so that subsequent task
|
|
67
|
+
executions reuse the same guest agent connection.
|
|
68
|
+
|
|
69
|
+
Parameters
|
|
70
|
+
----------
|
|
71
|
+
image
|
|
72
|
+
Docker image name. Built automatically from the bundled
|
|
73
|
+
``Dockerfile`` if it doesn't exist locally.
|
|
74
|
+
container_name
|
|
75
|
+
Name assigned to the running container.
|
|
76
|
+
guest_port
|
|
77
|
+
TCP port the guest agent listens on inside the container.
|
|
78
|
+
cpus
|
|
79
|
+
Number of vCPUs allocated to the container.
|
|
80
|
+
memory_gb
|
|
81
|
+
Gigabytes of RAM allocated to the container.
|
|
82
|
+
timeout
|
|
83
|
+
Maximum seconds for a single function execution.
|
|
84
|
+
boot_timeout
|
|
85
|
+
Maximum seconds to wait for the container to become reachable.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
def __init__(
|
|
89
|
+
self,
|
|
90
|
+
*,
|
|
91
|
+
image: str = _DEFAULT_IMAGE,
|
|
92
|
+
container_name: str = _DEFAULT_CONTAINER,
|
|
93
|
+
guest_port: int = 9749,
|
|
94
|
+
cpus: int = 2,
|
|
95
|
+
memory_gb: int = 2,
|
|
96
|
+
timeout: float = 60.0,
|
|
97
|
+
boot_timeout: float = 30.0,
|
|
98
|
+
) -> None:
|
|
99
|
+
if cpus < 1:
|
|
100
|
+
raise ValueError(f"cpus must be at least 1, got {cpus}")
|
|
101
|
+
if memory_gb < 1:
|
|
102
|
+
raise ValueError(f"memory_gb must be at least 1, got {memory_gb}")
|
|
103
|
+
if timeout <= 0:
|
|
104
|
+
raise ValueError(f"timeout must be positive, got {timeout}")
|
|
105
|
+
if boot_timeout <= 0:
|
|
106
|
+
raise ValueError(f"boot_timeout must be positive, got {boot_timeout}")
|
|
107
|
+
|
|
108
|
+
self.image = image
|
|
109
|
+
self.container_name = container_name
|
|
110
|
+
self.guest_port = guest_port
|
|
111
|
+
self.cpus = cpus
|
|
112
|
+
self.memory_gb = memory_gb
|
|
113
|
+
self.timeout = timeout
|
|
114
|
+
self.boot_timeout = boot_timeout
|
|
115
|
+
|
|
116
|
+
self._host_port: int | None = None
|
|
117
|
+
self._reader: asyncio.StreamReader | None = None
|
|
118
|
+
self._writer: asyncio.StreamWriter | None = None
|
|
119
|
+
self._lock = asyncio.Lock()
|
|
120
|
+
self._started = False
|
|
121
|
+
|
|
122
|
+
# -- public API ----------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
async def execute(
|
|
125
|
+
self,
|
|
126
|
+
source: str,
|
|
127
|
+
function_name: str,
|
|
128
|
+
args: tuple[Any, ...],
|
|
129
|
+
kwargs: dict[str, Any],
|
|
130
|
+
*,
|
|
131
|
+
owner_class: str | None = None,
|
|
132
|
+
) -> Any:
|
|
133
|
+
"""Send *source* + *function_name* to the guest agent and return the result."""
|
|
134
|
+
if not self._started:
|
|
135
|
+
await self.start()
|
|
136
|
+
|
|
137
|
+
request: dict[str, Any] = {
|
|
138
|
+
"source": source,
|
|
139
|
+
"function_name": function_name,
|
|
140
|
+
"args": [_to_jsonable(a) for a in args],
|
|
141
|
+
"kwargs": {k: _to_jsonable(v) for k, v in kwargs.items()},
|
|
142
|
+
}
|
|
143
|
+
if owner_class is not None:
|
|
144
|
+
request["owner_class"] = owner_class
|
|
145
|
+
|
|
146
|
+
# Pick up the host-side progress callback (set by _handle_task)
|
|
147
|
+
# so we can forward progress messages from the container.
|
|
148
|
+
progress_cb = _progress_callback.get(None)
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
response = await asyncio.wait_for(
|
|
152
|
+
self._send_request(request, progress_cb=progress_cb),
|
|
153
|
+
timeout=self.timeout,
|
|
154
|
+
)
|
|
155
|
+
except asyncio.TimeoutError:
|
|
156
|
+
raise WorkerError(
|
|
157
|
+
f"Sandbox execution of '{function_name}' timed out "
|
|
158
|
+
f"after {self.timeout}s"
|
|
159
|
+
) from None
|
|
160
|
+
except (asyncio.IncompleteReadError, ConnectionError, OSError) as exc:
|
|
161
|
+
raise WorkerError(
|
|
162
|
+
f"Sandbox connection lost while executing '{function_name}': "
|
|
163
|
+
f"{type(exc).__name__}: {exc}"
|
|
164
|
+
) from exc
|
|
165
|
+
|
|
166
|
+
if response["status"] == "error":
|
|
167
|
+
raise WorkerError(
|
|
168
|
+
f"Sandbox error — {response.get('error_type', 'Unknown')}: "
|
|
169
|
+
f"{response.get('error_message', '')}"
|
|
170
|
+
)
|
|
171
|
+
# Return values from the guest agent travel through the same
|
|
172
|
+
# sentinel encoding so non-JSON-native types (tuples, datetimes,
|
|
173
|
+
# custom classes, etc.) round-trip transparently.
|
|
174
|
+
return _resolve(response.get("result"), {})
|
|
175
|
+
|
|
176
|
+
async def start(self) -> None:
|
|
177
|
+
"""Build the image (if needed), start the container, connect."""
|
|
178
|
+
_check_docker_available()
|
|
179
|
+
|
|
180
|
+
if not await _image_exists(self.image):
|
|
181
|
+
logger.info("Building Docker image '%s' …", self.image)
|
|
182
|
+
await _build_image(self.image)
|
|
183
|
+
|
|
184
|
+
container = self.container_name
|
|
185
|
+
if await _container_exists(container):
|
|
186
|
+
current_image = await _container_image(container)
|
|
187
|
+
if current_image != self.image:
|
|
188
|
+
logger.info(
|
|
189
|
+
"Container '%s' was built from a different image (%s); "
|
|
190
|
+
"recreating from '%s'",
|
|
191
|
+
container, current_image or "<unknown>", self.image,
|
|
192
|
+
)
|
|
193
|
+
await _docker_wait("rm", "-f", container)
|
|
194
|
+
|
|
195
|
+
if not await _container_running(container):
|
|
196
|
+
if await _container_exists(container):
|
|
197
|
+
await _docker_wait("rm", "-f", container)
|
|
198
|
+
logger.info("Starting container '%s' …", container)
|
|
199
|
+
await self._run_container()
|
|
200
|
+
|
|
201
|
+
self._host_port = await _mapped_port(container, self.guest_port)
|
|
202
|
+
await self._wait_for_agent()
|
|
203
|
+
await self._connect()
|
|
204
|
+
self._started = True
|
|
205
|
+
logger.info(
|
|
206
|
+
"Docker sandbox ready (localhost:%d → container:%d)",
|
|
207
|
+
self._host_port,
|
|
208
|
+
self.guest_port,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
async def stop(self) -> None:
|
|
212
|
+
"""Disconnect and stop the container."""
|
|
213
|
+
if self._writer is not None:
|
|
214
|
+
self._writer.close()
|
|
215
|
+
self._reader = self._writer = None
|
|
216
|
+
container = self.container_name
|
|
217
|
+
if await _container_running(container):
|
|
218
|
+
logger.info("Stopping container '%s' …", container)
|
|
219
|
+
await _docker_wait("stop", container)
|
|
220
|
+
self._started = False
|
|
221
|
+
|
|
222
|
+
async def __aenter__(self) -> "DockerSandbox":
|
|
223
|
+
await self.start()
|
|
224
|
+
return self
|
|
225
|
+
|
|
226
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
227
|
+
await self.stop()
|
|
228
|
+
|
|
229
|
+
# -- internals -----------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
async def _run_container(self) -> None:
|
|
232
|
+
cmd = [
|
|
233
|
+
"docker", "run", "-d",
|
|
234
|
+
"--name", self.container_name,
|
|
235
|
+
"-p", f"0:{self.guest_port}",
|
|
236
|
+
]
|
|
237
|
+
if self.cpus:
|
|
238
|
+
cmd += ["--cpus", str(self.cpus)]
|
|
239
|
+
if self.memory_gb:
|
|
240
|
+
cmd += ["--memory", f"{self.memory_gb}g"]
|
|
241
|
+
cmd.append(self.image)
|
|
242
|
+
|
|
243
|
+
rc, _stdout, stderr = await _docker_wait(*cmd[1:])
|
|
244
|
+
if rc != 0:
|
|
245
|
+
raise WorkerError(f"Failed to start Docker container: {stderr.strip()}")
|
|
246
|
+
|
|
247
|
+
async def _wait_for_agent(self) -> None:
|
|
248
|
+
"""Block until the guest agent is actually answering on the wire.
|
|
249
|
+
|
|
250
|
+
A bare TCP ``open_connection`` is not sufficient on Linux: when
|
|
251
|
+
a container port is published, ``docker-proxy`` accepts host
|
|
252
|
+
connections *before* the in-container process is listening,
|
|
253
|
+
which causes the very first request to race with agent startup
|
|
254
|
+
and come back as ``IncompleteReadError``. Performing an actual
|
|
255
|
+
ping/pong exchange guarantees end-to-end readiness.
|
|
256
|
+
"""
|
|
257
|
+
assert self._host_port is not None
|
|
258
|
+
deadline = asyncio.get_running_loop().time() + self.boot_timeout
|
|
259
|
+
last_err: Exception | None = None
|
|
260
|
+
while asyncio.get_running_loop().time() < deadline:
|
|
261
|
+
try:
|
|
262
|
+
reader, writer = await asyncio.wait_for(
|
|
263
|
+
asyncio.open_connection("127.0.0.1", self._host_port),
|
|
264
|
+
timeout=2.0,
|
|
265
|
+
)
|
|
266
|
+
try:
|
|
267
|
+
await asyncio.wait_for(
|
|
268
|
+
async_send(writer, {"op": "ping"}), timeout=2.0,
|
|
269
|
+
)
|
|
270
|
+
msg = await asyncio.wait_for(async_recv(reader), timeout=2.0)
|
|
271
|
+
finally:
|
|
272
|
+
writer.close()
|
|
273
|
+
with contextlib.suppress(ConnectionError, OSError):
|
|
274
|
+
await writer.wait_closed()
|
|
275
|
+
if msg.get("status") == "pong":
|
|
276
|
+
return
|
|
277
|
+
last_err = WorkerError(f"Unexpected handshake reply: {msg!r}")
|
|
278
|
+
except (
|
|
279
|
+
OSError,
|
|
280
|
+
asyncio.TimeoutError,
|
|
281
|
+
asyncio.IncompleteReadError,
|
|
282
|
+
ConnectionError,
|
|
283
|
+
) as exc:
|
|
284
|
+
last_err = exc
|
|
285
|
+
await asyncio.sleep(0.5)
|
|
286
|
+
raise WorkerError(
|
|
287
|
+
f"Docker guest agent did not become reachable within "
|
|
288
|
+
f"{self.boot_timeout}s: {last_err!r}"
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
async def _connect(self) -> None:
|
|
292
|
+
assert self._host_port is not None
|
|
293
|
+
self._reader, self._writer = await asyncio.open_connection(
|
|
294
|
+
"127.0.0.1", self._host_port,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
async def _send_request(
|
|
298
|
+
self,
|
|
299
|
+
request: dict[str, Any],
|
|
300
|
+
*,
|
|
301
|
+
progress_cb: Callable[..., Any] | None = None,
|
|
302
|
+
) -> dict[str, Any]:
|
|
303
|
+
async with self._lock:
|
|
304
|
+
if self._reader is None or self._writer is None:
|
|
305
|
+
await self._connect()
|
|
306
|
+
assert self._reader is not None and self._writer is not None
|
|
307
|
+
success = False
|
|
308
|
+
try:
|
|
309
|
+
try:
|
|
310
|
+
await async_send(self._writer, request)
|
|
311
|
+
result = await self._read_response(progress_cb)
|
|
312
|
+
success = True
|
|
313
|
+
return result
|
|
314
|
+
except (asyncio.IncompleteReadError, ConnectionError, OSError):
|
|
315
|
+
self._reader = self._writer = None
|
|
316
|
+
await self._connect()
|
|
317
|
+
assert self._reader is not None and self._writer is not None
|
|
318
|
+
await async_send(self._writer, request)
|
|
319
|
+
result = await self._read_response(progress_cb)
|
|
320
|
+
success = True
|
|
321
|
+
return result
|
|
322
|
+
finally:
|
|
323
|
+
if not success:
|
|
324
|
+
# On cancellation or timeout the guest agent may still
|
|
325
|
+
# be processing and will eventually write a stale
|
|
326
|
+
# response. Reset the connection so the next request
|
|
327
|
+
# doesn't read that leftover data.
|
|
328
|
+
if self._writer is not None:
|
|
329
|
+
self._writer.close()
|
|
330
|
+
self._reader = self._writer = None
|
|
331
|
+
|
|
332
|
+
async def _read_response(
|
|
333
|
+
self,
|
|
334
|
+
progress_cb: Callable[..., Any] | None = None,
|
|
335
|
+
) -> dict[str, Any]:
|
|
336
|
+
"""Read messages until a terminal (ok/error) response arrives.
|
|
337
|
+
|
|
338
|
+
Intermediate ``{"status": "progress", ...}`` frames are forwarded
|
|
339
|
+
to *progress_cb* so that ``offwork.progress()`` calls inside the
|
|
340
|
+
container surface on the host in real time.
|
|
341
|
+
"""
|
|
342
|
+
assert self._reader is not None
|
|
343
|
+
while True:
|
|
344
|
+
msg = await async_recv(self._reader)
|
|
345
|
+
if msg.get("status") == "progress":
|
|
346
|
+
if progress_cb is not None:
|
|
347
|
+
progress_cb(
|
|
348
|
+
msg.get("current", 0),
|
|
349
|
+
msg.get("total"),
|
|
350
|
+
msg.get("message"),
|
|
351
|
+
)
|
|
352
|
+
continue
|
|
353
|
+
return msg
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
# ---------------------------------------------------------------------------
|
|
357
|
+
# Docker CLI helpers
|
|
358
|
+
# ---------------------------------------------------------------------------
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _check_docker_available() -> None:
|
|
362
|
+
if shutil.which("docker") is None:
|
|
363
|
+
raise WorkerError(
|
|
364
|
+
"'docker' command not found. "
|
|
365
|
+
"Install Docker from https://docs.docker.com/get-docker/"
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
async def _docker_wait(*args: str) -> tuple[int, str, str]:
|
|
370
|
+
"""Run ``docker <args>`` and wait for it to finish."""
|
|
371
|
+
proc = await asyncio.create_subprocess_exec(
|
|
372
|
+
"docker", *args,
|
|
373
|
+
stdout=asyncio.subprocess.PIPE,
|
|
374
|
+
stderr=asyncio.subprocess.PIPE,
|
|
375
|
+
)
|
|
376
|
+
stdout_bytes, stderr_bytes = await proc.communicate()
|
|
377
|
+
return (
|
|
378
|
+
proc.returncode or 0,
|
|
379
|
+
stdout_bytes.decode() if stdout_bytes else "",
|
|
380
|
+
stderr_bytes.decode() if stderr_bytes else "",
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
async def _image_exists(image: str) -> bool:
|
|
385
|
+
rc, _, _ = await _docker_wait("image", "inspect", image)
|
|
386
|
+
return rc == 0
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
async def _build_image(image: str) -> None:
|
|
390
|
+
rc, _stdout, stderr = await _docker_wait(
|
|
391
|
+
"build", "-t", image, str(_DOCKERFILE_DIR),
|
|
392
|
+
)
|
|
393
|
+
if rc != 0:
|
|
394
|
+
raise WorkerError(f"Failed to build Docker image '{image}':\n{stderr.strip()}")
|
|
395
|
+
logger.info("Docker image '%s' built successfully.", image)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
async def _container_exists(name: str) -> bool:
|
|
399
|
+
rc, _, _ = await _docker_wait("container", "inspect", name)
|
|
400
|
+
return rc == 0
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
async def _container_running(name: str) -> bool:
|
|
404
|
+
rc, stdout, _ = await _docker_wait(
|
|
405
|
+
"inspect", "-f", "{{.State.Running}}", name,
|
|
406
|
+
)
|
|
407
|
+
return rc == 0 and stdout.strip().lower() == "true"
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
async def _container_image(name: str) -> str | None:
|
|
411
|
+
"""Return the image (with tag) the container was created from, or None."""
|
|
412
|
+
rc, stdout, _ = await _docker_wait(
|
|
413
|
+
"inspect", "-f", "{{.Config.Image}}", name,
|
|
414
|
+
)
|
|
415
|
+
if rc != 0:
|
|
416
|
+
return None
|
|
417
|
+
image = stdout.strip()
|
|
418
|
+
return image or None
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
async def _mapped_port(container: str, guest_port: int) -> int:
|
|
422
|
+
rc, stdout, stderr = await _docker_wait("port", container, str(guest_port))
|
|
423
|
+
if rc != 0:
|
|
424
|
+
raise WorkerError(
|
|
425
|
+
f"Could not determine mapped port for {container}:{guest_port}: "
|
|
426
|
+
f"{stderr.strip()}"
|
|
427
|
+
)
|
|
428
|
+
for line in stdout.strip().splitlines():
|
|
429
|
+
parts = line.rsplit(":", 1)
|
|
430
|
+
if len(parts) == 2:
|
|
431
|
+
try:
|
|
432
|
+
return int(parts[1])
|
|
433
|
+
except ValueError:
|
|
434
|
+
continue
|
|
435
|
+
raise WorkerError(
|
|
436
|
+
f"Unexpected 'docker port' output for {container}:{guest_port}: "
|
|
437
|
+
f"{stdout.strip()!r}"
|
|
438
|
+
)
|