flashnode 0.3.1__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.
- {flashnode-0.3.1/flashnode.egg-info → flashnode-0.3.2}/PKG-INFO +23 -2
- flashnode-0.3.1/PKG-INFO → flashnode-0.3.2/README.md +21 -17
- {flashnode-0.3.1 → flashnode-0.3.2}/flashnode/agent/cli.py +68 -3
- {flashnode-0.3.1 → flashnode-0.3.2}/flashnode/doctor.py +93 -6
- {flashnode-0.3.1 → flashnode-0.3.2}/flashnode/executor/argv_runner.py +4 -0
- {flashnode-0.3.1 → flashnode-0.3.2}/flashnode/executor/docker_runner.py +5 -0
- {flashnode-0.3.1 → flashnode-0.3.2}/flashnode/executor/hardening.py +33 -0
- {flashnode-0.3.1 → flashnode-0.3.2}/flashnode/executor/loop.py +101 -1
- {flashnode-0.3.1 → flashnode-0.3.2}/flashnode/inventory/capabilities.py +17 -1
- flashnode-0.3.2/flashnode/inventory/gpu.py +155 -0
- flashnode-0.3.2/flashnode/status.py +103 -0
- flashnode-0.3.1/README.md → flashnode-0.3.2/flashnode.egg-info/PKG-INFO +38 -0
- {flashnode-0.3.1 → flashnode-0.3.2}/flashnode.egg-info/SOURCES.txt +7 -1
- {flashnode-0.3.1 → flashnode-0.3.2}/flashnode.egg-info/requires.txt +1 -1
- {flashnode-0.3.1 → flashnode-0.3.2}/pyproject.toml +11 -2
- {flashnode-0.3.1 → flashnode-0.3.2}/tests/test_capabilities.py +26 -0
- {flashnode-0.3.1 → flashnode-0.3.2}/tests/test_doctor.py +122 -2
- flashnode-0.3.2/tests/test_gpu_probe.py +176 -0
- flashnode-0.3.2/tests/test_hardening.py +203 -0
- flashnode-0.3.2/tests/test_loop_counters.py +223 -0
- flashnode-0.3.2/tests/test_status.py +125 -0
- {flashnode-0.3.1 → flashnode-0.3.2}/tests/test_work_gate.py +25 -0
- flashnode-0.3.2/tests/test_work_status.py +129 -0
- flashnode-0.3.1/tests/test_hardening.py +0 -44
- {flashnode-0.3.1 → flashnode-0.3.2}/LICENSE +0 -0
- {flashnode-0.3.1 → flashnode-0.3.2}/flashnode/__init__.py +0 -0
- {flashnode-0.3.1 → flashnode-0.3.2}/flashnode/agent/__init__.py +0 -0
- {flashnode-0.3.1 → flashnode-0.3.2}/flashnode/agent/daemon.py +0 -0
- {flashnode-0.3.1 → flashnode-0.3.2}/flashnode/agent/kube.py +0 -0
- {flashnode-0.3.1 → flashnode-0.3.2}/flashnode/artifacts/__init__.py +0 -0
- {flashnode-0.3.1 → flashnode-0.3.2}/flashnode/benchmark/__init__.py +0 -0
- {flashnode-0.3.1 → flashnode-0.3.2}/flashnode/config/__init__.py +0 -0
- {flashnode-0.3.1 → flashnode-0.3.2}/flashnode/config/local_data.py +0 -0
- {flashnode-0.3.1 → flashnode-0.3.2}/flashnode/executor/__init__.py +0 -0
- {flashnode-0.3.1 → flashnode-0.3.2}/flashnode/executor/archives.py +0 -0
- {flashnode-0.3.1 → flashnode-0.3.2}/flashnode/executor/client.py +0 -0
- {flashnode-0.3.1 → flashnode-0.3.2}/flashnode/executor/images.py +0 -0
- {flashnode-0.3.1 → flashnode-0.3.2}/flashnode/executor/runner.py +0 -0
- {flashnode-0.3.1 → flashnode-0.3.2}/flashnode/identity/__init__.py +0 -0
- {flashnode-0.3.1 → flashnode-0.3.2}/flashnode/identity/credentials.py +0 -0
- {flashnode-0.3.1 → flashnode-0.3.2}/flashnode/identity/enrol.py +0 -0
- {flashnode-0.3.1 → flashnode-0.3.2}/flashnode/identity/store.py +0 -0
- {flashnode-0.3.1 → flashnode-0.3.2}/flashnode/inventory/__init__.py +0 -0
- {flashnode-0.3.1 → flashnode-0.3.2}/flashnode/telemetry/__init__.py +0 -0
- {flashnode-0.3.1 → flashnode-0.3.2}/flashnode.egg-info/dependency_links.txt +0 -0
- {flashnode-0.3.1 → flashnode-0.3.2}/flashnode.egg-info/entry_points.txt +0 -0
- {flashnode-0.3.1 → flashnode-0.3.2}/flashnode.egg-info/top_level.txt +0 -0
- {flashnode-0.3.1 → flashnode-0.3.2}/setup.cfg +0 -0
- {flashnode-0.3.1 → flashnode-0.3.2}/tests/test_agent.py +0 -0
- {flashnode-0.3.1 → flashnode-0.3.2}/tests/test_allowlist_drift.py +0 -0
- {flashnode-0.3.1 → flashnode-0.3.2}/tests/test_archives.py +0 -0
- {flashnode-0.3.1 → flashnode-0.3.2}/tests/test_argv_runner.py +0 -0
- {flashnode-0.3.1 → flashnode-0.3.2}/tests/test_checkpoint_relay.py +0 -0
- {flashnode-0.3.1 → flashnode-0.3.2}/tests/test_credentials.py +0 -0
- {flashnode-0.3.1 → flashnode-0.3.2}/tests/test_docker_runner.py +0 -0
- {flashnode-0.3.1 → flashnode-0.3.2}/tests/test_enrol.py +0 -0
- {flashnode-0.3.1 → flashnode-0.3.2}/tests/test_executor.py +0 -0
- {flashnode-0.3.1 → flashnode-0.3.2}/tests/test_hardening_platform.py +0 -0
- {flashnode-0.3.1 → flashnode-0.3.2}/tests/test_identity.py +0 -0
- {flashnode-0.3.1 → flashnode-0.3.2}/tests/test_images_allowlist.py +0 -0
- {flashnode-0.3.1 → flashnode-0.3.2}/tests/test_interfaces.py +0 -0
- {flashnode-0.3.1 → 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.
|
|
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
|
|
@@ -65,6 +65,27 @@ image pull — a registry blip should not stop an agent whose images are
|
|
|
65
65
|
already cached — and refuses to start if anything fails, because a host that
|
|
66
66
|
cannot run tasks should not be claiming them.
|
|
67
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
|
+
|
|
68
89
|
## What it does today
|
|
69
90
|
|
|
70
91
|
```bash
|
|
@@ -1,20 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: flashnode
|
|
3
|
-
Version: 0.3.1
|
|
4
|
-
Summary: Open host agent for the FlashML fragmented-compute network: join, benchmark, execute sandboxed ML tasks, earn contribution credits.
|
|
5
|
-
License: Apache-2.0
|
|
6
|
-
Project-URL: Homepage, https://github.com/Zolli-Labs/flashnode
|
|
7
|
-
Requires-Python: >=3.10
|
|
8
|
-
Description-Content-Type: text/markdown
|
|
9
|
-
License-File: LICENSE
|
|
10
|
-
Requires-Dist: flashruntime<0.5,>=0.4
|
|
11
|
-
Requires-Dist: psutil>=5.9
|
|
12
|
-
Requires-Dist: websockets>=12
|
|
13
|
-
Requires-Dist: cryptography>=42
|
|
14
|
-
Provides-Extra: dev
|
|
15
|
-
Requires-Dist: pytest; extra == "dev"
|
|
16
|
-
Dynamic: license-file
|
|
17
|
-
|
|
18
1
|
# FlashNode
|
|
19
2
|
|
|
20
3
|
> **The open host agent of the FlashML system.** Install FlashNode on a
|
|
@@ -65,6 +48,27 @@ image pull — a registry blip should not stop an agent whose images are
|
|
|
65
48
|
already cached — and refuses to start if anything fails, because a host that
|
|
66
49
|
cannot run tasks should not be claiming them.
|
|
67
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
|
+
|
|
68
72
|
## What it does today
|
|
69
73
|
|
|
70
74
|
```bash
|
|
@@ -180,6 +180,16 @@ def _work(args: list[str]) -> int:
|
|
|
180
180
|
)
|
|
181
181
|
parser.add_argument("--max-tasks", type=int, default=None)
|
|
182
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
|
+
)
|
|
183
193
|
opts = parser.parse_args(args)
|
|
184
194
|
|
|
185
195
|
runner = None
|
|
@@ -214,10 +224,18 @@ def _work(args: list[str]) -> int:
|
|
|
214
224
|
# someone else's machine, and a transient registry blip must not
|
|
215
225
|
# stop one whose images are already cached. `flashnode doctor` does
|
|
216
226
|
# the pull.
|
|
217
|
-
|
|
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
|
|
218
236
|
|
|
219
237
|
results = run_checks(pull=False)
|
|
220
|
-
if any(r.status
|
|
238
|
+
if any(r.status not in NON_BLOCKING_STATUSES for r in results):
|
|
221
239
|
print(
|
|
222
240
|
f"flashnode work: this machine cannot run tasks with "
|
|
223
241
|
f"--runner {opts.runner}.\n" + format_results(results)
|
|
@@ -264,10 +282,29 @@ def _work(args: list[str]) -> int:
|
|
|
264
282
|
module_capable=(opts.runner != "argv"),
|
|
265
283
|
)
|
|
266
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
|
+
|
|
267
302
|
loop = ExecutorLoop(
|
|
268
303
|
client, node_id, runner=runner,
|
|
269
304
|
poll_seconds=opts.poll_seconds, workdir_base=workdir_base,
|
|
270
305
|
registration=registration, # survives coordinator restarts
|
|
306
|
+
health_check=_blocking_problems,
|
|
307
|
+
max_consecutive_failures=opts.max_consecutive_failures,
|
|
271
308
|
)
|
|
272
309
|
|
|
273
310
|
def _stop(signum, frame): # noqa: ARG001
|
|
@@ -275,7 +312,35 @@ def _work(args: list[str]) -> int:
|
|
|
275
312
|
|
|
276
313
|
signal.signal(signal.SIGTERM, _stop)
|
|
277
314
|
signal.signal(signal.SIGINT, _stop)
|
|
278
|
-
|
|
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
|
|
279
344
|
print(f"flashnode work: {accepted} task(s) accepted", file=sys.stderr)
|
|
280
345
|
return 0
|
|
281
346
|
|
|
@@ -34,12 +34,16 @@ from flashnode.executor.hardening import (
|
|
|
34
34
|
_bind_mount_source,
|
|
35
35
|
harden_args,
|
|
36
36
|
)
|
|
37
|
+
from flashnode.inventory.gpu import probe_gpus
|
|
37
38
|
|
|
38
39
|
__all__ = [
|
|
40
|
+
"IMAGE_TAG",
|
|
41
|
+
"NON_BLOCKING_STATUSES",
|
|
39
42
|
"PROBE_IMAGE",
|
|
40
43
|
"CheckResult",
|
|
41
44
|
"check_cli_on_path",
|
|
42
45
|
"check_engine",
|
|
46
|
+
"check_gpus",
|
|
43
47
|
"check_hardened_run",
|
|
44
48
|
"check_local_datasets",
|
|
45
49
|
"check_pull",
|
|
@@ -52,15 +56,48 @@ __all__ = [
|
|
|
52
56
|
"run_command",
|
|
53
57
|
]
|
|
54
58
|
|
|
59
|
+
#: The curated-image tag this agent probes. It must equal `IMAGE_TAG` in
|
|
60
|
+
#: `.github/workflows/images.yml` (which builds the images) and in
|
|
61
|
+
#: flashml-cloud's `apps/api/flashml_cloud_api/images.py` (which hands
|
|
62
|
+
#: references to jobs). All three are bumped together; the workflow's
|
|
63
|
+
#: immutability guard refuses to repush an existing tag, so a bump is
|
|
64
|
+
#: mandatory for any image change, never cosmetic.
|
|
65
|
+
#:
|
|
66
|
+
#: Named rather than baked into the string below because the drift is
|
|
67
|
+
#: SILENT: old tags keep pulling forever, so a doctor probing an image two
|
|
68
|
+
#: releases behind the fleet passes every check and certifies a host against
|
|
69
|
+
#: an image no job will ever use. Nothing in either repo tests that the three
|
|
70
|
+
#: agree — the test that would have is gone, deleted on 2026-08-01 when the
|
|
71
|
+
#: image sources moved to this repo. One greppable constant per repo is what
|
|
72
|
+
#: is left holding this together.
|
|
73
|
+
IMAGE_TAG = "2026.08.2"
|
|
74
|
+
|
|
55
75
|
#: The image every container-level check runs. python-slim, never
|
|
56
76
|
#: pytorch-cpu: registry auth, TLS and the credential helper are properties
|
|
57
77
|
#: of the REGISTRY, so the smallest curated image proves the same thing, and
|
|
58
78
|
#: making a volunteer download gigabytes to learn their helper is missing is
|
|
59
|
-
#: a hostile diagnostic.
|
|
60
|
-
|
|
79
|
+
#: a hostile diagnostic. Never pytorch-cuda either, for the same reason
|
|
80
|
+
#: doubled: a GPU host that cannot pull has the same broken registry as
|
|
81
|
+
#: everyone else, and would find out 3 GB later.
|
|
82
|
+
PROBE_IMAGE = f"ghcr.io/zolli-labs/flashml-python-slim:{IMAGE_TAG}"
|
|
61
83
|
|
|
62
84
|
CommandRunner = Callable[..., subprocess.CompletedProcess]
|
|
63
85
|
|
|
86
|
+
#: Statuses that do NOT stop a host from working. Everything else does, and
|
|
87
|
+
#: an unrecognised status counts as blocking — this is the certification
|
|
88
|
+
#: predicate, so it fails closed (see `exit_code`).
|
|
89
|
+
#:
|
|
90
|
+
#: The set exists because "info" does. Six of the seven checks answer "can
|
|
91
|
+
#: this machine run a task at all", and blocking is the only correct answer
|
|
92
|
+
#: when one of them fails. The GPU check answers something else — "is the
|
|
93
|
+
#: hardware you think you contributed actually visible" — and most volunteers
|
|
94
|
+
#: have no GPU and must keep taking CPU work. Blocking on that would lock the
|
|
95
|
+
#: entire existing fleet out of the network on upgrade.
|
|
96
|
+
#:
|
|
97
|
+
#: `flashnode work` reads THIS set rather than testing `!= "ok"` itself, so
|
|
98
|
+
#: the gate cannot drift from the exit code.
|
|
99
|
+
NON_BLOCKING_STATUSES = frozenset({"ok", "info"})
|
|
100
|
+
|
|
64
101
|
|
|
65
102
|
@dataclass(frozen=True)
|
|
66
103
|
class CheckResult:
|
|
@@ -71,7 +108,7 @@ class CheckResult:
|
|
|
71
108
|
"""
|
|
72
109
|
|
|
73
110
|
name: str
|
|
74
|
-
status: str # "ok" | "fail" | "skip"
|
|
111
|
+
status: str # "ok" | "fail" | "skip" | "info" (see NON_BLOCKING_STATUSES)
|
|
75
112
|
detail: str = ""
|
|
76
113
|
fix: str = ""
|
|
77
114
|
|
|
@@ -353,6 +390,44 @@ def check_local_datasets(raw: str | None = None) -> CheckResult:
|
|
|
353
390
|
return CheckResult(name, "ok", detail=", ".join(sorted(mapping)))
|
|
354
391
|
|
|
355
392
|
|
|
393
|
+
def check_gpus(probe: Callable[[], list] | None = None) -> CheckResult:
|
|
394
|
+
"""What this host will advertise as GPUs — and NOTHING is a fine answer.
|
|
395
|
+
|
|
396
|
+
The only non-gating check in this module (spec §8). It never returns
|
|
397
|
+
"fail": a host with no GPU is the normal host, and the CPU work they
|
|
398
|
+
signed up for is unaffected. It exists for the other case — a host who
|
|
399
|
+
BELIEVES they contributed a GPU learning here that the agent cannot see
|
|
400
|
+
it, rather than wondering why no GPU job ever arrives. That is exactly
|
|
401
|
+
the class of silent failure `doctor` was written for.
|
|
402
|
+
|
|
403
|
+
It reports the same probe `discover()` advertises, not `nvidia-smi`
|
|
404
|
+
directly. A doctor that consults a different source than the agent is a
|
|
405
|
+
doctor that can pass while the agent registers `[]`.
|
|
406
|
+
"""
|
|
407
|
+
name = "GPU devices"
|
|
408
|
+
probe = probe or probe_gpus
|
|
409
|
+
try:
|
|
410
|
+
gpus = list(probe())
|
|
411
|
+
except Exception as exc: # noqa: BLE001
|
|
412
|
+
# probe_gpus already promises this cannot happen. If it ever does,
|
|
413
|
+
# a diagnostic that crashes has diagnosed nothing — and crashing on
|
|
414
|
+
# the one check that was never allowed to block would be perverse.
|
|
415
|
+
return CheckResult(name, "info", detail=f"could not probe for GPUs: {exc}")
|
|
416
|
+
if not gpus:
|
|
417
|
+
return CheckResult(
|
|
418
|
+
name, "info",
|
|
419
|
+
detail="no GPU detected — this host will take CPU work only",
|
|
420
|
+
fix="Nothing to do unless you expected a GPU here. If you did: "
|
|
421
|
+
"check `nvidia-smi` runs in this terminal, and install the "
|
|
422
|
+
"NVIDIA Container Toolkit so containers can see the device.",
|
|
423
|
+
)
|
|
424
|
+
lines = [f"{len(gpus)} GPU{'s' if len(gpus) != 1 else ''}"]
|
|
425
|
+
for gpu in gpus:
|
|
426
|
+
memory = f", {gpu.memory_total_mb} MiB" if gpu.memory_total_mb else ""
|
|
427
|
+
lines.append(f"{gpu.index}: {gpu.name or 'unnamed device'}{memory}")
|
|
428
|
+
return CheckResult(name, "ok", detail="\n".join(lines))
|
|
429
|
+
|
|
430
|
+
|
|
356
431
|
def run_checks(
|
|
357
432
|
*,
|
|
358
433
|
pull: bool,
|
|
@@ -360,6 +435,7 @@ def run_checks(
|
|
|
360
435
|
which: Callable[[str], str | None] | None = None,
|
|
361
436
|
workdir: Path | None = None,
|
|
362
437
|
raw_local_data: str | None = None,
|
|
438
|
+
gpu_probe: Callable[[], list] | None = None,
|
|
363
439
|
) -> list[CheckResult]:
|
|
364
440
|
"""Run every check, in order, skipping what a prior failure makes
|
|
365
441
|
meaningless.
|
|
@@ -396,13 +472,18 @@ def run_checks(
|
|
|
396
472
|
if results[-1].status != "ok":
|
|
397
473
|
stopped = True
|
|
398
474
|
results.append(check_local_datasets(raw=raw_local_data))
|
|
475
|
+
# Neither of the last two depends on Docker, so neither is skipped by a
|
|
476
|
+
# failure above: a host debugging their engine should still learn that
|
|
477
|
+
# their local-data label is a typo and that their GPU is invisible.
|
|
478
|
+
results.append(check_gpus(probe=gpu_probe))
|
|
399
479
|
return results
|
|
400
480
|
|
|
401
481
|
|
|
402
482
|
def format_results(results: Sequence[CheckResult]) -> str:
|
|
403
483
|
lines = []
|
|
404
484
|
for r in results:
|
|
405
|
-
tag = {"ok": "[ok] ", "fail": "[FAIL]", "skip": "[skip]"
|
|
485
|
+
tag = {"ok": "[ok] ", "fail": "[FAIL]", "skip": "[skip]",
|
|
486
|
+
"info": "[info]"}.get(r.status, "[????]")
|
|
406
487
|
head = r.detail.splitlines()[0] if r.detail else ""
|
|
407
488
|
lines.append(f" {tag} {r.name:<30} {head}".rstrip())
|
|
408
489
|
for extra in r.detail.splitlines()[1:]:
|
|
@@ -429,8 +510,14 @@ def format_results(results: Sequence[CheckResult]) -> str:
|
|
|
429
510
|
def exit_code(results: Sequence[CheckResult]) -> int:
|
|
430
511
|
"""Skipped counts as not-passed. A host whose checks did not run has not
|
|
431
512
|
been certified, and calling it healthy is the exact failure this command
|
|
432
|
-
removes.
|
|
433
|
-
|
|
513
|
+
removes.
|
|
514
|
+
|
|
515
|
+
"info" does not count against a host — see NON_BLOCKING_STATUSES. The
|
|
516
|
+
membership test is deliberately the way round that makes an unknown
|
|
517
|
+
status BLOCK: a check whose verdict this function does not understand
|
|
518
|
+
has not certified anything.
|
|
519
|
+
"""
|
|
520
|
+
return 0 if all(r.status in NON_BLOCKING_STATUSES for r in results) else 1
|
|
434
521
|
|
|
435
522
|
|
|
436
523
|
def doctor_main(argv: list[str]) -> int:
|
|
@@ -76,6 +76,10 @@ class ArgvDockerRunner:
|
|
|
76
76
|
# here (before any subprocess) if this host does not lend them
|
|
77
77
|
# — see hardening.local_data_mounts.
|
|
78
78
|
local_inputs=payload.get("local_inputs"),
|
|
79
|
+
# The device count the job asked for. Absent for every job
|
|
80
|
+
# that does not ask, and validated in hardening.gpu_flags
|
|
81
|
+
# rather than here, so both runners cannot drift on it.
|
|
82
|
+
gpus=payload.get("gpus"),
|
|
79
83
|
),
|
|
80
84
|
*env_args,
|
|
81
85
|
image, # argv follows the image, where docker treats it
|
|
@@ -80,6 +80,11 @@ class DockerRunner:
|
|
|
80
80
|
# See hardening.local_data_mounts: read-only, only what the
|
|
81
81
|
# payload named, and a refusal if this host does not lend it.
|
|
82
82
|
local_inputs=payload.get("local_inputs"),
|
|
83
|
+
# The device count the job asked for — see
|
|
84
|
+
# hardening.gpu_flags. Both runners forward this; a runner
|
|
85
|
+
# that quietly does not is a GPU job silently running on the
|
|
86
|
+
# CPU, which still succeeds and so tells nobody.
|
|
87
|
+
gpus=payload.get("gpus"),
|
|
83
88
|
),
|
|
84
89
|
image,
|
|
85
90
|
"python", "-m", module,
|
|
@@ -183,6 +183,30 @@ def local_data_mounts(
|
|
|
183
183
|
return args
|
|
184
184
|
|
|
185
185
|
|
|
186
|
+
def gpu_flags(gpus: object) -> list[str]:
|
|
187
|
+
"""`--gpus N`, or nothing at all.
|
|
188
|
+
|
|
189
|
+
Every other flag in this module NARROWS what a container may touch.
|
|
190
|
+
This one widens it — it hands a device on someone else's machine to a
|
|
191
|
+
stranger's code — so it is absent unless the payload asked, and the ask
|
|
192
|
+
has to be a plain positive integer.
|
|
193
|
+
|
|
194
|
+
Anything else emits nothing rather than raising. The coordinator's
|
|
195
|
+
placement gate already refused a task whose `gpus` was not a positive
|
|
196
|
+
int, so a bad value arriving here means gate and payload disagree; this
|
|
197
|
+
is the second lock, not the validation. Two values are worth naming:
|
|
198
|
+
|
|
199
|
+
- `True`. `bool` is an `int` subclass, so a naive check reads it as
|
|
200
|
+
"1 GPU" — and `--gpus True` is not something docker accepts anyway.
|
|
201
|
+
- `"all"`. This is *valid* docker syntax meaning every device on the
|
|
202
|
+
host, which is exactly why an untrusted string must never reach the
|
|
203
|
+
flag. A task asks for a count; it does not get to name a device.
|
|
204
|
+
"""
|
|
205
|
+
if isinstance(gpus, bool) or not isinstance(gpus, int) or gpus <= 0:
|
|
206
|
+
return []
|
|
207
|
+
return ["--gpus", str(gpus)]
|
|
208
|
+
|
|
209
|
+
|
|
186
210
|
def harden_args(
|
|
187
211
|
workdir: Path,
|
|
188
212
|
*,
|
|
@@ -191,6 +215,7 @@ def harden_args(
|
|
|
191
215
|
pids_limit: int = 512,
|
|
192
216
|
local_inputs: object = None,
|
|
193
217
|
local_data: dict[str, str] | None = None,
|
|
218
|
+
gpus: object = None,
|
|
194
219
|
) -> list[str]:
|
|
195
220
|
"""Docker flags common to every sandboxed task.
|
|
196
221
|
|
|
@@ -199,6 +224,11 @@ def harden_args(
|
|
|
199
224
|
byte-for-byte what they were before the feature existed). `local_data` is
|
|
200
225
|
the host owner's label→path map; it defaults to their environment, so a
|
|
201
226
|
runner never has to know where the map comes from.
|
|
227
|
+
|
|
228
|
+
`gpus` is the payload's device count, straight from the wire and
|
|
229
|
+
therefore typed `object` — None for every job that exists today, and
|
|
230
|
+
then, as with `local_inputs`, the flag list is byte-for-byte what it was
|
|
231
|
+
before this argument existed. `doctor.check_hardened_run` passes neither.
|
|
202
232
|
"""
|
|
203
233
|
return [
|
|
204
234
|
# the job never reaches the volunteer's LAN or the internet; the
|
|
@@ -215,6 +245,9 @@ def harden_args(
|
|
|
215
245
|
# bypassable by swapping
|
|
216
246
|
"--memory", f"{memory_gb}g",
|
|
217
247
|
"--memory-swap", f"{memory_gb}g",
|
|
248
|
+
# With the other resource caps, and absent entirely for the job that
|
|
249
|
+
# asked for no device — which is every job that exists today.
|
|
250
|
+
*gpu_flags(gpus),
|
|
218
251
|
"--ulimit", "nofile=1024:1024",
|
|
219
252
|
"-v", f"{_bind_mount_source(workdir)}:{CONTAINER_WORKDIR}",
|
|
220
253
|
# After the workdir mount, never before: these land *inside* it, at
|
|
@@ -141,6 +141,8 @@ class ExecutorLoop:
|
|
|
141
141
|
registration=None, # NodeRegistration; enables re-register after coordinator restart
|
|
142
142
|
max_unpacked_bytes: int = DEFAULT_MAX_BYTES,
|
|
143
143
|
max_unpacked_members: int = DEFAULT_MAX_MEMBERS,
|
|
144
|
+
health_check=None,
|
|
145
|
+
max_consecutive_failures: int = 0,
|
|
144
146
|
):
|
|
145
147
|
self.client = client
|
|
146
148
|
self.node_id = node_id
|
|
@@ -159,6 +161,30 @@ class ExecutorLoop:
|
|
|
159
161
|
self.stop_event = threading.Event()
|
|
160
162
|
self.tasks_accepted = 0
|
|
161
163
|
self._last_node_hb = 0.0
|
|
164
|
+
# Host-facing state. Plain attributes on purpose: flashnode/status.py
|
|
165
|
+
# reads them from a separate thread on a timer, and putting a lock in
|
|
166
|
+
# the claim path to protect a counter would be trading a correctness
|
|
167
|
+
# risk for a cosmetic one.
|
|
168
|
+
self.tasks_failed = 0
|
|
169
|
+
self.consecutive_failures = 0
|
|
170
|
+
self.current_task: str | None = None
|
|
171
|
+
self.current_attempt: int | None = None
|
|
172
|
+
self.current_task_started: float | None = None
|
|
173
|
+
self.quarantined = False
|
|
174
|
+
self.health_report: list | None = None
|
|
175
|
+
# INJECTED, never imported. `doctor.py` imports
|
|
176
|
+
# `flashnode.executor.hardening`, which initialises this package,
|
|
177
|
+
# whose __init__ imports this module — loop -> doctor -> executor ->
|
|
178
|
+
# loop is a cycle that resolves or explodes by import order, which is
|
|
179
|
+
# the worst kind of bug to ship to machines we cannot reach.
|
|
180
|
+
#
|
|
181
|
+
# CONTRACT: health_check() returns the BLOCKING problems; an empty
|
|
182
|
+
# list means healthy. This loop never inspects a `.status`, because
|
|
183
|
+
# the doctor's GPU check reports "info" and never fails — a loop
|
|
184
|
+
# testing `!= "ok"` itself would quarantine every CPU-only volunteer
|
|
185
|
+
# on their third unlucky job.
|
|
186
|
+
self.health_check = health_check
|
|
187
|
+
self.max_consecutive_failures = max_consecutive_failures
|
|
162
188
|
|
|
163
189
|
# -- inputs --------------------------------------------------------------
|
|
164
190
|
|
|
@@ -212,6 +238,44 @@ class ExecutorLoop:
|
|
|
212
238
|
# -- one task ------------------------------------------------------------
|
|
213
239
|
|
|
214
240
|
def execute_one(self, lease: Lease) -> bool:
|
|
241
|
+
"""Run one lease, and record what its outcome says about this HOST.
|
|
242
|
+
|
|
243
|
+
Three different things return False here and only one implicates the
|
|
244
|
+
machine:
|
|
245
|
+
|
|
246
|
+
TaskExecutionError — could not run it here -> counts
|
|
247
|
+
LeaseLost — someone else has the work -> does not
|
|
248
|
+
accepted=False — coordinator declined the result -> does not
|
|
249
|
+
|
|
250
|
+
That last one is HTTP 200. Counting it would punish a healthy host
|
|
251
|
+
for losing a commit race — the same trap the contributions ledger hit
|
|
252
|
+
by crediting on 2xx.
|
|
253
|
+
"""
|
|
254
|
+
self.current_task = lease.task_id
|
|
255
|
+
self.current_attempt = lease.attempt_number
|
|
256
|
+
self.current_task_started = time.monotonic()
|
|
257
|
+
try:
|
|
258
|
+
accepted = self._execute_inner(lease)
|
|
259
|
+
except TaskExecutionError:
|
|
260
|
+
# _execute_inner already reported fail() and logged the cause.
|
|
261
|
+
self.tasks_failed += 1
|
|
262
|
+
self.consecutive_failures += 1
|
|
263
|
+
return False
|
|
264
|
+
except LeaseLost:
|
|
265
|
+
# _execute_inner normally swallows this and returns False; the
|
|
266
|
+
# belt-and-braces handler makes the contract above true wherever
|
|
267
|
+
# it is raised from, and costs a healthy host nothing.
|
|
268
|
+
return False
|
|
269
|
+
else:
|
|
270
|
+
if accepted:
|
|
271
|
+
self.consecutive_failures = 0
|
|
272
|
+
return accepted
|
|
273
|
+
finally:
|
|
274
|
+
self.current_task = None
|
|
275
|
+
self.current_attempt = None
|
|
276
|
+
self.current_task_started = None
|
|
277
|
+
|
|
278
|
+
def _execute_inner(self, lease: Lease) -> bool:
|
|
215
279
|
"""Run a claimed lease end-to-end. Returns True if the commit was
|
|
216
280
|
accepted. Never raises for task-level problems — they are reported."""
|
|
217
281
|
payload = lease.payload
|
|
@@ -311,7 +375,10 @@ class ExecutorLoop:
|
|
|
311
375
|
self.client.fail(lease.lease_id, str(exc)[:500])
|
|
312
376
|
except Exception:
|
|
313
377
|
pass # lease will expire on its own — same outcome, slower
|
|
314
|
-
|
|
378
|
+
# Re-raised, not returned: execute_one counts THIS outcome
|
|
379
|
+
# against the host and the other two against nobody. Caught one
|
|
380
|
+
# frame up, so callers still see False.
|
|
381
|
+
raise
|
|
315
382
|
except LeaseLost:
|
|
316
383
|
log.warning(_jlog("lease lost", task=lease.task_id))
|
|
317
384
|
return False
|
|
@@ -330,6 +397,37 @@ class ExecutorLoop:
|
|
|
330
397
|
self.client.register(self.registration)
|
|
331
398
|
self._last_node_hb = time.monotonic()
|
|
332
399
|
|
|
400
|
+
def _should_stop_volunteering(self) -> bool:
|
|
401
|
+
"""After a streak of host-side failures, ask whether it is the HOST.
|
|
402
|
+
|
|
403
|
+
A counter alone would guess. This measures: re-run the same checks
|
|
404
|
+
`flashnode doctor` runs, and let the answer decide.
|
|
405
|
+
|
|
406
|
+
- nothing blocking -> this machine is fine and the JOBS are failing.
|
|
407
|
+
Say so, reset, keep working. A host that stops because of someone
|
|
408
|
+
else's broken job is a host that stops for no reason.
|
|
409
|
+
- blocking problems -> stop claiming. Continuing means burning this
|
|
410
|
+
job's retries on a machine that cannot run anything.
|
|
411
|
+
"""
|
|
412
|
+
if self.health_check is None or self.max_consecutive_failures <= 0:
|
|
413
|
+
return False
|
|
414
|
+
if self.consecutive_failures < self.max_consecutive_failures:
|
|
415
|
+
return False
|
|
416
|
+
unhealthy = self.health_check()
|
|
417
|
+
if not unhealthy:
|
|
418
|
+
log.info(_jlog(
|
|
419
|
+
"consecutive task failures, but this host passes its own "
|
|
420
|
+
"checks — the jobs are failing, not the machine",
|
|
421
|
+
failures=self.consecutive_failures))
|
|
422
|
+
self.consecutive_failures = 0
|
|
423
|
+
return False
|
|
424
|
+
self.quarantined = True
|
|
425
|
+
self.health_report = unhealthy
|
|
426
|
+
log.error(_jlog("stopping: this host can no longer run tasks",
|
|
427
|
+
failures=self.consecutive_failures,
|
|
428
|
+
failed_checks=[getattr(r, "name", "?") for r in unhealthy]))
|
|
429
|
+
return True
|
|
430
|
+
|
|
333
431
|
def run(self, max_tasks: int | None = None, idle_exit: bool = False) -> int:
|
|
334
432
|
"""Claim-and-execute until stopped, `max_tasks` accepted, or — with
|
|
335
433
|
`idle_exit` — the queue drains (drain mode for tests/one-shot runs).
|
|
@@ -356,6 +454,8 @@ class ExecutorLoop:
|
|
|
356
454
|
continue
|
|
357
455
|
log.info(_jlog("claimed", task=lease.task_id, attempt=lease.attempt_number))
|
|
358
456
|
self.execute_one(lease)
|
|
457
|
+
if self._should_stop_volunteering():
|
|
458
|
+
break
|
|
359
459
|
return self.tasks_accepted
|
|
360
460
|
|
|
361
461
|
|
|
@@ -14,6 +14,7 @@ import socket
|
|
|
14
14
|
import psutil
|
|
15
15
|
|
|
16
16
|
from flashnode.config.local_data import load_local_data
|
|
17
|
+
from flashnode.inventory.gpu import probe_gpus
|
|
17
18
|
from flashruntime.protocol.v1alpha1 import (
|
|
18
19
|
NodeCapabilities,
|
|
19
20
|
NodeEnvironment,
|
|
@@ -90,6 +91,17 @@ def discover(node_id: str, kubernetes_node: str,
|
|
|
90
91
|
|
|
91
92
|
from flashnode import __version__
|
|
92
93
|
|
|
94
|
+
# What `nvidia-smi` reports, or nothing. probe_gpus already promises it
|
|
95
|
+
# never raises; this is a second lock on the same door, because the cost
|
|
96
|
+
# of being wrong here is not "no GPU work" but "this agent does not
|
|
97
|
+
# start" — on a machine that was only ever going to run CPU tasks. Unlike
|
|
98
|
+
# local_datasets below, an unreadable GPU is not a misconfiguration the
|
|
99
|
+
# owner must fix; it is the normal state of most hosts.
|
|
100
|
+
try:
|
|
101
|
+
gpus = probe_gpus()
|
|
102
|
+
except Exception:
|
|
103
|
+
gpus = []
|
|
104
|
+
|
|
93
105
|
return NodeRegistration(
|
|
94
106
|
node_id=node_id,
|
|
95
107
|
kubernetes_node=kubernetes_node,
|
|
@@ -97,7 +109,11 @@ def discover(node_id: str, kubernetes_node: str,
|
|
|
97
109
|
capabilities=NodeCapabilities(
|
|
98
110
|
cpu_cores=cpu,
|
|
99
111
|
memory_bytes=memory,
|
|
100
|
-
|
|
112
|
+
# One entry per device the driver reports; [] on the hosts that
|
|
113
|
+
# have no driver, which is most of them. Placement reads the
|
|
114
|
+
# LENGTH of this list, so an entry we are unsure of is worse
|
|
115
|
+
# than no entry — see inventory/gpu.py.
|
|
116
|
+
gpus=gpus,
|
|
101
117
|
os=labels.get("kubernetes.io/os", platform.system().lower()),
|
|
102
118
|
architecture=labels.get("kubernetes.io/arch", arch),
|
|
103
119
|
),
|