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
This commit is contained in:
gouki
2026-10-12 00:43:37 +00:00
parent 192d448a28
commit 46faadbcae
42 changed files with 5570 additions and 99 deletions
+369
View File
@@ -0,0 +1,369 @@
"""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 变更为异步)"}