flashnode 0.3.3__tar.gz → 0.3.4__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.3.3 → flashnode-0.3.4}/PKG-INFO +2 -2
- {flashnode-0.3.3 → flashnode-0.3.4}/flashnode/agent/cli.py +21 -2
- flashnode-0.3.4/flashnode/executor/trusted_runner.py +83 -0
- {flashnode-0.3.3 → flashnode-0.3.4}/flashnode/inventory/capabilities.py +4 -1
- {flashnode-0.3.3 → flashnode-0.3.4}/flashnode.egg-info/PKG-INFO +2 -2
- {flashnode-0.3.3 → flashnode-0.3.4}/flashnode.egg-info/SOURCES.txt +3 -0
- {flashnode-0.3.3 → flashnode-0.3.4}/flashnode.egg-info/requires.txt +1 -1
- {flashnode-0.3.3 → flashnode-0.3.4}/pyproject.toml +15 -2
- flashnode-0.3.4/tests/test_cli_trusted.py +74 -0
- flashnode-0.3.4/tests/test_trusted_runner.py +92 -0
- {flashnode-0.3.3 → flashnode-0.3.4}/LICENSE +0 -0
- {flashnode-0.3.3 → flashnode-0.3.4}/README.md +0 -0
- {flashnode-0.3.3 → flashnode-0.3.4}/flashnode/__init__.py +0 -0
- {flashnode-0.3.3 → flashnode-0.3.4}/flashnode/agent/__init__.py +0 -0
- {flashnode-0.3.3 → flashnode-0.3.4}/flashnode/agent/daemon.py +0 -0
- {flashnode-0.3.3 → flashnode-0.3.4}/flashnode/agent/kube.py +0 -0
- {flashnode-0.3.3 → flashnode-0.3.4}/flashnode/artifacts/__init__.py +0 -0
- {flashnode-0.3.3 → flashnode-0.3.4}/flashnode/benchmark/__init__.py +0 -0
- {flashnode-0.3.3 → flashnode-0.3.4}/flashnode/config/__init__.py +0 -0
- {flashnode-0.3.3 → flashnode-0.3.4}/flashnode/config/local_data.py +0 -0
- {flashnode-0.3.3 → flashnode-0.3.4}/flashnode/doctor.py +0 -0
- {flashnode-0.3.3 → flashnode-0.3.4}/flashnode/executor/__init__.py +0 -0
- {flashnode-0.3.3 → flashnode-0.3.4}/flashnode/executor/archives.py +0 -0
- {flashnode-0.3.3 → flashnode-0.3.4}/flashnode/executor/argv_runner.py +0 -0
- {flashnode-0.3.3 → flashnode-0.3.4}/flashnode/executor/client.py +0 -0
- {flashnode-0.3.3 → flashnode-0.3.4}/flashnode/executor/docker_runner.py +0 -0
- {flashnode-0.3.3 → flashnode-0.3.4}/flashnode/executor/evidence.py +0 -0
- {flashnode-0.3.3 → flashnode-0.3.4}/flashnode/executor/hardening.py +0 -0
- {flashnode-0.3.3 → flashnode-0.3.4}/flashnode/executor/images.py +0 -0
- {flashnode-0.3.3 → flashnode-0.3.4}/flashnode/executor/loop.py +0 -0
- {flashnode-0.3.3 → flashnode-0.3.4}/flashnode/executor/runner.py +0 -0
- {flashnode-0.3.3 → flashnode-0.3.4}/flashnode/identity/__init__.py +0 -0
- {flashnode-0.3.3 → flashnode-0.3.4}/flashnode/identity/credentials.py +0 -0
- {flashnode-0.3.3 → flashnode-0.3.4}/flashnode/identity/enrol.py +0 -0
- {flashnode-0.3.3 → flashnode-0.3.4}/flashnode/identity/store.py +0 -0
- {flashnode-0.3.3 → flashnode-0.3.4}/flashnode/inventory/__init__.py +0 -0
- {flashnode-0.3.3 → flashnode-0.3.4}/flashnode/inventory/gpu.py +0 -0
- {flashnode-0.3.3 → flashnode-0.3.4}/flashnode/status.py +0 -0
- {flashnode-0.3.3 → flashnode-0.3.4}/flashnode/telemetry/__init__.py +0 -0
- {flashnode-0.3.3 → flashnode-0.3.4}/flashnode.egg-info/dependency_links.txt +0 -0
- {flashnode-0.3.3 → flashnode-0.3.4}/flashnode.egg-info/entry_points.txt +0 -0
- {flashnode-0.3.3 → flashnode-0.3.4}/flashnode.egg-info/top_level.txt +0 -0
- {flashnode-0.3.3 → flashnode-0.3.4}/setup.cfg +0 -0
- {flashnode-0.3.3 → flashnode-0.3.4}/tests/test_agent.py +0 -0
- {flashnode-0.3.3 → flashnode-0.3.4}/tests/test_allowlist_drift.py +0 -0
- {flashnode-0.3.3 → flashnode-0.3.4}/tests/test_archives.py +0 -0
- {flashnode-0.3.3 → flashnode-0.3.4}/tests/test_argv_runner.py +0 -0
- {flashnode-0.3.3 → flashnode-0.3.4}/tests/test_capabilities.py +0 -0
- {flashnode-0.3.3 → flashnode-0.3.4}/tests/test_checkpoint_relay.py +0 -0
- {flashnode-0.3.3 → flashnode-0.3.4}/tests/test_credentials.py +0 -0
- {flashnode-0.3.3 → flashnode-0.3.4}/tests/test_docker_runner.py +0 -0
- {flashnode-0.3.3 → flashnode-0.3.4}/tests/test_doctor.py +0 -0
- {flashnode-0.3.3 → flashnode-0.3.4}/tests/test_enrol.py +0 -0
- {flashnode-0.3.3 → flashnode-0.3.4}/tests/test_evidence.py +0 -0
- {flashnode-0.3.3 → flashnode-0.3.4}/tests/test_executor.py +0 -0
- {flashnode-0.3.3 → flashnode-0.3.4}/tests/test_gpu_probe.py +0 -0
- {flashnode-0.3.3 → flashnode-0.3.4}/tests/test_hardening.py +0 -0
- {flashnode-0.3.3 → flashnode-0.3.4}/tests/test_hardening_platform.py +0 -0
- {flashnode-0.3.3 → flashnode-0.3.4}/tests/test_identity.py +0 -0
- {flashnode-0.3.3 → flashnode-0.3.4}/tests/test_images_allowlist.py +0 -0
- {flashnode-0.3.3 → flashnode-0.3.4}/tests/test_interfaces.py +0 -0
- {flashnode-0.3.3 → flashnode-0.3.4}/tests/test_local_data.py +0 -0
- {flashnode-0.3.3 → flashnode-0.3.4}/tests/test_loop_counters.py +0 -0
- {flashnode-0.3.3 → flashnode-0.3.4}/tests/test_status.py +0 -0
- {flashnode-0.3.3 → flashnode-0.3.4}/tests/test_work_gate.py +0 -0
- {flashnode-0.3.3 → flashnode-0.3.4}/tests/test_work_status.py +0 -0
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: flashnode
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.4
|
|
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.5,>=0.4.
|
|
10
|
+
Requires-Dist: flashruntime<0.5,>=0.4.3
|
|
11
11
|
Requires-Dist: psutil>=5.9
|
|
12
12
|
Requires-Dist: websockets>=12
|
|
13
13
|
Requires-Dist: cryptography>=42
|
|
@@ -174,9 +174,10 @@ def _work(args: list[str]) -> int:
|
|
|
174
174
|
)
|
|
175
175
|
parser.add_argument(
|
|
176
176
|
"--runner",
|
|
177
|
-
choices=["subprocess", "docker", "argv"],
|
|
177
|
+
choices=["subprocess", "docker", "argv", "trusted"],
|
|
178
178
|
default=os.environ.get("FLASHNODE_RUNNER", "subprocess"),
|
|
179
|
-
help="task execution tier (docker/argv need the docker CLI on PATH
|
|
179
|
+
help="task execution tier (docker/argv need the docker CLI on PATH; "
|
|
180
|
+
"trusted runs pool jobs unsandboxed — opt-in only)",
|
|
180
181
|
)
|
|
181
182
|
parser.add_argument("--max-tasks", type=int, default=None)
|
|
182
183
|
parser.add_argument("--poll-seconds", type=float, default=1.0)
|
|
@@ -260,6 +261,19 @@ def _work(args: list[str]) -> int:
|
|
|
260
261
|
os.environ.get("FLASHNODE_MAX_OUTPUT_BYTES", str(2 * 1024**3))
|
|
261
262
|
),
|
|
262
263
|
)
|
|
264
|
+
elif opts.runner == "trusted":
|
|
265
|
+
# Deliberately OUTSIDE the ("docker", "argv") branch above: this
|
|
266
|
+
# tier has no container and no docker CLI dependency, so the docker
|
|
267
|
+
# doctor gate has nothing to check — same as subprocess. The only
|
|
268
|
+
# gate here is the operator typing --runner trusted at all.
|
|
269
|
+
from flashnode.executor.trusted_runner import TrustedArgvRunner
|
|
270
|
+
|
|
271
|
+
print(
|
|
272
|
+
"trusted runner: pool jobs from your team run UNSANDBOXED on this\n"
|
|
273
|
+
"machine — no container, no network isolation. Only continue if\n"
|
|
274
|
+
"every member of your pool is someone you trust to run code here."
|
|
275
|
+
)
|
|
276
|
+
runner = TrustedArgvRunner()
|
|
263
277
|
|
|
264
278
|
workdir_base = os.environ.get("FLASHNODE_WORKDIR") or None
|
|
265
279
|
|
|
@@ -280,6 +294,11 @@ def _work(args: list[str]) -> int:
|
|
|
280
294
|
# tasks burn every attempt against ArgvDockerRunner's payload
|
|
281
295
|
# rejection before the job ever fails for real.
|
|
282
296
|
module_capable=(opts.runner != "argv"),
|
|
297
|
+
# Set true ONLY by the explicit --runner trusted opt-in, never
|
|
298
|
+
# inferred — the scheduler's placement gate treats this field as
|
|
299
|
+
# fail-closed (flashruntime.scheduler), so a node that merely has a
|
|
300
|
+
# shell available must not advertise it.
|
|
301
|
+
unsandboxed_argv_capable=(opts.runner == "trusted"),
|
|
283
302
|
)
|
|
284
303
|
client.register(registration)
|
|
285
304
|
from flashnode.doctor import NON_BLOCKING_STATUSES, run_checks
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Trusted-pool argv execution: no container, no sandbox, by explicit
|
|
2
|
+
operator opt-in only.
|
|
3
|
+
|
|
4
|
+
This runner exists for hosts that CANNOT run Docker — Colab notebooks and
|
|
5
|
+
provider pods are themselves containers — inside a team pool whose members
|
|
6
|
+
chose to trust each other. It is not a security boundary and never claims
|
|
7
|
+
to be: the placement contract (pool + allowFallback + the operator's
|
|
8
|
+
--runner trusted opt-in) is what keeps strangers' code away from it.
|
|
9
|
+
|
|
10
|
+
Same interface as SubprocessRunner/ArgvDockerRunner:
|
|
11
|
+
run(payload, workdir, inputs) -> outdir.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import subprocess
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from flashnode.executor.runner import TaskExecutionError, task_env
|
|
20
|
+
|
|
21
|
+
_CONTAINER_WORKDIR = "/work"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TrustedArgvRunner:
|
|
25
|
+
def __init__(self, timeout_seconds: float = 600.0):
|
|
26
|
+
self.timeout_seconds = timeout_seconds
|
|
27
|
+
# Evidence attributes, same contract and same reset rule as
|
|
28
|
+
# SubprocessRunner: a stale value is a measurement of a DIFFERENT
|
|
29
|
+
# run wearing this one's name.
|
|
30
|
+
self.last_exit_code: int | None = None
|
|
31
|
+
#: Always "" — no image bytes executed; echoing the payload's image
|
|
32
|
+
#: reference would claim a container ran when none did.
|
|
33
|
+
self.last_image_digest: str = ""
|
|
34
|
+
|
|
35
|
+
def run(self, payload: dict, workdir: Path, inputs: dict[str, Path]) -> Path:
|
|
36
|
+
self.last_exit_code = None
|
|
37
|
+
workdir = Path(workdir)
|
|
38
|
+
argv = payload.get("argv")
|
|
39
|
+
if not isinstance(argv, list) or not argv or not all(
|
|
40
|
+
isinstance(a, str) for a in argv
|
|
41
|
+
):
|
|
42
|
+
raise TaskExecutionError(
|
|
43
|
+
"trusted runner requires a payload with a string-list 'argv'"
|
|
44
|
+
)
|
|
45
|
+
# Rewrite /work-prefixed TOKENS onto the real workdir. Token-wise,
|
|
46
|
+
# never substring: an argument that merely contains "/work" belongs
|
|
47
|
+
# to the submitter. The compiled argv uses /work because the docker
|
|
48
|
+
# runners bind the workdir there; this runner has no container, so
|
|
49
|
+
# /work is a naming convention to honour, not a mount to make.
|
|
50
|
+
rewritten = [
|
|
51
|
+
str(workdir) + a[len(_CONTAINER_WORKDIR):]
|
|
52
|
+
if a == _CONTAINER_WORKDIR or a.startswith(_CONTAINER_WORKDIR + "/")
|
|
53
|
+
else a
|
|
54
|
+
for a in argv
|
|
55
|
+
]
|
|
56
|
+
outdir = workdir / "out"
|
|
57
|
+
outdir.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
try:
|
|
59
|
+
proc = subprocess.run(
|
|
60
|
+
rewritten,
|
|
61
|
+
cwd=workdir,
|
|
62
|
+
env=task_env(),
|
|
63
|
+
timeout=self.timeout_seconds,
|
|
64
|
+
capture_output=True,
|
|
65
|
+
)
|
|
66
|
+
except subprocess.TimeoutExpired as exc:
|
|
67
|
+
raise TaskExecutionError(
|
|
68
|
+
f"task exceeded {self.timeout_seconds}s"
|
|
69
|
+
) from exc
|
|
70
|
+
except OSError as exc:
|
|
71
|
+
raise TaskExecutionError(f"could not start task: {exc}") from exc
|
|
72
|
+
self.last_exit_code = proc.returncode
|
|
73
|
+
if proc.returncode != 0:
|
|
74
|
+
tail = proc.stderr.decode(errors="replace")[-2000:]
|
|
75
|
+
raise TaskExecutionError(
|
|
76
|
+
f"task exited {proc.returncode}: {tail}"
|
|
77
|
+
)
|
|
78
|
+
if not (outdir / "metrics.json").is_file():
|
|
79
|
+
# Same rule as both sibling runners: an exit-0 workload that
|
|
80
|
+
# wrote no metrics is a task failure HERE, attributably — not a
|
|
81
|
+
# mysterious commit rejection three hops later.
|
|
82
|
+
raise TaskExecutionError("task produced no metrics.json — nothing to commit")
|
|
83
|
+
return outdir
|
|
@@ -62,7 +62,8 @@ def _parse_k8s_memory(value: str) -> int:
|
|
|
62
62
|
def discover(node_id: str, kubernetes_node: str,
|
|
63
63
|
node_meta: dict | None = None,
|
|
64
64
|
argv_capable: bool = False,
|
|
65
|
-
module_capable: bool = True
|
|
65
|
+
module_capable: bool = True,
|
|
66
|
+
unsandboxed_argv_capable: bool = False) -> NodeRegistration:
|
|
66
67
|
"""Build the registration payload. `node_meta` is the Kubernetes Node
|
|
67
68
|
object (status/metadata) when the agent has API access; None degrades to
|
|
68
69
|
host-level probes only."""
|
|
@@ -127,6 +128,8 @@ def discover(node_id: str, kubernetes_node: str,
|
|
|
127
128
|
# coordinator's module gate is fail-open (unlike argv_capable), so
|
|
128
129
|
# the default here matches every caller that doesn't pass it.
|
|
129
130
|
module_capable=module_capable,
|
|
131
|
+
# Set by the agent only for `--runner trusted` — never inferred.
|
|
132
|
+
unsandboxed_argv_capable=unsandboxed_argv_capable,
|
|
130
133
|
# The LABELS of the datasets this host owner lends to tasks
|
|
131
134
|
# (FLASHNODE_LOCAL_DATA) — never the paths. The coordinator needs the
|
|
132
135
|
# names to place a job that requires `patients` on a machine that has
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: flashnode
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.4
|
|
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.5,>=0.4.
|
|
10
|
+
Requires-Dist: flashruntime<0.5,>=0.4.3
|
|
11
11
|
Requires-Dist: psutil>=5.9
|
|
12
12
|
Requires-Dist: websockets>=12
|
|
13
13
|
Requires-Dist: cryptography>=42
|
|
@@ -28,6 +28,7 @@ flashnode/executor/hardening.py
|
|
|
28
28
|
flashnode/executor/images.py
|
|
29
29
|
flashnode/executor/loop.py
|
|
30
30
|
flashnode/executor/runner.py
|
|
31
|
+
flashnode/executor/trusted_runner.py
|
|
31
32
|
flashnode/identity/__init__.py
|
|
32
33
|
flashnode/identity/credentials.py
|
|
33
34
|
flashnode/identity/enrol.py
|
|
@@ -42,6 +43,7 @@ tests/test_archives.py
|
|
|
42
43
|
tests/test_argv_runner.py
|
|
43
44
|
tests/test_capabilities.py
|
|
44
45
|
tests/test_checkpoint_relay.py
|
|
46
|
+
tests/test_cli_trusted.py
|
|
45
47
|
tests/test_credentials.py
|
|
46
48
|
tests/test_docker_runner.py
|
|
47
49
|
tests/test_doctor.py
|
|
@@ -57,5 +59,6 @@ tests/test_interfaces.py
|
|
|
57
59
|
tests/test_local_data.py
|
|
58
60
|
tests/test_loop_counters.py
|
|
59
61
|
tests/test_status.py
|
|
62
|
+
tests/test_trusted_runner.py
|
|
60
63
|
tests/test_work_gate.py
|
|
61
64
|
tests/test_work_status.py
|
|
@@ -15,7 +15,12 @@ name = "flashnode"
|
|
|
15
15
|
# flashruntime 0.4.2's ExecutionEvidence — see the floor below. Bumped even
|
|
16
16
|
# though nothing breaks: an agent whose wire behaviour differs must not share
|
|
17
17
|
# a version string with one that predates it.
|
|
18
|
-
|
|
18
|
+
# 0.3.4 adds `--runner trusted` (TrustedArgvRunner): unsandboxed argv
|
|
19
|
+
# execution, opt-in only, for hosts that cannot run Docker (Colab, provider
|
|
20
|
+
# pods) inside a team pool whose members trust each other. `discover()` now
|
|
21
|
+
# passes `unsandboxed_argv_capable` to NodeRegistration — see the floor
|
|
22
|
+
# below for why that moves the pin in this commit, not just at tag time.
|
|
23
|
+
version = "0.3.4"
|
|
19
24
|
description = "Open host agent for the FlashML fragmented-compute network: join, benchmark, execute sandboxed ML tasks, earn contribution credits."
|
|
20
25
|
readme = "README.md"
|
|
21
26
|
license = { text = "Apache-2.0" }
|
|
@@ -49,7 +54,15 @@ dependencies = [
|
|
|
49
54
|
# not exist in the published 0.4.1 wheel. The agent raises ImportError and
|
|
50
55
|
# never registers at all. The release workflow's `resolvable` job runs
|
|
51
56
|
# `flashnode --help` against PyPI alone precisely to catch this.
|
|
52
|
-
|
|
57
|
+
# Raised again to >=0.4.3 for `--runner trusted`: `discover()` now passes
|
|
58
|
+
# `unsandboxed_argv_capable` to NodeRegistration, a field 0.4.2's model
|
|
59
|
+
# does not declare. Unlike the two floors above, this is not an
|
|
60
|
+
# import-time ImportError — it is pydantic refusing an unknown kwarg at
|
|
61
|
+
# CALL time, inside `flashnode work`, on every runner tier, not just
|
|
62
|
+
# trusted. Same import-time-hard-failure shape as 0.3.2/0.3.3 in effect
|
|
63
|
+
# (the agent never registers), so the floor moves in this commit, not at
|
|
64
|
+
# tag time.
|
|
65
|
+
"flashruntime>=0.4.3,<0.5",
|
|
53
66
|
"psutil>=5.9",
|
|
54
67
|
"websockets>=12",
|
|
55
68
|
"cryptography>=42",
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""--runner trusted: the opt-in wiring. The flag is the ONLY path to
|
|
2
|
+
unsandboxed_argv_capable=True — discover() must default it False for every
|
|
3
|
+
other caller (security fields fail closed)."""
|
|
4
|
+
|
|
5
|
+
from flashnode.inventory.capabilities import discover
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_discover_defaults_unsandboxed_argv_capable_false():
|
|
9
|
+
reg = discover("n-1", kubernetes_node="")
|
|
10
|
+
assert reg.unsandboxed_argv_capable is False
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_discover_can_opt_in():
|
|
14
|
+
reg = discover("n-1", kubernetes_node="", unsandboxed_argv_capable=True)
|
|
15
|
+
assert reg.unsandboxed_argv_capable is True
|
|
16
|
+
assert reg.argv_capable is False # trusted is NOT the containerised contract
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_trusted_runner_selected_for_runner_trusted(monkeypatch, tmp_path, capsys):
|
|
20
|
+
"""--runner trusted must:
|
|
21
|
+
- build a TrustedArgvRunner (passed to ExecutorLoop)
|
|
22
|
+
- register with unsandboxed_argv_capable=True, argv_capable=False
|
|
23
|
+
- NEVER invoke the docker doctor gate (trusted is outside the
|
|
24
|
+
("docker", "argv") branch, same as subprocess)
|
|
25
|
+
- print a plain-words warning that pool jobs run unsandboxed
|
|
26
|
+
|
|
27
|
+
Mirrors test_agent.py::test_work_cli_sets_module_capable_from_runner_choice.
|
|
28
|
+
"""
|
|
29
|
+
import flashnode.inventory.capabilities as capabilities_mod
|
|
30
|
+
from flashnode.agent import cli
|
|
31
|
+
from flashnode.executor import CoordinatorClient, ExecutorLoop
|
|
32
|
+
from flashnode.executor.trusted_runner import TrustedArgvRunner
|
|
33
|
+
|
|
34
|
+
monkeypatch.setenv("FLASHNODE_STATE_DIR", str(tmp_path))
|
|
35
|
+
|
|
36
|
+
# The docker doctor gate must not run for --runner trusted. Rather than
|
|
37
|
+
# counting calls, make one explode: any invocation fails the test loudly
|
|
38
|
+
# and precisely where it happened.
|
|
39
|
+
def _boom(**kw):
|
|
40
|
+
raise AssertionError("doctor.run_checks must not run for --runner trusted")
|
|
41
|
+
|
|
42
|
+
monkeypatch.setattr("flashnode.doctor.run_checks", _boom)
|
|
43
|
+
|
|
44
|
+
captured = {}
|
|
45
|
+
real_discover = capabilities_mod.discover
|
|
46
|
+
|
|
47
|
+
def spy_discover(*args, **kwargs):
|
|
48
|
+
reg = real_discover(*args, **kwargs)
|
|
49
|
+
captured["registration"] = reg
|
|
50
|
+
return reg
|
|
51
|
+
|
|
52
|
+
monkeypatch.setattr(capabilities_mod, "discover", spy_discover)
|
|
53
|
+
monkeypatch.setattr(CoordinatorClient, "register", lambda self, reg: None)
|
|
54
|
+
|
|
55
|
+
real_init = ExecutorLoop.__init__
|
|
56
|
+
|
|
57
|
+
def spy_init(self, *args, **kwargs):
|
|
58
|
+
captured["runner"] = kwargs.get("runner")
|
|
59
|
+
return real_init(self, *args, **kwargs)
|
|
60
|
+
|
|
61
|
+
monkeypatch.setattr(ExecutorLoop, "__init__", spy_init)
|
|
62
|
+
monkeypatch.setattr(ExecutorLoop, "run", lambda self, max_tasks=None: 0)
|
|
63
|
+
|
|
64
|
+
rc = cli.main(["work", "--runner", "trusted", "--coordinator", "http://localhost:1"])
|
|
65
|
+
|
|
66
|
+
assert rc == 0
|
|
67
|
+
assert isinstance(captured["runner"], TrustedArgvRunner)
|
|
68
|
+
|
|
69
|
+
reg = captured["registration"]
|
|
70
|
+
assert reg.unsandboxed_argv_capable is True
|
|
71
|
+
assert reg.argv_capable is False
|
|
72
|
+
|
|
73
|
+
out = capsys.readouterr().out
|
|
74
|
+
assert "unsandboxed" in out.lower()
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""TrustedArgvRunner: pool argv payloads, no container, /work rewritten.
|
|
2
|
+
|
|
3
|
+
The compiled argv names CONTAINER paths (`python /work/inputs/code/train.py`
|
|
4
|
+
— the docker runners bind the workdir at /work). Here there is no container,
|
|
5
|
+
so every argv token starting with /work is rewritten onto the real workdir.
|
|
6
|
+
Rewriting tokens, not substrings: an argument that merely CONTAINS "/work"
|
|
7
|
+
is the submitter's business.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
from flashnode.executor.runner import TaskExecutionError
|
|
16
|
+
from flashnode.executor.trusted_runner import TrustedArgvRunner
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _payload(argv):
|
|
20
|
+
return {"argv": argv, "isolation": {"tier": "sandboxed", "allowFallback": True},
|
|
21
|
+
"pool": "p-1"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_runs_the_argv_with_work_prefix_rewritten(tmp_path):
|
|
25
|
+
workdir = tmp_path
|
|
26
|
+
code = workdir / "inputs" / "code"
|
|
27
|
+
code.mkdir(parents=True)
|
|
28
|
+
(code / "train.py").write_text(
|
|
29
|
+
"import json, pathlib, sys\n"
|
|
30
|
+
"out = pathlib.Path(sys.argv[1]); out.mkdir(parents=True, exist_ok=True)\n"
|
|
31
|
+
"(out / 'metrics.json').write_text(json.dumps({'ok': True}))\n"
|
|
32
|
+
)
|
|
33
|
+
runner = TrustedArgvRunner()
|
|
34
|
+
outdir = runner.run(
|
|
35
|
+
_payload(["python", "/work/inputs/code/train.py", "/work/out"]),
|
|
36
|
+
workdir, {"code": code},
|
|
37
|
+
)
|
|
38
|
+
assert json.loads((outdir / "metrics.json").read_text()) == {"ok": True}
|
|
39
|
+
assert runner.last_exit_code == 0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_refuses_a_payload_without_argv(tmp_path):
|
|
43
|
+
with pytest.raises(TaskExecutionError):
|
|
44
|
+
TrustedArgvRunner().run({"module": "flashml_workloads.sklearn_trial"},
|
|
45
|
+
tmp_path, {})
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_refuses_a_non_list_argv(tmp_path):
|
|
49
|
+
with pytest.raises(TaskExecutionError):
|
|
50
|
+
TrustedArgvRunner().run(_payload("python /work/x.py"), tmp_path, {})
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_nonzero_exit_raises_and_records_the_code(tmp_path):
|
|
54
|
+
(tmp_path / "inputs").mkdir()
|
|
55
|
+
runner = TrustedArgvRunner()
|
|
56
|
+
with pytest.raises(TaskExecutionError):
|
|
57
|
+
runner.run(_payload(["python", "-c", "raise SystemExit(3)"]), tmp_path, {})
|
|
58
|
+
assert runner.last_exit_code == 3
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_environment_is_scrubbed(tmp_path, monkeypatch):
|
|
62
|
+
"""The task must not inherit the agent's secrets — same whitelist as
|
|
63
|
+
SubprocessRunner (task_env). The out path travels as an argv token so
|
|
64
|
+
the /work rewrite applies to it."""
|
|
65
|
+
monkeypatch.setenv("FLASHNODE_MACHINE_TOKEN", "fmk_secret")
|
|
66
|
+
probe = tmp_path / "probe.py"
|
|
67
|
+
probe.write_text(
|
|
68
|
+
"import json, os, pathlib, sys\n"
|
|
69
|
+
"out = pathlib.Path(sys.argv[1]); out.mkdir(parents=True, exist_ok=True)\n"
|
|
70
|
+
"(out / 'env.txt').write_text(str('FLASHNODE_MACHINE_TOKEN' in os.environ))\n"
|
|
71
|
+
"(out / 'metrics.json').write_text(json.dumps({'env_scrubbed': True}))\n"
|
|
72
|
+
)
|
|
73
|
+
runner = TrustedArgvRunner()
|
|
74
|
+
runner.run(_payload(["python", str(probe), "/work/out"]), tmp_path, {})
|
|
75
|
+
assert (tmp_path / "out" / "env.txt").read_text() == "False"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_exit_zero_without_metrics_json_raises(tmp_path):
|
|
79
|
+
"""Mirrors SubprocessRunner/ArgvDockerRunner: metrics.json is
|
|
80
|
+
load-bearing; exit 0 without it is a task error, not a success."""
|
|
81
|
+
probe = tmp_path / "probe.py"
|
|
82
|
+
probe.write_text("print('did nothing')\n")
|
|
83
|
+
runner = TrustedArgvRunner()
|
|
84
|
+
with pytest.raises(TaskExecutionError, match="metrics.json"):
|
|
85
|
+
runner.run(_payload(["python", str(probe)]), tmp_path, {})
|
|
86
|
+
assert runner.last_exit_code == 0
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_image_digest_is_always_empty():
|
|
90
|
+
"""No container ran; claiming an image digest would be fabricated
|
|
91
|
+
evidence (the SubprocessRunner rule)."""
|
|
92
|
+
assert TrustedArgvRunner().last_image_digest == ""
|
|
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
|
|
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
|