Files
vps-manager/app/adapters/base.py
T
gouki 103f48e38b feat(adapters): 接入 CloudCone/Zeabur/Dynadot/新网/Agnes 五个平台
线上平台列表里 cloudcone、zeabur、xinwang、dynadot、agnes 的 sdk_type 仍为空,
平台卡片只能显示「未对接」,用户无法同步。按各自真实 API 能力补齐:

- CloudCone:实例清单 + 开机/关机/重启;无公开账单接口,不声明余额与续费
- Zeabur:服务器清单 + 重启;毫核/MB 归一化为核/GB,未采集时不伪造规格
- Dynadot:域名清单(含到期日)+ 账户余额;set_dns2 位置参数无法安全 CRUD
- 新网:代理 API 按域名逐个查到期日;无列表接口,需在账号里填 domains 清单
- Agnes:/models 校验 Key;无余额接口,如实标记不支持

配套改动:
- _align_preset_providers 为库中为空的 sdk_type 回填种子值(启动即生效),
  避免新适配器上线后线上仍停留在「未对接」
- 适配器可声明 plain_config,代理号/域名等非密钥字段前端按明文输入
- PRESET_PROVIDERS 五条记录填上对应 sdk_type

测试:445 passed(新增 tests/test_platform_adapters.py),vitest 62 passed。
2026-10-26 09:16:45 +00:00

289 lines
11 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.
"""云厂商 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 = []
#: required_config 中不属于密钥的字段(前端按明文输入,避免代理号/域名被遮住)
plain_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}