cloud-sql-python-connector 1.22.0__py3-none-any.whl → 1.24.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.
- {cloud_sql_python_connector-1.22.0.dist-info → cloud_sql_python_connector-1.24.0.dist-info}/METADATA +89 -9
- cloud_sql_python_connector-1.24.0.dist-info/RECORD +28 -0
- {cloud_sql_python_connector-1.22.0.dist-info → cloud_sql_python_connector-1.24.0.dist-info}/WHEEL +1 -1
- google/cloud/sql/connector/client.py +15 -3
- google/cloud/sql/connector/connection_info.py +12 -1
- google/cloud/sql/connector/connector.py +445 -134
- google/cloud/sql/connector/enums.py +3 -0
- google/cloud/sql/connector/exceptions.py +33 -0
- google/cloud/sql/connector/instance.py +238 -2
- google/cloud/sql/connector/lazy.py +47 -3
- google/cloud/sql/connector/monitored_cache.py +14 -8
- google/cloud/sql/connector/postgres_connection.py +235 -0
- google/cloud/sql/connector/psycopg.py +241 -153
- google/cloud/sql/connector/refresh_utils.py +4 -3
- google/cloud/sql/connector/sqldata_client.py +654 -0
- google/cloud/sql/connector/utils.py +1 -1
- google/cloud/sql/connector/version.py +1 -1
- cloud_sql_python_connector-1.22.0.dist-info/RECORD +0 -26
- {cloud_sql_python_connector-1.22.0.dist-info → cloud_sql_python_connector-1.24.0.dist-info}/licenses/LICENSE +0 -0
- {cloud_sql_python_connector-1.22.0.dist-info → cloud_sql_python_connector-1.24.0.dist-info}/top_level.txt +0 -0
|
@@ -17,13 +17,17 @@ limitations under the License.
|
|
|
17
17
|
from __future__ import annotations
|
|
18
18
|
|
|
19
19
|
import asyncio
|
|
20
|
+
from collections.abc import Callable
|
|
20
21
|
from functools import partial
|
|
21
22
|
import logging
|
|
22
23
|
import os
|
|
24
|
+
import random
|
|
23
25
|
import socket
|
|
26
|
+
import ssl
|
|
24
27
|
from threading import Thread
|
|
28
|
+
import time
|
|
25
29
|
from types import TracebackType
|
|
26
|
-
from typing import Any
|
|
30
|
+
from typing import Any
|
|
27
31
|
|
|
28
32
|
import google.auth
|
|
29
33
|
from google.auth.credentials import Credentials
|
|
@@ -35,16 +39,22 @@ from google.cloud.sql.connector import psycopg
|
|
|
35
39
|
from google.cloud.sql.connector import pymysql
|
|
36
40
|
from google.cloud.sql.connector import pytds
|
|
37
41
|
from google.cloud.sql.connector.client import CloudSQLClient
|
|
42
|
+
from google.cloud.sql.connector.connection_info import ConnectionInfo
|
|
43
|
+
from google.cloud.sql.connector.connection_name import ConnectionName
|
|
38
44
|
from google.cloud.sql.connector.enums import DriverMapping
|
|
39
45
|
from google.cloud.sql.connector.enums import IPTypes
|
|
40
46
|
from google.cloud.sql.connector.enums import RefreshStrategy
|
|
41
47
|
from google.cloud.sql.connector.exceptions import ClosedConnectorError
|
|
42
48
|
from google.cloud.sql.connector.exceptions import ConnectorLoopError
|
|
49
|
+
from google.cloud.sql.connector.exceptions import IncompatibleDriverError
|
|
50
|
+
from google.cloud.sql.connector.exceptions import ResourceExhaustedError
|
|
43
51
|
from google.cloud.sql.connector.instance import RefreshAheadCache
|
|
44
52
|
from google.cloud.sql.connector.lazy import LazyRefreshCache
|
|
45
53
|
from google.cloud.sql.connector.monitored_cache import MonitoredCache
|
|
54
|
+
from google.cloud.sql.connector.postgres_connection import PostgresConnection
|
|
46
55
|
from google.cloud.sql.connector.resolver import DefaultResolver
|
|
47
56
|
from google.cloud.sql.connector.resolver import DnsResolver
|
|
57
|
+
from google.cloud.sql.connector.sqldata_client import SqlDataClient
|
|
48
58
|
from google.cloud.sql.connector.utils import format_database_user
|
|
49
59
|
from google.cloud.sql.connector.utils import generate_keys
|
|
50
60
|
|
|
@@ -57,6 +67,49 @@ _DEFAULT_UNIVERSE_DOMAIN = "googleapis.com"
|
|
|
57
67
|
_SQLADMIN_HOST_TEMPLATE = "sqladmin.{universe_domain}"
|
|
58
68
|
|
|
59
69
|
|
|
70
|
+
class SqlDataConnState:
|
|
71
|
+
"""Tracks connection state, fallback status, and resource exhaustion cooldown for SQL Data Service."""
|
|
72
|
+
|
|
73
|
+
def __init__(self) -> None:
|
|
74
|
+
self.allowed: bool = True
|
|
75
|
+
self.cooldown_until: float | None = None
|
|
76
|
+
self.backoff_counter: int = 0
|
|
77
|
+
self.last_err: Exception | None = None
|
|
78
|
+
|
|
79
|
+
def is_cooldown_active(self) -> bool:
|
|
80
|
+
"""Returns True if the instance connection is in cooldown due to resource exhaustion."""
|
|
81
|
+
return bool(
|
|
82
|
+
self.allowed
|
|
83
|
+
and self.cooldown_until
|
|
84
|
+
and time.time() < self.cooldown_until
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def record_exhausted(self, err: Exception, base_cooldown: float) -> float:
|
|
88
|
+
"""Records a resource exhaustion error, increments backoff counter, and returns the cooldown duration."""
|
|
89
|
+
if self.backoff_counter < 5:
|
|
90
|
+
self.backoff_counter += 1
|
|
91
|
+
backoff = _cooldown_backoff(base_cooldown, self.backoff_counter)
|
|
92
|
+
self.cooldown_until = time.time() + backoff
|
|
93
|
+
self.last_err = err
|
|
94
|
+
return backoff
|
|
95
|
+
|
|
96
|
+
def record_success(self) -> None:
|
|
97
|
+
"""Resets cooldown and backoff state on successful communication."""
|
|
98
|
+
self.backoff_counter = 0
|
|
99
|
+
self.cooldown_until = None
|
|
100
|
+
self.last_err = None
|
|
101
|
+
|
|
102
|
+
def record_fallback(self) -> None:
|
|
103
|
+
"""Marks SQL Data Service as not allowed for this instance."""
|
|
104
|
+
self.allowed = False
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _cooldown_backoff(base_cooldown: float, attempt: int) -> float:
|
|
108
|
+
multi = 1.618
|
|
109
|
+
exp = float(attempt - 1) + random.random()
|
|
110
|
+
return base_cooldown * (multi**exp)
|
|
111
|
+
|
|
112
|
+
|
|
60
113
|
class Connector:
|
|
61
114
|
"""Configure and create secure connections to Cloud SQL."""
|
|
62
115
|
|
|
@@ -74,14 +127,20 @@ class Connector:
|
|
|
74
127
|
refresh_strategy: str | RefreshStrategy = RefreshStrategy.BACKGROUND,
|
|
75
128
|
resolver: type[DefaultResolver | DnsResolver] = DefaultResolver,
|
|
76
129
|
failover_period: int = 30,
|
|
130
|
+
sql_data_endpoint: str | None = None,
|
|
131
|
+
sql_data_stream_timeout: int = 7200,
|
|
132
|
+
resource_exhausted_cooldown_period: float = 5.0,
|
|
77
133
|
) -> None:
|
|
78
134
|
"""Initializes a Connector instance.
|
|
79
135
|
|
|
80
136
|
Args:
|
|
81
137
|
ip_type (str | IPTypes): The default IP address type used to connect to
|
|
82
138
|
Cloud SQL instances. Can be one of the following:
|
|
83
|
-
IPTypes.PUBLIC ("PUBLIC"), IPTypes.PRIVATE ("PRIVATE"),
|
|
84
|
-
IPTypes.PSC ("PSC")
|
|
139
|
+
IPTypes.PUBLIC ("PUBLIC"), IPTypes.PRIVATE ("PRIVATE"),
|
|
140
|
+
IPTypes.PSC ("PSC"), or IPTypes.SQL_DATA ("SQL_DATA").
|
|
141
|
+
Note: IPTypes.SQL_DATA is not currently supported with the
|
|
142
|
+
asyncpg driver.
|
|
143
|
+
Default: IPTypes.PUBLIC
|
|
85
144
|
|
|
86
145
|
enable_iam_auth (bool): Enables automatic IAM database authentication
|
|
87
146
|
(Postgres and MySQL) as the default authentication method for all
|
|
@@ -126,6 +185,16 @@ class Connector:
|
|
|
126
185
|
attempt to check if a failover has occured for a given instance.
|
|
127
186
|
Must be used with `resolver=DnsResolver` to have any effect.
|
|
128
187
|
Default: 30
|
|
188
|
+
|
|
189
|
+
sql_data_endpoint (str | None): Endpoint host for SQL Data Service
|
|
190
|
+
calls. If not specified, derived from `sqladmin_api_endpoint`.
|
|
191
|
+
Default: "sqladmin.googleapis.com".
|
|
192
|
+
|
|
193
|
+
sql_data_stream_timeout (int): Timeout in seconds for the SQL Data
|
|
194
|
+
Service gRPC stream. Default: 7200.
|
|
195
|
+
|
|
196
|
+
resource_exhausted_cooldown_period (float): Cooldown period in seconds
|
|
197
|
+
after a ResourceExhausted error. Default: 5.0.
|
|
129
198
|
"""
|
|
130
199
|
# if refresh_strategy is str, convert to RefreshStrategy enum
|
|
131
200
|
if isinstance(refresh_strategy, str):
|
|
@@ -214,6 +283,22 @@ class Connector:
|
|
|
214
283
|
"configured the universe domain explicitly, `googleapis.com` "
|
|
215
284
|
"is the default."
|
|
216
285
|
)
|
|
286
|
+
if sql_data_endpoint:
|
|
287
|
+
self._sql_data_endpoint = sql_data_endpoint
|
|
288
|
+
else:
|
|
289
|
+
self._sql_data_endpoint = (
|
|
290
|
+
self._sqladmin_api_endpoint.split("://", 1)[-1].split("/", 1)[0]
|
|
291
|
+
)
|
|
292
|
+
self._sql_data_stream_timeout = sql_data_stream_timeout
|
|
293
|
+
self._resource_exhausted_cooldown_period = (
|
|
294
|
+
resource_exhausted_cooldown_period
|
|
295
|
+
)
|
|
296
|
+
self._sql_data_fallback_cache: set[str] = set()
|
|
297
|
+
self._sql_data_conn_state: dict[str, SqlDataConnState] = {}
|
|
298
|
+
self._sqldata_clients: set[Any] = set()
|
|
299
|
+
self._sqldata_client: SqlDataClient | None = None
|
|
300
|
+
|
|
301
|
+
|
|
217
302
|
|
|
218
303
|
@property
|
|
219
304
|
def universe_domain(self) -> str:
|
|
@@ -260,6 +345,63 @@ class Connector:
|
|
|
260
345
|
)
|
|
261
346
|
return connect_future.result()
|
|
262
347
|
|
|
348
|
+
def _get_or_create_cache(
|
|
349
|
+
self,
|
|
350
|
+
conn_name: ConnectionName,
|
|
351
|
+
enable_iam_auth: bool,
|
|
352
|
+
ip_type: IPTypes | None = None,
|
|
353
|
+
) -> MonitoredCache:
|
|
354
|
+
assert self._client is not None, "client must be initialized before creating cache"
|
|
355
|
+
assert self._keys is not None, "keys must be initialized before creating cache"
|
|
356
|
+
assert self._resolver is not None, "resolver must be initialized before creating cache"
|
|
357
|
+
if (str(conn_name), enable_iam_auth) in self._cache and not self._cache[
|
|
358
|
+
(str(conn_name), enable_iam_auth)
|
|
359
|
+
].closed:
|
|
360
|
+
return self._cache[(str(conn_name), enable_iam_auth)]
|
|
361
|
+
|
|
362
|
+
effective_ip_type = ip_type if ip_type is not None else self._ip_type
|
|
363
|
+
dial_func = partial(
|
|
364
|
+
self._dial_probe_connection,
|
|
365
|
+
enable_iam_auth=enable_iam_auth,
|
|
366
|
+
ip_type=effective_ip_type,
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
if self._refresh_strategy == RefreshStrategy.LAZY:
|
|
370
|
+
logger.debug(
|
|
371
|
+
f"['{conn_name}']: Refresh strategy is set to lazy refresh"
|
|
372
|
+
)
|
|
373
|
+
cache: LazyRefreshCache | RefreshAheadCache = LazyRefreshCache(
|
|
374
|
+
conn_name,
|
|
375
|
+
self._client,
|
|
376
|
+
self._keys,
|
|
377
|
+
enable_iam_auth,
|
|
378
|
+
ip_type=effective_ip_type,
|
|
379
|
+
timeout=self._timeout,
|
|
380
|
+
dial_func=dial_func,
|
|
381
|
+
)
|
|
382
|
+
else:
|
|
383
|
+
logger.debug(
|
|
384
|
+
f"['{conn_name}']: Refresh strategy is set to backgound refresh"
|
|
385
|
+
)
|
|
386
|
+
cache = RefreshAheadCache(
|
|
387
|
+
conn_name,
|
|
388
|
+
self._client,
|
|
389
|
+
self._keys,
|
|
390
|
+
enable_iam_auth,
|
|
391
|
+
ip_type=effective_ip_type,
|
|
392
|
+
timeout=self._timeout,
|
|
393
|
+
dial_func=dial_func,
|
|
394
|
+
)
|
|
395
|
+
# wrap cache as a MonitoredCache
|
|
396
|
+
monitored_cache = MonitoredCache(
|
|
397
|
+
cache,
|
|
398
|
+
self._failover_period,
|
|
399
|
+
self._resolver,
|
|
400
|
+
)
|
|
401
|
+
logger.debug(f"['{conn_name}']: Connection info added to cache")
|
|
402
|
+
self._cache[(str(conn_name), enable_iam_auth)] = monitored_cache
|
|
403
|
+
return monitored_cache
|
|
404
|
+
|
|
263
405
|
async def connect_async(
|
|
264
406
|
self, instance_connection_string: str, driver: str, **kwargs: Any
|
|
265
407
|
) -> Any:
|
|
@@ -321,42 +463,15 @@ class Connector:
|
|
|
321
463
|
if self._resolver is None:
|
|
322
464
|
self._resolver = self._resolver_cls(client=self._client)
|
|
323
465
|
enable_iam_auth = kwargs.pop("enable_iam_auth", self._enable_iam_auth)
|
|
466
|
+
ip_type = kwargs.pop("ip_type", self._ip_type)
|
|
467
|
+
if isinstance(ip_type, str):
|
|
468
|
+
ip_type = IPTypes._from_str(ip_type)
|
|
324
469
|
|
|
325
470
|
conn_name = await self._resolver.resolve(instance_connection_string)
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
].closed:
|
|
330
|
-
monitored_cache = self._cache[(str(conn_name), enable_iam_auth)]
|
|
331
|
-
else:
|
|
332
|
-
if self._refresh_strategy == RefreshStrategy.LAZY:
|
|
333
|
-
logger.debug(
|
|
334
|
-
f"['{conn_name}']: Refresh strategy is set to lazy refresh"
|
|
335
|
-
)
|
|
336
|
-
cache: LazyRefreshCache | RefreshAheadCache = LazyRefreshCache(
|
|
337
|
-
conn_name,
|
|
338
|
-
self._client,
|
|
339
|
-
self._keys,
|
|
340
|
-
enable_iam_auth,
|
|
341
|
-
)
|
|
342
|
-
else:
|
|
343
|
-
logger.debug(
|
|
344
|
-
f"['{conn_name}']: Refresh strategy is set to backgound refresh"
|
|
345
|
-
)
|
|
346
|
-
cache = RefreshAheadCache(
|
|
347
|
-
conn_name,
|
|
348
|
-
self._client,
|
|
349
|
-
self._keys,
|
|
350
|
-
enable_iam_auth,
|
|
351
|
-
)
|
|
352
|
-
# wrap cache as a MonitoredCache
|
|
353
|
-
monitored_cache = MonitoredCache(
|
|
354
|
-
cache,
|
|
355
|
-
self._failover_period,
|
|
356
|
-
self._resolver,
|
|
471
|
+
if ip_type != IPTypes.SQL_DATA:
|
|
472
|
+
monitored_cache = self._get_or_create_cache(
|
|
473
|
+
conn_name, enable_iam_auth, ip_type
|
|
357
474
|
)
|
|
358
|
-
logger.debug(f"['{conn_name}']: Connection info added to cache")
|
|
359
|
-
self._cache[(str(conn_name), enable_iam_auth)] = monitored_cache
|
|
360
475
|
|
|
361
476
|
connect_func = {
|
|
362
477
|
"pymysql": pymysql.connect,
|
|
@@ -371,11 +486,6 @@ class Connector:
|
|
|
371
486
|
connector: Callable = connect_func[driver] # type: ignore
|
|
372
487
|
except KeyError:
|
|
373
488
|
raise KeyError(f"Driver '{driver}' is not supported.")
|
|
374
|
-
|
|
375
|
-
ip_type = kwargs.pop("ip_type", self._ip_type)
|
|
376
|
-
# if ip_type is str, convert to IPTypes enum
|
|
377
|
-
if isinstance(ip_type, str):
|
|
378
|
-
ip_type = IPTypes._from_str(ip_type)
|
|
379
489
|
kwargs["timeout"] = kwargs.get("timeout", self._timeout)
|
|
380
490
|
|
|
381
491
|
# Host and ssl options come from the certificates and metadata, so we don't
|
|
@@ -384,113 +494,206 @@ class Connector:
|
|
|
384
494
|
kwargs.pop("ssl", None)
|
|
385
495
|
kwargs.pop("port", None)
|
|
386
496
|
|
|
387
|
-
# attempt to
|
|
497
|
+
# attempt to establish connection
|
|
388
498
|
try:
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
targets = []
|
|
400
|
-
# If the connector is configured with a custom DNS name, attempt to use
|
|
401
|
-
# that DNS name to connect to the instance. Fall back to the metadata IP
|
|
402
|
-
# address if the DNS name does not resolve to an IP address.
|
|
403
|
-
if conn_info.conn_name.domain_name and isinstance(self._resolver, DnsResolver):
|
|
404
|
-
try:
|
|
405
|
-
ips = await self._resolver.resolve_a_record(conn_info.conn_name.domain_name)
|
|
406
|
-
if ips:
|
|
407
|
-
targets.extend(ips)
|
|
499
|
+
if ip_type == IPTypes.SQL_DATA:
|
|
500
|
+
if driver in ASYNC_DRIVERS:
|
|
501
|
+
raise IncompatibleDriverError(
|
|
502
|
+
f"Driver '{driver}' is not supported with ip_type '{ip_type}'."
|
|
503
|
+
)
|
|
504
|
+
state = self._sql_data_conn_state.setdefault(
|
|
505
|
+
str(conn_name), SqlDataConnState()
|
|
506
|
+
)
|
|
507
|
+
if state.is_cooldown_active():
|
|
408
508
|
logger.debug(
|
|
409
|
-
f"['{
|
|
410
|
-
|
|
411
|
-
|
|
509
|
+
f"['{conn_name}']: SQL Data Service in cooldown until {state.cooldown_until}"
|
|
510
|
+
)
|
|
511
|
+
raise ResourceExhaustedError(
|
|
512
|
+
"cooldown active", str(conn_name), state.last_err
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
logger.debug(f"['{conn_name}']: Connecting via SQL Data Service tunnel")
|
|
516
|
+
if enable_iam_auth:
|
|
517
|
+
engine = DriverMapping[driver.upper()].value
|
|
518
|
+
formatted_user = format_database_user(
|
|
519
|
+
engine, kwargs["user"]
|
|
520
|
+
)
|
|
521
|
+
if formatted_user != kwargs["user"]:
|
|
522
|
+
logger.debug(
|
|
523
|
+
f"['{instance_connection_string}']: Truncated IAM database username from {kwargs['user']} to {formatted_user}"
|
|
524
|
+
)
|
|
525
|
+
kwargs["user"] = formatted_user
|
|
526
|
+
|
|
527
|
+
if self._sqldata_client is None:
|
|
528
|
+
sqldata_client = SqlDataClient(
|
|
529
|
+
endpoint=self._sql_data_endpoint,
|
|
530
|
+
credentials=self._credentials,
|
|
531
|
+
quota_project=self._quota_project,
|
|
532
|
+
timeout=self._sql_data_stream_timeout,
|
|
533
|
+
)
|
|
534
|
+
self._sqldata_client = sqldata_client
|
|
535
|
+
self._sqldata_clients.add(sqldata_client)
|
|
536
|
+
sqldata_client._on_close_callbacks.append(
|
|
537
|
+
lambda: self._sqldata_clients.discard(sqldata_client)
|
|
412
538
|
)
|
|
413
539
|
else:
|
|
540
|
+
sqldata_client = self._sqldata_client
|
|
541
|
+
|
|
542
|
+
def on_resource_exhausted(err: Exception) -> None:
|
|
543
|
+
backoff = state.record_exhausted(
|
|
544
|
+
err, self._resource_exhausted_cooldown_period
|
|
545
|
+
)
|
|
414
546
|
logger.debug(
|
|
415
|
-
f"['{
|
|
416
|
-
f"
|
|
417
|
-
f"entries, using '{preferred_ips}' from instance metadata"
|
|
547
|
+
f"['{conn_name}']: ResourceExhausted occurred, backing off for {backoff:.2f}s "
|
|
548
|
+
f"(attempt {state.backoff_counter})"
|
|
418
549
|
)
|
|
419
|
-
targets.extend(preferred_ips)
|
|
420
|
-
except Exception as e: # noqa: BLE001
|
|
421
|
-
logger.debug(
|
|
422
|
-
f"['{instance_connection_string}']: Custom DNS name "
|
|
423
|
-
f"'{conn_info.conn_name.domain_name}' did not resolve to an IP "
|
|
424
|
-
f"address: {e}, using '{preferred_ips}' from instance metadata"
|
|
425
|
-
)
|
|
426
|
-
targets.extend(preferred_ips)
|
|
427
|
-
else:
|
|
428
|
-
targets.extend(preferred_ips)
|
|
429
550
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
551
|
+
def on_success() -> None:
|
|
552
|
+
state.record_success()
|
|
553
|
+
|
|
554
|
+
def on_fallback(name: str) -> None:
|
|
555
|
+
state.record_fallback()
|
|
556
|
+
self._sql_data_fallback_cache.add(name)
|
|
557
|
+
|
|
558
|
+
def is_fallback_cached(name: str) -> bool:
|
|
559
|
+
return not state.allowed or name in self._sql_data_fallback_cache
|
|
560
|
+
|
|
561
|
+
# Defer cache creation and connect_info call
|
|
562
|
+
async def get_conn_info():
|
|
563
|
+
cache = self._get_or_create_cache(conn_name, enable_iam_auth)
|
|
564
|
+
return await cache.connect_info()
|
|
565
|
+
|
|
566
|
+
sock = await sqldata_client.connect(
|
|
567
|
+
instance_connection_name=str(conn_name),
|
|
568
|
+
region=conn_name.region,
|
|
569
|
+
project=conn_name.project,
|
|
570
|
+
get_conn_info=get_conn_info,
|
|
571
|
+
enable_iam_auth=enable_iam_auth,
|
|
572
|
+
on_fallback=on_fallback,
|
|
573
|
+
is_fallback_cached=is_fallback_cached,
|
|
574
|
+
on_resource_exhausted=on_resource_exhausted,
|
|
575
|
+
on_success=on_success,
|
|
576
|
+
connect_timeout=kwargs.get("timeout", self._timeout),
|
|
438
577
|
)
|
|
439
|
-
kwargs["user"] = formatted_user
|
|
440
578
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
579
|
+
cache = None
|
|
580
|
+
if conn_name.domain_name:
|
|
581
|
+
cache = self._get_or_create_cache(conn_name, enable_iam_auth)
|
|
582
|
+
cache.sockets.append(sock)
|
|
583
|
+
|
|
584
|
+
connect_partial = partial(
|
|
585
|
+
connector,
|
|
586
|
+
"127.0.0.1",
|
|
587
|
+
sock,
|
|
588
|
+
**kwargs,
|
|
589
|
+
)
|
|
445
590
|
try:
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
591
|
+
return await self._loop.run_in_executor(None, connect_partial)
|
|
592
|
+
except Exception:
|
|
593
|
+
sock.close()
|
|
594
|
+
if conn_name.domain_name and cache:
|
|
595
|
+
cache._purge_closed_sockets()
|
|
596
|
+
raise
|
|
597
|
+
else:
|
|
598
|
+
try:
|
|
599
|
+
conn_info = await monitored_cache.connect_info()
|
|
600
|
+
# validate driver matches intended database engine
|
|
601
|
+
DriverMapping.validate_engine(driver, conn_info.database_version)
|
|
602
|
+
preferred_ips = conn_info.get_preferred_ips(ip_type)
|
|
603
|
+
except Exception:
|
|
604
|
+
# with an error from Cloud SQL Admin API call or IP type, invalidate
|
|
605
|
+
# the cache and re-raise the error
|
|
606
|
+
await self._remove_cached(str(conn_name), enable_iam_auth)
|
|
607
|
+
raise
|
|
608
|
+
|
|
609
|
+
targets = await self._resolve_targets(conn_info, preferred_ips)
|
|
610
|
+
# format `user` param for automatic IAM database authn
|
|
611
|
+
if enable_iam_auth:
|
|
612
|
+
formatted_user = format_database_user(
|
|
613
|
+
conn_info.database_version, kwargs["user"]
|
|
614
|
+
)
|
|
615
|
+
if formatted_user != kwargs["user"]:
|
|
616
|
+
logger.debug(
|
|
617
|
+
f"['{instance_connection_string}']: Truncated IAM database username from {kwargs['user']} to {formatted_user}"
|
|
618
|
+
)
|
|
619
|
+
kwargs["user"] = formatted_user
|
|
620
|
+
|
|
621
|
+
last_ex = None
|
|
622
|
+
for target_ip in targets:
|
|
623
|
+
logger.debug(f"['{conn_info.conn_name}']: Connecting to {target_ip}:3307")
|
|
624
|
+
try:
|
|
625
|
+
# async drivers are unblocking and can be awaited directly
|
|
626
|
+
if driver in ASYNC_DRIVERS:
|
|
627
|
+
conn = await connector(
|
|
628
|
+
target_ip,
|
|
629
|
+
await conn_info.create_ssl_context(enable_iam_auth),
|
|
630
|
+
**kwargs,
|
|
631
|
+
)
|
|
632
|
+
if (
|
|
633
|
+
enable_iam_auth
|
|
634
|
+
and conn_info.database_version.startswith("POSTGRES")
|
|
635
|
+
):
|
|
636
|
+
db_name = kwargs.get("database")
|
|
637
|
+
if db_name is None:
|
|
638
|
+
db_name = kwargs.get("db")
|
|
639
|
+
user = kwargs.get("user")
|
|
640
|
+
if user:
|
|
641
|
+
monitored_cache.record_principal(
|
|
642
|
+
str(user),
|
|
643
|
+
str(db_name) if db_name else str(user),
|
|
644
|
+
)
|
|
645
|
+
last_ex = None
|
|
646
|
+
return conn
|
|
647
|
+
|
|
648
|
+
# Create socket with SSLContext for sync drivers
|
|
649
|
+
ctx = await conn_info.create_ssl_context(enable_iam_auth)
|
|
650
|
+
raw_sock = socket.create_connection((target_ip, SERVER_PROXY_PORT))
|
|
651
|
+
sync_sock: socket.socket | ssl.SSLSocket | PostgresConnection
|
|
652
|
+
try:
|
|
653
|
+
sync_sock = ctx.wrap_socket(
|
|
654
|
+
raw_sock,
|
|
655
|
+
server_hostname=target_ip,
|
|
656
|
+
)
|
|
657
|
+
except Exception:
|
|
658
|
+
raw_sock.close()
|
|
659
|
+
raise
|
|
660
|
+
|
|
661
|
+
if (
|
|
662
|
+
enable_iam_auth
|
|
663
|
+
and conn_info.database_version.startswith("POSTGRES")
|
|
664
|
+
):
|
|
665
|
+
sync_sock = PostgresConnection(
|
|
666
|
+
sync_sock,
|
|
667
|
+
on_startup=monitored_cache.record_principal,
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
# If this connection was opened using a domain name, then store it
|
|
671
|
+
# for later in case we need to forcibly close it on failover.
|
|
672
|
+
if conn_info.conn_name.domain_name:
|
|
673
|
+
monitored_cache.sockets.append(sync_sock)
|
|
674
|
+
# Synchronous drivers are blocking and run using executor
|
|
675
|
+
connect_partial = partial(
|
|
676
|
+
connector,
|
|
449
677
|
target_ip,
|
|
450
|
-
|
|
678
|
+
sync_sock,
|
|
451
679
|
**kwargs,
|
|
452
680
|
)
|
|
681
|
+
conn = await self._loop.run_in_executor(None, connect_partial)
|
|
453
682
|
last_ex = None
|
|
454
683
|
return conn
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
raw_sock = socket.create_connection((target_ip, SERVER_PROXY_PORT))
|
|
459
|
-
try:
|
|
460
|
-
sock = ctx.wrap_socket(
|
|
461
|
-
raw_sock,
|
|
462
|
-
server_hostname=target_ip,
|
|
684
|
+
except Exception as e: # noqa: BLE001
|
|
685
|
+
logger.debug(
|
|
686
|
+
f"['{conn_info.conn_name}']: Connection to {target_ip} failed: {e}"
|
|
463
687
|
)
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
# If this connection was opened using a domain name, then store it
|
|
469
|
-
# for later in case we need to forcibly close it on failover.
|
|
470
|
-
if conn_info.conn_name.domain_name:
|
|
471
|
-
monitored_cache.sockets.append(sock)
|
|
472
|
-
# Synchronous drivers are blocking and run using executor
|
|
473
|
-
connect_partial = partial(
|
|
474
|
-
connector,
|
|
475
|
-
target_ip,
|
|
476
|
-
sock,
|
|
477
|
-
**kwargs,
|
|
478
|
-
)
|
|
479
|
-
conn = await self._loop.run_in_executor(None, connect_partial)
|
|
480
|
-
last_ex = None
|
|
481
|
-
return conn
|
|
482
|
-
except Exception as e: # noqa: BLE001
|
|
483
|
-
logger.debug(
|
|
484
|
-
f"['{conn_info.conn_name}']: Connection to {target_ip} failed: {e}"
|
|
485
|
-
)
|
|
486
|
-
last_ex = e
|
|
487
|
-
|
|
488
|
-
if last_ex:
|
|
489
|
-
raise last_ex
|
|
688
|
+
last_ex = e
|
|
689
|
+
if last_ex:
|
|
690
|
+
raise last_ex
|
|
490
691
|
|
|
491
692
|
except Exception:
|
|
492
693
|
# with any exception, we attempt a force refresh, then throw the error
|
|
493
|
-
|
|
694
|
+
cached_entry = self._cache.get((str(conn_name), enable_iam_auth))
|
|
695
|
+
if cached_entry:
|
|
696
|
+
await cached_entry.force_refresh()
|
|
494
697
|
raise
|
|
495
698
|
|
|
496
699
|
async def _remove_cached(
|
|
@@ -506,6 +709,87 @@ class Connector:
|
|
|
506
709
|
cache = self._cache.pop((instance_connection_string, enable_iam_auth))
|
|
507
710
|
await cache.close()
|
|
508
711
|
|
|
712
|
+
async def _resolve_targets(
|
|
713
|
+
self,
|
|
714
|
+
conn_info: ConnectionInfo,
|
|
715
|
+
preferred_ips: list[str],
|
|
716
|
+
) -> list[str]:
|
|
717
|
+
"""Resolves target IP addresses for a Cloud SQL instance, with DNS fallback."""
|
|
718
|
+
targets: list[str] = []
|
|
719
|
+
if conn_info.conn_name.domain_name and isinstance(self._resolver, DnsResolver):
|
|
720
|
+
try:
|
|
721
|
+
ips = await self._resolver.resolve_a_record(
|
|
722
|
+
conn_info.conn_name.domain_name
|
|
723
|
+
)
|
|
724
|
+
if ips:
|
|
725
|
+
targets.extend(ips)
|
|
726
|
+
logger.debug(
|
|
727
|
+
f"['{conn_info.conn_name}']: Custom DNS name "
|
|
728
|
+
f"'{conn_info.conn_name.domain_name}' resolved to '{ips}', "
|
|
729
|
+
"using it to connect"
|
|
730
|
+
)
|
|
731
|
+
else:
|
|
732
|
+
logger.debug(
|
|
733
|
+
f"['{conn_info.conn_name}']: Custom DNS name "
|
|
734
|
+
f"'{conn_info.conn_name.domain_name}' resolved but returned no "
|
|
735
|
+
f"entries, using '{preferred_ips}' from instance metadata"
|
|
736
|
+
)
|
|
737
|
+
targets.extend(preferred_ips)
|
|
738
|
+
except Exception as e: # noqa: BLE001
|
|
739
|
+
logger.debug(
|
|
740
|
+
f"['{conn_info.conn_name}']: Custom DNS name "
|
|
741
|
+
f"'{conn_info.conn_name.domain_name}' did not resolve to an IP "
|
|
742
|
+
f"address: {e}, using '{preferred_ips}' from instance metadata"
|
|
743
|
+
)
|
|
744
|
+
targets.extend(preferred_ips)
|
|
745
|
+
else:
|
|
746
|
+
targets.extend(preferred_ips)
|
|
747
|
+
return targets
|
|
748
|
+
|
|
749
|
+
async def _dial_probe_connection(
|
|
750
|
+
self,
|
|
751
|
+
conn_info: ConnectionInfo,
|
|
752
|
+
enable_iam_auth: bool,
|
|
753
|
+
ip_type: IPTypes | None = None,
|
|
754
|
+
) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
|
|
755
|
+
"""Dials a TLS probe connection using the connector's IP resolution and SSLContext."""
|
|
756
|
+
effective_ip_type = ip_type if ip_type is not None else self._ip_type
|
|
757
|
+
preferred_ips = conn_info.get_preferred_ips(effective_ip_type)
|
|
758
|
+
targets = await self._resolve_targets(conn_info, preferred_ips)
|
|
759
|
+
|
|
760
|
+
if not targets:
|
|
761
|
+
raise ConnectionError(
|
|
762
|
+
f"No target IP addresses found for {conn_info.conn_name}"
|
|
763
|
+
)
|
|
764
|
+
|
|
765
|
+
ssl_context = await conn_info.create_ssl_context(enable_iam_auth)
|
|
766
|
+
server_hostname = (
|
|
767
|
+
conn_info.conn_name.domain_name
|
|
768
|
+
if conn_info.conn_name.domain_name
|
|
769
|
+
else None
|
|
770
|
+
)
|
|
771
|
+
|
|
772
|
+
last_err: Exception | None = None
|
|
773
|
+
for target in targets:
|
|
774
|
+
try:
|
|
775
|
+
reader, writer = await asyncio.wait_for(
|
|
776
|
+
asyncio.open_connection(
|
|
777
|
+
host=target,
|
|
778
|
+
port=SERVER_PROXY_PORT,
|
|
779
|
+
ssl=ssl_context,
|
|
780
|
+
server_hostname=server_hostname,
|
|
781
|
+
),
|
|
782
|
+
timeout=float(max(1, self._timeout)),
|
|
783
|
+
)
|
|
784
|
+
return reader, writer
|
|
785
|
+
except Exception as e: # noqa: BLE001
|
|
786
|
+
last_err = e
|
|
787
|
+
if last_err:
|
|
788
|
+
raise last_err
|
|
789
|
+
raise ConnectionError(
|
|
790
|
+
f"Failed to connect to any target for {conn_info.conn_name}"
|
|
791
|
+
)
|
|
792
|
+
|
|
509
793
|
def __enter__(self) -> Any:
|
|
510
794
|
"""Enter context manager by returning Connector object"""
|
|
511
795
|
return self
|
|
@@ -538,8 +822,11 @@ class Connector:
|
|
|
538
822
|
close_future = asyncio.run_coroutine_threadsafe(
|
|
539
823
|
self.close_async(), loop=self._loop
|
|
540
824
|
)
|
|
541
|
-
|
|
542
|
-
|
|
825
|
+
try:
|
|
826
|
+
# Will attempt to safely shut down tasks for 3s
|
|
827
|
+
close_future.result(timeout=3)
|
|
828
|
+
except Exception as e: # noqa: BLE001
|
|
829
|
+
logger.error(f"Error during close_async: {e}")
|
|
543
830
|
# if background thread exists for Connector, clean it up
|
|
544
831
|
if self._thread:
|
|
545
832
|
if self._loop.is_running():
|
|
@@ -552,9 +839,14 @@ class Connector:
|
|
|
552
839
|
"""Helper function to cancel the cache's tasks
|
|
553
840
|
and close aiohttp.ClientSession."""
|
|
554
841
|
self._closed = True
|
|
842
|
+
self._sqldata_client = None
|
|
555
843
|
if self._client:
|
|
556
844
|
await self._client.close()
|
|
557
|
-
await asyncio.gather(
|
|
845
|
+
await asyncio.gather(
|
|
846
|
+
*[cache.close() for cache in self._cache.values()],
|
|
847
|
+
*[client.close() for client in list(self._sqldata_clients)],
|
|
848
|
+
return_exceptions=True,
|
|
849
|
+
)
|
|
558
850
|
|
|
559
851
|
|
|
560
852
|
async def create_async_connector(
|
|
@@ -570,6 +862,9 @@ async def create_async_connector(
|
|
|
570
862
|
refresh_strategy: str | RefreshStrategy = RefreshStrategy.BACKGROUND,
|
|
571
863
|
resolver: type[DefaultResolver | DnsResolver] = DefaultResolver,
|
|
572
864
|
failover_period: int = 30,
|
|
865
|
+
sql_data_endpoint: str | None = None,
|
|
866
|
+
sql_data_stream_timeout: int = 7200,
|
|
867
|
+
resource_exhausted_cooldown_period: float = 5.0,
|
|
573
868
|
) -> Connector:
|
|
574
869
|
"""Helper function to create Connector object for asyncio connections.
|
|
575
870
|
|
|
@@ -579,8 +874,11 @@ async def create_async_connector(
|
|
|
579
874
|
Args:
|
|
580
875
|
ip_type (str | IPTypes): The default IP address type used to connect to
|
|
581
876
|
Cloud SQL instances. Can be one of the following:
|
|
582
|
-
IPTypes.PUBLIC ("PUBLIC"), IPTypes.PRIVATE ("PRIVATE"),
|
|
583
|
-
IPTypes.PSC ("PSC")
|
|
877
|
+
IPTypes.PUBLIC ("PUBLIC"), IPTypes.PRIVATE ("PRIVATE"),
|
|
878
|
+
IPTypes.PSC ("PSC"), or IPTypes.SQL_DATA ("SQL_DATA").
|
|
879
|
+
Note: IPTypes.SQL_DATA is not currently supported with the
|
|
880
|
+
asyncpg driver.
|
|
881
|
+
Default: IPTypes.PUBLIC
|
|
584
882
|
|
|
585
883
|
enable_iam_auth (bool): Enables automatic IAM database authentication
|
|
586
884
|
(Postgres and MySQL) as the default authentication method for all
|
|
@@ -626,6 +924,16 @@ async def create_async_connector(
|
|
|
626
924
|
Must be used with `resolver=DnsResolver` to have any effect.
|
|
627
925
|
Default: 30
|
|
628
926
|
|
|
927
|
+
sql_data_endpoint (str | None): Endpoint host for SQL Data Service
|
|
928
|
+
calls. If not specified, derived from `sqladmin_api_endpoint`.
|
|
929
|
+
Default: "sqladmin.googleapis.com".
|
|
930
|
+
|
|
931
|
+
sql_data_stream_timeout (int): Timeout in seconds for the SQL Data
|
|
932
|
+
Service gRPC stream. Default: 7200.
|
|
933
|
+
|
|
934
|
+
resource_exhausted_cooldown_period (float): Cooldown period in seconds
|
|
935
|
+
after a ResourceExhausted error. Default: 5.0.
|
|
936
|
+
|
|
629
937
|
Returns:
|
|
630
938
|
A Connector instance configured with running event loop.
|
|
631
939
|
"""
|
|
@@ -645,4 +953,7 @@ async def create_async_connector(
|
|
|
645
953
|
refresh_strategy=refresh_strategy,
|
|
646
954
|
resolver=resolver,
|
|
647
955
|
failover_period=failover_period,
|
|
956
|
+
sql_data_endpoint=sql_data_endpoint,
|
|
957
|
+
sql_data_stream_timeout=sql_data_stream_timeout,
|
|
958
|
+
resource_exhausted_cooldown_period=resource_exhausted_cooldown_period,
|
|
648
959
|
)
|