Skip to content

Commit 62640f3

Browse files
authored
feat: Add PSC DNS and Global Write Endpoint support to Python Connector (#1446)
The Private Service Connect (PSC) DNS Name Resolution & Fallback feature enables the Python connector to securely connect to Cloud SQL database instances using Private Service Connect (PSC) DNS, custom domain names (CNAMEs), and Global Write Endpoints. The connector can now dial PSC instances using: - Its regional DNS hostname (e.g., 0123456789ab.fedcba9876543.us-central1.sql-psc.goog) - A custom domain CNAME pointing to the instance PSC DNS hostname - A Global Write Endpoint DNS hostname (e.g., 0123456789ab.fedcba9876543.global.sql-psc.goog) - The connectors will dynamically resolve this hostname to its real Cloud SQL instance connection name via the SQL Admin API. For Global Write Endpoints (which use the "global" region in their DNS name), the resolver bypasses direct API resolution (as "global" is not a valid API region) and forces fallback to CNAME resolution. This CNAME should point to the active regional instance DNS hostname, enabling automatic failover. See GoogleCloudPlatform/cloud-sql-go-connector#1106
1 parent bcc1cbf commit 62640f3

6 files changed

Lines changed: 167 additions & 12 deletions

File tree

‎build.sh‎

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,17 +144,44 @@ function write_e2e_env(){
144144
done
145145
# Aliases for python e2e tests
146146
echo "export POSTGRES_CUSTOMER_CAS_PASS_VALID_DOMAIN_NAME=\"\$POSTGRES_CUSTOMER_CAS_DOMAIN_NAME\""
147+
echo "export MYSQL_USER_IAM='$(iam_user_mysql)'"
148+
echo "export POSTGRES_USER_IAM='$(iam_user_pg)'"
147149
echo "export POSTGRES_IAM_USER=\"\$POSTGRES_USER_IAM\""
148150
echo "export MYSQL_IAM_USER=\"\$MYSQL_USER_IAM\""
149151
} > "$1"
150152

151153
}
152154

153-
## with_venv - runs a command with the venv activated
154155
function with_venv() {
155156
"$@"
156157
}
157158

