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:
+159
-6
@@ -5,11 +5,14 @@
|
||||
供同步服务写入数据库。这样上层逻辑无需关心各平台 API 差异。
|
||||
"""
|
||||
|
||||
import re
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import date, datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
def parse_iso_date(value: Any) -> Optional[date]:
|
||||
"""把到期日归一化为 date 对象(接受 date / datetime / ISO 字符串,解析失败返回 None)
|
||||
@@ -33,6 +36,78 @@ def parse_iso_date(value: Any) -> Optional[date]:
|
||||
return None
|
||||
|
||||
|
||||
# 状态码 -> 排查提示。厂商 body 有时只给一个 code 不给原因(AWS 的 403、
|
||||
# Vultr 的 401 就常这样),这时至少告诉用户往哪个方向查。
|
||||
_STATUS_HINTS = {
|
||||
401: "凭证无效或已过期",
|
||||
403: "凭证权限不足",
|
||||
404: "资源不存在(实例 ID 或域名可能已变更)",
|
||||
429: "触发平台限流,请稍后重试",
|
||||
}
|
||||
|
||||
# 厂商放错误原因的键名(各家不统一,按此顺序找第一个可读字符串)
|
||||
_ERROR_KEYS = ("message", "msg", "reason", "error", "errormsg", "detail", "description")
|
||||
|
||||
|
||||
def _scan_error_text(node: Any, depth: int = 0) -> Optional[str]:
|
||||
"""在嵌套 JSON 里递归找第一个像错误原因的字符串"""
|
||||
if depth > 4:
|
||||
return None
|
||||
if isinstance(node, dict):
|
||||
for key in _ERROR_KEYS:
|
||||
for k, v in node.items():
|
||||
if k.lower() == key and isinstance(v, str) and v.strip():
|
||||
return v.strip()
|
||||
for v in node.values():
|
||||
found = _scan_error_text(v, depth + 1)
|
||||
if found:
|
||||
return found
|
||||
elif isinstance(node, list):
|
||||
for v in node:
|
||||
found = _scan_error_text(v, depth + 1)
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
|
||||
|
||||
def http_error_message(e: httpx.HTTPStatusError) -> str:
|
||||
"""把云厂商的 HTTP 错误压成一句人话
|
||||
|
||||
直接 str(e) 拿到的是 httpx 的多行文本,末尾还附一段 MDN 链接,
|
||||
显示在弹窗里既难看也没法排查。各厂商把原因放的位置又都不一样
|
||||
(Linode 在 body.errors[0].reason、Cloudflare 在 body.errors[0].message、
|
||||
腾讯云在 Response.Error.Message、AWS 在 XML 里),所以统一在这里提取。
|
||||
"""
|
||||
resp = e.response
|
||||
code = resp.status_code
|
||||
detail = None
|
||||
try:
|
||||
detail = _scan_error_text(resp.json())
|
||||
except Exception: # noqa: BLE001
|
||||
# AWS 等返回 XML,退化成抓 <Message>
|
||||
found = re.search(r"<Message>(.*?)</Message>", resp.text or "", re.S)
|
||||
if found:
|
||||
detail = found.group(1).strip()
|
||||
parts = [f"HTTP {code}"]
|
||||
if detail:
|
||||
parts.append(detail[:200])
|
||||
hint = _STATUS_HINTS.get(code)
|
||||
if hint and (not detail or hint not in detail):
|
||||
parts.append(hint)
|
||||
return ":".join(parts)
|
||||
|
||||
|
||||
def cloud_error_text(e: Exception) -> str:
|
||||
"""云端异常 -> 一句可排查的话
|
||||
|
||||
HTTP 错误交给 http_error_message 提取厂商 body 里的原因;
|
||||
其余(超时、DNS 解析失败、连接被重置)本身就短,直接用 str(e)。
|
||||
"""
|
||||
if isinstance(e, httpx.HTTPStatusError):
|
||||
return http_error_message(e)
|
||||
return str(e)
|
||||
|
||||
|
||||
@dataclass
|
||||
class NormalizedVPS:
|
||||
"""标准化的 VPS 实例"""
|
||||
@@ -46,8 +121,41 @@ class NormalizedVPS:
|
||||
memory_gb: Optional[float] = None
|
||||
disk_gb: Optional[int] = None
|
||||
status: str = "active"
|
||||
#: 月度费用。只有 Vultr / DO 的列表接口直接返回价格,其余厂商(阿里云、腾讯云、
|
||||
#: AWS、Linode)的 DescribeInstances 一律没有价格字段——要拿到得另调计价 API,
|
||||
#: 而那是挂牌价,不等于用户实付(折扣、备份、IPv4、额外硬盘都会改变金额)。
|
||||
#: 台账里 Asset.cost 的语义是「单次续费费用」,属于用户自己维护的账目值;
|
||||
#: 用一个看似合理的挂牌价去覆盖它,比留空更糟。所以这里刻意留 None,
|
||||
#: 前端把 cost=0 显示为「未填」而非「0 USD」,总览页则报出未填项数。
|
||||
monthly_cost: Optional[float] = None
|
||||
currency: str = "USD"
|
||||
#: 预付费实例的到期日;后付费(按量/按小时)平台恒为 None。
|
||||
#: 缺了它,云端同步进来的 VPS 永远进不了续费提醒——这是台账的核心用途。
|
||||
expiry_date: Optional[date] = None
|
||||
#: 计费方式,用于前端区分「预付费会到期」与「后付费不会到期」
|
||||
charge_type: Optional[str] = None
|
||||
raw: dict = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = asdict(self)
|
||||
if d.get("expiry_date") is not None:
|
||||
d["expiry_date"] = d["expiry_date"].isoformat()
|
||||
return d
|
||||
|
||||
|
||||
@dataclass
|
||||
class NormalizedDNSRecord:
|
||||
"""标准化的 DNS 解析记录"""
|
||||
|
||||
record_id: str
|
||||
zone_id: str
|
||||
name: str
|
||||
type: str
|
||||
content: str
|
||||
ttl: Optional[int] = None
|
||||
proxied: Optional[bool] = None
|
||||
priority: Optional[int] = None
|
||||
comment: Optional[str] = None
|
||||
raw: dict = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
@@ -96,13 +204,26 @@ def _overridden(instance: "BaseAdapter", method_name: str) -> bool:
|
||||
return impl is not None and impl is not getattr(BaseAdapter, method_name, None)
|
||||
|
||||
|
||||
#: 只读能力(同步用)
|
||||
READ_CAPABILITIES = ("list_vps", "list_domains", "get_account")
|
||||
#: 实例管理动作(写操作,前端须二次确认)
|
||||
INSTANCE_ACTIONS = ("start_instance", "stop_instance", "reboot_instance", "renew_instance")
|
||||
#: DNS 记录管理动作(写操作)
|
||||
DNS_ACTIONS = ("list_dns_records", "create_dns_record", "update_dns_record", "delete_dns_record")
|
||||
|
||||
|
||||
class BaseAdapter(ABC):
|
||||
"""云厂商适配器抽象基类
|
||||
|
||||
子类需:
|
||||
- 通过 @register("xxx-sdk") 注册到注册表
|
||||
- 设置 required_config(所需凭证字段,供前端表单提示)
|
||||
- 实现 test_connection;按需重写 list_vps / list_domains / get_account
|
||||
- 实现 test_connection;按需重写只读能力与动作方法
|
||||
|
||||
能力分三层,全部通过 capabilities() 如实上报,前端据此决定按钮可用性:
|
||||
1. 只读清单:list_vps / list_domains / get_account
|
||||
2. 实例动作:start / stop / reboot / renew_instance(写操作)
|
||||
3. DNS 动作:list / create / update / delete_dns_record(写操作)
|
||||
"""
|
||||
|
||||
#: 适配器所需的配置字段名(如 ["api_key"] 或 ["access_key_id", "access_key_secret"])
|
||||
@@ -115,6 +236,8 @@ class BaseAdapter(ABC):
|
||||
def test_connection(self) -> dict:
|
||||
"""测试连接 / 凭证有效性,返回 {"ok": bool, "message": str, ...}"""
|
||||
|
||||
# ---------------- 只读清单 ----------------
|
||||
|
||||
def list_vps(self) -> list:
|
||||
raise NotImplementedError(f"{type(self).__name__} 未实现 list_vps")
|
||||
|
||||
@@ -124,10 +247,40 @@ class BaseAdapter(ABC):
|
||||
def get_account(self) -> AccountInfo:
|
||||
raise NotImplementedError(f"{type(self).__name__} 未实现 get_account")
|
||||
|
||||
# ---------------- 实例管理动作 ----------------
|
||||
# 统一返回 {"ok": bool, "message": str, "raw": Any},与 test_connection 同构,
|
||||
# 让路由层不必区分「厂商返回成功」与「适配器不支持」。
|
||||
|
||||
def start_instance(self, external_id: str) -> dict:
|
||||
raise NotImplementedError(f"{type(self).__name__} 未实现 start_instance")
|
||||
|
||||
def stop_instance(self, external_id: str) -> dict:
|
||||
raise NotImplementedError(f"{type(self).__name__} 未实现 stop_instance")
|
||||
|
||||
def reboot_instance(self, external_id: str) -> dict:
|
||||
raise NotImplementedError(f"{type(self).__name__} 未实现 reboot_instance")
|
||||
|
||||
def renew_instance(self, external_id: str, period_months: int = 1) -> dict:
|
||||
"""续费预付费实例(后付费平台无此概念,保持未实现即可)"""
|
||||
raise NotImplementedError(f"{type(self).__name__} 未实现 renew_instance")
|
||||
|
||||
# ---------------- DNS 记录管理 ----------------
|
||||
|
||||
def list_dns_records(self, zone_id: str) -> list:
|
||||
raise NotImplementedError(f"{type(self).__name__} 未实现 list_dns_records")
|
||||
|
||||
def create_dns_record(self, zone_id: str, record: dict) -> dict:
|
||||
raise NotImplementedError(f"{type(self).__name__} 未实现 create_dns_record")
|
||||
|
||||
def update_dns_record(self, zone_id: str, record_id: str, record: dict) -> dict:
|
||||
raise NotImplementedError(f"{type(self).__name__} 未实现 update_dns_record")
|
||||
|
||||
def delete_dns_record(self, zone_id: str, record_id: str) -> dict:
|
||||
raise NotImplementedError(f"{type(self).__name__} 未实现 delete_dns_record")
|
||||
|
||||
# ---------------- 能力探测 ----------------
|
||||
|
||||
def capabilities(self) -> dict:
|
||||
"""返回适配器实际支持的能力(是否重写了相应方法)"""
|
||||
return {
|
||||
"list_vps": _overridden(self, "list_vps"),
|
||||
"list_domains": _overridden(self, "list_domains"),
|
||||
"get_account": _overridden(self, "get_account"),
|
||||
}
|
||||
names = READ_CAPABILITIES + INSTANCE_ACTIONS + DNS_ACTIONS
|
||||
return {name: _overridden(self, name) for name in names}
|
||||
|
||||
Reference in New Issue
Block a user