Skip to content

Commit a2b59be

Browse files
hessjcgKokoro Connector Build
authored andcommitted
feat: Add PSC DNS and Global Write Endpoint support to Python Connector
1 parent 53b3bca commit a2b59be

10 files changed

Lines changed: 475 additions & 86 deletions

File tree

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,7 @@ dist/
1010
sponge_log.xml
1111
.envrc
1212
*.iml
13+
.mypy_cache/
14+
.nox/
15+
.pytest_cache/
16+
.ruff_cache/

google/cloud/sql/connector/client.py

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ async def _get_metadata(
146146
)
147147

148148
ip_addresses = (
149-
{ip["type"]: ip["ipAddress"] for ip in ret_dict["ipAddresses"]}
149+
{ip["type"]: [ip["ipAddress"]] for ip in ret_dict["ipAddresses"]}
150150
if "ipAddresses" in ret_dict
151151
else {}
152152
)
@@ -156,27 +156,70 @@ async def _get_metadata(
156156
if ret_dict.get("pscEnabled"):
157157
# Find PSC instance DNS name in the dns_names field
158158
psc_dns_names = [
159-
d["name"]
159+
d["name"].rstrip(".")
160160
for d in ret_dict.get("dnsNames", [])
161161
if d["connectionType"] == "PRIVATE_SERVICE_CONNECT"
162162
and d["dnsScope"] == "INSTANCE"
163163
]
164-
dns_name = psc_dns_names[0] if psc_dns_names else None
164+
# Sort: .sql-psc.goog first
165+
psc_dns_names.sort(key=lambda x: x.endswith(".sql-psc.goog"), reverse=True)
165166

166167
# Fall back do dns_name field if dns_names is not set
167-
if dns_name is None:
168+
if not psc_dns_names:
168169
dns_name = ret_dict.get("dnsName", None)
170+
if dns_name:
171+
psc_dns_names = [dns_name.rstrip(".")]
169172

170-
# Remove trailing period from DNS name. Required for SSL in Python
171-
if dns_name:
172-
ip_addresses["PSC"] = dns_name.rstrip(".")
173+
if psc_dns_names:
174+
ip_addresses["PSC"] = psc_dns_names
173175

174176
return {
175177
"ip_addresses": ip_addresses,
176178
"server_ca_cert": ret_dict["serverCaCert"]["cert"],
177179
"database_version": ret_dict["databaseVersion"],
178180
}
179181

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 not 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:
217+
pass
218+
finally:
219+
resp.raise_for_status()
220+
221+
return ret_dict
222+
180223
async def _get_ephemeral(
181224
self,
182225
project: str,

google/cloud/sql/connector/connection_info.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,17 @@ def get_preferred_ip(self, ip_type: IPTypes) -> str:
121121
"""Returns the first IP address for the instance, according to the preference
122122
supplied by ip_type. If no IP addressess with the given preference are found,
123123
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."""
124135
if ip_type.value in self.ip_addrs:
125136
return self.ip_addrs[ip_type.value]
126137
raise CloudSQLIPTypeError(

google/cloud/sql/connector/connector.py

Lines changed: 89 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import asyncio
2020
from functools import partial
21+
import ipaddress
2122
import logging
2223
import os
2324
import socket
@@ -49,6 +50,27 @@
4950

5051
logger = logging.getLogger(name=__name__)
5152

53+
54+
def _is_ip_address(ip: str) -> bool:
55+
try:
56+
ipaddress.ip_address(ip)
57+
return True
58+
except ValueError:
59+
return False
60+
61+
62+
def _get_fallback_ips(
63+
current_ips: list[str], ip_addresses: dict[str, list[str]]
64+
) -> list[str]:
65+
if not current_ips:
66+
return current_ips
67+
if _is_ip_address(current_ips[0]):
68+
return current_ips
69+
fallback = ip_addresses.get("PRIVATE")
70+
if not fallback:
71+
fallback = ip_addresses.get("PRIMARY")
72+
return fallback if fallback else current_ips
73+
5274
ASYNC_DRIVERS = ["asyncpg"]
5375
SERVER_PROXY_PORT = 3307
5476
_DEFAULT_SCHEME = "https://"
@@ -316,6 +338,8 @@ async def connect_async(
316338
user_agent=self._user_agent,
317339
driver=driver,
318340
)
341+
if hasattr(self._resolver, "set_client"):
342+
self._resolver.set_client(self._client)
319343
enable_iam_auth = kwargs.pop("enable_iam_auth", self._enable_iam_auth)
320344

321345
conn_name = await self._resolver.resolve(instance_connection_string)
@@ -384,40 +408,50 @@ async def connect_async(
384408
conn_info = await monitored_cache.connect_info()
385409
# validate driver matches intended database engine
386410
DriverMapping.validate_engine(driver, conn_info.database_version)
387-
ip_address = conn_info.get_preferred_ip(ip_type)
411+
preferred_ips = conn_info.get_preferred_ips(ip_type)
388412
except Exception:
389413
# with an error from Cloud SQL Admin API call or IP type, invalidate
390414
# the cache and re-raise the error
391415
await self._remove_cached(str(conn_name), enable_iam_auth)
392416
raise
393417

418+
targets = []
394419
# If the connector is configured with a custom DNS name, attempt to use
395420
# that DNS name to connect to the instance. Fall back to the metadata IP
396421
# address if the DNS name does not resolve to an IP address.
397422
if conn_info.conn_name.domain_name and isinstance(self._resolver, DnsResolver):
398423
try:
399424
ips = await self._resolver.resolve_a_record(conn_info.conn_name.domain_name)
400425
if ips:
401-
ip_address = ips[0]
426+
targets.extend(ips)
402427
logger.debug(
403428
f"['{instance_connection_string}']: Custom DNS name "
404-
f"'{conn_info.conn_name.domain_name}' resolved to '{ip_address}', "
429+
f"'{conn_info.conn_name.domain_name}' resolved to '{ips}', "
405430
"using it to connect"
406431
)
407432
else:
433+
fallback_ips = _get_fallback_ips(
434+
preferred_ips, conn_info.ip_addrs
435+
)
408436
logger.debug(
409437
f"['{instance_connection_string}']: Custom DNS name "
410438
f"'{conn_info.conn_name.domain_name}' resolved but returned no "
411-
f"entries, using '{ip_address}' from instance metadata"
439+
f"entries, using '{fallback_ips[0]}' from instance metadata"
412440
)
441+
targets.extend(fallback_ips)
413442
except Exception as e:
443+
fallback_ips = _get_fallback_ips(
444+
preferred_ips, conn_info.ip_addrs
445+
)
414446
logger.debug(
415447
f"['{instance_connection_string}']: Custom DNS name "
416448
f"'{conn_info.conn_name.domain_name}' did not resolve to an IP "
417-
f"address: {e}, using '{ip_address}' from instance metadata"
449+
f"address: {e}, using '{fallback_ips[0]}' from instance metadata"
418450
)
451+
targets.extend(fallback_ips)
452+
else:
453+
targets.extend(preferred_ips)
419454

420-
logger.debug(f"['{conn_info.conn_name}']: Connecting to {ip_address}:3307")
421455
# format `user` param for automatic IAM database authn
422456
if enable_iam_auth:
423457
formatted_user = format_database_user(
@@ -428,32 +462,56 @@ async def connect_async(
428462
f"['{instance_connection_string}']: Truncated IAM database username from {kwargs['user']} to {formatted_user}"
429463
)
430464
kwargs["user"] = formatted_user
465+
431466
try:
432-
# async drivers are unblocking and can be awaited directly
433-
if driver in ASYNC_DRIVERS:
434-
return await connector(
435-
ip_address,
436-
await conn_info.create_ssl_context(enable_iam_auth),
437-
**kwargs,
438-
)
439-
# Create socket with SSLContext for sync drivers
440-
ctx = await conn_info.create_ssl_context(enable_iam_auth)
441-
sock = ctx.wrap_socket(
442-
socket.create_connection((ip_address, SERVER_PROXY_PORT)),
443-
server_hostname=ip_address,
444-
)
445-
# If this connection was opened using a domain name, then store it
446-
# for later in case we need to forcibly close it on failover.
447-
if conn_info.conn_name.domain_name:
448-
monitored_cache.sockets.append(sock)
449-
# Synchronous drivers are blocking and run using executor
450-
connect_partial = partial(
451-
connector,
452-
ip_address,
453-
sock,
454-
**kwargs,
455-
)
456-
return await self._loop.run_in_executor(None, connect_partial)
467+
last_ex = None
468+
for target_ip in targets:
469+
logger.debug(f"['{conn_info.conn_name}']: Connecting to {target_ip}:3307")
470+
try:
471+
# async drivers are unblocking and can be awaited directly
472+
if driver in ASYNC_DRIVERS:
473+
conn = await connector(
474+
target_ip,
475+
await conn_info.create_ssl_context(enable_iam_auth),
476+
**kwargs,
477+
)
478+
last_ex = None
479+
return conn
480+
481+
# Create socket with SSLContext for sync drivers
482+
ctx = await conn_info.create_ssl_context(enable_iam_auth)
483+
raw_sock = socket.create_connection((target_ip, SERVER_PROXY_PORT))
484+
try:
485+
sock = ctx.wrap_socket(
486+
raw_sock,
487+
server_hostname=target_ip,
488+
)
489+
except Exception:
490+
raw_sock.close()
491+
raise
492+
493+
# If this connection was opened using a domain name, then store it
494+
# for later in case we need to forcibly close it on failover.
495+
if conn_info.conn_name.domain_name:
496+
monitored_cache.sockets.append(sock)
497+
# Synchronous drivers are blocking and run using executor
498+
connect_partial = partial(
499+
connector,
500+
target_ip,
501+
sock,
502+
**kwargs,
503+
)
504+
conn = await self._loop.run_in_executor(None, connect_partial)
505+
last_ex = None
506+
return conn
507+
except Exception as e:
508+
logger.debug(
509+
f"['{conn_info.conn_name}']: Connection to {target_ip} failed: {e}"
510+
)
511+
last_ex = e
512+
513+
if last_ex:
514+
raise last_ex
457515

458516
except Exception:
459517
# with any exception, we attempt a force refresh, then throw the error

0 commit comments

Comments
 (0)