FluxIP/app/fleet/schemas.py

349 lines
12 KiB
Python

from __future__ import annotations
import ipaddress
import re
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from app.fleet.regions import AWS_LIGHTSAIL_REGION_CODES
HOSTNAME_RE = re.compile(
r"^(?=.{1,253}\.?$)(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+"
r"[a-zA-Z]{2,63}\.?$"
)
def _normalize_hostname(value: str) -> str:
normalized = value.strip().rstrip(".").lower()
if not HOSTNAME_RE.fullmatch(normalized):
raise ValueError("请输入完整域名")
return normalized
class ManagedInstanceBase(BaseModel):
aws_account_id: str | None = Field(default=None, min_length=1, max_length=128)
cloudflare_account_id: str | None = Field(default=None, min_length=1, max_length=128)
display_name: str = Field(min_length=1, max_length=80)
aws_region: str = Field(min_length=3, max_length=32)
lightsail_instance_name: str = Field(min_length=1, max_length=255)
cloudflare_zone_name: str = Field(min_length=3, max_length=253)
cloudflare_zone_id: str = Field(default="", max_length=64)
cloudflare_record_name: str = Field(min_length=3, max_length=253)
socks_port: int = Field(default=1080, ge=1, le=65535)
proxy_health_check: bool = True
health_timeout_seconds: int = Field(default=120, ge=10, le=600)
release_grace_seconds: int = Field(default=75, ge=60, le=600)
enabled: bool = True
@field_validator(
"display_name",
"aws_region",
"lightsail_instance_name",
"cloudflare_zone_id",
mode="before",
)
@classmethod
def strip_text(cls, value: object) -> object:
return value.strip() if isinstance(value, str) else value
@field_validator("aws_account_id", "cloudflare_account_id", mode="before")
@classmethod
def normalize_account_id(cls, value: object) -> object:
if isinstance(value, str):
return value.strip() or None
return value
@field_validator("aws_region")
@classmethod
def validate_region(cls, value: str) -> str:
normalized = value.lower()
if normalized not in AWS_LIGHTSAIL_REGION_CODES:
raise ValueError("请选择受支持的 Lightsail 区域")
return normalized
@field_validator("cloudflare_zone_name", "cloudflare_record_name")
@classmethod
def validate_hostname(cls, value: str) -> str:
return _normalize_hostname(value)
@model_validator(mode="after")
def validate_dns_relationship(self) -> ManagedInstanceBase:
if (
self.cloudflare_record_name != self.cloudflare_zone_name
and not self.cloudflare_record_name.endswith(f".{self.cloudflare_zone_name}")
):
raise ValueError("DNS 记录必须属于 Cloudflare Zone")
return self
class ManagedInstanceCreate(ManagedInstanceBase):
id: str | None = Field(default=None, min_length=1, max_length=128)
group_id: str | None = Field(default=None, min_length=1, max_length=128)
@field_validator("group_id", mode="before")
@classmethod
def normalize_group_id(cls, value: object) -> object:
if isinstance(value, str):
return value.strip() or None
return value
class ManagedInstanceUpdate(BaseModel):
config_version: int = Field(ge=1)
group_id: str | None = Field(default=None, min_length=1, max_length=128)
aws_account_id: str | None = Field(default=None, min_length=1, max_length=128)
cloudflare_account_id: str | None = Field(default=None, min_length=1, max_length=128)
display_name: str | None = Field(default=None, min_length=1, max_length=80)
aws_region: str | None = Field(default=None, min_length=3, max_length=32)
lightsail_instance_name: str | None = Field(default=None, min_length=1, max_length=255)
cloudflare_zone_name: str | None = Field(default=None, min_length=3, max_length=253)
cloudflare_zone_id: str | None = Field(default=None, max_length=64)
cloudflare_record_name: str | None = Field(default=None, min_length=3, max_length=253)
socks_port: int | None = Field(default=None, ge=1, le=65535)
proxy_health_check: bool | None = None
health_timeout_seconds: int | None = Field(default=None, ge=10, le=600)
release_grace_seconds: int | None = Field(default=None, ge=60, le=600)
enabled: bool | None = None
@field_validator("group_id", mode="before")
@classmethod
def normalize_group_id(cls, value: object) -> object:
if isinstance(value, str):
return value.strip() or None
return value
@field_validator(
"display_name",
"aws_region",
"lightsail_instance_name",
"cloudflare_zone_id",
mode="before",
)
@classmethod
def strip_text(cls, value: object) -> object:
return value.strip() if isinstance(value, str) else value
@field_validator("aws_account_id", "cloudflare_account_id", mode="before")
@classmethod
def normalize_account_id(cls, value: object) -> object:
if isinstance(value, str):
return value.strip() or None
return value
@field_validator("aws_region")
@classmethod
def validate_region(cls, value: str | None) -> str | None:
if value is None:
return None
normalized = value.lower()
if normalized not in AWS_LIGHTSAIL_REGION_CODES:
raise ValueError("请选择受支持的 Lightsail 区域")
return normalized
@field_validator("cloudflare_zone_name", "cloudflare_record_name")
@classmethod
def validate_hostname(cls, value: str | None) -> str | None:
return _normalize_hostname(value) if value is not None else None
@model_validator(mode="after")
def validate_dns_relationship(self) -> ManagedInstanceUpdate:
zone = self.cloudflare_zone_name
record = self.cloudflare_record_name
if zone and record and record != zone and not record.endswith(f".{zone}"):
raise ValueError("DNS 记录必须属于 Cloudflare Zone")
if not self.model_fields_set - {"config_version"}:
raise ValueError("请至少修改一项实例配置")
return self
class ManagedInstanceStatusUpdate(BaseModel):
last_known_ip: str | None = None
last_checked_at: str | None = None
@field_validator("last_known_ip")
@classmethod
def validate_ipv4(cls, value: str | None) -> str | None:
if value is None:
return None
try:
address = ipaddress.ip_address(value.strip())
except ValueError as exc:
raise ValueError("请输入有效的 IPv4 地址") from exc
if address.version != 4:
raise ValueError("只支持 IPv4 地址")
return str(address)
class ManagedInstanceView(ManagedInstanceBase):
model_config = ConfigDict(from_attributes=True)
id: str
config_version: int
last_known_ip: str | None
last_checked_at: str | None
created_at: str
updated_at: str
archived_at: str | None
class InstanceGroupCreate(BaseModel):
id: str | None = Field(default=None, min_length=1, max_length=128)
name: str = Field(min_length=1, max_length=80)
enabled: bool = False
interval_minutes: int = Field(default=60, ge=5, le=10080)
member_ids: list[str] = Field(default_factory=list, max_length=1000)
@field_validator("name", mode="before")
@classmethod
def strip_name(cls, value: object) -> object:
return value.strip() if isinstance(value, str) else value
@field_validator("member_ids")
@classmethod
def unique_members(cls, value: list[str]) -> list[str]:
normalized = [item.strip() for item in value]
if any(not item for item in normalized):
raise ValueError("实例 ID 不能为空")
if len(set(normalized)) != len(normalized):
raise ValueError("实例组中不能包含重复实例")
return normalized
@model_validator(mode="after")
def validate_enabled_members(self) -> InstanceGroupCreate:
if self.enabled and not self.member_ids:
raise ValueError("启用的实例组至少需要一个实例")
return self
class InstanceGroupUpdate(BaseModel):
config_version: int = Field(ge=1)
name: str | None = Field(default=None, min_length=1, max_length=80)
enabled: bool | None = None
interval_minutes: int | None = Field(default=None, ge=5, le=10080)
member_ids: list[str] | None = Field(default=None, max_length=1000)
@field_validator("name", mode="before")
@classmethod
def strip_name(cls, value: object) -> object:
return value.strip() if isinstance(value, str) else value
@field_validator("member_ids")
@classmethod
def unique_members(cls, value: list[str] | None) -> list[str] | None:
if value is None:
return None
normalized = [item.strip() for item in value]
if any(not item for item in normalized):
raise ValueError("实例 ID 不能为空")
if len(set(normalized)) != len(normalized):
raise ValueError("实例组中不能包含重复实例")
return normalized
@model_validator(mode="after")
def require_change(self) -> InstanceGroupUpdate:
if not self.model_fields_set - {"config_version"}:
raise ValueError("请至少修改一项实例组配置")
return self
class InstanceGroupMembersUpdate(BaseModel):
member_ids: list[str] = Field(max_length=1000)
@field_validator("member_ids")
@classmethod
def unique_members(cls, value: list[str]) -> list[str]:
normalized = [item.strip() for item in value]
if any(not item for item in normalized):
raise ValueError("实例 ID 不能为空")
if len(set(normalized)) != len(normalized):
raise ValueError("实例组中不能包含重复实例")
return normalized
class InstanceGroupView(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
name: str
enabled: bool
interval_minutes: int
next_run_at: str | None
last_run_at: str | None
config_version: int
member_ids: list[str]
created_at: str
updated_at: str
archived_at: str | None
class FleetRunView(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
target_type: str
target_id: str
target_name: str
trigger: str
status: str
current_item_id: str | None
error_code: str | None
error_message: str | None
total_items: int
succeeded_items: int
started_at: str
updated_at: str
finished_at: str | None
class FleetRunItemView(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
run_id: str
instance_id: str
position: int
status: str
stage: str
stage_started_at: str
attempt_count: int
config_version: int
instance_display_name: str
aws_account_id: str | None
cloudflare_account_id: str | None
aws_region: str
lightsail_instance_name: str
cloudflare_zone_name: str
cloudflare_zone_id: str
cloudflare_record_name: str
socks_port: int
proxy_health_check: bool
health_timeout_seconds: int
release_grace_seconds: int
old_static_ip_name: str | None
old_ip: str | None
new_static_ip_name: str | None
new_ip: str | None
dns_ip_before: str | None
dns_ip_after: str | None
grace_until: str | None
aws_operation_id: str | None
error_code: str | None
error_message: str | None
rollback_from_stage: str | None
rollback_reason_code: str | None
rollback_reason_message: str | None
started_at: str
updated_at: str
finished_at: str | None
# Short aliases keep service imports readable without changing generated schema names.
InstanceCreate = ManagedInstanceCreate
InstanceUpdate = ManagedInstanceUpdate
InstanceStatusUpdate = ManagedInstanceStatusUpdate
InstanceView = ManagedInstanceView
GroupCreate = InstanceGroupCreate
GroupUpdate = InstanceGroupUpdate
GroupMembersUpdate = InstanceGroupMembersUpdate
GroupView = InstanceGroupView