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

357 lines
11 KiB
Python

#!/usr/bin/env python3
"""Deploy and destroy the OpenSRE Telegram gateway on EC2 using a custom AMI."""
from __future__ import annotations
import argparse
import os
import time
from botocore.exceptions import ClientError
from platform.deployment.aws.client import DEFAULT_REGION
from platform.deployment.aws.config import (
GATEWAY_AMI_DESTROY_PURGE_ENV,
GATEWAY_AMI_ID_ENV,
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,
deregister_image,
find_stack_instance_ids,
launch_instance,
terminate_instance,
wait_for_running,
)
from platform.deployment.aws.ssm import wait_for_ssm_registration
from platform.deployment.ecr_deploy.prep import run_lifecycle_main, validate_deploy_env
from platform.deployment.gateway.bake import bake_ami
from platform.deployment.gateway.direct_deploy import deploy_direct, destroy_direct
from platform.deployment.gateway.provision import (
provision_gateway_via_ssm,
wait_for_gateway_ready,
)
from platform.deployment.gateway.stack import (
ami_id_exists,
delete_outputs,
get_stack,
load_ami_id,
load_outputs,
outputs_exists,
save_outputs,
)
REGION = DEFAULT_REGION
_CONTAINER_ENV_KEYS = (
"TELEGRAM_BOT_TOKEN",
"TELEGRAM_ALLOWED_USERS",
"LLM_PROVIDER",
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"ANTHROPIC_MODEL",
)
_ABORT_IF_EXISTS_ENV = "OPENSRE_DEPLOY_ABORT_IF_EXISTS"
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_ami_enabled() -> bool:
return os.getenv(GATEWAY_AMI_DESTROY_PURGE_ENV, "").strip().lower() in {"1", "true", "yes"}
def _resolve_ami_id() -> str:
"""Return the AMI id to deploy.
Resolution order:
1. ``OPENSRE_GATEWAY_AMI_ID`` environment variable.
2. AMI id saved on disk by the last ``make bake-gateway`` run.
Raises:
RuntimeError: When neither source is available.
"""
ami_id = os.getenv(GATEWAY_AMI_ID_ENV, "").strip()
if ami_id:
return ami_id
if ami_id_exists():
return load_ami_id()
raise RuntimeError(
"No pre-built gateway AMI found. Run `make bake-gateway` first to build one, "
f"or set {GATEWAY_AMI_ID_ENV} to an existing AMI id.\n\n"
" Quick start:\n"
" make bake-gateway # bake once, saves AMI id locally\n"
" make deploy-gateway # launch from saved AMI (fast)\n\n"
" Or in one step:\n"
f" {GATEWAY_AMI_ID_ENV}=<ami-id> make deploy-gateway"
)
def cleanup_existing_deployment(*, region: str = DEFAULT_REGION) -> bool:
"""Destroy a prior gateway deployment when outputs or tagged instances exist.
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 gateway deployment detected "
f"({len(instance_ids)} active instance(s)). "
"Run `make destroy-gateway` first, or unset OPENSRE_DEPLOY_ABORT_IF_EXISTS."
)
print("=" * 60)
print("Existing gateway deployment detected — destroying previous stack")
if instance_ids:
print(f" Active instances: {', '.join(instance_ids)}")
print("=" * 60)
print()
for instance_id in instance_ids:
print(f"Terminating stack instance {instance_id}...")
terminate_instance(instance_id, region)
if has_outputs or instance_ids:
destroy()
print()
return True
def deploy() -> dict[str, str]:
"""Launch an EC2 instance from the pre-built gateway AMI and start the service."""
validate_deploy_env()
stack = get_stack()
start_time = time.time()
ami_id = _resolve_ami_id()
print("=" * 60)
print(f"Deploying {stack.stack_name} (gateway on one EC2 instance, systemd)")
print("=" * 60)
print()
print(f" AMI: {ami_id}")
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("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 from gateway AMI...")
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,
)
instance_id = instance["InstanceId"]
print(f" - Instance ID: {instance_id}")
print("Waiting for instance to start...")
running = wait_for_running(instance_id, REGION)
public_ip = running["PublicIpAddress"]
print(f" - Public IP: {public_ip}")
print("Waiting for SSM agent to register...")
wait_for_ssm_registration(instance_id, REGION)
print(" - SSM: Online")
print("Provisioning gateway (writing env file, starting service)...")
provision_gateway_via_ssm(
instance_id,
env_vars=_collect_deploy_env_vars(),
region=REGION,
)
print(" - Provision: OK")
print("Waiting for gateway service to become ready...")
wait_for_gateway_ready(instance_id, region=REGION)
print(" - Gateway: Ready")
outputs: dict[str, str] = {
"StackName": stack.stack_name,
"InstanceId": instance_id,
"PublicIpAddress": public_ip,
"SecurityGroupId": security_group_id,
"ProfileName": profile_info["ProfileName"],
"RoleName": profile_info["RoleName"],
"AmiId": ami_id,
}
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 custom AMI is kept by default so that a subsequent deploy does not need
a full re-bake. Set ``OPENSRE_GATEWAY_DESTROY_PURGE_AMI=1`` to also
deregister the AMI and delete its backing snapshot (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")
saved_ami_id = outputs.get("AmiId", "")
if instance_id:
print(f"Terminating EC2 instance {instance_id}...")
try:
terminate_instance(instance_id, 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=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, 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_ami_enabled() and saved_ami_id:
print(f"Deregistering gateway AMI {saved_ami_id} ({GATEWAY_AMI_DESTROY_PURGE_ENV}=1)...")
try:
deregister_image(saved_ami_id, region=REGION)
results["deleted"].append(f"ami:{saved_ami_id}")
print(" - AMI deregistered")
except ClientError as e:
msg = f"ami:{saved_ami_id} - {e}"
results["failed"].append(msg)
print(f" - Failed: {e}")
elif saved_ami_id:
print(
f"Keeping gateway AMI {saved_ami_id} "
f"(set {GATEWAY_AMI_DESTROY_PURGE_ENV}=1 to also deregister 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 gateway deployment lifecycle")
subparsers = parser.add_subparsers(dest="command", required=True)
subparsers.add_parser("bake-ami", help="Bake a gateway AMI (run once per code change)")
subparsers.add_parser("deploy", help="Launch EC2 instance using a pre-built gateway AMI")
subparsers.add_parser("destroy", help="Tear down the gateway AMI stack")
subparsers.add_parser(
"deploy-direct",
help="Launch a fresh EC2 instance and install the gateway inline via SSM (no pre-baked AMI)",
)
subparsers.add_parser("destroy-direct", help="Tear down the direct-deploy gateway stack")
args = parser.parse_args()
if args.command == "bake-ami":
bake_ami(region=REGION)
elif args.command == "deploy":
deploy()
elif args.command == "deploy-direct":
validate_deploy_env()
deploy_direct(env_vars=_collect_deploy_env_vars(), region=REGION)
elif args.command == "destroy-direct":
destroy_direct(region=REGION)
else:
destroy()
if __name__ == "__main__":
run_lifecycle_main(main)