Skip to content

Commit 54b7048

Browse files
committed
Add PSC DNS and Global Write Endpoint support to Python Connector
- Implement resolve_cname and resolve_txt async wrappers inside DnsResolver. - Implement SetClient in DnsResolver to inject the lazy-loaded CloudSQLClient. - Update DnsResolver.resolve() to intercept direct PSC DNS names and call resolve_connect_settings() API. - Handle CNAME recursive resolution loop recursion-free (max 10 depth) inside resolve(), checking loop circularity. - Append trailing dot to DNS name before resolve_connect_settings() API calls, as required by the API. - Implement resolve_connect_settings() inside CloudSQLClient to fetch metadata on ConnectSettings resolve REST endpoint. - Implement _get_fallback_ip() and _is_ip_address() helpers in connector.py. - Update connector.py connect_async() to support fallback to Private/Primary (Public) IP from metadata if CNAME/PSC DNS resolution fails and metadata preferred address is a hostname. - Add comprehensive unit tests for direct PSC DNS, simple CNAME to PSC, recursive CNAMEs, and CNAME loop detection in test_resolver.py. - Add test_Connector_connect_async_custom_dns_resolver_fallback_psc_to_private_ip unit test to test_connector.py. TAG=agy CONV=0b6ef4f2-88db-48b8-9bb9-54cd56459291
1 parent 4aaee21 commit 54b7048

5 files changed

Lines changed: 341 additions & 38 deletions

File tree

‎google/cloud/sql/connector/client.py‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,43 @@ async def _get_metadata(
177177
"database_version": ret_dict["databaseVersion"],
178178
}
179179

