flashnode 0.3.0__tar.gz → 0.3.2__tar.gz

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.
Files changed (62) hide show
  1. {flashnode-0.3.0 → flashnode-0.3.2}/PKG-INFO +40 -2
  2. {flashnode-0.3.0 → flashnode-0.3.2}/README.md +38 -0
  3. {flashnode-0.3.0 → flashnode-0.3.2}/flashnode/agent/cli.py +91 -4
  4. flashnode-0.3.2/flashnode/doctor.py +533 -0
  5. {flashnode-0.3.0 → flashnode-0.3.2}/flashnode/executor/argv_runner.py +4 -0
  6. {flashnode-0.3.0 → flashnode-0.3.2}/flashnode/executor/docker_runner.py +5 -0
  7. {flashnode-0.3.0 → flashnode-0.3.2}/flashnode/executor/hardening.py +33 -0
  8. {flashnode-0.3.0 → flashnode-0.3.2}/flashnode/executor/loop.py +101 -1
  9. {flashnode-0.3.0 → flashnode-0.3.2}/flashnode/inventory/capabilities.py +17 -1
  10. flashnode-0.3.2/flashnode/inventory/gpu.py +155 -0
  11. flashnode-0.3.2/flashnode/status.py +103 -0
  12. {flashnode-0.3.0 → flashnode-0.3.2}/flashnode.egg-info/PKG-INFO +40 -2
  13. {flashnode-0.3.0 → flashnode-0.3.2}/flashnode.egg-info/SOURCES.txt +10 -1
  14. {flashnode-0.3.0 → flashnode-0.3.2}/flashnode.egg-info/requires.txt +1 -1
  15. {flashnode-0.3.0 → flashnode-0.3.2}/pyproject.toml +11 -2
  16. {flashnode-0.3.0 → flashnode-0.3.2}/tests/test_agent.py +14 -15
  17. {flashnode-0.3.0 → flashnode-0.3.2}/tests/test_capabilities.py +26 -0
  18. flashnode-0.3.2/tests/test_doctor.py +530 -0
  19. flashnode-0.3.2/tests/test_gpu_probe.py +176 -0
  20. flashnode-0.3.2/tests/test_hardening.py +203 -0
  21. flashnode-0.3.2/tests/test_loop_counters.py +223 -0
  22. flashnode-0.3.2/tests/test_status.py +125 -0
  23. flashnode-0.3.2/tests/test_work_gate.py +96 -0
  24. flashnode-0.3.2/tests/test_work_status.py +129 -0
  25. flashnode-0.3.0/tests/test_hardening.py +0 -44
  26. {flashnode-0.3.0 → flashnode-0.3.2}/LICENSE +0 -0
  27. {flashnode-0.3.0 → flashnode-0.3.2}/flashnode/__init__.py +0 -0
  28. {flashnode-0.3.0 → flashnode-0.3.2}/flashnode/agent/__init__.py +0 -0
  29. {flashnode-0.3.0 → flashnode-0.3.2}/flashnode/agent/daemon.py +0 -0
  30. {flashnode-0.3.0 → flashnode-0.3.2}/flashnode/agent/kube.py +0 -0
  31. {flashnode-0.3.0 → flashnode-0.3.2}/flashnode/artifacts/__init__.py +0 -0
  32. {flashnode-0.3.0 → flashnode-0.3.2}/flashnode/benchmark/__init__.py +0 -0
  33. {flashnode-0.3.0 → flashnode-0.3.2}/flashnode/config/__init__.py +0 -0
  34. {flashnode-0.3.0 → flashnode-0.3.2}/flashnode/config/local_data.py +0 -0
  35. {flashnode-0.3.0 → flashnode-0.3.2}/flashnode/executor/__init__.py +0 -0
  36. {flashnode-0.3.0 → flashnode-0.3.2}/flashnode/executor/archives.py +0 -0
  37. {flashnode-0.3.0 → flashnode-0.3.2}/flashnode/executor/client.py +0 -0
  38. {flashnode-0.3.0 → flashnode-0.3.2}/flashnode/executor/images.py +0 -0
  39. {flashnode-0.3.0 → flashnode-0.3.2}/flashnode/executor/runner.py +0 -0
  40. {flashnode-0.3.0 → flashnode-0.3.2}/flashnode/identity/__init__.py +0 -0
  41. {flashnode-0.3.0 → flashnode-0.3.2}/flashnode/identity/credentials.py +0 -0
  42. {flashnode-0.3.0 → flashnode-0.3.2}/flashnode/identity/enrol.py +0 -0
  43. {flashnode-0.3.0 → flashnode-0.3.2}/flashnode/identity/store.py +0 -0
  44. {flashnode-0.3.0 → flashnode-0.3.2}/flashnode/inventory/__init__.py +0 -0
  45. {flashnode-0.3.0 → flashnode-0.3.2}/flashnode/telemetry/__init__.py +0 -0
  46. {flashnode-0.3.0 → flashnode-0.3.2}/flashnode.egg-info/dependency_links.txt +0 -0
  47. {flashnode-0.3.0 → flashnode-0.3.2}/flashnode.egg-info/entry_points.txt +0 -0
  48. {flashnode-0.3.0 → flashnode-0.3.2}/flashnode.egg-info/top_level.txt +0 -0
  49. {flashnode-0.3.0 → flashnode-0.3.2}/setup.cfg +0 -0
  50. {flashnode-0.3.0 → flashnode-0.3.2}/tests/test_allowlist_drift.py +0 -0
  51. {flashnode-0.3.0 → flashnode-0.3.2}/tests/test_archives.py +0 -0
  52. {flashnode-0.3.0 → flashnode-0.3.2}/tests/test_argv_runner.py +0 -0
  53. {flashnode-0.3.0 → flashnode-0.3.2}/tests/test_checkpoint_relay.py +0 -0
  54. {flashnode-0.3.0 → flashnode-0.3.2}/tests/test_credentials.py +0 -0
  55. {flashnode-0.3.0 → flashnode-0.3.2}/tests/test_docker_runner.py +0 -0
  56. {flashnode-0.3.0 → flashnode-0.3.2}/tests/test_enrol.py +0 -0
  57. {flashnode-0.3.0 → flashnode-0.3.2}/tests/test_executor.py +0 -0
  58. {flashnode-0.3.0 → flashnode-0.3.2}/tests/test_hardening_platform.py +0 -0
  59. {flashnode-0.3.0 → flashnode-0.3.2}/tests/test_identity.py +0 -0
  60. {flashnode-0.3.0 → flashnode-0.3.2}/tests/test_images_allowlist.py +0 -0
  61. {flashnode-0.3.0 → flashnode-0.3.2}/tests/test_interfaces.py +0 -0
  62. {flashnode-0.3.0 → flashnode-0.3.2}/tests/test_local_data.py +0 -0
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flashnode
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: Open host agent for the FlashML fragmented-compute network: join, benchmark, execute sandboxed ML tasks, earn contribution credits.
5
5
  License: Apache-2.0
