flashnode 0.2.1__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.
- {flashnode-0.2.1/flashnode.egg-info → flashnode-0.3.0}/PKG-INFO +11 -2
- flashnode-0.2.1/PKG-INFO → flashnode-0.3.0/README.md +9 -17
- flashnode-0.3.0/flashnode/config/local_data.py +111 -0
- {flashnode-0.2.1 → flashnode-0.3.0}/flashnode/executor/argv_runner.py +7 -1
- {flashnode-0.2.1 → flashnode-0.3.0}/flashnode/executor/docker_runner.py +6 -1
- {flashnode-0.2.1 → flashnode-0.3.0}/flashnode/executor/hardening.py +84 -1
- {flashnode-0.2.1 → flashnode-0.3.0}/flashnode/inventory/capabilities.py +11 -0
- flashnode-0.2.1/README.md → flashnode-0.3.0/flashnode.egg-info/PKG-INFO +26 -0
- {flashnode-0.2.1 → flashnode-0.3.0}/flashnode.egg-info/SOURCES.txt +3 -1
- {flashnode-0.2.1 → flashnode-0.3.0}/flashnode.egg-info/requires.txt +1 -1
- {flashnode-0.2.1 → flashnode-0.3.0}/pyproject.toml +15 -12
- flashnode-0.3.0/tests/test_local_data.py +325 -0
- {flashnode-0.2.1 → flashnode-0.3.0}/LICENSE +0 -0
- {flashnode-0.2.1 → flashnode-0.3.0}/flashnode/__init__.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.0}/flashnode/agent/__init__.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.0}/flashnode/agent/cli.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.0}/flashnode/agent/daemon.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.0}/flashnode/agent/kube.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.0}/flashnode/artifacts/__init__.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.0}/flashnode/benchmark/__init__.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.0}/flashnode/config/__init__.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.0}/flashnode/executor/__init__.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.0}/flashnode/executor/archives.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.0}/flashnode/executor/client.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.0}/flashnode/executor/images.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.0}/flashnode/executor/loop.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.0}/flashnode/executor/runner.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.0}/flashnode/identity/__init__.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.0}/flashnode/identity/credentials.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.0}/flashnode/identity/enrol.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.0}/flashnode/identity/store.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.0}/flashnode/inventory/__init__.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.0}/flashnode/telemetry/__init__.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.0}/flashnode.egg-info/dependency_links.txt +0 -0
- {flashnode-0.2.1 → flashnode-0.3.0}/flashnode.egg-info/entry_points.txt +0 -0
- {flashnode-0.2.1 → flashnode-0.3.0}/flashnode.egg-info/top_level.txt +0 -0
- {flashnode-0.2.1 → flashnode-0.3.0}/setup.cfg +0 -0
- {flashnode-0.2.1 → flashnode-0.3.0}/tests/test_agent.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.0}/tests/test_allowlist_drift.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.0}/tests/test_archives.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.0}/tests/test_argv_runner.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.0}/tests/test_capabilities.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.0}/tests/test_checkpoint_relay.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.0}/tests/test_credentials.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.0}/tests/test_docker_runner.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.0}/tests/test_enrol.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.0}/tests/test_executor.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.0}/tests/test_hardening.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.0}/tests/test_hardening_platform.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.0}/tests/test_identity.py +0 -0
- {flashnode-0.2.1 → flashnode-0.3.0}/tests/test_images_allowlist.py +0 -0
- {flashnode-0.2.1 → 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.
|
|
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.
|
|
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.1
|
|
4
|
-
Summary: Open host agent for the FlashML fragmented-compute network: join, benchmark, execute sandboxed ML tasks, earn contribution credits.
|
|
5
|
-
License: Apache-2.0
|
|
6
|
-
Project-URL: Homepage, https://github.com/Zolli-Labs/flashnode
|
|
7
|
-
Requires-Python: >=3.10
|
|
8
|
-
Description-Content-Type: text/markdown
|
|
9
|
-
License-File: LICENSE
|
|
10
|
-
Requires-Dist: flashruntime<0.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,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(
|
|
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(
|
|
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
|
|
@@ -4,12 +4,13 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "flashnode"
|
|
7
|
-
# 0.
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
# `flashnode-vX.Y.Z` tag, and only
|
|
12
|
-
|
|
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"
|
|
13
14
|
description = "Open host agent for the FlashML fragmented-compute network: join, benchmark, execute sandboxed ML tasks, earn contribution credits."
|
|
14
15
|
readme = "README.md"
|
|
15
16
|
license = { text = "Apache-2.0" }
|
|
@@ -21,12 +22,14 @@ dependencies = [
|
|
|
21
22
|
# coordinator's fail-open module gate happens to tolerate that, but
|
|
22
23
|
# argv_capable's fail-closed gate does not.
|
|
23
24
|
#
|
|
24
|
-
# Raised to >=0.
|
|
25
|
-
#
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
|
|
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",
|
|
30
33
|
"psutil>=5.9",
|
|
31
34
|
"websockets>=12",
|
|
32
35
|
"cryptography>=42",
|
|
@@ -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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|