328 lines
11 KiB
Python
328 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
import ipaddress
|
|
import re
|
|
|
|
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
|
|
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}\.?$"
|
|
)
|
|
AWS_REGION_RE = re.compile(r"^[a-z]{2}(?:-gov)?-[a-z0-9-]+-\d+$")
|
|
|
|
|
|
def _normalize_hostname(value: str) -> str:
|
|
normalized = value.strip().rstrip(".").lower()
|
|
if not HOSTNAME_RE.fullmatch(normalized):
|
|
raise ValueError("请输入完整域名")
|
|
return normalized
|
|
|
|
|
|
class ManagedInstanceBase(BaseModel):
|
|
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_region")
|
|
@classmethod
|
|
def validate_region(cls, value: str) -> str:
|
|
normalized = value.lower()
|
|
if not AWS_REGION_RE.fullmatch(normalized):
|
|
raise ValueError("请输入有效的 AWS 区域")
|
|
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)
|
|
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_region")
|
|
@classmethod
|
|
def validate_region(cls, value: str | None) -> str | None:
|
|
if value is None:
|
|
return None
|
|
normalized = value.lower()
|
|
if not AWS_REGION_RE.fullmatch(normalized):
|
|
raise ValueError("请输入有效的 AWS 区域")
|
|
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_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
|