gpulayer 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 (59) hide show
  1. gpulayer/__init__.py +45 -0
  2. gpulayer/agents/__init__.py +1 -0
  3. gpulayer/agents/mcp_server.py +447 -0
  4. gpulayer/api/__init__.py +0 -0
  5. gpulayer/api/auth.py +179 -0
  6. gpulayer/api/client.py +355 -0
  7. gpulayer/api/server.py +561 -0
  8. gpulayer/api/tls.py +56 -0
  9. gpulayer/api/ui/app.css +165 -0
  10. gpulayer/api/ui/app.js +566 -0
  11. gpulayer/api/ui/icon.svg +1 -0
  12. gpulayer/api/ui/index.html +14 -0
  13. gpulayer/artifacts/__init__.py +0 -0
  14. gpulayer/artifacts/store.py +170 -0
  15. gpulayer/cli/__init__.py +0 -0
  16. gpulayer/cli/main.py +1029 -0
  17. gpulayer/contracts/__init__.py +4 -0
  18. gpulayer/contracts/models.py +737 -0
  19. gpulayer/control_plane/__init__.py +0 -0
  20. gpulayer/control_plane/provisioning.py +414 -0
  21. gpulayer/control_plane/scheduler.py +308 -0
  22. gpulayer/control_plane/service.py +1287 -0
  23. gpulayer/errors.py +58 -0
  24. gpulayer/gpu/__init__.py +0 -0
  25. gpulayer/gpu/discovery.py +383 -0
  26. gpulayer/gpu/support.py +59 -0
  27. gpulayer/observability/__init__.py +1 -0
  28. gpulayer/observability/metrics.py +195 -0
  29. gpulayer/providers/__init__.py +0 -0
  30. gpulayer/providers/base.py +112 -0
  31. gpulayer/providers/fake.py +106 -0
  32. gpulayer/pytorch/__init__.py +35 -0
  33. gpulayer/pytorch/callbacks.py +224 -0
  34. gpulayer/pytorch/launch.py +167 -0
  35. gpulayer/pytorch/module.py +75 -0
  36. gpulayer/pytorch/runtime.py +243 -0
  37. gpulayer/pytorch/trainer.py +487 -0
  38. gpulayer/runtime/__init__.py +0 -0
  39. gpulayer/runtime/dist_function_runner.py +36 -0
  40. gpulayer/runtime/distributed.py +109 -0
  41. gpulayer/runtime/function_runner.py +37 -0
  42. gpulayer/runtime/process.py +116 -0
  43. gpulayer/runtime/rank_launcher.py +94 -0
  44. gpulayer/sdk/__init__.py +0 -0
  45. gpulayer/sdk/builders.py +135 -0
  46. gpulayer/sdk/client.py +247 -0
  47. gpulayer/sdk/home.py +34 -0
  48. gpulayer/sdk/remote.py +305 -0
  49. gpulayer/storage/__init__.py +0 -0
  50. gpulayer/storage/sqlite.py +201 -0
  51. gpulayer/worker/__init__.py +0 -0
  52. gpulayer/worker/agent.py +576 -0
  53. gpulayer/worker/channel.py +95 -0
  54. gpulayer/worker/telemetry.py +121 -0
  55. gpulayer-0.3.0.dist-info/METADATA +216 -0
  56. gpulayer-0.3.0.dist-info/RECORD +59 -0
  57. gpulayer-0.3.0.dist-info/WHEEL +4 -0
  58. gpulayer-0.3.0.dist-info/entry_points.txt +2 -0
  59. gpulayer-0.3.0.dist-info/licenses/LICENSE +202 -0
