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.
- {flashnode-0.2.1 → flashnode-0.3.1}/PKG-INFO +28 -2
- {flashnode-0.2.1 → flashnode-0.3.1}/README.md +26 -0
- {flashnode-0.2.1 → flashnode-0.3.1}/flashnode/agent/cli.py +25 -3
- flashnode-0.3.1/flashnode/config/local_data.py +111 -0
- flashnode-0.3.1/flashnode/doctor.py +446 -0
- {flashnode-0.2.1 → flashnode-0.3.1}/flashnode/executor/argv_runner.py +7 -1
- {flashnode-0.2.1 → flashnode-0.3.1}/flashnode/executor/docker_runner.py +6 -1
- {flashnode-0.2.1 → flashnode-0.3.1}/flashnode/executor/hardening.py +84 -1
- {flashnode-0.2.1 → flashnode-0.3.1}/flashnode/inventory/capabilities.py +11 -0
- {flashnode-0.2.1 → flashnode-0.3.1}/flashnode.egg-info/PKG-INFO +28 -2
- {flashnode-0.2.1 → flashnode-0.3.1}/flashnode.egg-info/SOURCES.txt +6 -1
- {flashnode-0.2.1 → flashnode-0.3.1}/flashnode.egg-info/requires.txt +1 -1
- {flashnode-0.2.1 → flashnode-0.3.1}/pyproject.toml +15 -12
- {flashnode-0.2.1 → flashnode-0.3.1}/tests/test_agent.py +14 -15
- flashnode-0.3.1/tests/test_doctor.py +410 -0
- flashnode-0.3.1/tests/test_local_data.py +325 -0
- flashnode-0.3.1/tests/test_work_gate.py +71 -0
- {flashnode-0.2.1 → flashnode-0.3.1}/LICENSE +0 -0
- {flashnode-0.2.1 → flashnode-0.3.1}/flashnode/__init__.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.1}/flashnode/agent/__init__.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.1}/flashnode/agent/daemon.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.1}/flashnode/agent/kube.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.1}/flashnode/artifacts/__init__.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.1}/flashnode/benchmark/__init__.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.1}/flashnode/config/__init__.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.1}/flashnode/executor/__init__.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.1}/flashnode/executor/archives.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.1}/flashnode/executor/client.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.1}/flashnode/executor/images.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.1}/flashnode/executor/loop.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.1}/flashnode/executor/runner.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.1}/flashnode/identity/__init__.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.1}/flashnode/identity/credentials.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.1}/flashnode/identity/enrol.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.1}/flashnode/identity/store.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.1}/flashnode/inventory/__init__.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.1}/flashnode/telemetry/__init__.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.1}/flashnode.egg-info/dependency_links.txt +0 -0
- {flashnode-0.2.1 → flashnode-0.3.1}/flashnode.egg-info/entry_points.txt +0 -0
- {flashnode-0.2.1 → flashnode-0.3.1}/flashnode.egg-info/top_level.txt +0 -0
- {flashnode-0.2.1 → flashnode-0.3.1}/setup.cfg +0 -0
- {flashnode-0.2.1 → flashnode-0.3.1}/tests/test_allowlist_drift.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.1}/tests/test_archives.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.1}/tests/test_argv_runner.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.1}/tests/test_capabilities.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.1}/tests/test_checkpoint_relay.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.1}/tests/test_credentials.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.1}/tests/test_docker_runner.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.1}/tests/test_enrol.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.1}/tests/test_executor.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.1}/tests/test_hardening.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.1}/tests/test_hardening_platform.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.1}/tests/test_identity.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.1}/tests/test_images_allowlist.py +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
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:
|
|
207
|
-
"
|
|
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))
|