6
6
  Project-URL: Homepage, https://github.com/Zolli-Labs/flashnode
7
7
  Requires-Python: >=3.10
8
8
  Description-Content-Type: text/markdown
9
9
  License-File: LICENSE
10
- Requires-Dist: flashruntime<0.5,>=0.4
10
+ Requires-Dist: flashruntime<0.5,>=0.4.1
11
11
  Requires-Dist: psutil>=5.9
12
12
  Requires-Dist: websockets>=12
13
13
  Requires-Dist: cryptography>=42
@@ -49,10 +49,48 @@ verified results. Two profiles:
49
49
  inside managed pools (DaemonSet); KubeRay owns workload pods there.
50
50
  Implemented.
51
51
 
52
+ ## Check your machine first
53
+
54
+ ```bash
55
+ flashnode doctor
56
+ ```
57
+
58
+ Six checks: the `docker` CLI, the engine behind it, an anonymous pull of a
59
+ curated image, whether a container can see your work directory, whether your
60
+ Docker accepts the sandbox flags, and whether any directories you lend via
61
+ `FLASHNODE_LOCAL_DATA` are readable. Every failure names the fix.
62
+
63
+ Run it once before `flashnode work`. `work` repeats all of it **except** the
64
+ image pull — a registry blip should not stop an agent whose images are
65
+ already cached — and refuses to start if anything fails, because a host that
66
+ cannot run tasks should not be claiming them.
67
+
68
+ ## While it runs
69
+
70
+ On a terminal you get a live status block:
71
+
72
+ ```
73
+ flashnode 0.3.2 · flashml-api.onrender.com · up 2h14m
74
+ running fed-2e2d4d6ab57f · attempt 1 · 38s
75
+ session 12 accepted 0 failed
76
+ heartbeat 2s ago
77
+ ```
78
+
79
+ `waiting · no work queued — this is normal` means exactly that: the pool has
80
+ nothing for you right now, and your machine is fine. Pipe the output
81
+ anywhere, or pass `--log-json`, and you get the machine-readable log instead.
82
+
83
+ If three tasks in a row fail **on your machine**, the agent re-runs its own
84
+ checks. Pass, and the jobs were broken rather than your host, so it carries
85
+ on. Fail, and it stops claiming and tells you what to fix — instead of
86
+ burning a job's retries on a machine that cannot run anything.
87
+ `--max-consecutive-failures 0` turns that off.
88
+
52
89
  ## What it does today
53
90
 
