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,391 @@
|
|
|
1
|
+
"""FlashRuntime API.
|
|
2
|
+
|
|
3
|
+
Endpoints (all under /v1alpha1):
|
|
4
|
+
POST /jobs submit a JobSpec
|
|
5
|
+
GET /jobs list job records
|
|
6
|
+
GET /jobs/{id} one job record
|
|
7
|
+
GET /jobs/{id}/events ordered event ledger
|
|
8
|
+
GET /jobs/{id}/logs driver logs from the backend
|
|
9
|
+
POST /jobs/{id}/cancel cancel
|
|
10
|
+
GET /healthz liveness
|
|
11
|
+
|
|
12
|
+
Configuration is environment-driven (see `RuntimeSettings`); the public
|
|
13
|
+
JobSpec never carries deployment details.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import json
|
|
20
|
+
import logging
|
|
21
|
+
import os
|
|
22
|
+
import tempfile
|
|
23
|
+
import uuid
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from fastapi import FastAPI, HTTPException
|
|
28
|
+
|
|
29
|
+
from flashruntime.artifacts import artifact_uri_to_key, store_from_env
|
|
30
|
+
from flashruntime.backends.base import SpecValidationError
|
|
31
|
+
from flashruntime.backends.kuberay import KubeRayBackendConfig, KubeRayExecutionBackend
|
|
32
|
+
from flashruntime.checkpoint import CheckpointCatalog
|
|
33
|
+
from flashruntime.leases import LeaseManager
|
|
34
|
+
from flashruntime.leases.sqlite_store import SqliteLeaseStore
|
|
35
|
+
from flashruntime.protocol.v1alpha1 import (
|
|
36
|
+
Event,
|
|
37
|
+
EventType,
|
|
38
|
+
JobRecord,
|
|
39
|
+
JobSpec,
|
|
40
|
+
JobState,
|
|
41
|
+
utcnow,
|
|
42
|
+
)
|
|
43
|
+
from flashruntime.service import checkpoints, dashboard, modea
|
|
44
|
+
from flashruntime.service.ledger import Ledger
|
|
45
|
+
from flashruntime.service.modea import ModeAState
|
|
46
|
+
|
|
47
|
+
log = logging.getLogger("flashruntime.service")
|
|
48
|
+
logging.basicConfig(
|
|
49
|
+
level=logging.INFO,
|
|
50
|
+
format='{"ts":"%(asctime)s","level":"%(levelname)s","logger":"%(name)s","msg":%(message)s}',
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _jlog(msg: str, **kv) -> str:
|
|
55
|
+
return json.dumps({"text": msg, **kv})
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class RuntimeSettings:
|
|
60
|
+
profile: str = field(default_factory=lambda: os.environ.get("FLASHML_PROFILE", "local"))
|
|
61
|
+
namespace: str = field(default_factory=lambda: os.environ.get("FLASHML_NAMESPACE", "flashml"))
|
|
62
|
+
ledger_path: str = field(
|
|
63
|
+
default_factory=lambda: os.environ.get("FLASHML_LEDGER_PATH", "/data/flashruntime.db")
|
|
64
|
+
)
|
|
65
|
+
# Mode B (KubeRay) is optional: the self-hosted local loop runs leases +
|
|
66
|
+
# local artifacts with no Kubernetes anywhere near it.
|
|
67
|
+
enable_kuberay: bool = field(
|
|
68
|
+
default_factory=lambda: os.environ.get("FLASHML_ENABLE_KUBERAY", "1") == "1"
|
|
69
|
+
)
|
|
70
|
+
artifacts_dir: str = field(
|
|
71
|
+
default_factory=lambda: os.environ.get("FLASHML_LOCAL_ARTIFACTS_DIR", "/data/artifacts")
|
|
72
|
+
)
|
|
73
|
+
# Optional first auth primitive: when set, node registration requires the
|
|
74
|
+
# matching X-FlashML-Join-Code header. Unset = open (self-hosted default).
|
|
75
|
+
join_code: str | None = field(
|
|
76
|
+
default_factory=lambda: os.environ.get("FLASHML_JOIN_CODE") or None
|
|
77
|
+
)
|
|
78
|
+
max_artifact_mb: int = field(
|
|
79
|
+
default_factory=lambda: int(os.environ.get("FLASHML_MAX_ARTIFACT_MB", "256"))
|
|
80
|
+
)
|
|
81
|
+
standard_node_selector: dict[str, str] | None = None
|
|
82
|
+
sandbox_node_selector: dict[str, str] = field(default_factory=dict)
|
|
83
|
+
sandbox_runtime_class: str | None = None
|
|
84
|
+
|
|
85
|
+
@staticmethod
|
|
86
|
+
def _parse_selector(value: str) -> dict[str, str]:
|
|
87
|
+
return dict(pair.split("=", 1) for pair in value.split(",") if "=" in pair)
|
|
88
|
+
|
|
89
|
+
@classmethod
|
|
90
|
+
def from_env(cls) -> "RuntimeSettings":
|
|
91
|
+
settings = cls()
|
|
92
|
+
standard = os.environ.get("FLASHML_STANDARD_NODE_SELECTOR", "")
|
|
93
|
+
if standard:
|
|
94
|
+
settings.standard_node_selector = cls._parse_selector(standard)
|
|
95
|
+
sandbox = os.environ.get("FLASHML_SANDBOX_NODE_SELECTOR", "")
|
|
96
|
+
if sandbox:
|
|
97
|
+
settings.sandbox_node_selector = cls._parse_selector(sandbox)
|
|
98
|
+
settings.sandbox_runtime_class = os.environ.get("FLASHML_RUNTIME_CLASS") or None
|
|
99
|
+
return settings
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def create_app(settings: RuntimeSettings | None = None) -> FastAPI:
|
|
103
|
+
settings = settings or RuntimeSettings.from_env()
|
|
104
|
+
Path(settings.ledger_path).parent.mkdir(parents=True, exist_ok=True)
|
|
105
|
+
ledger = Ledger(settings.ledger_path)
|
|
106
|
+
|
|
107
|
+
backend = None
|
|
108
|
+
if settings.enable_kuberay:
|
|
109
|
+
backend_cfg = KubeRayBackendConfig(
|
|
110
|
+
namespace=settings.namespace,
|
|
111
|
+
deployment_profile=settings.profile,
|
|
112
|
+
image_registry_prefix=os.environ.get("FLASHML_IMAGE_REGISTRY_PREFIX", ""),
|
|
113
|
+
sandbox_node_selector=settings.sandbox_node_selector,
|
|
114
|
+
sandbox_runtime_class=settings.sandbox_runtime_class,
|
|
115
|
+
**(
|
|
116
|
+
{"standard_node_selector": settings.standard_node_selector}
|
|
117
|
+
if settings.standard_node_selector is not None
|
|
118
|
+
else {}
|
|
119
|
+
),
|
|
120
|
+
# Workload pods read the artifact store from the same env contract the
|
|
121
|
+
# service itself uses; pass it through.
|
|
122
|
+
extra_env={
|
|
123
|
+
k: v
|
|
124
|
+
for k, v in os.environ.items()
|
|
125
|
+
if k.startswith("FLASHML_ARTIFACT_") and "SECRET" not in k and "ACCESS" not in k
|
|
126
|
+
},
|
|
127
|
+
env_secret=os.environ.get("FLASHML_ARTIFACT_CREDENTIALS_SECRET") or None,
|
|
128
|
+
)
|
|
129
|
+
backend = KubeRayExecutionBackend(backend_cfg)
|
|
130
|
+
|
|
131
|
+
app = FastAPI(title="FlashRuntime", version="0.1.0")
|
|
132
|
+
app.state.watchers = {}
|
|
133
|
+
|
|
134
|
+
# -- Mode A: lease coordinator + node registry + local artifacts --------
|
|
135
|
+
artifacts_dir = Path(settings.artifacts_dir)
|
|
136
|
+
artifacts_dir.mkdir(parents=True, exist_ok=True)
|
|
137
|
+
# Durable lease table beside the ledger: tasks, leases, and attempt
|
|
138
|
+
# history survive coordinator restarts (nodes re-register on their own).
|
|
139
|
+
lease_store = SqliteLeaseStore(Path(settings.ledger_path).with_name("leases.db"))
|
|
140
|
+
lease_manager = LeaseManager(store=lease_store, on_event=lambda e: record_event(e))
|
|
141
|
+
modea_state = ModeAState(
|
|
142
|
+
lease_manager,
|
|
143
|
+
artifacts_dir,
|
|
144
|
+
join_code=settings.join_code,
|
|
145
|
+
max_artifact_bytes=settings.max_artifact_mb * 1024 * 1024,
|
|
146
|
+
)
|
|
147
|
+
if os.environ.get("FLASHML_REQUIRE_NODE_AUTH") == "1" and not modea_state.authenticator.enforcing:
|
|
148
|
+
raise RuntimeError(
|
|
149
|
+
"FLASHML_REQUIRE_NODE_AUTH=1 but no node tokens are configured — "
|
|
150
|
+
"set FLASHML_NODE_TOKENS. Refusing to start an internet-exposed "
|
|
151
|
+
"coordinator with unauthenticated writes."
|
|
152
|
+
)
|
|
153
|
+
app.state.modea = modea_state
|
|
154
|
+
app.include_router(modea.build_router(modea_state))
|
|
155
|
+
checkpoint_catalog = CheckpointCatalog(on_event=lambda e: record_event(e))
|
|
156
|
+
app.include_router(checkpoints.build_router(checkpoint_catalog, state=modea_state, manager=lease_manager))
|
|
157
|
+
app.include_router(dashboard.build_router())
|
|
158
|
+
|
|
159
|
+
def refresh_lease_job(job: JobRecord) -> JobRecord:
|
|
160
|
+
"""Lease-mode job status is *derived* from the task table on read."""
|
|
161
|
+
if job.backend != "leases" or job.state.terminal:
|
|
162
|
+
return job
|
|
163
|
+
name, counts = modea.lease_job_state(lease_manager, job.job_id)
|
|
164
|
+
new_state = JobState(name)
|
|
165
|
+
if new_state != job.state:
|
|
166
|
+
job.state = new_state
|
|
167
|
+
if new_state.terminal:
|
|
168
|
+
job.finished_at = utcnow()
|
|
169
|
+
record_event(Event(
|
|
170
|
+
job_id=job.job_id,
|
|
171
|
+
type=EventType.JOB_SUCCEEDED if new_state == JobState.SUCCEEDED else EventType.JOB_FAILED,
|
|
172
|
+
source="flashruntime.leases",
|
|
173
|
+
message=f"lease job finished: {counts}",
|
|
174
|
+
))
|
|
175
|
+
ledger.upsert_job(job)
|
|
176
|
+
return job
|
|
177
|
+
|
|
178
|
+
async def lease_sweeper() -> None:
|
|
179
|
+
"""Expire dead leases and refresh derived job states every 2 s, so
|
|
180
|
+
recovery happens even when no worker is currently claiming."""
|
|
181
|
+
while True:
|
|
182
|
+
try:
|
|
183
|
+
lease_manager.sweep()
|
|
184
|
+
for job in ledger.list_jobs():
|
|
185
|
+
refresh_lease_job(job)
|
|
186
|
+
except Exception:
|
|
187
|
+
log.exception("lease sweeper iteration failed")
|
|
188
|
+
await asyncio.sleep(2)
|
|
189
|
+
|
|
190
|
+
@app.on_event("startup")
|
|
191
|
+
async def _start_sweeper():
|
|
192
|
+
app.state.sweeper = asyncio.create_task(lease_sweeper())
|
|
193
|
+
|
|
194
|
+
@app.on_event("shutdown")
|
|
195
|
+
async def _stop_sweeper():
|
|
196
|
+
app.state.sweeper.cancel()
|
|
197
|
+
|
|
198
|
+
def record_event(event: Event) -> None:
|
|
199
|
+
ledger.append_event(event)
|
|
200
|
+
log.info(_jlog(event.message or event.type.value, job_id=event.job_id,
|
|
201
|
+
event=event.type.value, source=event.source))
|
|
202
|
+
|
|
203
|
+
async def ingest_workload_artifacts(job: JobRecord) -> None:
|
|
204
|
+
"""After success: collect artifact records from the store and fold the
|
|
205
|
+
workload's own attempt/recovery evidence into the ledger."""
|
|
206
|
+
try:
|
|
207
|
+
store = store_from_env()
|
|
208
|
+
except Exception as exc:
|
|
209
|
+
log.warning(_jlog("artifact store unavailable", error=str(exc)))
|
|
210
|
+
return
|
|
211
|
+
prefix = artifact_uri_to_key(
|
|
212
|
+
job.spec.spec.artifacts.outputPrefix.replace("{job_id}", job.job_id)
|
|
213
|
+
)
|
|
214
|
+
record_event(Event(job_id=job.job_id, type=EventType.ARTIFACT_UPLOAD_STARTED,
|
|
215
|
+
source="flashruntime.service",
|
|
216
|
+
message=f"collecting artifacts under {prefix}"))
|
|
217
|
+
records = await store.list_prefix(prefix)
|
|
218
|
+
job.artifacts = records
|
|
219
|
+
for record in records:
|
|
220
|
+
record_event(Event(
|
|
221
|
+
job_id=job.job_id, type=EventType.ARTIFACT_COMMITTED,
|
|
222
|
+
source=store.backend, message=f"artifact committed: {record.uri}",
|
|
223
|
+
data={"object_key": record.object_key, "size_bytes": record.size_bytes,
|
|
224
|
+
"etag": record.etag},
|
|
225
|
+
))
|
|
226
|
+
# The workload writes execution-summary.json + recovery-events.json
|
|
227
|
+
# with real per-task attempt metadata; fold them into the ledger.
|
|
228
|
+
for name in ("recovery-events.json",):
|
|
229
|
+
key = f"{prefix}{name}"
|
|
230
|
+
if await store.exists(key):
|
|
231
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
232
|
+
dest = Path(tmp) / name
|
|
233
|
+
await store.get_file(key, dest)
|
|
234
|
+
for raw in json.loads(dest.read_text()):
|
|
235
|
+
try:
|
|
236
|
+
record_event(Event.model_validate(raw))
|
|
237
|
+
except Exception as exc:
|
|
238
|
+
log.warning(_jlog("bad workload event", error=str(exc)))
|
|
239
|
+
|
|
240
|
+
async def watch_job(job: JobRecord) -> None:
|
|
241
|
+
try:
|
|
242
|
+
async for event in backend.stream_events(job.runtime_execution_id):
|
|
243
|
+
record_event(event)
|
|
244
|
+
if event.type == EventType.RAY_WORKER_LOST:
|
|
245
|
+
job.state = JobState.RECOVERING
|
|
246
|
+
ledger.upsert_job(job)
|
|
247
|
+
elif event.type == EventType.RAY_WORKER_REPLACED and job.state == JobState.RECOVERING:
|
|
248
|
+
job.state = JobState.RUNNING
|
|
249
|
+
ledger.upsert_job(job)
|
|
250
|
+
elif event.type == EventType.JOB_SUCCEEDED:
|
|
251
|
+
job.state = JobState.SUCCEEDED
|
|
252
|
+
elif event.type == EventType.JOB_FAILED:
|
|
253
|
+
job.state = JobState.FAILED
|
|
254
|
+
job.error = event.message
|
|
255
|
+
status = await backend.get_status(job.runtime_execution_id)
|
|
256
|
+
if status.state.terminal:
|
|
257
|
+
job.state = status.state
|
|
258
|
+
job.finished_at = utcnow()
|
|
259
|
+
if job.state == JobState.SUCCEEDED:
|
|
260
|
+
await ingest_workload_artifacts(job)
|
|
261
|
+
ledger.upsert_job(job)
|
|
262
|
+
except asyncio.CancelledError:
|
|
263
|
+
raise
|
|
264
|
+
except Exception as exc:
|
|
265
|
+
log.exception("watcher failed")
|
|
266
|
+
job.state = JobState.FAILED
|
|
267
|
+
job.error = f"watcher error: {exc}"
|
|
268
|
+
job.finished_at = utcnow()
|
|
269
|
+
ledger.upsert_job(job)
|
|
270
|
+
|
|
271
|
+
@app.get("/healthz")
|
|
272
|
+
async def healthz():
|
|
273
|
+
return {"status": "ok", "profile": settings.profile}
|
|
274
|
+
|
|
275
|
+
@app.post("/v1alpha1/jobs", status_code=201)
|
|
276
|
+
async def submit_job(spec: JobSpec):
|
|
277
|
+
# Mode A: expand into leased tasks; no cluster backend involved.
|
|
278
|
+
if spec.spec.execution.backend == "leases":
|
|
279
|
+
job = JobRecord(
|
|
280
|
+
job_id=uuid.uuid4().hex[:12],
|
|
281
|
+
spec=spec,
|
|
282
|
+
backend="leases",
|
|
283
|
+
deployment_profile=settings.profile,
|
|
284
|
+
state=JobState.RUNNING,
|
|
285
|
+
)
|
|
286
|
+
try:
|
|
287
|
+
tasks = modea.expand_tasks(job.job_id, spec)
|
|
288
|
+
except modea.ExpansionError as exc:
|
|
289
|
+
raise HTTPException(status_code=422, detail=str(exc))
|
|
290
|
+
record_event(Event(job_id=job.job_id, type=EventType.JOB_ACCEPTED,
|
|
291
|
+
source="flashruntime.service",
|
|
292
|
+
message=f"job '{spec.metadata.name}' accepted"))
|
|
293
|
+
record_event(Event(job_id=job.job_id, type=EventType.BACKEND_SELECTED,
|
|
294
|
+
source="flashruntime.service",
|
|
295
|
+
message=f"backend: leases ({len(tasks)} independent tasks)"))
|
|
296
|
+
for task in tasks:
|
|
297
|
+
lease_manager.add_task(task)
|
|
298
|
+
modea_state.lease_jobs.add(job.job_id)
|
|
299
|
+
ledger.upsert_job(job)
|
|
300
|
+
return job
|
|
301
|
+
|
|
302
|
+
if backend is None:
|
|
303
|
+
raise HTTPException(
|
|
304
|
+
status_code=503,
|
|
305
|
+
detail="ray backend disabled on this coordinator (FLASHML_ENABLE_KUBERAY=0); "
|
|
306
|
+
"use execution.backend: leases",
|
|
307
|
+
)
|
|
308
|
+
try:
|
|
309
|
+
await backend.validate(spec)
|
|
310
|
+
except SpecValidationError as exc:
|
|
311
|
+
raise HTTPException(status_code=422, detail=str(exc))
|
|
312
|
+
|
|
313
|
+
job = JobRecord(
|
|
314
|
+
job_id=uuid.uuid4().hex[:12],
|
|
315
|
+
spec=spec,
|
|
316
|
+
backend=backend.name,
|
|
317
|
+
deployment_profile=settings.profile,
|
|
318
|
+
)
|
|
319
|
+
record_event(Event(job_id=job.job_id, type=EventType.JOB_ACCEPTED,
|
|
320
|
+
source="flashruntime.service",
|
|
321
|
+
message=f"job '{spec.metadata.name}' accepted"))
|
|
322
|
+
record_event(Event(job_id=job.job_id, type=EventType.BACKEND_SELECTED,
|
|
323
|
+
source="flashruntime.service",
|
|
324
|
+
message=f"backend: {backend.name} (profile: {settings.profile})"))
|
|
325
|
+
try:
|
|
326
|
+
execution = await backend.submit(job)
|
|
327
|
+
except Exception as exc:
|
|
328
|
+
job.state = JobState.FAILED
|
|
329
|
+
job.error = str(exc)
|
|
330
|
+
ledger.upsert_job(job)
|
|
331
|
+
raise HTTPException(status_code=502, detail=f"backend submit failed: {exc}")
|
|
332
|
+
|
|
333
|
+
job.runtime_execution_id = execution.execution_id
|
|
334
|
+
job.state = JobState.SUBMITTED
|
|
335
|
+
ledger.upsert_job(job)
|
|
336
|
+
record_event(Event(job_id=job.job_id, type=EventType.RAYJOB_CREATED,
|
|
337
|
+
source="kuberay.rayjob",
|
|
338
|
+
message=f"RayJob {execution.execution_id} created",
|
|
339
|
+
data={"execution_id": execution.execution_id,
|
|
340
|
+
"namespace": settings.namespace}))
|
|
341
|
+
app.state.watchers[job.job_id] = asyncio.create_task(watch_job(job))
|
|
342
|
+
return job
|
|
343
|
+
|
|
344
|
+
@app.get("/v1alpha1/jobs")
|
|
345
|
+
async def list_jobs():
|
|
346
|
+
return [refresh_lease_job(j) for j in ledger.list_jobs()]
|
|
347
|
+
|
|
348
|
+
def _job_or_404(job_id: str) -> JobRecord:
|
|
349
|
+
job = ledger.get_job(job_id)
|
|
350
|
+
if job is None:
|
|
351
|
+
raise HTTPException(status_code=404, detail=f"no such job: {job_id}")
|
|
352
|
+
return refresh_lease_job(job)
|
|
353
|
+
|
|
354
|
+
@app.get("/v1alpha1/jobs/{job_id}")
|
|
355
|
+
async def get_job(job_id: str):
|
|
356
|
+
return _job_or_404(job_id)
|
|
357
|
+
|
|
358
|
+
@app.get("/v1alpha1/jobs/{job_id}/events")
|
|
359
|
+
async def get_events(job_id: str):
|
|
360
|
+
_job_or_404(job_id)
|
|
361
|
+
return ledger.events_for(job_id)
|
|
362
|
+
|
|
363
|
+
@app.get("/v1alpha1/jobs/{job_id}/logs")
|
|
364
|
+
async def get_logs(job_id: str):
|
|
365
|
+
job = _job_or_404(job_id)
|
|
366
|
+
if not job.runtime_execution_id:
|
|
367
|
+
return {"logs": ""}
|
|
368
|
+
return {"logs": await backend.get_logs(job.runtime_execution_id)}
|
|
369
|
+
|
|
370
|
+
@app.post("/v1alpha1/jobs/{job_id}/cancel")
|
|
371
|
+
async def cancel_job(job_id: str):
|
|
372
|
+
job = _job_or_404(job_id)
|
|
373
|
+
if job.state.terminal:
|
|
374
|
+
return job
|
|
375
|
+
if job.backend == "leases":
|
|
376
|
+
for record in lease_manager.records(job_id):
|
|
377
|
+
lease_manager.cancel_task(record.spec.job_id, record.spec.task_id)
|
|
378
|
+
if job.runtime_execution_id:
|
|
379
|
+
await backend.cancel(job.runtime_execution_id)
|
|
380
|
+
watcher = app.state.watchers.pop(job_id, None)
|
|
381
|
+
if watcher:
|
|
382
|
+
watcher.cancel()
|
|
383
|
+
job.state = JobState.CANCELLED
|
|
384
|
+
job.finished_at = utcnow()
|
|
385
|
+
ledger.upsert_job(job)
|
|
386
|
+
return job
|
|
387
|
+
|
|
388
|
+
return app
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
app = create_app() if os.environ.get("FLASHML_SERVICE_AUTOINIT", "1") == "1" else None
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Who is this caller? — the node-authentication seam.
|
|
2
|
+
|
|
3
|
+
Deliberately knows nothing about leases or authorization. This module answers
|
|
4
|
+
only "which node_id does this token belong to"; what that node may WRITE is a
|
|
5
|
+
separate question answered by the lease store (service/modea.py). Keeping them
|
|
6
|
+
apart is what lets the cloud replace authentication (Plan 3) without touching
|
|
7
|
+
authorization.
|
|
8
|
+
|
|
9
|
+
Default is OPEN: `flashruntime/CLAUDE.md` rule 4 makes the self-hosted local
|
|
10
|
+
coordinator a first-class mode, and requiring credentials on a laptop would
|
|
11
|
+
break it. An operator exposing the coordinator turns enforcement on with
|
|
12
|
+
FLASHML_NODE_TOKENS, and FLASHML_REQUIRE_NODE_AUTH=1 makes startup fail closed
|
|
13
|
+
if they forget (see service/modea.py).
|
|
14
|
+
|
|
15
|
+
Two credential classes, because two kinds of caller write here:
|
|
16
|
+
|
|
17
|
+
- **node tokens** (FLASHML_NODE_TOKENS) — a volunteer machine. Identified as
|
|
18
|
+
a `node_id`, and everything it writes is confined to the leases it holds.
|
|
19
|
+
- **operator tokens** (FLASHML_OPERATOR_TOKENS) — a *driver*: the federated
|
|
20
|
+
averaging / K-means reducers run inside the trusted cloud API, hold no
|
|
21
|
+
lease, and legitimately write keys (`jobs/{job}/round-NNN/weights.json`,
|
|
22
|
+
shard CSVs) that belong to no task. They are authenticated and attributable
|
|
23
|
+
but not lease-scoped. Volunteers never receive one.
|
|
24
|
+
|
|
25
|
+
An operator token is deliberately NOT a node: `authenticate()` returns None
|
|
26
|
+
for it, so it can never claim, complete, or fail somebody's lease. It only
|
|
27
|
+
lifts the lease-scope confinement on writes.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import hmac
|
|
33
|
+
from typing import Protocol, runtime_checkable
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"AuthConfigError",
|
|
37
|
+
"NodeAuthenticator",
|
|
38
|
+
"OpenAuthenticator",
|
|
39
|
+
"StaticTokenAuthenticator",
|
|
40
|
+
"authenticator_from_env",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class AuthConfigError(RuntimeError):
|
|
45
|
+
"""The authenticator configuration is unusable. Raised at construction —
|
|
46
|
+
never at request time, so a misconfiguration cannot silently admit
|
|
47
|
+
callers."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@runtime_checkable
|
|
51
|
+
class NodeAuthenticator(Protocol):
|
|
52
|
+
@property
|
|
53
|
+
def enforcing(self) -> bool:
|
|
54
|
+
"""True when callers must present a valid token."""
|
|
55
|
+
|
|
56
|
+
def authenticate(self, token: str | None) -> str | None:
|
|
57
|
+
"""Return the caller's node_id, or None to deny."""
|
|
58
|
+
|
|
59
|
+
def is_operator(self, token: str | None) -> bool:
|
|
60
|
+
"""True for a driver credential: authenticated and attributable, but
|
|
61
|
+
NOT lease-scoped. False for node tokens and for anything unknown."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class OpenAuthenticator:
|
|
65
|
+
"""Self-hosted default: no credentials, no scoping. Behavior identical to
|
|
66
|
+
the coordinator before this seam existed."""
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def enforcing(self) -> bool:
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
def authenticate(self, token: str | None) -> str | None: # noqa: ARG002
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
def is_operator(self, token: str | None) -> bool: # noqa: ARG002
|
|
76
|
+
# Nothing is enforced, so nothing needs the exemption either.
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class StaticTokenAuthenticator:
|
|
81
|
+
"""Token → node_id from configuration. The self-hosted multi-machine case,
|
|
82
|
+
and the test double for the cloud's authenticator."""
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def enforcing(self) -> bool:
|
|
86
|
+
return True
|
|
87
|
+
|
|
88
|
+
def __init__(
|
|
89
|
+
self,
|
|
90
|
+
tokens: dict[str, str],
|
|
91
|
+
operator_tokens: dict[str, str] | None = None,
|
|
92
|
+
):
|
|
93
|
+
for token, node_id in tokens.items():
|
|
94
|
+
if not token:
|
|
95
|
+
raise AuthConfigError(
|
|
96
|
+
f"empty token configured for node {node_id!r}: an empty token "
|
|
97
|
+
"would authenticate every caller that sends none"
|
|
98
|
+
)
|
|
99
|
+
operators = dict(operator_tokens or {})
|
|
100
|
+
for token, name in operators.items():
|
|
101
|
+
if not token:
|
|
102
|
+
raise AuthConfigError(
|
|
103
|
+
f"empty token configured for operator {name!r}: an empty token "
|
|
104
|
+
"would authenticate every caller that sends none"
|
|
105
|
+
)
|
|
106
|
+
if token in tokens:
|
|
107
|
+
# One string that is both a lease-scoped node and an
|
|
108
|
+
# unscoped driver is a privilege escalation waiting to be
|
|
109
|
+
# found, and it makes revocation ambiguous.
|
|
110
|
+
raise AuthConfigError(
|
|
111
|
+
f"operator token for {name!r} collides with the node token "
|
|
112
|
+
f"for {tokens[token]!r}: a credential must belong to exactly "
|
|
113
|
+
"one class"
|
|
114
|
+
)
|
|
115
|
+
self._tokens = dict(tokens)
|
|
116
|
+
self._operators = operators
|
|
117
|
+
|
|
118
|
+
@staticmethod
|
|
119
|
+
def _usable(token: str | None) -> bool:
|
|
120
|
+
"""A token we can even compare. `hmac.compare_digest` raises
|
|
121
|
+
TypeError on a non-ASCII `str`, which would turn a malformed bearer
|
|
122
|
+
header into an unauthenticated 500 — and a 500-vs-401 difference is
|
|
123
|
+
an oracle. Deny instead."""
|
|
124
|
+
return isinstance(token, str) and bool(token) and token.isascii()
|
|
125
|
+
|
|
126
|
+
def authenticate(self, token: str | None) -> str | None:
|
|
127
|
+
if not self._usable(token):
|
|
128
|
+
return None
|
|
129
|
+
# compare_digest against every candidate: a dict lookup leaks token
|
|
130
|
+
# contents through timing, and the candidate set is small.
|
|
131
|
+
for candidate, node_id in self._tokens.items():
|
|
132
|
+
if hmac.compare_digest(candidate, token):
|
|
133
|
+
return node_id
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
def is_operator(self, token: str | None) -> bool:
|
|
137
|
+
if not self._usable(token):
|
|
138
|
+
return False
|
|
139
|
+
for candidate in self._operators:
|
|
140
|
+
if hmac.compare_digest(candidate, token):
|
|
141
|
+
return True
|
|
142
|
+
return False
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _parse_pairs(raw: str, var: str, shape: str) -> dict[str, str]:
|
|
146
|
+
"""`name:token` pairs → `{token: name}`."""
|
|
147
|
+
out: dict[str, str] = {}
|
|
148
|
+
for pair in raw.split(","):
|
|
149
|
+
pair = pair.strip()
|
|
150
|
+
if not pair:
|
|
151
|
+
continue
|
|
152
|
+
if pair.count(":") != 1:
|
|
153
|
+
raise AuthConfigError(f"{var} entry {pair!r} is not '{shape}'")
|
|
154
|
+
name, token = (p.strip() for p in pair.split(":"))
|
|
155
|
+
if token in out:
|
|
156
|
+
raise AuthConfigError(
|
|
157
|
+
f"duplicate token shared by {out[token]!r} and {name!r}: "
|
|
158
|
+
"shared tokens make attribution and revocation meaningless"
|
|
159
|
+
)
|
|
160
|
+
out[token] = name
|
|
161
|
+
return out
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def authenticator_from_env(env: dict[str, str] | None = None) -> NodeAuthenticator:
|
|
165
|
+
import os
|
|
166
|
+
|
|
167
|
+
env = os.environ if env is None else env
|
|
168
|
+
raw = (env.get("FLASHML_NODE_TOKENS") or "").strip()
|
|
169
|
+
raw_ops = (env.get("FLASHML_OPERATOR_TOKENS") or "").strip()
|
|
170
|
+
if not raw and not raw_ops:
|
|
171
|
+
return OpenAuthenticator()
|
|
172
|
+
|
|
173
|
+
tokens = _parse_pairs(raw, "FLASHML_NODE_TOKENS", "node_id:token")
|
|
174
|
+
operators = _parse_pairs(raw_ops, "FLASHML_OPERATOR_TOKENS", "name:token")
|
|
175
|
+
if not tokens and not operators:
|
|
176
|
+
return OpenAuthenticator()
|
|
177
|
+
# Configuring ONLY operator tokens still enforces. Ignoring them would
|
|
178
|
+
# leave a coordinator its operator believes is credentialed wide open;
|
|
179
|
+
# a loud "no node can write" beats a silent open door.
|
|
180
|
+
return StaticTokenAuthenticator(tokens, operators)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Checkpoint HTTP surface: the CheckpointCatalog over the wire.
|
|
2
|
+
|
|
3
|
+
Scoped per (job, task): each Mode A task owns its checkpoint lineage, so a
|
|
4
|
+
retried attempt — possibly on a different machine — resumes from *its
|
|
5
|
+
task's* latest valid manifest. Internally the scope is a composite catalog
|
|
6
|
+
key; the catalog itself (parts-first / manifest-last, validation ladder,
|
|
7
|
+
selection) is untouched and separately tested.
|
|
8
|
+
|
|
9
|
+
Known limitation, on purpose: the catalog is in-memory, so manifests do not
|
|
10
|
+
survive a coordinator restart (the checkpoint *files* in the artifact store
|
|
11
|
+
do). Manifest persistence joins the Postgres stage; the ledger already
|
|
12
|
+
records every commit event.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
18
|
+
from pydantic import BaseModel, Field
|
|
19
|
+
|
|
20
|
+
from flashruntime.checkpoint import CheckpointCatalog, CheckpointError
|
|
21
|
+
from flashruntime.protocol.v1alpha1 import CheckpointPart
|
|
22
|
+
from flashruntime.service.modea import authorize_task_write
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _scope(job_id: str, task_id: str) -> str:
|
|
26
|
+
return f"{job_id}::{task_id}"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class RegisterPartRequest(BaseModel):
|
|
30
|
+
attempt_id: str
|
|
31
|
+
step: int = Field(ge=0)
|
|
32
|
+
part: CheckpointPart
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CommitRequest(BaseModel):
|
|
36
|
+
attempt_id: str
|
|
37
|
+
step: int = Field(ge=0)
|
|
38
|
+
expected_parts: list[CheckpointPart]
|
|
39
|
+
storage_prefix: str
|
|
40
|
+
world_size: int = 1
|
|
41
|
+
framework: str = ""
|
|
42
|
+
strategy_family: str = ""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def build_router(catalog: CheckpointCatalog, state, manager) -> APIRouter:
|
|
46
|
+
router = APIRouter(prefix="/v1alpha1/jobs/{job_id}/tasks/{task_id}/checkpoints")
|
|
47
|
+
|
|
48
|
+
@router.post("/parts")
|
|
49
|
+
async def register_part(job_id: str, task_id: str, req: RegisterPartRequest, request: Request):
|
|
50
|
+
authorize_task_write(state, manager, request, job_id, task_id)
|
|
51
|
+
catalog.register_part(_scope(job_id, task_id), req.attempt_id, req.step, req.part)
|
|
52
|
+
return {"status": "registered", "key": req.part.key}
|
|
53
|
+
|
|
54
|
+
@router.post("/commit")
|
|
55
|
+
async def commit(job_id: str, task_id: str, req: CommitRequest, request: Request):
|
|
56
|
+
authorize_task_write(state, manager, request, job_id, task_id)
|
|
57
|
+
try:
|
|
58
|
+
manifest = catalog.commit(
|
|
59
|
+
job_id=_scope(job_id, task_id),
|
|
60
|
+
attempt_id=req.attempt_id,
|
|
61
|
+
step=req.step,
|
|
62
|
+
expected_parts=req.expected_parts,
|
|
63
|
+
storage_prefix=req.storage_prefix,
|
|
64
|
+
world_size=req.world_size,
|
|
65
|
+
framework=req.framework,
|
|
66
|
+
strategy_family=req.strategy_family,
|
|
67
|
+
)
|
|
68
|
+
except CheckpointError as exc:
|
|
69
|
+
# 409: the manifest was refused — parts missing or hashes wrong.
|
|
70
|
+
raise HTTPException(status_code=409, detail=str(exc))
|
|
71
|
+
return manifest
|
|
72
|
+
|
|
73
|
+
@router.get("/latest")
|
|
74
|
+
async def latest(job_id: str, task_id: str, world_size: int | None = None):
|
|
75
|
+
manifest = catalog.latest_valid(_scope(job_id, task_id), world_size=world_size)
|
|
76
|
+
if manifest is None:
|
|
77
|
+
raise HTTPException(status_code=404, detail="no valid checkpoint")
|
|
78
|
+
return manifest
|
|
79
|
+
|
|
80
|
+
@router.get("/lost-work")
|
|
81
|
+
async def lost_work(job_id: str, task_id: str, failed_at_step: int):
|
|
82
|
+
manifest = catalog.latest_valid(_scope(job_id, task_id))
|
|
83
|
+
if manifest is None:
|
|
84
|
+
return {"lost_steps": None, "latest_step": None}
|
|
85
|
+
return {
|
|
86
|
+
"lost_steps": max(0, failed_at_step - manifest.step),
|
|
87
|
+
"latest_step": manifest.step,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return router
|