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.
@@ -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}