blob: 508d8d9b2fb28913af3da7c5ea21be21a2368a1b [file]
#
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
import inspect
import time
import logging
logger = logging.getLogger(__name__)
# pylint: disable=wrong-import-order, wrong-import-position
try:
from google.cloud import pubsub_v1
except ImportError:
pubsub_v1 = None
# pylint: enable=wrong-import-order, wrong-import-position
class TestPubsubContext:
"""A highly advanced Pub/Sub resource lifecycle manager for Python integration tests.
Implements cascading third-party subscription cleanup and selective
graceful teardown for debugging on failures.
Includes a safety 'dry_run' switch for safe deployment and validation of resources.
Any catastrophic leaks are handled independently by the global 'stale_cleaner.py'.
"""
def __init__(
self,
project_id,
dry_run=False
): # Keep dry_run=False to allow actual deletions during testing
if pubsub_v1 is None:
raise ImportError(
"The 'google-cloud-pubsub' library is required for TestPubsubContext. "
"Please install it using 'pip install google-cloud-pubsub'.")
self.project_id = project_id
self.dry_run = dry_run
self.publisher = pubsub_v1.PublisherClient()
self.subscriber = pubsub_v1.SubscriberClient()
# Lists to track resources created during the test execution
self.tracked_topics = []
self.tracked_subscriptions = []
self.caller_class = "UnknownTestClass"
stack = inspect.stack()
for frame in stack:
self_obj = frame[0].f_locals.get('self', None)
if self_obj and hasattr(self_obj, '__class__'):
self.caller_class = self_obj.__class__.__name__
break
def register_topic(self, topic_path: str):
"""Registers a topic to be monitored and deleted at the end."""
if topic_path not in self.tracked_topics:
self.tracked_topics.append(topic_path)
logger.info(
"[%s] Registering Topic for monitoring: %s",
self.caller_class,
topic_path)
def register_subscription(self, subscription_path: str):
"""Registers a subscription to be monitored and deleted at the end."""
if subscription_path not in self.tracked_subscriptions:
self.tracked_subscriptions.append(subscription_path)
logger.info(
"[%s] Registering Subscription for monitoring: %s",
self.caller_class,
subscription_path)
def __enter__(self):
logger.info(
"[START] [%s] Initializing Pub/Sub context (dry_run=%s)",
self.caller_class,
self.dry_run)
return self
def _delete_cascading_subscriptions(self, topic_path: str):
"""
Finds and deletes from GCP any third-party subscription that is
connected to our test topic, preventing loose residual resources.
"""
logger.info(
"[%s] Checking for cascading subscriptions on topic: %s",
self.caller_class,
topic_path)
try:
# List all subscriptions associated with this specific topic in GCP
for sub_path in self.publisher.list_topic_subscriptions(
request={"topic": topic_path}):
if self.dry_run:
logger.info(
"[%s] [Cascade] (Dry Run) Would delete subscription: %s",
self.caller_class,
sub_path)
else:
logger.info(
"[%s] [Teardown - Cascade] Deleting residual third-party subscription: %s",
self.caller_class,
sub_path)
try:
self.subscriber.delete_subscription(
request={"subscription": sub_path})
except Exception as e:
logger.error(
"[%s] [Error] Could not delete cascading sub %s: %s",
self.caller_class,
sub_path,
e)
except Exception as e:
logger.error(
"[%s] [Error] Could not list subs for topic %s: %s",
self.caller_class,
topic_path,
e)
def __exit__(self, exc_type, exc_val, exc_tb):
logger.info("Starting teardown of registered resources...")
# If the test failed (exc_type is not None), we leave the subscriptions active for 24 hours
# with an automatic TTL in GCP so the developer can debug the backlog.
# If the test was successful, we clean up everything immediately to save 100% of the cost.
test_failed = exc_type is not None
if test_failed:
logger.warning(
"[%s] [ALERT] Failed test detected. Applying Graceful Teardown.",
self.caller_class)
return False
logger.info(
"[%s] [SUCCESS] Test passed. Proceeding with cleanup.",
self.caller_class)
# 1. Delete registered Subscriptions (Only if the test was successful)
for sub_path in list(self.tracked_subscriptions):
try:
if self.dry_run:
logger.info("(Dry Run) Would delete subscription: %s", sub_path)
else:
logger.info("Deleting temporary subscription: %s", sub_path)
self.subscriber.delete_subscription(
request={"subscription": sub_path})
self.tracked_subscriptions.remove(sub_path)
except Exception as e:
logger.error(
"[%s] [Error] Could not delete subscription %s: %s",
self.caller_class,
sub_path,
e)
# 2. Cascading Topic Cleanup (Check connected third-party subscriptions)
for topic_path in list(self.tracked_topics):
# Execute cascading deletion inspired by Java logic
self._delete_cascading_subscriptions(topic_path)
try:
if self.dry_run:
logger.info("(Dry Run) Would delete temporary topic: %s", topic_path)
else:
logger.info("Deleting temporary topic: %s", topic_path)
self.publisher.delete_topic(request={"topic": topic_path})
self.tracked_topics.remove(topic_path)
except Exception as e:
logger.error("Could not delete topic %s: %s", topic_path, e)
return False