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/core/transport.py
ADDED
|
@@ -0,0 +1,1225 @@
|
|
|
1
|
+
"""Local and OpenSSH-backed RPC transports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import queue
|
|
9
|
+
import shlex
|
|
10
|
+
import signal
|
|
11
|
+
import socket
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import tarfile
|
|
15
|
+
import tempfile
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
import uuid
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Callable, Protocol
|
|
21
|
+
|
|
22
|
+
from .config import (
|
|
23
|
+
default_socket_path,
|
|
24
|
+
parse_environment_spec,
|
|
25
|
+
wrap_conda_command,
|
|
26
|
+
)
|
|
27
|
+
from .limits import (
|
|
28
|
+
DEFAULT_EXEC_TIMEOUT_SECONDS,
|
|
29
|
+
EXEC_RPC_COMPLETION_GRACE_SECONDS,
|
|
30
|
+
MAX_RPC_ERROR_BYTES,
|
|
31
|
+
MAX_RPC_RESPONSE_BYTES,
|
|
32
|
+
RPC_PROCESS_CLEANUP_GRACE_SECONDS,
|
|
33
|
+
validate_exec_timeout_seconds,
|
|
34
|
+
)
|
|
35
|
+
from .resources import Environment, Node, parse_variable_assignment
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
SCP_BOOTSTRAP_TIMEOUT_SECONDS = 120
|
|
39
|
+
REMOTE_CLEANUP_TIMEOUT_SECONDS = 10
|
|
40
|
+
LOCAL_RPC_TIMEOUT_SECONDS = 60.0
|
|
41
|
+
SSH_RPC_TIMEOUT_SECONDS = 120.0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _rpc_timeout_seconds(
|
|
45
|
+
method: str,
|
|
46
|
+
params: dict[str, Any] | None,
|
|
47
|
+
default: float,
|
|
48
|
+
) -> float | None:
|
|
49
|
+
if method not in {"exec", "exec.stream"}:
|
|
50
|
+
return default
|
|
51
|
+
timeout = (params or {}).get(
|
|
52
|
+
"timeout_seconds",
|
|
53
|
+
DEFAULT_EXEC_TIMEOUT_SECONDS,
|
|
54
|
+
)
|
|
55
|
+
timeout = validate_exec_timeout_seconds(timeout)
|
|
56
|
+
if timeout == -1:
|
|
57
|
+
return None
|
|
58
|
+
return timeout + EXEC_RPC_COMPLETION_GRACE_SECONDS
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class RpcError(RuntimeError):
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class DaemonUnavailableError(RpcError):
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class _BoundedOutputError(RuntimeError):
|
|
70
|
+
def __init__(self, stream_name: str):
|
|
71
|
+
self.stream_name = stream_name
|
|
72
|
+
super().__init__(stream_name)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _run_bounded_process(
|
|
76
|
+
args: list[str],
|
|
77
|
+
*,
|
|
78
|
+
input_data: bytes,
|
|
79
|
+
timeout: float | None,
|
|
80
|
+
stdout_limit: int,
|
|
81
|
+
stderr_limit: int,
|
|
82
|
+
keep_stdin_open: bool = False,
|
|
83
|
+
) -> subprocess.CompletedProcess[bytes]:
|
|
84
|
+
process = subprocess.Popen(
|
|
85
|
+
args,
|
|
86
|
+
stdin=subprocess.PIPE,
|
|
87
|
+
stdout=subprocess.PIPE,
|
|
88
|
+
stderr=subprocess.PIPE,
|
|
89
|
+
start_new_session=(os.name == "posix"),
|
|
90
|
+
)
|
|
91
|
+
assert process.stdin is not None
|
|
92
|
+
assert process.stdout is not None
|
|
93
|
+
assert process.stderr is not None
|
|
94
|
+
stdout = bytearray()
|
|
95
|
+
stderr = bytearray()
|
|
96
|
+
overflow: list[str] = []
|
|
97
|
+
overflow_lock = threading.Lock()
|
|
98
|
+
|
|
99
|
+
def terminate_process_group() -> None:
|
|
100
|
+
if process.poll() is not None:
|
|
101
|
+
return
|
|
102
|
+
try:
|
|
103
|
+
if os.name == "posix":
|
|
104
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
105
|
+
else:
|
|
106
|
+
process.kill()
|
|
107
|
+
except (OSError, ProcessLookupError):
|
|
108
|
+
pass
|
|
109
|
+
|
|
110
|
+
def drain(stream: Any, retained: bytearray, limit: int, name: str) -> None:
|
|
111
|
+
try:
|
|
112
|
+
while True:
|
|
113
|
+
chunk = stream.read(64 * 1024)
|
|
114
|
+
if not chunk:
|
|
115
|
+
return
|
|
116
|
+
if len(retained) + len(chunk) > limit:
|
|
117
|
+
with overflow_lock:
|
|
118
|
+
if not overflow:
|
|
119
|
+
overflow.append(name)
|
|
120
|
+
terminate_process_group()
|
|
121
|
+
return
|
|
122
|
+
retained.extend(chunk)
|
|
123
|
+
except (OSError, ValueError):
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
def write_input() -> None:
|
|
127
|
+
try:
|
|
128
|
+
process.stdin.write(input_data)
|
|
129
|
+
process.stdin.flush()
|
|
130
|
+
except (BrokenPipeError, OSError):
|
|
131
|
+
pass
|
|
132
|
+
finally:
|
|
133
|
+
if not keep_stdin_open:
|
|
134
|
+
try:
|
|
135
|
+
process.stdin.close()
|
|
136
|
+
except OSError:
|
|
137
|
+
pass
|
|
138
|
+
|
|
139
|
+
threads = [
|
|
140
|
+
threading.Thread(
|
|
141
|
+
target=drain,
|
|
142
|
+
args=(process.stdout, stdout, stdout_limit, "stdout"),
|
|
143
|
+
daemon=True,
|
|
144
|
+
),
|
|
145
|
+
threading.Thread(
|
|
146
|
+
target=drain,
|
|
147
|
+
args=(process.stderr, stderr, stderr_limit, "stderr"),
|
|
148
|
+
daemon=True,
|
|
149
|
+
),
|
|
150
|
+
threading.Thread(target=write_input, daemon=True),
|
|
151
|
+
]
|
|
152
|
+
for thread in threads:
|
|
153
|
+
thread.start()
|
|
154
|
+
timed_out: subprocess.TimeoutExpired | None = None
|
|
155
|
+
interrupted: BaseException | None = None
|
|
156
|
+
try:
|
|
157
|
+
returncode = process.wait(timeout=timeout)
|
|
158
|
+
except subprocess.TimeoutExpired as exc:
|
|
159
|
+
timed_out = exc
|
|
160
|
+
terminate_process_group()
|
|
161
|
+
returncode = 124
|
|
162
|
+
except BaseException as exc:
|
|
163
|
+
interrupted = exc
|
|
164
|
+
terminate_process_group()
|
|
165
|
+
returncode = 130
|
|
166
|
+
cleanup_deadline = time.monotonic() + RPC_PROCESS_CLEANUP_GRACE_SECONDS
|
|
167
|
+
try:
|
|
168
|
+
if process.poll() is None:
|
|
169
|
+
try:
|
|
170
|
+
process.wait(
|
|
171
|
+
timeout=max(0.0, cleanup_deadline - time.monotonic())
|
|
172
|
+
)
|
|
173
|
+
except subprocess.TimeoutExpired:
|
|
174
|
+
terminate_process_group()
|
|
175
|
+
for thread in threads:
|
|
176
|
+
thread.join(max(0.0, cleanup_deadline - time.monotonic()))
|
|
177
|
+
for stream in (process.stdin, process.stdout, process.stderr):
|
|
178
|
+
try:
|
|
179
|
+
stream.close()
|
|
180
|
+
except OSError:
|
|
181
|
+
pass
|
|
182
|
+
for thread in threads:
|
|
183
|
+
if thread.is_alive():
|
|
184
|
+
thread.join(max(0.0, cleanup_deadline - time.monotonic()))
|
|
185
|
+
finally:
|
|
186
|
+
terminate_process_group()
|
|
187
|
+
|
|
188
|
+
if timed_out is not None:
|
|
189
|
+
raise timed_out
|
|
190
|
+
if interrupted is not None:
|
|
191
|
+
raise interrupted
|
|
192
|
+
|
|
193
|
+
if overflow:
|
|
194
|
+
raise _BoundedOutputError(overflow[0])
|
|
195
|
+
return subprocess.CompletedProcess(
|
|
196
|
+
args=args,
|
|
197
|
+
returncode=returncode,
|
|
198
|
+
stdout=bytes(stdout),
|
|
199
|
+
stderr=bytes(stderr),
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _run_streaming_process(
|
|
204
|
+
args: list[str],
|
|
205
|
+
*,
|
|
206
|
+
input_data: bytes,
|
|
207
|
+
timeout: float | None,
|
|
208
|
+
stdout_limit: int,
|
|
209
|
+
stderr_limit: int,
|
|
210
|
+
on_line: Callable[[bytes], None],
|
|
211
|
+
) -> subprocess.CompletedProcess[bytes]:
|
|
212
|
+
process = subprocess.Popen(
|
|
213
|
+
args,
|
|
214
|
+
stdin=subprocess.PIPE,
|
|
215
|
+
stdout=subprocess.PIPE,
|
|
216
|
+
stderr=subprocess.PIPE,
|
|
217
|
+
start_new_session=(os.name == "posix"),
|
|
218
|
+
)
|
|
219
|
+
assert process.stdin is not None
|
|
220
|
+
assert process.stdout is not None
|
|
221
|
+
assert process.stderr is not None
|
|
222
|
+
lines: queue.Queue[bytes | None] = queue.Queue(maxsize=16)
|
|
223
|
+
stderr = bytearray()
|
|
224
|
+
overflow: list[str] = []
|
|
225
|
+
stop = threading.Event()
|
|
226
|
+
|
|
227
|
+
def terminate_process_group() -> None:
|
|
228
|
+
if process.poll() is not None:
|
|
229
|
+
return
|
|
230
|
+
try:
|
|
231
|
+
if os.name == "posix":
|
|
232
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
233
|
+
else:
|
|
234
|
+
process.kill()
|
|
235
|
+
except (OSError, ProcessLookupError):
|
|
236
|
+
pass
|
|
237
|
+
|
|
238
|
+
def enqueue(value: bytes | None) -> None:
|
|
239
|
+
while not stop.is_set():
|
|
240
|
+
try:
|
|
241
|
+
lines.put(value, timeout=0.1)
|
|
242
|
+
return
|
|
243
|
+
except queue.Full:
|
|
244
|
+
continue
|
|
245
|
+
|
|
246
|
+
def read_stdout() -> None:
|
|
247
|
+
try:
|
|
248
|
+
while not stop.is_set():
|
|
249
|
+
line = process.stdout.readline(stdout_limit + 1)
|
|
250
|
+
if not line:
|
|
251
|
+
break
|
|
252
|
+
if len(line) > stdout_limit:
|
|
253
|
+
overflow.append("stdout")
|
|
254
|
+
terminate_process_group()
|
|
255
|
+
break
|
|
256
|
+
enqueue(line)
|
|
257
|
+
except (OSError, ValueError):
|
|
258
|
+
pass
|
|
259
|
+
finally:
|
|
260
|
+
enqueue(None)
|
|
261
|
+
|
|
262
|
+
def read_stderr() -> None:
|
|
263
|
+
try:
|
|
264
|
+
while not stop.is_set():
|
|
265
|
+
chunk = process.stderr.read(64 * 1024)
|
|
266
|
+
if not chunk:
|
|
267
|
+
return
|
|
268
|
+
if len(stderr) + len(chunk) > stderr_limit:
|
|
269
|
+
overflow.append("stderr")
|
|
270
|
+
terminate_process_group()
|
|
271
|
+
return
|
|
272
|
+
stderr.extend(chunk)
|
|
273
|
+
except (OSError, ValueError):
|
|
274
|
+
return
|
|
275
|
+
|
|
276
|
+
def write_input() -> None:
|
|
277
|
+
try:
|
|
278
|
+
process.stdin.write(input_data)
|
|
279
|
+
process.stdin.flush()
|
|
280
|
+
except (BrokenPipeError, OSError):
|
|
281
|
+
pass
|
|
282
|
+
|
|
283
|
+
threads = [
|
|
284
|
+
threading.Thread(target=read_stdout, daemon=True),
|
|
285
|
+
threading.Thread(target=read_stderr, daemon=True),
|
|
286
|
+
threading.Thread(target=write_input, daemon=True),
|
|
287
|
+
]
|
|
288
|
+
for thread in threads:
|
|
289
|
+
thread.start()
|
|
290
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
291
|
+
try:
|
|
292
|
+
while True:
|
|
293
|
+
if overflow:
|
|
294
|
+
raise _BoundedOutputError(overflow[0])
|
|
295
|
+
remaining = (
|
|
296
|
+
None if deadline is None else deadline - time.monotonic()
|
|
297
|
+
)
|
|
298
|
+
if remaining is not None and remaining <= 0:
|
|
299
|
+
raise subprocess.TimeoutExpired(args, timeout)
|
|
300
|
+
try:
|
|
301
|
+
line = lines.get(
|
|
302
|
+
timeout=0.1
|
|
303
|
+
if remaining is None
|
|
304
|
+
else min(0.1, remaining)
|
|
305
|
+
)
|
|
306
|
+
except queue.Empty:
|
|
307
|
+
continue
|
|
308
|
+
if line is None:
|
|
309
|
+
break
|
|
310
|
+
on_line(line)
|
|
311
|
+
remaining = (
|
|
312
|
+
None
|
|
313
|
+
if deadline is None
|
|
314
|
+
else max(0.0, deadline - time.monotonic())
|
|
315
|
+
)
|
|
316
|
+
returncode = process.wait(timeout=remaining)
|
|
317
|
+
except BaseException:
|
|
318
|
+
terminate_process_group()
|
|
319
|
+
try:
|
|
320
|
+
process.wait(timeout=RPC_PROCESS_CLEANUP_GRACE_SECONDS)
|
|
321
|
+
except subprocess.TimeoutExpired:
|
|
322
|
+
terminate_process_group()
|
|
323
|
+
raise
|
|
324
|
+
finally:
|
|
325
|
+
stop.set()
|
|
326
|
+
for stream in (process.stdin, process.stdout, process.stderr):
|
|
327
|
+
try:
|
|
328
|
+
stream.close()
|
|
329
|
+
except OSError:
|
|
330
|
+
pass
|
|
331
|
+
cleanup_deadline = (
|
|
332
|
+
time.monotonic() + RPC_PROCESS_CLEANUP_GRACE_SECONDS
|
|
333
|
+
)
|
|
334
|
+
for thread in threads:
|
|
335
|
+
thread.join(max(0.0, cleanup_deadline - time.monotonic()))
|
|
336
|
+
|
|
337
|
+
return subprocess.CompletedProcess(
|
|
338
|
+
args=args,
|
|
339
|
+
returncode=returncode,
|
|
340
|
+
stdout=b"",
|
|
341
|
+
stderr=bytes(stderr),
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
class RpcTransport(Protocol):
|
|
346
|
+
def call(self, method: str, params: dict[str, Any] | None = None) -> Any: ...
|
|
347
|
+
|
|
348
|
+
def stream_exec(
|
|
349
|
+
self,
|
|
350
|
+
params: dict[str, Any],
|
|
351
|
+
on_output: Callable[[str, bytes], None],
|
|
352
|
+
) -> dict[str, Any]: ...
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _request(
|
|
356
|
+
method: str,
|
|
357
|
+
params: dict[str, Any] | None = None,
|
|
358
|
+
*,
|
|
359
|
+
cancel_on_disconnect: bool = False,
|
|
360
|
+
) -> tuple[str, bytes]:
|
|
361
|
+
request_id = uuid.uuid4().hex
|
|
362
|
+
payload = {
|
|
363
|
+
"id": request_id,
|
|
364
|
+
"method": method,
|
|
365
|
+
"params": params or {},
|
|
366
|
+
}
|
|
367
|
+
if cancel_on_disconnect:
|
|
368
|
+
payload["cancel_on_disconnect"] = True
|
|
369
|
+
return request_id, json.dumps(payload, ensure_ascii=False).encode("utf-8") + b"\n"
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _bounded_utf8(value: Any, maximum: int) -> str:
|
|
373
|
+
text = str(value)
|
|
374
|
+
retained = bytearray()
|
|
375
|
+
for offset in range(0, len(text), 4096):
|
|
376
|
+
chunk = text[offset : offset + 4096].encode("utf-8", errors="replace")
|
|
377
|
+
remaining = maximum - len(retained)
|
|
378
|
+
if remaining <= 0:
|
|
379
|
+
break
|
|
380
|
+
retained.extend(chunk[:remaining])
|
|
381
|
+
return bytes(retained).decode("utf-8", errors="ignore")
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _decode_response(request_id: str, raw: bytes) -> Any:
|
|
385
|
+
try:
|
|
386
|
+
response = json.loads(raw)
|
|
387
|
+
except json.JSONDecodeError as exc:
|
|
388
|
+
raise RpcError(f"invalid RPC response: {raw[:500]!r}") from exc
|
|
389
|
+
if response.get("id") not in {request_id, None}:
|
|
390
|
+
raise RpcError("RPC response id mismatch")
|
|
391
|
+
if "error" in response:
|
|
392
|
+
error = response["error"]
|
|
393
|
+
if response.get("id") is None and error.get("type") in {
|
|
394
|
+
"FileNotFoundError",
|
|
395
|
+
"ConnectionRefusedError",
|
|
396
|
+
}:
|
|
397
|
+
raise DaemonUnavailableError(
|
|
398
|
+
"remote msdevd is not running or its socket is unavailable; "
|
|
399
|
+
"run `msdev node bootstrap <node>`"
|
|
400
|
+
)
|
|
401
|
+
error_type = _bounded_utf8(error.get("type", "Error"), 128)
|
|
402
|
+
message = _bounded_utf8(error.get("message", ""), MAX_RPC_ERROR_BYTES)
|
|
403
|
+
raise RpcError(f"{error_type}: {message}")
|
|
404
|
+
return response.get("result")
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _decode_stream_event(
|
|
408
|
+
request_id: str,
|
|
409
|
+
raw: bytes,
|
|
410
|
+
on_output: Callable[[str, bytes], None],
|
|
411
|
+
) -> dict[str, Any] | None:
|
|
412
|
+
try:
|
|
413
|
+
event = json.loads(raw)
|
|
414
|
+
except json.JSONDecodeError as exc:
|
|
415
|
+
raise RpcError(f"invalid streaming RPC event: {raw[:500]!r}") from exc
|
|
416
|
+
if not isinstance(event, dict):
|
|
417
|
+
raise RpcError("streaming RPC event must be an object")
|
|
418
|
+
if "error" in event and "event" not in event:
|
|
419
|
+
_decode_response(request_id, raw)
|
|
420
|
+
raise RpcError("streaming RPC returned an invalid error response")
|
|
421
|
+
if event.get("id") != request_id:
|
|
422
|
+
raise RpcError("streaming RPC response id mismatch")
|
|
423
|
+
event_type = event.get("event")
|
|
424
|
+
if event_type in {"stdout", "stderr"}:
|
|
425
|
+
encoded = event.get("data_base64")
|
|
426
|
+
if not isinstance(encoded, str):
|
|
427
|
+
raise RpcError("streaming RPC output event has invalid data")
|
|
428
|
+
try:
|
|
429
|
+
chunk = base64.b64decode(encoded, validate=True)
|
|
430
|
+
except (ValueError, TypeError) as exc:
|
|
431
|
+
raise RpcError("streaming RPC output event has invalid base64") from exc
|
|
432
|
+
on_output(event_type, chunk)
|
|
433
|
+
return None
|
|
434
|
+
if event_type == "result":
|
|
435
|
+
result = event.get("result")
|
|
436
|
+
if not isinstance(result, dict):
|
|
437
|
+
raise RpcError("streaming RPC result event has invalid result")
|
|
438
|
+
return result
|
|
439
|
+
if event_type == "error":
|
|
440
|
+
error = event.get("error") or {}
|
|
441
|
+
error_type = _bounded_utf8(error.get("type", "Error"), 128)
|
|
442
|
+
message = _bounded_utf8(error.get("message", ""), MAX_RPC_ERROR_BYTES)
|
|
443
|
+
raise RpcError(f"{error_type}: {message}")
|
|
444
|
+
raise RpcError(f"unsupported streaming RPC event: {event_type!r}")
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
class UnixRpcTransport:
|
|
448
|
+
def __init__(self, socket_path: Path | None = None):
|
|
449
|
+
self.socket_path = socket_path or default_socket_path()
|
|
450
|
+
|
|
451
|
+
def call(self, method: str, params: dict[str, Any] | None = None) -> Any:
|
|
452
|
+
request_id, payload = _request(method, params)
|
|
453
|
+
rpc_timeout = _rpc_timeout_seconds(
|
|
454
|
+
method,
|
|
455
|
+
params,
|
|
456
|
+
LOCAL_RPC_TIMEOUT_SECONDS,
|
|
457
|
+
)
|
|
458
|
+
deadline = (
|
|
459
|
+
None if rpc_timeout is None else time.monotonic() + rpc_timeout
|
|
460
|
+
)
|
|
461
|
+
try:
|
|
462
|
+
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
|
|
463
|
+
client.settimeout(
|
|
464
|
+
None
|
|
465
|
+
if deadline is None
|
|
466
|
+
else max(0.001, deadline - time.monotonic())
|
|
467
|
+
)
|
|
468
|
+
client.connect(str(self.socket_path.expanduser()))
|
|
469
|
+
client.settimeout(
|
|
470
|
+
None
|
|
471
|
+
if deadline is None
|
|
472
|
+
else max(0.001, deadline - time.monotonic())
|
|
473
|
+
)
|
|
474
|
+
client.sendall(payload)
|
|
475
|
+
response = bytearray()
|
|
476
|
+
while not response.endswith(b"\n"):
|
|
477
|
+
if deadline is not None:
|
|
478
|
+
remaining = deadline - time.monotonic()
|
|
479
|
+
if remaining <= 0:
|
|
480
|
+
raise TimeoutError("RPC deadline exceeded")
|
|
481
|
+
client.settimeout(remaining)
|
|
482
|
+
chunk = client.recv(65536)
|
|
483
|
+
if not chunk:
|
|
484
|
+
break
|
|
485
|
+
if len(response) + len(chunk) > MAX_RPC_RESPONSE_BYTES:
|
|
486
|
+
raise RpcError(
|
|
487
|
+
"RPC response exceeded the configured byte limit"
|
|
488
|
+
)
|
|
489
|
+
response.extend(chunk)
|
|
490
|
+
except OSError as exc:
|
|
491
|
+
detail = _bounded_utf8(exc, MAX_RPC_ERROR_BYTES)
|
|
492
|
+
raise RpcError(
|
|
493
|
+
f"cannot reach local msdevd at {self.socket_path}: {detail}"
|
|
494
|
+
) from exc
|
|
495
|
+
return _decode_response(request_id, bytes(response))
|
|
496
|
+
|
|
497
|
+
def stream_exec(
|
|
498
|
+
self,
|
|
499
|
+
params: dict[str, Any],
|
|
500
|
+
on_output: Callable[[str, bytes], None],
|
|
501
|
+
) -> dict[str, Any]:
|
|
502
|
+
request_id, payload = _request("exec.stream", params)
|
|
503
|
+
rpc_timeout = _rpc_timeout_seconds(
|
|
504
|
+
"exec.stream",
|
|
505
|
+
params,
|
|
506
|
+
LOCAL_RPC_TIMEOUT_SECONDS,
|
|
507
|
+
)
|
|
508
|
+
deadline = (
|
|
509
|
+
None if rpc_timeout is None else time.monotonic() + rpc_timeout
|
|
510
|
+
)
|
|
511
|
+
buffer = bytearray()
|
|
512
|
+
try:
|
|
513
|
+
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
|
|
514
|
+
client.settimeout(
|
|
515
|
+
None
|
|
516
|
+
if deadline is None
|
|
517
|
+
else max(0.001, deadline - time.monotonic())
|
|
518
|
+
)
|
|
519
|
+
client.connect(str(self.socket_path.expanduser()))
|
|
520
|
+
client.sendall(payload)
|
|
521
|
+
while True:
|
|
522
|
+
if deadline is not None:
|
|
523
|
+
remaining = deadline - time.monotonic()
|
|
524
|
+
if remaining <= 0:
|
|
525
|
+
raise TimeoutError("RPC deadline exceeded")
|
|
526
|
+
client.settimeout(remaining)
|
|
527
|
+
chunk = client.recv(65536)
|
|
528
|
+
if not chunk:
|
|
529
|
+
break
|
|
530
|
+
buffer.extend(chunk)
|
|
531
|
+
while b"\n" in buffer:
|
|
532
|
+
raw, _, remainder = buffer.partition(b"\n")
|
|
533
|
+
buffer = bytearray(remainder)
|
|
534
|
+
result = _decode_stream_event(
|
|
535
|
+
request_id,
|
|
536
|
+
bytes(raw),
|
|
537
|
+
on_output,
|
|
538
|
+
)
|
|
539
|
+
if result is not None:
|
|
540
|
+
return result
|
|
541
|
+
if len(buffer) > MAX_RPC_RESPONSE_BYTES:
|
|
542
|
+
raise RpcError(
|
|
543
|
+
"streaming RPC event exceeded the configured byte limit"
|
|
544
|
+
)
|
|
545
|
+
except OSError as exc:
|
|
546
|
+
detail = _bounded_utf8(exc, MAX_RPC_ERROR_BYTES)
|
|
547
|
+
raise RpcError(
|
|
548
|
+
f"cannot reach local msdevd at {self.socket_path}: {detail}"
|
|
549
|
+
) from exc
|
|
550
|
+
raise RpcError("streaming RPC ended before the result event")
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
class SshRpcTransport:
|
|
554
|
+
def __init__(
|
|
555
|
+
self,
|
|
556
|
+
node: Node,
|
|
557
|
+
environment: Environment | None = None,
|
|
558
|
+
*,
|
|
559
|
+
auto_bootstrap: bool | None = None,
|
|
560
|
+
):
|
|
561
|
+
self.node = node
|
|
562
|
+
self.environment = environment or Environment(node.name, node.name)
|
|
563
|
+
self.auto_bootstrap = (
|
|
564
|
+
node.auto_bootstrap if auto_bootstrap is None else auto_bootstrap
|
|
565
|
+
)
|
|
566
|
+
control_dir = Path(os.environ.get("MSDEV_SSH_CONTROL_DIR", "~/.cache/msdev/ssh")).expanduser()
|
|
567
|
+
control_dir.mkdir(parents=True, exist_ok=True)
|
|
568
|
+
self.control_path = str(control_dir / "%C")
|
|
569
|
+
self.control_persist = os.environ.get("MSDEV_SSH_CONTROL_PERSIST", "yes")
|
|
570
|
+
|
|
571
|
+
def _node_environment(self) -> dict[str, str]:
|
|
572
|
+
return dict(
|
|
573
|
+
parse_variable_assignment(value)
|
|
574
|
+
for value in self.node.variables
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
def _ssh_options(self) -> list[str]:
|
|
578
|
+
return [
|
|
579
|
+
"-o",
|
|
580
|
+
"ControlMaster=auto",
|
|
581
|
+
"-o",
|
|
582
|
+
f"ControlPersist={self.control_persist}",
|
|
583
|
+
"-o",
|
|
584
|
+
f"ControlPath={self.control_path}",
|
|
585
|
+
"-o",
|
|
586
|
+
"ConnectTimeout=10",
|
|
587
|
+
"-o",
|
|
588
|
+
"ServerAliveInterval=15",
|
|
589
|
+
"-o",
|
|
590
|
+
"ServerAliveCountMax=2",
|
|
591
|
+
]
|
|
592
|
+
|
|
593
|
+
def _ssh_base(
|
|
594
|
+
self,
|
|
595
|
+
*,
|
|
596
|
+
dedicated: bool = False,
|
|
597
|
+
tty: bool = False,
|
|
598
|
+
) -> list[str]:
|
|
599
|
+
options = self._ssh_options()
|
|
600
|
+
if dedicated:
|
|
601
|
+
options.extend(
|
|
602
|
+
[
|
|
603
|
+
"-o",
|
|
604
|
+
"ControlMaster=no",
|
|
605
|
+
"-o",
|
|
606
|
+
"ControlPath=none",
|
|
607
|
+
]
|
|
608
|
+
)
|
|
609
|
+
return [
|
|
610
|
+
"ssh",
|
|
611
|
+
*options,
|
|
612
|
+
*(["-tt"] if tty else []),
|
|
613
|
+
self.node.ssh_host,
|
|
614
|
+
]
|
|
615
|
+
|
|
616
|
+
def open_shell(self, *, cwd: str | None = None) -> int:
|
|
617
|
+
if cwd is not None and (
|
|
618
|
+
not isinstance(cwd, str) or not cwd or "\0" in cwd
|
|
619
|
+
):
|
|
620
|
+
raise ValueError("shell cwd must be a non-empty string")
|
|
621
|
+
if self.environment.runtime == "docker":
|
|
622
|
+
shell_setup = """
|
|
623
|
+
if command -v bash >/dev/null 2>&1; then
|
|
624
|
+
shell=$(command -v bash)
|
|
625
|
+
else
|
|
626
|
+
echo "msdev: bash not found; falling back to /bin/sh" >&2
|
|
627
|
+
shell=/bin/sh
|
|
628
|
+
fi
|
|
629
|
+
""".strip()
|
|
630
|
+
else:
|
|
631
|
+
shell_setup = 'shell="${SHELL:-/bin/sh}"'
|
|
632
|
+
if self.environment.layers:
|
|
633
|
+
shell_script = shell_setup + "\n" + """
|
|
634
|
+
case "${shell##*/}" in
|
|
635
|
+
bash)
|
|
636
|
+
rc="$(mktemp "${TMPDIR:-/tmp}/msdev-shell.XXXXXX")" || exit 1
|
|
637
|
+
if [ -r "$HOME/.bashrc" ]; then
|
|
638
|
+
cat "$HOME/.bashrc" > "$rc"
|
|
639
|
+
else
|
|
640
|
+
: > "$rc"
|
|
641
|
+
fi
|
|
642
|
+
export -p >> "$rc"
|
|
643
|
+
if [ "${CONDA_PREFIX+x}" != x ]; then
|
|
644
|
+
printf '\\nunset CONDA_PREFIX CONDA_DEFAULT_ENV CONDA_PROMPT_MODIFIER CONDA_SHLVL _CE_CONDA _CE_M\\n' >> "$rc"
|
|
645
|
+
fi
|
|
646
|
+
cat >> "$rc" <<'MSDEV_SHELL_RC'
|
|
647
|
+
case "$PS1" in
|
|
648
|
+
\\(*\\)\\ *) PS1="${PS1#*) }" ;;
|
|
649
|
+
esac
|
|
650
|
+
if [ -n "${CONDA_PROMPT_MODIFIER:-}" ]; then
|
|
651
|
+
PS1="${CONDA_PROMPT_MODIFIER}${PS1}"
|
|
652
|
+
fi
|
|
653
|
+
MSDEV_SHELL_RC
|
|
654
|
+
printf '\\nrm -f -- "${BASH_SOURCE[0]}"\\n' >> "$rc"
|
|
655
|
+
exec "$shell" --noprofile --rcfile "$rc" -i
|
|
656
|
+
;;
|
|
657
|
+
*)
|
|
658
|
+
exec "$shell" -i
|
|
659
|
+
;;
|
|
660
|
+
esac
|
|
661
|
+
""".strip()
|
|
662
|
+
else:
|
|
663
|
+
shell_script = shell_setup + '\nexec "$shell" -il'
|
|
664
|
+
wrapped = ["sh", "-c", shell_script]
|
|
665
|
+
if cwd is not None and self.environment.runtime == "host":
|
|
666
|
+
wrapped = [
|
|
667
|
+
"sh",
|
|
668
|
+
"-c",
|
|
669
|
+
'cd "$1" && shift && exec "$@"',
|
|
670
|
+
"msdev-shell-cwd",
|
|
671
|
+
cwd,
|
|
672
|
+
*wrapped,
|
|
673
|
+
]
|
|
674
|
+
environments = [
|
|
675
|
+
parse_environment_spec(value)
|
|
676
|
+
for value in self.environment.layers
|
|
677
|
+
]
|
|
678
|
+
for kind, value in reversed(environments):
|
|
679
|
+
if kind == "uv":
|
|
680
|
+
wrapped = ["uv", "run", "--project", value, "--", *wrapped]
|
|
681
|
+
elif kind == "venv":
|
|
682
|
+
activate = f"{value.rstrip('/')}/bin/activate"
|
|
683
|
+
wrapped = [
|
|
684
|
+
"sh",
|
|
685
|
+
"-c",
|
|
686
|
+
f'. {shlex.quote(activate)} && exec "$@"',
|
|
687
|
+
"msdev-shell-venv",
|
|
688
|
+
*wrapped,
|
|
689
|
+
]
|
|
690
|
+
elif kind == "conda":
|
|
691
|
+
wrapped = wrap_conda_command(wrapped, value)
|
|
692
|
+
if self.environment.runtime == "host" and self.node.variables:
|
|
693
|
+
wrapped = ["env", *self.node.variables, *wrapped]
|
|
694
|
+
if self.environment.runtime == "docker":
|
|
695
|
+
remote = ["docker", "exec", "-it"]
|
|
696
|
+
if cwd is not None:
|
|
697
|
+
remote.extend(["--workdir", cwd])
|
|
698
|
+
assert self.environment.container is not None
|
|
699
|
+
remote.extend([self.environment.container, *wrapped])
|
|
700
|
+
else:
|
|
701
|
+
remote = wrapped
|
|
702
|
+
completed = subprocess.run(
|
|
703
|
+
[*self._ssh_base(tty=True), shlex.join(remote)],
|
|
704
|
+
check=False,
|
|
705
|
+
)
|
|
706
|
+
return int(completed.returncode)
|
|
707
|
+
|
|
708
|
+
def connection_active(self) -> bool:
|
|
709
|
+
completed = subprocess.run(
|
|
710
|
+
[
|
|
711
|
+
"ssh",
|
|
712
|
+
"-o",
|
|
713
|
+
f"ControlPath={self.control_path}",
|
|
714
|
+
"-O",
|
|
715
|
+
"check",
|
|
716
|
+
self.node.ssh_host,
|
|
717
|
+
],
|
|
718
|
+
capture_output=True,
|
|
719
|
+
text=True,
|
|
720
|
+
check=False,
|
|
721
|
+
timeout=10,
|
|
722
|
+
)
|
|
723
|
+
return completed.returncode == 0
|
|
724
|
+
|
|
725
|
+
def connect(self) -> dict[str, Any]:
|
|
726
|
+
"""Open a persistent SSH master, prompting on the caller's TTY."""
|
|
727
|
+
if self.connection_active():
|
|
728
|
+
return {
|
|
729
|
+
"connected": True,
|
|
730
|
+
"already_connected": True,
|
|
731
|
+
"persist": self.control_persist,
|
|
732
|
+
}
|
|
733
|
+
try:
|
|
734
|
+
completed = subprocess.run(
|
|
735
|
+
[
|
|
736
|
+
"ssh",
|
|
737
|
+
"-MNf",
|
|
738
|
+
*self._ssh_options(),
|
|
739
|
+
self.node.ssh_host,
|
|
740
|
+
],
|
|
741
|
+
check=False,
|
|
742
|
+
timeout=120,
|
|
743
|
+
)
|
|
744
|
+
except subprocess.TimeoutExpired as exc:
|
|
745
|
+
raise RpcError(
|
|
746
|
+
f"SSH connection to node {self.node.name!r} timed out"
|
|
747
|
+
) from exc
|
|
748
|
+
if completed.returncode != 0:
|
|
749
|
+
raise RpcError(
|
|
750
|
+
f"SSH authentication or connection failed for node "
|
|
751
|
+
f"{self.node.name!r} ({self.node.ssh_host})"
|
|
752
|
+
)
|
|
753
|
+
if not self.connection_active():
|
|
754
|
+
raise RpcError(
|
|
755
|
+
f"SSH master for node {self.node.name!r} did not become active"
|
|
756
|
+
)
|
|
757
|
+
return {
|
|
758
|
+
"connected": True,
|
|
759
|
+
"already_connected": False,
|
|
760
|
+
"persist": self.control_persist,
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
def disconnect(self) -> dict[str, Any]:
|
|
764
|
+
if not self.connection_active():
|
|
765
|
+
return {"connected": False, "already_disconnected": True}
|
|
766
|
+
completed = subprocess.run(
|
|
767
|
+
[
|
|
768
|
+
"ssh",
|
|
769
|
+
"-o",
|
|
770
|
+
f"ControlPath={self.control_path}",
|
|
771
|
+
"-O",
|
|
772
|
+
"exit",
|
|
773
|
+
self.node.ssh_host,
|
|
774
|
+
],
|
|
775
|
+
capture_output=True,
|
|
776
|
+
text=True,
|
|
777
|
+
check=False,
|
|
778
|
+
timeout=10,
|
|
779
|
+
)
|
|
780
|
+
if completed.returncode != 0 and self.connection_active():
|
|
781
|
+
raise RpcError(
|
|
782
|
+
f"failed to close SSH master for node {self.node.name!r}: "
|
|
783
|
+
f"{completed.stderr.strip()}"
|
|
784
|
+
)
|
|
785
|
+
return {"connected": False, "already_disconnected": False}
|
|
786
|
+
|
|
787
|
+
def validate(self) -> dict[str, str]:
|
|
788
|
+
completed = subprocess.run(
|
|
789
|
+
["ssh", "-G", self.node.ssh_host],
|
|
790
|
+
capture_output=True,
|
|
791
|
+
text=True,
|
|
792
|
+
check=False,
|
|
793
|
+
)
|
|
794
|
+
if completed.returncode != 0:
|
|
795
|
+
raise RpcError(completed.stderr.strip() or f"invalid SSH host: {self.node.ssh_host}")
|
|
796
|
+
values: dict[str, str] = {}
|
|
797
|
+
for line in completed.stdout.splitlines():
|
|
798
|
+
key, _, value = line.partition(" ")
|
|
799
|
+
if key in {"hostname", "user", "port", "proxyjump"}:
|
|
800
|
+
values[key] = value
|
|
801
|
+
return values
|
|
802
|
+
|
|
803
|
+
def call(self, method: str, params: dict[str, Any] | None = None) -> Any:
|
|
804
|
+
try:
|
|
805
|
+
return self._call_once(method, params)
|
|
806
|
+
except DaemonUnavailableError as exc:
|
|
807
|
+
if not self.auto_bootstrap:
|
|
808
|
+
raise
|
|
809
|
+
print(
|
|
810
|
+
f"[msdev] node {self.node.name!r} is reachable but msdevd "
|
|
811
|
+
"is unavailable; bootstrapping user daemon...",
|
|
812
|
+
file=sys.stderr,
|
|
813
|
+
)
|
|
814
|
+
try:
|
|
815
|
+
bootstrap_node(self.node)
|
|
816
|
+
except Exception as bootstrap_exc:
|
|
817
|
+
raise RpcError(
|
|
818
|
+
f"automatic bootstrap failed for node {self.node.name!r}: "
|
|
819
|
+
f"{bootstrap_exc}. Retry with `msdev node bootstrap "
|
|
820
|
+
f"{self.node.name}` for detailed diagnostics."
|
|
821
|
+
) from bootstrap_exc
|
|
822
|
+
print(
|
|
823
|
+
f"[msdev] msdevd is ready on node {self.node.name!r}; "
|
|
824
|
+
"retrying request.",
|
|
825
|
+
file=sys.stderr,
|
|
826
|
+
)
|
|
827
|
+
return self._call_once(method, params)
|
|
828
|
+
|
|
829
|
+
def stream_exec(
|
|
830
|
+
self,
|
|
831
|
+
params: dict[str, Any],
|
|
832
|
+
on_output: Callable[[str, bytes], None],
|
|
833
|
+
) -> dict[str, Any]:
|
|
834
|
+
try:
|
|
835
|
+
return self._stream_exec_once(params, on_output)
|
|
836
|
+
except DaemonUnavailableError as exc:
|
|
837
|
+
if not self.auto_bootstrap:
|
|
838
|
+
raise
|
|
839
|
+
print(
|
|
840
|
+
f"[msdev] node {self.node.name!r} is reachable but msdevd "
|
|
841
|
+
"is unavailable; bootstrapping user daemon...",
|
|
842
|
+
file=sys.stderr,
|
|
843
|
+
)
|
|
844
|
+
try:
|
|
845
|
+
bootstrap_node(self.node)
|
|
846
|
+
except Exception as bootstrap_exc:
|
|
847
|
+
raise RpcError(
|
|
848
|
+
f"automatic bootstrap failed for node "
|
|
849
|
+
f"{self.node.name!r}: {bootstrap_exc}. Retry with "
|
|
850
|
+
f"`msdev node bootstrap {self.node.name}` for "
|
|
851
|
+
"detailed diagnostics."
|
|
852
|
+
) from bootstrap_exc
|
|
853
|
+
return self._stream_exec_once(params, on_output)
|
|
854
|
+
|
|
855
|
+
def _stream_exec_once(
|
|
856
|
+
self,
|
|
857
|
+
params: dict[str, Any],
|
|
858
|
+
on_output: Callable[[str, bytes], None],
|
|
859
|
+
) -> dict[str, Any]:
|
|
860
|
+
self.connect()
|
|
861
|
+
request_params = dict(params)
|
|
862
|
+
if self.environment.runtime == "docker":
|
|
863
|
+
request_params["_runtime"] = {
|
|
864
|
+
"type": "docker",
|
|
865
|
+
"container": self.environment.container,
|
|
866
|
+
}
|
|
867
|
+
if self.environment.layers:
|
|
868
|
+
request_params["_environments"] = list(self.environment.layers)
|
|
869
|
+
if self.environment.runtime == "host" and self.node.variables:
|
|
870
|
+
request_params["_node_env"] = self._node_environment()
|
|
871
|
+
request_id, payload = _request(
|
|
872
|
+
"exec.stream",
|
|
873
|
+
request_params,
|
|
874
|
+
cancel_on_disconnect=True,
|
|
875
|
+
)
|
|
876
|
+
result: list[dict[str, Any]] = []
|
|
877
|
+
|
|
878
|
+
def on_line(raw: bytes) -> None:
|
|
879
|
+
decoded = _decode_stream_event(request_id, raw, on_output)
|
|
880
|
+
if decoded is not None:
|
|
881
|
+
result.append(decoded)
|
|
882
|
+
|
|
883
|
+
remote = f"{self.node.remote_bin} rpc"
|
|
884
|
+
try:
|
|
885
|
+
completed = _run_streaming_process(
|
|
886
|
+
[*self._ssh_base(dedicated=True), remote],
|
|
887
|
+
input_data=payload,
|
|
888
|
+
timeout=_rpc_timeout_seconds(
|
|
889
|
+
"exec.stream",
|
|
890
|
+
request_params,
|
|
891
|
+
SSH_RPC_TIMEOUT_SECONDS,
|
|
892
|
+
),
|
|
893
|
+
stdout_limit=MAX_RPC_RESPONSE_BYTES,
|
|
894
|
+
stderr_limit=MAX_RPC_ERROR_BYTES,
|
|
895
|
+
on_line=on_line,
|
|
896
|
+
)
|
|
897
|
+
except subprocess.TimeoutExpired as exc:
|
|
898
|
+
raise RpcError(
|
|
899
|
+
f"SSH RPC to environment {self.environment.name!r} timed out"
|
|
900
|
+
) from exc
|
|
901
|
+
except _BoundedOutputError as exc:
|
|
902
|
+
output_name = (
|
|
903
|
+
"response" if exc.stream_name == "stdout" else "error output"
|
|
904
|
+
)
|
|
905
|
+
raise RpcError(
|
|
906
|
+
f"SSH RPC {output_name} exceeded the configured byte limit"
|
|
907
|
+
) from exc
|
|
908
|
+
if completed.returncode != 0:
|
|
909
|
+
detail = _bounded_utf8(
|
|
910
|
+
completed.stderr.decode("utf-8", errors="replace"),
|
|
911
|
+
MAX_RPC_ERROR_BYTES,
|
|
912
|
+
)
|
|
913
|
+
raise RpcError(
|
|
914
|
+
f"SSH RPC to environment {self.environment.name!r} failed: {detail}"
|
|
915
|
+
)
|
|
916
|
+
if len(result) != 1:
|
|
917
|
+
raise RpcError("streaming RPC ended before the result event")
|
|
918
|
+
return result[0]
|
|
919
|
+
|
|
920
|
+
def _call_once(self, method: str, params: dict[str, Any] | None = None) -> Any:
|
|
921
|
+
self.connect()
|
|
922
|
+
request_params = dict(params or {})
|
|
923
|
+
if self.environment.runtime == "docker":
|
|
924
|
+
request_params["_runtime"] = {
|
|
925
|
+
"type": "docker",
|
|
926
|
+
"container": self.environment.container,
|
|
927
|
+
}
|
|
928
|
+
if self.environment.layers:
|
|
929
|
+
request_params["_environments"] = list(self.environment.layers)
|
|
930
|
+
if self.environment.runtime == "host" and self.node.variables:
|
|
931
|
+
request_params["_node_env"] = self._node_environment()
|
|
932
|
+
request_id, payload = _request(
|
|
933
|
+
method,
|
|
934
|
+
request_params,
|
|
935
|
+
cancel_on_disconnect=True,
|
|
936
|
+
)
|
|
937
|
+
remote = f"{self.node.remote_bin} rpc"
|
|
938
|
+
try:
|
|
939
|
+
completed = _run_bounded_process(
|
|
940
|
+
[
|
|
941
|
+
*self._ssh_base(
|
|
942
|
+
dedicated=method
|
|
943
|
+
in {
|
|
944
|
+
"exec",
|
|
945
|
+
"workspace.git_status",
|
|
946
|
+
"workspace.git_diff",
|
|
947
|
+
}
|
|
948
|
+
),
|
|
949
|
+
remote,
|
|
950
|
+
],
|
|
951
|
+
input_data=payload,
|
|
952
|
+
timeout=_rpc_timeout_seconds(
|
|
953
|
+
method,
|
|
954
|
+
request_params,
|
|
955
|
+
SSH_RPC_TIMEOUT_SECONDS,
|
|
956
|
+
),
|
|
957
|
+
stdout_limit=MAX_RPC_RESPONSE_BYTES,
|
|
958
|
+
stderr_limit=MAX_RPC_ERROR_BYTES,
|
|
959
|
+
keep_stdin_open=True,
|
|
960
|
+
)
|
|
961
|
+
except subprocess.TimeoutExpired as exc:
|
|
962
|
+
raise RpcError(
|
|
963
|
+
f"SSH RPC to environment {self.environment.name!r} timed out"
|
|
964
|
+
) from exc
|
|
965
|
+
except _BoundedOutputError as exc:
|
|
966
|
+
output_name = (
|
|
967
|
+
"response" if exc.stream_name == "stdout" else "error output"
|
|
968
|
+
)
|
|
969
|
+
raise RpcError(
|
|
970
|
+
f"SSH RPC {output_name} from environment {self.environment.name!r} "
|
|
971
|
+
"exceeded the configured byte limit; request a smaller result"
|
|
972
|
+
) from exc
|
|
973
|
+
if completed.returncode != 0 and not completed.stdout:
|
|
974
|
+
stderr = completed.stderr.decode("utf-8", errors="replace").strip()
|
|
975
|
+
if completed.returncode == 127 or "not found" in stderr.lower():
|
|
976
|
+
raise DaemonUnavailableError(
|
|
977
|
+
f"remote msdevd command is unavailable on node "
|
|
978
|
+
f"{self.node.name!r}; run `msdev node bootstrap {self.node.name}`"
|
|
979
|
+
)
|
|
980
|
+
raise RpcError(
|
|
981
|
+
stderr
|
|
982
|
+
or f"SSH RPC to environment {self.environment.name!r} failed with "
|
|
983
|
+
f"exit code {completed.returncode}"
|
|
984
|
+
)
|
|
985
|
+
return _decode_response(request_id, completed.stdout)
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
SERVICE_UNIT = """[Unit]
|
|
989
|
+
Description=msdev user daemon
|
|
990
|
+
After=default.target
|
|
991
|
+
|
|
992
|
+
[Service]
|
|
993
|
+
Type=simple
|
|
994
|
+
ExecStart=%h/.local/bin/msdevd serve --socket %h/.local/share/msdevd/msdevd.sock
|
|
995
|
+
Restart=on-failure
|
|
996
|
+
RestartSec=2
|
|
997
|
+
|
|
998
|
+
[Install]
|
|
999
|
+
WantedBy=default.target
|
|
1000
|
+
"""
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
def _package_source(package_path: Path | None = None) -> Path:
|
|
1004
|
+
"""Resolve an msdev package from a source checkout or user installation."""
|
|
1005
|
+
if package_path is None:
|
|
1006
|
+
return Path(__file__).resolve().parents[1]
|
|
1007
|
+
candidate = package_path.expanduser().resolve(strict=True)
|
|
1008
|
+
for source in (candidate / "src" / "msdev", candidate / "msdev", candidate):
|
|
1009
|
+
if (source / "__init__.py").is_file() and (source / "daemon.py").is_file():
|
|
1010
|
+
return source
|
|
1011
|
+
raise ValueError(f"msdev source package not found under: {candidate}")
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
def _bootstrap_archive_filter(info: tarfile.TarInfo) -> tarfile.TarInfo | None:
|
|
1015
|
+
parts = info.name.split("/")
|
|
1016
|
+
if (
|
|
1017
|
+
any(part in {"__pycache__", ".pytest_cache"} for part in parts)
|
|
1018
|
+
or any(part.endswith(".egg-info") for part in parts)
|
|
1019
|
+
or info.name.endswith(".pyc")
|
|
1020
|
+
):
|
|
1021
|
+
return None
|
|
1022
|
+
return info
|
|
1023
|
+
|
|
1024
|
+
|
|
1025
|
+
def _cleanup_remote_archive(
|
|
1026
|
+
transport: SshRpcTransport,
|
|
1027
|
+
remote_archive: str,
|
|
1028
|
+
package_token: str,
|
|
1029
|
+
) -> None:
|
|
1030
|
+
"""Best-effort cleanup through the already-established SSH master."""
|
|
1031
|
+
command = (
|
|
1032
|
+
f"rm -f -- {shlex.quote(remote_archive)}; "
|
|
1033
|
+
'rm -rf -- "$HOME/.local/share/msdevd/'
|
|
1034
|
+
f'package.tmp.{package_token}"'
|
|
1035
|
+
)
|
|
1036
|
+
try:
|
|
1037
|
+
subprocess.run(
|
|
1038
|
+
[
|
|
1039
|
+
*transport._ssh_base(),
|
|
1040
|
+
command,
|
|
1041
|
+
],
|
|
1042
|
+
capture_output=True,
|
|
1043
|
+
text=True,
|
|
1044
|
+
check=False,
|
|
1045
|
+
timeout=REMOTE_CLEANUP_TIMEOUT_SECONDS,
|
|
1046
|
+
)
|
|
1047
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
1048
|
+
pass
|
|
1049
|
+
|
|
1050
|
+
|
|
1051
|
+
def bootstrap_node(node: Node, package_path: Path | None = None) -> dict[str, Any]:
|
|
1052
|
+
"""Deploy the pure-Python package remotely and start a user daemon."""
|
|
1053
|
+
package_source = _package_source(package_path)
|
|
1054
|
+
|
|
1055
|
+
transport = SshRpcTransport(node, auto_bootstrap=False)
|
|
1056
|
+
ssh_config = transport.validate()
|
|
1057
|
+
transport.connect()
|
|
1058
|
+
with tempfile.TemporaryDirectory(prefix="msdev-bootstrap-") as temp_dir:
|
|
1059
|
+
archive = Path(temp_dir) / "msdev-source.tar.gz"
|
|
1060
|
+
with tarfile.open(archive, "w:gz") as bundle:
|
|
1061
|
+
bundle.add(
|
|
1062
|
+
package_source,
|
|
1063
|
+
arcname="msdev",
|
|
1064
|
+
filter=_bootstrap_archive_filter,
|
|
1065
|
+
)
|
|
1066
|
+
package_token = uuid.uuid4().hex
|
|
1067
|
+
remote_archive = f"/tmp/msdev-source-{package_token}.tar.gz"
|
|
1068
|
+
try:
|
|
1069
|
+
copied = subprocess.run(
|
|
1070
|
+
[
|
|
1071
|
+
"scp",
|
|
1072
|
+
"-o",
|
|
1073
|
+
"ControlMaster=auto",
|
|
1074
|
+
"-o",
|
|
1075
|
+
f"ControlPersist={transport.control_persist}",
|
|
1076
|
+
"-o",
|
|
1077
|
+
f"ControlPath={transport.control_path}",
|
|
1078
|
+
str(archive),
|
|
1079
|
+
f"{node.ssh_host}:{remote_archive}",
|
|
1080
|
+
],
|
|
1081
|
+
capture_output=True,
|
|
1082
|
+
text=True,
|
|
1083
|
+
check=False,
|
|
1084
|
+
timeout=SCP_BOOTSTRAP_TIMEOUT_SECONDS,
|
|
1085
|
+
)
|
|
1086
|
+
except subprocess.TimeoutExpired as exc:
|
|
1087
|
+
_cleanup_remote_archive(
|
|
1088
|
+
transport,
|
|
1089
|
+
remote_archive,
|
|
1090
|
+
package_token,
|
|
1091
|
+
)
|
|
1092
|
+
raise RpcError(
|
|
1093
|
+
f"SCP upload to node {node.name!r} timed out after "
|
|
1094
|
+
f"{SCP_BOOTSTRAP_TIMEOUT_SECONDS} seconds; check node "
|
|
1095
|
+
f"connectivity and retry `msdev node bootstrap {node.name}`"
|
|
1096
|
+
) from exc
|
|
1097
|
+
if copied.returncode != 0:
|
|
1098
|
+
_cleanup_remote_archive(
|
|
1099
|
+
transport,
|
|
1100
|
+
remote_archive,
|
|
1101
|
+
package_token,
|
|
1102
|
+
)
|
|
1103
|
+
raise RuntimeError(copied.stderr.strip() or "failed to upload msdev wheel")
|
|
1104
|
+
|
|
1105
|
+
unit_b64 = base64.b64encode(SERVICE_UNIT.encode("utf-8")).decode("ascii")
|
|
1106
|
+
wrapper = """#!/usr/bin/env bash
|
|
1107
|
+
set -e
|
|
1108
|
+
export PYTHONPATH="${HOME}/.local/share/msdevd/package${PYTHONPATH:+:${PYTHONPATH}}"
|
|
1109
|
+
exec python3 -m msdev.daemon "$@"
|
|
1110
|
+
"""
|
|
1111
|
+
wrapper_b64 = base64.b64encode(wrapper.encode("utf-8")).decode("ascii")
|
|
1112
|
+
script = f"""
|
|
1113
|
+
set -eu
|
|
1114
|
+
bootstrap_dir="$HOME/.local/share/msdevd"
|
|
1115
|
+
package_root="$bootstrap_dir/package"
|
|
1116
|
+
package_tmp="$bootstrap_dir/package.tmp.{package_token}"
|
|
1117
|
+
lock_path="$bootstrap_dir/bootstrap.lock"
|
|
1118
|
+
cleanup_bootstrap() {{
|
|
1119
|
+
rm -f -- {shlex.quote(remote_archive)}
|
|
1120
|
+
rm -rf -- "$package_tmp"
|
|
1121
|
+
}}
|
|
1122
|
+
trap cleanup_bootstrap EXIT
|
|
1123
|
+
trap 'exit 1' HUP INT TERM
|
|
1124
|
+
mkdir -p "$bootstrap_dir"
|
|
1125
|
+
exec 9>"$lock_path"
|
|
1126
|
+
if ! flock -w 30 9; then
|
|
1127
|
+
echo "timed out waiting for msdev bootstrap lock" >&2
|
|
1128
|
+
exit 75
|
|
1129
|
+
fi
|
|
1130
|
+
rm -rf "$package_tmp"
|
|
1131
|
+
mkdir -p "$package_tmp" "$HOME/.config/systemd/user" "$HOME/.local/bin"
|
|
1132
|
+
tar -xzf {shlex.quote(remote_archive)} -C "$package_tmp"
|
|
1133
|
+
rm -f {shlex.quote(remote_archive)}
|
|
1134
|
+
rm -rf "$package_root.old"
|
|
1135
|
+
if [ -d "$package_root" ]; then mv "$package_root" "$package_root.old"; fi
|
|
1136
|
+
mv "$package_tmp" "$package_root"
|
|
1137
|
+
printf %s {shlex.quote(wrapper_b64)} | base64 -d > "$HOME/.local/bin/msdevd"
|
|
1138
|
+
chmod 700 "$HOME/.local/bin/msdevd"
|
|
1139
|
+
printf %s {shlex.quote(unit_b64)} | base64 -d > "$HOME/.config/systemd/user/msdevd.service"
|
|
1140
|
+
# Stop stale user daemons from older releases that selected a session-specific
|
|
1141
|
+
# socket through XDG_RUNTIME_DIR or TMPDIR.
|
|
1142
|
+
python3 - <<'PY'
|
|
1143
|
+
import os
|
|
1144
|
+
import signal
|
|
1145
|
+
import time
|
|
1146
|
+
from pathlib import Path
|
|
1147
|
+
|
|
1148
|
+
current = os.getpid()
|
|
1149
|
+
stale_pids = []
|
|
1150
|
+
for entry in Path("/proc").iterdir():
|
|
1151
|
+
if not entry.name.isdigit() or int(entry.name) == current:
|
|
1152
|
+
continue
|
|
1153
|
+
try:
|
|
1154
|
+
if entry.stat().st_uid != os.getuid():
|
|
1155
|
+
continue
|
|
1156
|
+
command = [part for part in (entry / "cmdline").read_bytes().split(b"\\0") if part]
|
|
1157
|
+
except (FileNotFoundError, PermissionError, ProcessLookupError):
|
|
1158
|
+
continue
|
|
1159
|
+
try:
|
|
1160
|
+
module_index = command.index(b"-m")
|
|
1161
|
+
except ValueError:
|
|
1162
|
+
continue
|
|
1163
|
+
if command[module_index + 1:module_index + 3] == [b"msdev.daemon", b"serve"]:
|
|
1164
|
+
try:
|
|
1165
|
+
os.kill(int(entry.name), signal.SIGTERM)
|
|
1166
|
+
stale_pids.append(int(entry.name))
|
|
1167
|
+
except (ProcessLookupError, PermissionError):
|
|
1168
|
+
pass
|
|
1169
|
+
for _ in range(20):
|
|
1170
|
+
if not any(Path(f"/proc/{{pid}}").exists() for pid in stale_pids):
|
|
1171
|
+
break
|
|
1172
|
+
time.sleep(0.1)
|
|
1173
|
+
PY
|
|
1174
|
+
rm -f "$HOME/.local/share/msdevd/msdevd.sock"
|
|
1175
|
+
flock -u 9
|
|
1176
|
+
exec 9>&-
|
|
1177
|
+
if command -v systemctl >/dev/null 2>&1 && systemctl --user daemon-reload >/dev/null 2>&1; then
|
|
1178
|
+
systemctl --user enable msdevd.service >/dev/null
|
|
1179
|
+
systemctl --user restart msdevd.service
|
|
1180
|
+
else
|
|
1181
|
+
socket_path="$HOME/.local/share/msdevd/msdevd.sock"
|
|
1182
|
+
if [ ! -S "$socket_path" ]; then
|
|
1183
|
+
nohup "$HOME/.local/bin/msdevd" serve --socket "$socket_path" \
|
|
1184
|
+
> "$HOME/.local/share/msdevd/daemon.log" 2>&1 < /dev/null &
|
|
1185
|
+
fi
|
|
1186
|
+
fi
|
|
1187
|
+
"""
|
|
1188
|
+
try:
|
|
1189
|
+
try:
|
|
1190
|
+
installed = subprocess.run(
|
|
1191
|
+
[*transport._ssh_base(), f"bash -lc {shlex.quote(script)}"],
|
|
1192
|
+
capture_output=True,
|
|
1193
|
+
text=True,
|
|
1194
|
+
check=False,
|
|
1195
|
+
timeout=180,
|
|
1196
|
+
)
|
|
1197
|
+
except subprocess.TimeoutExpired as exc:
|
|
1198
|
+
raise RpcError(
|
|
1199
|
+
f"remote msdev installation on node {node.name!r} "
|
|
1200
|
+
"timed out after 180 seconds; check node capacity and "
|
|
1201
|
+
f"retry `msdev node bootstrap {node.name}`"
|
|
1202
|
+
) from exc
|
|
1203
|
+
finally:
|
|
1204
|
+
_cleanup_remote_archive(
|
|
1205
|
+
transport,
|
|
1206
|
+
remote_archive,
|
|
1207
|
+
package_token,
|
|
1208
|
+
)
|
|
1209
|
+
if installed.returncode != 0:
|
|
1210
|
+
raise RuntimeError(installed.stderr.strip() or installed.stdout.strip())
|
|
1211
|
+
|
|
1212
|
+
last_error: Exception | None = None
|
|
1213
|
+
ping = None
|
|
1214
|
+
for _ in range(50):
|
|
1215
|
+
try:
|
|
1216
|
+
ping = transport.call("ping")
|
|
1217
|
+
break
|
|
1218
|
+
except DaemonUnavailableError as exc:
|
|
1219
|
+
last_error = exc
|
|
1220
|
+
time.sleep(0.1)
|
|
1221
|
+
if ping is None:
|
|
1222
|
+
raise RpcError(
|
|
1223
|
+
f"msdevd did not become ready on node {node.name!r}: {last_error}"
|
|
1224
|
+
)
|
|
1225
|
+
return {"ssh": ssh_config, "daemon": ping}
|