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.
- sql_harness/SKILL.md +61 -0
- sql_harness/__init__.py +27 -0
- sql_harness/agent_loader.py +55 -0
- sql_harness/cli.py +869 -0
- sql_harness/config.py +241 -0
- sql_harness/drivers/__init__.py +59 -0
- sql_harness/drivers/mysql.py +43 -0
- sql_harness/drivers/postgres.py +55 -0
- sql_harness/drivers/redis.py +33 -0
- sql_harness/drivers/sqlite.py +39 -0
- sql_harness/drivers/ssh.py +293 -0
- sql_harness/helpers.py +789 -0
- sql_harness/manager.py +122 -0
- sql_harness/paths.py +112 -0
- sql_harness/run.py +85 -0
- sql_harness-0.1.0.dist-info/METADATA +137 -0
- sql_harness-0.1.0.dist-info/RECORD +19 -0
- sql_harness-0.1.0.dist-info/WHEEL +4 -0
- sql_harness-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -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)
|