flashnode 0.2.0__tar.gz → 0.3.0__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 (53) hide show
  1. {flashnode-0.2.0/flashnode.egg-info → flashnode-0.3.0}/PKG-INFO +11 -2
  2. flashnode-0.2.0/PKG-INFO → flashnode-0.3.0/README.md +9 -17
  3. flashnode-0.3.0/flashnode/__init__.py +21 -0
  4. flashnode-0.3.0/flashnode/config/local_data.py +111 -0
  5. {flashnode-0.2.0 → flashnode-0.3.0}/flashnode/executor/argv_runner.py +7 -1
  6. {flashnode-0.2.0 → flashnode-0.3.0}/flashnode/executor/docker_runner.py +6 -1
  7. {flashnode-0.2.0 → flashnode-0.3.0}/flashnode/executor/hardening.py +84 -1
  8. {flashnode-0.2.0 → flashnode-0.3.0}/flashnode/inventory/capabilities.py +11 -0
  9. flashnode-0.2.0/README.md → flashnode-0.3.0/flashnode.egg-info/PKG-INFO +26 -0
  10. {flashnode-0.2.0 → flashnode-0.3.0}/flashnode.egg-info/SOURCES.txt +3 -1
  11. {flashnode-0.2.0 → flashnode-0.3.0}/flashnode.egg-info/requires.txt +1 -1
  12. {flashnode-0.2.0 → flashnode-0.3.0}/pyproject.toml +15 -10
  13. {flashnode-0.2.0 → flashnode-0.3.0}/tests/test_capabilities.py +19 -0
  14. flashnode-0.3.0/tests/test_local_data.py +325 -0
  15. flashnode-0.2.0/flashnode/__init__.py +0 -7
  16. {flashnode-0.2.0 → flashnode-0.3.0}/LICENSE +0 -0
  17. {flashnode-0.2.0 → flashnode-0.3.0}/flashnode/agent/__init__.py +0 -0
  18. {flashnode-0.2.0 → flashnode-0.3.0}/flashnode/agent/cli.py +0 -0
  19. {flashnode-0.2.0 → flashnode-0.3.0}/flashnode/agent/daemon.py +0 -0
  20. {flashnode-0.2.0 → flashnode-0.3.0}/flashnode/agent/kube.py +0 -0
  21. {flashnode-0.2.0 → flashnode-0.3.0}/flashnode/artifacts/__init__.py +0 -0
  22. {flashnode-0.2.0 → flashnode-0.3.0}/flashnode/benchmark/__init__.py +0 -0
  23. {flashnode-0.2.0 → flashnode-0.3.0}/flashnode/config/__init__.py +0 -0
  24. {flashnode-0.2.0 → flashnode-0.3.0}/flashnode/executor/__init__.py +0 -0
  25. {flashnode-0.2.0 → flashnode-0.3.0}/flashnode/executor/archives.py +0 -0
  26. {flashnode-0.2.0 → flashnode-0.3.0}/flashnode/executor/client.py +0 -0
  27. {flashnode-0.2.0 → flashnode-0.3.0}/flashnode/executor/images.py +0 -0
  28. {flashnode-0.2.0 → flashnode-0.3.0}/flashnode/executor/loop.py +0 -0
  29. {flashnode-0.2.0 → flashnode-0.3.0}/flashnode/executor/runner.py +0 -0
  30. {flashnode-0.2.0 → flashnode-0.3.0}/flashnode/identity/__init__.py +0 -0
  31. {flashnode-0.2.0 → flashnode-0.3.0}/flashnode/identity/credentials.py +0 -0
  32. {flashnode-0.2.0 → flashnode-0.3.0}/flashnode/identity/enrol.py +0 -0
  33. {flashnode-0.2.0 → flashnode-0.3.0}/flashnode/identity/store.py +0 -0
  34. {flashnode-0.2.0 → flashnode-0.3.0}/flashnode/inventory/__init__.py +0 -0
  35. {flashnode-0.2.0 → flashnode-0.3.0}/flashnode/telemetry/__init__.py +0 -0
  36. {flashnode-0.2.0 → flashnode-0.3.0}/flashnode.egg-info/dependency_links.txt +0 -0
  37. {flashnode-0.2.0 → flashnode-0.3.0}/flashnode.egg-info/entry_points.txt +0 -0
  38. {flashnode-0.2.0 → flashnode-0.3.0}/flashnode.egg-info/top_level.txt +0 -0
  39. {flashnode-0.2.0 → flashnode-0.3.0}/setup.cfg +0 -0
  40. {flashnode-0.2.0 → flashnode-0.3.0}/tests/test_agent.py +0 -0
  41. {flashnode-0.2.0 → flashnode-0.3.0}/tests/test_allowlist_drift.py +0 -0
  42. {flashnode-0.2.0 → flashnode-0.3.0}/tests/test_archives.py +0 -0
  43. {flashnode-0.2.0 → flashnode-0.3.0}/tests/test_argv_runner.py +0 -0
  44. {flashnode-0.2.0 → flashnode-0.3.0}/tests/test_checkpoint_relay.py +0 -0
  45. {flashnode-0.2.0 → flashnode-0.3.0}/tests/test_credentials.py +0 -0
  46. {flashnode-0.2.0 → flashnode-0.3.0}/tests/test_docker_runner.py +0 -0
  47. {flashnode-0.2.0 → flashnode-0.3.0}/tests/test_enrol.py +0 -0
  48. {flashnode-0.2.0 → flashnode-0.3.0}/tests/test_executor.py +0 -0
  49. {flashnode-0.2.0 → flashnode-0.3.0}/tests/test_hardening.py +0 -0
  50. {flashnode-0.2.0 → flashnode-0.3.0}/tests/test_hardening_platform.py +0 -0
  51. {flashnode-0.2.0 → flashnode-0.3.0}/tests/test_identity.py +0 -0
  52. {flashnode-0.2.0 → flashnode-0.3.0}/tests/test_images_allowlist.py +0 -0
  53. {flashnode-0.2.0 → flashnode-0.3.0}/tests/test_interfaces.py +0 -0
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flashnode
3
- Version: 0.2.0
3
+ Version: 0.3.0
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
@@ -60,8 +60,17 @@ flashnode work --coordinator http://<coordinator>:8100
60
60
  # FLASHNODE_WORKDIR=$HOME/.cache/flashnode (macOS + colima: VM-visible workdirs)
