from __future__ import annotations import asyncio import uuid from typing import Any 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, ) -> None: self.repository = repository self.integration_service = integration_service self.rotation_repository = rotation_repository 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]: self.integration_service.ensure_configured() settings, secrets = self.integration_service.credentials() token = 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=settings.use_default_aws_credentials, secrets=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", } if code in validations: return ValidationAppError(validations[code], code=code) return ValidationAppError("配置保存失败", code=code)