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
568 lines
18 KiB
Python
568 lines
18 KiB
Python
"""EC2 instance provisioning for OpenSRE deployments."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import json
|
|
import logging
|
|
import os
|
|
import time
|
|
|
|
from botocore.exceptions import ClientError
|
|
|
|
from platform.deployment.aws.client import DEFAULT_REGION, get_boto3_client, get_standard_tags
|
|
from platform.deployment.aws.config import (
|
|
AL2023_AMI_SSM_PARAMETER,
|
|
BEDROCK_POLICY_ARN,
|
|
EC2_INSTANCE_ROLE_DESCRIPTION,
|
|
EC2_ROOT_DEVICE_NAME,
|
|
EC2_VOLUME_SIZE_GB,
|
|
EC2_VOLUME_TYPE,
|
|
EC2_WAITER_DELAY_SECONDS,
|
|
EC2_WAITER_MAX_ATTEMPTS,
|
|
ECR_READ_POLICY_ARN,
|
|
IAM_PROFILE_PROPAGATION_SECONDS,
|
|
INSTANCE_TYPE,
|
|
STACK_TAG_KEY,
|
|
WEB_API_INGRESS_CIDR_DEFAULT,
|
|
WEB_API_INGRESS_CIDR_ENV,
|
|
WEB_API_PORT,
|
|
)
|
|
|
|
ACTIVE_EC2_INSTANCE_STATES = ("pending", "running", "stopping")
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def get_latest_al2023_ami(region: str = DEFAULT_REGION) -> str:
|
|
"""Find the latest Amazon Linux 2023 x86_64 AMI via SSM parameter."""
|
|
ssm = get_boto3_client("ssm", region)
|
|
resp = ssm.get_parameter(Name=AL2023_AMI_SSM_PARAMETER)
|
|
return str(resp["Parameter"]["Value"])
|
|
|
|
|
|
def get_latest_ubuntu2204_ami(region: str = DEFAULT_REGION) -> str:
|
|
"""Find the latest Ubuntu 22.04 LTS (Jammy) x86_64 AMI via Canonical's SSM parameter.
|
|
|
|
Ubuntu 22.04 ships glibc 2.35, which satisfies the pre-built opensre PyInstaller
|
|
binary's runtime requirement. Amazon Linux 2023 only ships glibc 2.34 and will
|
|
fail with a ``GLIBC_2.35 not found`` error at binary launch time.
|
|
"""
|
|
from platform.deployment.aws.config import UBUNTU2204_AMI_SSM_PARAMETER
|
|
|
|
ssm = get_boto3_client("ssm", region)
|
|
resp = ssm.get_parameter(Name=UBUNTU2204_AMI_SSM_PARAMETER)
|
|
return str(resp["Parameter"]["Value"])
|
|
|
|
|
|
def create_instance_profile(
|
|
role_name: str,
|
|
profile_name: str,
|
|
stack_name: str,
|
|
region: str = DEFAULT_REGION,
|
|
extra_policy_arns: list[str] | None = None,
|
|
) -> dict[str, str]:
|
|
"""Create an IAM instance profile with EC2 trust, ECR read, and Bedrock policies.
|
|
|
|
Passes ``extra_policy_arns`` for additional managed policies (e.g. SSM access).
|
|
Returns a dict with ProfileName, ProfileArn, and RoleName.
|
|
"""
|
|
iam = get_boto3_client("iam", region)
|
|
tags = get_standard_tags(stack_name)
|
|
|
|
ec2_trust_policy = {
|
|
"Version": "2012-10-17",
|
|
"Statement": [
|
|
{
|
|
"Effect": "Allow",
|
|
"Principal": {"Service": "ec2.amazonaws.com"},
|
|
"Action": "sts:AssumeRole",
|
|
}
|
|
],
|
|
}
|
|
|
|
try:
|
|
resp = iam.create_role(
|
|
RoleName=role_name,
|
|
AssumeRolePolicyDocument=json.dumps(ec2_trust_policy),
|
|
Description=EC2_INSTANCE_ROLE_DESCRIPTION,
|
|
Tags=tags,
|
|
)
|
|
role_arn = resp["Role"]["Arn"]
|
|
except ClientError as e:
|
|
if e.response["Error"]["Code"] == "EntityAlreadyExists":
|
|
resp = iam.get_role(RoleName=role_name)
|
|
role_arn = resp["Role"]["Arn"]
|
|
else:
|
|
raise
|
|
|
|
logger.info("IAM role ready: %s (%s)", role_name, role_arn)
|
|
|
|
try:
|
|
iam.create_instance_profile(InstanceProfileName=profile_name, Tags=tags)
|
|
except ClientError as e:
|
|
if e.response["Error"]["Code"] != "EntityAlreadyExists":
|
|
raise
|
|
|
|
try:
|
|
iam.add_role_to_instance_profile(InstanceProfileName=profile_name, RoleName=role_name)
|
|
except ClientError as e:
|
|
if e.response["Error"]["Code"] != "LimitExceeded":
|
|
raise
|
|
|
|
with contextlib.suppress(ClientError):
|
|
iam.attach_role_policy(RoleName=role_name, PolicyArn=ECR_READ_POLICY_ARN)
|
|
|
|
with contextlib.suppress(ClientError):
|
|
iam.attach_role_policy(RoleName=role_name, PolicyArn=BEDROCK_POLICY_ARN)
|
|
|
|
for arn in extra_policy_arns or []:
|
|
with contextlib.suppress(ClientError):
|
|
iam.attach_role_policy(RoleName=role_name, PolicyArn=arn)
|
|
|
|
if IAM_PROFILE_PROPAGATION_SECONDS > 0:
|
|
time.sleep(IAM_PROFILE_PROPAGATION_SECONDS)
|
|
|
|
resp = iam.get_instance_profile(InstanceProfileName=profile_name)
|
|
return {
|
|
"ProfileName": profile_name,
|
|
"ProfileArn": resp["InstanceProfile"]["Arn"],
|
|
"RoleName": role_name,
|
|
}
|
|
|
|
|
|
def delete_instance_profile(
|
|
profile_name: str,
|
|
role_name: str,
|
|
region: str = DEFAULT_REGION,
|
|
) -> None:
|
|
"""Delete an IAM instance profile and its associated role."""
|
|
iam = get_boto3_client("iam", region)
|
|
|
|
try:
|
|
iam.remove_role_from_instance_profile(InstanceProfileName=profile_name, RoleName=role_name)
|
|
except ClientError as e:
|
|
if e.response["Error"]["Code"] != "NoSuchEntity":
|
|
logger.warning("Failed to remove role from profile: %s", e)
|
|
|
|
try:
|
|
iam.delete_instance_profile(InstanceProfileName=profile_name)
|
|
except ClientError as e:
|
|
if e.response["Error"]["Code"] != "NoSuchEntity":
|
|
logger.warning("Failed to delete instance profile: %s", e)
|
|
|
|
try:
|
|
attached = iam.list_attached_role_policies(RoleName=role_name)
|
|
for policy in attached.get("AttachedPolicies", []):
|
|
iam.detach_role_policy(RoleName=role_name, PolicyArn=policy["PolicyArn"])
|
|
except ClientError as e:
|
|
if e.response["Error"]["Code"] != "NoSuchEntity":
|
|
logger.warning("Failed to detach policies: %s", e)
|
|
|
|
try:
|
|
inline = iam.list_role_policies(RoleName=role_name)
|
|
for policy_name in inline.get("PolicyNames", []):
|
|
iam.delete_role_policy(RoleName=role_name, PolicyName=policy_name)
|
|
except ClientError as e:
|
|
if e.response["Error"]["Code"] != "NoSuchEntity":
|
|
logger.warning("Failed to delete inline policies: %s", e)
|
|
|
|
try:
|
|
iam.delete_role(RoleName=role_name)
|
|
except ClientError as e:
|
|
if e.response["Error"]["Code"] != "NoSuchEntity":
|
|
raise
|
|
|
|
|
|
def stack_security_group_name(stack_name: str) -> str:
|
|
"""Return the deterministic security group name for a deployment stack."""
|
|
return f"{stack_name}-sg"
|
|
|
|
|
|
def web_api_ingress_cidr() -> str:
|
|
"""Return the CIDR allowed to reach the web API port."""
|
|
return os.getenv(WEB_API_INGRESS_CIDR_ENV, WEB_API_INGRESS_CIDR_DEFAULT).strip()
|
|
|
|
|
|
def get_default_vpc_id(*, region: str = DEFAULT_REGION) -> str:
|
|
"""Return the account default VPC id.
|
|
|
|
Raises:
|
|
ClientError: When the region has no default VPC (``DefaultVpcNotFound``).
|
|
"""
|
|
ec2 = get_boto3_client("ec2", region)
|
|
resp = ec2.describe_vpcs(Filters=[{"Name": "isDefault", "Values": ["true"]}])
|
|
vpcs = resp.get("Vpcs", [])
|
|
if not vpcs:
|
|
raise ClientError(
|
|
{
|
|
"Error": {
|
|
"Code": "DefaultVpcNotFound",
|
|
"Message": (
|
|
f"No default VPC found in {region}. Create a default VPC or "
|
|
"deploy into an account/region with a default VPC."
|
|
),
|
|
}
|
|
},
|
|
"DescribeVpcs",
|
|
)
|
|
return str(vpcs[0]["VpcId"])
|
|
|
|
|
|
def _web_api_ingress_permission(
|
|
*,
|
|
port: int,
|
|
cidr: str,
|
|
description: str,
|
|
) -> dict[str, object]:
|
|
return {
|
|
"IpProtocol": "tcp",
|
|
"FromPort": port,
|
|
"ToPort": port,
|
|
"IpRanges": [{"CidrIp": cidr, "Description": description}],
|
|
}
|
|
|
|
|
|
def _revoke_stale_web_api_ingress(
|
|
*,
|
|
group_id: str,
|
|
port: int,
|
|
desired_cidr: str,
|
|
region: str = DEFAULT_REGION,
|
|
) -> bool:
|
|
"""Revoke web API ingress CIDRs that do not match ``desired_cidr``.
|
|
|
|
Returns True when ``desired_cidr`` is already authorized on ``port``.
|
|
"""
|
|
ec2 = get_boto3_client("ec2", region)
|
|
response = ec2.describe_security_groups(GroupIds=[group_id])
|
|
security_groups = response.get("SecurityGroups", [])
|
|
if not security_groups:
|
|
return False
|
|
desired_present = False
|
|
|
|
for permission in security_groups[0].get("IpPermissions", []):
|
|
if permission.get("IpProtocol") != "tcp":
|
|
continue
|
|
if permission.get("FromPort") != port or permission.get("ToPort") != port:
|
|
continue
|
|
|
|
ip_ranges = permission.get("IpRanges") or []
|
|
stale_ranges = [
|
|
ip_range for ip_range in ip_ranges if ip_range.get("CidrIp") != desired_cidr
|
|
]
|
|
if any(ip_range.get("CidrIp") == desired_cidr for ip_range in ip_ranges):
|
|
desired_present = True
|
|
if not stale_ranges:
|
|
continue
|
|
|
|
try:
|
|
ec2.revoke_security_group_ingress(
|
|
GroupId=group_id,
|
|
IpPermissions=[
|
|
{
|
|
"IpProtocol": "tcp",
|
|
"FromPort": port,
|
|
"ToPort": port,
|
|
"IpRanges": stale_ranges,
|
|
}
|
|
],
|
|
)
|
|
except ClientError as e:
|
|
if e.response["Error"]["Code"] != "InvalidPermission.NotFound":
|
|
raise
|
|
|
|
return desired_present
|
|
|
|
|
|
def _authorize_tcp_ingress(
|
|
*,
|
|
group_id: str,
|
|
port: int,
|
|
cidr: str,
|
|
description: str,
|
|
region: str = DEFAULT_REGION,
|
|
) -> None:
|
|
ec2 = get_boto3_client("ec2", region)
|
|
try:
|
|
ec2.authorize_security_group_ingress(
|
|
GroupId=group_id,
|
|
IpPermissions=[
|
|
_web_api_ingress_permission(port=port, cidr=cidr, description=description)
|
|
],
|
|
)
|
|
except ClientError as e:
|
|
if e.response["Error"]["Code"] != "InvalidPermission.Duplicate":
|
|
raise
|
|
|
|
|
|
def _sync_web_api_ingress(
|
|
*,
|
|
group_id: str,
|
|
cidr: str,
|
|
region: str = DEFAULT_REGION,
|
|
) -> None:
|
|
"""Ensure only ``cidr`` can reach the web API port on ``group_id``."""
|
|
description = f"OpenSRE web API port {WEB_API_PORT}"
|
|
desired_present = _revoke_stale_web_api_ingress(
|
|
group_id=group_id,
|
|
port=WEB_API_PORT,
|
|
desired_cidr=cidr,
|
|
region=region,
|
|
)
|
|
if desired_present:
|
|
return
|
|
_authorize_tcp_ingress(
|
|
group_id=group_id,
|
|
port=WEB_API_PORT,
|
|
cidr=cidr,
|
|
description=description,
|
|
region=region,
|
|
)
|
|
|
|
|
|
def create_stack_security_group(
|
|
stack_name: str,
|
|
*,
|
|
region: str = DEFAULT_REGION,
|
|
) -> str:
|
|
"""Create (or reuse) a stack security group with inbound TCP on the web API port.
|
|
|
|
Returns the security group id (e.g. ``sg-0abc123...``).
|
|
"""
|
|
ec2 = get_boto3_client("ec2", region)
|
|
vpc_id = get_default_vpc_id(region=region)
|
|
group_name = stack_security_group_name(stack_name)
|
|
tags = get_standard_tags(stack_name)
|
|
tags.append({"Key": "Name", "Value": group_name})
|
|
|
|
response = ec2.describe_security_groups(
|
|
Filters=[
|
|
{"Name": "group-name", "Values": [group_name]},
|
|
{"Name": "vpc-id", "Values": [vpc_id]},
|
|
]
|
|
)
|
|
existing = response.get("SecurityGroups", [])
|
|
if existing:
|
|
group_id = str(existing[0]["GroupId"])
|
|
else:
|
|
created = ec2.create_security_group(
|
|
GroupName=group_name,
|
|
Description=f"OpenSRE deployment security group for {stack_name}",
|
|
VpcId=vpc_id,
|
|
TagSpecifications=[{"ResourceType": "security-group", "Tags": tags}],
|
|
)
|
|
group_id = str(created["GroupId"])
|
|
logger.info("Created security group %s for stack %s", group_id, stack_name)
|
|
|
|
cidr = web_api_ingress_cidr()
|
|
_sync_web_api_ingress(group_id=group_id, cidr=cidr, region=region)
|
|
return group_id
|
|
|
|
|
|
def delete_stack_security_group(
|
|
security_group_id: str,
|
|
*,
|
|
region: str = DEFAULT_REGION,
|
|
) -> None:
|
|
"""Delete a stack security group. Tolerates missing groups for idempotent destroy."""
|
|
if not security_group_id:
|
|
return
|
|
ec2 = get_boto3_client("ec2", region)
|
|
try:
|
|
ec2.delete_security_group(GroupId=security_group_id)
|
|
logger.info("Deleted security group %s", security_group_id)
|
|
except ClientError as e:
|
|
if e.response["Error"]["Code"] != "InvalidGroup.NotFound":
|
|
raise
|
|
|
|
|
|
def find_stack_instance_ids(
|
|
stack_name: str,
|
|
*,
|
|
region: str = DEFAULT_REGION,
|
|
) -> list[str]:
|
|
"""Return instance IDs tagged for the stack that are still active."""
|
|
ec2 = get_boto3_client("ec2", region)
|
|
response = ec2.describe_instances(
|
|
Filters=[
|
|
{"Name": f"tag:{STACK_TAG_KEY}", "Values": [stack_name]},
|
|
{"Name": "instance-state-name", "Values": list(ACTIVE_EC2_INSTANCE_STATES)},
|
|
]
|
|
)
|
|
|
|
instance_ids: list[str] = []
|
|
for reservation in response.get("Reservations", []):
|
|
for instance in reservation.get("Instances", []):
|
|
instance_id = instance.get("InstanceId")
|
|
if instance_id:
|
|
instance_ids.append(str(instance_id))
|
|
return sorted(instance_ids)
|
|
|
|
|
|
def launch_instance(
|
|
ami_id: str,
|
|
instance_profile_arn: str,
|
|
stack_name: str,
|
|
*,
|
|
user_data: str | None = None,
|
|
instance_type: str = INSTANCE_TYPE,
|
|
root_device_name: str = EC2_ROOT_DEVICE_NAME,
|
|
security_group_ids: list[str] | None = None,
|
|
region: str = DEFAULT_REGION,
|
|
) -> dict[str, str]:
|
|
"""Launch an EC2 instance in the default VPC and return its InstanceId.
|
|
|
|
When ``security_group_ids`` is omitted, AWS assigns the default VPC security
|
|
group. Pass a stack security group from :func:`create_stack_security_group` to
|
|
expose the web API port publicly.
|
|
|
|
``root_device_name`` must match the AMI root block device (``/dev/xvda`` for
|
|
Amazon Linux 2023, ``/dev/sda1`` for Ubuntu official AMIs) or the requested
|
|
EBS volume size is silently ignored.
|
|
"""
|
|
ec2 = get_boto3_client("ec2", region)
|
|
tags = get_standard_tags(stack_name)
|
|
tags.append({"Key": "Name", "Value": f"{stack_name}-instance"})
|
|
|
|
launch_kwargs: dict = {
|
|
"ImageId": ami_id,
|
|
"InstanceType": instance_type,
|
|
"MinCount": 1,
|
|
"MaxCount": 1,
|
|
"IamInstanceProfile": {"Arn": instance_profile_arn},
|
|
"TagSpecifications": [{"ResourceType": "instance", "Tags": tags}],
|
|
"BlockDeviceMappings": [
|
|
{
|
|
"DeviceName": root_device_name,
|
|
"Ebs": {"VolumeSize": EC2_VOLUME_SIZE_GB, "VolumeType": EC2_VOLUME_TYPE},
|
|
}
|
|
],
|
|
}
|
|
if user_data:
|
|
launch_kwargs["UserData"] = user_data
|
|
if security_group_ids:
|
|
launch_kwargs["SecurityGroupIds"] = security_group_ids
|
|
key_name = os.getenv("EC2_KEY_NAME")
|
|
if key_name:
|
|
launch_kwargs["KeyName"] = key_name
|
|
resp = ec2.run_instances(**launch_kwargs)
|
|
|
|
instance_id = resp["Instances"][0]["InstanceId"]
|
|
logger.info("Launched EC2 instance: %s", instance_id)
|
|
return {"InstanceId": instance_id}
|
|
|
|
|
|
def wait_for_running(instance_id: str, region: str = DEFAULT_REGION) -> dict[str, str]:
|
|
"""Wait for an EC2 instance to reach the running state and return its public IP."""
|
|
ec2 = get_boto3_client("ec2", region)
|
|
waiter = ec2.get_waiter("instance_running")
|
|
waiter.wait(
|
|
InstanceIds=[instance_id],
|
|
WaiterConfig={"Delay": EC2_WAITER_DELAY_SECONDS, "MaxAttempts": EC2_WAITER_MAX_ATTEMPTS},
|
|
)
|
|
|
|
resp = ec2.describe_instances(InstanceIds=[instance_id])
|
|
instance = resp["Reservations"][0]["Instances"][0]
|
|
public_ip = instance.get("PublicIpAddress", "")
|
|
|
|
logger.info("Instance %s running at %s", instance_id, public_ip)
|
|
return {"InstanceId": instance_id, "PublicIpAddress": public_ip}
|
|
|
|
|
|
def create_image_from_instance(
|
|
instance_id: str,
|
|
name: str,
|
|
stack_name: str,
|
|
*,
|
|
region: str = DEFAULT_REGION,
|
|
) -> str:
|
|
"""Create an AMI from a running instance and wait until it is available.
|
|
|
|
AWS stops the instance, takes the snapshot, then restarts it. The caller
|
|
is responsible for terminating the builder instance afterward.
|
|
|
|
Returns the new AMI id (e.g. ``ami-0abc123...``).
|
|
"""
|
|
from platform.deployment.aws.config import (
|
|
GATEWAY_AMI_WAITER_DELAY_SECONDS,
|
|
GATEWAY_AMI_WAITER_MAX_ATTEMPTS,
|
|
)
|
|
|
|
ec2 = get_boto3_client("ec2", region)
|
|
tags = get_standard_tags(stack_name)
|
|
tags.append({"Key": "Name", "Value": name})
|
|
|
|
resp = ec2.create_image(
|
|
InstanceId=instance_id,
|
|
Name=name,
|
|
NoReboot=False,
|
|
TagSpecifications=[{"ResourceType": "image", "Tags": tags}],
|
|
)
|
|
image_id = str(resp["ImageId"])
|
|
logger.info("Creating AMI %s from instance %s", image_id, instance_id)
|
|
|
|
waiter = ec2.get_waiter("image_available")
|
|
waiter.wait(
|
|
ImageIds=[image_id],
|
|
WaiterConfig={
|
|
"Delay": GATEWAY_AMI_WAITER_DELAY_SECONDS,
|
|
"MaxAttempts": GATEWAY_AMI_WAITER_MAX_ATTEMPTS,
|
|
},
|
|
)
|
|
logger.info("AMI %s is now available", image_id)
|
|
return image_id
|
|
|
|
|
|
def deregister_image(image_id: str, *, region: str = DEFAULT_REGION) -> None:
|
|
"""Deregister an AMI and delete its backing EBS snapshot.
|
|
|
|
Tolerates ``InvalidAMIID.NotFound`` so destroy() is idempotent.
|
|
"""
|
|
ec2 = get_boto3_client("ec2", region)
|
|
|
|
try:
|
|
resp = ec2.describe_images(ImageIds=[image_id])
|
|
images = resp.get("Images", [])
|
|
snapshot_ids: list[str] = []
|
|
for image in images:
|
|
for mapping in image.get("BlockDeviceMappings", []):
|
|
ebs = mapping.get("Ebs", {})
|
|
if ebs.get("SnapshotId"):
|
|
snapshot_ids.append(str(ebs["SnapshotId"]))
|
|
|
|
ec2.deregister_image(ImageId=image_id)
|
|
logger.info("Deregistered AMI %s", image_id)
|
|
|
|
for snapshot_id in snapshot_ids:
|
|
try:
|
|
ec2.delete_snapshot(SnapshotId=snapshot_id)
|
|
logger.info("Deleted snapshot %s", snapshot_id)
|
|
except ClientError as e:
|
|
if "InvalidSnapshot.NotFound" not in str(e):
|
|
logger.warning("Failed to delete snapshot %s: %s", snapshot_id, e)
|
|
except ClientError as e:
|
|
if "InvalidAMIID.NotFound" not in str(e):
|
|
raise
|
|
logger.warning("AMI %s already gone", image_id)
|
|
|
|
|
|
def terminate_instance(instance_id: str, region: str = DEFAULT_REGION) -> None:
|
|
"""Terminate an EC2 instance and wait for termination."""
|
|
ec2 = get_boto3_client("ec2", region)
|
|
try:
|
|
ec2.terminate_instances(InstanceIds=[instance_id])
|
|
waiter = ec2.get_waiter("instance_terminated")
|
|
waiter.wait(
|
|
InstanceIds=[instance_id],
|
|
WaiterConfig={
|
|
"Delay": EC2_WAITER_DELAY_SECONDS,
|
|
"MaxAttempts": EC2_WAITER_MAX_ATTEMPTS,
|
|
},
|
|
)
|
|
logger.info("Instance %s terminated", instance_id)
|
|
except ClientError as e:
|
|
if "InvalidInstanceID.NotFound" not in str(e):
|
|
raise
|
|
logger.warning("Instance %s already terminated", instance_id)
|