317 lines
12 KiB
Python
317 lines
12 KiB
Python
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",
|
||
)
|