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
357 lines
11 KiB
Python
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)
|