180+
async def resolve_connect_settings(
181+
self,
182+
dns_name: str,
183+
location: str,
184+
) -> dict[str, Any]:
185+
"""Asynchronously calls the resolveConnectSettings endpoint to resolve a
186+
PSC DNS name to a connection name.
187+
188+
Args:
189+
dns_name (str): The DNS name of the Cloud SQL instance.
190+
location (str): The region/location of the instance.
191+
192+
Returns:
193+
A dictionary containing the resolve response (e.g. connectionName).
194+
"""
195+
headers = {
196+
"Authorization": f"Bearer {self._credentials.token}",
197+
}
198+
199+
url = f"{self._sqladmin_api_endpoint}/sql/{API_VERSION}/dns/{dns_name}/locations/{location}:resolveConnectSettings"
200+
201+
resp = await self._client.get(url, headers=headers)
202+
if resp.status >= 500:
203+
resp = await retry_50x(self._client.get, url, headers=headers)
204+
try:
205+
ret_dict = await resp.json()
206+
if resp.status >= 400:
207+
message = ret_dict.get("error", {}).get("message")
208+
if message:
209+
resp.reason = message
210+
except Exception:
211+
pass
212+
finally:
213+
resp.raise_for_status()
214+
215+
return ret_dict
216+
180217
async def _get_ephemeral(
181218
self,
182219
project: str,

‎google/cloud/sql/connector/connector.py‎

Lines changed: 30 additions & 2 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,23 @@
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_ip(current_ip: str, ip_addresses: dict[str, str]) -> str:
63+
if _is_ip_address(current_ip):
64+
return current_ip
65+
fallback = ip_addresses.get("PRIVATE")
66+
if fallback is None:
67+
fallback = ip_addresses.get("PRIMARY")
68+
return fallback if fallback else current_ip
69+
5270
ASYNC_DRIVERS = ["asyncpg"]
5371
SERVER_PROXY_PORT = 3307
5472
_DEFAULT_SCHEME = "https://"
@@ -316,6 +334,8 @@ async def connect_async(
316334
user_agent=self._user_agent,
317335
driver=driver,
318336
)
337+
if hasattr(self._resolver, "set_client"):
338+
self._resolver.set_client(self._client)
319339
enable_iam_auth = kwargs.pop("enable_iam_auth", self._enable_iam_auth)
320340

321341
conn_name = await self._resolver.resolve(instance_connection_string)
@@ -405,17 +425,25 @@ async def connect_async(
405425
"using it to connect"
406426
)
407427
else:
428+
fallback_ip = _get_fallback_ip(
429+
ip_address, conn_info.ip_addrs
430+
)
408431
logger.debug(
409432
f"['{instance_connection_string}']: Custom DNS name "
410433
f"'{conn_info.conn_name.domain_name}' resolved but returned no "
411-
f"entries, using '{ip_address}' from instance metadata"
434+
f"entries, using '{fallback_ip}' from instance metadata"
412435
)
436+
ip_address = fallback_ip
413437
except Exception as e:
438+
fallback_ip = _get_fallback_ip(
439+
ip_address, conn_info.ip_addrs
440+
)
414441
logger.debug(
415442
f"['{instance_connection_string}']: Custom DNS name "
416443
f"'{conn_info.conn_name.domain_name}' did not resolve to an IP "
417-
f"address: {e}, using '{ip_address}' from instance metadata"
444+
f"address: {e}, using '{fallback_ip}' from instance metadata"
418445
)
446+
ip_address = fallback_ip
419447

420448
logger.debug(f"['{conn_info.conn_name}']: Connecting to {ip_address}:3307")
421449
# format `user` param for automatic IAM database authn

‎google/cloud/sql/connector/resolver.py‎

Lines changed: 101 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15-
from typing import List
15+
import re
16+
from typing import Any, List
1617

1718
import dns.asyncresolver
1819

@@ -24,6 +25,10 @@
2425
from google.cloud.sql.connector.connection_name import ConnectionName
2526
from google.cloud.sql.connector.exceptions import DnsResolutionError
2627

28+
PSC_DNS_PATTERN = re.compile(
29+
r"^([a-f0-9]{12})\.([^.]+)\.([a-z0-9]+-[a-z0-9]+)\.(sql|sql-psa|sql-psc)\.goog\.?$"
30+
)
31+
2732

2833
class DefaultResolver:
2934
"""DefaultResolver simply validates and parses instance connection name."""
@@ -38,54 +43,115 @@ class DnsResolver(dns.asyncresolver.Resolver):
3843
TXT records in DNS.
3944
"""
4045

46+
def __init__(self, *args: Any, **kwargs: Any) -> None:
47+
super().__init__(*args, **kwargs)
48+
self._client: Any = None
49+
50+
def set_client(self, client: Any) -> None:
51+
self._client = client
52+
4153
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:
54+
current = dns
55+
visited = {current}
56+
57+
# Max 10 iterations to prevent infinite CNAME loops
58+
for _ in range(10):
59+
try:
60+
domain_val = dns if current != dns else ""
61+
conn_name = _parse_connection_name_with_domain_name(
62+
current, domain_val
63+
)
64+
return conn_name
65+
except ValueError:
66+
pass
67+
68+
dns_normalized = current.rstrip(".")
69+
match = PSC_DNS_PATTERN.match(dns_normalized.lower())
70+
if match:
71+
region = match.group(3)
72+
if self._client is None:
73+
raise ValueError(
74+
"SQLAdmin client is not configured in the resolver."
75+
)
76+
77+
dns_name_with_dot = dns_normalized + "."
78+
resp = await self._client.resolve_connect_settings(
79+
dns_name_with_dot, region
80+
)
81+
resolved_conn_name = resp["connectionName"]
82+
return _parse_connection_name_with_domain_name(
83+
resolved_conn_name, dns
84+
)
85+
86+
if not _is_valid_domain(current):
5187
raise ValueError(
5288
"Arg `instance_connection_string` must have "
5389
"format: PROJECT:REGION:INSTANCE or be a valid DNS domain "
5490
f"name, got {dns}."
5591
)
56-
return conn_name
5792

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 []
93+
cname_found = False
94+
try:
95+
cname = await self.resolve_cname(current)
96+
cname_found = True
97+
except DnsResolutionError:
98+
pass
99+
100+
if cname_found:
101+
if cname in visited:
102+
raise DnsResolutionError(f"CNAME loop detected for `{dns}`")
103+
visited.add(cname)
104+
current = cname
105+
continue
106+
107+
try:
108+
rdata = await self.resolve_txt(current)
109+
except DnsResolutionError as e:
110+
raise DnsResolutionError(
111+
f"Unable to resolve TXT record for `{dns}`"
112+
) from e
67113

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]
75114
rdata.sort()
76-
# Attempt to parse records, returning the first valid record.
77115
for record in rdata:
78116
try:
79-
conn_name = _parse_connection_name_with_domain_name(record, dns)
117+
conn_name = _parse_connection_name_with_domain_name(
118+
record, dns
119+
)
80120
return conn_name
81121
except Exception:
82122
continue
83-
# If all records failed to parse, throw error
123+
84124
raise DnsResolutionError(
85-
f"Unable to parse TXT record for `{dns}` -> `{rdata[0]}`"
125+
f"Unable to parse TXT record for `{current}` -> `{rdata[0]}`"
126+
if rdata
127+
else f"Unable to resolve TXT record for `{current}`"
86128
)
87-
# Don't override above DnsResolutionError
88-
except DnsResolutionError:
89-
raise
129+
130+
raise DnsResolutionError(
131+
f"CNAME loop detected or max resolution depth reached for `{dns}`"
132+
)
133+
134+
async def resolve_cname(self, dns: str) -> str:
135+
try:
136+
answers = await super().resolve(dns, "CNAME", raise_on_no_answer=True)
137+
return str(answers[0].target).rstrip(".")
138+
except Exception as e:
139+
raise DnsResolutionError(
140+
f"Unable to resolve CNAME record for `{dns}`"
141+
) from e
142+
143+
async def resolve_txt(self, dns: str) -> List[str]:
144+
try:
145+
answers = await super().resolve(dns, "TXT", raise_on_no_answer=True)
146+
return [record.to_text().strip('"') for record in answers]
90147
except Exception as e:
91-
raise DnsResolutionError(f"Unable to resolve TXT record for `{dns}`") from e
148+
raise DnsResolutionError(
149+
f"Unable to resolve TXT record for `{dns}`"
150+
) from e
151+
152+
async def resolve_a_record(self, dns: str) -> List[str]:
153+
try:
154+
records = await super().resolve(dns, "A", raise_on_no_answer=True)
155+
return [record.to_text() for record in records]
156+
except Exception:
157+
return []

‎tests/unit/test_connector.py‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -659,3 +659,63 @@ async def test_Connector_connect_async_custom_dns_resolver_fallback(
659659
fake_client.instance.ip_addrs = original_ips
660660

661661

662+
@pytest.mark.asyncio
663+
async def test_Connector_connect_async_custom_dns_resolver_fallback_psc_to_private_ip(
664+
fake_credentials: Credentials, fake_client: CloudSQLClient
665+
) -> None:
666+
"""Test that Connector.connect_async falls back to Private IP if CNAME/PSC DNS resolution fails."""
667+
668+
with patch(
669+
"google.cloud.sql.connector.resolver.DnsResolver.resolve_a_record"
670+
) as mock_resolve_a:
671+
# DNS resolution fails
672+
mock_resolve_a.return_value = []
673+
674+
with patch(
675+
"google.cloud.sql.connector.resolver.DnsResolver.resolve"
676+
) as mock_resolve:
677+
conn_name_with_domain = ConnectionName(
678+
"test-project", "test-region", "test-instance", "db.example.com"
679+
)
680+
mock_resolve.return_value = conn_name_with_domain
681+
682+
async with Connector(
683+
credentials=fake_credentials,
684+
loop=asyncio.get_running_loop(),
685+
resolver=DnsResolver,
686+
ip_type="PSC", # Use PSC IP type
687+
) as connector:
688+
connector._client = fake_client
689+
690+
original_ips = fake_client.instance.ip_addrs
691+
# Configure instance to be PSC enabled, but also have a PRIVATE IP fallback!
692+
fake_client.instance.psc_enabled = True
693+
fake_client.instance.ip_addrs = {
694+
"PSC": "1ad3b5d73f10.3oxon2yfo9tob.us-east1.sql.goog",
695+
"PRIVATE": "10.0.0.1",
696+
}
697+
698+
try:
699+
with patch(
700+
"google.cloud.sql.connector.asyncpg.connect"
701+
) as mock_connect:
702+
mock_connect.return_value = True
703+
704+
connection = await connector.connect_async(
705+
"db.example.com",
706+
"asyncpg",
707+
user="my-user",
708+
password="my-pass",
709+
db="my-db",
710+
)
711+
712+
# Verify mock_connect fell back to PRIVATE IP "10.0.0.1"!
713+
args, _ = mock_connect.call_args
714+
assert args[0] == "10.0.0.1"
715+
assert connection is True
716+
finally:
717+
# Restore original IPs
718+
fake_client.instance.ip_addrs = original_ips
719+
fake_client.instance.psc_enabled = False
720+
721+

0 commit comments

Comments
 (0)