@melaya/runner 1.1.42 → 1.1.44
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.
- package/dist/localDbProbe.py +295 -252
- package/localDbProbe.py +295 -252
- package/package.json +1 -1
package/dist/localDbProbe.py
CHANGED
|
@@ -1,252 +1,295 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
localDbProbe.py
|
|
4
|
-
RUNNER on behalf of the Melaya server.
|
|
5
|
-
|
|
6
|
-
Why it exists: some databases sit behind an IP allow-list or inside a VPC that
|
|
7
|
-
the Melaya cloud can never reach, but the user's own runner (on their LAN /
|
|
8
|
-
bastion) can. The server hands us the resolved credentials over the already-
|
|
9
|
-
authenticated Socket.IO channel; we open a single real connection from THIS
|
|
10
|
-
machine's network vantage point, run a trivial read-only probe, and print the
|
|
11
|
-
outcome. We never persist the credentials and never write to the database.
|
|
12
|
-
|
|
13
|
-
Contract:
|
|
14
|
-
stdin : one JSON object {"service": "...", "creds": { ... }}
|
|
15
|
-
stdout : one JSON object {"ok": bool, "message"|"error": "..."}
|
|
16
|
-
Exit code is always 0 (the result is the JSON on stdout); any crash is caught
|
|
17
|
-
and reported as {"ok": false, "error": ...}.
|
|
18
|
-
"""
|
|
19
|
-
import sys
|
|
20
|
-
import json
|
|
21
|
-
import base64
|
|
22
|
-
import hashlib
|
|
23
|
-
import urllib.parse
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
def _out(ok, text):
|
|
27
|
-
key = "message" if ok else "error"
|
|
28
|
-
sys.stdout.write(json.dumps({"ok": bool(ok), key: str(text)[:300]}))
|
|
29
|
-
sys.stdout.flush()
|
|
30
|
-
sys.exit(0)
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def _pick(creds, *keys):
|
|
34
|
-
for k in keys:
|
|
35
|
-
v = (creds.get(k) or "").strip()
|
|
36
|
-
if v:
|
|
37
|
-
return v
|
|
38
|
-
return ""
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
if not
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
conn.
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
r
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
_out(True, f"Connected to
|
|
202
|
-
_out(False, f"
|
|
203
|
-
except Exception as e: # noqa: BLE001
|
|
204
|
-
_out(False, f"
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
# ──
|
|
208
|
-
def
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
localDbProbe.py - one-shot, read-only database reachability probe run BY THE
|
|
4
|
+
RUNNER on behalf of the Melaya server.
|
|
5
|
+
|
|
6
|
+
Why it exists: some databases sit behind an IP allow-list or inside a VPC that
|
|
7
|
+
the Melaya cloud can never reach, but the user's own runner (on their LAN /
|
|
8
|
+
bastion) can. The server hands us the resolved credentials over the already-
|
|
9
|
+
authenticated Socket.IO channel; we open a single real connection from THIS
|
|
10
|
+
machine's network vantage point, run a trivial read-only probe, and print the
|
|
11
|
+
outcome. We never persist the credentials and never write to the database.
|
|
12
|
+
|
|
13
|
+
Contract:
|
|
14
|
+
stdin : one JSON object {"service": "...", "creds": { ... }}
|
|
15
|
+
stdout : one JSON object {"ok": bool, "message"|"error": "..."}
|
|
16
|
+
Exit code is always 0 (the result is the JSON on stdout); any crash is caught
|
|
17
|
+
and reported as {"ok": false, "error": ...}.
|
|
18
|
+
"""
|
|
19
|
+
import sys
|
|
20
|
+
import json
|
|
21
|
+
import base64
|
|
22
|
+
import hashlib
|
|
23
|
+
import urllib.parse
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _out(ok, text):
|
|
27
|
+
key = "message" if ok else "error"
|
|
28
|
+
sys.stdout.write(json.dumps({"ok": bool(ok), key: str(text)[:300]}))
|
|
29
|
+
sys.stdout.flush()
|
|
30
|
+
sys.exit(0)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _pick(creds, *keys):
|
|
34
|
+
for k in keys:
|
|
35
|
+
v = (creds.get(k) or "").strip()
|
|
36
|
+
if v:
|
|
37
|
+
return v
|
|
38
|
+
return ""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
_PLACEHOLDER_HOSTS = {"host", "hostname", "your-host", "your_host", "<host>", "myhost", "dbhost", "example.com"}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _friendly_conn_error(e, host, port, engine):
|
|
45
|
+
"""Turn a raw driver/socket exception into an actionable message. The most
|
|
46
|
+
common failure is a copy-pasted EXAMPLE DSN (host == 'host') or a Docker/VPC
|
|
47
|
+
hostname that doesn't resolve from the runner's machine."""
|
|
48
|
+
import socket
|
|
49
|
+
msg = str(e) or e.__class__.__name__
|
|
50
|
+
low = msg.lower()
|
|
51
|
+
host = host or ""
|
|
52
|
+
is_dns = isinstance(e, socket.gaierror) or "nodename nor servname" in low \
|
|
53
|
+
or "name or service not known" in low or "getaddrinfo" in low \
|
|
54
|
+
or "name does not resolve" in low or "no address associated" in low
|
|
55
|
+
if is_dns:
|
|
56
|
+
if host.lower() in _PLACEHOLDER_HOSTS:
|
|
57
|
+
return (f"the host is literally \"{host}\" - that's the EXAMPLE DSN, not your database. "
|
|
58
|
+
f"Paste your real connection string with the actual host, e.g. an AWS RDS / Cloud SQL "
|
|
59
|
+
f"endpoint: {engine.lower()}://user:pass@your-db.abc123.us-east-1.rds.amazonaws.com:{port}/db.")
|
|
60
|
+
if host and "." not in host and host not in ("localhost",):
|
|
61
|
+
return (f"can't resolve host \"{host}\" from the runner's machine. A bare name like this is "
|
|
62
|
+
f"usually a Docker/compose service or a VPC-internal alias that only resolves elsewhere - "
|
|
63
|
+
f"use the host that's reachable from this machine (e.g. your RDS/Cloud SQL endpoint, or a VPN/bastion host).")
|
|
64
|
+
return (f"can't resolve host \"{host}\" from the runner's machine - check the host in your DSN, and that "
|
|
65
|
+
f"this machine can reach it (DNS + network route; for a private RDS/VPC DB the runner must sit in the VPC or on an allow-listed IP).")
|
|
66
|
+
if isinstance(e, ConnectionRefusedError) or "refused" in low:
|
|
67
|
+
return f"host \"{host}\" reachable but port {port} refused the connection - is {engine} listening there / bound to that interface?"
|
|
68
|
+
if isinstance(e, (TimeoutError,)) or "timed out" in low or "timeout" in low:
|
|
69
|
+
return f"timed out connecting to {host}:{port} - firewall, IP allow-list, or wrong host/port?"
|
|
70
|
+
if "password authentication failed" in low or "authentication failed" in low or "access denied" in low:
|
|
71
|
+
return "authentication failed - check the username/password in the DSN."
|
|
72
|
+
if 'database "' in low and "does not exist" in low or "unknown database" in low:
|
|
73
|
+
return f"connected, but the database name in the DSN doesn't exist: {msg}"
|
|
74
|
+
return msg
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ── PostgreSQL (asyncpg) ─────────────────────────────────────────────────────
|
|
78
|
+
def probe_postgres(creds):
|
|
79
|
+
dsn = _pick(creds, "dsn", "POSTGRES_DSN")
|
|
80
|
+
if not dsn:
|
|
81
|
+
_out(False, "Connection DSN required")
|
|
82
|
+
try:
|
|
83
|
+
import asyncio
|
|
84
|
+
import asyncpg # type: ignore
|
|
85
|
+
except ImportError:
|
|
86
|
+
_out(False, "asyncpg not installed on this runner (pip install asyncpg)")
|
|
87
|
+
p = urllib.parse.urlparse(dsn)
|
|
88
|
+
host, port = (p.hostname or ""), (p.port or 5432)
|
|
89
|
+
if not host:
|
|
90
|
+
_out(False, "PostgreSQL connection failed: no host in the DSN - expected postgresql://user:pass@HOST:5432/db")
|
|
91
|
+
|
|
92
|
+
async def _run():
|
|
93
|
+
conn = await asyncpg.connect(dsn, timeout=10)
|
|
94
|
+
try:
|
|
95
|
+
await conn.fetchval("SELECT 1")
|
|
96
|
+
ver = await conn.fetchval("SELECT version()")
|
|
97
|
+
finally:
|
|
98
|
+
await conn.close()
|
|
99
|
+
return ver or ""
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
ver = asyncio.run(asyncio.wait_for(_run(), timeout=15))
|
|
103
|
+
short = str(ver).split(" on ")[0] if ver else ""
|
|
104
|
+
_out(True, f"Connected to PostgreSQL{(' - ' + short) if short else ''}")
|
|
105
|
+
except Exception as e: # noqa: BLE001
|
|
106
|
+
_out(False, f"PostgreSQL connection failed: {_friendly_conn_error(e, host, port, 'PostgreSQL')}")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ── MySQL / MariaDB (PyMySQL - pure-python, sync) ────────────────────────────
|
|
110
|
+
def probe_mysql(creds):
|
|
111
|
+
dsn = _pick(creds, "dsn", "MYSQL_DSN")
|
|
112
|
+
if not dsn:
|
|
113
|
+
_out(False, "Connection DSN required")
|
|
114
|
+
try:
|
|
115
|
+
import pymysql # type: ignore
|
|
116
|
+
except ImportError:
|
|
117
|
+
_out(False, "PyMySQL not installed on this runner (pip install PyMySQL)")
|
|
118
|
+
p = urllib.parse.urlparse(dsn)
|
|
119
|
+
host, port = (p.hostname or ""), (p.port or 3306)
|
|
120
|
+
if not host:
|
|
121
|
+
_out(False, "MySQL connection failed: no host in the DSN - expected mysql://user:pass@HOST:3306/db")
|
|
122
|
+
try:
|
|
123
|
+
conn = pymysql.connect(
|
|
124
|
+
host=host,
|
|
125
|
+
port=port,
|
|
126
|
+
user=urllib.parse.unquote(p.username or ""),
|
|
127
|
+
password=urllib.parse.unquote(p.password or ""),
|
|
128
|
+
db=(p.path or "").lstrip("/") or None,
|
|
129
|
+
connect_timeout=10,
|
|
130
|
+
read_timeout=10,
|
|
131
|
+
)
|
|
132
|
+
try:
|
|
133
|
+
with conn.cursor() as cur:
|
|
134
|
+
cur.execute("SELECT VERSION()")
|
|
135
|
+
row = cur.fetchone()
|
|
136
|
+
ver = (row[0] if row else "") or ""
|
|
137
|
+
finally:
|
|
138
|
+
conn.close()
|
|
139
|
+
_out(True, f"Connected to MySQL{(' - ' + str(ver)) if ver else ''}")
|
|
140
|
+
except Exception as e: # noqa: BLE001
|
|
141
|
+
_out(False, f"MySQL connection failed: {_friendly_conn_error(e, host, port, 'MySQL')}")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ── Snowflake (key-pair JWT, mirrors the server handler) ─────────────────────
|
|
145
|
+
def probe_snowflake(creds):
|
|
146
|
+
account = _pick(creds, "account", "SNOWFLAKE_ACCOUNT")
|
|
147
|
+
user = _pick(creds, "user", "SNOWFLAKE_USER")
|
|
148
|
+
pk = _pick(creds, "private_key", "SNOWFLAKE_PRIVATE_KEY")
|
|
149
|
+
if not account or not user or not pk:
|
|
150
|
+
_out(False, "Account + Username + Private Key required")
|
|
151
|
+
try:
|
|
152
|
+
from cryptography.hazmat.primitives import serialization # type: ignore
|
|
153
|
+
import requests # type: ignore
|
|
154
|
+
except ImportError:
|
|
155
|
+
_out(False, "cryptography/requests not installed on this runner")
|
|
156
|
+
|
|
157
|
+
def b64url(b):
|
|
158
|
+
if isinstance(b, str):
|
|
159
|
+
b = b.encode()
|
|
160
|
+
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
import time
|
|
164
|
+
key = serialization.load_pem_private_key(pk.encode(), password=None)
|
|
165
|
+
der_spki = key.public_key().public_bytes(
|
|
166
|
+
serialization.Encoding.DER,
|
|
167
|
+
serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
168
|
+
)
|
|
169
|
+
fp = "SHA256:" + base64.b64encode(hashlib.sha256(der_spki).digest()).decode()
|
|
170
|
+
acct_upper = account.upper()
|
|
171
|
+
if "." in acct_upper:
|
|
172
|
+
acct_upper = acct_upper.split(".")[0]
|
|
173
|
+
qual = f"{acct_upper}.{user.upper()}"
|
|
174
|
+
now = int(time.time())
|
|
175
|
+
header = b64url(json.dumps({"alg": "RS256", "typ": "JWT"}))
|
|
176
|
+
payload = b64url(json.dumps(
|
|
177
|
+
{"iss": f"{qual}.{fp}", "sub": qual, "iat": now, "exp": now + 3540}))
|
|
178
|
+
signing_input = f"{header}.{payload}".encode()
|
|
179
|
+
from cryptography.hazmat.primitives import hashes # type: ignore
|
|
180
|
+
from cryptography.hazmat.primitives.asymmetric import padding # type: ignore
|
|
181
|
+
sig = key.sign(signing_input, padding.PKCS1v15(), hashes.SHA256())
|
|
182
|
+
jwt = f"{header}.{payload}.{b64url(sig)}"
|
|
183
|
+
host = f"https://{account.replace('_', '-').lower()}.snowflakecomputing.com"
|
|
184
|
+
r = requests.post(
|
|
185
|
+
f"{host}/api/v2/statements",
|
|
186
|
+
headers={
|
|
187
|
+
"Authorization": f"Bearer {jwt}",
|
|
188
|
+
"X-Snowflake-Authorization-Token-Type": "KEYPAIR_JWT",
|
|
189
|
+
"Content-Type": "application/json",
|
|
190
|
+
"Accept": "application/json",
|
|
191
|
+
},
|
|
192
|
+
json={"statement": "SELECT CURRENT_VERSION()", "timeout": 20},
|
|
193
|
+
timeout=15,
|
|
194
|
+
)
|
|
195
|
+
if r.ok:
|
|
196
|
+
v = ""
|
|
197
|
+
try:
|
|
198
|
+
v = (r.json().get("data") or [[None]])[0][0] or ""
|
|
199
|
+
except Exception: # noqa: BLE001
|
|
200
|
+
pass
|
|
201
|
+
_out(True, f"Connected to Snowflake{(' (v' + str(v) + ')') if v else ''}")
|
|
202
|
+
_out(False, f"Snowflake auth/query failed: HTTP {r.status_code} {r.text[:160]}")
|
|
203
|
+
except Exception as e: # noqa: BLE001
|
|
204
|
+
_out(False, f"Snowflake connection failed: {e}")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# ── Databricks (PAT or OAuth M2M, mirrors the server handler) ────────────────
|
|
208
|
+
def probe_databricks(creds):
|
|
209
|
+
host = _pick(creds, "workspace_url", "DATABRICKS_HOST").rstrip("/")
|
|
210
|
+
pat = _pick(creds, "token", "DATABRICKS_TOKEN")
|
|
211
|
+
cid = _pick(creds, "client_id", "DATABRICKS_CLIENT_ID")
|
|
212
|
+
sec = _pick(creds, "client_secret", "DATABRICKS_CLIENT_SECRET")
|
|
213
|
+
if not host:
|
|
214
|
+
_out(False, "Workspace URL required")
|
|
215
|
+
try:
|
|
216
|
+
import requests # type: ignore
|
|
217
|
+
except ImportError:
|
|
218
|
+
_out(False, "requests not installed on this runner")
|
|
219
|
+
try:
|
|
220
|
+
bearer = pat
|
|
221
|
+
if not bearer:
|
|
222
|
+
if not cid or not sec:
|
|
223
|
+
_out(False, "Provide a Personal Access Token, or Client ID + Client Secret")
|
|
224
|
+
tr = requests.post(
|
|
225
|
+
f"{host}/oidc/v1/token",
|
|
226
|
+
auth=(cid, sec),
|
|
227
|
+
data={"grant_type": "client_credentials", "scope": "all-apis"},
|
|
228
|
+
timeout=12,
|
|
229
|
+
)
|
|
230
|
+
tj = {}
|
|
231
|
+
try:
|
|
232
|
+
tj = tr.json()
|
|
233
|
+
except Exception: # noqa: BLE001
|
|
234
|
+
pass
|
|
235
|
+
if not tr.ok or not tj.get("access_token"):
|
|
236
|
+
_out(False, f"Databricks OAuth failed: {tj.get('error_description') or tj.get('error') or ('HTTP ' + str(tr.status_code))}")
|
|
237
|
+
bearer = tj["access_token"]
|
|
238
|
+
r = requests.get(
|
|
239
|
+
f"{host}/api/2.1/clusters/list?page_size=1",
|
|
240
|
+
headers={"Authorization": f"Bearer {bearer}"},
|
|
241
|
+
timeout=12,
|
|
242
|
+
)
|
|
243
|
+
if r.ok:
|
|
244
|
+
_out(True, f"Connected to Databricks ({host.replace('https://', '').replace('http://', '')})")
|
|
245
|
+
_out(False, f"Databricks unreachable: HTTP {r.status_code} {r.text[:120]}")
|
|
246
|
+
except Exception as e: # noqa: BLE001
|
|
247
|
+
_out(False, f"Databricks connection failed: {e}")
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
# ── SQLite (local file on the runner) ────────────────────────────────────────
|
|
251
|
+
def probe_sqlite(creds):
|
|
252
|
+
import os
|
|
253
|
+
import sqlite3
|
|
254
|
+
path = _pick(creds, "dsn", "db_path", "path")
|
|
255
|
+
if not path:
|
|
256
|
+
_out(False, "Database file path required")
|
|
257
|
+
if not os.path.exists(path):
|
|
258
|
+
_out(False, f"No SQLite file at {path}")
|
|
259
|
+
try:
|
|
260
|
+
conn = sqlite3.connect(path, timeout=8)
|
|
261
|
+
try:
|
|
262
|
+
conn.execute("SELECT 1")
|
|
263
|
+
finally:
|
|
264
|
+
conn.close()
|
|
265
|
+
_out(True, f"Opened SQLite database ({os.path.basename(path)})")
|
|
266
|
+
except Exception as e: # noqa: BLE001
|
|
267
|
+
_out(False, f"SQLite open failed: {e}")
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
_PROBES = {
|
|
271
|
+
"postgres": probe_postgres,
|
|
272
|
+
"mysql": probe_mysql,
|
|
273
|
+
"snowflake": probe_snowflake,
|
|
274
|
+
"databricks": probe_databricks,
|
|
275
|
+
"sqlite": probe_sqlite,
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def main():
|
|
280
|
+
try:
|
|
281
|
+
job = json.loads(sys.stdin.read() or "{}")
|
|
282
|
+
except Exception as e: # noqa: BLE001
|
|
283
|
+
_out(False, f"bad probe job: {e}")
|
|
284
|
+
return
|
|
285
|
+
service = (job.get("service") or "").strip()
|
|
286
|
+
creds = job.get("creds") or {}
|
|
287
|
+
fn = _PROBES.get(service)
|
|
288
|
+
if not fn:
|
|
289
|
+
_out(False, f"unsupported service: {service}")
|
|
290
|
+
return
|
|
291
|
+
fn(creds)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
if __name__ == "__main__":
|
|
295
|
+
main()
|
package/localDbProbe.py
CHANGED
|
@@ -1,252 +1,295 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
localDbProbe.py
|
|
4
|
-
RUNNER on behalf of the Melaya server.
|
|
5
|
-
|
|
6
|
-
Why it exists: some databases sit behind an IP allow-list or inside a VPC that
|
|
7
|
-
the Melaya cloud can never reach, but the user's own runner (on their LAN /
|
|
8
|
-
bastion) can. The server hands us the resolved credentials over the already-
|
|
9
|
-
authenticated Socket.IO channel; we open a single real connection from THIS
|
|
10
|
-
machine's network vantage point, run a trivial read-only probe, and print the
|
|
11
|
-
outcome. We never persist the credentials and never write to the database.
|
|
12
|
-
|
|
13
|
-
Contract:
|
|
14
|
-
stdin : one JSON object {"service": "...", "creds": { ... }}
|
|
15
|
-
stdout : one JSON object {"ok": bool, "message"|"error": "..."}
|
|
16
|
-
Exit code is always 0 (the result is the JSON on stdout); any crash is caught
|
|
17
|
-
and reported as {"ok": false, "error": ...}.
|
|
18
|
-
"""
|
|
19
|
-
import sys
|
|
20
|
-
import json
|
|
21
|
-
import base64
|
|
22
|
-
import hashlib
|
|
23
|
-
import urllib.parse
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
def _out(ok, text):
|
|
27
|
-
key = "message" if ok else "error"
|
|
28
|
-
sys.stdout.write(json.dumps({"ok": bool(ok), key: str(text)[:300]}))
|
|
29
|
-
sys.stdout.flush()
|
|
30
|
-
sys.exit(0)
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def _pick(creds, *keys):
|
|
34
|
-
for k in keys:
|
|
35
|
-
v = (creds.get(k) or "").strip()
|
|
36
|
-
if v:
|
|
37
|
-
return v
|
|
38
|
-
return ""
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
if not
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
conn.
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
r
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
_out(True, f"Connected to
|
|
202
|
-
_out(False, f"
|
|
203
|
-
except Exception as e: # noqa: BLE001
|
|
204
|
-
_out(False, f"
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
# ──
|
|
208
|
-
def
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
localDbProbe.py - one-shot, read-only database reachability probe run BY THE
|
|
4
|
+
RUNNER on behalf of the Melaya server.
|
|
5
|
+
|
|
6
|
+
Why it exists: some databases sit behind an IP allow-list or inside a VPC that
|
|
7
|
+
the Melaya cloud can never reach, but the user's own runner (on their LAN /
|
|
8
|
+
bastion) can. The server hands us the resolved credentials over the already-
|
|
9
|
+
authenticated Socket.IO channel; we open a single real connection from THIS
|
|
10
|
+
machine's network vantage point, run a trivial read-only probe, and print the
|
|
11
|
+
outcome. We never persist the credentials and never write to the database.
|
|
12
|
+
|
|
13
|
+
Contract:
|
|
14
|
+
stdin : one JSON object {"service": "...", "creds": { ... }}
|
|
15
|
+
stdout : one JSON object {"ok": bool, "message"|"error": "..."}
|
|
16
|
+
Exit code is always 0 (the result is the JSON on stdout); any crash is caught
|
|
17
|
+
and reported as {"ok": false, "error": ...}.
|
|
18
|
+
"""
|
|
19
|
+
import sys
|
|
20
|
+
import json
|
|
21
|
+
import base64
|
|
22
|
+
import hashlib
|
|
23
|
+
import urllib.parse
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _out(ok, text):
|
|
27
|
+
key = "message" if ok else "error"
|
|
28
|
+
sys.stdout.write(json.dumps({"ok": bool(ok), key: str(text)[:300]}))
|
|
29
|
+
sys.stdout.flush()
|
|
30
|
+
sys.exit(0)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _pick(creds, *keys):
|
|
34
|
+
for k in keys:
|
|
35
|
+
v = (creds.get(k) or "").strip()
|
|
36
|
+
if v:
|
|
37
|
+
return v
|
|
38
|
+
return ""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
_PLACEHOLDER_HOSTS = {"host", "hostname", "your-host", "your_host", "<host>", "myhost", "dbhost", "example.com"}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _friendly_conn_error(e, host, port, engine):
|
|
45
|
+
"""Turn a raw driver/socket exception into an actionable message. The most
|
|
46
|
+
common failure is a copy-pasted EXAMPLE DSN (host == 'host') or a Docker/VPC
|
|
47
|
+
hostname that doesn't resolve from the runner's machine."""
|
|
48
|
+
import socket
|
|
49
|
+
msg = str(e) or e.__class__.__name__
|
|
50
|
+
low = msg.lower()
|
|
51
|
+
host = host or ""
|
|
52
|
+
is_dns = isinstance(e, socket.gaierror) or "nodename nor servname" in low \
|
|
53
|
+
or "name or service not known" in low or "getaddrinfo" in low \
|
|
54
|
+
or "name does not resolve" in low or "no address associated" in low
|
|
55
|
+
if is_dns:
|
|
56
|
+
if host.lower() in _PLACEHOLDER_HOSTS:
|
|
57
|
+
return (f"the host is literally \"{host}\" - that's the EXAMPLE DSN, not your database. "
|
|
58
|
+
f"Paste your real connection string with the actual host, e.g. an AWS RDS / Cloud SQL "
|
|
59
|
+
f"endpoint: {engine.lower()}://user:pass@your-db.abc123.us-east-1.rds.amazonaws.com:{port}/db.")
|
|
60
|
+
if host and "." not in host and host not in ("localhost",):
|
|
61
|
+
return (f"can't resolve host \"{host}\" from the runner's machine. A bare name like this is "
|
|
62
|
+
f"usually a Docker/compose service or a VPC-internal alias that only resolves elsewhere - "
|
|
63
|
+
f"use the host that's reachable from this machine (e.g. your RDS/Cloud SQL endpoint, or a VPN/bastion host).")
|
|
64
|
+
return (f"can't resolve host \"{host}\" from the runner's machine - check the host in your DSN, and that "
|
|
65
|
+
f"this machine can reach it (DNS + network route; for a private RDS/VPC DB the runner must sit in the VPC or on an allow-listed IP).")
|
|
66
|
+
if isinstance(e, ConnectionRefusedError) or "refused" in low:
|
|
67
|
+
return f"host \"{host}\" reachable but port {port} refused the connection - is {engine} listening there / bound to that interface?"
|
|
68
|
+
if isinstance(e, (TimeoutError,)) or "timed out" in low or "timeout" in low:
|
|
69
|
+
return f"timed out connecting to {host}:{port} - firewall, IP allow-list, or wrong host/port?"
|
|
70
|
+
if "password authentication failed" in low or "authentication failed" in low or "access denied" in low:
|
|
71
|
+
return "authentication failed - check the username/password in the DSN."
|
|
72
|
+
if 'database "' in low and "does not exist" in low or "unknown database" in low:
|
|
73
|
+
return f"connected, but the database name in the DSN doesn't exist: {msg}"
|
|
74
|
+
return msg
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ── PostgreSQL (asyncpg) ─────────────────────────────────────────────────────
|
|
78
|
+
def probe_postgres(creds):
|
|
79
|
+
dsn = _pick(creds, "dsn", "POSTGRES_DSN")
|
|
80
|
+
if not dsn:
|
|
81
|
+
_out(False, "Connection DSN required")
|
|
82
|
+
try:
|
|
83
|
+
import asyncio
|
|
84
|
+
import asyncpg # type: ignore
|
|
85
|
+
except ImportError:
|
|
86
|
+
_out(False, "asyncpg not installed on this runner (pip install asyncpg)")
|
|
87
|
+
p = urllib.parse.urlparse(dsn)
|
|
88
|
+
host, port = (p.hostname or ""), (p.port or 5432)
|
|
89
|
+
if not host:
|
|
90
|
+
_out(False, "PostgreSQL connection failed: no host in the DSN - expected postgresql://user:pass@HOST:5432/db")
|
|
91
|
+
|
|
92
|
+
async def _run():
|
|
93
|
+
conn = await asyncpg.connect(dsn, timeout=10)
|
|
94
|
+
try:
|
|
95
|
+
await conn.fetchval("SELECT 1")
|
|
96
|
+
ver = await conn.fetchval("SELECT version()")
|
|
97
|
+
finally:
|
|
98
|
+
await conn.close()
|
|
99
|
+
return ver or ""
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
ver = asyncio.run(asyncio.wait_for(_run(), timeout=15))
|
|
103
|
+
short = str(ver).split(" on ")[0] if ver else ""
|
|
104
|
+
_out(True, f"Connected to PostgreSQL{(' - ' + short) if short else ''}")
|
|
105
|
+
except Exception as e: # noqa: BLE001
|
|
106
|
+
_out(False, f"PostgreSQL connection failed: {_friendly_conn_error(e, host, port, 'PostgreSQL')}")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ── MySQL / MariaDB (PyMySQL - pure-python, sync) ────────────────────────────
|
|
110
|
+
def probe_mysql(creds):
|
|
111
|
+
dsn = _pick(creds, "dsn", "MYSQL_DSN")
|
|
112
|
+
if not dsn:
|
|
113
|
+
_out(False, "Connection DSN required")
|
|
114
|
+
try:
|
|
115
|
+
import pymysql # type: ignore
|
|
116
|
+
except ImportError:
|
|
117
|
+
_out(False, "PyMySQL not installed on this runner (pip install PyMySQL)")
|
|
118
|
+
p = urllib.parse.urlparse(dsn)
|
|
119
|
+
host, port = (p.hostname or ""), (p.port or 3306)
|
|
120
|
+
if not host:
|
|
121
|
+
_out(False, "MySQL connection failed: no host in the DSN - expected mysql://user:pass@HOST:3306/db")
|
|
122
|
+
try:
|
|
123
|
+
conn = pymysql.connect(
|
|
124
|
+
host=host,
|
|
125
|
+
port=port,
|
|
126
|
+
user=urllib.parse.unquote(p.username or ""),
|
|
127
|
+
password=urllib.parse.unquote(p.password or ""),
|
|
128
|
+
db=(p.path or "").lstrip("/") or None,
|
|
129
|
+
connect_timeout=10,
|
|
130
|
+
read_timeout=10,
|
|
131
|
+
)
|
|
132
|
+
try:
|
|
133
|
+
with conn.cursor() as cur:
|
|
134
|
+
cur.execute("SELECT VERSION()")
|
|
135
|
+
row = cur.fetchone()
|
|
136
|
+
ver = (row[0] if row else "") or ""
|
|
137
|
+
finally:
|
|
138
|
+
conn.close()
|
|
139
|
+
_out(True, f"Connected to MySQL{(' - ' + str(ver)) if ver else ''}")
|
|
140
|
+
except Exception as e: # noqa: BLE001
|
|
141
|
+
_out(False, f"MySQL connection failed: {_friendly_conn_error(e, host, port, 'MySQL')}")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ── Snowflake (key-pair JWT, mirrors the server handler) ─────────────────────
|
|
145
|
+
def probe_snowflake(creds):
|
|
146
|
+
account = _pick(creds, "account", "SNOWFLAKE_ACCOUNT")
|
|
147
|
+
user = _pick(creds, "user", "SNOWFLAKE_USER")
|
|
148
|
+
pk = _pick(creds, "private_key", "SNOWFLAKE_PRIVATE_KEY")
|
|
149
|
+
if not account or not user or not pk:
|
|
150
|
+
_out(False, "Account + Username + Private Key required")
|
|
151
|
+
try:
|
|
152
|
+
from cryptography.hazmat.primitives import serialization # type: ignore
|
|
153
|
+
import requests # type: ignore
|
|
154
|
+
except ImportError:
|
|
155
|
+
_out(False, "cryptography/requests not installed on this runner")
|
|
156
|
+
|
|
157
|
+
def b64url(b):
|
|
158
|
+
if isinstance(b, str):
|
|
159
|
+
b = b.encode()
|
|
160
|
+
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
import time
|
|
164
|
+
key = serialization.load_pem_private_key(pk.encode(), password=None)
|
|
165
|
+
der_spki = key.public_key().public_bytes(
|
|
166
|
+
serialization.Encoding.DER,
|
|
167
|
+
serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
168
|
+
)
|
|
169
|
+
fp = "SHA256:" + base64.b64encode(hashlib.sha256(der_spki).digest()).decode()
|
|
170
|
+
acct_upper = account.upper()
|
|
171
|
+
if "." in acct_upper:
|
|
172
|
+
acct_upper = acct_upper.split(".")[0]
|
|
173
|
+
qual = f"{acct_upper}.{user.upper()}"
|
|
174
|
+
now = int(time.time())
|
|
175
|
+
header = b64url(json.dumps({"alg": "RS256", "typ": "JWT"}))
|
|
176
|
+
payload = b64url(json.dumps(
|
|
177
|
+
{"iss": f"{qual}.{fp}", "sub": qual, "iat": now, "exp": now + 3540}))
|
|
178
|
+
signing_input = f"{header}.{payload}".encode()
|
|
179
|
+
from cryptography.hazmat.primitives import hashes # type: ignore
|
|
180
|
+
from cryptography.hazmat.primitives.asymmetric import padding # type: ignore
|
|
181
|
+
sig = key.sign(signing_input, padding.PKCS1v15(), hashes.SHA256())
|
|
182
|
+
jwt = f"{header}.{payload}.{b64url(sig)}"
|
|
183
|
+
host = f"https://{account.replace('_', '-').lower()}.snowflakecomputing.com"
|
|
184
|
+
r = requests.post(
|
|
185
|
+
f"{host}/api/v2/statements",
|
|
186
|
+
headers={
|
|
187
|
+
"Authorization": f"Bearer {jwt}",
|
|
188
|
+
"X-Snowflake-Authorization-Token-Type": "KEYPAIR_JWT",
|
|
189
|
+
"Content-Type": "application/json",
|
|
190
|
+
"Accept": "application/json",
|
|
191
|
+
},
|
|
192
|
+
json={"statement": "SELECT CURRENT_VERSION()", "timeout": 20},
|
|
193
|
+
timeout=15,
|
|
194
|
+
)
|
|
195
|
+
if r.ok:
|
|
196
|
+
v = ""
|
|
197
|
+
try:
|
|
198
|
+
v = (r.json().get("data") or [[None]])[0][0] or ""
|
|
199
|
+
except Exception: # noqa: BLE001
|
|
200
|
+
pass
|
|
201
|
+
_out(True, f"Connected to Snowflake{(' (v' + str(v) + ')') if v else ''}")
|
|
202
|
+
_out(False, f"Snowflake auth/query failed: HTTP {r.status_code} {r.text[:160]}")
|
|
203
|
+
except Exception as e: # noqa: BLE001
|
|
204
|
+
_out(False, f"Snowflake connection failed: {e}")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# ── Databricks (PAT or OAuth M2M, mirrors the server handler) ────────────────
|
|
208
|
+
def probe_databricks(creds):
|
|
209
|
+
host = _pick(creds, "workspace_url", "DATABRICKS_HOST").rstrip("/")
|
|
210
|
+
pat = _pick(creds, "token", "DATABRICKS_TOKEN")
|
|
211
|
+
cid = _pick(creds, "client_id", "DATABRICKS_CLIENT_ID")
|
|
212
|
+
sec = _pick(creds, "client_secret", "DATABRICKS_CLIENT_SECRET")
|
|
213
|
+
if not host:
|
|
214
|
+
_out(False, "Workspace URL required")
|
|
215
|
+
try:
|
|
216
|
+
import requests # type: ignore
|
|
217
|
+
except ImportError:
|
|
218
|
+
_out(False, "requests not installed on this runner")
|
|
219
|
+
try:
|
|
220
|
+
bearer = pat
|
|
221
|
+
if not bearer:
|
|
222
|
+
if not cid or not sec:
|
|
223
|
+
_out(False, "Provide a Personal Access Token, or Client ID + Client Secret")
|
|
224
|
+
tr = requests.post(
|
|
225
|
+
f"{host}/oidc/v1/token",
|
|
226
|
+
auth=(cid, sec),
|
|
227
|
+
data={"grant_type": "client_credentials", "scope": "all-apis"},
|
|
228
|
+
timeout=12,
|
|
229
|
+
)
|
|
230
|
+
tj = {}
|
|
231
|
+
try:
|
|
232
|
+
tj = tr.json()
|
|
233
|
+
except Exception: # noqa: BLE001
|
|
234
|
+
pass
|
|
235
|
+
if not tr.ok or not tj.get("access_token"):
|
|
236
|
+
_out(False, f"Databricks OAuth failed: {tj.get('error_description') or tj.get('error') or ('HTTP ' + str(tr.status_code))}")
|
|
237
|
+
bearer = tj["access_token"]
|
|
238
|
+
r = requests.get(
|
|
239
|
+
f"{host}/api/2.1/clusters/list?page_size=1",
|
|
240
|
+
headers={"Authorization": f"Bearer {bearer}"},
|
|
241
|
+
timeout=12,
|
|
242
|
+
)
|
|
243
|
+
if r.ok:
|
|
244
|
+
_out(True, f"Connected to Databricks ({host.replace('https://', '').replace('http://', '')})")
|
|
245
|
+
_out(False, f"Databricks unreachable: HTTP {r.status_code} {r.text[:120]}")
|
|
246
|
+
except Exception as e: # noqa: BLE001
|
|
247
|
+
_out(False, f"Databricks connection failed: {e}")
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
# ── SQLite (local file on the runner) ────────────────────────────────────────
|
|
251
|
+
def probe_sqlite(creds):
|
|
252
|
+
import os
|
|
253
|
+
import sqlite3
|
|
254
|
+
path = _pick(creds, "dsn", "db_path", "path")
|
|
255
|
+
if not path:
|
|
256
|
+
_out(False, "Database file path required")
|
|
257
|
+
if not os.path.exists(path):
|
|
258
|
+
_out(False, f"No SQLite file at {path}")
|
|
259
|
+
try:
|
|
260
|
+
conn = sqlite3.connect(path, timeout=8)
|
|
261
|
+
try:
|
|
262
|
+
conn.execute("SELECT 1")
|
|
263
|
+
finally:
|
|
264
|
+
conn.close()
|
|
265
|
+
_out(True, f"Opened SQLite database ({os.path.basename(path)})")
|
|
266
|
+
except Exception as e: # noqa: BLE001
|
|
267
|
+
_out(False, f"SQLite open failed: {e}")
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
_PROBES = {
|
|
271
|
+
"postgres": probe_postgres,
|
|
272
|
+
"mysql": probe_mysql,
|
|
273
|
+
"snowflake": probe_snowflake,
|
|
274
|
+
"databricks": probe_databricks,
|
|
275
|
+
"sqlite": probe_sqlite,
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def main():
|
|
280
|
+
try:
|
|
281
|
+
job = json.loads(sys.stdin.read() or "{}")
|
|
282
|
+
except Exception as e: # noqa: BLE001
|
|
283
|
+
_out(False, f"bad probe job: {e}")
|
|
284
|
+
return
|
|
285
|
+
service = (job.get("service") or "").strip()
|
|
286
|
+
creds = job.get("creds") or {}
|
|
287
|
+
fn = _PROBES.get(service)
|
|
288
|
+
if not fn:
|
|
289
|
+
_out(False, f"unsupported service: {service}")
|
|
290
|
+
return
|
|
291
|
+
fn(creds)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
if __name__ == "__main__":
|
|
295
|
+
main()
|