flashnode 0.2.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.
- flashnode/__init__.py +7 -0
- flashnode/agent/__init__.py +6 -0
- flashnode/agent/cli.py +285 -0
- flashnode/agent/daemon.py +108 -0
- flashnode/agent/kube.py +41 -0
- flashnode/artifacts/__init__.py +5 -0
- flashnode/benchmark/__init__.py +100 -0
- flashnode/config/__init__.py +103 -0
- flashnode/executor/__init__.py +27 -0
- flashnode/executor/archives.py +290 -0
- flashnode/executor/argv_runner.py +114 -0
- flashnode/executor/client.py +201 -0
- flashnode/executor/docker_runner.py +108 -0
- flashnode/executor/hardening.py +141 -0
- flashnode/executor/images.py +161 -0
- flashnode/executor/loop.py +363 -0
- flashnode/executor/runner.py +109 -0
- flashnode/identity/__init__.py +5 -0
- flashnode/identity/credentials.py +70 -0
- flashnode/identity/enrol.py +179 -0
- flashnode/identity/store.py +48 -0
- flashnode/inventory/__init__.py +5 -0
- flashnode/inventory/capabilities.py +117 -0
- flashnode/telemetry/__init__.py +79 -0
- flashnode-0.2.0.dist-info/METADATA +160 -0
- flashnode-0.2.0.dist-info/RECORD +30 -0
- flashnode-0.2.0.dist-info/WHEEL +5 -0
- flashnode-0.2.0.dist-info/entry_points.txt +2 -0
- flashnode-0.2.0.dist-info/licenses/LICENSE +202 -0
- flashnode-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Device-code enrolment: claim this machine from a browser.
|
|
2
|
+
|
|
3
|
+
The volunteer runs one command on the machine they are lending, reads a
|
|
4
|
+
short code off the terminal, and types it into a page they are already
|
|
5
|
+
signed in to — on any device, typically a phone. The machine never handles
|
|
6
|
+
the account password, and the person approving is provably the account
|
|
7
|
+
owner because the approval endpoint requires their session.
|
|
8
|
+
|
|
9
|
+
This is RFC 8628's device authorization grant, which exists for exactly
|
|
10
|
+
this shape of problem: a client that can display a code but cannot host a
|
|
11
|
+
login. The API implements the server half (`/v1alpha1/device/code`,
|
|
12
|
+
`/v1alpha1/device/token`); this is the client half, which was missing —
|
|
13
|
+
`flashnode login` required `--token`, a credential the enrolment flow never
|
|
14
|
+
hands out, so the documented path could not be walked.
|
|
15
|
+
|
|
16
|
+
Everything I/O-ish is injected (`http`, `sleep`, `now`) so the polling loop
|
|
17
|
+
is testable without a server or real time passing.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import platform as platform_mod
|
|
24
|
+
import socket
|
|
25
|
+
import time
|
|
26
|
+
import urllib.error
|
|
27
|
+
import urllib.request
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from typing import Callable, Protocol
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"DeviceCodeStart",
|
|
33
|
+
"EnrolmentError",
|
|
34
|
+
"describe_this_machine",
|
|
35
|
+
"request_device_code",
|
|
36
|
+
"poll_for_token",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
# Cap on how long we keep polling if the server never tells us otherwise.
|
|
40
|
+
# The server's own expiry is authoritative; this only bounds the case where
|
|
41
|
+
# it hands back something unparseable.
|
|
42
|
+
MAX_POLL_SECONDS = 15 * 60
|
|
43
|
+
DEFAULT_INTERVAL = 5.0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class EnrolmentError(RuntimeError):
|
|
47
|
+
"""Enrolment could not complete. The message is shown to a human."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Http(Protocol):
|
|
51
|
+
def __call__(self, url: str, payload: dict) -> tuple[int, dict]:
|
|
52
|
+
"""POST `payload` as JSON, return (status, decoded body)."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class DeviceCodeStart:
|
|
57
|
+
device_code: str
|
|
58
|
+
user_code: str
|
|
59
|
+
verification_uri: str
|
|
60
|
+
interval: float
|
|
61
|
+
expires_at: str
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _post(url: str, payload: dict) -> tuple[int, dict]:
|
|
65
|
+
"""POST JSON with the stdlib. flashnode deliberately has no HTTP
|
|
66
|
+
dependency — every byte a volunteer installs is a byte they did not ask
|
|
67
|
+
for — and `agent/daemon.py` already reaches for urllib for the same
|
|
68
|
+
reason."""
|
|
69
|
+
body = json.dumps(payload).encode()
|
|
70
|
+
req = urllib.request.Request(
|
|
71
|
+
url,
|
|
72
|
+
data=body,
|
|
73
|
+
headers={"Content-Type": "application/json"},
|
|
74
|
+
method="POST",
|
|
75
|
+
)
|
|
76
|
+
try:
|
|
77
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
78
|
+
return resp.status, json.loads(resp.read() or b"{}")
|
|
79
|
+
except urllib.error.HTTPError as exc:
|
|
80
|
+
# A 400 is the protocol's "not yet" and carries a body worth
|
|
81
|
+
# reading, so this is control flow, not an error path.
|
|
82
|
+
raw = exc.read() or b"{}"
|
|
83
|
+
try:
|
|
84
|
+
return exc.code, json.loads(raw)
|
|
85
|
+
except ValueError:
|
|
86
|
+
return exc.code, {"error": raw.decode("utf-8", "replace")[:200]}
|
|
87
|
+
except urllib.error.URLError as exc:
|
|
88
|
+
raise EnrolmentError(
|
|
89
|
+
f"could not reach {url}: {exc.reason}. Check the --coordinator "
|
|
90
|
+
"URL, and that you are online."
|
|
91
|
+
) from exc
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def describe_this_machine() -> tuple[str, str]:
|
|
95
|
+
"""(hostname, platform) for the approval screen.
|
|
96
|
+
|
|
97
|
+
Purely for the human deciding whether to approve: a list of machines
|
|
98
|
+
reading "fn-9c2a…" tells them nothing, "phongs-macbook-air / macOS-15"
|
|
99
|
+
tells them which laptop they are looking at. Never trusted by the
|
|
100
|
+
server for anything.
|
|
101
|
+
"""
|
|
102
|
+
try:
|
|
103
|
+
hostname = socket.gethostname() or "unknown"
|
|
104
|
+
except OSError:
|
|
105
|
+
hostname = "unknown"
|
|
106
|
+
return hostname[:120], platform_mod.platform()[:120]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def request_device_code(
|
|
110
|
+
api_base: str,
|
|
111
|
+
node_id: str,
|
|
112
|
+
hostname: str,
|
|
113
|
+
platform_name: str,
|
|
114
|
+
http: Http = _post,
|
|
115
|
+
) -> DeviceCodeStart:
|
|
116
|
+
status, body = http(
|
|
117
|
+
f"{api_base.rstrip('/')}/v1alpha1/device/code",
|
|
118
|
+
{"node_id": node_id, "hostname": hostname, "platform": platform_name},
|
|
119
|
+
)
|
|
120
|
+
if status != 200:
|
|
121
|
+
detail = body.get("detail") or body.get("error") or f"HTTP {status}"
|
|
122
|
+
raise EnrolmentError(f"could not start enrolment: {detail}")
|
|
123
|
+
try:
|
|
124
|
+
return DeviceCodeStart(
|
|
125
|
+
device_code=body["device_code"],
|
|
126
|
+
user_code=body["user_code"],
|
|
127
|
+
verification_uri=body["verification_uri"],
|
|
128
|
+
interval=float(body.get("interval") or DEFAULT_INTERVAL),
|
|
129
|
+
expires_at=str(body.get("expires_at") or ""),
|
|
130
|
+
)
|
|
131
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
132
|
+
raise EnrolmentError(
|
|
133
|
+
f"enrolment response was not in the expected shape: {body!r}"
|
|
134
|
+
) from exc
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def poll_for_token(
|
|
138
|
+
api_base: str,
|
|
139
|
+
device_code: str,
|
|
140
|
+
interval: float = DEFAULT_INTERVAL,
|
|
141
|
+
http: Http = _post,
|
|
142
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
143
|
+
now: Callable[[], float] = time.monotonic,
|
|
144
|
+
max_seconds: float = MAX_POLL_SECONDS,
|
|
145
|
+
) -> str:
|
|
146
|
+
"""Block until a human approves, then return the machine token.
|
|
147
|
+
|
|
148
|
+
The server answers unknown / unapproved / expired / already-redeemed
|
|
149
|
+
with one indistinguishable `authorization_pending`, deliberately, so a
|
|
150
|
+
caller cannot probe which codes exist. That means we cannot report
|
|
151
|
+
*why* nothing happened — only that nothing did — so the timeout message
|
|
152
|
+
has to name the likely causes itself.
|
|
153
|
+
"""
|
|
154
|
+
url = f"{api_base.rstrip('/')}/v1alpha1/device/token"
|
|
155
|
+
deadline = now() + max_seconds
|
|
156
|
+
# Never poll faster than the server asked, and never so slowly that an
|
|
157
|
+
# approval sits unnoticed. A server that returns 0 or something absurd
|
|
158
|
+
# must not turn this into a hot loop against its own API.
|
|
159
|
+
wait = min(max(float(interval or DEFAULT_INTERVAL), 1.0), 30.0)
|
|
160
|
+
|
|
161
|
+
while True:
|
|
162
|
+
status, body = http(url, {"device_code": device_code})
|
|
163
|
+
if status == 200 and body.get("token"):
|
|
164
|
+
return str(body["token"])
|
|
165
|
+
if status == 400 and body.get("error") == "authorization_pending":
|
|
166
|
+
server_interval = body.get("interval")
|
|
167
|
+
if server_interval:
|
|
168
|
+
wait = min(max(float(server_interval), 1.0), 30.0)
|
|
169
|
+
elif status != 400:
|
|
170
|
+
detail = body.get("detail") or body.get("error") or f"HTTP {status}"
|
|
171
|
+
raise EnrolmentError(f"enrolment failed: {detail}")
|
|
172
|
+
|
|
173
|
+
if now() >= deadline:
|
|
174
|
+
raise EnrolmentError(
|
|
175
|
+
"timed out waiting for approval. The code may have expired, "
|
|
176
|
+
"or it was never approved — run `flashnode login` again to "
|
|
177
|
+
"get a fresh one."
|
|
178
|
+
)
|
|
179
|
+
sleep(wait)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Stable FlashNode identity.
|
|
2
|
+
|
|
3
|
+
One ID per node, created on first run and persisted in the state directory
|
|
4
|
+
(a hostPath volume in the Kubernetes profile, so the ID survives pod
|
|
5
|
+
restarts and represents the *node*, not the pod). Ed25519 signing keys are a
|
|
6
|
+
documented future step — the POC identity is an opaque random ID.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import uuid
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
# Per-user, not system-wide. /var/lib/flashnode was the default and is not
|
|
16
|
+
# writable by an ordinary account on macOS or Windows, so the very first
|
|
17
|
+
# command a volunteer runs died with
|
|
18
|
+
# PermissionError: [Errno 13] Permission denied: '/var/lib/flashnode'
|
|
19
|
+
# before enrolment could start, and with nothing to suggest that a directory
|
|
20
|
+
# choice was the problem.
|
|
21
|
+
#
|
|
22
|
+
# ~/.flashnode also matches where the credential store already writes
|
|
23
|
+
# (identity/credentials.py), so a machine's identity and its token sit
|
|
24
|
+
# together and `rm -rf ~/.flashnode` fully un-enrols it.
|
|
25
|
+
#
|
|
26
|
+
# The Kubernetes profile sets FLASHNODE_STATE_DIR explicitly
|
|
27
|
+
# (flashml-cloud/infra/base/flashnode.yaml) to a hostPath volume, so there
|
|
28
|
+
# the ID still survives pod restarts and still identifies the *node*.
|
|
29
|
+
DEFAULT_STATE_DIR = "~/.flashnode"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def state_dir() -> Path:
|
|
33
|
+
raw = os.environ.get("FLASHNODE_STATE_DIR") or DEFAULT_STATE_DIR
|
|
34
|
+
return Path(raw).expanduser()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def load_or_create_node_id() -> str:
|
|
38
|
+
path = state_dir() / "node-id"
|
|
39
|
+
if path.exists():
|
|
40
|
+
node_id = path.read_text().strip()
|
|
41
|
+
if node_id:
|
|
42
|
+
return node_id
|
|
43
|
+
node_id = f"fn-{uuid.uuid4().hex[:16]}"
|
|
44
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
tmp = path.with_suffix(".tmp")
|
|
46
|
+
tmp.write_text(node_id + "\n")
|
|
47
|
+
tmp.replace(path)
|
|
48
|
+
return node_id
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Hardware/software discovery for the node the agent runs on.
|
|
2
|
+
|
|
3
|
+
psutil + platform probes; Kubernetes allocatable values (when available from
|
|
4
|
+
the API) take precedence over raw host numbers because they are what the
|
|
5
|
+
scheduler can actually place on the node.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import platform
|
|
12
|
+
import socket
|
|
13
|
+
|
|
14
|
+
import psutil
|
|
15
|
+
|
|
16
|
+
from flashruntime.protocol.v1alpha1 import (
|
|
17
|
+
NodeCapabilities,
|
|
18
|
+
NodeEnvironment,
|
|
19
|
+
NodeRegistration,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
# Kubernetes reports arch as amd64/arm64; platform.machine() says x86_64/arm64.
|
|
23
|
+
_ARCH_MAP = {"x86_64": "amd64", "aarch64": "arm64", "arm64": "arm64", "amd64": "amd64"}
|
|
24
|
+
|
|
25
|
+
# Only labels in these namespaces are reported upstream — never arbitrary
|
|
26
|
+
# cluster metadata.
|
|
27
|
+
_ALLOWED_LABEL_PREFIXES = ("flashml.dev/", "kubernetes.io/arch", "kubernetes.io/os",
|
|
28
|
+
"node.kubernetes.io/instance-type",
|
|
29
|
+
"topology.kubernetes.io/")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def classify_environment() -> NodeEnvironment:
|
|
33
|
+
value = os.environ.get("FLASHNODE_ENVIRONMENT", "local").lower()
|
|
34
|
+
try:
|
|
35
|
+
return NodeEnvironment(value)
|
|
36
|
+
except ValueError:
|
|
37
|
+
return NodeEnvironment.LOCAL
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def filter_node_labels(labels: dict[str, str]) -> dict[str, str]:
|
|
41
|
+
return {
|
|
42
|
+
k: v for k, v in labels.items()
|
|
43
|
+
if any(k.startswith(p) for p in _ALLOWED_LABEL_PREFIXES)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _parse_k8s_cpu(value: str) -> float:
|
|
48
|
+
return float(value[:-1]) / 1000 if value.endswith("m") else float(value)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _parse_k8s_memory(value: str) -> int:
|
|
52
|
+
units = {"Ki": 1024, "Mi": 1024**2, "Gi": 1024**3, "Ti": 1024**4,
|
|
53
|
+
"K": 1000, "M": 1000**2, "G": 1000**3, "T": 1000**4}
|
|
54
|
+
for suffix, mult in units.items():
|
|
55
|
+
if value.endswith(suffix):
|
|
56
|
+
return int(float(value[: -len(suffix)]) * mult)
|
|
57
|
+
return int(value)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def discover(node_id: str, kubernetes_node: str,
|
|
61
|
+
node_meta: dict | None = None,
|
|
62
|
+
argv_capable: bool = False,
|
|
63
|
+
module_capable: bool = True) -> NodeRegistration:
|
|
64
|
+
"""Build the registration payload. `node_meta` is the Kubernetes Node
|
|
65
|
+
object (status/metadata) when the agent has API access; None degrades to
|
|
66
|
+
host-level probes only."""
|
|
67
|
+
allocatable = (node_meta or {}).get("status", {}).get("allocatable", {})
|
|
68
|
+
labels = filter_node_labels((node_meta or {}).get("metadata", {}).get("labels", {}))
|
|
69
|
+
|
|
70
|
+
if allocatable:
|
|
71
|
+
cpu = _parse_k8s_cpu(allocatable.get("cpu", "0"))
|
|
72
|
+
memory = _parse_k8s_memory(allocatable.get("memory", "0"))
|
|
73
|
+
else:
|
|
74
|
+
cpu = float(psutil.cpu_count() or 0)
|
|
75
|
+
memory = psutil.virtual_memory().total
|
|
76
|
+
|
|
77
|
+
arch = _ARCH_MAP.get(platform.machine().lower(), platform.machine().lower())
|
|
78
|
+
environment = classify_environment()
|
|
79
|
+
sandbox_capable = (
|
|
80
|
+
os.environ.get("FLASHNODE_SANDBOX_CAPABLE", "").lower() == "true"
|
|
81
|
+
or labels.get("flashml.dev/sandbox-capable") == "true"
|
|
82
|
+
# ArgvDockerRunner is container-only by construction — there is no
|
|
83
|
+
# unsandboxed code path — so argv capability implies sandbox
|
|
84
|
+
# capability. This is deliberately asymmetric with --runner docker
|
|
85
|
+
# (DockerRunner does not imply sandboxing on its own): widening that
|
|
86
|
+
# path is a separate judgment call, not an oversight here.
|
|
87
|
+
or argv_capable
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
from flashnode import __version__
|
|
91
|
+
|
|
92
|
+
return NodeRegistration(
|
|
93
|
+
node_id=node_id,
|
|
94
|
+
kubernetes_node=kubernetes_node,
|
|
95
|
+
hostname=socket.gethostname(),
|
|
96
|
+
capabilities=NodeCapabilities(
|
|
97
|
+
cpu_cores=cpu,
|
|
98
|
+
memory_bytes=memory,
|
|
99
|
+
gpus=[], # GPU probing is a documented follow-up; never guess.
|
|
100
|
+
os=labels.get("kubernetes.io/os", platform.system().lower()),
|
|
101
|
+
architecture=labels.get("kubernetes.io/arch", arch),
|
|
102
|
+
),
|
|
103
|
+
environment=environment,
|
|
104
|
+
sandbox_capable=sandbox_capable,
|
|
105
|
+
# Set by the agent when it is actually running an argv-capable
|
|
106
|
+
# runner — never inferred, so the coordinator's fail-closed gate
|
|
107
|
+
# cannot be satisfied by a node that merely has docker installed.
|
|
108
|
+
argv_capable=argv_capable,
|
|
109
|
+
# Set by the agent to False only for an argv-only runner — the
|
|
110
|
+
# coordinator's module gate is fail-open (unlike argv_capable), so
|
|
111
|
+
# the default here matches every caller that doesn't pass it.
|
|
112
|
+
module_capable=module_capable,
|
|
113
|
+
pool=labels.get("flashml.dev/pool", os.environ.get("FLASHNODE_POOL", "local")),
|
|
114
|
+
runtime_profile=os.environ.get("FLASHNODE_RUNTIME_PROFILE", "kubernetes"),
|
|
115
|
+
labels=labels,
|
|
116
|
+
agent_version=__version__,
|
|
117
|
+
)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Telemetry: what this machine is doing, as data the network can use.
|
|
2
|
+
|
|
3
|
+
Scope boundary — telemetry is *resource observation only*. Lease/attempt
|
|
4
|
+
heartbeats and task progress already live in `executor/loop.py` and must
|
|
5
|
+
stay there (they are correctness signals, not metrics; mixing them with
|
|
6
|
+
best-effort telemetry would let a metrics hiccup look like a dead task).
|
|
7
|
+
|
|
8
|
+
Intended flow: the work loop calls `collector.sample()` on its node-
|
|
9
|
+
heartbeat cadence and attaches the sample to the heartbeat. NOTE for the
|
|
10
|
+
implementer: `protocol.v1alpha1.NodeHeartbeat` does not carry a telemetry
|
|
11
|
+
field yet — adding `telemetry: dict | None = None` there is an *additive*
|
|
12
|
+
protocol change (allowed within v1alpha1) and is the actual wiring step;
|
|
13
|
+
do it protocol-first, then here.
|
|
14
|
+
|
|
15
|
+
Privacy stance (trust-through-transparency, AGENTS hard rule 5): samples
|
|
16
|
+
describe the MACHINE (utilization, free space, temperatures), never the
|
|
17
|
+
user of the machine (no process lists, no window titles, no network
|
|
18
|
+
destinations). Every field an owner might question must be visible in this
|
|
19
|
+
schema — that is why it is a typed model, not a dict.
|
|
20
|
+
|
|
21
|
+
Status: interface complete; the psutil-based collector is a small slice
|
|
22
|
+
(psutil is already an agent dependency via inventory/) — lands with the
|
|
23
|
+
community-pool tier alongside `benchmark/`.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from abc import ABC, abstractmethod
|
|
29
|
+
from datetime import datetime, timezone
|
|
30
|
+
|
|
31
|
+
from pydantic import BaseModel, Field
|
|
32
|
+
|
|
33
|
+
__all__ = ["GpuSample", "TelemetrySample", "TelemetryCollector"]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class GpuSample(BaseModel):
|
|
37
|
+
"""One GPU's instantaneous state (NVML-shaped; all optional because
|
|
38
|
+
partial visibility is normal — record what's readable, omit the rest)."""
|
|
39
|
+
|
|
40
|
+
index: int = 0
|
|
41
|
+
utilization_percent: float | None = None
|
|
42
|
+
memory_used_bytes: int | None = None
|
|
43
|
+
memory_total_bytes: int | None = None
|
|
44
|
+
temperature_c: float | None = None
|
|
45
|
+
power_watts: float | None = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class TelemetrySample(BaseModel):
|
|
49
|
+
"""A point-in-time snapshot of machine health.
|
|
50
|
+
|
|
51
|
+
Fields are chosen for what the *coordinator* can act on: scheduling
|
|
52
|
+
away from thermally-throttling or disk-full nodes, and (later) the
|
|
53
|
+
reliability score's thermal-stability input (master report §9.1)."""
|
|
54
|
+
|
|
55
|
+
timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
|
56
|
+
cpu_percent: float | None = Field(default=None, ge=0)
|
|
57
|
+
memory_used_bytes: int | None = Field(default=None, ge=0)
|
|
58
|
+
memory_total_bytes: int | None = Field(default=None, ge=0)
|
|
59
|
+
disk_free_bytes: int | None = Field(
|
|
60
|
+
default=None, ge=0, description="Free space on the workdir volume — the checkpoint path"
|
|
61
|
+
)
|
|
62
|
+
load_1m: float | None = Field(default=None, ge=0)
|
|
63
|
+
gpus: list[GpuSample] = Field(default_factory=list)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class TelemetryCollector(ABC):
|
|
67
|
+
"""Produces samples; owns nothing else.
|
|
68
|
+
|
|
69
|
+
Contract: `sample()` must be cheap (called on the heartbeat cadence,
|
|
70
|
+
default 5 s), must never raise for missing sensors (absent fields stay
|
|
71
|
+
None — same honesty rule as probes), and must never block on hardware
|
|
72
|
+
that is busy (a wedged NVML call gets a short internal timeout and an
|
|
73
|
+
omitted field, not a stuck heartbeat — the heartbeat is a correctness
|
|
74
|
+
signal and telemetry must never delay it).
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
@abstractmethod
|
|
78
|
+
def sample(self) -> TelemetrySample:
|
|
79
|
+
"""Take one snapshot now."""
|
|
@@ -0,0 +1,160 @@
|
|
|
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
|
+
# FlashNode
|
|
19
|
+
|
|
20
|
+
> **The open host agent of the FlashML system.** Install FlashNode on a
|
|
21
|
+
> machine you own, and it can safely execute distributed ML tasks for the
|
|
22
|
+
> FlashML network — earning contribution credits for verified useful work.
|
|
23
|
+
|
|
24
|
+
FlashNode is one of three components in the FlashML system by
|
|
25
|
+
[Zolli Labs](https://github.com/Zolli-Labs):
|
|
26
|
+
|
|
27
|
+
- **flashnode** (this repo) — open host agent installed by resource
|
|
28
|
+
contributors. Because it runs on someone else's machine and executes
|
|
29
|
+
third-party workloads, it must be inspectable, minimal, and explicit about
|
|
30
|
+
permissions — which is why it is open source.
|
|
31
|
+
- **[flashruntime](https://github.com/Zolli-Labs/flashruntime)** — the open
|
|
32
|
+
workload protocol and execution layer.
|
|
33
|
+
- **flashml-cloud** (private) — the managed control plane and dashboard.
|
|
34
|
+
|
|
35
|
+
Read [`docs/SYSTEM_OVERVIEW.md`](docs/SYSTEM_OVERVIEW.md) for the full
|
|
36
|
+
product architecture, and [`AGENTS.md`](AGENTS.md) if you are an AI coding
|
|
37
|
+
agent working in this repo.
|
|
38
|
+
|
|
39
|
+
## Status
|
|
40
|
+
|
|
41
|
+
**Pre-release; the device executor works today** (July 2026). A machine
|
|
42
|
+
with this agent can join a FlashRuntime coordinator over outbound HTTP,
|
|
43
|
+
pull leased tasks, execute them, relay training checkpoints, and commit
|
|
44
|
+
verified results. Two profiles:
|
|
45
|
+
|
|
46
|
+
- **Device profile** (`flashnode work`) — the pull-based executor for
|
|
47
|
+
laptops/workstations. Implemented.
|
|
48
|
+
- **Kubernetes profile** (`flashnode agent`) — per-node telemetry reporter
|
|
49
|
+
inside managed pools (DaemonSet); KubeRay owns workload pods there.
|
|
50
|
+
Implemented.
|
|
51
|
+
|
|
52
|
+
## What it does today
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pip install -e . # plus: pip install -e ../flashruntime
|
|
56
|
+
flashnode work --coordinator http://<coordinator>:8100
|
|
57
|
+
# optional hardening / pool config:
|
|
58
|
+
# FLASHNODE_JOIN_CODE=... join-code-gated pools
|
|
59
|
+
# --runner docker + FLASHNODE_ALLOWED_IMAGES=img:tag,... container tier
|
|
60
|
+
# FLASHNODE_WORKDIR=$HOME/.cache/flashnode (macOS + colima: VM-visible workdirs)
|
|
61
|
+
# FLASHNODE_WORKDIR=C:\Users\<you>\.flashnode (Windows: must be under a
|
|
62
|
+
# directory Docker Desktop shares)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
If the coordinator enforces per-machine authentication
|
|
66
|
+
(`FLASHML_NODE_TOKENS` set server-side), save the bearer token you were
|
|
67
|
+
given before running `work`:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
flashnode login --coordinator http://<coordinator>:8100 --token <token>
|
|
71
|
+
flashnode work --coordinator http://<coordinator>:8100 # reads the saved token automatically
|
|
72
|
+
flashnode logout --coordinator http://<coordinator>:8100 # forget it locally (does not revoke server-side)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`login`/`logout` write to a per-coordinator credential store at
|
|
76
|
+
`~/.flashnode/credentials.json` (override with `FLASHNODE_CREDENTIALS`),
|
|
77
|
+
keyed by coordinator URL so one machine can hold separate tokens for
|
|
78
|
+
separate pools. The file is written with mode `0600` on every save. A
|
|
79
|
+
missing or unparseable file is treated as "no saved token" rather than a
|
|
80
|
+
crash. `CoordinatorClient` sends the saved token as a bearer header on
|
|
81
|
+
every request to that coordinator once it's saved — there is nothing else
|
|
82
|
+
to configure. Token issuance is still manual and out-of-band today (the
|
|
83
|
+
coordinator operator hands you the token; there is no self-service signup
|
|
84
|
+
or browser device flow yet), and `flashnode logout` only removes the local
|
|
85
|
+
copy — the operator revokes access by removing your token from the
|
|
86
|
+
coordinator's configuration.
|
|
87
|
+
|
|
88
|
+
- Stable node identity; registers with capabilities (CPU, RAM, arch, GPU)
|
|
89
|
+
and **re-registers automatically** if the coordinator restarts.
|
|
90
|
+
- **Outbound-only** HTTP — no inbound ports, no router configuration.
|
|
91
|
+
- Claims task leases, renews them with attempt heartbeats, and stops work
|
|
92
|
+
the moment a lease is refused (the coordinator's idempotent commit
|
|
93
|
+
rejects late duplicates regardless — defense in depth).
|
|
94
|
+
- Two execution tiers behind one interface: `SubprocessRunner`
|
|
95
|
+
(allowlisted Python modules, wall-clock timeout, **scrubbed
|
|
96
|
+
environment** — agent secrets never reach task code) and `DockerRunner`
|
|
97
|
+
(allowlisted images, `--network none`, cpu/memory limits, read-only
|
|
98
|
+
rootfs, uid mapping).
|
|
99
|
+
- Downloads shared input artifacts; uploads outputs with sha256 for the
|
|
100
|
+
coordinator's commit-time validation.
|
|
101
|
+
- **Checkpoint courier**: tasks stay network-isolated, so the agent
|
|
102
|
+
fetches the task's latest valid checkpoint before a run (resume) and
|
|
103
|
+
ships each new checkpoint file during it — a task killed on this
|
|
104
|
+
machine resumes from its checkpoint on another.
|
|
105
|
+
|
|
106
|
+
Still to come: Ed25519-signed identity, admission benchmarks (`benchmark/`),
|
|
107
|
+
richer telemetry (`telemetry/`), gVisor/Kata isolation tiers, and the
|
|
108
|
+
`join`/`status`/`leave` UX.
|
|
109
|
+
|
|
110
|
+
## Security contract
|
|
111
|
+
|
|
112
|
+
- Outbound-only control connection; no inbound SSH or public ports.
|
|
113
|
+
- Signed node identity; short-lived session credentials.
|
|
114
|
+
- Allowlisted or signed workload images only.
|
|
115
|
+
- Non-root execution; no host Docker socket, device passthrough, or
|
|
116
|
+
privileged mode.
|
|
117
|
+
- The agent shows exactly which limits and permissions apply to a workload
|
|
118
|
+
before executing it.
|
|
119
|
+
- Complete event logging of task assignment, image digest, permissions, and
|
|
120
|
+
artifact commits.
|
|
121
|
+
|
|
122
|
+
Supported host class (initial): x86-64 Linux, macOS (Docker Desktop or
|
|
123
|
+
Colima), or Windows (Docker Desktop with the **WSL2 backend**), Python
|
|
124
|
+
3.10+, ≥4 CPU cores, ≥8 GB RAM, stable outbound internet.
|
|
125
|
+
|
|
126
|
+
**Windows note:** `flashnode work` used to crash immediately on Windows
|
|
127
|
+
(`os.getuid`/`os.getgid` don't exist there). It now omits `--user` on
|
|
128
|
+
Windows instead, relying on the curated images' own non-root `USER`
|
|
129
|
+
declaration for non-root execution — see
|
|
130
|
+
[`docs/guides/donate-a-machine.md`](https://github.com/Zolli-Labs/flashruntime/blob/main/docs/guides/donate-a-machine.md#platform-support)
|
|
131
|
+
in flashruntime for the full picture, including honest caveats: **Windows
|
|
132
|
+
support is constructed-argv-verified (tests fake the platform), not yet
|
|
133
|
+
execution-verified against a real Windows machine.**
|
|
134
|
+
|
|
135
|
+
## Package layout
|
|
136
|
+
|
|
137
|
+
Working today:
|
|
138
|
+
|
|
139
|
+
```
|
|
140
|
+
flashnode/
|
|
141
|
+
├── agent/ # CLI (`work`, `agent`), K8s-profile daemon, kube helper
|
|
142
|
+
├── identity/ # stable node ID (Ed25519 signing: planned); credentials.py
|
|
143
|
+
│ # is the per-coordinator bearer-token store behind
|
|
144
|
+
│ # `flashnode login`/`logout`
|
|
145
|
+
├── inventory/ # capability discovery (psutil + K8s allocatable)
|
|
146
|
+
└── executor/ # the device work cycle:
|
|
147
|
+
├── client.py # stdlib outbound HTTP: leases, artifacts, checkpoints
|
|
148
|
+
├── runner.py # Tier 1: allowlisted subprocess, scrubbed env
|
|
149
|
+
├── docker_runner.py # Tier 2: allowlisted containers, network-none
|
|
150
|
+
└── loop.py # claim → run (heartbeating) → relay ckpts → commit
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Scaffolds awaiting their vertical slice: `benchmark/` (admission probes),
|
|
154
|
+
`telemetry/` (rich metrics), `artifacts/` (local caching), `config/`
|
|
155
|
+
(host-owner policy).
|
|
156
|
+
|
|
157
|
+
## License
|
|
158
|
+
|
|
159
|
+
[Apache-2.0](LICENSE). Contributions via Developer Certificate of Origin
|
|
160
|
+
(`git commit -s`).
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
flashnode/__init__.py,sha256=TuoTHNk6l-uwAWQNPoHJ3xKrgMmwnOdkJCQT6j4oZJ0,203
|
|
2
|
+
flashnode/agent/__init__.py,sha256=SKSAPuExVvhqDfdDgp-_fNZYzpnBYnw6I9QNuJd8sQw,227
|
|
3
|
+
flashnode/agent/cli.py,sha256=DhTrjIVOiHHOsME6Gnqh_rOJRqR3k_shNGgMtWZ_bi0,10775
|
|
4
|
+
flashnode/agent/daemon.py,sha256=R2bS9ACRlhdAAoGGfk9tasmYDfEzFSFTMHDxNxGJGOg,3868
|
|
5
|
+
flashnode/agent/kube.py,sha256=3olz_oVv0RdDNogFwakiXyUD-q8J7PKVZ5_pABWiQew,1400
|
|
6
|
+
flashnode/artifacts/__init__.py,sha256=J3VdNm08o0AL1kLD_cjsxmGgm8e_uhwnljvYMI5nmek,177
|
|
7
|
+
flashnode/benchmark/__init__.py,sha256=YC66KYR-W7NANMtV0Y8kpixdzBxslgsaK9mDYaDxbBI,4168
|
|
8
|
+
flashnode/config/__init__.py,sha256=t6C8iJkWyIGJmdv9kqh30iFkcw6vkdy73UkPnvDbZhs,4404
|
|
9
|
+
flashnode/executor/__init__.py,sha256=6wQKrF-0zl5jAU21_xkeNA3P3iQ5jhbx0IFUlXdjkNU,1035
|
|
10
|
+
flashnode/executor/archives.py,sha256=HsFpwR_MyICSp7wkw4Wr_4rmobvnaT_d2l2I6LSNJkI,12472
|
|
11
|
+
flashnode/executor/argv_runner.py,sha256=OuvInw5s6QWqdGaLNrYg70NzGVpel98oeuRU5c_XMbg,5284
|
|
12
|
+
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
|
|
15
|
+
flashnode/executor/images.py,sha256=OGJt36m1HKwUOErN_MR_sixG3gxIFKH8htfTVK7Opeg,7563
|
|
16
|
+
flashnode/executor/loop.py,sha256=E_gBh6A-T3oEQmR-lvYDR2k5NXrYSySX61RIiOgy4To,16847
|
|
17
|
+
flashnode/executor/runner.py,sha256=AT6M80QDB9p8GFIpT7rgym2Tus-IUNnh5eBpIPavyx0,4218
|
|
18
|
+
flashnode/identity/__init__.py,sha256=bT0-8rAXwZdhGsTpVb1_KcCYSDwr9Tpwzfl7OiP19wc,161
|
|
19
|
+
flashnode/identity/credentials.py,sha256=y-OGUYEyv5J5z_FUT01tSYWGdpPfC9z0F6sX5AyZKv0,2059
|
|
20
|
+
flashnode/identity/enrol.py,sha256=GHvBLQDWdCTfDR45CQ1C70Jd00wFINfcxzvcDRC4ofg,6622
|
|
21
|
+
flashnode/identity/store.py,sha256=ZGCPhSiH63PAE3GRPGrlhlc2nNPgstDSMtNPu4N-OCE,1734
|
|
22
|
+
flashnode/inventory/__init__.py,sha256=epNMD283icBq-jjg5rN0u51tpSWm1RTw8PhkAXr88fM,193
|
|
23
|
+
flashnode/inventory/capabilities.py,sha256=XyE3pAVHG5Tvsk6kslJtx7vo5UXWYP4ZOiVgEym5FpY,4602
|
|
24
|
+
flashnode/telemetry/__init__.py,sha256=MRJ99bQykL1do3dQYMNiQABmYhTuEkgZdfsm5Np0KD4,3363
|
|
25
|
+
flashnode-0.2.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
|
|
26
|
+
flashnode-0.2.0.dist-info/METADATA,sha256=qL1vFkT0WcNQ8ZJpOHTVzpxHqfgfskO8uLn8FyDhWa8,7522
|
|
27
|
+
flashnode-0.2.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
28
|
+
flashnode-0.2.0.dist-info/entry_points.txt,sha256=rn0W3v0MU8yhzp6b3j9frGA92h5bN3vgqMQRuYAoBkM,55
|
|
29
|
+
flashnode-0.2.0.dist-info/top_level.txt,sha256=BErbFoSvml51DxXayRtL5l8_1p59Z4XnC2bmKeaWdBo,10
|
|
30
|
+
flashnode-0.2.0.dist-info/RECORD,,
|