61
61
  # FLASHNODE_WORKDIR=C:\Users\<you>\.flashnode (Windows: must be under a
62
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
63
65
  ```
64
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
+
65
74
  If the coordinator enforces per-machine authentication
66
75
  (`FLASHML_NODE_TOKENS` set server-side), save the bearer token you were
67
76
  given before running `work`:
@@ -1,20 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: flashnode
3
- Version: 0.2.0
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.4,>=0.3
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
@@ -60,8 +43,17 @@ flashnode work --coordinator http://<coordinator>:8100
60
43
  # FLASHNODE_WORKDIR=$HOME/.cache/flashnode (macOS + colima: VM-visible workdirs)
61
44
  # FLASHNODE_WORKDIR=C:\Users\<you>\.flashnode (Windows: must be under a
62
45
  # directory Docker Desktop shares)
46
+ # FLASHNODE_LOCAL_DATA=patients=/srv/data/patients-2026,labs=/srv/labs
47
+ # lend local directories to tasks by LABEL
63
48
  ```
64
49
 
50
+ `FLASHNODE_LOCAL_DATA` lets you offer data **without uploading it**. Only the
51
+ label names (`patients`, `labs`) are advertised to the coordinator — never the
52
+ paths — and a task that names a label in its `local_inputs` gets that directory
53
+ bind-mounted **read-only** at `/work/inputs/<label>`. A task asking for a label
54
+ this machine does not lend is refused, not run half-fed; a task that asks for
55
+ nothing sees nothing, exactly as before.
56
+
65
57
  If the coordinator enforces per-machine authentication
66
58
  (`FLASHML_NODE_TOKENS` set server-side), save the bearer token you were
67
59
  given before running `work`:
@@ -0,0 +1,21 @@
1
+ """FlashNode: the open host agent of the FlashML system.
2
+
3
+ Installed by resource contributors to safely execute distributed ML tasks.
4
+ See README.md and docs/SYSTEM_OVERVIEW.md.
5
+ """
6
+
7
+ from importlib.metadata import PackageNotFoundError, version as _pkg_version
8
+
9
+ # Read from installed metadata rather than hardcoding. A literal here is a
10
+ # SECOND source of truth for the version, and it drifted the moment it
11
+ # existed: 0.2.0 shipped to PyPI while this file still said 0.1.0, so
12
+ # `flashnode --help` reported 0.1.0 and — far worse — every agent registered
13
+ # with the coordinator as agent_version="0.1.0" regardless of what was
14
+ # actually installed (inventory/capabilities.py). Any version-based decision
15
+ # the coordinator makes would have been reading a constant.
16
+ try:
17
+ __version__ = _pkg_version("flashnode")
18
+ except PackageNotFoundError: # running from a source tree with no install
19
+ __version__ = "0.0.0+unknown"
20
+
21
+ __all__ = ["__version__"]
@@ -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))
@@ -70,7 +70,13 @@ class ArgvDockerRunner:
70
70
  name = container_name(payload.get("task_id"))
