flashruntime 0.3.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.
- flashml_workloads/__init__.py +7 -0
- flashml_workloads/fedavg_driver.py +569 -0
- flashml_workloads/fedavg_weights.py +223 -0
- flashml_workloads/fedavg_worker.py +166 -0
- flashml_workloads/kmeans_driver.py +134 -0
- flashml_workloads/kmeans_shard.py +69 -0
- flashml_workloads/sgd_trainer.py +127 -0
- flashml_workloads/sharded_kmeans.py +323 -0
- flashml_workloads/sklearn_trial.py +89 -0
- flashruntime/__init__.py +125 -0
- flashruntime/artifacts/__init__.py +25 -0
- flashruntime/artifacts/store.py +228 -0
- flashruntime/backends/__init__.py +26 -0
- flashruntime/backends/base.py +63 -0
- flashruntime/backends/kuberay.py +465 -0
- flashruntime/checkpoint/__init__.py +20 -0
- flashruntime/checkpoint/catalog.py +198 -0
- flashruntime/checkpoint/local.py +109 -0
- flashruntime/checkpoint/store.py +86 -0
- flashruntime/integrations/__init__.py +5 -0
- flashruntime/integrations/huggingface.py +59 -0
- flashruntime/integrations/pytorch.py +52 -0
- flashruntime/integrations/sklearn.py +42 -0
- flashruntime/launchers/__init__.py +130 -0
- flashruntime/launchers/local.py +126 -0
- flashruntime/leases/__init__.py +27 -0
- flashruntime/leases/manager.py +365 -0
- flashruntime/leases/sqlite_store.py +169 -0
- flashruntime/leases/store.py +103 -0
- flashruntime/monitor/__init__.py +7 -0
- flashruntime/monitor/sampler.py +232 -0
- flashruntime/planner/__init__.py +56 -0
- flashruntime/planner/candidates.py +597 -0
- flashruntime/planner/catalog.py +129 -0
- flashruntime/planner/comm.py +95 -0
- flashruntime/planner/explain.py +109 -0
- flashruntime/planner/memory.py +166 -0
- flashruntime/planner/resolve.py +120 -0
- flashruntime/planner/selector.py +169 -0
- flashruntime/planner/timecost.py +81 -0
- flashruntime/profiling/__init__.py +113 -0
- flashruntime/protocol/__init__.py +18 -0
- flashruntime/protocol/plan_v1alpha1.py +320 -0
- flashruntime/protocol/v1alpha1.py +465 -0
- flashruntime/providers/__init__.py +138 -0
- flashruntime/py.typed +0 -0
- flashruntime/recipes/__init__.py +135 -0
- flashruntime/recipes/command.py +166 -0
- flashruntime/recovery/__init__.py +21 -0
- flashruntime/recovery/policy.py +170 -0
- flashruntime/recovery/signals.py +135 -0
- flashruntime/recovery/taxonomy.py +91 -0
- flashruntime/scheduler/__init__.py +170 -0
- flashruntime/sdk.py +402 -0
- flashruntime/service/__init__.py +3 -0
- flashruntime/service/app.py +391 -0
- flashruntime/service/auth.py +180 -0
- flashruntime/service/checkpoints.py +90 -0
- flashruntime/service/cli.py +167 -0
- flashruntime/service/dashboard.py +193 -0
- flashruntime/service/ledger.py +101 -0
- flashruntime/service/modea.py +821 -0
- flashruntime/strategies/__init__.py +156 -0
- flashruntime/strategies/command.py +56 -0
- flashruntime/torch/__init__.py +274 -0
- flashruntime/viewer/__init__.py +20 -0
- flashruntime/viewer/_docs/benchmarks.html +771 -0
- flashruntime/viewer/_docs/concepts/architecture.html +302 -0
- flashruntime/viewer/_docs/get-started.html +263 -0
- flashruntime/viewer/_docs/guides/federated-averaging.html +363 -0
- flashruntime/viewer/_docs/guides/huggingface.html +223 -0
- flashruntime/viewer/_docs/guides/jobspec-and-isolation.html +271 -0
- flashruntime/viewer/_docs/guides/pytorch.html +313 -0
- flashruntime/viewer/_docs/guides/sklearn.html +232 -0
- flashruntime/viewer/_docs/index.html +251 -0
- flashruntime/viewer/_docs/reference/cli.html +254 -0
- flashruntime/viewer/_docs/reference/integrations.html +240 -0
- flashruntime/viewer/_docs/reference/sdk.html +341 -0
- flashruntime/viewer/_docs/reference/torch-helper.html +244 -0
- flashruntime/viewer/_docs/search-index.json +1 -0
- flashruntime/viewer/_docs/tutorials/convnet.html +571 -0
- flashruntime/viewer/_docs/tutorials/fault-tolerance.html +375 -0
- flashruntime/viewer/_docs/tutorials/sklearn-sweeps.html +278 -0
- flashruntime/viewer/flowmap.py +307 -0
- flashruntime/viewer/page.py +594 -0
- flashruntime/viewer/server.py +134 -0
- flashruntime/viewer/state.py +250 -0
- flashruntime/workloads/__init__.py +6 -0
- flashruntime/workloads/command.py +127 -0
- flashruntime-0.3.0.dist-info/METADATA +365 -0
- flashruntime-0.3.0.dist-info/RECORD +95 -0
- flashruntime-0.3.0.dist-info/WHEEL +5 -0
- flashruntime-0.3.0.dist-info/entry_points.txt +2 -0
- flashruntime-0.3.0.dist-info/licenses/LICENSE +202 -0
- flashruntime-0.3.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,821 @@
|
|
|
1
|
+
"""Mode A over HTTP: the lease coordinator, node registry, and local
|
|
2
|
+
artifact hosting for the self-hosted profile.
|
|
3
|
+
|
|
4
|
+
This module makes the pure-library pieces reachable by remote workers:
|
|
5
|
+
|
|
6
|
+
- **Leases** — HTTP verbs over the `LeaseManager` (claim / heartbeat /
|
|
7
|
+
complete / fail) plus a background sweep. FlashNode's executor is the
|
|
8
|
+
intended client, but anything that speaks the protocol can pull work.
|
|
9
|
+
- **Node registry** — minimal register/heartbeat/list so a self-hosted
|
|
10
|
+
coordinator knows its workers (FlashML Cloud fronts this with join codes
|
|
11
|
+
and trust tiers in the managed product; the wire models are the same).
|
|
12
|
+
- **Local artifacts** — PUT/GET raw bytes under a local directory, so
|
|
13
|
+
shared data (datasets in, trial outputs back) needs no cloud and no
|
|
14
|
+
MinIO: the coordinator *is* the artifact host for the local loop. Keys
|
|
15
|
+
are sha256-verified on upload; the same `artifact://` URIs used by the
|
|
16
|
+
cloud stores apply, keeping job specs portable.
|
|
17
|
+
|
|
18
|
+
Job → task expansion lives here too: a JobSpec with
|
|
19
|
+
`execution.backend: leases` and `workload.type: hyperparameter_search`
|
|
20
|
+
becomes N `TaskSpec`s whose payloads carry the executor contract
|
|
21
|
+
(module, params, input artifact keys, output prefix).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import hashlib
|
|
27
|
+
import itertools
|
|
28
|
+
import math
|
|
29
|
+
from datetime import datetime, timezone
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
from fastapi import APIRouter, HTTPException, Request, Response
|
|
33
|
+
from pydantic import BaseModel
|
|
34
|
+
|
|
35
|
+
import flashruntime.recipes.command # noqa: F401 — registers the "command" recipe
|
|
36
|
+
from flashruntime.leases import LeaseManager
|
|
37
|
+
from flashruntime.protocol.v1alpha1 import (
|
|
38
|
+
ArtifactRecord,
|
|
39
|
+
JobSpec,
|
|
40
|
+
NodeHeartbeat,
|
|
41
|
+
NodeRegistration,
|
|
42
|
+
TaskSpec,
|
|
43
|
+
TaskState,
|
|
44
|
+
)
|
|
45
|
+
from flashruntime.recipes import recipe_for
|
|
46
|
+
from flashruntime.scheduler import IsolationAwarePlacement
|
|
47
|
+
|
|
48
|
+
NODE_OFFLINE_AFTER_S = 15.0
|
|
49
|
+
|
|
50
|
+
# Task modules the *coordinator* will hand out. The executor enforces its own
|
|
51
|
+
# allowlist too — both ends fail closed.
|
|
52
|
+
ALLOWED_TASK_MODULES = {
|
|
53
|
+
"flashml_workloads.sklearn_trial",
|
|
54
|
+
"flashml_workloads.kmeans_shard",
|
|
55
|
+
"flashml_workloads.sgd_trainer",
|
|
56
|
+
"flashml_workloads.fedavg_worker",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
#: Longest lease a submitter may ask for. A lease deadline is the ONLY thing
|
|
61
|
+
#: that returns an abandoned task to the queue, so an unbounded
|
|
62
|
+
#: `lease_seconds` is a denial-of-service knob: `1e9` pins a task to a
|
|
63
|
+
#: machine that closed its laptop for ~31 years, and `float("inf")` does not
|
|
64
|
+
#: even survive the claim path (`timedelta(seconds=inf)` raises OverflowError
|
|
65
|
+
#: *inside* the coordinator). One hour is far above any real task here and
|
|
66
|
+
#: far below "forever".
|
|
67
|
+
MAX_LEASE_SECONDS = 3600.0
|
|
68
|
+
|
|
69
|
+
#: The worker params `_expand_fedavg` must forward. Single source of truth so
|
|
70
|
+
#: the expansion and `flashml_workloads.fedavg_worker`'s reads cannot drift —
|
|
71
|
+
#: `tests/test_service_fedavg.py` binds this tuple to the worker's actual
|
|
72
|
+
#: parameter accesses by parsing its source.
|
|
73
|
+
FEDAVG_WORKER_PARAM_KEYS = ("local_steps", "lr", "batch_size", "seed",
|
|
74
|
+
"in_dim", "hidden", "out_dim", "dataset_size")
|
|
75
|
+
|
|
76
|
+
#: Params `_expand_fedavg` computes itself per task, so the submitter does
|
|
77
|
+
#: not supply them even though the worker reads them.
|
|
78
|
+
FEDAVG_DRIVER_SUPPLIED_KEYS = ("round", "shard", "num_shards")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class ExpansionError(ValueError):
|
|
82
|
+
"""The JobSpec cannot be expanded into tasks (bad workload/parameters)."""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _lease_seconds(params: dict, default: float) -> float:
|
|
86
|
+
"""Validated, clamped `lease_seconds` from submitter-supplied params."""
|
|
87
|
+
raw = params.get("lease_seconds", default)
|
|
88
|
+
try:
|
|
89
|
+
value = float(raw)
|
|
90
|
+
except (TypeError, ValueError):
|
|
91
|
+
raise ExpansionError(
|
|
92
|
+
f"lease_seconds must be a number, got {raw!r}"
|
|
93
|
+
) from None
|
|
94
|
+
if not math.isfinite(value):
|
|
95
|
+
raise ExpansionError(
|
|
96
|
+
f"lease_seconds must be finite, got {raw!r} (a non-finite lease "
|
|
97
|
+
"deadline overflows timedelta in the claim path)"
|
|
98
|
+
)
|
|
99
|
+
if value <= 0:
|
|
100
|
+
raise ExpansionError(f"lease_seconds must be > 0, got {value}")
|
|
101
|
+
return min(value, MAX_LEASE_SECONDS)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def expand_tasks(job_id: str, spec: JobSpec) -> list[TaskSpec]:
|
|
105
|
+
"""Turn a lease-mode JobSpec into independent TaskSpecs.
|
|
106
|
+
|
|
107
|
+
hyperparameter_search parameters:
|
|
108
|
+
trials: explicit list of param dicts, or
|
|
109
|
+
grid: {param: [values, ...]} — cartesian product
|
|
110
|
+
module: task module (allowlisted; default sklearn_trial)
|
|
111
|
+
inputs: {name: "artifact://key"} shared data, downloaded per task
|
|
112
|
+
lease_seconds: heartbeat window per attempt (default 60)
|
|
113
|
+
"""
|
|
114
|
+
workload = spec.spec.workload
|
|
115
|
+
try:
|
|
116
|
+
recipe = recipe_for(workload.type)
|
|
117
|
+
except LookupError:
|
|
118
|
+
recipe = None
|
|
119
|
+
if recipe is not None:
|
|
120
|
+
try:
|
|
121
|
+
return recipe.expand(job_id, spec)
|
|
122
|
+
except ValueError as exc:
|
|
123
|
+
raise ExpansionError(str(exc)) from None
|
|
124
|
+
if workload.type == "sharded_kmeans":
|
|
125
|
+
return _expand_kmeans(job_id, spec)
|
|
126
|
+
if workload.type == "federated_averaging":
|
|
127
|
+
return _expand_fedavg(job_id, spec)
|
|
128
|
+
if workload.type != "hyperparameter_search":
|
|
129
|
+
raise ExpansionError(
|
|
130
|
+
f"lease backend supports workload types 'hyperparameter_search', "
|
|
131
|
+
f"'sharded_kmeans' and 'federated_averaging', got '{workload.type}'"
|
|
132
|
+
)
|
|
133
|
+
p = workload.parameters
|
|
134
|
+
trials: list[dict] = list(p.get("trials") or [])
|
|
135
|
+
if not trials and p.get("grid"):
|
|
136
|
+
grid: dict[str, list] = p["grid"]
|
|
137
|
+
keys = sorted(grid)
|
|
138
|
+
trials = [dict(zip(keys, combo)) for combo in itertools.product(*(grid[k] for k in keys))]
|
|
139
|
+
if not trials:
|
|
140
|
+
raise ExpansionError("hyperparameter_search needs 'trials' (list) or 'grid' (dict of lists)")
|
|
141
|
+
|
|
142
|
+
module = p.get("module", "flashml_workloads.sklearn_trial")
|
|
143
|
+
if module not in ALLOWED_TASK_MODULES:
|
|
144
|
+
raise ExpansionError(f"task module '{module}' is not allowlisted")
|
|
145
|
+
inputs = dict(p.get("inputs") or {})
|
|
146
|
+
for name, uri in inputs.items():
|
|
147
|
+
if not str(uri).startswith("artifact://"):
|
|
148
|
+
raise ExpansionError(f"input '{name}' must be an artifact:// URI, got {uri!r}")
|
|
149
|
+
|
|
150
|
+
checkpoint = p.get("checkpoint") # non-None turns the executor's relay on
|
|
151
|
+
# Stamp the isolation requirement so the placement gate can fail closed —
|
|
152
|
+
# a sandboxed job must never lease to a non-sandbox node (mirrors
|
|
153
|
+
# recipes/command.py; the legacy expansions were dropping this).
|
|
154
|
+
isolation = {
|
|
155
|
+
"tier": spec.spec.isolation.tier,
|
|
156
|
+
"allowFallback": spec.spec.isolation.allowFallback,
|
|
157
|
+
}
|
|
158
|
+
tasks = []
|
|
159
|
+
for i, params in enumerate(trials):
|
|
160
|
+
task_id = f"trial-{i:03d}"
|
|
161
|
+
payload = {
|
|
162
|
+
"module": module,
|
|
163
|
+
"params": params,
|
|
164
|
+
"inputs": inputs,
|
|
165
|
+
"output_prefix": f"jobs/{job_id}/{task_id}/",
|
|
166
|
+
"task_id": task_id,
|
|
167
|
+
# the docker-runner tier resolves and allowlists this
|
|
168
|
+
"image": spec.spec.image.reference,
|
|
169
|
+
"isolation": isolation,
|
|
170
|
+
}
|
|
171
|
+
if checkpoint is not None:
|
|
172
|
+
payload["checkpoint"] = checkpoint
|
|
173
|
+
tasks.append(
|
|
174
|
+
TaskSpec(
|
|
175
|
+
task_id=task_id,
|
|
176
|
+
job_id=job_id,
|
|
177
|
+
commit_key=f"jobs/{job_id}/{task_id}/metrics.json",
|
|
178
|
+
max_attempts=spec.spec.retryPolicy.maxTaskAttempts,
|
|
179
|
+
lease_seconds=_lease_seconds(p, 60.0),
|
|
180
|
+
payload=payload,
|
|
181
|
+
)
|
|
182
|
+
)
|
|
183
|
+
return tasks
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _expand_kmeans(job_id: str, spec: JobSpec) -> list[TaskSpec]:
|
|
187
|
+
"""One K-means *iteration*: one task per data shard, each computing
|
|
188
|
+
partial sums against the broadcast centroids. The driver
|
|
189
|
+
(`flashml_workloads.kmeans_driver`) reduces and submits the next
|
|
190
|
+
iteration as a new job — stage composition, not a new execution mode."""
|
|
191
|
+
p = spec.spec.workload.parameters
|
|
192
|
+
shards: list[str] = list(p.get("shards") or [])
|
|
193
|
+
centroids = p.get("centroids")
|
|
194
|
+
if not shards or not centroids:
|
|
195
|
+
raise ExpansionError("sharded_kmeans needs 'shards' (artifact:// list) and 'centroids'")
|
|
196
|
+
for uri in shards:
|
|
197
|
+
if not str(uri).startswith("artifact://"):
|
|
198
|
+
raise ExpansionError(f"shard must be an artifact:// URI, got {uri!r}")
|
|
199
|
+
iteration = int(p.get("iteration", 0))
|
|
200
|
+
# Same fail-closed stamp as the hyperparameter_search path (mirrors
|
|
201
|
+
# recipes/command.py) — without it a sandboxed job leases anywhere.
|
|
202
|
+
isolation = {
|
|
203
|
+
"tier": spec.spec.isolation.tier,
|
|
204
|
+
"allowFallback": spec.spec.isolation.allowFallback,
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
tasks = []
|
|
208
|
+
for i, shard_uri in enumerate(shards):
|
|
209
|
+
task_id = f"it{iteration:02d}-shard-{i:03d}"
|
|
210
|
+
tasks.append(
|
|
211
|
+
TaskSpec(
|
|
212
|
+
task_id=task_id,
|
|
213
|
+
job_id=job_id,
|
|
214
|
+
commit_key=f"jobs/{job_id}/{task_id}/metrics.json",
|
|
215
|
+
max_attempts=spec.spec.retryPolicy.maxTaskAttempts,
|
|
216
|
+
lease_seconds=_lease_seconds(p, 60.0),
|
|
217
|
+
payload={
|
|
218
|
+
"module": "flashml_workloads.kmeans_shard",
|
|
219
|
+
"params": {"centroids": centroids},
|
|
220
|
+
"inputs": {"shard": shard_uri},
|
|
221
|
+
"output_prefix": f"jobs/{job_id}/{task_id}/",
|
|
222
|
+
"task_id": task_id,
|
|
223
|
+
"image": spec.spec.image.reference,
|
|
224
|
+
"isolation": isolation,
|
|
225
|
+
},
|
|
226
|
+
)
|
|
227
|
+
)
|
|
228
|
+
return tasks
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _expand_fedavg(job_id: str, spec: JobSpec) -> list[TaskSpec]:
|
|
232
|
+
"""One federated-averaging *round*: one task per shard, each training
|
|
233
|
+
locally from the round's broadcast weights. The driver
|
|
234
|
+
(`flashml_workloads.fedavg_driver`) reduces the deltas and submits the
|
|
235
|
+
next round — same stage-composition pattern as `_expand_kmeans`.
|
|
236
|
+
"""
|
|
237
|
+
p = spec.spec.workload.parameters
|
|
238
|
+
num_shards = int(p.get("num_shards", 0))
|
|
239
|
+
if num_shards < 1 or num_shards > 999:
|
|
240
|
+
raise ExpansionError(
|
|
241
|
+
f"federated_averaging needs 1 <= num_shards <= 999, got {num_shards} "
|
|
242
|
+
"(task ids are zero-padded to 3 digits and are sorted as strings)"
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
inputs: dict[str, str] = {}
|
|
246
|
+
weights = p.get("weights")
|
|
247
|
+
if weights is not None:
|
|
248
|
+
if not str(weights).startswith("artifact://"):
|
|
249
|
+
raise ExpansionError(
|
|
250
|
+
f"input 'weights' must be an artifact:// URI, got {weights!r}"
|
|
251
|
+
)
|
|
252
|
+
inputs["weights"] = weights
|
|
253
|
+
|
|
254
|
+
isolation = {
|
|
255
|
+
"tier": spec.spec.isolation.tier,
|
|
256
|
+
"allowFallback": spec.spec.isolation.allowFallback,
|
|
257
|
+
}
|
|
258
|
+
# Every one of these is read unconditionally by fedavg_worker. Dropping a
|
|
259
|
+
# missing key here would defer the failure to a KeyError inside a container
|
|
260
|
+
# on a volunteer's machine, where it burns an attempt and reads as a node
|
|
261
|
+
# fault rather than a bad submission. Fail at expansion instead.
|
|
262
|
+
worker_keys = FEDAVG_WORKER_PARAM_KEYS
|
|
263
|
+
missing = [k for k in worker_keys if k not in p]
|
|
264
|
+
if missing:
|
|
265
|
+
raise ExpansionError(
|
|
266
|
+
f"federated_averaging is missing required parameters: {sorted(missing)}"
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
tasks = []
|
|
270
|
+
for shard in range(num_shards):
|
|
271
|
+
task_id = f"shard-{shard:03d}"
|
|
272
|
+
params = {k: p[k] for k in worker_keys}
|
|
273
|
+
params.update({"round": int(p.get("round", 0)),
|
|
274
|
+
"shard": shard, "num_shards": num_shards})
|
|
275
|
+
tasks.append(
|
|
276
|
+
TaskSpec(
|
|
277
|
+
task_id=task_id,
|
|
278
|
+
job_id=job_id,
|
|
279
|
+
commit_key=f"jobs/{job_id}/{task_id}/metrics.json",
|
|
280
|
+
max_attempts=spec.spec.retryPolicy.maxTaskAttempts,
|
|
281
|
+
lease_seconds=_lease_seconds(p, 120.0),
|
|
282
|
+
payload={
|
|
283
|
+
"module": "flashml_workloads.fedavg_worker",
|
|
284
|
+
"params": params,
|
|
285
|
+
"inputs": inputs,
|
|
286
|
+
"output_prefix": f"jobs/{job_id}/{task_id}/",
|
|
287
|
+
"task_id": task_id,
|
|
288
|
+
"image": spec.spec.image.reference,
|
|
289
|
+
"isolation": isolation,
|
|
290
|
+
},
|
|
291
|
+
)
|
|
292
|
+
)
|
|
293
|
+
return tasks
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
class _NodeEntry(BaseModel):
|
|
297
|
+
registration: NodeRegistration
|
|
298
|
+
last_heartbeat: datetime
|
|
299
|
+
accepted_tasks: int = 0
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
class ModeAState:
|
|
303
|
+
"""Shared state behind the router. In-memory by design for the local
|
|
304
|
+
loop (the ledger keeps the durable event history); the Stage-6 upgrade
|
|
305
|
+
swaps the store for Postgres without touching the endpoints."""
|
|
306
|
+
|
|
307
|
+
def __init__(
|
|
308
|
+
self,
|
|
309
|
+
manager: LeaseManager,
|
|
310
|
+
artifacts_dir: Path,
|
|
311
|
+
join_code: str | None = None,
|
|
312
|
+
max_artifact_bytes: int = 256 * 1024 * 1024,
|
|
313
|
+
authenticator: NodeAuthenticator | None = None,
|
|
314
|
+
):
|
|
315
|
+
self.manager = manager
|
|
316
|
+
self.artifacts_dir = artifacts_dir
|
|
317
|
+
self.join_code = join_code # None = open registration (self-hosted default)
|
|
318
|
+
self.max_artifact_bytes = max_artifact_bytes
|
|
319
|
+
self.nodes: dict[str, _NodeEntry] = {}
|
|
320
|
+
self.lease_jobs: set[str] = set() # job_ids running on the lease path
|
|
321
|
+
from flashruntime.service.auth import NodeAuthenticator, authenticator_from_env
|
|
322
|
+
|
|
323
|
+
self.authenticator: NodeAuthenticator = authenticator or authenticator_from_env()
|
|
324
|
+
|
|
325
|
+
def node_view(self) -> list[dict]:
|
|
326
|
+
now = datetime.now(timezone.utc)
|
|
327
|
+
out = []
|
|
328
|
+
for entry in self.nodes.values():
|
|
329
|
+
age = (now - entry.last_heartbeat).total_seconds()
|
|
330
|
+
out.append(
|
|
331
|
+
{
|
|
332
|
+
"node_id": entry.registration.node_id,
|
|
333
|
+
"hostname": entry.registration.hostname,
|
|
334
|
+
"environment": entry.registration.environment,
|
|
335
|
+
"argv_capable": entry.registration.argv_capable,
|
|
336
|
+
"module_capable": entry.registration.module_capable,
|
|
337
|
+
"capabilities": entry.registration.capabilities.model_dump(),
|
|
338
|
+
"online": age < NODE_OFFLINE_AFTER_S,
|
|
339
|
+
"last_heartbeat_age_s": round(age, 1),
|
|
340
|
+
"accepted_tasks": entry.accepted_tasks,
|
|
341
|
+
}
|
|
342
|
+
)
|
|
343
|
+
return sorted(out, key=lambda n: n["node_id"])
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
class ClaimRequest(BaseModel):
|
|
347
|
+
node_id: str
|
|
348
|
+
job_id: str | None = None
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
class CompleteRequest(BaseModel):
|
|
352
|
+
output_sha256: str
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
class FailRequest(BaseModel):
|
|
356
|
+
reason: str
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _output_valid(artifacts_dir: Path, commit_key: str, claimed_sha256: str) -> bool:
|
|
360
|
+
path = artifacts_dir / commit_key
|
|
361
|
+
if not path.is_file():
|
|
362
|
+
return False
|
|
363
|
+
return hashlib.sha256(path.read_bytes()).hexdigest() == claimed_sha256
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _safe_key(key: str) -> str:
|
|
367
|
+
"""Artifact keys are relative paths under the artifacts dir — refuse
|
|
368
|
+
anything that could escape it."""
|
|
369
|
+
if key.startswith("/") or ".." in key.split("/"):
|
|
370
|
+
raise HTTPException(status_code=400, detail=f"invalid artifact key: {key}")
|
|
371
|
+
return key
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _bearer(request: Request) -> str | None:
|
|
375
|
+
header = request.headers.get("Authorization") or ""
|
|
376
|
+
scheme, _, token = header.partition(" ")
|
|
377
|
+
return token.strip() if scheme.lower() == "bearer" and token.strip() else None
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _is_operator(state, token: str | None) -> bool:
|
|
381
|
+
"""Is this a driver credential (authenticated, attributable, unscoped)?
|
|
382
|
+
|
|
383
|
+
Read defensively: the cloud supplies its own authenticator (Plan 3), and
|
|
384
|
+
one written before this concept existed has no `is_operator`. A missing
|
|
385
|
+
or non-bool answer means "no operators here" — the confinement stays on.
|
|
386
|
+
"""
|
|
387
|
+
checker = getattr(state.authenticator, "is_operator", None)
|
|
388
|
+
if checker is None:
|
|
389
|
+
return False
|
|
390
|
+
return checker(token) is True
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _authenticated_node(state, token: str | None) -> str:
|
|
394
|
+
"""The caller's node_id, or 401. Callers must have checked `enforcing`."""
|
|
395
|
+
node_id = state.authenticator.authenticate(token)
|
|
396
|
+
if not isinstance(node_id, str) or not node_id:
|
|
397
|
+
raise HTTPException(status_code=401, detail="invalid or missing node token")
|
|
398
|
+
return node_id
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
#: An operator may name the machine it is forwarding for, on writes AND on
|
|
402
|
+
#: the lease lifecycle. Honoured ONLY from an operator credential, and read in
|
|
403
|
+
#: exactly one place — see `_write_identity`.
|
|
404
|
+
DELEGATION_HEADER = "X-FlashML-On-Behalf-Of"
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _delegated_node(request: Request) -> str | None:
|
|
408
|
+
"""The node named by the delegation header, or None if absent.
|
|
409
|
+
|
|
410
|
+
Never call this for a non-operator caller: for anyone else the header is
|
|
411
|
+
not authoritative, so it must not be parsed either — a volunteer that
|
|
412
|
+
cannot be believed also cannot be allowed to turn its own writes into
|
|
413
|
+
400s, and probing the delegation path should tell it nothing.
|
|
414
|
+
"""
|
|
415
|
+
values = request.headers.getlist(DELEGATION_HEADER)
|
|
416
|
+
if not values:
|
|
417
|
+
return None
|
|
418
|
+
if len(values) > 1:
|
|
419
|
+
# Two values means somebody appended one. The API forwarding an
|
|
420
|
+
# agent's request would produce exactly that if it failed to strip
|
|
421
|
+
# the agent's copy, and Starlette would hand us the *first* — an
|
|
422
|
+
# ordering the agent controls. Refuse rather than pick a winner.
|
|
423
|
+
raise HTTPException(
|
|
424
|
+
status_code=400,
|
|
425
|
+
detail=f"ambiguous {DELEGATION_HEADER}: {len(values)} values",
|
|
426
|
+
)
|
|
427
|
+
# An empty value is deliberately returned as "" rather than None: it is a
|
|
428
|
+
# header that was sent, and `_write_identity` fails it closed. Folding it
|
|
429
|
+
# into None would let an API bug that emits a blank header silently
|
|
430
|
+
# restore unscoped operator reach.
|
|
431
|
+
return values[0].strip()
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _write_identity(state, request: Request) -> str | None:
|
|
435
|
+
"""Which machine is this request acting as? A node_id, or None for an
|
|
436
|
+
unscoped operator that named nobody. 401 if the caller is neither.
|
|
437
|
+
|
|
438
|
+
THE single place delegation is decided — the only reader of
|
|
439
|
+
`DELEGATION_HEADER` in the repo. Every authorization surface funnels
|
|
440
|
+
through here: artifacts key writes by prefix, checkpoints key them by the
|
|
441
|
+
(job, task) pair, and the lease lifecycle keys them by lease holder. If
|
|
442
|
+
each resolved the caller for itself the three would drift, and the drift
|
|
443
|
+
would be a silent hole — this repo has been bitten by exactly that seam
|
|
444
|
+
twice. (The name is historical: it predates the lifecycle endpoints. It
|
|
445
|
+
resolves *identity*, not writes.)
|
|
446
|
+
|
|
447
|
+
Delegation can only ever *narrow*. An operator with no header keeps the
|
|
448
|
+
unscoped driver reach it has always had on writes; the moment it asserts
|
|
449
|
+
an identity it is authorized precisely as that node would have been, so a
|
|
450
|
+
driver speaking for node-a loses its own `jobs/{job}/round-NNN/` keys.
|
|
451
|
+
"""
|
|
452
|
+
token = _bearer(request)
|
|
453
|
+
if _is_operator(state, token):
|
|
454
|
+
delegated = _delegated_node(request)
|
|
455
|
+
if delegated is None:
|
|
456
|
+
return None # unscoped driver — the pre-delegation behaviour
|
|
457
|
+
if not delegated:
|
|
458
|
+
raise HTTPException(
|
|
459
|
+
status_code=403, detail=f"empty {DELEGATION_HEADER}"
|
|
460
|
+
)
|
|
461
|
+
# NOT checked against the node registry. Liveness of a lease is the
|
|
462
|
+
# only authority that matters, and it is strictly stronger: a node
|
|
463
|
+
# can only hold one by having registered and claimed. Consulting the
|
|
464
|
+
# in-memory registry too would add a second source of truth about
|
|
465
|
+
# identity that a coordinator restart empties while durable leases
|
|
466
|
+
# survive — refusing writes the lease table still authorizes.
|
|
467
|
+
return delegated
|
|
468
|
+
# Any other caller: the header is ignored entirely. Not an error — it is
|
|
469
|
+
# simply not authoritative, and a volunteer must never act as another
|
|
470
|
+
# machine.
|
|
471
|
+
return _authenticated_node(state, token)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _acting_node(state, request: Request) -> str:
|
|
475
|
+
"""The machine this request acts as, where acting as *nobody* is not a
|
|
476
|
+
valid answer. Same resolution as `_write_identity` — same single header
|
|
477
|
+
reader — with the unscoped-operator case turned into a refusal.
|
|
478
|
+
|
|
479
|
+
The lease lifecycle (register / claim / heartbeat / complete / fail) is
|
|
480
|
+
always an act by a specific machine: a claim assigns work to a node, a
|
|
481
|
+
`fail` returns another node's task to the queue. An operator that names
|
|
482
|
+
nobody has no identity to check, and quietly letting it through would
|
|
483
|
+
hand the API the ability to drive *any* lease purely by omitting a
|
|
484
|
+
header — the exact requeue-steal hole `_require_lease_holder` closes,
|
|
485
|
+
re-opened for the credential that is easiest to forward wrongly.
|
|
486
|
+
|
|
487
|
+
A node token is unaffected: `_write_identity` never honours the header
|
|
488
|
+
for it, so it is always itself and can never reach another node's lease.
|
|
489
|
+
"""
|
|
490
|
+
node_id = _write_identity(state, request)
|
|
491
|
+
if node_id is None:
|
|
492
|
+
# The pre-delegation answer, unchanged and deliberately so: an
|
|
493
|
+
# operator token is not a node identity, so it 401s here exactly as
|
|
494
|
+
# it did before this header existed (pinned by the write-scope
|
|
495
|
+
# suite). Naming a machine is what gives it one.
|
|
496
|
+
raise HTTPException(
|
|
497
|
+
status_code=401,
|
|
498
|
+
detail=(
|
|
499
|
+
"operator credential is not a node identity — name the "
|
|
500
|
+
f"machine in {DELEGATION_HEADER} to act for it"
|
|
501
|
+
),
|
|
502
|
+
)
|
|
503
|
+
return node_id
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _authorize_write(state, manager, request: Request, key: str) -> None:
|
|
507
|
+
"""Confine a write to the tasks this caller currently holds.
|
|
508
|
+
|
|
509
|
+
Not enforcing ⇒ allow: the self-hosted profile predates credentials and
|
|
510
|
+
must keep working (CLAUDE.md rule 4). When enforcing, an unknown caller is
|
|
511
|
+
401 and an out-of-scope key is 403 — distinct so an operator can tell a
|
|
512
|
+
misconfigured agent from a misbehaving one.
|
|
513
|
+
|
|
514
|
+
An *operator* token passes unscoped: a driver (fedavg/K-means reducer)
|
|
515
|
+
runs inside the trusted API, holds no lease, and must still write
|
|
516
|
+
`jobs/{job}/round-NNN/weights.json`. That is a second credential class,
|
|
517
|
+
not an exemption — the caller is still authenticated and attributable.
|
|
518
|
+
An operator forwarding for a machine names it in `DELEGATION_HEADER` and
|
|
519
|
+
gets that machine's scope instead.
|
|
520
|
+
"""
|
|
521
|
+
if not state.authenticator.enforcing:
|
|
522
|
+
return
|
|
523
|
+
node_id = _write_identity(state, request)
|
|
524
|
+
if node_id is None:
|
|
525
|
+
return
|
|
526
|
+
# Trailing slash is load-bearing: without it `jobs/j/trial-000extra/...`
|
|
527
|
+
# would satisfy a `jobs/j/trial-000` prefix test.
|
|
528
|
+
allowed = [f"jobs/{job}/{task}/" for job, task in manager.live_leases_for_node(node_id)]
|
|
529
|
+
if not any(key.startswith(p) for p in allowed):
|
|
530
|
+
raise HTTPException(
|
|
531
|
+
status_code=403,
|
|
532
|
+
detail=f"node {node_id} holds no live lease covering {key!r}",
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def authorize_task_write(state, manager, request: Request, job_id: str, task_id: str) -> None:
|
|
537
|
+
"""Checkpoint routes name (job, task) in the path, so authorize the pair
|
|
538
|
+
directly instead of reconstructing a key prefix.
|
|
539
|
+
|
|
540
|
+
Same caller resolution as `_authorize_write` — including delegation —
|
|
541
|
+
because it is the same question asked about a differently-shaped key.
|
|
542
|
+
"""
|
|
543
|
+
if not state.authenticator.enforcing:
|
|
544
|
+
return
|
|
545
|
+
node_id = _write_identity(state, request)
|
|
546
|
+
if node_id is None:
|
|
547
|
+
return
|
|
548
|
+
if (job_id, task_id) not in manager.live_leases_for_node(node_id):
|
|
549
|
+
raise HTTPException(
|
|
550
|
+
status_code=403,
|
|
551
|
+
detail=f"node {node_id} holds no live lease on {job_id}/{task_id}",
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _require_lease_holder(state, manager, request: Request, lease_id: str) -> None:
|
|
556
|
+
"""Only the node that was issued a lease may drive its lifecycle.
|
|
557
|
+
|
|
558
|
+
Without this, `complete`/`fail`/`heartbeat` take a lease_id and check
|
|
559
|
+
nothing: an attacker fails another node's attempt over and over until the
|
|
560
|
+
task requeues to *him*, then writes his poison entirely within the write
|
|
561
|
+
scoping above. Scoping writes without owning the lifecycle just moves the
|
|
562
|
+
hole down one layer.
|
|
563
|
+
|
|
564
|
+
A *bare* operator token is still NOT accepted here. A driver holds no
|
|
565
|
+
lease; letting an unscoped credential fail somebody's attempt would
|
|
566
|
+
re-open exactly the requeue attack this closes. An operator that names a
|
|
567
|
+
machine in `DELEGATION_HEADER` is a different thing: it is checked
|
|
568
|
+
against *that machine's* lease, so the forwarded call is authorized
|
|
569
|
+
exactly as the direct call would have been and no more.
|
|
570
|
+
"""
|
|
571
|
+
if not state.authenticator.enforcing:
|
|
572
|
+
return
|
|
573
|
+
node_id = _acting_node(state, request)
|
|
574
|
+
lease = manager.lease_info(lease_id)
|
|
575
|
+
if lease is None or getattr(lease, "node_id", None) != node_id:
|
|
576
|
+
# 403 for unknown as well as not-yours: a 404/403 split would tell an
|
|
577
|
+
# attacker which lease ids exist.
|
|
578
|
+
raise HTTPException(
|
|
579
|
+
status_code=403, detail=f"node {node_id} does not hold lease {lease_id}"
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def build_router(state: ModeAState) -> APIRouter:
|
|
584
|
+
router = APIRouter(prefix="/v1alpha1")
|
|
585
|
+
manager = state.manager
|
|
586
|
+
|
|
587
|
+
# -- node registry ------------------------------------------------------
|
|
588
|
+
|
|
589
|
+
@router.post("/nodes/register")
|
|
590
|
+
async def register_node(reg: NodeRegistration, request: Request):
|
|
591
|
+
if state.join_code is not None:
|
|
592
|
+
supplied = request.headers.get("X-FlashML-Join-Code")
|
|
593
|
+
if supplied != state.join_code:
|
|
594
|
+
raise HTTPException(status_code=403, detail="invalid or missing join code")
|
|
595
|
+
if state.authenticator.enforcing:
|
|
596
|
+
# Identity comes from the credential (or, for an operator, from
|
|
597
|
+
# the machine it names) — never from the body. A node must not be
|
|
598
|
+
# able to register (and so claim work) as another.
|
|
599
|
+
reg = reg.model_copy(update={"node_id": _acting_node(state, request)})
|
|
600
|
+
state.nodes[reg.node_id] = _NodeEntry(
|
|
601
|
+
registration=reg, last_heartbeat=datetime.now(timezone.utc)
|
|
602
|
+
)
|
|
603
|
+
return {"node_id": reg.node_id, "status": "registered"}
|
|
604
|
+
|
|
605
|
+
@router.post("/nodes/{node_id}/heartbeat")
|
|
606
|
+
async def node_heartbeat(node_id: str, hb: NodeHeartbeat, request: Request):
|
|
607
|
+
if state.authenticator.enforcing:
|
|
608
|
+
caller = _acting_node(state, request)
|
|
609
|
+
if caller != node_id:
|
|
610
|
+
# "itself" means the asserted machine when an operator is
|
|
611
|
+
# forwarding — a heartbeat keeps a node marked online, so
|
|
612
|
+
# letting the API send one for an arbitrary node_id would
|
|
613
|
+
# falsify the pool view.
|
|
614
|
+
raise HTTPException(
|
|
615
|
+
status_code=403, detail="a node may only heartbeat itself"
|
|
616
|
+
)
|
|
617
|
+
entry = state.nodes.get(node_id)
|
|
618
|
+
if entry is None:
|
|
619
|
+
raise HTTPException(status_code=404, detail=f"unknown node {node_id} — register first")
|
|
620
|
+
entry.last_heartbeat = hb.timestamp
|
|
621
|
+
return {"status": "ok"}
|
|
622
|
+
|
|
623
|
+
@router.get("/nodes")
|
|
624
|
+
async def list_nodes():
|
|
625
|
+
return state.node_view()
|
|
626
|
+
|
|
627
|
+
# -- leases -------------------------------------------------------------
|
|
628
|
+
|
|
629
|
+
@router.post("/leases/claim")
|
|
630
|
+
async def claim(req: ClaimRequest, request: Request):
|
|
631
|
+
if state.authenticator.enforcing:
|
|
632
|
+
# Overwrite rather than validate-and-reject: a body node_id is
|
|
633
|
+
# simply not authoritative, so a disagreement is not an error to
|
|
634
|
+
# report — there is nothing here to disagree with. That holds for
|
|
635
|
+
# the forwarded case too: the operator's header decides, the body
|
|
636
|
+
# is still ignored.
|
|
637
|
+
req.node_id = _acting_node(state, request)
|
|
638
|
+
entry = state.nodes.get(req.node_id)
|
|
639
|
+
if entry is None:
|
|
640
|
+
raise HTTPException(status_code=403, detail="unregistered node — register first")
|
|
641
|
+
node_view = {
|
|
642
|
+
"node_id": req.node_id,
|
|
643
|
+
"sandbox_capable": entry.registration.sandbox_capable,
|
|
644
|
+
"argv_capable": entry.registration.argv_capable,
|
|
645
|
+
"module_capable": entry.registration.module_capable,
|
|
646
|
+
"capabilities": entry.registration.capabilities.model_dump(),
|
|
647
|
+
}
|
|
648
|
+
lease = manager.claim(
|
|
649
|
+
req.node_id,
|
|
650
|
+
job_id=req.job_id,
|
|
651
|
+
policy=IsolationAwarePlacement(),
|
|
652
|
+
node=node_view,
|
|
653
|
+
)
|
|
654
|
+
if lease is None:
|
|
655
|
+
return Response(status_code=204) # nothing claimable right now
|
|
656
|
+
return lease
|
|
657
|
+
|
|
658
|
+
@router.post("/attempts/{lease_id}/heartbeat")
|
|
659
|
+
async def attempt_heartbeat(lease_id: str, request: Request):
|
|
660
|
+
from flashruntime.leases import LeaseError
|
|
661
|
+
|
|
662
|
+
# Same ownership rule as complete/fail: keeping somebody else's lease
|
|
663
|
+
# alive stops the sweeper from ever requeueing their stalled task.
|
|
664
|
+
_require_lease_holder(state, manager, request, lease_id)
|
|
665
|
+
try:
|
|
666
|
+
return manager.heartbeat(lease_id)
|
|
667
|
+
except LeaseError as exc:
|
|
668
|
+
# 410 Gone: the worker must stop — its lease is dead.
|
|
669
|
+
raise HTTPException(status_code=410, detail=str(exc))
|
|
670
|
+
|
|
671
|
+
@router.post("/attempts/{lease_id}/complete")
|
|
672
|
+
async def attempt_complete(lease_id: str, req: CompleteRequest, request: Request):
|
|
673
|
+
from flashruntime.leases import LeaseError
|
|
674
|
+
|
|
675
|
+
_require_lease_holder(state, manager, request, lease_id)
|
|
676
|
+
lease = manager.lease_info(lease_id)
|
|
677
|
+
if lease is None:
|
|
678
|
+
raise HTTPException(status_code=404, detail=f"unknown lease {lease_id}")
|
|
679
|
+
|
|
680
|
+
# Accepted work = validated output: the artifact at the task's
|
|
681
|
+
# commit_key must exist and hash to what the worker claims. A bad
|
|
682
|
+
# upload fails the attempt (task requeues elsewhere); it never
|
|
683
|
+
# commits. Fault tolerance that accepts wrong results is worse than
|
|
684
|
+
# failure.
|
|
685
|
+
record = next(
|
|
686
|
+
(r for r in manager.records(lease.job_id) if r.spec.task_id == lease.task_id), None
|
|
687
|
+
)
|
|
688
|
+
if record is not None and not _output_valid(
|
|
689
|
+
state.artifacts_dir, record.spec.commit_key, req.output_sha256
|
|
690
|
+
):
|
|
691
|
+
try:
|
|
692
|
+
manager.fail(
|
|
693
|
+
lease_id, f"output validation failed for {record.spec.commit_key}"
|
|
694
|
+
)
|
|
695
|
+
return {"accepted": False, "detail": "output validation failed; attempt requeued"}
|
|
696
|
+
except LeaseError:
|
|
697
|
+
pass # lease already dead → fall through to the late-commit rejection
|
|
698
|
+
|
|
699
|
+
try:
|
|
700
|
+
accepted = manager.complete(lease_id, output_sha256=req.output_sha256)
|
|
701
|
+
except LeaseError as exc:
|
|
702
|
+
raise HTTPException(status_code=404, detail=str(exc))
|
|
703
|
+
if accepted and lease.node_id in state.nodes:
|
|
704
|
+
# credit accepted work only — the contribution-accounting rule
|
|
705
|
+
state.nodes[lease.node_id].accepted_tasks += 1
|
|
706
|
+
return {"accepted": accepted}
|
|
707
|
+
|
|
708
|
+
@router.post("/attempts/{lease_id}/fail")
|
|
709
|
+
async def attempt_fail(lease_id: str, req: FailRequest, request: Request):
|
|
710
|
+
from flashruntime.leases import LeaseError
|
|
711
|
+
|
|
712
|
+
_require_lease_holder(state, manager, request, lease_id)
|
|
713
|
+
try:
|
|
714
|
+
manager.fail(lease_id, req.reason)
|
|
715
|
+
except LeaseError as exc:
|
|
716
|
+
raise HTTPException(status_code=410, detail=str(exc))
|
|
717
|
+
return {"status": "requeued-or-exhausted"}
|
|
718
|
+
|
|
719
|
+
# -- tasks view ---------------------------------------------------------
|
|
720
|
+
|
|
721
|
+
@router.get("/jobs/{job_id}/tasks")
|
|
722
|
+
async def job_tasks(job_id: str):
|
|
723
|
+
out = []
|
|
724
|
+
for r in manager.records(job_id):
|
|
725
|
+
lease = r.active_lease
|
|
726
|
+
last = None
|
|
727
|
+
if r.lease_history:
|
|
728
|
+
last = list(r.lease_history.values())[-1]
|
|
729
|
+
out.append(
|
|
730
|
+
{
|
|
731
|
+
"task_id": r.spec.task_id,
|
|
732
|
+
"state": r.state.value,
|
|
733
|
+
"attempts": r.attempts_used,
|
|
734
|
+
"max_attempts": r.spec.max_attempts,
|
|
735
|
+
"node_id": (lease or last).node_id if (lease or last) else None,
|
|
736
|
+
"deadline": lease.deadline.isoformat() if lease else None,
|
|
737
|
+
}
|
|
738
|
+
)
|
|
739
|
+
return sorted(out, key=lambda t: t["task_id"])
|
|
740
|
+
|
|
741
|
+
# -- local artifact hosting --------------------------------------------
|
|
742
|
+
|
|
743
|
+
@router.put("/artifacts/{key:path}")
|
|
744
|
+
async def put_artifact(key: str, request: Request):
|
|
745
|
+
key = _safe_key(key)
|
|
746
|
+
# Checked TWICE, on purpose. The first call rejects an unauthorized
|
|
747
|
+
# caller before we buffer a body that may be hundreds of megabytes.
|
|
748
|
+
_authorize_write(state, manager, request, key)
|
|
749
|
+
data = await request.body()
|
|
750
|
+
if len(data) > state.max_artifact_bytes:
|
|
751
|
+
raise HTTPException(
|
|
752
|
+
status_code=413,
|
|
753
|
+
detail=f"artifact exceeds {state.max_artifact_bytes} bytes",
|
|
754
|
+
)
|
|
755
|
+
# ...and the second closes the TOCTOU window between them. Reading the
|
|
756
|
+
# body takes as long as the client wants it to: a node can claim a
|
|
757
|
+
# task, open a chunked PUT, trickle bytes while the sweeper expires
|
|
758
|
+
# its lease and another node completes and commits the task — and
|
|
759
|
+
# then land its body on top of the committed result. That window is
|
|
760
|
+
# attacker-controlled, not bounded by the lease duration, and it
|
|
761
|
+
# defeats revocation entirely. `live_leases_for_node` stops covering
|
|
762
|
+
# a completed or reclaimed task, so re-checking here is the fix.
|
|
763
|
+
_authorize_write(state, manager, request, key)
|
|
764
|
+
path = state.artifacts_dir / key
|
|
765
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
766
|
+
path.write_bytes(data)
|
|
767
|
+
return ArtifactRecord(
|
|
768
|
+
uri=f"artifact://{key}",
|
|
769
|
+
backend="local",
|
|
770
|
+
bucket=str(state.artifacts_dir),
|
|
771
|
+
object_key=key,
|
|
772
|
+
sha256=hashlib.sha256(data).hexdigest(),
|
|
773
|
+
size_bytes=len(data),
|
|
774
|
+
)
|
|
775
|
+
|
|
776
|
+
@router.get("/artifacts/{key:path}")
|
|
777
|
+
async def get_artifact(key: str):
|
|
778
|
+
key = _safe_key(key)
|
|
779
|
+
path = state.artifacts_dir / key
|
|
780
|
+
if not path.is_file():
|
|
781
|
+
raise HTTPException(status_code=404, detail=f"no artifact at {key}")
|
|
782
|
+
return Response(content=path.read_bytes(), media_type="application/octet-stream")
|
|
783
|
+
|
|
784
|
+
@router.get("/jobs/{job_id}/artifacts")
|
|
785
|
+
async def job_artifacts(job_id: str):
|
|
786
|
+
base = state.artifacts_dir / "jobs" / job_id
|
|
787
|
+
if not base.is_dir():
|
|
788
|
+
return []
|
|
789
|
+
out = []
|
|
790
|
+
for path in sorted(base.rglob("*")):
|
|
791
|
+
if path.is_file():
|
|
792
|
+
key = str(path.relative_to(state.artifacts_dir))
|
|
793
|
+
out.append(
|
|
794
|
+
{
|
|
795
|
+
"uri": f"artifact://{key}",
|
|
796
|
+
"key": key,
|
|
797
|
+
"size_bytes": path.stat().st_size,
|
|
798
|
+
}
|
|
799
|
+
)
|
|
800
|
+
return out
|
|
801
|
+
|
|
802
|
+
return router
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
def lease_job_state(manager: LeaseManager, job_id: str) -> tuple[str, dict[str, int]]:
|
|
806
|
+
"""Derive a JobState name from the task counts (status is never a
|
|
807
|
+
hand-mutated field — it falls out of the lease table)."""
|
|
808
|
+
counts = manager.job_state(job_id)
|
|
809
|
+
total = sum(counts.values())
|
|
810
|
+
done = counts.get(TaskState.COMPLETED.value, 0)
|
|
811
|
+
failed = counts.get(TaskState.FAILED.value, 0)
|
|
812
|
+
active = counts.get(TaskState.PENDING.value, 0) + counts.get(TaskState.LEASED.value, 0)
|
|
813
|
+
if total == 0:
|
|
814
|
+
return "PENDING", counts
|
|
815
|
+
if active > 0:
|
|
816
|
+
return "RUNNING", counts
|
|
817
|
+
if failed > 0:
|
|
818
|
+
return "FAILED", counts
|
|
819
|
+
if done == total:
|
|
820
|
+
return "SUCCEEDED", counts
|
|
821
|
+
return "CANCELLED", counts
|