Files
vps-manager/app/adapters/aws.py
T
gouki 46faadbcae feat(multicloud): 补齐上线前的多云管理缺口(适配器/动作层/DNS/定时同步)
评估发现项目只有台账能力,缺失真正的多云管理链路。本次按缺口逐项补齐:

适配层
- 新增 aws / linode / namesilo 适配器,registry 登记;阿里云、腾讯云补
  list_domains 与 get_account 余额
- NormalizedVPS 补 expiry_date + charge_type:此前云端同步进来的 VPS 拿不到
  到期日,永远进不了续费提醒——而续费提醒正是这套台账存在的理由
- base.py 统一云端错误可读化(http_error_message / cloud_error_text):各厂商
  把原因放的位置都不同(Linode errors[].reason、Cloudflare errors[].message、
  腾讯 Response.Error.Message、AWS XML),此前直接 str(e) 会把 httpx 的多行文本
  连同 MDN 链接弹给用户

动作层与 DNS
- 新增 /api/actions:实例启停/重启/续费,写操作全部二次确认 + 审计
- 新增 Cloudflare DNS 记录的增删改查

定时同步
- scripts/sync_all_assets.py + /api/sync/assets + vps-sync-assets.timer(08:30)
- 08:30 而非随机时刻:必须早于 09:00 的续费检查,否则提醒读到上一天的旧到期日
- sync_all_assets 修正计数:每一项子同步都失败时不再计为「成功」,否则凭证过期
  会显示「成功 1」,用户以为数据拉到了、实际一条没有

数据一致性
- AssetRead 输出 external_id(只出不进):前端「云操作」「DNS」按钮靠它渲染,
  缺了则所有按钮永远出不来
- import_assets 落库 external_id:导出带了去重键而导入丢弃,会让迁移后首次同步
  把所有云端资产当新增再建一份
- _align_preset_providers 取代 _backfill_provider_services:库里声明但注册表未
  实现的 sdk_type 清空(声明支持却不支持,比明说不支持更糟),services 只增量
  补标签不覆盖,用户自建平台不动

前端如实展示
- cost 为 0 统一显示「未填」而非「0 USD」:0 既可能是没填、也可能是云端不返回
  价格,而汇总处又把 0 当「无费用」排除,两处口径不一致
- 总览「本年支出」增加 year_cost_missing,把合计的覆盖缺口说出来
- DNS 弹窗补 MX/SRV 优先级与备注;列表请求失败时 provider 用资产自带 slug 兜底,
  否则 Cloudflare 代理勾选框永远不出现

测试:257 -> 309 passed
2026-10-12 00:43:37 +00:00