159+
function iam_user_pg() {
160+
local email
161+
local pguser
162+
163+
email="$(iam_user_email)"
164+
pguser="${email%%.gserviceaccount.com}"
165+
if [[ -n "$pguser" ]] ; then
166+
echo "$pguser"
167+
else
168+
echo "$email"
169+
fi
170+
}
171+
172+
function iam_user_mysql() {
173+
local email
174+
local mysqluser
175+
176+
email=$(iam_user_email)
177+
mysqluser="${email%%@*}"
178+
echo "$mysqluser"
179+
}
180+
181+
function iam_user_email() {
182+
gcloud auth list --format json | jq -r '.[] | select (.status == "ACTIVE") | .account'
183+
}
184+
158185
## help - prints the help details
159186
##
160187
function help() {

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,12 @@ class DnsResolutionError(Exception):
7373
"""
7474

7575

76+
class DnsLoopError(DnsResolutionError):
77+
"""
78+
Exception to be raised when a CNAME loop is detected during DNS resolution.
79+
"""
80+
81+
7682
class CacheClosedError(Exception):
7783
"""
7884
Exception to be raised when a ConnectionInfoCache can not be accessed after

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

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,17 @@
1919
import ssl
2020
from typing import Any, Callable
2121

22+
import aiohttp
23+
2224
from google.cloud.sql.connector.connection_info import ConnectionInfo
2325
from google.cloud.sql.connector.connection_info import ConnectionInfoCache
2426
from google.cloud.sql.connector.exceptions import CacheClosedError
27+
from google.cloud.sql.connector.exceptions import DnsLoopError
2528
from google.cloud.sql.connector.instance import RefreshAheadCache
2629
from google.cloud.sql.connector.lazy import LazyRefreshCache
2730
from google.cloud.sql.connector.resolver import DefaultResolver
2831
from google.cloud.sql.connector.resolver import DnsResolver
32+
from google.cloud.sql.connector.resolver import is_instance_dns_name
2933

3034
logger = logging.getLogger(name=__name__)
3135

@@ -44,7 +48,16 @@ def __init__(
4448

4549
# If domain name is configured for instance and failover period is set,
4650
# poll for DNS record changes.
47-
if self.cache.conn_name.domain_name and failover_period > 0:
51+
is_instance_dns = False
52+
if self.cache.conn_name.domain_name:
53+
is_instance_dns = is_instance_dns_name(
54+
self.cache.conn_name.domain_name or ""
55+
)
56+
if (
57+
self.cache.conn_name.domain_name
58+
and failover_period > 0
59+
and not is_instance_dns
60+
):
4861
self.domain_name_ticker = asyncio.create_task(
4962
ticker(failover_period, self._check_domain_name)
5063
)
@@ -90,12 +103,28 @@ async def _check_domain_name(self) -> None:
90103
await self.close()
91104

92105
except Exception as e: # noqa: BLE001
93-
# Domain name checks should not be fatal, log error and continue.
94-
logger.debug(
95-
f"['{self.cache.conn_name}']: Unable to check domain name, "
96-
f"domain name {self.cache.conn_name.domain_name} did not "
97-
f"resolve: {e}"
106+
if self._is_non_transient(e):
107+
logger.debug(
108+
f"['{self.cache.conn_name}']: Domain name check failed with "
109+
f"non-transient error, closing all connections: {e}"
110+
)
111+
await self.close()
112+
else:
113+
# Domain name checks should not be fatal for transient errors, log error and continue.
114+
logger.debug(
115+
f"['{self.cache.conn_name}']: Unable to check domain name, "
116+
f"domain name {self.cache.conn_name.domain_name} did not "
117+
f"resolve (transient error): {e}"
118+
)
119+
120+
def _is_non_transient(self, e: Exception) -> bool:
121+
return (
122+
isinstance(e, (ValueError, DnsLoopError))
123+
or (
124+
isinstance(e, aiohttp.ClientResponseError)
125+
and e.status in (403, 404)
98126
)
127+
)
99128

100129
async def connect_info(self) -> ConnectionInfo:
101130
if self.closed:

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

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,25 @@
2525
_parse_connection_name_with_domain_name,
2626
)
2727
from google.cloud.sql.connector.connection_name import ConnectionName
28+
from google.cloud.sql.connector.exceptions import DnsLoopError
2829
from google.cloud.sql.connector.exceptions import DnsResolutionError
2930

30-
PSC_DNS_PATTERN = re.compile(
31-
r"^([a-f0-9]{12})\.([^.]+)\.([a-z0-9]+-[a-z0-9]+)\.(sql|sql-psa|sql-psc)\.goog\.?$"
31+
# ^([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])
32+
33+
INSTANCE_DNS_NAME_PATTERN = re.compile(
34+
r"^([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])\.([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])\.([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])\.(sql|sql-psa|sql-psc)\.goog\.?$"
3235
)
3336

37+
GLOBAL_INSTANCE_DNS_PATTERN = re.compile(r"\.global\.sql(-\w+)?\.goog\.?$")
38+
39+
40+
def is_instance_dns_name(name: str) -> bool:
41+
lower = name.lower()
42+
return bool(
43+
INSTANCE_DNS_NAME_PATTERN.search(lower)
44+
and not GLOBAL_INSTANCE_DNS_PATTERN.search(lower)
45+
)
46+
3447

3548
class DefaultResolver:
3649
"""DefaultResolver simply validates and parses instance connection name."""
@@ -68,7 +81,7 @@ async def resolve(self, dns: str) -> ConnectionName: # type: ignore
6881
pass
6982

7083
dns_normalized = current.rstrip(".")
71-
match = PSC_DNS_PATTERN.match(dns_normalized.lower())
84+
match = INSTANCE_DNS_NAME_PATTERN.match(dns_normalized.lower())
7285
if match:
7386
region = match.group(3)
7487
if region != "global":
@@ -102,7 +115,7 @@ async def resolve(self, dns: str) -> ConnectionName: # type: ignore
102115

103116
if cname_found:
104117
if cname in visited:
105-
raise DnsResolutionError(f"CNAME loop detected for `{dns}`")
118+
raise DnsLoopError(f"CNAME loop detected for `{dns}`")
106119
visited.add(cname)
107120
current = cname
108121
continue
@@ -130,7 +143,7 @@ async def resolve(self, dns: str) -> ConnectionName: # type: ignore
130143
else f"Unable to resolve TXT record for `{current}`"
131144
)
132145

133-
raise DnsResolutionError(
146+
raise DnsLoopError(
134147
f"CNAME loop detected or max resolution depth reached for `{dns}`"
135148
)
136149

‎tests/unit/test_monitored_cache.py‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,3 +238,60 @@ async def test_MonitoredCache_purge_closed_sockets(
238238
# call _purge_closed_sockets and verify socket is removed
239239
monitored_cache._purge_closed_sockets()
240240
assert len(monitored_cache.sockets) == 0
241+
242+
243+
async def test_MonitoredCache_OptimizationForImmutableNames(
244+
fake_client: CloudSQLClient,
245+
) -> None:
246+
"""
247+
Test that MonitoredCache disables DNS polling for immutable instance DNS names
248+
but enables it for custom DNS names and mutable global instance DNS names.
249+
"""
250+
# 1. Custom DNS name -> should enable ticker
251+
conn_name_custom = ConnectionName(
252+
"test-project", "test-region", "test-instance", "db.example.com"
253+
)
254+
cache_custom = LazyRefreshCache(
255+
conn_name_custom,
256+
client=fake_client,
257+
keys=asyncio.create_task(generate_keys()),
258+
enable_iam_auth=False,
259+
)
260+
monitored_cache_custom = MonitoredCache(cache_custom, 30, DnsResolver())
261+
assert monitored_cache_custom.domain_name_ticker is not None
262+
await monitored_cache_custom.close()
263+
264+
# 2. Immutable instance DNS name -> should NOT enable ticker
265+
conn_name_immutable = ConnectionName(
266+
"test-project",
267+
"test-region",
268+
"test-instance",
269+
"0123456789ab.fedcba9876543.us-central1.sql-psc.goog",
270+
)
271+
cache_immutable = LazyRefreshCache(
272+
conn_name_immutable,
273+
client=fake_client,
274+
keys=asyncio.create_task(generate_keys()),
275+
enable_iam_auth=False,
276+
)
277+
monitored_cache_immutable = MonitoredCache(cache_immutable, 30, DnsResolver())
278+
assert monitored_cache_immutable.domain_name_ticker is None
279+
await monitored_cache_immutable.close()
280+
281+
# 3. Mutable global instance DNS name -> should enable ticker
282+
conn_name_global = ConnectionName(
283+
"test-project",
284+
"test-region",
285+
"test-instance",
286+
"0123456789ab.fedcba9876543.global.sql-psc.goog",
287+
)
288+
cache_global = LazyRefreshCache(
289+
conn_name_global,
290+
client=fake_client,
291+
keys=asyncio.create_task(generate_keys()),
292+
enable_iam_auth=False,
293+
)
294+
monitored_cache_global = MonitoredCache(cache_global, 30, DnsResolver())
295+
assert monitored_cache_global.domain_name_ticker is not None
296+
await monitored_cache_global.close()
297+

‎tests/unit/test_resolver.py‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,29 @@ async def test_DnsResolver_with_direct_psc_dns_name() -> None:
194194
)
195195

196196

197+
async def test_DnsResolver_with_direct_psc_dns_name_permissive() -> None:
198+
"""Test DnsResolver resolves direct PSC DNS name with permissive formatting."""
199+
dns_name = "g123.p.uscentral.sql-psc.goog"
200+
real_conn_name = ConnectionName(
201+
"my-project", "uscentral", "my-instance", dns_name
202+
)
203+
204+
mock_client = AsyncMock()
205+
mock_client.resolve_connect_settings.return_value = {
206+
"connectionName": "my-project:uscentral:my-instance"
207+
}
208+
209+
resolver = DnsResolver(client=mock_client)
210+
211+
result = await resolver.resolve(dns_name)
212+
213+
assert result == real_conn_name
214+
mock_client.resolve_connect_settings.assert_awaited_once_with(
215+
dns_name + ".", "uscentral"
216+
)
217+
218+
219+
197220
async def test_DnsResolver_with_cname_resolving_to_psc_dns_name() -> None:
198221
"""Test DnsResolver resolves CNAME to PSC DNS and returns proper connection name."""
199222
dns_name = "db.example.com"

0 commit comments

Comments
 (0)