Files
ProxyAuto/services/lightsail_static_ip.py

133 lines
3.9 KiB
Python

"""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}