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.
@@ -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,5 @@
1
+ """Node keys and registration.
2
+
3
+ Generates an Ed25519 keypair on first run; the node registers a signed node
4
+ ID and receives short-lived session credentials.
5
+ """
@@ -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