flashnode 0.2.1__tar.gz → 0.3.1__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 (55) hide show
  1. {flashnode-0.2.1 → flashnode-0.3.1}/PKG-INFO +28 -2
  2. {flashnode-0.2.1 → flashnode-0.3.1}/README.md +26 -0
  3. {flashnode-0.2.1 → flashnode-0.3.1}/flashnode/agent/cli.py +25 -3
  4. flashnode-0.3.1/flashnode/config/local_data.py +111 -0
  5. flashnode-0.3.1/flashnode/doctor.py +446 -0
  6. {flashnode-0.2.1 → flashnode-0.3.1}/flashnode/executor/argv_runner.py +7 -1
  7. {flashnode-0.2.1 → flashnode-0.3.1}/flashnode/executor/docker_runner.py +6 -1
  8. {flashnode-0.2.1 → flashnode-0.3.1}/flashnode/executor/hardening.py +84 -1
  9. {flashnode-0.2.1 → flashnode-0.3.1}/flashnode/inventory/capabilities.py +11 -0
  10. {flashnode-0.2.1 → flashnode-0.3.1}/flashnode.egg-info/PKG-INFO +28 -2
  11. {flashnode-0.2.1 → flashnode-0.3.1}/flashnode.egg-info/SOURCES.txt +6 -1
  12. {flashnode-0.2.1 → flashnode-0.3.1}/flashnode.egg-info/requires.txt +1 -1
  13. {flashnode-0.2.1 → flashnode-0.3.1}/pyproject.toml +15 -12
  14. {flashnode-0.2.1 → flashnode-0.3.1}/tests/test_agent.py +14 -15
  15. flashnode-0.3.1/tests/test_doctor.py +410 -0
  16. flashnode-0.3.1/tests/test_local_data.py +325 -0
  17. flashnode-0.3.1/tests/test_work_gate.py +71 -0
  18. {flashnode-0.2.1 → flashnode-0.3.1}/LICENSE +0 -0
  19. {flashnode-0.2.1 → flashnode-0.3.1}/flashnode/__init__.py +0 -0
  20. {flashnode-0.2.1 → flashnode-0.3.1}/flashnode/agent/__init__.py +0 -0
  21. {flashnode-0.2.1 → flashnode-0.3.1}/flashnode/agent/daemon.py +0 -0
  22. {flashnode-0.2.1 → flashnode-0.3.1}/flashnode/agent/kube.py +0 -0
  23. {flashnode-0.2.1 → flashnode-0.3.1}/flashnode/artifacts/__init__.py +0 -0
  24. {flashnode-0.2.1 → flashnode-0.3.1}/flashnode/benchmark/__init__.py +0 -0
  25. {flashnode-0.2.1 → flashnode-0.3.1}/flashnode/config/__init__.py +0 -0
  26. {flashnode-0.2.1 → flashnode-0.3.1}/flashnode/executor/__init__.py +0 -0
  27. {flashnode-0.2.1 → flashnode-0.3.1}/flashnode/executor/archives.py +0 -0
  28. {flashnode-0.2.1 → flashnode-0.3.1}/flashnode/executor/client.py +0 -0
  29. {flashnode-0.2.1 → flashnode-0.3.1}/flashnode/executor/images.py +0 -0
  30. {flashnode-0.2.1 → flashnode-0.3.1}/flashnode/executor/loop.py +0 -0
  31. {flashnode-0.2.1 → flashnode-0.3.1}/flashnode/executor/runner.py +0 -0
  32. {flashnode-0.2.1 → flashnode-0.3.1}/flashnode/identity/__init__.py +0 -0
  33. {flashnode-0.2.1 → flashnode-0.3.1}/flashnode/identity/credentials.py +0 -0
  34. {flashnode-0.2.1 → flashnode-0.3.1}/flashnode/identity/enrol.py +0 -0
  35. {flashnode-0.2.1 → flashnode-0.3.1}/flashnode/identity/store.py +0 -0
  36. {flashnode-0.2.1 → flashnode-0.3.1}/flashnode/inventory/__init__.py +0 -0
  37. {flashnode-0.2.1 → flashnode-0.3.1}/flashnode/telemetry/__init__.py +0 -0
  38. {flashnode-0.2.1 → flashnode-0.3.1}/flashnode.egg-info/dependency_links.txt +0 -0
  39. {flashnode-0.2.1 → flashnode-0.3.1}/flashnode.egg-info/entry_points.txt +0 -0
  40. {flashnode-0.2.1 → flashnode-0.3.1}/flashnode.egg-info/top_level.txt +0 -0
  41. {flashnode-0.2.1 → flashnode-0.3.1}/setup.cfg +0 -0
  42. {flashnode-0.2.1 → flashnode-0.3.1}/tests/test_allowlist_drift.py +0 -0
  43. {flashnode-0.2.1 → flashnode-0.3.1}/tests/test_archives.py +0 -0
  44. {flashnode-0.2.1 → flashnode-0.3.1}/tests/test_argv_runner.py +0 -0
  45. {flashnode-0.2.1 → flashnode-0.3.1}/tests/test_capabilities.py +0 -0
  46. {flashnode-0.2.1 → flashnode-0.3.1}/tests/test_checkpoint_relay.py +0 -0
  47. {flashnode-0.2.1 → flashnode-0.3.1}/tests/test_credentials.py +0 -0
  48. {flashnode-0.2.1 → flashnode-0.3.1}/tests/test_docker_runner.py +0 -0
  49. {flashnode-0.2.1 → flashnode-0.3.1}/tests/test_enrol.py +0 -0
  50. {flashnode-0.2.1 → flashnode-0.3.1}/tests/test_executor.py +0 -0
  51. {flashnode-0.2.1 → flashnode-0.3.1}/tests/test_hardening.py +0 -0
  52. {flashnode-0.2.1 → flashnode-0.3.1}/tests/test_hardening_platform.py +0 -0
  53. {flashnode-0.2.1 → flashnode-0.3.1}/tests/test_identity.py +0 -0
  54. {flashnode-0.2.1 → flashnode-0.3.1}/tests/test_images_allowlist.py +0 -0
  55. {flashnode-0.2.1 → flashnode-0.3.1}/tests/test_interfaces.py +0 -0
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flashnode
3
- Version: 0.2.1
3
+ Version: 0.3.1
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.4,>=0.3
10
+ Requires-Dist: flashruntime<0.5,>=0.4
11
11
  Requires-Dist: psutil>=5.9
