FluxIP/app/integrations/aws_client.py

317 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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",
)