flashnode 0.3.5__tar.gz → 0.4.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.
Files changed (80) hide show
  1. {flashnode-0.3.5 → flashnode-0.4.0}/PKG-INFO +2 -2
  2. {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/agent/cli.py +137 -0
  3. flashnode-0.4.0/flashnode/executor/datasets.py +457 -0
  4. {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/executor/loop.py +31 -0
  5. {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/inventory/capabilities.py +27 -0
  6. {flashnode-0.3.5 → flashnode-0.4.0}/flashnode.egg-info/PKG-INFO +2 -2
  7. {flashnode-0.3.5 → flashnode-0.4.0}/flashnode.egg-info/SOURCES.txt +5 -0
  8. {flashnode-0.3.5 → flashnode-0.4.0}/flashnode.egg-info/requires.txt +1 -1
  9. {flashnode-0.3.5 → flashnode-0.4.0}/pyproject.toml +22 -2
  10. {flashnode-0.3.5 → flashnode-0.4.0}/tests/test_capabilities.py +59 -0
  11. flashnode-0.4.0/tests/test_cli_data.py +150 -0
  12. flashnode-0.4.0/tests/test_datasets.py +155 -0
  13. flashnode-0.4.0/tests/test_datasets_fetch.py +240 -0
  14. flashnode-0.4.0/tests/test_loop_datasets.py +264 -0
  15. {flashnode-0.3.5 → flashnode-0.4.0}/LICENSE +0 -0
  16. {flashnode-0.3.5 → flashnode-0.4.0}/README.md +0 -0
  17. {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/__init__.py +0 -0
  18. {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/agent/__init__.py +0 -0
  19. {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/agent/daemon.py +0 -0
  20. {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/agent/kube.py +0 -0
  21. {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/artifacts/__init__.py +0 -0
  22. {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/benchmark/__init__.py +0 -0
  23. {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/config/__init__.py +0 -0
  24. {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/config/local_data.py +0 -0
  25. {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/doctor.py +0 -0
  26. {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/executor/__init__.py +0 -0
  27. {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/executor/archives.py +0 -0
  28. {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/executor/argv_runner.py +0 -0
  29. {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/executor/client.py +0 -0
  30. {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/executor/docker_runner.py +0 -0
  31. {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/executor/environments.py +0 -0
  32. {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/executor/evidence.py +0 -0
  33. {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/executor/hardening.py +0 -0
  34. {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/executor/health.py +0 -0
  35. {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/executor/images.py +0 -0
  36. {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/executor/runner.py +0 -0
  37. {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/executor/task_logs.py +0 -0
  38. {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/executor/trusted_runner.py +0 -0
  39. {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/identity/__init__.py +0 -0
  40. {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/identity/credentials.py +0 -0
  41. {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/identity/enrol.py +0 -0
  42. {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/identity/store.py +0 -0
  43. {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/inventory/__init__.py +0 -0
  44. {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/inventory/gpu.py +0 -0
  45. {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/status.py +0 -0
  46. {flashnode-0.3.5 → flashnode-0.4.0}/flashnode/telemetry/__init__.py +0 -0
  47. {flashnode-0.3.5 → flashnode-0.4.0}/flashnode.egg-info/dependency_links.txt +0 -0
  48. {flashnode-0.3.5 → flashnode-0.4.0}/flashnode.egg-info/entry_points.txt +0 -0
  49. {flashnode-0.3.5 → flashnode-0.4.0}/flashnode.egg-info/top_level.txt +0 -0
  50. {flashnode-0.3.5 → flashnode-0.4.0}/setup.cfg +0 -0
  51. {flashnode-0.3.5 → flashnode-0.4.0}/tests/test_agent.py +0 -0
  52. {flashnode-0.3.5 → flashnode-0.4.0}/tests/test_allowlist_drift.py +0 -0
  53. {flashnode-0.3.5 → flashnode-0.4.0}/tests/test_archives.py +0 -0
  54. {flashnode-0.3.5 → flashnode-0.4.0}/tests/test_argv_runner.py +0 -0
  55. {flashnode-0.3.5 → flashnode-0.4.0}/tests/test_checkpoint_relay.py +0 -0
  56. {flashnode-0.3.5 → flashnode-0.4.0}/tests/test_cli_env.py +0 -0
  57. {flashnode-0.3.5 → flashnode-0.4.0}/tests/test_cli_join.py +0 -0
  58. {flashnode-0.3.5 → flashnode-0.4.0}/tests/test_cli_trusted.py +0 -0
  59. {flashnode-0.3.5 → flashnode-0.4.0}/tests/test_credentials.py +0 -0
  60. {flashnode-0.3.5 → flashnode-0.4.0}/tests/test_docker_runner.py +0 -0
  61. {flashnode-0.3.5 → flashnode-0.4.0}/tests/test_doctor.py +0 -0
  62. {flashnode-0.3.5 → flashnode-0.4.0}/tests/test_enrol.py +0 -0
  63. {flashnode-0.3.5 → flashnode-0.4.0}/tests/test_environments.py +0 -0
  64. {flashnode-0.3.5 → flashnode-0.4.0}/tests/test_evidence.py +0 -0
  65. {flashnode-0.3.5 → flashnode-0.4.0}/tests/test_executor.py +0 -0
  66. {flashnode-0.3.5 → flashnode-0.4.0}/tests/test_gpu_probe.py +0 -0
  67. {flashnode-0.3.5 → flashnode-0.4.0}/tests/test_hardening.py +0 -0
  68. {flashnode-0.3.5 → flashnode-0.4.0}/tests/test_hardening_platform.py +0 -0
  69. {flashnode-0.3.5 → flashnode-0.4.0}/tests/test_identity.py +0 -0
  70. {flashnode-0.3.5 → flashnode-0.4.0}/tests/test_images_allowlist.py +0 -0
  71. {flashnode-0.3.5 → flashnode-0.4.0}/tests/test_interfaces.py +0 -0
  72. {flashnode-0.3.5 → flashnode-0.4.0}/tests/test_local_data.py +0 -0
  73. {flashnode-0.3.5 → flashnode-0.4.0}/tests/test_loop_counters.py +0 -0
  74. {flashnode-0.3.5 → flashnode-0.4.0}/tests/test_status.py +0 -0
  75. {flashnode-0.3.5 → flashnode-0.4.0}/tests/test_task_logs.py +0 -0
  76. {flashnode-0.3.5 → flashnode-0.4.0}/tests/test_tier_health.py +0 -0
  77. {flashnode-0.3.5 → flashnode-0.4.0}/tests/test_trusted_runner.py +0 -0
  78. {flashnode-0.3.5 → flashnode-0.4.0}/tests/test_trusted_tier_endtoend.py +0 -0
  79. {flashnode-0.3.5 → flashnode-0.4.0}/tests/test_work_gate.py +0 -0
  80. {flashnode-0.3.5 → flashnode-0.4.0}/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.5
3
+ Version: 0.4.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.6,>=0.4.3
10
+ Requires-Dist: flashruntime<0.7,>=0.6
11
11
  Requires-Dist: psutil>=5.9
12
12
  Requires-Dist: websockets>=12
13
13
  Requires-Dist: cryptography>=42
@@ -29,6 +29,8 @@ commands:
29
29
  doctor check this machine can run tasks (docker engine, images, mounts)
30
30
  env see and reclaim disk held by cached dependency environments
31
31
  (list | purge — purge never touches node-id or credentials.json)
32
+ data see and reclaim disk held by cached dataset shards
33
+ (list | purge — purge never touches node-id or credentials.json)
32
34
  join connect this machine to a FlashML control plane (not yet implemented)
33
35
  status show node identity, capabilities, and active leases (not yet implemented)
34
36
  leave drain and disconnect (not yet implemented)
@@ -624,6 +626,139 @@ def _env(args: list[str]) -> int:
624
626
  return 0
625
627
 
626
628
 
629
+ def _data(args: list[str]) -> int:
630
+ """`flashnode data list|purge` — the disk cached dataset shards hold.
631
+
632
+ The sibling of `_env`, with the same contract: `purge` deletes only what
633
+ `DatasetCache.entries()` reports — the contents of
634
+ `$FLASHNODE_STATE_DIR/data/` — and never the state directory itself, so
635
+ `node-id` and `credentials.json` survive. That is the entire difference
636
+ between this command and un-enrolling the machine.
637
+
638
+ Its own command rather than a flag on `env` because the two caches hold
639
+ different orders of magnitude for different reasons, and a host owner
640
+ who wants to keep 8 GB of virtualenvs while giving back 200 GB of a
641
+ stranger's shards must be able to say exactly that.
642
+ """
643
+ import argparse
644
+ import shutil
645
+ from datetime import datetime
646
+
647
+ from flashnode.executor.datasets import DatasetCache
648
+
649
+ parser = argparse.ArgumentParser(prog="flashnode data")
650
+ sub = parser.add_subparsers(dest="subcommand", required=True)
651
+ sub.add_parser("list", help="show cached shards, their size, and last use")
652
+ sub.add_parser(
653
+ "purge",
654
+ help="delete all cached dataset shards (identity and credentials "
655
+ "survive; stop `flashnode work` first — this can break a task "
656
+ "in flight)",
657
+ )
658
+ opts = parser.parse_args(args)
659
+
660
+ cache = DatasetCache()
661
+ entries = cache.entries() # most recently used first
662
+
663
+ # Scratch directories from fetches that were KILLED — SIGKILL, the OOM
664
+ # killer, a power cut. `entries()` skips dotted names, so without this
665
+ # the two commands whose whole job is to account for disk would be the
666
+ # two that could not see a half-downloaded multi-GB shard.
667
+ stale = cache.stale_bytes()
668
+
669
+ if opts.subcommand == "list":
670
+ if not entries and not stale:
671
+ print(
672
+ "flashnode data: no dataset shards cached — nothing is using "
673
+ "disk yet."
674
+ )
675
+ return 0
676
+ for entry in entries:
677
+ when = datetime.fromtimestamp(entry.last_used).strftime("%Y-%m-%d %H:%M")
678
+ # Truncated: the key is a sha256, and 64 hex characters per line
679
+ # buries the two columns the owner is actually reading.
680
+ print(
681
+ f" {entry.key[:16]}… {_human_bytes(entry.size_bytes):>8} "
682
+ f"last used {when}"
683
+ )
684
+ total = sum(e.size_bytes for e in entries) + stale
685
+ if stale:
686
+ print(
687
+ f" leftover fetch directories {_human_bytes(stale):>8} "
688
+ "from interrupted fetches"
689
+ )
690
+ print(
691
+ f"total: {_human_bytes(total)} across {len(entries)} shard(s) "
692
+ f"at {cache.root}"
693
+ )
694
+ if stale:
695
+ print(
696
+ " the leftover fetch directories are swept by the next "
697
+ "fetch, or now by `flashnode data purge`"
698
+ )
699
+ return 0
700
+
701
+ # purge
702
+ #
703
+ # This runs in a SEPARATE process from the agent, so it sidesteps the
704
+ # `_evict(protect=…)` that makes in-process eviction safe and can delete
705
+ # the shard a running task is reading through its hard link. Refusing
706
+ # would beat warning, but nothing in the state directory records a
707
+ # running agent — no pidfile, no lock — and a guessed signal would refuse
708
+ # on a machine with no agent at all. So: say it, plainly, every time, and
709
+ # before the deletion rather than after it.
710
+ print(
711
+ "flashnode data purge: warning — if `flashnode work` is running on "
712
+ "this machine, this can delete a shard a task is reading and fail "
713
+ "that task. This command cannot tell whether an agent is running. "
714
+ "Stop the agent first if you are not sure."
715
+ )
716
+ # Counted AFTER each removal, never before — the `_env` bug, which must
717
+ # not be reintroduced here. Totalling up front and deleting with
718
+ # `ignore_errors=True` prints the full figure as reclaimed when a
719
+ # permission error left the bytes on disk, on the one command whose
720
+ # entire value is that number and which a volunteer has no other way to
721
+ # check.
722
+ freed = 0
723
+ removed = 0
724
+ survivors = []
725
+ for entry in entries:
726
+ shutil.rmtree(entry.path, ignore_errors=True)
727
+ if entry.path.exists():
728
+ survivors.append(entry.path)
729
+ else:
730
+ freed += entry.size_bytes
731
+ removed += 1
732
+ stale_freed = cache.sweep_stale() # counts only what actually went
733
+ freed += stale_freed
734
+ survivors.extend(cache.stale_dirs())
735
+ leftovers = (
736
+ f" (including {_human_bytes(stale_freed)} of leftover fetch "
737
+ "directories from interrupted fetches)"
738
+ if stale_freed
739
+ else ""
740
+ )
741
+ print(
742
+ f"flashnode data purge: freed {_human_bytes(freed)} across "
743
+ f"{removed} shard(s){leftovers}. node-id and credentials.json were "
744
+ "not touched."
745
+ )
746
+ if survivors:
747
+ noun = "directory" if len(survivors) == 1 else "directories"
748
+ print(
749
+ f"flashnode data purge: could not remove {len(survivors)} {noun} "
750
+ "— the disk it holds was NOT reclaimed:"
751
+ )
752
+ for path in survivors:
753
+ print(f" {path}")
754
+ print(
755
+ " check the permissions on those paths, or whether another "
756
+ "process is holding them open, and run purge again."
757
+ )
758
+ return 1
759
+ return 0
760
+
761
+
627
762
  def main(argv: list[str] | None = None) -> int:
628
763
  args = sys.argv[1:] if argv is None else argv
629
764
  if args and args[0] == "agent":
@@ -642,6 +777,8 @@ def main(argv: list[str] | None = None) -> int:
642
777
  return _logout(args[1:])
643
778
  if args and args[0] == "env":
644
779
  return _env(args[1:])
780
+ if args and args[0] == "data":
781
+ return _data(args[1:])
645
782
  print(USAGE.format(version=__version__), end="")
646
783
  if args and args[0] in {"join", "status", "leave"}:
647
784
  print(f"\nerror: '{args[0]}' is not implemented yet in this scaffold.", file=sys.stderr)
@@ -0,0 +1,457 @@
1
+ """Cached dataset shards, one entry per shard checksum.
2
+
3
+ A task runs with `--network none` and cannot fetch its own data. The agent
4
+ fetches instead, before the sandbox closes, and this module is where the
5
+ bytes live between tasks.
6
+
7
+ Modelled on `environments.py`, and for the same reason: **disk is somebody
8
+ else's.** Bounded (FLASHNODE_DATA_BUDGET_GB, default 16), evicted
9
+ least-recently-USED, and a fetch is refused before it starts unless the
10
+ filesystem still has 1 GB free.
11
+
12
+ Two deliberate differences from EnvironmentCache:
13
+
14
+ * **The key is the shard's own checksum**, not a hash of a request. Two
15
+ jobs training on the same public dataset — or two revisions of one
16
+ dataset sharing an unchanged shard — hit the same entry for free.
17
+ * **Size is a written marker.** EnvironmentCache measures with a tree walk
18
+ on every `entries()` call, which `_evict` also calls. That is fine for a
19
+ handful of venvs and hopeless for a dataset of a million files.
20
+
21
+ A separate budget from the venv cache on purpose: a host owner should be
22
+ able to say yes to 8 GB of virtualenvs and no to 200 GB of a stranger's
23
+ images. Different orders of magnitude, different consent.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import hashlib
29
+ import http.client
30
+ import os
31
+ import re
32
+ import shutil
33
+ import time
34
+ import urllib.error
35
+ import urllib.request
36
+ from dataclasses import dataclass
37
+ from pathlib import Path
38
+
39
+ from flashnode.executor.runner import TaskExecutionError
40
+ from flashnode.identity.store import state_dir
41
+
42
+ DEFAULT_BUDGET_GB = 16.0
43
+ # A floor on free space that must EXIST before a fetch starts. Read it
44
+ # literally: it is not a promise the gigabyte survives the fetch.
45
+ MIN_FREE_BYTES = 1024**3
46
+ _USED_MARKER = "used"
47
+ _SIZE_MARKER = "size"
48
+ _BUILD_PREFIX = ".fetching-"
49
+
50
+ #: Same shape flashnode already requires of an input name (loop.py) and a
51
+ #: local-data label. A dataset name becomes a directory on a stranger's disk.
52
+ _SAFE_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
53
+
54
+ #: Transport-level retry, the same shape and for the same measured reason as
55
+ #: `client._TRANSPORT_ATTEMPTS`: a single dropped connection on a volunteer's
56
+ #: home network must not cost the submitter their task.
57
+ #: What counts as "the origin hiccuped" rather than "the task is broken".
58
+ #:
59
+ #: `http.client.IncompleteRead` is here and is NOT in `client.py`'s
60
+ #: byte-identical-looking tuple, deliberately. It descends from
61
+ #: HTTPException, not OSError, so the obvious three-way tuple misses it —
62
+ #: and a CDN closing a connection early is the single most likely way a
63
+ #: large shard fetch fails. Uncaught it leaves `materialise`, misses
64
+ #: `_execute_inner`'s `except TaskExecutionError`, and lands in
65
+ #: `execute_one`'s last-line-of-defence handler, which says of itself:
66
+ #: "This DOES count against the host". So a flaky ORIGIN would be charged
67
+ #: to the volunteer and could quarantine their machine — inverting the one
68
+ #: rule this module exists to keep, that a broken origin costs the
69
+ #: submitter their task and never the volunteer their node.
70
+ #:
71
+ #: `client.py` has the same gap on the coordinator transport. Left alone
72
+ #: here on purpose: changing shipped retry semantics is a separate change
73
+ #: with its own evidence, not a side effect of adding datasets.
74
+ _TRANSIENT = (
75
+ urllib.error.URLError,
76
+ TimeoutError,
77
+ OSError,
78
+ http.client.IncompleteRead,
79
+ )
80
+
81
+ _FETCH_ATTEMPTS = 4
82
+ _FETCH_BACKOFF_S = 0.5
83
+ _CHUNK = 1 << 20 # 1 MiB
84
+
85
+
86
+ class DatasetFetchError(TaskExecutionError):
87
+ """A shard could not be fetched, verified, or stored.
88
+
89
+ Subclasses TaskExecutionError deliberately: the executor loop already
90
+ reports the cause through fail() before re-raising, so a broken origin
91
+ costs the submitter their task and never the volunteer their node.
92
+ """
93
+
94
+
95
+ @dataclass(frozen=True)
96
+ class ShardEntry:
97
+ key: str
98
+ path: Path
99
+ size_bytes: int
100
+ last_used: float
101
+
102
+
103
+ def _free_bytes(path: Path) -> int:
104
+ # A module function, not an inline shutil call, so tests can patch it by
105
+ # dotted string — the seam test_environments.py already relies on.
106
+ return shutil.disk_usage(path).free
107
+
108
+
109
+ def budget_bytes_from_env() -> int:
110
+ """FLASHNODE_DATA_BUDGET_GB in bytes, or 0 if it is not a usable number.
111
+
112
+ THE one place this variable is parsed. It has two readers that must
113
+ never disagree: this cache, which decides what the host will actually
114
+ hold, and `inventory.capabilities.discover`, which decides what the
115
+ host ADVERTISES. Advertising more than the cache holds means accepting
116
+ work the cache then evicts mid-job, so a second copy of this arithmetic
117
+ is a bug waiting for a typo — the same duplicated-derivation trap
118
+ `elastic.py`'s docstring warns about.
119
+
120
+ Malformed reads as 0, never as the default. Fail closed: 0 is
121
+ indistinguishable on the wire from the owner's deliberate opt-out and
122
+ costs nobody a task, whereas silently substituting 16 GB would advertise
123
+ capacity the owner never agreed to lend.
124
+
125
+ `int()` is inside the guard, not just `float()`: `float("nan")` parses
126
+ happily and raises in `int()`, and `"inf"` raises OverflowError there.
127
+ """
128
+ try:
129
+ return max(0, int(float(
130
+ os.environ.get("FLASHNODE_DATA_BUDGET_GB", DEFAULT_BUDGET_GB)
131
+ ) * 1024**3))
132
+ except (TypeError, ValueError, OverflowError):
133
+ return 0
134
+
135
+
136
+ class DatasetCache:
137
+ """Shard checksum -> cached file, bounded and LRU-evicted."""
138
+
139
+ def __init__(self, root: Path | None = None, budget_gb: float | None = None):
140
+ self.root = Path(root) if root is not None else state_dir() / "data"
141
+ self.budget_bytes = (
142
+ budget_bytes_from_env()
143
+ if budget_gb is None
144
+ else max(0, int(budget_gb * 1024**3))
145
+ )
146
+
147
+ # -- public -----------------------------------------------------------
148
+ def get(self, integrity_value: str) -> Path | None:
149
+ """The cached shard for this checksum, or None. Records a USE."""
150
+ entry = self.root / integrity_value
151
+ blob = entry / "blob"
152
+ if not blob.exists():
153
+ return None
154
+ self._touch(entry)
155
+ return blob
156
+
157
+ def put(self, integrity_value: str, source: Path) -> Path:
158
+ """Publish `source` under `integrity_value`; return the stored path.
159
+
160
+ The move is a rename into place, the write-then-rename idiom
161
+ `environments.py` and `identity/store.py` both use, so a partial
162
+ entry is never published or returned.
163
+ """
164
+ self.root.mkdir(parents=True, exist_ok=True)
165
+ entry = self.root / integrity_value
166
+ size = Path(source).stat().st_size
167
+ scratch = self.root / f"{_BUILD_PREFIX}{integrity_value}"
168
+ if scratch.exists():
169
+ shutil.rmtree(scratch, ignore_errors=True)
170
+ scratch.mkdir(parents=True)
171
+ try:
172
+ os.replace(source, scratch / "blob")
173
+ # Read-only, and load-bearing rather than tidy. `materialise`
174
+ # hard-links out of this cache, so the file a task sees at
175
+ # /work/data/ IS this blob — one inode, many jobs. A task writing
176
+ # to its own input therefore rewrites the CACHE, and every later
177
+ # job on this host gets the corrupted bytes as a HIT, with no
178
+ # re-verification and no signal. Measured before this line: job 1
179
+ # writes, job 2 silently trains on job 1's garbage.
180
+ #
181
+ # 0o444 turns that into an immediate EACCES in the job that tried
182
+ # it, which is the correct blame. Eviction still works: removing a
183
+ # file needs write permission on the DIRECTORY, not on the file.
184
+ (scratch / "blob").chmod(0o444)
185
+ (scratch / _SIZE_MARKER).write_text(str(size))
186
+ self._touch(scratch)
187
+ if entry.exists(): # wreckage from an interrupted run
188
+ shutil.rmtree(entry, ignore_errors=True)
189
+ scratch.replace(entry)
190
+ except BaseException:
191
+ shutil.rmtree(scratch, ignore_errors=True)
192
+ raise
193
+ self._evict(protect=entry)
194
+ return entry / "blob"
195
+
196
+ def reserve(self, nbytes: int) -> None:
197
+ """Prune, then refuse unless the disk can take `nbytes`.
198
+
199
+ Pruning first is load-bearing, not an optimisation: `_evict`
200
+ otherwise runs only after a successful put, so a node that dips
201
+ under the floor can never fetch, therefore never evicts, therefore
202
+ never recovers.
203
+
204
+ Unlike EnvironmentCache's floor — which cannot know an install's
205
+ size until the resolver has run — a manifest entry states its own
206
+ `size`, so the bytes about to arrive are counted here as well as
207
+ the floor beneath them.
208
+ """
209
+ self.root.mkdir(parents=True, exist_ok=True)
210
+ if _free_bytes(self.root) - nbytes >= MIN_FREE_BYTES:
211
+ return
212
+ self._evict()
213
+ free = _free_bytes(self.root)
214
+ if free - nbytes < MIN_FREE_BYTES:
215
+ raise DatasetFetchError(
216
+ f"refusing to fetch {nbytes // 1024**2} MB of dataset: after "
217
+ f"pruning the shard cache to its "
218
+ f"{self.budget_bytes // 1024**2} MB budget, only "
219
+ f"{free // 1024**2} MB is free on disk at {self.root}, and "
220
+ f"this node will not start a fetch that would leave less "
221
+ f"than {MIN_FREE_BYTES // 1024**2} MB free. Free disk space, "
222
+ f"or lower FLASHNODE_DATA_BUDGET_GB so the next attempt "
223
+ f"prunes further."
224
+ )
225
+
226
+ def entries(self) -> list[ShardEntry]:
227
+ """Cached shards, most recently used first."""
228
+ if not self.root.is_dir():
229
+ return []
230
+ found = []
231
+ for path in self.root.iterdir():
232
+ if not path.is_dir() or path.name.startswith("."):
233
+ continue
234
+ # The written marker, not a tree walk. `_evict` calls this on
235
+ # every put, and a shard directory can hold a million files.
236
+ # The fallback covers an entry whose marker was lost with the
237
+ # process that should have written it: one stat is still cheap,
238
+ # and reporting 0 would make `_evict` unable to shrink.
239
+ try:
240
+ size = int((path / _SIZE_MARKER).read_text())
241
+ except (OSError, ValueError):
242
+ blob = path / "blob"
243
+ size = blob.stat().st_size if blob.exists() else 0
244
+ # Seconds, deliberately: `agent/cli.py` renders this with
245
+ # `datetime.fromtimestamp`. The PRECISION comes from the written
246
+ # nanosecond stamp — see `_touch`; the unit does not change.
247
+ try:
248
+ last_used = int((path / _USED_MARKER).read_text()) / 1e9
249
+ except (OSError, ValueError):
250
+ last_used = path.stat().st_mtime
251
+ found.append(ShardEntry(path.name, path, size, last_used))
252
+ return sorted(found, key=lambda e: e.last_used, reverse=True)
253
+
254
+ def stale_dirs(self) -> list[Path]:
255
+ """Scratch directories left by fetches that were killed outright.
256
+
257
+ Deliberately NOT folded into `entries()`, for the reason
258
+ `EnvironmentCache.stale_build_dirs` gives: those are cache entries
259
+ `_evict` deletes by age to stay inside the budget, and a half-written
260
+ shard has no `used` marker and is not a cacheable thing. Reported
261
+ separately so the owner sees the bytes rather than the cache quietly
262
+ under-reporting what it holds.
263
+ """
264
+ if not self.root.is_dir():
265
+ return []
266
+ return sorted(
267
+ p for p in self.root.iterdir()
268
+ if p.is_dir() and p.name.startswith(_BUILD_PREFIX)
269
+ )
270
+
271
+ def stale_bytes(self) -> int:
272
+ return sum(
273
+ sum(f.stat().st_size for f in p.rglob("*") if f.is_file())
274
+ for p in self.stale_dirs()
275
+ )
276
+
277
+ def sweep_stale(self) -> int:
278
+ """Delete wreckage; return bytes actually reclaimed (counted after)."""
279
+ freed = 0
280
+ for path in self.stale_dirs():
281
+ size = sum(f.stat().st_size for f in path.rglob("*") if f.is_file())
282
+ shutil.rmtree(path, ignore_errors=True)
283
+ if not path.exists():
284
+ freed += size
285
+ return freed
286
+
287
+ # -- internals --------------------------------------------------------
288
+ def _touch(self, entry: Path) -> None:
289
+ """Record a USE with a written nanosecond stamp.
290
+
291
+ NOT mtime. Linux stamps inodes from a coarse clock, so two events
292
+ microseconds apart get byte-identical st_mtime_ns; `sorted` is
293
+ stable, so the tie fell through to iterdir() order, which ext4
294
+ hashes. The result was an LRU that evicted its most recently used
295
+ entry. See EnvironmentCache._touch — do not simplify this.
296
+ """
297
+ (entry / _USED_MARKER).write_text(str(time.time_ns()))
298
+
299
+ def _evict(self, protect: Path | None = None) -> None:
300
+ """Delete least-recently-used entries until within budget.
301
+
302
+ `protect` is the entry a caller is about to be handed. Pruning to
303
+ make room takes no such argument: nothing is in use at that moment,
304
+ so the cache may empty itself if that is what the budget now says.
305
+ """
306
+ entries = self.entries() # most recently used first
307
+ total = sum(e.size_bytes for e in entries)
308
+ for entry in reversed(entries): # oldest first
309
+ if total <= self.budget_bytes:
310
+ return
311
+ if entry.path == protect:
312
+ continue
313
+ shutil.rmtree(entry.path, ignore_errors=True)
314
+ total -= entry.size_bytes
315
+
316
+
317
+ def _open_url(url: str, offset: int):
318
+ """Open `url`, optionally resuming at `offset`. The single network seam.
319
+
320
+ Deliberately NOT `client._request`: that prefixes the coordinator base
321
+ URL and attaches the agent's bearer token, and a dataset origin is a
322
+ third party that must never see it.
323
+
324
+ A module function, and the ONLY place this module touches the network,
325
+ so every test above patches it by dotted string and the real thing runs
326
+ only under `-m integration`.
327
+ """
328
+ request = urllib.request.Request(url, headers={"User-Agent": "flashnode"})
329
+ if offset:
330
+ request.add_header("Range", f"bytes={offset}-")
331
+ return urllib.request.urlopen(request, timeout=60)
332
+
333
+
334
+ def fetch_shard(entry: dict, cache: DatasetCache, scratch: Path) -> Path:
335
+ """Return a local path to this manifest entry's bytes, fetching if needed."""
336
+ integrity = entry.get("integrity") or {}
337
+ kind = integrity.get("kind")
338
+ value = integrity.get("value")
339
+ if not isinstance(value, str) or not value:
340
+ raise DatasetFetchError(
341
+ f"manifest entry {entry.get('path')!r} has no integrity value"
342
+ )
343
+
344
+ cached = cache.get(value)
345
+ if cached is not None:
346
+ return cached
347
+
348
+ size = entry.get("size")
349
+ cache.reserve(int(size) if isinstance(size, int) and size > 0 else 0)
350
+
351
+ scratch.mkdir(parents=True, exist_ok=True)
352
+ target = scratch / "blob"
353
+ written = 0
354
+ last: Exception | None = None
355
+ for attempt in range(_FETCH_ATTEMPTS):
356
+ try:
357
+ with _open_url(entry["url"], written) as resp:
358
+ mode = "ab" if written else "wb"
359
+ with open(target, mode) as fh:
360
+ # copyfileobj, never resp.read(): a multi-GB shard must
361
+ # not be assembled in RAM on a volunteer's laptop.
362
+ shutil.copyfileobj(resp, fh, _CHUNK)
363
+ written = target.stat().st_size
364
+ break
365
+ except _TRANSIENT as exc:
366
+ last = exc
367
+ # Resume from whatever landed. An origin that ignores the Range
368
+ # header and re-serves the whole body will produce a wrong file,
369
+ # not a silently wrong one — the checksum below catches it.
370
+ written = target.stat().st_size if target.exists() else 0
371
+ if attempt < _FETCH_ATTEMPTS - 1:
372
+ time.sleep(_FETCH_BACKOFF_S * (2 ** attempt))
373
+ else:
374
+ raise DatasetFetchError(
375
+ f"could not fetch {entry.get('path')!r} after {_FETCH_ATTEMPTS} "
376
+ f"attempts: {last}"
377
+ )
378
+
379
+ # sha256 is the only kind we can prove. An ETag is md5-of-md5s for a
380
+ # multipart upload, so asserting on it would fail every valid multipart
381
+ # object; it is a change-detection token the control plane already used
382
+ # to pin the revision, not something to verify here.
383
+ # `declared-sha256` is verified exactly like `sha256`. The two differ in
384
+ # PROVENANCE, not in arithmetic: one hash came from the origin's own API,
385
+ # the other from a manifest the submitter wrote. Neither is a reason to
386
+ # skip the check — a declared hash is the only integrity signal an
387
+ # arbitrary HTTPS origin offers at all, so not checking it would leave
388
+ # that whole scheme with no verification of any kind.
389
+ #
390
+ # An ETag stays unverified: for a multipart upload it is md5-of-md5s, so
391
+ # asserting on it would fail every valid multipart object.
392
+ if kind in ("sha256", "declared-sha256"):
393
+ computed = hashlib.sha256()
394
+ with open(target, "rb") as fh:
395
+ # Streamed, like the download: hashing via read_bytes() would
396
+ # undo the whole point of copyfileobj above.
397
+ for block in iter(lambda: fh.read(_CHUNK), b""):
398
+ computed.update(block)
399
+ if computed.hexdigest() != value:
400
+ target.unlink(missing_ok=True)
401
+ raise DatasetFetchError(
402
+ f"checksum mismatch for {entry.get('path')!r}: the origin "
403
+ f"served bytes that are not what the manifest pinned "
404
+ f"(wanted {value[:16]}…, got {computed.hexdigest()[:16]}…). "
405
+ f"The dataset may have changed since the job was submitted."
406
+ )
407
+ return cache.put(value, target)
408
+
409
+
410
+ def materialise(datasets: list[dict], cache: DatasetCache, workdir: Path) -> None:
411
+ """Lay every declared dataset out under `workdir/data/<name>/`.
412
+
413
+ Hard-links from the cache where the filesystem allows it and copies
414
+ otherwise, so N tasks sharing a shard cost one copy on disk.
415
+ """
416
+ for dataset in datasets or []:
417
+ name = dataset.get("name")
418
+ if (
419
+ not isinstance(name, str)
420
+ or not _SAFE_NAME.match(name)
421
+ or name in (".", "..")
422
+ ):
423
+ raise DatasetFetchError(
424
+ f"refusing a dataset with unsafe name {name!r}: a name is not "
425
+ f"a path"
426
+ )
427
+ root = (workdir / "data" / name).resolve()
428
+ for entry in dataset.get("entries") or []:
429
+ rel = str(entry.get("path") or "")
430
+ dest = (root / rel).resolve()
431
+ # Resolve THEN require containment. A naive join misses the
432
+ # absolute form entirely: Path("/work/data") / "/etc/x" is
433
+ # "/etc/x" — pathlib discards the left operand. Same reasoning
434
+ # as archives.py.
435
+ #
436
+ # Strictly INSIDE, never equal: an entry whose path is empty or
437
+ # "." names the dataset directory itself, and linking a blob
438
+ # onto a directory raises a bare OSError out of here. A broken
439
+ # manifest must fail the task as a typed refusal.
440
+ if not str(dest).startswith(str(root) + os.sep):
441
+ raise DatasetFetchError(
442
+ f"refusing dataset entry {rel!r}: it resolves outside "
443
+ f"{root}"
444
+ )
445
+ blob = fetch_shard(entry, cache, workdir / ".fetching" / name)
446
+ dest.parent.mkdir(parents=True, exist_ok=True)
447
+ try:
448
+ os.link(blob, dest)
449
+ except OSError:
450
+ # A separate filesystem, a hard-link-hostile one, or the
451
+ # destination already there from a previous task on this
452
+ # workdir. A copy is slower, never wrong.
453
+ shutil.copyfile(blob, dest)
454
+ # A copy is a NEW inode and would default to writable, so the
455
+ # link and copy paths would disagree about whether a task can
456
+ # scribble on its own input. Freeze it to match.
457
+ dest.chmod(0o444)
@@ -431,6 +431,37 @@ class ExecutorLoop:
431
431
  f"unpack_inputs names inputs that do not exist: {sorted(unknown)}"
432
432
  )
433
433
 
434
+ declared = payload.get("datasets")
435
+ if declared:
436
+ # Shape-checked first, exactly as `unpack_inputs` is
437
+ # above and for the same reason: the payload is
438
+ # attacker-influenced all the way from the submission,
439
+ # and `materialise` reaches for `.get` on every element.
440
+ # A `datasets` that is not a list of dicts would raise a
441
+ # bare AttributeError, miss the `except TaskExecutionError`
442
+ # below, never reach fail(), and land in `execute_one`'s
443
+ # last-line-of-defence — which counts against the HOST.
444
+ # A broken payload must cost the submitter their task.
445
+ if not isinstance(declared, list) or not all(
446
+ isinstance(d, dict) for d in declared
447
+ ):
448
+ raise TaskExecutionError(
449
+ "payload 'datasets' must be a list of dataset slices"
450
+ )
451
+ # Before the runner, always. The task runs with
452
+ # `--network none`, so a fetch after the sandbox closes
453
+ # is a fetch that never happens.
454
+ #
455
+ # Imported here, not at module scope, following the rule
456
+ # trusted_runner.py already follows for `environments`.
457
+ from flashnode.executor import datasets as datasets_mod
458
+
459
+ datasets_mod.materialise(
460
+ declared, datasets_mod.DatasetCache(), workdir,
461
+ )
462
+ log.info(_jlog("datasets ready", task=lease.task_id,
463
+ count=len(declared)))
464
+
434
465
  prefix = payload.get("output_prefix", f"jobs/{lease.job_id}/{lease.task_id}/")
435
466
  if payload.get("checkpoint") is not None:
436
467
  # resume from the task's latest valid checkpoint, wherever