12
12
  Requires-Dist: websockets>=12
13
13
  Requires-Dist: cryptography>=42
@@ -49,10 +49,27 @@ 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
+
52
68
  ## What it does today
53
69
 
54
70
  ```bash
55
71
  pip install -e . # plus: pip install -e ../flashruntime
72
+ flashnode doctor # check this machine can run tasks
56
73
  flashnode work --coordinator http://<coordinator>:8100
57
74
  # optional hardening / pool config:
58
75
  # FLASHNODE_JOIN_CODE=... join-code-gated pools
@@ -60,8 +77,17 @@ flashnode work --coordinator http://<coordinator>:8100
60
77
  # FLASHNODE_WORKDIR=$HOME/.cache/flashnode (macOS + colima: VM-visible workdirs)
61
78
  # FLASHNODE_WORKDIR=C:\Users\<you>\.flashnode (Windows: must be under a
62
79
  # directory Docker Desktop shares)
80
+ # FLASHNODE_LOCAL_DATA=patients=/srv/data/patients-2026,labs=/srv/labs
81
+ # lend local directories to tasks by LABEL
63
82
  ```
64
83
 
84
+ `FLASHNODE_LOCAL_DATA` lets you offer data **without uploading it**. Only the
85
+ label names (`patients`, `labs`) are advertised to the coordinator — never the
86
+ paths — and a task that names a label in its `local_inputs` gets that directory
87
+ bind-mounted **read-only** at `/work/inputs/<label>`. A task asking for a label
88
+ this machine does not lend is refused, not run half-fed; a task that asks for
89
+ nothing sees nothing, exactly as before.
90
+
65
91
  If the coordinator enforces per-machine authentication
66
92
  (`FLASHML_NODE_TOKENS` set server-side), save the bearer token you were
67
93
  given before running `work`:
@@ -32,10 +32,27 @@ 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
+
35
51
  ## What it does today
36
52
 
