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,363 @@
|
|
|
1
|
+
"""The device executor loop: FlashNode's Mode A work cycle.
|
|
2
|
+
|
|
3
|
+
register → [ claim → download inputs → run (heartbeating) → upload
|
|
4
|
+
outputs → commit ] → repeat
|
|
5
|
+
|
|
6
|
+
Everything is *pull*: the device makes outbound calls only. While a task
|
|
7
|
+
runs, a background thread renews the attempt lease; if the coordinator
|
|
8
|
+
answers 410 (lease expired/superseded — e.g. this machine was presumed dead
|
|
9
|
+
and the task reassigned), the result is thrown away and never committed —
|
|
10
|
+
and even a bug here is caught by the coordinator's idempotent commit, which
|
|
11
|
+
rejects late duplicates. Defense in depth: polite client, unforgiving
|
|
12
|
+
server.
|
|
13
|
+
|
|
14
|
+
Failures are *reported, not raised*: a task error calls `fail()` (the task
|
|
15
|
+
requeues elsewhere), a coordinator outage backs off and retries. The loop
|
|
16
|
+
only exits on stop/max_tasks.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import hashlib
|
|
22
|
+
import json
|
|
23
|
+
import logging
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
import shutil
|
|
27
|
+
import tempfile
|
|
28
|
+
import threading
|
|
29
|
+
import time
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
from flashruntime.protocol.v1alpha1 import Lease
|
|
33
|
+
|
|
34
|
+
from flashnode.executor.archives import (
|
|
35
|
+
DEFAULT_MAX_BYTES,
|
|
36
|
+
DEFAULT_MAX_MEMBERS,
|
|
37
|
+
ArchiveError,
|
|
38
|
+
extract_archive_safely,
|
|
39
|
+
)
|
|
40
|
+
from flashnode.executor.client import CoordinatorClient, LeaseLost
|
|
41
|
+
from flashnode.executor.runner import SubprocessRunner, TaskExecutionError
|
|
42
|
+
|
|
43
|
+
log = logging.getLogger("flashnode.executor")
|
|
44
|
+
|
|
45
|
+
#: An input name becomes a directory name under the task's workdir when the
|
|
46
|
+
#: input is unpacked, so it has to be one harmless path segment. The payload
|
|
47
|
+
#: is attacker-influenced all the way from the job submission, and an input
|
|
48
|
+
#: called ``../../.ssh`` must be a refused task, not a written directory.
|
|
49
|
+
_SAFE_INPUT_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _jlog(msg: str, **kv) -> str:
|
|
53
|
+
return json.dumps({"text": msg, **kv})
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class _AttemptHeartbeat(threading.Thread):
|
|
57
|
+
"""Renews one attempt lease until stopped; flags the lease as lost on 410."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, client: CoordinatorClient, lease: Lease):
|
|
60
|
+
super().__init__(daemon=True)
|
|
61
|
+
self._client = client
|
|
62
|
+
self._lease = lease
|
|
63
|
+
self._stop = threading.Event()
|
|
64
|
+
window = max(2.0, (lease.deadline.timestamp() - time.time()) / 3.0)
|
|
65
|
+
self._interval = window
|
|
66
|
+
self.lost = False
|
|
67
|
+
|
|
68
|
+
def run(self) -> None:
|
|
69
|
+
while not self._stop.wait(self._interval):
|
|
70
|
+
try:
|
|
71
|
+
self._client.attempt_heartbeat(self._lease.lease_id)
|
|
72
|
+
except LeaseLost:
|
|
73
|
+
self.lost = True
|
|
74
|
+
return
|
|
75
|
+
except Exception as exc: # transient coordinator trouble: keep trying
|
|
76
|
+
log.warning(_jlog("attempt heartbeat error", error=str(exc)))
|
|
77
|
+
|
|
78
|
+
def stop(self) -> None:
|
|
79
|
+
self._stop.set()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class _CheckpointRelay(threading.Thread):
|
|
83
|
+
"""Ships the task's checkpoint files while it runs: each new
|
|
84
|
+
`ckpt/step-*.json` is uploaded, registered as a part, and committed as a
|
|
85
|
+
single-part manifest — so a crash a moment later still leaves a valid,
|
|
86
|
+
resumable checkpoint on the coordinator. Best-effort by design: a failed
|
|
87
|
+
ship just means an older resume point; the run itself is never blocked."""
|
|
88
|
+
|
|
89
|
+
def __init__(self, client: CoordinatorClient, lease: Lease, ckpt_dir: Path, prefix: str):
|
|
90
|
+
super().__init__(daemon=True)
|
|
91
|
+
self._client = client
|
|
92
|
+
self._lease = lease
|
|
93
|
+
self._ckpt_dir = ckpt_dir
|
|
94
|
+
self._prefix = prefix
|
|
95
|
+
self._halt = threading.Event()
|
|
96
|
+
self._shipped: set[str] = set()
|
|
97
|
+
|
|
98
|
+
def run(self) -> None:
|
|
99
|
+
while not self._halt.wait(0.3):
|
|
100
|
+
self._ship_new()
|
|
101
|
+
|
|
102
|
+
def finish(self) -> None:
|
|
103
|
+
"""Stop scanning and do one final sweep — a dying attempt's last
|
|
104
|
+
checkpoint must be shipped even if the process died mid-interval."""
|
|
105
|
+
self._halt.set()
|
|
106
|
+
self.join(timeout=10)
|
|
107
|
+
self._ship_new()
|
|
108
|
+
|
|
109
|
+
def _ship_new(self) -> None:
|
|
110
|
+
if not self._ckpt_dir.is_dir():
|
|
111
|
+
return
|
|
112
|
+
for path in sorted(self._ckpt_dir.glob("step-*.json")):
|
|
113
|
+
if path.name in self._shipped:
|
|
114
|
+
continue
|
|
115
|
+
try:
|
|
116
|
+
step = int(path.stem.split("-")[1])
|
|
117
|
+
key = f"{self._prefix}ckpt/{path.name}"
|
|
118
|
+
sha = self._client.upload_artifact(path, key)
|
|
119
|
+
part = {"key": key, "sha256": sha, "size_bytes": path.stat().st_size}
|
|
120
|
+
self._client.checkpoint_register_part(
|
|
121
|
+
self._lease.job_id, self._lease.task_id, self._lease.lease_id, step, part
|
|
122
|
+
)
|
|
123
|
+
self._client.checkpoint_commit(
|
|
124
|
+
self._lease.job_id, self._lease.task_id, self._lease.lease_id,
|
|
125
|
+
step, [part], f"artifact://{self._prefix}ckpt/{step}/",
|
|
126
|
+
)
|
|
127
|
+
self._shipped.add(path.name)
|
|
128
|
+
except Exception as exc: # best effort — older resume point, not a dead run
|
|
129
|
+
log.warning(_jlog("checkpoint ship failed", file=path.name, error=str(exc)))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class ExecutorLoop:
|
|
133
|
+
def __init__(
|
|
134
|
+
self,
|
|
135
|
+
client: CoordinatorClient,
|
|
136
|
+
node_id: str,
|
|
137
|
+
runner: SubprocessRunner | None = None,
|
|
138
|
+
poll_seconds: float = 1.0,
|
|
139
|
+
node_heartbeat_seconds: float = 5.0,
|
|
140
|
+
workdir_base: Path | None = None,
|
|
141
|
+
registration=None, # NodeRegistration; enables re-register after coordinator restart
|
|
142
|
+
max_unpacked_bytes: int = DEFAULT_MAX_BYTES,
|
|
143
|
+
max_unpacked_members: int = DEFAULT_MAX_MEMBERS,
|
|
144
|
+
):
|
|
145
|
+
self.client = client
|
|
146
|
+
self.node_id = node_id
|
|
147
|
+
self.runner = runner or SubprocessRunner()
|
|
148
|
+
self.poll_seconds = poll_seconds
|
|
149
|
+
self.node_heartbeat_seconds = node_heartbeat_seconds
|
|
150
|
+
# Where per-task tempdirs are created. Matters for the docker tier on
|
|
151
|
+
# macOS: colima/Docker Desktop only share $HOME, so the default
|
|
152
|
+
# system tmp (/var/folders/…) bind-mounts as an empty dir in the VM.
|
|
153
|
+
self.workdir_base = Path(workdir_base) if workdir_base else None
|
|
154
|
+
self.registration = registration
|
|
155
|
+
# The host owner's ceiling on what one task's inputs may cost them
|
|
156
|
+
# in disk and inodes — a submitter cannot raise it from the payload.
|
|
157
|
+
self.max_unpacked_bytes = max_unpacked_bytes
|
|
158
|
+
self.max_unpacked_members = max_unpacked_members
|
|
159
|
+
self.stop_event = threading.Event()
|
|
160
|
+
self.tasks_accepted = 0
|
|
161
|
+
self._last_node_hb = 0.0
|
|
162
|
+
|
|
163
|
+
# -- inputs --------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
def _staged_directory(self, name: str, key: str, workdir: Path) -> Path:
|
|
166
|
+
"""Download an archive input and hand back the *directory* it
|
|
167
|
+
unpacks to, at ``workdir/inputs/<name>/``.
|
|
168
|
+
|
|
169
|
+
That path is the contract the coordinator compiles argv against
|
|
170
|
+
(``python /work/inputs/code/train.py``), so the unpacked tree has to
|
|
171
|
+
land there exactly — which is why the archive is unpacked in a
|
|
172
|
+
staging area first and then moved into place in one rename, rather
|
|
173
|
+
than extracted over the final path. Two reasons: a refused archive
|
|
174
|
+
never appears at the path the task will look at, even momentarily,
|
|
175
|
+
and GitHub's wrapper directory (``owner-repo-<sha>/``) is stripped
|
|
176
|
+
by moving the extractor's content root, not by shuffling files.
|
|
177
|
+
|
|
178
|
+
The downloaded archive itself is deleted afterwards and never sits
|
|
179
|
+
under ``inputs/``: the task should see its code, not a second copy
|
|
180
|
+
of its own bytes it could be confused by (or fill the disk with).
|
|
181
|
+
"""
|
|
182
|
+
if not _SAFE_INPUT_NAME.match(name) or name in (".", ".."):
|
|
183
|
+
raise TaskExecutionError(
|
|
184
|
+
f"refusing to unpack input with unsafe name {name!r}"
|
|
185
|
+
)
|
|
186
|
+
stage = workdir / ".staging" / name
|
|
187
|
+
dest = workdir / "inputs" / name
|
|
188
|
+
if dest.exists():
|
|
189
|
+
raise TaskExecutionError(f"input {name!r} collides with an existing path")
|
|
190
|
+
try:
|
|
191
|
+
stage.mkdir(parents=True, exist_ok=True)
|
|
192
|
+
# A fixed local filename: the artifact key is submitter-chosen
|
|
193
|
+
# and has no business naming a file on this machine. Nothing
|
|
194
|
+
# downstream reads the name anyway — the extractor detects the
|
|
195
|
+
# container format from the bytes.
|
|
196
|
+
archive = self.client.download_artifact(key, stage / "archive.bin")
|
|
197
|
+
root = extract_archive_safely(
|
|
198
|
+
Path(archive), stage / "unpacked",
|
|
199
|
+
self.max_unpacked_bytes, self.max_unpacked_members,
|
|
200
|
+
)
|
|
201
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
202
|
+
os.replace(root, dest)
|
|
203
|
+
except ArchiveError as exc:
|
|
204
|
+
# A hostile archive fails this task; it never kills the agent.
|
|
205
|
+
log.warning(_jlog("refused unsafe input archive", input=name, error=str(exc)))
|
|
206
|
+
raise TaskExecutionError(f"input {name!r}: {exc}") from None
|
|
207
|
+
finally:
|
|
208
|
+
shutil.rmtree(stage, ignore_errors=True)
|
|
209
|
+
log.info(_jlog("unpacked input", input=name, path=str(dest)))
|
|
210
|
+
return dest
|
|
211
|
+
|
|
212
|
+
# -- one task ------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
def execute_one(self, lease: Lease) -> bool:
|
|
215
|
+
"""Run a claimed lease end-to-end. Returns True if the commit was
|
|
216
|
+
accepted. Never raises for task-level problems — they are reported."""
|
|
217
|
+
payload = lease.payload
|
|
218
|
+
hb = _AttemptHeartbeat(self.client, lease)
|
|
219
|
+
hb.start()
|
|
220
|
+
relay: _CheckpointRelay | None = None
|
|
221
|
+
try:
|
|
222
|
+
if self.workdir_base:
|
|
223
|
+
self.workdir_base.mkdir(parents=True, exist_ok=True)
|
|
224
|
+
with tempfile.TemporaryDirectory(
|
|
225
|
+
prefix=f"flashnode-{lease.task_id}-",
|
|
226
|
+
dir=str(self.workdir_base) if self.workdir_base else None,
|
|
227
|
+
) as tmp:
|
|
228
|
+
workdir = Path(tmp)
|
|
229
|
+
# Explicit, never inferred: an input is unpacked only if the
|
|
230
|
+
# payload names it in `unpack_inputs`. Sniffing the file
|
|
231
|
+
# extension would hand the decision to whoever chose the
|
|
232
|
+
# artifact key — i.e. to the submitter — and "it ended in
|
|
233
|
+
# .tar.gz so we ran an extractor over it" is exactly the
|
|
234
|
+
# kind of implicit behaviour that turns a hostile upload
|
|
235
|
+
# into a host-owner problem.
|
|
236
|
+
unpack = payload.get("unpack_inputs") or []
|
|
237
|
+
if not isinstance(unpack, list) or not all(isinstance(n, str) for n in unpack):
|
|
238
|
+
raise TaskExecutionError(
|
|
239
|
+
"payload 'unpack_inputs' must be a list of input names"
|
|
240
|
+
)
|
|
241
|
+
unpack_set = set(unpack)
|
|
242
|
+
|
|
243
|
+
inputs: dict[str, Path] = {}
|
|
244
|
+
for name, uri in (payload.get("inputs") or {}).items():
|
|
245
|
+
key = str(uri).removeprefix("artifact://")
|
|
246
|
+
if name in unpack_set:
|
|
247
|
+
inputs[name] = self._staged_directory(name, key, workdir)
|
|
248
|
+
else:
|
|
249
|
+
inputs[name] = self.client.download_artifact(
|
|
250
|
+
key, workdir / "inputs" / Path(key).name
|
|
251
|
+
)
|
|
252
|
+
unknown = unpack_set - set(payload.get("inputs") or {})
|
|
253
|
+
if unknown:
|
|
254
|
+
# A named-but-absent input means the payload and the job
|
|
255
|
+
# disagree; failing loudly beats silently running a
|
|
256
|
+
# command whose code was never staged.
|
|
257
|
+
raise TaskExecutionError(
|
|
258
|
+
f"unpack_inputs names inputs that do not exist: {sorted(unknown)}"
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
prefix = payload.get("output_prefix", f"jobs/{lease.job_id}/{lease.task_id}/")
|
|
262
|
+
if payload.get("checkpoint") is not None:
|
|
263
|
+
# resume from the task's latest valid checkpoint, wherever
|
|
264
|
+
# the previous attempt ran
|
|
265
|
+
manifest = self.client.checkpoint_latest(lease.job_id, lease.task_id)
|
|
266
|
+
if manifest and manifest.get("parts"):
|
|
267
|
+
inputs["resume"] = self.client.download_artifact(
|
|
268
|
+
manifest["parts"][0]["key"], workdir / "inputs" / "resume.json"
|
|
269
|
+
)
|
|
270
|
+
log.info(_jlog("resuming from checkpoint",
|
|
271
|
+
task=lease.task_id, step=manifest.get("step")))
|
|
272
|
+
relay = _CheckpointRelay(self.client, lease, workdir / "out" / "ckpt", prefix)
|
|
273
|
+
relay.start()
|
|
274
|
+
|
|
275
|
+
try:
|
|
276
|
+
outdir = self.runner.run(payload, workdir, inputs)
|
|
277
|
+
finally:
|
|
278
|
+
if relay is not None:
|
|
279
|
+
relay.finish() # ship the dying attempt's last checkpoint too
|
|
280
|
+
|
|
281
|
+
if hb.lost:
|
|
282
|
+
log.warning(_jlog("lease lost during run — discarding result",
|
|
283
|
+
task=lease.task_id))
|
|
284
|
+
return False
|
|
285
|
+
|
|
286
|
+
prefix = payload.get("output_prefix", f"jobs/{lease.job_id}/{lease.task_id}/")
|
|
287
|
+
metrics_sha = ""
|
|
288
|
+
# rglob, not iterdir: a job that writes nested output (e.g.
|
|
289
|
+
# out/checkpoints/model.pt) must not have it silently
|
|
290
|
+
# dropped just because ArgvDockerRunner's size cap already
|
|
291
|
+
# walks the tree recursively (argv_runner.py's rglob) while
|
|
292
|
+
# this used to upload only the top level.
|
|
293
|
+
for path in sorted(outdir.rglob("*")):
|
|
294
|
+
if path.is_file():
|
|
295
|
+
rel = path.relative_to(outdir)
|
|
296
|
+
sha = self.client.upload_artifact(path, f"{prefix}{rel.as_posix()}")
|
|
297
|
+
# metrics.json is the commit key: only the file AT
|
|
298
|
+
# the output root counts, never a same-named file
|
|
299
|
+
# nested in a subdirectory.
|
|
300
|
+
if rel == Path("metrics.json"):
|
|
301
|
+
metrics_sha = sha
|
|
302
|
+
accepted = self.client.complete(lease.lease_id, metrics_sha or "0" * 64)
|
|
303
|
+
if accepted:
|
|
304
|
+
self.tasks_accepted += 1
|
|
305
|
+
log.info(_jlog("task finished", task=lease.task_id,
|
|
306
|
+
attempt=lease.attempt_number, accepted=accepted))
|
|
307
|
+
return accepted
|
|
308
|
+
except TaskExecutionError as exc:
|
|
309
|
+
log.warning(_jlog("task failed", task=lease.task_id, error=str(exc)))
|
|
310
|
+
try:
|
|
311
|
+
self.client.fail(lease.lease_id, str(exc)[:500])
|
|
312
|
+
except Exception:
|
|
313
|
+
pass # lease will expire on its own — same outcome, slower
|
|
314
|
+
return False
|
|
315
|
+
except LeaseLost:
|
|
316
|
+
log.warning(_jlog("lease lost", task=lease.task_id))
|
|
317
|
+
return False
|
|
318
|
+
finally:
|
|
319
|
+
hb.stop()
|
|
320
|
+
|
|
321
|
+
# -- the loop ------------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
def _maybe_node_heartbeat(self) -> None:
|
|
324
|
+
if time.monotonic() - self._last_node_hb >= self.node_heartbeat_seconds:
|
|
325
|
+
ok = self.client.node_heartbeat(self.node_id)
|
|
326
|
+
if not ok and self.registration is not None:
|
|
327
|
+
# A refused heartbeat usually means the coordinator restarted
|
|
328
|
+
# and forgot us — re-register instead of starving forever.
|
|
329
|
+
log.info(_jlog("heartbeat refused — re-registering", node=self.node_id))
|
|
330
|
+
self.client.register(self.registration)
|
|
331
|
+
self._last_node_hb = time.monotonic()
|
|
332
|
+
|
|
333
|
+
def run(self, max_tasks: int | None = None, idle_exit: bool = False) -> int:
|
|
334
|
+
"""Claim-and-execute until stopped, `max_tasks` accepted, or — with
|
|
335
|
+
`idle_exit` — the queue drains (drain mode for tests/one-shot runs).
|
|
336
|
+
Returns the number of accepted tasks."""
|
|
337
|
+
backoff = 1.0
|
|
338
|
+
while not self.stop_event.is_set():
|
|
339
|
+
if max_tasks is not None and self.tasks_accepted >= max_tasks:
|
|
340
|
+
break
|
|
341
|
+
try:
|
|
342
|
+
self._maybe_node_heartbeat()
|
|
343
|
+
lease = self.client.claim(self.node_id)
|
|
344
|
+
backoff = 1.0
|
|
345
|
+
except Exception as exc:
|
|
346
|
+
log.warning(_jlog("coordinator unreachable", error=str(exc), backoff_s=backoff))
|
|
347
|
+
if self.stop_event.wait(backoff):
|
|
348
|
+
break
|
|
349
|
+
backoff = min(backoff * 2, 30)
|
|
350
|
+
continue
|
|
351
|
+
if lease is None:
|
|
352
|
+
if idle_exit:
|
|
353
|
+
break
|
|
354
|
+
if self.stop_event.wait(self.poll_seconds):
|
|
355
|
+
break
|
|
356
|
+
continue
|
|
357
|
+
log.info(_jlog("claimed", task=lease.task_id, attempt=lease.attempt_number))
|
|
358
|
+
self.execute_one(lease)
|
|
359
|
+
return self.tasks_accepted
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def sha256_file(path: Path) -> str:
|
|
363
|
+
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Task runners: how a claimed payload actually executes.
|
|
2
|
+
|
|
3
|
+
Tier 1 — `SubprocessRunner` (this file, dev/trusted profile): runs an
|
|
4
|
+
**allowlisted Python module** in a fresh subprocess with a wall-clock
|
|
5
|
+
timeout and an isolated working directory. Refuses argv payloads (which would
|
|
6
|
+
run unsandboxed on the host). Suitable for the local loop and trusted pools;
|
|
7
|
+
it is not a security boundary against malicious code.
|
|
8
|
+
|
|
9
|
+
Tier 2 — Docker (`docker run` with cpu/memory limits, `--network none`,
|
|
10
|
+
non-root, read-only rootfs, image allowlist) implements this same
|
|
11
|
+
`run(payload, workdir, inputs) → outdir` interface next; the executor loop
|
|
12
|
+
does not change when the tier does. That is the point of the interface.
|
|
13
|
+
|
|
14
|
+
Both tiers fail closed: an unlisted module/image is refused before anything
|
|
15
|
+
executes.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
DEFAULT_ALLOWED_MODULES = frozenset(
|
|
27
|
+
{
|
|
28
|
+
"flashml_workloads.sklearn_trial",
|
|
29
|
+
"flashml_workloads.kmeans_shard",
|
|
30
|
+
"flashml_workloads.sgd_trainer",
|
|
31
|
+
"flashml_workloads.fedavg_worker",
|
|
32
|
+
}
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# The only environment a task subprocess inherits. Everything else — join
|
|
36
|
+
# codes, coordinator URLs, cloud credentials — is the *agent's* business and
|
|
37
|
+
# must never leak into workload code.
|
|
38
|
+
_TASK_ENV_WHITELIST = ("PATH", "HOME", "PYTHONPATH", "LANG", "LC_ALL", "TMPDIR")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def task_env() -> dict[str, str]:
|
|
42
|
+
return {k: os.environ[k] for k in _TASK_ENV_WHITELIST if k in os.environ}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class TaskExecutionError(Exception):
|
|
46
|
+
"""The task refused to start or exited non-zero."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class SubprocessRunner:
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
allowed_modules: frozenset[str] = DEFAULT_ALLOWED_MODULES,
|
|
53
|
+
timeout_seconds: float = 600.0,
|
|
54
|
+
):
|
|
55
|
+
self.allowed_modules = allowed_modules
|
|
56
|
+
self.timeout_seconds = timeout_seconds
|
|
57
|
+
|
|
58
|
+
def run(self, payload: dict, workdir: Path, inputs: dict[str, Path]) -> Path:
|
|
59
|
+
"""Execute one task payload; return the output directory.
|
|
60
|
+
|
|
61
|
+
Contract with the task module:
|
|
62
|
+
spec.json ← {task_id, params, inputs: {name: local path}}
|
|
63
|
+
argv ← python -m <module> --spec spec.json --out out/
|
|
64
|
+
outputs → files written under out/ (metrics.json required)
|
|
65
|
+
"""
|
|
66
|
+
# Tier 1 has no isolation, so it must never execute a caller-supplied
|
|
67
|
+
# command line. Argv workloads are container-only (ArgvDockerRunner);
|
|
68
|
+
# refusing here keeps a misrouted payload from silently running
|
|
69
|
+
# unsandboxed on the host.
|
|
70
|
+
if "argv" in payload:
|
|
71
|
+
raise TaskExecutionError(
|
|
72
|
+
"argv payloads require a sandboxed runner — "
|
|
73
|
+
"start the agent with --runner argv"
|
|
74
|
+
)
|
|
75
|
+
module = payload.get("module", "")
|
|
76
|
+
if module not in self.allowed_modules:
|
|
77
|
+
raise TaskExecutionError(f"module {module!r} is not allowlisted — refusing to run")
|
|
78
|
+
|
|
79
|
+
workdir = Path(workdir)
|
|
80
|
+
outdir = workdir / "out"
|
|
81
|
+
outdir.mkdir(parents=True, exist_ok=True)
|
|
82
|
+
spec_path = workdir / "spec.json"
|
|
83
|
+
spec_path.write_text(
|
|
84
|
+
json.dumps(
|
|
85
|
+
{
|
|
86
|
+
"task_id": payload.get("task_id", ""),
|
|
87
|
+
"params": payload.get("params", {}),
|
|
88
|
+
"inputs": {name: str(path) for name, path in inputs.items()},
|
|
89
|
+
}
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
proc = subprocess.run(
|
|
95
|
+
[sys.executable, "-m", module, "--spec", str(spec_path), "--out", str(outdir)],
|
|
96
|
+
cwd=workdir,
|
|
97
|
+
capture_output=True,
|
|
98
|
+
timeout=self.timeout_seconds,
|
|
99
|
+
check=False,
|
|
100
|
+
env=task_env(),
|
|
101
|
+
)
|
|
102
|
+
except subprocess.TimeoutExpired:
|
|
103
|
+
raise TaskExecutionError(f"task exceeded {self.timeout_seconds}s wall clock")
|
|
104
|
+
if proc.returncode != 0:
|
|
105
|
+
tail = proc.stderr.decode(errors="replace")[-800:]
|
|
106
|
+
raise TaskExecutionError(f"task exited {proc.returncode}: {tail}")
|
|
107
|
+
if not (outdir / "metrics.json").is_file():
|
|
108
|
+
raise TaskExecutionError("task produced no metrics.json — nothing to commit")
|
|
109
|
+
return outdir
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Per-coordinator credential store for the device profile.
|
|
2
|
+
|
|
3
|
+
Tokens are keyed by *normalized* coordinator URL (trailing slash stripped) so
|
|
4
|
+
one machine can join several pools without one `flashnode login` clobbering
|
|
5
|
+
another. The file is JSON, chmod 0600 after every write. A corrupt or
|
|
6
|
+
unparseable file must never crash the agent — it simply behaves as if no
|
|
7
|
+
token were saved (`load_token` returns `None`).
|
|
8
|
+
|
|
9
|
+
This is the manual-token half. The interactive device flow (browser code)
|
|
10
|
+
needs a server and arrives with the cloud API plan; it will reuse this store
|
|
11
|
+
and the `CoordinatorClient` header plumbing that reads from it.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
DEFAULT_CREDENTIALS_PATH = "~/.flashnode/credentials.json"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def credentials_path() -> Path:
|
|
24
|
+
raw = os.environ.get("FLASHNODE_CREDENTIALS") or DEFAULT_CREDENTIALS_PATH
|
|
25
|
+
return Path(raw).expanduser()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _normalize(coordinator: str) -> str:
|
|
29
|
+
return coordinator.rstrip("/")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _read_all() -> dict[str, str]:
|
|
33
|
+
path = credentials_path()
|
|
34
|
+
if not path.exists():
|
|
35
|
+
return {}
|
|
36
|
+
try:
|
|
37
|
+
data = json.loads(path.read_text())
|
|
38
|
+
except (OSError, ValueError):
|
|
39
|
+
return {}
|
|
40
|
+
if not isinstance(data, dict):
|
|
41
|
+
return {}
|
|
42
|
+
return {k: v for k, v in data.items() if isinstance(k, str) and isinstance(v, str)}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _write_all(tokens: dict[str, str]) -> Path:
|
|
46
|
+
path = credentials_path()
|
|
47
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
path.write_text(json.dumps(tokens, indent=2, sort_keys=True))
|
|
49
|
+
os.chmod(path, 0o600)
|
|
50
|
+
return path
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def save_token(coordinator: str, token: str) -> Path:
|
|
54
|
+
tokens = _read_all()
|
|
55
|
+
tokens[_normalize(coordinator)] = token
|
|
56
|
+
return _write_all(tokens)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def load_token(coordinator: str) -> str | None:
|
|
60
|
+
return _read_all().get(_normalize(coordinator))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def clear_token(coordinator: str) -> bool:
|
|
64
|
+
tokens = _read_all()
|
|
65
|
+
key = _normalize(coordinator)
|
|
66
|
+
if key not in tokens:
|
|
67
|
+
return False
|
|
68
|
+
del tokens[key]
|
|
69
|
+
_write_all(tokens)
|
|
70
|
+
return True
|