sql-harness 0.1.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,293 @@
1
+ """SSH driver — generic remote-shell channel (paramiko-based).
2
+
3
+ A "connection" with driver=ssh opens a paramiko SSHClient (interactive shell
4
+ channel + optional SFTP). It is treated as a Workspace like any DB engine:
5
+ lazy open, cached for the process lifetime, disposed on close.
6
+
7
+ URL format:
8
+ ssh://user[:password]@host[:port][?key=/path/to/private_key]
9
+ ssh+password://user:password@host:port
10
+ ssh+key://user@host:port?key=/path/to/private_key
11
+ ssh://user@host:port (uses BH_SSH_KEY env or ~/.ssh/id_rsa default)
12
+
13
+ If `?key=...` is omitted, paramiko falls back to:
14
+ 1. $BH_SSH_KEY env var
15
+ 2. ~/.ssh/id_rsa
16
+ 3. password-less (no auth — likely to fail)
17
+
18
+ Use the `Workspace.client` attribute (and `.sftp`) for shell + file ops.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import os
24
+ import shlex
25
+ import time
26
+ from dataclasses import dataclass
27
+ from typing import Any
28
+
29
+ import paramiko
30
+
31
+
32
+ SSH_DEFAULT_PORT = 22
33
+ KEY_FALLBACK = ("~/.ssh/id_ed25519", "~/.ssh/id_rsa", "~/.ssh/id_ecdsa")
34
+ KEY_ENV = "BH_SSH_KEY"
35
+ PASSWORD_ENV = "BH_SSH_PASSWORD"
36
+
37
+
38
+ @dataclass
39
+ class CommandResult:
40
+ """Result of an SSH command execution."""
41
+
42
+ command: str
43
+ stdout: str
44
+ stderr: str
45
+ exit_code: int
46
+ duration_ms: float
47
+
48
+ @property
49
+ def ok(self) -> bool:
50
+ return self.exit_code == 0
51
+
52
+ def to_dict(self) -> dict[str, Any]:
53
+ return {
54
+ "command": self.command,
55
+ "stdout": self.stdout,
56
+ "stderr": self.stderr,
57
+ "exit_code": self.exit_code,
58
+ "duration_ms": self.duration_ms,
59
+ "ok": self.ok,
60
+ }
61
+
62
+
63
+ class SshDriver:
64
+ """Drives a paramiko SSHClient + interactive shell channel as a Workspace.
65
+
66
+ Not registered for SQL — sql-harness callers don't use sql() on this
67
+ workspace. Use .client / .sftp directly.
68
+ """
69
+
70
+ name = "ssh"
71
+
72
+ def make_engine(self, url: str, pool): # noqa: D401 — mirror DB Driver API
73
+ """Return a live `SshClient` (opened). Caller owns it.
74
+
75
+ PoolConfig is ignored for SSH (no pooling). We accept it for API parity
76
+ with DB drivers.
77
+ """
78
+ host, port, username, password, key_path = _parse_url(url)
79
+ client = _open_client(
80
+ host=host,
81
+ port=port,
82
+ username=username,
83
+ password=password,
84
+ key_path=key_path,
85
+ )
86
+ # Attach a long-lived shell channel (paramiko's transport keeps it
87
+ # alive). For one-shot commands, callers may use exec_command().
88
+ shell = client.invoke_shell(term="xterm", width=200, height=50)
89
+ shell.settimeout(5.0)
90
+ try:
91
+ sftp = client.open_sftp()
92
+ except Exception:
93
+ sftp = None
94
+ return _SshHandle(client=client, shell=shell, sftp=sftp, url=url)
95
+
96
+ def list_tables(self, handle, schema=None):
97
+ """Not applicable for SSH — raise to fail loudly."""
98
+ raise NotImplementedError("SSH workspaces have no tables; use ssh_exec()")
99
+
100
+ def describe(self, handle, table, schema=None):
101
+ raise NotImplementedError("SSH workspaces have no tables")
102
+
103
+
104
+ class _SshHandle:
105
+ """Live SSH client + shell + SFTP, held by Workspace.engine.
106
+
107
+ Public surface (the bits agents actually call):
108
+ - .client → paramiko.SSHClient
109
+ - .sftp → paramiko.SFTPClient (or None if subsystem unavailable)
110
+ - .exec(cmd, timeout=30) → CommandResult (stdout/stderr/exit_code)
111
+ - .upload(src, dst) → None (local→remote)
112
+ - .download(src, dst) → None (remote→local)
113
+ - .close() → tear down
114
+ """
115
+
116
+ def __init__(self, client, shell, sftp, url: str):
117
+ self.client = client
118
+ self._shell = shell
119
+ self.sftp = sftp
120
+ self._url = url
121
+ self._shell_buf = ""
122
+
123
+ # --- one-shot command (preferred for non-interactive) ----------------
124
+
125
+ def exec(self, command: str, timeout: float = 30.0) -> CommandResult:
126
+ """Run `command` and wait for completion.
127
+
128
+ Uses exec_command (returns a single result tuple), NOT the interactive
129
+ shell — safer for parsing. Use the shell via .client.invoke_shell()
130
+ if you need a long-running interactive session.
131
+ """
132
+ t0 = time.monotonic()
133
+ stdin, stdout, stderr = self.client.exec_command(command, timeout=timeout)
134
+ # Close stdin so the remote side sees EOF and exits cleanly.
135
+ stdin.close()
136
+ # readlines() waits for EOF → exit.
137
+ out = stdout.read().decode("utf-8", errors="replace")
138
+ err = stderr.read().decode("utf-8", errors="replace")
139
+ code = stdout.channel.recv_exit_status()
140
+ dt = (time.monotonic() - t0) * 1000
141
+ return CommandResult(command, out, err, code, round(dt, 2))
142
+
143
+ # --- file transfer (SFTP) ------------------------------------------------
144
+
145
+ def upload(self, local_path: str, remote_path: str) -> None:
146
+ """Copy local file → remote. Creates remote parent dirs."""
147
+ if self.sftp is None:
148
+ raise RuntimeError("SFTP subsystem not available on this host")
149
+ _ensure_remote_dir(self.sftp, os.path.dirname(remote_path) or ".")
150
+ self.sftp.put(local_path, remote_path)
151
+
152
+ def download(self, remote_path: str, local_path: str) -> None:
153
+ """Copy remote file → local. Creates local parent dirs."""
154
+ if self.sftp is None:
155
+ raise RuntimeError("SFTP subsystem not available on this host")
156
+ Path(local_path).parent.mkdir(parents=True, exist_ok=True)
157
+ self.sftp.get(remote_path, local_path)
158
+
159
+ # --- context manager helpers --------------------------------------------
160
+
161
+ def close(self) -> None:
162
+ try:
163
+ if self.sftp is not None:
164
+ self.sftp.close()
165
+ finally:
166
+ try:
167
+ self._shell.close()
168
+ finally:
169
+ self.client.close()
170
+
171
+
172
+ # --- URL parsing + connection bootstrap ----------------------------------
173
+
174
+
175
+ def _parse_url(url: str) -> tuple[str, int, str, str | None, str | None]:
176
+ """Extract (host, port, username, password, key_path) from ssh:// URL.
177
+
178
+ Accepts:
179
+ ssh://user:pw@host:port?key=/path
180
+ ssh://user@host:port (auth from env / default key)
181
+ ssh+password://user:pw@host:port
182
+ ssh+key://user@host:port?key=...
183
+ """
184
+ from urllib.parse import urlparse, parse_qs
185
+
186
+ p = urlparse(url)
187
+ scheme = p.scheme
188
+ if not scheme.startswith("ssh"):
189
+ raise ValueError(f"not an ssh URL: {url!r}")
190
+ host = p.hostname
191
+ if not host:
192
+ raise ValueError(f"missing host in URL: {url!r}")
193
+ port = p.port or SSH_DEFAULT_PORT
194
+ username = p.username or "root"
195
+ password = p.password
196
+ query = parse_qs(p.query)
197
+ key_path = query.get("key", [None])[0]
198
+ # Scheme hints
199
+ if scheme == "ssh+password" and not password:
200
+ raise ValueError(f"ssh+password:// requires a password: {url!r}")
201
+ return host, port, username, password, key_path
202
+
203
+
204
+ def _resolve_key_path(explicit: str | None) -> str | None:
205
+ """Resolve a key path: explicit > $BH_SSH_KEY > ~/.ssh/{ed25519,id_rsa,id_ecdsa}."""
206
+ import os.path as _op
207
+
208
+ def expand(p: str) -> str:
209
+ return _op.expanduser(_op.expandvars(p))
210
+
211
+ if explicit:
212
+ p = expand(explicit)
213
+ if not _op.exists(p):
214
+ raise FileNotFoundError(f"SSH key not found: {p}")
215
+ return p
216
+ env = os.environ.get(KEY_ENV)
217
+ if env:
218
+ p = expand(env)
219
+ if _op.exists(p):
220
+ return p
221
+ for cand in KEY_FALLBACK:
222
+ p = expand(cand)
223
+ if _op.exists(p):
224
+ return p
225
+ return None
226
+
227
+
228
+ def _open_client(
229
+ *, host: str, port: int, username: str, password: str | None, key_path: str | None
230
+ ) -> paramiko.SSHClient:
231
+ """Open a paramiko SSHClient, with policy that trusts the user's known_hosts."""
232
+ client = paramiko.SSHClient()
233
+ # Auto-add host keys (like `ssh` does on first connect, when
234
+ # StrictHostKeyChecking=accept-new is set).
235
+ client.load_system_host_keys()
236
+ client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
237
+ key_file = _resolve_key_path(key_path)
238
+ try:
239
+ if key_file is not None:
240
+ client.connect(
241
+ hostname=host,
242
+ port=port,
243
+ username=username,
244
+ password=password,
245
+ key_filename=key_file,
246
+ timeout=15.0,
247
+ allow_agent=True,
248
+ look_for_keys=True,
249
+ )
250
+ else:
251
+ client.connect(
252
+ hostname=host,
253
+ port=port,
254
+ username=username,
255
+ password=password or os.environ.get(PASSWORD_ENV),
256
+ timeout=15.0,
257
+ allow_agent=False,
258
+ look_for_keys=False,
259
+ )
260
+ except paramiko.AuthenticationException as e:
261
+ raise RuntimeError(
262
+ f"SSH auth failed for {username}@{host}:{port}: {e}\n"
263
+ f"(provide ?key= or set $BH_SSH_KEY for key auth, "
264
+ f"or $BH_SSH_PASSWORD / embed password in URL for password auth)"
265
+ ) from e
266
+ return client
267
+
268
+
269
+ def _ensure_remote_dir(sftp, remote_dir: str) -> None:
270
+ """mkdir -p on the remote side, no error if exists."""
271
+ remote_dir = remote_dir.rstrip("/")
272
+ if not remote_dir or remote_dir == "." or remote_dir == "/":
273
+ return
274
+ try:
275
+ sftp.stat(remote_dir)
276
+ except IOError:
277
+ # Recurse up.
278
+ parent = "/".join(remote_dir.split("/")[:-1])
279
+ _ensure_remote_dir(sftp, parent)
280
+ sftp.mkdir(remote_dir)
281
+
282
+
283
+ def run_remote_script(handle, local_script_path: str, remote_dir: str, interpreter: str = "bash") -> CommandResult:
284
+ """Upload a local script to remote_dir and execute it.
285
+
286
+ Convenience for `sql-harness ssh run-script`. Returns the CommandResult.
287
+ """
288
+ import os.path as _op
289
+ fname = _op.basename(local_script_path)
290
+ remote_path = remote_dir.rstrip("/") + "/" + fname
291
+ handle.upload(local_script_path, remote_path)
292
+ cmd = f"{shlex.quote(interpreter)} {shlex.quote(remote_path)}"
293
+ return handle.exec(cmd)