cloud-sql-python-connector 1.9.2__py2.py3-none-any.whl → 1.11.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.11.0.dist-info}/METADATA +75 -13
- cloud_sql_python_connector-1.11.0.dist-info/RECORD +22 -0
- google/cloud/sql/connector/__init__.py +9 -2
- google/cloud/sql/connector/client.py +96 -20
- google/cloud/sql/connector/connection_info.py +105 -0
- google/cloud/sql/connector/connector.py +98 -47
- google/cloud/sql/connector/enums.py +85 -0
- google/cloud/sql/connector/exceptions.py +11 -3
- google/cloud/sql/connector/instance.py +49 -179
- google/cloud/sql/connector/lazy.py +132 -0
- google/cloud/sql/connector/refresh_utils.py +48 -1
- 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.11.0.dist-info}/LICENSE +0 -0
- {cloud_sql_python_connector-1.9.2.dist-info → cloud_sql_python_connector-1.11.0.dist-info}/WHEEL +0 -0
- {cloud_sql_python_connector-1.9.2.dist-info → cloud_sql_python_connector-1.11.0.dist-info}/top_level.txt +0 -0
|
@@ -19,10 +19,9 @@ from __future__ import annotations
|
|
|
19
19
|
import asyncio
|
|
20
20
|
from functools import partial
|
|
21
21
|
import logging
|
|
22
|
-
import socket
|
|
23
22
|
from threading import Thread
|
|
24
23
|
from types import TracebackType
|
|
25
|
-
from typing import Any, Dict, Optional, Type
|
|
24
|
+
from typing import Any, Dict, Optional, Type, Union
|
|
26
25
|
|
|
27
26
|
import google.auth
|
|
28
27
|
from google.auth.credentials import Credentials
|
|
@@ -30,10 +29,12 @@ from google.auth.credentials import with_scopes_if_required
|
|
|
30
29
|
|
|
31
30
|
import google.cloud.sql.connector.asyncpg as asyncpg
|
|
32
31
|
from google.cloud.sql.connector.client import CloudSQLClient
|
|
32
|
+
from google.cloud.sql.connector.enums import DriverMapping
|
|
33
|
+
from google.cloud.sql.connector.enums import IPTypes
|
|
34
|
+
from google.cloud.sql.connector.enums import RefreshStrategy
|
|
33
35
|
from google.cloud.sql.connector.exceptions import ConnectorLoopError
|
|
34
|
-
from google.cloud.sql.connector.exceptions import DnsNameResolutionError
|
|
35
|
-
from google.cloud.sql.connector.instance import IPTypes
|
|
36
36
|
from google.cloud.sql.connector.instance import RefreshAheadCache
|
|
37
|
+
from google.cloud.sql.connector.lazy import LazyRefreshCache
|
|
37
38
|
import google.cloud.sql.connector.pg8000 as pg8000
|
|
38
39
|
import google.cloud.sql.connector.pymysql as pymysql
|
|
39
40
|
import google.cloud.sql.connector.pytds as pytds
|
|
@@ -62,6 +63,7 @@ class Connector:
|
|
|
62
63
|
sqladmin_api_endpoint: Optional[str] = None,
|
|
63
64
|
user_agent: Optional[str] = None,
|
|
64
65
|
universe_domain: Optional[str] = None,
|
|
66
|
+
refresh_strategy: str | RefreshStrategy = RefreshStrategy.BACKGROUND,
|
|
65
67
|
) -> None:
|
|
66
68
|
"""Initializes a Connector instance.
|
|
67
69
|
|
|
@@ -98,22 +100,39 @@ class Connector:
|
|
|
98
100
|
universe_domain (str): The universe domain for Cloud SQL API calls.
|
|
99
101
|
Default: "googleapis.com".
|
|
100
102
|
|
|
103
|
+
refresh_strategy (str | RefreshStrategy): The default refresh strategy
|
|
104
|
+
used to refresh SSL/TLS cert and instance metadata. Can be one
|
|
105
|
+
of the following: RefreshStrategy.LAZY ("LAZY") or
|
|
106
|
+
RefreshStrategy.BACKGROUND ("BACKGROUND").
|
|
107
|
+
Default: RefreshStrategy.BACKGROUND
|
|
101
108
|
"""
|
|
109
|
+
# if refresh_strategy is str, convert to RefreshStrategy enum
|
|
110
|
+
if isinstance(refresh_strategy, str):
|
|
111
|
+
refresh_strategy = RefreshStrategy._from_str(refresh_strategy)
|
|
112
|
+
self._refresh_strategy = refresh_strategy
|
|
102
113
|
# if event loop is given, use for background tasks
|
|
103
114
|
if loop:
|
|
104
115
|
self._loop: asyncio.AbstractEventLoop = loop
|
|
105
116
|
self._thread: Optional[Thread] = None
|
|
106
|
-
|
|
117
|
+
# if lazy refresh is specified we should lazy init keys
|
|
118
|
+
if self._refresh_strategy == RefreshStrategy.LAZY:
|
|
119
|
+
self._keys: Optional[asyncio.Future] = None
|
|
120
|
+
else:
|
|
121
|
+
self._keys = loop.create_task(generate_keys())
|
|
107
122
|
# if no event loop is given, spin up new loop in background thread
|
|
108
123
|
else:
|
|
109
124
|
self._loop = asyncio.new_event_loop()
|
|
110
125
|
self._thread = Thread(target=self._loop.run_forever, daemon=True)
|
|
111
126
|
self._thread.start()
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
127
|
+
# if lazy refresh is specified we should lazy init keys
|
|
128
|
+
if self._refresh_strategy == RefreshStrategy.LAZY:
|
|
129
|
+
self._keys = None
|
|
130
|
+
else:
|
|
131
|
+
self._keys = asyncio.wrap_future(
|
|
132
|
+
asyncio.run_coroutine_threadsafe(generate_keys(), self._loop),
|
|
133
|
+
loop=self._loop,
|
|
134
|
+
)
|
|
135
|
+
self._cache: Dict[str, Union[RefreshAheadCache, LazyRefreshCache]] = {}
|
|
117
136
|
self._client: Optional[CloudSQLClient] = None
|
|
118
137
|
|
|
119
138
|
# initialize credentials
|
|
@@ -242,9 +261,9 @@ class Connector:
|
|
|
242
261
|
and then subsequent attempt with IAM database authentication.
|
|
243
262
|
KeyError: Unsupported database driver Must be one of pymysql, asyncpg,
|
|
244
263
|
pg8000, and pytds.
|
|
245
|
-
DnsNameResolutionError: Could not resolve PSC IP address from DNS
|
|
246
|
-
host name.
|
|
247
264
|
"""
|
|
265
|
+
if self._keys is None:
|
|
266
|
+
self._keys = asyncio.create_task(generate_keys())
|
|
248
267
|
if self._client is None:
|
|
249
268
|
# lazy init client as it has to be initialized in async context
|
|
250
269
|
self._client = CloudSQLClient(
|
|
@@ -265,11 +284,30 @@ class Connector:
|
|
|
265
284
|
"connector.Connector object."
|
|
266
285
|
)
|
|
267
286
|
else:
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
287
|
+
if self._refresh_strategy == RefreshStrategy.LAZY:
|
|
288
|
+
logger.debug(
|
|
289
|
+
f"['{instance_connection_string}']: Refresh strategy is set"
|
|
290
|
+
" to lazy refresh"
|
|
291
|
+
)
|
|
292
|
+
cache = LazyRefreshCache(
|
|
293
|
+
instance_connection_string,
|
|
294
|
+
self._client,
|
|
295
|
+
self._keys,
|
|
296
|
+
enable_iam_auth,
|
|
297
|
+
)
|
|
298
|
+
else:
|
|
299
|
+
logger.debug(
|
|
300
|
+
f"['{instance_connection_string}']: Refresh strategy is set"
|
|
301
|
+
" to backgound refresh"
|
|
302
|
+
)
|
|
303
|
+
cache = RefreshAheadCache(
|
|
304
|
+
instance_connection_string,
|
|
305
|
+
self._client,
|
|
306
|
+
self._keys,
|
|
307
|
+
enable_iam_auth,
|
|
308
|
+
)
|
|
309
|
+
logger.debug(
|
|
310
|
+
f"['{instance_connection_string}']: Connection info added to cache"
|
|
273
311
|
)
|
|
274
312
|
self._cache[instance_connection_string] = cache
|
|
275
313
|
|
|
@@ -298,42 +336,44 @@ class Connector:
|
|
|
298
336
|
kwargs.pop("ssl", None)
|
|
299
337
|
kwargs.pop("port", None)
|
|
300
338
|
|
|
301
|
-
# attempt to
|
|
339
|
+
# attempt to get connection info for Cloud SQL instance
|
|
302
340
|
try:
|
|
303
|
-
|
|
304
|
-
#
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
instance_data.database_version, kwargs["user"]
|
|
341
|
+
conn_info = await cache.connect_info()
|
|
342
|
+
# validate driver matches intended database engine
|
|
343
|
+
DriverMapping.validate_engine(driver, conn_info.database_version)
|
|
344
|
+
ip_address = conn_info.get_preferred_ip(ip_type)
|
|
345
|
+
except Exception:
|
|
346
|
+
# with an error from Cloud SQL Admin API call or IP type, invalidate
|
|
347
|
+
# the cache and re-raise the error
|
|
348
|
+
await self._remove_cached(instance_connection_string)
|
|
349
|
+
raise
|
|
350
|
+
logger.debug(
|
|
351
|
+
f"['{instance_connection_string}']: Connecting to {ip_address}:3307"
|
|
352
|
+
)
|
|
353
|
+
# format `user` param for automatic IAM database authn
|
|
354
|
+
if enable_iam_auth:
|
|
355
|
+
formatted_user = format_database_user(
|
|
356
|
+
conn_info.database_version, kwargs["user"]
|
|
357
|
+
)
|
|
358
|
+
if formatted_user != kwargs["user"]:
|
|
359
|
+
logger.debug(
|
|
360
|
+
f"['{instance_connection_string}']: Truncated IAM database username from {kwargs['user']} to {formatted_user}"
|
|
324
361
|
)
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
f"['{instance_connection_string}']: Truncated IAM database username from {kwargs['user']} to {formatted_user}"
|
|
328
|
-
)
|
|
329
|
-
kwargs["user"] = formatted_user
|
|
330
|
-
|
|
362
|
+
kwargs["user"] = formatted_user
|
|
363
|
+
try:
|
|
331
364
|
# async drivers are unblocking and can be awaited directly
|
|
332
365
|
if driver in ASYNC_DRIVERS:
|
|
333
|
-
return await connector(
|
|
366
|
+
return await connector(
|
|
367
|
+
ip_address,
|
|
368
|
+
conn_info.create_ssl_context(enable_iam_auth),
|
|
369
|
+
**kwargs,
|
|
370
|
+
)
|
|
334
371
|
# synchronous drivers are blocking and run using executor
|
|
335
372
|
connect_partial = partial(
|
|
336
|
-
connector,
|
|
373
|
+
connector,
|
|
374
|
+
ip_address,
|
|
375
|
+
conn_info.create_ssl_context(enable_iam_auth),
|
|
376
|
+
**kwargs,
|
|
337
377
|
)
|
|
338
378
|
return await self._loop.run_in_executor(None, connect_partial)
|
|
339
379
|
|
|
@@ -342,6 +382,17 @@ class Connector:
|
|
|
342
382
|
await cache.force_refresh()
|
|
343
383
|
raise
|
|
344
384
|
|
|
385
|
+
async def _remove_cached(self, instance_connection_string: str) -> None:
|
|
386
|
+
"""Stops all background refreshes and deletes the connection
|
|
387
|
+
info cache from the map of caches.
|
|
388
|
+
"""
|
|
389
|
+
logger.debug(
|
|
390
|
+
f"['{instance_connection_string}']: Removing connection info from cache"
|
|
391
|
+
)
|
|
392
|
+
# remove cache from stored caches and close it
|
|
393
|
+
cache = self._cache.pop(instance_connection_string)
|
|
394
|
+
await cache.close()
|
|
395
|
+
|
|
345
396
|
def __enter__(self) -> Any:
|
|
346
397
|
"""Enter context manager by returning Connector object"""
|
|
347
398
|
return self
|
|
@@ -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
|
+
)
|
|
@@ -57,8 +57,16 @@ class AutoIAMAuthNotSupported(Exception):
|
|
|
57
57
|
pass
|
|
58
58
|
|
|
59
59
|
|
|
60
|
-
class
|
|
60
|
+
class RefreshNotValidError(Exception):
|
|
61
61
|
"""
|
|
62
|
-
Exception to be raised when the
|
|
63
|
-
|
|
62
|
+
Exception to be raised when the task returned from refresh is not valid.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class IncompatibleDriverError(Exception):
|
|
69
|
+
"""
|
|
70
|
+
Exception to be raised when the database driver given is for the wrong
|
|
71
|
+
database engine. (i.e. asyncpg for a MySQL database)
|
|
64
72
|
"""
|
|
@@ -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
|
-
e.message = "Forbidden: Authenticated IAM principal does not
|
|
153
|
+
e.message = "Forbidden: Authenticated IAM principal does not seem 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)
|