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.
- gpulayer/__init__.py +45 -0
- gpulayer/agents/__init__.py +1 -0
- gpulayer/agents/mcp_server.py +447 -0
- gpulayer/api/__init__.py +0 -0
- gpulayer/api/auth.py +179 -0
- gpulayer/api/client.py +355 -0
- gpulayer/api/server.py +561 -0
- gpulayer/api/tls.py +56 -0
- gpulayer/api/ui/app.css +165 -0
- gpulayer/api/ui/app.js +566 -0
- gpulayer/api/ui/icon.svg +1 -0
- gpulayer/api/ui/index.html +14 -0
- gpulayer/artifacts/__init__.py +0 -0
- gpulayer/artifacts/store.py +170 -0
- gpulayer/cli/__init__.py +0 -0
- gpulayer/cli/main.py +1029 -0
- gpulayer/contracts/__init__.py +4 -0
- gpulayer/contracts/models.py +737 -0
- gpulayer/control_plane/__init__.py +0 -0
- gpulayer/control_plane/provisioning.py +414 -0
- gpulayer/control_plane/scheduler.py +308 -0
- gpulayer/control_plane/service.py +1287 -0
- gpulayer/errors.py +58 -0
- gpulayer/gpu/__init__.py +0 -0
- gpulayer/gpu/discovery.py +383 -0
- gpulayer/gpu/support.py +59 -0
- gpulayer/observability/__init__.py +1 -0
- gpulayer/observability/metrics.py +195 -0
- gpulayer/providers/__init__.py +0 -0
- gpulayer/providers/base.py +112 -0
- gpulayer/providers/fake.py +106 -0
- gpulayer/pytorch/__init__.py +35 -0
- gpulayer/pytorch/callbacks.py +224 -0
- gpulayer/pytorch/launch.py +167 -0
- gpulayer/pytorch/module.py +75 -0
- gpulayer/pytorch/runtime.py +243 -0
- gpulayer/pytorch/trainer.py +487 -0
- gpulayer/runtime/__init__.py +0 -0
- gpulayer/runtime/dist_function_runner.py +36 -0
- gpulayer/runtime/distributed.py +109 -0
- gpulayer/runtime/function_runner.py +37 -0
- gpulayer/runtime/process.py +116 -0
- gpulayer/runtime/rank_launcher.py +94 -0
- gpulayer/sdk/__init__.py +0 -0
- gpulayer/sdk/builders.py +135 -0
- gpulayer/sdk/client.py +247 -0
- gpulayer/sdk/home.py +34 -0
- gpulayer/sdk/remote.py +305 -0
- gpulayer/storage/__init__.py +0 -0
- gpulayer/storage/sqlite.py +201 -0
- gpulayer/worker/__init__.py +0 -0
- gpulayer/worker/agent.py +576 -0
- gpulayer/worker/channel.py +95 -0
- gpulayer/worker/telemetry.py +121 -0
- gpulayer-0.3.0.dist-info/METADATA +216 -0
- gpulayer-0.3.0.dist-info/RECORD +59 -0
- gpulayer-0.3.0.dist-info/WHEEL +4 -0
- gpulayer-0.3.0.dist-info/entry_points.txt +2 -0
- 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()
|
gpulayer/api/__init__.py
ADDED
|
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]])
|