评估发现项目只有台账能力,缺失真正的多云管理链路。本次按缺口逐项补齐: 适配层 - 新增 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
287 lines
11 KiB
Python
287 lines
11 KiB
Python
"""云厂商 SDK 适配层 — 抽象基类与标准化数据结构
|
||
|
||
各云厂商适配器继承 BaseAdapter,实现统一接口,将平台特定的 API 返回
|
||
转换为标准化的资产结构(NormalizedVPS / NormalizedDomain / AccountInfo),
|
||
供同步服务写入数据库。这样上层逻辑无需关心各平台 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)
|
||
|
||
各厂商返回形态不一('2027-03-15'、'2027-03-15T00:00:00Z'、'2027-03-15T00:00:00+00:00')。
|
||
必须统一成 date 再交给上层:expiry_date 是 SQLAlchemy Date 列,
|
||
直接塞字符串会在写库时抛 TypeError,导致整批同步在循环末尾的 commit 上全量失败。
|
||
"""
|
||
if value is None:
|
||
return None
|
||
if isinstance(value, datetime):
|
||
return value.date()
|
||
if isinstance(value, date):
|
||
return value
|
||
text = str(value).strip()
|
||
if not text:
|
||
return None
|
||
try:
|
||
return date.fromisoformat(text[:10])
|
||
except ValueError:
|
||
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 实例"""
|
||
|
||
external_id: str
|
||
name: str
|
||
ip_address: Optional[str] = None
|
||
region: Optional[str] = None
|
||
os: Optional[str] = None
|
||
cpu_cores: Optional[int] = None
|
||
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:
|
||
return asdict(self)
|
||
|
||
|
||
@dataclass
|
||
class NormalizedDomain:
|
||
"""标准化的域名"""
|
||
|
||
external_id: str
|
||
domain_name: str
|
||
registrar: Optional[str] = None
|
||
expiry_date: Optional[date] = None # 到期日 date;厂商原始值请过 parse_iso_date
|
||
status: str = "active"
|
||
raw: dict = field(default_factory=dict)
|
||
|
||
def to_dict(self) -> dict:
|
||
d = asdict(self)
|
||
# date 不可直接 JSON 序列化,转回 ISO 字符串(对外形态与旧契约一致)
|
||
if d.get("expiry_date") is not None:
|
||
d["expiry_date"] = d["expiry_date"].isoformat()
|
||
return d
|
||
|
||
|
||
@dataclass
|
||
class AccountInfo:
|
||
"""标准化的账户信息"""
|
||
|
||
balance: Optional[float] = None
|
||
currency: str = "USD"
|
||
pending_charges: Optional[float] = None
|
||
raw: dict = field(default_factory=dict)
|
||
|
||
def to_dict(self) -> dict:
|
||
return asdict(self)
|
||
|
||
|
||
def _overridden(instance: "BaseAdapter", method_name: str) -> bool:
|
||
"""判断子类是否重写了某方法(用于能力探测)
|
||
|
||
比较按 MRO 解析到的实现与 BaseAdapter 的默认实现:只查 type(instance).__dict__
|
||
的话,由中间层基类(如 ai._AIBase)实现的能力会被误判为「未实现」。
|
||
"""
|
||
impl = getattr(type(instance), method_name, None)
|
||
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;按需重写只读能力与动作方法
|
||
|
||
能力分三层,全部通过 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"])
|
||
required_config: list = []
|
||
|
||
def __init__(self, config: Optional[dict] = None):
|
||
self.config = config or {}
|
||
|
||
@abstractmethod
|
||
def test_connection(self) -> dict:
|
||
"""测试连接 / 凭证有效性,返回 {"ok": bool, "message": str, ...}"""
|
||
|
||
# ---------------- 只读清单 ----------------
|
||
|
||
def list_vps(self) -> list:
|
||
raise NotImplementedError(f"{type(self).__name__} 未实现 list_vps")
|
||
|
||
def list_domains(self) -> list:
|
||
raise NotImplementedError(f"{type(self).__name__} 未实现 list_domains")
|
||
|
||
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:
|
||
"""返回适配器实际支持的能力(是否重写了相应方法)"""
|
||
names = READ_CAPABILITIES + INSTANCE_ACTIONS + DNS_ACTIONS
|
||
return {name: _overridden(self, name) for name in names}
|