From 25e0fcfb32d100be0cba3799d62543569dd2d2c6 Mon Sep 17 00:00:00 2001 From: Yoshi Automation Bot Date: Mon, 20 Apr 2020 19:10:02 -0700 Subject: [PATCH] feat: add mTLS support (via synth) (#4) --- .flake8 | 16 ++ .github/ISSUE_TEMPLATE/bug_report.md | 3 +- CONTRIBUTING.rst | 15 +- MANIFEST.in | 16 ++ google/cloud/servicedirectory/__init__.py | 2 +- .../servicedirectory_v1beta1/__init__.py | 2 +- .../services/__init__.py | 2 +- .../services/lookup_service/__init__.py | 2 +- .../services/lookup_service/client.py | 85 +++++- .../lookup_service/transports/__init__.py | 2 +- .../lookup_service/transports/base.py | 2 +- .../lookup_service/transports/grpc.py | 57 +++- .../services/registration_service/__init__.py | 2 +- .../services/registration_service/client.py | 165 ++++++++--- .../services/registration_service/pagers.py | 2 +- .../transports/__init__.py | 2 +- .../registration_service/transports/base.py | 2 +- .../registration_service/transports/grpc.py | 57 +++- .../types/__init__.py | 2 +- .../types/endpoint.py | 2 +- .../types/lookup_service.py | 2 +- .../types/namespace.py | 2 +- .../types/registration_service.py | 2 +- .../servicedirectory_v1beta1/types/service.py | 2 +- mypy.ini | 2 +- noxfile.py | 2 +- scripts/fixup_keywords.py | 2 +- setup.cfg | 16 ++ setup.py | 2 +- synth.metadata | 16 +- .../test_lookup_service.py | 201 ++++++++++++- .../test_registration_service.py | 272 ++++++++++++++++-- 32 files changed, 811 insertions(+), 148 deletions(-) diff --git a/.flake8 b/.flake8 index ed758f48..49e82698 100644 --- a/.flake8 +++ b/.flake8 @@ -1,3 +1,19 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2020 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. + # Generated by synthtool. DO NOT EDIT! [flake8] ignore = E203, E266, E501, W503, F401, F841 diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 9488d1d2..af76fb4e 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -11,8 +11,7 @@ Thanks for stopping by to let us know something could be better! Please run down the following list and make sure you've tried the usual "quick fixes": - Search the issues already opened: https://github.com/googleapis/python-service-directory/issues - - Search the issues on our "catch-all" repository: https://github.com/googleapis/google-cloud-python - - Search StackOverflow: http://stackoverflow.com/questions/tagged/google-cloud-platform+python + - Search StackOverflow: https://stackoverflow.com/questions/tagged/google-cloud-platform+python If you are still having issues, please be sure to include as much information as possible: diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index e83edce2..7d38431f 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -22,7 +22,7 @@ In order to add a feature: documentation. - The feature must work fully on the following CPython versions: 2.7, - 3.5, 3.6, and 3.7 on both UNIX and Windows. + 3.5, 3.6, 3.7 and 3.8 on both UNIX and Windows. - The feature must not add unnecessary dependencies (where "unnecessary" is of course subjective, but new dependencies should @@ -214,26 +214,18 @@ We support: - `Python 3.5`_ - `Python 3.6`_ - `Python 3.7`_ +- `Python 3.8`_ .. _Python 3.5: https://docs.python.org/3.5/ .. _Python 3.6: https://docs.python.org/3.6/ .. _Python 3.7: https://docs.python.org/3.7/ +.. _Python 3.8: https://docs.python.org/3.8/ Supported versions can be found in our ``noxfile.py`` `config`_. .. _config: https://github.com/googleapis/python-service-directory/blob/master/noxfile.py -We explicitly decided not to support `Python 2.5`_ due to `decreased usage`_ -and lack of continuous integration `support`_. - -.. _Python 2.5: https://docs.python.org/2.5/ -.. _decreased usage: https://caremad.io/2013/10/a-look-at-pypi-downloads/ -.. _support: https://blog.travis-ci.com/2013-11-18-upcoming-build-environment-updates/ - -We have `dropped 2.6`_ as a supported version as well since Python 2.6 is no -longer supported by the core development team. - Python 2.7 support is deprecated. All code changes should maintain Python 2.7 compatibility until January 1, 2020. We also explicitly decided to support Python 3 beginning with version @@ -247,7 +239,6 @@ We also explicitly decided to support Python 3 beginning with version .. _prominent: https://docs.djangoproject.com/en/1.9/faq/install/#what-python-version-can-i-use-with-django .. _projects: http://flask.pocoo.org/docs/0.10/python3/ .. _Unicode literal support: https://www.python.org/dev/peps/pep-0414/ -.. _dropped 2.6: https://github.com/googleapis/google-cloud-python/issues/995 ********** Versioning diff --git a/MANIFEST.in b/MANIFEST.in index cd011be2..68855abc 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,19 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2020 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. + # Generated by synthtool. DO NOT EDIT! include README.rst LICENSE recursive-include google *.json *.proto diff --git a/google/cloud/servicedirectory/__init__.py b/google/cloud/servicedirectory/__init__.py index 9bb31939..08bca5ae 100644 --- a/google/cloud/servicedirectory/__init__.py +++ b/google/cloud/servicedirectory/__init__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright (C) 2019 Google LLC +# Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/google/cloud/servicedirectory_v1beta1/__init__.py b/google/cloud/servicedirectory_v1beta1/__init__.py index 35128e2c..86aff12a 100644 --- a/google/cloud/servicedirectory_v1beta1/__init__.py +++ b/google/cloud/servicedirectory_v1beta1/__init__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright (C) 2019 Google LLC +# Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/google/cloud/servicedirectory_v1beta1/services/__init__.py b/google/cloud/servicedirectory_v1beta1/services/__init__.py index 2c56c537..42ffdf2b 100644 --- a/google/cloud/servicedirectory_v1beta1/services/__init__.py +++ b/google/cloud/servicedirectory_v1beta1/services/__init__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright (C) 2019 Google LLC +# Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/google/cloud/servicedirectory_v1beta1/services/lookup_service/__init__.py b/google/cloud/servicedirectory_v1beta1/services/lookup_service/__init__.py index b753ed56..20c31ae5 100644 --- a/google/cloud/servicedirectory_v1beta1/services/lookup_service/__init__.py +++ b/google/cloud/servicedirectory_v1beta1/services/lookup_service/__init__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright (C) 2019 Google LLC +# Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/google/cloud/servicedirectory_v1beta1/services/lookup_service/client.py b/google/cloud/servicedirectory_v1beta1/services/lookup_service/client.py index 29a47af0..99540270 100644 --- a/google/cloud/servicedirectory_v1beta1/services/lookup_service/client.py +++ b/google/cloud/servicedirectory_v1beta1/services/lookup_service/client.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright (C) 2019 Google LLC +# Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -16,7 +16,8 @@ # from collections import OrderedDict -from typing import Dict, Sequence, Tuple, Type, Union +import re +from typing import Callable, Dict, Sequence, Tuple, Type, Union import pkg_resources import google.api_core.client_options as ClientOptions # type: ignore @@ -66,8 +67,38 @@ def get_transport_class(cls, label: str = None) -> Type[LookupServiceTransport]: class LookupServiceClient(metaclass=LookupServiceClientMeta): """Service Directory API for looking up service data at runtime.""" - DEFAULT_OPTIONS = ClientOptions.ClientOptions( - api_endpoint="servicedirectory.googleapis.com" + @staticmethod + def _get_default_mtls_endpoint(api_endpoint): + """Convert api endpoint to mTLS endpoint. + Convert "*.sandbox.googleapis.com" and "*.googleapis.com" to + "*.mtls.sandbox.googleapis.com" and "*.mtls.googleapis.com" respectively. + Args: + api_endpoint (Optional[str]): the api endpoint to convert. + Returns: + str: converted mTLS api endpoint. + """ + if not api_endpoint: + return api_endpoint + + mtls_endpoint_re = re.compile( + r"(?P[^.]+)(?P\.mtls)?(?P\.sandbox)?(?P\.googleapis\.com)?" + ) + + m = mtls_endpoint_re.match(api_endpoint) + name, mtls, sandbox, googledomain = m.groups() + if mtls or not googledomain: + return api_endpoint + + if sandbox: + return api_endpoint.replace( + "sandbox.googleapis.com", "mtls.sandbox.googleapis.com" + ) + + return api_endpoint.replace(".googleapis.com", ".mtls.googleapis.com") + + DEFAULT_ENDPOINT = "servicedirectory.googleapis.com" + DEFAULT_MTLS_ENDPOINT = _get_default_mtls_endpoint.__func__( # type: ignore + DEFAULT_ENDPOINT ) @classmethod @@ -95,7 +126,7 @@ def __init__( *, credentials: credentials.Credentials = None, transport: Union[str, LookupServiceTransport] = None, - client_options: ClientOptions = DEFAULT_OPTIONS, + client_options: ClientOptions = None, ) -> None: """Instantiate the lookup service client. @@ -109,6 +140,17 @@ def __init__( transport to use. If set to None, a transport is chosen automatically. client_options (ClientOptions): Custom options for the client. + (1) The ``api_endpoint`` property can be used to override the + default endpoint provided by the client. + (2) If ``transport`` argument is None, ``client_options`` can be + used to create a mutual TLS transport. If ``client_cert_source`` + is provided, mutual TLS transport will be created with the given + ``api_endpoint`` or the default mTLS endpoint, and the client + SSL credentials obtained from ``client_cert_source``. + + Raises: + google.auth.exceptions.MutualTlsChannelError: If mutual TLS transport + creation failed for any reason. """ if isinstance(client_options, dict): client_options = ClientOptions.from_dict(client_options) @@ -117,17 +159,46 @@ def __init__( # Ordinarily, we provide the transport, but allowing a custom transport # instance provides an extensibility point for unusual situations. if isinstance(transport, LookupServiceTransport): + # transport is a LookupServiceTransport instance. if credentials: raise ValueError( "When providing a transport instance, " "provide its credentials directly." ) self._transport = transport - else: + elif client_options is None or ( + client_options.api_endpoint is None + and client_options.client_cert_source is None + ): + # Don't trigger mTLS if we get an empty ClientOptions. Transport = type(self).get_transport_class(transport) self._transport = Transport( + credentials=credentials, host=self.DEFAULT_ENDPOINT + ) + else: + # We have a non-empty ClientOptions. If client_cert_source is + # provided, trigger mTLS with user provided endpoint or the default + # mTLS endpoint. + if client_options.client_cert_source: + api_mtls_endpoint = ( + client_options.api_endpoint + if client_options.api_endpoint + else self.DEFAULT_MTLS_ENDPOINT + ) + else: + api_mtls_endpoint = None + + api_endpoint = ( + client_options.api_endpoint + if client_options.api_endpoint + else self.DEFAULT_ENDPOINT + ) + + self._transport = LookupServiceGrpcTransport( credentials=credentials, - host=client_options.api_endpoint or "servicedirectory.googleapis.com", + host=api_endpoint, + api_mtls_endpoint=api_mtls_endpoint, + client_cert_source=client_options.client_cert_source, ) def resolve_service( diff --git a/google/cloud/servicedirectory_v1beta1/services/lookup_service/transports/__init__.py b/google/cloud/servicedirectory_v1beta1/services/lookup_service/transports/__init__.py index e1d3903d..3384ec65 100644 --- a/google/cloud/servicedirectory_v1beta1/services/lookup_service/transports/__init__.py +++ b/google/cloud/servicedirectory_v1beta1/services/lookup_service/transports/__init__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright (C) 2019 Google LLC +# Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/google/cloud/servicedirectory_v1beta1/services/lookup_service/transports/base.py b/google/cloud/servicedirectory_v1beta1/services/lookup_service/transports/base.py index 52b64aaf..ffb6853d 100644 --- a/google/cloud/servicedirectory_v1beta1/services/lookup_service/transports/base.py +++ b/google/cloud/servicedirectory_v1beta1/services/lookup_service/transports/base.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright (C) 2019 Google LLC +# Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/google/cloud/servicedirectory_v1beta1/services/lookup_service/transports/grpc.py b/google/cloud/servicedirectory_v1beta1/services/lookup_service/transports/grpc.py index 017fa80c..8bbf277d 100644 --- a/google/cloud/servicedirectory_v1beta1/services/lookup_service/transports/grpc.py +++ b/google/cloud/servicedirectory_v1beta1/services/lookup_service/transports/grpc.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright (C) 2019 Google LLC +# Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -15,10 +15,12 @@ # limitations under the License. # -from typing import Callable, Dict +from typing import Callable, Dict, Tuple from google.api_core import grpc_helpers # type: ignore from google.auth import credentials # type: ignore +from google.auth.transport.grpc import SslCredentials # type: ignore + import grpc # type: ignore @@ -45,7 +47,9 @@ def __init__( *, host: str = "servicedirectory.googleapis.com", credentials: credentials.Credentials = None, - channel: grpc.Channel = None + channel: grpc.Channel = None, + api_mtls_endpoint: str = None, + client_cert_source: Callable[[], Tuple[bytes, bytes]] = None ) -> None: """Instantiate the transport. @@ -59,20 +63,55 @@ def __init__( This argument is ignored if ``channel`` is provided. channel (Optional[grpc.Channel]): A ``Channel`` instance through which to make calls. + api_mtls_endpoint (Optional[str]): The mutual TLS endpoint. If + provided, it overrides the ``host`` argument and tries to create + a mutual TLS channel with client SSL credentials from + ``client_cert_source`` or applicatin default SSL credentials. + client_cert_source (Optional[Callable[[], Tuple[bytes, bytes]]]): A + callback to provide client SSL certificate bytes and private key + bytes, both in PEM format. It is ignored if ``api_mtls_endpoint`` + is None. + + Raises: + google.auth.exceptions.MutualTlsChannelError: If mutual TLS transport + creation failed for any reason. """ - # Sanity check: Ensure that channel and credentials are not both - # provided. if channel: + # Sanity check: Ensure that channel and credentials are not both + # provided. credentials = False + # If a channel was explicitly provided, set it. + self._grpc_channel = channel + elif api_mtls_endpoint: + host = ( + api_mtls_endpoint + if ":" in api_mtls_endpoint + else api_mtls_endpoint + ":443" + ) + + # Create SSL credentials with client_cert_source or application + # default SSL credentials. + if client_cert_source: + cert, key = client_cert_source() + ssl_credentials = grpc.ssl_channel_credentials( + certificate_chain=cert, private_key=key + ) + else: + ssl_credentials = SslCredentials().ssl_credentials + + # create a new channel. The provided one is ignored. + self._grpc_channel = grpc_helpers.create_channel( + host, + credentials=credentials, + ssl_credentials=ssl_credentials, + scopes=self.AUTH_SCOPES, + ) + # Run the base constructor. super().__init__(host=host, credentials=credentials) self._stubs = {} # type: Dict[str, Callable] - # If a channel was explicitly provided, set it. - if channel: - self._grpc_channel = channel - @classmethod def create_channel( cls, diff --git a/google/cloud/servicedirectory_v1beta1/services/registration_service/__init__.py b/google/cloud/servicedirectory_v1beta1/services/registration_service/__init__.py index 767765a4..35eda798 100644 --- a/google/cloud/servicedirectory_v1beta1/services/registration_service/__init__.py +++ b/google/cloud/servicedirectory_v1beta1/services/registration_service/__init__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright (C) 2019 Google LLC +# Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/google/cloud/servicedirectory_v1beta1/services/registration_service/client.py b/google/cloud/servicedirectory_v1beta1/services/registration_service/client.py index 8ebb8479..c6177106 100644 --- a/google/cloud/servicedirectory_v1beta1/services/registration_service/client.py +++ b/google/cloud/servicedirectory_v1beta1/services/registration_service/client.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright (C) 2019 Google LLC +# Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -16,7 +16,8 @@ # from collections import OrderedDict -from typing import Dict, Sequence, Tuple, Type, Union +import re +from typing import Callable, Dict, Sequence, Tuple, Type, Union import pkg_resources import google.api_core.client_options as ClientOptions # type: ignore @@ -95,8 +96,38 @@ class RegistrationServiceClient(metaclass=RegistrationServiceClientMeta): ``projects/*/locations/*/namespaces/*/services/*/endpoints/*``. """ - DEFAULT_OPTIONS = ClientOptions.ClientOptions( - api_endpoint="servicedirectory.googleapis.com" + @staticmethod + def _get_default_mtls_endpoint(api_endpoint): + """Convert api endpoint to mTLS endpoint. + Convert "*.sandbox.googleapis.com" and "*.googleapis.com" to + "*.mtls.sandbox.googleapis.com" and "*.mtls.googleapis.com" respectively. + Args: + api_endpoint (Optional[str]): the api endpoint to convert. + Returns: + str: converted mTLS api endpoint. + """ + if not api_endpoint: + return api_endpoint + + mtls_endpoint_re = re.compile( + r"(?P[^.]+)(?P\.mtls)?(?P\.sandbox)?(?P\.googleapis\.com)?" + ) + + m = mtls_endpoint_re.match(api_endpoint) + name, mtls, sandbox, googledomain = m.groups() + if mtls or not googledomain: + return api_endpoint + + if sandbox: + return api_endpoint.replace( + "sandbox.googleapis.com", "mtls.sandbox.googleapis.com" + ) + + return api_endpoint.replace(".googleapis.com", ".mtls.googleapis.com") + + DEFAULT_ENDPOINT = "servicedirectory.googleapis.com" + DEFAULT_MTLS_ENDPOINT = _get_default_mtls_endpoint.__func__( # type: ignore + DEFAULT_ENDPOINT ) @classmethod @@ -119,6 +150,28 @@ def from_service_account_file(cls, filename: str, *args, **kwargs): from_service_account_json = from_service_account_file + @staticmethod + def endpoint_path( + project: str, location: str, namespace: str, service: str, endpoint: str + ) -> str: + """Return a fully-qualified endpoint string.""" + return "projects/{project}/locations/{location}/namespaces/{namespace}/services/{service}/endpoints/{endpoint}".format( + project=project, + location=location, + namespace=namespace, + service=service, + endpoint=endpoint, + ) + + @staticmethod + def parse_endpoint_path(path: str) -> Dict[str, str]: + """Parse a endpoint path into its component segments.""" + m = re.match( + r"^projects/(?P.+?)/locations/(?P.+?)/namespaces/(?P.+?)/services/(?P.+?)/endpoints/(?P.+?)$", + path, + ) + return m.groupdict() if m else {} + @staticmethod def service_path(project: str, location: str, namespace: str, service: str) -> str: """Return a fully-qualified service string.""" @@ -126,6 +179,15 @@ def service_path(project: str, location: str, namespace: str, service: str) -> s project=project, location=location, namespace=namespace, service=service ) + @staticmethod + def parse_service_path(path: str) -> Dict[str, str]: + """Parse a service path into its component segments.""" + m = re.match( + r"^projects/(?P.+?)/locations/(?P.+?)/namespaces/(?P.+?)/services/(?P.+?)$", + path, + ) + return m.groupdict() if m else {} + @staticmethod def namespace_path(project: str, location: str, namespace: str) -> str: """Return a fully-qualified namespace string.""" @@ -134,24 +196,20 @@ def namespace_path(project: str, location: str, namespace: str) -> str: ) @staticmethod - def endpoint_path( - project: str, location: str, namespace: str, service: str, endpoint: str - ) -> str: - """Return a fully-qualified endpoint string.""" - return "projects/{project}/locations/{location}/namespaces/{namespace}/services/{service}/endpoints/{endpoint}".format( - project=project, - location=location, - namespace=namespace, - service=service, - endpoint=endpoint, + def parse_namespace_path(path: str) -> Dict[str, str]: + """Parse a namespace path into its component segments.""" + m = re.match( + r"^projects/(?P.+?)/locations/(?P.+?)/namespaces/(?P.+?)$", + path, ) + return m.groupdict() if m else {} def __init__( self, *, credentials: credentials.Credentials = None, transport: Union[str, RegistrationServiceTransport] = None, - client_options: ClientOptions = DEFAULT_OPTIONS, + client_options: ClientOptions = None, ) -> None: """Instantiate the registration service client. @@ -165,6 +223,17 @@ def __init__( transport to use. If set to None, a transport is chosen automatically. client_options (ClientOptions): Custom options for the client. + (1) The ``api_endpoint`` property can be used to override the + default endpoint provided by the client. + (2) If ``transport`` argument is None, ``client_options`` can be + used to create a mutual TLS transport. If ``client_cert_source`` + is provided, mutual TLS transport will be created with the given + ``api_endpoint`` or the default mTLS endpoint, and the client + SSL credentials obtained from ``client_cert_source``. + + Raises: + google.auth.exceptions.MutualTlsChannelError: If mutual TLS transport + creation failed for any reason. """ if isinstance(client_options, dict): client_options = ClientOptions.from_dict(client_options) @@ -173,17 +242,46 @@ def __init__( # Ordinarily, we provide the transport, but allowing a custom transport # instance provides an extensibility point for unusual situations. if isinstance(transport, RegistrationServiceTransport): + # transport is a RegistrationServiceTransport instance. if credentials: raise ValueError( "When providing a transport instance, " "provide its credentials directly." ) self._transport = transport - else: + elif client_options is None or ( + client_options.api_endpoint is None + and client_options.client_cert_source is None + ): + # Don't trigger mTLS if we get an empty ClientOptions. Transport = type(self).get_transport_class(transport) self._transport = Transport( + credentials=credentials, host=self.DEFAULT_ENDPOINT + ) + else: + # We have a non-empty ClientOptions. If client_cert_source is + # provided, trigger mTLS with user provided endpoint or the default + # mTLS endpoint. + if client_options.client_cert_source: + api_mtls_endpoint = ( + client_options.api_endpoint + if client_options.api_endpoint + else self.DEFAULT_MTLS_ENDPOINT + ) + else: + api_mtls_endpoint = None + + api_endpoint = ( + client_options.api_endpoint + if client_options.api_endpoint + else self.DEFAULT_ENDPOINT + ) + + self._transport = RegistrationServiceGrpcTransport( credentials=credentials, - host=client_options.api_endpoint or "servicedirectory.googleapis.com", + host=api_endpoint, + api_mtls_endpoint=api_mtls_endpoint, + client_cert_source=client_options.client_cert_source, ) def create_namespace( @@ -257,14 +355,11 @@ def create_namespace( # If we have keyword arguments corresponding to fields on the # request, apply these. + if parent is not None: request.parent = parent - # If we have keyword arguments corresponding to fields on the - # request, apply these. if namespace is not None: request.namespace = namespace - # If we have keyword arguments corresponding to fields on the - # request, apply these. if namespace_id is not None: request.namespace_id = namespace_id @@ -333,6 +428,7 @@ def list_namespaces( # If we have keyword arguments corresponding to fields on the # request, apply these. + if parent is not None: request.parent = parent @@ -412,6 +508,7 @@ def get_namespace( # If we have keyword arguments corresponding to fields on the # request, apply these. + if name is not None: request.name = name @@ -491,10 +588,9 @@ def update_namespace( # If we have keyword arguments corresponding to fields on the # request, apply these. + if namespace is not None: request.namespace = namespace - # If we have keyword arguments corresponding to fields on the - # request, apply these. if update_mask is not None: request.update_mask = update_mask @@ -554,6 +650,7 @@ def delete_namespace( # If we have keyword arguments corresponding to fields on the # request, apply these. + if name is not None: request.name = name @@ -637,14 +734,11 @@ def create_service( # If we have keyword arguments corresponding to fields on the # request, apply these. + if parent is not None: request.parent = parent - # If we have keyword arguments corresponding to fields on the - # request, apply these. if service is not None: request.service = service - # If we have keyword arguments corresponding to fields on the - # request, apply these. if service_id is not None: request.service_id = service_id @@ -713,6 +807,7 @@ def list_services( # If we have keyword arguments corresponding to fields on the # request, apply these. + if parent is not None: request.parent = parent @@ -794,6 +889,7 @@ def get_service( # If we have keyword arguments corresponding to fields on the # request, apply these. + if name is not None: request.name = name @@ -870,10 +966,9 @@ def update_service( # If we have keyword arguments corresponding to fields on the # request, apply these. + if service is not None: request.service = service - # If we have keyword arguments corresponding to fields on the - # request, apply these. if update_mask is not None: request.update_mask = update_mask @@ -933,6 +1028,7 @@ def delete_service( # If we have keyword arguments corresponding to fields on the # request, apply these. + if name is not None: request.name = name @@ -1015,14 +1111,11 @@ def create_endpoint( # If we have keyword arguments corresponding to fields on the # request, apply these. + if parent is not None: request.parent = parent - # If we have keyword arguments corresponding to fields on the - # request, apply these. if endpoint is not None: request.endpoint = endpoint - # If we have keyword arguments corresponding to fields on the - # request, apply these. if endpoint_id is not None: request.endpoint_id = endpoint_id @@ -1091,6 +1184,7 @@ def list_endpoints( # If we have keyword arguments corresponding to fields on the # request, apply these. + if parent is not None: request.parent = parent @@ -1170,6 +1264,7 @@ def get_endpoint( # If we have keyword arguments corresponding to fields on the # request, apply these. + if name is not None: request.name = name @@ -1245,10 +1340,9 @@ def update_endpoint( # If we have keyword arguments corresponding to fields on the # request, apply these. + if endpoint is not None: request.endpoint = endpoint - # If we have keyword arguments corresponding to fields on the - # request, apply these. if update_mask is not None: request.update_mask = update_mask @@ -1307,6 +1401,7 @@ def delete_endpoint( # If we have keyword arguments corresponding to fields on the # request, apply these. + if name is not None: request.name = name diff --git a/google/cloud/servicedirectory_v1beta1/services/registration_service/pagers.py b/google/cloud/servicedirectory_v1beta1/services/registration_service/pagers.py index e032f1d9..d1e4129c 100644 --- a/google/cloud/servicedirectory_v1beta1/services/registration_service/pagers.py +++ b/google/cloud/servicedirectory_v1beta1/services/registration_service/pagers.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright (C) 2019 Google LLC +# Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/google/cloud/servicedirectory_v1beta1/services/registration_service/transports/__init__.py b/google/cloud/servicedirectory_v1beta1/services/registration_service/transports/__init__.py index cd861ec2..bfbd9471 100644 --- a/google/cloud/servicedirectory_v1beta1/services/registration_service/transports/__init__.py +++ b/google/cloud/servicedirectory_v1beta1/services/registration_service/transports/__init__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright (C) 2019 Google LLC +# Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/google/cloud/servicedirectory_v1beta1/services/registration_service/transports/base.py b/google/cloud/servicedirectory_v1beta1/services/registration_service/transports/base.py index c1b36ddd..b686d3db 100644 --- a/google/cloud/servicedirectory_v1beta1/services/registration_service/transports/base.py +++ b/google/cloud/servicedirectory_v1beta1/services/registration_service/transports/base.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright (C) 2019 Google LLC +# Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/google/cloud/servicedirectory_v1beta1/services/registration_service/transports/grpc.py b/google/cloud/servicedirectory_v1beta1/services/registration_service/transports/grpc.py index 8d115758..9bd5945c 100644 --- a/google/cloud/servicedirectory_v1beta1/services/registration_service/transports/grpc.py +++ b/google/cloud/servicedirectory_v1beta1/services/registration_service/transports/grpc.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright (C) 2019 Google LLC +# Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -15,10 +15,12 @@ # limitations under the License. # -from typing import Callable, Dict +from typing import Callable, Dict, Tuple from google.api_core import grpc_helpers # type: ignore from google.auth import credentials # type: ignore +from google.auth.transport.grpc import SslCredentials # type: ignore + import grpc # type: ignore @@ -69,7 +71,9 @@ def __init__( *, host: str = "servicedirectory.googleapis.com", credentials: credentials.Credentials = None, - channel: grpc.Channel = None + channel: grpc.Channel = None, + api_mtls_endpoint: str = None, + client_cert_source: Callable[[], Tuple[bytes, bytes]] = None ) -> None: """Instantiate the transport. @@ -83,20 +87,55 @@ def __init__( This argument is ignored if ``channel`` is provided. channel (Optional[grpc.Channel]): A ``Channel`` instance through which to make calls. + api_mtls_endpoint (Optional[str]): The mutual TLS endpoint. If + provided, it overrides the ``host`` argument and tries to create + a mutual TLS channel with client SSL credentials from + ``client_cert_source`` or applicatin default SSL credentials. + client_cert_source (Optional[Callable[[], Tuple[bytes, bytes]]]): A + callback to provide client SSL certificate bytes and private key + bytes, both in PEM format. It is ignored if ``api_mtls_endpoint`` + is None. + + Raises: + google.auth.exceptions.MutualTlsChannelError: If mutual TLS transport + creation failed for any reason. """ - # Sanity check: Ensure that channel and credentials are not both - # provided. if channel: + # Sanity check: Ensure that channel and credentials are not both + # provided. credentials = False + # If a channel was explicitly provided, set it. + self._grpc_channel = channel + elif api_mtls_endpoint: + host = ( + api_mtls_endpoint + if ":" in api_mtls_endpoint + else api_mtls_endpoint + ":443" + ) + + # Create SSL credentials with client_cert_source or application + # default SSL credentials. + if client_cert_source: + cert, key = client_cert_source() + ssl_credentials = grpc.ssl_channel_credentials( + certificate_chain=cert, private_key=key + ) + else: + ssl_credentials = SslCredentials().ssl_credentials + + # create a new channel. The provided one is ignored. + self._grpc_channel = grpc_helpers.create_channel( + host, + credentials=credentials, + ssl_credentials=ssl_credentials, + scopes=self.AUTH_SCOPES, + ) + # Run the base constructor. super().__init__(host=host, credentials=credentials) self._stubs = {} # type: Dict[str, Callable] - # If a channel was explicitly provided, set it. - if channel: - self._grpc_channel = channel - @classmethod def create_channel( cls, diff --git a/google/cloud/servicedirectory_v1beta1/types/__init__.py b/google/cloud/servicedirectory_v1beta1/types/__init__.py index de3d157e..37db9a86 100644 --- a/google/cloud/servicedirectory_v1beta1/types/__init__.py +++ b/google/cloud/servicedirectory_v1beta1/types/__init__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright (C) 2019 Google LLC +# Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/google/cloud/servicedirectory_v1beta1/types/endpoint.py b/google/cloud/servicedirectory_v1beta1/types/endpoint.py index 2657ddd5..873e4251 100644 --- a/google/cloud/servicedirectory_v1beta1/types/endpoint.py +++ b/google/cloud/servicedirectory_v1beta1/types/endpoint.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright (C) 2019 Google LLC +# Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/google/cloud/servicedirectory_v1beta1/types/lookup_service.py b/google/cloud/servicedirectory_v1beta1/types/lookup_service.py index cbbc0efc..edeaee63 100644 --- a/google/cloud/servicedirectory_v1beta1/types/lookup_service.py +++ b/google/cloud/servicedirectory_v1beta1/types/lookup_service.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright (C) 2019 Google LLC +# Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/google/cloud/servicedirectory_v1beta1/types/namespace.py b/google/cloud/servicedirectory_v1beta1/types/namespace.py index 26942cb2..ddf6c374 100644 --- a/google/cloud/servicedirectory_v1beta1/types/namespace.py +++ b/google/cloud/servicedirectory_v1beta1/types/namespace.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright (C) 2019 Google LLC +# Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/google/cloud/servicedirectory_v1beta1/types/registration_service.py b/google/cloud/servicedirectory_v1beta1/types/registration_service.py index fe8e9ea5..02777bf9 100644 --- a/google/cloud/servicedirectory_v1beta1/types/registration_service.py +++ b/google/cloud/servicedirectory_v1beta1/types/registration_service.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright (C) 2019 Google LLC +# Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/google/cloud/servicedirectory_v1beta1/types/service.py b/google/cloud/servicedirectory_v1beta1/types/service.py index 297ad7f8..db1f7bb3 100644 --- a/google/cloud/servicedirectory_v1beta1/types/service.py +++ b/google/cloud/servicedirectory_v1beta1/types/service.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright (C) 2019 Google LLC +# Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/mypy.ini b/mypy.ini index f23e6b53..4505b485 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,3 +1,3 @@ [mypy] -python_version = 3.5 +python_version = 3.6 namespace_packages = True diff --git a/noxfile.py b/noxfile.py index c6232832..9c6bcdcc 100644 --- a/noxfile.py +++ b/noxfile.py @@ -111,7 +111,7 @@ def system(session): # Install all test dependencies, then install this package into the # virtualenv's dist-packages. session.install("mock", "pytest") - + session.install("git+https://github.com/googleapis/python-test-utils") session.install("-e", ".") # Run py.test against the system tests. diff --git a/scripts/fixup_keywords.py b/scripts/fixup_keywords.py index dd0178af..efa2d35b 100644 --- a/scripts/fixup_keywords.py +++ b/scripts/fixup_keywords.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright (C) 2019 Google LLC +# Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/setup.cfg b/setup.cfg index 3bd55550..c3a2b39f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,3 +1,19 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2020 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. + # Generated by synthtool. DO NOT EDIT! [bdist_wheel] universal = 1 diff --git a/setup.py b/setup.py index d30a6ef9..d9b90751 100644 --- a/setup.py +++ b/setup.py @@ -40,7 +40,7 @@ platforms="Posix; MacOS X; Windows", include_package_data=True, install_requires=( - "google-api-core >= 1.8.0, < 2.0.0dev", + "google-api-core >= 1.17.0, < 2.0.0dev", "googleapis-common-protos >= 1.5.8", "grpcio >= 1.10.0", "proto-plus >= 0.4.0", diff --git a/synth.metadata b/synth.metadata index 89f7ad22..2e76c672 100644 --- a/synth.metadata +++ b/synth.metadata @@ -1,25 +1,25 @@ { - "updateTime": "2020-03-09T20:51:05.311008Z", "sources": [ { "git": { "name": ".", - "remote": "sso://devrel/cloud/libraries/python/python-service-directory" + "remote": "https://github.com/googleapis/python-service-directory.git", + "sha": "f00742670168646d1028ef740e574e244316a76c" } }, { "git": { "name": "googleapis", "remote": "https://github.com/googleapis/googleapis.git", - "sha": "29a47c965aac79e3fe8e3314482ca0b5967680f0", - "internalRef": "299917154" + "sha": "bcc476396e799806d3355e87246c6becf6250a70", + "internalRef": "306974763" } }, { - "template": { - "name": "python_library", - "origin": "synthtool.gcp", - "version": "2020.2.4" + "git": { + "name": "synthtool", + "remote": "https://github.com/googleapis/synthtool.git", + "sha": "6980131905b652563280e4d2482384d4acc9eafc" } } ], diff --git a/tests/unit/servicedirectory_v1beta1/test_lookup_service.py b/tests/unit/servicedirectory_v1beta1/test_lookup_service.py index f64b801d..c6fdd5b0 100644 --- a/tests/unit/servicedirectory_v1beta1/test_lookup_service.py +++ b/tests/unit/servicedirectory_v1beta1/test_lookup_service.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright (C) 2019 Google LLC +# Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -23,6 +23,7 @@ from google import auth from google.api_core import client_options +from google.api_core import grpc_helpers from google.auth import credentials from google.cloud.servicedirectory_v1beta1.services.lookup_service import ( LookupServiceClient, @@ -33,6 +34,39 @@ from google.oauth2 import service_account +def client_cert_source_callback(): + return b"cert bytes", b"key bytes" + + +def test__get_default_mtls_endpoint(): + api_endpoint = "example.googleapis.com" + api_mtls_endpoint = "example.mtls.googleapis.com" + sandbox_endpoint = "example.sandbox.googleapis.com" + sandbox_mtls_endpoint = "example.mtls.sandbox.googleapis.com" + non_googleapi = "api.example.com" + + assert LookupServiceClient._get_default_mtls_endpoint(None) is None + assert ( + LookupServiceClient._get_default_mtls_endpoint(api_endpoint) + == api_mtls_endpoint + ) + assert ( + LookupServiceClient._get_default_mtls_endpoint(api_mtls_endpoint) + == api_mtls_endpoint + ) + assert ( + LookupServiceClient._get_default_mtls_endpoint(sandbox_endpoint) + == sandbox_mtls_endpoint + ) + assert ( + LookupServiceClient._get_default_mtls_endpoint(sandbox_mtls_endpoint) + == sandbox_mtls_endpoint + ) + assert ( + LookupServiceClient._get_default_mtls_endpoint(non_googleapi) == non_googleapi + ) + + def test_lookup_service_client_from_service_account_file(): creds = credentials.AnonymousCredentials() with mock.patch.object( @@ -49,31 +83,89 @@ def test_lookup_service_client_from_service_account_file(): def test_lookup_service_client_client_options(): - # Check the default options have their expected values. - assert ( - LookupServiceClient.DEFAULT_OPTIONS.api_endpoint - == "servicedirectory.googleapis.com" - ) + # Check that if channel is provided we won't create a new one. + with mock.patch( + "google.cloud.servicedirectory_v1beta1.services.lookup_service.LookupServiceClient.get_transport_class" + ) as gtc: + transport = transports.LookupServiceGrpcTransport( + credentials=credentials.AnonymousCredentials() + ) + client = LookupServiceClient(transport=transport) + gtc.assert_not_called() - # Check that options can be customized. - options = client_options.ClientOptions(api_endpoint="squid.clam.whelk") + # Check mTLS is not triggered with empty client options. + options = client_options.ClientOptions() with mock.patch( "google.cloud.servicedirectory_v1beta1.services.lookup_service.LookupServiceClient.get_transport_class" ) as gtc: transport = gtc.return_value = mock.MagicMock() client = LookupServiceClient(client_options=options) - transport.assert_called_once_with(credentials=None, host="squid.clam.whelk") + transport.assert_called_once_with( + credentials=None, host=client.DEFAULT_ENDPOINT + ) + + # Check mTLS is not triggered if api_endpoint is provided but + # client_cert_source is None. + options = client_options.ClientOptions(api_endpoint="squid.clam.whelk") + with mock.patch( + "google.cloud.servicedirectory_v1beta1.services.lookup_service.transports.LookupServiceGrpcTransport.__init__" + ) as grpc_transport: + grpc_transport.return_value = None + client = LookupServiceClient(client_options=options) + grpc_transport.assert_called_once_with( + api_mtls_endpoint=None, + client_cert_source=None, + credentials=None, + host="squid.clam.whelk", + ) + + # Check mTLS is triggered if client_cert_source is provided. + options = client_options.ClientOptions( + client_cert_source=client_cert_source_callback + ) + with mock.patch( + "google.cloud.servicedirectory_v1beta1.services.lookup_service.transports.LookupServiceGrpcTransport.__init__" + ) as grpc_transport: + grpc_transport.return_value = None + client = LookupServiceClient(client_options=options) + grpc_transport.assert_called_once_with( + api_mtls_endpoint=client.DEFAULT_MTLS_ENDPOINT, + client_cert_source=client_cert_source_callback, + credentials=None, + host=client.DEFAULT_ENDPOINT, + ) + + # Check mTLS is triggered if api_endpoint and client_cert_source are provided. + options = client_options.ClientOptions( + api_endpoint="squid.clam.whelk", client_cert_source=client_cert_source_callback + ) + with mock.patch( + "google.cloud.servicedirectory_v1beta1.services.lookup_service.transports.LookupServiceGrpcTransport.__init__" + ) as grpc_transport: + grpc_transport.return_value = None + client = LookupServiceClient(client_options=options) + grpc_transport.assert_called_once_with( + api_mtls_endpoint="squid.clam.whelk", + client_cert_source=client_cert_source_callback, + credentials=None, + host="squid.clam.whelk", + ) def test_lookup_service_client_client_options_from_dict(): with mock.patch( - "google.cloud.servicedirectory_v1beta1.services.lookup_service.LookupServiceClient.get_transport_class" - ) as gtc: - transport = gtc.return_value = mock.MagicMock() + "google.cloud.servicedirectory_v1beta1.services.lookup_service.transports.LookupServiceGrpcTransport.__init__" + ) as grpc_transport: + grpc_transport.return_value = None client = LookupServiceClient( client_options={"api_endpoint": "squid.clam.whelk"} ) - transport.assert_called_once_with(credentials=None, host="squid.clam.whelk") + grpc_transport.assert_called_once_with( + api_mtls_endpoint=None, + client_cert_source=None, + credentials=None, + host="squid.clam.whelk", + ) def test_resolve_service(transport: str = "grpc"): @@ -176,5 +268,84 @@ def test_lookup_service_host_with_port(): def test_lookup_service_grpc_transport_channel(): channel = grpc.insecure_channel("http://localhost/") - transport = transports.LookupServiceGrpcTransport(channel=channel) - assert transport.grpc_channel is channel + + # Check that if channel is provided, mtls endpoint and client_cert_source + # won't be used. + callback = mock.MagicMock() + transport = transports.LookupServiceGrpcTransport( + host="squid.clam.whelk", + channel=channel, + api_mtls_endpoint="mtls.squid.clam.whelk", + client_cert_source=callback, + ) + assert transport.grpc_channel == channel + assert transport._host == "squid.clam.whelk:443" + assert not callback.called + + +@mock.patch("grpc.ssl_channel_credentials", autospec=True) +@mock.patch("google.api_core.grpc_helpers.create_channel", autospec=True) +def test_lookup_service_grpc_transport_channel_mtls_with_client_cert_source( + grpc_create_channel, grpc_ssl_channel_cred +): + # Check that if channel is None, but api_mtls_endpoint and client_cert_source + # are provided, then a mTLS channel will be created. + mock_cred = mock.Mock() + + mock_ssl_cred = mock.Mock() + grpc_ssl_channel_cred.return_value = mock_ssl_cred + + mock_grpc_channel = mock.Mock() + grpc_create_channel.return_value = mock_grpc_channel + + transport = transports.LookupServiceGrpcTransport( + host="squid.clam.whelk", + credentials=mock_cred, + api_mtls_endpoint="mtls.squid.clam.whelk", + client_cert_source=client_cert_source_callback, + ) + grpc_ssl_channel_cred.assert_called_once_with( + certificate_chain=b"cert bytes", private_key=b"key bytes" + ) + grpc_create_channel.assert_called_once_with( + "mtls.squid.clam.whelk:443", + credentials=mock_cred, + ssl_credentials=mock_ssl_cred, + scopes=("https://www.googleapis.com/auth/cloud-platform",), + ) + assert transport.grpc_channel == mock_grpc_channel + + +@pytest.mark.parametrize( + "api_mtls_endpoint", ["mtls.squid.clam.whelk", "mtls.squid.clam.whelk:443"] +) +@mock.patch("google.api_core.grpc_helpers.create_channel", autospec=True) +def test_lookup_service_grpc_transport_channel_mtls_with_adc( + grpc_create_channel, api_mtls_endpoint +): + # Check that if channel and client_cert_source are None, but api_mtls_endpoint + # is provided, then a mTLS channel will be created with SSL ADC. + mock_grpc_channel = mock.Mock() + grpc_create_channel.return_value = mock_grpc_channel + + # Mock google.auth.transport.grpc.SslCredentials class. + mock_ssl_cred = mock.Mock() + with mock.patch.multiple( + "google.auth.transport.grpc.SslCredentials", + __init__=mock.Mock(return_value=None), + ssl_credentials=mock.PropertyMock(return_value=mock_ssl_cred), + ): + mock_cred = mock.Mock() + transport = transports.LookupServiceGrpcTransport( + host="squid.clam.whelk", + credentials=mock_cred, + api_mtls_endpoint=api_mtls_endpoint, + client_cert_source=None, + ) + grpc_create_channel.assert_called_once_with( + "mtls.squid.clam.whelk:443", + credentials=mock_cred, + ssl_credentials=mock_ssl_cred, + scopes=("https://www.googleapis.com/auth/cloud-platform",), + ) + assert transport.grpc_channel == mock_grpc_channel diff --git a/tests/unit/servicedirectory_v1beta1/test_registration_service.py b/tests/unit/servicedirectory_v1beta1/test_registration_service.py index 42d57b94..fd58e4cc 100644 --- a/tests/unit/servicedirectory_v1beta1/test_registration_service.py +++ b/tests/unit/servicedirectory_v1beta1/test_registration_service.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright (C) 2019 Google LLC +# Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -23,6 +23,7 @@ from google import auth from google.api_core import client_options +from google.api_core import grpc_helpers from google.auth import credentials from google.cloud.servicedirectory_v1beta1.services.registration_service import ( RegistrationServiceClient, @@ -44,6 +45,40 @@ from google.protobuf import field_mask_pb2 as field_mask # type: ignore +def client_cert_source_callback(): + return b"cert bytes", b"key bytes" + + +def test__get_default_mtls_endpoint(): + api_endpoint = "example.googleapis.com" + api_mtls_endpoint = "example.mtls.googleapis.com" + sandbox_endpoint = "example.sandbox.googleapis.com" + sandbox_mtls_endpoint = "example.mtls.sandbox.googleapis.com" + non_googleapi = "api.example.com" + + assert RegistrationServiceClient._get_default_mtls_endpoint(None) is None + assert ( + RegistrationServiceClient._get_default_mtls_endpoint(api_endpoint) + == api_mtls_endpoint + ) + assert ( + RegistrationServiceClient._get_default_mtls_endpoint(api_mtls_endpoint) + == api_mtls_endpoint + ) + assert ( + RegistrationServiceClient._get_default_mtls_endpoint(sandbox_endpoint) + == sandbox_mtls_endpoint + ) + assert ( + RegistrationServiceClient._get_default_mtls_endpoint(sandbox_mtls_endpoint) + == sandbox_mtls_endpoint + ) + assert ( + RegistrationServiceClient._get_default_mtls_endpoint(non_googleapi) + == non_googleapi + ) + + def test_registration_service_client_from_service_account_file(): creds = credentials.AnonymousCredentials() with mock.patch.object( @@ -64,31 +99,89 @@ def test_registration_service_client_from_service_account_file(): def test_registration_service_client_client_options(): - # Check the default options have their expected values. - assert ( - RegistrationServiceClient.DEFAULT_OPTIONS.api_endpoint - == "servicedirectory.googleapis.com" - ) + # Check that if channel is provided we won't create a new one. + with mock.patch( + "google.cloud.servicedirectory_v1beta1.services.registration_service.RegistrationServiceClient.get_transport_class" + ) as gtc: + transport = transports.RegistrationServiceGrpcTransport( + credentials=credentials.AnonymousCredentials() + ) + client = RegistrationServiceClient(transport=transport) + gtc.assert_not_called() - # Check that options can be customized. - options = client_options.ClientOptions(api_endpoint="squid.clam.whelk") + # Check mTLS is not triggered with empty client options. + options = client_options.ClientOptions() with mock.patch( "google.cloud.servicedirectory_v1beta1.services.registration_service.RegistrationServiceClient.get_transport_class" ) as gtc: transport = gtc.return_value = mock.MagicMock() client = RegistrationServiceClient(client_options=options) - transport.assert_called_once_with(credentials=None, host="squid.clam.whelk") + transport.assert_called_once_with( + credentials=None, host=client.DEFAULT_ENDPOINT + ) + + # Check mTLS is not triggered if api_endpoint is provided but + # client_cert_source is None. + options = client_options.ClientOptions(api_endpoint="squid.clam.whelk") + with mock.patch( + "google.cloud.servicedirectory_v1beta1.services.registration_service.transports.RegistrationServiceGrpcTransport.__init__" + ) as grpc_transport: + grpc_transport.return_value = None + client = RegistrationServiceClient(client_options=options) + grpc_transport.assert_called_once_with( + api_mtls_endpoint=None, + client_cert_source=None, + credentials=None, + host="squid.clam.whelk", + ) + + # Check mTLS is triggered if client_cert_source is provided. + options = client_options.ClientOptions( + client_cert_source=client_cert_source_callback + ) + with mock.patch( + "google.cloud.servicedirectory_v1beta1.services.registration_service.transports.RegistrationServiceGrpcTransport.__init__" + ) as grpc_transport: + grpc_transport.return_value = None + client = RegistrationServiceClient(client_options=options) + grpc_transport.assert_called_once_with( + api_mtls_endpoint=client.DEFAULT_MTLS_ENDPOINT, + client_cert_source=client_cert_source_callback, + credentials=None, + host=client.DEFAULT_ENDPOINT, + ) + + # Check mTLS is triggered if api_endpoint and client_cert_source are provided. + options = client_options.ClientOptions( + api_endpoint="squid.clam.whelk", client_cert_source=client_cert_source_callback + ) + with mock.patch( + "google.cloud.servicedirectory_v1beta1.services.registration_service.transports.RegistrationServiceGrpcTransport.__init__" + ) as grpc_transport: + grpc_transport.return_value = None + client = RegistrationServiceClient(client_options=options) + grpc_transport.assert_called_once_with( + api_mtls_endpoint="squid.clam.whelk", + client_cert_source=client_cert_source_callback, + credentials=None, + host="squid.clam.whelk", + ) def test_registration_service_client_client_options_from_dict(): with mock.patch( - "google.cloud.servicedirectory_v1beta1.services.registration_service.RegistrationServiceClient.get_transport_class" - ) as gtc: - transport = gtc.return_value = mock.MagicMock() + "google.cloud.servicedirectory_v1beta1.services.registration_service.transports.RegistrationServiceGrpcTransport.__init__" + ) as grpc_transport: + grpc_transport.return_value = None client = RegistrationServiceClient( client_options={"api_endpoint": "squid.clam.whelk"} ) - transport.assert_called_once_with(credentials=None, host="squid.clam.whelk") + grpc_transport.assert_called_once_with( + api_mtls_endpoint=None, + client_cert_source=None, + credentials=None, + host="squid.clam.whelk", + ) def test_create_namespace(transport: str = "grpc"): @@ -1545,8 +1638,122 @@ def test_registration_service_host_with_port(): def test_registration_service_grpc_transport_channel(): channel = grpc.insecure_channel("http://localhost/") - transport = transports.RegistrationServiceGrpcTransport(channel=channel) - assert transport.grpc_channel is channel + + # Check that if channel is provided, mtls endpoint and client_cert_source + # won't be used. + callback = mock.MagicMock() + transport = transports.RegistrationServiceGrpcTransport( + host="squid.clam.whelk", + channel=channel, + api_mtls_endpoint="mtls.squid.clam.whelk", + client_cert_source=callback, + ) + assert transport.grpc_channel == channel + assert transport._host == "squid.clam.whelk:443" + assert not callback.called + + +@mock.patch("grpc.ssl_channel_credentials", autospec=True) +@mock.patch("google.api_core.grpc_helpers.create_channel", autospec=True) +def test_registration_service_grpc_transport_channel_mtls_with_client_cert_source( + grpc_create_channel, grpc_ssl_channel_cred +): + # Check that if channel is None, but api_mtls_endpoint and client_cert_source + # are provided, then a mTLS channel will be created. + mock_cred = mock.Mock() + + mock_ssl_cred = mock.Mock() + grpc_ssl_channel_cred.return_value = mock_ssl_cred + + mock_grpc_channel = mock.Mock() + grpc_create_channel.return_value = mock_grpc_channel + + transport = transports.RegistrationServiceGrpcTransport( + host="squid.clam.whelk", + credentials=mock_cred, + api_mtls_endpoint="mtls.squid.clam.whelk", + client_cert_source=client_cert_source_callback, + ) + grpc_ssl_channel_cred.assert_called_once_with( + certificate_chain=b"cert bytes", private_key=b"key bytes" + ) + grpc_create_channel.assert_called_once_with( + "mtls.squid.clam.whelk:443", + credentials=mock_cred, + ssl_credentials=mock_ssl_cred, + scopes=("https://www.googleapis.com/auth/cloud-platform",), + ) + assert transport.grpc_channel == mock_grpc_channel + + +@pytest.mark.parametrize( + "api_mtls_endpoint", ["mtls.squid.clam.whelk", "mtls.squid.clam.whelk:443"] +) +@mock.patch("google.api_core.grpc_helpers.create_channel", autospec=True) +def test_registration_service_grpc_transport_channel_mtls_with_adc( + grpc_create_channel, api_mtls_endpoint +): + # Check that if channel and client_cert_source are None, but api_mtls_endpoint + # is provided, then a mTLS channel will be created with SSL ADC. + mock_grpc_channel = mock.Mock() + grpc_create_channel.return_value = mock_grpc_channel + + # Mock google.auth.transport.grpc.SslCredentials class. + mock_ssl_cred = mock.Mock() + with mock.patch.multiple( + "google.auth.transport.grpc.SslCredentials", + __init__=mock.Mock(return_value=None), + ssl_credentials=mock.PropertyMock(return_value=mock_ssl_cred), + ): + mock_cred = mock.Mock() + transport = transports.RegistrationServiceGrpcTransport( + host="squid.clam.whelk", + credentials=mock_cred, + api_mtls_endpoint=api_mtls_endpoint, + client_cert_source=None, + ) + grpc_create_channel.assert_called_once_with( + "mtls.squid.clam.whelk:443", + credentials=mock_cred, + ssl_credentials=mock_ssl_cred, + scopes=("https://www.googleapis.com/auth/cloud-platform",), + ) + assert transport.grpc_channel == mock_grpc_channel + + +def test_endpoint_path(): + project = "squid" + location = "clam" + namespace = "whelk" + service = "octopus" + endpoint = "oyster" + + expected = "projects/{project}/locations/{location}/namespaces/{namespace}/services/{service}/endpoints/{endpoint}".format( + project=project, + location=location, + namespace=namespace, + service=service, + endpoint=endpoint, + ) + actual = RegistrationServiceClient.endpoint_path( + project, location, namespace, service, endpoint + ) + assert expected == actual + + +def test_parse_endpoint_path(): + expected = { + "project": "nudibranch", + "location": "cuttlefish", + "namespace": "mussel", + "service": "winkle", + "endpoint": "nautilus", + } + path = RegistrationServiceClient.endpoint_path(**expected) + + # Check that the path construction is reversible. + actual = RegistrationServiceClient.parse_endpoint_path(path) + assert expected == actual def test_service_path(): @@ -1564,6 +1771,20 @@ def test_service_path(): assert expected == actual +def test_parse_service_path(): + expected = { + "project": "oyster", + "location": "nudibranch", + "namespace": "cuttlefish", + "service": "mussel", + } + path = RegistrationServiceClient.service_path(**expected) + + # Check that the path construction is reversible. + actual = RegistrationServiceClient.parse_service_path(path) + assert expected == actual + + def test_namespace_path(): project = "squid" location = "clam" @@ -1576,21 +1797,10 @@ def test_namespace_path(): assert expected == actual -def test_endpoint_path(): - project = "squid" - location = "clam" - namespace = "whelk" - service = "octopus" - endpoint = "oyster" +def test_parse_namespace_path(): + expected = {"project": "octopus", "location": "oyster", "namespace": "nudibranch"} + path = RegistrationServiceClient.namespace_path(**expected) - expected = "projects/{project}/locations/{location}/namespaces/{namespace}/services/{service}/endpoints/{endpoint}".format( - project=project, - location=location, - namespace=namespace, - service=service, - endpoint=endpoint, - ) - actual = RegistrationServiceClient.endpoint_path( - project, location, namespace, service, endpoint - ) + # Check that the path construction is reversible. + actual = RegistrationServiceClient.parse_namespace_path(path) assert expected == actual