Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:10:45 +08:00

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)