133 lines
3.9 KiB
Python
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}
|