@neoline/hostbridge 1.4.4 → 2.0.1

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,108 @@
1
+ """Deterministic HostBridge installation diagnostics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import tomllib
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Protocol
10
+
11
+ from . import __version__
12
+ from .config import load_v1_config
13
+ from .runtime import runtime_paths
14
+
15
+
16
+ class DoctorClient(Protocol):
17
+ def hello(self) -> dict[str, object]:
18
+ raise NotImplementedError
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class DiagnosticResult:
23
+ name: str
24
+ status: str
25
+ message: str
26
+ details: dict[str, object]
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class DoctorReport:
31
+ results: tuple[DiagnosticResult, ...]
32
+
33
+ @property
34
+ def ok(self) -> bool:
35
+ return all(result.status != "fail" for result in self.results)
36
+
37
+
38
+ def collect_diagnostics(
39
+ config_path: Path | str,
40
+ *,
41
+ client: DoctorClient,
42
+ executable_paths: list[str],
43
+ ) -> DoctorReport:
44
+ results = [
45
+ _version_diagnostic(),
46
+ _config_diagnostic(config_path),
47
+ _runtime_diagnostic(),
48
+ _daemon_diagnostic(client),
49
+ _executable_diagnostic(executable_paths),
50
+ ]
51
+ return DoctorReport(tuple(results))
52
+
53
+
54
+ def _version_diagnostic() -> DiagnosticResult:
55
+ root = Path(__file__).parents[2]
56
+ npm = json.loads((root / "package.json").read_text(encoding="utf-8"))
57
+ pyproject = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8"))
58
+ versions = {
59
+ "runtime": __version__,
60
+ "npm": npm.get("version"),
61
+ "python": pyproject.get("project", {}).get("version"),
62
+ }
63
+ status = "pass" if len(set(versions.values())) == 1 and __version__ == "2.0.1" else "fail"
64
+ return DiagnosticResult("version", status, "version sources agree" if status == "pass" else "version drift detected", versions)
65
+
66
+
67
+ def _config_diagnostic(config_path: Path | str) -> DiagnosticResult:
68
+ try:
69
+ config = load_v1_config(config_path)
70
+ except (OSError, ValueError) as exc:
71
+ return DiagnosticResult("config", "fail", str(exc), {"path": str(config_path)})
72
+ return DiagnosticResult("config", "pass", "schema v1 configuration is valid", {"path": str(config.path), "hosts": len(config.hosts)})
73
+
74
+
75
+ def _runtime_diagnostic() -> DiagnosticResult:
76
+ paths = runtime_paths()
77
+ try:
78
+ paths.ensure_directory()
79
+ except OSError as exc:
80
+ return DiagnosticResult("runtime", "fail", str(exc), {"directory": str(paths.directory)})
81
+ mode = paths.directory.stat().st_mode & 0o777
82
+ status = "pass" if mode == 0o700 else "fail"
83
+ return DiagnosticResult(
84
+ "runtime",
85
+ status,
86
+ "runtime directory is private" if status == "pass" else "runtime directory must use mode 0700",
87
+ {"directory": str(paths.directory), "mode": format(mode, "04o")},
88
+ )
89
+
90
+
91
+ def _daemon_diagnostic(client: DoctorClient) -> DiagnosticResult:
92
+ try:
93
+ hello = client.hello()
94
+ except Exception as exc:
95
+ return DiagnosticResult("daemon", "fail", f"daemon unavailable: {exc}", {})
96
+ valid = hello.get("service") == "hostbridge" and hello.get("protocol") == 1
97
+ return DiagnosticResult(
98
+ "daemon",
99
+ "pass" if valid else "fail",
100
+ "daemon protocol v1 is healthy" if valid else "daemon returned an incompatible handshake",
101
+ dict(hello),
102
+ )
103
+
104
+
105
+ def _executable_diagnostic(paths: list[str]) -> DiagnosticResult:
106
+ if not paths:
107
+ return DiagnosticResult("executable", "fail", "hostbridge executable was not found", {"paths": []})
108
+ return DiagnosticResult("executable", "pass", "hostbridge executable found", {"paths": paths})
@@ -34,11 +34,12 @@ class HostConfig:
34
34
  secrets: dict[str, str] = field(default_factory=dict)
35
35
  login_steps: list[LoginStep] = field(default_factory=list)
36
36
  ready_expect: str | None = None
37
+ transport: str = "auto"
37
38
 
38
39
  @classmethod
39
- def script(cls, host_id: str, label: str, script: Path | str) -> HostConfig:
40
+ def script(cls, host_id: str, label: str, script: Path | str, *, transport: str = "auto") -> HostConfig:
40
41
  path = Path(script).expanduser()
41
- return cls(host_id, label, "/bin/sh", [str(path)], "script", path)
42
+ return cls(host_id, label, "/bin/sh", [str(path)], "script", path, transport=transport)
42
43
 
43
44
  @classmethod
44
45
  def command_line(
@@ -52,6 +53,7 @@ class HostConfig:
52
53
  secrets: dict[str, str] | None = None,
53
54
  login_steps: list[LoginStep] | None = None,
54
55
  ready_expect: str | None = None,
56
+ transport: str = "auto",
55
57
  ) -> HostConfig:
56
58
  return cls(
57
59
  host_id,
@@ -63,6 +65,7 @@ class HostConfig:
63
65
  secrets or {},
64
66
  login_steps or [],
65
67
  ready_expect,
68
+ transport,
66
69
  )
67
70
 
68
71
  def describe(self) -> dict[str, object]:
@@ -72,6 +75,7 @@ class HostConfig:
72
75
  "connection_type": self.connection_type,
73
76
  "command": self.command,
74
77
  "args": list(self.args),
78
+ "transport": self.transport,
75
79
  }
76
80
  if self.script_path is not None:
77
81
  info.update(
@@ -130,16 +134,40 @@ def _parse_host(item: object, path: Path, index: int) -> HostConfig:
130
134
  secrets = _parse_secrets(item.get("secrets", {}), path, index)
131
135
  login_steps = _parse_login_steps(item.get("login_steps", []), path, index)
132
136
  ready_expect = _parse_ready_expect(item.get("ready_expect"), path, index)
137
+ transport = str(item.get("transport", "auto")).strip().lower()
138
+ if transport not in {"auto", "pty", "ssh"}:
139
+ raise ValueError(f"{path} hosts[{index}].transport must be one of: auto, pty, ssh")
133
140
 
134
141
  if "script" in item:
135
142
  script = str(item.get("script", "")).strip()
136
143
  if not script:
137
144
  raise ValueError(f"{path} hosts[{index}].script must not be empty")
138
- host = HostConfig.script(host_id, label, script)
139
- return HostConfig(host.id, host.label, host.command, host.args, host.connection_type, host.script_path, secrets, login_steps, ready_expect)
145
+ host = HostConfig.script(host_id, label, script, transport=transport)
146
+ return HostConfig(
147
+ host.id,
148
+ host.label,
149
+ host.command,
150
+ host.args,
151
+ host.connection_type,
152
+ host.script_path,
153
+ secrets,
154
+ login_steps,
155
+ ready_expect,
156
+ transport,
157
+ )
140
158
 
141
159
  if "ssh" in item:
142
- return HostConfig.command_line(host_id, label, "ssh", _parse_ssh_args(item["ssh"], path, index), connection_type="ssh", secrets=secrets, login_steps=login_steps, ready_expect=ready_expect)
160
+ return HostConfig.command_line(
161
+ host_id,
162
+ label,
163
+ "ssh",
164
+ _parse_ssh_args(item["ssh"], path, index),
165
+ connection_type="ssh",
166
+ secrets=secrets,
167
+ login_steps=login_steps,
168
+ ready_expect=ready_expect,
169
+ transport=transport,
170
+ )
143
171
 
144
172
  command = str(item.get("command", "")).strip()
145
173
  args = item.get("args", [])
@@ -147,7 +175,16 @@ def _parse_host(item: object, path: Path, index: int) -> HostConfig:
147
175
  raise ValueError(f"{path} hosts[{index}].command must not be empty")
148
176
  if not isinstance(args, list) or not all(isinstance(arg, str) for arg in args):
149
177
  raise ValueError(f"{path} hosts[{index}].args must be a list of strings")
150
- return HostConfig.command_line(host_id, label, command, args, secrets=secrets, login_steps=login_steps, ready_expect=ready_expect)
178
+ return HostConfig.command_line(
179
+ host_id,
180
+ label,
181
+ command,
182
+ args,
183
+ secrets=secrets,
184
+ login_steps=login_steps,
185
+ ready_expect=ready_expect,
186
+ transport=transport,
187
+ )
151
188
 
152
189
 
153
190
  def _parse_ready_expect(raw_ready_expect: object, path: Path, index: int) -> str | None:
@@ -196,7 +233,15 @@ def _parse_ssh_args(raw_ssh: object, path: Path, index: int) -> list[str]:
196
233
  args: list[str] = []
197
234
  port = raw_ssh.get("port")
198
235
  if port not in (None, ""):
199
- args.extend(["-p", str(int(port))])
236
+ if isinstance(port, bool) or not isinstance(port, int | str):
237
+ raise ValueError(f"{path} hosts[{index}].ssh.port must be an integer")
238
+ try:
239
+ parsed_port = int(port)
240
+ except ValueError as exc:
241
+ raise ValueError(f"{path} hosts[{index}].ssh.port must be an integer") from exc
242
+ if not 1 <= parsed_port <= 65535:
243
+ raise ValueError(f"{path} hosts[{index}].ssh.port must be between 1 and 65535")
244
+ args.extend(["-p", str(parsed_port)])
200
245
  identity_file = str(raw_ssh.get("identity_file", "")).strip()
201
246
  if identity_file:
202
247
  args.extend(["-i", str(Path(identity_file).expanduser())])