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,622 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Lightweight guest agent for offwork sandbox.
|
|
3
|
+
|
|
4
|
+
This script is deployed inside the Docker container and listens for
|
|
5
|
+
execution requests over TCP. It is completely self-contained (stdlib
|
|
6
|
+
only) so the container only needs a working Python ≥ 3.10 interpreter.
|
|
7
|
+
|
|
8
|
+
Wire protocol
|
|
9
|
+
-------------
|
|
10
|
+
Length-prefixed JSON (4-byte big-endian header + UTF-8 JSON payload),
|
|
11
|
+
identical to ``offwork.worker.sandbox._protocol``.
|
|
12
|
+
|
|
13
|
+
Request format::
|
|
14
|
+
|
|
15
|
+
{
|
|
16
|
+
"source": "<reconstructed Python source>",
|
|
17
|
+
"function_name": "f",
|
|
18
|
+
"args": [21],
|
|
19
|
+
"kwargs": {},
|
|
20
|
+
"owner_class": null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
Success response::
|
|
24
|
+
|
|
25
|
+
{"status": "ok", "result": <value>}
|
|
26
|
+
|
|
27
|
+
Error response::
|
|
28
|
+
|
|
29
|
+
{
|
|
30
|
+
"status": "error",
|
|
31
|
+
"error_type": "ValueError",
|
|
32
|
+
"error_message": "...",
|
|
33
|
+
"error_traceback": "..."
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
Usage::
|
|
37
|
+
|
|
38
|
+
python guest_agent.py [--host 0.0.0.0] [--port 9749]
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
import sys
|
|
42
|
+
import json
|
|
43
|
+
import enum
|
|
44
|
+
import uuid
|
|
45
|
+
import types
|
|
46
|
+
import struct
|
|
47
|
+
import base64
|
|
48
|
+
import pickle
|
|
49
|
+
import asyncio
|
|
50
|
+
import inspect
|
|
51
|
+
import pathlib
|
|
52
|
+
import argparse
|
|
53
|
+
import functools
|
|
54
|
+
import ipaddress
|
|
55
|
+
import collections
|
|
56
|
+
import traceback as tb_mod
|
|
57
|
+
import contextvars
|
|
58
|
+
import datetime as _dt
|
|
59
|
+
from decimal import Decimal
|
|
60
|
+
from fractions import Fraction
|
|
61
|
+
from typing import Any
|
|
62
|
+
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
# Wire helpers (duplicated from _protocol.py to stay dependency-free)
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
_HEADER = struct.Struct("!I")
|
|
68
|
+
|
|
69
|
+
_OBJECT_SENTINEL = "__offwork_obj__"
|
|
70
|
+
_BYTES_SENTINEL = "__offwork_bytes__"
|
|
71
|
+
_BUILTIN_SENTINEL = "__offwork_builtin__"
|
|
72
|
+
_TUPLE_SENTINEL = "__offwork_tuple__"
|
|
73
|
+
_DICT_SENTINEL = "__offwork_dict__"
|
|
74
|
+
_PICKLE_SENTINEL = "__offwork_pickle__"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _encode(obj: dict[str, Any]) -> bytes:
|
|
78
|
+
payload = json.dumps(obj, separators=(",", ":"), default=_json_default).encode()
|
|
79
|
+
return _HEADER.pack(len(payload)) + payload
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _json_default(o: Any) -> Any:
|
|
83
|
+
"""Fallback for objects the JSON encoder doesn't natively handle.
|
|
84
|
+
|
|
85
|
+
The host-side encoder pre-walks the tree, but the guest receives
|
|
86
|
+
*real* objects from user code (return values, exceptions) that need
|
|
87
|
+
the same sentinel treatment on the way back.
|
|
88
|
+
"""
|
|
89
|
+
return _to_jsonable(o)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
async def _recv(reader: asyncio.StreamReader) -> dict[str, Any]:
|
|
93
|
+
raw = await reader.readexactly(_HEADER.size)
|
|
94
|
+
(length,) = _HEADER.unpack(raw)
|
|
95
|
+
data = await reader.readexactly(length)
|
|
96
|
+
result: dict[str, Any] = json.loads(data)
|
|
97
|
+
return result
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
async def _send(writer: asyncio.StreamWriter, obj: dict[str, Any]) -> None:
|
|
101
|
+
writer.write(_encode(obj))
|
|
102
|
+
await writer.drain()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
# Sentinel encoding / decoding (mirrors offwork.core.task)
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
_FACTORY_BY_NAME: dict[str, Any] = {
|
|
110
|
+
"int": int, "list": list, "dict": dict, "set": set,
|
|
111
|
+
"tuple": tuple, "frozenset": frozenset, "str": str, "float": float,
|
|
112
|
+
"bytes": bytes, "bool": bool,
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
_PATH_CLASSES: dict[str, type[pathlib.PurePath]] = {
|
|
116
|
+
"PurePath": pathlib.PurePath,
|
|
117
|
+
"PurePosixPath": pathlib.PurePosixPath,
|
|
118
|
+
"PureWindowsPath": pathlib.PureWindowsPath,
|
|
119
|
+
"Path": pathlib.Path,
|
|
120
|
+
"PosixPath": pathlib.PurePosixPath,
|
|
121
|
+
"WindowsPath": pathlib.PureWindowsPath,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
_IP_CLASSES: dict[str, Any] = {
|
|
125
|
+
"IPv4Address": ipaddress.IPv4Address,
|
|
126
|
+
"IPv6Address": ipaddress.IPv6Address,
|
|
127
|
+
"IPv4Network": ipaddress.IPv4Network,
|
|
128
|
+
"IPv6Network": ipaddress.IPv6Network,
|
|
129
|
+
"IPv4Interface": ipaddress.IPv4Interface,
|
|
130
|
+
"IPv6Interface": ipaddress.IPv6Interface,
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _encode_factory(factory: Any) -> str | None:
|
|
135
|
+
if factory is None:
|
|
136
|
+
return None
|
|
137
|
+
name = getattr(factory, "__name__", None)
|
|
138
|
+
if isinstance(name, str) and _FACTORY_BY_NAME.get(name) is factory:
|
|
139
|
+
return name
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _encode_builtin(o: object) -> dict[str, Any] | None:
|
|
144
|
+
if isinstance(o, enum.Enum):
|
|
145
|
+
return {
|
|
146
|
+
"type": "enum",
|
|
147
|
+
"cls": type(o).__name__,
|
|
148
|
+
"name": o.name,
|
|
149
|
+
"value": _to_jsonable(o.value),
|
|
150
|
+
}
|
|
151
|
+
if isinstance(o, _dt.datetime):
|
|
152
|
+
return {"type": "datetime", "value": o.isoformat()}
|
|
153
|
+
if isinstance(o, _dt.date):
|
|
154
|
+
return {"type": "date", "value": o.isoformat()}
|
|
155
|
+
if isinstance(o, _dt.time):
|
|
156
|
+
return {"type": "time", "value": o.isoformat()}
|
|
157
|
+
if isinstance(o, _dt.timedelta):
|
|
158
|
+
return {"type": "timedelta", "value": o.total_seconds()}
|
|
159
|
+
if isinstance(o, Decimal):
|
|
160
|
+
return {"type": "decimal", "value": str(o)}
|
|
161
|
+
if isinstance(o, Fraction):
|
|
162
|
+
return {"type": "fraction", "value": [o.numerator, o.denominator]}
|
|
163
|
+
if isinstance(o, uuid.UUID):
|
|
164
|
+
return {"type": "uuid", "value": o.hex}
|
|
165
|
+
if isinstance(o, complex):
|
|
166
|
+
return {"type": "complex", "value": [o.real, o.imag]}
|
|
167
|
+
if isinstance(o, range):
|
|
168
|
+
return {"type": "range", "value": [o.start, o.stop, o.step]}
|
|
169
|
+
if isinstance(o, frozenset):
|
|
170
|
+
return {"type": "frozenset", "value": [_to_jsonable(v) for v in o]}
|
|
171
|
+
if isinstance(o, set):
|
|
172
|
+
return {"type": "set", "value": [_to_jsonable(v) for v in o]}
|
|
173
|
+
if isinstance(o, collections.deque):
|
|
174
|
+
return {
|
|
175
|
+
"type": "deque",
|
|
176
|
+
"value": [_to_jsonable(v) for v in o],
|
|
177
|
+
"maxlen": o.maxlen,
|
|
178
|
+
}
|
|
179
|
+
if isinstance(o, pathlib.PurePath):
|
|
180
|
+
return {"type": "path", "value": str(o), "cls": type(o).__name__}
|
|
181
|
+
if isinstance(
|
|
182
|
+
o,
|
|
183
|
+
(
|
|
184
|
+
ipaddress.IPv4Address, ipaddress.IPv6Address,
|
|
185
|
+
ipaddress.IPv4Network, ipaddress.IPv6Network,
|
|
186
|
+
ipaddress.IPv4Interface, ipaddress.IPv6Interface,
|
|
187
|
+
),
|
|
188
|
+
):
|
|
189
|
+
return {"type": "ipaddress", "cls": type(o).__name__, "value": str(o)}
|
|
190
|
+
return None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _extract_object_state(o: object) -> dict[str, Any] | None:
|
|
194
|
+
if hasattr(o, "__dict__"):
|
|
195
|
+
d = getattr(o, "__dict__", None)
|
|
196
|
+
if isinstance(d, dict):
|
|
197
|
+
return dict(d)
|
|
198
|
+
if hasattr(type(o), "__slots__"):
|
|
199
|
+
all_slots: set[str] = set()
|
|
200
|
+
for klass in type(o).__mro__:
|
|
201
|
+
all_slots.update(getattr(klass, "__slots__", ()))
|
|
202
|
+
all_slots -= {"__weakref__", "__dict__"}
|
|
203
|
+
return {
|
|
204
|
+
slot: getattr(o, slot)
|
|
205
|
+
for slot in sorted(all_slots)
|
|
206
|
+
if hasattr(o, slot)
|
|
207
|
+
}
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _to_jsonable(o: Any) -> Any:
|
|
212
|
+
if o is None or isinstance(o, (str, bool)):
|
|
213
|
+
return o
|
|
214
|
+
if isinstance(o, enum.Enum):
|
|
215
|
+
return {_BUILTIN_SENTINEL: _encode_builtin(o)}
|
|
216
|
+
if isinstance(o, (int, float)):
|
|
217
|
+
return o
|
|
218
|
+
if isinstance(o, (bytes, bytearray)):
|
|
219
|
+
return {
|
|
220
|
+
_BYTES_SENTINEL: {
|
|
221
|
+
"data": base64.b64encode(bytes(o)).decode("ascii"),
|
|
222
|
+
"type": type(o).__name__,
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if isinstance(o, memoryview):
|
|
226
|
+
return {
|
|
227
|
+
_BYTES_SENTINEL: {
|
|
228
|
+
"data": base64.b64encode(bytes(o)).decode("ascii"),
|
|
229
|
+
"type": "memoryview",
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if isinstance(o, tuple):
|
|
233
|
+
if hasattr(o, "_fields") and hasattr(o, "_asdict"):
|
|
234
|
+
return {
|
|
235
|
+
_BUILTIN_SENTINEL: {
|
|
236
|
+
"type": "namedtuple",
|
|
237
|
+
"cls": type(o).__name__,
|
|
238
|
+
"fields": list(o._fields),
|
|
239
|
+
"values": [_to_jsonable(v) for v in o],
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return {_TUPLE_SENTINEL: [_to_jsonable(v) for v in o]}
|
|
243
|
+
if isinstance(o, list):
|
|
244
|
+
return [_to_jsonable(v) for v in o]
|
|
245
|
+
if isinstance(o, dict):
|
|
246
|
+
if isinstance(o, collections.Counter):
|
|
247
|
+
return {
|
|
248
|
+
_BUILTIN_SENTINEL: {
|
|
249
|
+
"type": "counter",
|
|
250
|
+
"items": [[_to_jsonable(k), v] for k, v in o.items()],
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if isinstance(o, collections.OrderedDict):
|
|
254
|
+
return {
|
|
255
|
+
_BUILTIN_SENTINEL: {
|
|
256
|
+
"type": "ordereddict",
|
|
257
|
+
"items": [
|
|
258
|
+
[_to_jsonable(k), _to_jsonable(v)] for k, v in o.items()
|
|
259
|
+
],
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if isinstance(o, collections.defaultdict):
|
|
263
|
+
return {
|
|
264
|
+
_BUILTIN_SENTINEL: {
|
|
265
|
+
"type": "defaultdict",
|
|
266
|
+
"factory": _encode_factory(o.default_factory),
|
|
267
|
+
"items": [
|
|
268
|
+
[_to_jsonable(k), _to_jsonable(v)] for k, v in o.items()
|
|
269
|
+
],
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
if all(isinstance(k, str) for k in o):
|
|
273
|
+
return {k: _to_jsonable(v) for k, v in o.items()}
|
|
274
|
+
return {
|
|
275
|
+
_DICT_SENTINEL: [
|
|
276
|
+
[_to_jsonable(k), _to_jsonable(v)] for k, v in o.items()
|
|
277
|
+
]
|
|
278
|
+
}
|
|
279
|
+
builtin = _encode_builtin(o)
|
|
280
|
+
if builtin is not None:
|
|
281
|
+
return {_BUILTIN_SENTINEL: builtin}
|
|
282
|
+
state = _extract_object_state(o)
|
|
283
|
+
if state is not None:
|
|
284
|
+
return {
|
|
285
|
+
_OBJECT_SENTINEL: {
|
|
286
|
+
"class": type(o).__name__,
|
|
287
|
+
"state": {k: _to_jsonable(v) for k, v in state.items()},
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
try:
|
|
291
|
+
data = pickle.dumps(o)
|
|
292
|
+
except Exception as exc:
|
|
293
|
+
raise TypeError(
|
|
294
|
+
f"Object of type {type(o).__name__} is not serializable: {exc}"
|
|
295
|
+
) from exc
|
|
296
|
+
return {_PICKLE_SENTINEL: base64.b64encode(data).decode("ascii")}
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _decode_builtin(info: dict[str, Any], namespace: dict[str, Any]) -> Any:
|
|
300
|
+
kind = info.get("type")
|
|
301
|
+
raw: Any = info.get("value")
|
|
302
|
+
if kind == "datetime":
|
|
303
|
+
return _dt.datetime.fromisoformat(str(raw))
|
|
304
|
+
if kind == "date":
|
|
305
|
+
return _dt.date.fromisoformat(str(raw))
|
|
306
|
+
if kind == "time":
|
|
307
|
+
return _dt.time.fromisoformat(str(raw))
|
|
308
|
+
if kind == "timedelta":
|
|
309
|
+
return _dt.timedelta(seconds=float(raw))
|
|
310
|
+
if kind == "decimal":
|
|
311
|
+
return Decimal(str(raw))
|
|
312
|
+
if kind == "fraction":
|
|
313
|
+
return Fraction(int(raw[0]), int(raw[1]))
|
|
314
|
+
if kind == "uuid":
|
|
315
|
+
return uuid.UUID(hex=str(raw))
|
|
316
|
+
if kind == "complex":
|
|
317
|
+
return complex(raw[0], raw[1])
|
|
318
|
+
if kind == "range":
|
|
319
|
+
return range(raw[0], raw[1], raw[2])
|
|
320
|
+
if kind == "set":
|
|
321
|
+
return {_resolve(v, namespace) for v in raw}
|
|
322
|
+
if kind == "frozenset":
|
|
323
|
+
return frozenset(_resolve(v, namespace) for v in raw)
|
|
324
|
+
if kind == "deque":
|
|
325
|
+
return collections.deque(
|
|
326
|
+
(_resolve(v, namespace) for v in raw),
|
|
327
|
+
maxlen=info.get("maxlen"),
|
|
328
|
+
)
|
|
329
|
+
if kind == "counter":
|
|
330
|
+
return collections.Counter({
|
|
331
|
+
_resolve(k, namespace): v for k, v in info["items"]
|
|
332
|
+
})
|
|
333
|
+
if kind == "ordereddict":
|
|
334
|
+
return collections.OrderedDict(
|
|
335
|
+
(_resolve(k, namespace), _resolve(v, namespace))
|
|
336
|
+
for k, v in info["items"]
|
|
337
|
+
)
|
|
338
|
+
if kind == "defaultdict":
|
|
339
|
+
factory = _FACTORY_BY_NAME.get(info.get("factory") or "")
|
|
340
|
+
dd: collections.defaultdict[Any, Any] = collections.defaultdict(factory)
|
|
341
|
+
for k, v in info["items"]:
|
|
342
|
+
dd[_resolve(k, namespace)] = _resolve(v, namespace)
|
|
343
|
+
return dd
|
|
344
|
+
if kind == "namedtuple":
|
|
345
|
+
cls = namespace.get(info["cls"])
|
|
346
|
+
values = [_resolve(v, namespace) for v in info["values"]]
|
|
347
|
+
if cls is None:
|
|
348
|
+
return tuple(values)
|
|
349
|
+
return cls(*values)
|
|
350
|
+
if kind == "enum":
|
|
351
|
+
cls = namespace.get(info["cls"])
|
|
352
|
+
if cls is None:
|
|
353
|
+
return _resolve(raw, namespace)
|
|
354
|
+
try:
|
|
355
|
+
return cls[info["name"]]
|
|
356
|
+
except KeyError:
|
|
357
|
+
return cls(_resolve(raw, namespace))
|
|
358
|
+
if kind == "path":
|
|
359
|
+
cls = _PATH_CLASSES.get(info.get("cls", ""), pathlib.PurePath)
|
|
360
|
+
try:
|
|
361
|
+
return cls(str(raw))
|
|
362
|
+
except (NotImplementedError, TypeError):
|
|
363
|
+
return pathlib.PurePath(str(raw))
|
|
364
|
+
if kind == "ipaddress":
|
|
365
|
+
ip_cls = _IP_CLASSES.get(info.get("cls", ""))
|
|
366
|
+
if ip_cls is None:
|
|
367
|
+
return str(raw)
|
|
368
|
+
return ip_cls(str(raw))
|
|
369
|
+
raise ValueError(f"Unknown builtin sentinel type: {kind!r}")
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _reconstruct_object(info: dict[str, Any], namespace: dict[str, Any]) -> Any:
|
|
373
|
+
cls = namespace.get(info["class"])
|
|
374
|
+
if cls is None:
|
|
375
|
+
return {_OBJECT_SENTINEL: info}
|
|
376
|
+
obj = cls.__new__(cls)
|
|
377
|
+
state = {k: _resolve(v, namespace) for k, v in info.get("state", {}).items()}
|
|
378
|
+
if hasattr(obj, "__dict__"):
|
|
379
|
+
obj.__dict__.update(state)
|
|
380
|
+
else:
|
|
381
|
+
for key, val in state.items():
|
|
382
|
+
object.__setattr__(obj, key, val)
|
|
383
|
+
return obj
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _resolve(value: Any, namespace: dict[str, Any]) -> Any:
|
|
387
|
+
if isinstance(value, list):
|
|
388
|
+
return [_resolve(v, namespace) for v in value]
|
|
389
|
+
if not isinstance(value, dict):
|
|
390
|
+
return value
|
|
391
|
+
if len(value) == 1:
|
|
392
|
+
if _OBJECT_SENTINEL in value:
|
|
393
|
+
return _reconstruct_object(value[_OBJECT_SENTINEL], namespace)
|
|
394
|
+
if _BYTES_SENTINEL in value:
|
|
395
|
+
info = value[_BYTES_SENTINEL]
|
|
396
|
+
raw = base64.b64decode(info["data"])
|
|
397
|
+
kind = info.get("type")
|
|
398
|
+
if kind == "bytearray":
|
|
399
|
+
return bytearray(raw)
|
|
400
|
+
if kind == "memoryview":
|
|
401
|
+
return memoryview(raw)
|
|
402
|
+
return raw
|
|
403
|
+
if _BUILTIN_SENTINEL in value:
|
|
404
|
+
return _decode_builtin(value[_BUILTIN_SENTINEL], namespace)
|
|
405
|
+
if _TUPLE_SENTINEL in value:
|
|
406
|
+
return tuple(_resolve(v, namespace) for v in value[_TUPLE_SENTINEL])
|
|
407
|
+
if _DICT_SENTINEL in value:
|
|
408
|
+
return {
|
|
409
|
+
_resolve(k, namespace): _resolve(v, namespace)
|
|
410
|
+
for k, v in value[_DICT_SENTINEL]
|
|
411
|
+
}
|
|
412
|
+
if _PICKLE_SENTINEL in value:
|
|
413
|
+
return pickle.loads(base64.b64decode(value[_PICKLE_SENTINEL]))
|
|
414
|
+
return {k: _resolve(v, namespace) for k, v in value.items()}
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
# ---------------------------------------------------------------------------
|
|
418
|
+
# Execution engine
|
|
419
|
+
# ---------------------------------------------------------------------------
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _extract_callable(
|
|
423
|
+
namespace: dict[str, Any],
|
|
424
|
+
function_name: str,
|
|
425
|
+
owner_class: str | None,
|
|
426
|
+
) -> Any:
|
|
427
|
+
if owner_class:
|
|
428
|
+
class_name = owner_class.rsplit(".", 1)[-1]
|
|
429
|
+
cls = namespace.get(class_name)
|
|
430
|
+
if cls is None:
|
|
431
|
+
raise RuntimeError(f"Class '{class_name}' not found")
|
|
432
|
+
func = getattr(cls, function_name, None)
|
|
433
|
+
if func is None:
|
|
434
|
+
raise RuntimeError(
|
|
435
|
+
f"Method '{function_name}' not found on '{class_name}'"
|
|
436
|
+
)
|
|
437
|
+
return func
|
|
438
|
+
func = namespace.get(function_name)
|
|
439
|
+
if func is None:
|
|
440
|
+
raise RuntimeError(f"Function '{function_name}' not found")
|
|
441
|
+
return func
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _install_offwork_shim(
|
|
445
|
+
writer: asyncio.StreamWriter | None,
|
|
446
|
+
) -> tuple[Any, ...]:
|
|
447
|
+
"""Install a fake ``offwork`` package so ``from offwork import progress`` works.
|
|
448
|
+
|
|
449
|
+
The shim's ``progress()`` writes a ``{"status": "progress", ...}``
|
|
450
|
+
frame directly to *writer*. When *writer* is ``None`` (unit tests),
|
|
451
|
+
progress calls are silently ignored.
|
|
452
|
+
|
|
453
|
+
Returns the previous ``sys.modules`` entries so they can be restored.
|
|
454
|
+
"""
|
|
455
|
+
|
|
456
|
+
def _progress(
|
|
457
|
+
_value: float,
|
|
458
|
+
_total: int | None = None,
|
|
459
|
+
/,
|
|
460
|
+
*,
|
|
461
|
+
message: str | None = None,
|
|
462
|
+
) -> None:
|
|
463
|
+
if writer is None:
|
|
464
|
+
return
|
|
465
|
+
msg: dict[str, Any] = {"status": "progress", "current": _value}
|
|
466
|
+
if _total is not None:
|
|
467
|
+
msg["total"] = _total
|
|
468
|
+
if message is not None:
|
|
469
|
+
msg["message"] = message
|
|
470
|
+
# Synchronous write — fine from the event-loop thread and from
|
|
471
|
+
# executor threads via loop.call_soon_threadsafe (see below).
|
|
472
|
+
writer.write(_encode(msg))
|
|
473
|
+
|
|
474
|
+
# Build a minimal offwork package hierarchy.
|
|
475
|
+
fake = types.ModuleType("offwork")
|
|
476
|
+
fake.progress = _progress # type: ignore[attr-defined]
|
|
477
|
+
fake_core = types.ModuleType("offwork.core")
|
|
478
|
+
fake_core_progress = types.ModuleType("offwork.core.progress")
|
|
479
|
+
fake_core_progress.progress = _progress # type: ignore[attr-defined]
|
|
480
|
+
fake.core = fake_core # type: ignore[attr-defined]
|
|
481
|
+
fake_core.progress = fake_core_progress # type: ignore[attr-defined]
|
|
482
|
+
|
|
483
|
+
saved = (
|
|
484
|
+
sys.modules.get("offwork"),
|
|
485
|
+
sys.modules.get("offwork.core"),
|
|
486
|
+
sys.modules.get("offwork.core.progress"),
|
|
487
|
+
)
|
|
488
|
+
sys.modules["offwork"] = fake
|
|
489
|
+
sys.modules["offwork.core"] = fake_core
|
|
490
|
+
sys.modules["offwork.core.progress"] = fake_core_progress
|
|
491
|
+
return saved
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _uninstall_offwork_shim(saved: tuple[Any, ...]) -> None:
|
|
495
|
+
for key, prev in zip(
|
|
496
|
+
("offwork", "offwork.core", "offwork.core.progress"), saved
|
|
497
|
+
):
|
|
498
|
+
if prev is None:
|
|
499
|
+
sys.modules.pop(key, None)
|
|
500
|
+
else:
|
|
501
|
+
sys.modules[key] = prev
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
async def _execute_request(
|
|
505
|
+
req: dict[str, Any],
|
|
506
|
+
writer: asyncio.StreamWriter | None = None,
|
|
507
|
+
) -> dict[str, Any]:
|
|
508
|
+
"""Execute a single request and return a response dict.
|
|
509
|
+
|
|
510
|
+
When *writer* is provided, ``offwork.progress()`` calls inside the
|
|
511
|
+
user function are forwarded as ``{"status": "progress", ...}``
|
|
512
|
+
frames over the wire before the final ``ok`` / ``error`` response.
|
|
513
|
+
"""
|
|
514
|
+
saved = _install_offwork_shim(writer)
|
|
515
|
+
try:
|
|
516
|
+
source: str = req["source"]
|
|
517
|
+
function_name: str = req["function_name"]
|
|
518
|
+
raw_args: list[Any] = req.get("args", [])
|
|
519
|
+
raw_kwargs: dict[str, Any] = req.get("kwargs", {})
|
|
520
|
+
owner_class: str | None = req.get("owner_class")
|
|
521
|
+
|
|
522
|
+
# Compile and exec
|
|
523
|
+
code = compile(source, f"<offwork-sandbox:{function_name}>", "exec")
|
|
524
|
+
namespace: dict[str, Any] = {}
|
|
525
|
+
exec(code, namespace) # noqa: S102
|
|
526
|
+
|
|
527
|
+
# Resolve serialised object arguments
|
|
528
|
+
args = tuple(_resolve(a, namespace) for a in raw_args)
|
|
529
|
+
kwargs = {k: _resolve(v, namespace) for k, v in raw_kwargs.items()}
|
|
530
|
+
|
|
531
|
+
func = _extract_callable(namespace, function_name, owner_class)
|
|
532
|
+
|
|
533
|
+
if inspect.iscoroutinefunction(func):
|
|
534
|
+
result = await func(*args, **kwargs)
|
|
535
|
+
else:
|
|
536
|
+
# Run sync functions in an executor so the event loop stays
|
|
537
|
+
# free to flush buffered progress writes.
|
|
538
|
+
loop = asyncio.get_running_loop()
|
|
539
|
+
ctx = contextvars.copy_context()
|
|
540
|
+
result = await loop.run_in_executor(
|
|
541
|
+
None, ctx.run, functools.partial(func, *args, **kwargs),
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
# Flush any buffered progress frames before the final response.
|
|
545
|
+
if writer is not None:
|
|
546
|
+
await writer.drain()
|
|
547
|
+
|
|
548
|
+
return {"status": "ok", "result": _to_jsonable(result)}
|
|
549
|
+
|
|
550
|
+
except Exception as exc:
|
|
551
|
+
return {
|
|
552
|
+
"status": "error",
|
|
553
|
+
"error_type": type(exc).__name__,
|
|
554
|
+
"error_message": str(exc),
|
|
555
|
+
"error_traceback": "".join(tb_mod.format_exception(exc)),
|
|
556
|
+
}
|
|
557
|
+
finally:
|
|
558
|
+
_uninstall_offwork_shim(saved)
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
# ---------------------------------------------------------------------------
|
|
562
|
+
# TCP server
|
|
563
|
+
# ---------------------------------------------------------------------------
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
async def _handle_client(
|
|
567
|
+
reader: asyncio.StreamReader,
|
|
568
|
+
writer: asyncio.StreamWriter,
|
|
569
|
+
) -> None:
|
|
570
|
+
peer = writer.get_extra_info("peername")
|
|
571
|
+
print(f"[guest-agent] connection from {peer}", flush=True)
|
|
572
|
+
try:
|
|
573
|
+
while True:
|
|
574
|
+
req = await _recv(reader)
|
|
575
|
+
# Cheap liveness handshake used by the host to confirm the
|
|
576
|
+
# in-container agent is actually accepting requests (a TCP
|
|
577
|
+
# connection alone isn't sufficient: on Linux docker-proxy
|
|
578
|
+
# accepts the connection on the host port even before the
|
|
579
|
+
# guest agent process has started listening).
|
|
580
|
+
if req.get("op") == "ping":
|
|
581
|
+
await _send(writer, {"status": "pong"})
|
|
582
|
+
continue
|
|
583
|
+
resp = await _execute_request(req, writer)
|
|
584
|
+
await _send(writer, resp)
|
|
585
|
+
except (asyncio.IncompleteReadError, ConnectionError, OSError):
|
|
586
|
+
pass
|
|
587
|
+
finally:
|
|
588
|
+
writer.close()
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
async def serve(host: str, port: int) -> None:
|
|
592
|
+
server = await asyncio.start_server(_handle_client, host, port)
|
|
593
|
+
addrs = ", ".join(str(s.getsockname()) for s in server.sockets)
|
|
594
|
+
print(f"[guest-agent] listening on {addrs}", flush=True)
|
|
595
|
+
async with server:
|
|
596
|
+
await server.serve_forever()
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
# ---------------------------------------------------------------------------
|
|
600
|
+
# CLI
|
|
601
|
+
# ---------------------------------------------------------------------------
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def main() -> None:
|
|
605
|
+
parser = argparse.ArgumentParser(description="offwork sandbox guest agent")
|
|
606
|
+
parser.add_argument("--host", default="0.0.0.0", help="Bind address")
|
|
607
|
+
parser.add_argument("--port", type=int, default=9749, help="Bind port")
|
|
608
|
+
args = parser.parse_args()
|
|
609
|
+
|
|
610
|
+
print(
|
|
611
|
+
f"[guest-agent] starting on {args.host}:{args.port} "
|
|
612
|
+
f"(Python {sys.version})",
|
|
613
|
+
flush=True,
|
|
614
|
+
)
|
|
615
|
+
try:
|
|
616
|
+
asyncio.run(serve(args.host, args.port))
|
|
617
|
+
except KeyboardInterrupt:
|
|
618
|
+
print("[guest-agent] shutting down", flush=True)
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
if __name__ == "__main__":
|
|
622
|
+
main()
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Schedule handle for recurring task management."""
|
|
2
|
+
|
|
3
|
+
from offwork.worker.backends.base import Backend
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ScheduleHandle:
|
|
7
|
+
"""Handle for a recurring schedule, allowing cancellation."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, schedule_id: str, backend: Backend) -> None:
|
|
10
|
+
self._schedule_id = schedule_id
|
|
11
|
+
self._backend = backend
|
|
12
|
+
|
|
13
|
+
@property
|
|
14
|
+
def schedule_id(self) -> str:
|
|
15
|
+
return self._schedule_id
|
|
16
|
+
|
|
17
|
+
async def cancel(self) -> None:
|
|
18
|
+
"""Cancel this recurring schedule.
|
|
19
|
+
|
|
20
|
+
The worker will stop re-enqueuing new occurrences after the
|
|
21
|
+
current one completes.
|
|
22
|
+
"""
|
|
23
|
+
await self._backend.cancel_schedule(self._schedule_id)
|
|
24
|
+
|
|
25
|
+
def __repr__(self) -> str:
|
|
26
|
+
return f"ScheduleHandle(schedule_id={self._schedule_id!r})"
|