261 lines
9.9 KiB
Python
261 lines
9.9 KiB
Python
#!/usr/bin/env python3
|
|
"""Docker integration: remote SSH reconnect after host restart."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import glob
|
|
import json
|
|
import os
|
|
import secrets
|
|
import shutil
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent))
|
|
from cmux import cmux, cmuxError
|
|
|
|
|
|
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
|
|
REMOTE_HTTP_PORT = int(os.environ.get("CMUX_SSH_TEST_REMOTE_HTTP_PORT", "43174"))
|
|
|
|
|
|
def _must(cond: bool, msg: str) -> None:
|
|
if not cond:
|
|
raise cmuxError(msg)
|
|
|
|
|
|
def _find_cli_binary() -> str:
|
|
env_cli = os.environ.get("CMUXTERM_CLI")
|
|
if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK):
|
|
return env_cli
|
|
|
|
fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux")
|
|
if os.path.isfile(fixed) and os.access(fixed, os.X_OK):
|
|
return fixed
|
|
|
|
candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True)
|
|
candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux")
|
|
candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)]
|
|
if not candidates:
|
|
raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI")
|
|
candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True)
|
|
return candidates[0]
|
|
|
|
|
|
def _run(cmd: list[str], *, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]:
|
|
proc = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False)
|
|
if check and proc.returncode != 0:
|
|
merged = f"{proc.stdout}\n{proc.stderr}".strip()
|
|
raise cmuxError(f"Command failed ({' '.join(cmd)}): {merged}")
|
|
return proc
|
|
|
|
|
|
def _run_cli_json(cli: str, args: list[str]) -> dict:
|
|
env = dict(os.environ)
|
|
env.pop("CMUX_WORKSPACE_ID", None)
|
|
env.pop("CMUX_SURFACE_ID", None)
|
|
env.pop("CMUX_TAB_ID", None)
|
|
proc = _run([cli, "--socket", SOCKET_PATH, "--json", *args], env=env)
|
|
try:
|
|
return json.loads(proc.stdout or "{}")
|
|
except Exception as exc: # noqa: BLE001
|
|
raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})")
|
|
|
|
|
|
def _docker_available() -> bool:
|
|
if shutil.which("docker") is None:
|
|
return False
|
|
probe = _run(["docker", "info"], check=False)
|
|
return probe.returncode == 0
|
|
|
|
|
|
def _http_get(url: str, timeout: float = 2.0) -> str:
|
|
with urllib.request.urlopen(url, timeout=timeout) as resp: # nosec B310 - test loopback endpoint only
|
|
return resp.read().decode("utf-8", errors="replace")
|
|
|
|
|
|
def _find_free_loopback_port() -> int:
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
sock.bind(("127.0.0.1", 0))
|
|
return int(sock.getsockname()[1])
|
|
|
|
|
|
def _start_container(image_tag: str, container_name: str, pubkey: str, host_ssh_port: int) -> None:
|
|
for _ in range(20):
|
|
proc = _run(
|
|
[
|
|
"docker",
|
|
"run",
|
|
"-d",
|
|
"--rm",
|
|
"--name",
|
|
container_name,
|
|
"-e",
|
|
f"AUTHORIZED_KEY={pubkey}",
|
|
"-e",
|
|
f"REMOTE_HTTP_PORT={REMOTE_HTTP_PORT}",
|
|
"-p",
|
|
f"127.0.0.1:{host_ssh_port}:22",
|
|
image_tag,
|
|
],
|
|
check=False,
|
|
)
|
|
if proc.returncode == 0:
|
|
return
|
|
time.sleep(0.5)
|
|
merged = f"{proc.stdout}\n{proc.stderr}".strip()
|
|
raise cmuxError(f"Failed to start ssh test container on fixed port {host_ssh_port}: {merged}")
|
|
|
|
|
|
def _wait_remote_connected(client: cmux, workspace_id: str, timeout: float) -> dict:
|
|
deadline = time.time() + timeout
|
|
last_status = {}
|
|
while time.time() < deadline:
|
|
last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {}
|
|
remote = last_status.get("remote") or {}
|
|
forwarded = set(int(x) for x in (remote.get("forwarded_ports") or []) if str(x).isdigit())
|
|
if str(remote.get("state") or "") == "connected" and REMOTE_HTTP_PORT in forwarded:
|
|
return last_status
|
|
time.sleep(0.5)
|
|
raise cmuxError(f"Remote did not reach connected+forwarded state: {last_status}")
|
|
|
|
|
|
def _wait_remote_degraded(client: cmux, workspace_id: str, timeout: float) -> dict:
|
|
deadline = time.time() + timeout
|
|
last_status = {}
|
|
while time.time() < deadline:
|
|
last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {}
|
|
remote = last_status.get("remote") or {}
|
|
state = str(remote.get("state") or "")
|
|
if state in {"error", "connecting", "disconnected"}:
|
|
return last_status
|
|
time.sleep(0.5)
|
|
raise cmuxError(f"Remote did not enter reconnecting/degraded state: {last_status}")
|
|
|
|
|
|
def main() -> int:
|
|
if not _docker_available():
|
|
print("SKIP: docker is not available")
|
|
return 0
|
|
|
|
cli = _find_cli_binary()
|
|
repo_root = Path(__file__).resolve().parents[1]
|
|
fixture_dir = repo_root / "tests" / "fixtures" / "ssh-remote"
|
|
_must(fixture_dir.is_dir(), f"Missing docker fixture directory: {fixture_dir}")
|
|
|
|
temp_dir = Path(tempfile.mkdtemp(prefix="cmux-ssh-reconnect-"))
|
|
image_tag = f"cmux-ssh-test:{secrets.token_hex(4)}"
|
|
container_name = f"cmux-ssh-reconnect-{secrets.token_hex(4)}"
|
|
host_ssh_port = _find_free_loopback_port()
|
|
workspace_id = ""
|
|
container_running = False
|
|
|
|
try:
|
|
key_path = temp_dir / "id_ed25519"
|
|
_run(["ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(key_path)])
|
|
pubkey = (key_path.with_suffix(".pub")).read_text(encoding="utf-8").strip()
|
|
_must(bool(pubkey), "Generated SSH public key was empty")
|
|
|
|
_run(["docker", "build", "-t", image_tag, str(fixture_dir)])
|
|
_start_container(image_tag, container_name, pubkey, host_ssh_port)
|
|
container_running = True
|
|
|
|
with cmux(SOCKET_PATH) as client:
|
|
payload = _run_cli_json(
|
|
cli,
|
|
[
|
|
"ssh",
|
|
"root@127.0.0.1",
|
|
"--name",
|
|
"docker-ssh-reconnect",
|
|
"--port",
|
|
str(host_ssh_port),
|
|
"--identity",
|
|
str(key_path),
|
|
"--ssh-option",
|
|
"UserKnownHostsFile=/dev/null",
|
|
"--ssh-option",
|
|
"StrictHostKeyChecking=no",
|
|
],
|
|
)
|
|
workspace_id = str(payload.get("workspace_id") or "")
|
|
workspace_ref = str(payload.get("workspace_ref") or "")
|
|
if not workspace_id and workspace_ref.startswith("workspace:"):
|
|
listed = client._call("workspace.list", {}) or {}
|
|
for row in listed.get("workspaces") or []:
|
|
if str(row.get("ref") or "") == workspace_ref:
|
|
workspace_id = str(row.get("id") or "")
|
|
break
|
|
_must(bool(workspace_id), f"cmux ssh output missing workspace_id: {payload}")
|
|
|
|
first_status = _wait_remote_connected(client, workspace_id, timeout=45.0)
|
|
first_daemon = ((first_status.get("remote") or {}).get("daemon") or {})
|
|
_must(str(first_daemon.get("state") or "") == "ready", f"daemon should be ready after first connect: {first_status}")
|
|
|
|
first_body = ""
|
|
first_deadline_http = time.time() + 15.0
|
|
while time.time() < first_deadline_http:
|
|
try:
|
|
first_body = _http_get(f"http://127.0.0.1:{REMOTE_HTTP_PORT}/")
|
|
except Exception:
|
|
time.sleep(0.5)
|
|
continue
|
|
if "cmux-ssh-forward-ok" in first_body:
|
|
break
|
|
time.sleep(0.3)
|
|
_must("cmux-ssh-forward-ok" in first_body, f"Forwarded HTTP endpoint failed before reconnect: {first_body[:120]!r}")
|
|
|
|
_run(["docker", "rm", "-f", container_name], check=False)
|
|
container_running = False
|
|
_wait_remote_degraded(client, workspace_id, timeout=20.0)
|
|
|
|
_start_container(image_tag, container_name, pubkey, host_ssh_port)
|
|
container_running = True
|
|
|
|
second_status = _wait_remote_connected(client, workspace_id, timeout=60.0)
|
|
second_daemon = ((second_status.get("remote") or {}).get("daemon") or {})
|
|
_must(str(second_daemon.get("state") or "") == "ready", f"daemon should be ready after reconnect: {second_status}")
|
|
|
|
second_body = ""
|
|
deadline_http = time.time() + 15.0
|
|
while time.time() < deadline_http:
|
|
try:
|
|
second_body = _http_get(f"http://127.0.0.1:{REMOTE_HTTP_PORT}/")
|
|
except Exception:
|
|
time.sleep(0.5)
|
|
continue
|
|
if "cmux-ssh-forward-ok" in second_body:
|
|
break
|
|
time.sleep(0.3)
|
|
_must("cmux-ssh-forward-ok" in second_body, f"Forwarded HTTP endpoint failed after reconnect: {second_body[:120]!r}")
|
|
|
|
try:
|
|
client.close_workspace(workspace_id)
|
|
except Exception:
|
|
pass
|
|
workspace_id = ""
|
|
|
|
print("PASS: docker SSH remote reconnects and re-establishes forwarded ports")
|
|
return 0
|
|
|
|
finally:
|
|
if workspace_id:
|
|
try:
|
|
with cmux(SOCKET_PATH) as cleanup_client:
|
|
cleanup_client.close_workspace(workspace_id)
|
|
except Exception:
|
|
pass
|
|
|
|
if container_running:
|
|
_run(["docker", "rm", "-f", container_name], check=False)
|
|
_run(["docker", "rmi", "-f", image_tag], check=False)
|
|
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|