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