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.
@@ -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,5 @@
1
+ """Hardware and software discovery.
2
+
3
+ Reports CPU, RAM, disk, OS, architecture, container runtime, and optional
4
+ GPU via psutil and platform probes. Produces NodeCapabilitySnapshot payloads.
5
+ """
@@ -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,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ flashnode = flashnode.agent.cli:main