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:
@@ -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 变更为异步)"}
|
||||
Reference in New Issue
Block a user