Skip to content

Commit 1a8f274

Browse files
feat: add support for DNS names with Connector (#1204)
The Connector may be configured to use a DNS name to look up the instance name instead of configuring the connector with the instance connection name directly. Add a DNS TXT record for the Cloud SQL instance to a private DNS server or a private Google Cloud DNS Zone used by your application. For example: Record type: TXT Name: prod-db.mycompany.example.com – This is the domain name used by the application Value: my-project:my-region:my-instance – This is the instance connection name Configure the Connector to use a DNS name via setting resolver=DnsResolver
1 parent 11f9fe9 commit 1a8f274

File tree

14 files changed

+309
-26
lines changed

14 files changed

+309
-26
lines changed

README.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,69 @@ conn = connector.connect(
365365
)
366366
```
367367

368+
### Using DNS domain names to identify instances
369+
370+
The connector can be configured to use DNS to look up an instance. This would
371+
allow you to configure your application to connect to a database instance, and
372+
centrally configure which instance in your DNS zone.
373+
374+
#### Configure your DNS Records
375+
376+
Add a DNS TXT record for the Cloud SQL instance to a **private** DNS server
377+
or a private Google Cloud DNS Zone used by your application.
378+
379+
> [!NOTE]
380+
>
381+
> You are strongly discouraged from adding DNS records for your
382+
> Cloud SQL instances to a public DNS server. This would allow anyone on the
383+
> internet to discover the Cloud SQL instance name.
384+
385+
For example: suppose you wanted to use the domain name
386+
`prod-db.mycompany.example.com` to connect to your database instance
387+
`my-project:region:my-instance`. You would create the following DNS record:
388+
389+
* Record type: `TXT`
390+
* Name: `prod-db.mycompany.example.com` – This is the domain name used by the application
391+
* Value: `my-project:my-region:my-instance` – This is the Cloud SQL instance connection name
392+
393+
#### Configure the connector
394+
395+
Configure the connector to resolve DNS names by initializing it with
396+
`resolver=DnsResolver` and replacing the instance connection name with the DNS
397+
name in `connector.connect`:
398+
399+
```python
400+
from google.cloud.sql.connector import Connector, DnsResolver
401+
import pymysql
402+
import sqlalchemy
403+
404+
# helper function to return SQLAlchemy connection pool
405+
def init_connection_pool(connector: Connector) -> sqlalchemy.engine.Engine:
406+
# function used to generate database connection
407+
def getconn() -> pymysql.connections.Connection:
408+
conn = connector.connect(
409+
"prod-db.mycompany.example.com", # using DNS name
410+
"pymysql",
411+
user="my-user",
412+
password="my-password",
413+
db="my-db-name"
414+
)
415+
return conn
416+
417+
# create connection pool
418+
pool = sqlalchemy.create_engine(
419+
"mysql+pymysql://",
420+
creator=getconn,
421+
)
422+
return pool
423+
424+
# initialize Cloud SQL Python Connector with `resolver=DnsResolver`
425+
with Connector(resolver=DnsResolver) as connector:
426+
# initialize connection pool
427+
pool = init_connection_pool(connector)
428+
# ... use SQLAlchemy engine normally
429+
```
430+
368431
### Using the Python Connector with Python Web Frameworks
369432

370433
The Python Connector can be used alongside popular Python web frameworks such

google/cloud/sql/connector/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,16 @@
1818
from google.cloud.sql.connector.connector import create_async_connector
1919
from google.cloud.sql.connector.enums import IPTypes
2020
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
2123
from google.cloud.sql.connector.version import __version__
2224

2325
__all__ = [
2426
"__version__",
2527
"create_async_connector",
2628
"Connector",
29+
"DefaultResolver",
30+
"DnsResolver",
2731
"IPTypes",
2832
"RefreshStrategy",
2933
]

google/cloud/sql/connector/connector.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@
3737
import google.cloud.sql.connector.pg8000 as pg8000
3838
import google.cloud.sql.connector.pymysql as pymysql
3939
import google.cloud.sql.connector.pytds as pytds
40+
from google.cloud.sql.connector.resolver import DefaultResolver
41+
from google.cloud.sql.connector.resolver import DnsResolver
4042
from google.cloud.sql.connector.utils import format_database_user
4143
from google.cloud.sql.connector.utils import generate_keys
4244

@@ -63,6 +65,7 @@ def __init__(
6365
user_agent: Optional[str] = None,
6466
universe_domain: Optional[str] = None,
6567
refresh_strategy: str | RefreshStrategy = RefreshStrategy.BACKGROUND,
68+
resolver: Type[DefaultResolver] | Type[DnsResolver] = DefaultResolver,
6669
) -> None:
6770
"""Initializes a Connector instance.
6871
@@ -104,6 +107,13 @@ def __init__(
104107
of the following: RefreshStrategy.LAZY ("LAZY") or
105108
RefreshStrategy.BACKGROUND ("BACKGROUND").
106109
Default: RefreshStrategy.BACKGROUND
110+
111+
resolver (DefaultResolver | DnsResolver): The class name of the
112+
resolver to use for resolving the Cloud SQL instance connection
113+
name. To resolve a DNS record to an instance connection name, use
114+
DnsResolver.
115+
Default: DefaultResolver
116+
107117
"""
108118
# if refresh_strategy is str, convert to RefreshStrategy enum
109119
if isinstance(refresh_strategy, str):
@@ -157,6 +167,7 @@ def __init__(
157167
self._enable_iam_auth = enable_iam_auth
158168
self._quota_project = quota_project
159169
self._user_agent = user_agent
170+
self._resolver = resolver()
160171
# if ip_type is str, convert to IPTypes enum
161172
if isinstance(ip_type, str):
162173
ip_type = IPTypes._from_str(ip_type)
@@ -269,13 +280,14 @@ async def connect_async(
269280
if (instance_connection_string, enable_iam_auth) in self._cache:
270281
cache = self._cache[(instance_connection_string, enable_iam_auth)]
271282
else:
283+
conn_name = await self._resolver.resolve(instance_connection_string)
272284
if self._refresh_strategy == RefreshStrategy.LAZY:
273285
logger.debug(
274286
f"['{instance_connection_string}']: Refresh strategy is set"
275287
" to lazy refresh"
276288
)
277289
cache = LazyRefreshCache(
278-
instance_connection_string,
290+
conn_name,
279291
self._client,
280292
self._keys,
281293
enable_iam_auth,
@@ -286,7 +298,7 @@ async def connect_async(
286298
" to backgound refresh"
287299
)
288300
cache = RefreshAheadCache(
289-
instance_connection_string,
301+
conn_name,
290302
self._client,
291303
self._keys,
292304
enable_iam_auth,

google/cloud/sql/connector/exceptions.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,10 @@ class IncompatibleDriverError(Exception):
7070
Exception to be raised when the database driver given is for the wrong
7171
database engine. (i.e. asyncpg for a MySQL database)
7272
"""
73+
74+
75+
class DnsResolutionError(Exception):
76+
"""
77+
Exception to be raised when an instance connection name can not be resolved
78+
from a DNS record.
79+
"""

google/cloud/sql/connector/instance.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424

2525
from google.cloud.sql.connector.client import CloudSQLClient
2626
from google.cloud.sql.connector.connection_info import ConnectionInfo
27-
from google.cloud.sql.connector.connection_name import _parse_instance_connection_name
27+
from google.cloud.sql.connector.connection_name import ConnectionName
2828
from google.cloud.sql.connector.exceptions import RefreshNotValidError
2929
from google.cloud.sql.connector.rate_limiter import AsyncRateLimiter
3030
from google.cloud.sql.connector.refresh_utils import _is_valid
@@ -45,25 +45,23 @@ class RefreshAheadCache:
4545

4646
def __init__(
4747
self,
48-
instance_connection_string: str,
48+
conn_name: ConnectionName,
4949
client: CloudSQLClient,
5050
keys: asyncio.Future,
5151
enable_iam_auth: bool = False,
5252
) -> None:
5353
"""Initializes a RefreshAheadCache instance.
5454
5555
Args:
56-
instance_connection_string (str): The Cloud SQL Instance's
57-
connection string (also known as an instance connection name).
56+
conn_name (ConnectionName): The Cloud SQL instance's
57+
connection name.
5858
client (CloudSQLClient): The Cloud SQL Client instance.
5959
keys (asyncio.Future): A future to the client's public-private key
6060
pair.
6161
enable_iam_auth (bool): Enables automatic IAM database authentication
6262
(Postgres and MySQL) as the default authentication method for all
6363
connections.
6464
"""
65-
# validate and parse instance connection name
66-
conn_name = _parse_instance_connection_name(instance_connection_string)
6765
self._project, self._region, self._instance = (
6866
conn_name.project,
6967
conn_name.region,

google/cloud/sql/connector/lazy.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121

2222
from google.cloud.sql.connector.client import CloudSQLClient
2323
from google.cloud.sql.connector.connection_info import ConnectionInfo
24-
from google.cloud.sql.connector.connection_name import _parse_instance_connection_name
24+
from google.cloud.sql.connector.connection_name import ConnectionName
2525
from google.cloud.sql.connector.refresh_utils import _refresh_buffer
2626

2727
logger = logging.getLogger(name=__name__)
@@ -38,25 +38,23 @@ class LazyRefreshCache:
3838

3939
def __init__(
4040
self,
41-
instance_connection_string: str,
41+
conn_name: ConnectionName,
4242
client: CloudSQLClient,
4343
keys: asyncio.Future,
4444
enable_iam_auth: bool = False,
4545
) -> None:
4646
"""Initializes a LazyRefreshCache instance.
4747
4848
Args:
49-
instance_connection_string (str): The Cloud SQL Instance's
50-
connection string (also known as an instance connection name).
49+
conn_name (ConnectionName): The Cloud SQL instance's
50+
connection name.
5151
client (CloudSQLClient): The Cloud SQL Client instance.
5252
keys (asyncio.Future): A future to the client's public-private key
5353
pair.
5454
enable_iam_auth (bool): Enables automatic IAM database authentication
5555
(Postgres and MySQL) as the default authentication method for all
5656
connections.
5757
"""
58-
# validate and parse instance connection name
59-
conn_name = _parse_instance_connection_name(instance_connection_string)
6058
self._project, self._region, self._instance = (
6159
conn_name.project,
6260
conn_name.region,
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
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+
import dns.asyncresolver
16+
17+
from google.cloud.sql.connector.connection_name import _parse_instance_connection_name
18+
from google.cloud.sql.connector.connection_name import ConnectionName
19+
from google.cloud.sql.connector.exceptions import DnsResolutionError
20+
21+
22+
class DefaultResolver:
23+
"""DefaultResolver simply validates and parses instance connection name."""
24+
25+
async def resolve(self, connection_name: str) -> ConnectionName:
26+
return _parse_instance_connection_name(connection_name)
27+
28+
29+
class DnsResolver(dns.asyncresolver.Resolver):
30+
"""
31+
DnsResolver resolves domain names into instance connection names using
32+
TXT records in DNS.
33+
"""
34+
35+
async def resolve(self, dns: str) -> ConnectionName: # type: ignore
36+
try:
37+
conn_name = _parse_instance_connection_name(dns)
38+
except ValueError:
39+
# The connection name was not project:region:instance format.
40+
# Attempt to query a TXT record to get connection name.
41+
conn_name = await self.query_dns(dns)
42+
return conn_name
43+
44+
async def query_dns(self, dns: str) -> ConnectionName:
45+
try:
46+
# Attempt to query the TXT records.
47+
records = await super().resolve(dns, "TXT", raise_on_no_answer=True)
48+
# Sort the TXT record values alphabetically, strip quotes as record
49+
# values can be returned as raw strings
50+
rdata = [record.to_text().strip('"') for record in records]
51+
rdata.sort()
52+
# Attempt to parse records, returning the first valid record.
53+
for record in rdata:
54+
try:
55+
conn_name = _parse_instance_connection_name(record)
56+
return conn_name
57+
except Exception:
58+
continue
59+
# If all records failed to parse, throw error
60+
raise DnsResolutionError(
61+
f"Unable to parse TXT record for `{dns}` -> `{rdata[0]}`"
62+
)
63+
# Don't override above DnsResolutionError
64+
except DnsResolutionError:
65+
raise
66+
except Exception as e:
67+
raise DnsResolutionError(f"Unable to resolve TXT record for `{dns}`") from e

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
aiofiles==24.1.0
22
aiohttp==3.11.9
33
cryptography==44.0.0
4+
dnspython==2.7.0
45
Requests==2.32.3
56
google-auth==2.36.0

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"aiofiles",
2929
"aiohttp",
3030
"cryptography>=42.0.0",
31+
"dnspython>=2.0.0",
3132
"Requests",
3233
"google-auth>=2.28.0",
3334
]

tests/conftest.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from unit.mocks import FakeCSQLInstance # type: ignore
2727

2828
from google.cloud.sql.connector.client import CloudSQLClient
29+
from google.cloud.sql.connector.connection_name import ConnectionName
2930
from google.cloud.sql.connector.instance import RefreshAheadCache
3031
from google.cloud.sql.connector.utils import generate_keys
3132

@@ -144,7 +145,7 @@ async def fake_client(
144145
async def cache(fake_client: CloudSQLClient) -> AsyncGenerator[RefreshAheadCache, None]:
145146
keys = asyncio.create_task(generate_keys())
146147
cache = RefreshAheadCache(
147-
"test-project:test-region:test-instance",
148+
ConnectionName("test-project", "test-region", "test-instance"),
148149
client=fake_client,
149150
keys=keys,
150151
)

0 commit comments

Comments
 (0)