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

416 lines
13 KiB
Python

#!/usr/bin/env python3
"""Deploy and destroy OpenSRE on EC2 (web + gateway containers on one instance)."""
from __future__ import annotations
import argparse
import os
import time
from botocore.exceptions import ClientError
from config.constants.paths import REPO_ROOT
from platform.deployment.aws import ecr
from platform.deployment.aws.client import DEFAULT_REGION
from platform.deployment.aws.config import (
ECR_DEFAULT_IMAGE_TAG,
ECR_DOCKER_PLATFORM,
INSTANCE_TYPE,
SSM_MANAGED_POLICY_ARN,
)
from platform.deployment.aws.ec2 import (
create_instance_profile,
create_stack_security_group,
delete_instance_profile,
delete_stack_security_group,
find_stack_instance_ids,
get_latest_al2023_ami,
launch_instance,
terminate_instance,
wait_for_running,
)
from platform.deployment.aws.ssm import wait_for_ssm_registration
from platform.deployment.ecr_deploy.instance import (
provision_instance_via_ssm,
wait_for_deployment_ready,
)
from platform.deployment.ecr_deploy.prep import run_lifecycle_main, validate_deploy_env
from platform.deployment.ecr_deploy.stack import (
delete_outputs,
get_stack,
image_uri_exists,
load_image_uri,
load_outputs,
outputs_exists,
save_image_uri,
save_outputs,
)
REGION = DEFAULT_REGION
DOCKERFILE = REPO_ROOT / "Dockerfile"
_ABORT_IF_EXISTS_ENV = "OPENSRE_DEPLOY_ABORT_IF_EXISTS"
_IMAGE_URI_ENV = "OPENSRE_IMAGE_URI"
_PURGE_ECR_ENV = "OPENSRE_DESTROY_PURGE_ECR"
_CONTAINER_ENV_KEYS = (
"TELEGRAM_BOT_TOKEN",
"TELEGRAM_ALLOWED_USERS",
"LLM_PROVIDER",
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"ANTHROPIC_MODEL",
)
def _collect_deploy_env_vars() -> dict[str, str]:
env_vars: dict[str, str] = {}
for key in _CONTAINER_ENV_KEYS:
val = os.getenv(key)
if val:
env_vars[key] = val
return env_vars
def _abort_if_exists_enabled() -> bool:
return os.getenv(_ABORT_IF_EXISTS_ENV, "").strip().lower() in {"1", "true", "yes"}
def _purge_ecr_enabled() -> bool:
return os.getenv(_PURGE_ECR_ENV, "").strip().lower() in {"1", "true", "yes"}
def _resolve_image_uri() -> str:
"""Return the Docker image URI for the current deploy.
Resolution order:
1. ``OPENSRE_IMAGE_URI`` environment variable (explicit override).
2. URI saved on disk by the last ``make build-image`` run.
Raises:
RuntimeError: When neither source is available, so the caller fails
fast with a clear message rather than silently building in-band.
"""
uri = os.getenv(_IMAGE_URI_ENV, "").strip()
if uri:
return uri
if image_uri_exists():
return load_image_uri()
raise RuntimeError(
"No pre-built image found. Run `make build-image` first to build and push the "
f"Docker image, or set the {_IMAGE_URI_ENV} environment variable to an existing "
"ECR image URI.\n\n"
" Quick start:\n"
" make build-image # build once, saves URI locally\n"
" make deploy # reuse the saved URI (fast)\n\n"
" Or in one step:\n"
f" {_IMAGE_URI_ENV}=<uri> make deploy"
)
def build_image() -> str:
"""Build the Docker image and push it to ECR.
Saves the resulting image URI locally so subsequent ``make deploy`` calls
can reuse it without rebuilding. Run this once per code change, then call
``make deploy`` as many times as needed.
Returns:
The full ECR image URI (e.g. ``123….dkr.ecr.us-east-1.amazonaws.com/opensre:latest``).
"""
stack = get_stack()
start_time = time.time()
print("=" * 60)
print(f"Building and pushing image for {stack.stack_name}")
print("=" * 60)
print()
print("Creating ECR repository (if needed)...")
repo = ecr.create_repository(stack.ecr_repo_name, stack.stack_name, REGION)
print(f" - Repository: {repo['uri']}")
print("Building and pushing Docker image...")
image_uri = ecr.build_and_push(
dockerfile_path=DOCKERFILE,
repository_uri=repo["uri"],
tag=ECR_DEFAULT_IMAGE_TAG,
platform=ECR_DOCKER_PLATFORM,
context_dir=REPO_ROOT,
region=REGION,
)
save_image_uri(image_uri)
elapsed = time.time() - start_time
print()
print("=" * 60)
print(f"Image built and pushed in {elapsed:.1f}s")
print(f" URI: {image_uri}")
print(" URI saved — run `make deploy` to launch an instance with this image.")
print("=" * 60)
print()
return image_uri
def cleanup_existing_deployment(*, region: str = DEFAULT_REGION) -> bool:
"""Destroy a prior deployment when outputs or stack-tagged instances exist.
Terminates all active stack instances first so orphaned instances from a
prior redeploy do not block security-group cleanup.
Returns True when cleanup ran.
"""
stack = get_stack()
has_outputs = outputs_exists()
instance_ids = find_stack_instance_ids(stack.stack_name, region=region)
if not has_outputs and not instance_ids:
return False
if _abort_if_exists_enabled():
raise RuntimeError(
"Existing deployment detected "
f"(outputs file and/or {len(instance_ids)} active instance(s)). "
"Run `make destroy` first, or unset OPENSRE_DEPLOY_ABORT_IF_EXISTS."
)
print("=" * 60)
print("Existing deployment detected — destroying previous stack")
if instance_ids:
print(f" Active instances: {', '.join(instance_ids)}")
if has_outputs:
print(" Outputs file: present")
print("=" * 60)
print()
for instance_id in instance_ids:
print(f"Terminating stack instance {instance_id}...")
terminate_instance(instance_id, region)
if has_outputs:
destroy()
elif instance_ids:
print("No outputs file — skipped IAM cleanup.")
print()
return True
def deploy() -> dict[str, str]:
"""Launch an EC2 instance and wait for web + gateway containers to become healthy.
Requires a pre-built ECR image. Run ``make build-image`` first, or set the
``OPENSRE_IMAGE_URI`` environment variable to an existing image URI.
"""
validate_deploy_env()
stack = get_stack()
start_time = time.time()
image_uri = _resolve_image_uri()
print("=" * 60)
print(f"Deploying {stack.stack_name} (web + gateway containers on one EC2 instance)")
print("=" * 60)
print()
print(f"Using image: {image_uri}")
print()
cleanup_existing_deployment(region=REGION)
print("Creating IAM instance profile...")
profile_info = create_instance_profile(
role_name=f"{stack.stack_name}-role",
profile_name=f"{stack.stack_name}-profile",
stack_name=stack.stack_name,
region=REGION,
extra_policy_arns=[SSM_MANAGED_POLICY_ARN],
)
print(f" - Profile: {profile_info['ProfileName']}")
print("Looking up latest Amazon Linux 2023 AMI...")
ami_id = get_latest_al2023_ami(REGION)
print(f" - AMI: {ami_id}")
print("Creating security group...")
security_group_id = create_stack_security_group(stack.stack_name, region=REGION)
print(f" - Security group: {security_group_id}")
print("Launching EC2 instance...")
instance = launch_instance(
ami_id=ami_id,
instance_profile_arn=profile_info["ProfileArn"],
stack_name=stack.stack_name,
instance_type=INSTANCE_TYPE,
security_group_ids=[security_group_id],
region=REGION,
)
print(f" - Instance ID: {instance['InstanceId']}")
print("Waiting for instance to start...")
running = wait_for_running(instance["InstanceId"], REGION)
public_ip = running["PublicIpAddress"]
print(f" - Public IP: {public_ip}")
print("Waiting for SSM agent to register...")
wait_for_ssm_registration(instance["InstanceId"], REGION)
print(" - SSM: Online")
print("Provisioning instance via SSM (Docker install, image pull, containers)...")
provision_instance_via_ssm(
instance["InstanceId"],
image_uri=image_uri,
container_env_vars=_collect_deploy_env_vars(),
region=REGION,
)
print(" - Provision: OK")
print("Waiting for web and gateway containers (may take several minutes)...")
wait_for_deployment_ready(
instance_id=instance["InstanceId"],
region=REGION,
)
outputs = {
"StackName": stack.stack_name,
"InstanceId": instance["InstanceId"],
"PublicIpAddress": public_ip,
"SecurityGroupId": security_group_id,
"ProfileName": profile_info["ProfileName"],
"RoleName": profile_info["RoleName"],
"AmiId": ami_id,
"ImageUri": image_uri,
"WebContainer": stack.web_container_name,
"GatewayContainer": stack.gateway_container_name,
}
save_outputs(outputs)
elapsed = time.time() - start_time
print()
print("=" * 60)
print(f"Deployment completed in {elapsed:.1f}s")
print("=" * 60)
print()
for key, value in outputs.items():
print(f" {key}: {value}")
return outputs
def destroy() -> dict[str, list[str]]:
"""Terminate the EC2 instance and clean up EC2/IAM resources.
The ECR repository and its images are kept by default so that a
subsequent ``make deploy`` does not need a full ``make build-image``
rebuild — this is the main lever for keeping repeated deploy/destroy
cycles fast and cheap. Set ``OPENSRE_DESTROY_PURGE_ECR=1`` to also
delete the ECR repository (e.g. for a full account cleanup).
"""
stack = get_stack()
start_time = time.time()
print("=" * 60)
print(f"Destroying {stack.stack_name} infrastructure")
print("=" * 60)
print()
results: dict[str, list[str]] = {"deleted": [], "failed": []}
try:
outputs = load_outputs()
except FileNotFoundError:
print("No outputs file found — attempting cleanup by known names.")
outputs = {}
instance_id = outputs.get("InstanceId", "")
security_group_id = outputs.get("SecurityGroupId", "")
profile_name = outputs.get("ProfileName", f"{stack.stack_name}-profile")
role_name = outputs.get("RoleName", f"{stack.stack_name}-role")
if instance_id:
print(f"Terminating EC2 instance {instance_id}...")
try:
terminate_instance(instance_id, DEFAULT_REGION)
results["deleted"].append(f"ec2-instance:{instance_id}")
print(" - Instance terminated")
except ClientError as e:
msg = f"ec2-instance:{instance_id} - {e}"
results["failed"].append(msg)
print(f" - Failed: {e}")
if security_group_id:
print(f"Deleting security group {security_group_id}...")
try:
delete_stack_security_group(security_group_id, region=DEFAULT_REGION)
results["deleted"].append(f"security-group:{security_group_id}")
print(" - Security group deleted")
except ClientError as e:
msg = f"security-group:{security_group_id} - {e}"
results["failed"].append(msg)
print(f" - Failed: {e}")
print(f"Deleting IAM profile {profile_name} and role {role_name}...")
try:
delete_instance_profile(profile_name, role_name, DEFAULT_REGION)
results["deleted"].append(f"instance-profile:{profile_name}")
results["deleted"].append(f"iam-role:{role_name}")
print(" - Profile and role deleted")
except ClientError as e:
msg = f"iam:{profile_name}/{role_name} - {e}"
results["failed"].append(msg)
print(f" - Failed: {e}")
if _purge_ecr_enabled():
print(f"Deleting ECR repository {stack.ecr_repo_name} ({_PURGE_ECR_ENV}=1)...")
try:
ecr.delete_repository(stack.ecr_repo_name, DEFAULT_REGION)
results["deleted"].append(f"ecr-repository:{stack.ecr_repo_name}")
print(" - ECR repository deleted")
except ClientError as e:
msg = f"ecr-repository:{stack.ecr_repo_name} - {e}"
results["failed"].append(msg)
print(f" - Failed: {e}")
else:
print(
f"Keeping ECR repository {stack.ecr_repo_name} "
f"(set {_PURGE_ECR_ENV}=1 to also delete it)."
)
delete_outputs()
elapsed = time.time() - start_time
print()
print("=" * 60)
print(f"Destroy completed in {elapsed:.1f}s")
print("=" * 60)
if results["deleted"]:
print(f"\nDeleted {len(results['deleted'])} resources:")
for r in results["deleted"]:
print(f" - {r}")
if results["failed"]:
print(f"\nFailed to delete {len(results['failed'])} resources:")
for r in results["failed"]:
print(f" - {r}")
return results
def main() -> None:
parser = argparse.ArgumentParser(description="OpenSRE EC2 deployment lifecycle")
subparsers = parser.add_subparsers(dest="command", required=True)
subparsers.add_parser("build-image", help="Build and push the Docker image to ECR")
subparsers.add_parser("deploy", help="Launch EC2 instance using a pre-built image")
subparsers.add_parser("destroy", help="Tear down the EC2 stack")
args = parser.parse_args()
if args.command == "build-image":
build_image()
elif args.command == "deploy":
deploy()
else:
destroy()
if __name__ == "__main__":
run_lifecycle_main(main)