370 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""AWS 适配器(SigV4 签名,EC2 查询协议 + Route53 JSON 协议)
所需配置:{"access_key_id": "...", "secret_access_key": "...", "region": "us-east-1"}
不引入 boto3:项目其余适配器统一走 httpx + 手写签名(阿里云 RPC V1、腾讯云 TC3),
boto3 会带来数十 MB 依赖与独立的凭证/重试体系,与现有风格割裂。
协议差异(SigV4 相同,封装不同):
- EC2 ec2.{region}.amazonaws.com 查询协议,POST 表单编码 body
- Route53 route53.amazonaws.com JSON 1.1,X-Amz-Target 头,全局无区域
- Route53Domains route53domains.{region}... JSON 1.1,域名注册清单(含到期日)
刻意不实现 get_account:AWS 没有「账户余额」这一概念(预付的是信用额/账单),
硬凑一个数字会比如实报「不支持」更误导,参见 ai.py 对 OpenAI 的同类处理。
"""
import hashlib
import hmac
import json
from datetime import datetime, timezone
from urllib.parse import quote
import httpx
from app.adapters.base import (
BaseAdapter,
NormalizedDNSRecord,
NormalizedDomain,
NormalizedVPS,
parse_iso_date,
)
from app.adapters.registry import register
EC2_VERSION = "2016-11-15"
ROUTE53DOMAINS_TARGET_PREFIX = "Route53Domains_v20140515"
#: EC2 实例状态 -> 台账状态;terminated/shutting-down 不入库,避免台账堆满已销毁实例
_STATE_MAP = {
"running": "active",
"stopped": "stopped",
"stopping": "stopped",
"pending": "active",
}
_ALGORITHM = "AWS4-HMAC-SHA256"
def _sign(key: bytes, msg: str) -> bytes:
return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()
def _signing_key(secret: str, date_stamp: str, region: str, service: str) -> bytes:
k_date = _sign(("AWS4" + secret).encode("utf-8"), date_stamp)
k_region = _sign(k_date, region)
k_service = _sign(k_region, service)
return _sign(k_service, "aws4_request")
@register("aws-api")
class AWSAdapter(BaseAdapter):
required_config = ["access_key_id", "secret_access_key"]
def _region(self) -> str:
return self.config.get("region") or "us-east-1"
def _credentials(self) -> tuple[str, str]:
return (
self.config.get("access_key_id", ""),
self.config.get("secret_access_key", ""),
)
def _signed_request(
self,
service: str,
host: str,
region: str,
method: str,
path: str,
body: str = "",
content_type: str = "application/x-www-form-urlencoded; charset=utf-8",
extra_headers: dict = None,
) -> dict:
"""构造并发送一个 SigV4 签名请求,返回解析后的 JSON/XML-less dict"""
access_key, secret_key = self._credentials()
now = datetime.now(timezone.utc)
amz_date = now.strftime("%Y%m%dT%H%M%SZ")
date_stamp = now.strftime("%Y%m%d")
headers = {"host": host, "x-amz-date": amz_date, "content-type": content_type}
if extra_headers:
headers.update({k.lower(): v for k, v in extra_headers.items()})
signed_headers = ";".join(sorted(headers))
canonical_headers = "".join(f"{k}:{headers[k].strip()}\n" for k in sorted(headers))
payload_hash = hashlib.sha256(body.encode("utf-8")).hexdigest()
# 查询协议的参数在 body 里,CanonicalQueryString 为空;path 恒为 "/"
canonical_request = (
f"{method}\n{path}\n\n{canonical_headers}\n{signed_headers}\n{payload_hash}"
)
credential_scope = f"{date_stamp}/{region}/{service}/aws4_request"
string_to_sign = (
f"{_ALGORITHM}\n{amz_date}\n{credential_scope}\n"
f"{hashlib.sha256(canonical_request.encode('utf-8')).hexdigest()}"
)
signature = hmac.new(
_signing_key(secret_key, date_stamp, region, service),
string_to_sign.encode("utf-8"),
hashlib.sha256,
).hexdigest()
headers["Authorization"] = (
f"{_ALGORITHM} Credential={access_key}/{credential_scope}, "
f"SignedHeaders={signed_headers}, Signature={signature}"
)
# httpx 会自己算 Content-Length,host 头也由它设;这里传签名时用的小写 host 值必须一致
send_headers = {k: v for k, v in headers.items() if k != "host"}
with httpx.Client(timeout=30) as client:
resp = client.request(
method, f"https://{host}{path}", content=body.encode("utf-8"), headers=send_headers
)
resp.raise_for_status()
return resp.json() if resp.content else {}
# ---------------- EC2(查询协议) ----------------
def _ec2_call(self, params: dict) -> dict:
body = "&".join(f"{quote(k, safe='')}={quote(str(v), safe='')}" for k, v in sorted(params.items()))
return self._signed_request(
service="ec2",
host=f"ec2.{self._region()}.amazonaws.com",
region=self._region(),
method="POST",
path="/",
body=body,
)
def test_connection(self) -> dict:
try:
data = self._ec2_call({"Action": "DescribeRegions", "Version": EC2_VERSION})
count = len((data.get("regionInfo") or {}).get("item") or [])
return {"ok": True, "message": f"连接成功,可用区域 {count} 个"}
except httpx.HTTPStatusError as e:
return {"ok": False, "message": f"HTTP {e.response.status_code}:凭证无效或权限不足"}
except Exception as e: # noqa: BLE001
return {"ok": False, "message": str(e)}
def list_vps(self) -> list:
result = []
token = None
while True:
params = {"Action": "DescribeInstances", "Version": EC2_VERSION}
if token:
params["NextToken"] = token
data = self._ec2_call(params)
# EC2 查询协议转 JSON 后是驼峰包装:reservationSet.item[].instancesSet.item[]
reservations = (data.get("reservationSet") or {}).get("item") or []
if isinstance(reservations, dict):
reservations = [reservations]
for res in reservations:
instances = (res.get("instancesSet") or {}).get("item") or []
if isinstance(instances, dict):
instances = [instances]
for inst in instances:
state = ((inst.get("instanceState") or {}).get("name") or "").lower()
if state not in _STATE_MAP:
continue # terminated / shutting-down 不入台账
tags = {
t.get("key"): t.get("value")
for t in ((inst.get("tagSet") or {}).get("item") or [])
}
result.append(
NormalizedVPS(
external_id=inst.get("instanceId"),
name=tags.get("Name") or inst.get("instanceId"),
ip_address=inst.get("ipAddress") or inst.get("privateIpAddress"),
region=(inst.get("placement") or {}).get("availabilityZone"),
os=None, # EC2 列表只给 imageId,解析平台名需额外 DescribeImages
cpu_cores=None,
memory_gb=None,
status=_STATE_MAP[state],
currency="USD",
# AWS 以按需/预留为主,实例列表里没有到期日;预留实例到期在
# DescribeReservedInstances 里,与单机不是一对一,故不猜
expiry_date=None,
charge_type=(inst.get("instanceLifecycle") or "on-demand"),
raw=inst,
)
)
token = data.get("nextToken")
if not token:
break
return result
def _ec2_action(self, action: str, external_id: str, label: str) -> dict:
try:
self._ec2_call(
{"Action": action, "Version": EC2_VERSION, "InstanceId.1": external_id}
)
return {"ok": True, "message": f"{label}指令已下发"}
except httpx.HTTPStatusError as e:
return {"ok": False, "message": f"HTTP {e.response.status_code}:{label}失败"}
except Exception as e: # noqa: BLE001
return {"ok": False, "message": f"{label}失败:{e}"}
def start_instance(self, external_id: str) -> dict:
return self._ec2_action("StartInstances", external_id, "开机")
def stop_instance(self, external_id: str) -> dict:
return self._ec2_action("StopInstances", external_id, "关机")
def reboot_instance(self, external_id: str) -> dict:
return self._ec2_action("RebootInstances", external_id, "重启")
# ---------------- Route53 注册域名(含到期日) ----------------
def _r53domains_call(self, action: str, payload: dict) -> dict:
return self._signed_request(
service="route53domains",
host=f"route53domains.{self._region()}.amazonaws.com",
region=self._region(),
method="POST",
path="/",
body=json.dumps(payload),
content_type="application/x-amz-json-1.1",
extra_headers={"X-Amz-Target": f"{ROUTE53DOMAINS_TARGET_PREFIX}.{action}"},
)
def list_domains(self) -> list:
"""Route53 注册域名清单(含到期日)
只覆盖在 Route53 注册的域名;仅用 Route53 托管 DNS、域名在别处注册的场景
请走 list_dns_records(hosted zone)而不是这里。
"""
result = []
marker = None
while True:
payload = {"MaxItems": 100}
if marker:
payload["Marker"] = marker
data = self._r53domains_call("ListDomains", payload)
for d in data.get("Domains", []):
result.append(
NormalizedDomain(
external_id=d.get("DomainName"),
domain_name=d.get("DomainName"),
registrar="aws-route53",
# Expiry 是 epoch 秒
expiry_date=self._epoch_to_date(d.get("Expiry")),
status="active",
raw=d,
)
)
marker = data.get("NextPageMarker")
if not marker:
break
return result
@staticmethod
def _epoch_to_date(value):
try:
return parse_iso_date(datetime.fromtimestamp(float(value), tz=timezone.utc).isoformat())
except (TypeError, ValueError, OSError):
return None
# ---------------- Route53 DNS 记录 ----------------
@staticmethod
def _hosted_zone_id(zone_id: str) -> str:
"""Route53 的 zone id 形如 /hostedzone/Z123,API 只要 Z123"""
return zone_id.rsplit("/", 1)[-1]
def list_dns_records(self, zone_id: str) -> list:
zid = self._hosted_zone_id(zone_id)
data = self._signed_request(
service="route53",
host="route53.amazonaws.com",
region="us-east-1",
method="GET",
path=f"/2013-04-01/hostedzone/{zid}/rrset",
body="",
content_type="application/json",
)
result = []
for r in data.get("ResourceRecordSets", []):
values = [rr.get("Value") for rr in (r.get("ResourceRecords") or [])]
result.append(
NormalizedDNSRecord(
record_id=f"{r.get('Name')}|{r.get('Type')}",
zone_id=zone_id,
name=(r.get("Name") or "").rstrip("."),
type=r.get("Type"),
content=", ".join(v for v in values if v),
ttl=r.get("TTL"),
priority=None,
raw=r,
)
)
return result
def _change(self, zone_id: str, action: str, record: dict) -> dict:
zid = self._hosted_zone_id(zone_id)
# Route53 的记录值必须是数组,单值也要包一层
values = [v.strip() for v in str(record.get("content", "")).split(",") if v.strip()]
change = {
"Action": action,
"ResourceRecordSet": {
"Name": record.get("name", ""),
"Type": record.get("type", "A"),
"TTL": record.get("ttl") or 300,
"ResourceRecords": [{"Value": v} for v in values],
},
}
body = json.dumps({"Changes": [change]})
try:
self._signed_request(
service="route53",
host="route53.amazonaws.com",
region="us-east-1",
method="POST",
path=f"/2013-04-01/hostedzone/{zid}/change",
body=body,
# Route53 的 /2013-04-01/* 是 REST-JSON(按路径路由,无 X-Amz-Target),
# 用 x-amz-json-1.1 会被当成 JSON 协议服务而报 MissingAuthenticationToken
content_type="application/json",
)
return {"ok": True}
except httpx.HTTPStatusError as e:
return {"ok": False, "message": f"HTTP {e.response.status_code}"}
except Exception as e: # noqa: BLE001
return {"ok": False, "message": str(e)}
def create_dns_record(self, zone_id: str, record: dict) -> dict:
res = self._change(zone_id, "CREATE", record)
if not res["ok"]:
return {"ok": False, "message": f"创建失败:{res['message']}"}
return {
"ok": True,
"message": "记录已创建(Route53 变更为异步,生效需数秒)",
"record_id": f"{record.get('name')}|{record.get('type')}",
}
def update_dns_record(self, zone_id: str, record_id: str, record: dict) -> dict:
res = self._change(zone_id, "UPSERT", record)
if not res["ok"]:
return {"ok": False, "message": f"更新失败:{res['message']}"}
return {"ok": True, "message": "记录已更新(Route53 变更为异步,生效需数秒)"}
def delete_dns_record(self, zone_id: str, record_id: str) -> dict:
# record_id 形如 "name|TYPE",Route53 删除必须带上完整的记录集
if "|" not in record_id:
return {"ok": False, "message": "删除失败:record_id 需为 name|TYPE 形式"}
name, rtype = record_id.rsplit("|", 1)
try:
existing = next(
(r for r in self.list_dns_records(zone_id) if r.record_id == record_id), None
)
except Exception as e: # noqa: BLE001
return {"ok": False, "message": f"删除失败:无法读取现有记录 {e}"}
if not existing:
return {"ok": False, "message": "删除失败:记录不存在"}
res = self._change(
zone_id,
"DELETE",
{"name": name, "type": rtype, "content": existing.content, "ttl": existing.ttl},
)
if not res["ok"]:
return {"ok": False, "message": f"删除失败:{res['message']}"}
return {"ok": True, "message": "记录已删除(Route53 变更为异步)"}