diff --git a/tools/run_tests/xds_k8s_test_driver/bin/lib/common.py b/tools/run_tests/xds_k8s_test_driver/bin/lib/common.py index 0c38a39d23a..edf4c58e9da 100755 --- a/tools/run_tests/xds_k8s_test_driver/bin/lib/common.py +++ b/tools/run_tests/xds_k8s_test_driver/bin/lib/common.py @@ -86,6 +86,7 @@ def make_server_runner(namespace: k8s.KubernetesNamespace, deployment_name=xds_flags.SERVER_NAME.value, image_name=xds_k8s_flags.SERVER_IMAGE.value, td_bootstrap_image=xds_k8s_flags.TD_BOOTSTRAP_IMAGE.value, + xds_server_uri=xds_flags.XDS_SERVER_URI.value, gcp_project=xds_flags.PROJECT.value, gcp_api_manager=gcp_api_manager, gcp_service_account=xds_k8s_flags.GCP_SERVICE_ACCOUNT.value, @@ -95,9 +96,7 @@ def make_server_runner(namespace: k8s.KubernetesNamespace, debug_use_port_forwarding=port_forwarding) if secure: - runner_kwargs.update( - xds_server_uri=xds_flags.XDS_SERVER_URI.value, - deployment_template='server-secure.deployment.yaml') + runner_kwargs['deployment_template'] = 'server-secure.deployment.yaml' return KubernetesServerRunner(namespace, **runner_kwargs) diff --git a/tools/run_tests/xds_k8s_test_driver/config/common.cfg b/tools/run_tests/xds_k8s_test_driver/config/common.cfg index 996ce597cc0..3290638b9e2 100644 --- a/tools/run_tests/xds_k8s_test_driver/config/common.cfg +++ b/tools/run_tests/xds_k8s_test_driver/config/common.cfg @@ -11,6 +11,3 @@ --logger_levels=__main__:DEBUG,framework:INFO --verbosity=0 -# Google projects: remove if console.cloud.google.com redirects to Logs Explorer -# ref: https://github.com/grpc/grpc/pull/26844#discussion_r680224772 ---gcp_ui_url=pantheon.corp.google.com diff --git a/tools/run_tests/xds_k8s_test_driver/framework/helpers/datetime.py b/tools/run_tests/xds_k8s_test_driver/framework/helpers/datetime.py index 0e2f9d1d261..2833af0a291 100644 --- a/tools/run_tests/xds_k8s_test_driver/framework/helpers/datetime.py +++ b/tools/run_tests/xds_k8s_test_driver/framework/helpers/datetime.py @@ -14,7 +14,7 @@ """This contains common helpers for working with dates and time.""" import datetime import re -from typing import Optional, Pattern +from typing import Pattern RE_ZERO_OFFSET: Pattern[str] = re.compile(r'[+\-]00:?00$') @@ -29,12 +29,10 @@ def shorten_utc_zone(utc_datetime_str: str) -> str: return RE_ZERO_OFFSET.sub('Z', utc_datetime_str) -def iso8601_utc_time(timedelta: Optional[datetime.timedelta] = None) -> str: - """Return datetime relative to current in ISO-8601 format, UTC tz.""" - time: datetime.datetime = utc_now() - if timedelta: - time += timedelta - return shorten_utc_zone(time.isoformat()) +def iso8601_utc_time(time: datetime.datetime = None) -> str: + """Converts datetime UTC and formats as ISO-8601 Zulu time.""" + utc_time = time.astimezone(tz=datetime.timezone.utc) + return shorten_utc_zone(utc_time.isoformat()) def datetime_suffix(*, seconds: bool = False) -> str: diff --git a/tools/run_tests/xds_k8s_test_driver/framework/test_app/runners/k8s/k8s_base_runner.py b/tools/run_tests/xds_k8s_test_driver/framework/test_app/runners/k8s/k8s_base_runner.py index fce79512c6c..896894d0256 100644 --- a/tools/run_tests/xds_k8s_test_driver/framework/test_app/runners/k8s/k8s_base_runner.py +++ b/tools/run_tests/xds_k8s_test_driver/framework/test_app/runners/k8s/k8s_base_runner.py @@ -14,6 +14,7 @@ """ Common functionality for running xDS Test Client and Server on Kubernetes. """ +from abc import ABCMeta import contextlib import datetime import logging @@ -37,42 +38,95 @@ logger = logging.getLogger(__name__) _RunnerError = base_runner.RunnerError _HighlighterYaml = framework.helpers.highlighter.HighlighterYaml _helper_datetime = framework.helpers.datetime +_datetime = datetime.datetime _timedelta = datetime.timedelta -class KubernetesBaseRunner(base_runner.BaseRunner): +class KubernetesBaseRunner(base_runner.BaseRunner, metaclass=ABCMeta): + # Pylint wants abstract classes to override abstract methods. + # pylint: disable=abstract-method + TEMPLATE_DIR_NAME = 'kubernetes-manifests' TEMPLATE_DIR_RELATIVE_PATH = f'../../../../{TEMPLATE_DIR_NAME}' ROLE_WORKLOAD_IDENTITY_USER = 'roles/iam.workloadIdentityUser' pod_port_forwarders: List[k8s.PortForwarder] pod_log_collectors: List[k8s.PodLogCollector] + # Required fields. + k8s_namespace: k8s.KubernetesNamespace + deployment_name: str + image_name: str + gcp_project: str + gcp_service_account: str + gcp_ui_url: str + + # Fields with default values. + namespace_template: str = 'namespace.yaml' + reuse_namespace: bool = False + + # Mutable state. + deployment: Optional[k8s.V1Deployment] = None + service_account: Optional[k8s.V1ServiceAccount] = None + time_start_requested: Optional[_datetime] = None + time_start_completed: Optional[_datetime] = None + time_stopped: Optional[_datetime] = None + def __init__(self, - k8s_namespace, - namespace_template=None, - reuse_namespace=False): + k8s_namespace: k8s.KubernetesNamespace, + *, + deployment_name: str, + image_name: str, + gcp_project: str, + gcp_service_account: str, + gcp_ui_url: str, + namespace_template: Optional[str] = 'namespace.yaml', + reuse_namespace: bool = False): super().__init__() - self._highlighter = _HighlighterYaml() - # Kubernetes namespaced resources manager - self.k8s_namespace: k8s.KubernetesNamespace = k8s_namespace + # Required fields. + self.deployment_name = deployment_name + self.image_name = image_name + self.gcp_project = gcp_project + # Maps GCP service account to Kubernetes service account + self.gcp_service_account = gcp_service_account + self.gcp_ui_url = gcp_ui_url + + # Kubernetes namespace resources manager. + self.k8s_namespace = k8s_namespace + if namespace_template: + self.namespace_template = namespace_template self.reuse_namespace = reuse_namespace - self.namespace_template = namespace_template or 'namespace.yaml' # Mutable state self.namespace: Optional[k8s.V1Namespace] = None self.pod_port_forwarders = [] self.pod_log_collectors = [] + # Highlighter. + self._highlighter = _HighlighterYaml() + def run(self, **kwargs): del kwargs + if self.time_start_requested: + if self.time_start_completed: + raise RuntimeError( + f"Deployment {self.deployment_name}: has already been" + f" started at {self.time_start_completed.isoformat()}") + else: + raise RuntimeError( + f"Deployment {self.deployment_name}: start has already been" + f" requested at {self.time_start_requested.isoformat()}") + + self.time_start_requested = _datetime.now() + self.logs_explorer_link() + if self.reuse_namespace: self.namespace = self._reuse_namespace() if not self.namespace: self.namespace = self._create_namespace( self.namespace_template, namespace_name=self.k8s_namespace.name) - def cleanup(self, *, force=False): + def _cleanup_namespace(self, *, force=False): if (self.namespace and not self.reuse_namespace) or force: self.delete_namespace() self.namespace = None @@ -405,6 +459,19 @@ class KubernetesBaseRunner(base_runner.BaseRunner): logger.info("Service %s: detected NEG=%s in zones=%s", name, neg_name, neg_zones) + def logs_explorer_link(self): + if not self.time_start_requested: + logger.warning( + 'Skipped printing GCP log link for a non-started deployment %s', + self.deployment_name) + return + self._logs_explorer_link(deployment_name=self.deployment_name, + namespace_name=self.k8s_namespace.name, + gcp_project=self.gcp_project, + gcp_ui_url=self.gcp_ui_url, + start_time=self.time_start_requested, + end_time=self.time_stopped) + @classmethod def _logs_explorer_link(cls, *, @@ -412,13 +479,17 @@ class KubernetesBaseRunner(base_runner.BaseRunner): namespace_name: str, gcp_project: str, gcp_ui_url: str, - end_delta: Optional[_timedelta] = None) -> None: + start_time: Optional[_datetime] = None, + end_time: Optional[_datetime] = None): """Output the link to test server/client logs in GCP Logs Explorer.""" - if end_delta is None: - end_delta = _timedelta(hours=1) - time_now = _helper_datetime.iso8601_utc_time() - time_end = _helper_datetime.iso8601_utc_time(end_delta) - request = {'timeRange': f'{time_now}/{time_end}'} + if not start_time: + start_time = _datetime.now() + if not end_time: + end_time = start_time + _timedelta(minutes=30) + + logs_start = _helper_datetime.iso8601_utc_time(start_time) + logs_end = _helper_datetime.iso8601_utc_time(end_time) + request = {'timeRange': f'{logs_start}/{logs_end}'} query = { 'resource.type': 'k8s_container', 'resource.labels.project_id': gcp_project, diff --git a/tools/run_tests/xds_k8s_test_driver/framework/test_app/runners/k8s/k8s_xds_client_runner.py b/tools/run_tests/xds_k8s_test_driver/framework/test_app/runners/k8s/k8s_xds_client_runner.py index c6850ae1460..5b8cf73ffca 100644 --- a/tools/run_tests/xds_k8s_test_driver/framework/test_app/runners/k8s/k8s_xds_client_runner.py +++ b/tools/run_tests/xds_k8s_test_driver/framework/test_app/runners/k8s/k8s_xds_client_runner.py @@ -14,7 +14,7 @@ """ Run xDS Test Client on Kubernetes. """ - +import datetime import logging from typing import Optional @@ -28,61 +28,69 @@ logger = logging.getLogger(__name__) class KubernetesClientRunner(k8s_base_runner.KubernetesBaseRunner): + # Required fields. + xds_server_uri: str + stats_port: int + deployment_template: str + enable_workload_identity: bool + debug_use_port_forwarding: bool + td_bootstrap_image: str + network: str + + # Optional fields. + service_account_name: Optional[str] = None + service_account_template: Optional[str] = None + gcp_iam: Optional[gcp.iam.IamV1] = None + def __init__( # pylint: disable=too-many-locals self, - k8s_namespace, + k8s_namespace: k8s.KubernetesNamespace, *, - deployment_name, - image_name, - td_bootstrap_image, + deployment_name: str, + image_name: str, + td_bootstrap_image: str, + network='default', + xds_server_uri: Optional[str] = None, gcp_api_manager: gcp.api.GcpApiManager, gcp_project: str, gcp_service_account: str, - xds_server_uri=None, - network='default', - service_account_name=None, - stats_port=8079, - deployment_template='client.deployment.yaml', - service_account_template='service-account.yaml', - reuse_namespace=False, - namespace_template=None, - debug_use_port_forwarding=False, - enable_workload_identity=True): - super().__init__(k8s_namespace, namespace_template, reuse_namespace) + service_account_name: Optional[str] = None, + stats_port: int = 8079, + deployment_template: str = 'client.deployment.yaml', + service_account_template: str = 'service-account.yaml', + reuse_namespace: bool = False, + namespace_template: Optional[str] = None, + debug_use_port_forwarding: bool = False, + enable_workload_identity: bool = True): + super().__init__(k8s_namespace, + deployment_name=deployment_name, + image_name=image_name, + gcp_project=gcp_project, + gcp_service_account=gcp_service_account, + gcp_ui_url=gcp_api_manager.gcp_ui_url, + namespace_template=namespace_template, + reuse_namespace=reuse_namespace) # Settings - self.deployment_name = deployment_name - self.image_name = image_name self.stats_port = stats_port - # xDS bootstrap generator - self.td_bootstrap_image = td_bootstrap_image - self.xds_server_uri = xds_server_uri - self.network = network self.deployment_template = deployment_template - self.debug_use_port_forwarding = debug_use_port_forwarding self.enable_workload_identity = enable_workload_identity - # Service account settings: - # Kubernetes service account + self.debug_use_port_forwarding = debug_use_port_forwarding + + # Used by the TD bootstrap generator. + self.td_bootstrap_image = td_bootstrap_image + self.network = network + self.xds_server_uri = xds_server_uri + + # Workload identity settings: if self.enable_workload_identity: + # Kubernetes service account. self.service_account_name = service_account_name or deployment_name self.service_account_template = service_account_template - else: - self.service_account_name = None - self.service_account_template = None - # GCP. - self.gcp_project = gcp_project - self.gcp_ui_url = gcp_api_manager.gcp_ui_url - # GCP service account to map to Kubernetes service account - self.gcp_service_account = gcp_service_account - # GCP IAM API used to grant allow workload service accounts permission - # to use GCP service account identity. - self.gcp_iam = gcp.iam.IamV1(gcp_api_manager, gcp_project) - - # Mutable state - self.deployment: Optional[k8s.V1Deployment] = None - self.service_account: Optional[k8s.V1ServiceAccount] = None - - # TODO(sergiitk): make rpc UnaryCall enum or get it from proto + # GCP IAM API used to grant allow workload service accounts + # permission to use GCP service account identity. + self.gcp_iam = gcp.iam.IamV1(gcp_api_manager, gcp_project) + def run( # pylint: disable=arguments-differ self, *, @@ -99,11 +107,6 @@ class KubernetesClientRunner(k8s_base_runner.KubernetesBaseRunner): 'server_target=%s rpc=%s qps=%s metadata=%r secure_mode=%s ' 'print_response=%s', self.deployment_name, self.k8s_namespace.name, server_target, rpc, qps, metadata, secure_mode, print_response) - self._logs_explorer_link(deployment_name=self.deployment_name, - namespace_name=self.k8s_namespace.name, - gcp_project=self.gcp_project, - gcp_ui_url=self.gcp_ui_url) - super().run() if self.enable_workload_identity: @@ -148,6 +151,7 @@ class KubernetesClientRunner(k8s_base_runner.KubernetesBaseRunner): # Verify the deployment reports all pods started as well. self._wait_deployment_with_available_replicas(self.deployment_name) + self.time_start_completed = datetime.datetime.now() return self._xds_test_client_for_pod(pod, server_target=server_target) @@ -165,18 +169,25 @@ class KubernetesClientRunner(k8s_base_runner.KubernetesBaseRunner): hostname=pod.metadata.name, rpc_host=rpc_host) - def cleanup(self, *, force=False, force_namespace=False): # pylint: disable=arguments-differ - if self.deployment or force: - self._delete_deployment(self.deployment_name) - self.deployment = None - if self.enable_workload_identity and (self.service_account or force): - self._revoke_workload_identity_user( - gcp_iam=self.gcp_iam, - gcp_service_account=self.gcp_service_account, - service_account_name=self.service_account_name) - self._delete_service_account(self.service_account_name) - self.service_account = None - super().cleanup(force=force_namespace and force) + # pylint: disable=arguments-differ + def cleanup(self, *, force=False, force_namespace=False): + try: + if self.deployment or force: + self._delete_deployment(self.deployment_name) + self.deployment = None + if (self.enable_workload_identity and + (self.service_account or force)): + self._revoke_workload_identity_user( + gcp_iam=self.gcp_iam, + gcp_service_account=self.gcp_service_account, + service_account_name=self.service_account_name) + self._delete_service_account(self.service_account_name) + self.service_account = None + self._cleanup_namespace(force=force_namespace and force) + finally: + self.time_stopped = datetime.datetime.now() + + # pylint: enable=arguments-differ @classmethod def make_namespace_name(cls, diff --git a/tools/run_tests/xds_k8s_test_driver/framework/test_app/runners/k8s/k8s_xds_server_runner.py b/tools/run_tests/xds_k8s_test_driver/framework/test_app/runners/k8s/k8s_xds_server_runner.py index 11222b9dd4a..20d3186c9c7 100644 --- a/tools/run_tests/xds_k8s_test_driver/framework/test_app/runners/k8s/k8s_xds_server_runner.py +++ b/tools/run_tests/xds_k8s_test_driver/framework/test_app/runners/k8s/k8s_xds_server_runner.py @@ -14,6 +14,7 @@ """ Run xDS Test Client on Kubernetes. """ +import datetime import logging from typing import List, Optional @@ -30,70 +31,82 @@ class KubernetesServerRunner(k8s_base_runner.KubernetesBaseRunner): DEFAULT_MAINTENANCE_PORT = 8080 DEFAULT_SECURE_MODE_MAINTENANCE_PORT = 8081 + # Required fields. + deployment_template: str + service_name: str + service_template: str + reuse_service: bool + enable_workload_identity: bool + debug_use_port_forwarding: bool + gcp_neg_name: str + td_bootstrap_image: str + xds_server_uri: str + network: str + + # Optional fields. + service_account_name: Optional[str] = None + service_account_template: Optional[str] = None + gcp_iam: Optional[gcp.iam.IamV1] = None + + # Mutable state. + service: Optional[k8s.V1Service] = None + def __init__( # pylint: disable=too-many-locals self, - k8s_namespace, + k8s_namespace: k8s.KubernetesNamespace, *, - deployment_name, - image_name, - td_bootstrap_image, + deployment_name: str, + image_name: str, + td_bootstrap_image: str, + network: str = 'default', + xds_server_uri: Optional[str] = None, gcp_api_manager: gcp.api.GcpApiManager, gcp_project: str, gcp_service_account: str, - service_account_name=None, - service_name=None, - neg_name=None, - xds_server_uri=None, - network='default', - deployment_template='server.deployment.yaml', - service_account_template='service-account.yaml', - service_template='server.service.yaml', - reuse_service=False, - reuse_namespace=False, - namespace_template=None, - debug_use_port_forwarding=False, - enable_workload_identity=True): - super().__init__(k8s_namespace, namespace_template, reuse_namespace) + service_account_name: Optional[str] = None, + service_name: Optional[str] = None, + neg_name: Optional[str] = None, + deployment_template: str = 'server.deployment.yaml', + service_account_template: str = 'service-account.yaml', + service_template: str = 'server.service.yaml', + reuse_service: bool = False, + reuse_namespace: bool = False, + namespace_template: Optional[str] = None, + debug_use_port_forwarding: bool = False, + enable_workload_identity: bool = True): + super().__init__(k8s_namespace, + deployment_name=deployment_name, + image_name=image_name, + gcp_project=gcp_project, + gcp_service_account=gcp_service_account, + gcp_ui_url=gcp_api_manager.gcp_ui_url, + namespace_template=namespace_template, + reuse_namespace=reuse_namespace) # Settings - self.deployment_name = deployment_name - self.image_name = image_name - self.service_name = service_name or deployment_name - # xDS bootstrap generator - self.td_bootstrap_image = td_bootstrap_image - self.xds_server_uri = xds_server_uri - # This only works in k8s >= 1.18.10-gke.600 - # https://cloud.google.com/kubernetes-engine/docs/how-to/standalone-neg#naming_negs - self.neg_name = neg_name or (f'{self.k8s_namespace.name}-' - f'{self.service_name}') - self.network = network self.deployment_template = deployment_template + self.service_name = service_name or deployment_name self.service_template = service_template self.reuse_service = reuse_service - self.debug_use_port_forwarding = debug_use_port_forwarding self.enable_workload_identity = enable_workload_identity - # Service account settings: - # Kubernetes service account + self.debug_use_port_forwarding = debug_use_port_forwarding + # GCP Network Endpoint Group. + self.gcp_neg_name = neg_name or (f'{self.k8s_namespace.name}-' + f'{self.service_name}') + + # Used by the TD bootstrap generator. + self.td_bootstrap_image = td_bootstrap_image + self.network = network + self.xds_server_uri = xds_server_uri + + # Workload identity settings: if self.enable_workload_identity: + # Kubernetes service account. self.service_account_name = service_account_name or deployment_name self.service_account_template = service_account_template - else: - self.service_account_name = None - self.service_account_template = None - - # GCP. - self.gcp_project = gcp_project - self.gcp_ui_url = gcp_api_manager.gcp_ui_url - # GCP service account to map to Kubernetes service account - self.gcp_service_account = gcp_service_account - # GCP IAM API used to grant allow workload service accounts permission - # to use GCP service account identity. - self.gcp_iam = gcp.iam.IamV1(gcp_api_manager, gcp_project) - - # Mutable state - self.deployment: Optional[k8s.V1Deployment] = None - self.service_account: Optional[k8s.V1ServiceAccount] = None - self.service: Optional[k8s.V1Service] = None + # GCP IAM API used to grant allow workload service accounts + # permission to use GCP service account identity. + self.gcp_iam = gcp.iam.IamV1(gcp_api_manager, gcp_project) def run( # pylint: disable=arguments-differ,too-many-branches self, @@ -126,12 +139,6 @@ class KubernetesServerRunner(k8s_base_runner.KubernetesBaseRunner): 'maintenance_port=%s secure_mode=%s replica_count=%s', self.deployment_name, self.k8s_namespace.name, test_port, maintenance_port, secure_mode, replica_count) - self._logs_explorer_link(deployment_name=self.deployment_name, - namespace_name=self.k8s_namespace.name, - gcp_project=self.gcp_project, - gcp_ui_url=self.gcp_ui_url) - - # Create namespace. super().run() # Reuse existing if requested, create a new deployment when missing. @@ -144,7 +151,7 @@ class KubernetesServerRunner(k8s_base_runner.KubernetesBaseRunner): service_name=self.service_name, namespace_name=self.k8s_namespace.name, deployment_name=self.deployment_name, - neg_name=self.neg_name, + neg_name=self.gcp_neg_name, test_port=test_port) self._wait_service_neg(self.service_name, test_port) @@ -190,6 +197,8 @@ class KubernetesServerRunner(k8s_base_runner.KubernetesBaseRunner): # Verify the deployment reports all pods started as well. self._wait_deployment_with_available_replicas(self.deployment_name, replica_count) + self.time_start_completed = datetime.datetime.now() + servers: List[XdsTestServer] = [] for pod in pods: servers.append( @@ -228,21 +237,28 @@ class KubernetesServerRunner(k8s_base_runner.KubernetesBaseRunner): secure_mode=secure_mode, rpc_host=rpc_host) - def cleanup(self, *, force=False, force_namespace=False): # pylint: disable=arguments-differ - if self.deployment or force: - self._delete_deployment(self.deployment_name) - self.deployment = None - if (self.service and not self.reuse_service) or force: - self._delete_service(self.service_name) - self.service = None - if self.enable_workload_identity and (self.service_account or force): - self._revoke_workload_identity_user( - gcp_iam=self.gcp_iam, - gcp_service_account=self.gcp_service_account, - service_account_name=self.service_account_name) - self._delete_service_account(self.service_account_name) - self.service_account = None - super().cleanup(force=(force_namespace and force)) + # pylint: disable=arguments-differ + def cleanup(self, *, force=False, force_namespace=False): + try: + if self.deployment or force: + self._delete_deployment(self.deployment_name) + self.deployment = None + if (self.service and not self.reuse_service) or force: + self._delete_service(self.service_name) + self.service = None + if (self.enable_workload_identity and + (self.service_account or force)): + self._revoke_workload_identity_user( + gcp_iam=self.gcp_iam, + gcp_service_account=self.gcp_service_account, + service_account_name=self.service_account_name) + self._delete_service_account(self.service_account_name) + self.service_account = None + self._cleanup_namespace(force=(force_namespace and force)) + finally: + self.time_stopped = datetime.datetime.now() + + # pylint: enable=arguments-differ @classmethod def make_namespace_name(cls, diff --git a/tools/run_tests/xds_k8s_test_driver/framework/xds_k8s_testcase.py b/tools/run_tests/xds_k8s_test_driver/framework/xds_k8s_testcase.py index e430644b613..bf314fed7cf 100644 --- a/tools/run_tests/xds_k8s_test_driver/framework/xds_k8s_testcase.py +++ b/tools/run_tests/xds_k8s_test_driver/framework/xds_k8s_testcase.py @@ -511,6 +511,10 @@ class IsolatedXdsKubernetesTestCase(XdsKubernetesBaseTestCase, except retryers.RetryError: logger.exception('Got error during teardown') finally: + logger.info('----- Test client/server logs -----') + self.client_runner.logs_explorer_link() + self.server_runner.logs_explorer_link() + # Fail if any of the pods restarted. self.assertEqual( client_restarts, diff --git a/tools/run_tests/xds_k8s_test_driver/framework/xds_url_map_testcase.py b/tools/run_tests/xds_k8s_test_driver/framework/xds_url_map_testcase.py index 5ce96a50194..c61f79fb79f 100644 --- a/tools/run_tests/xds_k8s_test_driver/framework/xds_url_map_testcase.py +++ b/tools/run_tests/xds_k8s_test_driver/framework/xds_url_map_testcase.py @@ -393,6 +393,10 @@ class XdsUrlMapTestCase(absltest.TestCase, metaclass=_MetaXdsUrlMapTestCase): except retryers.RetryError: logging.exception('Got error during teardown') finally: + if hasattr(cls, 'test_client_runner') and cls.test_client_runner: + logging.info('----- Test client logs -----') + cls.test_client_runner.logs_explorer_link() + # Fail if any of the pods restarted. error_msg = ( 'Client pods unexpectedly restarted'