flashnode 0.2.1__py3-none-any.whl → 0.3.0__py3-none-any.whl

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.
@@ -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,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flashnode
3
- Version: 0.2.1
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`:
@@ -6,12 +6,13 @@ flashnode/agent/kube.py,sha256=3olz_oVv0RdDNogFwakiXyUD-q8J7PKVZ5_pABWiQew,1400
6
6
  flashnode/artifacts/__init__.py,sha256=J3VdNm08o0AL1kLD_cjsxmGgm8e_uhwnljvYMI5nmek,177
7
7
  flashnode/benchmark/__init__.py,sha256=YC66KYR-W7NANMtV0Y8kpixdzBxslgsaK9mDYaDxbBI,4168
8
8
  flashnode/config/__init__.py,sha256=t6C8iJkWyIGJmdv9kqh30iFkcw6vkdy73UkPnvDbZhs,4404
9
+ flashnode/config/local_data.py,sha256=Q-eFDd9BM7UkHkA9SYN4Mcv3xBFsTeSuZMjQIKqPxFc,4919
9
10
  flashnode/executor/__init__.py,sha256=6wQKrF-0zl5jAU21_xkeNA3P3iQ5jhbx0IFUlXdjkNU,1035
10
11
  flashnode/executor/archives.py,sha256=HsFpwR_MyICSp7wkw4Wr_4rmobvnaT_d2l2I6LSNJkI,12472
11
- flashnode/executor/argv_runner.py,sha256=OuvInw5s6QWqdGaLNrYg70NzGVpel98oeuRU5c_XMbg,5284
12
+ flashnode/executor/argv_runner.py,sha256=fKc9Fk0RdJdFVAWAzKEvqLp8cLtvxQALaTBj0QeWszk,5586
12
13
  flashnode/executor/client.py,sha256=0mSkjIrb1M66Jz9ofL-OqM7fu13arrMjEqTfKgYl2a8,7473
13
- flashnode/executor/docker_runner.py,sha256=J9Ym1nY18RvFSbnA5b71Zu-PV8Lf34mHF-rQ6YpNh7I,5072
14
- flashnode/executor/hardening.py,sha256=xha8ab-gM16qAsqEXk0s_zywcmsV323f5cODJ_Jf9Gc,5984
14
+ flashnode/executor/docker_runner.py,sha256=6o6rB7RarSoAd3I1UGB6le92d6J5flNJ7IArI6G4Grw,5315
15
+ flashnode/executor/hardening.py,sha256=irdvMGMbupbBJdzsCfLWN6PHmSDlXRGMSqMCIcMaWtg,9969
15
16
  flashnode/executor/images.py,sha256=OGJt36m1HKwUOErN_MR_sixG3gxIFKH8htfTVK7Opeg,7563
16
17
  flashnode/executor/loop.py,sha256=E_gBh6A-T3oEQmR-lvYDR2k5NXrYSySX61RIiOgy4To,16847
17
18
  flashnode/executor/runner.py,sha256=AT6M80QDB9p8GFIpT7rgym2Tus-IUNnh5eBpIPavyx0,4218
@@ -20,11 +21,11 @@ flashnode/identity/credentials.py,sha256=y-OGUYEyv5J5z_FUT01tSYWGdpPfC9z0F6sX5Ay
20
21
  flashnode/identity/enrol.py,sha256=GHvBLQDWdCTfDR45CQ1C70Jd00wFINfcxzvcDRC4ofg,6622
21
22
  flashnode/identity/store.py,sha256=ZGCPhSiH63PAE3GRPGrlhlc2nNPgstDSMtNPu4N-OCE,1734
22
23
  flashnode/inventory/__init__.py,sha256=epNMD283icBq-jjg5rN0u51tpSWm1RTw8PhkAXr88fM,193
23
- flashnode/inventory/capabilities.py,sha256=XyE3pAVHG5Tvsk6kslJtx7vo5UXWYP4ZOiVgEym5FpY,4602
24
+ flashnode/inventory/capabilities.py,sha256=3jMrE7Ua41O5FqU_keoNz8t4ExagDZgqYFGxVHmEfa4,5377
24
25
  flashnode/telemetry/__init__.py,sha256=MRJ99bQykL1do3dQYMNiQABmYhTuEkgZdfsm5Np0KD4,3363
25
- flashnode-0.2.1.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
26
- flashnode-0.2.1.dist-info/METADATA,sha256=tiJGSxIxWrAco9mnh9eBOQZS8tk-GIyazdm6_MfnYNE,7522
27
- flashnode-0.2.1.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
28
- flashnode-0.2.1.dist-info/entry_points.txt,sha256=rn0W3v0MU8yhzp6b3j9frGA92h5bN3vgqMQRuYAoBkM,55
29
- flashnode-0.2.1.dist-info/top_level.txt,sha256=BErbFoSvml51DxXayRtL5l8_1p59Z4XnC2bmKeaWdBo,10
30
- flashnode-0.2.1.dist-info/RECORD,,
26
+ flashnode-0.3.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
27
+ flashnode-0.3.0.dist-info/METADATA,sha256=4LPXSLr0DK4mv7R9Z1amKxAb_FRe5qrs58-MCNJ0Yho,8114
28
+ flashnode-0.3.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
29
+ flashnode-0.3.0.dist-info/entry_points.txt,sha256=rn0W3v0MU8yhzp6b3j9frGA92h5bN3vgqMQRuYAoBkM,55
30
+ flashnode-0.3.0.dist-info/top_level.txt,sha256=BErbFoSvml51DxXayRtL5l8_1p59Z4XnC2bmKeaWdBo,10
31
+ flashnode-0.3.0.dist-info/RECORD,,