dbx-tools-postgres 0.6.78__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.
- dbx_tools/postgres/__init__.py +109 -0
- dbx_tools/postgres/address.py +114 -0
- dbx_tools/postgres/advisory_lock.py +223 -0
- dbx_tools/postgres/engine.py +425 -0
- dbx_tools/postgres/topic_bus.py +380 -0
- dbx_tools_postgres-0.6.78.dist-info/METADATA +188 -0
- dbx_tools_postgres-0.6.78.dist-info/RECORD +8 -0
- dbx_tools_postgres-0.6.78.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import uuid
|
|
5
|
+
from collections.abc import Callable, Mapping
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, Protocol
|
|
8
|
+
|
|
9
|
+
from sqlalchemy import URL, Engine, event
|
|
10
|
+
from sqlalchemy import create_engine as sqlalchemy_create_engine
|
|
11
|
+
from sqlalchemy.ext.asyncio import AsyncEngine
|
|
12
|
+
from sqlalchemy.ext.asyncio import create_async_engine as sqlalchemy_create_async_engine
|
|
13
|
+
|
|
14
|
+
from .address import SSL_MODES, ParsedAddress, SslMode, parse_address, parse_resource_path
|
|
15
|
+
|
|
16
|
+
CredentialProvider = Callable[[], str]
|
|
17
|
+
|
|
18
|
+
_API_BASE = "/api/2.0/postgres"
|
|
19
|
+
_DEFAULT_DATABASE = "databricks_postgres"
|
|
20
|
+
_DEFAULT_PORT = 5432
|
|
21
|
+
_DEFAULT_SSL_MODE: SslMode = "require"
|
|
22
|
+
_READ_WRITE_ENDPOINT_TYPE = "ENDPOINT_TYPE_READ_WRITE"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class WorkspaceApiClient(Protocol):
|
|
26
|
+
def do(self, method: str, path: str, **kwargs: Any) -> Any: ...
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class WorkspaceClientLike(Protocol):
|
|
30
|
+
api_client: WorkspaceApiClient
|
|
31
|
+
config: Any
|
|
32
|
+
current_user: Any
|
|
33
|
+
database: Any
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True, slots=True)
|
|
37
|
+
class PostgresEngineConfig:
|
|
38
|
+
address: str | None = None
|
|
39
|
+
instance_name: str | None = None
|
|
40
|
+
project: str | None = None
|
|
41
|
+
branch: str | None = None
|
|
42
|
+
endpoint: str | None = None
|
|
43
|
+
database: str | None = None
|
|
44
|
+
host: str | None = None
|
|
45
|
+
port: int | None = None
|
|
46
|
+
ssl_mode: SslMode | None = None
|
|
47
|
+
user: str | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True, slots=True)
|
|
51
|
+
class ResolvedPostgresConnection:
|
|
52
|
+
host: str
|
|
53
|
+
database: str
|
|
54
|
+
user: str
|
|
55
|
+
port: int = _DEFAULT_PORT
|
|
56
|
+
ssl_mode: SslMode = _DEFAULT_SSL_MODE
|
|
57
|
+
project: str | None = None
|
|
58
|
+
branch: str | None = None
|
|
59
|
+
endpoint: str | None = None
|
|
60
|
+
instance_name: str | None = None
|
|
61
|
+
|
|
62
|
+
def url(self, drivername: str) -> URL:
|
|
63
|
+
ssl_parameter = "ssl" if drivername.endswith("+asyncpg") else "sslmode"
|
|
64
|
+
return URL.create(
|
|
65
|
+
drivername,
|
|
66
|
+
username=self.user,
|
|
67
|
+
password=None,
|
|
68
|
+
host=self.host,
|
|
69
|
+
port=self.port,
|
|
70
|
+
database=self.database,
|
|
71
|
+
query={ssl_parameter: self.ssl_mode},
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def workspace_credential_provider(
|
|
76
|
+
workspace_client: WorkspaceClientLike,
|
|
77
|
+
instance_name: str,
|
|
78
|
+
) -> CredentialProvider:
|
|
79
|
+
if not instance_name.strip():
|
|
80
|
+
raise ValueError("instance_name must not be empty")
|
|
81
|
+
|
|
82
|
+
def provide() -> str:
|
|
83
|
+
credential = workspace_client.database.generate_database_credential(
|
|
84
|
+
request_id=str(uuid.uuid4()),
|
|
85
|
+
instance_names=[instance_name],
|
|
86
|
+
)
|
|
87
|
+
token = getattr(credential, "token", None)
|
|
88
|
+
if not isinstance(token, str) or not token:
|
|
89
|
+
raise RuntimeError("WorkspaceClient returned no Lakebase database credential token")
|
|
90
|
+
return token
|
|
91
|
+
|
|
92
|
+
return provide
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def autoscaling_credential_provider(
|
|
96
|
+
workspace_client: WorkspaceClientLike,
|
|
97
|
+
endpoint: str,
|
|
98
|
+
) -> CredentialProvider:
|
|
99
|
+
if not endpoint.strip():
|
|
100
|
+
raise ValueError("endpoint must not be empty")
|
|
101
|
+
|
|
102
|
+
def provide() -> str:
|
|
103
|
+
credential = workspace_client.api_client.do(
|
|
104
|
+
"POST",
|
|
105
|
+
f"{_API_BASE}/credentials",
|
|
106
|
+
headers={"Accept": "application/json", "Content-Type": "application/json"},
|
|
107
|
+
body={"endpoint": endpoint},
|
|
108
|
+
)
|
|
109
|
+
token = credential.get("token") if isinstance(credential, dict) else None
|
|
110
|
+
if not isinstance(token, str) or not token:
|
|
111
|
+
raise RuntimeError("WorkspaceClient returned no Lakebase database credential token")
|
|
112
|
+
return token
|
|
113
|
+
|
|
114
|
+
return provide
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def install_credential_injection(engine: Engine, provider: CredentialProvider) -> None:
|
|
118
|
+
@event.listens_for(engine, "do_connect")
|
|
119
|
+
def provide_token(
|
|
120
|
+
dialect: Any,
|
|
121
|
+
connection_record: Any,
|
|
122
|
+
connection_arguments: list[Any],
|
|
123
|
+
connection_parameters: dict[str, Any],
|
|
124
|
+
) -> None:
|
|
125
|
+
del dialect, connection_record, connection_arguments
|
|
126
|
+
connection_parameters["password"] = provider()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def create_engine(
|
|
130
|
+
workspace_client: WorkspaceClientLike,
|
|
131
|
+
config: PostgresEngineConfig | None = None,
|
|
132
|
+
*,
|
|
133
|
+
credential_provider: CredentialProvider | None = None,
|
|
134
|
+
drivername: str = "postgresql+psycopg",
|
|
135
|
+
**engine_options: Any,
|
|
136
|
+
) -> Engine:
|
|
137
|
+
resolved = resolve_postgres_connection(workspace_client, config)
|
|
138
|
+
engine = sqlalchemy_create_engine(resolved.url(drivername), **engine_options)
|
|
139
|
+
provider = credential_provider or _default_provider(workspace_client, resolved)
|
|
140
|
+
install_credential_injection(engine, provider)
|
|
141
|
+
return engine
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def create_async_engine(
|
|
145
|
+
workspace_client: WorkspaceClientLike,
|
|
146
|
+
config: PostgresEngineConfig | None = None,
|
|
147
|
+
*,
|
|
148
|
+
credential_provider: CredentialProvider | None = None,
|
|
149
|
+
drivername: str = "postgresql+asyncpg",
|
|
150
|
+
**engine_options: Any,
|
|
151
|
+
) -> AsyncEngine:
|
|
152
|
+
resolved = resolve_postgres_connection(workspace_client, config)
|
|
153
|
+
engine = sqlalchemy_create_async_engine(resolved.url(drivername), **engine_options)
|
|
154
|
+
provider = credential_provider or _default_provider(workspace_client, resolved)
|
|
155
|
+
install_credential_injection(engine.sync_engine, provider)
|
|
156
|
+
return engine
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def resolve_postgres_connection(
|
|
160
|
+
workspace_client: WorkspaceClientLike,
|
|
161
|
+
config: PostgresEngineConfig | None = None,
|
|
162
|
+
*,
|
|
163
|
+
environ: Mapping[str, str] | None = None,
|
|
164
|
+
) -> ResolvedPostgresConnection:
|
|
165
|
+
config = config or PostgresEngineConfig()
|
|
166
|
+
env = os.environ if environ is None else environ
|
|
167
|
+
raw_address = _first(config.address, config.endpoint, env.get("LAKEBASE_ENDPOINT"))
|
|
168
|
+
parsed = parse_address(raw_address)
|
|
169
|
+
instance_name = _first(config.instance_name, env.get("LAKEBASE_INSTANCE_NAME"))
|
|
170
|
+
project = _first(config.project, parsed.project)
|
|
171
|
+
branch = _first(config.branch, parsed.branch)
|
|
172
|
+
endpoint = _first(config.endpoint, env.get("LAKEBASE_ENDPOINT"), parsed.endpoint)
|
|
173
|
+
host = _first(config.host, env.get("PGHOST"), parsed.host)
|
|
174
|
+
database_value = _first(config.database, env.get("PGDATABASE"), parsed.database)
|
|
175
|
+
database_path = parse_address(database_value)
|
|
176
|
+
database_resource_id = parsed.database_resource_id or database_path.database_resource_id
|
|
177
|
+
database = None if database_resource_id else database_value
|
|
178
|
+
port = _parse_port(_first(config.port, env.get("PGPORT"), parsed.port))
|
|
179
|
+
ssl_mode = _parse_ssl_mode(_first(config.ssl_mode, env.get("PGSSLMODE"), parsed.ssl_mode))
|
|
180
|
+
user = _first(config.user, env.get("PGUSER"), parsed.user)
|
|
181
|
+
|
|
182
|
+
if instance_name and not host:
|
|
183
|
+
instance = workspace_client.database.get_database_instance(instance_name)
|
|
184
|
+
host = getattr(instance, "read_write_dns", None)
|
|
185
|
+
|
|
186
|
+
if not project and host and not instance_name:
|
|
187
|
+
found = _find_endpoint_by_host(workspace_client, host)
|
|
188
|
+
if found:
|
|
189
|
+
project, branch, endpoint = found
|
|
190
|
+
|
|
191
|
+
if not host and not instance_name:
|
|
192
|
+
project = project or _pick_project(workspace_client)
|
|
193
|
+
branch = branch or _pick_branch(workspace_client, project)
|
|
194
|
+
if endpoint:
|
|
195
|
+
endpoint_parts = parse_address(endpoint)
|
|
196
|
+
if endpoint_parts.project and endpoint_parts.branch and endpoint_parts.endpoint_id:
|
|
197
|
+
response = _get(
|
|
198
|
+
workspace_client,
|
|
199
|
+
f"{_API_BASE}/projects/{endpoint_parts.project}/branches/"
|
|
200
|
+
f"{endpoint_parts.branch}/endpoints/{endpoint_parts.endpoint_id}",
|
|
201
|
+
)
|
|
202
|
+
host = _endpoint_host(response)
|
|
203
|
+
if not host:
|
|
204
|
+
endpoint_record = _pick_endpoint(workspace_client, project, branch)
|
|
205
|
+
endpoint = _string(endpoint_record.get("name"))
|
|
206
|
+
host = _endpoint_host(endpoint_record)
|
|
207
|
+
|
|
208
|
+
if not database and project and branch:
|
|
209
|
+
database = _pick_database(workspace_client, project, branch, database_resource_id)
|
|
210
|
+
database = database or _DEFAULT_DATABASE
|
|
211
|
+
user = user or _workspace_user(workspace_client)
|
|
212
|
+
|
|
213
|
+
if not host:
|
|
214
|
+
raise ValueError("Could not resolve PGHOST from config, address, or WorkspaceClient")
|
|
215
|
+
if not user:
|
|
216
|
+
raise ValueError("Could not resolve PGUSER from config, address, or WorkspaceClient")
|
|
217
|
+
return ResolvedPostgresConnection(
|
|
218
|
+
host=host,
|
|
219
|
+
database=database,
|
|
220
|
+
user=user,
|
|
221
|
+
port=port,
|
|
222
|
+
ssl_mode=ssl_mode,
|
|
223
|
+
project=project,
|
|
224
|
+
branch=branch,
|
|
225
|
+
endpoint=endpoint,
|
|
226
|
+
instance_name=instance_name,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _default_provider(
|
|
231
|
+
workspace_client: WorkspaceClientLike,
|
|
232
|
+
resolved: ResolvedPostgresConnection,
|
|
233
|
+
) -> CredentialProvider:
|
|
234
|
+
if resolved.instance_name:
|
|
235
|
+
return workspace_credential_provider(workspace_client, resolved.instance_name)
|
|
236
|
+
if resolved.endpoint:
|
|
237
|
+
return autoscaling_credential_provider(workspace_client, resolved.endpoint)
|
|
238
|
+
raise ValueError(
|
|
239
|
+
"instance_name, endpoint, or credential_provider is required for connect-time credential injection"
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _workspace_user(workspace_client: WorkspaceClientLike) -> str | None:
|
|
244
|
+
current_user = workspace_client.current_user.me()
|
|
245
|
+
return _first(
|
|
246
|
+
getattr(current_user, "user_name", None),
|
|
247
|
+
getattr(current_user, "userName", None),
|
|
248
|
+
getattr(workspace_client.config, "client_id", None),
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _get(workspace_client: WorkspaceClientLike, path: str) -> dict[str, Any]:
|
|
253
|
+
value = workspace_client.api_client.do("GET", path, headers={"Accept": "application/json"})
|
|
254
|
+
if not isinstance(value, dict):
|
|
255
|
+
raise TypeError(f"WorkspaceClient returned a non-object response for {path}")
|
|
256
|
+
return value
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _list(workspace_client: WorkspaceClientLike, path: str, key: str) -> list[dict[str, Any]]:
|
|
260
|
+
values = _get(workspace_client, path).get(key, [])
|
|
261
|
+
if not isinstance(values, list):
|
|
262
|
+
raise TypeError(f"WorkspaceClient returned a non-list {key} response for {path}")
|
|
263
|
+
return [value for value in values if isinstance(value, dict)]
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _pick_project(workspace_client: WorkspaceClientLike) -> str:
|
|
267
|
+
projects = _list(workspace_client, f"{_API_BASE}/projects", "projects")
|
|
268
|
+
candidates = [_resource_part(project.get("name"), "project") for project in projects]
|
|
269
|
+
candidates = [candidate for candidate in candidates if candidate]
|
|
270
|
+
if len(candidates) != 1:
|
|
271
|
+
raise ValueError(f"Expected one Lakebase project, found: {', '.join(candidates) or 'none'}")
|
|
272
|
+
return candidates[0]
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _pick_branch(workspace_client: WorkspaceClientLike, project: str) -> str:
|
|
276
|
+
branches = _list(
|
|
277
|
+
workspace_client,
|
|
278
|
+
f"{_API_BASE}/projects/{project}/branches",
|
|
279
|
+
"branches",
|
|
280
|
+
)
|
|
281
|
+
selected = next(
|
|
282
|
+
(
|
|
283
|
+
branch
|
|
284
|
+
for branch in branches
|
|
285
|
+
if isinstance(branch.get("status"), dict) and branch["status"].get("default") is True
|
|
286
|
+
),
|
|
287
|
+
branches[0] if len(branches) == 1 else None,
|
|
288
|
+
)
|
|
289
|
+
branch = _resource_part(selected.get("name") if selected else None, "branch")
|
|
290
|
+
if not branch:
|
|
291
|
+
raise ValueError(f"Could not choose a Lakebase branch for project {project}")
|
|
292
|
+
return branch
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _pick_endpoint(
|
|
296
|
+
workspace_client: WorkspaceClientLike,
|
|
297
|
+
project: str,
|
|
298
|
+
branch: str,
|
|
299
|
+
) -> dict[str, Any]:
|
|
300
|
+
endpoints = _list(
|
|
301
|
+
workspace_client,
|
|
302
|
+
f"{_API_BASE}/projects/{project}/branches/{branch}/endpoints",
|
|
303
|
+
"endpoints",
|
|
304
|
+
)
|
|
305
|
+
selected = next(
|
|
306
|
+
(
|
|
307
|
+
endpoint
|
|
308
|
+
for endpoint in endpoints
|
|
309
|
+
if isinstance(endpoint.get("status"), dict)
|
|
310
|
+
and endpoint["status"].get("endpoint_type") == _READ_WRITE_ENDPOINT_TYPE
|
|
311
|
+
),
|
|
312
|
+
endpoints[0] if len(endpoints) == 1 else None,
|
|
313
|
+
)
|
|
314
|
+
if not selected:
|
|
315
|
+
raise ValueError(f"Could not choose a Lakebase endpoint for {project}/{branch}")
|
|
316
|
+
return selected
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _pick_database(
|
|
320
|
+
workspace_client: WorkspaceClientLike,
|
|
321
|
+
project: str,
|
|
322
|
+
branch: str,
|
|
323
|
+
resource_id: str | None,
|
|
324
|
+
) -> str:
|
|
325
|
+
databases = _list(
|
|
326
|
+
workspace_client,
|
|
327
|
+
f"{_API_BASE}/projects/{project}/branches/{branch}/databases",
|
|
328
|
+
"databases",
|
|
329
|
+
)
|
|
330
|
+
if resource_id:
|
|
331
|
+
databases = [
|
|
332
|
+
database
|
|
333
|
+
for database in databases
|
|
334
|
+
if _resource_part(database.get("name"), "database") == resource_id
|
|
335
|
+
]
|
|
336
|
+
names = [
|
|
337
|
+
_string(database.get("status", {}).get("postgres_database"))
|
|
338
|
+
for database in databases
|
|
339
|
+
if isinstance(database.get("status"), dict)
|
|
340
|
+
]
|
|
341
|
+
names = [name for name in names if name]
|
|
342
|
+
if _DEFAULT_DATABASE in names:
|
|
343
|
+
return _DEFAULT_DATABASE
|
|
344
|
+
if len(names) == 1:
|
|
345
|
+
return names[0]
|
|
346
|
+
raise ValueError(f"Could not choose a Lakebase database for {project}/{branch}")
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _find_endpoint_by_host(
|
|
350
|
+
workspace_client: WorkspaceClientLike,
|
|
351
|
+
host: str,
|
|
352
|
+
) -> tuple[str, str, str] | None:
|
|
353
|
+
for project_record in _list(workspace_client, f"{_API_BASE}/projects", "projects"):
|
|
354
|
+
project = _resource_part(project_record.get("name"), "project")
|
|
355
|
+
if not project:
|
|
356
|
+
continue
|
|
357
|
+
for branch_record in _list(
|
|
358
|
+
workspace_client,
|
|
359
|
+
f"{_API_BASE}/projects/{project}/branches",
|
|
360
|
+
"branches",
|
|
361
|
+
):
|
|
362
|
+
branch = _resource_part(branch_record.get("name"), "branch")
|
|
363
|
+
if not branch:
|
|
364
|
+
continue
|
|
365
|
+
for endpoint in _list(
|
|
366
|
+
workspace_client,
|
|
367
|
+
f"{_API_BASE}/projects/{project}/branches/{branch}/endpoints",
|
|
368
|
+
"endpoints",
|
|
369
|
+
):
|
|
370
|
+
if _endpoint_host(endpoint) == host and (name := _string(endpoint.get("name"))):
|
|
371
|
+
return project, branch, name
|
|
372
|
+
return None
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _endpoint_host(endpoint: Mapping[str, Any]) -> str | None:
|
|
376
|
+
status = endpoint.get("status")
|
|
377
|
+
hosts = status.get("hosts") if isinstance(status, dict) else None
|
|
378
|
+
return _string(hosts.get("host")) if isinstance(hosts, dict) else None
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _resource_part(value: object, kind: str) -> str | None:
|
|
382
|
+
parsed: ParsedAddress = parse_resource_path(_string(value))
|
|
383
|
+
return {
|
|
384
|
+
"project": parsed.project,
|
|
385
|
+
"branch": parsed.branch,
|
|
386
|
+
"endpoint": parsed.endpoint_id,
|
|
387
|
+
"database": parsed.database_resource_id,
|
|
388
|
+
}[kind]
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _parse_port(value: object) -> int:
|
|
392
|
+
if value is None or value == "":
|
|
393
|
+
return _DEFAULT_PORT
|
|
394
|
+
try:
|
|
395
|
+
port = int(value)
|
|
396
|
+
except (TypeError, ValueError) as error:
|
|
397
|
+
raise ValueError(f"PGPORT must be a TCP port, got {value!r}") from error
|
|
398
|
+
if not 1 <= port <= 65535:
|
|
399
|
+
raise ValueError(f"PGPORT must be between 1 and 65535, got {port}")
|
|
400
|
+
return port
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _parse_ssl_mode(value: object) -> SslMode:
|
|
404
|
+
if value is None or value == "":
|
|
405
|
+
return _DEFAULT_SSL_MODE
|
|
406
|
+
mode = str(value).strip().lower()
|
|
407
|
+
if mode not in SSL_MODES:
|
|
408
|
+
raise ValueError(f"PGSSLMODE must be one of {', '.join(SSL_MODES)}, got {value!r}")
|
|
409
|
+
return mode # type: ignore[return-value]
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _first(*values: Any) -> Any:
|
|
413
|
+
return next((value for value in values if value is not None and value != ""), None)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _string(value: object) -> str | None:
|
|
417
|
+
return value.strip() if isinstance(value, str) and value.strip() else None
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
createEngine = create_engine
|
|
421
|
+
createAsyncEngine = create_async_engine
|
|
422
|
+
installCredentialInjection = install_credential_injection
|
|
423
|
+
resolvePostgresConnection = resolve_postgres_connection
|
|
424
|
+
workspaceCredentialProvider = workspace_credential_provider
|
|
425
|
+
autoscalingCredentialProvider = autoscaling_credential_provider
|