54
91
  ```bash
55
92
  pip install -e . # plus: pip install -e ../flashruntime
93
+ flashnode doctor # check this machine can run tasks
56
94
  flashnode work --coordinator http://<coordinator>:8100
57
95
  # optional hardening / pool config:
58
96
  # FLASHNODE_JOIN_CODE=... join-code-gated pools
@@ -32,10 +32,48 @@ verified results. Two profiles:
32
32
  inside managed pools (DaemonSet); KubeRay owns workload pods there.
33
33
  Implemented.
34
34
 
35
+ ## Check your machine first
36
+
37
+ ```bash
38
+ flashnode doctor
39
+ ```
40
+
41
+ Six checks: the `docker` CLI, the engine behind it, an anonymous pull of a
42
+ curated image, whether a container can see your work directory, whether your
43
+ Docker accepts the sandbox flags, and whether any directories you lend via
44
+ `FLASHNODE_LOCAL_DATA` are readable. Every failure names the fix.
45
+
46
+ Run it once before `flashnode work`. `work` repeats all of it **except** the
47
+ image pull — a registry blip should not stop an agent whose images are
48
+ already cached — and refuses to start if anything fails, because a host that
49
+ cannot run tasks should not be claiming them.
50
+
51
+ ## While it runs
52
+
53
+ On a terminal you get a live status block:
54
+
55
+ ```
56
+ flashnode 0.3.2 · flashml-api.onrender.com · up 2h14m
57
+ running fed-2e2d4d6ab57f · attempt 1 · 38s
58
+ session 12 accepted 0 failed
59
+ heartbeat 2s ago
60
+ ```
61
+
62
+ `waiting · no work queued — this is normal` means exactly that: the pool has
63
+ nothing for you right now, and your machine is fine. Pipe the output
64
+ anywhere, or pass `--log-json`, and you get the machine-readable log instead.
65
+
66
+ If three tasks in a row fail **on your machine**, the agent re-runs its own
67
+ checks. Pass, and the jobs were broken rather than your host, so it carries
68
+ on. Fail, and it stops claiming and tells you what to fix — instead of
69
+ burning a job's retries on a machine that cannot run anything.
70
+ `--max-consecutive-failures 0` turns that off.
71
+
35
72
  ## What it does today
36
73
 
37
74
  ```bash
38
75
  pip install -e . # plus: pip install -e ../flashruntime
76
+ flashnode doctor # check this machine can run tasks
39
77
  flashnode work --coordinator http://<coordinator>:8100
40
78
  # optional hardening / pool config:
41
79
  # FLASHNODE_JOIN_CODE=... join-code-gated pools
@@ -26,6 +26,7 @@ commands:
26
26
  (--coordinator URL; --token TOKEN to skip the browser step)
27
27
  logout remove the saved bearer token for a FlashRuntime coordinator
28
28
  (--coordinator URL)
29
+ doctor check this machine can run tasks (docker engine, images, mounts)
29
30
  join connect this machine to a FlashML control plane (not yet implemented)
30
31
  status show node identity, capabilities, and active leases (not yet implemented)
31
32
  leave drain and disconnect (not yet implemented)
@@ -179,6 +180,16 @@ def _work(args: list[str]) -> int:
179
180
  )
180
181
  parser.add_argument("--max-tasks", type=int, default=None)
181
182
  parser.add_argument("--poll-seconds", type=float, default=1.0)
183
+ parser.add_argument(
184
+ "--log-json", action="store_true",
185
+ help="keep the machine-readable JSON log instead of the live status view",
186
+ )
187
+ parser.add_argument(
188
+ "--max-consecutive-failures", type=int,
189
+ default=int(os.environ.get("FLASHNODE_MAX_CONSECUTIVE_FAILURES", "3")),
190
+ help="host-side failures in a row before re-checking this machine and "
191
+ "stopping if it is broken (0 disables)",
192
+ )
182
193
  opts = parser.parse_args(args)
183
194
 
184
195
  runner = None
@@ -201,10 +212,35 @@ def _work(args: list[str]) -> int:
201
212
  # (subprocess.run(["docker", ...])); if it isn't installed that call
202
213
  # raises FileNotFoundError deep inside a task attempt. Check for it
203
214
  # here, at startup, rather than let the agent die on the first task.
