FluxIP/app/fleet/service.py

297 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 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)