cloud-sql-python-connector 1.20.4__tar.gz → 1.21.0__tar.gz
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.20.4 → cloud_sql_python_connector-1.21.0}/PKG-INFO +4 -2
- {cloud_sql_python_connector-1.20.4 → cloud_sql_python_connector-1.21.0}/cloud_sql_python_connector.egg-info/PKG-INFO +4 -2
- {cloud_sql_python_connector-1.20.4 → cloud_sql_python_connector-1.21.0}/cloud_sql_python_connector.egg-info/requires.txt +3 -1
- {cloud_sql_python_connector-1.20.4 → cloud_sql_python_connector-1.21.0}/google/cloud/sql/connector/client.py +61 -19
- {cloud_sql_python_connector-1.20.4 → cloud_sql_python_connector-1.21.0}/google/cloud/sql/connector/connection_info.py +15 -5
- {cloud_sql_python_connector-1.20.4 → cloud_sql_python_connector-1.21.0}/google/cloud/sql/connector/connection_name.py +2 -4
- {cloud_sql_python_connector-1.20.4 → cloud_sql_python_connector-1.21.0}/google/cloud/sql/connector/connector.py +93 -62
- {cloud_sql_python_connector-1.20.4 → cloud_sql_python_connector-1.21.0}/google/cloud/sql/connector/enums.py +1 -1
- {cloud_sql_python_connector-1.20.4 → cloud_sql_python_connector-1.21.0}/google/cloud/sql/connector/exceptions.py +0 -6
- {cloud_sql_python_connector-1.20.4 → cloud_sql_python_connector-1.21.0}/google/cloud/sql/connector/instance.py +1 -1
- {cloud_sql_python_connector-1.20.4 → cloud_sql_python_connector-1.21.0}/google/cloud/sql/connector/lazy.py +5 -5
- {cloud_sql_python_connector-1.20.4 → cloud_sql_python_connector-1.21.0}/google/cloud/sql/connector/monitored_cache.py +7 -5
- {cloud_sql_python_connector-1.20.4 → cloud_sql_python_connector-1.21.0}/google/cloud/sql/connector/pymysql.py +1 -1
- {cloud_sql_python_connector-1.20.4 → cloud_sql_python_connector-1.21.0}/google/cloud/sql/connector/rate_limiter.py +3 -3
- {cloud_sql_python_connector-1.20.4 → cloud_sql_python_connector-1.21.0}/google/cloud/sql/connector/refresh_utils.py +4 -2
- cloud_sql_python_connector-1.21.0/google/cloud/sql/connector/resolver.py +160 -0
- {cloud_sql_python_connector-1.20.4 → cloud_sql_python_connector-1.21.0}/google/cloud/sql/connector/utils.py +40 -8
- {cloud_sql_python_connector-1.20.4 → cloud_sql_python_connector-1.21.0}/google/cloud/sql/connector/version.py +1 -1
- {cloud_sql_python_connector-1.20.4 → cloud_sql_python_connector-1.21.0}/pyproject.toml +3 -1
- cloud_sql_python_connector-1.20.4/google/cloud/sql/connector/resolver.py +0 -91
- {cloud_sql_python_connector-1.20.4 → cloud_sql_python_connector-1.21.0}/LICENSE +0 -0
- {cloud_sql_python_connector-1.20.4 → cloud_sql_python_connector-1.21.0}/README.md +0 -0
- {cloud_sql_python_connector-1.20.4 → cloud_sql_python_connector-1.21.0}/cloud_sql_python_connector.egg-info/SOURCES.txt +0 -0
- {cloud_sql_python_connector-1.20.4 → cloud_sql_python_connector-1.21.0}/cloud_sql_python_connector.egg-info/dependency_links.txt +0 -0
- {cloud_sql_python_connector-1.20.4 → cloud_sql_python_connector-1.21.0}/cloud_sql_python_connector.egg-info/top_level.txt +0 -0
- {cloud_sql_python_connector-1.20.4 → cloud_sql_python_connector-1.21.0}/google/cloud/sql/connector/__init__.py +2 -2
- {cloud_sql_python_connector-1.20.4 → cloud_sql_python_connector-1.21.0}/google/cloud/sql/connector/asyncpg.py +0 -0
- {cloud_sql_python_connector-1.20.4 → cloud_sql_python_connector-1.21.0}/google/cloud/sql/connector/pg8000.py +0 -0
- {cloud_sql_python_connector-1.20.4 → cloud_sql_python_connector-1.21.0}/google/cloud/sql/connector/py.typed +0 -0
- {cloud_sql_python_connector-1.20.4 → cloud_sql_python_connector-1.21.0}/google/cloud/sql/connector/pytds.py +0 -0
- {cloud_sql_python_connector-1.20.4 → cloud_sql_python_connector-1.21.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cloud-sql-python-connector
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.21.0
|
|
4
4
|
Summary: Google Cloud SQL Python Connector library
|
|
5
5
|
Author-email: Google LLC <googleapis-packages@google.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -21,12 +21,14 @@ Classifier: Operating System :: OS Independent
|
|
|
21
21
|
Requires-Python: >=3.9
|
|
22
22
|
Description-Content-Type: text/markdown
|
|
23
23
|
License-File: LICENSE
|
|
24
|
-
Requires-Dist: aiofiles
|
|
25
24
|
Requires-Dist: aiohttp
|
|
26
25
|
Requires-Dist: cryptography>=42.0.0
|
|
27
26
|
Requires-Dist: dnspython>=2.0.0
|
|
28
27
|
Requires-Dist: Requests
|
|
29
28
|
Requires-Dist: google-auth>=2.28.0
|
|
29
|
+
Requires-Dist: grpcio
|
|
30
|
+
Requires-Dist: protobuf
|
|
31
|
+
Requires-Dist: googleapis-common-protos
|
|
30
32
|
Provides-Extra: pymysql
|
|
31
33
|
Requires-Dist: PyMySQL>=1.1.0; extra == "pymysql"
|
|
32
34
|
Provides-Extra: pg8000
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cloud-sql-python-connector
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.21.0
|
|
4
4
|
Summary: Google Cloud SQL Python Connector library
|
|
5
5
|
Author-email: Google LLC <googleapis-packages@google.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -21,12 +21,14 @@ Classifier: Operating System :: OS Independent
|
|
|
21
21
|
Requires-Python: >=3.9
|
|
22
22
|
Description-Content-Type: text/markdown
|
|
23
23
|
License-File: LICENSE
|
|
24
|
-
Requires-Dist: aiofiles
|
|
25
24
|
Requires-Dist: aiohttp
|
|
26
25
|
Requires-Dist: cryptography>=42.0.0
|
|
27
26
|
Requires-Dist: dnspython>=2.0.0
|
|
28
27
|
Requires-Dist: Requests
|
|
29
28
|
Requires-Dist: google-auth>=2.28.0
|
|
29
|
+
Requires-Dist: grpcio
|
|
30
|
+
Requires-Dist: protobuf
|
|
31
|
+
Requires-Dist: googleapis-common-protos
|
|
30
32
|
Provides-Extra: pymysql
|
|
31
33
|
Requires-Dist: PyMySQL>=1.1.0; extra == "pymysql"
|
|
32
34
|
Provides-Extra: pg8000
|
|
@@ -17,7 +17,7 @@ from __future__ import annotations
|
|
|
17
17
|
import asyncio
|
|
18
18
|
import datetime
|
|
19
19
|
import logging
|
|
20
|
-
from typing import Any,
|
|
20
|
+
from typing import Any, TYPE_CHECKING
|
|
21
21
|
|
|
22
22
|
import aiohttp
|
|
23
23
|
from cryptography.hazmat.backends import default_backend
|
|
@@ -42,7 +42,7 @@ DEFAULT_SERVICE_ENDPOINT: str = "https://sqladmin.googleapis.com"
|
|
|
42
42
|
logger = logging.getLogger(name=__name__)
|
|
43
43
|
|
|
44
44
|
|
|
45
|
-
def _format_user_agent(driver:
|
|
45
|
+
def _format_user_agent(driver: str | None, custom: str | None) -> str:
|
|
46
46
|
agent = f"{USER_AGENT}+{driver}" if driver else USER_AGENT
|
|
47
47
|
if custom and isinstance(custom, str):
|
|
48
48
|
agent = f"{agent} {custom}"
|
|
@@ -52,12 +52,12 @@ def _format_user_agent(driver: Optional[str], custom: Optional[str]) -> str:
|
|
|
52
52
|
class CloudSQLClient:
|
|
53
53
|
def __init__(
|
|
54
54
|
self,
|
|
55
|
-
sqladmin_api_endpoint:
|
|
56
|
-
quota_project:
|
|
55
|
+
sqladmin_api_endpoint: str | None,
|
|
56
|
+
quota_project: str | None,
|
|
57
57
|
credentials: Credentials,
|
|
58
|
-
client:
|
|
59
|
-
driver:
|
|
60
|
-
user_agent:
|
|
58
|
+
client: aiohttp.ClientSession | None = None,
|
|
59
|
+
driver: str | None = None,
|
|
60
|
+
user_agent: str | None = None,
|
|
61
61
|
) -> None:
|
|
62
62
|
"""Establishes the client to be used for Cloud SQL Admin API requests.
|
|
63
63
|
|
|
@@ -135,7 +135,7 @@ class CloudSQLClient:
|
|
|
135
135
|
if message:
|
|
136
136
|
resp.reason = message
|
|
137
137
|
# skip, raise_for_status will catch all errors in finally block
|
|
138
|
-
except Exception:
|
|
138
|
+
except Exception: # noqa: BLE001, S110
|
|
139
139
|
pass
|
|
140
140
|
finally:
|
|
141
141
|
resp.raise_for_status()
|
|
@@ -146,7 +146,7 @@ class CloudSQLClient:
|
|
|
146
146
|
)
|
|
147
147
|
|
|
148
148
|
ip_addresses = (
|
|
149
|
-
{ip["type"]: ip["ipAddress"] for ip in ret_dict["ipAddresses"]}
|
|
149
|
+
{ip["type"]: [ip["ipAddress"]] for ip in ret_dict["ipAddresses"]}
|
|
150
150
|
if "ipAddresses" in ret_dict
|
|
151
151
|
else {}
|
|
152
152
|
)
|
|
@@ -156,20 +156,22 @@ class CloudSQLClient:
|
|
|
156
156
|
if ret_dict.get("pscEnabled"):
|
|
157
157
|
# Find PSC instance DNS name in the dns_names field
|
|
158
158
|
psc_dns_names = [
|
|
159
|
-
d["name"]
|
|
159
|
+
d["name"].rstrip(".")
|
|
160
160
|
for d in ret_dict.get("dnsNames", [])
|
|
161
161
|
if d["connectionType"] == "PRIVATE_SERVICE_CONNECT"
|
|
162
162
|
and d["dnsScope"] == "INSTANCE"
|
|
163
163
|
]
|
|
164
|
-
|
|
164
|
+
# Sort: .sql-psc.goog first
|
|
165
|
+
psc_dns_names.sort(key=lambda x: x.endswith(".sql-psc.goog"), reverse=True)
|
|
165
166
|
|
|
166
167
|
# Fall back do dns_name field if dns_names is not set
|
|
167
|
-
if
|
|
168
|
+
if not psc_dns_names:
|
|
168
169
|
dns_name = ret_dict.get("dnsName", None)
|
|
170
|
+
if dns_name:
|
|
171
|
+
psc_dns_names = [dns_name.rstrip(".")]
|
|
169
172
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
ip_addresses["PSC"] = dns_name.rstrip(".")
|
|
173
|
+
if psc_dns_names:
|
|
174
|
+
ip_addresses["PSC"] = psc_dns_names
|
|
173
175
|
|
|
174
176
|
return {
|
|
175
177
|
"ip_addresses": ip_addresses,
|
|
@@ -177,6 +179,47 @@ class CloudSQLClient:
|
|
|
177
179
|
"database_version": ret_dict["databaseVersion"],
|
|
178
180
|
}
|
|
179
181
|
|
|
182
|
+
async def resolve_connect_settings(
|
|
183
|
+
self,
|
|
184
|
+
dns_name: str,
|
|
185
|
+
location: str,
|
|
186
|
+
) -> dict[str, Any]:
|
|
187
|
+
"""Asynchronously calls the resolveConnectSettings endpoint to resolve a
|
|
188
|
+
PSC DNS name to a connection name.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
dns_name (str): The DNS name of the Cloud SQL instance.
|
|
192
|
+
location (str): The region/location of the instance.
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
A dictionary containing the resolve response (e.g. connectionName).
|
|
196
|
+
"""
|
|
197
|
+
# before making Cloud SQL Admin API calls, refresh creds if required
|
|
198
|
+
if self._credentials.token_state != TokenState.FRESH:
|
|
199
|
+
self._credentials.refresh(requests.Request())
|
|
200
|
+
|
|
201
|
+
headers = {
|
|
202
|
+
"Authorization": f"Bearer {self._credentials.token}",
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
url = f"{self._sqladmin_api_endpoint}/sql/{API_VERSION}/locations/{location}/dns/{dns_name}:resolveConnectSettings"
|
|
206
|
+
|
|
207
|
+
resp = await self._client.get(url, headers=headers)
|
|
208
|
+
if resp.status >= 500:
|
|
209
|
+
resp = await retry_50x(self._client.get, url, headers=headers)
|
|
210
|
+
try:
|
|
211
|
+
ret_dict = await resp.json()
|
|
212
|
+
if resp.status >= 400:
|
|
213
|
+
message = ret_dict.get("error", {}).get("message")
|
|
214
|
+
if message:
|
|
215
|
+
resp.reason = message
|
|
216
|
+
except Exception: # noqa: BLE001, S110
|
|
217
|
+
pass
|
|
218
|
+
finally:
|
|
219
|
+
resp.raise_for_status()
|
|
220
|
+
|
|
221
|
+
return ret_dict
|
|
222
|
+
|
|
180
223
|
async def _get_ephemeral(
|
|
181
224
|
self,
|
|
182
225
|
project: str,
|
|
@@ -223,7 +266,7 @@ class CloudSQLClient:
|
|
|
223
266
|
if message:
|
|
224
267
|
resp.reason = message
|
|
225
268
|
# skip, raise_for_status will catch all errors in finally block
|
|
226
|
-
except Exception:
|
|
269
|
+
except Exception: # noqa: BLE001, S110
|
|
227
270
|
pass
|
|
228
271
|
finally:
|
|
229
272
|
resp.raise_for_status()
|
|
@@ -244,8 +287,7 @@ class CloudSQLClient:
|
|
|
244
287
|
# Ref: https://github.com/googleapis/google-auth-library-python/blob/49a5ff7411a2ae4d32a7d11700f9f961c55406a9/google/auth/_helpers.py#L93-L99
|
|
245
288
|
token_expiration = token_expiration.replace(tzinfo=datetime.timezone.utc)
|
|
246
289
|
|
|
247
|
-
|
|
248
|
-
expiration = token_expiration
|
|
290
|
+
expiration = min(expiration, token_expiration)
|
|
249
291
|
return ephemeral_cert, expiration
|
|
250
292
|
|
|
251
293
|
async def get_connection_info(
|
|
@@ -274,7 +316,7 @@ class CloudSQLClient:
|
|
|
274
316
|
"""
|
|
275
317
|
priv_key, pub_key = await keys
|
|
276
318
|
# before making Cloud SQL Admin API calls, refresh creds if required
|
|
277
|
-
if
|
|
319
|
+
if self._credentials.token_state != TokenState.FRESH:
|
|
278
320
|
self._credentials.refresh(requests.Request())
|
|
279
321
|
|
|
280
322
|
metadata_task = asyncio.create_task(
|
|
@@ -18,13 +18,12 @@ import abc
|
|
|
18
18
|
from dataclasses import dataclass
|
|
19
19
|
import logging
|
|
20
20
|
import ssl
|
|
21
|
-
from typing import Any,
|
|
22
|
-
|
|
23
|
-
from aiofiles.tempfile import TemporaryDirectory
|
|
21
|
+
from typing import Any, TYPE_CHECKING
|
|
24
22
|
|
|
25
23
|
from google.cloud.sql.connector.connection_name import ConnectionName
|
|
26
24
|
from google.cloud.sql.connector.exceptions import CloudSQLIPTypeError
|
|
27
25
|
from google.cloud.sql.connector.exceptions import TLSVersionError
|
|
26
|
+
from google.cloud.sql.connector.utils import AsyncTemporaryDirectory
|
|
28
27
|
from google.cloud.sql.connector.utils import write_to_file
|
|
29
28
|
|
|
30
29
|
if TYPE_CHECKING:
|
|
@@ -68,7 +67,7 @@ class ConnectionInfo:
|
|
|
68
67
|
ip_addrs: dict[str, Any]
|
|
69
68
|
database_version: str
|
|
70
69
|
expiration: datetime.datetime
|
|
71
|
-
context:
|
|
70
|
+
context: ssl.SSLContext | None = None
|
|
72
71
|
|
|
73
72
|
async def create_ssl_context(self, enable_iam_auth: bool = False) -> ssl.SSLContext:
|
|
74
73
|
"""Constructs a SSL/TLS context for the given connection info.
|
|
@@ -108,7 +107,7 @@ class ConnectionInfo:
|
|
|
108
107
|
# tmpdir and its contents are automatically deleted after the CA cert
|
|
109
108
|
# and ephemeral cert are loaded into the SSLcontext. The values
|
|
110
109
|
# need to be written to files in order to be loaded by the SSLContext
|
|
111
|
-
async with
|
|
110
|
+
async with AsyncTemporaryDirectory() as tmpdir:
|
|
112
111
|
ca_filename, cert_filename, key_filename = await write_to_file(
|
|
113
112
|
tmpdir, self.server_ca_cert, self.client_cert, self.private_key
|
|
114
113
|
)
|
|
@@ -122,6 +121,17 @@ class ConnectionInfo:
|
|
|
122
121
|
"""Returns the first IP address for the instance, according to the preference
|
|
123
122
|
supplied by ip_type. If no IP addressess with the given preference are found,
|
|
124
123
|
an error is raised."""
|
|
124
|
+
if ip_type.value in self.ip_addrs:
|
|
125
|
+
return self.ip_addrs[ip_type.value][0]
|
|
126
|
+
raise CloudSQLIPTypeError(
|
|
127
|
+
"Cloud SQL instance does not have any IP addresses matching "
|
|
128
|
+
f"preference: {ip_type.value}"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
def get_preferred_ips(self, ip_type: IPTypes) -> list[str]:
|
|
132
|
+
"""Returns all IP addresses for the instance, according to the preference
|
|
133
|
+
supplied by ip_type. If no IP addressess with the given preference are found,
|
|
134
|
+
an error is raised."""
|
|
125
135
|
if ip_type.value in self.ip_addrs:
|
|
126
136
|
return self.ip_addrs[ip_type.value]
|
|
127
137
|
raise CloudSQLIPTypeError(
|
|
@@ -18,7 +18,7 @@ import re
|
|
|
18
18
|
# Instance connection name is the format <PROJECT>:<REGION>:<INSTANCE_NAME>
|
|
19
19
|
# Additionally, we have to support legacy "domain-scoped" projects
|
|
20
20
|
# (e.g. "google.com:PROJECT")
|
|
21
|
-
CONN_NAME_REGEX = re.compile(
|
|
21
|
+
CONN_NAME_REGEX = re.compile("([^:]+(:[^:]+)?):([^:]+):([^:]+)")
|
|
22
22
|
# The domain name pattern in accordance with RFC 1035, RFC 1123 and RFC 2181.
|
|
23
23
|
DOMAIN_NAME_REGEX = re.compile(
|
|
24
24
|
r"^(?:[_a-z0-9](?:[_a-z0-9-]{0,61}[a-z0-9])?\.)+(?:[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?)?$"
|
|
@@ -48,9 +48,7 @@ class ConnectionName:
|
|
|
48
48
|
|
|
49
49
|
|
|
50
50
|
def _is_valid_domain(domain_name: str) -> bool:
|
|
51
|
-
|
|
52
|
-
return False
|
|
53
|
-
return True
|
|
51
|
+
return DOMAIN_NAME_REGEX.fullmatch(domain_name) is not None
|
|
54
52
|
|
|
55
53
|
|
|
56
54
|
def _parse_connection_name(connection_name: str) -> ConnectionName:
|
|
@@ -23,13 +23,16 @@ import os
|
|
|
23
23
|
import socket
|
|
24
24
|
from threading import Thread
|
|
25
25
|
from types import TracebackType
|
|
26
|
-
from typing import Any, Callable
|
|
26
|
+
from typing import Any, Callable
|
|
27
27
|
|
|
28
28
|
import google.auth
|
|
29
29
|
from google.auth.credentials import Credentials
|
|
30
30
|
from google.auth.credentials import with_scopes_if_required
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
from google.cloud.sql.connector import asyncpg
|
|
33
|
+
from google.cloud.sql.connector import pg8000
|
|
34
|
+
from google.cloud.sql.connector import pymysql
|
|
35
|
+
from google.cloud.sql.connector import pytds
|
|
33
36
|
from google.cloud.sql.connector.client import CloudSQLClient
|
|
34
37
|
from google.cloud.sql.connector.enums import DriverMapping
|
|
35
38
|
from google.cloud.sql.connector.enums import IPTypes
|
|
@@ -39,9 +42,6 @@ from google.cloud.sql.connector.exceptions import ConnectorLoopError
|
|
|
39
42
|
from google.cloud.sql.connector.instance import RefreshAheadCache
|
|
40
43
|
from google.cloud.sql.connector.lazy import LazyRefreshCache
|
|
41
44
|
from google.cloud.sql.connector.monitored_cache import MonitoredCache
|
|
42
|
-
import google.cloud.sql.connector.pg8000 as pg8000
|
|
43
|
-
import google.cloud.sql.connector.pymysql as pymysql
|
|
44
|
-
import google.cloud.sql.connector.pytds as pytds
|
|
45
45
|
from google.cloud.sql.connector.resolver import DefaultResolver
|
|
46
46
|
from google.cloud.sql.connector.resolver import DnsResolver
|
|
47
47
|
from google.cloud.sql.connector.utils import format_database_user
|
|
@@ -64,14 +64,14 @@ class Connector:
|
|
|
64
64
|
ip_type: str | IPTypes = IPTypes.PUBLIC,
|
|
65
65
|
enable_iam_auth: bool = False,
|
|
66
66
|
timeout: int = 30,
|
|
67
|
-
credentials:
|
|
68
|
-
loop:
|
|
69
|
-
quota_project:
|
|
70
|
-
sqladmin_api_endpoint:
|
|
71
|
-
user_agent:
|
|
72
|
-
universe_domain:
|
|
67
|
+
credentials: Credentials | None = None,
|
|
68
|
+
loop: asyncio.AbstractEventLoop | None = None,
|
|
69
|
+
quota_project: str | None = None,
|
|
70
|
+
sqladmin_api_endpoint: str | None = None,
|
|
71
|
+
user_agent: str | None = None,
|
|
72
|
+
universe_domain: str | None = None,
|
|
73
73
|
refresh_strategy: str | RefreshStrategy = RefreshStrategy.BACKGROUND,
|
|
74
|
-
resolver: type[DefaultResolver
|
|
74
|
+
resolver: type[DefaultResolver | DnsResolver] = DefaultResolver,
|
|
75
75
|
failover_period: int = 30,
|
|
76
76
|
) -> None:
|
|
77
77
|
"""Initializes a Connector instance.
|
|
@@ -133,10 +133,10 @@ class Connector:
|
|
|
133
133
|
# if event loop is given, use for background tasks
|
|
134
134
|
if loop:
|
|
135
135
|
self._loop: asyncio.AbstractEventLoop = loop
|
|
136
|
-
self._thread:
|
|
136
|
+
self._thread: Thread | None = None
|
|
137
137
|
# if lazy refresh is specified we should lazy init keys
|
|
138
138
|
if self._refresh_strategy == RefreshStrategy.LAZY:
|
|
139
|
-
self._keys:
|
|
139
|
+
self._keys: asyncio.Future | None = None
|
|
140
140
|
else:
|
|
141
141
|
self._keys = loop.create_task(generate_keys())
|
|
142
142
|
# if no event loop is given, spin up new loop in background thread
|
|
@@ -155,7 +155,7 @@ class Connector:
|
|
|
155
155
|
# initialize dict to store caches, key is a tuple consisting of instance
|
|
156
156
|
# connection name string and enable_iam_auth boolean flag
|
|
157
157
|
self._cache: dict[tuple[str, bool], MonitoredCache] = {}
|
|
158
|
-
self._client:
|
|
158
|
+
self._client: CloudSQLClient | None = None
|
|
159
159
|
self._closed: bool = False
|
|
160
160
|
|
|
161
161
|
# initialize credentials
|
|
@@ -176,7 +176,8 @@ class Connector:
|
|
|
176
176
|
self._timeout = timeout
|
|
177
177
|
self._enable_iam_auth = enable_iam_auth
|
|
178
178
|
self._user_agent = user_agent
|
|
179
|
-
self.
|
|
179
|
+
self._resolver_cls = resolver
|
|
180
|
+
self._resolver: DefaultResolver | DnsResolver | None = None
|
|
180
181
|
self._failover_period = failover_period
|
|
181
182
|
# if ip_type is str, convert to IPTypes enum
|
|
182
183
|
if isinstance(ip_type, str):
|
|
@@ -316,6 +317,8 @@ class Connector:
|
|
|
316
317
|
user_agent=self._user_agent,
|
|
317
318
|
driver=driver,
|
|
318
319
|
)
|
|
320
|
+
if self._resolver is None:
|
|
321
|
+
self._resolver = self._resolver_cls(client=self._client)
|
|
319
322
|
enable_iam_auth = kwargs.pop("enable_iam_auth", self._enable_iam_auth)
|
|
320
323
|
|
|
321
324
|
conn_name = await self._resolver.resolve(instance_connection_string)
|
|
@@ -329,7 +332,7 @@ class Connector:
|
|
|
329
332
|
logger.debug(
|
|
330
333
|
f"['{conn_name}']: Refresh strategy is set to lazy refresh"
|
|
331
334
|
)
|
|
332
|
-
cache:
|
|
335
|
+
cache: LazyRefreshCache | RefreshAheadCache = LazyRefreshCache(
|
|
333
336
|
conn_name,
|
|
334
337
|
self._client,
|
|
335
338
|
self._keys,
|
|
@@ -384,13 +387,14 @@ class Connector:
|
|
|
384
387
|
conn_info = await monitored_cache.connect_info()
|
|
385
388
|
# validate driver matches intended database engine
|
|
386
389
|
DriverMapping.validate_engine(driver, conn_info.database_version)
|
|
387
|
-
|
|
390
|
+
preferred_ips = conn_info.get_preferred_ips(ip_type)
|
|
388
391
|
except Exception:
|
|
389
392
|
# with an error from Cloud SQL Admin API call or IP type, invalidate
|
|
390
393
|
# the cache and re-raise the error
|
|
391
394
|
await self._remove_cached(str(conn_name), enable_iam_auth)
|
|
392
395
|
raise
|
|
393
396
|
|
|
397
|
+
targets = []
|
|
394
398
|
# If the connector is configured with a custom DNS name, attempt to use
|
|
395
399
|
# that DNS name to connect to the instance. Fall back to the metadata IP
|
|
396
400
|
# address if the DNS name does not resolve to an IP address.
|
|
@@ -398,26 +402,29 @@ class Connector:
|
|
|
398
402
|
try:
|
|
399
403
|
ips = await self._resolver.resolve_a_record(conn_info.conn_name.domain_name)
|
|
400
404
|
if ips:
|
|
401
|
-
|
|
405
|
+
targets.extend(ips)
|
|
402
406
|
logger.debug(
|
|
403
407
|
f"['{instance_connection_string}']: Custom DNS name "
|
|
404
|
-
f"'{conn_info.conn_name.domain_name}' resolved to '{
|
|
408
|
+
f"'{conn_info.conn_name.domain_name}' resolved to '{ips}', "
|
|
405
409
|
"using it to connect"
|
|
406
410
|
)
|
|
407
411
|
else:
|
|
408
412
|
logger.debug(
|
|
409
413
|
f"['{instance_connection_string}']: Custom DNS name "
|
|
410
414
|
f"'{conn_info.conn_name.domain_name}' resolved but returned no "
|
|
411
|
-
f"entries, using '{
|
|
415
|
+
f"entries, using '{preferred_ips}' from instance metadata"
|
|
412
416
|
)
|
|
413
|
-
|
|
417
|
+
targets.extend(preferred_ips)
|
|
418
|
+
except Exception as e: # noqa: BLE001
|
|
414
419
|
logger.debug(
|
|
415
420
|
f"['{instance_connection_string}']: Custom DNS name "
|
|
416
421
|
f"'{conn_info.conn_name.domain_name}' did not resolve to an IP "
|
|
417
|
-
f"address: {e}, using '{
|
|
422
|
+
f"address: {e}, using '{preferred_ips}' from instance metadata"
|
|
418
423
|
)
|
|
424
|
+
targets.extend(preferred_ips)
|
|
425
|
+
else:
|
|
426
|
+
targets.extend(preferred_ips)
|
|
419
427
|
|
|
420
|
-
logger.debug(f"['{conn_info.conn_name}']: Connecting to {ip_address}:3307")
|
|
421
428
|
# format `user` param for automatic IAM database authn
|
|
422
429
|
if enable_iam_auth:
|
|
423
430
|
formatted_user = format_database_user(
|
|
@@ -428,32 +435,56 @@ class Connector:
|
|
|
428
435
|
f"['{instance_connection_string}']: Truncated IAM database username from {kwargs['user']} to {formatted_user}"
|
|
429
436
|
)
|
|
430
437
|
kwargs["user"] = formatted_user
|
|
438
|
+
|
|
431
439
|
try:
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
440
|
+
last_ex = None
|
|
441
|
+
for target_ip in targets:
|
|
442
|
+
logger.debug(f"['{conn_info.conn_name}']: Connecting to {target_ip}:3307")
|
|
443
|
+
try:
|
|
444
|
+
# async drivers are unblocking and can be awaited directly
|
|
445
|
+
if driver in ASYNC_DRIVERS:
|
|
446
|
+
conn = await connector(
|
|
447
|
+
target_ip,
|
|
448
|
+
await conn_info.create_ssl_context(enable_iam_auth),
|
|
449
|
+
**kwargs,
|
|
450
|
+
)
|
|
451
|
+
last_ex = None
|
|
452
|
+
return conn
|
|
453
|
+
|
|
454
|
+
# Create socket with SSLContext for sync drivers
|
|
455
|
+
ctx = await conn_info.create_ssl_context(enable_iam_auth)
|
|
456
|
+
raw_sock = socket.create_connection((target_ip, SERVER_PROXY_PORT))
|
|
457
|
+
try:
|
|
458
|
+
sock = ctx.wrap_socket(
|
|
459
|
+
raw_sock,
|
|
460
|
+
server_hostname=target_ip,
|
|
461
|
+
)
|
|
462
|
+
except Exception:
|
|
463
|
+
raw_sock.close()
|
|
464
|
+
raise
|
|
465
|
+
|
|
466
|
+
# If this connection was opened using a domain name, then store it
|
|
467
|
+
# for later in case we need to forcibly close it on failover.
|
|
468
|
+
if conn_info.conn_name.domain_name:
|
|
469
|
+
monitored_cache.sockets.append(sock)
|
|
470
|
+
# Synchronous drivers are blocking and run using executor
|
|
471
|
+
connect_partial = partial(
|
|
472
|
+
connector,
|
|
473
|
+
target_ip,
|
|
474
|
+
sock,
|
|
475
|
+
**kwargs,
|
|
476
|
+
)
|
|
477
|
+
conn = await self._loop.run_in_executor(None, connect_partial)
|
|
478
|
+
last_ex = None
|
|
479
|
+
return conn
|
|
480
|
+
except Exception as e: # noqa: BLE001
|
|
481
|
+
logger.debug(
|
|
482
|
+
f"['{conn_info.conn_name}']: Connection to {target_ip} failed: {e}"
|
|
483
|
+
)
|
|
484
|
+
last_ex = e
|
|
485
|
+
|
|
486
|
+
if last_ex:
|
|
487
|
+
raise last_ex
|
|
457
488
|
|
|
458
489
|
except Exception:
|
|
459
490
|
# with any exception, we attempt a force refresh, then throw the error
|
|
@@ -479,9 +510,9 @@ class Connector:
|
|
|
479
510
|
|
|
480
511
|
def __exit__(
|
|
481
512
|
self,
|
|
482
|
-
exc_type:
|
|
483
|
-
exc_val:
|
|
484
|
-
exc_tb:
|
|
513
|
+
exc_type: type[BaseException] | None,
|
|
514
|
+
exc_val: BaseException | None,
|
|
515
|
+
exc_tb: TracebackType | None,
|
|
485
516
|
) -> None:
|
|
486
517
|
"""Exit context manager by closing Connector"""
|
|
487
518
|
self.close()
|
|
@@ -492,9 +523,9 @@ class Connector:
|
|
|
492
523
|
|
|
493
524
|
async def __aexit__(
|
|
494
525
|
self,
|
|
495
|
-
exc_type:
|
|
496
|
-
exc_val:
|
|
497
|
-
exc_tb:
|
|
526
|
+
exc_type: type[BaseException] | None,
|
|
527
|
+
exc_val: BaseException | None,
|
|
528
|
+
exc_tb: TracebackType | None,
|
|
498
529
|
) -> None:
|
|
499
530
|
"""Exit async context manager by closing Connector"""
|
|
500
531
|
await self.close_async()
|
|
@@ -528,14 +559,14 @@ async def create_async_connector(
|
|
|
528
559
|
ip_type: str | IPTypes = IPTypes.PUBLIC,
|
|
529
560
|
enable_iam_auth: bool = False,
|
|
530
561
|
timeout: int = 30,
|
|
531
|
-
credentials:
|
|
532
|
-
loop:
|
|
533
|
-
quota_project:
|
|
534
|
-
sqladmin_api_endpoint:
|
|
535
|
-
user_agent:
|
|
536
|
-
universe_domain:
|
|
562
|
+
credentials: Credentials | None = None,
|
|
563
|
+
loop: asyncio.AbstractEventLoop | None = None,
|
|
564
|
+
quota_project: str | None = None,
|
|
565
|
+
sqladmin_api_endpoint: str | None = None,
|
|
566
|
+
user_agent: str | None = None,
|
|
567
|
+
universe_domain: str | None = None,
|
|
537
568
|
refresh_strategy: str | RefreshStrategy = RefreshStrategy.BACKGROUND,
|
|
538
|
-
resolver: type[DefaultResolver
|
|
569
|
+
resolver: type[DefaultResolver | DnsResolver] = DefaultResolver,
|
|
539
570
|
failover_period: int = 30,
|
|
540
571
|
) -> Connector:
|
|
541
572
|
"""Helper function to create Connector object for asyncio connections.
|
|
@@ -21,7 +21,6 @@ class ConnectorLoopError(Exception):
|
|
|
21
21
|
in an invalid state (event loop in current thread).
|
|
22
22
|
"""
|
|
23
23
|
|
|
24
|
-
pass
|
|
25
24
|
|
|
26
25
|
|
|
27
26
|
class TLSVersionError(Exception):
|
|
@@ -29,7 +28,6 @@ class TLSVersionError(Exception):
|
|
|
29
28
|
Raised when the required TLS protocol version is not supported.
|
|
30
29
|
"""
|
|
31
30
|
|
|
32
|
-
pass
|
|
33
31
|
|
|
34
32
|
|
|
35
33
|
class CloudSQLIPTypeError(Exception):
|
|
@@ -37,7 +35,6 @@ class CloudSQLIPTypeError(Exception):
|
|
|
37
35
|
Raised when IP address for the preferred IP type is not found.
|
|
38
36
|
"""
|
|
39
37
|
|
|
40
|
-
pass
|
|
41
38
|
|
|
42
39
|
|
|
43
40
|
class PlatformNotSupportedError(Exception):
|
|
@@ -45,7 +42,6 @@ class PlatformNotSupportedError(Exception):
|
|
|
45
42
|
Raised when a feature is not supported on the current platform.
|
|
46
43
|
"""
|
|
47
44
|
|
|
48
|
-
pass
|
|
49
45
|
|
|
50
46
|
|
|
51
47
|
class AutoIAMAuthNotSupported(Exception):
|
|
@@ -54,7 +50,6 @@ class AutoIAMAuthNotSupported(Exception):
|
|
|
54
50
|
supported with database engine version.
|
|
55
51
|
"""
|
|
56
52
|
|
|
57
|
-
pass
|
|
58
53
|
|
|
59
54
|
|
|
60
55
|
class RefreshNotValidError(Exception):
|
|
@@ -62,7 +57,6 @@ class RefreshNotValidError(Exception):
|
|
|
62
57
|
Exception to be raised when the task returned from refresh is not valid.
|
|
63
58
|
"""
|
|
64
59
|
|
|
65
|
-
pass
|
|
66
60
|
|
|
67
61
|
|
|
68
62
|
class IncompatibleDriverError(Exception):
|
|
@@ -12,12 +12,13 @@
|
|
|
12
12
|
# See the License for the specific language governing permissions and
|
|
13
13
|
# limitations under the License.
|
|
14
14
|
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
15
17
|
import asyncio
|
|
16
18
|
from datetime import datetime
|
|
17
19
|
from datetime import timedelta
|
|
18
20
|
from datetime import timezone
|
|
19
21
|
import logging
|
|
20
|
-
from typing import Optional
|
|
21
22
|
|
|
22
23
|
from google.cloud.sql.connector.client import CloudSQLClient
|
|
23
24
|
from google.cloud.sql.connector.connection_info import ConnectionInfo
|
|
@@ -61,7 +62,7 @@ class LazyRefreshCache(ConnectionInfoCache):
|
|
|
61
62
|
self._keys = keys
|
|
62
63
|
self._client = client
|
|
63
64
|
self._lock = asyncio.Lock()
|
|
64
|
-
self._cached:
|
|
65
|
+
self._cached: ConnectionInfo | None = None
|
|
65
66
|
self._needs_refresh = False
|
|
66
67
|
self._closed = False
|
|
67
68
|
|
|
@@ -112,7 +113,7 @@ class LazyRefreshCache(ConnectionInfoCache):
|
|
|
112
113
|
except Exception as e:
|
|
113
114
|
logger.debug(
|
|
114
115
|
f"['{self._conn_name}']: Connection info "
|
|
115
|
-
f"refresh operation failed: {
|
|
116
|
+
f"refresh operation failed: {e!s}"
|
|
116
117
|
)
|
|
117
118
|
raise
|
|
118
119
|
logger.debug(
|
|
@@ -121,7 +122,7 @@ class LazyRefreshCache(ConnectionInfoCache):
|
|
|
121
122
|
)
|
|
122
123
|
logger.debug(
|
|
123
124
|
f"['{self._conn_name}']: Current certificate "
|
|
124
|
-
f"expiration = {
|
|
125
|
+
f"expiration = {conn_info.expiration!s}"
|
|
125
126
|
)
|
|
126
127
|
self._cached = conn_info
|
|
127
128
|
self._needs_refresh = False
|
|
@@ -132,4 +133,3 @@ class LazyRefreshCache(ConnectionInfoCache):
|
|
|
132
133
|
other cache types.
|
|
133
134
|
"""
|
|
134
135
|
self._closed = True
|
|
135
|
-
return
|
|
@@ -12,10 +12,12 @@
|
|
|
12
12
|
# See the License for the specific language governing permissions and
|
|
13
13
|
# limitations under the License.
|
|
14
14
|
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
15
17
|
import asyncio
|
|
16
18
|
import logging
|
|
17
19
|
import ssl
|
|
18
|
-
from typing import Any, Callable
|
|
20
|
+
from typing import Any, Callable
|
|
19
21
|
|
|
20
22
|
from google.cloud.sql.connector.connection_info import ConnectionInfo
|
|
21
23
|
from google.cloud.sql.connector.connection_info import ConnectionInfoCache
|
|
@@ -31,13 +33,13 @@ logger = logging.getLogger(name=__name__)
|
|
|
31
33
|
class MonitoredCache(ConnectionInfoCache):
|
|
32
34
|
def __init__(
|
|
33
35
|
self,
|
|
34
|
-
cache:
|
|
36
|
+
cache: RefreshAheadCache | LazyRefreshCache,
|
|
35
37
|
failover_period: int,
|
|
36
|
-
resolver:
|
|
38
|
+
resolver: DefaultResolver | DnsResolver,
|
|
37
39
|
) -> None:
|
|
38
40
|
self.resolver = resolver
|
|
39
41
|
self.cache = cache
|
|
40
|
-
self.domain_name_ticker:
|
|
42
|
+
self.domain_name_ticker: asyncio.Task | None = None
|
|
41
43
|
self.sockets: list[ssl.SSLSocket] = []
|
|
42
44
|
|
|
43
45
|
# If domain name is configured for instance and failover period is set,
|
|
@@ -87,7 +89,7 @@ class MonitoredCache(ConnectionInfoCache):
|
|
|
87
89
|
)
|
|
88
90
|
await self.close()
|
|
89
91
|
|
|
90
|
-
except Exception as e:
|
|
92
|
+
except Exception as e: # noqa: BLE001
|
|
91
93
|
# Domain name checks should not be fatal, log error and continue.
|
|
92
94
|
logger.debug(
|
|
93
95
|
f"['{self.cache.conn_name}']: Unable to check domain name, "
|
|
@@ -47,7 +47,7 @@ def connect(
|
|
|
47
47
|
)
|
|
48
48
|
|
|
49
49
|
# allow automatic IAM database authentication to not require password
|
|
50
|
-
kwargs["password"] = kwargs
|
|
50
|
+
kwargs["password"] = kwargs.get("password", None)
|
|
51
51
|
|
|
52
52
|
# pop timeout as timeout arg is called 'connect_timeout' for pymysql
|
|
53
53
|
timeout = kwargs.pop("timeout")
|
|
@@ -13,12 +13,12 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
|
13
13
|
See the License for the specific language governing permissions and
|
|
14
14
|
limitations under the License.
|
|
15
15
|
"""
|
|
16
|
+
from __future__ import annotations
|
|
16
17
|
|
|
17
18
|
import asyncio
|
|
18
|
-
from typing import Optional
|
|
19
19
|
|
|
20
20
|
|
|
21
|
-
class AsyncRateLimiter
|
|
21
|
+
class AsyncRateLimiter:
|
|
22
22
|
"""An asyncio-compatible rate limiter which uses the Token Bucket algorithm
|
|
23
23
|
(https://en.wikipedia.org/wiki/Token_bucket) to limit the number
|
|
24
24
|
of function calls over a time interval using an event queue.
|
|
@@ -35,7 +35,7 @@ class AsyncRateLimiter(object):
|
|
|
35
35
|
self,
|
|
36
36
|
max_capacity: int = 1,
|
|
37
37
|
rate: float = 1 / 60,
|
|
38
|
-
loop:
|
|
38
|
+
loop: asyncio.AbstractEventLoop | None = None,
|
|
39
39
|
) -> None:
|
|
40
40
|
self.rate = rate
|
|
41
41
|
self.max_capacity = max_capacity
|
|
@@ -72,7 +72,7 @@ async def _is_valid(task: asyncio.Task) -> bool:
|
|
|
72
72
|
# only valid if now is before the cert expires
|
|
73
73
|
if datetime.datetime.now(datetime.timezone.utc) < metadata.expiration:
|
|
74
74
|
return True
|
|
75
|
-
except Exception:
|
|
75
|
+
except Exception: # noqa: BLE001
|
|
76
76
|
# supress any errors from task
|
|
77
77
|
logger.debug("Current instance metadata is invalid.")
|
|
78
78
|
return False
|
|
@@ -80,7 +80,7 @@ async def _is_valid(task: asyncio.Task) -> bool:
|
|
|
80
80
|
|
|
81
81
|
def _downscope_credentials(
|
|
82
82
|
credentials: Credentials,
|
|
83
|
-
scopes: list[str] =
|
|
83
|
+
scopes: list[str] | None = None,
|
|
84
84
|
) -> Credentials:
|
|
85
85
|
"""Generate a down-scoped credential.
|
|
86
86
|
|
|
@@ -95,6 +95,8 @@ def _downscope_credentials(
|
|
|
95
95
|
"""
|
|
96
96
|
# credentials sourced from a service account or metadata are children of
|
|
97
97
|
# Scoped class and are capable of being re-scoped
|
|
98
|
+
if scopes is None:
|
|
99
|
+
scopes = ["https://www.googleapis.com/auth/sqlservice.login"]
|
|
98
100
|
if isinstance(credentials, Scoped):
|
|
99
101
|
scoped_creds = credentials.with_scopes(scopes=scopes)
|
|
100
102
|
# authenticated user credentials can not be re-scoped
|
|
@@ -0,0 +1,160 @@
|
|
|
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
|
+
import re
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import dns.asyncresolver
|
|
21
|
+
|
|
22
|
+
from google.cloud.sql.connector.connection_name import _is_valid_domain
|
|
23
|
+
from google.cloud.sql.connector.connection_name import _parse_connection_name
|
|
24
|
+
from google.cloud.sql.connector.connection_name import (
|
|
25
|
+
_parse_connection_name_with_domain_name,
|
|
26
|
+
)
|
|
27
|
+
from google.cloud.sql.connector.connection_name import ConnectionName
|
|
28
|
+
from google.cloud.sql.connector.exceptions import DnsResolutionError
|
|
29
|
+
|
|
30
|
+
PSC_DNS_PATTERN = re.compile(
|
|
31
|
+
r"^([a-f0-9]{12})\.([^.]+)\.([a-z0-9]+-[a-z0-9]+)\.(sql|sql-psa|sql-psc)\.goog\.?$"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class DefaultResolver:
|
|
36
|
+
"""DefaultResolver simply validates and parses instance connection name."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, *args: Any, client: Any | None = None, **kwargs: Any) -> None:
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
async def resolve(self, connection_name: str) -> ConnectionName:
|
|
42
|
+
return _parse_connection_name(connection_name)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class DnsResolver(dns.asyncresolver.Resolver):
|
|
46
|
+
"""
|
|
47
|
+
DnsResolver resolves domain names into instance connection names using
|
|
48
|
+
TXT records in DNS.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, *args: Any, client: Any | None = None, **kwargs: Any) -> None:
|
|
52
|
+
super().__init__(*args, **kwargs)
|
|
53
|
+
self._client = client
|
|
54
|
+
|
|
55
|
+
async def resolve(self, dns: str) -> ConnectionName: # type: ignore
|
|
56
|
+
current = dns
|
|
57
|
+
visited = {current}
|
|
58
|
+
|
|
59
|
+
# Max 10 iterations to prevent infinite CNAME loops
|
|
60
|
+
for _ in range(10):
|
|
61
|
+
try:
|
|
62
|
+
domain_val = dns if current != dns else ""
|
|
63
|
+
conn_name = _parse_connection_name_with_domain_name(
|
|
64
|
+
current, domain_val
|
|
65
|
+
)
|
|
66
|
+
return conn_name
|
|
67
|
+
except ValueError:
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
dns_normalized = current.rstrip(".")
|
|
71
|
+
match = PSC_DNS_PATTERN.match(dns_normalized.lower())
|
|
72
|
+
if match:
|
|
73
|
+
region = match.group(3)
|
|
74
|
+
if region != "global":
|
|
75
|
+
if self._client is None:
|
|
76
|
+
raise ValueError(
|
|
77
|
+
"SQLAdmin client is not configured in the resolver."
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
dns_name_with_dot = dns_normalized + "."
|
|
81
|
+
resp = await self._client.resolve_connect_settings(
|
|
82
|
+
dns_name_with_dot, region
|
|
83
|
+
)
|
|
84
|
+
resolved_conn_name = resp["connectionName"]
|
|
85
|
+
return _parse_connection_name_with_domain_name(
|
|
86
|
+
resolved_conn_name, dns
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
if not _is_valid_domain(current):
|
|
90
|
+
raise ValueError(
|
|
91
|
+
"Arg `instance_connection_string` must have "
|
|
92
|
+
"format: PROJECT:REGION:INSTANCE or be a valid DNS domain "
|
|
93
|
+
f"name, got {dns}."
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
cname_found = False
|
|
97
|
+
try:
|
|
98
|
+
cname = await self.resolve_cname(current)
|
|
99
|
+
cname_found = True
|
|
100
|
+
except DnsResolutionError:
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
if cname_found:
|
|
104
|
+
if cname in visited:
|
|
105
|
+
raise DnsResolutionError(f"CNAME loop detected for `{dns}`")
|
|
106
|
+
visited.add(cname)
|
|
107
|
+
current = cname
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
rdata = await self.resolve_txt(current)
|
|
112
|
+
except DnsResolutionError as e:
|
|
113
|
+
raise DnsResolutionError(
|
|
114
|
+
f"Unable to resolve TXT record for `{dns}`"
|
|
115
|
+
) from e
|
|
116
|
+
|
|
117
|
+
rdata.sort()
|
|
118
|
+
for record in rdata:
|
|
119
|
+
try:
|
|
120
|
+
conn_name = _parse_connection_name_with_domain_name(
|
|
121
|
+
record, dns
|
|
122
|
+
)
|
|
123
|
+
return conn_name
|
|
124
|
+
except Exception: # noqa: BLE001, S112
|
|
125
|
+
continue
|
|
126
|
+
|
|
127
|
+
raise DnsResolutionError(
|
|
128
|
+
f"Unable to parse TXT record for `{current}` -> `{rdata[0]}`"
|
|
129
|
+
if rdata
|
|
130
|
+
else f"Unable to resolve TXT record for `{current}`"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
raise DnsResolutionError(
|
|
134
|
+
f"CNAME loop detected or max resolution depth reached for `{dns}`"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
async def resolve_cname(self, dns: str) -> str:
|
|
138
|
+
try:
|
|
139
|
+
answers = await super().resolve(dns, "CNAME", raise_on_no_answer=True)
|
|
140
|
+
return str(answers[0].target).rstrip(".")
|
|
141
|
+
except Exception as e:
|
|
142
|
+
raise DnsResolutionError(
|
|
143
|
+
f"Unable to resolve CNAME record for `{dns}`"
|
|
144
|
+
) from e
|
|
145
|
+
|
|
146
|
+
async def resolve_txt(self, dns: str) -> list[str]:
|
|
147
|
+
try:
|
|
148
|
+
answers = await super().resolve(dns, "TXT", raise_on_no_answer=True)
|
|
149
|
+
return [record.to_text().strip('"') for record in answers]
|
|
150
|
+
except Exception as e:
|
|
151
|
+
raise DnsResolutionError(
|
|
152
|
+
f"Unable to resolve TXT record for `{dns}`"
|
|
153
|
+
) from e
|
|
154
|
+
|
|
155
|
+
async def resolve_a_record(self, dns: str) -> list[str]:
|
|
156
|
+
try:
|
|
157
|
+
records = await super().resolve(dns, "A", raise_on_no_answer=True)
|
|
158
|
+
return [record.to_text() for record in records]
|
|
159
|
+
except Exception: # noqa: BLE001
|
|
160
|
+
return []
|
|
@@ -14,7 +14,9 @@ See the License for the specific language governing permissions and
|
|
|
14
14
|
limitations under the License.
|
|
15
15
|
"""
|
|
16
16
|
|
|
17
|
-
import
|
|
17
|
+
import asyncio
|
|
18
|
+
import tempfile
|
|
19
|
+
|
|
18
20
|
from cryptography.hazmat.backends import default_backend
|
|
19
21
|
from cryptography.hazmat.primitives import serialization
|
|
20
22
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
@@ -57,6 +59,33 @@ async def generate_keys() -> tuple[bytes, str]:
|
|
|
57
59
|
return priv_key, pub_key
|
|
58
60
|
|
|
59
61
|
|
|
62
|
+
class AsyncTemporaryDirectory:
|
|
63
|
+
"""Async context manager wrapper around tempfile.TemporaryDirectory."""
|
|
64
|
+
|
|
65
|
+
async def __aenter__(self) -> str:
|
|
66
|
+
self.temp_dir = await asyncio.to_thread(tempfile.TemporaryDirectory)
|
|
67
|
+
return self.temp_dir.name
|
|
68
|
+
|
|
69
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
70
|
+
await asyncio.to_thread(self.temp_dir.cleanup)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _write_files(
|
|
74
|
+
ca_filename: str,
|
|
75
|
+
serverCaCert: str,
|
|
76
|
+
cert_filename: str,
|
|
77
|
+
ephemeralCert: str,
|
|
78
|
+
key_filename: str,
|
|
79
|
+
priv_key: bytes,
|
|
80
|
+
) -> None:
|
|
81
|
+
with open(ca_filename, "w+") as ca_out:
|
|
82
|
+
ca_out.write(serverCaCert)
|
|
83
|
+
with open(cert_filename, "w+") as ephemeral_out:
|
|
84
|
+
ephemeral_out.write(ephemeralCert)
|
|
85
|
+
with open(key_filename, "wb") as priv_out:
|
|
86
|
+
priv_out.write(priv_key)
|
|
87
|
+
|
|
88
|
+
|
|
60
89
|
async def write_to_file(
|
|
61
90
|
dir_path: str, serverCaCert: str, ephemeralCert: str, priv_key: bytes
|
|
62
91
|
) -> tuple[str, str, str]:
|
|
@@ -68,12 +97,15 @@ async def write_to_file(
|
|
|
68
97
|
cert_filename = f"{dir_path}/cert.pem"
|
|
69
98
|
key_filename = f"{dir_path}/priv.pem"
|
|
70
99
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
100
|
+
await asyncio.to_thread(
|
|
101
|
+
_write_files,
|
|
102
|
+
ca_filename,
|
|
103
|
+
serverCaCert,
|
|
104
|
+
cert_filename,
|
|
105
|
+
ephemeralCert,
|
|
106
|
+
key_filename,
|
|
107
|
+
priv_key,
|
|
108
|
+
)
|
|
77
109
|
|
|
78
110
|
return (ca_filename, cert_filename, key_filename)
|
|
79
111
|
|
|
@@ -91,7 +123,7 @@ def format_database_user(database_version: str, user: str) -> str:
|
|
|
91
123
|
# remove suffix for Postgres service accounts
|
|
92
124
|
if database_version.startswith("POSTGRES"):
|
|
93
125
|
suffix = ".gserviceaccount.com"
|
|
94
|
-
user = user
|
|
126
|
+
user = user.removesuffix(suffix)
|
|
95
127
|
return user
|
|
96
128
|
|
|
97
129
|
# remove everything after and including the @ for MySQL
|
|
@@ -41,12 +41,14 @@ classifiers = [
|
|
|
41
41
|
"Operating System :: OS Independent",
|
|
42
42
|
]
|
|
43
43
|
dependencies = [
|
|
44
|
-
"aiofiles",
|
|
45
44
|
"aiohttp",
|
|
46
45
|
"cryptography>=42.0.0",
|
|
47
46
|
"dnspython>=2.0.0",
|
|
48
47
|
"Requests",
|
|
49
48
|
"google-auth>=2.28.0",
|
|
49
|
+
"grpcio",
|
|
50
|
+
"protobuf",
|
|
51
|
+
"googleapis-common-protos",
|
|
50
52
|
]
|
|
51
53
|
dynamic = ["version"]
|
|
52
54
|
|
|
@@ -1,91 +0,0 @@
|
|
|
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 typing import List
|
|
16
|
-
|
|
17
|
-
import dns.asyncresolver
|
|
18
|
-
|
|
19
|
-
from google.cloud.sql.connector.connection_name import _is_valid_domain
|
|
20
|
-
from google.cloud.sql.connector.connection_name import _parse_connection_name
|
|
21
|
-
from google.cloud.sql.connector.connection_name import (
|
|
22
|
-
_parse_connection_name_with_domain_name,
|
|
23
|
-
)
|
|
24
|
-
from google.cloud.sql.connector.connection_name import ConnectionName
|
|
25
|
-
from google.cloud.sql.connector.exceptions import DnsResolutionError
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
class DefaultResolver:
|
|
29
|
-
"""DefaultResolver simply validates and parses instance connection name."""
|
|
30
|
-
|
|
31
|
-
async def resolve(self, connection_name: str) -> ConnectionName:
|
|
32
|
-
return _parse_connection_name(connection_name)
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
class DnsResolver(dns.asyncresolver.Resolver):
|
|
36
|
-
"""
|
|
37
|
-
DnsResolver resolves domain names into instance connection names using
|
|
38
|
-
TXT records in DNS.
|
|
39
|
-
"""
|
|
40
|
-
|
|
41
|
-
async def resolve(self, dns: str) -> ConnectionName: # type: ignore
|
|
42
|
-
try:
|
|
43
|
-
conn_name = _parse_connection_name(dns)
|
|
44
|
-
except ValueError:
|
|
45
|
-
# The connection name was not project:region:instance format.
|
|
46
|
-
# Check if connection name is a valid DNS domain name
|
|
47
|
-
if _is_valid_domain(dns):
|
|
48
|
-
# Attempt to query a TXT record to get connection name.
|
|
49
|
-
conn_name = await self.query_dns(dns)
|
|
50
|
-
else:
|
|
51
|
-
raise ValueError(
|
|
52
|
-
"Arg `instance_connection_string` must have "
|
|
53
|
-
"format: PROJECT:REGION:INSTANCE or be a valid DNS domain "
|
|
54
|
-
f"name, got {dns}."
|
|
55
|
-
)
|
|
56
|
-
return conn_name
|
|
57
|
-
|
|
58
|
-
async def resolve_a_record(self, dns: str) -> List[str]:
|
|
59
|
-
try:
|
|
60
|
-
# Attempt to query the A records.
|
|
61
|
-
records = await super().resolve(dns, "A", raise_on_no_answer=True)
|
|
62
|
-
# return IP addresses as strings
|
|
63
|
-
return [record.to_text() for record in records]
|
|
64
|
-
except Exception:
|
|
65
|
-
# On any error, return empty list
|
|
66
|
-
return []
|
|
67
|
-
|
|
68
|
-
async def query_dns(self, dns: str) -> ConnectionName:
|
|
69
|
-
try:
|
|
70
|
-
# Attempt to query the TXT records.
|
|
71
|
-
records = await super().resolve(dns, "TXT", raise_on_no_answer=True)
|
|
72
|
-
# Sort the TXT record values alphabetically, strip quotes as record
|
|
73
|
-
# values can be returned as raw strings
|
|
74
|
-
rdata = [record.to_text().strip('"') for record in records]
|
|
75
|
-
rdata.sort()
|
|
76
|
-
# Attempt to parse records, returning the first valid record.
|
|
77
|
-
for record in rdata:
|
|
78
|
-
try:
|
|
79
|
-
conn_name = _parse_connection_name_with_domain_name(record, dns)
|
|
80
|
-
return conn_name
|
|
81
|
-
except Exception:
|
|
82
|
-
continue
|
|
83
|
-
# If all records failed to parse, throw error
|
|
84
|
-
raise DnsResolutionError(
|
|
85
|
-
f"Unable to parse TXT record for `{dns}` -> `{rdata[0]}`"
|
|
86
|
-
)
|
|
87
|
-
# Don't override above DnsResolutionError
|
|
88
|
-
except DnsResolutionError:
|
|
89
|
-
raise
|
|
90
|
-
except Exception as e:
|
|
91
|
-
raise DnsResolutionError(f"Unable to resolve TXT record for `{dns}`") from e
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -23,11 +23,11 @@ from google.cloud.sql.connector.resolver import DnsResolver
|
|
|
23
23
|
from google.cloud.sql.connector.version import __version__
|
|
24
24
|
|
|
25
25
|
__all__ = [
|
|
26
|
-
"__version__",
|
|
27
|
-
"create_async_connector",
|
|
28
26
|
"Connector",
|
|
29
27
|
"DefaultResolver",
|
|
30
28
|
"DnsResolver",
|
|
31
29
|
"IPTypes",
|
|
32
30
|
"RefreshStrategy",
|
|
31
|
+
"__version__",
|
|
32
|
+
"create_async_connector",
|
|
33
33
|
]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|