204
- if shutil.which("docker") is None:
215
+ # A `docker` binary on PATH says nothing about the daemon behind it,
216
+ # the credential helper Docker consults when it pulls, or whether
217
+ # this machine's work directory is even visible inside the VM. Both
218
+ # hosts that stopped the 2026-08-02 §10 run-through passed the old
219
+ # `shutil.which` check and then failed every task they claimed —
220
+ # docker_runner raises TaskExecutionError, loop.py calls fail() and
221
+ # claims the next one, forever, silently.
222
+ #
223
+ # pull=False deliberately: an agent is a long-running daemon on
224
+ # someone else's machine, and a transient registry blip must not
225
+ # stop one whose images are already cached. `flashnode doctor` does
226
+ # the pull.
227
+ #
228
+ # The gate reads doctor's own NON_BLOCKING_STATUSES rather than
229
+ # testing `!= "ok"` here. Not every check is a gate: the GPU check is
230
+ # informational, because most volunteers have no GPU and blocking on
231
+ # it would lock the entire existing fleet out of CPU work the moment
232
+ # they upgraded. Keeping the predicate in one place is what stops
233
+ # `flashnode work` and `flashnode doctor` disagreeing about which
234
+ # verdicts are fatal.
235
+ from flashnode.doctor import NON_BLOCKING_STATUSES, format_results, run_checks
236
+
237
+ results = run_checks(pull=False)
238
+ if any(r.status not in NON_BLOCKING_STATUSES for r in results):
205
239
  print(
206
- f"flashnode work: --runner {opts.runner} requires the `docker` CLI "
207
- "on PATH refusing to start without it",
240
+ f"flashnode work: this machine cannot run tasks with "
241
+ f"--runner {opts.runner}.\n" + format_results(results)
242
+ + "\n\nRun `flashnode doctor` for the full check, including "
243
+ "the image pull this skipped.",
208
244
  file=sys.stderr,
209
245
  )
210
246
  return 2
@@ -246,10 +282,29 @@ def _work(args: list[str]) -> int:
246
282
  module_capable=(opts.runner != "argv"),
247
283
  )
248
284
  client.register(registration)
285
+ from flashnode.doctor import NON_BLOCKING_STATUSES, run_checks
286
+
287
+ def _blocking_problems():
288
+ """What the loop calls after a streak of host-side failures.
289
+
290
+ Filtered HERE, with the same set the startup gate reads, so the two
291
+ cannot drift — and so `loop.py` never has to import the doctor
292
+ (which would close a loop -> doctor -> executor -> loop cycle).
293
+ The GPU check reports "info" and never fails; a loop testing
294
+ `!= "ok"` itself would quarantine every CPU-only volunteer.
295
+
296
+ pull=False for the same reason the startup gate uses it: a registry
297
+ blip must not stop an agent whose images are already cached.
298
+ """
299
+ return [r for r in run_checks(pull=False)
300
+ if r.status not in NON_BLOCKING_STATUSES]
301
+
249
302
  loop = ExecutorLoop(
250
303
  client, node_id, runner=runner,
251
304
  poll_seconds=opts.poll_seconds, workdir_base=workdir_base,
252
305
  registration=registration, # survives coordinator restarts
306
+ health_check=_blocking_problems,
307
+ max_consecutive_failures=opts.max_consecutive_failures,
253
308
  )
254
309
 
255
310
  def _stop(signum, frame): # noqa: ARG001
@@ -257,7 +312,35 @@ def _work(args: list[str]) -> int:
257
312
 
258
313
  signal.signal(signal.SIGTERM, _stop)
259
314
  signal.signal(signal.SIGINT, _stop)
260
- accepted = loop.run(max_tasks=opts.max_tasks)
315
+ view = None
316
+ if sys.stdout.isatty() and not opts.log_json:
317
+ # Two writers redrawing one terminal is unreadable, so the JSON
318
+ # handler goes when the view arrives. --log-json keeps it, and a
319
+ # non-TTY never gets here — ANSI cursor movement into a pipe or a
320
+ # systemd journal is corruption, not output.
321
+ from flashnode.status import StatusView
322
+
323
+ logging.getLogger().handlers.clear()
324
+ view = StatusView(loop, coordinator=opts.coordinator,
325
+ version=__version__, stream=sys.stdout)
326
+ view.start()
327
+ try:
328
+ accepted = loop.run(max_tasks=opts.max_tasks)
329
+ finally:
330
+ if view is not None:
331
+ view.stop()
332
+
333
+ if getattr(loop, "quarantined", False):
334
+ from flashnode.doctor import format_results
335
+
336
+ print(
337
+ "\nflashnode work: stopping — this machine can no longer run "
338
+ "tasks.\n" + format_results(loop.health_report or [])
339
+ + "\n\nFix the above, then `flashnode doctor` to confirm before "
340
+ "restarting.",
341
+ file=sys.stderr,
342
+ )
343
+ return 2
261
344
  print(f"flashnode work: {accepted} task(s) accepted", file=sys.stderr)
262
345
  return 0
263
346
 
@@ -270,6 +353,10 @@ def main(argv: list[str] | None = None) -> int:
270
353
  return agent_main()
271
354
  if args and args[0] == "work":
272
355
  return _work(args[1:])
356
+ if args and args[0] == "doctor":
357
+ from flashnode.doctor import doctor_main
358
+
359
+ return doctor_main(args[1:])
273
360
  if args and args[0] == "login":
274
361
  return _login(args[1:])
275
362
  if args and args[0] == "logout":