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.
Files changed (95) hide show
  1. flashml_workloads/__init__.py +7 -0
  2. flashml_workloads/fedavg_driver.py +569 -0
  3. flashml_workloads/fedavg_weights.py +223 -0
  4. flashml_workloads/fedavg_worker.py +166 -0
  5. flashml_workloads/kmeans_driver.py +134 -0
  6. flashml_workloads/kmeans_shard.py +69 -0
  7. flashml_workloads/sgd_trainer.py +127 -0
  8. flashml_workloads/sharded_kmeans.py +323 -0
  9. flashml_workloads/sklearn_trial.py +89 -0
  10. flashruntime/__init__.py +125 -0
  11. flashruntime/artifacts/__init__.py +25 -0
  12. flashruntime/artifacts/store.py +228 -0
  13. flashruntime/backends/__init__.py +26 -0
  14. flashruntime/backends/base.py +63 -0
  15. flashruntime/backends/kuberay.py +465 -0
  16. flashruntime/checkpoint/__init__.py +20 -0
  17. flashruntime/checkpoint/catalog.py +198 -0
  18. flashruntime/checkpoint/local.py +109 -0
  19. flashruntime/checkpoint/store.py +86 -0
  20. flashruntime/integrations/__init__.py +5 -0
  21. flashruntime/integrations/huggingface.py +59 -0
  22. flashruntime/integrations/pytorch.py +52 -0
  23. flashruntime/integrations/sklearn.py +42 -0
  24. flashruntime/launchers/__init__.py +130 -0
  25. flashruntime/launchers/local.py +126 -0
  26. flashruntime/leases/__init__.py +27 -0
  27. flashruntime/leases/manager.py +365 -0
  28. flashruntime/leases/sqlite_store.py +169 -0
  29. flashruntime/leases/store.py +103 -0
  30. flashruntime/monitor/__init__.py +7 -0
  31. flashruntime/monitor/sampler.py +232 -0
  32. flashruntime/planner/__init__.py +56 -0
  33. flashruntime/planner/candidates.py +597 -0
  34. flashruntime/planner/catalog.py +129 -0
  35. flashruntime/planner/comm.py +95 -0
  36. flashruntime/planner/explain.py +109 -0
  37. flashruntime/planner/memory.py +166 -0
  38. flashruntime/planner/resolve.py +120 -0
  39. flashruntime/planner/selector.py +169 -0
  40. flashruntime/planner/timecost.py +81 -0
  41. flashruntime/profiling/__init__.py +113 -0
  42. flashruntime/protocol/__init__.py +18 -0
  43. flashruntime/protocol/plan_v1alpha1.py +320 -0
  44. flashruntime/protocol/v1alpha1.py +465 -0
  45. flashruntime/providers/__init__.py +138 -0
  46. flashruntime/py.typed +0 -0
  47. flashruntime/recipes/__init__.py +135 -0
  48. flashruntime/recipes/command.py +166 -0
  49. flashruntime/recovery/__init__.py +21 -0
  50. flashruntime/recovery/policy.py +170 -0
  51. flashruntime/recovery/signals.py +135 -0
  52. flashruntime/recovery/taxonomy.py +91 -0
  53. flashruntime/scheduler/__init__.py +170 -0
  54. flashruntime/sdk.py +402 -0
  55. flashruntime/service/__init__.py +3 -0
  56. flashruntime/service/app.py +391 -0
  57. flashruntime/service/auth.py +180 -0
  58. flashruntime/service/checkpoints.py +90 -0
  59. flashruntime/service/cli.py +167 -0
  60. flashruntime/service/dashboard.py +193 -0
  61. flashruntime/service/ledger.py +101 -0
  62. flashruntime/service/modea.py +821 -0
  63. flashruntime/strategies/__init__.py +156 -0
  64. flashruntime/strategies/command.py +56 -0
  65. flashruntime/torch/__init__.py +274 -0
  66. flashruntime/viewer/__init__.py +20 -0
  67. flashruntime/viewer/_docs/benchmarks.html +771 -0
  68. flashruntime/viewer/_docs/concepts/architecture.html +302 -0
  69. flashruntime/viewer/_docs/get-started.html +263 -0
  70. flashruntime/viewer/_docs/guides/federated-averaging.html +363 -0
  71. flashruntime/viewer/_docs/guides/huggingface.html +223 -0
  72. flashruntime/viewer/_docs/guides/jobspec-and-isolation.html +271 -0
  73. flashruntime/viewer/_docs/guides/pytorch.html +313 -0
  74. flashruntime/viewer/_docs/guides/sklearn.html +232 -0
  75. flashruntime/viewer/_docs/index.html +251 -0
  76. flashruntime/viewer/_docs/reference/cli.html +254 -0
  77. flashruntime/viewer/_docs/reference/integrations.html +240 -0
  78. flashruntime/viewer/_docs/reference/sdk.html +341 -0
  79. flashruntime/viewer/_docs/reference/torch-helper.html +244 -0
  80. flashruntime/viewer/_docs/search-index.json +1 -0
  81. flashruntime/viewer/_docs/tutorials/convnet.html +571 -0
  82. flashruntime/viewer/_docs/tutorials/fault-tolerance.html +375 -0
  83. flashruntime/viewer/_docs/tutorials/sklearn-sweeps.html +278 -0
  84. flashruntime/viewer/flowmap.py +307 -0
  85. flashruntime/viewer/page.py +594 -0
  86. flashruntime/viewer/server.py +134 -0
  87. flashruntime/viewer/state.py +250 -0
  88. flashruntime/workloads/__init__.py +6 -0
  89. flashruntime/workloads/command.py +127 -0
  90. flashruntime-0.3.0.dist-info/METADATA +365 -0
  91. flashruntime-0.3.0.dist-info/RECORD +95 -0
  92. flashruntime-0.3.0.dist-info/WHEEL +5 -0
  93. flashruntime-0.3.0.dist-info/entry_points.txt +2 -0
  94. flashruntime-0.3.0.dist-info/licenses/LICENSE +202 -0
  95. 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