cloud-sql-python-connector 1.17.0.dev0__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.17.0.dev0.dist-info/LICENSE +202 -0
- cloud_sql_python_connector-1.17.0.dev0.dist-info/METADATA +838 -0
- cloud_sql_python_connector-1.17.0.dev0.dist-info/RECORD +24 -0
- cloud_sql_python_connector-1.17.0.dev0.dist-info/WHEEL +5 -0
- cloud_sql_python_connector-1.17.0.dev0.dist-info/top_level.txt +1 -0
- google/cloud/sql/connector/__init__.py +33 -0
- google/cloud/sql/connector/asyncpg.py +65 -0
- google/cloud/sql/connector/client.py +324 -0
- google/cloud/sql/connector/connection_info.py +108 -0
- google/cloud/sql/connector/connection_name.py +61 -0
- google/cloud/sql/connector/connector.py +528 -0
- google/cloud/sql/connector/enums.py +86 -0
- google/cloud/sql/connector/exceptions.py +79 -0
- google/cloud/sql/connector/instance.py +214 -0
- google/cloud/sql/connector/lazy.py +124 -0
- google/cloud/sql/connector/pg8000.py +66 -0
- google/cloud/sql/connector/py.typed +0 -0
- google/cloud/sql/connector/pymysql.py +64 -0
- google/cloud/sql/connector/pytds.py +78 -0
- google/cloud/sql/connector/rate_limiter.py +87 -0
- google/cloud/sql/connector/refresh_utils.py +154 -0
- google/cloud/sql/connector/resolver.py +70 -0
- google/cloud/sql/connector/utils.py +103 -0
- google/cloud/sql/connector/version.py +15 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
google/cloud/sql/connector/__init__.py,sha256=kSGB2q6hQwkFLD1eI7RjY4BvPLvg2FD3wfPBmbnsjDE,1149
|
|
2
|
+
google/cloud/sql/connector/asyncpg.py,sha256=HUOB0rRhr2-zMbygDtdWTDNlYqEPkN0ADqCIpM3waDc,1839
|
|
3
|
+
google/cloud/sql/connector/client.py,sha256=ocUN1nrgZyJRnxgh6Ym3w0c_qKb8hqKCmR1OiJ2KUpM,12622
|
|
4
|
+
google/cloud/sql/connector/connection_info.py,sha256=cFNOeHJ0lQODf7q5Hc25LXsNWzWgzYcFMrBuNdZ1y5k,4430
|
|
5
|
+
google/cloud/sql/connector/connection_name.py,sha256=4F5XhgmSWGEeF-3TJk2E61YPbxMAecPGw56nPTZmQ2o,2067
|
|
6
|
+
google/cloud/sql/connector/connector.py,sha256=emahfH9ZSrbooFBV-m6gcwGJ7yYQwaUJuCJPz0DHfHA,22547
|
|
7
|
+
google/cloud/sql/connector/enums.py,sha256=dVBS9eEWNrHtzze0AJlmtbdUogG3EocNWOQcI_8qPd0,2937
|
|
8
|
+
google/cloud/sql/connector/exceptions.py,sha256=eyflYggkKZx3t944FTPqQrX-R75lZgtpOwC4uVLPhG0,1823
|
|
9
|
+
google/cloud/sql/connector/instance.py,sha256=nRzhE7t_M6AhjiYvEZHOjkBHx3d9wGRCC6RekgDApFI,8379
|
|
10
|
+
google/cloud/sql/connector/lazy.py,sha256=p1wLte49xE_aHiY5shjMbxwytgW-ycS0Hd67C34gaDY,4631
|
|
11
|
+
google/cloud/sql/connector/pg8000.py,sha256=gZisjfXP4rNIjyxOQS75fvr0Xe6eLrFo4mIhVjoMLTw,1815
|
|
12
|
+
google/cloud/sql/connector/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
|
+
google/cloud/sql/connector/pymysql.py,sha256=82DwSJUhZHH2AW-vO7qhkljyShwj6Kmd17WCbOGZH74,2106
|
|
14
|
+
google/cloud/sql/connector/pytds.py,sha256=h-1pDhqLDkhviKBBOJ29gnIeYHKWw7Dtj91VLu1iNUI,2453
|
|
15
|
+
google/cloud/sql/connector/rate_limiter.py,sha256=bPTupfM4f5QRSCmCAWMqjQylEB7bUM-50plVBU3W2CA,2993
|
|
16
|
+
google/cloud/sql/connector/refresh_utils.py,sha256=HfuuLdlqg2IxusB4T3BMj-BJlGsXtHsrCX_Hp-eJ_j0,5063
|
|
17
|
+
google/cloud/sql/connector/resolver.py,sha256=cNDJMhmS_0CIAmz8kCABKHJNLNm1wiwIVoPEN-gWzzY,2850
|
|
18
|
+
google/cloud/sql/connector/utils.py,sha256=7g1fX-JsZc7xOgTuxrAiroFHQIQk_BEJZNYaKOinwQY,3508
|
|
19
|
+
google/cloud/sql/connector/version.py,sha256=YfNpSkpcPhRwrFt2dV7ut5xGe0KjgvNinweha483X2w,603
|
|
20
|
+
cloud_sql_python_connector-1.17.0.dev0.dist-info/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
|
|
21
|
+
cloud_sql_python_connector-1.17.0.dev0.dist-info/METADATA,sha256=qlls3nyZF0ZxHsLxEvorF9ukUEyY5pQpWUhvbQ0H2tk,30494
|
|
22
|
+
cloud_sql_python_connector-1.17.0.dev0.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
|
|
23
|
+
cloud_sql_python_connector-1.17.0.dev0.dist-info/top_level.txt,sha256=_1QvSJIhFAGfxb79D6DhB7SUw2X6T4rwnz_LLrbcD3c,7
|
|
24
|
+
cloud_sql_python_connector-1.17.0.dev0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
google
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
""""
|
|
2
|
+
Copyright 2019 Google LLC
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
https://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from google.cloud.sql.connector.connector import Connector
|
|
18
|
+
from google.cloud.sql.connector.connector import create_async_connector
|
|
19
|
+
from google.cloud.sql.connector.enums import IPTypes
|
|
20
|
+
from google.cloud.sql.connector.enums import RefreshStrategy
|
|
21
|
+
from google.cloud.sql.connector.resolver import DefaultResolver
|
|
22
|
+
from google.cloud.sql.connector.resolver import DnsResolver
|
|
23
|
+
from google.cloud.sql.connector.version import __version__
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"__version__",
|
|
27
|
+
"create_async_connector",
|
|
28
|
+
"Connector",
|
|
29
|
+
"DefaultResolver",
|
|
30
|
+
"DnsResolver",
|
|
31
|
+
"IPTypes",
|
|
32
|
+
"RefreshStrategy",
|
|
33
|
+
]
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright 2022 Google LLC
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
https://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import ssl
|
|
18
|
+
from typing import Any, TYPE_CHECKING
|
|
19
|
+
|
|
20
|
+
SERVER_PROXY_PORT = 3307
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
import asyncpg
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def connect(
|
|
27
|
+
ip_address: str, ctx: ssl.SSLContext, **kwargs: Any
|
|
28
|
+
) -> "asyncpg.Connection":
|
|
29
|
+
"""Helper function to create an asyncpg DB-API connection object.
|
|
30
|
+
|
|
31
|
+
:type ip_address: str
|
|
32
|
+
:param ip_address: A string containing an IP address for the Cloud SQL
|
|
33
|
+
instance.
|
|
34
|
+
|
|
35
|
+
:type ctx: ssl.SSLContext
|
|
36
|
+
:param ctx: An SSLContext object created from the Cloud SQL server CA
|
|
37
|
+
cert and ephemeral cert.
|
|
38
|
+
|
|
39
|
+
:type kwargs: Any
|
|
40
|
+
:param kwargs: Keyword arguments for establishing asyncpg connection
|
|
41
|
+
object to Cloud SQL instance.
|
|
42
|
+
|
|
43
|
+
:rtype: asyncpg.Connection
|
|
44
|
+
:returns: An asyncpg.Connection object to a Cloud SQL instance.
|
|
45
|
+
"""
|
|
46
|
+
try:
|
|
47
|
+
import asyncpg
|
|
48
|
+
except ImportError:
|
|
49
|
+
raise ImportError(
|
|
50
|
+
'Unable to import module "asyncpg." Please install and try again.'
|
|
51
|
+
)
|
|
52
|
+
user = kwargs.pop("user")
|
|
53
|
+
db = kwargs.pop("db")
|
|
54
|
+
passwd = kwargs.pop("password", None)
|
|
55
|
+
|
|
56
|
+
return await asyncpg.connect(
|
|
57
|
+
user=user,
|
|
58
|
+
database=db,
|
|
59
|
+
password=passwd,
|
|
60
|
+
host=ip_address,
|
|
61
|
+
port=SERVER_PROXY_PORT,
|
|
62
|
+
ssl=ctx,
|
|
63
|
+
direct_tls=True,
|
|
64
|
+
**kwargs,
|
|
65
|
+
)
|
|
@@ -0,0 +1,324 @@
|
|
|
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 asyncio
|
|
18
|
+
import datetime
|
|
19
|
+
import logging
|
|
20
|
+
from typing import Any, Optional, TYPE_CHECKING
|
|
21
|
+
|
|
22
|
+
import aiohttp
|
|
23
|
+
from cryptography.hazmat.backends import default_backend
|
|
24
|
+
from cryptography.x509 import load_pem_x509_certificate
|
|
25
|
+
from google.auth.credentials import TokenState
|
|
26
|
+
from google.auth.transport import requests
|
|
27
|
+
|
|
28
|
+
from google.cloud.sql.connector.connection_info import ConnectionInfo
|
|
29
|
+
from google.cloud.sql.connector.connection_name import ConnectionName
|
|
30
|
+
from google.cloud.sql.connector.exceptions import AutoIAMAuthNotSupported
|
|
31
|
+
from google.cloud.sql.connector.refresh_utils import _downscope_credentials
|
|
32
|
+
from google.cloud.sql.connector.refresh_utils import retry_50x
|
|
33
|
+
from google.cloud.sql.connector.version import __version__ as version
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from google.auth.credentials import Credentials
|
|
37
|
+
|
|
38
|
+
USER_AGENT: str = f"cloud-sql-python-connector/{version}"
|
|
39
|
+
API_VERSION: str = "v1beta4"
|
|
40
|
+
DEFAULT_SERVICE_ENDPOINT: str = "https://sqladmin.googleapis.com"
|
|
41
|
+
|
|
42
|
+
logger = logging.getLogger(name=__name__)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _format_user_agent(driver: Optional[str], custom: Optional[str]) -> str:
|
|
46
|
+
agent = f"{USER_AGENT}+{driver}" if driver else USER_AGENT
|
|
47
|
+
if custom and isinstance(custom, str):
|
|
48
|
+
agent = f"{agent} {custom}"
|
|
49
|
+
return agent
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class CloudSQLClient:
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
sqladmin_api_endpoint: Optional[str],
|
|
56
|
+
quota_project: Optional[str],
|
|
57
|
+
credentials: Credentials,
|
|
58
|
+
client: Optional[aiohttp.ClientSession] = None,
|
|
59
|
+
driver: Optional[str] = None,
|
|
60
|
+
user_agent: Optional[str] = None,
|
|
61
|
+
) -> None:
|
|
62
|
+
"""
|
|
63
|
+
Establish the client to be used for Cloud SQL Admin API requests.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
sqladmin_api_endpoint (str): Base URL to use when calling
|
|
67
|
+
the Cloud SQL Admin API endpoints.
|
|
68
|
+
quota_project (str): The Project ID for an existing Google Cloud
|
|
69
|
+
project. The project specified is used for quota and
|
|
70
|
+
billing purposes.
|
|
71
|
+
credentials (google.auth.credentials.Credentials):
|
|
72
|
+
A credentials object created from the google-auth Python library.
|
|
73
|
+
Must have the Cloud SQL Admin scopes. For more info check out
|
|
74
|
+
https://google-auth.readthedocs.io/en/latest/.
|
|
75
|
+
client (aiohttp.ClientSession): Async client used to make requests to
|
|
76
|
+
Cloud SQL Admin APIs.
|
|
77
|
+
Optional, defaults to None and creates new client.
|
|
78
|
+
driver (str): Database driver to be used by the client.
|
|
79
|
+
"""
|
|
80
|
+
user_agent = _format_user_agent(driver, user_agent)
|
|
81
|
+
headers = {
|
|
82
|
+
"x-goog-api-client": user_agent,
|
|
83
|
+
"User-Agent": user_agent,
|
|
84
|
+
"Content-Type": "application/json",
|
|
85
|
+
}
|
|
86
|
+
if quota_project:
|
|
87
|
+
headers["x-goog-user-project"] = quota_project
|
|
88
|
+
|
|
89
|
+
self._client = client if client else aiohttp.ClientSession(headers=headers)
|
|
90
|
+
self._credentials = credentials
|
|
91
|
+
if sqladmin_api_endpoint is None:
|
|
92
|
+
self._sqladmin_api_endpoint = DEFAULT_SERVICE_ENDPOINT
|
|
93
|
+
else:
|
|
94
|
+
self._sqladmin_api_endpoint = sqladmin_api_endpoint
|
|
95
|
+
self._user_agent = user_agent
|
|
96
|
+
|
|
97
|
+
async def _get_metadata(
|
|
98
|
+
self,
|
|
99
|
+
project: str,
|
|
100
|
+
region: str,
|
|
101
|
+
instance: str,
|
|
102
|
+
) -> dict[str, Any]:
|
|
103
|
+
"""Requests metadata from the Cloud SQL Instance
|
|
104
|
+
and returns a dictionary containing the IP addresses and certificate
|
|
105
|
+
authority of the Cloud SQL Instance.
|
|
106
|
+
|
|
107
|
+
:type project: str
|
|
108
|
+
:param project:
|
|
109
|
+
A string representing the name of the project.
|
|
110
|
+
|
|
111
|
+
:type region: str
|
|
112
|
+
:param region : A string representing the name of the region.
|
|
113
|
+
|
|
114
|
+
:type instance: str
|
|
115
|
+
:param instance: A string representing the name of the instance.
|
|
116
|
+
|
|
117
|
+
:rtype: dict[str: Union[dict, str]]
|
|
118
|
+
:returns: Returns a dictionary containing a dictionary of all IP
|
|
119
|
+
addresses and their type and a string representing the
|
|
120
|
+
certificate authority.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
headers = {
|
|
124
|
+
"Authorization": f"Bearer {self._credentials.token}",
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
url = f"{self._sqladmin_api_endpoint}/sql/{API_VERSION}/projects/{project}/instances/{instance}/connectSettings"
|
|
128
|
+
|
|
129
|
+
resp = await self._client.get(url, headers=headers)
|
|
130
|
+
if resp.status >= 500:
|
|
131
|
+
resp = await retry_50x(self._client.get, url, headers=headers)
|
|
132
|
+
# try to get response json for better error message
|
|
133
|
+
try:
|
|
134
|
+
ret_dict = await resp.json()
|
|
135
|
+
if resp.status >= 400:
|
|
136
|
+
# if detailed error message is in json response, use as error message
|
|
137
|
+
message = ret_dict.get("error", {}).get("message")
|
|
138
|
+
if message:
|
|
139
|
+
resp.reason = message
|
|
140
|
+
# skip, raise_for_status will catch all errors in finally block
|
|
141
|
+
except Exception:
|
|
142
|
+
pass
|
|
143
|
+
finally:
|
|
144
|
+
resp.raise_for_status()
|
|
145
|
+
|
|
146
|
+
if ret_dict["region"] != region:
|
|
147
|
+
raise ValueError(
|
|
148
|
+
f'[{project}:{region}:{instance}]: Provided region was mismatched - got region {region}, expected {ret_dict["region"]}.'
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
ip_addresses = (
|
|
152
|
+
{ip["type"]: ip["ipAddress"] for ip in ret_dict["ipAddresses"]}
|
|
153
|
+
if "ipAddresses" in ret_dict
|
|
154
|
+
else {}
|
|
155
|
+
)
|
|
156
|
+
# resolve dnsName into IP address for PSC
|
|
157
|
+
# Note that we have to check for PSC enablement also because CAS
|
|
158
|
+
# instances also set the dnsName field.
|
|
159
|
+
# Remove trailing period from DNS name. Required for SSL in Python
|
|
160
|
+
dns_name = ret_dict.get("dnsName", "").rstrip(".")
|
|
161
|
+
if dns_name and ret_dict.get("pscEnabled"):
|
|
162
|
+
ip_addresses["PSC"] = dns_name
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
"ip_addresses": ip_addresses,
|
|
166
|
+
"server_ca_cert": ret_dict["serverCaCert"]["cert"],
|
|
167
|
+
"database_version": ret_dict["databaseVersion"],
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async def _get_ephemeral(
|
|
171
|
+
self,
|
|
172
|
+
project: str,
|
|
173
|
+
instance: str,
|
|
174
|
+
pub_key: str,
|
|
175
|
+
enable_iam_auth: bool = False,
|
|
176
|
+
) -> tuple[str, datetime.datetime]:
|
|
177
|
+
"""Asynchronously requests an ephemeral certificate from the Cloud SQL Instance.
|
|
178
|
+
|
|
179
|
+
:type project: str
|
|
180
|
+
:param project : A string representing the name of the project.
|
|
181
|
+
|
|
182
|
+
:type instance: str
|
|
183
|
+
:param instance: A string representing the name of the instance.
|
|
184
|
+
|
|
185
|
+
:type pub_key:
|
|
186
|
+
:param str: A string representing PEM-encoded RSA public key.
|
|
187
|
+
|
|
188
|
+
:type enable_iam_auth: bool
|
|
189
|
+
:param enable_iam_auth
|
|
190
|
+
Enables automatic IAM database authentication for Postgres or MySQL
|
|
191
|
+
instances.
|
|
192
|
+
|
|
193
|
+
:rtype: str
|
|
194
|
+
:returns: An ephemeral certificate from the Cloud SQL instance that allows
|
|
195
|
+
authorized connections to the instance.
|
|
196
|
+
"""
|
|
197
|
+
headers = {
|
|
198
|
+
"Authorization": f"Bearer {self._credentials.token}",
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
url = f"{self._sqladmin_api_endpoint}/sql/{API_VERSION}/projects/{project}/instances/{instance}:generateEphemeralCert"
|
|
202
|
+
|
|
203
|
+
data = {"public_key": pub_key}
|
|
204
|
+
|
|
205
|
+
if enable_iam_auth:
|
|
206
|
+
# down-scope credentials with only IAM login scope (refreshes them too)
|
|
207
|
+
login_creds = _downscope_credentials(self._credentials)
|
|
208
|
+
data["access_token"] = login_creds.token
|
|
209
|
+
|
|
210
|
+
resp = await self._client.post(url, headers=headers, json=data)
|
|
211
|
+
if resp.status >= 500:
|
|
212
|
+
resp = await retry_50x(self._client.post, url, headers=headers, json=data)
|
|
213
|
+
# try to get response json for better error message
|
|
214
|
+
try:
|
|
215
|
+
ret_dict = await resp.json()
|
|
216
|
+
if resp.status >= 400:
|
|
217
|
+
# if detailed error message is in json response, use as error message
|
|
218
|
+
message = ret_dict.get("error", {}).get("message")
|
|
219
|
+
if message:
|
|
220
|
+
resp.reason = message
|
|
221
|
+
# skip, raise_for_status will catch all errors in finally block
|
|
222
|
+
except Exception:
|
|
223
|
+
pass
|
|
224
|
+
finally:
|
|
225
|
+
resp.raise_for_status()
|
|
226
|
+
|
|
227
|
+
ephemeral_cert: str = ret_dict["ephemeralCert"]["cert"]
|
|
228
|
+
|
|
229
|
+
# decode cert to read expiration
|
|
230
|
+
x509 = load_pem_x509_certificate(
|
|
231
|
+
ephemeral_cert.encode("UTF-8"), default_backend()
|
|
232
|
+
)
|
|
233
|
+
expiration = x509.not_valid_after_utc
|
|
234
|
+
# for IAM authentication OAuth2 token is embedded in cert so it
|
|
235
|
+
# must still be valid for successful connection
|
|
236
|
+
if enable_iam_auth:
|
|
237
|
+
token_expiration: datetime.datetime = login_creds.expiry
|
|
238
|
+
# google.auth library strips timezone info for backwards compatibality
|
|
239
|
+
# reasons with Python 2. Add it back to allow timezone aware datetimes.
|
|
240
|
+
# Ref: https://github.com/googleapis/google-auth-library-python/blob/49a5ff7411a2ae4d32a7d11700f9f961c55406a9/google/auth/_helpers.py#L93-L99
|
|
241
|
+
token_expiration = token_expiration.replace(tzinfo=datetime.timezone.utc)
|
|
242
|
+
|
|
243
|
+
if expiration > token_expiration:
|
|
244
|
+
expiration = token_expiration
|
|
245
|
+
return ephemeral_cert, expiration
|
|
246
|
+
|
|
247
|
+
async def get_connection_info(
|
|
248
|
+
self,
|
|
249
|
+
conn_name: ConnectionName,
|
|
250
|
+
keys: asyncio.Future,
|
|
251
|
+
enable_iam_auth: bool,
|
|
252
|
+
) -> ConnectionInfo:
|
|
253
|
+
"""Immediately performs a full refresh operation using the Cloud SQL
|
|
254
|
+
Admin API.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
conn_name (ConnectionName): The Cloud SQL instance's
|
|
258
|
+
connection name.
|
|
259
|
+
keys (asyncio.Future): A future to the client's public-private key
|
|
260
|
+
pair.
|
|
261
|
+
enable_iam_auth (bool): Whether an automatic IAM database
|
|
262
|
+
authentication connection is being requested (Postgres and MySQL).
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
ConnectionInfo: All the information required to connect securely to
|
|
266
|
+
the Cloud SQL instance.
|
|
267
|
+
Raises:
|
|
268
|
+
AutoIAMAuthNotSupported: Database engine does not support automatic
|
|
269
|
+
IAM authentication.
|
|
270
|
+
"""
|
|
271
|
+
priv_key, pub_key = await keys
|
|
272
|
+
# before making Cloud SQL Admin API calls, refresh creds if required
|
|
273
|
+
if not self._credentials.token_state == TokenState.FRESH:
|
|
274
|
+
self._credentials.refresh(requests.Request())
|
|
275
|
+
|
|
276
|
+
metadata_task = asyncio.create_task(
|
|
277
|
+
self._get_metadata(
|
|
278
|
+
conn_name.project,
|
|
279
|
+
conn_name.region,
|
|
280
|
+
conn_name.instance_name,
|
|
281
|
+
)
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
ephemeral_task = asyncio.create_task(
|
|
285
|
+
self._get_ephemeral(
|
|
286
|
+
conn_name.project,
|
|
287
|
+
conn_name.instance_name,
|
|
288
|
+
pub_key,
|
|
289
|
+
enable_iam_auth,
|
|
290
|
+
)
|
|
291
|
+
)
|
|
292
|
+
try:
|
|
293
|
+
metadata = await metadata_task
|
|
294
|
+
# check if automatic IAM database authn is supported for database engine
|
|
295
|
+
if enable_iam_auth and not metadata["database_version"].startswith(
|
|
296
|
+
("POSTGRES", "MYSQL")
|
|
297
|
+
):
|
|
298
|
+
raise AutoIAMAuthNotSupported(
|
|
299
|
+
f"'{metadata['database_version']}' does not support "
|
|
300
|
+
"automatic IAM authentication. It is only supported with "
|
|
301
|
+
"Cloud SQL Postgres or MySQL instances."
|
|
302
|
+
)
|
|
303
|
+
except Exception:
|
|
304
|
+
# cancel ephemeral cert task if exception occurs before it is awaited
|
|
305
|
+
ephemeral_task.cancel()
|
|
306
|
+
raise
|
|
307
|
+
|
|
308
|
+
ephemeral_cert, expiration = await ephemeral_task
|
|
309
|
+
|
|
310
|
+
return ConnectionInfo(
|
|
311
|
+
conn_name,
|
|
312
|
+
ephemeral_cert,
|
|
313
|
+
metadata["server_ca_cert"],
|
|
314
|
+
priv_key,
|
|
315
|
+
metadata["ip_addresses"],
|
|
316
|
+
metadata["database_version"],
|
|
317
|
+
expiration,
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
async def close(self) -> None:
|
|
321
|
+
"""Close CloudSQLClient gracefully."""
|
|
322
|
+
logger.debug("Waiting for Connector's http client to close")
|
|
323
|
+
await self._client.close()
|
|
324
|
+
logger.debug("Closed Connector's http client")
|
|
@@ -0,0 +1,108 @@
|
|
|
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 typing import Any, Optional, TYPE_CHECKING
|
|
21
|
+
|
|
22
|
+
from aiofiles.tempfile import TemporaryDirectory
|
|
23
|
+
|
|
24
|
+
from google.cloud.sql.connector.connection_name import ConnectionName
|
|
25
|
+
from google.cloud.sql.connector.exceptions import CloudSQLIPTypeError
|
|
26
|
+
from google.cloud.sql.connector.exceptions import TLSVersionError
|
|
27
|
+
from google.cloud.sql.connector.utils import write_to_file
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
import datetime
|
|
31
|
+
|
|
32
|
+
from google.cloud.sql.connector.enums import IPTypes
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(name=__name__)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class ConnectionInfo:
|
|
39
|
+
"""Contains all necessary information to connect securely to the
|
|
40
|
+
server-side Proxy running on a Cloud SQL instance."""
|
|
41
|
+
|
|
42
|
+
conn_name: ConnectionName
|
|
43
|
+
client_cert: str
|
|
44
|
+
server_ca_cert: str
|
|
45
|
+
private_key: bytes
|
|
46
|
+
ip_addrs: dict[str, Any]
|
|
47
|
+
database_version: str
|
|
48
|
+
expiration: datetime.datetime
|
|
49
|
+
context: Optional[ssl.SSLContext] = None
|
|
50
|
+
|
|
51
|
+
async def create_ssl_context(self, enable_iam_auth: bool = False) -> ssl.SSLContext:
|
|
52
|
+
"""Constructs a SSL/TLS context for the given connection info.
|
|
53
|
+
|
|
54
|
+
Cache the SSL context to ensure we don't read from disk repeatedly when
|
|
55
|
+
configuring a secure connection.
|
|
56
|
+
"""
|
|
57
|
+
# if SSL context is cached, use it
|
|
58
|
+
if self.context is not None:
|
|
59
|
+
return self.context
|
|
60
|
+
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
61
|
+
|
|
62
|
+
# update ssl.PROTOCOL_TLS_CLIENT default
|
|
63
|
+
context.check_hostname = False
|
|
64
|
+
|
|
65
|
+
# TODO: remove if/else when Python 3.10 is min version. PEP 644 has been
|
|
66
|
+
# implemented. The ssl module requires OpenSSL 1.1.1 or newer.
|
|
67
|
+
# verify OpenSSL version supports TLSv1.3
|
|
68
|
+
if ssl.HAS_TLSv1_3:
|
|
69
|
+
# force TLSv1.3 if supported by client
|
|
70
|
+
context.minimum_version = ssl.TLSVersion.TLSv1_3
|
|
71
|
+
# fallback to TLSv1.2 for older versions of OpenSSL
|
|
72
|
+
else:
|
|
73
|
+
if enable_iam_auth:
|
|
74
|
+
raise TLSVersionError(
|
|
75
|
+
f"Your current version of OpenSSL ({ssl.OPENSSL_VERSION}) does not "
|
|
76
|
+
"support TLSv1.3, which is required to use IAM Authentication.\n"
|
|
77
|
+
"Upgrade your OpenSSL version to 1.1.1 for TLSv1.3 support."
|
|
78
|
+
)
|
|
79
|
+
logger.warning(
|
|
80
|
+
"TLSv1.3 is not supported with your version of OpenSSL "
|
|
81
|
+
f"({ssl.OPENSSL_VERSION}), falling back to TLSv1.2\n"
|
|
82
|
+
"Upgrade your OpenSSL version to 1.1.1 for TLSv1.3 support."
|
|
83
|
+
)
|
|
84
|
+
context.minimum_version = ssl.TLSVersion.TLSv1_2
|
|
85
|
+
|
|
86
|
+
# tmpdir and its contents are automatically deleted after the CA cert
|
|
87
|
+
# and ephemeral cert are loaded into the SSLcontext. The values
|
|
88
|
+
# need to be written to files in order to be loaded by the SSLContext
|
|
89
|
+
async with TemporaryDirectory() as tmpdir:
|
|
90
|
+
ca_filename, cert_filename, key_filename = await write_to_file(
|
|
91
|
+
tmpdir, self.server_ca_cert, self.client_cert, self.private_key
|
|
92
|
+
)
|
|
93
|
+
context.load_cert_chain(cert_filename, keyfile=key_filename)
|
|
94
|
+
context.load_verify_locations(cafile=ca_filename)
|
|
95
|
+
# set class attribute to cache context for subsequent calls
|
|
96
|
+
self.context = context
|
|
97
|
+
return context
|
|
98
|
+
|
|
99
|
+
def get_preferred_ip(self, ip_type: IPTypes) -> str:
|
|
100
|
+
"""Returns the first IP address for the instance, according to the preference
|
|
101
|
+
supplied by ip_type. If no IP addressess with the given preference are found,
|
|
102
|
+
an error is raised."""
|
|
103
|
+
if ip_type.value in self.ip_addrs:
|
|
104
|
+
return self.ip_addrs[ip_type.value]
|
|
105
|
+
raise CloudSQLIPTypeError(
|
|
106
|
+
"Cloud SQL instance does not have any IP addresses matching "
|
|
107
|
+
f"preference: {ip_type.value}"
|
|
108
|
+
)
|
|
@@ -0,0 +1,61 @@
|
|
|
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 dataclasses import dataclass
|
|
16
|
+
import re
|
|
17
|
+
|
|
18
|
+
# Instance connection name is the format <PROJECT>:<REGION>:<INSTANCE_NAME>
|
|
19
|
+
# Additionally, we have to support legacy "domain-scoped" projects
|
|
20
|
+
# (e.g. "google.com:PROJECT")
|
|
21
|
+
CONN_NAME_REGEX = re.compile(("([^:]+(:[^:]+)?):([^:]+):([^:]+)"))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class ConnectionName:
|
|
26
|
+
"""ConnectionName represents a Cloud SQL instance's "instance connection name".
|
|
27
|
+
|
|
28
|
+
Takes the format "<PROJECT>:<REGION>:<INSTANCE_NAME>".
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
project: str
|
|
32
|
+
region: str
|
|
33
|
+
instance_name: str
|
|
34
|
+
domain_name: str = ""
|
|
35
|
+
|
|
36
|
+
def __str__(self) -> str:
|
|
37
|
+
if self.domain_name:
|
|
38
|
+
return f"{self.domain_name} -> {self.project}:{self.region}:{self.instance_name}"
|
|
39
|
+
return f"{self.project}:{self.region}:{self.instance_name}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _parse_connection_name(connection_name: str) -> ConnectionName:
|
|
43
|
+
return _parse_connection_name_with_domain_name(connection_name, "")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _parse_connection_name_with_domain_name(
|
|
47
|
+
connection_name: str, domain_name: str
|
|
48
|
+
) -> ConnectionName:
|
|
49
|
+
if CONN_NAME_REGEX.fullmatch(connection_name) is None:
|
|
50
|
+
raise ValueError(
|
|
51
|
+
"Arg `instance_connection_string` must have "
|
|
52
|
+
"format: PROJECT:REGION:INSTANCE, "
|
|
53
|
+
f"got {connection_name}."
|
|
54
|
+
)
|
|
55
|
+
connection_name_split = CONN_NAME_REGEX.split(connection_name)
|
|
56
|
+
return ConnectionName(
|
|
57
|
+
connection_name_split[1],
|
|
58
|
+
connection_name_split[3],
|
|
59
|
+
connection_name_split[4],
|
|
60
|
+
domain_name,
|
|
61
|
+
)
|