37
53
  ```bash
38
54
  pip install -e . # plus: pip install -e ../flashruntime
55
+ flashnode doctor # check this machine can run tasks
39
56
  flashnode work --coordinator http://<coordinator>:8100
40
57
  # optional hardening / pool config:
41
58
  # FLASHNODE_JOIN_CODE=... join-code-gated pools
@@ -43,8 +60,17 @@ flashnode work --coordinator http://<coordinator>:8100
43
60
  # FLASHNODE_WORKDIR=$HOME/.cache/flashnode (macOS + colima: VM-visible workdirs)
44
61
  # FLASHNODE_WORKDIR=C:\Users\<you>\.flashnode (Windows: must be under a
45
62
  # directory Docker Desktop shares)
63
+ # FLASHNODE_LOCAL_DATA=patients=/srv/data/patients-2026,labs=/srv/labs
64
+ # lend local directories to tasks by LABEL
46
65
  ```
47
66
 
67
+ `FLASHNODE_LOCAL_DATA` lets you offer data **without uploading it**. Only the
68
+ label names (`patients`, `labs`) are advertised to the coordinator — never the
69
+ paths — and a task that names a label in its `local_inputs` gets that directory
70
+ bind-mounted **read-only** at `/work/inputs/<label>`. A task asking for a label
71
+ this machine does not lend is refused, not run half-fed; a task that asks for
72
+ nothing sees nothing, exactly as before.
73
+
48
74
  If the coordinator enforces per-machine authentication
49
75
  (`FLASHML_NODE_TOKENS` set server-side), save the bearer token you were
50
76
  given before running `work`:
@@ -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)
@@ -201,10 +202,27 @@ def _work(args: list[str]) -> int:
201
202
  # (subprocess.run(["docker", ...])); if it isn't installed that call
202
203
  # raises FileNotFoundError deep inside a task attempt. Check for it
203
204
  # here, at startup, rather than let the agent die on the first task.
204
- if shutil.which("docker") is None:
205
+ # A `docker` binary on PATH says nothing about the daemon behind it,
206
+ # the credential helper Docker consults when it pulls, or whether
207
+ # this machine's work directory is even visible inside the VM. Both
208
+ # hosts that stopped the 2026-08-02 §10 run-through passed the old
209
+ # `shutil.which` check and then failed every task they claimed —
210
+ # docker_runner raises TaskExecutionError, loop.py calls fail() and
211
+ # claims the next one, forever, silently.
212
+ #
213
+ # pull=False deliberately: an agent is a long-running daemon on
214
+ # someone else's machine, and a transient registry blip must not
215
+ # stop one whose images are already cached. `flashnode doctor` does
216
+ # the pull.
217
+ from flashnode.doctor import format_results, run_checks
218
+
219
+ results = run_checks(pull=False)
220
+ if any(r.status != "ok" for r in results):
205
221
  print(
206
- f"flashnode work: --runner {opts.runner} requires the `docker` CLI "
207
- "on PATH refusing to start without it",
222
+ f"flashnode work: this machine cannot run tasks with "
223
+ f"--runner {opts.runner}.\n" + format_results(results)
224
+ + "\n\nRun `flashnode doctor` for the full check, including "
225
+ "the image pull this skipped.",
208
226
  file=sys.stderr,
209
227
  )
210
228
  return 2
@@ -270,6 +288,10 @@ def main(argv: list[str] | None = None) -> int:
270
288
  return agent_main()
271
289
  if args and args[0] == "work":
272
290
  return _work(args[1:])
291
+ if args and args[0] == "doctor":
292
+ from flashnode.doctor import doctor_main
293
+
294
+ return doctor_main(args[1:])
273
295
  if args and args[0] == "login":
274
296
  return _login(args[1:])
275
297
  if args and args[0] == "logout":