71
71
  command = [
72
72
  "docker", "run", "--rm", "--name", name,
73
- *harden_args(workdir, cpus=self.cpus, memory_gb=self.memory_gb),
73
+ *harden_args(
74
+ workdir, cpus=self.cpus, memory_gb=self.memory_gb,
75
+ # The host owner's local datasets this task asked for. Refused
76
+ # here (before any subprocess) if this host does not lend them
77
+ # — see hardening.local_data_mounts.
78
+ local_inputs=payload.get("local_inputs"),
79
+ ),
74
80
  *env_args,
75
81
  image, # argv follows the image, where docker treats it
76
82
  *argv, # as the container command: leading '-' is inert
@@ -75,7 +75,12 @@ class DockerRunner:
75
75
  name = container_name(payload.get("task_id"))
76
76
  argv = [
77
77
  "docker", "run", "--rm", "--name", name,
78
- *harden_args(workdir, cpus=self.cpus, memory_gb=self.memory_gb),
78
+ *harden_args(
79
+ workdir, cpus=self.cpus, memory_gb=self.memory_gb,
80
+ # See hardening.local_data_mounts: read-only, only what the
81
+ # payload named, and a refusal if this host does not lend it.
82
+ local_inputs=payload.get("local_inputs"),
83
+ ),
79
84
  image,
80
85
  "python", "-m", module,
81
86
  "--spec", f"{CONTAINER_WORKDIR}/spec.json",
@@ -16,6 +16,9 @@ import sys
16
16
  import uuid
17
17
  from pathlib import Path, PureWindowsPath
18
18
 
19
+ from flashnode.config.local_data import LocalDataError, check_label, load_local_data
20
+ from flashnode.executor.runner import TaskExecutionError
21
+
19
22
  CONTAINER_WORKDIR = "/work"
20
23
 
21
24
  # Docker container names must match [a-zA-Z0-9][a-zA-Z0-9_.-]*. We prefix
@@ -112,14 +115,91 @@ def _bind_mount_source(workdir: Path) -> str:
112
115
  return str(workdir)
113
116
 
114
117
 
118
+ def local_data_mounts(
119
+ local_inputs: object,
120
+ available: dict[str, str] | None = None,
121
+ ) -> list[str]:
122
+ """`-v` flags binding the host owner's local datasets READ-ONLY under
123
+ `/work/inputs/<label>`, for the labels this payload asked for.
124
+
125
+ This is the half of the local-data feature that never uploads anything:
126
+ the bytes stay on the owner's disk and the task reads them in place. Three
127
+ properties are load-bearing.
128
+
129
+ - **`:ro`, always.** The owner lends the data; they do not hand a
130
+ stranger's code write access to it. There is no flag to turn this off.
131
+ - **Only what was requested.** A payload that names `patients` gets
132
+ `patients`. A host that also lends `labs` does not silently expose it to
133
+ a job that never asked — least privilege, per task.
134
+ - **Refuse rather than run half-fed** (AGENTS.md rule 3, fail closed). A
135
+ label this host has not mapped is a `TaskExecutionError` naming the
136
+ label — the *task* fails and requeues elsewhere; the agent lives. It
137
+ should not be reachable at all, because the coordinator's placement gate
138
+ reads the same advertisement this host published, so arriving here means
139
+ gate and host disagree — precisely the moment to stop, not to improvise
140
+ an empty directory the workload would read as "no patients".
141
+
142
+ `local_inputs` comes from an untrusted payload, so it is type-checked and
143
+ charset-checked here rather than trusted to be the list the compiler
144
+ produced. A label is never joined to a host path: it is a map key, and one
145
+ container-side directory segment.
146
+
147
+ `available` defaults to the host owner's `FLASHNODE_LOCAL_DATA` — read
148
+ only when a task actually asks for something, so a host whose value is
149
+ malformed fails the tasks that need it rather than every task on the
150
+ machine.
151
+ """
152
+ if local_inputs is None:
153
+ return []
154
+ if not isinstance(local_inputs, list) or not all(
155
+ isinstance(name, str) for name in local_inputs
156
+ ):
157
+ raise TaskExecutionError(
158
+ "payload 'local_inputs' must be a list of local dataset labels"
159
+ )
160
+ if not local_inputs:
161
+ return []
162
+ if available is None:
163
+ try:
164
+ available = load_local_data()
165
+ except LocalDataError as exc:
166
+ # Barely reachable — `discover()` parses the same value at startup
167
+ # and the agent refuses to register on a malformed one. If it ever
168
+ # is reached, a misconfigured host fails a task; it does not die.
169
+ raise TaskExecutionError(f"host local-data config is unusable: {exc}") from None
170
+ args: list[str] = []
171
+ for label in local_inputs:
172
+ try:
173
+ check_label(label)
174
+ except LocalDataError as exc:
175
+ raise TaskExecutionError(str(exc)) from None
176
+ if label not in available:
177
+ raise TaskExecutionError(
178
+ f"task requires local dataset {label!r}, which this host does "
179
+ "not provide — refusing to run"
180
+ )
181
+ source = _bind_mount_source(Path(available[label]))
182
+ args += ["-v", f"{source}:{CONTAINER_WORKDIR}/inputs/{label}:ro"]
183
+ return args
184
+
185
+
115
186
  def harden_args(
116
187
  workdir: Path,
117
188
  *,
118
189
  cpus: float,
119
190
  memory_gb: float,
120
191
  pids_limit: int = 512,
192
+ local_inputs: object = None,
193
+ local_data: dict[str, str] | None = None,
121
194
  ) -> list[str]:
122
- """Docker flags common to every sandboxed task."""
195
+ """Docker flags common to every sandboxed task.
196
+
197
+ `local_inputs` is the payload's list of local dataset labels (None or []
198
+ for the overwhelmingly common task that needs none, and then the flags are
199
+ byte-for-byte what they were before the feature existed). `local_data` is
200
+ the host owner's label→path map; it defaults to their environment, so a
201
+ runner never has to know where the map comes from.
202
+ """
123
203
  return [
124
204
  # the job never reaches the volunteer's LAN or the internet; the
125
205
  # agent is the courier for inputs, outputs, and checkpoints
@@ -137,5 +217,8 @@ def harden_args(
137
217
  "--memory-swap", f"{memory_gb}g",
138
218
  "--ulimit", "nofile=1024:1024",
139
219
  "-v", f"{_bind_mount_source(workdir)}:{CONTAINER_WORKDIR}",
220
+ # After the workdir mount, never before: these land *inside* it, at
221
+ # /work/inputs/<label>, and the outer mount has to exist first.
222
+ *local_data_mounts(local_inputs, local_data),
140
223
  "-w", CONTAINER_WORKDIR,
141
224
  ]
@@ -13,6 +13,7 @@ import socket
13
13
 
14
14
  import psutil
15
15
 
16
+ from flashnode.config.local_data import load_local_data
16
17
  from flashruntime.protocol.v1alpha1 import (
17
18
  NodeCapabilities,
18
19
  NodeEnvironment,
@@ -110,6 +111,16 @@ def discover(node_id: str, kubernetes_node: str,
110
111
  # coordinator's module gate is fail-open (unlike argv_capable), so
111
112
  # the default here matches every caller that doesn't pass it.
112
113
  module_capable=module_capable,
114
+ # The LABELS of the datasets this host owner lends to tasks
115
+ # (FLASHNODE_LOCAL_DATA) — never the paths. The coordinator needs the
116
+ # names to place a job that requires `patients` on a machine that has
117
+ # it; it has no use for `/srv/data/patients-2026`, which would leak
118
+ # the owner's directory layout, their username, and often the
119
+ # dataset's identity off the machine the whole feature exists to keep
120
+ # the data on. Sorted so the wire form is stable across restarts.
121
+ # A malformed value raises out of here rather than advertising a
122
+ # subset: an owner who typed it wrong must find out at startup.
123
+ local_datasets=sorted(load_local_data()),
113
124
  pool=labels.get("flashml.dev/pool", os.environ.get("FLASHNODE_POOL", "local")),
114
125
  runtime_profile=os.environ.get("FLASHNODE_RUNTIME_PROFILE", "kubernetes"),
115
126
  labels=labels,
@@ -1,3 +1,20 @@
1
+ Metadata-Version: 2.4
2
+ Name: flashnode
3
+ Version: 0.3.0
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
+
1
18
  # FlashNode
2
19
 
3
20
  > **The open host agent of the FlashML system.** Install FlashNode on a
@@ -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`:
@@ -15,6 +15,7 @@ flashnode/agent/kube.py
15
15
  flashnode/artifacts/__init__.py
16
16
  flashnode/benchmark/__init__.py
17
17
  flashnode/config/__init__.py
18
+ flashnode/config/local_data.py
18
19
  flashnode/executor/__init__.py
19
20
  flashnode/executor/archives.py
20
21
  flashnode/executor/argv_runner.py
@@ -45,4 +46,5 @@ tests/test_hardening.py
45
46
  tests/test_hardening_platform.py
46
47
  tests/test_identity.py
47
48
  tests/test_images_allowlist.py
48
- tests/test_interfaces.py
49
+ tests/test_interfaces.py
50
+ tests/test_local_data.py
@@ -1,4 +1,4 @@
1
- flashruntime<0.4,>=0.3
1
+ flashruntime<0.5,>=0.4
2
2
  psutil>=5.9
3
3
  websockets>=12
4
4
  cryptography>=42
@@ -4,10 +4,13 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "flashnode"
7
- # 0.2.0 is the FIRST published release; 0.1.0 never reached PyPI. Released from
8
- # the Zolli-Labs/flashml monorepo by pushing a `flashnode-v0.2.0` tag, and only
9
- # AFTER flashruntime 0.3.0 is on PyPI see the floor below.
10
- version = "0.2.0"
7
+ # 0.3.0 lends a host directory to tasks by label (FLASHNODE_LOCAL_DATA), which
8
+ # needs flashruntime 0.4's NodeRegistration.local_datasets to advertise what
9
+ # this host can serve. (0.2.0 was the first published release; 0.2.1 fixed a
10
+ # hardcoded __version__ that made every agent register as 0.1.0.) Released from
11
+ # the Zolli-Labs/flashml monorepo by pushing a `flashnode-vX.Y.Z` tag, and only
12
+ # AFTER the flashruntime floor below is on PyPI.
13
+ version = "0.3.0"
11
14
  description = "Open host agent for the FlashML fragmented-compute network: join, benchmark, execute sandboxed ML tasks, earn contribution credits."
12
15
  readme = "README.md"
13
16
  license = { text = "Apache-2.0" }
@@ -19,12 +22,14 @@ dependencies = [
19
22
  # coordinator's fail-open module gate happens to tolerate that, but
20
23
  # argv_capable's fail-closed gate does not.
21
24
  #
22
- # Raised to >=0.3,<0.4 for the first published release. 0.3.0 is the first
23
- # flashruntime that exists on PyPI at all, so any lower floor names
24
- # something a volunteer cannot install. The upper bound is deliberate: this
25
- # agent runs on machines we cannot reach, so it must never be silently
26
- # upgraded across a protocol-breaking runtime release.
27
- "flashruntime>=0.3,<0.4",
25
+ # Raised to >=0.4,<0.5: this agent advertises local_datasets, which only
26
+ # exists from flashruntime 0.4. On 0.3 pydantic silently ignores the extra
27
+ # kwarg, so the host would advertise nothing and the coordinator's gate
28
+ # would simply never place local-data work on it fail-closed, but
29
+ # INVISIBLY, which is the worst way for a floor to be wrong. The upper
30
+ # bound is deliberate: this agent runs on machines we cannot reach, so it
31
+ # must never be silently upgraded across a protocol-breaking release.
32
+ "flashruntime>=0.4,<0.5",
28
33
  "psutil>=5.9",
29
34
  "websockets>=12",
30
35
  "cryptography>=42",
@@ -108,3 +108,22 @@ def test_env_and_label_sandbox_capable_still_work_without_argv(monkeypatch):
108
108
  node_meta = {"metadata": {"labels": {"flashml.dev/sandbox-capable": "true"}}}
109
109
  reg = discover("node-1", kubernetes_node="", node_meta=node_meta, argv_capable=False)
110
110
  assert reg.sandbox_capable is True
111
+
112
+
113
+ def test_registration_reports_the_installed_version():
114
+ """The coordinator's `agent_version` must be what is actually installed.
115
+
116
+ It was a hardcoded literal in flashnode/__init__.py, and it drifted the
117
+ first time it mattered: 0.2.0 shipped to PyPI while the literal still read
118
+ 0.1.0, so every agent in the fleet would have registered as 0.1.0 no matter
119
+ what its owner had installed. Any version-based decision the coordinator
120
+ makes — refusing a too-old protocol, reporting upgrade coverage — would
121
+ have been reading a constant.
122
+
123
+ Compare against installed metadata rather than a literal here, or this
124
+ test becomes the third source of truth it exists to prevent.
125
+ """
126
+ from importlib.metadata import version
127
+
128
+ reg = discover("n-test", kubernetes_node="", node_meta=None)
129
+ assert reg.agent_version == version("flashnode")
@@ -0,0 +1,325 @@
1
+ """`FLASHNODE_LOCAL_DATA` — a host owner lends a directory to tasks by LABEL.
2
+
3
+ FLASHNODE_LOCAL_DATA="patients=/srv/data/patients-2026,labs=/srv/labs"
4
+
5
+ Two invariants carry the whole feature and are what these tests exist to pin:
6
+
7
+ 1. **The data never moves.** Nothing is uploaded; the coordinator learns the
8
+ label NAMES and never the host paths. A path is host-private — it leaks the
9
+ owner's directory layout, their username, and often the dataset's identity.
10
+ 2. **A label is a name, never a path fragment.** It is matched against the
11
+ owner's map and used as one container-side directory segment; it is never
12
+ joined to a host path. The charset test below keeps that true even if
13
+ someone later changes the assumption.
14
+
15
+ The mount assertions read the CONSTRUCTED argv, so they need no Docker daemon
16
+ — same style as test_hardening.py.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ from pathlib import Path
23
+ from unittest import mock
24
+
25
+ import pytest
26
+
27
+ from flashnode.config.local_data import (
28
+ LocalDataError,
29
+ load_local_data,
30
+ parse_local_data,
31
+ )
32
+ from flashnode.executor.argv_runner import ArgvDockerRunner
33
+ from flashnode.executor.hardening import CONTAINER_WORKDIR, harden_args
34
+ from flashnode.executor.runner import TaskExecutionError
35
+ from flashnode.inventory.capabilities import discover
36
+
37
+ ENV = "FLASHNODE_LOCAL_DATA"
38
+
39
+
40
+ # -- parsing ------------------------------------------------------------------
41
+
42
+
43
+ def test_parses_label_path_pairs():
44
+ assert parse_local_data("a=/x,b=/y") == {"a": "/x", "b": "/y"}
45
+
46
+
47
+ def test_parses_a_single_pair_and_tolerates_surrounding_whitespace():
48
+ assert parse_local_data(" patients = /srv/data/patients-2026 ") == {
49
+ "patients": "/srv/data/patients-2026"
50
+ }
51
+
52
+
53
+ @pytest.mark.parametrize("raw", [None, "", " ", ",", " , "])
54
+ def test_unset_or_empty_is_no_local_data(raw):
55
+ """The default path: a host that never opted in advertises nothing and
56
+ mounts nothing."""
57
+ assert parse_local_data(raw) == {}
58
+
59
+
60
+ def test_load_local_data_reads_the_env_var(monkeypatch):
61
+ monkeypatch.setenv(ENV, "labs=/srv/labs")
62
+ assert load_local_data() == {"labs": "/srv/labs"}
63
+ monkeypatch.delenv(ENV)
64
+ assert load_local_data() == {}
65
+
66
+
67
+ @pytest.mark.parametrize(
68
+ "raw",
69
+ [
70
+ "good=/x,garbage", # an entry with no '=' at all
71
+ "good=/x,=/y", # empty label
72
+ "good=/x,b=", # empty path
73
+ "good=/x,b=relative", # a relative path resolves against the agent's
74
+ # cwd, which is not what the owner meant
75
+ ],
76
+ )
77
+ def test_a_malformed_entry_is_refused_not_partially_accepted(raw):
78
+ """Half-applying the owner's intent is the dangerous outcome: they believe
79
+ two directories are exposed, the agent exposes one, and the mismatch shows
80
+ up as an unplaceable job hours later. Refuse the whole value."""
81
+ with pytest.raises(LocalDataError):
82
+ parse_local_data(raw)
83
+
84
+
85
+ def test_a_duplicate_label_is_refused():
86
+ """Two paths cannot both be `patients`; silently keeping the last one
87
+ would expose a directory the owner did not intend for that name."""
88
+ with pytest.raises(LocalDataError):
89
+ parse_local_data("patients=/srv/a,patients=/srv/b")
90
+
91
+
92
+ @pytest.mark.parametrize(
93
+ "label",
94
+ ["pa/tients", "..", ".", "../../etc", "pat ients", "pat*", "pat:ients", "él"],
95
+ )
96
+ def test_a_label_outside_the_safe_charset_is_rejected(label):
97
+ """A label is never joined to a filesystem path — but it IS used as one
98
+ container-side directory segment, and it is the key an untrusted payload
99
+ looks up. Restricting it to [A-Za-z0-9._-] (and never '.'/'..') means a
100
+ later refactor that DOES join it to a path cannot escape a directory."""
101
+ with pytest.raises(LocalDataError) as exc:
102
+ parse_local_data(f"{label}=/srv/x")
103
+ assert label in str(exc.value)
104
+
105
+
106
+ def test_a_path_that_cannot_be_a_bind_mount_source_is_refused():
107
+ """`docker -v` splits its argument on ':'. A host path containing one
108
+ would silently reinterpret as src:dst:opts — refuse it at parse time
109
+ rather than construct a mount that means something else."""
110
+ with pytest.raises(LocalDataError):
111
+ parse_local_data("labs=/srv/we:ird")
112
+
113
+
114
+ # -- advertising: names travel, paths do not ----------------------------------
115
+
116
+
117
+ def test_discover_advertises_the_label_names(monkeypatch):
118
+ monkeypatch.setenv(ENV, "patients=/srv/data/patients-2026,labs=/srv/labs")
119
+ reg = discover("node-1", kubernetes_node="", node_meta=None)
120
+ assert reg.local_datasets == ["labs", "patients"] # sorted: stable on the wire
121
+
122
+
123
+ def test_discover_never_sends_the_host_paths(monkeypatch):
124
+ """A host path is host-private. It must not travel to the coordinator in
125
+ any field — not local_datasets, not labels, not by accident."""
126
+ monkeypatch.setenv(ENV, "patients=/srv/data/patients-2026,labs=/srv/labs")
127
+ reg = discover("node-1", kubernetes_node="", node_meta=None)
128
+ wire = reg.model_dump_json()
129
+ assert "/srv/data/patients-2026" not in wire
130
+ assert "/srv/labs" not in wire
131
+ assert "/srv" not in wire
132
+ assert "patients" in wire and "labs" in wire # the names DID travel
133
+
134
+
135
+ def test_discover_advertises_nothing_when_the_owner_opted_out(monkeypatch):
136
+ monkeypatch.delenv(ENV, raising=False)
137
+ assert discover("node-1", kubernetes_node="", node_meta=None).local_datasets == []
138
+
139
+
140
+ def test_discover_refuses_to_start_on_a_malformed_value(monkeypatch):
141
+ """Fail closed, like config/HostPolicy: the owner must learn their typo
142
+ now, not by wondering why their dataset is never used."""
143
+ monkeypatch.setenv(ENV, "patients=/srv/a,garbage")
144
+ with pytest.raises(LocalDataError):
145
+ discover("node-1", kubernetes_node="", node_meta=None)
146
+
147
+
148
+ # -- mounting -----------------------------------------------------------------
149
+
150
+
151
+ def test_requested_label_is_mounted_read_only_at_inputs(tmp_path):
152
+ args = harden_args(
153
+ tmp_path, cpus=1.0, memory_gb=1.0,
154
+ local_inputs=["patients"],
155
+ local_data={"patients": "/srv/data/patients-2026", "labs": "/srv/labs"},
156
+ )
157
+ assert f"/srv/data/patients-2026:{CONTAINER_WORKDIR}/inputs/patients:ro" in args
158
+ # exactly one -v beyond the workdir mount, and the label the task did NOT
159
+ # ask for is not mounted
160
+ assert not any("/srv/labs" in a for a in args)
161
+
162
+
163
+ def test_the_mount_is_read_only_not_merely_present(tmp_path):
164
+ """Read-only is the entire consent model: the owner lends the data, they
165
+ do not hand a stranger's code write access to it."""
166
+ args = harden_args(
167
+ tmp_path, cpus=1.0, memory_gb=1.0,
168
+ local_inputs=["patients"], local_data={"patients": "/srv/p"},
169
+ )
170
+ mount = next(a for a in args if a.startswith("/srv/p:"))
171
+ assert mount.endswith(":ro")
172
+
173
+
174
+ def test_several_labels_all_mount(tmp_path):
175
+ args = harden_args(
176
+ tmp_path, cpus=1.0, memory_gb=1.0,
177
+ local_inputs=["patients", "labs"],
178
+ local_data={"patients": "/srv/p", "labs": "/srv/l"},
179
+ )
180
+ assert f"/srv/p:{CONTAINER_WORKDIR}/inputs/patients:ro" in args
181
+ assert f"/srv/l:{CONTAINER_WORKDIR}/inputs/labs:ro" in args
182
+
183
+
184
+ def test_a_label_the_host_has_not_mapped_is_refused_and_named(tmp_path):
185
+ with pytest.raises(TaskExecutionError) as exc:
186
+ harden_args(
187
+ tmp_path, cpus=1.0, memory_gb=1.0,
188
+ local_inputs=["patients"], local_data={"labs": "/srv/l"},
189
+ )
190
+ assert "patients" in str(exc.value)
191
+
192
+
193
+ def test_a_hostile_label_in_the_payload_is_refused(tmp_path):
194
+ """The payload is attacker-influenced from job submission onward. It must
195
+ not be able to name a mount destination of its choosing."""
196
+ for label in ["../../root", "/etc", "a b", ".."]:
197
+ with pytest.raises(TaskExecutionError):
198
+ harden_args(
199
+ tmp_path, cpus=1.0, memory_gb=1.0,
200
+ local_inputs=[label], local_data={"labs": "/srv/l"},
201
+ )
202
+
203
+
204
+ def test_local_inputs_must_be_a_list_of_strings(tmp_path):
205
+ for bad in ["patients", 3, [1], {"patients": "/x"}]:
206
+ with pytest.raises(TaskExecutionError):
207
+ harden_args(
208
+ tmp_path, cpus=1.0, memory_gb=1.0,
209
+ local_inputs=bad, local_data={"patients": "/srv/p"},
210
+ )
211
+
212
+
213
+ def test_the_default_path_is_byte_for_byte_unchanged(tmp_path, monkeypatch):
214
+ """No FLASHNODE_LOCAL_DATA, no local_inputs: every existing host must
215
+ produce exactly the argv it produced before this feature existed."""
216
+ monkeypatch.delenv(ENV, raising=False)
217
+ before = harden_args(tmp_path, cpus=2.0, memory_gb=4.0)
218
+ for extra in ({}, {"local_inputs": None}, {"local_inputs": []}):
219
+ assert harden_args(tmp_path, cpus=2.0, memory_gb=4.0, **extra) == before
220
+
221
+
222
+ def test_no_local_inputs_mounts_nothing_even_when_the_host_offers_data(tmp_path,
223
+ monkeypatch):
224
+ monkeypatch.setenv(ENV, "patients=/srv/p")
225
+ args = harden_args(tmp_path, cpus=1.0, memory_gb=1.0)
226
+ assert not any("/srv/p" in a for a in args)
227
+
228
+
229
+ def test_a_broken_host_config_only_fails_the_tasks_that_need_it(tmp_path, monkeypatch):
230
+ """The owner's typo must not take down every unrelated task on the
231
+ machine — the map is read only when a task actually asks for a label."""
232
+ monkeypatch.setenv(ENV, "garbage")
233
+ harden_args(tmp_path, cpus=1.0, memory_gb=1.0) # no request: fine
234
+ with pytest.raises(TaskExecutionError):
235
+ harden_args(tmp_path, cpus=1.0, memory_gb=1.0, local_inputs=["patients"])
236
+
237
+
238
+ def test_the_host_map_defaults_to_the_env_var(tmp_path, monkeypatch):
239
+ """The runners do not have to know where the map comes from — the owner's
240
+ env var is the single source."""
241
+ monkeypatch.setenv(ENV, "patients=/srv/p")
242
+ args = harden_args(tmp_path, cpus=1.0, memory_gb=1.0, local_inputs=["patients"])
243
+ assert f"/srv/p:{CONTAINER_WORKDIR}/inputs/patients:ro" in args
244
+
245
+
246
+ # -- the runners actually forward the request ---------------------------------
247
+ #
248
+ # A mount is only as real as the payload key reaching it: without these the
249
+ # feature would be "implemented" in hardening.py and dead in every runner.
250
+
251
+
252
+ def _argv_payload(**over):
253
+ base = {"argv": ["python", "train.py"], "image": "ghcr.io/zolli/trainer:1.0",
254
+ "task_id": "task-000", "local_inputs": ["patients"]}
255
+ base.update(over)
256
+ return base
257
+
258
+
259
+ def _fake_ok_run(outdir: Path):
260
+ def run(cmd, **kw):
261
+ if cmd[:2] == ["docker", "run"]:
262
+ (outdir / "metrics.json").write_text("{}")
263
+ return mock.Mock(returncode=0, stdout=b"", stderr=b"")
264
+ return run
265
+
266
+
267
+ def test_argv_runner_forwards_local_inputs_to_the_mount(tmp_path, monkeypatch):
268
+ monkeypatch.setenv(ENV, "patients=/srv/p")
269
+ runner = ArgvDockerRunner(allowed_images=frozenset({"ghcr.io/zolli/trainer:1.0"}))
270
+ with mock.patch("subprocess.run", side_effect=_fake_ok_run(tmp_path / "out")) as sp:
271
+ runner.run(_argv_payload(), tmp_path, {})
272
+ cmd = sp.call_args_list[0].args[0]
273
+ assert f"/srv/p:{CONTAINER_WORKDIR}/inputs/patients:ro" in cmd
274
+
275
+
276
+ def test_argv_runner_refuses_a_task_this_host_cannot_serve(tmp_path, monkeypatch):
277
+ """This node should never have been leased the task (the coordinator's
278
+ placement gate reads the same advertisement), so reaching here means the
279
+ two disagree — fail the task, do not run it half-fed."""
280
+ monkeypatch.setenv(ENV, "labs=/srv/l")
281
+ runner = ArgvDockerRunner(allowed_images=frozenset({"ghcr.io/zolli/trainer:1.0"}))
282
+ with mock.patch("subprocess.run") as sp:
283
+ with pytest.raises(TaskExecutionError, match="patients"):
284
+ runner.run(_argv_payload(), tmp_path, {})
285
+ sp.assert_not_called() # refused before docker was ever invoked
286
+
287
+
288
+ def test_argv_runner_unchanged_without_local_inputs(tmp_path, monkeypatch):
289
+ monkeypatch.setenv(ENV, "patients=/srv/p")
290
+ runner = ArgvDockerRunner(allowed_images=frozenset({"ghcr.io/zolli/trainer:1.0"}))
291
+ payload = _argv_payload()
292
+ payload.pop("local_inputs")
293
+ with mock.patch("subprocess.run", side_effect=_fake_ok_run(tmp_path / "out")) as sp:
294
+ runner.run(payload, tmp_path, {})
295
+ assert not any("/srv/p" in a for a in sp.call_args_list[0].args[0])
296
+
297
+
298
+ def test_docker_runner_forwards_local_inputs_to_the_mount(tmp_path, monkeypatch):
299
+ from flashnode.executor.docker_runner import DockerRunner
300
+
301
+ monkeypatch.setenv(ENV, "patients=/srv/p")
302
+ runner = DockerRunner(allowed_images=frozenset({"ghcr.io/zolli/trainer:1.0"}))
303
+ payload = {"module": "flashml_workloads.sgd_trainer",
304
+ "image": "ghcr.io/zolli/trainer:1.0", "task_id": "t1",
305
+ "local_inputs": ["patients"]}
306
+ with mock.patch("subprocess.run", side_effect=_fake_ok_run(tmp_path / "out")) as sp:
307
+ runner.run(payload, tmp_path, {})
308
+ cmd = sp.call_args_list[0].args[0]
309
+ assert f"/srv/p:{CONTAINER_WORKDIR}/inputs/patients:ro" in cmd
310
+
311
+
312
+ def test_nothing_about_the_host_path_reaches_the_task_spec(tmp_path, monkeypatch):
313
+ """DockerRunner writes spec.json for the workload. The task sees the
314
+ container path; the host path is not the task's business either."""
315
+ from flashnode.executor.docker_runner import DockerRunner
316
+
317
+ monkeypatch.setenv(ENV, "patients=/srv/data/patients-2026")
318
+ runner = DockerRunner(allowed_images=frozenset({"ghcr.io/zolli/trainer:1.0"}))
319
+ payload = {"module": "flashml_workloads.sgd_trainer",
320
+ "image": "ghcr.io/zolli/trainer:1.0", "task_id": "t1",
321
+ "local_inputs": ["patients"]}
322
+ with mock.patch("subprocess.run", side_effect=_fake_ok_run(tmp_path / "out")):
323
+ runner.run(payload, tmp_path, {})
324
+ spec = json.loads((tmp_path / "spec.json").read_text())
325
+ assert "/srv/data/patients-2026" not in json.dumps(spec)
@@ -1,7 +0,0 @@
1
- """FlashNode: the open host agent of the FlashML system.
2
-
3
- Installed by resource contributors to safely execute distributed ML tasks.
4
- See README.md and docs/SYSTEM_OVERVIEW.md.
5
- """
6
-
7
- __version__ = "0.1.0"
File without changes
File without changes
File without changes
File without changes