gpulayer/__init__.py ADDED
@@ -0,0 +1,45 @@
1
+ """GPUlayer: one job model, one capability system, one scheduling layer, many environments."""
2
+
3
+ from gpulayer.contracts import (
4
+ API_VERSION,
5
+ Accelerator,
6
+ ExecutionTarget,
7
+ JobSpec,
8
+ JobState,
9
+ PlacementPolicy,
10
+ ResourceRequest,
11
+ )
12
+ from gpulayer.sdk.builders import command, distributed, function
13
+ from gpulayer.sdk.client import Client, Job
14
+ from gpulayer.sdk.remote import RemoteError, RemoteFunction, connect, default_client, remote
15
+
16
+ __version__ = "0.3.0"
17
+
18
+ __all__ = [
19
+ "API_VERSION",
20
+ "Accelerator",
21
+ "Client",
22
+ "ExecutionTarget",
23
+ "Job",
24
+ "JobSpec",
25
+ "JobState",
26
+ "PlacementPolicy",
27
+ "RemoteError",
28
+ "RemoteFunction",
29
+ "ResourceRequest",
30
+ "command",
31
+ "connect",
32
+ "default_client",
33
+ "distributed",
34
+ "function",
35
+ "remote",
36
+ ]
37
+
38
+
39
+ def __getattr__(name: str) -> object:
40
+ # `gl.pytorch` is imported lazily so CPU-only installs without torch keep working.
41
+ if name == "pytorch":
42
+ import importlib
43
+
44
+ return importlib.import_module("gpulayer.pytorch")
45
+ raise AttributeError(f"module 'gpulayer' has no attribute {name!r}")
@@ -0,0 +1 @@
1
+ """Integrations for AI and coding agents (MCP server)."""
@@ -0,0 +1,447 @@
1
+ """GPUlayer as a Model Context Protocol (MCP) server, for AI and coding agents.
2
+
3
+ gpulayer mcp # stdio transport; embedded mode runs jobs on this machine
4
+ GPULAYER_URL=... GPULAYER_TOKEN=... gpulayer mcp # use a GPUlayer server instead
5
+ gpulayer mcp --read-only # inspect only: no submit, cancel or download
6
+
7
+ The protocol is JSON-RPC 2.0, one message per line on stdin/stdout. Only the parts a tool
8
+ server needs are implemented: initialize, ping, tools/list and tools/call. Tool failures are
9
+ returned as tool results with ``isError: true`` so the agent can read and react to them.
10
+
11
+ Agents act with the permissions of the token in use (or of the local user in embedded mode):
12
+ submitting a job runs code on a worker. Use a submitter token, never an admin token.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import sys
19
+ from collections.abc import Callable
20
+ from pathlib import Path
21
+ from typing import IO, Any
22
+
23
+ import gpulayer
24
+ from gpulayer.contracts import (
25
+ Accelerator,
26
+ CommandJob,
27
+ ExecutionTarget,
28
+ InputArtifact,
29
+ JobSpec,
30
+ JobState,
31
+ JobStatus,
32
+ PlacementPolicy,
33
+ ResourceRequest,
34
+ )
35
+ from gpulayer.errors import GpuLayerError
36
+
37
+ PROTOCOL_VERSIONS = ("2025-06-18", "2025-03-26", "2024-11-05")
38
+ MAX_WAIT = 600.0 # seconds a single tool call may block
39
+ MAX_LOG_LINES = 2000
40
+
41
+ INSTRUCTIONS = """\
42
+ GPUlayer runs commands on GPU and CPU machines (this computer and any servers that joined).
43
+ Typical flow: gpulayer_resources to see what is available; gpulayer_submit with the command
44
+ (and source_dir to ship a project directory); gpulayer_wait or gpulayer_job_status; then
45
+ gpulayer_job_logs and gpulayer_download_outputs. Ask for accelerator "cuda" (NVIDIA) or
46
+ "mps" (Apple GPU) only when the work needs a GPU. If a job cannot be placed, the status
47
+ explains why for every worker; fix the request instead of retrying blindly. The first
48
+ element "python" in a command means the worker's own Python. Files written to
49
+ $GPULAYER_OUTPUT_DIR are returned as outputs."""
50
+
51
+
52
+ def _schema(props: dict[str, Any], required: list[str] | None = None) -> dict[str, Any]:
53
+ return {
54
+ "type": "object",
55
+ "properties": props,
56
+ "required": required or [],
57
+ "additionalProperties": False,
58
+ }
59
+
60
+
61
+ JOB_ID = {"type": "string", "description": "Job id, e.g. job-1a2b3c4d5e6f"}
62
+
63
+ TOOLS: list[dict[str, Any]] = [
64
+ {
65
+ "name": "gpulayer_resources",
66
+ "description": "List the workers that can run jobs now: their GPUs, GPU memory, "
67
+ "frameworks (torch, jax, mlx, ...), CPU, memory and free task slots.",
68
+ "inputSchema": _schema({}),
69
+ "annotations": {"readOnlyHint": True},
70
+ },
71
+ {
72
+ "name": "gpulayer_submit",
73
+ "description": "Run a command as a GPUlayer job on a machine that has what it needs. "
74
+ "Returns the job id; with wait_seconds > 0 also waits and returns the outcome and the "
75
+ "last log lines. Use source_dir to ship a local project directory (the command runs "
76
+ "inside it).",
77
+ "inputSchema": _schema(
78
+ {
79
+ "command": {
80
+ "type": "array",
81
+ "items": {"type": "string"},
82
+ "minItems": 1,
83
+ "description": 'argv, e.g. ["python", "train.py", "--epochs", "3"]',
84
+ },
85
+ "name": {"type": "string", "description": "Short job name"},
86
+ "accelerator": {
87
+ "type": "string",
88
+ "enum": ["cpu", "cuda", "mps"],
89
+ "description": "cuda = NVIDIA GPU, mps = Apple GPU; default cpu",
90
+ },
91
+ "gpus": {"type": "integer", "minimum": 0, "description": "Number of GPUs"},
92
+ "gpu_memory_gb": {"type": "number", "minimum": 0},
93
+ "frameworks": {
94
+ "type": "object",
95
+ "additionalProperties": {"type": "string"},
96
+ "description": 'Required packages on the worker, e.g. {"torch": ">=2.1"}',
97
+ },
98
+ "source_dir": {
99
+ "type": "string",
100
+ "description": "Local directory to package and run in (relative to the "
101
+ "server's working directory)",
102
+ },
103
+ "inputs": {
104
+ "type": "array",
105
+ "items": _schema(
106
+ {"path": {"type": "string"}, "dest": {"type": "string"}}, ["path"]
107
+ ),
108
+ "description": "Local files staged into $GPULAYER_INPUT_DIR",
109
+ },
110
+ "env": {"type": "object", "additionalProperties": {"type": "string"}},
111
+ "target": {"type": "string", "enum": ["local", "remote", "cloud"]},
112
+ "timeout_seconds": {"type": "number", "minimum": 1},
113
+ "wait_seconds": {
114
+ "type": "number",
115
+ "minimum": 0,
116
+ "maximum": MAX_WAIT,
117
+ "description": "Wait up to this long for the job to finish (default 0)",
118
+ },
119
+ },
120
+ ["command"],
121
+ ),
122
+ },
123
+ {
124
+ "name": "gpulayer_job_status",
125
+ "description": "State of a job, where it was placed, exit codes and errors. When it "
126
+ "could not be placed, gives the reason for every worker.",
127
+ "inputSchema": _schema({"job_id": JOB_ID}, ["job_id"]),
128
+ "annotations": {"readOnlyHint": True},
129
+ },
130
+ {
131
+ "name": "gpulayer_wait",
132
+ "description": "Wait until a job finishes (or the timeout passes) and return its "
133
+ "status with the last log lines. Call again if it is still running.",
134
+ "inputSchema": _schema(
135
+ {
136
+ "job_id": JOB_ID,
137
+ "timeout_seconds": {"type": "number", "minimum": 0, "maximum": MAX_WAIT},
138
+ },
139
+ ["job_id"],
140
+ ),
141
+ "annotations": {"readOnlyHint": True},
142
+ },
143
+ {
144
+ "name": "gpulayer_job_logs",
145
+ "description": "Output of a job. Distributed jobs prefix each line with its rank.",
146
+ "inputSchema": _schema(
147
+ {
148
+ "job_id": JOB_ID,
149
+ "tail": {"type": "integer", "minimum": 1, "maximum": MAX_LOG_LINES},
150
+ "after_seq": {
151
+ "type": "integer",
152
+ "minimum": 0,
153
+ "description": "Only lines after this sequence number (for following)",
154
+ },
155
+ },
156
+ ["job_id"],
157
+ ),
158
+ "annotations": {"readOnlyHint": True},
159
+ },
160
+ {
161
+ "name": "gpulayer_list_jobs",
162
+ "description": "Recent jobs, newest first.",
163
+ "inputSchema": _schema(
164
+ {
165
+ "limit": {"type": "integer", "minimum": 1, "maximum": 200},
166
+ "state": {"type": "string", "enum": [s.value for s in JobState]},
167
+ }
168
+ ),
169
+ "annotations": {"readOnlyHint": True},
170
+ },
171
+ {
172
+ "name": "gpulayer_cancel",
173
+ "description": "Cancel a job that has not finished.",
174
+ "inputSchema": _schema({"job_id": JOB_ID}, ["job_id"]),
175
+ "annotations": {"destructiveHint": True},
176
+ },
177
+ {
178
+ "name": "gpulayer_download_outputs",
179
+ "description": "Download the files a finished job wrote to $GPULAYER_OUTPUT_DIR into "
180
+ "a local directory.",
181
+ "inputSchema": _schema(
182
+ {"job_id": JOB_ID, "dest_dir": {"type": "string"}}, ["job_id", "dest_dir"]
183
+ ),
184
+ },
185
+ ]
186
+ WRITE_TOOLS = {"gpulayer_submit", "gpulayer_cancel", "gpulayer_download_outputs"}
187
+
188
+
189
+ class ToolError(Exception):
190
+ """Reported to the agent as a tool result with isError: true."""
191
+
192
+
193
+ def _status(st: JobStatus) -> dict[str, Any]:
194
+ out: dict[str, Any] = {
195
+ "job_id": st.job_id,
196
+ "name": st.name,
197
+ "state": st.state.value,
198
+ "terminal": st.state.terminal,
199
+ "workload": st.workload_type.value,
200
+ "error": st.error,
201
+ "tasks": [
202
+ {
203
+ "node_rank": t.node_rank,
204
+ "worker_id": t.worker_id,
205
+ "state": t.state.value,
206
+ "exit_code": t.exit_code,
207
+ "error": t.error,
208
+ }
209
+ for t in st.tasks
210
+ ],
211
+ }
212
+ if st.scheduling is not None:
213
+ out["placement"] = st.scheduling.summary
214
+ if not st.scheduling.placed:
215
+ out["why_not_placed"] = {
216
+ c.worker_id: c.reasons for c in st.scheduling.candidates if c.reasons
217
+ }
218
+ return out
219
+
220
+
221
+ class GpuLayerMcp:
222
+ def __init__(self, client: gpulayer.Client, *, read_only: bool = False) -> None:
223
+ self.c = client
224
+ self.read_only = read_only
225
+ self.handlers: dict[str, Callable[[dict[str, Any]], Any]] = {
226
+ "gpulayer_resources": self.resources,
227
+ "gpulayer_submit": self.submit,
228
+ "gpulayer_job_status": lambda a: _status(self.c.job(a["job_id"]).status()),
229
+ "gpulayer_wait": self.wait,
230
+ "gpulayer_job_logs": self.logs,
231
+ "gpulayer_list_jobs": self.list_jobs,
232
+ "gpulayer_cancel": lambda a: _status(self.c.job(a["job_id"]).cancel()),
233
+ "gpulayer_download_outputs": self.download,
234
+ }
235
+
236
+ # ------------------------------------------------------------------ tools
237
+
238
+ def tools(self) -> list[dict[str, Any]]:
239
+ return [t for t in TOOLS if not (self.read_only and t["name"] in WRITE_TOOLS)]
240
+
241
+ def resources(self, _: dict[str, Any]) -> Any:
242
+ if self.c.mode == "embedded":
243
+ self.c.ensure_local_worker()
244
+ workers = []
245
+ for w in self.c.workers():
246
+ if w.state.value == "offline":
247
+ continue
248
+ cap = w.capabilities
249
+ workers.append(
250
+ {
251
+ "name": w.name,
252
+ "worker_id": w.worker_id,
253
+ "state": w.state.value,
254
+ "target": cap.target.value,
255
+ "accelerators": [
256
+ {
257
+ "type": d.accelerator.value,
258
+ "name": d.name,
259
+ "memory_gb": d.memory_gb,
260
+ "usable": d.execution_supported,
261
+ }
262
+ for d in cap.accelerators
263
+ ],
264
+ "frameworks": cap.frameworks,
265
+ "cpu_cores": cap.cpu_cores,
266
+ "memory_gb": cap.memory_gb,
267
+ "running_tasks": len(w.running_task_ids),
268
+ "task_slots": cap.max_concurrent_tasks,
269
+ }
270
+ )
271
+ return {"mode": self.c.mode, "workers": workers}
272
+
273
+ def submit(self, a: dict[str, Any]) -> Any:
274
+ accel = Accelerator(a.get("accelerator", "cpu"))
275
+ gpus = a.get("gpus")
276
+ source = None
277
+ if a.get("source_dir"):
278
+ d = Path(a["source_dir"]).expanduser()
279
+ if not d.is_dir():
280
+ raise ToolError(f"source_dir {str(d)!r} is not a directory")
281
+ source = self.c.package(d)
282
+ inputs = []
283
+ for item in a.get("inputs", []):
284
+ p = Path(item["path"]).expanduser()
285
+ if not p.is_file():
286
+ raise ToolError(f"input {str(p)!r} is not a file")
287
+ inputs.append(InputArtifact(artifact=self.c.upload(p), path=item.get("dest") or p.name))
288
+ target = ExecutionTarget(
289
+ a.get("target") or ("remote" if self.c.mode == "remote" else "local")
290
+ )
291
+ spec = JobSpec(
292
+ name=a.get("name") or "agent-job",
293
+ workload=CommandJob(command=list(a["command"]), source=source),
294
+ resources=ResourceRequest(
295
+ accelerator=accel,
296
+ gpu_count=gpus if gpus is not None else (0 if accel == Accelerator.CPU else 1),
297
+ gpu_memory_gb=a.get("gpu_memory_gb"),
298
+ frameworks=a.get("frameworks") or {},
299
+ ),
300
+ placement=PlacementPolicy(target=target, on_unschedulable="fail"),
301
+ inputs=inputs,
302
+ env=a.get("env") or {},
303
+ timeout_seconds=a.get("timeout_seconds"),
304
+ )
305
+ job = self.c.submit(spec)
306
+ wait = float(a.get("wait_seconds") or 0)
307
+ if wait > 0:
308
+ return self.wait({"job_id": job.id, "timeout_seconds": wait})
309
+ return _status(job.status())
310
+
311
+ def wait(self, a: dict[str, Any]) -> Any:
312
+ job = self.c.job(a["job_id"])
313
+ timeout = min(float(a.get("timeout_seconds", 60)), MAX_WAIT)
314
+ try:
315
+ st = job.wait(timeout)
316
+ except TimeoutError:
317
+ st = job.status()
318
+ out = _status(st)
319
+ out["log_tail"] = [self._line(x) for x in job.logs()[-50:]]
320
+ if st.state.terminal:
321
+ res = job.result()
322
+ out["exit_code"] = res.exit_code
323
+ out["outputs"] = [o.name for o in res.outputs]
324
+ if res.return_value is not None:
325
+ out["return_value"] = res.return_value
326
+ return out
327
+
328
+ @staticmethod
329
+ def _line(x: Any) -> str:
330
+ prefix = f"[{x.origin}] " if x.rank is not None else ""
331
+ return prefix + (f"[{x.stream}] " if x.stream != "stdout" else "") + x.line
332
+
333
+ def logs(self, a: dict[str, Any]) -> Any:
334
+ lines = self.c.job(a["job_id"]).logs(after_seq=int(a.get("after_seq", 0)))
335
+ tail = int(a.get("tail", 200))
336
+ shown = lines[-tail:]
337
+ return {
338
+ "lines": [self._line(x) for x in shown],
339
+ "omitted": len(lines) - len(shown),
340
+ "last_seq": lines[-1].seq if lines else int(a.get("after_seq", 0)),
341
+ }
342
+
343
+ def list_jobs(self, a: dict[str, Any]) -> Any:
344
+ jobs = self.c.jobs(limit=int(a.get("limit", 20)))
345
+ if a.get("state"):
346
+ jobs = [j for j in jobs if j.state.value == a["state"]]
347
+ return [
348
+ {
349
+ "job_id": j.job_id,
350
+ "name": j.name,
351
+ "state": j.state.value,
352
+ "created_at": j.created_at.isoformat(),
353
+ }
354
+ for j in jobs
355
+ ]
356
+
357
+ def download(self, a: dict[str, Any]) -> Any:
358
+ res = self.c.job(a["job_id"]).result()
359
+ out = Path(a["dest_dir"]).expanduser().resolve()
360
+ written = []
361
+ for ref in res.outputs:
362
+ dest = (out / ref.name).resolve()
363
+ if not dest.is_relative_to(out):
364
+ raise ToolError(f"refusing unsafe output name {ref.name!r}")
365
+ dest.parent.mkdir(parents=True, exist_ok=True)
366
+ dest.write_bytes(self.c.download(ref))
367
+ written.append(str(dest))
368
+ return {"files": written}
369
+
370
+ # --------------------------------------------------------------- protocol
371
+
372
+ def call_tool(self, name: str, args: dict[str, Any]) -> dict[str, Any]:
373
+ handler = self.handlers.get(name)
374
+ if handler is None or (self.read_only and name in WRITE_TOOLS):
375
+ return _text(f"unknown or disabled tool: {name}", error=True)
376
+ try:
377
+ result = handler(args or {})
378
+ except (GpuLayerError, ToolError, ValueError, KeyError) as e:
379
+ return _text(f"{type(e).__name__}: {e}", error=True)
380
+ body = _text(json.dumps(result, indent=2, default=str))
381
+ if isinstance(result, dict):
382
+ body["structuredContent"] = json.loads(json.dumps(result, default=str))
383
+ return body
384
+
385
+ def handle(self, msg: dict[str, Any]) -> dict[str, Any] | None:
386
+ method, mid = msg.get("method"), msg.get("id")
387
+ if mid is None:
388
+ return None # notification (e.g. notifications/initialized)
389
+ params = msg.get("params") or {}
390
+ if method == "initialize":
391
+ asked = params.get("protocolVersion")
392
+ version = asked if asked in PROTOCOL_VERSIONS else PROTOCOL_VERSIONS[0]
393
+ result: Any = {
394
+ "protocolVersion": version,
395
+ "capabilities": {"tools": {"listChanged": False}},
396
+ "serverInfo": {"name": "gpulayer", "version": gpulayer.__version__},
397
+ "instructions": INSTRUCTIONS,
398
+ }
399
+ elif method == "ping":
400
+ result = {}
401
+ elif method == "tools/list":
402
+ result = {"tools": self.tools()}
403
+ elif method == "tools/call":
404
+ result = self.call_tool(params.get("name", ""), params.get("arguments") or {})
405
+ else:
406
+ return {
407
+ "jsonrpc": "2.0",
408
+ "id": mid,
409
+ "error": {"code": -32601, "message": f"method not found: {method}"},
410
+ }
411
+ return {"jsonrpc": "2.0", "id": mid, "result": result}
412
+
413
+
414
+ def _text(text: str, *, error: bool = False) -> dict[str, Any]:
415
+ return {"content": [{"type": "text", "text": text}], "isError": error}
416
+
417
+
418
+ def serve(
419
+ client: gpulayer.Client,
420
+ *,
421
+ read_only: bool = False,
422
+ stdin: IO[str] | None = None,
423
+ stdout: IO[str] | None = None,
424
+ ) -> None:
425
+ """Serve MCP over stdio until stdin closes."""
426
+ proto_in = stdin or sys.stdin
427
+ proto_out = stdout or sys.stdout
428
+ # Anything else that prints must not corrupt the protocol stream.
429
+ sys.stdout = sys.stderr
430
+ server = GpuLayerMcp(client, read_only=read_only)
431
+ for raw in proto_in:
432
+ raw = raw.strip()
433
+ if not raw:
434
+ continue
435
+ try:
436
+ msg = json.loads(raw)
437
+ except json.JSONDecodeError as e:
438
+ reply: dict[str, Any] | None = {
439
+ "jsonrpc": "2.0",
440
+ "id": None,
441
+ "error": {"code": -32700, "message": str(e)},
442
+ }
443
+ else:
444
+ reply = server.handle(msg) if isinstance(msg, dict) else None
445
+ if reply is not None:
446
+ proto_out.write(json.dumps(reply) + "\n")
447
+ proto_out.flush()
File without changes
gpulayer/api/auth.py ADDED
@@ -0,0 +1,179 @@
1
+ """Control-plane credentials.
2
+
3
+ Named tokens, stored only as SHA-256 hashes, each with one role:
4
+
5
+ * ``admin`` -- everything a client can do, plus revoking workers, managing tokens and
6
+ maintenance (garbage collection);
7
+ * ``submitter`` -- submit jobs, read all jobs and workers, cancel only its own jobs;
8
+ * ``join`` -- may only call ``POST /v1/workers/register``;
9
+ * ``metrics`` -- may only read ``GET /metrics`` (for Prometheus).
10
+
11
+ Tokens can expire and can be revoked. Registered workers then get a per-worker session
12
+ token (checked by the control plane), and provider-provisioned workers use a single-use
13
+ instance token (control_plane/provisioning.py). The server re-reads the credentials file
14
+ when it changes, so ``gpulayer token create/revoke`` take effect without a restart.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import hashlib
20
+ import hmac
21
+ import json
22
+ import os
23
+ import secrets
24
+ import threading
25
+ from dataclasses import asdict, dataclass, field
26
+ from datetime import datetime, timedelta, timezone
27
+ from pathlib import Path
28
+
29
+ ROLES = ("admin", "submitter", "join", "metrics")
30
+ METRICS_ROLES = ("admin", "submitter", "metrics")
31
+ CLIENT_ROLES = ("admin", "submitter")
32
+ _PREFIX = {"admin": "glc_", "submitter": "glc_", "join": "glj_", "metrics": "glm_"}
33
+
34
+
35
+ def token_hash(token: str) -> str:
36
+ return hashlib.sha256(token.encode()).hexdigest()
37
+
38
+
39
+ def _now() -> datetime:
40
+ return datetime.now(timezone.utc)
41
+
42
+
43
+ @dataclass
44
+ class TokenEntry:
45
+ name: str
46
+ role: str
47
+ hash: str
48
+ created_at: str
49
+ expires_at: str | None = None
50
+ revoked: bool = False
51
+
52
+ def active(self, now: datetime | None = None) -> bool:
53
+ if self.revoked:
54
+ return False
55
+ expired = bool(
56
+ self.expires_at and datetime.fromisoformat(self.expires_at) <= (now or _now())
57
+ )
58
+ return not expired
59
+
60
+
61
+ @dataclass
62
+ class ServerCredentials:
63
+ tokens: list[TokenEntry] = field(default_factory=list)
64
+ path: Path | None = None
65
+ _mtime: float | None = field(default=None, repr=False)
66
+ _lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
67
+
68
+ # ------------------------------------------------------------ lookup
69
+
70
+ def authenticate(self, token: str, roles: tuple[str, ...]) -> TokenEntry | None:
71
+ """The active entry for ``token`` if its role is in ``roles`` (constant-time match)."""
72
+ self._maybe_reload()
73
+ h = token_hash(token)
74
+ match: TokenEntry | None = None
75
+ for entry in self.tokens: # compare against every entry; no early exit
76
+ if hmac.compare_digest(h, entry.hash):
77
+ match = entry
78
+ if match is None or match.role not in roles or not match.active():
79
+ return None
80
+ return match
81
+
82
+ def is_client(self, token: str) -> bool:
83
+ return self.authenticate(token, CLIENT_ROLES) is not None
84
+
85
+ def is_join(self, token: str) -> bool:
86
+ return self.authenticate(token, ("join",)) is not None
87
+
88
+ # ------------------------------------------------------------ management
89
+
90
+ def create(self, name: str, role: str, ttl: timedelta | None = None) -> str:
91
+ """Add a token and return its plaintext value (shown once)."""
92
+ if role not in ROLES:
93
+ raise ValueError(f"role must be one of {ROLES}")
94
+ if any(t.name == name and t.active() for t in self.tokens):
95
+ raise ValueError(f"an active token named {name!r} already exists")
96
+ token = _PREFIX[role] + secrets.token_urlsafe(32)
97
+ now = _now()
98
+ self.tokens.append(
99
+ TokenEntry(
100
+ name=name,
101
+ role=role,
102
+ hash=token_hash(token),
103
+ created_at=now.isoformat(),
104
+ expires_at=(now + ttl).isoformat() if ttl else None,
105
+ )
106
+ )
107
+ return token
108
+
109
+ def revoke(self, name: str) -> int:
110
+ n = 0
111
+ for t in self.tokens:
112
+ if t.name == name and not t.revoked:
113
+ t.revoked = True
114
+ n += 1
115
+ return n
116
+
117
+ # ------------------------------------------------------------ persistence
118
+
119
+ def save(self, path: Path | None = None) -> None:
120
+ path = path or self.path
121
+ assert path is not None
122
+ path.parent.mkdir(parents=True, exist_ok=True)
123
+ tmp = path.with_suffix(".tmp")
124
+ tmp.write_text(json.dumps({"tokens": [asdict(t) for t in self.tokens]}, indent=2))
125
+ os.chmod(tmp, 0o600)
126
+ tmp.replace(path)
127
+ self.path = path
128
+ self._mtime = path.stat().st_mtime
129
+
130
+ @classmethod
131
+ def load(cls, path: Path) -> ServerCredentials:
132
+ creds = cls(tokens=_read(path), path=path)
133
+ creds._mtime = path.stat().st_mtime
134
+ return creds
135
+
136
+ def _maybe_reload(self) -> None:
137
+ if self.path is None:
138
+ return
139
+ try:
140
+ mtime = self.path.stat().st_mtime
141
+ except OSError:
142
+ return
143
+ if mtime != self._mtime:
144
+ with self._lock:
145
+ self.tokens = _read(self.path)
146
+ self._mtime = mtime
147
+
148
+
149
+ def _read(path: Path) -> list[TokenEntry]:
150
+ data = json.loads(path.read_text())
151
+ if "tokens" in data:
152
+ return [TokenEntry(**t) for t in data["tokens"]]
153
+ # Format written before named tokens: unnamed admin and join hashes.
154
+ created = datetime.fromtimestamp(path.stat().st_mtime, timezone.utc).isoformat()
155
+ entries = [
156
+ TokenEntry(name=f"client-{i}", role="admin", hash=h, created_at=created)
157
+ for i, h in enumerate(data.get("client_token_hashes", []))
158
+ ]
159
+ entries += [
160
+ TokenEntry(name=f"join-{i}", role="join", hash=h, created_at=created)
161
+ for i, h in enumerate(data.get("join_token_hashes", []))
162
+ ]
163
+ return entries
164
+
165
+
166
+ def generate_credentials() -> tuple[ServerCredentials, str, str]:
167
+ """A fresh set with one admin and one join token. Returns (creds, client, join)."""
168
+ creds = ServerCredentials()
169
+ client = creds.create("admin", "admin")
170
+ join = creds.create("join", "join")
171
+ return creds, client, join
172
+
173
+
174
+ def parse_ttl(text: str) -> timedelta:
175
+ """'90m', '12h', '7d' -> timedelta."""
176
+ units = {"s": 1, "m": 60, "h": 3600, "d": 86400}
177
+ if not text or text[-1] not in units or not text[:-1].isdigit():
178
+ raise ValueError("TTL looks like 90m, 12h or 7d")
179
+ return timedelta(seconds=int(text[:-1]) * units[text[-1]])