327 lines
14 KiB
Python
327 lines
14 KiB
Python
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import uuid
|
||
from typing import Any
|
||
|
||
from app.accounts.service import AccountService
|
||
from app.core.errors import ConflictError, NotFoundError, ValidationAppError
|
||
from app.core.time import to_iso
|
||
from app.fleet.repository import FleetRepository, InstanceGroupRecord, ManagedInstanceRecord
|
||
from app.fleet.schemas import (
|
||
InstanceCreate,
|
||
InstanceGroupCreate,
|
||
InstanceGroupUpdate,
|
||
InstanceUpdate,
|
||
)
|
||
from app.integrations.aws_client import LightsailClient
|
||
from app.integrations.cloudflare_client import CloudflareClient
|
||
from app.integrations.service import IntegrationService
|
||
from app.rotation.repository import RotationRepository
|
||
|
||
|
||
class FleetService:
|
||
def __init__(
|
||
self,
|
||
repository: FleetRepository,
|
||
integration_service: IntegrationService,
|
||
rotation_repository: RotationRepository,
|
||
account_service: AccountService | None = None,
|
||
) -> None:
|
||
self.repository = repository
|
||
self.integration_service = integration_service
|
||
self.rotation_repository = rotation_repository
|
||
self.account_service = account_service
|
||
|
||
def list_instances(self) -> list[dict[str, Any]]:
|
||
groups = self.repository.list_groups()
|
||
membership = {
|
||
instance_id: (group.id, group.name)
|
||
for group in groups
|
||
for instance_id in group.member_ids
|
||
}
|
||
return [
|
||
self._instance_view(instance, membership.get(instance.id))
|
||
for instance in self.repository.list_instances()
|
||
]
|
||
|
||
def get_instance(self, instance_id: str) -> dict[str, Any]:
|
||
instance = self._require_instance(instance_id)
|
||
membership = next(
|
||
(
|
||
(group.id, group.name)
|
||
for group in self.repository.list_groups()
|
||
if instance_id in group.member_ids
|
||
),
|
||
None,
|
||
)
|
||
return self._instance_view(instance, membership)
|
||
|
||
def create_instance(self, payload: InstanceCreate) -> dict[str, Any]:
|
||
try:
|
||
record = self.repository.create_instance(payload.model_dump(exclude_none=True))
|
||
except RuntimeError as exc:
|
||
raise self._repository_error(exc) from exc
|
||
return self.get_instance(record.id)
|
||
|
||
def update_instance(self, instance_id: str, payload: InstanceUpdate) -> dict[str, Any]:
|
||
try:
|
||
record = self.repository.update_instance(
|
||
instance_id,
|
||
payload.model_dump(exclude_unset=True),
|
||
)
|
||
except RuntimeError as exc:
|
||
raise self._repository_error(exc) from exc
|
||
return self.get_instance(record.id)
|
||
|
||
def archive_instance(self, instance_id: str) -> dict[str, Any]:
|
||
try:
|
||
record = self.repository.archive_instance(instance_id)
|
||
except RuntimeError as exc:
|
||
raise self._repository_error(exc) from exc
|
||
return record.to_dict()
|
||
|
||
def list_groups(self) -> list[dict[str, Any]]:
|
||
instances = {item.id: item for item in self.repository.list_instances()}
|
||
return [self._group_view(group, instances) for group in self.repository.list_groups()]
|
||
|
||
def get_group(self, group_id: str) -> dict[str, Any]:
|
||
group = self.repository.get_group(group_id)
|
||
if group is None:
|
||
raise NotFoundError("实例组不存在", code="INSTANCE_GROUP_NOT_FOUND")
|
||
instances = {item.id: item for item in self.repository.list_instances()}
|
||
return self._group_view(group, instances)
|
||
|
||
def create_group(self, payload: InstanceGroupCreate) -> dict[str, Any]:
|
||
try:
|
||
group = self.repository.create_group(payload.model_dump(exclude_none=True))
|
||
except RuntimeError as exc:
|
||
raise self._repository_error(exc) from exc
|
||
return self.get_group(group.id)
|
||
|
||
def update_group(self, group_id: str, payload: InstanceGroupUpdate) -> dict[str, Any]:
|
||
try:
|
||
group = self.repository.update_group(
|
||
group_id,
|
||
payload.model_dump(exclude_unset=True),
|
||
)
|
||
except RuntimeError as exc:
|
||
raise self._repository_error(exc) from exc
|
||
return self.get_group(group.id)
|
||
|
||
def archive_group(self, group_id: str) -> dict[str, Any]:
|
||
try:
|
||
group = self.repository.archive_group(group_id)
|
||
except RuntimeError as exc:
|
||
raise self._repository_error(exc) from exc
|
||
return group.to_dict()
|
||
|
||
async def test_instance(self, instance_id: str) -> dict[str, Any]:
|
||
instance = self._require_instance(instance_id)
|
||
aws, cloudflare = self._clients(instance)
|
||
aws_result, cloudflare_result = await asyncio.gather(
|
||
asyncio.to_thread(aws.test_connection),
|
||
asyncio.to_thread(cloudflare.test_connection),
|
||
)
|
||
public_ip = str(aws_result.get("public_ip") or "") or None
|
||
record_ip = str(cloudflare_result.get("record_ip") or "") or None
|
||
proxied = bool(cloudflare_result.get("proxied"))
|
||
route_ok = bool(public_ip and record_ip == public_ip and not proxied)
|
||
values: dict[str, Any] = {
|
||
"last_known_ip": public_ip,
|
||
"last_checked_at": to_iso(),
|
||
}
|
||
try:
|
||
self.repository.update_instance_status(instance_id, values)
|
||
zone_id = str(cloudflare_result.get("zone_id") or "")
|
||
if zone_id and zone_id != instance.cloudflare_zone_id:
|
||
self.repository.update_instance(instance_id, {"cloudflare_zone_id": zone_id})
|
||
except RuntimeError as exc:
|
||
raise self._repository_error(exc) from exc
|
||
return {
|
||
"ok": route_ok,
|
||
"route_ok": route_ok,
|
||
"aws": aws_result,
|
||
"cloudflare": cloudflare_result,
|
||
"message": (
|
||
"实例、Static IP 与 Cloudflare 路由一致"
|
||
if route_ok
|
||
else "连接可用,但实例公网 IP 与 Cloudflare 记录尚未对齐"
|
||
),
|
||
}
|
||
|
||
async def sync_dns(self, instance_id: str) -> dict[str, Any]:
|
||
instance = self._require_instance(instance_id)
|
||
owner_id = f"dns-sync-{uuid.uuid4()}"
|
||
if not self.rotation_repository.acquire_dns_sync_lock(owner_id):
|
||
raise ConflictError(
|
||
"已有轮换或 DNS 同步任务进行中",
|
||
code="ROTATION_IN_PROGRESS",
|
||
)
|
||
try:
|
||
current = self._require_instance(instance_id)
|
||
if current.config_version != instance.config_version:
|
||
raise ConflictError(
|
||
"实例配置已变化,请重新执行 DNS 同步",
|
||
code="INSTANCE_CONFIG_CHANGED",
|
||
)
|
||
instance = current
|
||
aws, cloudflare = self._clients(instance)
|
||
snapshot = await asyncio.to_thread(aws.get_instance)
|
||
if snapshot.state != "running" or not snapshot.public_ip:
|
||
raise ValidationAppError("实例未运行或暂无公网 IPv4,无法同步 DNS")
|
||
if not self.rotation_repository.renew_operation_lock(owner_id):
|
||
raise ConflictError("DNS 同步锁已失效", code="DNS_SYNC_LOCK_LOST")
|
||
record = await asyncio.to_thread(
|
||
cloudflare.upsert_a_record,
|
||
snapshot.public_ip,
|
||
)
|
||
try:
|
||
updates: dict[str, Any] = {
|
||
"last_known_ip": snapshot.public_ip,
|
||
"last_checked_at": to_iso(),
|
||
}
|
||
self.repository.update_instance_status(
|
||
instance_id,
|
||
updates,
|
||
operation_owner_id=owner_id,
|
||
)
|
||
if cloudflare.zone_id and cloudflare.zone_id != instance.cloudflare_zone_id:
|
||
self.repository.update_instance(
|
||
instance_id,
|
||
{"cloudflare_zone_id": cloudflare.zone_id},
|
||
operation_owner_id=owner_id,
|
||
)
|
||
except RuntimeError as exc:
|
||
raise self._repository_error(exc) from exc
|
||
return {
|
||
"ok": True,
|
||
"ip": snapshot.public_ip,
|
||
"record_name": record.get("name"),
|
||
"proxied": bool(record.get("proxied")),
|
||
"ttl": record.get("ttl"),
|
||
}
|
||
finally:
|
||
self.rotation_repository.release_operation_lock(owner_id)
|
||
|
||
def _clients(
|
||
self,
|
||
instance: ManagedInstanceRecord,
|
||
) -> tuple[LightsailClient, CloudflareClient]:
|
||
legacy_settings = None
|
||
legacy_secrets: dict[str, str | None] = {}
|
||
if not instance.aws_account_id or not instance.cloudflare_account_id:
|
||
legacy_settings, legacy_secrets = self.integration_service.credentials()
|
||
|
||
if instance.aws_account_id and self.account_service is not None:
|
||
use_default, aws_secrets = self.account_service.resolve_aws(
|
||
instance.aws_account_id
|
||
)
|
||
else:
|
||
if legacy_settings is None: # pragma: no cover - guarded by condition above
|
||
legacy_settings, legacy_secrets = self.integration_service.credentials()
|
||
use_default = legacy_settings.use_default_aws_credentials
|
||
aws_secrets = legacy_secrets
|
||
if not use_default and not (
|
||
aws_secrets.get("aws_access_key_id")
|
||
and aws_secrets.get("aws_secret_access_key")
|
||
):
|
||
raise ValidationAppError("当前没有可用的 AWS 凭据")
|
||
|
||
if instance.cloudflare_account_id and self.account_service is not None:
|
||
token = self.account_service.resolve_cloudflare(
|
||
instance.cloudflare_account_id
|
||
)
|
||
else:
|
||
token = legacy_secrets.get("cloudflare_api_token")
|
||
if not token: # pragma: no cover - ensure_configured guards this
|
||
raise ValidationAppError("当前没有可用的 Cloudflare API Token")
|
||
return (
|
||
LightsailClient.from_credentials(
|
||
region=instance.aws_region,
|
||
instance_name=instance.lightsail_instance_name,
|
||
use_default=use_default,
|
||
secrets=aws_secrets,
|
||
),
|
||
CloudflareClient(
|
||
token=token,
|
||
zone_name=instance.cloudflare_zone_name,
|
||
zone_id=instance.cloudflare_zone_id,
|
||
record_name=instance.cloudflare_record_name,
|
||
),
|
||
)
|
||
|
||
def _require_instance(self, instance_id: str) -> ManagedInstanceRecord:
|
||
instance = self.repository.get_instance(instance_id)
|
||
if instance is None:
|
||
raise NotFoundError("托管实例不存在", code="INSTANCE_NOT_FOUND")
|
||
return instance
|
||
|
||
@staticmethod
|
||
def _instance_view(
|
||
instance: ManagedInstanceRecord,
|
||
membership: tuple[str, str] | None,
|
||
) -> dict[str, Any]:
|
||
return {
|
||
**instance.to_dict(),
|
||
"group_id": membership[0] if membership else None,
|
||
"group_name": membership[1] if membership else None,
|
||
}
|
||
|
||
@staticmethod
|
||
def _group_view(
|
||
group: InstanceGroupRecord,
|
||
instances: dict[str, ManagedInstanceRecord],
|
||
) -> dict[str, Any]:
|
||
members = [instances[item_id] for item_id in group.member_ids if item_id in instances]
|
||
return {
|
||
**group.to_dict(),
|
||
"members": [
|
||
{
|
||
"id": member.id,
|
||
"display_name": member.display_name,
|
||
"enabled": member.enabled,
|
||
"last_known_ip": member.last_known_ip,
|
||
"cloudflare_record_name": member.cloudflare_record_name,
|
||
}
|
||
for member in members
|
||
],
|
||
}
|
||
|
||
@staticmethod
|
||
def _repository_error(exc: RuntimeError) -> Exception:
|
||
code = str(exc)
|
||
if code in {"ACTIVE_ROTATION", "FLEET_RUN_ACTIVE"}:
|
||
return ConflictError(
|
||
"轮换任务进行中,暂时不能修改实例或分组",
|
||
code="ROTATION_IN_PROGRESS",
|
||
)
|
||
if code == "INSTANCE_NOT_FOUND":
|
||
return NotFoundError("托管实例不存在", code="INSTANCE_NOT_FOUND")
|
||
if code == "GROUP_NOT_FOUND":
|
||
return NotFoundError("实例组不存在", code="INSTANCE_GROUP_NOT_FOUND")
|
||
conflicts = {
|
||
"INSTANCE_DISPLAY_NAME_CONFLICT": "实例显示名称已存在",
|
||
"INSTANCE_AWS_TARGET_CONFLICT": "该 Lightsail 实例已被其他配置管理",
|
||
"INSTANCE_DNS_RECORD_CONFLICT": "该 Cloudflare 记录已被其他实例使用",
|
||
"INSTANCE_ALREADY_GROUPED": "一个实例只能加入一个实例组",
|
||
"GROUP_NAME_CONFLICT": "实例组名称已存在",
|
||
"INSTANCE_CONFIG_VERSION_CONFLICT": "实例配置已被其他操作更新,请刷新后重试",
|
||
"GROUP_CONFIG_VERSION_CONFLICT": "实例组配置已被其他操作更新,请刷新后重试",
|
||
}
|
||
if code in conflicts:
|
||
return ConflictError(conflicts[code], code=code)
|
||
validations = {
|
||
"GROUP_REQUIRES_ENABLED_MEMBER": "启用的实例组至少需要一个已启用实例",
|
||
"GROUP_MEMBER_NOT_FOUND": "实例组包含不存在或已归档的实例",
|
||
"INSTANCE_DISABLE_BREAKS_GROUP": "该实例是启用组中最后一个可用成员",
|
||
"DNS_RECORD_OUTSIDE_ZONE": "DNS 记录必须属于所选 Cloudflare Zone",
|
||
"AWS_ACCOUNT_NOT_FOUND": "所选 AWS 账号不存在或已归档",
|
||
"CLOUDFLARE_ACCOUNT_NOT_FOUND": "所选 Cloudflare 账号不存在或已归档",
|
||
"AWS_ACCOUNT_PROVIDER_MISMATCH": "所选账号不是 AWS 账号",
|
||
"CLOUDFLARE_ACCOUNT_PROVIDER_MISMATCH": "所选账号不是 Cloudflare 账号",
|
||
}
|
||
if code in validations:
|
||
return ValidationAppError(validations[code], code=code)
|
||
return ValidationAppError("配置保存失败", code=code)
|