Skip to content

Commit 12773d7

Browse files
sagnghosgemini-code-assist[bot]gcf-merge-on-green[bot]
authored
feat: add TLS/mTLS support for experimental host (#1479)
Previously #1452 introduced changes to support python spanner client against spanner experimental host endpoints over insecure communication This PR extends those changes to support python spanner client connections to experimental host endpoints over TLS / mTLS connections as well. It also includes changes to run Integration Tests against experimental hosts across all 3 modes of network communication (plain-text, TLS, mTLS) To run IT tests against experimental host set below variables ``` export SPANNER_EXPERIMENTAL_HOST=localhost:15000 ``` For tls/mTLS set below additonal variables: - (mTLS/TLS) ``` export CA_CERTIFICATE=/tmp/experimental_host/ca-certificates/ca.crt ``` - (mTLS) ``` export CLIENT_CERTIFICATE=/tmp/experimental_host/certs/client.crt export CLIENT_KEY=/tmp/experimental_host/certs/client.key ``` Then we can run below command to tigger the tests: ``` python -m pytest -v -s --disable-warnings tests/system/ ``` --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: gcf-merge-on-green[bot] <60162190+gcf-merge-on-green[bot]@users.noreply.github.com>
1 parent 600c136 commit 12773d7

File tree

13 files changed

+197
-28
lines changed

13 files changed

+197
-28
lines changed

google/cloud/spanner_dbapi/connection.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -736,6 +736,10 @@ def connect(
736736
route_to_leader_enabled=True,
737737
database_role=None,
738738
experimental_host=None,
739+
use_plain_text=False,
740+
ca_certificate=None,
741+
client_certificate=None,
742+
client_key=None,
739743
**kwargs,
740744
):
741745
"""Creates a connection to a Google Cloud Spanner database.
@@ -789,6 +793,28 @@ def connect(
789793
:rtype: :class:`google.cloud.spanner_dbapi.connection.Connection`
790794
:returns: Connection object associated with the given Google Cloud Spanner
791795
resource.
796+
797+
:type experimental_host: str
798+
:param experimental_host: (Optional) The endpoint for a spanner experimental host deployment.
799+
This is intended only for experimental host spanner endpoints.
800+
801+
:type use_plain_text: bool
802+
:param use_plain_text: (Optional) Whether to use plain text for the connection.
803+
This is intended only for experimental host spanner endpoints.
804+
If not set, the default behavior is to use TLS.
805+
806+
:type ca_certificate: str
807+
:param ca_certificate: (Optional) The path to the CA certificate file used for TLS connection.
808+
This is intended only for experimental host spanner endpoints.
809+
This is mandatory if the experimental_host requires a TLS connection.
810+
:type client_certificate: str
811+
:param client_certificate: (Optional) The path to the client certificate file used for mTLS connection.
812+
This is intended only for experimental host spanner endpoints.
813+
This is mandatory if the experimental_host requires an mTLS connection.
814+
:type client_key: str
815+
:param client_key: (Optional) The path to the client key file used for mTLS connection.
816+
This is intended only for experimental host spanner endpoints.
817+
This is mandatory if the experimental_host requires an mTLS connection.
792818
"""
793819
if client is None:
794820
client_info = ClientInfo(
@@ -817,6 +843,10 @@ def connect(
817843
client_info=client_info,
818844
route_to_leader_enabled=route_to_leader_enabled,
819845
client_options=client_options,
846+
use_plain_text=use_plain_text,
847+
ca_certificate=ca_certificate,
848+
client_certificate=client_certificate,
849+
client_key=client_key,
820850
)
821851
else:
822852
if project is not None and client.project != project:

google/cloud/spanner_v1/_helpers.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -868,3 +868,65 @@ def _merge_Transaction_Options(
868868

869869
# Convert protobuf object back into a TransactionOptions instance
870870
return TransactionOptions(merged_pb)
871+
872+
873+
def _create_experimental_host_transport(
874+
transport_factory,
875+
experimental_host,
876+
use_plain_text,
877+
ca_certificate,
878+
client_certificate,
879+
client_key,
880+
interceptors=None,
881+
):
882+
"""Creates an experimental host transport for Spanner.
883+
884+
Args:
885+
transport_factory (type): The transport class to instantiate (e.g.
886+
`SpannerGrpcTransport`).
887+
experimental_host (str): The endpoint for the experimental host.
888+
use_plain_text (bool): Whether to use a plain text (insecure) connection.
889+
ca_certificate (str): Path to the CA certificate file for TLS.
890+
client_certificate (str): Path to the client certificate file for mTLS.
891+
client_key (str): Path to the client key file for mTLS.
892+
interceptors (list): Optional list of interceptors to add to the channel.
893+
894+
Returns:
895+
object: An instance of the transport class created by `transport_factory`.
896+
897+
Raises:
898+
ValueError: If TLS/mTLS configuration is invalid.
899+
"""
900+
import grpc
901+
from google.auth.credentials import AnonymousCredentials
902+
903+
channel = None
904+
if use_plain_text:
905+
channel = grpc.insecure_channel(target=experimental_host)
906+
elif ca_certificate:
907+
with open(ca_certificate, "rb") as f:
908+
ca_cert = f.read()
909+
if client_certificate and client_key:
910+
with open(client_certificate, "rb") as f:
911+
client_cert = f.read()
912+
with open(client_key, "rb") as f:
913+
private_key = f.read()
914+
ssl_creds = grpc.ssl_channel_credentials(
915+
root_certificates=ca_cert,
916+
private_key=private_key,
917+
certificate_chain=client_cert,
918+
)
919+
elif client_certificate or client_key:
920+
raise ValueError(
921+
"Both client_certificate and client_key must be provided for mTLS connection"
922+
)
923+
else:
924+
ssl_creds = grpc.ssl_channel_credentials(root_certificates=ca_cert)
925+
channel = grpc.secure_channel(experimental_host, ssl_creds)
926+
else:
927+
raise ValueError(
928+
"TLS/mTLS connection requires ca_certificate to be set for experimental_host"
929+
)
930+
if interceptors is not None:
931+
channel = grpc.intercept_channel(channel, *interceptors)
932+
return transport_factory(channel=channel, credentials=AnonymousCredentials())

google/cloud/spanner_v1/client.py

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,10 @@
5050
from google.cloud.spanner_v1 import __version__
5151
from google.cloud.spanner_v1 import ExecuteSqlRequest
5252
from google.cloud.spanner_v1 import DefaultTransactionOptions
53-
from google.cloud.spanner_v1._helpers import _merge_query_options
53+
from google.cloud.spanner_v1._helpers import (
54+
_create_experimental_host_transport,
55+
_merge_query_options,
56+
)
5457
from google.cloud.spanner_v1._helpers import _metadata_with_prefix
5558
from google.cloud.spanner_v1.instance import Instance
5659
from google.cloud.spanner_v1.metrics.constants import (
@@ -227,6 +230,30 @@ class Client(ClientWithProject):
227230
228231
:raises: :class:`ValueError <exceptions.ValueError>` if both ``read_only``
229232
and ``admin`` are :data:`True`
233+
234+
:type use_plain_text: bool
235+
:param use_plain_text: (Optional) Whether to use plain text for the connection.
236+
This is intended only for experimental host spanner endpoints.
237+
If set, this will override the `api_endpoint` in `client_options`.
238+
If not set, the default behavior is to use TLS.
239+
240+
:type ca_certificate: str
241+
:param ca_certificate: (Optional) The path to the CA certificate file used for TLS connection.
242+
This is intended only for experimental host spanner endpoints.
243+
If set, this will override the `api_endpoint` in `client_options`.
244+
This is mandatory if the experimental_host requires a TLS connection.
245+
246+
:type client_certificate: str
247+
:param client_certificate: (Optional) The path to the client certificate file used for mTLS connection.
248+
This is intended only for experimental host spanner endpoints.
249+
If set, this will override the `api_endpoint` in `client_options`.
250+
This is mandatory if the experimental_host requires a mTLS connection.
251+
252+
:type client_key: str
253+
:param client_key: (Optional) The path to the client key file used for mTLS connection.
254+
This is intended only for experimental host spanner endpoints.
255+
If set, this will override the `api_endpoint` in `client_options`.
256+
This is mandatory if the experimental_host requires a mTLS connection.
230257
"""
231258

232259
_instance_admin_api = None
@@ -251,6 +278,10 @@ def __init__(
251278
default_transaction_options: Optional[DefaultTransactionOptions] = None,
252279
experimental_host=None,
253280
disable_builtin_metrics=False,
281+
use_plain_text=False,
282+
ca_certificate=None,
283+
client_certificate=None,
284+
client_key=None,
254285
):
255286
self._emulator_host = _get_spanner_emulator_host()
256287
self._experimental_host = experimental_host
@@ -265,6 +296,12 @@ def __init__(
265296
if self._emulator_host:
266297
credentials = AnonymousCredentials()
267298
elif self._experimental_host:
299+
# For all experimental host endpoints project is default
300+
project = "default"
301+
self._use_plain_text = use_plain_text
302+
self._ca_certificate = ca_certificate
303+
self._client_certificate = client_certificate
304+
self._client_key = client_key
268305
credentials = AnonymousCredentials()
269306
elif isinstance(credentials, AnonymousCredentials):
270307
self._emulator_host = self._client_options.api_endpoint
@@ -361,8 +398,13 @@ def instance_admin_api(self):
361398
transport=transport,
362399
)
363400
elif self._experimental_host:
364-
transport = InstanceAdminGrpcTransport(
365-
channel=grpc.insecure_channel(target=self._experimental_host)
401+
transport = _create_experimental_host_transport(
402+
InstanceAdminGrpcTransport,
403+
self._experimental_host,
404+
self._use_plain_text,
405+
self._ca_certificate,
406+
self._client_certificate,
407+
self._client_key,
366408
)
367409
self._instance_admin_api = InstanceAdminClient(
368410
client_info=self._client_info,
@@ -391,8 +433,13 @@ def database_admin_api(self):
391433
transport=transport,
392434
)
393435
elif self._experimental_host:
394-
transport = DatabaseAdminGrpcTransport(
395-
channel=grpc.insecure_channel(target=self._experimental_host)
436+
transport = _create_experimental_host_transport(
437+
DatabaseAdminGrpcTransport,
438+
self._experimental_host,
439+
self._use_plain_text,
440+
self._ca_certificate,
441+
self._client_certificate,
442+
self._client_key,
396443
)
397444
self._database_admin_api = DatabaseAdminClient(
398445
client_info=self._client_info,
@@ -539,7 +586,6 @@ def instance(
539586
self._emulator_host,
540587
labels,
541588
processing_units,
542-
self._experimental_host,
543589
)
544590

545591
def list_instances(self, filter_="", page_size=None):

google/cloud/spanner_v1/database.py

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
_metadata_with_request_id,
5757
_augment_errors_with_request_id,
5858
_metadata_with_request_id_and_req_id,
59+
_create_experimental_host_transport,
5960
)
6061
from google.cloud.spanner_v1.batch import Batch
6162
from google.cloud.spanner_v1.batch import MutationGroups
@@ -198,17 +199,15 @@ def __init__(
198199
)
199200
self._proto_descriptors = proto_descriptors
200201
self._channel_id = 0 # It'll be created when _spanner_api is created.
202+
self._experimental_host = self._instance._client._experimental_host
201203

202204
if pool is None:
203205
pool = BurstyPool(database_role=database_role)
204206

205207
self._pool = pool
206208
pool.bind(self)
207-
is_experimental_host = self._instance.experimental_host is not None
208209

209-
self._sessions_manager = DatabaseSessionsManager(
210-
self, pool, is_experimental_host
211-
)
210+
self._sessions_manager = DatabaseSessionsManager(self, pool)
212211

213212
@classmethod
214213
def from_pb(cls, database_pb, instance, pool=None):
@@ -453,9 +452,14 @@ def spanner_api(self):
453452
client_info=client_info, transport=transport
454453
)
455454
return self._spanner_api
456-
if self._instance.experimental_host is not None:
457-
transport = SpannerGrpcTransport(
458-
channel=grpc.insecure_channel(self._instance.experimental_host)
455+
if self._experimental_host is not None:
456+
transport = _create_experimental_host_transport(
457+
SpannerGrpcTransport,
458+
self._experimental_host,
459+
self._instance._client._use_plain_text,
460+
self._instance._client._ca_certificate,
461+
self._instance._client._client_certificate,
462+
self._instance._client._client_key,
459463
)
460464
self._spanner_api = SpannerClient(
461465
client_info=client_info,

google/cloud/spanner_v1/database_sessions_manager.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,9 @@ class DatabaseSessionsManager(object):
6262
_MAINTENANCE_THREAD_POLLING_INTERVAL = timedelta(minutes=10)
6363
_MAINTENANCE_THREAD_REFRESH_INTERVAL = timedelta(days=7)
6464

65-
def __init__(self, database, pool, is_experimental_host: bool = False):
65+
def __init__(self, database, pool):
6666
self._database = database
6767
self._pool = pool
68-
self._is_experimental_host = is_experimental_host
6968

7069
# Declare multiplexed session attributes. When a multiplexed session for the
7170
# database session manager is created, a maintenance thread is initialized to
@@ -89,7 +88,8 @@ def get_session(self, transaction_type: TransactionType) -> Session:
8988

9089
session = (
9190
self._get_multiplexed_session()
92-
if self._use_multiplexed(transaction_type) or self._is_experimental_host
91+
if self._use_multiplexed(transaction_type)
92+
or self._database._experimental_host is not None
9393
else self._pool.get()
9494
)
9595

google/cloud/spanner_v1/instance.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,6 @@ def __init__(
122122
emulator_host=None,
123123
labels=None,
124124
processing_units=None,
125-
experimental_host=None,
126125
):
127126
self.instance_id = instance_id
128127
self._client = client
@@ -143,7 +142,6 @@ def __init__(
143142
self._node_count = processing_units // PROCESSING_UNITS_PER_NODE
144143
self.display_name = display_name or instance_id
145144
self.emulator_host = emulator_host
146-
self.experimental_host = experimental_host
147145
if labels is None:
148146
labels = {}
149147
self.labels = labels

google/cloud/spanner_v1/testing/database_test.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import google.auth.credentials
1818
from google.cloud.spanner_admin_database_v1 import DatabaseDialect
1919
from google.cloud.spanner_v1 import SpannerClient
20+
from google.cloud.spanner_v1._helpers import _create_experimental_host_transport
2021
from google.cloud.spanner_v1.database import Database, SPANNER_DATA_SCOPE
2122
from google.cloud.spanner_v1.services.spanner.transports import (
2223
SpannerGrpcTransport,
@@ -86,12 +87,18 @@ def spanner_api(self):
8687
transport=transport,
8788
)
8889
return self._spanner_api
89-
if self._instance.experimental_host is not None:
90-
channel = grpc.insecure_channel(self._instance.experimental_host)
90+
if self._experimental_host is not None:
9191
self._x_goog_request_id_interceptor = XGoogRequestIDHeaderInterceptor()
9292
self._interceptors.append(self._x_goog_request_id_interceptor)
93-
channel = grpc.intercept_channel(channel, *self._interceptors)
94-
transport = SpannerGrpcTransport(channel=channel)
93+
transport = _create_experimental_host_transport(
94+
SpannerGrpcTransport,
95+
self._experimental_host,
96+
self._instance._client._use_plain_text,
97+
self._instance._client._ca_certificate,
98+
self._instance._client._client_certificate,
99+
self._instance._client._client_key,
100+
self._interceptors,
101+
)
95102
self._spanner_api = SpannerClient(
96103
client_info=client_info,
97104
transport=transport,

tests/system/_helpers.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,14 @@
6060
EXPERIMENTAL_HOST = os.getenv(USE_EXPERIMENTAL_HOST_ENVVAR)
6161
USE_EXPERIMENTAL_HOST = EXPERIMENTAL_HOST is not None
6262

63-
EXPERIMENTAL_HOST_PROJECT = "default"
63+
CA_CERTIFICATE_ENVVAR = "CA_CERTIFICATE"
64+
CA_CERTIFICATE = os.getenv(CA_CERTIFICATE_ENVVAR)
65+
CLIENT_CERTIFICATE_ENVVAR = "CLIENT_CERTIFICATE"
66+
CLIENT_CERTIFICATE = os.getenv(CLIENT_CERTIFICATE_ENVVAR)
67+
CLIENT_KEY_ENVVAR = "CLIENT_KEY"
68+
CLIENT_KEY = os.getenv(CLIENT_KEY_ENVVAR)
69+
USE_PLAIN_TEXT = CA_CERTIFICATE is None
70+
6471
EXPERIMENTAL_HOST_INSTANCE = "default"
6572

6673
DDL_STATEMENTS = (

tests/system/conftest.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,10 @@ def spanner_client():
115115

116116
credentials = AnonymousCredentials()
117117
return spanner_v1.Client(
118-
project=_helpers.EXPERIMENTAL_HOST_PROJECT,
118+
use_plain_text=_helpers.USE_PLAIN_TEXT,
119+
ca_certificate=_helpers.CA_CERTIFICATE,
120+
client_certificate=_helpers.CLIENT_CERTIFICATE,
121+
client_key=_helpers.CLIENT_KEY,
119122
credentials=credentials,
120123
experimental_host=_helpers.EXPERIMENTAL_HOST,
121124
)

tests/system/test_dbapi.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1442,6 +1442,10 @@ def test_user_agent(self, shared_instance, dbapi_database):
14421442
experimental_host=_helpers.EXPERIMENTAL_HOST
14431443
if _helpers.USE_EXPERIMENTAL_HOST
14441444
else None,
1445+
use_plain_text=_helpers.USE_PLAIN_TEXT,
1446+
ca_certificate=_helpers.CA_CERTIFICATE,
1447+
client_certificate=_helpers.CLIENT_CERTIFICATE,
1448+
client_key=_helpers.CLIENT_KEY,
14451449
)
14461450
assert (
14471451
conn.instance._client._client_info.user_agent

0 commit comments

Comments
 (0)