@@ -0,0 +1,111 @@
1
+ """Local datasets: data the host owner lends to tasks without uploading it.
2
+
3
+ FLASHNODE_LOCAL_DATA="patients=/srv/data/patients-2026,labs=/srv/labs"
4
+
5
+ The owner names a directory on their machine and gives it a LABEL. The label
6
+ is what the agent advertises to the coordinator (`NodeRegistration.
7
+ local_datasets`, so the placement gate can route a job that needs `patients`
8
+ to a machine that has it); the PATH never leaves this machine. That asymmetry
9
+ is the feature — a path leaks the owner's directory layout, their username,
10
+ and frequently the dataset's identity, and none of that is needed to schedule
11
+ work.
12
+
13
+ Two rules make the rest of the system safe to write:
14
+
15
+ - **Fail closed on garbage** (same judgement as `HostPolicy` above): a
16
+ malformed value raises rather than yielding the entries that happened to
17
+ parse. Half-applying the owner's intent is the worst outcome — they believe
18
+ two directories are exposed, one is, and the disagreement surfaces hours
19
+ later as a job that never places.
20
+ - **A label is a name, not a path fragment.** It is restricted to
21
+ ``[A-Za-z0-9._-]`` here, once, so that no consumer downstream has to defend
22
+ itself: it is a map key, and a single container-side directory segment. If
23
+ someone later joins it to a host path, the charset already forbids the
24
+ escape.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import os
30
+ import re
31
+
32
+ __all__ = ["LocalDataError", "LABEL_RE", "check_label", "parse_local_data",
33
+ "load_local_data", "LOCAL_DATA_ENV"]
34
+
35
+ LOCAL_DATA_ENV = "FLASHNODE_LOCAL_DATA"
36
+
37
+ #: The whole alphabet a dataset label may use. Deliberately narrower than
38
+ #: anything a filesystem accepts: '/', '..' and whitespace are the characters
39
+ #: that turn a name into a traversal, and they are simply not expressible.
40
+ LABEL_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
41
+
42
+
43
+ class LocalDataError(ValueError):
44
+ """The owner's `FLASHNODE_LOCAL_DATA` cannot be honoured as written."""
45
+
46
+
47
+ def check_label(label: str) -> str:
48
+ """Return `label` if it is a legal dataset label; raise otherwise.
49
+
50
+ Shared by the parser (owner-supplied labels) and the mount builder
51
+ (payload-supplied labels) so both ends of the wire agree on what a label
52
+ is — one definition, not two that can drift.
53
+ """
54
+ if not LABEL_RE.match(label) or label in (".", ".."):
55
+ raise LocalDataError(
56
+ f"illegal local dataset label {label!r}: labels are names, not "
57
+ "paths — use only [A-Za-z0-9._-]"
58
+ )
59
+ return label
60
+
61
+
62
+ def parse_local_data(raw: str | None) -> dict[str, str]:
63
+ """Parse ``"label=/path,other=/path2"`` into ``{label: path}``.
64
+
65
+ Unset, empty, or all-separators means the owner did not opt in, and that
66
+ is the normal case: an empty map, no mounts, nothing advertised.
67
+ """
68
+ result: dict[str, str] = {}
69
+ for entry in (raw or "").split(","):
70
+ entry = entry.strip()
71
+ if not entry:
72
+ continue
73
+ label, sep, path = entry.partition("=")
74
+ label, path = label.strip(), path.strip()
75
+ if not sep or not label or not path:
76
+ raise LocalDataError(
77
+ f"malformed {LOCAL_DATA_ENV} entry {entry!r}: expected "
78
+ "label=/absolute/path"
79
+ )
80
+ check_label(label)
81
+ if not path.startswith("/") and ":" not in path:
82
+ # A relative path would resolve against whatever directory the
83
+ # agent happens to have been started in — never what the owner
84
+ # meant, and a moving target between a shell run and a systemd
85
+ # unit. (The ':' escape hatch is a Windows drive letter, which
86
+ # the next check then judges on its own terms.)
87
+ raise LocalDataError(
88
+ f"local dataset {label!r} path {path!r} is not absolute — "
89
+ "refusing to resolve it against the agent's working directory"
90
+ )
91
+ if ":" in path and not re.match(r"^[A-Za-z]:[\\/]", path):
92
+ # `docker -v` splits its argument on ':'. A source containing one
93
+ # silently re-reads as src:dst:opts — i.e. as a mount the owner
94
+ # did not write. A Windows drive letter is the one form that is
95
+ # both legitimate and rewritable (hardening._bind_mount_source).
96
+ raise LocalDataError(
97
+ f"local dataset {label!r} path {path!r} contains ':', which "
98
+ "cannot be used as a bind-mount source"
99
+ )
100
+ if label in result:
101
+ raise LocalDataError(
102
+ f"local dataset label {label!r} is mapped twice — refusing to "
103
+ "guess which directory the owner meant"
104
+ )
105
+ result[label] = path
106
+ return result
107
+
108
+
109
+ def load_local_data(env: dict[str, str] | None = None) -> dict[str, str]:
110
+ """The host owner's dataset map, from the environment."""
111
+ return parse_local_data((env if env is not None else os.environ).get(LOCAL_DATA_ENV))