flashnode 0.2.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.
- flashnode/__init__.py +7 -0
- flashnode/agent/__init__.py +6 -0
- flashnode/agent/cli.py +285 -0
- flashnode/agent/daemon.py +108 -0
- flashnode/agent/kube.py +41 -0
- flashnode/artifacts/__init__.py +5 -0
- flashnode/benchmark/__init__.py +100 -0
- flashnode/config/__init__.py +103 -0
- flashnode/executor/__init__.py +27 -0
- flashnode/executor/archives.py +290 -0
- flashnode/executor/argv_runner.py +114 -0
- flashnode/executor/client.py +201 -0
- flashnode/executor/docker_runner.py +108 -0
- flashnode/executor/hardening.py +141 -0
- flashnode/executor/images.py +161 -0
- flashnode/executor/loop.py +363 -0
- flashnode/executor/runner.py +109 -0
- flashnode/identity/__init__.py +5 -0
- flashnode/identity/credentials.py +70 -0
- flashnode/identity/enrol.py +179 -0
- flashnode/identity/store.py +48 -0
- flashnode/inventory/__init__.py +5 -0
- flashnode/inventory/capabilities.py +117 -0
- flashnode/telemetry/__init__.py +79 -0
- flashnode-0.2.0.dist-info/METADATA +160 -0
- flashnode-0.2.0.dist-info/RECORD +30 -0
- flashnode-0.2.0.dist-info/WHEEL +5 -0
- flashnode-0.2.0.dist-info/entry_points.txt +2 -0
- flashnode-0.2.0.dist-info/licenses/LICENSE +202 -0
- flashnode-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""Unpacking an untrusted archive onto a volunteer's machine.
|
|
2
|
+
|
|
3
|
+
This is the one place in flashnode that writes attacker-chosen *paths* to a
|
|
4
|
+
host owner's disk. The bytes arrive as a task input: any signed-in user
|
|
5
|
+
points the cloud API at any repo they like, the API stages the original
|
|
6
|
+
archive as an ``artifact://`` input, and this agent downloads it. Member
|
|
7
|
+
names, symlink targets, member types and declared sizes are all chosen by
|
|
8
|
+
whoever submitted the job — never by us.
|
|
9
|
+
|
|
10
|
+
The cloud API has an equivalent guard on its own copy of the same bytes
|
|
11
|
+
(``flashml_cloud_api.repo.extract_safely``). That one is *not* reusable
|
|
12
|
+
here: flashnode may import ``flashruntime.protocol`` and nothing else (see
|
|
13
|
+
AGENTS.md hard rule 2), and a private control-plane module must never be a
|
|
14
|
+
dependency of the public host agent. So the guard is deliberately
|
|
15
|
+
duplicated, and the drift that duplication invites is caught by a parity
|
|
16
|
+
test in the workspace ``e2e/`` suite — the only place allowed to import
|
|
17
|
+
both repos — which feeds one attack corpus to both extractors.
|
|
18
|
+
|
|
19
|
+
What is refused, and why each one matters on someone else's laptop:
|
|
20
|
+
|
|
21
|
+
- **zip-slip**, relative (``../../.ssh/authorized_keys``) or absolute
|
|
22
|
+
(``/etc/cron.d/evil``). Both are caught by the same check rather than
|
|
23
|
+
special-cased, because a naive join-then-compare misses the absolute
|
|
24
|
+
form entirely: ``Path("/work/inputs/code") / "/etc/evil"`` is
|
|
25
|
+
``/etc/evil`` — pathlib silently discards the left operand when the
|
|
26
|
+
right side is absolute. Resolving first and then requiring containment
|
|
27
|
+
via ``relative_to`` has no such blind spot.
|
|
28
|
+
- **escaping symlinks**. A member linking to ``/`` or ``../..`` turns a
|
|
29
|
+
later, individually-innocent write into an escape. Contained symlinks
|
|
30
|
+
are validated and then *not created at all*: nothing downstream needs a
|
|
31
|
+
link, and not materialising one removes the whole TOCTOU class (member
|
|
32
|
+
``a`` is a symlink to a safe dir; member ``a/b`` then resolves through
|
|
33
|
+
it) for free.
|
|
34
|
+
- **decompression bombs**, checked *during* extraction and in bounded
|
|
35
|
+
chunks. A 40 KB archive can hold petabytes; a total measured after the
|
|
36
|
+
fact has already filled the disk it was meant to protect.
|
|
37
|
+
- **member count**, because millions of empty files exhaust inodes without
|
|
38
|
+
ever tripping a byte cap.
|
|
39
|
+
- **device, fifo and hard-link members**, which have no legitimate place in
|
|
40
|
+
a code archive and every place in an attack.
|
|
41
|
+
|
|
42
|
+
Nothing is executed, nothing is made executable (members are rewritten as
|
|
43
|
+
plain files with the process umask, so an archive cannot smuggle in a
|
|
44
|
+
setuid bit), and on any refusal everything already written is removed —
|
|
45
|
+
a rejected archive must not leave half a payload behind for the next stage
|
|
46
|
+
to trip over.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
from __future__ import annotations
|
|
50
|
+
|
|
51
|
+
import shutil
|
|
52
|
+
import stat
|
|
53
|
+
import tarfile
|
|
54
|
+
import zipfile
|
|
55
|
+
from pathlib import Path, PurePosixPath
|
|
56
|
+
|
|
57
|
+
#: Read/write in bounded chunks so an oversized member is caught partway
|
|
58
|
+
#: through rather than only once it is fully on disk.
|
|
59
|
+
_CHUNK_SIZE = 1 << 16 # 64 KiB
|
|
60
|
+
|
|
61
|
+
#: Defaults for a task input. Generous enough for a real repo (the cloud
|
|
62
|
+
#: API caps an extracted repo at 128 MiB), small enough that a hostile job
|
|
63
|
+
#: cannot fill a volunteer's disk before the cap fires.
|
|
64
|
+
DEFAULT_MAX_BYTES = 128 * 1024 * 1024
|
|
65
|
+
DEFAULT_MAX_MEMBERS = 20_000
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ArchiveError(Exception):
|
|
69
|
+
"""Raised for any archive this agent refuses to unpack: a malformed
|
|
70
|
+
container, a member whose path or symlink target escapes the
|
|
71
|
+
destination, an unsupported member type, or a cap (bytes, members)
|
|
72
|
+
exceeded. The executor turns it into a task failure, never into a
|
|
73
|
+
crashed agent — a hostile input must cost the submitter their task, not
|
|
74
|
+
the volunteer their node."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _is_within(path: Path, root: Path) -> bool:
|
|
78
|
+
try:
|
|
79
|
+
path.relative_to(root)
|
|
80
|
+
return True
|
|
81
|
+
except ValueError:
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _safe_target(name: str, dest_root: Path) -> Path:
|
|
86
|
+
"""Resolve a member name against the extraction root, refusing anything
|
|
87
|
+
that lands outside it. Handles the relative and the absolute escape with
|
|
88
|
+
one check — see the module docstring on why joining first is not enough
|
|
89
|
+
on its own but resolving after it is."""
|
|
90
|
+
resolved = (dest_root / name).resolve()
|
|
91
|
+
if not _is_within(resolved, dest_root):
|
|
92
|
+
raise ArchiveError(f"refusing archive member with unsafe path: {name!r}")
|
|
93
|
+
return resolved
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _check_symlink(member_path: Path, link_name: str, dest_root: Path) -> None:
|
|
97
|
+
"""A symlink target resolves against the link's own directory unless it
|
|
98
|
+
is absolute — standard symlink semantics, and the semantics an attacker
|
|
99
|
+
is counting on."""
|
|
100
|
+
if PurePosixPath(link_name).is_absolute():
|
|
101
|
+
target = Path(link_name).resolve()
|
|
102
|
+
else:
|
|
103
|
+
target = (member_path.parent / link_name).resolve()
|
|
104
|
+
if not _is_within(target, dest_root):
|
|
105
|
+
raise ArchiveError(
|
|
106
|
+
f"refusing symlink member {member_path.name!r} whose target "
|
|
107
|
+
f"{link_name!r} escapes the destination"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _top_level(name: str) -> str | None:
|
|
112
|
+
"""First path component of a member name, or None for a name that has
|
|
113
|
+
none (``.``/``./``, which GNU tar emits for the archive root). The
|
|
114
|
+
None case is not hypothetical: indexing ``parts[0]`` unguarded turns a
|
|
115
|
+
one-member archive into an IndexError instead of a clean refusal."""
|
|
116
|
+
parts = PurePosixPath(name).parts
|
|
117
|
+
return parts[0] if parts else None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _write_stream(src, out_path: Path, budget: list[int]) -> None:
|
|
121
|
+
"""Copy a member's bytes in chunks, decrementing a shared budget as we
|
|
122
|
+
go. ``budget`` is a one-element list so the running total survives
|
|
123
|
+
across members without a class or a global."""
|
|
124
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
125
|
+
with open(out_path, "wb") as out:
|
|
126
|
+
while True:
|
|
127
|
+
chunk = src.read(_CHUNK_SIZE)
|
|
128
|
+
if not chunk:
|
|
129
|
+
return
|
|
130
|
+
budget[0] -= len(chunk)
|
|
131
|
+
if budget[0] < 0:
|
|
132
|
+
raise ArchiveError(
|
|
133
|
+
"archive exceeds the uncompressed size cap during "
|
|
134
|
+
"extraction — refusing the rest"
|
|
135
|
+
)
|
|
136
|
+
out.write(chunk)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _extract_tar(src: Path, dest_root: Path, budget: list[int], max_members: int) -> set[str]:
|
|
140
|
+
try:
|
|
141
|
+
tar = tarfile.open(src, mode="r:*")
|
|
142
|
+
except tarfile.TarError as exc:
|
|
143
|
+
raise ArchiveError(f"not a valid tar archive: {exc}") from exc
|
|
144
|
+
|
|
145
|
+
tops: set[str] = set()
|
|
146
|
+
seen = 0
|
|
147
|
+
with tar:
|
|
148
|
+
for member in tar:
|
|
149
|
+
seen += 1
|
|
150
|
+
if seen > max_members:
|
|
151
|
+
raise ArchiveError(
|
|
152
|
+
f"archive has more than {max_members} members — refusing the rest"
|
|
153
|
+
)
|
|
154
|
+
top = _top_level(member.name)
|
|
155
|
+
if top is not None:
|
|
156
|
+
tops.add(top)
|
|
157
|
+
member_path = _safe_target(member.name, dest_root)
|
|
158
|
+
|
|
159
|
+
if member.issym():
|
|
160
|
+
_check_symlink(member_path, member.linkname, dest_root)
|
|
161
|
+
continue # validated, deliberately not created — see docstring
|
|
162
|
+
if member.islnk():
|
|
163
|
+
raise ArchiveError(
|
|
164
|
+
f"refusing hard-link member: {member.name!r}"
|
|
165
|
+
)
|
|
166
|
+
if member.isdir():
|
|
167
|
+
member_path.mkdir(parents=True, exist_ok=True)
|
|
168
|
+
continue
|
|
169
|
+
if not member.isfile():
|
|
170
|
+
# chr/blk/fifo — nothing a code archive needs.
|
|
171
|
+
raise ArchiveError(
|
|
172
|
+
f"refusing archive member of unsupported type: {member.name!r}"
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
stream = tar.extractfile(member)
|
|
176
|
+
if stream is None:
|
|
177
|
+
continue
|
|
178
|
+
_write_stream(stream, member_path, budget)
|
|
179
|
+
return tops
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _extract_zip(src: Path, dest_root: Path, budget: list[int], max_members: int) -> set[str]:
|
|
183
|
+
try:
|
|
184
|
+
zf = zipfile.ZipFile(src)
|
|
185
|
+
except zipfile.BadZipFile as exc:
|
|
186
|
+
raise ArchiveError(f"not a valid zip archive: {exc}") from exc
|
|
187
|
+
|
|
188
|
+
tops: set[str] = set()
|
|
189
|
+
with zf:
|
|
190
|
+
infos = zf.infolist()
|
|
191
|
+
if len(infos) > max_members:
|
|
192
|
+
raise ArchiveError(
|
|
193
|
+
f"archive has more than {max_members} members — refusing it"
|
|
194
|
+
)
|
|
195
|
+
for info in infos:
|
|
196
|
+
top = _top_level(info.filename)
|
|
197
|
+
if top is not None:
|
|
198
|
+
tops.add(top)
|
|
199
|
+
member_path = _safe_target(info.filename, dest_root)
|
|
200
|
+
|
|
201
|
+
# Zip stores unix mode in the high 16 bits of external_attr, but
|
|
202
|
+
# only when it was created on a unix host AND the writer bothered
|
|
203
|
+
# to set the file-type bits — plenty of legitimate zips carry
|
|
204
|
+
# bare permission bits with no S_IFREG. Treating a missing type
|
|
205
|
+
# as "unsupported" would refuse ordinary archives, so the type
|
|
206
|
+
# check only runs when there is a type to check.
|
|
207
|
+
mode = info.external_attr >> 16
|
|
208
|
+
if info.create_system == 3 and stat.S_IFMT(mode):
|
|
209
|
+
if stat.S_ISLNK(mode):
|
|
210
|
+
# A zip symlink stores its target as the member's own
|
|
211
|
+
# content, so reading it is a read of attacker bytes:
|
|
212
|
+
# cap it rather than pulling an arbitrary "target" into
|
|
213
|
+
# memory to validate a link we are not going to create.
|
|
214
|
+
if info.file_size > 4096:
|
|
215
|
+
raise ArchiveError(
|
|
216
|
+
f"refusing symlink member {info.filename!r} with an "
|
|
217
|
+
f"implausible {info.file_size}-byte target"
|
|
218
|
+
)
|
|
219
|
+
_check_symlink(
|
|
220
|
+
member_path,
|
|
221
|
+
zf.read(info).decode("utf-8", errors="replace"),
|
|
222
|
+
dest_root,
|
|
223
|
+
)
|
|
224
|
+
continue
|
|
225
|
+
if not (stat.S_ISREG(mode) or stat.S_ISDIR(mode)):
|
|
226
|
+
raise ArchiveError(
|
|
227
|
+
f"refusing archive member of unsupported type: "
|
|
228
|
+
f"{info.filename!r}"
|
|
229
|
+
)
|
|
230
|
+
if info.is_dir():
|
|
231
|
+
member_path.mkdir(parents=True, exist_ok=True)
|
|
232
|
+
continue
|
|
233
|
+
with zf.open(info) as stream:
|
|
234
|
+
_write_stream(stream, member_path, budget)
|
|
235
|
+
return tops
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def extract_archive_safely(
|
|
239
|
+
src: Path,
|
|
240
|
+
dest: Path,
|
|
241
|
+
max_bytes: int = DEFAULT_MAX_BYTES,
|
|
242
|
+
max_members: int = DEFAULT_MAX_MEMBERS,
|
|
243
|
+
) -> Path:
|
|
244
|
+
"""Unpack the archive at ``src`` under ``dest`` and return the path the
|
|
245
|
+
*content* actually lives at.
|
|
246
|
+
|
|
247
|
+
That return value is not always ``dest``: an archive whose members all
|
|
248
|
+
sit under a single top-level directory — which is exactly what GitHub's
|
|
249
|
+
``codeload`` tarballs are, wrapping every repo in ``owner-name-<sha>/``
|
|
250
|
+
— has that wrapper as its content root. Returning it is what lets the
|
|
251
|
+
caller present the repo at the path the job's argv was compiled
|
|
252
|
+
against; returning ``dest`` blindly would leave every such job pointing
|
|
253
|
+
one directory too high, which is the same "file not found" the missing
|
|
254
|
+
unpacking caused in the first place.
|
|
255
|
+
|
|
256
|
+
The container format is detected from the bytes, never from the file
|
|
257
|
+
name: an attacker supplies the name, so trusting it would let them pick
|
|
258
|
+
which parser reads their bytes. Supported: tar with any of gzip/bzip2/xz
|
|
259
|
+
compression or none, and zip.
|
|
260
|
+
|
|
261
|
+
Raises ``ArchiveError`` for every refusal. On refusal ``dest`` is
|
|
262
|
+
removed entirely.
|
|
263
|
+
"""
|
|
264
|
+
src = Path(src)
|
|
265
|
+
dest = Path(dest)
|
|
266
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
267
|
+
dest_root = dest.resolve()
|
|
268
|
+
budget = [int(max_bytes)]
|
|
269
|
+
|
|
270
|
+
try:
|
|
271
|
+
if zipfile.is_zipfile(src):
|
|
272
|
+
tops = _extract_zip(src, dest_root, budget, max_members)
|
|
273
|
+
else:
|
|
274
|
+
tops = _extract_tar(src, dest_root, budget, max_members)
|
|
275
|
+
except ArchiveError:
|
|
276
|
+
shutil.rmtree(dest_root, ignore_errors=True)
|
|
277
|
+
raise
|
|
278
|
+
except Exception as exc:
|
|
279
|
+
# Anything the stdlib raises from hostile bytes (an OSError on an
|
|
280
|
+
# absurd name, a zlib error mid-stream) becomes the same typed
|
|
281
|
+
# refusal: the caller must never have to catch the union of every
|
|
282
|
+
# decompressor's private exception tree.
|
|
283
|
+
shutil.rmtree(dest_root, ignore_errors=True)
|
|
284
|
+
raise ArchiveError(f"could not extract archive: {exc}") from exc
|
|
285
|
+
|
|
286
|
+
if len(tops) == 1:
|
|
287
|
+
only = dest_root / next(iter(tops))
|
|
288
|
+
if only.is_dir():
|
|
289
|
+
return only
|
|
290
|
+
return dest_root
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Tier-2 argv runner: run the user's own command inside a hardened container.
|
|
2
|
+
|
|
3
|
+
The module runners execute an allowlisted `python -m <module>`. This runner
|
|
4
|
+
executes whatever argv the job carried, which is what makes an arbitrary
|
|
5
|
+
machine a useful compute resource — and is why it is container-only. There
|
|
6
|
+
is no unsandboxed argv path here, by default or otherwise.
|
|
7
|
+
|
|
8
|
+
The security control is the isolation tier plus the operator's IMAGE
|
|
9
|
+
allowlist (what this volunteer consents to run), not a code allowlist: the
|
|
10
|
+
user's code inside a permitted image is unrestricted.
|
|
11
|
+
|
|
12
|
+
The task sees exactly one directory: its workdir bound at /work. Inputs are
|
|
13
|
+
pre-staged by the agent at /work/inputs; outputs are written to /work/out.
|
|
14
|
+
With --network none the job cannot fetch anything itself.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
import subprocess
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from flashnode.executor.hardening import container_name, harden_args
|
|
24
|
+
from flashnode.executor.images import DEFAULT_ALLOWED_IMAGE_PREFIXES, image_is_allowed
|
|
25
|
+
from flashnode.executor.runner import TaskExecutionError
|
|
26
|
+
|
|
27
|
+
_ENV_KEY = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ArgvDockerRunner:
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
allowed_images: frozenset[str] = frozenset(),
|
|
34
|
+
cpus: float = 2.0,
|
|
35
|
+
memory_gb: float = 2.0,
|
|
36
|
+
timeout_seconds: float = 3600.0,
|
|
37
|
+
max_output_bytes: int = 2 * 1024**3,
|
|
38
|
+
allowed_image_prefixes: frozenset[str] = DEFAULT_ALLOWED_IMAGE_PREFIXES,
|
|
39
|
+
):
|
|
40
|
+
self.allowed_images = allowed_images
|
|
41
|
+
self.allowed_image_prefixes = allowed_image_prefixes
|
|
42
|
+
self.cpus = cpus
|
|
43
|
+
self.memory_gb = memory_gb
|
|
44
|
+
self.timeout_seconds = timeout_seconds
|
|
45
|
+
self.max_output_bytes = max_output_bytes
|
|
46
|
+
|
|
47
|
+
def run(self, payload: dict, workdir: Path, inputs: dict[str, Path]) -> Path:
|
|
48
|
+
argv = payload.get("argv")
|
|
49
|
+
if not argv or not isinstance(argv, list) or not all(isinstance(t, str) for t in argv):
|
|
50
|
+
raise TaskExecutionError("payload 'argv' must be a non-empty list of strings")
|
|
51
|
+
|
|
52
|
+
# Checked BEFORE any subprocess call, so a hostile value such as
|
|
53
|
+
# "--privileged" can never reach docker's flag parser.
|
|
54
|
+
image = payload.get("image")
|
|
55
|
+
if not image or not image_is_allowed(
|
|
56
|
+
image, self.allowed_images, self.allowed_image_prefixes
|
|
57
|
+
):
|
|
58
|
+
raise TaskExecutionError(f"image {image!r} is not allowlisted — refusing to run")
|
|
59
|
+
|
|
60
|
+
env_args: list[str] = []
|
|
61
|
+
for key, value in (payload.get("env") or {}).items():
|
|
62
|
+
if not _ENV_KEY.match(str(key)):
|
|
63
|
+
raise TaskExecutionError(f"illegal env key {key!r} — refusing to run")
|
|
64
|
+
env_args += ["--env", f"{key}={value}"]
|
|
65
|
+
|
|
66
|
+
workdir = Path(workdir)
|
|
67
|
+
outdir = workdir / "out"
|
|
68
|
+
outdir.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
|
|
70
|
+
name = container_name(payload.get("task_id"))
|
|
71
|
+
command = [
|
|
72
|
+
"docker", "run", "--rm", "--name", name,
|
|
73
|
+
*harden_args(workdir, cpus=self.cpus, memory_gb=self.memory_gb),
|
|
74
|
+
*env_args,
|
|
75
|
+
image, # argv follows the image, where docker treats it
|
|
76
|
+
*argv, # as the container command: leading '-' is inert
|
|
77
|
+
]
|
|
78
|
+
try:
|
|
79
|
+
proc = subprocess.run(
|
|
80
|
+
command, capture_output=True, timeout=self.timeout_seconds, check=False
|
|
81
|
+
)
|
|
82
|
+
except subprocess.TimeoutExpired:
|
|
83
|
+
# subprocess.run's timeout kills the docker CLIENT process, not
|
|
84
|
+
# the daemon-side container — it keeps running unless we kill it
|
|
85
|
+
# by name ourselves. Best-effort: the container may already be
|
|
86
|
+
# gone by the time we ask, and either way the wall-clock error
|
|
87
|
+
# below is what the caller needs to see, not a kill failure.
|
|
88
|
+
try:
|
|
89
|
+
subprocess.run(["docker", "kill", name], capture_output=True, timeout=10, check=False)
|
|
90
|
+
except Exception:
|
|
91
|
+
pass
|
|
92
|
+
raise TaskExecutionError(f"task exceeded {self.timeout_seconds}s wall clock")
|
|
93
|
+
except OSError as exc:
|
|
94
|
+
# `docker` missing/removed mid-run raises FileNotFoundError here
|
|
95
|
+
# (a subclass of OSError). Degrade to a failed task, not a dead
|
|
96
|
+
# agent — execute_one only catches TaskExecutionError/LeaseLost.
|
|
97
|
+
raise TaskExecutionError(f"docker is unavailable: {exc}") from exc
|
|
98
|
+
if proc.returncode != 0:
|
|
99
|
+
tail = proc.stderr.decode(errors="replace")[-800:]
|
|
100
|
+
raise TaskExecutionError(f"task exited {proc.returncode}: {tail}")
|
|
101
|
+
|
|
102
|
+
# metrics.json is load-bearing, not a preference: CommandRecipe sets
|
|
103
|
+
# commit_key to <prefix>/metrics.json and the coordinator validates
|
|
104
|
+
# the artifact at that key by sha256. Failing here turns an opaque
|
|
105
|
+
# commit rejection into a clear task error.
|
|
106
|
+
if not (outdir / "metrics.json").is_file():
|
|
107
|
+
raise TaskExecutionError("task produced no metrics.json — nothing to commit")
|
|
108
|
+
|
|
109
|
+
total = sum(p.stat().st_size for p in outdir.rglob("*") if p.is_file())
|
|
110
|
+
if total > self.max_output_bytes:
|
|
111
|
+
raise TaskExecutionError(
|
|
112
|
+
f"task output {total} B exceeds the {self.max_output_bytes} B cap"
|
|
113
|
+
)
|
|
114
|
+
return outdir
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Outbound-only HTTP client for the FlashRuntime coordinator.
|
|
2
|
+
|
|
3
|
+
stdlib `urllib` on purpose: this code runs on strangers' machines, and every
|
|
4
|
+
dependency is attack surface (AGENTS.md). All calls are *outbound* to the
|
|
5
|
+
coordinator — the device never listens on anything.
|
|
6
|
+
|
|
7
|
+
Two heartbeats, two endpoints, never merged:
|
|
8
|
+
- node heartbeat → /v1alpha1/nodes/{id}/heartbeat ("this machine is up")
|
|
9
|
+
- attempt heartbeat→ /v1alpha1/attempts/{id}/heartbeat ("this task is alive")
|
|
10
|
+
A 410 on the attempt heartbeat means the lease is dead: the worker must stop
|
|
11
|
+
work on that task (`LeaseLost`).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
import urllib.error
|
|
19
|
+
import urllib.request
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from flashruntime.protocol.v1alpha1 import Lease, NodeHeartbeat, NodeRegistration
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class LeaseLost(Exception):
|
|
26
|
+
"""The coordinator refused the attempt heartbeat — stop working."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CoordinatorClient:
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
base_url: str,
|
|
33
|
+
timeout: float = 15.0,
|
|
34
|
+
join_code: str | None = None,
|
|
35
|
+
token: str | None = None,
|
|
36
|
+
):
|
|
37
|
+
self.base_url = base_url.rstrip("/")
|
|
38
|
+
self.timeout = timeout
|
|
39
|
+
self.join_code = join_code # sent on register when the pool requires one
|
|
40
|
+
self._token = token # bearer credential for every authenticated call
|
|
41
|
+
|
|
42
|
+
# -- auth -----------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
def _headers(self) -> dict[str, str]:
|
|
45
|
+
"""Auth header merged into every request this class makes.
|
|
46
|
+
|
|
47
|
+
Empty when no token is configured — correct for the self-hosted open
|
|
48
|
+
profile, where absence of a credential simply means no header.
|
|
49
|
+
"""
|
|
50
|
+
if self._token:
|
|
51
|
+
return {"Authorization": f"Bearer {self._token}"}
|
|
52
|
+
return {}
|
|
53
|
+
|
|
54
|
+
# -- transport (patchable in tests) -------------------------------------
|
|
55
|
+
|
|
56
|
+
def _request(
|
|
57
|
+
self,
|
|
58
|
+
method: str,
|
|
59
|
+
path: str,
|
|
60
|
+
body: bytes | None = None,
|
|
61
|
+
content_type: str = "application/json",
|
|
62
|
+
headers: dict[str, str] | None = None,
|
|
63
|
+
) -> tuple[int, bytes]:
|
|
64
|
+
all_headers = self._headers()
|
|
65
|
+
all_headers.update(headers or {})
|
|
66
|
+
if body is not None:
|
|
67
|
+
all_headers["Content-Type"] = content_type
|
|
68
|
+
req = urllib.request.Request(
|
|
69
|
+
f"{self.base_url}{path}",
|
|
70
|
+
data=body,
|
|
71
|
+
method=method,
|
|
72
|
+
headers=all_headers,
|
|
73
|
+
)
|
|
74
|
+
try:
|
|
75
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
76
|
+
return resp.status, resp.read()
|
|
77
|
+
except urllib.error.HTTPError as exc:
|
|
78
|
+
return exc.code, exc.read()
|
|
79
|
+
|
|
80
|
+
def _json(self, method: str, path: str, payload: dict | str | None = None) -> tuple[int, dict]:
|
|
81
|
+
body = None
|
|
82
|
+
if payload is not None:
|
|
83
|
+
body = (payload if isinstance(payload, str) else json.dumps(payload)).encode()
|
|
84
|
+
status, raw = self._request(method, path, body)
|
|
85
|
+
return status, (json.loads(raw) if raw else {})
|
|
86
|
+
|
|
87
|
+
# -- node lifecycle -----------------------------------------------------
|
|
88
|
+
|
|
89
|
+
def register(self, registration: NodeRegistration) -> None:
|
|
90
|
+
headers = {"X-FlashML-Join-Code": self.join_code} if self.join_code else None
|
|
91
|
+
status, raw = self._request(
|
|
92
|
+
"POST",
|
|
93
|
+
"/v1alpha1/nodes/register",
|
|
94
|
+
registration.model_dump_json().encode(),
|
|
95
|
+
headers=headers,
|
|
96
|
+
)
|
|
97
|
+
if status != 200:
|
|
98
|
+
raise RuntimeError(f"registration failed ({status}): {raw.decode(errors='replace')}")
|
|
99
|
+
|
|
100
|
+
def node_heartbeat(self, node_id: str) -> bool:
|
|
101
|
+
status, _ = self._json(
|
|
102
|
+
"POST",
|
|
103
|
+
f"/v1alpha1/nodes/{node_id}/heartbeat",
|
|
104
|
+
NodeHeartbeat(node_id=node_id).model_dump_json(),
|
|
105
|
+
)
|
|
106
|
+
return status == 200
|
|
107
|
+
|
|
108
|
+
# -- lease lifecycle ----------------------------------------------------
|
|
109
|
+
|
|
110
|
+
def claim(self, node_id: str) -> Lease | None:
|
|
111
|
+
status, body = self._json("POST", "/v1alpha1/leases/claim", {"node_id": node_id})
|
|
112
|
+
if status == 204:
|
|
113
|
+
return None
|
|
114
|
+
if status != 200:
|
|
115
|
+
raise RuntimeError(f"claim failed ({status}): {body}")
|
|
116
|
+
return Lease.model_validate(body)
|
|
117
|
+
|
|
118
|
+
def attempt_heartbeat(self, lease_id: str) -> None:
|
|
119
|
+
status, body = self._json("POST", f"/v1alpha1/attempts/{lease_id}/heartbeat")
|
|
120
|
+
if status == 410:
|
|
121
|
+
raise LeaseLost(str(body))
|
|
122
|
+
if status != 200:
|
|
123
|
+
raise RuntimeError(f"attempt heartbeat failed ({status}): {body}")
|
|
124
|
+
|
|
125
|
+
def complete(self, lease_id: str, output_sha256: str) -> bool:
|
|
126
|
+
status, body = self._json(
|
|
127
|
+
"POST",
|
|
128
|
+
f"/v1alpha1/attempts/{lease_id}/complete",
|
|
129
|
+
{"output_sha256": output_sha256},
|
|
130
|
+
)
|
|
131
|
+
if status != 200:
|
|
132
|
+
raise RuntimeError(f"complete failed ({status}): {body}")
|
|
133
|
+
return bool(body.get("accepted"))
|
|
134
|
+
|
|
135
|
+
def fail(self, lease_id: str, reason: str) -> None:
|
|
136
|
+
self._json("POST", f"/v1alpha1/attempts/{lease_id}/fail", {"reason": reason})
|
|
137
|
+
|
|
138
|
+
# -- checkpoints (the agent is the courier; tasks stay offline) ----------
|
|
139
|
+
|
|
140
|
+
def checkpoint_latest(self, job_id: str, task_id: str) -> dict | None:
|
|
141
|
+
status, body = self._json(
|
|
142
|
+
"GET", f"/v1alpha1/jobs/{job_id}/tasks/{task_id}/checkpoints/latest"
|
|
143
|
+
)
|
|
144
|
+
if status == 404:
|
|
145
|
+
return None
|
|
146
|
+
if status != 200:
|
|
147
|
+
raise RuntimeError(f"checkpoint latest failed ({status}): {body}")
|
|
148
|
+
return body
|
|
149
|
+
|
|
150
|
+
def checkpoint_register_part(
|
|
151
|
+
self, job_id: str, task_id: str, attempt_id: str, step: int, part: dict
|
|
152
|
+
) -> None:
|
|
153
|
+
status, body = self._json(
|
|
154
|
+
"POST",
|
|
155
|
+
f"/v1alpha1/jobs/{job_id}/tasks/{task_id}/checkpoints/parts",
|
|
156
|
+
{"attempt_id": attempt_id, "step": step, "part": part},
|
|
157
|
+
)
|
|
158
|
+
if status != 200:
|
|
159
|
+
raise RuntimeError(f"checkpoint part registration failed ({status}): {body}")
|
|
160
|
+
|
|
161
|
+
def checkpoint_commit(
|
|
162
|
+
self,
|
|
163
|
+
job_id: str,
|
|
164
|
+
task_id: str,
|
|
165
|
+
attempt_id: str,
|
|
166
|
+
step: int,
|
|
167
|
+
parts: list[dict],
|
|
168
|
+
storage_prefix: str,
|
|
169
|
+
) -> dict:
|
|
170
|
+
status, body = self._json(
|
|
171
|
+
"POST",
|
|
172
|
+
f"/v1alpha1/jobs/{job_id}/tasks/{task_id}/checkpoints/commit",
|
|
173
|
+
{
|
|
174
|
+
"attempt_id": attempt_id,
|
|
175
|
+
"step": step,
|
|
176
|
+
"expected_parts": parts,
|
|
177
|
+
"storage_prefix": storage_prefix,
|
|
178
|
+
},
|
|
179
|
+
)
|
|
180
|
+
if status != 200:
|
|
181
|
+
raise RuntimeError(f"checkpoint commit refused ({status}): {body}")
|
|
182
|
+
return body
|
|
183
|
+
|
|
184
|
+
# -- artifacts (coordinator-hosted shared data) --------------------------
|
|
185
|
+
|
|
186
|
+
def download_artifact(self, key: str, destination: Path) -> Path:
|
|
187
|
+
status, raw = self._request("GET", f"/v1alpha1/artifacts/{key}")
|
|
188
|
+
if status != 200:
|
|
189
|
+
raise RuntimeError(f"artifact download failed ({status}): {key}")
|
|
190
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
191
|
+
destination.write_bytes(raw)
|
|
192
|
+
return destination
|
|
193
|
+
|
|
194
|
+
def upload_artifact(self, local_path: Path, key: str) -> str:
|
|
195
|
+
data = Path(local_path).read_bytes()
|
|
196
|
+
status, _ = self._request(
|
|
197
|
+
"PUT", f"/v1alpha1/artifacts/{key}", data, content_type="application/octet-stream"
|
|
198
|
+
)
|
|
199
|
+
if status != 200:
|
|
200
|
+
raise RuntimeError(f"artifact upload failed ({status}): {key}")
|
|
201
|
+
return hashlib.sha256(data).hexdigest()
|