"""AWS Lightsail Static IP 操作""" from __future__ import annotations import re import time from dataclasses import dataclass from datetime import datetime, timezone from typing import Any import boto3 from botocore.exceptions import ClientError from .aws_region import normalize_aws_region @dataclass(frozen=True) class LightsailStaticIp: name: str ip_address: str | None attached_to: str | None is_attached: bool _INSTANCE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$") def is_valid_lightsail_instance_name(value: str | None) -> bool: if not value: return False return bool(_INSTANCE_NAME_RE.match(value.strip())) def create_lightsail_client( *, region: str, aws_access_key: str | None, aws_secret_key: str | None, ): kwargs: dict[str, Any] = {"region_name": normalize_aws_region(region)} if aws_access_key and aws_secret_key: kwargs["aws_access_key_id"] = aws_access_key kwargs["aws_secret_access_key"] = aws_secret_key return boto3.client("lightsail", **kwargs) def _list_static_ips(client) -> list[LightsailStaticIp]: resp = client.get_static_ips() items = resp.get("staticIps") or [] result: list[LightsailStaticIp] = [] for item in items: result.append( LightsailStaticIp( name=item.get("name") or "", ip_address=item.get("ipAddress"), attached_to=item.get("attachedTo"), is_attached=bool(item.get("isAttached")), ) ) return [ip for ip in result if ip.name] def _get_attached_static_ip(client, *, instance_name: str) -> LightsailStaticIp | None: for ip in _list_static_ips(client): if ip.is_attached and ip.attached_to == instance_name: return ip return None def _generate_static_ip_name(instance_name: str) -> str: safe = re.sub(r"[^A-Za-z0-9-]+", "-", instance_name).strip("-").lower() or "instance" safe = safe[:24] ts = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S") return f"proxyauto-{safe}-{ts}" def rotate_lightsail_static_ip( client, *, instance_name: str, release_old: bool, ) -> dict[str, str]: current = _get_attached_static_ip(client, instance_name=instance_name) if current: client.detach_static_ip(staticIpName=current.name) if release_old: for attempt in range(8): try: client.release_static_ip(staticIpName=current.name) break except ClientError as exc: if attempt == 7: raise code = exc.response.get("Error", {}).get("Code") if code in {"OperationFailureException", "InvalidInputException"}: time.sleep(1) continue raise new_name = _generate_static_ip_name(instance_name) for attempt in range(5): try: client.allocate_static_ip(staticIpName=new_name) break except ClientError as exc: if attempt == 4: raise message = (exc.response.get("Error", {}).get("Message") or "").lower() if "already exists" in message or "alreadyexist" in message: new_name = f"{new_name}-{attempt + 1}" continue raise client.attach_static_ip(staticIpName=new_name, instanceName=instance_name) public_ip: str | None = None for _ in range(20): try: resp = client.get_static_ip(staticIpName=new_name) static_ip = resp.get("staticIp") or {} public_ip = static_ip.get("ipAddress") if public_ip: break except ClientError: pass time.sleep(1) if not public_ip: raise RuntimeError("Lightsail 未返回新的 Static IP 地址,请稍后重试") return {"public_ip": public_ip, "static_ip_name": new_name}