from __future__ import annotations import re import time from dataclasses import dataclass from typing import Any import boto3 from botocore.config import Config from botocore.exceptions import BotoCoreError, ClientError from app.core.errors import ExternalServiceError, NotFoundError, ValidationAppError _STATIC_IP_NAME_PATTERN = re.compile(r"[A-Za-z0-9][A-Za-z0-9-]{0,253}[A-Za-z0-9]") @dataclass(slots=True) class InstanceSnapshot: name: str state: str public_ip: str | None is_static_ip: bool region: str @dataclass(slots=True) class StaticIpSnapshot: name: str ip_address: str | None is_attached: bool attached_to: str | None arn: str | None region: str class LightsailClient: operation_timeout_seconds = 180.0 operation_poll_interval_seconds = 2.0 def __init__( self, *, region: str, instance_name: str, access_key_id: str | None = None, secret_access_key: str | None = None, session_token: str | None = None, ) -> None: kwargs: dict[str, Any] = { "region_name": region, "config": Config( connect_timeout=10, read_timeout=20, retries={"max_attempts": 4, "mode": "standard"}, ), } if access_key_id and secret_access_key: kwargs.update( aws_access_key_id=access_key_id, aws_secret_access_key=secret_access_key, aws_session_token=session_token, ) self._client = boto3.client("lightsail", **kwargs) self.region = region self.instance_name = instance_name @classmethod def from_credentials( cls, *, region: str, instance_name: str, use_default: bool, secrets: dict[str, str | None], ) -> LightsailClient: if use_default: return cls(region=region, instance_name=instance_name) access_key_id = secrets.get("aws_access_key_id") secret_access_key = secrets.get("aws_secret_access_key") if not access_key_id or not secret_access_key: raise ValidationAppError( "使用显式 AWS 凭据时必须同时提供 Access Key ID 和 Secret Access Key", code="AWS_CREDENTIALS_MISSING", ) return cls( region=region, instance_name=instance_name, access_key_id=access_key_id, secret_access_key=secret_access_key, session_token=secrets.get("aws_session_token"), ) def get_instance(self) -> InstanceSnapshot: try: response = self._client.get_instance(instanceName=self.instance_name) except self._client.exceptions.NotFoundException as exc: raise NotFoundError( f"在 {self.region} 未找到 Lightsail 实例 {self.instance_name}", code="LIGHTSAIL_INSTANCE_NOT_FOUND", ) from exc except (BotoCoreError, ClientError) as exc: raise self._external_error(exc) from exc instance = response["instance"] return InstanceSnapshot( name=instance["name"], state=instance.get("state", {}).get("name", "unknown").lower(), public_ip=instance.get("publicIpAddress"), is_static_ip=bool(instance.get("isStaticIp", False)), region=self.region, ) def list_static_ips(self) -> list[StaticIpSnapshot]: snapshots: list[StaticIpSnapshot] = [] page_token: str | None = None seen_tokens: set[str] = set() try: while True: kwargs = {"pageToken": page_token} if page_token else {} response = self._client.get_static_ips(**kwargs) snapshots.extend( self._static_ip_snapshot(item) for item in response.get("staticIps", []) ) next_page_token = response.get("nextPageToken") if not next_page_token: return snapshots next_page_token = str(next_page_token) if next_page_token in seen_tokens: raise ExternalServiceError( "AWS Lightsail 返回了重复的静态 IP 分页令牌", service="aws", code="AWS_INVALID_PAGINATION", ) seen_tokens.add(next_page_token) page_token = next_page_token except (BotoCoreError, ClientError) as exc: raise self._external_error(exc) from exc def get_static_ip(self, static_ip_name: str) -> StaticIpSnapshot | None: self._validate_static_ip_name(static_ip_name) try: response = self._client.get_static_ip(staticIpName=static_ip_name) except self._client.exceptions.NotFoundException: return None except (BotoCoreError, ClientError) as exc: raise self._external_error(exc) from exc static_ip = response.get("staticIp") if not isinstance(static_ip, dict): raise ExternalServiceError( "AWS Lightsail 未返回有效的静态 IP 信息", service="aws", code="AWS_INVALID_RESPONSE", ) return self._static_ip_snapshot(static_ip) def get_attached_static_ips(self, instance_name: str) -> list[StaticIpSnapshot]: return [ static_ip for static_ip in self.list_static_ips() if static_ip.is_attached and static_ip.attached_to == instance_name ] def allocate_static_ip(self, static_ip_name: str) -> None: self._validate_static_ip_name(static_ip_name) self._mutate( "allocate_static_ip", action="分配静态 IP", staticIpName=static_ip_name, ) def detach_static_ip(self, static_ip_name: str) -> None: self._validate_static_ip_name(static_ip_name) self._mutate( "detach_static_ip", action="解绑静态 IP", staticIpName=static_ip_name, ) def attach_static_ip( self, static_ip_name: str, instance_name: str | None = None, ) -> None: self._validate_static_ip_name(static_ip_name) self._mutate( "attach_static_ip", action="绑定静态 IP", staticIpName=static_ip_name, instanceName=instance_name or self.instance_name, ) def release_static_ip(self, static_ip_name: str) -> None: self._validate_static_ip_name(static_ip_name) self._mutate( "release_static_ip", action="释放静态 IP", staticIpName=static_ip_name, ) def test_connection(self) -> dict[str, object]: instance = self.get_instance() attached_static_ips = self.get_attached_static_ips(instance.name) return { "ok": True, "service": "aws", "message": "Lightsail 连接正常", "instance_name": instance.name, "state": instance.state, "public_ip": instance.public_ip, "is_static_ip": instance.is_static_ip, "region": instance.region, "attached_static_ips": [ { "name": static_ip.name, "ip_address": static_ip.ip_address, "attached_to": static_ip.attached_to, } for static_ip in attached_static_ips ], } def _mutate(self, method_name: str, *, action: str, **kwargs: str) -> None: try: response = getattr(self._client, method_name)(**kwargs) except (BotoCoreError, ClientError) as exc: raise self._external_error(exc) from exc self._wait_for_operations(response, action=action) def _wait_for_operations(self, response: dict[str, Any], *, action: str) -> None: operation_ids = [ str(operation["id"]) for operation in response.get("operations", []) if isinstance(operation, dict) and operation.get("id") ] if not operation_ids: raise ExternalServiceError( f"AWS Lightsail {action}响应缺少 operation ID", service="aws", code="AWS_INVALID_OPERATION_RESPONSE", ) pending = set(operation_ids) deadline = time.monotonic() + self.operation_timeout_seconds while pending: for operation_id in list(pending): try: response = self._client.get_operation(operationId=operation_id) except (BotoCoreError, ClientError) as exc: raise self._external_error(exc) from exc operation = response.get("operation") if not isinstance(operation, dict): raise ExternalServiceError( f"AWS Lightsail 未返回有效的 {action} operation", service="aws", code="AWS_INVALID_OPERATION_RESPONSE", ) if not operation.get("isTerminal"): continue status = str(operation.get("status", "")) error_code = str(operation.get("errorCode") or "") if status.lower() == "failed" or error_code: error_details = str(operation.get("errorDetails") or "AWS 操作执行失败") raise ExternalServiceError( f"AWS Lightsail {action}失败:{error_details}", service="aws", code=error_code or "AWS_OPERATION_FAILED", ) pending.remove(operation_id) if not pending: return if time.monotonic() >= deadline: raise ExternalServiceError( f"等待 AWS Lightsail {action}完成超时,操作结果仍需重新查询确认", service="aws", code="AWS_OPERATION_TIMEOUT", ) time.sleep(self.operation_poll_interval_seconds) def _static_ip_snapshot(self, static_ip: dict[str, Any]) -> StaticIpSnapshot: attached_to = static_ip.get("attachedTo") arn = static_ip.get("arn") ip_address = static_ip.get("ipAddress") location = static_ip.get("location") or {} return StaticIpSnapshot( name=str(static_ip["name"]), ip_address=str(ip_address) if ip_address else None, is_attached=bool(static_ip.get("isAttached", False)), attached_to=str(attached_to) if attached_to else None, arn=str(arn) if arn else None, region=str(location.get("regionName") or self.region), ) @staticmethod def _validate_static_ip_name(static_ip_name: str) -> None: if not _STATIC_IP_NAME_PATTERN.fullmatch(static_ip_name): raise ValidationAppError( "静态 IP 名称必须为 2 到 255 位 ASCII 字母、数字或横线,且首尾必须是字母或数字", code="INVALID_STATIC_IP_NAME", ) @staticmethod def _external_error(exc: Exception) -> ExternalServiceError: if isinstance(exc, ClientError): error = exc.response.get("Error", {}) code = str(error.get("Code", "AWS_API_ERROR")) message = str(error.get("Message", "AWS 请求失败")) return ExternalServiceError( f"AWS Lightsail 返回错误:{message}", service="aws", code=code ) return ExternalServiceError( "无法连接 AWS Lightsail,请检查网络与区域配置", service="aws", code="AWS_CONNECTION_FAILED", )