cloud-sql-python-connector 1.9.2__py2.py3-none-any.whl → 1.10.0__py2.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.9.2.dist-info → cloud_sql_python_connector-1.10.0.dist-info}/METADATA +75 -13
- cloud_sql_python_connector-1.10.0.dist-info/RECORD +22 -0
- google/cloud/sql/connector/__init__.py +9 -2
- google/cloud/sql/connector/client.py +83 -13
- google/cloud/sql/connector/connection_info.py +105 -0
- google/cloud/sql/connector/connector.py +72 -18
- google/cloud/sql/connector/enums.py +85 -0
- google/cloud/sql/connector/exceptions.py +15 -0
- google/cloud/sql/connector/instance.py +48 -178
- google/cloud/sql/connector/lazy.py +132 -0
- google/cloud/sql/connector/version.py +1 -1
- cloud_sql_python_connector-1.9.2.dist-info/RECORD +0 -19
- {cloud_sql_python_connector-1.9.2.dist-info → cloud_sql_python_connector-1.10.0.dist-info}/LICENSE +0 -0
- {cloud_sql_python_connector-1.9.2.dist-info → cloud_sql_python_connector-1.10.0.dist-info}/WHEEL +0 -0
- {cloud_sql_python_connector-1.9.2.dist-info → cloud_sql_python_connector-1.10.0.dist-info}/top_level.txt +0 -0
{cloud_sql_python_connector-1.9.2.dist-info → cloud_sql_python_connector-1.10.0.dist-info}/METADATA
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: cloud-sql-python-connector
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.10.0
|
|
4
4
|
Summary: The Cloud SQL Python Connector is a library that can be used alongside a database driver to allow users with sufficient permissions to connect to a Cloud SQL database without having to manually allowlist IPs or manage SSL certificates.
|
|
5
5
|
Home-page: https://github.com/GoogleCloudPlatform/cloud-sql-python-connector
|
|
6
6
|
Author: Google LLC
|
|
@@ -207,9 +207,10 @@ To close the `Connector` object's background resources, call its `close()` metho
|
|
|
207
207
|
connector.close()
|
|
208
208
|
```
|
|
209
209
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
210
|
+
> [!NOTE]
|
|
211
|
+
>
|
|
212
|
+
> For more examples of using SQLAlchemy to manage connection pooling with the connector,
|
|
213
|
+
> please see [Cloud SQL SQLAlchemy Samples](https://cloud.google.com/sql/docs/postgres/connect-connectors#python_1).
|
|
213
214
|
|
|
214
215
|
### Configuring the Connector
|
|
215
216
|
|
|
@@ -218,14 +219,15 @@ defaults for each connection to make, you can initialize a
|
|
|
218
219
|
`Connector` object as follows:
|
|
219
220
|
|
|
220
221
|
```python
|
|
221
|
-
from google.cloud.sql.connector import Connector
|
|
222
|
+
from google.cloud.sql.connector import Connector
|
|
222
223
|
|
|
223
224
|
# Note: all parameters below are optional
|
|
224
225
|
connector = Connector(
|
|
225
226
|
ip_type="public", # can also be "private" or "psc"
|
|
226
227
|
enable_iam_auth=False,
|
|
227
228
|
timeout=30,
|
|
228
|
-
credentials=custom_creds # google.auth.credentials.Credentials
|
|
229
|
+
credentials=custom_creds, # google.auth.credentials.Credentials
|
|
230
|
+
refresh_strategy="lazy", # can be "lazy" or "background"
|
|
229
231
|
)
|
|
230
232
|
```
|
|
231
233
|
|
|
@@ -287,6 +289,21 @@ with Connector() as connector:
|
|
|
287
289
|
print(row)
|
|
288
290
|
```
|
|
289
291
|
|
|
292
|
+
### Configuring a Lazy Refresh (Cloud Run, Cloud Functions etc.)
|
|
293
|
+
|
|
294
|
+
The Connector's `refresh_strategy` argument can be set to `"lazy"` to configure
|
|
295
|
+
the Python Connector to retrieve connection info lazily and as-needed.
|
|
296
|
+
Otherwise, a background refresh cycle runs to retrive the connection info
|
|
297
|
+
periodically. This setting is useful in environments where the CPU may be
|
|
298
|
+
throttled outside of a request context, e.g., Cloud Run, Cloud Functions, etc.
|
|
299
|
+
|
|
300
|
+
To set the refresh strategy, set the `refresh_strategy` keyword argument when
|
|
301
|
+
initializing a `Connector`:
|
|
302
|
+
|
|
303
|
+
```python
|
|
304
|
+
connector = Connector(refresh_strategy="lazy")
|
|
305
|
+
```
|
|
306
|
+
|
|
290
307
|
### Specifying IP Address Type
|
|
291
308
|
|
|
292
309
|
The Cloud SQL Python Connector can be used to connect to Cloud SQL instances
|
|
@@ -309,9 +326,11 @@ conn = connector.connect(
|
|
|
309
326
|
)
|
|
310
327
|
```
|
|
311
328
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
329
|
+
> [!IMPORTANT]
|
|
330
|
+
>
|
|
331
|
+
> If specifying Private IP or Private Service Connect (PSC), your application must be
|
|
332
|
+
> attached to the proper VPC network to connect to your Cloud SQL instance. For most
|
|
333
|
+
> applications this will require the use of a [VPC Connector][vpc-connector].
|
|
315
334
|
|
|
316
335
|
[psc]: https://cloud.google.com/vpc/docs/private-service-connect
|
|
317
336
|
[vpc-connector]: https://cloud.google.com/vpc/docs/configure-serverless-vpc-access#create-connector
|
|
@@ -340,9 +359,20 @@ conn = connector.connect(
|
|
|
340
359
|
)
|
|
341
360
|
```
|
|
342
361
|
|
|
343
|
-
### SQL Server
|
|
362
|
+
### SQL Server (MSSQL)
|
|
363
|
+
|
|
364
|
+
> [!IMPORTANT]
|
|
365
|
+
>
|
|
366
|
+
> If your SQL Server instance is set to [enforce SSL connections](https://cloud.google.com/sql/docs/sqlserver/configure-ssl-instance#enforcing-ssl),
|
|
367
|
+
> you need to download the CA certificate for your instance and include `cafile={path to downloaded certificate}`
|
|
368
|
+
> and `validate_host=False`. This is a workaround for a [known issue](https://issuetracker.google.com/184867147).
|
|
369
|
+
|
|
370
|
+
#### Active Directory Authentication
|
|
344
371
|
|
|
345
|
-
Active Directory authentication for SQL Server instances is currently only supported on Windows.
|
|
372
|
+
Active Directory authentication for SQL Server instances is currently only supported on Windows.
|
|
373
|
+
First, make sure to follow [these steps](https://cloud.google.com/blog/topics/developers-practitioners/creating-sql-server-instance-integrated-active-directory-using-google-cloud-sql)
|
|
374
|
+
to set up a Managed AD domain and join your Cloud SQL instance to the domain.
|
|
375
|
+
[See here for more info on Cloud SQL Active Directory integration](https://cloud.google.com/sql/docs/sqlserver/ad).
|
|
346
376
|
|
|
347
377
|
Once you have followed the steps linked above, you can run the following code to return a connection object:
|
|
348
378
|
|
|
@@ -375,6 +405,14 @@ The Python Connector can be used alongside popular Python web frameworks such
|
|
|
375
405
|
as Flask, FastAPI, etc, to integrate Cloud SQL databases within your
|
|
376
406
|
web applications.
|
|
377
407
|
|
|
408
|
+
> [!NOTE]
|
|
409
|
+
>
|
|
410
|
+
> For serverless environments such as Cloud Functions, Cloud Run, etc, it may be
|
|
411
|
+
> beneficial to initialize the `Connector` with the lazy refresh strategy.
|
|
412
|
+
> i.e. `Connector(refresh_strategy="lazy")`
|
|
413
|
+
>
|
|
414
|
+
> See [Configuring a Lazy Refresh](#configuring-a-lazy-refresh-cloud-run-cloud-functions-etc)
|
|
415
|
+
|
|
378
416
|
#### Flask-SQLAlchemy
|
|
379
417
|
|
|
380
418
|
[Flask-SQLAlchemy](https://flask-sqlalchemy.palletsprojects.com/en/2.x/)
|
|
@@ -555,8 +593,10 @@ An alternative to using the `create_async_connector` function is initializing
|
|
|
555
593
|
a `Connector` as an async context manager, removing the need for explicit
|
|
556
594
|
calls to `connector.close_async()` to cleanup resources.
|
|
557
595
|
|
|
558
|
-
|
|
559
|
-
|
|
596
|
+
> [!NOTE]
|
|
597
|
+
>
|
|
598
|
+
> This alternative requires that the running event loop be
|
|
599
|
+
> passed in as the `loop` argument to `Connector()`.
|
|
560
600
|
|
|
561
601
|
```python
|
|
562
602
|
import asyncio
|
|
@@ -603,6 +643,28 @@ async def main():
|
|
|
603
643
|
await pool.dispose()
|
|
604
644
|
```
|
|
605
645
|
|
|
646
|
+
### Debug Logging
|
|
647
|
+
|
|
648
|
+
The Cloud SQL Python Connector uses the standard [Python logging module][python-logging]
|
|
649
|
+
for debug logging support.
|
|
650
|
+
|
|
651
|
+
Add the below code to your application to enable debug logging with the Cloud SQL
|
|
652
|
+
Python Connector:
|
|
653
|
+
|
|
654
|
+
```python
|
|
655
|
+
import logging
|
|
656
|
+
|
|
657
|
+
logging.basicConfig(format="%(asctime)s [%(levelname)s]: %(message)s")
|
|
658
|
+
logger = logging.getLogger(name="google.cloud.sql.connector")
|
|
659
|
+
logger.setLevel(logging.DEBUG)
|
|
660
|
+
```
|
|
661
|
+
|
|
662
|
+
For more details on configuring logging, please refer to the
|
|
663
|
+
[Python logging docs][configure-logging].
|
|
664
|
+
|
|
665
|
+
[python-logging]: https://docs.python.org/3/library/logging.html
|
|
666
|
+
[configure-logging]: https://docs.python.org/3/howto/logging.html#configuring-logging
|
|
667
|
+
|
|
606
668
|
## Support policy
|
|
607
669
|
|
|
608
670
|
### Major version lifecycle
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
google/cloud/sql/connector/__init__.py,sha256=x8ui7u3gMlMI-LLJkKTtUU9yvzZvQkTJ-yzr2FH4VTU,983
|
|
2
|
+
google/cloud/sql/connector/asyncpg.py,sha256=c4lAlvEgGf_eBq0G8MVNPRAJQQvo6DcwybiXDW36qkU,1838
|
|
3
|
+
google/cloud/sql/connector/client.py,sha256=VvfDgJVZ1V2X54m1P6X3GCL7HHUoSvvAzEl1PKDDQ1I,11119
|
|
4
|
+
google/cloud/sql/connector/connection_info.py,sha256=esasEjExqkHt3V9tgx7jqeGgSPJMCuTZQmSMEFqAvWY,4309
|
|
5
|
+
google/cloud/sql/connector/connector.py,sha256=PtRRQ0n-OAM4d3bDmLZErPRikSudDFmd9xY9SbCVfcc,21761
|
|
6
|
+
google/cloud/sql/connector/enums.py,sha256=44tfMm8D_t0ls6GfKwiQ6YkslWM21PZR40RhJPWntkc,2882
|
|
7
|
+
google/cloud/sql/connector/exceptions.py,sha256=wRK3gKXomzi9FUs9qju3WN1FDUTuwozzZEkucTe4wKI,1860
|
|
8
|
+
google/cloud/sql/connector/instance.py,sha256=v3x936OddIaqcGD8aIRi-bzcpOncTExwZTKu_4MpzBo,10137
|
|
9
|
+
google/cloud/sql/connector/lazy.py,sha256=og4UPiJxeuLgN7tyqrFAnQF3895Yqdf6kkd4HZSzVJA,5093
|
|
10
|
+
google/cloud/sql/connector/pg8000.py,sha256=EZuO7XgCkG_Y79NQlh3Vv8Ff2w85BFJyFVO237hx1GM,1814
|
|
11
|
+
google/cloud/sql/connector/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
+
google/cloud/sql/connector/pymysql.py,sha256=wldUgwDPlPa8Nj5F3JaipuP8hJyvzlgOAJbRnOoaB1U,2105
|
|
13
|
+
google/cloud/sql/connector/pytds.py,sha256=vKJoE7E2P0GrhZojg8bGZkqCMLc8dgKAcXE4ee7vEVc,2452
|
|
14
|
+
google/cloud/sql/connector/rate_limiter.py,sha256=bPTupfM4f5QRSCmCAWMqjQylEB7bUM-50plVBU3W2CA,2993
|
|
15
|
+
google/cloud/sql/connector/refresh_utils.py,sha256=P2ye3qDymdK1iOlGCXby4VIaqwvHdc9GEmuUsMcufxY,3680
|
|
16
|
+
google/cloud/sql/connector/utils.py,sha256=FQs7RgoViJLIFgJIynVT-_DwAVWnpyXIKu5WZ4m10Ts,3448
|
|
17
|
+
google/cloud/sql/connector/version.py,sha256=4BE16mU-VOqsOw52ua4QjeLiTwW8fwyo0zXrk3YHwF0,598
|
|
18
|
+
cloud_sql_python_connector-1.10.0.dist-info/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
|
|
19
|
+
cloud_sql_python_connector-1.10.0.dist-info/METADATA,sha256=M8VvudvgqHr2ETZrjHR_G7qsOOw-yK8FlzmT4VwZ93w,25925
|
|
20
|
+
cloud_sql_python_connector-1.10.0.dist-info/WHEEL,sha256=bb2Ot9scclHKMOLDEHY6B2sicWOgugjFKaJsT7vwMQo,110
|
|
21
|
+
cloud_sql_python_connector-1.10.0.dist-info/top_level.txt,sha256=_1QvSJIhFAGfxb79D6DhB7SUw2X6T4rwnz_LLrbcD3c,7
|
|
22
|
+
cloud_sql_python_connector-1.10.0.dist-info/RECORD,,
|
|
@@ -16,7 +16,14 @@ limitations under the License.
|
|
|
16
16
|
|
|
17
17
|
from google.cloud.sql.connector.connector import Connector
|
|
18
18
|
from google.cloud.sql.connector.connector import create_async_connector
|
|
19
|
-
from google.cloud.sql.connector.
|
|
19
|
+
from google.cloud.sql.connector.enums import IPTypes
|
|
20
|
+
from google.cloud.sql.connector.enums import RefreshStrategy
|
|
20
21
|
from google.cloud.sql.connector.version import __version__
|
|
21
22
|
|
|
22
|
-
__all__ = [
|
|
23
|
+
__all__ = [
|
|
24
|
+
"__version__",
|
|
25
|
+
"create_async_connector",
|
|
26
|
+
"Connector",
|
|
27
|
+
"IPTypes",
|
|
28
|
+
"RefreshStrategy",
|
|
29
|
+
]
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
from __future__ import annotations
|
|
16
16
|
|
|
17
|
+
import asyncio
|
|
17
18
|
import datetime
|
|
18
19
|
import logging
|
|
19
20
|
from typing import Any, Dict, Optional, Tuple, TYPE_CHECKING
|
|
@@ -21,8 +22,11 @@ from typing import Any, Dict, Optional, Tuple, TYPE_CHECKING
|
|
|
21
22
|
import aiohttp
|
|
22
23
|
from cryptography.hazmat.backends import default_backend
|
|
23
24
|
from cryptography.x509 import load_pem_x509_certificate
|
|
24
|
-
|
|
25
|
+
from google.auth.credentials import TokenState
|
|
26
|
+
from google.auth.transport import requests
|
|
25
27
|
|
|
28
|
+
from google.cloud.sql.connector.connection_info import ConnectionInfo
|
|
29
|
+
from google.cloud.sql.connector.exceptions import AutoIAMAuthNotSupported
|
|
26
30
|
from google.cloud.sql.connector.refresh_utils import _downscope_credentials
|
|
27
31
|
from google.cloud.sql.connector.version import __version__ as version
|
|
28
32
|
|
|
@@ -113,9 +117,6 @@ class CloudSQLClient:
|
|
|
113
117
|
addresses and their type and a string representing the
|
|
114
118
|
certificate authority.
|
|
115
119
|
"""
|
|
116
|
-
if not self._credentials.valid:
|
|
117
|
-
request = google.auth.transport.requests.Request()
|
|
118
|
-
self._credentials.refresh(request)
|
|
119
120
|
|
|
120
121
|
headers = {
|
|
121
122
|
"Authorization": f"Bearer {self._credentials.token}",
|
|
@@ -123,8 +124,6 @@ class CloudSQLClient:
|
|
|
123
124
|
|
|
124
125
|
url = f"{self._sqladmin_api_endpoint}/sql/{API_VERSION}/projects/{project}/instances/{instance}/connectSettings"
|
|
125
126
|
|
|
126
|
-
logger.debug(f"['{instance}']: Requesting metadata")
|
|
127
|
-
|
|
128
127
|
resp = await self._client.get(url, headers=headers, raise_for_status=True)
|
|
129
128
|
ret_dict = await resp.json()
|
|
130
129
|
|
|
@@ -174,13 +173,6 @@ class CloudSQLClient:
|
|
|
174
173
|
:returns: An ephemeral certificate from the Cloud SQL instance that allows
|
|
175
174
|
authorized connections to the instance.
|
|
176
175
|
"""
|
|
177
|
-
|
|
178
|
-
logger.debug(f"['{instance}']: Requesting ephemeral certificate")
|
|
179
|
-
|
|
180
|
-
if not self._credentials.valid:
|
|
181
|
-
request = google.auth.transport.requests.Request()
|
|
182
|
-
self._credentials.refresh(request)
|
|
183
|
-
|
|
184
176
|
headers = {
|
|
185
177
|
"Authorization": f"Bearer {self._credentials.token}",
|
|
186
178
|
}
|
|
@@ -220,6 +212,84 @@ class CloudSQLClient:
|
|
|
220
212
|
expiration = token_expiration
|
|
221
213
|
return ephemeral_cert, expiration
|
|
222
214
|
|
|
215
|
+
async def get_connection_info(
|
|
216
|
+
self,
|
|
217
|
+
project: str,
|
|
218
|
+
region: str,
|
|
219
|
+
instance: str,
|
|
220
|
+
keys: asyncio.Future,
|
|
221
|
+
enable_iam_auth: bool,
|
|
222
|
+
) -> ConnectionInfo:
|
|
223
|
+
"""Immediately performs a full refresh operation using the Cloud SQL
|
|
224
|
+
Admin API.
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
project (str): The name of the project the Cloud SQL instance is
|
|
228
|
+
located in.
|
|
229
|
+
region (str): The region the Cloud SQL instance is located in.
|
|
230
|
+
instance (str): Name of the Cloud SQL instance.
|
|
231
|
+
keys (asyncio.Future): A future to the client's public-private key
|
|
232
|
+
pair.
|
|
233
|
+
enable_iam_auth (bool): Whether an automatic IAM database
|
|
234
|
+
authentication connection is being requested (Postgres and MySQL).
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
ConnectionInfo: All the information required to connect securely to
|
|
238
|
+
the Cloud SQL instance.
|
|
239
|
+
Raises:
|
|
240
|
+
AutoIAMAuthNotSupported: Database engine does not support automatic
|
|
241
|
+
IAM authentication.
|
|
242
|
+
"""
|
|
243
|
+
priv_key, pub_key = await keys
|
|
244
|
+
# before making Cloud SQL Admin API calls, refresh creds if required
|
|
245
|
+
if not self._credentials.token_state == TokenState.FRESH:
|
|
246
|
+
self._credentials.refresh(requests.Request())
|
|
247
|
+
|
|
248
|
+
metadata_task = asyncio.create_task(
|
|
249
|
+
self._get_metadata(
|
|
250
|
+
project,
|
|
251
|
+
region,
|
|
252
|
+
instance,
|
|
253
|
+
)
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
ephemeral_task = asyncio.create_task(
|
|
257
|
+
self._get_ephemeral(
|
|
258
|
+
project,
|
|
259
|
+
instance,
|
|
260
|
+
pub_key,
|
|
261
|
+
enable_iam_auth,
|
|
262
|
+
)
|
|
263
|
+
)
|
|
264
|
+
try:
|
|
265
|
+
metadata = await metadata_task
|
|
266
|
+
# check if automatic IAM database authn is supported for database engine
|
|
267
|
+
if enable_iam_auth and not metadata["database_version"].startswith(
|
|
268
|
+
("POSTGRES", "MYSQL")
|
|
269
|
+
):
|
|
270
|
+
raise AutoIAMAuthNotSupported(
|
|
271
|
+
f"'{metadata['database_version']}' does not support "
|
|
272
|
+
"automatic IAM authentication. It is only supported with "
|
|
273
|
+
"Cloud SQL Postgres or MySQL instances."
|
|
274
|
+
)
|
|
275
|
+
except Exception:
|
|
276
|
+
# cancel ephemeral cert task if exception occurs before it is awaited
|
|
277
|
+
ephemeral_task.cancel()
|
|
278
|
+
raise
|
|
279
|
+
|
|
280
|
+
ephemeral_cert, expiration = await ephemeral_task
|
|
281
|
+
|
|
282
|
+
return ConnectionInfo(
|
|
283
|
+
ephemeral_cert,
|
|
284
|
+
metadata["server_ca_cert"],
|
|
285
|
+
priv_key,
|
|
286
|
+
metadata["ip_addresses"],
|
|
287
|
+
metadata["database_version"],
|
|
288
|
+
expiration,
|
|
289
|
+
)
|
|
290
|
+
|
|
223
291
|
async def close(self) -> None:
|
|
224
292
|
"""Close CloudSQLClient gracefully."""
|
|
293
|
+
logger.debug("Waiting for Connector's http client to close")
|
|
225
294
|
await self._client.close()
|
|
295
|
+
logger.debug("Closed Connector's http client")
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# Copyright 2024 Google LLC
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
import logging
|
|
19
|
+
import ssl
|
|
20
|
+
from tempfile import TemporaryDirectory
|
|
21
|
+
from typing import Any, Dict, Optional, TYPE_CHECKING
|
|
22
|
+
|
|
23
|
+
from google.cloud.sql.connector.exceptions import CloudSQLIPTypeError
|
|
24
|
+
from google.cloud.sql.connector.exceptions import TLSVersionError
|
|
25
|
+
from google.cloud.sql.connector.utils import write_to_file
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
import datetime
|
|
29
|
+
|
|
30
|
+
from google.cloud.sql.connector.enums import IPTypes
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(name=__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class ConnectionInfo:
|
|
37
|
+
"""Contains all necessary information to connect securely to the
|
|
38
|
+
server-side Proxy running on a Cloud SQL instance."""
|
|
39
|
+
|
|
40
|
+
client_cert: str
|
|
41
|
+
server_ca_cert: str
|
|
42
|
+
private_key: bytes
|
|
43
|
+
ip_addrs: Dict[str, Any]
|
|
44
|
+
database_version: str
|
|
45
|
+
expiration: datetime.datetime
|
|
46
|
+
context: Optional[ssl.SSLContext] = None
|
|
47
|
+
|
|
48
|
+
def create_ssl_context(self, enable_iam_auth: bool = False) -> ssl.SSLContext:
|
|
49
|
+
"""Constructs a SSL/TLS context for the given connection info.
|
|
50
|
+
|
|
51
|
+
Cache the SSL context to ensure we don't read from disk repeatedly when
|
|
52
|
+
configuring a secure connection.
|
|
53
|
+
"""
|
|
54
|
+
# if SSL context is cached, use it
|
|
55
|
+
if self.context is not None:
|
|
56
|
+
return self.context
|
|
57
|
+
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
58
|
+
|
|
59
|
+
# update ssl.PROTOCOL_TLS_CLIENT default
|
|
60
|
+
context.check_hostname = False
|
|
61
|
+
|
|
62
|
+
# TODO: remove if/else when Python 3.10 is min version. PEP 644 has been
|
|
63
|
+
# implemented. The ssl module requires OpenSSL 1.1.1 or newer.
|
|
64
|
+
# verify OpenSSL version supports TLSv1.3
|
|
65
|
+
if ssl.HAS_TLSv1_3:
|
|
66
|
+
# force TLSv1.3 if supported by client
|
|
67
|
+
context.minimum_version = ssl.TLSVersion.TLSv1_3
|
|
68
|
+
# fallback to TLSv1.2 for older versions of OpenSSL
|
|
69
|
+
else:
|
|
70
|
+
if enable_iam_auth:
|
|
71
|
+
raise TLSVersionError(
|
|
72
|
+
f"Your current version of OpenSSL ({ssl.OPENSSL_VERSION}) does not "
|
|
73
|
+
"support TLSv1.3, which is required to use IAM Authentication.\n"
|
|
74
|
+
"Upgrade your OpenSSL version to 1.1.1 for TLSv1.3 support."
|
|
75
|
+
)
|
|
76
|
+
logger.warning(
|
|
77
|
+
"TLSv1.3 is not supported with your version of OpenSSL "
|
|
78
|
+
f"({ssl.OPENSSL_VERSION}), falling back to TLSv1.2\n"
|
|
79
|
+
"Upgrade your OpenSSL version to 1.1.1 for TLSv1.3 support."
|
|
80
|
+
)
|
|
81
|
+
context.minimum_version = ssl.TLSVersion.TLSv1_2
|
|
82
|
+
|
|
83
|
+
# tmpdir and its contents are automatically deleted after the CA cert
|
|
84
|
+
# and ephemeral cert are loaded into the SSLcontext. The values
|
|
85
|
+
# need to be written to files in order to be loaded by the SSLContext
|
|
86
|
+
with TemporaryDirectory() as tmpdir:
|
|
87
|
+
ca_filename, cert_filename, key_filename = write_to_file(
|
|
88
|
+
tmpdir, self.server_ca_cert, self.client_cert, self.private_key
|
|
89
|
+
)
|
|
90
|
+
context.load_cert_chain(cert_filename, keyfile=key_filename)
|
|
91
|
+
context.load_verify_locations(cafile=ca_filename)
|
|
92
|
+
# set class attribute to cache context for subsequent calls
|
|
93
|
+
self.context = context
|
|
94
|
+
return context
|
|
95
|
+
|
|
96
|
+
def get_preferred_ip(self, ip_type: IPTypes) -> str:
|
|
97
|
+
"""Returns the first IP address for the instance, according to the preference
|
|
98
|
+
supplied by ip_type. If no IP addressess with the given preference are found,
|
|
99
|
+
an error is raised."""
|
|
100
|
+
if ip_type.value in self.ip_addrs:
|
|
101
|
+
return self.ip_addrs[ip_type.value]
|
|
102
|
+
raise CloudSQLIPTypeError(
|
|
103
|
+
"Cloud SQL instance does not have any IP addresses matching "
|
|
104
|
+
f"preference: {ip_type.value})"
|
|
105
|
+
)
|
|
@@ -22,7 +22,7 @@ import logging
|
|
|
22
22
|
import socket
|
|
23
23
|
from threading import Thread
|
|
24
24
|
from types import TracebackType
|
|
25
|
-
from typing import Any, Dict, Optional, Type
|
|
25
|
+
from typing import Any, Dict, Optional, Type, Union
|
|
26
26
|
|
|
27
27
|
import google.auth
|
|
28
28
|
from google.auth.credentials import Credentials
|
|
@@ -30,10 +30,13 @@ from google.auth.credentials import with_scopes_if_required
|
|
|
30
30
|
|
|
31
31
|
import google.cloud.sql.connector.asyncpg as asyncpg
|
|
32
32
|
from google.cloud.sql.connector.client import CloudSQLClient
|
|
33
|
+
from google.cloud.sql.connector.enums import DriverMapping
|
|
34
|
+
from google.cloud.sql.connector.enums import IPTypes
|
|
35
|
+
from google.cloud.sql.connector.enums import RefreshStrategy
|
|
33
36
|
from google.cloud.sql.connector.exceptions import ConnectorLoopError
|
|
34
37
|
from google.cloud.sql.connector.exceptions import DnsNameResolutionError
|
|
35
|
-
from google.cloud.sql.connector.instance import IPTypes
|
|
36
38
|
from google.cloud.sql.connector.instance import RefreshAheadCache
|
|
39
|
+
from google.cloud.sql.connector.lazy import LazyRefreshCache
|
|
37
40
|
import google.cloud.sql.connector.pg8000 as pg8000
|
|
38
41
|
import google.cloud.sql.connector.pymysql as pymysql
|
|
39
42
|
import google.cloud.sql.connector.pytds as pytds
|
|
@@ -62,6 +65,7 @@ class Connector:
|
|
|
62
65
|
sqladmin_api_endpoint: Optional[str] = None,
|
|
63
66
|
user_agent: Optional[str] = None,
|
|
64
67
|
universe_domain: Optional[str] = None,
|
|
68
|
+
refresh_strategy: str | RefreshStrategy = RefreshStrategy.BACKGROUND,
|
|
65
69
|
) -> None:
|
|
66
70
|
"""Initializes a Connector instance.
|
|
67
71
|
|
|
@@ -98,22 +102,39 @@ class Connector:
|
|
|
98
102
|
universe_domain (str): The universe domain for Cloud SQL API calls.
|
|
99
103
|
Default: "googleapis.com".
|
|
100
104
|
|
|
105
|
+
refresh_strategy (str | RefreshStrategy): The default refresh strategy
|
|
106
|
+
used to refresh SSL/TLS cert and instance metadata. Can be one
|
|
107
|
+
of the following: RefreshStrategy.LAZY ("LAZY") or
|
|
108
|
+
RefreshStrategy.BACKGROUND ("BACKGROUND").
|
|
109
|
+
Default: RefreshStrategy.BACKGROUND
|
|
101
110
|
"""
|
|
111
|
+
# if refresh_strategy is str, convert to RefreshStrategy enum
|
|
112
|
+
if isinstance(refresh_strategy, str):
|
|
113
|
+
refresh_strategy = RefreshStrategy._from_str(refresh_strategy)
|
|
114
|
+
self._refresh_strategy = refresh_strategy
|
|
102
115
|
# if event loop is given, use for background tasks
|
|
103
116
|
if loop:
|
|
104
117
|
self._loop: asyncio.AbstractEventLoop = loop
|
|
105
118
|
self._thread: Optional[Thread] = None
|
|
106
|
-
|
|
119
|
+
# if lazy refresh is specified we should lazy init keys
|
|
120
|
+
if self._refresh_strategy == RefreshStrategy.LAZY:
|
|
121
|
+
self._keys: Optional[asyncio.Future] = None
|
|
122
|
+
else:
|
|
123
|
+
self._keys = loop.create_task(generate_keys())
|
|
107
124
|
# if no event loop is given, spin up new loop in background thread
|
|
108
125
|
else:
|
|
109
126
|
self._loop = asyncio.new_event_loop()
|
|
110
127
|
self._thread = Thread(target=self._loop.run_forever, daemon=True)
|
|
111
128
|
self._thread.start()
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
129
|
+
# if lazy refresh is specified we should lazy init keys
|
|
130
|
+
if self._refresh_strategy == RefreshStrategy.LAZY:
|
|
131
|
+
self._keys = None
|
|
132
|
+
else:
|
|
133
|
+
self._keys = asyncio.wrap_future(
|
|
134
|
+
asyncio.run_coroutine_threadsafe(generate_keys(), self._loop),
|
|
135
|
+
loop=self._loop,
|
|
136
|
+
)
|
|
137
|
+
self._cache: Dict[str, Union[RefreshAheadCache, LazyRefreshCache]] = {}
|
|
117
138
|
self._client: Optional[CloudSQLClient] = None
|
|
118
139
|
|
|
119
140
|
# initialize credentials
|
|
@@ -245,6 +266,8 @@ class Connector:
|
|
|
245
266
|
DnsNameResolutionError: Could not resolve PSC IP address from DNS
|
|
246
267
|
host name.
|
|
247
268
|
"""
|
|
269
|
+
if self._keys is None:
|
|
270
|
+
self._keys = asyncio.create_task(generate_keys())
|
|
248
271
|
if self._client is None:
|
|
249
272
|
# lazy init client as it has to be initialized in async context
|
|
250
273
|
self._client = CloudSQLClient(
|
|
@@ -265,11 +288,30 @@ class Connector:
|
|
|
265
288
|
"connector.Connector object."
|
|
266
289
|
)
|
|
267
290
|
else:
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
291
|
+
if self._refresh_strategy == RefreshStrategy.LAZY:
|
|
292
|
+
logger.debug(
|
|
293
|
+
f"['{instance_connection_string}']: Refresh strategy is set"
|
|
294
|
+
" to lazy refresh"
|
|
295
|
+
)
|
|
296
|
+
cache = LazyRefreshCache(
|
|
297
|
+
instance_connection_string,
|
|
298
|
+
self._client,
|
|
299
|
+
self._keys,
|
|
300
|
+
enable_iam_auth,
|
|
301
|
+
)
|
|
302
|
+
else:
|
|
303
|
+
logger.debug(
|
|
304
|
+
f"['{instance_connection_string}']: Refresh strategy is set"
|
|
305
|
+
" to backgound refresh"
|
|
306
|
+
)
|
|
307
|
+
cache = RefreshAheadCache(
|
|
308
|
+
instance_connection_string,
|
|
309
|
+
self._client,
|
|
310
|
+
self._keys,
|
|
311
|
+
enable_iam_auth,
|
|
312
|
+
)
|
|
313
|
+
logger.debug(
|
|
314
|
+
f"['{instance_connection_string}']: Connection info added to cache"
|
|
273
315
|
)
|
|
274
316
|
self._cache[instance_connection_string] = cache
|
|
275
317
|
|
|
@@ -300,7 +342,10 @@ class Connector:
|
|
|
300
342
|
|
|
301
343
|
# attempt to make connection to Cloud SQL instance
|
|
302
344
|
try:
|
|
303
|
-
|
|
345
|
+
conn_info = await cache.connect_info()
|
|
346
|
+
# validate driver matches intended database engine
|
|
347
|
+
DriverMapping.validate_engine(driver, conn_info.database_version)
|
|
348
|
+
ip_address = conn_info.get_preferred_ip(ip_type)
|
|
304
349
|
# resolve DNS name into IP address for PSC
|
|
305
350
|
if ip_type.value == "PSC":
|
|
306
351
|
addr_info = await self._loop.getaddrinfo(
|
|
@@ -316,11 +361,13 @@ class Connector:
|
|
|
316
361
|
raise DnsNameResolutionError(
|
|
317
362
|
f"['{instance_connection_string}']: DNS name could not be resolved into IP address"
|
|
318
363
|
) from e
|
|
319
|
-
|
|
364
|
+
logger.debug(
|
|
365
|
+
f"['{instance_connection_string}']: Connecting to {ip_address}:3307"
|
|
366
|
+
)
|
|
320
367
|
# format `user` param for automatic IAM database authn
|
|
321
368
|
if enable_iam_auth:
|
|
322
369
|
formatted_user = format_database_user(
|
|
323
|
-
|
|
370
|
+
conn_info.database_version, kwargs["user"]
|
|
324
371
|
)
|
|
325
372
|
if formatted_user != kwargs["user"]:
|
|
326
373
|
logger.debug(
|
|
@@ -330,10 +377,17 @@ class Connector:
|
|
|
330
377
|
|
|
331
378
|
# async drivers are unblocking and can be awaited directly
|
|
332
379
|
if driver in ASYNC_DRIVERS:
|
|
333
|
-
return await connector(
|
|
380
|
+
return await connector(
|
|
381
|
+
ip_address,
|
|
382
|
+
conn_info.create_ssl_context(enable_iam_auth),
|
|
383
|
+
**kwargs,
|
|
384
|
+
)
|
|
334
385
|
# synchronous drivers are blocking and run using executor
|
|
335
386
|
connect_partial = partial(
|
|
336
|
-
connector,
|
|
387
|
+
connector,
|
|
388
|
+
ip_address,
|
|
389
|
+
conn_info.create_ssl_context(enable_iam_auth),
|
|
390
|
+
**kwargs,
|
|
337
391
|
)
|
|
338
392
|
return await self._loop.run_in_executor(None, connect_partial)
|
|
339
393
|
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Copyright 2024 Google LLC
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from enum import Enum
|
|
18
|
+
|
|
19
|
+
from google.cloud.sql.connector.exceptions import IncompatibleDriverError
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RefreshStrategy(Enum):
|
|
23
|
+
LAZY: str = "LAZY"
|
|
24
|
+
BACKGROUND: str = "BACKGROUND"
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def _missing_(cls, value: object) -> None:
|
|
28
|
+
raise ValueError(
|
|
29
|
+
f"Incorrect value for refresh_strategy, got '{value}'. Want one of: "
|
|
30
|
+
f"{', '.join([repr(m.value) for m in cls])}."
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def _from_str(cls, refresh_strategy: str) -> RefreshStrategy:
|
|
35
|
+
"""Convert refresh strategy from a str into RefreshStrategy."""
|
|
36
|
+
return cls(refresh_strategy.upper())
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class IPTypes(Enum):
|
|
40
|
+
PUBLIC: str = "PRIMARY"
|
|
41
|
+
PRIVATE: str = "PRIVATE"
|
|
42
|
+
PSC: str = "PSC"
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def _missing_(cls, value: object) -> None:
|
|
46
|
+
raise ValueError(
|
|
47
|
+
f"Incorrect value for ip_type, got '{value}'. Want one of: "
|
|
48
|
+
f"{', '.join([repr(m.value) for m in cls])}, 'PUBLIC'."
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def _from_str(cls, ip_type_str: str) -> IPTypes:
|
|
53
|
+
"""Convert IP type from a str into IPTypes."""
|
|
54
|
+
if ip_type_str.upper() == "PUBLIC":
|
|
55
|
+
ip_type_str = "PRIMARY"
|
|
56
|
+
return cls(ip_type_str.upper())
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class DriverMapping(Enum):
|
|
60
|
+
"""Maps a given database driver to it's corresponding database engine."""
|
|
61
|
+
|
|
62
|
+
ASYNCPG = "POSTGRES"
|
|
63
|
+
PG8000 = "POSTGRES"
|
|
64
|
+
PYMYSQL = "MYSQL"
|
|
65
|
+
PYTDS = "SQLSERVER"
|
|
66
|
+
|
|
67
|
+
@staticmethod
|
|
68
|
+
def validate_engine(driver: str, engine_version: str) -> None:
|
|
69
|
+
"""Validate that the given driver is compatible with the given engine.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
driver (str): Database driver being used. (i.e. "pg8000")
|
|
73
|
+
engine_version (str): Database engine version. (i.e. "POSTGRES_16")
|
|
74
|
+
|
|
75
|
+
Raises:
|
|
76
|
+
IncompatibleDriverError: If the given driver is not compatible with
|
|
77
|
+
the given engine.
|
|
78
|
+
"""
|
|
79
|
+
mapping = DriverMapping[driver.upper()]
|
|
80
|
+
if not engine_version.startswith(mapping.value):
|
|
81
|
+
raise IncompatibleDriverError(
|
|
82
|
+
f"Database driver '{driver}' is incompatible with database "
|
|
83
|
+
f"version '{engine_version}'. Given driver can "
|
|
84
|
+
f"only be used with Cloud SQL {mapping.value} databases."
|
|
85
|
+
)
|
|
@@ -62,3 +62,18 @@ class DnsNameResolutionError(Exception):
|
|
|
62
62
|
Exception to be raised when the DnsName of a PSC connection to a
|
|
63
63
|
Cloud SQL instance can not be resolved to a proper IP address.
|
|
64
64
|
"""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class RefreshNotValidError(Exception):
|
|
68
|
+
"""
|
|
69
|
+
Exception to be raised when the task returned from refresh is not valid.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class IncompatibleDriverError(Exception):
|
|
76
|
+
"""
|
|
77
|
+
Exception to be raised when the database driver given is for the wrong
|
|
78
|
+
database engine. (i.e. asyncpg for a MySQL database)
|
|
79
|
+
"""
|
|
@@ -17,26 +17,21 @@ limitations under the License.
|
|
|
17
17
|
from __future__ import annotations
|
|
18
18
|
|
|
19
19
|
import asyncio
|
|
20
|
-
from
|
|
20
|
+
from datetime import datetime
|
|
21
|
+
from datetime import timedelta
|
|
22
|
+
from datetime import timezone
|
|
21
23
|
import logging
|
|
22
24
|
import re
|
|
23
|
-
import
|
|
24
|
-
from tempfile import TemporaryDirectory
|
|
25
|
-
from typing import Any, Dict, Tuple, TYPE_CHECKING
|
|
25
|
+
from typing import Tuple
|
|
26
26
|
|
|
27
27
|
import aiohttp
|
|
28
28
|
|
|
29
29
|
from google.cloud.sql.connector.client import CloudSQLClient
|
|
30
|
-
from google.cloud.sql.connector.
|
|
31
|
-
from google.cloud.sql.connector.exceptions import
|
|
32
|
-
from google.cloud.sql.connector.exceptions import TLSVersionError
|
|
30
|
+
from google.cloud.sql.connector.connection_info import ConnectionInfo
|
|
31
|
+
from google.cloud.sql.connector.exceptions import RefreshNotValidError
|
|
33
32
|
from google.cloud.sql.connector.rate_limiter import AsyncRateLimiter
|
|
34
33
|
from google.cloud.sql.connector.refresh_utils import _is_valid
|
|
35
34
|
from google.cloud.sql.connector.refresh_utils import _seconds_until_refresh
|
|
36
|
-
from google.cloud.sql.connector.utils import write_to_file
|
|
37
|
-
|
|
38
|
-
if TYPE_CHECKING:
|
|
39
|
-
import datetime
|
|
40
35
|
|
|
41
36
|
logger = logging.getLogger(name=__name__)
|
|
42
37
|
|
|
@@ -59,91 +54,6 @@ def _parse_instance_connection_name(connection_name: str) -> Tuple[str, str, str
|
|
|
59
54
|
return connection_name_split[1], connection_name_split[3], connection_name_split[4]
|
|
60
55
|
|
|
61
56
|
|
|
62
|
-
class IPTypes(Enum):
|
|
63
|
-
PUBLIC: str = "PRIMARY"
|
|
64
|
-
PRIVATE: str = "PRIVATE"
|
|
65
|
-
PSC: str = "PSC"
|
|
66
|
-
|
|
67
|
-
@classmethod
|
|
68
|
-
def _missing_(cls, value: object) -> None:
|
|
69
|
-
raise ValueError(
|
|
70
|
-
f"Incorrect value for ip_type, got '{value}'. Want one of: "
|
|
71
|
-
f"{', '.join([repr(m.value) for m in cls])}, 'PUBLIC'."
|
|
72
|
-
)
|
|
73
|
-
|
|
74
|
-
@classmethod
|
|
75
|
-
def _from_str(cls, ip_type_str: str) -> IPTypes:
|
|
76
|
-
"""Convert IP type from a str into IPTypes."""
|
|
77
|
-
if ip_type_str.upper() == "PUBLIC":
|
|
78
|
-
ip_type_str = "PRIMARY"
|
|
79
|
-
return cls(ip_type_str.upper())
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
class ConnectionInfo:
|
|
83
|
-
ip_addrs: Dict[str, Any]
|
|
84
|
-
context: ssl.SSLContext
|
|
85
|
-
database_version: str
|
|
86
|
-
expiration: datetime.datetime
|
|
87
|
-
|
|
88
|
-
def __init__(
|
|
89
|
-
self,
|
|
90
|
-
ephemeral_cert: str,
|
|
91
|
-
database_version: str,
|
|
92
|
-
ip_addrs: Dict[str, Any],
|
|
93
|
-
private_key: bytes,
|
|
94
|
-
server_ca_cert: str,
|
|
95
|
-
expiration: datetime.datetime,
|
|
96
|
-
enable_iam_auth: bool,
|
|
97
|
-
) -> None:
|
|
98
|
-
self.ip_addrs = ip_addrs
|
|
99
|
-
self.database_version = database_version
|
|
100
|
-
self.context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
101
|
-
|
|
102
|
-
# update ssl.PROTOCOL_TLS_CLIENT default
|
|
103
|
-
self.context.check_hostname = False
|
|
104
|
-
|
|
105
|
-
# verify OpenSSL version supports TLSv1.3
|
|
106
|
-
if ssl.HAS_TLSv1_3:
|
|
107
|
-
# force TLSv1.3 if supported by client
|
|
108
|
-
self.context.minimum_version = ssl.TLSVersion.TLSv1_3
|
|
109
|
-
# fallback to TLSv1.2 for older versions of OpenSSL
|
|
110
|
-
else:
|
|
111
|
-
if enable_iam_auth:
|
|
112
|
-
raise TLSVersionError(
|
|
113
|
-
f"Your current version of OpenSSL ({ssl.OPENSSL_VERSION}) does not "
|
|
114
|
-
"support TLSv1.3, which is required to use IAM Authentication.\n"
|
|
115
|
-
"Upgrade your OpenSSL version to 1.1.1 for TLSv1.3 support."
|
|
116
|
-
)
|
|
117
|
-
logger.warning(
|
|
118
|
-
"TLSv1.3 is not supported with your version of OpenSSL "
|
|
119
|
-
f"({ssl.OPENSSL_VERSION}), falling back to TLSv1.2\n"
|
|
120
|
-
"Upgrade your OpenSSL version to 1.1.1 for TLSv1.3 support."
|
|
121
|
-
)
|
|
122
|
-
self.context.minimum_version = ssl.TLSVersion.TLSv1_2
|
|
123
|
-
self.expiration = expiration
|
|
124
|
-
|
|
125
|
-
# tmpdir and its contents are automatically deleted after the CA cert
|
|
126
|
-
# and ephemeral cert are loaded into the SSLcontext. The values
|
|
127
|
-
# need to be written to files in order to be loaded by the SSLContext
|
|
128
|
-
with TemporaryDirectory() as tmpdir:
|
|
129
|
-
ca_filename, cert_filename, key_filename = write_to_file(
|
|
130
|
-
tmpdir, server_ca_cert, ephemeral_cert, private_key
|
|
131
|
-
)
|
|
132
|
-
self.context.load_cert_chain(cert_filename, keyfile=key_filename)
|
|
133
|
-
self.context.load_verify_locations(cafile=ca_filename)
|
|
134
|
-
|
|
135
|
-
def get_preferred_ip(self, ip_type: IPTypes) -> str:
|
|
136
|
-
"""Returns the first IP address for the instance, according to the preference
|
|
137
|
-
supplied by ip_type. If no IP addressess with the given preference are found,
|
|
138
|
-
an error is raised."""
|
|
139
|
-
if ip_type.value in self.ip_addrs:
|
|
140
|
-
return self.ip_addrs[ip_type.value]
|
|
141
|
-
raise CloudSQLIPTypeError(
|
|
142
|
-
"Cloud SQL instance does not have any IP addresses matching "
|
|
143
|
-
f"preference: {ip_type.value})"
|
|
144
|
-
)
|
|
145
|
-
|
|
146
|
-
|
|
147
57
|
class RefreshAheadCache:
|
|
148
58
|
"""Cache that refreshes connection info in the background prior to expiration.
|
|
149
59
|
|
|
@@ -212,73 +122,47 @@ class RefreshAheadCache:
|
|
|
212
122
|
"""
|
|
213
123
|
self._refresh_in_progress.set()
|
|
214
124
|
logger.debug(
|
|
215
|
-
f"['{self._instance_connection_string}']:
|
|
125
|
+
f"['{self._instance_connection_string}']: Connection info refresh "
|
|
126
|
+
"operation started"
|
|
216
127
|
)
|
|
217
128
|
|
|
218
129
|
try:
|
|
219
130
|
await self._refresh_rate_limiter.acquire()
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
self.
|
|
226
|
-
self._project,
|
|
227
|
-
self._region,
|
|
228
|
-
self._instance,
|
|
229
|
-
)
|
|
131
|
+
connection_info = await self._client.get_connection_info(
|
|
132
|
+
self._project,
|
|
133
|
+
self._region,
|
|
134
|
+
self._instance,
|
|
135
|
+
self._keys,
|
|
136
|
+
self._enable_iam_auth,
|
|
230
137
|
)
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
)
|
|
138
|
+
logger.debug(
|
|
139
|
+
f"['{self._instance_connection_string}']: Connection info "
|
|
140
|
+
"refresh operation complete"
|
|
141
|
+
)
|
|
142
|
+
logger.debug(
|
|
143
|
+
f"['{self._instance_connection_string}']: Current certificate "
|
|
144
|
+
f"expiration = {connection_info.expiration.isoformat()}"
|
|
239
145
|
)
|
|
240
|
-
try:
|
|
241
|
-
metadata = await metadata_task
|
|
242
|
-
# check if automatic IAM database authn is supported for database engine
|
|
243
|
-
if self._enable_iam_auth and not metadata[
|
|
244
|
-
"database_version"
|
|
245
|
-
].startswith(("POSTGRES", "MYSQL")):
|
|
246
|
-
raise AutoIAMAuthNotSupported(
|
|
247
|
-
f"'{metadata['database_version']}' does not support automatic IAM authentication. It is only supported with Cloud SQL Postgres or MySQL instances."
|
|
248
|
-
)
|
|
249
|
-
except Exception:
|
|
250
|
-
# cancel ephemeral cert task if exception occurs before it is awaited
|
|
251
|
-
ephemeral_task.cancel()
|
|
252
|
-
raise
|
|
253
|
-
|
|
254
|
-
ephemeral_cert, expiration = await ephemeral_task
|
|
255
146
|
|
|
256
147
|
except aiohttp.ClientResponseError as e:
|
|
257
148
|
logger.debug(
|
|
258
|
-
f"['{self._instance_connection_string}']:
|
|
149
|
+
f"['{self._instance_connection_string}']: Connection info "
|
|
150
|
+
f"refresh operation failed: {str(e)}"
|
|
259
151
|
)
|
|
260
152
|
if e.status == 403:
|
|
261
153
|
e.message = "Forbidden: Authenticated IAM principal does not seeem authorized to make API request. Verify 'Cloud SQL Admin API' is enabled within your GCP project and 'Cloud SQL Client' role has been granted to IAM principal."
|
|
262
154
|
raise
|
|
263
155
|
|
|
264
|
-
except Exception:
|
|
156
|
+
except Exception as e:
|
|
265
157
|
logger.debug(
|
|
266
|
-
f"['{self._instance_connection_string}']:
|
|
158
|
+
f"['{self._instance_connection_string}']: Connection info "
|
|
159
|
+
f"refresh operation failed: {str(e)}"
|
|
267
160
|
)
|
|
268
161
|
raise
|
|
269
162
|
|
|
270
163
|
finally:
|
|
271
164
|
self._refresh_in_progress.clear()
|
|
272
|
-
|
|
273
|
-
return ConnectionInfo(
|
|
274
|
-
ephemeral_cert,
|
|
275
|
-
metadata["database_version"],
|
|
276
|
-
metadata["ip_addresses"],
|
|
277
|
-
priv_key,
|
|
278
|
-
metadata["server_ca_cert"],
|
|
279
|
-
expiration,
|
|
280
|
-
self._enable_iam_auth,
|
|
281
|
-
)
|
|
165
|
+
return connection_info
|
|
282
166
|
|
|
283
167
|
def _schedule_refresh(self, delay: int) -> asyncio.Task:
|
|
284
168
|
"""
|
|
@@ -298,14 +182,19 @@ class RefreshAheadCache:
|
|
|
298
182
|
"""
|
|
299
183
|
refresh_task: asyncio.Task
|
|
300
184
|
try:
|
|
301
|
-
logger.debug(f"['{self._instance_connection_string}']: Entering sleep")
|
|
302
185
|
if delay > 0:
|
|
303
186
|
await asyncio.sleep(delay)
|
|
304
187
|
refresh_task = asyncio.create_task(self._perform_refresh())
|
|
305
188
|
refresh_data = await refresh_task
|
|
189
|
+
# check that refresh is valid
|
|
190
|
+
if not await _is_valid(refresh_task):
|
|
191
|
+
raise RefreshNotValidError(
|
|
192
|
+
f"['{self._instance_connection_string}']: Invalid refresh operation. Certficate appears to be expired."
|
|
193
|
+
)
|
|
306
194
|
except asyncio.CancelledError:
|
|
307
195
|
logger.debug(
|
|
308
|
-
f"['{self._instance_connection_string}']:
|
|
196
|
+
f"['{self._instance_connection_string}']: Scheduled refresh"
|
|
197
|
+
" operation cancelled"
|
|
309
198
|
)
|
|
310
199
|
raise
|
|
311
200
|
# bad refresh attempt
|
|
@@ -327,6 +216,12 @@ class RefreshAheadCache:
|
|
|
327
216
|
self._current = refresh_task
|
|
328
217
|
# calculate refresh delay based on certificate expiration
|
|
329
218
|
delay = _seconds_until_refresh(refresh_data.expiration)
|
|
219
|
+
logger.debug(
|
|
220
|
+
f"['{self._instance_connection_string}']: Connection info refresh"
|
|
221
|
+
" operation scheduled for "
|
|
222
|
+
f"{(datetime.now(timezone.utc) + timedelta(seconds=delay)).isoformat(timespec='seconds')} "
|
|
223
|
+
f"(now + {timedelta(seconds=delay)})"
|
|
224
|
+
)
|
|
330
225
|
self._next = self._schedule_refresh(delay)
|
|
331
226
|
|
|
332
227
|
return refresh_data
|
|
@@ -335,47 +230,22 @@ class RefreshAheadCache:
|
|
|
335
230
|
scheduled_task = asyncio.create_task(_refresh_task(self, delay))
|
|
336
231
|
return scheduled_task
|
|
337
232
|
|
|
338
|
-
async def connect_info(
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
) -> Tuple[ConnectionInfo, str]:
|
|
342
|
-
"""Retrieve instance metadata and ip address required
|
|
343
|
-
for making connection to Cloud SQL instance.
|
|
344
|
-
|
|
345
|
-
Args:
|
|
346
|
-
ip_type (IPTypes): Enum specifying type of IP address to lookup and
|
|
347
|
-
use for connection.
|
|
348
|
-
|
|
349
|
-
Returns:
|
|
350
|
-
A tuple with the first item being the ConnectionInfo instance for
|
|
351
|
-
establishing the connection, and the second item being the IP
|
|
352
|
-
address of the Cloud SQL instance matching the specified IP type.
|
|
233
|
+
async def connect_info(self) -> ConnectionInfo:
|
|
234
|
+
"""Retrieves ConnectionInfo instance for establishing a secure
|
|
235
|
+
connection to the Cloud SQL instance.
|
|
353
236
|
"""
|
|
354
|
-
|
|
355
|
-
f"['{self._instance_connection_string}']: Entered connect_info method"
|
|
356
|
-
)
|
|
357
|
-
|
|
358
|
-
instance_data: ConnectionInfo
|
|
359
|
-
|
|
360
|
-
instance_data = await self._current
|
|
361
|
-
ip_address: str = instance_data.get_preferred_ip(ip_type)
|
|
362
|
-
return instance_data, ip_address
|
|
237
|
+
return await self._current
|
|
363
238
|
|
|
364
239
|
async def close(self) -> None:
|
|
365
|
-
"""Cleanup function to make sure
|
|
366
|
-
|
|
240
|
+
"""Cleanup function to make sure tasks have finished to have a
|
|
241
|
+
graceful exit.
|
|
367
242
|
"""
|
|
368
243
|
logger.debug(
|
|
369
|
-
f"['{self._instance_connection_string}']:
|
|
244
|
+
f"['{self._instance_connection_string}']: Canceling connection info "
|
|
245
|
+
"refresh operation tasks"
|
|
370
246
|
)
|
|
371
247
|
self._current.cancel()
|
|
372
|
-
logger.debug(
|
|
373
|
-
f"['{self._instance_connection_string}']: Waiting for _next to be cancelled"
|
|
374
|
-
)
|
|
375
248
|
self._next.cancel()
|
|
376
|
-
logger.debug(
|
|
377
|
-
f"['{self._instance_connection_string}']: Waiting for _client_session to close"
|
|
378
|
-
)
|
|
379
249
|
# gracefully wait for tasks to cancel
|
|
380
250
|
tasks = asyncio.gather(self._current, self._next, return_exceptions=True)
|
|
381
251
|
await asyncio.wait_for(tasks, timeout=2.0)
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# Copyright 2024 Google LLC
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
from datetime import timedelta
|
|
18
|
+
from datetime import timezone
|
|
19
|
+
import logging
|
|
20
|
+
from typing import Optional
|
|
21
|
+
|
|
22
|
+
from google.cloud.sql.connector.client import CloudSQLClient
|
|
23
|
+
from google.cloud.sql.connector.connection_info import ConnectionInfo
|
|
24
|
+
from google.cloud.sql.connector.instance import _parse_instance_connection_name
|
|
25
|
+
from google.cloud.sql.connector.refresh_utils import _refresh_buffer
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(name=__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class LazyRefreshCache:
|
|
31
|
+
"""Cache that refreshes connection info when a caller requests a connection.
|
|
32
|
+
|
|
33
|
+
Only refreshes the cache when a new connection is requested and the current
|
|
34
|
+
certificate is close to or already expired.
|
|
35
|
+
|
|
36
|
+
This is the recommended option for serverless environments.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
instance_connection_string: str,
|
|
42
|
+
client: CloudSQLClient,
|
|
43
|
+
keys: asyncio.Future,
|
|
44
|
+
enable_iam_auth: bool = False,
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Initializes a LazyRefreshCache instance.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
instance_connection_string (str): The Cloud SQL Instance's
|
|
50
|
+
connection string (also known as an instance connection name).
|
|
51
|
+
client (CloudSQLClient): The Cloud SQL Client instance.
|
|
52
|
+
keys (asyncio.Future): A future to the client's public-private key
|
|
53
|
+
pair.
|
|
54
|
+
enable_iam_auth (bool): Enables automatic IAM database authentication
|
|
55
|
+
(Postgres and MySQL) as the default authentication method for all
|
|
56
|
+
connections.
|
|
57
|
+
"""
|
|
58
|
+
# validate and parse instance connection name
|
|
59
|
+
self._project, self._region, self._instance = _parse_instance_connection_name(
|
|
60
|
+
instance_connection_string
|
|
61
|
+
)
|
|
62
|
+
self._instance_connection_string = instance_connection_string
|
|
63
|
+
|
|
64
|
+
self._enable_iam_auth = enable_iam_auth
|
|
65
|
+
self._keys = keys
|
|
66
|
+
self._client = client
|
|
67
|
+
self._lock = asyncio.Lock()
|
|
68
|
+
self._cached: Optional[ConnectionInfo] = None
|
|
69
|
+
self._needs_refresh = False
|
|
70
|
+
|
|
71
|
+
async def force_refresh(self) -> None:
|
|
72
|
+
"""
|
|
73
|
+
Invalidates the cache and configures the next call to
|
|
74
|
+
connect_info() to retrieve a fresh ConnectionInfo instance.
|
|
75
|
+
"""
|
|
76
|
+
async with self._lock:
|
|
77
|
+
self._needs_refresh = True
|
|
78
|
+
|
|
79
|
+
async def connect_info(self) -> ConnectionInfo:
|
|
80
|
+
"""Retrieves ConnectionInfo instance for establishing a secure
|
|
81
|
+
connection to the Cloud SQL instance.
|
|
82
|
+
"""
|
|
83
|
+
async with self._lock:
|
|
84
|
+
# If connection info is cached, check expiration.
|
|
85
|
+
# Pad expiration with a buffer to give the client plenty of time to
|
|
86
|
+
# establish a connection to the server with the certificate.
|
|
87
|
+
if (
|
|
88
|
+
self._cached
|
|
89
|
+
and not self._needs_refresh
|
|
90
|
+
and datetime.now(timezone.utc)
|
|
91
|
+
< (self._cached.expiration - timedelta(seconds=_refresh_buffer))
|
|
92
|
+
):
|
|
93
|
+
logger.debug(
|
|
94
|
+
f"['{self._instance_connection_string}']: Connection info "
|
|
95
|
+
"is still valid, using cached info"
|
|
96
|
+
)
|
|
97
|
+
return self._cached
|
|
98
|
+
logger.debug(
|
|
99
|
+
f"['{self._instance_connection_string}']: Connection info "
|
|
100
|
+
"refresh operation started"
|
|
101
|
+
)
|
|
102
|
+
try:
|
|
103
|
+
conn_info = await self._client.get_connection_info(
|
|
104
|
+
self._project,
|
|
105
|
+
self._region,
|
|
106
|
+
self._instance,
|
|
107
|
+
self._keys,
|
|
108
|
+
self._enable_iam_auth,
|
|
109
|
+
)
|
|
110
|
+
except Exception as e:
|
|
111
|
+
logger.debug(
|
|
112
|
+
f"['{self._instance_connection_string}']: Connection info "
|
|
113
|
+
f"refresh operation failed: {str(e)}"
|
|
114
|
+
)
|
|
115
|
+
raise
|
|
116
|
+
logger.debug(
|
|
117
|
+
f"['{self._instance_connection_string}']: Connection info "
|
|
118
|
+
"refresh operation completed successfully"
|
|
119
|
+
)
|
|
120
|
+
logger.debug(
|
|
121
|
+
f"['{self._instance_connection_string}']: Current certificate "
|
|
122
|
+
f"expiration = {str(conn_info.expiration)}"
|
|
123
|
+
)
|
|
124
|
+
self._cached = conn_info
|
|
125
|
+
self._needs_refresh = False
|
|
126
|
+
return conn_info
|
|
127
|
+
|
|
128
|
+
async def close(self) -> None:
|
|
129
|
+
"""Close is a no-op and provided purely for a consistent interface with
|
|
130
|
+
other cache types.
|
|
131
|
+
"""
|
|
132
|
+
pass
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
google/cloud/sql/connector/__init__.py,sha256=Z3AkPOQwjNF1ogo2DR18GN-CnYvI4MTmhsCe8hURKUo,883
|
|
2
|
-
google/cloud/sql/connector/asyncpg.py,sha256=c4lAlvEgGf_eBq0G8MVNPRAJQQvo6DcwybiXDW36qkU,1838
|
|
3
|
-
google/cloud/sql/connector/client.py,sha256=NZZnPqE_f13S04tpRCs0UGF-RxR4EL-_vH7ebQBXTaA,8494
|
|
4
|
-
google/cloud/sql/connector/connector.py,sha256=q1P0lqDjU5O5U8FQjNa9punrdExwcjvVz7SodbaVmSw,19147
|
|
5
|
-
google/cloud/sql/connector/exceptions.py,sha256=rTBtbCoiZ3U5IFVsyXPw5baHbCODixx2hPDQeXUzgzs,1524
|
|
6
|
-
google/cloud/sql/connector/instance.py,sha256=Oed1kFTaVolce74DzsSRbooj6mUthwZtt75OamTfOQs,14966
|
|
7
|
-
google/cloud/sql/connector/pg8000.py,sha256=EZuO7XgCkG_Y79NQlh3Vv8Ff2w85BFJyFVO237hx1GM,1814
|
|
8
|
-
google/cloud/sql/connector/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
-
google/cloud/sql/connector/pymysql.py,sha256=wldUgwDPlPa8Nj5F3JaipuP8hJyvzlgOAJbRnOoaB1U,2105
|
|
10
|
-
google/cloud/sql/connector/pytds.py,sha256=vKJoE7E2P0GrhZojg8bGZkqCMLc8dgKAcXE4ee7vEVc,2452
|
|
11
|
-
google/cloud/sql/connector/rate_limiter.py,sha256=bPTupfM4f5QRSCmCAWMqjQylEB7bUM-50plVBU3W2CA,2993
|
|
12
|
-
google/cloud/sql/connector/refresh_utils.py,sha256=P2ye3qDymdK1iOlGCXby4VIaqwvHdc9GEmuUsMcufxY,3680
|
|
13
|
-
google/cloud/sql/connector/utils.py,sha256=FQs7RgoViJLIFgJIynVT-_DwAVWnpyXIKu5WZ4m10Ts,3448
|
|
14
|
-
google/cloud/sql/connector/version.py,sha256=x6crtIS26FuBXZ_fZ636i8UD16NlwWdlYJf5SEIqLIc,597
|
|
15
|
-
cloud_sql_python_connector-1.9.2.dist-info/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
|
|
16
|
-
cloud_sql_python_connector-1.9.2.dist-info/METADATA,sha256=5ufJWE8PP34QHa8_1oh1vPJmzaOY-Byqfso7o_BbCnw,24113
|
|
17
|
-
cloud_sql_python_connector-1.9.2.dist-info/WHEEL,sha256=bb2Ot9scclHKMOLDEHY6B2sicWOgugjFKaJsT7vwMQo,110
|
|
18
|
-
cloud_sql_python_connector-1.9.2.dist-info/top_level.txt,sha256=_1QvSJIhFAGfxb79D6DhB7SUw2X6T4rwnz_LLrbcD3c,7
|
|
19
|
-
cloud_sql_python_connector-1.9.2.dist-info/RECORD,,
|
{cloud_sql_python_connector-1.9.2.dist-info → cloud_sql_python_connector-1.10.0.dist-info}/LICENSE
RENAMED
|
File without changes
|
{cloud_sql_python_connector-1.9.2.dist-info → cloud_sql_python_connector-1.10.0.dist-info}/WHEEL
RENAMED
|
File without changes
|
|
File without changes
|