msdev 0.9.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.
- msdev/__init__.py +3 -0
- msdev/cli.py +1460 -0
- msdev/core/__init__.py +1 -0
- msdev/core/config.py +193 -0
- msdev/core/guides.py +100 -0
- msdev/core/inventory.py +1390 -0
- msdev/core/limits.py +44 -0
- msdev/core/resources.py +300 -0
- msdev/core/services/__init__.py +111 -0
- msdev/core/services/context.py +61 -0
- msdev/core/services/environment.py +84 -0
- msdev/core/services/execution.py +136 -0
- msdev/core/services/model.py +1085 -0
- msdev/core/services/node.py +210 -0
- msdev/core/services/npu.py +116 -0
- msdev/core/services/workspace.py +567 -0
- msdev/core/transport.py +1225 -0
- msdev/core/workspace/__init__.py +36 -0
- msdev/core/workspace/access.py +1216 -0
- msdev/core/workspace/client.py +274 -0
- msdev/core/workspace/paths.py +45 -0
- msdev/core/workspace/registry.py +151 -0
- msdev/daemon.py +1322 -0
- msdev/session/__init__.py +13 -0
- msdev/session/export.py +190 -0
- msdev/session/log.py +269 -0
- msdev-0.9.0.dist-info/METADATA +295 -0
- msdev-0.9.0.dist-info/RECORD +31 -0
- msdev-0.9.0.dist-info/WHEEL +5 -0
- msdev-0.9.0.dist-info/entry_points.txt +3 -0
- msdev-0.9.0.dist-info/top_level.txt +1 -0
msdev/daemon.py
ADDED
|
@@ -0,0 +1,1322 @@
|
|
|
1
|
+
"""User-level msdev daemon and Unix-socket JSON RPC."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import base64
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import platform
|
|
10
|
+
import select
|
|
11
|
+
import shlex
|
|
12
|
+
import shutil
|
|
13
|
+
import signal
|
|
14
|
+
import socket
|
|
15
|
+
import socketserver
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
import threading
|
|
19
|
+
import time
|
|
20
|
+
import traceback
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any, Callable
|
|
23
|
+
|
|
24
|
+
from . import __version__
|
|
25
|
+
from .core.config import (
|
|
26
|
+
wrap_conda_activation_command,
|
|
27
|
+
data_home,
|
|
28
|
+
default_socket_path,
|
|
29
|
+
parse_environment_spec,
|
|
30
|
+
wrap_conda_command,
|
|
31
|
+
)
|
|
32
|
+
from .core.inventory import Inventory
|
|
33
|
+
from .core.limits import (
|
|
34
|
+
DEFAULT_EXEC_TIMEOUT_SECONDS,
|
|
35
|
+
MAX_JSON_RPC_REQUEST_BYTES,
|
|
36
|
+
MAX_RPC_ERROR_BYTES,
|
|
37
|
+
MAX_RPC_RESPONSE_BYTES,
|
|
38
|
+
validate_exec_timeout_seconds,
|
|
39
|
+
)
|
|
40
|
+
from .core.resources import parse_variable_assignment
|
|
41
|
+
from .core.workspace.access import (
|
|
42
|
+
atomic_write,
|
|
43
|
+
delete_file,
|
|
44
|
+
glob_paths,
|
|
45
|
+
list_directory,
|
|
46
|
+
read_file,
|
|
47
|
+
resolve_path,
|
|
48
|
+
search,
|
|
49
|
+
stat_path,
|
|
50
|
+
workspace_root,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
MAX_REQUEST_BYTES = MAX_JSON_RPC_REQUEST_BYTES
|
|
55
|
+
MAX_EXEC_OUTPUT_BYTES = 4 * 1024 * 1024
|
|
56
|
+
EXEC_PIPE_DRAIN_GRACE_SECONDS = 0.25
|
|
57
|
+
_EXEC_READ_CHUNK_BYTES = 64 * 1024
|
|
58
|
+
_EXEC_FORCE_CLEANUP_SECONDS = 0.25
|
|
59
|
+
_EXEC_THREAD_JOIN_SECONDS = 0.05
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _bounded_utf8(value: Any, maximum: int) -> str:
|
|
63
|
+
text = str(value)
|
|
64
|
+
retained = bytearray()
|
|
65
|
+
for offset in range(0, len(text), 4096):
|
|
66
|
+
chunk = text[offset : offset + 4096].encode(
|
|
67
|
+
"utf-8",
|
|
68
|
+
errors="replace",
|
|
69
|
+
)
|
|
70
|
+
remaining = maximum - len(retained)
|
|
71
|
+
if remaining <= 0:
|
|
72
|
+
break
|
|
73
|
+
retained.extend(chunk[:remaining])
|
|
74
|
+
return bytes(retained).decode("utf-8", errors="ignore")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _encode_json_response(value: Any, maximum: int) -> bytes | None:
|
|
78
|
+
retained = bytearray()
|
|
79
|
+
encoder = json.JSONEncoder(ensure_ascii=False)
|
|
80
|
+
for fragment in encoder.iterencode(value):
|
|
81
|
+
encoded = fragment.encode("utf-8")
|
|
82
|
+
if len(retained) + len(encoded) + 1 > maximum:
|
|
83
|
+
return None
|
|
84
|
+
retained.extend(encoded)
|
|
85
|
+
retained.extend(b"\n")
|
|
86
|
+
return bytes(retained)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _write_proxy_error(error_type: str, message: Any) -> None:
|
|
90
|
+
value = {
|
|
91
|
+
"id": None,
|
|
92
|
+
"error": {
|
|
93
|
+
"type": _bounded_utf8(error_type, 128),
|
|
94
|
+
"message": _bounded_utf8(message, MAX_RPC_ERROR_BYTES),
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
raw = _encode_json_response(value, MAX_RPC_RESPONSE_BYTES)
|
|
98
|
+
if raw is not None:
|
|
99
|
+
sys.stdout.buffer.write(raw)
|
|
100
|
+
sys.stdout.buffer.flush()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _drain_exec_stream(
|
|
104
|
+
stream: Any,
|
|
105
|
+
retained: bytearray,
|
|
106
|
+
truncated: list[bool],
|
|
107
|
+
lock: threading.Lock,
|
|
108
|
+
done: threading.Event,
|
|
109
|
+
stream_name: str,
|
|
110
|
+
output_callback: Callable[[str, bytes], None] | None,
|
|
111
|
+
retain_output: bool,
|
|
112
|
+
) -> None:
|
|
113
|
+
try:
|
|
114
|
+
while True:
|
|
115
|
+
try:
|
|
116
|
+
chunk = os.read(stream.fileno(), _EXEC_READ_CHUNK_BYTES)
|
|
117
|
+
except (OSError, ValueError):
|
|
118
|
+
return
|
|
119
|
+
if not chunk:
|
|
120
|
+
return
|
|
121
|
+
if output_callback is not None:
|
|
122
|
+
output_callback(stream_name, chunk)
|
|
123
|
+
if retain_output:
|
|
124
|
+
with lock:
|
|
125
|
+
remaining = MAX_EXEC_OUTPUT_BYTES - len(retained)
|
|
126
|
+
if remaining > 0:
|
|
127
|
+
retained.extend(chunk[:remaining])
|
|
128
|
+
if len(chunk) > remaining:
|
|
129
|
+
truncated[0] = True
|
|
130
|
+
finally:
|
|
131
|
+
try:
|
|
132
|
+
stream.close()
|
|
133
|
+
except (OSError, ValueError):
|
|
134
|
+
pass
|
|
135
|
+
done.set()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _kill_exec_process_group(process: subprocess.Popen[bytes]) -> None:
|
|
139
|
+
"""Kill only the isolated child group, never the daemon's process group."""
|
|
140
|
+
group_killed = False
|
|
141
|
+
if os.name == "posix" and hasattr(os, "killpg"):
|
|
142
|
+
child_group = process.pid
|
|
143
|
+
if child_group != os.getpgrp():
|
|
144
|
+
try:
|
|
145
|
+
os.killpg(child_group, signal.SIGKILL)
|
|
146
|
+
group_killed = True
|
|
147
|
+
except (ProcessLookupError, PermissionError):
|
|
148
|
+
pass
|
|
149
|
+
if not group_killed and process.poll() is None:
|
|
150
|
+
process.kill()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _wait_for_exec_drainers(
|
|
154
|
+
done: list[threading.Event],
|
|
155
|
+
deadline: float,
|
|
156
|
+
) -> bool:
|
|
157
|
+
for event in done:
|
|
158
|
+
if event.is_set():
|
|
159
|
+
continue
|
|
160
|
+
remaining = deadline - time.monotonic()
|
|
161
|
+
if remaining <= 0 or not event.wait(remaining):
|
|
162
|
+
return False
|
|
163
|
+
return True
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _close_exec_pipes(process: subprocess.Popen[bytes]) -> None:
|
|
167
|
+
for stream in (process.stdout, process.stderr):
|
|
168
|
+
if stream is None:
|
|
169
|
+
continue
|
|
170
|
+
try:
|
|
171
|
+
os.close(stream.fileno())
|
|
172
|
+
except (OSError, ValueError):
|
|
173
|
+
pass
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _reap_exec_process(
|
|
177
|
+
process: subprocess.Popen[bytes],
|
|
178
|
+
deadline: float,
|
|
179
|
+
) -> None:
|
|
180
|
+
remaining = max(0.0, deadline - time.monotonic())
|
|
181
|
+
try:
|
|
182
|
+
process.wait(timeout=remaining)
|
|
183
|
+
except subprocess.TimeoutExpired:
|
|
184
|
+
if process.poll() is None:
|
|
185
|
+
process.kill()
|
|
186
|
+
remaining = max(0.0, deadline - time.monotonic())
|
|
187
|
+
try:
|
|
188
|
+
process.wait(timeout=remaining)
|
|
189
|
+
except subprocess.TimeoutExpired:
|
|
190
|
+
pass
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _execute_bounded(
|
|
194
|
+
command: list[str],
|
|
195
|
+
*,
|
|
196
|
+
cwd: str | None,
|
|
197
|
+
env: dict[str, str],
|
|
198
|
+
timeout: float | None,
|
|
199
|
+
cancellation_requested: Callable[[], bool] | None = None,
|
|
200
|
+
output_callback: Callable[[str, bytes], None] | None = None,
|
|
201
|
+
retain_output: bool = True,
|
|
202
|
+
) -> tuple[int, str, str, bool, bool, bool, bool, bool]:
|
|
203
|
+
"""Execute while retaining bounded output.
|
|
204
|
+
|
|
205
|
+
``timeout=None`` intentionally permits long-running same-user commands.
|
|
206
|
+
Once the direct child exits, inherited output pipes receive only a bounded
|
|
207
|
+
drain grace so detached descendants cannot hold the RPC open forever.
|
|
208
|
+
"""
|
|
209
|
+
started = time.monotonic()
|
|
210
|
+
deadline = started + timeout if timeout is not None else None
|
|
211
|
+
process = subprocess.Popen(
|
|
212
|
+
command,
|
|
213
|
+
cwd=cwd,
|
|
214
|
+
env=env,
|
|
215
|
+
stdout=subprocess.PIPE,
|
|
216
|
+
stderr=subprocess.PIPE,
|
|
217
|
+
start_new_session=(os.name == "posix"),
|
|
218
|
+
)
|
|
219
|
+
assert process.stdout is not None
|
|
220
|
+
assert process.stderr is not None
|
|
221
|
+
stdout = bytearray()
|
|
222
|
+
stderr = bytearray()
|
|
223
|
+
stdout_truncated = [False]
|
|
224
|
+
stderr_truncated = [False]
|
|
225
|
+
stdout_lock = threading.Lock()
|
|
226
|
+
stderr_lock = threading.Lock()
|
|
227
|
+
drain_done = [threading.Event(), threading.Event()]
|
|
228
|
+
drainers = [
|
|
229
|
+
threading.Thread(
|
|
230
|
+
target=_drain_exec_stream,
|
|
231
|
+
args=(
|
|
232
|
+
process.stdout,
|
|
233
|
+
stdout,
|
|
234
|
+
stdout_truncated,
|
|
235
|
+
stdout_lock,
|
|
236
|
+
drain_done[0],
|
|
237
|
+
"stdout",
|
|
238
|
+
output_callback,
|
|
239
|
+
retain_output,
|
|
240
|
+
),
|
|
241
|
+
daemon=True,
|
|
242
|
+
),
|
|
243
|
+
threading.Thread(
|
|
244
|
+
target=_drain_exec_stream,
|
|
245
|
+
args=(
|
|
246
|
+
process.stderr,
|
|
247
|
+
stderr,
|
|
248
|
+
stderr_truncated,
|
|
249
|
+
stderr_lock,
|
|
250
|
+
drain_done[1],
|
|
251
|
+
"stderr",
|
|
252
|
+
output_callback,
|
|
253
|
+
retain_output,
|
|
254
|
+
),
|
|
255
|
+
daemon=True,
|
|
256
|
+
),
|
|
257
|
+
]
|
|
258
|
+
for drainer in drainers:
|
|
259
|
+
drainer.start()
|
|
260
|
+
timed_out = False
|
|
261
|
+
cancelled = False
|
|
262
|
+
output_drain_truncated = False
|
|
263
|
+
cleanup_deadline: float | None = None
|
|
264
|
+
try:
|
|
265
|
+
while True:
|
|
266
|
+
if (
|
|
267
|
+
cancellation_requested is not None
|
|
268
|
+
and cancellation_requested()
|
|
269
|
+
):
|
|
270
|
+
cancelled = True
|
|
271
|
+
cleanup_deadline = (
|
|
272
|
+
time.monotonic() + _EXEC_FORCE_CLEANUP_SECONDS
|
|
273
|
+
)
|
|
274
|
+
_kill_exec_process_group(process)
|
|
275
|
+
_reap_exec_process(process, cleanup_deadline)
|
|
276
|
+
returncode = 130
|
|
277
|
+
break
|
|
278
|
+
remaining = (
|
|
279
|
+
None
|
|
280
|
+
if deadline is None
|
|
281
|
+
else deadline - time.monotonic()
|
|
282
|
+
)
|
|
283
|
+
if remaining is not None and remaining <= 0:
|
|
284
|
+
timed_out = True
|
|
285
|
+
cleanup_deadline = (
|
|
286
|
+
time.monotonic() + _EXEC_FORCE_CLEANUP_SECONDS
|
|
287
|
+
)
|
|
288
|
+
_kill_exec_process_group(process)
|
|
289
|
+
_reap_exec_process(process, cleanup_deadline)
|
|
290
|
+
returncode = 124
|
|
291
|
+
break
|
|
292
|
+
wait_timeout = remaining
|
|
293
|
+
if cancellation_requested is not None:
|
|
294
|
+
wait_timeout = (
|
|
295
|
+
0.1
|
|
296
|
+
if remaining is None
|
|
297
|
+
else min(0.1, remaining)
|
|
298
|
+
)
|
|
299
|
+
try:
|
|
300
|
+
returncode = process.wait(timeout=wait_timeout)
|
|
301
|
+
break
|
|
302
|
+
except subprocess.TimeoutExpired:
|
|
303
|
+
if cancellation_requested is not None:
|
|
304
|
+
continue
|
|
305
|
+
timed_out = True
|
|
306
|
+
cleanup_deadline = (
|
|
307
|
+
time.monotonic() + _EXEC_FORCE_CLEANUP_SECONDS
|
|
308
|
+
)
|
|
309
|
+
_kill_exec_process_group(process)
|
|
310
|
+
_reap_exec_process(process, cleanup_deadline)
|
|
311
|
+
returncode = 124
|
|
312
|
+
break
|
|
313
|
+
|
|
314
|
+
drain_deadline = time.monotonic() + EXEC_PIPE_DRAIN_GRACE_SECONDS
|
|
315
|
+
if deadline is not None:
|
|
316
|
+
drain_deadline = min(drain_deadline, deadline)
|
|
317
|
+
drained = all(event.is_set() for event in drain_done)
|
|
318
|
+
if not drained and drain_deadline > time.monotonic():
|
|
319
|
+
drained = _wait_for_exec_drainers(drain_done, drain_deadline)
|
|
320
|
+
if not drained:
|
|
321
|
+
output_drain_truncated = True
|
|
322
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
323
|
+
timed_out = True
|
|
324
|
+
returncode = 124
|
|
325
|
+
cleanup_deadline = cleanup_deadline or (
|
|
326
|
+
time.monotonic() + _EXEC_FORCE_CLEANUP_SECONDS
|
|
327
|
+
)
|
|
328
|
+
_kill_exec_process_group(process)
|
|
329
|
+
_reap_exec_process(process, cleanup_deadline)
|
|
330
|
+
_close_exec_pipes(process)
|
|
331
|
+
_wait_for_exec_drainers(
|
|
332
|
+
drain_done,
|
|
333
|
+
cleanup_deadline,
|
|
334
|
+
)
|
|
335
|
+
except BaseException:
|
|
336
|
+
cleanup_deadline = cleanup_deadline or (
|
|
337
|
+
time.monotonic() + _EXEC_FORCE_CLEANUP_SECONDS
|
|
338
|
+
)
|
|
339
|
+
_kill_exec_process_group(process)
|
|
340
|
+
_reap_exec_process(process, cleanup_deadline)
|
|
341
|
+
_close_exec_pipes(process)
|
|
342
|
+
raise
|
|
343
|
+
finally:
|
|
344
|
+
join_deadline = cleanup_deadline or (
|
|
345
|
+
time.monotonic() + _EXEC_THREAD_JOIN_SECONDS
|
|
346
|
+
)
|
|
347
|
+
for drainer in drainers:
|
|
348
|
+
drainer.join(max(0.0, join_deadline - time.monotonic()))
|
|
349
|
+
|
|
350
|
+
with stdout_lock:
|
|
351
|
+
stdout_value = bytes(stdout).decode("utf-8", errors="replace")
|
|
352
|
+
stdout_was_truncated = stdout_truncated[0]
|
|
353
|
+
with stderr_lock:
|
|
354
|
+
stderr_value = bytes(stderr).decode("utf-8", errors="replace")
|
|
355
|
+
stderr_was_truncated = stderr_truncated[0]
|
|
356
|
+
return (
|
|
357
|
+
returncode,
|
|
358
|
+
stdout_value,
|
|
359
|
+
stderr_value,
|
|
360
|
+
stdout_was_truncated,
|
|
361
|
+
stderr_was_truncated,
|
|
362
|
+
timed_out,
|
|
363
|
+
cancelled,
|
|
364
|
+
output_drain_truncated,
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
class RpcDispatcher:
|
|
369
|
+
def __init__(self, inventory: Inventory):
|
|
370
|
+
self.inventory = inventory
|
|
371
|
+
self._activation_cache: dict[
|
|
372
|
+
tuple[
|
|
373
|
+
str,
|
|
374
|
+
str | None,
|
|
375
|
+
tuple[tuple[str, str], ...],
|
|
376
|
+
tuple[tuple[str, str], ...],
|
|
377
|
+
],
|
|
378
|
+
tuple[tuple[tuple[str, int | None, int | None], ...] | None, dict[str, str]],
|
|
379
|
+
] = {}
|
|
380
|
+
self._activation_cache_lock = threading.Lock()
|
|
381
|
+
|
|
382
|
+
def dispatch(
|
|
383
|
+
self,
|
|
384
|
+
method: str,
|
|
385
|
+
params: dict[str, Any],
|
|
386
|
+
cancellation_requested: Callable[[], bool] | None = None,
|
|
387
|
+
) -> Any:
|
|
388
|
+
if method == "exec":
|
|
389
|
+
return self.exec_command(params, cancellation_requested)
|
|
390
|
+
if method == "workspace.git_status":
|
|
391
|
+
return self.workspace_git_status(params, cancellation_requested)
|
|
392
|
+
if method == "workspace.git_diff":
|
|
393
|
+
return self.workspace_git_diff(params, cancellation_requested)
|
|
394
|
+
handlers = {
|
|
395
|
+
"ping": self.ping,
|
|
396
|
+
"node.info": self.node_info,
|
|
397
|
+
"npu.list": self.npu_list,
|
|
398
|
+
"model.register": self.model_register,
|
|
399
|
+
"model.discover": self.model_discover,
|
|
400
|
+
"model.list": self.model_list,
|
|
401
|
+
"model.inspect": self.model_inspect,
|
|
402
|
+
"model.update": self.model_update,
|
|
403
|
+
"model.delete": self.model_delete,
|
|
404
|
+
"model.restore": self.model_restore,
|
|
405
|
+
"model.refresh": self.model_refresh,
|
|
406
|
+
"model.validate": self.model_validate,
|
|
407
|
+
"model.audit": self.model_audit,
|
|
408
|
+
"model.verify": self.model_verify,
|
|
409
|
+
"model.unregister": self.model_unregister,
|
|
410
|
+
"replica.list": self.replica_list,
|
|
411
|
+
"replica.add": self.replica_add,
|
|
412
|
+
"replica.remove": self.replica_remove,
|
|
413
|
+
"model.export": self.model_export,
|
|
414
|
+
"model.import": self.model_import,
|
|
415
|
+
"model.rebind": self.model_rebind,
|
|
416
|
+
"exec": self.exec_command,
|
|
417
|
+
"workspace.stat": self.workspace_stat,
|
|
418
|
+
"workspace.read": self.workspace_read,
|
|
419
|
+
"workspace.list": self.workspace_list,
|
|
420
|
+
"workspace.glob": self.workspace_glob,
|
|
421
|
+
"workspace.search": self.workspace_search,
|
|
422
|
+
"workspace.write": self.workspace_write,
|
|
423
|
+
"workspace.delete": self.workspace_delete,
|
|
424
|
+
}
|
|
425
|
+
handler = handlers.get(method)
|
|
426
|
+
if handler is None:
|
|
427
|
+
raise KeyError(f"unknown RPC method: {method}")
|
|
428
|
+
return handler(params)
|
|
429
|
+
|
|
430
|
+
def ping(self, _params: dict[str, Any]) -> dict[str, Any]:
|
|
431
|
+
return {"ok": True, "version": __version__, "node_id": self.inventory.node_id}
|
|
432
|
+
|
|
433
|
+
@staticmethod
|
|
434
|
+
def _runtime(params: dict[str, Any]) -> dict[str, str]:
|
|
435
|
+
runtime = params.get("_runtime") or {"type": "host"}
|
|
436
|
+
if not isinstance(runtime, dict):
|
|
437
|
+
raise ValueError("runtime must be an object")
|
|
438
|
+
runtime_type = str(runtime.get("type", "host"))
|
|
439
|
+
if runtime_type == "host":
|
|
440
|
+
return {"type": "host"}
|
|
441
|
+
if runtime_type != "docker":
|
|
442
|
+
raise ValueError(f"unsupported runtime: {runtime_type}")
|
|
443
|
+
container = runtime.get("container")
|
|
444
|
+
if not isinstance(container, str) or not container:
|
|
445
|
+
raise ValueError("docker runtime requires a container name")
|
|
446
|
+
return {"type": "docker", "container": container}
|
|
447
|
+
|
|
448
|
+
@staticmethod
|
|
449
|
+
def _environments(params: dict[str, Any]) -> list[tuple[str, str]]:
|
|
450
|
+
values = params.get("_environments") or []
|
|
451
|
+
if not isinstance(values, list) or not all(isinstance(item, str) for item in values):
|
|
452
|
+
raise ValueError("environments must be a string array")
|
|
453
|
+
return [parse_environment_spec(item) for item in values]
|
|
454
|
+
|
|
455
|
+
@staticmethod
|
|
456
|
+
def _node_environment(params: dict[str, Any]) -> dict[str, str]:
|
|
457
|
+
values = params.get("_node_env") or {}
|
|
458
|
+
if not isinstance(values, dict):
|
|
459
|
+
raise ValueError("node environment must be an object")
|
|
460
|
+
environment: dict[str, str] = {}
|
|
461
|
+
for key, value in values.items():
|
|
462
|
+
if not isinstance(key, str) or not isinstance(value, str):
|
|
463
|
+
raise ValueError(
|
|
464
|
+
"node environment names and values must be strings"
|
|
465
|
+
)
|
|
466
|
+
parsed_key, parsed_value = parse_variable_assignment(
|
|
467
|
+
f"{key}={value}"
|
|
468
|
+
)
|
|
469
|
+
environment[parsed_key] = parsed_value
|
|
470
|
+
return environment
|
|
471
|
+
|
|
472
|
+
@staticmethod
|
|
473
|
+
def _wrap_environment_command(
|
|
474
|
+
command: list[str],
|
|
475
|
+
environments: list[tuple[str, str]],
|
|
476
|
+
) -> list[str]:
|
|
477
|
+
"""Wrap an argv from the innermost environment to the outermost."""
|
|
478
|
+
wrapped = list(command)
|
|
479
|
+
for kind, value in reversed(environments):
|
|
480
|
+
if kind == "uv":
|
|
481
|
+
wrapped = ["uv", "run", "--project", value, "--", *wrapped]
|
|
482
|
+
elif kind == "venv":
|
|
483
|
+
activate = f"{value.rstrip('/')}/bin/activate"
|
|
484
|
+
script = f". {shlex.quote(activate)} && exec \"$@\""
|
|
485
|
+
wrapped = ["sh", "-c", script, "msdev-venv", *wrapped]
|
|
486
|
+
elif kind == "conda":
|
|
487
|
+
wrapped = wrap_conda_command(wrapped, value)
|
|
488
|
+
return wrapped
|
|
489
|
+
|
|
490
|
+
@staticmethod
|
|
491
|
+
def _wrap_activation_probe(
|
|
492
|
+
command: list[str],
|
|
493
|
+
environments: list[tuple[str, str]],
|
|
494
|
+
) -> list[str]:
|
|
495
|
+
wrapped = list(command)
|
|
496
|
+
for kind, value in reversed(environments):
|
|
497
|
+
if kind == "uv":
|
|
498
|
+
wrapped = ["uv", "run", "--project", value, "--", *wrapped]
|
|
499
|
+
elif kind == "venv":
|
|
500
|
+
activate = f"{value.rstrip('/')}/bin/activate"
|
|
501
|
+
script = f". {shlex.quote(activate)} && exec \"$@\""
|
|
502
|
+
wrapped = ["sh", "-c", script, "msdev-venv", *wrapped]
|
|
503
|
+
elif kind == "conda":
|
|
504
|
+
wrapped = wrap_conda_activation_command(wrapped, value)
|
|
505
|
+
return wrapped
|
|
506
|
+
|
|
507
|
+
@staticmethod
|
|
508
|
+
def _activation_fingerprint(
|
|
509
|
+
runtime: dict[str, str],
|
|
510
|
+
environments: list[tuple[str, str]],
|
|
511
|
+
activated: dict[str, str],
|
|
512
|
+
) -> tuple[tuple[str, int | None, int | None], ...] | None:
|
|
513
|
+
if runtime["type"] != "host":
|
|
514
|
+
return None
|
|
515
|
+
watched: set[Path] = set()
|
|
516
|
+
for kind, value in environments:
|
|
517
|
+
if kind == "conda":
|
|
518
|
+
prefix = (
|
|
519
|
+
value
|
|
520
|
+
if "/" in value
|
|
521
|
+
else activated.get("CONDA_PREFIX", "")
|
|
522
|
+
)
|
|
523
|
+
if prefix:
|
|
524
|
+
root = Path(prefix).expanduser()
|
|
525
|
+
watched.add(root / "conda-meta" / "history")
|
|
526
|
+
hooks = root / "etc" / "conda" / "activate.d"
|
|
527
|
+
watched.add(hooks)
|
|
528
|
+
if hooks.is_dir():
|
|
529
|
+
watched.update(hooks.iterdir())
|
|
530
|
+
elif kind == "venv":
|
|
531
|
+
root = Path(value).expanduser()
|
|
532
|
+
watched.add(root / "pyvenv.cfg")
|
|
533
|
+
watched.add(root / "bin" / "activate")
|
|
534
|
+
elif kind == "uv":
|
|
535
|
+
root = Path(value).expanduser()
|
|
536
|
+
watched.add(root / "pyproject.toml")
|
|
537
|
+
watched.add(root / "uv.lock")
|
|
538
|
+
watched.add(root / ".venv" / "pyvenv.cfg")
|
|
539
|
+
fingerprint: list[tuple[str, int | None, int | None]] = []
|
|
540
|
+
for path in sorted(watched, key=str):
|
|
541
|
+
try:
|
|
542
|
+
stat = path.stat()
|
|
543
|
+
fingerprint.append((str(path), stat.st_mtime_ns, stat.st_size))
|
|
544
|
+
except FileNotFoundError:
|
|
545
|
+
fingerprint.append((str(path), None, None))
|
|
546
|
+
return tuple(fingerprint)
|
|
547
|
+
|
|
548
|
+
def _load_activation_environment(
|
|
549
|
+
self,
|
|
550
|
+
runtime: dict[str, str],
|
|
551
|
+
environments: list[tuple[str, str]],
|
|
552
|
+
base_environment: dict[str, str],
|
|
553
|
+
) -> dict[str, str]:
|
|
554
|
+
command = self._wrap_activation_probe(["env", "-0"], environments)
|
|
555
|
+
if runtime["type"] == "docker":
|
|
556
|
+
command = ["docker", "exec", runtime["container"], *command]
|
|
557
|
+
probe_environment = os.environ.copy()
|
|
558
|
+
probe_environment.update(base_environment)
|
|
559
|
+
(
|
|
560
|
+
returncode,
|
|
561
|
+
stdout,
|
|
562
|
+
stderr,
|
|
563
|
+
stdout_truncated,
|
|
564
|
+
stderr_truncated,
|
|
565
|
+
timed_out,
|
|
566
|
+
_cancelled,
|
|
567
|
+
output_drain_truncated,
|
|
568
|
+
) = _execute_bounded(
|
|
569
|
+
command,
|
|
570
|
+
cwd=None,
|
|
571
|
+
env=probe_environment,
|
|
572
|
+
timeout=30.0,
|
|
573
|
+
)
|
|
574
|
+
if (
|
|
575
|
+
returncode != 0
|
|
576
|
+
or timed_out
|
|
577
|
+
or stdout_truncated
|
|
578
|
+
or stderr_truncated
|
|
579
|
+
or output_drain_truncated
|
|
580
|
+
):
|
|
581
|
+
detail = stderr.strip() or f"exit code {returncode}"
|
|
582
|
+
raise RuntimeError(
|
|
583
|
+
f"failed to activate execution environment: {detail}"
|
|
584
|
+
)
|
|
585
|
+
activated: dict[str, str] = {}
|
|
586
|
+
for item in stdout.split("\0"):
|
|
587
|
+
key, separator, value = item.partition("=")
|
|
588
|
+
if separator and key and "\0" not in key:
|
|
589
|
+
activated[key] = value
|
|
590
|
+
for transient in ("_", "PWD", "OLDPWD", "SHLVL"):
|
|
591
|
+
activated.pop(transient, None)
|
|
592
|
+
if "PATH" not in activated:
|
|
593
|
+
raise RuntimeError("activated environment did not provide PATH")
|
|
594
|
+
return activated
|
|
595
|
+
|
|
596
|
+
def _activation_environment(
|
|
597
|
+
self,
|
|
598
|
+
runtime: dict[str, str],
|
|
599
|
+
environments: list[tuple[str, str]],
|
|
600
|
+
base_environment: dict[str, str] | None = None,
|
|
601
|
+
) -> dict[str, str]:
|
|
602
|
+
if not environments:
|
|
603
|
+
return {}
|
|
604
|
+
base_environment = dict(base_environment or {})
|
|
605
|
+
key = (
|
|
606
|
+
runtime["type"],
|
|
607
|
+
runtime.get("container"),
|
|
608
|
+
tuple(environments),
|
|
609
|
+
tuple(sorted(base_environment.items())),
|
|
610
|
+
)
|
|
611
|
+
with self._activation_cache_lock:
|
|
612
|
+
cached = self._activation_cache.get(key)
|
|
613
|
+
if cached is not None:
|
|
614
|
+
fingerprint, activated = cached
|
|
615
|
+
current = self._activation_fingerprint(
|
|
616
|
+
runtime,
|
|
617
|
+
environments,
|
|
618
|
+
activated,
|
|
619
|
+
)
|
|
620
|
+
if current == fingerprint:
|
|
621
|
+
return dict(activated)
|
|
622
|
+
activated = self._load_activation_environment(
|
|
623
|
+
runtime,
|
|
624
|
+
environments,
|
|
625
|
+
base_environment,
|
|
626
|
+
)
|
|
627
|
+
fingerprint = self._activation_fingerprint(
|
|
628
|
+
runtime,
|
|
629
|
+
environments,
|
|
630
|
+
activated,
|
|
631
|
+
)
|
|
632
|
+
self._activation_cache[key] = (fingerprint, dict(activated))
|
|
633
|
+
return activated
|
|
634
|
+
|
|
635
|
+
@staticmethod
|
|
636
|
+
def _docker_info(container: str) -> dict[str, Any]:
|
|
637
|
+
try:
|
|
638
|
+
completed = subprocess.run(
|
|
639
|
+
["docker", "inspect", container],
|
|
640
|
+
capture_output=True,
|
|
641
|
+
text=True,
|
|
642
|
+
timeout=20,
|
|
643
|
+
check=False,
|
|
644
|
+
)
|
|
645
|
+
except FileNotFoundError as exc:
|
|
646
|
+
raise RuntimeError("docker command not found on node host") from exc
|
|
647
|
+
if completed.returncode != 0:
|
|
648
|
+
raise RuntimeError(
|
|
649
|
+
completed.stderr.strip() or f"docker container not found: {container}"
|
|
650
|
+
)
|
|
651
|
+
values = json.loads(completed.stdout)
|
|
652
|
+
if not values:
|
|
653
|
+
raise RuntimeError(f"docker inspect returned no data for {container}")
|
|
654
|
+
value = values[0]
|
|
655
|
+
state = value.get("State") or {}
|
|
656
|
+
return {
|
|
657
|
+
"type": "docker",
|
|
658
|
+
"container": container,
|
|
659
|
+
"id": str(value.get("Id", ""))[:12],
|
|
660
|
+
"image": (value.get("Config") or {}).get("Image"),
|
|
661
|
+
"status": state.get("Status"),
|
|
662
|
+
"running": bool(state.get("Running")),
|
|
663
|
+
"pid": state.get("Pid"),
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
def node_info(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
667
|
+
usage = shutil.disk_usage(self.inventory.data_dir)
|
|
668
|
+
value = {
|
|
669
|
+
"node_id": self.inventory.node_id,
|
|
670
|
+
"hostname": platform.node(),
|
|
671
|
+
"architecture": platform.machine(),
|
|
672
|
+
"platform": platform.platform(),
|
|
673
|
+
"python": platform.python_version(),
|
|
674
|
+
"daemon_version": __version__,
|
|
675
|
+
"uid": os.getuid(),
|
|
676
|
+
"data_dir": str(self.inventory.data_dir),
|
|
677
|
+
"disk": {
|
|
678
|
+
"total": usage.total,
|
|
679
|
+
"used": usage.used,
|
|
680
|
+
"free": usage.free,
|
|
681
|
+
},
|
|
682
|
+
}
|
|
683
|
+
runtime = self._runtime(params)
|
|
684
|
+
environments = self._environments(params)
|
|
685
|
+
value["runtime"] = (
|
|
686
|
+
self._docker_info(runtime["container"])
|
|
687
|
+
if runtime["type"] == "docker"
|
|
688
|
+
else runtime
|
|
689
|
+
)
|
|
690
|
+
value["environments"] = [
|
|
691
|
+
{"type": kind, "value": item}
|
|
692
|
+
for kind, item in environments
|
|
693
|
+
]
|
|
694
|
+
return value
|
|
695
|
+
|
|
696
|
+
def npu_list(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
697
|
+
runtime = self._runtime(params)
|
|
698
|
+
environments = self._environments(params)
|
|
699
|
+
node_environment = self._node_environment(params)
|
|
700
|
+
command = self._wrap_environment_command(
|
|
701
|
+
["npu-smi", "info"],
|
|
702
|
+
environments,
|
|
703
|
+
)
|
|
704
|
+
if runtime["type"] == "docker":
|
|
705
|
+
command = ["docker", "exec", runtime["container"], *command]
|
|
706
|
+
run_environment = os.environ.copy()
|
|
707
|
+
if runtime["type"] == "host":
|
|
708
|
+
run_environment.update(node_environment)
|
|
709
|
+
try:
|
|
710
|
+
completed = subprocess.run(
|
|
711
|
+
command,
|
|
712
|
+
capture_output=True,
|
|
713
|
+
text=True,
|
|
714
|
+
timeout=20,
|
|
715
|
+
check=False,
|
|
716
|
+
env=run_environment,
|
|
717
|
+
)
|
|
718
|
+
except FileNotFoundError:
|
|
719
|
+
return {
|
|
720
|
+
"available": False,
|
|
721
|
+
"reason": f"{command[0]} not found",
|
|
722
|
+
"output": "",
|
|
723
|
+
"runtime": runtime,
|
|
724
|
+
"environments": environments,
|
|
725
|
+
}
|
|
726
|
+
except subprocess.TimeoutExpired:
|
|
727
|
+
return {
|
|
728
|
+
"available": False,
|
|
729
|
+
"reason": "npu-smi timed out",
|
|
730
|
+
"output": "",
|
|
731
|
+
"runtime": runtime,
|
|
732
|
+
"environments": environments,
|
|
733
|
+
}
|
|
734
|
+
output = (completed.stdout or "") + (completed.stderr or "")
|
|
735
|
+
return {
|
|
736
|
+
"available": completed.returncode == 0,
|
|
737
|
+
"returncode": completed.returncode,
|
|
738
|
+
"output": output,
|
|
739
|
+
"runtime": runtime,
|
|
740
|
+
"environments": environments,
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
def model_register(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
744
|
+
return self.inventory.register_model(
|
|
745
|
+
ref=str(params["ref"]),
|
|
746
|
+
path=str(params["path"]),
|
|
747
|
+
scope=str(params.get("scope", "user")),
|
|
748
|
+
format_name=params.get("format"),
|
|
749
|
+
full_checksum=bool(params.get("full_checksum", False)),
|
|
750
|
+
)
|
|
751
|
+
|
|
752
|
+
def model_discover(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
753
|
+
return self.inventory.discover_models(
|
|
754
|
+
root=str(params["root"]),
|
|
755
|
+
max_depth=int(params.get("max_depth", 5)),
|
|
756
|
+
register=bool(params.get("register", False)),
|
|
757
|
+
namespace=str(params.get("namespace", "discovered")),
|
|
758
|
+
)
|
|
759
|
+
|
|
760
|
+
def model_list(self, params: dict[str, Any]) -> list[dict[str, Any]]:
|
|
761
|
+
return self.inventory.list_models(
|
|
762
|
+
format_name=params.get("format"),
|
|
763
|
+
model_type=params.get("model_type"),
|
|
764
|
+
state=params.get("state"),
|
|
765
|
+
tag=params.get("tag"),
|
|
766
|
+
name=params.get("name"),
|
|
767
|
+
include_deleted=bool(params.get("include_deleted", False)),
|
|
768
|
+
)
|
|
769
|
+
|
|
770
|
+
def model_inspect(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
771
|
+
return self.inventory.inspect_model(str(params["ref"]))
|
|
772
|
+
|
|
773
|
+
def model_update(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
774
|
+
return self.inventory.update_model(
|
|
775
|
+
str(params["ref"]),
|
|
776
|
+
add_aliases=params.get("add_aliases") or [],
|
|
777
|
+
remove_aliases=params.get("remove_aliases") or [],
|
|
778
|
+
add_tags=params.get("add_tags") or [],
|
|
779
|
+
remove_tags=params.get("remove_tags") or [],
|
|
780
|
+
description=params.get("description"),
|
|
781
|
+
clear_description=bool(params.get("clear_description", False)),
|
|
782
|
+
)
|
|
783
|
+
|
|
784
|
+
def model_delete(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
785
|
+
return self.inventory.delete_model(str(params["ref"]))
|
|
786
|
+
|
|
787
|
+
def model_restore(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
788
|
+
return self.inventory.restore_model(str(params["ref"]))
|
|
789
|
+
|
|
790
|
+
def model_refresh(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
791
|
+
return self.inventory.refresh_model(
|
|
792
|
+
str(params["ref"]),
|
|
793
|
+
path=params.get("path"),
|
|
794
|
+
full_checksum=bool(params.get("full_checksum", False)),
|
|
795
|
+
)
|
|
796
|
+
|
|
797
|
+
def model_validate(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
798
|
+
return self.inventory.validate_model(
|
|
799
|
+
str(params["ref"]),
|
|
800
|
+
path=params.get("path"),
|
|
801
|
+
)
|
|
802
|
+
|
|
803
|
+
def model_audit(self, params: dict[str, Any]) -> list[dict[str, Any]]:
|
|
804
|
+
return self.inventory.audit_records(
|
|
805
|
+
ref=params.get("ref"),
|
|
806
|
+
limit=int(params.get("limit", 100)),
|
|
807
|
+
)
|
|
808
|
+
|
|
809
|
+
def model_verify(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
810
|
+
return self.inventory.verify_model(
|
|
811
|
+
str(params["ref"]),
|
|
812
|
+
full_checksum=bool(params.get("full_checksum", False)),
|
|
813
|
+
)
|
|
814
|
+
|
|
815
|
+
def model_unregister(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
816
|
+
return self.inventory.unregister_model(
|
|
817
|
+
str(params["ref"]),
|
|
818
|
+
path=params.get("path"),
|
|
819
|
+
)
|
|
820
|
+
|
|
821
|
+
def replica_list(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
822
|
+
return self.inventory.list_replicas(str(params["ref"]))
|
|
823
|
+
|
|
824
|
+
def replica_add(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
825
|
+
model = self.inventory.inspect_model(str(params["ref"]))
|
|
826
|
+
return self.inventory.register_model(
|
|
827
|
+
ref=model["ref"],
|
|
828
|
+
path=str(params["path"]),
|
|
829
|
+
format_name=model.get("format"),
|
|
830
|
+
full_checksum=bool(params.get("full_checksum", False)),
|
|
831
|
+
)
|
|
832
|
+
|
|
833
|
+
def replica_remove(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
834
|
+
return self.inventory.remove_replica(
|
|
835
|
+
str(params["ref"]),
|
|
836
|
+
str(params["path"]),
|
|
837
|
+
)
|
|
838
|
+
|
|
839
|
+
def model_export(self, _params: dict[str, Any]) -> dict[str, Any]:
|
|
840
|
+
return self.inventory.export_records()
|
|
841
|
+
|
|
842
|
+
def model_import(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
843
|
+
root_maps = [tuple(item) for item in params.get("root_maps", [])]
|
|
844
|
+
return self.inventory.import_records(params["payload"], root_maps=root_maps)
|
|
845
|
+
|
|
846
|
+
def model_rebind(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
847
|
+
return self.inventory.register_model(
|
|
848
|
+
ref=str(params["ref"]),
|
|
849
|
+
path=str(params["path"]),
|
|
850
|
+
scope=str(params.get("scope", "user")),
|
|
851
|
+
full_checksum=bool(params.get("full_checksum", False)),
|
|
852
|
+
)
|
|
853
|
+
|
|
854
|
+
def workspace_stat(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
855
|
+
return stat_path(params.get("root"), params.get("path", "."))
|
|
856
|
+
|
|
857
|
+
def workspace_read(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
858
|
+
return read_file(
|
|
859
|
+
params.get("root"),
|
|
860
|
+
params.get("path"),
|
|
861
|
+
params.get("max_bytes", 1024 * 1024),
|
|
862
|
+
)
|
|
863
|
+
|
|
864
|
+
def workspace_list(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
865
|
+
return list_directory(params.get("root"), params.get("path", "."))
|
|
866
|
+
|
|
867
|
+
def workspace_glob(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
868
|
+
return glob_paths(
|
|
869
|
+
params.get("root"),
|
|
870
|
+
params.get("pattern"),
|
|
871
|
+
params.get("max_results", 1000),
|
|
872
|
+
)
|
|
873
|
+
|
|
874
|
+
def workspace_search(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
875
|
+
return search(
|
|
876
|
+
params.get("root"),
|
|
877
|
+
params.get("pattern"),
|
|
878
|
+
params.get("paths"),
|
|
879
|
+
params.get("glob"),
|
|
880
|
+
params.get("max_results", 1000),
|
|
881
|
+
)
|
|
882
|
+
|
|
883
|
+
def workspace_write(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
884
|
+
return atomic_write(
|
|
885
|
+
params.get("root"),
|
|
886
|
+
params.get("path"),
|
|
887
|
+
params.get("content_base64"),
|
|
888
|
+
params.get("expected_sha256"),
|
|
889
|
+
)
|
|
890
|
+
|
|
891
|
+
def workspace_delete(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
892
|
+
return delete_file(params.get("root"), params.get("path"))
|
|
893
|
+
|
|
894
|
+
def _workspace_git(
|
|
895
|
+
self,
|
|
896
|
+
params: dict[str, Any],
|
|
897
|
+
command: list[str],
|
|
898
|
+
cancellation_requested: Callable[[], bool] | None = None,
|
|
899
|
+
) -> dict[str, Any]:
|
|
900
|
+
root = workspace_root(params.get("root"))
|
|
901
|
+
runtime = self._runtime(params)
|
|
902
|
+
execution_root = params.get("execution_root")
|
|
903
|
+
if not isinstance(execution_root, str) or not execution_root:
|
|
904
|
+
execution_root = str(root)
|
|
905
|
+
if runtime["type"] == "host":
|
|
906
|
+
candidate = Path(execution_root).expanduser()
|
|
907
|
+
if candidate.is_absolute():
|
|
908
|
+
execution_path = candidate.resolve(strict=True)
|
|
909
|
+
try:
|
|
910
|
+
execution_path.relative_to(root)
|
|
911
|
+
except ValueError as exc:
|
|
912
|
+
raise PermissionError(
|
|
913
|
+
"host workspace execution root must stay under workspace root"
|
|
914
|
+
) from exc
|
|
915
|
+
else:
|
|
916
|
+
_, execution_path = resolve_path(str(root), execution_root)
|
|
917
|
+
if not execution_path.is_dir():
|
|
918
|
+
raise NotADirectoryError(
|
|
919
|
+
f"workspace execution root is not a directory: {execution_path}"
|
|
920
|
+
)
|
|
921
|
+
execution_root = str(execution_path)
|
|
922
|
+
forwarded = dict(params)
|
|
923
|
+
forwarded["cwd"] = execution_root
|
|
924
|
+
forwarded["command"] = command
|
|
925
|
+
forwarded["env"] = {}
|
|
926
|
+
forwarded["timeout_seconds"] = min(
|
|
927
|
+
DEFAULT_EXEC_TIMEOUT_SECONDS,
|
|
928
|
+
60.0,
|
|
929
|
+
)
|
|
930
|
+
result = self.exec_command(forwarded, cancellation_requested)
|
|
931
|
+
result["workspace_root"] = str(root)
|
|
932
|
+
result["execution_root"] = execution_root
|
|
933
|
+
return result
|
|
934
|
+
|
|
935
|
+
def workspace_git_status(
|
|
936
|
+
self,
|
|
937
|
+
params: dict[str, Any],
|
|
938
|
+
cancellation_requested: Callable[[], bool] | None = None,
|
|
939
|
+
) -> dict[str, Any]:
|
|
940
|
+
return self._workspace_git(
|
|
941
|
+
params,
|
|
942
|
+
["git", "status", "--short", "--branch"],
|
|
943
|
+
cancellation_requested,
|
|
944
|
+
)
|
|
945
|
+
|
|
946
|
+
def workspace_git_diff(
|
|
947
|
+
self,
|
|
948
|
+
params: dict[str, Any],
|
|
949
|
+
cancellation_requested: Callable[[], bool] | None = None,
|
|
950
|
+
) -> dict[str, Any]:
|
|
951
|
+
argv = params.get("argv") or []
|
|
952
|
+
if not isinstance(argv, list) or not all(
|
|
953
|
+
isinstance(item, str) for item in argv
|
|
954
|
+
):
|
|
955
|
+
raise ValueError("workspace Git diff argv must be a string array")
|
|
956
|
+
return self._workspace_git(
|
|
957
|
+
params,
|
|
958
|
+
["git", "diff", *argv],
|
|
959
|
+
cancellation_requested,
|
|
960
|
+
)
|
|
961
|
+
|
|
962
|
+
def exec_command(
|
|
963
|
+
self,
|
|
964
|
+
params: dict[str, Any],
|
|
965
|
+
cancellation_requested: Callable[[], bool] | None = None,
|
|
966
|
+
output_callback: Callable[[str, bytes], None] | None = None,
|
|
967
|
+
retain_output: bool = True,
|
|
968
|
+
) -> dict[str, Any]:
|
|
969
|
+
command = params.get("command")
|
|
970
|
+
if not isinstance(command, list) or not command or not all(isinstance(x, str) for x in command):
|
|
971
|
+
raise ValueError("exec command must be a non-empty string array")
|
|
972
|
+
runtime = self._runtime(params)
|
|
973
|
+
environments = self._environments(params)
|
|
974
|
+
node_environment = self._node_environment(params)
|
|
975
|
+
cwd = params.get("cwd")
|
|
976
|
+
if cwd is not None and runtime["type"] == "host":
|
|
977
|
+
cwd = str(Path(str(cwd)).expanduser().resolve(strict=True))
|
|
978
|
+
additions = params.get("env") or {}
|
|
979
|
+
if not isinstance(additions, dict):
|
|
980
|
+
raise ValueError("exec env must be an object")
|
|
981
|
+
additions = {str(key): str(value) for key, value in additions.items()}
|
|
982
|
+
if "timeout" in params:
|
|
983
|
+
raise ValueError("timeout is unsupported; use timeout_seconds")
|
|
984
|
+
timeout_seconds = validate_exec_timeout_seconds(params.get(
|
|
985
|
+
"timeout_seconds",
|
|
986
|
+
DEFAULT_EXEC_TIMEOUT_SECONDS,
|
|
987
|
+
))
|
|
988
|
+
timeout = None if timeout_seconds == -1 else timeout_seconds
|
|
989
|
+
run_command = list(command)
|
|
990
|
+
run_cwd = cwd
|
|
991
|
+
run_env = os.environ.copy()
|
|
992
|
+
if runtime["type"] == "docker":
|
|
993
|
+
docker_info = self._docker_info(runtime["container"])
|
|
994
|
+
if not docker_info["running"]:
|
|
995
|
+
raise RuntimeError(
|
|
996
|
+
f"docker container is not running: {runtime['container']}"
|
|
997
|
+
)
|
|
998
|
+
activated = self._activation_environment(
|
|
999
|
+
runtime,
|
|
1000
|
+
environments,
|
|
1001
|
+
)
|
|
1002
|
+
run_command = ["docker", "exec"]
|
|
1003
|
+
if cwd:
|
|
1004
|
+
run_command += ["--workdir", str(cwd)]
|
|
1005
|
+
for key, value in activated.items():
|
|
1006
|
+
run_command += ["--env", f"{key}={value}"]
|
|
1007
|
+
for key, value in additions.items():
|
|
1008
|
+
run_command += ["--env", f"{key}={value}"]
|
|
1009
|
+
run_command += [
|
|
1010
|
+
runtime["container"],
|
|
1011
|
+
*command,
|
|
1012
|
+
]
|
|
1013
|
+
run_cwd = None
|
|
1014
|
+
else:
|
|
1015
|
+
run_env.update(node_environment)
|
|
1016
|
+
run_env.update(
|
|
1017
|
+
self._activation_environment(
|
|
1018
|
+
runtime,
|
|
1019
|
+
environments,
|
|
1020
|
+
node_environment,
|
|
1021
|
+
)
|
|
1022
|
+
)
|
|
1023
|
+
run_env.update(additions)
|
|
1024
|
+
(
|
|
1025
|
+
returncode,
|
|
1026
|
+
stdout,
|
|
1027
|
+
stderr,
|
|
1028
|
+
stdout_truncated,
|
|
1029
|
+
stderr_truncated,
|
|
1030
|
+
timed_out,
|
|
1031
|
+
cancelled,
|
|
1032
|
+
output_drain_truncated,
|
|
1033
|
+
) = _execute_bounded(
|
|
1034
|
+
run_command,
|
|
1035
|
+
cwd=run_cwd,
|
|
1036
|
+
env=run_env,
|
|
1037
|
+
timeout=timeout,
|
|
1038
|
+
cancellation_requested=cancellation_requested,
|
|
1039
|
+
output_callback=output_callback,
|
|
1040
|
+
retain_output=retain_output,
|
|
1041
|
+
)
|
|
1042
|
+
result: dict[str, Any] = {
|
|
1043
|
+
"returncode": returncode,
|
|
1044
|
+
"output_drain_truncated": output_drain_truncated,
|
|
1045
|
+
"cwd": cwd,
|
|
1046
|
+
"runtime": runtime,
|
|
1047
|
+
"environments": environments,
|
|
1048
|
+
}
|
|
1049
|
+
if retain_output:
|
|
1050
|
+
result.update(
|
|
1051
|
+
{
|
|
1052
|
+
"stdout": stdout,
|
|
1053
|
+
"stderr": stderr,
|
|
1054
|
+
"stdout_truncated": stdout_truncated,
|
|
1055
|
+
"stderr_truncated": stderr_truncated,
|
|
1056
|
+
}
|
|
1057
|
+
)
|
|
1058
|
+
if timed_out:
|
|
1059
|
+
result["timed_out"] = True
|
|
1060
|
+
if cancelled:
|
|
1061
|
+
result["cancelled"] = True
|
|
1062
|
+
return result
|
|
1063
|
+
|
|
1064
|
+
|
|
1065
|
+
class RpcHandler(socketserver.StreamRequestHandler):
|
|
1066
|
+
def _connection_closed(self) -> bool:
|
|
1067
|
+
try:
|
|
1068
|
+
readable, _, _ = select.select([self.connection], [], [], 0)
|
|
1069
|
+
if not readable:
|
|
1070
|
+
return False
|
|
1071
|
+
return (
|
|
1072
|
+
self.connection.recv(
|
|
1073
|
+
1,
|
|
1074
|
+
socket.MSG_PEEK | socket.MSG_DONTWAIT,
|
|
1075
|
+
)
|
|
1076
|
+
== b""
|
|
1077
|
+
)
|
|
1078
|
+
except BlockingIOError:
|
|
1079
|
+
return False
|
|
1080
|
+
except OSError:
|
|
1081
|
+
return True
|
|
1082
|
+
|
|
1083
|
+
def _handle_exec_stream(
|
|
1084
|
+
self,
|
|
1085
|
+
request_id: Any,
|
|
1086
|
+
params: dict[str, Any],
|
|
1087
|
+
) -> None:
|
|
1088
|
+
write_lock = threading.Lock()
|
|
1089
|
+
writer_failed = threading.Event()
|
|
1090
|
+
sequence = [0]
|
|
1091
|
+
|
|
1092
|
+
def emit(event: dict[str, Any]) -> None:
|
|
1093
|
+
with write_lock:
|
|
1094
|
+
if writer_failed.is_set():
|
|
1095
|
+
return
|
|
1096
|
+
sequence[0] += 1
|
|
1097
|
+
value = {
|
|
1098
|
+
"id": request_id,
|
|
1099
|
+
"seq": sequence[0],
|
|
1100
|
+
**event,
|
|
1101
|
+
}
|
|
1102
|
+
raw = _encode_json_response(value, MAX_RPC_RESPONSE_BYTES)
|
|
1103
|
+
if raw is None:
|
|
1104
|
+
writer_failed.set()
|
|
1105
|
+
return
|
|
1106
|
+
try:
|
|
1107
|
+
self.wfile.write(raw)
|
|
1108
|
+
self.wfile.flush()
|
|
1109
|
+
except (BrokenPipeError, ConnectionResetError, OSError):
|
|
1110
|
+
writer_failed.set()
|
|
1111
|
+
|
|
1112
|
+
def output_callback(stream_name: str, chunk: bytes) -> None:
|
|
1113
|
+
emit(
|
|
1114
|
+
{
|
|
1115
|
+
"event": stream_name,
|
|
1116
|
+
"data_base64": base64.b64encode(chunk).decode("ascii"),
|
|
1117
|
+
}
|
|
1118
|
+
)
|
|
1119
|
+
|
|
1120
|
+
def cancellation_requested() -> bool:
|
|
1121
|
+
return writer_failed.is_set() or self._connection_closed()
|
|
1122
|
+
|
|
1123
|
+
try:
|
|
1124
|
+
result = self.server.dispatcher.exec_command( # type: ignore[attr-defined]
|
|
1125
|
+
params,
|
|
1126
|
+
cancellation_requested,
|
|
1127
|
+
output_callback,
|
|
1128
|
+
False,
|
|
1129
|
+
)
|
|
1130
|
+
emit({"event": "result", "result": result})
|
|
1131
|
+
except Exception as exc:
|
|
1132
|
+
emit(
|
|
1133
|
+
{
|
|
1134
|
+
"event": "error",
|
|
1135
|
+
"error": {
|
|
1136
|
+
"type": type(exc).__name__,
|
|
1137
|
+
"message": _bounded_utf8(exc, MAX_RPC_ERROR_BYTES),
|
|
1138
|
+
},
|
|
1139
|
+
}
|
|
1140
|
+
)
|
|
1141
|
+
|
|
1142
|
+
def handle(self) -> None:
|
|
1143
|
+
raw = self.rfile.readline(MAX_REQUEST_BYTES + 1)
|
|
1144
|
+
if not raw:
|
|
1145
|
+
return
|
|
1146
|
+
if len(raw) > MAX_REQUEST_BYTES:
|
|
1147
|
+
self._write({"id": None, "error": {"type": "RequestTooLarge", "message": "request too large"}})
|
|
1148
|
+
return
|
|
1149
|
+
request_id = None
|
|
1150
|
+
try:
|
|
1151
|
+
request = json.loads(raw)
|
|
1152
|
+
request_id = request.get("id")
|
|
1153
|
+
method = request.get("method")
|
|
1154
|
+
params = request.get("params") or {}
|
|
1155
|
+
if not isinstance(method, str) or not isinstance(params, dict):
|
|
1156
|
+
raise ValueError("request requires string method and object params")
|
|
1157
|
+
if method == "exec.stream":
|
|
1158
|
+
self._handle_exec_stream(request_id, params)
|
|
1159
|
+
return
|
|
1160
|
+
result = self.server.dispatcher.dispatch( # type: ignore[attr-defined]
|
|
1161
|
+
method,
|
|
1162
|
+
params,
|
|
1163
|
+
self._connection_closed,
|
|
1164
|
+
)
|
|
1165
|
+
response = {"id": request_id, "result": result}
|
|
1166
|
+
except Exception as exc: # RPC boundary must return structured errors.
|
|
1167
|
+
response = {
|
|
1168
|
+
"id": request_id,
|
|
1169
|
+
"error": {
|
|
1170
|
+
"type": type(exc).__name__,
|
|
1171
|
+
"message": str(exc),
|
|
1172
|
+
},
|
|
1173
|
+
}
|
|
1174
|
+
if os.environ.get("MSDEVD_DEBUG") == "1":
|
|
1175
|
+
traceback.print_exc(file=sys.stderr)
|
|
1176
|
+
self._write(response)
|
|
1177
|
+
|
|
1178
|
+
def _write(self, value: dict[str, Any]) -> None:
|
|
1179
|
+
prepared = dict(value)
|
|
1180
|
+
error = prepared.get("error")
|
|
1181
|
+
if isinstance(error, dict):
|
|
1182
|
+
bounded_error = dict(error)
|
|
1183
|
+
bounded_error["message"] = _bounded_utf8(
|
|
1184
|
+
bounded_error.get("message", ""),
|
|
1185
|
+
MAX_RPC_ERROR_BYTES,
|
|
1186
|
+
)
|
|
1187
|
+
prepared["error"] = bounded_error
|
|
1188
|
+
|
|
1189
|
+
raw = _encode_json_response(prepared, MAX_RPC_RESPONSE_BYTES)
|
|
1190
|
+
if raw is None:
|
|
1191
|
+
raw = _encode_json_response(
|
|
1192
|
+
{
|
|
1193
|
+
"id": prepared.get("id"),
|
|
1194
|
+
"error": {
|
|
1195
|
+
"type": "ResponseTooLarge",
|
|
1196
|
+
"message": "RPC response exceeded the configured byte limit",
|
|
1197
|
+
},
|
|
1198
|
+
},
|
|
1199
|
+
MAX_RPC_RESPONSE_BYTES,
|
|
1200
|
+
)
|
|
1201
|
+
if raw is None:
|
|
1202
|
+
return
|
|
1203
|
+
try:
|
|
1204
|
+
self.wfile.write(raw)
|
|
1205
|
+
self.wfile.flush()
|
|
1206
|
+
except (BrokenPipeError, ConnectionResetError, OSError):
|
|
1207
|
+
return
|
|
1208
|
+
|
|
1209
|
+
|
|
1210
|
+
class ThreadingUnixServer(socketserver.ThreadingMixIn, socketserver.UnixStreamServer):
|
|
1211
|
+
daemon_threads = True
|
|
1212
|
+
|
|
1213
|
+
def __init__(self, path: str, dispatcher: RpcDispatcher):
|
|
1214
|
+
self.dispatcher = dispatcher
|
|
1215
|
+
super().__init__(path, RpcHandler)
|
|
1216
|
+
|
|
1217
|
+
|
|
1218
|
+
def serve(socket_path: Path, daemon_data_home: Path) -> None:
|
|
1219
|
+
socket_path = socket_path.expanduser()
|
|
1220
|
+
socket_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1221
|
+
if socket_path.exists():
|
|
1222
|
+
try:
|
|
1223
|
+
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as probe:
|
|
1224
|
+
probe.settimeout(0.5)
|
|
1225
|
+
probe.connect(str(socket_path))
|
|
1226
|
+
raise RuntimeError(f"daemon already running at {socket_path}")
|
|
1227
|
+
except (ConnectionRefusedError, FileNotFoundError, socket.timeout):
|
|
1228
|
+
socket_path.unlink(missing_ok=True)
|
|
1229
|
+
|
|
1230
|
+
inventory = Inventory(daemon_data_home)
|
|
1231
|
+
server = ThreadingUnixServer(str(socket_path), RpcDispatcher(inventory))
|
|
1232
|
+
os.chmod(socket_path, 0o600)
|
|
1233
|
+
try:
|
|
1234
|
+
server.serve_forever()
|
|
1235
|
+
finally:
|
|
1236
|
+
server.server_close()
|
|
1237
|
+
socket_path.unlink(missing_ok=True)
|
|
1238
|
+
|
|
1239
|
+
|
|
1240
|
+
def proxy_rpc(socket_path: Path) -> int:
|
|
1241
|
+
raw = sys.stdin.buffer.readline(MAX_REQUEST_BYTES + 1)
|
|
1242
|
+
if not raw:
|
|
1243
|
+
print(json.dumps({"id": None, "error": {"type": "EmptyRequest", "message": "empty request"}}))
|
|
1244
|
+
return 2
|
|
1245
|
+
try:
|
|
1246
|
+
request = json.loads(raw)
|
|
1247
|
+
cancel_on_disconnect = (
|
|
1248
|
+
isinstance(request, dict)
|
|
1249
|
+
and request.get("cancel_on_disconnect") is True
|
|
1250
|
+
)
|
|
1251
|
+
stream_response = (
|
|
1252
|
+
isinstance(request, dict)
|
|
1253
|
+
and request.get("method") == "exec.stream"
|
|
1254
|
+
)
|
|
1255
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
1256
|
+
cancel_on_disconnect = False
|
|
1257
|
+
stream_response = False
|
|
1258
|
+
try:
|
|
1259
|
+
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
|
|
1260
|
+
client.connect(str(socket_path.expanduser()))
|
|
1261
|
+
client.sendall(raw if raw.endswith(b"\n") else raw + b"\n")
|
|
1262
|
+
response = bytearray()
|
|
1263
|
+
while stream_response or not response.endswith(b"\n"):
|
|
1264
|
+
if cancel_on_disconnect:
|
|
1265
|
+
readable, _, _ = select.select(
|
|
1266
|
+
[client, sys.stdin.buffer],
|
|
1267
|
+
[],
|
|
1268
|
+
[],
|
|
1269
|
+
)
|
|
1270
|
+
if sys.stdin.buffer in readable:
|
|
1271
|
+
if os.read(sys.stdin.fileno(), 1) == b"":
|
|
1272
|
+
return 130
|
|
1273
|
+
continue
|
|
1274
|
+
chunk = client.recv(65536)
|
|
1275
|
+
if not chunk:
|
|
1276
|
+
break
|
|
1277
|
+
if stream_response:
|
|
1278
|
+
try:
|
|
1279
|
+
sys.stdout.buffer.write(chunk)
|
|
1280
|
+
sys.stdout.buffer.flush()
|
|
1281
|
+
except (BrokenPipeError, OSError):
|
|
1282
|
+
return 130
|
|
1283
|
+
continue
|
|
1284
|
+
if len(response) + len(chunk) > MAX_RPC_RESPONSE_BYTES:
|
|
1285
|
+
_write_proxy_error(
|
|
1286
|
+
"ResponseTooLarge",
|
|
1287
|
+
"RPC response exceeded the configured byte limit",
|
|
1288
|
+
)
|
|
1289
|
+
return 1
|
|
1290
|
+
response.extend(chunk)
|
|
1291
|
+
except OSError as exc:
|
|
1292
|
+
_write_proxy_error(type(exc).__name__, exc)
|
|
1293
|
+
return 1
|
|
1294
|
+
if not stream_response:
|
|
1295
|
+
sys.stdout.buffer.write(bytes(response))
|
|
1296
|
+
sys.stdout.buffer.flush()
|
|
1297
|
+
return 0
|
|
1298
|
+
|
|
1299
|
+
|
|
1300
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
1301
|
+
parser = argparse.ArgumentParser(prog="msdevd", description="User-level msdev node daemon")
|
|
1302
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
1303
|
+
|
|
1304
|
+
serve_parser = sub.add_parser("serve", help="run the Unix-socket daemon")
|
|
1305
|
+
serve_parser.add_argument("--socket", type=Path, default=default_socket_path())
|
|
1306
|
+
serve_parser.add_argument("--data-dir", type=Path, default=data_home())
|
|
1307
|
+
|
|
1308
|
+
rpc_parser = sub.add_parser("rpc", help="proxy one JSON RPC request over stdin/stdout")
|
|
1309
|
+
rpc_parser.add_argument("--socket", type=Path, default=default_socket_path())
|
|
1310
|
+
return parser
|
|
1311
|
+
|
|
1312
|
+
|
|
1313
|
+
def main() -> None:
|
|
1314
|
+
args = build_parser().parse_args()
|
|
1315
|
+
if args.command == "serve":
|
|
1316
|
+
serve(args.socket, args.data_dir)
|
|
1317
|
+
elif args.command == "rpc":
|
|
1318
|
+
raise SystemExit(proxy_rpc(args.socket))
|
|
1319
|
+
|
|
1320
|
+
|
|
1321
|
+
if __name__ == "__main__":
|
|
1322
|
+
main()
|