Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add support for PSC #291

Merged
merged 2 commits into from
Apr 8, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ jobs:
ALLOYDB_CLUSTER_PASS:${{ secrets.GOOGLE_CLOUD_PROJECT }}/ALLOYDB_CLUSTER_PASS
ALLOYDB_IAM_USER:${{ secrets.GOOGLE_CLOUD_PROJECT }}/ALLOYDB_PYTHON_IAM_USER
ALLOYDB_INSTANCE_IP:${{ secrets.GOOGLE_CLOUD_PROJECT }}/ALLOYDB_INSTANCE_IP
ALLOYDB_PSC_INSTANCE_URI:${{ secrets.GOOGLE_CLOUD_PROJECT }}/ALLOYDB_PSC_INSTANCE_URI

- name: Run tests
env:
Expand All @@ -178,6 +179,7 @@ jobs:
ALLOYDB_IAM_USER: '${{ steps.secrets.outputs.ALLOYDB_IAM_USER }}'
ALLOYDB_INSTANCE_IP: '${{ steps.secrets.outputs.ALLOYDB_INSTANCE_IP }}'
ALLOYDB_INSTANCE_URI: '${{ steps.secrets.outputs.ALLOYDB_INSTANCE_URI }}'
ALLOYDB_PSC_INSTANCE_URI: '${{ steps.secrets.outputs.ALLOYDB_PSC_INSTANCE_URI }}'
run: nox -s system-${{ matrix.python-version }}

- name: FlakyBot (Linux)
Expand Down
13 changes: 9 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,8 @@ connections. These functions are used with your database driver to connect to
your AlloyDB instance.

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

[vpc]: https://cloud.google.com/vpc/docs/vpc
[alloydb-connectivity]: https://cloud.google.com/alloydb/docs/configure-connectivity
[psc]: https://cloud.google.com/vpc/docs/private-service-connect

### Synchronous Driver Usage

