4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
275 lines
9.4 KiB
Python
275 lines
9.4 KiB
Python
"""Tag-based resource cleanup."""
|
|
|
|
import logging
|
|
import time
|
|
from typing import Any
|
|
|
|
from botocore.exceptions import ClientError
|
|
|
|
from tests.shared.infrastructure_sdk.deployer import get_boto3_client
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Resource types and their deletion order (dependencies matter)
|
|
RESOURCE_DELETION_ORDER = [
|
|
"elasticloadbalancing:loadbalancer",
|
|
"elasticloadbalancing:targetgroup",
|
|
"ecs:service",
|
|
"ecs:task",
|
|
"ecs:cluster",
|
|
"apigateway:restapis",
|
|
"lambda:function",
|
|
"iam:role",
|
|
"s3:bucket",
|
|
"logs:log-group",
|
|
"ecr:repository",
|
|
"ec2:security-group",
|
|
]
|
|
|
|
|
|
def find_resources_by_stack(stack_name: str, region: str = "us-east-1") -> dict[str, list[str]]:
|
|
"""Find all resources tagged with stack name using Resource Groups Tagging API.
|
|
|
|
Args:
|
|
stack_name: The stack name to search for.
|
|
region: AWS region.
|
|
|
|
Returns:
|
|
Dictionary mapping resource type to list of resource ARNs.
|
|
"""
|
|
client = get_boto3_client("resourcegroupstaggingapi", region)
|
|
resources: dict[str, list[str]] = {}
|
|
|
|
paginator = client.get_paginator("get_resources")
|
|
for page in paginator.paginate(
|
|
TagFilters=[
|
|
{"Key": "tracer:stack", "Values": [stack_name]},
|
|
{"Key": "tracer:managed", "Values": ["sdk"]},
|
|
]
|
|
):
|
|
for resource in page.get("ResourceTagMappingList", []):
|
|
arn = resource["ResourceARN"]
|
|
# Extract resource type from ARN (e.g., arn:aws:s3:::bucket -> s3:bucket)
|
|
resource_type = _get_resource_type_from_arn(arn)
|
|
if resource_type not in resources:
|
|
resources[resource_type] = []
|
|
resources[resource_type].append(arn)
|
|
|
|
return resources
|
|
|
|
|
|
def _get_resource_type_from_arn(arn: str) -> str:
|
|
"""Extract resource type from ARN."""
|
|
# ARN format: arn:aws:service:region:account:resource-type/resource-id
|
|
parts = arn.split(":")
|
|
if len(parts) < 6:
|
|
return "unknown"
|
|
|
|
service = parts[2]
|
|
resource_part = parts[5]
|
|
|
|
# Handle different ARN formats
|
|
if "/" in resource_part:
|
|
resource_type = resource_part.split("/")[0]
|
|
else:
|
|
resource_type = resource_part
|
|
|
|
return f"{service}:{resource_type}"
|
|
|
|
|
|
def destroy_stack(stack_name: str, region: str = "us-east-1") -> dict[str, Any]:
|
|
"""Delete all resources for a stack in correct dependency order.
|
|
|
|
Order: Services -> Tasks -> Clusters -> APIs -> Lambdas -> Roles -> Buckets -> Log Groups
|
|
|
|
Args:
|
|
stack_name: Name of the stack to destroy.
|
|
region: AWS region.
|
|
|
|
Returns:
|
|
Dictionary with deletion results.
|
|
"""
|
|
from tests.shared.infrastructure_sdk.config import delete_outputs
|
|
|
|
resources = find_resources_by_stack(stack_name, region)
|
|
results: dict[str, Any] = {"deleted": [], "failed": [], "not_found": []}
|
|
|
|
# Delete in dependency order
|
|
for resource_type in RESOURCE_DELETION_ORDER:
|
|
if resource_type not in resources:
|
|
continue
|
|
|
|
for arn in resources[resource_type]:
|
|
try:
|
|
_delete_resource(arn, resource_type, region)
|
|
results["deleted"].append(arn)
|
|
except ClientError as e:
|
|
if e.response["Error"]["Code"] in [
|
|
"NoSuchEntity",
|
|
"ResourceNotFoundException",
|
|
"404",
|
|
]:
|
|
results["not_found"].append(arn)
|
|
else:
|
|
results["failed"].append({"arn": arn, "error": str(e)})
|
|
except Exception as e:
|
|
results["failed"].append({"arn": arn, "error": str(e)})
|
|
|
|
# Delete any remaining resources not in our ordered list
|
|
for resource_type, arns in resources.items():
|
|
if resource_type in RESOURCE_DELETION_ORDER:
|
|
continue
|
|
for arn in arns:
|
|
if arn not in results["deleted"] and arn not in [
|
|
r["arn"] for r in results["failed"] if isinstance(r, dict)
|
|
]:
|
|
try:
|
|
_delete_resource(arn, resource_type, region)
|
|
results["deleted"].append(arn)
|
|
except Exception as e:
|
|
results["failed"].append({"arn": arn, "error": str(e)})
|
|
|
|
# Delete outputs file
|
|
delete_outputs(stack_name)
|
|
|
|
return results
|
|
|
|
|
|
def _delete_resource(arn: str, resource_type: str, region: str) -> None:
|
|
"""Delete a single resource by ARN."""
|
|
if resource_type.startswith("ecs:service"):
|
|
_delete_ecs_service(arn, region)
|
|
elif resource_type.startswith("ecs:cluster"):
|
|
_delete_ecs_cluster(arn, region)
|
|
elif resource_type.startswith("apigateway"):
|
|
_delete_api_gateway(arn, region)
|
|
elif resource_type.startswith("lambda"):
|
|
_delete_lambda(arn, region)
|
|
elif resource_type.startswith("iam:role"):
|
|
_delete_iam_role(arn, region)
|
|
elif resource_type.startswith("s3"):
|
|
_delete_s3_bucket(arn, region)
|
|
elif resource_type.startswith("logs"):
|
|
_delete_log_group(arn, region)
|
|
elif resource_type.startswith("ecr"):
|
|
_delete_ecr_repository(arn, region)
|
|
elif resource_type.startswith("ec2:security-group"):
|
|
_delete_security_group(arn, region)
|
|
|
|
|
|
def _delete_ecs_service(arn: str, region: str) -> None:
|
|
"""Delete ECS service."""
|
|
client = get_boto3_client("ecs", region)
|
|
# Extract cluster and service name from ARN
|
|
# Format: arn:aws:ecs:region:account:service/cluster-name/service-name
|
|
parts = arn.split("/")
|
|
if len(parts) >= 3:
|
|
cluster = parts[-2]
|
|
service = parts[-1]
|
|
# Scale to 0 first
|
|
try:
|
|
client.update_service(cluster=cluster, service=service, desiredCount=0)
|
|
time.sleep(5)
|
|
except ClientError:
|
|
# Service may be draining or already at 0; proceed with deletion
|
|
logger.debug("Could not scale ECS service before deletion", exc_info=True)
|
|
client.delete_service(cluster=cluster, service=service, force=True)
|
|
|
|
|
|
def _delete_ecs_cluster(arn: str, region: str) -> None:
|
|
"""Delete ECS cluster."""
|
|
client = get_boto3_client("ecs", region)
|
|
# Extract cluster name from ARN
|
|
cluster_name = arn.split("/")[-1]
|
|
client.delete_cluster(cluster=cluster_name)
|
|
|
|
|
|
def _delete_api_gateway(arn: str, region: str) -> None:
|
|
"""Delete API Gateway."""
|
|
client = get_boto3_client("apigateway", region)
|
|
# Extract API ID from ARN
|
|
api_id = arn.split("/")[-1]
|
|
client.delete_rest_api(restApiId=api_id)
|
|
|
|
|
|
def _delete_lambda(arn: str, region: str) -> None:
|
|
"""Delete Lambda function."""
|
|
client = get_boto3_client("lambda", region)
|
|
# Extract function name from ARN
|
|
function_name = arn.split(":")[-1]
|
|
client.delete_function(FunctionName=function_name)
|
|
|
|
|
|
def _delete_iam_role(arn: str, region: str) -> None:
|
|
"""Delete IAM role and all attached policies."""
|
|
client = get_boto3_client("iam", region)
|
|
role_name = arn.split("/")[-1]
|
|
|
|
# Detach managed policies
|
|
try:
|
|
attached = client.list_attached_role_policies(RoleName=role_name)
|
|
for policy in attached.get("AttachedPolicies", []):
|
|
client.detach_role_policy(RoleName=role_name, PolicyArn=policy["PolicyArn"])
|
|
except ClientError:
|
|
# Role may not exist or policies already detached
|
|
logger.debug("Could not detach managed role policies", exc_info=True)
|
|
|
|
# Delete inline policies
|
|
try:
|
|
inline = client.list_role_policies(RoleName=role_name)
|
|
for policy_name in inline.get("PolicyNames", []):
|
|
client.delete_role_policy(RoleName=role_name, PolicyName=policy_name)
|
|
except ClientError:
|
|
# Role may not exist or inline policies already deleted
|
|
logger.debug("Could not delete inline role policies", exc_info=True)
|
|
|
|
# Delete instance profiles
|
|
try:
|
|
profiles = client.list_instance_profiles_for_role(RoleName=role_name)
|
|
for profile in profiles.get("InstanceProfiles", []):
|
|
client.remove_role_from_instance_profile(
|
|
InstanceProfileName=profile["InstanceProfileName"],
|
|
RoleName=role_name,
|
|
)
|
|
except ClientError:
|
|
# Role may not exist or instance profiles already removed
|
|
logger.debug("Could not remove role from instance profiles", exc_info=True)
|
|
|
|
client.delete_role(RoleName=role_name)
|
|
|
|
|
|
def _delete_s3_bucket(arn: str, region: str) -> None:
|
|
"""Delete S3 bucket and all objects."""
|
|
from tests.shared.infrastructure_sdk.resources.s3 import delete_bucket
|
|
|
|
# Extract bucket name from ARN (arn:aws:s3:::bucket-name)
|
|
bucket_name = arn.split(":")[-1]
|
|
delete_bucket(bucket_name, region)
|
|
|
|
|
|
def _delete_log_group(arn: str, region: str) -> None:
|
|
"""Delete CloudWatch log group."""
|
|
client = get_boto3_client("logs", region)
|
|
# Extract log group name from ARN
|
|
# Format: arn:aws:logs:region:account:log-group:name:*
|
|
parts = arn.split(":")
|
|
if len(parts) >= 7:
|
|
log_group_name = parts[6]
|
|
client.delete_log_group(logGroupName=log_group_name)
|
|
|
|
|
|
def _delete_ecr_repository(arn: str, region: str) -> None:
|
|
"""Delete ECR repository and all images."""
|
|
client = get_boto3_client("ecr", region)
|
|
# Extract repository name from ARN
|
|
repo_name = arn.split("/")[-1]
|
|
client.delete_repository(repositoryName=repo_name, force=True)
|
|
|
|
|
|
def _delete_security_group(arn: str, region: str) -> None:
|
|
"""Delete security group."""
|
|
client = get_boto3_client("ec2", region)
|
|
# Extract security group ID from ARN
|
|
sg_id = arn.split("/")[-1]
|
|
client.delete_security_group(GroupId=sg_id)
|