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
|
@@ -41,6 +41,7 @@ class IPTypes(Enum):
|
|
|
41
41
|
PUBLIC = "PRIMARY"
|
|
42
42
|
PRIVATE = "PRIVATE"
|
|
43
43
|
PSC = "PSC"
|
|
44
|
+
SQL_DATA = "SQL_DATA"
|
|
44
45
|
|
|
45
46
|
@classmethod
|
|
46
47
|
def _missing_(cls, value: object) -> None:
|
|
@@ -54,6 +55,8 @@ class IPTypes(Enum):
|
|
|
54
55
|
"""Convert IP type from a str into IPTypes."""
|
|
55
56
|
if ip_type_str.upper() == "PUBLIC":
|
|
56
57
|
ip_type_str = "PRIMARY"
|
|
58
|
+
elif ip_type_str.upper() in ("SQLDATA", "SQL_DATA"):
|
|
59
|
+
ip_type_str = "SQL_DATA"
|
|
57
60
|
return cls(ip_type_str.upper())
|
|
58
61
|
|
|
59
62
|
|
|
@@ -14,6 +14,8 @@ See the License for the specific language governing permissions and
|
|
|
14
14
|
limitations under the License.
|
|
15
15
|
"""
|
|
16
16
|
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
17
19
|
|
|
18
20
|
class ConnectorLoopError(Exception):
|
|
19
21
|
"""
|
|
@@ -91,3 +93,34 @@ class ClosedConnectorError(Exception):
|
|
|
91
93
|
Exception to be raised when a Connector is closed and connect method is
|
|
92
94
|
called on it.
|
|
93
95
|
"""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class CloudSQLConnectionError(Exception):
|
|
99
|
+
"""
|
|
100
|
+
Exception to be raised when a connection cannot be established to a Cloud SQL instance.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class ResourceExhaustedError(CloudSQLConnectionError):
|
|
105
|
+
"""
|
|
106
|
+
Exception to be raised when a connection cannot be established because
|
|
107
|
+
the SQL Data Service is busy / in cooldown due to resource exhaustion.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
def __init__(
|
|
111
|
+
self,
|
|
112
|
+
message: str,
|
|
113
|
+
connection_name: str | None = None,
|
|
114
|
+
raw_error: Exception | None = None,
|
|
115
|
+
) -> None:
|
|
116
|
+
self.message = message
|
|
117
|
+
self.connection_name = connection_name
|
|
118
|
+
self.raw_error = raw_error
|
|
119
|
+
super().__init__(
|
|
120
|
+
f"[{connection_name}] {message}: {raw_error}"
|
|
121
|
+
if connection_name and raw_error
|
|
122
|
+
else f"[{connection_name}] {message}"
|
|
123
|
+
if connection_name
|
|
124
|
+
else message
|
|
125
|
+
)
|
|
126
|
+
|
|
@@ -17,16 +17,21 @@ limitations under the License.
|
|
|
17
17
|
from __future__ import annotations
|
|
18
18
|
|
|
19
19
|
import asyncio
|
|
20
|
+
from collections.abc import Awaitable
|
|
21
|
+
from collections.abc import Callable
|
|
20
22
|
from datetime import datetime
|
|
21
23
|
from datetime import timedelta
|
|
22
|
-
from datetime import
|
|
24
|
+
from datetime import UTC
|
|
23
25
|
import logging
|
|
24
26
|
|
|
25
27
|
from google.cloud.sql.connector.client import CloudSQLClient
|
|
26
28
|
from google.cloud.sql.connector.connection_info import ConnectionInfo
|
|
27
29
|
from google.cloud.sql.connector.connection_info import ConnectionInfoCache
|
|
28
30
|
from google.cloud.sql.connector.connection_name import ConnectionName
|
|
31
|
+
from google.cloud.sql.connector.enums import IPTypes
|
|
29
32
|
from google.cloud.sql.connector.exceptions import RefreshNotValidError
|
|
33
|
+
from google.cloud.sql.connector.postgres_connection import build_postgres_startup_packet
|
|
34
|
+
from google.cloud.sql.connector.postgres_connection import build_terminate_packet
|
|
30
35
|
from google.cloud.sql.connector.rate_limiter import AsyncRateLimiter
|
|
31
36
|
from google.cloud.sql.connector.refresh_utils import _is_valid
|
|
32
37
|
from google.cloud.sql.connector.refresh_utils import _seconds_until_refresh
|
|
@@ -34,6 +39,8 @@ from google.cloud.sql.connector.refresh_utils import _seconds_until_refresh
|
|
|
34
39
|
logger = logging.getLogger(name=__name__)
|
|
35
40
|
|
|
36
41
|
APPLICATION_NAME = "cloud-sql-python-connector"
|
|
42
|
+
SERVER_PROXY_PORT = 3307
|
|
43
|
+
DEFAULT_CONNECT_TIMEOUT = 30
|
|
37
44
|
|
|
38
45
|
|
|
39
46
|
class RefreshAheadCache(ConnectionInfoCache):
|
|
@@ -50,6 +57,15 @@ class RefreshAheadCache(ConnectionInfoCache):
|
|
|
50
57
|
client: CloudSQLClient,
|
|
51
58
|
keys: asyncio.Future,
|
|
52
59
|
enable_iam_auth: bool = False,
|
|
60
|
+
ip_type: IPTypes | str = IPTypes.PUBLIC,
|
|
61
|
+
timeout: int = DEFAULT_CONNECT_TIMEOUT,
|
|
62
|
+
dial_func: (
|
|
63
|
+
Callable[
|
|
64
|
+
[ConnectionInfo],
|
|
65
|
+
Awaitable[tuple[asyncio.StreamReader, asyncio.StreamWriter]],
|
|
66
|
+
]
|
|
67
|
+
| None
|
|
68
|
+
) = None,
|
|
53
69
|
) -> None:
|
|
54
70
|
"""Initializes a RefreshAheadCache instance.
|
|
55
71
|
|
|
@@ -62,10 +78,18 @@ class RefreshAheadCache(ConnectionInfoCache):
|
|
|
62
78
|
enable_iam_auth (bool): Enables automatic IAM database authentication
|
|
63
79
|
(Postgres and MySQL) as the default authentication method for all
|
|
64
80
|
connections.
|
|
81
|
+
ip_type (IPTypes | str): Preferred IP type used to connect to the instance.
|
|
82
|
+
timeout (int): Connect timeout in seconds.
|
|
83
|
+
dial_func: Optional callable to dial probe connections.
|
|
65
84
|
"""
|
|
66
85
|
self._conn_name = conn_name
|
|
67
86
|
|
|
68
87
|
self._enable_iam_auth = enable_iam_auth
|
|
88
|
+
if isinstance(ip_type, str):
|
|
89
|
+
ip_type = IPTypes._from_str(ip_type)
|
|
90
|
+
self._ip_type = ip_type
|
|
91
|
+
self._timeout = timeout
|
|
92
|
+
self._dial_func = dial_func
|
|
69
93
|
self._keys = keys
|
|
70
94
|
self._client = client
|
|
71
95
|
self._refresh_rate_limiter = AsyncRateLimiter(
|
|
@@ -73,10 +97,15 @@ class RefreshAheadCache(ConnectionInfoCache):
|
|
|
73
97
|
rate=1 / 30,
|
|
74
98
|
)
|
|
75
99
|
self._refresh_in_progress = asyncio.locks.Event()
|
|
100
|
+
self._iam_principals: list[tuple[str, str]] = []
|
|
76
101
|
self._current: asyncio.Task = self._schedule_refresh(0)
|
|
77
102
|
self._next: asyncio.Task = self._current
|
|
78
103
|
self._closed = False
|
|
79
104
|
|
|
105
|
+
def record_principal(self, user: str, database: str) -> None:
|
|
106
|
+
"""Records a PostgreSQL (user, database) pair observed on an IAM connection."""
|
|
107
|
+
_append_iam_principal(self._iam_principals, user, database)
|
|
108
|
+
|
|
80
109
|
@property
|
|
81
110
|
def conn_name(self) -> ConnectionName:
|
|
82
111
|
return self._conn_name
|
|
@@ -119,6 +148,8 @@ class RefreshAheadCache(ConnectionInfoCache):
|
|
|
119
148
|
self._keys,
|
|
120
149
|
self._enable_iam_auth,
|
|
121
150
|
)
|
|
151
|
+
if self._enable_iam_auth:
|
|
152
|
+
await self._probe_connection(connection_info)
|
|
122
153
|
logger.debug(
|
|
123
154
|
f"['{self._conn_name}']: Connection info refresh operation complete"
|
|
124
155
|
)
|
|
@@ -138,6 +169,18 @@ class RefreshAheadCache(ConnectionInfoCache):
|
|
|
138
169
|
self._refresh_in_progress.clear()
|
|
139
170
|
return connection_info
|
|
140
171
|
|
|
172
|
+
async def _probe_connection(self, conn_info: ConnectionInfo) -> None:
|
|
173
|
+
"""Proactively probes the database to refresh IAM tokens on server-side MCP."""
|
|
174
|
+
await _probe_instance_connection(
|
|
175
|
+
self._conn_name,
|
|
176
|
+
conn_info,
|
|
177
|
+
self._enable_iam_auth,
|
|
178
|
+
self._ip_type,
|
|
179
|
+
self._timeout,
|
|
180
|
+
self._iam_principals,
|
|
181
|
+
self._dial_func,
|
|
182
|
+
)
|
|
183
|
+
|
|
141
184
|
def _schedule_refresh(self, delay: int) -> asyncio.Task:
|
|
142
185
|
"""
|
|
143
186
|
Schedule task to sleep and then perform refresh to get ConnectionInfo.
|
|
@@ -192,7 +235,7 @@ class RefreshAheadCache(ConnectionInfoCache):
|
|
|
192
235
|
logger.debug(
|
|
193
236
|
f"['{self._conn_name}']: Connection info refresh"
|
|
194
237
|
" operation scheduled for "
|
|
195
|
-
f"{(datetime.now(
|
|
238
|
+
f"{(datetime.now(UTC) + timedelta(seconds=delay)).isoformat(timespec='seconds')} "
|
|
196
239
|
f"(now + {timedelta(seconds=delay)})"
|
|
197
240
|
)
|
|
198
241
|
self._next = self._schedule_refresh(delay)
|
|
@@ -223,3 +266,196 @@ class RefreshAheadCache(ConnectionInfoCache):
|
|
|
223
266
|
tasks = asyncio.gather(self._current, self._next, return_exceptions=True)
|
|
224
267
|
await asyncio.wait_for(tasks, timeout=2.0)
|
|
225
268
|
self._closed = True
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _append_iam_principal(
|
|
272
|
+
principals: list[tuple[str, str]], user: str, database: str
|
|
273
|
+
) -> None:
|
|
274
|
+
"""Appends (user, database) to principals if not already present."""
|
|
275
|
+
if not user:
|
|
276
|
+
return
|
|
277
|
+
if not database:
|
|
278
|
+
database = user
|
|
279
|
+
pair = (user, database)
|
|
280
|
+
if pair not in principals:
|
|
281
|
+
principals.append(pair)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
async def _probe_instance_connection(
|
|
285
|
+
conn_name: ConnectionName,
|
|
286
|
+
conn_info: ConnectionInfo,
|
|
287
|
+
enable_iam_auth: bool,
|
|
288
|
+
ip_type: IPTypes,
|
|
289
|
+
timeout: int,
|
|
290
|
+
iam_principals: list[tuple[str, str]],
|
|
291
|
+
dial_func: (
|
|
292
|
+
Callable[
|
|
293
|
+
[ConnectionInfo],
|
|
294
|
+
Awaitable[tuple[asyncio.StreamReader, asyncio.StreamWriter]],
|
|
295
|
+
]
|
|
296
|
+
| None
|
|
297
|
+
) = None,
|
|
298
|
+
) -> None:
|
|
299
|
+
"""Proactively probes the database to refresh IAM tokens on server-side MCP."""
|
|
300
|
+
if not enable_iam_auth:
|
|
301
|
+
return
|
|
302
|
+
|
|
303
|
+
# Proactive IAM refresh probe is PostgreSQL-only.
|
|
304
|
+
if not conn_info.database_version.startswith("POSTGRES"):
|
|
305
|
+
logger.debug(
|
|
306
|
+
f"['{conn_name}']: Proactive IAM token refresh probe skipped: instance engine '{conn_info.database_version}' is not PostgreSQL"
|
|
307
|
+
)
|
|
308
|
+
return
|
|
309
|
+
|
|
310
|
+
# Do not probe if no IAM principals have connected yet.
|
|
311
|
+
if not iam_principals:
|
|
312
|
+
logger.debug(
|
|
313
|
+
f"['{conn_name}']: Proactive IAM token refresh probe skipped: no IAM principals connected"
|
|
314
|
+
)
|
|
315
|
+
return
|
|
316
|
+
|
|
317
|
+
targets: list[str] = []
|
|
318
|
+
ssl_context = None
|
|
319
|
+
if dial_func is None:
|
|
320
|
+
if conn_name.domain_name:
|
|
321
|
+
targets.append(conn_name.domain_name)
|
|
322
|
+
if ip_type.value in conn_info.ip_addrs:
|
|
323
|
+
targets.extend(conn_info.ip_addrs[ip_type.value])
|
|
324
|
+
|
|
325
|
+
if not targets:
|
|
326
|
+
logger.debug(
|
|
327
|
+
f"['{conn_name}']: Proactive IAM token refresh probe skipped: no target IP addresses"
|
|
328
|
+
)
|
|
329
|
+
return
|
|
330
|
+
|
|
331
|
+
try:
|
|
332
|
+
ssl_context = await conn_info.create_ssl_context(enable_iam_auth)
|
|
333
|
+
except Exception as e: # noqa: BLE001
|
|
334
|
+
logger.debug(
|
|
335
|
+
f"['{conn_name}']: Failed to create SSL context for probe: {e!s}"
|
|
336
|
+
)
|
|
337
|
+
return
|
|
338
|
+
|
|
339
|
+
loop = asyncio.get_running_loop()
|
|
340
|
+
deadline = loop.time() + float(max(1, timeout))
|
|
341
|
+
|
|
342
|
+
all_succeeded = True
|
|
343
|
+
for user, database in list(iam_principals):
|
|
344
|
+
remaining = deadline - loop.time()
|
|
345
|
+
if remaining <= 0:
|
|
346
|
+
all_succeeded = False
|
|
347
|
+
break
|
|
348
|
+
|
|
349
|
+
probed_principal = False
|
|
350
|
+
if dial_func is not None:
|
|
351
|
+
try:
|
|
352
|
+
reader, writer = await asyncio.wait_for(
|
|
353
|
+
dial_func(conn_info),
|
|
354
|
+
timeout=remaining,
|
|
355
|
+
)
|
|
356
|
+
probed_principal = await _send_startup_probe(
|
|
357
|
+
reader,
|
|
358
|
+
writer,
|
|
359
|
+
user,
|
|
360
|
+
database,
|
|
361
|
+
max(0.0, deadline - loop.time()),
|
|
362
|
+
conn_name,
|
|
363
|
+
)
|
|
364
|
+
except Exception as e: # noqa: BLE001
|
|
365
|
+
logger.debug(
|
|
366
|
+
f"['{conn_name}']: Probing IAM token refresh for ({user}, {database}) failed: {e!s}"
|
|
367
|
+
)
|
|
368
|
+
else:
|
|
369
|
+
for target in targets:
|
|
370
|
+
remaining = deadline - loop.time()
|
|
371
|
+
if remaining <= 0:
|
|
372
|
+
break
|
|
373
|
+
try:
|
|
374
|
+
logger.debug(
|
|
375
|
+
f"['{conn_name}']: Probing IAM token refresh on {target}:{SERVER_PROXY_PORT}"
|
|
376
|
+
)
|
|
377
|
+
reader, writer = await asyncio.wait_for(
|
|
378
|
+
asyncio.open_connection(
|
|
379
|
+
host=target,
|
|
380
|
+
port=SERVER_PROXY_PORT,
|
|
381
|
+
ssl=ssl_context,
|
|
382
|
+
server_hostname=(
|
|
383
|
+
conn_name.domain_name
|
|
384
|
+
if conn_name.domain_name
|
|
385
|
+
else None
|
|
386
|
+
),
|
|
387
|
+
),
|
|
388
|
+
timeout=remaining,
|
|
389
|
+
)
|
|
390
|
+
probed_principal = await _send_startup_probe(
|
|
391
|
+
reader,
|
|
392
|
+
writer,
|
|
393
|
+
user,
|
|
394
|
+
database,
|
|
395
|
+
max(0.0, deadline - loop.time()),
|
|
396
|
+
conn_name,
|
|
397
|
+
)
|
|
398
|
+
break
|
|
399
|
+
except Exception as e: # noqa: BLE001
|
|
400
|
+
logger.debug(
|
|
401
|
+
f"['{conn_name}']: Probing IAM token refresh on {target}:{SERVER_PROXY_PORT} failed: {e!s}"
|
|
402
|
+
)
|
|
403
|
+
if not probed_principal:
|
|
404
|
+
all_succeeded = False
|
|
405
|
+
|
|
406
|
+
if all_succeeded:
|
|
407
|
+
logger.debug(
|
|
408
|
+
f"['{conn_name}']: Proactive IAM token refresh probe successful"
|
|
409
|
+
)
|
|
410
|
+
else:
|
|
411
|
+
logger.debug(
|
|
412
|
+
f"['{conn_name}']: Proactive IAM token refresh probe encountered error across all targets"
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
async def _send_startup_probe(
|
|
417
|
+
reader: asyncio.StreamReader,
|
|
418
|
+
writer: asyncio.StreamWriter,
|
|
419
|
+
user: str,
|
|
420
|
+
database: str,
|
|
421
|
+
timeout: float,
|
|
422
|
+
conn_name: ConnectionName,
|
|
423
|
+
) -> bool:
|
|
424
|
+
"""Sends a PostgreSQL StartupMessage probe and gracefully terminates."""
|
|
425
|
+
loop = asyncio.get_running_loop()
|
|
426
|
+
deadline = loop.time() + max(0.0, float(timeout))
|
|
427
|
+
try:
|
|
428
|
+
remaining = deadline - loop.time()
|
|
429
|
+
if remaining <= 0:
|
|
430
|
+
return False
|
|
431
|
+
writer.write(build_postgres_startup_packet(user, database))
|
|
432
|
+
await asyncio.wait_for(writer.drain(), timeout=remaining)
|
|
433
|
+
await asyncio.wait_for(
|
|
434
|
+
reader.read(1024),
|
|
435
|
+
timeout=max(0.0, deadline - loop.time()),
|
|
436
|
+
)
|
|
437
|
+
writer.write(build_terminate_packet())
|
|
438
|
+
await asyncio.wait_for(
|
|
439
|
+
writer.drain(),
|
|
440
|
+
timeout=max(0.0, deadline - loop.time()),
|
|
441
|
+
)
|
|
442
|
+
eof_timeout = min(max(0.0, deadline - loop.time()), 1.0)
|
|
443
|
+
if eof_timeout > 0:
|
|
444
|
+
try:
|
|
445
|
+
await asyncio.wait_for(reader.read(1024), timeout=eof_timeout)
|
|
446
|
+
except Exception as e: # noqa: BLE001
|
|
447
|
+
logger.debug(
|
|
448
|
+
f"['{conn_name}']: Ignoring error while waiting for server EOF: {e!s}"
|
|
449
|
+
)
|
|
450
|
+
return True
|
|
451
|
+
finally:
|
|
452
|
+
writer.close()
|
|
453
|
+
try:
|
|
454
|
+
await asyncio.wait_for(writer.wait_closed(), timeout=2.0)
|
|
455
|
+
except Exception as e: # noqa: BLE001
|
|
456
|
+
logger.debug(
|
|
457
|
+
f"['{conn_name}']: Ignoring error while waiting for socket to close: {e!s}"
|
|
458
|
+
)
|
|
459
|
+
transport = getattr(writer, "transport", None)
|
|
460
|
+
if transport is not None and hasattr(transport, "abort"):
|
|
461
|
+
transport.abort()
|
|
@@ -15,15 +15,21 @@
|
|
|
15
15
|
from __future__ import annotations
|
|
16
16
|
|
|
17
17
|
import asyncio
|
|
18
|
+
from collections.abc import Awaitable
|
|
19
|
+
from collections.abc import Callable
|
|
18
20
|
from datetime import datetime
|
|
19
21
|
from datetime import timedelta
|
|
20
|
-
from datetime import
|
|
22
|
+
from datetime import UTC
|
|
21
23
|
import logging
|
|
22
24
|
|
|
23
25
|
from google.cloud.sql.connector.client import CloudSQLClient
|
|
24
26
|
from google.cloud.sql.connector.connection_info import ConnectionInfo
|
|
25
27
|
from google.cloud.sql.connector.connection_info import ConnectionInfoCache
|
|
26
28
|
from google.cloud.sql.connector.connection_name import ConnectionName
|
|
29
|
+
from google.cloud.sql.connector.enums import IPTypes
|
|
30
|
+
from google.cloud.sql.connector.instance import _append_iam_principal
|
|
31
|
+
from google.cloud.sql.connector.instance import _probe_instance_connection
|
|
32
|
+
from google.cloud.sql.connector.instance import DEFAULT_CONNECT_TIMEOUT
|
|
27
33
|
from google.cloud.sql.connector.refresh_utils import _refresh_buffer
|
|
28
34
|
|
|
29
35
|
logger = logging.getLogger(name=__name__)
|
|
@@ -44,6 +50,15 @@ class LazyRefreshCache(ConnectionInfoCache):
|
|
|
44
50
|
client: CloudSQLClient,
|
|
45
51
|
keys: asyncio.Future,
|
|
46
52
|
enable_iam_auth: bool = False,
|
|
53
|
+
ip_type: IPTypes | str = IPTypes.PUBLIC,
|
|
54
|
+
timeout: int = DEFAULT_CONNECT_TIMEOUT,
|
|
55
|
+
dial_func: (
|
|
56
|
+
Callable[
|
|
57
|
+
[ConnectionInfo],
|
|
58
|
+
Awaitable[tuple[asyncio.StreamReader, asyncio.StreamWriter]],
|
|
59
|
+
]
|
|
60
|
+
| None
|
|
61
|
+
) = None,
|
|
47
62
|
) -> None:
|
|
48
63
|
"""Initializes a LazyRefreshCache instance.
|
|
49
64
|
|
|
@@ -56,16 +71,29 @@ class LazyRefreshCache(ConnectionInfoCache):
|
|
|
56
71
|
enable_iam_auth (bool): Enables automatic IAM database authentication
|
|
57
72
|
(Postgres and MySQL) as the default authentication method for all
|
|
58
73
|
connections.
|
|
74
|
+
ip_type (IPTypes | str): Preferred IP type used to connect to the instance.
|
|
75
|
+
timeout (int): Connect timeout in seconds.
|
|
76
|
+
dial_func: Optional callable to dial probe connections.
|
|
59
77
|
"""
|
|
60
78
|
self._conn_name = conn_name
|
|
61
79
|
self._enable_iam_auth = enable_iam_auth
|
|
80
|
+
if isinstance(ip_type, str):
|
|
81
|
+
ip_type = IPTypes._from_str(ip_type)
|
|
82
|
+
self._ip_type = ip_type
|
|
83
|
+
self._timeout = timeout
|
|
84
|
+
self._dial_func = dial_func
|
|
62
85
|
self._keys = keys
|
|
63
86
|
self._client = client
|
|
64
87
|
self._lock = asyncio.Lock()
|
|
88
|
+
self._iam_principals: list[tuple[str, str]] = []
|
|
65
89
|
self._cached: ConnectionInfo | None = None
|
|
66
90
|
self._needs_refresh = False
|
|
67
91
|
self._closed = False
|
|
68
92
|
|
|
93
|
+
def record_principal(self, user: str, database: str) -> None:
|
|
94
|
+
"""Records a PostgreSQL (user, database) pair observed on an IAM connection."""
|
|
95
|
+
_append_iam_principal(self._iam_principals, user, database)
|
|
96
|
+
|
|
69
97
|
@property
|
|
70
98
|
def conn_name(self) -> ConnectionName:
|
|
71
99
|
return self._conn_name
|
|
@@ -82,6 +110,18 @@ class LazyRefreshCache(ConnectionInfoCache):
|
|
|
82
110
|
async with self._lock:
|
|
83
111
|
self._needs_refresh = True
|
|
84
112
|
|
|
113
|
+
async def _probe_connection(self, conn_info: ConnectionInfo) -> None:
|
|
114
|
+
"""Proactively probes the database to refresh IAM tokens on server-side MCP."""
|
|
115
|
+
await _probe_instance_connection(
|
|
116
|
+
self._conn_name,
|
|
117
|
+
conn_info,
|
|
118
|
+
self._enable_iam_auth,
|
|
119
|
+
self._ip_type,
|
|
120
|
+
self._timeout,
|
|
121
|
+
self._iam_principals,
|
|
122
|
+
self._dial_func,
|
|
123
|
+
)
|
|
124
|
+
|
|
85
125
|
async def connect_info(self) -> ConnectionInfo:
|
|
86
126
|
"""Retrieves ConnectionInfo instance for establishing a secure
|
|
87
127
|
connection to the Cloud SQL instance.
|
|
@@ -93,7 +133,7 @@ class LazyRefreshCache(ConnectionInfoCache):
|
|
|
93
133
|
if (
|
|
94
134
|
self._cached
|
|
95
135
|
and not self._needs_refresh
|
|
96
|
-
and datetime.now(
|
|
136
|
+
and datetime.now(UTC)
|
|
97
137
|
< (self._cached.expiration - timedelta(seconds=_refresh_buffer))
|
|
98
138
|
):
|
|
99
139
|
logger.debug(
|
|
@@ -126,7 +166,11 @@ class LazyRefreshCache(ConnectionInfoCache):
|
|
|
126
166
|
)
|
|
127
167
|
self._cached = conn_info
|
|
128
168
|
self._needs_refresh = False
|
|
129
|
-
|
|
169
|
+
|
|
170
|
+
if self._enable_iam_auth:
|
|
171
|
+
await self._probe_connection(conn_info)
|
|
172
|
+
|
|
173
|
+
return conn_info
|
|
130
174
|
|
|
131
175
|
async def close(self) -> None:
|
|
132
176
|
"""Close is a no-op and provided purely for a consistent interface with
|
|
@@ -15,9 +15,11 @@
|
|
|
15
15
|
from __future__ import annotations
|
|
16
16
|
|
|
17
17
|
import asyncio
|
|
18
|
+
from collections.abc import Callable
|
|
18
19
|
import logging
|
|
20
|
+
import socket
|
|
19
21
|
import ssl
|
|
20
|
-
from typing import Any
|
|
22
|
+
from typing import Any
|
|
21
23
|
|
|
22
24
|
import aiohttp
|
|
23
25
|
|
|
@@ -27,6 +29,7 @@ from google.cloud.sql.connector.exceptions import CacheClosedError
|
|
|
27
29
|
from google.cloud.sql.connector.exceptions import DnsLoopError
|
|
28
30
|
from google.cloud.sql.connector.instance import RefreshAheadCache
|
|
29
31
|
from google.cloud.sql.connector.lazy import LazyRefreshCache
|
|
32
|
+
from google.cloud.sql.connector.postgres_connection import PostgresConnection
|
|
30
33
|
from google.cloud.sql.connector.resolver import DefaultResolver
|
|
31
34
|
from google.cloud.sql.connector.resolver import DnsResolver
|
|
32
35
|
from google.cloud.sql.connector.resolver import is_instance_dns_name
|
|
@@ -44,7 +47,7 @@ class MonitoredCache(ConnectionInfoCache):
|
|
|
44
47
|
self.resolver = resolver
|
|
45
48
|
self.cache = cache
|
|
46
49
|
self.domain_name_ticker: asyncio.Task | None = None
|
|
47
|
-
self.sockets: list[ssl.SSLSocket] = []
|
|
50
|
+
self.sockets: list[socket.socket | ssl.SSLSocket | PostgresConnection] = []
|
|
48
51
|
|
|
49
52
|
# If domain name is configured for instance and failover period is set,
|
|
50
53
|
# poll for DNS record changes.
|
|
@@ -77,11 +80,11 @@ class MonitoredCache(ConnectionInfoCache):
|
|
|
77
80
|
list of sockets.
|
|
78
81
|
"""
|
|
79
82
|
open_sockets = []
|
|
80
|
-
for
|
|
83
|
+
for sock in self.sockets:
|
|
81
84
|
# Check fileno for if socket is closed. Will return
|
|
82
85
|
# -1 on failure, which will be used to signal socket closed.
|
|
83
|
-
if
|
|
84
|
-
open_sockets.append(
|
|
86
|
+
if sock.fileno() != -1:
|
|
87
|
+
open_sockets.append(sock)
|
|
85
88
|
self.sockets = open_sockets
|
|
86
89
|
|
|
87
90
|
async def _check_domain_name(self) -> None:
|
|
@@ -139,6 +142,9 @@ class MonitoredCache(ConnectionInfoCache):
|
|
|
139
142
|
return
|
|
140
143
|
return await self.cache.force_refresh()
|
|
141
144
|
|
|
145
|
+
def record_principal(self, user: str, database: str) -> None:
|
|
146
|
+
self.cache.record_principal(user, database)
|
|
147
|
+
|
|
142
148
|
async def close(self) -> None:
|
|
143
149
|
# Cancel domain name ticker task.
|
|
144
150
|
if self.domain_name_ticker:
|
|
@@ -159,11 +165,11 @@ class MonitoredCache(ConnectionInfoCache):
|
|
|
159
165
|
await self.cache.close()
|
|
160
166
|
|
|
161
167
|
# Close any still open sockets
|
|
162
|
-
for
|
|
168
|
+
for sock in self.sockets:
|
|
163
169
|
# Check fileno for if socket is closed. Will return
|
|
164
170
|
# -1 on failure, which will be used to signal socket closed.
|
|
165
|
-
if
|
|
166
|
-
|
|
171
|
+
if sock.fileno() != -1:
|
|
172
|
+
sock.close()
|
|
167
173
|
|
|
168
174
|
|
|
169
175
|
async def ticker(interval: int, function: Callable, *args: Any, **kwargs: Any) -> None:
|