Skip to content

Commit 9698431

Browse files
feat: add support for PSC (GoogleCloudPlatform#291)
1 parent d845ac3 commit 9698431

File tree

12 files changed

+300
-11
lines changed

12 files changed

+300
-11
lines changed

.github/workflows/tests.yaml

+2
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ jobs:
169169
ALLOYDB_CLUSTER_PASS:${{ secrets.GOOGLE_CLOUD_PROJECT }}/ALLOYDB_CLUSTER_PASS
170170
ALLOYDB_IAM_USER:${{ secrets.GOOGLE_CLOUD_PROJECT }}/ALLOYDB_PYTHON_IAM_USER
171171
ALLOYDB_INSTANCE_IP:${{ secrets.GOOGLE_CLOUD_PROJECT }}/ALLOYDB_INSTANCE_IP
172+
ALLOYDB_PSC_INSTANCE_URI:${{ secrets.GOOGLE_CLOUD_PROJECT }}/ALLOYDB_PSC_INSTANCE_URI
172173
173174
- name: Run tests
174175
env:
@@ -178,6 +179,7 @@ jobs:
178179
ALLOYDB_IAM_USER: '${{ steps.secrets.outputs.ALLOYDB_IAM_USER }}'
179180
ALLOYDB_INSTANCE_IP: '${{ steps.secrets.outputs.ALLOYDB_INSTANCE_IP }}'
180181
ALLOYDB_INSTANCE_URI: '${{ steps.secrets.outputs.ALLOYDB_INSTANCE_URI }}'
182+
ALLOYDB_PSC_INSTANCE_URI: '${{ steps.secrets.outputs.ALLOYDB_PSC_INSTANCE_URI }}'
181183
run: nox -s system-${{ matrix.python-version }}
182184

183185
- name: FlakyBot (Linux)

README.md

+9-4
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,8 @@ connections. These functions are used with your database driver to connect to
9595
your AlloyDB instance.
9696

9797
AlloyDB supports network connectivity through public IP addresses and private,
98-
internal IP addresses. By default this package will attempt to connect over a
98+
internal IP addresses, as well as [Private Service Connect][psc] (PSC).
99+
By default this package will attempt to connect over a
99100
private IP connection. When doing so, this package must be run in an
100101
environment that is connected to the [VPC Network][vpc] that hosts your
101102
AlloyDB private IP address.
@@ -104,6 +105,7 @@ Please see [Configuring AlloyDB Connectivity][alloydb-connectivity] for more det
104105

105106
[vpc]: https://cloud.google.com/vpc/docs/vpc
106107
[alloydb-connectivity]: https://cloud.google.com/alloydb/docs/configure-connectivity
108+
[psc]: https://cloud.google.com/vpc/docs/private-service-connect
107109

108110
### Synchronous Driver Usage
109111

@@ -384,10 +386,13 @@ connector.connect(
384386

385387
The AlloyDB Python Connector by default will attempt to establish connections
386388
to your instance's private IP. To change this, such as connecting to AlloyDB
387-
over a public IP address, set the `ip_type` keyword argument when initializing
388-
a `Connector()` or when calling `connector.connect()`.
389+
over a public IP address or Private Service Connect (PSC), set the `ip_type`
390+
keyword argument when initializing a `Connector()` or when calling
391+
`connector.connect()`.
392+
393+
Possible values for `ip_type` are `"PRIVATE"` (default value), `"PUBLIC"`,
394+
and `"PSC"`.
389395

390-
Possible values for `ip_type` are `"PRIVATE"` (default value), and `"PUBLIC"`.
391396
Example:
392397

393398
```python

google/cloud/alloydb/connector/client.py

+6
Original file line numberDiff line numberDiff line change
@@ -129,9 +129,15 @@ async def _get_metadata(
129129
resp = await self._client.get(url, headers=headers, raise_for_status=True)
130130
resp_dict = await resp.json()
131131

132+
# Remove trailing period from PSC DNS name.
133+
psc_dns = resp_dict.get("pscDnsName")
134+
if psc_dns:
135+
psc_dns = psc_dns.rstrip(".")
136+
132137
return {
133138
"PRIVATE": resp_dict.get("ipAddress"),
134139
"PUBLIC": resp_dict.get("publicIpAddress"),
140+
"PSC": psc_dns,
135141
}
136142

137143
async def _get_client_certificate(

google/cloud/alloydb/connector/instance.py

+1
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ class IPTypes(Enum):
4848

4949
PUBLIC: str = "PUBLIC"
5050
PRIVATE: str = "PRIVATE"
51+
PSC: str = "PSC"
5152

5253
@classmethod
5354
def _missing_(cls, value: object) -> None:

google/cloud/alloydb/connector/refresh.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,8 @@ def __init__(
8686
self.ip_addrs = ip_addrs
8787
# create TLS context
8888
self.context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
89-
# update ssl.PROTOCOL_TLS_CLIENT default
89+
# TODO: Set check_hostname to True to verify the identity in the
90+
# certificate once PSC DNS is populated in all existing clusters.
9091
self.context.check_hostname = False
9192
# force TLSv1.3
9293
self.context.minimum_version = ssl.TLSVersion.TLSv1_3

tests/system/test_asyncpg_psc.py

+98
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
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+
# https://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 os
16+
from typing import Tuple
17+
18+
import asyncpg
19+
import pytest
20+
import sqlalchemy
21+
import sqlalchemy.ext.asyncio
22+
23+
from google.cloud.alloydb.connector import AsyncConnector
24+
25+
26+
async def create_sqlalchemy_engine(
27+
inst_uri: str,
28+
user: str,
29+
password: str,
30+
db: str,
31+
) -> Tuple[sqlalchemy.ext.asyncio.engine.AsyncEngine, AsyncConnector]:
32+
"""Creates a connection pool for an AlloyDB instance and returns the pool
33+
and the connector. Callers are responsible for closing the pool and the
34+
connector.
35+
36+
A sample invocation looks like:
37+
38+
engine, connector = await create_sqlalchemy_engine(
39+
inst_uri,
40+
user,
41+
password,
42+
db,
43+
)
44+
async with engine.connect() as conn:
45+
time = await conn.execute(sqlalchemy.text("SELECT NOW()")).fetchone()
46+
curr_time = time[0]
47+
# do something with query result
48+
await connector.close()
49+
50+
Args:
51+
instance_uri (str):
52+
The instance URI specifies the instance relative to the project,
53+
region, and cluster. For example:
54+
"projects/my-project/locations/us-central1/clusters/my-cluster/instances/my-instance"
55+
user (str):
56+
The database user name, e.g., postgres
57+
password (str):
58+
The database user's password, e.g., secret-password
59+
db_name (str):
60+
The name of the database, e.g., mydb
61+
"""
62+
connector = AsyncConnector()
63+
64+
async def getconn() -> asyncpg.Connection:
65+
conn: asyncpg.Connection = await connector.connect(
66+
inst_uri,
67+
"asyncpg",
68+
user=user,
69+
password=password,
70+
db=db,
71+
ip_type="PSC",
72+
)
73+
return conn
74+
75+
# create SQLAlchemy connection pool
76+
engine = sqlalchemy.ext.asyncio.create_async_engine(
77+
"postgresql+asyncpg://",
78+
async_creator=getconn,
79+
execution_options={"isolation_level": "AUTOCOMMIT"},
80+
)
81+
return engine, connector
82+
83+
84+
@pytest.mark.asyncio
85+
async def test_connection_with_asyncpg() -> None:
86+
"""Basic test to get time from database."""
87+
inst_uri = os.environ["ALLOYDB_PSC_INSTANCE_URI"]
88+
user = os.environ["ALLOYDB_USER"]
89+
password = os.environ["ALLOYDB_PASS"]
90+
db = os.environ["ALLOYDB_DB"]
91+
92+
pool, connector = await create_sqlalchemy_engine(inst_uri, user, password, db)
93+
94+
async with pool.connect() as conn:
95+
res = (await conn.execute(sqlalchemy.text("SELECT 1"))).fetchone()
96+
assert res[0] == 1
97+
98+
await connector.close()

tests/system/test_pg8000_psc.py

+96
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
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 datetime import datetime
16+
import os
17+
from typing import Tuple
18+
19+
import pg8000
20+
import sqlalchemy
21+
22+
from google.cloud.alloydb.connector import Connector
23+
24+
25+
def create_sqlalchemy_engine(
26+
inst_uri: str,
27+
user: str,
28+
password: str,
29+
db: str,
30+
) -> Tuple[sqlalchemy.engine.Engine, Connector]:
31+
"""Creates a connection pool for an AlloyDB instance and returns the pool
32+
and the connector. Callers are responsible for closing the pool and the
33+
connector.
34+
35+
A sample invocation looks like:
36+
37+
engine, connector = create_sqlalchemy_engine(
38+
inst_uri,
39+
user,
40+
password,
41+
db,
42+
)
43+
with engine.connect() as conn:
44+
time = conn.execute(sqlalchemy.text("SELECT NOW()")).fetchone()
45+
conn.commit()
46+
curr_time = time[0]
47+
# do something with query result
48+
connector.close()
49+
50+
Args:
51+
instance_uri (str):
52+
The instance URI specifies the instance relative to the project,
53+
region, and cluster. For example:
54+
"projects/my-project/locations/us-central1/clusters/my-cluster/instances/my-instance"
55+
user (str):
56+
The database user name, e.g., postgres
57+
password (str):
58+
The database user's password, e.g., secret-password
59+
db_name (str):
60+
The name of the database, e.g., mydb
61+
"""
62+
connector = Connector()
63+
64+
def getconn() -> pg8000.dbapi.Connection:
65+
conn: pg8000.dbapi.Connection = connector.connect(
66+
inst_uri,
67+
"pg8000",
68+
user=user,
69+
password=password,
70+
db=db,
71+
ip_type="PSC",
72+
)
73+
return conn
74+
75+
# create SQLAlchemy connection pool
76+
engine = sqlalchemy.create_engine(
77+
"postgresql+pg8000://",
78+
creator=getconn,
79+
)
80+
return engine, connector
81+
82+
83+
def test_pg8000_time() -> None:
84+
"""Basic test to get time from database."""
85+
inst_uri = os.environ["ALLOYDB_PSC_INSTANCE_URI"]
86+
user = os.environ["ALLOYDB_USER"]
87+
password = os.environ["ALLOYDB_PASS"]
88+
db = os.environ["ALLOYDB_DB"]
89+
90+
engine, connector = create_sqlalchemy_engine(inst_uri, user, password, db)
91+
with engine.connect() as conn:
92+
time = conn.execute(sqlalchemy.text("SELECT NOW()")).fetchone()
93+
conn.commit()
94+
curr_time = time[0]
95+
assert type(curr_time) is datetime
96+
connector.close()

tests/unit/mocks.py

+18-2
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from datetime import datetime
1717
from datetime import timedelta
1818
from datetime import timezone
19+
import ipaddress
1920
import ssl
2021
import struct
2122
from typing import Any, Callable, Dict, List, Optional, Tuple
@@ -60,14 +61,15 @@ def valid(self) -> bool:
6061

6162

6263
def generate_cert(
63-
common_name: str, expires_in: int = 60
64+
common_name: str, expires_in: int = 60, server_cert: bool = False
6465
) -> Tuple[x509.CertificateBuilder, rsa.RSAPrivateKey]:
6566
"""
6667
Generate a private key and cert object to be used in testing.
6768
6869
Args:
6970
common_name (str): The Common Name for the certificate.
7071
expires_in (int): Time in minutes until expiry of certificate.
72+
server_cert (bool): Whether it is a server certificate.
7173
7274
Returns:
7375
Tuple[x509.CertificateBuilder, rsa.RSAPrivateKey]
@@ -97,6 +99,17 @@ def generate_cert(
9799
.not_valid_before(now)
98100
.not_valid_after(expiration)
99101
)
102+
if server_cert:
103+
cert = cert.add_extension(
104+
x509.SubjectAlternativeName(
105+
general_names=[
106+
x509.IPAddress(ipaddress.ip_address("127.0.0.1")),
107+
x509.IPAddress(ipaddress.ip_address("10.0.0.1")),
108+
x509.DNSName("x.y.alloydb.goog."),
109+
]
110+
),
111+
critical=False,
112+
)
100113
return cert, key
101114

102115

@@ -112,6 +125,7 @@ def __init__(
112125
ip_addrs: Dict = {
113126
"PRIVATE": "127.0.0.1",
114127
"PUBLIC": "0.0.0.0",
128+
"PSC": "x.y.alloydb.goog",
115129
},
116130
server_name: str = "00000000-0000-0000-0000-000000000000.server.alloydb",
117131
cert_before: datetime = datetime.now(timezone.utc),
@@ -137,7 +151,9 @@ def __init__(
137151
self.root_key, hashes.SHA256()
138152
)
139153
# build server cert
140-
self.server_cert, self.server_key = generate_cert(self.server_name)
154+
self.server_cert, self.server_key = generate_cert(
155+
self.server_name, server_cert=True
156+
)
141157
# create server cert signed by root cert
142158
self.server_cert = self.server_cert.sign(self.root_key, hashes.SHA256())
143159

tests/unit/test_async_connector.py

+14-2
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,18 @@ async def test_AsyncConnector_init(credentials: FakeCredentials) -> None:
6969
IPTypes.PUBLIC,
7070
IPTypes.PUBLIC,
7171
),
72+
(
73+
"psc",
74+
IPTypes.PSC,
75+
),
76+
(
77+
"PSC",
78+
IPTypes.PSC,
79+
),
80+
(
81+
IPTypes.PSC,
82+
IPTypes.PSC,
83+
),
7284
],
7385
)
7486
async def test_AsyncConnector_init_ip_type(
@@ -90,7 +102,7 @@ async def test_AsyncConnector_init_bad_ip_type(credentials: FakeCredentials) ->
90102
AsyncConnector(ip_type=bad_ip_type, credentials=credentials)
91103
assert (
92104
exc_info.value.args[0]
93-
== f"Incorrect value for ip_type, got '{bad_ip_type}'. Want one of: 'PUBLIC', 'PRIVATE'."
105+
== f"Incorrect value for ip_type, got '{bad_ip_type}'. Want one of: 'PUBLIC', 'PRIVATE', 'PSC'."
94106
)
95107

96108

@@ -276,5 +288,5 @@ async def test_async_connect_bad_ip_type(
276288
)
277289
assert (
278290
exc_info.value.args[0]
279-
== f"Incorrect value for ip_type, got '{bad_ip_type}'. Want one of: 'PUBLIC', 'PRIVATE'."
291+
== f"Incorrect value for ip_type, got '{bad_ip_type}'. Want one of: 'PUBLIC', 'PRIVATE', 'PSC'."
280292
)

0 commit comments

Comments
 (0)