Expand Down Expand Up @@ -384,10 +386,13 @@ connector.connect(

The AlloyDB Python Connector by default will attempt to establish connections
to your instance's private IP. To change this, such as connecting to AlloyDB
over a public IP address, set the `ip_type` keyword argument when initializing
a `Connector()` or when calling `connector.connect()`.
over a public IP address or Private Service Connect (PSC), set the `ip_type`
keyword argument when initializing a `Connector()` or when calling
`connector.connect()`.

Possible values for `ip_type` are `"PRIVATE"` (default value), `"PUBLIC"`,
and `"PSC"`.

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

```python
Expand Down
6 changes: 6 additions & 0 deletions google/cloud/alloydb/connector/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,9 +129,15 @@ async def _get_metadata(
resp = await self._client.get(url, headers=headers, raise_for_status=True)
resp_dict = await resp.json()

# Remove trailing period from PSC DNS name.
psc_dns = resp_dict.get("pscDnsName")
if psc_dns:
psc_dns = psc_dns.rstrip(".")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was this necessary in Python?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried without it and it failed:

>       self._sslobj.do_handshake()
E       ssl.SSLError: [SSL: SSLV3_ALERT_UNEXPECTED_MESSAGE] sslv3 alert unexpected message (_ssl.c:1131)

https://github.com/GoogleCloudPlatform/alloydb-python-connector/actions/runs/8471675334/job/23489146452


return {
"PRIVATE": resp_dict.get("ipAddress"),
"PUBLIC": resp_dict.get("publicIpAddress"),
"PSC": psc_dns,
}

async def _get_client_certificate(
Expand Down
1 change: 1 addition & 0 deletions google/cloud/alloydb/connector/instance.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ class IPTypes(Enum):

PUBLIC: str = "PUBLIC"
PRIVATE: str = "PRIVATE"
PSC: str = "PSC"

@classmethod
def _missing_(cls, value: object) -> None:
Expand Down
3 changes: 2 additions & 1 deletion google/cloud/alloydb/connector/refresh.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,8 @@ def __init__(
self.ip_addrs = ip_addrs
# create TLS context
self.context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
# update ssl.PROTOCOL_TLS_CLIENT default
# TODO: Set check_hostname to True to verify the identity in the
# certificate once PSC DNS is populated in all existing clusters.
self.context.check_hostname = False
# force TLSv1.3
self.context.minimum_version = ssl.TLSVersion.TLSv1_3
Expand Down
102 changes: 102 additions & 0 deletions tests/system/test_asyncpg_psc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# Copyright 2024 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import os
from typing import Tuple

# [START alloydb_sqlalchemy_connect_async_connector_psc]
jackwotherspoon marked this conversation as resolved.
Show resolved Hide resolved
import asyncpg
import pytest
import sqlalchemy
import sqlalchemy.ext.asyncio

from google.cloud.alloydb.connector import AsyncConnector


async def create_sqlalchemy_engine(
inst_uri: str,
user: str,
password: str,
db: str,
) -> Tuple[sqlalchemy.ext.asyncio.engine.AsyncEngine, AsyncConnector]:
"""Creates a connection pool for an AlloyDB instance and returns the pool
and the connector. Callers are responsible for closing the pool and the
connector.

A sample invocation looks like:

engine, connector = await create_sqlalchemy_engine(
inst_uri,
user,
password,
db,
)
async with engine.connect() as conn:
time = await conn.execute(sqlalchemy.text("SELECT NOW()")).fetchone()
curr_time = time[0]
# do something with query result
await connector.close()

Args:
instance_uri (str):
The instance URI specifies the instance relative to the project,
region, and cluster. For example:
"projects/my-project/locations/us-central1/clusters/my-cluster/instances/my-instance"
user (str):
The database user name, e.g., postgres
password (str):
The database user's password, e.g., secret-password
db_name (str):
The name of the database, e.g., mydb
"""
connector = AsyncConnector()

async def getconn() -> asyncpg.Connection:
conn: asyncpg.Connection = await connector.connect(
inst_uri,
"asyncpg",
user=user,
password=password,
db=db,
ip_type="PSC",
)
return conn

# create SQLAlchemy connection pool
engine = sqlalchemy.ext.asyncio.create_async_engine(
"postgresql+asyncpg://",
async_creator=getconn,
execution_options={"isolation_level": "AUTOCOMMIT"},
)
return engine, connector


# [END alloydb_sqlalchemy_connect_async_connector_psc]


@pytest.mark.asyncio
async def test_connection_with_asyncpg() -> None:
"""Basic test to get time from database."""
inst_uri = os.environ["ALLOYDB_PSC_INSTANCE_URI"]
user = os.environ["ALLOYDB_USER"]
password = os.environ["ALLOYDB_PASS"]
db = os.environ["ALLOYDB_DB"]

pool, connector = await create_sqlalchemy_engine(inst_uri, user, password, db)

async with pool.connect() as conn:
res = (await conn.execute(sqlalchemy.text("SELECT 1"))).fetchone()
assert res[0] == 1

await connector.close()
100 changes: 100 additions & 0 deletions tests/system/test_pg8000_psc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# Copyright 2024 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from datetime import datetime
import os
from typing import Tuple

# [START alloydb_sqlalchemy_connect_connector_psc]
import pg8000
import sqlalchemy

from google.cloud.alloydb.connector import Connector


def create_sqlalchemy_engine(
inst_uri: str,
user: str,
password: str,
db: str,
) -> Tuple[sqlalchemy.engine.Engine, Connector]:
"""Creates a connection pool for an AlloyDB instance and returns the pool
and the connector. Callers are responsible for closing the pool and the
connector.

A sample invocation looks like:

engine, connector = create_sqlalchemy_engine(
inst_uri,
user,
password,
db,
)
with engine.connect() as conn:
time = conn.execute(sqlalchemy.text("SELECT NOW()")).fetchone()
conn.commit()
curr_time = time[0]
# do something with query result
connector.close()

Args:
instance_uri (str):
The instance URI specifies the instance relative to the project,
region, and cluster. For example:
"projects/my-project/locations/us-central1/clusters/my-cluster/instances/my-instance"
user (str):
The database user name, e.g., postgres
password (str):
The database user's password, e.g., secret-password
db_name (str):
The name of the database, e.g., mydb
"""
connector = Connector()

def getconn() -> pg8000.dbapi.Connection:
conn: pg8000.dbapi.Connection = connector.connect(
inst_uri,
"pg8000",
user=user,
password=password,
db=db,
ip_type="PSC",
)
return conn

# create SQLAlchemy connection pool
engine = sqlalchemy.create_engine(
"postgresql+pg8000://",
creator=getconn,
)
return engine, connector


# [END alloydb_sqlalchemy_connect_connector_psc]


def test_pg8000_time() -> None:
"""Basic test to get time from database."""
inst_uri = os.environ["ALLOYDB_PSC_INSTANCE_URI"]
user = os.environ["ALLOYDB_USER"]
password = os.environ["ALLOYDB_PASS"]
db = os.environ["ALLOYDB_DB"]

engine, connector = create_sqlalchemy_engine(inst_uri, user, password, db)
with engine.connect() as conn:
time = conn.execute(sqlalchemy.text("SELECT NOW()")).fetchone()
conn.commit()
curr_time = time[0]
assert type(curr_time) is datetime
connector.close()
20 changes: 18 additions & 2 deletions tests/unit/mocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from datetime import datetime
from datetime import timedelta
from datetime import timezone
import ipaddress
import ssl
import struct
from typing import Any, Callable, Dict, List, Optional, Tuple
Expand Down Expand Up @@ -60,14 +61,15 @@ def valid(self) -> bool:


def generate_cert(
common_name: str, expires_in: int = 60
common_name: str, expires_in: int = 60, server_cert: bool = False
) -> Tuple[x509.CertificateBuilder, rsa.RSAPrivateKey]:
"""
Generate a private key and cert object to be used in testing.

Args:
common_name (str): The Common Name for the certificate.
expires_in (int): Time in minutes until expiry of certificate.
server_cert (bool): Whether it is a server certificate.

Returns:
Tuple[x509.CertificateBuilder, rsa.RSAPrivateKey]
Expand Down Expand Up @@ -97,6 +99,17 @@ def generate_cert(
.not_valid_before(now)
.not_valid_after(expiration)
)
if server_cert:
cert = cert.add_extension(
x509.SubjectAlternativeName(
general_names=[
x509.IPAddress(ipaddress.ip_address("127.0.0.1")),
x509.IPAddress(ipaddress.ip_address("10.0.0.1")),
x509.DNSName("x.y.alloydb.goog."),
]
),
critical=False,
)
return cert, key


Expand All @@ -112,6 +125,7 @@ def __init__(
ip_addrs: Dict = {
"PRIVATE": "127.0.0.1",
"PUBLIC": "0.0.0.0",
"PSC": "x.y.alloydb.goog",
},
server_name: str = "00000000-0000-0000-0000-000000000000.server.alloydb",
cert_before: datetime = datetime.now(timezone.utc),
Expand All @@ -137,7 +151,9 @@ def __init__(
self.root_key, hashes.SHA256()
)
# build server cert
self.server_cert, self.server_key = generate_cert(self.server_name)
self.server_cert, self.server_key = generate_cert(
self.server_name, server_cert=True
)
# create server cert signed by root cert
self.server_cert = self.server_cert.sign(self.root_key, hashes.SHA256())

Expand Down
16 changes: 14 additions & 2 deletions tests/unit/test_async_connector.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,18 @@ async def test_AsyncConnector_init(credentials: FakeCredentials) -> None:
IPTypes.PUBLIC,
IPTypes.PUBLIC,
),
(
"psc",
IPTypes.PSC,
),
(
"PSC",
IPTypes.PSC,
),
(
IPTypes.PSC,
IPTypes.PSC,
),
],
)
async def test_AsyncConnector_init_ip_type(
Expand All @@ -90,7 +102,7 @@ async def test_AsyncConnector_init_bad_ip_type(credentials: FakeCredentials) ->
AsyncConnector(ip_type=bad_ip_type, credentials=credentials)
assert (
exc_info.value.args[0]
== f"Incorrect value for ip_type, got '{bad_ip_type}'. Want one of: 'PUBLIC', 'PRIVATE'."
== f"Incorrect value for ip_type, got '{bad_ip_type}'. Want one of: 'PUBLIC', 'PRIVATE', 'PSC'."
)


Expand Down Expand Up @@ -276,5 +288,5 @@ async def test_async_connect_bad_ip_type(
)
assert (
exc_info.value.args[0]
== f"Incorrect value for ip_type, got '{bad_ip_type}'. Want one of: 'PUBLIC', 'PRIVATE'."
== f"Incorrect value for ip_type, got '{bad_ip_type}'. Want one of: 'PUBLIC', 'PRIVATE', 'PSC'."
)
Loading