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:
+251
-12
@@ -3,8 +3,14 @@
|
||||
API 文档:https://help.aliyun.com/document_detail/25484.html
|
||||
所需配置:{"access_key_id": "...", "access_key_secret": "...", "region": "cn-hangzhou"}
|
||||
国际版(alibabacloud)只需将 region 设为海外区域(如 ap-southeast-1),端点自动按区域构造。
|
||||
说明:此处实现 RPC V1 签名(HMAC-SHA1),可直接调用 ECS OpenAPI;
|
||||
生产环境也可替换为官方 alibabacloud-ecs SDK,接口契约保持不变。
|
||||
|
||||
RPC V1 签名对所有产品线一致,只有 endpoint 与 Version 不同,故 _call 接受这两个参数:
|
||||
- ECS ecs.{region}.aliyuncs.com 2014-05-26 实例清单与启停/重启/续费
|
||||
- Domain domain.aliyuncs.com 2018-01-29 注册域名清单(含到期日)
|
||||
- BSS business.aliyuncs.com 2017-12-14 账户余额
|
||||
- Alidns alidns.aliyuncs.com 2015-01-09 解析记录增删改查
|
||||
|
||||
DNS 的 zone_id 即域名本身(阿里云不用 zone uuid)。
|
||||
"""
|
||||
|
||||
import base64
|
||||
@@ -16,19 +22,36 @@ from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
|
||||
from app.adapters.base import BaseAdapter, NormalizedVPS
|
||||
from app.adapters.base import (
|
||||
AccountInfo,
|
||||
BaseAdapter,
|
||||
NormalizedDNSRecord,
|
||||
NormalizedDomain,
|
||||
NormalizedVPS,
|
||||
parse_iso_date,
|
||||
)
|
||||
from app.adapters.registry import register
|
||||
|
||||
ECS_VERSION = "2014-05-26"
|
||||
DOMAIN_VERSION = "2018-01-29"
|
||||
BSS_VERSION = "2017-12-14"
|
||||
ALIDNS_VERSION = "2015-01-09"
|
||||
|
||||
#: ECS InstanceChargeType -> 台账 charge_type
|
||||
_CHARGE_TYPE_MAP = {"PrePaid": "prepaid", "PostPaid": "postpaid"}
|
||||
|
||||
#: ECS Status -> 台账状态
|
||||
_STATUS_MAP = {"Running": "active", "Stopped": "stopped", "Stopping": "stopped", "Starting": "active"}
|
||||
|
||||
|
||||
@register("aliyun-sdk", "alibabacloud-sdk")
|
||||
class AliyunAdapter(BaseAdapter):
|
||||
required_config = ["access_key_id", "access_key_secret"]
|
||||
API_VERSION = "2014-05-26"
|
||||
|
||||
def _region(self) -> str:
|
||||
return self.config.get("region") or "cn-hangzhou"
|
||||
|
||||
def _endpoint(self) -> str:
|
||||
def _ecs_endpoint(self) -> str:
|
||||
return f"https://ecs.{self._region()}.aliyuncs.com"
|
||||
|
||||
@staticmethod
|
||||
@@ -45,23 +68,33 @@ class AliyunAdapter(BaseAdapter):
|
||||
digest = hmac.new(key, string_to_sign.encode(), hashlib.sha1).digest()
|
||||
return base64.b64encode(digest).decode()
|
||||
|
||||
def _call(self, action: str, biz_params: dict = None) -> dict:
|
||||
def _call(
|
||||
self,
|
||||
action: str,
|
||||
biz_params: dict = None,
|
||||
endpoint: str = None,
|
||||
version: str = ECS_VERSION,
|
||||
with_region: bool = True,
|
||||
) -> dict:
|
||||
"""调用任一阿里云 RPC 产品;endpoint 缺省为 ECS 区域端点"""
|
||||
params = {
|
||||
"Format": "JSON",
|
||||
"Version": self.API_VERSION,
|
||||
"Version": version,
|
||||
"AccessKeyId": self.config.get("access_key_id", ""),
|
||||
"SignatureMethod": "HMAC-SHA1",
|
||||
"Timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"SignatureVersion": "1.0",
|
||||
"SignatureNonce": str(uuid.uuid4()),
|
||||
"Action": action,
|
||||
"RegionId": self._region(),
|
||||
}
|
||||
# BSS / Domain / Alidns 是中心化服务,传 RegionId 会被拒或忽略
|
||||
if with_region:
|
||||
params["RegionId"] = self._region()
|
||||
if biz_params:
|
||||
params.update(biz_params)
|
||||
params.update({k: v for k, v in biz_params.items() if v is not None})
|
||||
params["Signature"] = self._sign(params)
|
||||
with httpx.Client(timeout=30) as client:
|
||||
resp = client.get(self._endpoint(), params=params)
|
||||
resp = client.get(endpoint or self._ecs_endpoint(), params=params)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if data.get("Code"):
|
||||
@@ -78,6 +111,8 @@ class AliyunAdapter(BaseAdapter):
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "message": str(e)}
|
||||
|
||||
# ---------------- 只读清单 ----------------
|
||||
|
||||
def list_vps(self) -> list:
|
||||
# 分页拉取:单次最多 100 条,按 TotalCount 翻页,避免实例超 100 台时漏同步
|
||||
result = []
|
||||
@@ -89,7 +124,7 @@ class AliyunAdapter(BaseAdapter):
|
||||
public_ips = inst.get("PublicIpAddress", {}).get("IpAddress", [])
|
||||
eip = inst.get("EipAddress", {}).get("IpAddress")
|
||||
mem_mb = inst.get("Memory") or 0
|
||||
status_map = {"Running": "active", "Stopped": "stopped"}
|
||||
charge = inst.get("InstanceChargeType")
|
||||
result.append(
|
||||
NormalizedVPS(
|
||||
external_id=inst.get("InstanceId"),
|
||||
@@ -99,8 +134,11 @@ class AliyunAdapter(BaseAdapter):
|
||||
os=inst.get("OSName"),
|
||||
cpu_cores=inst.get("Cpu"),
|
||||
memory_gb=round(mem_mb / 1024, 1) if mem_mb else None,
|
||||
status=status_map.get(inst.get("Status"), inst.get("Status") or "unknown"),
|
||||
status=_STATUS_MAP.get(inst.get("Status"), inst.get("Status") or "unknown"),
|
||||
currency="CNY",
|
||||
# 预付费实例必须带上到期日,否则续费提醒对云端同步的机器完全失效
|
||||
expiry_date=parse_iso_date(inst.get("ExpiredTime")),
|
||||
charge_type=_CHARGE_TYPE_MAP.get(charge, charge),
|
||||
raw=inst,
|
||||
)
|
||||
)
|
||||
@@ -109,3 +147,204 @@ class AliyunAdapter(BaseAdapter):
|
||||
break
|
||||
page += 1
|
||||
return result
|
||||
|
||||
def list_domains(self) -> list:
|
||||
"""注册域名清单(domain.aliyuncs.com QueryDomainList)
|
||||
|
||||
与 DNS 托管是两回事:这里取的是「在阿里云注册的域名 + 到期日」,
|
||||
正是续费提醒需要的数据。Token 无域名权限时抛错由上层捕获,不静默返回空。
|
||||
"""
|
||||
result = []
|
||||
page = 1
|
||||
while True:
|
||||
data = self._call(
|
||||
"QueryDomainList",
|
||||
{"PageNum": str(page), "PageSize": "100"},
|
||||
endpoint="https://domain.aliyuncs.com",
|
||||
version=DOMAIN_VERSION,
|
||||
with_region=False,
|
||||
)
|
||||
domains = data.get("Data", {}).get("Domain", [])
|
||||
for d in domains:
|
||||
result.append(
|
||||
NormalizedDomain(
|
||||
external_id=d.get("DomainName"),
|
||||
domain_name=d.get("DomainName"),
|
||||
registrar="aliyun",
|
||||
expiry_date=parse_iso_date(d.get("ExpirationDate") or d.get("ExpirationDateLong")),
|
||||
status="active" if not d.get("Premium") else "active",
|
||||
raw=d,
|
||||
)
|
||||
)
|
||||
total = int(data.get("TotalItemNum") or data.get("TotalCount") or 0)
|
||||
if not domains or page * 100 >= total:
|
||||
break
|
||||
page += 1
|
||||
return result
|
||||
|
||||
def get_account(self) -> AccountInfo:
|
||||
"""账户余额(business.aliyuncs.com QueryAccountBalance)"""
|
||||
data = self._call(
|
||||
"QueryAccountBalance",
|
||||
endpoint="https://business.aliyuncs.com",
|
||||
version=BSS_VERSION,
|
||||
with_region=False,
|
||||
)
|
||||
d = data.get("Data", {})
|
||||
# AvailableAmount 返回字符串(如 "123.45"),转 float 失败时保持 None
|
||||
balance = self._to_float(d.get("AvailableAmount"))
|
||||
return AccountInfo(
|
||||
balance=balance,
|
||||
currency=d.get("Currency") or "CNY",
|
||||
pending_charges=self._to_float(d.get("MybankCreditAmount")),
|
||||
raw=d,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _to_float(value):
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
# ---------------- 实例管理动作 ----------------
|
||||
|
||||
def _action(self, action: str, params: dict, label: str) -> dict:
|
||||
try:
|
||||
self._call(action, params)
|
||||
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._action("StartInstance", {"InstanceId": external_id}, "开机")
|
||||
|
||||
def stop_instance(self, external_id: str) -> dict:
|
||||
# ForceStop=false 走正常关机;强制断电有丢数据风险,不默认开启
|
||||
return self._action(
|
||||
"StopInstance", {"InstanceId": external_id, "ForceStop": "false"}, "关机"
|
||||
)
|
||||
|
||||
def reboot_instance(self, external_id: str) -> dict:
|
||||
return self._action(
|
||||
"RebootInstance", {"InstanceId": external_id, "ForceStop": "false"}, "重启"
|
||||
)
|
||||
|
||||
def renew_instance(self, external_id: str, period_months: int = 1) -> dict:
|
||||
# RenewInstance 仅对预付费实例有效,后付费会返回 IncorrectInstanceStatus
|
||||
return self._action(
|
||||
"RenewInstance",
|
||||
{"InstanceId": external_id, "Period": str(period_months)},
|
||||
f"续费 {period_months} 个月",
|
||||
)
|
||||
|
||||
# ---------------- DNS 记录管理 ----------------
|
||||
|
||||
def list_dns_records(self, zone_id: str) -> list:
|
||||
result = []
|
||||
page = 1
|
||||
while True:
|
||||
data = self._call(
|
||||
"DescribeDomainRecords",
|
||||
{"DomainName": zone_id, "PageNumber": str(page), "PageSize": "100"},
|
||||
endpoint="https://alidns.aliyuncs.com",
|
||||
version=ALIDNS_VERSION,
|
||||
with_region=False,
|
||||
)
|
||||
records = data.get("DomainRecords", {}).get("Record", [])
|
||||
for r in records:
|
||||
result.append(
|
||||
NormalizedDNSRecord(
|
||||
record_id=str(r.get("RecordId")),
|
||||
zone_id=zone_id,
|
||||
name=self._fqdn(r.get("RR"), zone_id),
|
||||
type=r.get("Type"),
|
||||
content=r.get("Value"),
|
||||
ttl=int(r.get("TTL")) if r.get("TTL") else None,
|
||||
priority=int(r.get("Priority")) if r.get("Priority") else None,
|
||||
comment=r.get("Remark"),
|
||||
raw=r,
|
||||
)
|
||||
)
|
||||
total = int(data.get("TotalCount") or 0)
|
||||
if not records or page * 100 >= total:
|
||||
break
|
||||
page += 1
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _fqdn(rr: str, zone_id: str) -> str:
|
||||
"""阿里云存的是主机记录(RR,如 www / @),拼成完整域名供台账展示"""
|
||||
if not rr or rr == "@":
|
||||
return zone_id
|
||||
return f"{rr}.{zone_id}"
|
||||
|
||||
@staticmethod
|
||||
def _rr(name: str, zone_id: str) -> str:
|
||||
"""反向:把完整域名拆回主机记录"""
|
||||
if name == zone_id:
|
||||
return "@"
|
||||
suffix = f".{zone_id}"
|
||||
return name[: -len(suffix)] if name.endswith(suffix) else name
|
||||
|
||||
def create_dns_record(self, zone_id: str, record: dict) -> dict:
|
||||
params = {
|
||||
"DomainName": zone_id,
|
||||
"RR": self._rr(record.get("name", ""), zone_id),
|
||||
"Type": record.get("type", "A"),
|
||||
"Value": record.get("content", ""),
|
||||
"TTL": str(record.get("ttl") or 600),
|
||||
"Priority": str(record["priority"]) if record.get("priority") else None,
|
||||
}
|
||||
try:
|
||||
data = self._call(
|
||||
"AddDomainRecord",
|
||||
params,
|
||||
endpoint="https://alidns.aliyuncs.com",
|
||||
version=ALIDNS_VERSION,
|
||||
with_region=False,
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"message": "记录已创建",
|
||||
"record_id": str(data.get("RecordId", "")),
|
||||
"raw": data,
|
||||
}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "message": f"创建失败:{e}"}
|
||||
|
||||
def update_dns_record(self, zone_id: str, record_id: str, record: dict) -> dict:
|
||||
params = {
|
||||
"RecordId": record_id,
|
||||
"RR": self._rr(record.get("name", ""), zone_id),
|
||||
"Type": record.get("type", "A"),
|
||||
"Value": record.get("content", ""),
|
||||
"TTL": str(record.get("ttl") or 600),
|
||||
"Priority": str(record["priority"]) if record.get("priority") else None,
|
||||
}
|
||||
try:
|
||||
data = self._call(
|
||||
"UpdateDomainRecord",
|
||||
params,
|
||||
endpoint="https://alidns.aliyuncs.com",
|
||||
version=ALIDNS_VERSION,
|
||||
with_region=False,
|
||||
)
|
||||
return {"ok": True, "message": "记录已更新", "raw": data}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "message": f"更新失败:{e}"}
|
||||
|
||||
def delete_dns_record(self, zone_id: str, record_id: str) -> dict:
|
||||
try:
|
||||
data = self._call(
|
||||
"DeleteDomainRecord",
|
||||
{"RecordId": record_id},
|
||||
endpoint="https://alidns.aliyuncs.com",
|
||||
version=ALIDNS_VERSION,
|
||||
with_region=False,
|
||||
)
|
||||
return {"ok": True, "message": "记录已删除", "raw": data}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "message": f"删除失败:{e}"}
|
||||
|
||||
@@ -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 变更为异步)"}
|
||||
+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}
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
"""Cloudflare 适配器(REST API v4,Bearer Token)
|
||||
|
||||
API 文档:https://developers.cloudflare.com/api/
|
||||
所需配置:{"api_token": "..."}(建议用 API Token,权限含 Zone:Read / Account:Read / Registrar:Read)
|
||||
所需配置:{"api_token": "..."}
|
||||
Token 权限建议:Zone:Read / Zone:Edit(DNS 记录写操作需要)/ Account:Read / Registrar:Read
|
||||
Cloudflare 无传统 VPS,主要同步托管域名(zones);Workers/R2/Tunnel 子资产留待后续阶段。
|
||||
域名到期日通过 Registrar API(/accounts/{id}/registrar/domains)补充获取。
|
||||
DNS 的 zone_id 即 zone uuid(与 list_domains 返回的 external_id 一致)。
|
||||
"""
|
||||
|
||||
import httpx
|
||||
|
||||
from app.adapters.base import BaseAdapter, NormalizedDomain, parse_iso_date
|
||||
from app.adapters.base import (
|
||||
BaseAdapter,
|
||||
NormalizedDNSRecord,
|
||||
NormalizedDomain,
|
||||
parse_iso_date,
|
||||
)
|
||||
from app.adapters.registry import register
|
||||
|
||||
|
||||
@@ -20,16 +27,19 @@ class CloudflareAdapter(BaseAdapter):
|
||||
def _headers(self) -> dict:
|
||||
return {"Authorization": f"Bearer {self.config.get('api_token', '')}"}
|
||||
|
||||
def _get(self, path: str) -> dict:
|
||||
def _request(self, method: str, path: str, json_body: dict = None) -> dict:
|
||||
with httpx.Client(timeout=30) as client:
|
||||
resp = client.get(self.BASE + path, headers=self._headers())
|
||||
resp = client.request(method, self.BASE + path, headers=self._headers(), json=json_body)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
data = resp.json() if resp.content else {}
|
||||
if not data.get("success", True):
|
||||
errors = data.get("errors", [])
|
||||
raise RuntimeError(errors[0].get("message") if errors else "Cloudflare API 返回失败")
|
||||
return data
|
||||
|
||||
def _get(self, path: str) -> dict:
|
||||
return self._request("GET", path)
|
||||
|
||||
def test_connection(self) -> dict:
|
||||
try:
|
||||
data = self._get("/user/tokens/verify")
|
||||
@@ -98,3 +108,84 @@ class CloudflareAdapter(BaseAdapter):
|
||||
if parsed:
|
||||
expiry[name] = parsed
|
||||
return expiry
|
||||
|
||||
# ---------------- DNS 记录管理 ----------------
|
||||
|
||||
def list_dns_records(self, zone_id: str) -> list:
|
||||
result = []
|
||||
page = 1
|
||||
while True:
|
||||
data = self._get(f"/zones/{zone_id}/dns_records?per_page=100&page={page}")
|
||||
for r in data.get("result", []):
|
||||
result.append(
|
||||
NormalizedDNSRecord(
|
||||
record_id=str(r.get("id")),
|
||||
zone_id=zone_id,
|
||||
name=r.get("name"),
|
||||
type=r.get("type"),
|
||||
content=r.get("content"),
|
||||
ttl=r.get("ttl"),
|
||||
proxied=r.get("proxied"),
|
||||
priority=r.get("priority"),
|
||||
comment=r.get("comment"),
|
||||
raw=r,
|
||||
)
|
||||
)
|
||||
info = data.get("result_info", {})
|
||||
if page >= info.get("total_pages", 1):
|
||||
break
|
||||
page += 1
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _record_body(record: dict) -> dict:
|
||||
body = {
|
||||
"type": record.get("type", "A"),
|
||||
"name": record.get("name", ""),
|
||||
"content": record.get("content", ""),
|
||||
"ttl": record.get("ttl") or 1, # 1 = Cloudflare 的「自动」TTL
|
||||
}
|
||||
# proxied 只对 A/AAAA/CNAME 有意义,其余类型传了会被 API 拒绝
|
||||
if record.get("proxied") is not None and body["type"] in ("A", "AAAA", "CNAME"):
|
||||
body["proxied"] = bool(record["proxied"])
|
||||
if record.get("priority") is not None:
|
||||
body["priority"] = record["priority"]
|
||||
if record.get("comment"):
|
||||
body["comment"] = record["comment"]
|
||||
return body
|
||||
|
||||
def create_dns_record(self, zone_id: str, record: dict) -> dict:
|
||||
try:
|
||||
data = self._request(
|
||||
"POST", f"/zones/{zone_id}/dns_records", self._record_body(record)
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"message": "记录已创建",
|
||||
"record_id": str(data.get("result", {}).get("id", "")),
|
||||
"raw": data,
|
||||
}
|
||||
except httpx.HTTPStatusError as e:
|
||||
return {"ok": False, "message": f"HTTP {e.response.status_code}:创建失败(Token 可能缺 Zone:Edit)"}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "message": f"创建失败:{e}"}
|
||||
|
||||
def update_dns_record(self, zone_id: str, record_id: str, record: dict) -> dict:
|
||||
try:
|
||||
self._request(
|
||||
"PATCH", f"/zones/{zone_id}/dns_records/{record_id}", self._record_body(record)
|
||||
)
|
||||
return {"ok": True, "message": "记录已更新"}
|
||||
except httpx.HTTPStatusError as e:
|
||||
return {"ok": False, "message": f"HTTP {e.response.status_code}:更新失败(Token 可能缺 Zone:Edit)"}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "message": f"更新失败:{e}"}
|
||||
|
||||
def delete_dns_record(self, zone_id: str, record_id: str) -> dict:
|
||||
try:
|
||||
self._request("DELETE", f"/zones/{zone_id}/dns_records/{record_id}")
|
||||
return {"ok": True, "message": "记录已删除"}
|
||||
except httpx.HTTPStatusError as e:
|
||||
return {"ok": False, "message": f"HTTP {e.response.status_code}:删除失败(Token 可能缺 Zone:Edit)"}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "message": f"删除失败:{e}"}
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
API 文档:https://docs.digitalocean.com/reference/api/
|
||||
所需配置:{"api_key": "..."}
|
||||
支持功能:Droplet 清单、账户余额、域名清单、启停/重启、DNS 记录管理
|
||||
DNS 的 zone_id 即域名本身(DO 用域名字符串定位)。
|
||||
DO 为按小时计费的订阅制,无「续费」概念,故不实现 renew_instance。
|
||||
"""
|
||||
|
||||
import httpx
|
||||
@@ -9,6 +12,7 @@ import httpx
|
||||
from app.adapters.base import (
|
||||
AccountInfo,
|
||||
BaseAdapter,
|
||||
NormalizedDNSRecord,
|
||||
NormalizedDomain,
|
||||
NormalizedVPS,
|
||||
parse_iso_date,
|
||||
@@ -24,11 +28,14 @@ class DigitalOceanAdapter(BaseAdapter):
|
||||
def _headers(self) -> dict:
|
||||
return {"Authorization": f"Bearer {self.config.get('api_key', '')}"}
|
||||
|
||||
def _get(self, path: str) -> dict:
|
||||
def _request(self, method: str, path: str, json_body: dict = None) -> dict:
|
||||
with httpx.Client(timeout=30) as client:
|
||||
resp = client.get(self.BASE + path, headers=self._headers())
|
||||
resp = client.request(method, self.BASE + path, headers=self._headers(), json=json_body)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
return resp.json() if resp.content else {}
|
||||
|
||||
def _get(self, path: str) -> dict:
|
||||
return self._request("GET", path)
|
||||
|
||||
def test_connection(self) -> dict:
|
||||
try:
|
||||
@@ -73,7 +80,10 @@ class DigitalOceanAdapter(BaseAdapter):
|
||||
memory_gb=round(mem_mb / 1024, 1) if mem_mb else None,
|
||||
disk_gb=d.get("disk"),
|
||||
status="active" if d.get("status") == "active" else (d.get("status") or "unknown"),
|
||||
# size.price_monthly 是官方规格月价,比留空让台账自己猜准得多
|
||||
monthly_cost=(d.get("size", {}) or {}).get("price_monthly"),
|
||||
currency="USD",
|
||||
charge_type="postpaid",
|
||||
raw=d,
|
||||
)
|
||||
)
|
||||
@@ -102,3 +112,119 @@ class DigitalOceanAdapter(BaseAdapter):
|
||||
break
|
||||
page += 1
|
||||
return result
|
||||
|
||||
# ---------------- 实例管理动作 ----------------
|
||||
|
||||
def _droplet_action(self, external_id: str, action_type: str, label: str) -> dict:
|
||||
try:
|
||||
data = self._request(
|
||||
"POST", f"/droplets/{external_id}/actions", {"type": action_type}
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"message": f"{label}指令已下发",
|
||||
"raw": data.get("action", {}),
|
||||
}
|
||||
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._droplet_action(external_id, "power_on", "开机")
|
||||
|
||||
def stop_instance(self, external_id: str) -> dict:
|
||||
# shutdown 是优雅关机;power_off 等同拔电源,有丢数据风险,不默认使用
|
||||
return self._droplet_action(external_id, "shutdown", "关机")
|
||||
|
||||
def reboot_instance(self, external_id: str) -> dict:
|
||||
return self._droplet_action(external_id, "reboot", "重启")
|
||||
|
||||
# ---------------- DNS 记录管理 ----------------
|
||||
|
||||
def list_dns_records(self, zone_id: str) -> list:
|
||||
result = []
|
||||
page = 1
|
||||
while True:
|
||||
data = self._get(f"/domains/{zone_id}/records?per_page=100&page={page}")
|
||||
for r in data.get("domain_records", []):
|
||||
sub = r.get("name") or "@"
|
||||
result.append(
|
||||
NormalizedDNSRecord(
|
||||
record_id=str(r.get("id")),
|
||||
zone_id=zone_id,
|
||||
name=self._fqdn(sub, zone_id),
|
||||
type=r.get("type"),
|
||||
content=r.get("data"),
|
||||
ttl=r.get("ttl"),
|
||||
priority=r.get("priority"),
|
||||
raw=r,
|
||||
)
|
||||
)
|
||||
if not data.get("links", {}).get("pages", {}).get("next"):
|
||||
break
|
||||
page += 1
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _fqdn(sub: str, zone_id: str) -> str:
|
||||
"""DO 存的是子域(name,如 www / @),拼成完整域名供台账展示"""
|
||||
if not sub or sub == "@":
|
||||
return zone_id
|
||||
return f"{sub}.{zone_id}"
|
||||
|
||||
@staticmethod
|
||||
def _sub(name: str, zone_id: str) -> str:
|
||||
if name == zone_id:
|
||||
return "@"
|
||||
suffix = f".{zone_id}"
|
||||
return name[: -len(suffix)] if name.endswith(suffix) else name
|
||||
|
||||
def _record_body(self, zone_id: str, record: dict) -> dict:
|
||||
body = {
|
||||
"name": self._sub(record.get("name", ""), zone_id),
|
||||
"type": record.get("type", "A"),
|
||||
"data": record.get("content", ""),
|
||||
"ttl": record.get("ttl") or 1800,
|
||||
}
|
||||
if record.get("priority") is not None:
|
||||
body["priority"] = record["priority"]
|
||||
return body
|
||||
|
||||
def create_dns_record(self, zone_id: str, record: dict) -> dict:
|
||||
try:
|
||||
data = self._request(
|
||||
"POST", f"/domains/{zone_id}/records", self._record_body(zone_id, record)
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"message": "记录已创建",
|
||||
"record_id": str(data.get("domain_record", {}).get("id", "")),
|
||||
"raw": data,
|
||||
}
|
||||
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": f"创建失败:{e}"}
|
||||
|
||||
def update_dns_record(self, zone_id: str, record_id: str, record: dict) -> dict:
|
||||
try:
|
||||
self._request(
|
||||
"PUT",
|
||||
f"/domains/{zone_id}/records/{record_id}",
|
||||
self._record_body(zone_id, record),
|
||||
)
|
||||
return {"ok": True, "message": "记录已更新"}
|
||||
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": f"更新失败:{e}"}
|
||||
|
||||
def delete_dns_record(self, zone_id: str, record_id: str) -> dict:
|
||||
try:
|
||||
self._request("DELETE", f"/domains/{zone_id}/records/{record_id}")
|
||||
return {"ok": True, "message": "记录已删除"}
|
||||
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": f"删除失败:{e}"}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
"""Linode(Akamai)适配器(REST API v4,Bearer Token)
|
||||
|
||||
API 文档:https://www.linode.com/docs/api/
|
||||
所需配置:{"api_key": "..."}(Personal Access Token)
|
||||
|
||||
覆盖:实例清单与启停/重启、账户余额、托管域名清单、DNS 记录增删改查。
|
||||
Linode 为按小时计费,无「续费」概念,故不实现 renew_instance。
|
||||
DNS 的 zone_id 是数字域名 ID(/domains 返回的 id),不是域名字符串。
|
||||
"""
|
||||
|
||||
import httpx
|
||||
|
||||
from app.adapters.base import (
|
||||
AccountInfo,
|
||||
BaseAdapter,
|
||||
NormalizedDNSRecord,
|
||||
NormalizedDomain,
|
||||
NormalizedVPS,
|
||||
parse_iso_date,
|
||||
)
|
||||
from app.adapters.registry import register
|
||||
|
||||
_STATUS_MAP = {"running": "active", "offline": "stopped", "booting": "active", "shutting_down": "stopped"}
|
||||
|
||||
|
||||
@register("linode-api")
|
||||
class LinodeAdapter(BaseAdapter):
|
||||
required_config = ["api_key"]
|
||||
BASE = "https://api.linode.com/v4"
|
||||
|
||||
def _headers(self) -> dict:
|
||||
return {"Authorization": f"Bearer {self.config.get('api_key', '')}"}
|
||||
|
||||
def _request(self, method: str, path: str, json_body: dict = None) -> dict:
|
||||
with httpx.Client(timeout=30) as client:
|
||||
resp = client.request(method, self.BASE + path, headers=self._headers(), json=json_body)
|
||||
resp.raise_for_status()
|
||||
return resp.json() if resp.content else {}
|
||||
|
||||
def _get(self, path: str) -> dict:
|
||||
return self._request("GET", path)
|
||||
|
||||
@staticmethod
|
||||
def _error_message(e: httpx.HTTPStatusError) -> str:
|
||||
"""Linode 把原因放在 body.errors[0].reason,比裸状态码有用得多"""
|
||||
try:
|
||||
errors = e.response.json().get("errors", [])
|
||||
if errors:
|
||||
return f"HTTP {e.response.status_code}:{errors[0].get('reason')}"
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return f"HTTP {e.response.status_code}"
|
||||
|
||||
def test_connection(self) -> dict:
|
||||
try:
|
||||
data = self._get("/account")
|
||||
return {"ok": True, "message": f"连接成功,账户 {data.get('email') or data.get('company') or 'OK'}"}
|
||||
except httpx.HTTPStatusError as e:
|
||||
return {"ok": False, "message": f"{self._error_message(e)}:Token 无效或权限不足"}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "message": str(e)}
|
||||
|
||||
# ---------------- 只读清单 ----------------
|
||||
|
||||
def get_account(self) -> AccountInfo:
|
||||
acc = self._get("/account")
|
||||
# Linode 的 balance 为负数表示账户有余额(预付信用),正数表示欠款
|
||||
return AccountInfo(
|
||||
balance=acc.get("balance"),
|
||||
currency="USD",
|
||||
raw=acc,
|
||||
)
|
||||
|
||||
def list_vps(self) -> list:
|
||||
result = []
|
||||
page = 1
|
||||
while True:
|
||||
data = self._get(f"/linode/instances?page={page}&page_size=100")
|
||||
for inst in data.get("data", []):
|
||||
specs = inst.get("specs", {}) or {}
|
||||
mem_mb = specs.get("memory") or 0
|
||||
ipv4 = inst.get("ipv4") or []
|
||||
result.append(
|
||||
NormalizedVPS(
|
||||
external_id=str(inst.get("id")),
|
||||
name=inst.get("label") or str(inst.get("id")),
|
||||
ip_address=ipv4[0] if ipv4 else None,
|
||||
region=inst.get("region"),
|
||||
os=inst.get("image"),
|
||||
cpu_cores=specs.get("vcpus"),
|
||||
memory_gb=round(mem_mb / 1024, 1) if mem_mb else None,
|
||||
disk_gb=specs.get("disk"),
|
||||
status=_STATUS_MAP.get(inst.get("status"), inst.get("status") or "unknown"),
|
||||
currency="USD",
|
||||
charge_type="postpaid",
|
||||
raw=inst,
|
||||
)
|
||||
)
|
||||
if page >= (data.get("pages") or 1):
|
||||
break
|
||||
page += 1
|
||||
return result
|
||||
|
||||
def list_domains(self) -> list:
|
||||
result = []
|
||||
page = 1
|
||||
while True:
|
||||
data = self._get(f"/domains?page={page}&page_size=100")
|
||||
for d in data.get("data", []):
|
||||
result.append(
|
||||
NormalizedDomain(
|
||||
external_id=str(d.get("id")),
|
||||
domain_name=d.get("domain"),
|
||||
registrar="linode",
|
||||
# 仅 Linode 注册的域名有 expiry;纯 DNS 托管的为 null
|
||||
expiry_date=parse_iso_date(d.get("expiry")),
|
||||
status="active" if d.get("status") == "active" else (d.get("status") or "unknown"),
|
||||
raw=d,
|
||||
)
|
||||
)
|
||||
if page >= (data.get("pages") or 1):
|
||||
break
|
||||
page += 1
|
||||
return result
|
||||
|
||||
# ---------------- 实例管理动作 ----------------
|
||||
|
||||
def _instance_action(self, external_id: str, verb: str, label: str) -> dict:
|
||||
try:
|
||||
self._request("POST", f"/linode/instances/{external_id}/{verb}")
|
||||
return {"ok": True, "message": f"{label}指令已下发"}
|
||||
except httpx.HTTPStatusError as e:
|
||||
return {"ok": False, "message": f"{self._error_message(e)}:{label}失败"}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "message": f"{label}失败:{e}"}
|
||||
|
||||
def start_instance(self, external_id: str) -> dict:
|
||||
return self._instance_action(external_id, "boot", "开机")
|
||||
|
||||
def stop_instance(self, external_id: str) -> dict:
|
||||
return self._instance_action(external_id, "shutdown", "关机")
|
||||
|
||||
def reboot_instance(self, external_id: str) -> dict:
|
||||
return self._instance_action(external_id, "reboot", "重启")
|
||||
|
||||
# ---------------- DNS 记录管理 ----------------
|
||||
|
||||
def list_dns_records(self, zone_id: str) -> list:
|
||||
result = []
|
||||
page = 1
|
||||
while True:
|
||||
data = self._get(f"/domains/{zone_id}/records?page={page}&page_size=100")
|
||||
for r in data.get("data", []):
|
||||
result.append(
|
||||
NormalizedDNSRecord(
|
||||
record_id=str(r.get("id")),
|
||||
zone_id=zone_id,
|
||||
name=r.get("name") or "",
|
||||
type=r.get("type"),
|
||||
content=r.get("target"),
|
||||
ttl=r.get("ttl_sec"),
|
||||
priority=r.get("priority"),
|
||||
raw=r,
|
||||
)
|
||||
)
|
||||
if page >= (data.get("pages") or 1):
|
||||
break
|
||||
page += 1
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _record_body(record: dict) -> dict:
|
||||
body = {
|
||||
"type": record.get("type", "A"),
|
||||
"name": record.get("name", ""),
|
||||
"target": record.get("content", ""),
|
||||
"ttl_sec": record.get("ttl") or 300,
|
||||
}
|
||||
if record.get("priority") is not None:
|
||||
body["priority"] = record["priority"]
|
||||
return body
|
||||
|
||||
def create_dns_record(self, zone_id: str, record: dict) -> dict:
|
||||
try:
|
||||
data = self._request("POST", f"/domains/{zone_id}/records", self._record_body(record))
|
||||
return {
|
||||
"ok": True,
|
||||
"message": "记录已创建",
|
||||
"record_id": str(data.get("id", "")),
|
||||
"raw": data,
|
||||
}
|
||||
except httpx.HTTPStatusError as e:
|
||||
return {"ok": False, "message": f"{self._error_message(e)}:创建失败"}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "message": f"创建失败:{e}"}
|
||||
|
||||
def update_dns_record(self, zone_id: str, record_id: str, record: dict) -> dict:
|
||||
try:
|
||||
self._request("PUT", f"/domains/{zone_id}/records/{record_id}", self._record_body(record))
|
||||
return {"ok": True, "message": "记录已更新"}
|
||||
except httpx.HTTPStatusError as e:
|
||||
return {"ok": False, "message": f"{self._error_message(e)}:更新失败"}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "message": f"更新失败:{e}"}
|
||||
|
||||
def delete_dns_record(self, zone_id: str, record_id: str) -> dict:
|
||||
try:
|
||||
self._request("DELETE", f"/domains/{zone_id}/records/{record_id}")
|
||||
return {"ok": True, "message": "记录已删除"}
|
||||
except httpx.HTTPStatusError as e:
|
||||
return {"ok": False, "message": f"{self._error_message(e)}:删除失败"}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "message": f"删除失败:{e}"}
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Namesilo 适配器(REST API v1,API Key 查询参数)
|
||||
|
||||
API 文档:https://www.namesilo.com/api_reference.php
|
||||
所需配置:{"api_key": "..."}
|
||||
|
||||
覆盖:注册域名清单(含到期日)、DNS 记录增删改查。
|
||||
Namesilo 是纯域名注册商,无 VPS,故不实现 list_vps / 实例动作。
|
||||
|
||||
响应约定:所有命令都返回 {"request": ..., "reply": {...}},
|
||||
reply.code == 300 才是成功,其余(如 301 未授权、400 参数错)需读 reply.detail。
|
||||
"""
|
||||
|
||||
import httpx
|
||||
|
||||
from app.adapters.base import (
|
||||
BaseAdapter,
|
||||
NormalizedDNSRecord,
|
||||
NormalizedDomain,
|
||||
parse_iso_date,
|
||||
)
|
||||
from app.adapters.registry import register
|
||||
|
||||
SUCCESS_CODE = 300
|
||||
|
||||
|
||||
@register("namesilo-api")
|
||||
class NamesiloAdapter(BaseAdapter):
|
||||
required_config = ["api_key"]
|
||||
BASE = "https://www.namesilo.com/api"
|
||||
|
||||
def _call(self, command: str, params: dict = None) -> dict:
|
||||
query = {"version": "1", "type": "json", "key": self.config.get("api_key", "")}
|
||||
if params:
|
||||
query.update({k: v for k, v in params.items() if v is not None})
|
||||
with httpx.Client(timeout=30) as client:
|
||||
resp = client.get(f"{self.BASE}/{command}", params=query)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
reply = data.get("reply", {}) or {}
|
||||
code = reply.get("code")
|
||||
# Namesilo 的 code 可能是 int 也可能是 str,统一按 int 比较
|
||||
try:
|
||||
code = int(code)
|
||||
except (TypeError, ValueError):
|
||||
code = None
|
||||
if code != SUCCESS_CODE:
|
||||
raise RuntimeError(f"Namesilo {command} 失败(code={reply.get('code')}):{reply.get('detail')}")
|
||||
return reply
|
||||
|
||||
def test_connection(self) -> dict:
|
||||
try:
|
||||
reply = self._call("checkApiKey")
|
||||
return {
|
||||
"ok": True,
|
||||
"message": f"连接成功,令牌属于 {reply.get('username') or '未知账户'}",
|
||||
}
|
||||
except httpx.HTTPStatusError as e:
|
||||
return {"ok": False, "message": f"HTTP {e.response.status_code}:API Key 无效"}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "message": str(e)}
|
||||
|
||||
# ---------------- 只读清单 ----------------
|
||||
|
||||
def list_domains(self) -> list:
|
||||
reply = self._call("listDomains")
|
||||
result = []
|
||||
for d in reply.get("domains", []) or []:
|
||||
result.append(
|
||||
NormalizedDomain(
|
||||
external_id=d.get("domain"),
|
||||
domain_name=d.get("domain"),
|
||||
registrar="namesilo",
|
||||
# expires 形如 "2027-01-01";Namesilo 也可能给 "Jan 01, 2027",解析失败归 None
|
||||
expiry_date=parse_iso_date(d.get("expires")),
|
||||
status="active",
|
||||
raw=d,
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
# ---------------- DNS 记录管理 ----------------
|
||||
# zone_id 即域名字符串;record_id 用 Namesilo 的 record_id
|
||||
|
||||
def list_dns_records(self, zone_id: str) -> list:
|
||||
reply = self._call("listDnsRecords", {"domain": zone_id})
|
||||
result = []
|
||||
for r in reply.get("dns_records", reply.get("resource_record", [])) or []:
|
||||
host = r.get("host") or ""
|
||||
result.append(
|
||||
NormalizedDNSRecord(
|
||||
record_id=str(r.get("record_id") or r.get("id") or ""),
|
||||
zone_id=zone_id,
|
||||
name=self._fqdn(host, zone_id),
|
||||
type=r.get("type"),
|
||||
content=r.get("value"),
|
||||
ttl=int(r["ttl"]) if r.get("ttl") else None,
|
||||
priority=int(r["distance"]) if r.get("distance") else None,
|
||||
raw=r,
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _fqdn(host: str, zone_id: str) -> str:
|
||||
"""Namesilo 的 host 是子域(空或 @ 表示裸域),拼成完整域名供台账展示"""
|
||||
if not host or host == "@":
|
||||
return zone_id
|
||||
return f"{host}.{zone_id}"
|
||||
|
||||
@staticmethod
|
||||
def _host(name: str, zone_id: str) -> str:
|
||||
"""反向:把完整域名拆回子域(裸域用空串)"""
|
||||
if not name or name == zone_id:
|
||||
return ""
|
||||
suffix = f".{zone_id}"
|
||||
return name[: -len(suffix)] if name.endswith(suffix) else name
|
||||
|
||||
def create_dns_record(self, zone_id: str, record: dict) -> dict:
|
||||
# Namesilo 用 dnsRecordUpdate 同时承担新增(不带 record_id)与修改(带 record_id)
|
||||
params = {
|
||||
"domain": zone_id,
|
||||
"rr_type": record.get("type", "A"),
|
||||
"rr_host": self._host(record.get("name", ""), zone_id),
|
||||
"rr_value": record.get("content", ""),
|
||||
"rr_ttl": record.get("ttl") or 3600,
|
||||
}
|
||||
if record.get("priority") is not None:
|
||||
params["rr_distance"] = record["priority"]
|
||||
try:
|
||||
self._call("dnsRecordUpdate", params)
|
||||
return {"ok": True, "message": "记录已创建"}
|
||||
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": f"创建失败:{e}"}
|
||||
|
||||
def update_dns_record(self, zone_id: str, record_id: str, record: dict) -> dict:
|
||||
params = {
|
||||
"domain": zone_id,
|
||||
"rr_id": record_id,
|
||||
"rr_type": record.get("type", "A"),
|
||||
"rr_host": self._host(record.get("name", ""), zone_id),
|
||||
"rr_value": record.get("content", ""),
|
||||
"rr_ttl": record.get("ttl") or 3600,
|
||||
}
|
||||
if record.get("priority") is not None:
|
||||
params["rr_distance"] = record["priority"]
|
||||
try:
|
||||
self._call("dnsRecordUpdate", params)
|
||||
return {"ok": True, "message": "记录已更新"}
|
||||
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": f"更新失败:{e}"}
|
||||
|
||||
def delete_dns_record(self, zone_id: str, record_id: str) -> dict:
|
||||
try:
|
||||
self._call("dnsRecordDelete", {"domain": zone_id, "rr_id": record_id})
|
||||
return {"ok": True, "message": "记录已删除"}
|
||||
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": f"删除失败:{e}"}
|
||||
@@ -28,8 +28,11 @@ def load_all() -> None:
|
||||
from app.adapters import ( # noqa: F401
|
||||
ai,
|
||||
aliyun,
|
||||
aws,
|
||||
cloudflare,
|
||||
digitalocean,
|
||||
linode,
|
||||
namesilo,
|
||||
tencent,
|
||||
vultr,
|
||||
)
|
||||
|
||||
+257
-23
@@ -1,10 +1,16 @@
|
||||
"""腾讯云适配器(含国际版,CVM OpenAPI + TC3-HMAC-SHA256 签名)
|
||||
"""腾讯云适配器(含国际版,TC3-HMAC-SHA256 签名)
|
||||
|
||||
API 文档:https://cloud.tencent.com/document/api/213/15753
|
||||
所需配置:{"secret_id": "...", "secret_key": "...", "region": "ap-guangzhou"}
|
||||
国际版(intl)将 region 设为海外区域即可,端点统一为 cvm.tencentcloudapi.com。
|
||||
说明:此处实现 TC3-HMAC-SHA256 签名,可直接调用 CVM OpenAPI;
|
||||
生产环境也可替换为官方 tencentcloud-sdk-python,接口契约保持不变。
|
||||
国际版(intl)将 region 设为海外区域即可,端点统一为 *.tencentcloudapi.com。
|
||||
|
||||
TC3 签名对各产品一致,只有 host / service / version 不同,故 _call 接受这三者:
|
||||
- CVM cvm.tencentcloudapi.com 2017-03-12 实例清单与启停/重启/续费
|
||||
- Billing billing.tencentcloudapi.com 2018-07-09 账户余额(单位:分)
|
||||
- Domain domain.tencentcloudapi.com 2018-08-08 注册域名清单(含到期日)
|
||||
- DNSPod dnspod.tencentcloudapi.com 2021-03-23 解析记录增删改查
|
||||
|
||||
DNS 的 zone_id 即域名本身(DNSPod 用域名而非 zone uuid 定位)。
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
@@ -14,17 +20,39 @@ import time
|
||||
|
||||
import httpx
|
||||
|
||||
from app.adapters.base import BaseAdapter, NormalizedVPS
|
||||
from app.adapters.base import (
|
||||
AccountInfo,
|
||||
BaseAdapter,
|
||||
NormalizedDNSRecord,
|
||||
NormalizedDomain,
|
||||
NormalizedVPS,
|
||||
parse_iso_date,
|
||||
)
|
||||
from app.adapters.registry import register
|
||||
|
||||
CVM_HOST = "cvm.tencentcloudapi.com"
|
||||
CVM_VERSION = "2017-03-12"
|
||||
BILLING_HOST = "billing.tencentcloudapi.com"
|
||||
BILLING_VERSION = "2018-07-09"
|
||||
DOMAIN_HOST = "domain.tencentcloudapi.com"
|
||||
DOMAIN_VERSION = "2018-08-08"
|
||||
DNSPOD_HOST = "dnspod.tencentcloudapi.com"
|
||||
DNSPOD_VERSION = "2021-03-23"
|
||||
|
||||
ALGORITHM = "TC3-HMAC-SHA256"
|
||||
|
||||
_STATUS_MAP = {"RUNNING": "active", "STOPPED": "stopped", "STOPPING": "stopped", "STARTING": "active"}
|
||||
_CHARGE_TYPE_MAP = {"PREPAID": "prepaid", "POSTPAID_BY_HOUR": "postpaid", "POSTPAID_BY_MONTH": "postpaid"}
|
||||
|
||||
|
||||
def _service_of(host: str) -> str:
|
||||
"""从 host 推导 TC3 credential scope 用的 service 名"""
|
||||
return host.split(".")[0]
|
||||
|
||||
|
||||
@register("tencent-sdk", "tencent-intl-sdk")
|
||||
class TencentAdapter(BaseAdapter):
|
||||
required_config = ["secret_id", "secret_key"]
|
||||
HOST = "cvm.tencentcloudapi.com"
|
||||
SERVICE = "cvm"
|
||||
VERSION = "2017-03-12"
|
||||
ALGORITHM = "TC3-HMAC-SHA256"
|
||||
|
||||
def _region(self) -> str:
|
||||
return self.config.get("region") or "ap-guangzhou"
|
||||
@@ -33,48 +61,62 @@ class TencentAdapter(BaseAdapter):
|
||||
def _hmac_sha256(key: bytes, msg: str) -> bytes:
|
||||
return hmac.new(key, msg.encode(), hashlib.sha256).digest()
|
||||
|
||||
def _build_authorization(self, action: str, payload_json: str, timestamp: int, date: str):
|
||||
def _build_authorization(
|
||||
self, action: str, payload_json: str, timestamp: int, date: str, host: str, service: str
|
||||
):
|
||||
secret_id = self.config.get("secret_id", "")
|
||||
secret_key = self.config.get("secret_key", "")
|
||||
content_type = "application/json; charset=utf-8"
|
||||
canonical_headers = (
|
||||
f"content-type:{content_type}\nhost:{self.HOST}\nx-tc-action:{action.lower()}\n"
|
||||
f"content-type:{content_type}\nhost:{host}\nx-tc-action:{action.lower()}\n"
|
||||
)
|
||||
signed_headers = "content-type;host;x-tc-action"
|
||||
hashed_payload = hashlib.sha256(payload_json.encode()).hexdigest()
|
||||
canonical_request = (
|
||||
f"POST\n/\n\n{canonical_headers}\n{signed_headers}\n{hashed_payload}"
|
||||
)
|
||||
credential_scope = f"{date}/{self.SERVICE}/tc3_request"
|
||||
credential_scope = f"{date}/{service}/tc3_request"
|
||||
hashed_canonical = hashlib.sha256(canonical_request.encode()).hexdigest()
|
||||
string_to_sign = (
|
||||
f"{self.ALGORITHM}\n{timestamp}\n{credential_scope}\n{hashed_canonical}"
|
||||
f"{ALGORITHM}\n{timestamp}\n{credential_scope}\n{hashed_canonical}"
|
||||
)
|
||||
secret_date = self._hmac_sha256(("TC3" + secret_key).encode(), date)
|
||||
secret_service = self._hmac_sha256(secret_date, self.SERVICE)
|
||||
secret_service = self._hmac_sha256(secret_date, service)
|
||||
secret_signing = self._hmac_sha256(secret_service, "tc3_request")
|
||||
signature = hmac.new(secret_signing, string_to_sign.encode(), hashlib.sha256).hexdigest()
|
||||
return (
|
||||
f"{self.ALGORITHM} Credential={secret_id}/{credential_scope}, "
|
||||
f"{ALGORITHM} Credential={secret_id}/{credential_scope}, "
|
||||
f"SignedHeaders={signed_headers}, Signature={signature}"
|
||||
)
|
||||
|
||||
def _call(self, action: str, payload: dict = None) -> dict:
|
||||
def _call(
|
||||
self,
|
||||
action: str,
|
||||
payload: dict = None,
|
||||
host: str = CVM_HOST,
|
||||
version: str = CVM_VERSION,
|
||||
with_region: bool = True,
|
||||
) -> dict:
|
||||
payload_json = json.dumps(payload or {})
|
||||
timestamp = int(time.time())
|
||||
date = time.strftime("%Y-%m-%d", time.gmtime(timestamp))
|
||||
authorization = self._build_authorization(action, payload_json, timestamp, date)
|
||||
service = _service_of(host)
|
||||
authorization = self._build_authorization(
|
||||
action, payload_json, timestamp, date, host, service
|
||||
)
|
||||
headers = {
|
||||
"Authorization": authorization,
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Host": self.HOST,
|
||||
"Host": host,
|
||||
"X-TC-Action": action,
|
||||
"X-TC-Timestamp": str(timestamp),
|
||||
"X-TC-Version": self.VERSION,
|
||||
"X-TC-Region": self._region(),
|
||||
"X-TC-Version": version,
|
||||
}
|
||||
# Billing / Domain / DNSPod 是中心化服务,带 Region 反而可能报参数错误
|
||||
if with_region:
|
||||
headers["X-TC-Region"] = self._region()
|
||||
with httpx.Client(timeout=30) as client:
|
||||
resp = client.post(f"https://{self.HOST}", content=payload_json, headers=headers)
|
||||
resp = client.post(f"https://{host}", content=payload_json, headers=headers)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
response = data.get("Response", {})
|
||||
@@ -93,16 +135,18 @@ class TencentAdapter(BaseAdapter):
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "message": str(e)}
|
||||
|
||||
# ---------------- 只读清单 ----------------
|
||||
|
||||
def list_vps(self) -> list:
|
||||
# 分页拉取:单次最多 100 条,按 TotalCount 翻页,避免实例超 100 台时漏同步
|
||||
result = []
|
||||
status_map = {"RUNNING": "active", "STOPPED": "stopped"}
|
||||
offset = 0
|
||||
while True:
|
||||
data = self._call("DescribeInstances", {"Limit": 100, "Offset": offset})
|
||||
inst_set = data.get("InstanceSet", [])
|
||||
for inst in inst_set:
|
||||
public_ips = inst.get("PublicIpAddresses", [])
|
||||
charge = inst.get("InstanceChargeType")
|
||||
result.append(
|
||||
NormalizedVPS(
|
||||
external_id=inst.get("InstanceId"),
|
||||
@@ -113,8 +157,11 @@ class TencentAdapter(BaseAdapter):
|
||||
cpu_cores=inst.get("CPU"),
|
||||
memory_gb=inst.get("Memory"),
|
||||
disk_gb=inst.get("SystemDisk", {}).get("DiskSize"),
|
||||
status=status_map.get(inst.get("InstanceState"), inst.get("InstanceState") or "unknown"),
|
||||
status=_STATUS_MAP.get(inst.get("InstanceState"), inst.get("InstanceState") or "unknown"),
|
||||
currency="CNY",
|
||||
# 预付费实例必须带上到期日,否则续费提醒对云端同步的机器完全失效
|
||||
expiry_date=parse_iso_date(inst.get("ExpiredTime")),
|
||||
charge_type=_CHARGE_TYPE_MAP.get(charge, charge),
|
||||
raw=inst,
|
||||
)
|
||||
)
|
||||
@@ -123,3 +170,190 @@ class TencentAdapter(BaseAdapter):
|
||||
if not inst_set or offset >= total:
|
||||
break
|
||||
return result
|
||||
|
||||
def list_domains(self) -> list:
|
||||
"""注册域名清单(domain.tencentcloudapi.com DescribeDomainNameList)"""
|
||||
result = []
|
||||
offset = 0
|
||||
while True:
|
||||
data = self._call(
|
||||
"DescribeDomainNameList",
|
||||
{"Offset": offset, "Limit": 100},
|
||||
host=DOMAIN_HOST,
|
||||
version=DOMAIN_VERSION,
|
||||
with_region=False,
|
||||
)
|
||||
domains = data.get("DomainSet", [])
|
||||
for d in domains:
|
||||
result.append(
|
||||
NormalizedDomain(
|
||||
external_id=d.get("DomainName") or d.get("DomainId"),
|
||||
domain_name=d.get("DomainName"),
|
||||
registrar="tencent",
|
||||
# 该接口返回 ExpireDate(毫秒时间戳)或 ExpireTime(ISO),两者都兼容
|
||||
expiry_date=parse_iso_date(d.get("ExpireTime") or self._ms_to_iso(d.get("ExpireDate"))),
|
||||
status="active",
|
||||
raw=d,
|
||||
)
|
||||
)
|
||||
total = int(data.get("TotalCount") or 0)
|
||||
offset += len(domains)
|
||||
if not domains or offset >= total:
|
||||
break
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _ms_to_iso(value) -> str:
|
||||
"""毫秒时间戳 -> ISO 字符串(parse_iso_date 只取前 10 位)"""
|
||||
try:
|
||||
return time.strftime("%Y-%m-%d", time.gmtime(int(value) / 1000))
|
||||
except (TypeError, ValueError):
|
||||
return ""
|
||||
|
||||
def get_account(self) -> AccountInfo:
|
||||
"""账户余额(billing.tencentcloudapi.com DescribeAccountBalance)
|
||||
|
||||
注意 Balance 单位是「分」,必须除 100 才是元,否则台账金额会放大百倍。
|
||||
"""
|
||||
data = self._call(
|
||||
"DescribeAccountBalance",
|
||||
host=BILLING_HOST,
|
||||
version=BILLING_VERSION,
|
||||
with_region=False,
|
||||
)
|
||||
balance = data.get("Balance")
|
||||
return AccountInfo(
|
||||
balance=round(balance / 100, 2) if isinstance(balance, (int, float)) else None,
|
||||
currency="CNY",
|
||||
raw=data,
|
||||
)
|
||||
|
||||
# ---------------- 实例管理动作 ----------------
|
||||
|
||||
def _action(self, action: str, payload: dict, label: str) -> dict:
|
||||
try:
|
||||
data = self._call(action, payload)
|
||||
return {"ok": True, "message": f"{label}指令已下发", "raw": data}
|
||||
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._action("StartInstances", {"InstanceIds": [external_id]}, "开机")
|
||||
|
||||
def stop_instance(self, external_id: str) -> dict:
|
||||
# SOFT_FIRST:先正常关机,超时才强制;直接 HARD 有丢数据风险
|
||||
return self._action(
|
||||
"StopInstances",
|
||||
{"InstanceIds": [external_id], "StopType": "SOFT_FIRST", "StoppedBehavior": "KEEP_CHARGING"},
|
||||
"关机",
|
||||
)
|
||||
|
||||
def reboot_instance(self, external_id: str) -> dict:
|
||||
return self._action(
|
||||
"RebootInstances", {"InstanceIds": [external_id], "RebootType": "SOFT_FIRST"}, "重启"
|
||||
)
|
||||
|
||||
def renew_instance(self, external_id: str, period_months: int = 1) -> dict:
|
||||
# 仅对 PREPAID 实例有效;Period 单位为月
|
||||
return self._action(
|
||||
"RenewInstances",
|
||||
{
|
||||
"InstanceIds": [external_id],
|
||||
"InstanceChargePrepaid": {
|
||||
"RenewFlag": "NOTIFY_AND_MANUAL_RENEW",
|
||||
"Period": period_months,
|
||||
},
|
||||
},
|
||||
f"续费 {period_months} 个月",
|
||||
)
|
||||
|
||||
# ---------------- DNS 记录管理 ----------------
|
||||
|
||||
def _dnspod(self, action: str, payload: dict) -> dict:
|
||||
return self._call(
|
||||
action, payload, host=DNSPOD_HOST, version=DNSPOD_VERSION, with_region=False
|
||||
)
|
||||
|
||||
def list_dns_records(self, zone_id: str) -> list:
|
||||
data = self._dnspod("DescribeRecordList", {"Domain": zone_id})
|
||||
result = []
|
||||
for r in data.get("RecordList", []):
|
||||
sub = r.get("Name") or "@"
|
||||
result.append(
|
||||
NormalizedDNSRecord(
|
||||
record_id=str(r.get("RecordId") or r.get("ID")),
|
||||
zone_id=zone_id,
|
||||
name=self._fqdn(sub, zone_id),
|
||||
type=r.get("Type"),
|
||||
content=r.get("Value"),
|
||||
ttl=int(r.get("TTL")) if r.get("TTL") else None,
|
||||
priority=int(r["MX"]) if r.get("MX") else None,
|
||||
comment=r.get("Comment"),
|
||||
raw=r,
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _fqdn(sub: str, zone_id: str) -> str:
|
||||
"""DNSPod 存的是子域(Name,如 www / @),拼成完整域名供台账展示"""
|
||||
if not sub or sub == "@":
|
||||
return zone_id
|
||||
return f"{sub}.{zone_id}"
|
||||
|
||||
@staticmethod
|
||||
def _sub(name: str, zone_id: str) -> str:
|
||||
"""反向:把完整域名拆回子域"""
|
||||
if name == zone_id:
|
||||
return "@"
|
||||
suffix = f".{zone_id}"
|
||||
return name[: -len(suffix)] if name.endswith(suffix) else name
|
||||
|
||||
def create_dns_record(self, zone_id: str, record: dict) -> dict:
|
||||
payload = {
|
||||
"Domain": zone_id,
|
||||
"SubDomain": self._sub(record.get("name", ""), zone_id),
|
||||
"RecordType": record.get("type", "A"),
|
||||
"RecordLine": record.get("line") or "默认",
|
||||
"Value": record.get("content", ""),
|
||||
"TTL": record.get("ttl") or 600,
|
||||
}
|
||||
if record.get("priority"):
|
||||
payload["MX"] = record["priority"]
|
||||
try:
|
||||
data = self._dnspod("CreateRecord", payload)
|
||||
return {
|
||||
"ok": True,
|
||||
"message": "记录已创建",
|
||||
"record_id": str(data.get("RecordId", "")),
|
||||
"raw": data,
|
||||
}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "message": f"创建失败:{e}"}
|
||||
|
||||
def update_dns_record(self, zone_id: str, record_id: str, record: dict) -> dict:
|
||||
payload = {
|
||||
"Domain": zone_id,
|
||||
"RecordId": record_id,
|
||||
"SubDomain": self._sub(record.get("name", ""), zone_id),
|
||||
"RecordType": record.get("type", "A"),
|
||||
"RecordLine": record.get("line") or "默认",
|
||||
"Value": record.get("content", ""),
|
||||
"TTL": record.get("ttl") or 600,
|
||||
}
|
||||
if record.get("priority"):
|
||||
payload["MX"] = record["priority"]
|
||||
try:
|
||||
data = self._dnspod("ModifyRecord", payload)
|
||||
return {"ok": True, "message": "记录已更新", "raw": data}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "message": f"更新失败:{e}"}
|
||||
|
||||
def delete_dns_record(self, zone_id: str, record_id: str) -> dict:
|
||||
try:
|
||||
data = self._dnspod("DeleteRecord", {"Domain": zone_id, "RecordId": record_id})
|
||||
return {"ok": True, "message": "记录已删除", "raw": data}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "message": f"删除失败:{e}"}
|
||||
|
||||
+109
-4
@@ -2,7 +2,9 @@
|
||||
|
||||
API 文档:https://www.vultr.com/api/
|
||||
所需配置:{"api_key": "..."}
|
||||
支持功能:实例列表、订阅账单、区域查询
|
||||
支持功能:实例列表、订阅账单、区域查询、实例启停/重启、DNS 记录管理
|
||||
DNS 的 zone_id 即域名本身(Vultr 用域名字符串定位,非 uuid)。
|
||||
Vultr 为按小时计费的订阅制,无「续费」概念,故不实现 renew_instance。
|
||||
"""
|
||||
|
||||
import httpx
|
||||
@@ -10,6 +12,7 @@ import httpx
|
||||
from app.adapters.base import (
|
||||
AccountInfo,
|
||||
BaseAdapter,
|
||||
NormalizedDNSRecord,
|
||||
NormalizedDomain,
|
||||
NormalizedVPS,
|
||||
parse_iso_date,
|
||||
@@ -25,11 +28,15 @@ class VultrAdapter(BaseAdapter):
|
||||
def _headers(self) -> dict:
|
||||
return {"Authorization": f"Bearer {self.config.get('api_key', '')}"}
|
||||
|
||||
def _get(self, path: str) -> dict:
|
||||
def _request(self, method: str, path: str, json_body: dict = None) -> dict:
|
||||
with httpx.Client(timeout=30) as client:
|
||||
resp = client.get(self.BASE + path, headers=self._headers())
|
||||
resp = client.request(method, self.BASE + path, headers=self._headers(), json=json_body)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
# Vultr 的动作类接口成功时返回 204 无 body
|
||||
return resp.json() if resp.content else {}
|
||||
|
||||
def _get(self, path: str) -> dict:
|
||||
return self._request("GET", path)
|
||||
|
||||
def test_connection(self) -> dict:
|
||||
try:
|
||||
@@ -73,6 +80,8 @@ class VultrAdapter(BaseAdapter):
|
||||
status="active" if inst.get("status") == "active" else (inst.get("status") or "unknown"),
|
||||
monthly_cost=inst.get("monthly_cost"),
|
||||
currency="USD",
|
||||
# Vultr 按小时计费、无到期概念,标 postpaid 让前端不去催续费
|
||||
charge_type="postpaid",
|
||||
raw=inst,
|
||||
)
|
||||
)
|
||||
@@ -104,3 +113,99 @@ class VultrAdapter(BaseAdapter):
|
||||
if not cursor:
|
||||
break
|
||||
return result
|
||||
|
||||
# ---------------- 实例管理动作 ----------------
|
||||
|
||||
def _instance_action(self, external_id: str, verb: str, label: str) -> dict:
|
||||
try:
|
||||
self._request("POST", f"/instances/{external_id}/{verb}")
|
||||
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._instance_action(external_id, "start", "开机")
|
||||
|
||||
def stop_instance(self, external_id: str) -> dict:
|
||||
return self._instance_action(external_id, "halt", "关机")
|
||||
|
||||
def reboot_instance(self, external_id: str) -> dict:
|
||||
return self._instance_action(external_id, "reboot", "重启")
|
||||
|
||||
# ---------------- DNS 记录管理 ----------------
|
||||
|
||||
def list_dns_records(self, zone_id: str) -> list:
|
||||
result = []
|
||||
cursor = ""
|
||||
while True:
|
||||
path = f"/domains/{zone_id}/records?per_page=100"
|
||||
if cursor:
|
||||
path += f"&cursor={cursor}"
|
||||
data = self._get(path)
|
||||
for r in data.get("records", []):
|
||||
result.append(
|
||||
NormalizedDNSRecord(
|
||||
record_id=str(r.get("id")),
|
||||
zone_id=zone_id,
|
||||
name=r.get("name") or zone_id,
|
||||
type=r.get("type"),
|
||||
content=r.get("data"),
|
||||
ttl=r.get("ttl"),
|
||||
priority=r.get("priority"),
|
||||
raw=r,
|
||||
)
|
||||
)
|
||||
cursor = data.get("meta", {}).get("links", {}).get("next") or ""
|
||||
if not cursor:
|
||||
break
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _record_body(record: dict) -> dict:
|
||||
body = {
|
||||
"name": record.get("name", ""),
|
||||
"type": record.get("type", "A"),
|
||||
"data": record.get("content", ""),
|
||||
"ttl": record.get("ttl") or 300,
|
||||
}
|
||||
if record.get("priority") is not None:
|
||||
body["priority"] = record["priority"]
|
||||
return body
|
||||
|
||||
def create_dns_record(self, zone_id: str, record: dict) -> dict:
|
||||
try:
|
||||
data = self._request(
|
||||
"POST", f"/domains/{zone_id}/records", self._record_body(record)
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"message": "记录已创建",
|
||||
"record_id": str(data.get("record", {}).get("id", "")),
|
||||
"raw": data,
|
||||
}
|
||||
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": f"创建失败:{e}"}
|
||||
|
||||
def update_dns_record(self, zone_id: str, record_id: str, record: dict) -> dict:
|
||||
try:
|
||||
self._request(
|
||||
"PATCH", f"/domains/{zone_id}/records/{record_id}", self._record_body(record)
|
||||
)
|
||||
return {"ok": True, "message": "记录已更新"}
|
||||
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": f"更新失败:{e}"}
|
||||
|
||||
def delete_dns_record(self, zone_id: str, record_id: str) -> dict:
|
||||
try:
|
||||
self._request("DELETE", f"/domains/{zone_id}/records/{record_id}")
|
||||
return {"ok": True, "message": "记录已删除"}
|
||||
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": f"删除失败:{e}"}
|
||||
|
||||
+12
-6
@@ -23,21 +23,27 @@ PRESET_PROVIDERS = [
|
||||
{"slug": "tencent-intl", "name": "腾讯国际", "name_en": "Tencent Cloud Intl", "category": "vps",
|
||||
"services": "vps,domain,ssl_cert,cdn,dns",
|
||||
"website": "https://intl.cloud.tencent.com", "console_url": "https://console.intl.cloud.tencent.com", "sdk_type": "tencent-intl-sdk"},
|
||||
{"slug": "aws", "name": "AWS", "name_en": "Amazon Web Services", "category": "vps",
|
||||
"services": "vps,domain,dns,cdn,ssl_cert",
|
||||
"website": "https://aws.amazon.com", "console_url": "https://console.aws.amazon.com/ec2/", "sdk_type": "aws-api"},
|
||||
{"slug": "vultr", "name": "Vultr", "name_en": "Vultr", "category": "vps",
|
||||
"services": "vps",
|
||||
"services": "vps,domain,dns",
|
||||
"website": "https://vultr.com", "console_url": "https://my.vultr.com", "sdk_type": "vultr-api"},
|
||||
{"slug": "digitalocean", "name": "DigitalOcean", "name_en": "DigitalOcean", "category": "vps",
|
||||
"services": "vps,domain",
|
||||
"services": "vps,domain,dns",
|
||||
"website": "https://digitalocean.com", "console_url": "https://cloud.digitalocean.com", "sdk_type": "do-api"},
|
||||
{"slug": "linode", "name": "Linode", "name_en": "Akamai Linode", "category": "vps",
|
||||
"services": "vps",
|
||||
"services": "vps,domain,dns",
|
||||
"website": "https://linode.com", "console_url": "https://cloud.linode.com", "sdk_type": "linode-api"},
|
||||
# 以下平台暂无适配器:sdk_type 必须留空。
|
||||
# 填了不存在的 sdk_type 会让平台卡片显示「API 对接: xxx」,用户建账号点同步才撞
|
||||
# 「暂不支持的 SDK 类型」——声明支持却不支持,比明说不支持更糟。
|
||||
{"slug": "cloudcone", "name": "CloudCone", "name_en": "CloudCone", "category": "vps",
|
||||
"services": "vps",
|
||||
"website": "https://cloudcone.com", "console_url": "https://app.cloudcone.com", "sdk_type": "cloudcone-api"},
|
||||
"website": "https://cloudcone.com", "console_url": "https://app.cloudcone.com", "sdk_type": None},
|
||||
{"slug": "zeabur", "name": "Zeabur", "name_en": "Zeabur", "category": "vps",
|
||||
"services": "vps,domain",
|
||||
"website": "https://zeabur.com", "console_url": "https://dash.zeabur.com", "sdk_type": "zeabur-api"},
|
||||
"website": "https://zeabur.com", "console_url": "https://dash.zeabur.com", "sdk_type": None},
|
||||
# ---- 域名注册商 ----
|
||||
{"slug": "namesilo", "name": "Namesilo", "name_en": "Namesilo", "category": "domain",
|
||||
"services": "domain,dns",
|
||||
@@ -47,7 +53,7 @@ PRESET_PROVIDERS = [
|
||||
"website": "https://xinnet.com", "console_url": "https://www.xinnet.com", "sdk_type": None},
|
||||
{"slug": "dynadot", "name": "Dynadot", "name_en": "Dynadot", "category": "domain",
|
||||
"services": "domain,dns",
|
||||
"website": "https://dynadot.com", "console_url": "https://www.dynadot.com/account/domains", "sdk_type": "dynadot-api"},
|
||||
"website": "https://dynadot.com", "console_url": "https://www.dynadot.com/account/domains", "sdk_type": None},
|
||||
# ---- AI 服务商 ----
|
||||
{"slug": "openai", "name": "OpenAI", "name_en": "OpenAI", "category": "ai_agent",
|
||||
"services": "ai_agent",
|
||||
|
||||
+46
-14
@@ -98,8 +98,8 @@ def _migrate_assets_db() -> None:
|
||||
conn.execute(sa.text("ALTER TABLE providers ADD COLUMN last_synced_at DATETIME"))
|
||||
if "services" not in cols:
|
||||
conn.execute(sa.text("ALTER TABLE providers ADD COLUMN services VARCHAR DEFAULT ''"))
|
||||
# 为已有预设平台补充 services(仅补空值,用户自定义行不动)
|
||||
_backfill_provider_services(conn)
|
||||
# 预设平台的 sdk_type / services 对齐当前代码能力(幂等,用户自建行不动)
|
||||
_align_preset_providers(conn)
|
||||
if insp.has_table("ai_accounts"):
|
||||
cols = {c["name"] for c in insp.get_columns("ai_accounts")}
|
||||
if "api_key_encrypted" not in cols:
|
||||
@@ -182,22 +182,54 @@ def _migrate_plaintext_api_keys(conn) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _backfill_provider_services(conn) -> None:
|
||||
"""为已有预设平台补充 services 服务列表(只更新 services 为空的预设 slug)"""
|
||||
def _align_preset_providers(conn) -> None:
|
||||
"""把预设平台的 sdk_type / services 对齐到当前代码能力(幂等)
|
||||
|
||||
seed_providers 只插入缺失 slug、不更新已有行,所以代码侧的能力变化
|
||||
(新增适配器、废弃从未实现的 sdk_type)必须在这里落地,否则线上会长期
|
||||
停留在旧声明上。
|
||||
|
||||
- sdk_type:库里声明的值不在适配器注册表内就清空。留着会让平台卡片显示
|
||||
「API 对接: xxx」,用户建好账号点同步才撞「暂不支持的 SDK 类型」——
|
||||
声明支持却不支持,比明说不支持更糟。
|
||||
- services:只增量补标签,不删除。用户可能手动加过标签(线上 aliyun-intl
|
||||
就多了 ai_agent/other),按种子值整体覆盖会丢用户配置。
|
||||
|
||||
只处理 slug 命中 PRESET_PROVIDERS 的行,用户自建平台(如 tothost)不动。
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from app.adapters import registry
|
||||
from app.core.seed import PRESET_PROVIDERS
|
||||
|
||||
for data in PRESET_PROVIDERS:
|
||||
services = data.get("services")
|
||||
if not services:
|
||||
supported = set(registry.supported_types())
|
||||
preset = {d["slug"]: d for d in PRESET_PROVIDERS}
|
||||
|
||||
rows = conn.execute(
|
||||
sa.text("SELECT id, slug, sdk_type, services FROM providers")
|
||||
).fetchall()
|
||||
|
||||
for row in rows:
|
||||
data = preset.get(row[1])
|
||||
if not data:
|
||||
continue
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"UPDATE providers SET services = :services "
|
||||
"WHERE slug = :slug AND (services IS NULL OR services = '')"
|
||||
),
|
||||
{"services": services, "slug": data["slug"]},
|
||||
)
|
||||
|
||||
declared = row[2]
|
||||
if declared and declared not in supported:
|
||||
conn.execute(
|
||||
sa.text("UPDATE providers SET sdk_type = NULL WHERE id = :id"),
|
||||
{"id": row[0]},
|
||||
)
|
||||
|
||||
seed_services = [s.strip() for s in (data.get("services") or "").split(",") if s.strip()]
|
||||
if not seed_services:
|
||||
continue
|
||||
current = [s.strip() for s in (row[3] or "").split(",") if s.strip()]
|
||||
missing = [s for s in seed_services if s not in current]
|
||||
if missing:
|
||||
conn.execute(
|
||||
sa.text("UPDATE providers SET services = :services WHERE id = :id"),
|
||||
{"services": ",".join(current + missing), "id": row[0]},
|
||||
)
|
||||
|
||||
|
||||
def _dedupe_accounts(conn) -> None:
|
||||
|
||||
+2
-1
@@ -23,7 +23,7 @@ from sqlmodel import Session
|
||||
from app.core.config import settings
|
||||
from app.core.seed import seed_providers
|
||||
from app.database import assets_engine, init_db
|
||||
from app.routers import accounts, agent, assets, credentials, monitor, notify, providers, ssl, stats, sync
|
||||
from app.routers import accounts, actions, agent, assets, credentials, monitor, notify, providers, ssl, stats, sync
|
||||
from app.services import cleanup_service
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
@@ -138,6 +138,7 @@ app.include_router(stats.router)
|
||||
app.include_router(agent.router)
|
||||
app.include_router(monitor.router)
|
||||
app.include_router(sync.router)
|
||||
app.include_router(actions.router)
|
||||
app.include_router(notify.router)
|
||||
app.include_router(ssl.router)
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""云资源管理动作路由
|
||||
|
||||
统一入口 /api/actions/{asset_id},把资产解析成对应平台适配器后下发真实云端指令。
|
||||
|
||||
安全约束:
|
||||
- 所有写操作(实例动作、DNS 增删改)强制 require_api_key,与项目其余写端点一致
|
||||
- 每次动作由 action_service 落 EventLog 审计(source="action"),成功失败都记
|
||||
- 能力探测端点 GET /capabilities 无副作用,供前端预先禁用不支持的按钮
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.core.security import require_api_key
|
||||
from app.database import get_session
|
||||
from app.schemas.action import DNSRecordIn, InstanceActionRequest
|
||||
from app.services import action_service
|
||||
|
||||
router = APIRouter(prefix="/api/actions", tags=["actions"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{asset_id}/capabilities",
|
||||
summary="查询该资产可执行的动作(前端据此渲染/禁用按钮)",
|
||||
)
|
||||
def capabilities(asset_id: int, session: Session = Depends(get_session)) -> dict:
|
||||
return action_service.asset_actions(session, asset_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{asset_id}/instance",
|
||||
summary="实例动作:开机 / 关机 / 重启 / 续费(真实操作云端资源)",
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
def instance_action(
|
||||
asset_id: int, data: InstanceActionRequest, session: Session = Depends(get_session)
|
||||
) -> dict:
|
||||
return action_service.perform_instance_action(
|
||||
session, asset_id, data.action, data.period_months
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{asset_id}/dns",
|
||||
summary="列出该域名的 DNS 解析记录",
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
def list_dns(asset_id: int, session: Session = Depends(get_session)) -> dict:
|
||||
return action_service.list_dns_records(session, asset_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{asset_id}/dns",
|
||||
summary="新增 DNS 解析记录",
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
def create_dns(
|
||||
asset_id: int, record: DNSRecordIn, session: Session = Depends(get_session)
|
||||
) -> dict:
|
||||
return action_service.create_dns_record(session, asset_id, record.model_dump())
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{asset_id}/dns/{record_id}",
|
||||
summary="修改 DNS 解析记录",
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
def update_dns(
|
||||
asset_id: int,
|
||||
record_id: str,
|
||||
record: DNSRecordIn,
|
||||
session: Session = Depends(get_session),
|
||||
) -> dict:
|
||||
return action_service.update_dns_record(
|
||||
session, asset_id, record_id, record.model_dump()
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{asset_id}/dns/{record_id}",
|
||||
summary="删除 DNS 解析记录",
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
def delete_dns(
|
||||
asset_id: int, record_id: str, session: Session = Depends(get_session)
|
||||
) -> dict:
|
||||
return action_service.delete_dns_record(session, asset_id, record_id)
|
||||
@@ -17,3 +17,12 @@ router = APIRouter(prefix="/api/sync", tags=["sync"])
|
||||
)
|
||||
def sync_ai_balances(session: Session = Depends(get_session)) -> dict:
|
||||
return sync_service.sync_all_ai_balances(session)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/assets",
|
||||
summary="批量同步所有已配凭证账号的云端资产(VPS / 域名)",
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
def sync_assets(session: Session = Depends(get_session)) -> dict:
|
||||
return sync_service.sync_all_assets(session)
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""云资源管理动作相关 Schema
|
||||
|
||||
动作请求体单独建模而非直接透传 dict:period_months 必须有上界,
|
||||
否则一次误传 9999 就会真实扣掉一笔巨额续费账单。
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
#: 允许的实例动作
|
||||
VALID_ACTIONS = ("start", "stop", "reboot", "renew")
|
||||
|
||||
#: 允许的 DNS 记录类型(各厂商交集,避免写入后被 API 拒绝)
|
||||
VALID_RECORD_TYPES = (
|
||||
"A", "AAAA", "CNAME", "MX", "TXT", "NS", "SRV", "CAA", "LOC", "URL",
|
||||
)
|
||||
|
||||
|
||||
class InstanceActionRequest(SQLModel):
|
||||
"""实例动作请求"""
|
||||
|
||||
action: str = Field(description="start/stop/reboot/renew")
|
||||
period_months: int = Field(default=1, ge=1, le=60, description="续费周期(月),仅 renew 用")
|
||||
|
||||
@field_validator("action")
|
||||
@classmethod
|
||||
def _check_action(cls, v: str) -> str:
|
||||
if v not in VALID_ACTIONS:
|
||||
raise ValueError(f"action 必须是 {'/'.join(VALID_ACTIONS)} 之一,收到:{v}")
|
||||
return v
|
||||
|
||||
|
||||
class DNSRecordIn(SQLModel):
|
||||
"""DNS 记录输入(create/update 共用)
|
||||
|
||||
name 传完整域名(如 www.example.com)或子域(如 www)均可,
|
||||
各适配器按自身约定拆分;content 即记录值。
|
||||
"""
|
||||
|
||||
name: str = Field(min_length=1, description="记录名,完整域名或子域")
|
||||
type: str = Field(default="A", description="记录类型")
|
||||
content: str = Field(min_length=1, description="记录值")
|
||||
ttl: Optional[int] = Field(default=None, ge=60, le=86400, description="TTL 秒,留空用厂商默认")
|
||||
proxied: Optional[bool] = Field(default=None, description="Cloudflare 专用:是否走代理")
|
||||
priority: Optional[int] = Field(default=None, ge=0, le=65535, description="MX/SRV 优先级")
|
||||
comment: Optional[str] = Field(default=None, max_length=255, description="备注")
|
||||
|
||||
@field_validator("type")
|
||||
@classmethod
|
||||
def _check_type(cls, v: str) -> str:
|
||||
upper = (v or "").upper()
|
||||
if upper not in VALID_RECORD_TYPES:
|
||||
raise ValueError(f"不支持的记录类型:{v}(可选 {'/'.join(VALID_RECORD_TYPES)})")
|
||||
return upper
|
||||
@@ -186,6 +186,10 @@ class AssetRead(AssetBase):
|
||||
days_to_expiry: Optional[int] = None
|
||||
provider_name: Optional[str] = None
|
||||
account_name: Optional[str] = None # 账号标识(展示用,由 service 按 account_id 填充)
|
||||
# 云端实例 ID:只出不进(AssetCreate/AssetUpdate 不收,由 sync_service 写入)。
|
||||
# 前端据此判断「云操作」「DNS」按钮是否渲染——没有它按钮永远出不来,
|
||||
# 手工登记的资产则会渲染出点了必 400 的按钮。
|
||||
external_id: Optional[str] = None
|
||||
vps_detail: Optional[VPSDetailRead] = None
|
||||
domain_detail: Optional[DomainDetailRead] = None
|
||||
ai_detail: Optional[AIAccountRead] = None
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
"""云资源管理动作服务
|
||||
|
||||
把「资产 → 账号 → 平台适配器」解析成一次真实的云端写操作。
|
||||
|
||||
与只读同步的关键区别:这里的每个动作都会改变云端真实资源——关机会中断线上服务、
|
||||
续费会真实扣款。因此三条硬约束:
|
||||
1. 路由层强制 require_api_key(外部流量无 key 一律 401)
|
||||
2. 每次动作都写 EventLog(source="action"),成功与失败都记,且用独立 Session 提交,
|
||||
避免主流程回滚时把审计记录一起丢掉
|
||||
3. 适配器不支持该动作时如实抛 400,绝不静默 no-op 让调用方以为成功了
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.adapters import registry
|
||||
from app.adapters.base import BaseAdapter, cloud_error_text
|
||||
from app.core.timeutils import utcnow
|
||||
from app.models.asset import Asset, AssetType, DomainDetail
|
||||
from app.services.sync_service import _build_adapter_for_account, _missing_config
|
||||
|
||||
logger = logging.getLogger("vps-manager.action")
|
||||
|
||||
#: 动作名 -> 适配器方法名
|
||||
INSTANCE_ACTIONS = {
|
||||
"start": "start_instance",
|
||||
"stop": "stop_instance",
|
||||
"reboot": "reboot_instance",
|
||||
"renew": "renew_instance",
|
||||
}
|
||||
|
||||
#: 各动作的中文标签,用于审计日志与前端提示
|
||||
ACTION_LABELS = {
|
||||
"start": "开机",
|
||||
"stop": "关机",
|
||||
"reboot": "重启",
|
||||
"renew": "续费",
|
||||
}
|
||||
|
||||
|
||||
def audit(asset_id: int | None, message: str, level: str = "info") -> None:
|
||||
"""写审计日志(独立 Session,主流程回滚也不丢)
|
||||
|
||||
审计失败绝不能让业务动作失败——云端指令可能已经下发成功,
|
||||
此时抛错会让调用方误判为「没执行」,比丢一条日志危险得多。
|
||||
"""
|
||||
from app.database import metrics_engine
|
||||
from app.models.monitor import EventLog
|
||||
|
||||
try:
|
||||
with Session(metrics_engine) as s:
|
||||
s.add(EventLog(level=level, source="action", asset_id=asset_id, message=message))
|
||||
s.commit()
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("审计日志写入失败 asset_id=%s err=%s", asset_id, e)
|
||||
|
||||
|
||||
def _resolve(session: Session, asset_id: int) -> tuple[Asset, BaseAdapter, str]:
|
||||
"""解析资产对应的适配器,返回 (asset, adapter, provider_label)"""
|
||||
asset = session.get(Asset, asset_id)
|
||||
if not asset:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="资产不存在")
|
||||
if not asset.account_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="该资产未关联账号,无法确定使用哪套云凭证执行动作",
|
||||
)
|
||||
from app.models.asset import Account
|
||||
|
||||
account = session.get(Account, asset.account_id)
|
||||
if not account:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="资产关联的账号已不存在"
|
||||
)
|
||||
provider, adapter = _build_adapter_for_account(session, account)
|
||||
missing = _missing_config(adapter)
|
||||
if missing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"缺少凭证配置:{', '.join(missing)}(请先在账号编辑里填写 API 配置)",
|
||||
)
|
||||
return asset, adapter, provider.slug
|
||||
|
||||
|
||||
def _require_capability(adapter: BaseAdapter, method_name: str, label: str) -> None:
|
||||
if not adapter.capabilities().get(method_name):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"{type(adapter).__name__} 不支持{label}",
|
||||
)
|
||||
|
||||
|
||||
def perform_instance_action(
|
||||
session: Session, asset_id: int, action: str, period_months: int = 1
|
||||
) -> dict:
|
||||
"""对 VPS 资产执行 开机/关机/重启/续费"""
|
||||
if action not in INSTANCE_ACTIONS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"未知动作:{action}(可选 {'/'.join(INSTANCE_ACTIONS)})",
|
||||
)
|
||||
asset, adapter, provider_slug = _resolve(session, asset_id)
|
||||
if asset.asset_type != AssetType.VPS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"仅 VPS 资产支持该动作(当前类型:{asset.asset_type.value})",
|
||||
)
|
||||
if not asset.external_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="该资产没有云端实例 ID(external_id),可能是手工登记的,无法下发指令",
|
||||
)
|
||||
|
||||
method_name = INSTANCE_ACTIONS[action]
|
||||
label = ACTION_LABELS[action]
|
||||
_require_capability(adapter, method_name, label)
|
||||
|
||||
target = f"{provider_slug}/{asset.name}({asset.external_id})"
|
||||
audit(asset_id, f"{label}动作下发:{target}" + (f" 周期={period_months}月" if action == "renew" else ""))
|
||||
try:
|
||||
method = getattr(adapter, method_name)
|
||||
result = method(asset.external_id, period_months) if action == "renew" else method(asset.external_id)
|
||||
except NotImplementedError:
|
||||
audit(asset_id, f"{label}失败:适配器未实现 {target}", level="warning")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail=f"{provider_slug} 暂不支持{label}"
|
||||
) from None
|
||||
except Exception as e: # noqa: BLE001
|
||||
msg = cloud_error_text(e)
|
||||
audit(asset_id, f"{label}异常:{target} err={msg}", level="error")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY, detail=f"{label}失败:{msg}"
|
||||
) from e
|
||||
|
||||
ok = bool(result.get("ok"))
|
||||
audit(
|
||||
asset_id,
|
||||
f"{label}{'成功' if ok else '被拒'}:{target} msg={result.get('message')}",
|
||||
level="info" if ok else "error",
|
||||
)
|
||||
return {
|
||||
"ok": ok,
|
||||
"action": action,
|
||||
"asset_id": asset_id,
|
||||
"provider": provider_slug,
|
||||
"external_id": asset.external_id,
|
||||
"message": result.get("message", ""),
|
||||
"at": utcnow().isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def _resolve_zone(session: Session, asset_id: int) -> tuple[Asset, BaseAdapter, str, str]:
|
||||
"""解析域名资产的 DNS zone 标识,返回 (asset, adapter, provider_slug, zone_id)
|
||||
|
||||
zone_id 取 Asset.external_id:Cloudflare 是 zone uuid,阿里云/腾讯云/Vultr/DO 是域名字符串,
|
||||
两者的 list_dns_records 都已按各自约定实现。
|
||||
"""
|
||||
asset, adapter, provider_slug = _resolve(session, asset_id)
|
||||
if asset.asset_type != AssetType.DOMAIN:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"仅域名资产支持 DNS 记录管理(当前类型:{asset.asset_type.value})",
|
||||
)
|
||||
zone_id = asset.external_id
|
||||
if not zone_id:
|
||||
detail = session.exec(
|
||||
select(DomainDetail).where(DomainDetail.asset_id == asset_id)
|
||||
).first()
|
||||
zone_id = (detail.domain_name if detail else None) or asset.name
|
||||
if not zone_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="无法确定该域名的 zone 标识(external_id 与 domain_name 均为空)",
|
||||
)
|
||||
return asset, adapter, provider_slug, zone_id
|
||||
|
||||
|
||||
def list_dns_records(session: Session, asset_id: int) -> dict:
|
||||
asset, adapter, provider_slug, zone_id = _resolve_zone(session, asset_id)
|
||||
_require_capability(adapter, "list_dns_records", "DNS 记录查询")
|
||||
try:
|
||||
records = [r.to_dict() for r in adapter.list_dns_records(zone_id)]
|
||||
except NotImplementedError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail=f"{provider_slug} 暂不支持 DNS 记录查询"
|
||||
) from None
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY, detail=f"查询 DNS 记录失败:{cloud_error_text(e)}"
|
||||
) from e
|
||||
return {"asset_id": asset_id, "provider": provider_slug, "zone_id": zone_id, "records": records}
|
||||
|
||||
|
||||
def _dns_write(
|
||||
session: Session,
|
||||
asset_id: int,
|
||||
method_name: str,
|
||||
label: str,
|
||||
args: tuple,
|
||||
) -> dict:
|
||||
asset, adapter, provider_slug, zone_id = _resolve_zone(session, asset_id)
|
||||
_require_capability(adapter, method_name, label)
|
||||
target = f"{provider_slug}/{zone_id}"
|
||||
audit(asset_id, f"DNS {label}下发:{target} args={args[1:]}")
|
||||
try:
|
||||
result = getattr(adapter, method_name)(*args)
|
||||
except NotImplementedError:
|
||||
audit(asset_id, f"DNS {label}失败:适配器未实现 {target}", level="warning")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail=f"{provider_slug} 暂不支持{label}"
|
||||
) from None
|
||||
except Exception as e: # noqa: BLE001
|
||||
msg = cloud_error_text(e)
|
||||
audit(asset_id, f"DNS {label}异常:{target} err={msg}", level="error")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY, detail=f"{label}失败:{msg}"
|
||||
) from e
|
||||
ok = bool(result.get("ok"))
|
||||
audit(
|
||||
asset_id,
|
||||
f"DNS {label}{'成功' if ok else '被拒'}:{target} msg={result.get('message')}",
|
||||
level="info" if ok else "error",
|
||||
)
|
||||
return {
|
||||
"ok": ok,
|
||||
"asset_id": asset_id,
|
||||
"provider": provider_slug,
|
||||
"zone_id": zone_id,
|
||||
"record_id": result.get("record_id"),
|
||||
"message": result.get("message", ""),
|
||||
}
|
||||
|
||||
|
||||
def create_dns_record(session: Session, asset_id: int, record: dict) -> dict:
|
||||
_, _, _, zone_id = _resolve_zone(session, asset_id)
|
||||
return _dns_write(session, asset_id, "create_dns_record", "记录创建", (zone_id, record))
|
||||
|
||||
|
||||
def update_dns_record(session: Session, asset_id: int, record_id: str, record: dict) -> dict:
|
||||
_, _, _, zone_id = _resolve_zone(session, asset_id)
|
||||
return _dns_write(
|
||||
session, asset_id, "update_dns_record", "记录更新", (zone_id, record_id, record)
|
||||
)
|
||||
|
||||
|
||||
def delete_dns_record(session: Session, asset_id: int, record_id: str) -> dict:
|
||||
_, _, _, zone_id = _resolve_zone(session, asset_id)
|
||||
return _dns_write(
|
||||
session, asset_id, "delete_dns_record", "记录删除", (zone_id, record_id)
|
||||
)
|
||||
|
||||
|
||||
def asset_actions(session: Session, asset_id: int) -> dict:
|
||||
"""返回某资产当前可执行的动作(前端据此渲染/禁用按钮)
|
||||
|
||||
手工登记、无 external_id、或适配器不支持的,一律标 False,
|
||||
避免用户点了才收到 400。
|
||||
"""
|
||||
try:
|
||||
asset, adapter, provider_slug = _resolve(session, asset_id)
|
||||
except HTTPException as e:
|
||||
return {"asset_id": asset_id, "available": False, "reason": e.detail, "actions": {}}
|
||||
|
||||
caps = adapter.capabilities()
|
||||
is_vps = asset.asset_type == AssetType.VPS
|
||||
is_domain = asset.asset_type == AssetType.DOMAIN
|
||||
has_external = bool(asset.external_id)
|
||||
actions = {
|
||||
name: bool(is_vps and has_external and caps.get(method))
|
||||
for name, method in INSTANCE_ACTIONS.items()
|
||||
}
|
||||
actions["dns_records"] = bool(is_domain and caps.get("list_dns_records"))
|
||||
actions["dns_write"] = bool(is_domain and caps.get("create_dns_record"))
|
||||
return {
|
||||
"asset_id": asset_id,
|
||||
"provider": provider_slug,
|
||||
"sdk_type": getattr(type(adapter), "sdk_types", []),
|
||||
"available": any(actions.values()),
|
||||
"actions": actions,
|
||||
}
|
||||
@@ -377,11 +377,15 @@ def import_assets(session: Session, assets_data: list) -> dict:
|
||||
created = 0
|
||||
updated = 0
|
||||
errors = []
|
||||
skip_fields = {"id", "created_at", "updated_at", "days_to_expiry", "provider_name"}
|
||||
skip_fields = {"id", "created_at", "updated_at", "days_to_expiry", "provider_name", "account_name"}
|
||||
try:
|
||||
for item in assets_data:
|
||||
try:
|
||||
payload = {k: v for k, v in item.items() if k not in skip_fields}
|
||||
# external_id 是 sync_service 的写入字段,AssetCreate/AssetUpdate 不收,
|
||||
# 留在 payload 里会被 pydantic 静默丢弃;导出既然带了它(迁移用),
|
||||
# 这里就必须落到行上,否则迁移后首次同步会把所有云端资产当新增再建一份。
|
||||
external_id = payload.pop("external_id", None)
|
||||
existing = session.exec(
|
||||
select(Asset).where(
|
||||
Asset.name == payload.get("name"),
|
||||
@@ -390,11 +394,14 @@ def import_assets(session: Session, assets_data: list) -> dict:
|
||||
)
|
||||
).first()
|
||||
if existing:
|
||||
_update_asset_no_commit(session, existing.id, AssetUpdate(**payload))
|
||||
asset = _update_asset_no_commit(session, existing.id, AssetUpdate(**payload))
|
||||
updated += 1
|
||||
else:
|
||||
_create_asset_no_commit(session, AssetCreate(**payload))
|
||||
asset = _create_asset_no_commit(session, AssetCreate(**payload))
|
||||
created += 1
|
||||
if external_id and not asset.external_id:
|
||||
asset.external_id = external_id
|
||||
session.add(asset)
|
||||
except Exception as e: # noqa: BLE001
|
||||
errors.append(f"{item.get('name', '?')}: {e}")
|
||||
if errors:
|
||||
@@ -462,6 +469,9 @@ def get_overview(session: Session) -> dict:
|
||||
overdue = 0
|
||||
year_cost = 0.0
|
||||
month_cost = 0.0
|
||||
# 同窗口内没填费用的资产数。合计只加得到填了的那部分,缺一项就少算一项,
|
||||
# 界面必须把这个缺口说出来,否则「本年支出」看着像个准数、实际是下限。
|
||||
year_cost_missing = 0
|
||||
|
||||
for asset_type, status_val, expiry_date, cost in rows:
|
||||
tval = asset_type.value if hasattr(asset_type, "value") else asset_type
|
||||
@@ -480,7 +490,10 @@ def get_overview(session: Session) -> dict:
|
||||
elif days <= 30:
|
||||
expiring_30 += 1
|
||||
if expiry_date.year == today.year:
|
||||
year_cost += cost
|
||||
if cost:
|
||||
year_cost += cost
|
||||
else:
|
||||
year_cost_missing += 1
|
||||
if expiry_date.month == today.month:
|
||||
month_cost += cost
|
||||
|
||||
@@ -493,6 +506,7 @@ def get_overview(session: Session) -> dict:
|
||||
"overdue_count": overdue,
|
||||
"year_cost": round(year_cost, 2),
|
||||
"month_cost": round(month_cost, 2),
|
||||
"year_cost_missing": year_cost_missing,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ from sqlmodel import Session, select
|
||||
logger = logging.getLogger("vps-manager.sync")
|
||||
|
||||
from app.adapters import registry
|
||||
from app.adapters.base import BaseAdapter, parse_iso_date
|
||||
from app.adapters.base import BaseAdapter, cloud_error_text, parse_iso_date
|
||||
from app.core import crypto
|
||||
from app.core.timeutils import utcnow
|
||||
from app.models.asset import (
|
||||
@@ -161,6 +161,9 @@ def _find_asset(session: Session, provider_id: int, external_id: str, asset_type
|
||||
def _sync_vps(session: Session, provider: Provider, adapter: BaseAdapter, account_id: Optional[int] = None) -> dict:
|
||||
created = updated = 0
|
||||
for vps in adapter.list_vps():
|
||||
# 预付费实例(阿里云/腾讯云包年包月)带到期日,后付费平台为 None。
|
||||
# 漏了这一步,云端同步进来的 VPS 永远进不了续费提醒——而那是台账的核心用途。
|
||||
expiry = parse_iso_date(vps.expiry_date)
|
||||
existing = _find_asset(session, provider.id, vps.external_id, AssetType.VPS)
|
||||
if existing:
|
||||
existing.name = vps.name or existing.name
|
||||
@@ -168,6 +171,8 @@ def _sync_vps(session: Session, provider: Provider, adapter: BaseAdapter, accoun
|
||||
if vps.monthly_cost is not None:
|
||||
existing.cost = vps.monthly_cost
|
||||
existing.currency = vps.currency
|
||||
if expiry:
|
||||
existing.expiry_date = expiry
|
||||
if account_id:
|
||||
existing.account_id = account_id
|
||||
session.add(existing)
|
||||
@@ -194,6 +199,7 @@ def _sync_vps(session: Session, provider: Provider, adapter: BaseAdapter, accoun
|
||||
status=_norm_status(vps.status),
|
||||
cost=vps.monthly_cost or 0,
|
||||
currency=vps.currency,
|
||||
expiry_date=expiry,
|
||||
)
|
||||
session.add(asset)
|
||||
session.flush() # 获取 asset.id,统一在循环外提交
|
||||
@@ -292,18 +298,18 @@ def sync_account(session: Session, account_id: int) -> dict:
|
||||
# 写库失败会让 Session 进入 pending-rollback 状态,必须先回滚才能继续,
|
||||
# 否则下面更新 last_synced_at 的 commit 会抛 PendingRollbackError → 接口 500
|
||||
session.rollback()
|
||||
result["vps_error"] = str(e)
|
||||
result["vps_error"] = cloud_error_text(e)
|
||||
if caps["list_domains"]:
|
||||
try:
|
||||
result["domains"] = _sync_domains(session, provider, adapter, account.id)
|
||||
except Exception as e: # noqa: BLE001
|
||||
session.rollback()
|
||||
result["domains_error"] = str(e)
|
||||
result["domains_error"] = cloud_error_text(e)
|
||||
if caps["get_account"]:
|
||||
try:
|
||||
result["account_info"] = adapter.get_account().to_dict()
|
||||
except Exception as e: # noqa: BLE001
|
||||
result["account_info_error"] = str(e)
|
||||
result["account_info_error"] = cloud_error_text(e)
|
||||
|
||||
account.last_synced_at = utcnow()
|
||||
session.add(account)
|
||||
@@ -380,7 +386,7 @@ def refresh_ai_balance(session: Session, asset_id: int) -> dict:
|
||||
raise
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY, detail=f"查询余额失败:{e}"
|
||||
status_code=status.HTTP_502_BAD_GATEWAY, detail=f"查询余额失败:{cloud_error_text(e)}"
|
||||
) from e
|
||||
|
||||
if acc.balance is not None:
|
||||
@@ -436,7 +442,7 @@ def sync_all_ai_balances(session: Session, max_workers: int = 5) -> dict:
|
||||
errors.append(f"{name}: {e.detail}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
failed += 1
|
||||
errors.append(f"{name}: {e}")
|
||||
errors.append(f"{name}: {cloud_error_text(e)}")
|
||||
return {
|
||||
"total": len(asset_ids),
|
||||
"success": success,
|
||||
@@ -445,3 +451,86 @@ def sync_all_ai_balances(session: Session, max_workers: int = 5) -> dict:
|
||||
"errors": errors,
|
||||
"synced_at": utcnow().isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def sync_all_assets(session: Session, max_workers: int = 5) -> dict:
|
||||
"""并发同步所有已配凭证账号的云端资产(VPS / 域名)
|
||||
|
||||
与 sync_all_ai_balances 同构:单个账号失败不中断整体,每个 worker 用独立
|
||||
Session(SQLite Session 不能跨线程共享,WAL 模式下多连接读写安全)。
|
||||
|
||||
未配凭证或平台无适配器的账号计入 skipped 而非 failed——否则每次定时同步
|
||||
都会把手工登记的账号报成一堆错误。
|
||||
"""
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from app.database import assets_engine
|
||||
|
||||
accounts = session.exec(select(Account)).all()
|
||||
targets = []
|
||||
skipped_no_config = []
|
||||
skipped_no_adapter = []
|
||||
for acc in accounts:
|
||||
label = f"{acc.platform}/{acc.name}"
|
||||
if not acc.api_config_encrypted:
|
||||
skipped_no_config.append(label)
|
||||
continue
|
||||
try:
|
||||
provider = _find_provider_by_platform(session, acc.platform)
|
||||
except HTTPException:
|
||||
skipped_no_adapter.append(f"{label}(平台不存在)")
|
||||
continue
|
||||
if not provider.sdk_type or not registry.is_supported(provider.sdk_type):
|
||||
skipped_no_adapter.append(f"{label}({provider.sdk_type or '未配 sdk_type'})")
|
||||
continue
|
||||
targets.append((acc.id, label))
|
||||
|
||||
def _sync_one(account_id: int) -> dict:
|
||||
with Session(assets_engine) as worker_session:
|
||||
return sync_account(worker_session, account_id)
|
||||
|
||||
success = 0
|
||||
failed = 0
|
||||
errors = []
|
||||
created = updated = 0
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as pool:
|
||||
futures = {pool.submit(_sync_one, aid): label for aid, label in targets}
|
||||
for future in futures:
|
||||
label = futures[future]
|
||||
try:
|
||||
result = future.result()
|
||||
created += (result.get("vps", {}) or {}).get("created", 0)
|
||||
created += (result.get("domains", {}) or {}).get("created", 0)
|
||||
updated += (result.get("vps", {}) or {}).get("updated", 0)
|
||||
updated += (result.get("domains", {}) or {}).get("updated", 0)
|
||||
# 局部失败(某一类资产同步抛错)不整体算失败,但必须上报
|
||||
sub_error_keys = [
|
||||
k for k in ("vps_error", "domains_error", "account_info_error") if result.get(k)
|
||||
]
|
||||
for key in sub_error_keys:
|
||||
errors.append(f"{label} {key}: {result[key]}")
|
||||
# 但「每一项尝试都失败」不能算成功:凭证过期时三类全 401,
|
||||
# 若仍计成功,汇总行会显示「成功 1」,用户以为拉到了数据、实际一条没有。
|
||||
attempted_any = any(result.get(k) is not None for k in ("vps", "domains", "account_info"))
|
||||
if sub_error_keys and not attempted_any:
|
||||
failed += 1
|
||||
else:
|
||||
success += 1
|
||||
except HTTPException as e:
|
||||
failed += 1
|
||||
errors.append(f"{label}: {e.detail}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
failed += 1
|
||||
errors.append(f"{label}: {cloud_error_text(e)}")
|
||||
|
||||
return {
|
||||
"total": len(targets),
|
||||
"success": success,
|
||||
"failed": failed,
|
||||
"created": created,
|
||||
"updated": updated,
|
||||
"skipped_no_config": len(skipped_no_config),
|
||||
"skipped_no_adapter": len(skipped_no_adapter),
|
||||
"errors": errors,
|
||||
"synced_at": utcnow().isoformat(),
|
||||
}
|
||||
|
||||
+3
-1
@@ -80,13 +80,15 @@ cp deploy/vps-manager-update.service deploy/vps-manager-update.timer /etc/system
|
||||
cp deploy/vps-backup-assets.service deploy/vps-backup-assets.timer /etc/systemd/system/
|
||||
cp deploy/vps-backup-metrics.service deploy/vps-backup-metrics.timer /etc/systemd/system/
|
||||
cp deploy/vps-sync-ai.service deploy/vps-sync-ai.timer /etc/systemd/system/
|
||||
cp deploy/vps-sync-assets.service deploy/vps-sync-assets.timer /etc/systemd/system/
|
||||
cp deploy/vps-renewal-check.service deploy/vps-renewal-check.timer /etc/systemd/system/
|
||||
cp deploy/vps-cert-check.service deploy/vps-cert-check.timer /etc/systemd/system/
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now vps-manager
|
||||
systemctl enable --now vps-manager-update.timer
|
||||
systemctl enable --now vps-backup-assets.timer vps-backup-metrics.timer
|
||||
systemctl enable --now vps-sync-ai.timer vps-renewal-check.timer vps-cert-check.timer
|
||||
# vps-sync-assets 必须 enable:它 08:30 跑,早于 09:00 的续费检查,漏了会让提醒读到旧到期日
|
||||
systemctl enable --now vps-sync-ai.timer vps-sync-assets.timer vps-renewal-check.timer vps-cert-check.timer
|
||||
|
||||
echo "==> [6/6] 验证服务状态"
|
||||
sleep 3
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# 云资产同步服务(oneshot,由 timer 触发)
|
||||
# 部署位置:/etc/systemd/system/vps-sync-assets.service
|
||||
|
||||
[Unit]
|
||||
Description=Sync all cloud assets (VPS / domains) from configured accounts
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
WorkingDirectory=/opt/vps-manager
|
||||
EnvironmentFile=-/opt/vps-manager/.env
|
||||
ExecStart=/opt/vps-manager/.venv/bin/python /opt/vps-manager/scripts/sync_all_assets.py
|
||||
@@ -0,0 +1,16 @@
|
||||
# 云资产每日同步定时器
|
||||
# 部署位置:/etc/systemd/system/vps-sync-assets.timer
|
||||
#
|
||||
# 08:30 而非 daily 随机时刻:必须早于 vps-renewal-check(09:00),
|
||||
# 否则续费提醒会读到上一天的旧到期日。
|
||||
|
||||
[Unit]
|
||||
Description=Daily sync of cloud assets (VPS / domains)
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 08:30:00
|
||||
Persistent=true
|
||||
Unit=vps-sync-assets.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -0,0 +1,38 @@
|
||||
"""定时同步所有已配凭证账号的云端资产(供 systemd timer 调用)
|
||||
|
||||
用法:python scripts/sync_all_assets.py
|
||||
依赖 .env 中的 MASTER_KEY(解密各账号 API 配置)。
|
||||
|
||||
排程须早于 vps-renewal-check(09:00):续费提醒读的是本地库的 expiry_date,
|
||||
若同步在其之后跑,提醒就会用上一天的旧到期日。
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 确保能导入 app 包(脚本位于 scripts/ 子目录)
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.database import assets_engine, init_db
|
||||
from app.services import sync_service
|
||||
|
||||
|
||||
def main() -> int:
|
||||
init_db()
|
||||
with Session(assets_engine) as session:
|
||||
result = sync_service.sync_all_assets(session)
|
||||
print(
|
||||
f"[云资产同步] 账号 {result['total']} | 成功 {result['success']} | 失败 {result['failed']} "
|
||||
f"| 新增 {result['created']} | 更新 {result['updated']} "
|
||||
f"| 跳过(无凭证) {result['skipped_no_config']} | 跳过(无适配器) {result['skipped_no_adapter']}"
|
||||
)
|
||||
for err in result["errors"]:
|
||||
print(f" - {err}")
|
||||
# 有账号同步失败时返回非 0,让 systemd 记为 failed 便于告警
|
||||
return 1 if result["failed"] else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -134,6 +134,15 @@ window.VpsFmt = {
|
||||
if (d === 0) return '今天到期';
|
||||
return d + (opts.unit === '' ? ' 天' : ' 天后');
|
||||
},
|
||||
/* 费用文案:cost 为 0 一律显示「未填」,不显示「0 USD」
|
||||
0 有两种来源——用户没填、或云端接口压根不返回价格(阿里云/腾讯云/AWS 的
|
||||
DescribeInstances 都没有价格字段)。显示成「0 USD」会被读成「这台不花钱」,
|
||||
而汇总处(订阅页筛选、月度合计)又把 0 当作「无费用」排除掉,两处口径不一致。
|
||||
统一走这里,让「没数据」和「真的免费」在界面上不再混为一谈。 */
|
||||
costText(cost, currency) {
|
||||
if (!cost) return '未填';
|
||||
return currency ? cost + ' ' + currency : String(cost);
|
||||
},
|
||||
checkBadge(s) {
|
||||
return { pass: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', warn: 'bg-amber-500/10 text-amber-600 dark:text-amber-400', fail: 'bg-red-500/10 text-red-600 dark:text-red-400', unknown: 'bg-slate-500/10 text-slate-500' }[s] || 'bg-slate-500/10 text-slate-500';
|
||||
},
|
||||
|
||||
@@ -87,6 +87,8 @@ const AppRoot = {
|
||||
<account-modal></account-modal>
|
||||
<accounts-view-modal></accounts-view-modal>
|
||||
<credential-modal></credential-modal>
|
||||
<action-modal></action-modal>
|
||||
<dns-modal></dns-modal>
|
||||
|
||||
<!-- 复制等操作的轻提示(底部导航上方,不遮内容) -->
|
||||
<div v-if="toast.show" class="fixed bottom-20 md:bottom-6 left-1/2 -translate-x-1/2 z-[80] px-4 py-2 rounded-full bg-slate-900/90 dark:bg-slate-100/90 text-white dark:text-slate-900 text-xs shadow-lg pointer-events-none whitespace-nowrap">{{ toast.text }}</div>
|
||||
@@ -136,6 +138,8 @@ app.component('provider-modal', ProviderModal);
|
||||
app.component('account-modal', AccountModal);
|
||||
app.component('accounts-view-modal', AccountsViewModal);
|
||||
app.component('credential-modal', CredentialModal);
|
||||
app.component('action-modal', ActionModal);
|
||||
app.component('dns-modal', DnsModal);
|
||||
applyDark();
|
||||
initRouter();
|
||||
loadAll();
|
||||
|
||||
@@ -594,3 +594,206 @@ const CredentialModal = {
|
||||
return { store, Fmt, f, oauthGroups, oauthUnmatched, pickOauth, dropOauth: dropOauthProvider, save };
|
||||
},
|
||||
};
|
||||
|
||||
/* ================= 云资源操作弹窗(启停/重启/续费) =================
|
||||
* 每个按钮都会真实改变云端资源,所以:
|
||||
* 1. 可用性完全由后端 /actions/{id}/capabilities 决定,前端不自作主张放开
|
||||
* 2. 点击后先出二次确认条,写清后果(续费会扣款、关机会中断服务)
|
||||
* 3. 不支持时如实说明原因,而不是给一个点了才报错的按钮 */
|
||||
const ActionModal = {
|
||||
template: `
|
||||
<div v-if="m.show" class="fixed inset-0 bg-black/40 z-50 flex items-end md:items-center justify-center p-0 md:p-4">
|
||||
<div class="bg-white dark:bg-slate-900 w-full md:max-w-md md:rounded-xl rounded-t-xl max-h-[92vh] overflow-y-auto">
|
||||
<div class="px-5 py-3 border-b border-slate-100 dark:border-slate-800 flex justify-between items-center sticky top-0 bg-white dark:bg-slate-900">
|
||||
<h3 class="font-semibold text-sm">⚡ 云资源操作</h3>
|
||||
<button @click="close" class="text-slate-400 hover:text-slate-600">✕</button>
|
||||
</div>
|
||||
<div class="px-5 py-4 space-y-3">
|
||||
<div class="text-sm font-medium truncate">{{ m.asset && m.asset.name }}</div>
|
||||
<div v-if="m.asset" class="text-xs text-slate-400">
|
||||
{{ m.asset.provider }}<span v-if="m.asset.external_id"> · {{ m.asset.external_id }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="m.loading" class="text-xs text-slate-400 py-6 text-center">正在查询该平台支持的操作…</div>
|
||||
|
||||
<div v-else-if="m.error && !m.caps" class="text-xs px-3 py-2 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-900/50 text-red-700 dark:text-red-400">{{ m.error }}</div>
|
||||
|
||||
<!-- 平台不可用:如实说明,不给可点按钮 -->
|
||||
<div v-else-if="m.caps && !m.caps.available" class="text-xs px-3 py-2 rounded-lg bg-slate-50 dark:bg-slate-800/60 border border-slate-200 dark:border-slate-800 text-slate-600 dark:text-slate-300">
|
||||
<div class="font-medium mb-1">该平台暂不支持远程操作</div>
|
||||
<div>{{ m.caps.reason || '适配器未实现对应能力,请到云厂商控制台操作。' }}</div>
|
||||
</div>
|
||||
|
||||
<template v-else-if="m.caps">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button v-for="a in actions" :key="a.key"
|
||||
@click="ask(a.key)"
|
||||
:disabled="!m.caps.actions[a.key] || !!m.confirm"
|
||||
class="px-3 py-2.5 rounded-lg border text-sm transition disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
:class="a.cls">
|
||||
{{ a.icon }} {{ a.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 二次确认条 -->
|
||||
<div v-if="m.confirm" class="rounded-lg border border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-900/20 px-3 py-3 space-y-2">
|
||||
<div class="text-xs font-medium text-amber-800 dark:text-amber-300">确认{{ label(m.confirm) }}「{{ m.asset.name }}」?</div>
|
||||
<div class="text-xs text-amber-700 dark:text-amber-400">{{ warn(m.confirm) }}</div>
|
||||
<div v-if="m.confirm === 'renew'" class="flex items-center gap-2 text-xs text-amber-800 dark:text-amber-300">
|
||||
续费周期
|
||||
<select v-model.number="m.periodMonths" class="px-2 py-1 rounded border border-amber-300 dark:border-amber-700 bg-white dark:bg-slate-900 text-xs">
|
||||
<option v-for="n in [1,2,3,6,12,24,36]" :key="n" :value="n">{{ n }} 个月</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button @click="m.confirm=null" :disabled="m.busy" class="px-3 py-1.5 rounded-lg border border-slate-300 dark:border-slate-700 text-xs">取消</button>
|
||||
<button @click="run(m.confirm)" :disabled="m.busy" class="px-3 py-1.5 rounded-lg bg-amber-600 text-white text-xs disabled:opacity-50">
|
||||
{{ m.busy ? '下发中…' : '确认' + label(m.confirm) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="m.error" class="text-xs px-3 py-2 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-900/50 text-red-700 dark:text-red-400">{{ m.error }}</div>
|
||||
|
||||
<div class="text-[11px] text-slate-400 leading-relaxed">
|
||||
操作通过账号上配置的 API 凭证下发到云端,每次执行都会记入事件日志。
|
||||
平台能力:{{ capsText }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>`,
|
||||
setup() {
|
||||
const m = actionModal;
|
||||
const actions = [
|
||||
{ key: 'start', label: '开机', icon: '▶', cls: 'border-emerald-300 dark:border-emerald-800 text-emerald-700 dark:text-emerald-400 hover:bg-emerald-50 dark:hover:bg-emerald-900/20' },
|
||||
{ key: 'stop', label: '关机', icon: '⏻', cls: 'border-red-300 dark:border-red-800 text-red-700 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20' },
|
||||
{ key: 'reboot', label: '重启', icon: '⟳', cls: 'border-amber-300 dark:border-amber-800 text-amber-700 dark:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-900/20' },
|
||||
{ key: 'renew', label: '续费', icon: '💳', cls: 'border-blue-300 dark:border-blue-800 text-blue-700 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20' },
|
||||
];
|
||||
const capsText = Vue.computed(() => {
|
||||
if (!m.caps || !m.caps.sdk_type || !m.caps.sdk_type.length) return '未知';
|
||||
return m.caps.sdk_type.join(' / ');
|
||||
});
|
||||
return {
|
||||
m, actions, capsText,
|
||||
label: (k) => ACTION_LABELS[k] || k,
|
||||
warn: (k) => ACTION_WARNS[k] || '',
|
||||
ask: askAction, run: runAction, close: closeActions,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/* ================= DNS 解析记录管理弹窗 ================= */
|
||||
const DnsModal = {
|
||||
template: `
|
||||
<div v-if="m.show" class="fixed inset-0 bg-black/40 z-50 flex items-end md:items-center justify-center p-0 md:p-4">
|
||||
<div class="bg-white dark:bg-slate-900 w-full md:max-w-2xl md:rounded-xl rounded-t-xl max-h-[92vh] flex flex-col">
|
||||
<div class="px-5 py-3 border-b border-slate-100 dark:border-slate-800 flex justify-between items-center">
|
||||
<h3 class="font-semibold text-sm">🌐 DNS 解析<span v-if="m.zoneId" class="text-slate-400 font-normal"> · {{ m.zoneId }}</span></h3>
|
||||
<div class="flex items-center gap-3">
|
||||
<button v-if="!m.loading && !m.error" @click="newRecord" class="text-xs text-blue-600 dark:text-blue-400">+ 新增记录</button>
|
||||
<button @click="close" class="text-slate-400 hover:text-slate-600">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-5 py-4 overflow-y-auto space-y-3">
|
||||
<div v-if="m.loading" class="text-xs text-slate-400 py-6 text-center">正在读取解析记录…</div>
|
||||
|
||||
<div v-else-if="m.error && !m.records.length" class="text-xs px-3 py-2 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-900/50 text-red-700 dark:text-red-400">{{ m.error }}</div>
|
||||
|
||||
<template v-else>
|
||||
<p v-if="!m.records.length" class="text-xs text-slate-400 py-4 text-center">该域名下暂无解析记录。</p>
|
||||
|
||||
<!-- 记录列表:窄屏用卡片,宽屏用表格 -->
|
||||
<div v-else class="hidden md:block border border-slate-100 dark:border-slate-800 rounded-lg overflow-hidden">
|
||||
<table class="w-full text-xs">
|
||||
<thead class="bg-slate-50 dark:bg-slate-800/60 text-slate-500 dark:text-slate-400">
|
||||
<tr><th class="text-left px-3 py-2 font-medium">名称</th><th class="text-left px-3 py-2 font-medium">类型</th><th class="text-left px-3 py-2 font-medium">记录值</th><th class="text-left px-3 py-2 font-medium">TTL</th><th class="text-right px-3 py-2 font-medium">操作</th></tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100 dark:divide-slate-800">
|
||||
<tr v-for="r in m.records" :key="r.record_id">
|
||||
<td class="px-3 py-2 break-all">
|
||||
{{ r.name }}
|
||||
<span v-if="r.comment" class="block text-[10px] text-slate-400">{{ r.comment }}</span>
|
||||
</td>
|
||||
<td class="px-3 py-2"><span class="px-1.5 py-0.5 rounded bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-300">{{ r.type }}</span></td>
|
||||
<td class="px-3 py-2 break-all text-slate-600 dark:text-slate-300">{{ r.content }}<span v-if="r.proxied" class="ml-1 text-orange-500" title="已走 Cloudflare 代理">☁</span></td>
|
||||
<td class="px-3 py-2 text-slate-400 whitespace-nowrap">
|
||||
{{ r.ttl || '自动' }}
|
||||
<span v-if="hasPriority(r)" class="ml-1.5" title="MX/SRV 优先级,数字越小越优先">优 {{ r.priority }}</span>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-right whitespace-nowrap">
|
||||
<button @click="editRecord(r)" class="text-blue-600 dark:text-blue-400 mr-3">编辑</button>
|
||||
<button @click="del(r)" class="text-red-600 dark:text-red-400">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-if="m.records.length" class="md:hidden space-y-2">
|
||||
<div v-for="r in m.records" :key="r.record_id" class="border border-slate-100 dark:border-slate-800 rounded-lg px-3 py-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="px-1.5 py-0.5 rounded bg-slate-100 dark:bg-slate-800 text-[10px] text-slate-600 dark:text-slate-300">{{ r.type }}</span>
|
||||
<span class="text-xs truncate flex-1">{{ r.name }}</span>
|
||||
<span v-if="hasPriority(r)" class="text-[10px] text-slate-400">优 {{ r.priority }}</span>
|
||||
<span class="text-[10px] text-slate-400">TTL {{ r.ttl || '自动' }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-slate-600 dark:text-slate-300 break-all mt-1">{{ r.content }}<span v-if="r.proxied" class="ml-1 text-orange-500">☁</span></div>
|
||||
<div v-if="r.comment" class="text-[10px] text-slate-400 mt-0.5">{{ r.comment }}</div>
|
||||
<div class="flex gap-4 justify-end mt-1 text-xs">
|
||||
<button @click="editRecord(r)" class="text-blue-600 dark:text-blue-400">编辑</button>
|
||||
<button @click="del(r)" class="text-red-600 dark:text-red-400">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新增 / 编辑表单 -->
|
||||
<div v-if="m.editing" class="border border-blue-200 dark:border-blue-900 rounded-lg px-3 py-3 space-y-2 bg-blue-50/40 dark:bg-blue-900/10">
|
||||
<div class="text-xs font-medium text-blue-700 dark:text-blue-300">{{ m.editing === 'new' ? '新增记录' : '编辑记录' }}</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<label class="text-xs text-slate-500 col-span-2">名称
|
||||
<input v-model="m.form.name" placeholder="www.example.com 或 www" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-200">
|
||||
</label>
|
||||
<label class="text-xs text-slate-500">类型
|
||||
<select v-model="m.form.type" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-200">
|
||||
<option v-for="t in recordTypes" :key="t" :value="t">{{ t }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="text-xs text-slate-500">TTL(秒)
|
||||
<input v-model.number="m.form.ttl" type="number" min="60" max="86400" placeholder="厂商默认" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-200">
|
||||
</label>
|
||||
<label class="text-xs text-slate-500 col-span-2">记录值
|
||||
<input v-model="m.form.content" placeholder="1.2.3.4 / target.example.com" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-200">
|
||||
</label>
|
||||
<label v-if="m.form.type === 'MX' || m.form.type === 'SRV'" class="text-xs text-slate-500">优先级
|
||||
<input v-model.number="m.form.priority" type="number" min="0" max="65535" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-200">
|
||||
</label>
|
||||
<label v-if="isCloudflare" class="text-xs text-slate-500 flex items-center gap-2 pt-4">
|
||||
<input type="checkbox" v-model="m.form.proxied"> 走 Cloudflare 代理
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="m.error" class="text-xs px-2 py-1.5 rounded bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400">{{ m.error }}</div>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button @click="cancelEdit" :disabled="m.busy" class="px-3 py-1.5 rounded-lg border border-slate-300 dark:border-slate-700 text-xs">取消</button>
|
||||
<button @click="save" :disabled="m.busy" class="px-3 py-1.5 rounded-lg bg-blue-600 text-white text-xs disabled:opacity-50">{{ m.busy ? '保存中…' : '保存' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="m.error" class="text-xs px-3 py-2 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-900/50 text-red-700 dark:text-red-400">{{ m.error }}</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>`,
|
||||
setup() {
|
||||
const m = dnsModal;
|
||||
const isCloudflare = Vue.computed(() => m.provider === 'cloudflare' || m.provider === 'cloudflare-api');
|
||||
// 显式判 null:MX 优先级 0 是合法值且为 falsy,用 r.priority 直接当真值会漏显示
|
||||
const hasPriority = (r) => r.priority !== null && r.priority !== undefined && r.priority !== '';
|
||||
return {
|
||||
m, recordTypes: RECORD_TYPES, isCloudflare, hasPriority,
|
||||
newRecord: newDnsRecord, editRecord: editDnsRecord, cancelEdit: cancelDnsEdit,
|
||||
save: saveDnsRecord, del: deleteDnsRecord, close: closeDns,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
+178
-1
@@ -28,6 +28,10 @@ const store = reactive({
|
||||
assets: [], // 列表口径:按当前筛选条件(类型/状态/平台/搜索)的结果
|
||||
allAssets: [], // 统计口径:不带筛选的全量资产(总览/监控卡片/平台计数用)
|
||||
providers: [],
|
||||
// 适配器能力矩阵(/providers/adapters):{adapters, supported_types, capabilities}
|
||||
// 前端据此如实展示「这个平台到底能不能同步/能不能启停/能不能管 DNS」,
|
||||
// 而不是只看 Provider.sdk_type 有没有值就宣称支持
|
||||
adapters: { adapters: [], supported_types: [], capabilities: {} },
|
||||
accounts: [],
|
||||
subdomains: [],
|
||||
siteCerts: [],
|
||||
@@ -134,6 +138,7 @@ async function loadAssets() {
|
||||
store.assets = await Api.get('/assets?' + params.toString());
|
||||
}
|
||||
async function loadProviders() { store.providers = await Api.get('/providers'); }
|
||||
async function loadAdapters() { store.adapters = await Api.get('/providers/adapters'); }
|
||||
async function loadAccounts() { store.accounts = await Api.get('/accounts'); }
|
||||
async function loadCredentials() {
|
||||
const params = new URLSearchParams();
|
||||
@@ -147,7 +152,7 @@ async function loadSiteCerts() { store.siteCerts = await Api.get('/site-certs');
|
||||
async function loadOverview() { store.overview = await Api.get('/stats/overview'); }
|
||||
async function loadAll() {
|
||||
store.loading = true; store.error = '';
|
||||
try { await Promise.all([loadAssets(), loadProviders(), loadAccounts(), loadCredentials(), loadSubdomains(), loadSiteCerts(), loadOverview()]); }
|
||||
try { await Promise.all([loadAssets(), loadProviders(), loadAdapters(), loadAccounts(), loadCredentials(), loadSubdomains(), loadSiteCerts(), loadOverview()]); }
|
||||
catch (e) { store.error = e.message; } finally { store.loading = false; }
|
||||
}
|
||||
|
||||
@@ -523,6 +528,178 @@ async function viewServer(a) {
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 云端能力查询 ----------------
|
||||
* 能力矩阵来自后端 /providers/adapters(registry.sdk_capabilities()),
|
||||
* 是「适配器真的实现了这个方法」的事实源。前端一律以它为准,
|
||||
* 不再拿 Provider.sdk_type 有没有值当支持依据——填了不存在的 sdk_type
|
||||
* 会让按钮可点,用户点了才收到 400。 */
|
||||
function sdkCaps(sdkType) {
|
||||
if (!sdkType) return {};
|
||||
return (store.adapters.capabilities || {})[sdkType] || {};
|
||||
}
|
||||
function providerSdkType(slug) {
|
||||
const p = store.providers.find(x => x.slug === slug || x.name === slug);
|
||||
return p ? (p.sdk_type || null) : null;
|
||||
}
|
||||
/** 某平台是否声明了可用适配器(如实展示「未对接」而不是显示一个假的 sdk_type) */
|
||||
function providerIsSupported(slug) {
|
||||
const t = providerSdkType(slug);
|
||||
return !!t && (store.adapters.supported_types || []).includes(t);
|
||||
}
|
||||
/** 平台在某能力上是否可用,如 capsOf('aliyun','list_vps') */
|
||||
function capsOf(slug, cap) {
|
||||
return !!sdkCaps(providerSdkType(slug))[cap];
|
||||
}
|
||||
/** 资产 -> 所属平台的 sdk_type(优先 provider_id,回退平台名/slug) */
|
||||
function assetSdkType(a) {
|
||||
if (!a) return null;
|
||||
const p = a.provider_id
|
||||
? store.providers.find(x => x.id === a.provider_id)
|
||||
: store.providers.find(x => x.slug === a.provider || x.name === a.provider || x.slug === a.provider_name);
|
||||
return p ? (p.sdk_type || null) : null;
|
||||
}
|
||||
/** 资产在某能力上是否可用;没配适配器的平台一律 false,按钮不渲染 */
|
||||
function assetCan(a, cap) {
|
||||
return !!sdkCaps(assetSdkType(a))[cap];
|
||||
}
|
||||
const INSTANCE_CAPS = ['start_instance', 'stop_instance', 'reboot_instance', 'renew_instance'];
|
||||
/** VPS 是否可远程操作(任一实例动作可用即可) */
|
||||
function assetCanControl(a) {
|
||||
return a && a.asset_type === 'vps' && !!a.external_id && INSTANCE_CAPS.some(c => assetCan(a, c));
|
||||
}
|
||||
/** 域名是否可管 DNS 解析 */
|
||||
function assetCanDns(a) {
|
||||
return a && a.asset_type === 'domain' && assetCan(a, 'list_dns_records');
|
||||
}
|
||||
|
||||
/* ---------------- 云资源动作(启停/重启/续费) ----------------
|
||||
* 这些动作会真实改变云端资源:关机会中断线上服务、续费会真实扣款。
|
||||
* 因此一律走二次确认弹窗,且按钮可用性由后端 /actions/{id}/capabilities 决定。 */
|
||||
const ACTION_LABELS = { start: '开机', stop: '关机', reboot: '重启', renew: '续费' };
|
||||
const ACTION_WARNS = {
|
||||
start: '将向云端下发开机指令。',
|
||||
stop: '将向云端下发关机指令,机器上运行的服务会立即中断。',
|
||||
reboot: '将向云端下发重启指令,机器会短暂不可用。',
|
||||
renew: '将向云端下发续费指令,会产生真实扣款。',
|
||||
};
|
||||
|
||||
const actionModal = reactive({
|
||||
show: false, asset: null, caps: null, loading: true, busy: false,
|
||||
confirm: null, // 待二次确认的动作名
|
||||
periodMonths: 1,
|
||||
error: '',
|
||||
});
|
||||
|
||||
function openActions(asset) {
|
||||
actionModal.asset = asset;
|
||||
actionModal.caps = null;
|
||||
actionModal.loading = true;
|
||||
actionModal.confirm = null;
|
||||
actionModal.periodMonths = 1;
|
||||
actionModal.error = '';
|
||||
actionModal.show = true;
|
||||
Api.get('/actions/' + asset.id + '/capabilities')
|
||||
.then(r => { actionModal.caps = r; })
|
||||
.catch(e => { actionModal.error = e.message; })
|
||||
.finally(() => { actionModal.loading = false; });
|
||||
}
|
||||
function closeActions() { actionModal.show = false; actionModal.confirm = null; }
|
||||
function askAction(name) { actionModal.confirm = name; }
|
||||
async function runAction(name) {
|
||||
if (actionModal.busy) return;
|
||||
actionModal.busy = true; actionModal.error = '';
|
||||
try {
|
||||
const body = { action: name };
|
||||
if (name === 'renew') body.period_months = actionModal.periodMonths;
|
||||
const r = await Api.post('/actions/' + actionModal.asset.id + '/instance', body);
|
||||
if (r.ok) {
|
||||
showToast('✓ ' + ACTION_LABELS[name] + '指令已下发');
|
||||
actionModal.confirm = null;
|
||||
await loadAllAssets();
|
||||
if (store.view === 'assets') await loadAssets();
|
||||
} else {
|
||||
actionModal.error = r.message || '云端拒绝了该操作';
|
||||
}
|
||||
} catch (e) { actionModal.error = e.message; }
|
||||
finally { actionModal.busy = false; }
|
||||
}
|
||||
|
||||
/* ---------------- DNS 记录管理 ---------------- */
|
||||
const RECORD_TYPES = ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SRV', 'CAA'];
|
||||
const dnsModal = reactive({
|
||||
show: false, asset: null, zoneId: '', provider: '',
|
||||
records: [], loading: true, busy: false, error: '',
|
||||
editing: null, // null=关闭表单 / 'new'=新建 / record 对象=编辑
|
||||
form: null,
|
||||
});
|
||||
|
||||
function openDns(asset) {
|
||||
dnsModal.asset = asset;
|
||||
dnsModal.records = [];
|
||||
dnsModal.loading = true;
|
||||
dnsModal.error = '';
|
||||
dnsModal.editing = null;
|
||||
// 先用资产自带的平台 slug 兜底:列表请求失败时 provider 拿不到后端返回值,
|
||||
// 而 isCloudflare 靠它决定是否显示「走 Cloudflare 代理」勾选框。
|
||||
dnsModal.provider = asset.provider || '';
|
||||
dnsModal.show = true;
|
||||
Api.get('/actions/' + asset.id + '/dns')
|
||||
.then(r => { dnsModal.records = r.records || []; dnsModal.zoneId = r.zone_id; dnsModal.provider = r.provider; })
|
||||
.catch(e => { dnsModal.error = e.message; })
|
||||
.finally(() => { dnsModal.loading = false; });
|
||||
}
|
||||
function closeDns() { dnsModal.show = false; dnsModal.editing = null; }
|
||||
function reloadDns() {
|
||||
dnsModal.loading = true; dnsModal.error = '';
|
||||
Api.get('/actions/' + dnsModal.asset.id + '/dns')
|
||||
.then(r => { dnsModal.records = r.records || []; })
|
||||
.catch(e => { dnsModal.error = e.message; })
|
||||
.finally(() => { dnsModal.loading = false; });
|
||||
}
|
||||
function newDnsRecord() {
|
||||
dnsModal.form = { name: '', type: 'A', content: '', ttl: null, priority: null, proxied: null, comment: '' };
|
||||
dnsModal.editing = 'new';
|
||||
}
|
||||
function editDnsRecord(r) {
|
||||
dnsModal.form = {
|
||||
name: r.name || '', type: r.type || 'A', content: r.content || '',
|
||||
ttl: r.ttl || null, priority: r.priority ?? null, proxied: r.proxied ?? null, comment: r.comment || '',
|
||||
};
|
||||
dnsModal.editing = r;
|
||||
}
|
||||
function cancelDnsEdit() { dnsModal.editing = null; dnsModal.form = null; }
|
||||
async function saveDnsRecord() {
|
||||
if (dnsModal.busy) return;
|
||||
dnsModal.busy = true; dnsModal.error = '';
|
||||
const isNew = dnsModal.editing === 'new';
|
||||
const body = { ...dnsModal.form };
|
||||
// 空字符串会被后端的 ge/le 校验拒掉,未填的可选字段一律不发
|
||||
if (body.ttl === '' || body.ttl === null) delete body.ttl;
|
||||
if (body.priority === '' || body.priority === null) delete body.priority;
|
||||
if (body.proxied === null || body.proxied === '') delete body.proxied;
|
||||
if (!body.comment) delete body.comment;
|
||||
try {
|
||||
const r = isNew
|
||||
? await Api.post('/actions/' + dnsModal.asset.id + '/dns', body)
|
||||
: await Api.put('/actions/' + dnsModal.asset.id + '/dns/' + encodeURIComponent(dnsModal.editing.record_id), body);
|
||||
if (r.ok) { showToast('✓ 记录已' + (isNew ? '创建' : '更新')); cancelDnsEdit(); reloadDns(); }
|
||||
else dnsModal.error = r.message || '操作被拒绝';
|
||||
} catch (e) { dnsModal.error = e.message; }
|
||||
finally { dnsModal.busy = false; }
|
||||
}
|
||||
async function deleteDnsRecord(r) {
|
||||
// 删解析会立刻影响线上访问,必须二次确认而不是直接删
|
||||
if (!window.confirm('确认删除解析记录?\n\n' + r.name + ' ' + r.type + ' ' + r.content + '\n\n删除后该记录立即失效,可能中断线上访问。')) return;
|
||||
if (dnsModal.busy) return;
|
||||
dnsModal.busy = true; dnsModal.error = '';
|
||||
try {
|
||||
const res = await Api.del('/actions/' + dnsModal.asset.id + '/dns/' + encodeURIComponent(r.record_id));
|
||||
if (res.ok) { showToast('✓ 记录已删除'); reloadDns(); }
|
||||
else dnsModal.error = res.message || '删除被拒绝';
|
||||
} catch (e) { dnsModal.error = e.message; }
|
||||
finally { dnsModal.busy = false; }
|
||||
}
|
||||
|
||||
/* ---------------- 设置 ---------------- */
|
||||
const settings = reactive({ apiKey: Api.getApiKey(), saved: false });
|
||||
function saveSettings() { Api.setApiKey(settings.apiKey); settings.saved = true; setTimeout(() => settings.saved = false, 2000); }
|
||||
|
||||
@@ -31,6 +31,7 @@ const AssetsView = {
|
||||
<option v-for="(l,k) in Fmt.STATUS_LABELS" :key="k" :value="k">{{ l }}</option>
|
||||
</select>
|
||||
<button v-if="store.assetTab===''" @click="syncAllAI" class="text-sm px-3 py-1.5 rounded-lg border border-violet-300 dark:border-violet-700 text-violet-600 dark:text-violet-400 hover:bg-violet-50 dark:hover:bg-violet-900/20">同步AI余额</button>
|
||||
<button v-if="store.assetTab==='' || store.assetTab==='vps'" @click="syncAllAssets" class="text-sm px-3 py-1.5 rounded-lg border border-emerald-300 dark:border-emerald-700 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-50 dark:hover:bg-emerald-900/20" title="拉取所有已配 API 凭证账号下的云端 VPS 与域名">同步云端资产</button>
|
||||
<span class="ml-auto text-xs text-slate-400">共 {{ list.length }} 条</span>
|
||||
</div>
|
||||
|
||||
@@ -55,10 +56,11 @@ const AssetsView = {
|
||||
<td class="px-4 py-2.5"><span class="text-xs px-2 py-0.5 rounded-full" :class="Fmt.typeBadge(a.asset_type)">{{ Fmt.TYPE_LABELS[a.asset_type] }}</span></td>
|
||||
<td class="px-4 py-2.5 text-slate-500">{{ a.provider_name || a.provider }}</td>
|
||||
<td class="px-4 py-2.5" :class="Fmt.expiryText(a.days_to_expiry)">{{ a.expiry_date || '—' }}<span v-if="a.days_to_expiry!==null && a.days_to_expiry!==undefined" class="text-xs ml-1">({{ Fmt.countdownText(a.days_to_expiry, { unit: '' }) }})</span></td>
|
||||
<td class="px-4 py-2.5 text-slate-600 dark:text-slate-300">{{ a.cost }} {{ a.currency }}<span v-if="a.renewal_cycle" class="text-xs text-slate-400 ml-1">/{{ Fmt.CYCLE_LABELS[a.renewal_cycle] || a.renewal_cycle }}</span></td>
|
||||
<td class="px-4 py-2.5" :class="a.cost ? 'text-slate-600 dark:text-slate-300' : 'text-slate-400'">{{ Fmt.costText(a.cost, a.currency) }}<span v-if="a.cost && a.renewal_cycle" class="text-xs text-slate-400 ml-1">/{{ Fmt.CYCLE_LABELS[a.renewal_cycle] || a.renewal_cycle }}</span></td>
|
||||
<td class="px-4 py-2.5"><span class="text-xs px-2 py-0.5 rounded-full" :class="Fmt.statusBadge(a.status)">{{ Fmt.STATUS_LABELS[a.status] }}</span></td>
|
||||
<td class="px-4 py-2.5 text-right whitespace-nowrap">
|
||||
<button v-if="a.asset_type==='vps'" @click="viewServer(a)" class="text-emerald-600 dark:text-emerald-400 hover:underline mr-3 text-xs">监控</button>
|
||||
<button v-if="canControl(a)" @click="openAct(a)" class="text-amber-600 dark:text-amber-400 hover:underline mr-3 text-xs" title="远程开机/关机/重启/续费">云操作</button>
|
||||
<button v-if="a.asset_type==='ai_agent'" @click="refreshBalance(a)" class="text-violet-600 dark:text-violet-400 hover:underline mr-3 text-xs">刷新余额</button>
|
||||
<button @click="edit(a)" class="text-blue-600 dark:text-blue-400 hover:underline mr-3 text-xs">编辑</button>
|
||||
<button @click="del(a)" class="text-red-600 dark:text-red-400 hover:underline text-xs">删除</button>
|
||||
@@ -81,11 +83,12 @@ const AssetsView = {
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-2 text-xs">
|
||||
<span :class="Fmt.expiryText(a.days_to_expiry)">{{ a.expiry_date || '无到期' }}<span v-if="a.days_to_expiry!==null&&a.days_to_expiry!==undefined"> ({{ Fmt.countdownText(a.days_to_expiry, { unit: '' }) }})</span></span>
|
||||
<span class="text-slate-500">{{ a.cost }}{{ a.currency }}</span>
|
||||
<span :class="a.cost ? 'text-slate-500' : 'text-slate-400'">{{ Fmt.costText(a.cost, a.currency) }}</span>
|
||||
<span class="px-2 py-0.5 rounded-full" :class="Fmt.statusBadge(a.status)">{{ Fmt.STATUS_LABELS[a.status] }}</span>
|
||||
</div>
|
||||
<div class="flex gap-4 mt-2 pt-2 border-t border-slate-100 dark:border-slate-800 text-xs">
|
||||
<button v-if="a.asset_type==='vps'" @click="viewServer(a)" class="text-emerald-600 dark:text-emerald-400">监控</button>
|
||||
<button v-if="canControl(a)" @click="openAct(a)" class="text-amber-600 dark:text-amber-400">云操作</button>
|
||||
<button v-if="a.asset_type==='ai_agent'" @click="refreshBalance(a)" class="text-violet-600 dark:text-violet-400">刷新余额</button>
|
||||
<button @click="edit(a)" class="text-blue-600 dark:text-blue-400">编辑</button>
|
||||
<button @click="del(a)" class="text-red-600 dark:text-red-400">删除</button>
|
||||
@@ -156,6 +159,23 @@ const AssetsView = {
|
||||
await loadAssets();
|
||||
} catch (e) { alert('同步失败:' + e.message); }
|
||||
}
|
||||
return { store, Fmt, tabs, tabView, list, switchTab, providerName, reload: loadAssets, clearProvider: clearProviderFilter, edit: openAssetEdit, del: deleteAsset, viewServer, refreshBalance, syncAllAI };
|
||||
async function syncAllAssets() {
|
||||
if (!confirm('从云端拉取所有已配 API 凭证账号下的 VPS 与域名?\n\n会按 (平台, 云端实例ID) 去重:已存在则更新状态/到期日,不存在则新建。')) return;
|
||||
try {
|
||||
const r = await Api.post('/sync/assets', {});
|
||||
const lines = [
|
||||
'同步完成:成功 ' + r.success + ',失败 ' + r.failed,
|
||||
'新增 ' + r.created + ',更新 ' + r.updated,
|
||||
];
|
||||
// 未配凭证 / 平台无适配器属于「跳过」而非失败,但必须如实报出来,
|
||||
// 否则用户以为同步过了、实际一条都没拉
|
||||
if (r.skipped_no_config) lines.push('跳过(账号未配 API 凭证):' + r.skipped_no_config);
|
||||
if (r.skipped_no_adapter) lines.push('跳过(平台无可用适配器):' + r.skipped_no_adapter);
|
||||
if (r.errors && r.errors.length) lines.push('\n' + r.errors.join('\n'));
|
||||
alert(lines.join('\n'));
|
||||
await Promise.all([loadAssets(), loadOverview()]);
|
||||
} catch (e) { alert('同步失败:' + e.message); }
|
||||
}
|
||||
return { store, Fmt, tabs, tabView, list, switchTab, providerName, reload: loadAssets, clearProvider: clearProviderFilter, edit: openAssetEdit, del: deleteAsset, viewServer, refreshBalance, syncAllAI, syncAllAssets, canControl: assetCanControl, openAct: openActions };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -17,6 +17,8 @@ const DashboardView = {
|
||||
<button type="button" @click="goYearCost" title="点击查看付费资产汇总" class="text-left bg-white dark:bg-slate-900 rounded-xl p-4 border border-slate-200 dark:border-slate-800 hover:border-blue-400 dark:hover:border-blue-600 transition">
|
||||
<div class="text-xs text-slate-500">本年支出</div>
|
||||
<div class="text-2xl font-bold mt-1">{{ store.overview.year_cost || 0 }}</div>
|
||||
<!-- 合计只统计填了费用的资产,缺口必须说出来:否则这个数看着像准数、实际是下限 -->
|
||||
<div v-if="store.overview.year_cost_missing" class="text-[11px] mt-1 text-amber-600 dark:text-amber-400">另有 {{ store.overview.year_cost_missing }} 项未填费用,实际更高</div>
|
||||
</button>
|
||||
<button type="button" @click="goAbnormal" title="点击查看已停止/已过期的资产" class="text-left bg-white dark:bg-slate-900 rounded-xl p-4 border border-amber-200 dark:border-amber-900/50 hover:border-amber-400 dark:hover:border-amber-700 transition">
|
||||
<div class="text-xs text-slate-500">异常状态</div>
|
||||
|
||||
@@ -39,6 +39,7 @@ const DomainsView = {
|
||||
<td class="px-4 py-2.5"><span class="text-xs px-2 py-0.5 rounded-full" :class="a.domain_detail.is_using ? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400' : 'bg-slate-500/10 text-slate-400'">{{ a.domain_detail.is_using ? '使用' : '闲置' }}</span></td>
|
||||
<td class="px-4 py-2.5 text-right whitespace-nowrap">
|
||||
<button @click="toggle(a)" class="text-indigo-600 dark:text-indigo-400 hover:underline mr-3 text-xs">{{ expanded[a.id] ? '收起' : '子域名' }}</button>
|
||||
<button v-if="canDns(a)" @click="openDnsPanel(a)" class="text-emerald-600 dark:text-emerald-400 hover:underline mr-3 text-xs" title="直接管理云端 DNS 解析记录">DNS</button>
|
||||
<button @click="edit(a)" class="text-blue-600 dark:text-blue-400 hover:underline text-xs">编辑/绑定</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -164,6 +165,6 @@ const DomainsView = {
|
||||
if (d <= 90) return 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400';
|
||||
return 'bg-slate-500/10 text-slate-500';
|
||||
}
|
||||
return { store, Fmt, domains, subsByAsset, sslByAsset, expanded, forms, domainName, fullHost, toggle, resetForm, startEdit, saveSub, delSub, probeSsl, certText, certBadge, edit: openAssetEdit };
|
||||
return { store, Fmt, domains, subsByAsset, sslByAsset, expanded, forms, domainName, fullHost, toggle, resetForm, startEdit, saveSub, delSub, probeSsl, certText, certBadge, edit: openAssetEdit, canDns: assetCanDns, openDnsPanel: openDns };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -83,7 +83,7 @@ const MonitorView = {
|
||||
<td class="px-4 py-2.5 text-slate-500">{{ a.provider_name || a.provider }}</td>
|
||||
<td class="px-4 py-2.5">{{ a.expiry_date }}</td>
|
||||
<td class="px-4 py-2.5"><span class="text-xs font-semibold px-2 py-0.5 rounded-full" :class="countdownBadge(a.days_to_expiry)">{{ countdownText(a.days_to_expiry) }}</span></td>
|
||||
<td class="px-4 py-2.5 text-slate-600 dark:text-slate-300">{{ a.cost }} {{ a.currency }}<span v-if="a.renewal_cycle" class="text-xs text-slate-400 ml-1">/{{ Fmt.CYCLE_LABELS[a.renewal_cycle] || a.renewal_cycle }}</span></td>
|
||||
<td class="px-4 py-2.5" :class="a.cost ? 'text-slate-600 dark:text-slate-300' : 'text-slate-400'">{{ Fmt.costText(a.cost, a.currency) }}<span v-if="a.cost && a.renewal_cycle" class="text-xs text-slate-400 ml-1">/{{ Fmt.CYCLE_LABELS[a.renewal_cycle] || a.renewal_cycle }}</span></td>
|
||||
<td class="px-4 py-2.5 text-right whitespace-nowrap">
|
||||
<a v-if="a.renew_url" :href="a.renew_url" target="_blank" class="text-emerald-600 dark:text-emerald-400 hover:underline mr-3 text-xs">续费</a>
|
||||
<button @click="edit(a)" class="text-blue-600 dark:text-blue-400 hover:underline mr-3 text-xs">编辑</button>
|
||||
@@ -107,7 +107,7 @@ const MonitorView = {
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-2 text-xs">
|
||||
<span class="text-slate-500">{{ a.expiry_date }}</span>
|
||||
<span class="text-slate-500">{{ a.cost }}{{ a.currency }}</span>
|
||||
<span :class="a.cost ? 'text-slate-500' : 'text-slate-400'">{{ Fmt.costText(a.cost, a.currency) }}</span>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full" :class="Fmt.typeBadge(a.asset_type)">{{ Fmt.TYPE_LABELS[a.asset_type] }}</span>
|
||||
</div>
|
||||
<div class="flex gap-4 mt-2 pt-2 border-t border-slate-100 dark:border-slate-800 text-xs">
|
||||
|
||||
@@ -25,7 +25,16 @@ const ProvidersView = {
|
||||
<span v-else class="text-xs px-2 py-0.5 rounded-full" :class="Fmt.typeBadge(p.category)">{{ Fmt.CATEGORY_LABELS[p.category] }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-slate-500 mt-2 space-y-1">
|
||||
<div v-if="p.sdk_type">API 对接: {{ p.sdk_type }}</div>
|
||||
<!-- API 对接状态如实展示:能力矩阵来自后端 /providers/adapters,
|
||||
即「适配器真的实现了哪些方法」,不是拿 sdk_type 有没有值充数 -->
|
||||
<div v-if="isSupported(p)" class="flex flex-wrap items-center gap-1">
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">✓ API 已对接</span>
|
||||
<span v-for="c in capsSummary(p)" :key="c" class="text-[10px] px-1.5 py-0.5 rounded-full bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-400">{{ c }}</span>
|
||||
</div>
|
||||
<div v-else-if="p.sdk_type" class="text-[10px] px-1.5 py-0.5 inline-block rounded-full bg-red-500/10 text-red-600 dark:text-red-400" :title="'声明的 ' + p.sdk_type + ' 没有对应适配器实现'">
|
||||
⚠ 已声明 {{ p.sdk_type }},但无对应适配器
|
||||
</div>
|
||||
<div v-else class="text-[10px] px-1.5 py-0.5 inline-block rounded-full bg-slate-100 dark:bg-slate-800 text-slate-400">未对接 API(仅台账登记)</div>
|
||||
<div v-if="p.console_url"><a :href="p.console_url" target="_blank" class="text-blue-600 dark:text-blue-400 hover:underline">管理面板 ↗</a></div>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<button @click="goAssets(p)" :disabled="!countOf(p)" class="text-slate-500 hover:text-blue-600 dark:hover:text-blue-400 disabled:hover:text-slate-500" :title="countOf(p) ? '查看该平台下的资产' : ''">📎 关联 {{ countOf(p) }} 个资产</button>
|
||||
@@ -79,10 +88,29 @@ const ProvidersView = {
|
||||
function serviceList(p) {
|
||||
return (p.services || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||
}
|
||||
// API 对接状态:以适配器注册表为准,不看 sdk_type 有没有值
|
||||
function isSupported(p) {
|
||||
return !!p.sdk_type && (store.adapters.supported_types || []).includes(p.sdk_type);
|
||||
}
|
||||
// 能力摘要:把 capabilities 里为 true 的方法归并成用户看得懂的标签
|
||||
const CAPS_LABELS = [
|
||||
['list_vps', 'VPS同步'],
|
||||
['list_domains', '域名同步'],
|
||||
['get_account', '余额'],
|
||||
['start_instance', '开关机'],
|
||||
['reboot_instance', '重启'],
|
||||
['renew_instance', '续费'],
|
||||
['list_dns_records', 'DNS查询'],
|
||||
['create_dns_record', 'DNS写入'],
|
||||
];
|
||||
function capsSummary(p) {
|
||||
const caps = sdkCaps(p.sdk_type);
|
||||
return CAPS_LABELS.filter(([k]) => caps[k]).map(([, label]) => label);
|
||||
}
|
||||
function fmtTime(iso) {
|
||||
if (!iso) return '';
|
||||
return new Date(iso).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
return { store, Fmt, countOf, accountsCountOf, serviceList, showAll, toggleShowAll, visibleProviders, hiddenCount, fmtTime, seed: seedProviders, create: openProviderCreate, edit: openProviderEdit, del: deleteProvider, openAccounts: (p) => openAccountsView(p.slug), manageAccounts: () => openAccountsView(null), goAssets: openAssetsByProvider };
|
||||
return { store, Fmt, countOf, accountsCountOf, serviceList, isSupported, capsSummary, showAll, toggleShowAll, visibleProviders, hiddenCount, fmtTime, seed: seedProviders, create: openProviderCreate, edit: openProviderEdit, del: deleteProvider, openAccounts: (p) => openAccountsView(p.slug), manageAccounts: () => openAccountsView(null), goAssets: openAssetsByProvider };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -37,7 +37,7 @@ const SubscriptionsView = {
|
||||
<tr v-for="a in subs" :key="a.id" class="hover:bg-slate-50 dark:hover:bg-slate-800/30">
|
||||
<td class="px-4 py-2.5 font-medium">{{ a.name }}</td>
|
||||
<td class="px-4 py-2.5 text-slate-500">{{ a.provider_name || a.provider }}</td>
|
||||
<td class="px-4 py-2.5 text-slate-600 dark:text-slate-300">{{ a.cost }} {{ a.currency }}<span v-if="a.renewal_cycle" class="text-xs text-slate-400 ml-1">/{{ Fmt.CYCLE_LABELS[a.renewal_cycle] || a.renewal_cycle }}</span></td>
|
||||
<td class="px-4 py-2.5" :class="a.cost ? 'text-slate-600 dark:text-slate-300' : 'text-slate-400'">{{ Fmt.costText(a.cost, a.currency) }}<span v-if="a.cost && a.renewal_cycle" class="text-xs text-slate-400 ml-1">/{{ Fmt.CYCLE_LABELS[a.renewal_cycle] || a.renewal_cycle }}</span></td>
|
||||
<td class="px-4 py-2.5" :class="Fmt.expiryText(a.days_to_expiry)">{{ a.expiry_date || '—' }}<span v-if="a.days_to_expiry!==null&&a.days_to_expiry!==undefined" class="text-xs ml-1">({{ Fmt.countdownText(a.days_to_expiry, { unit: '' }) }})</span></td>
|
||||
<td class="px-4 py-2.5 text-right whitespace-nowrap">
|
||||
<a v-if="a.renew_url" :href="a.renew_url" target="_blank" class="text-emerald-600 dark:text-emerald-400 hover:underline mr-3 text-xs">续费</a>
|
||||
|
||||
@@ -0,0 +1,718 @@
|
||||
"""云资源管理动作(action_service)与请求 Schema 测试
|
||||
|
||||
这层的每个动作都会改变云端真实资源:关机会中断线上服务、续费会真实扣款。
|
||||
所以测试重点是「该拒绝的一律拒绝,且拒绝理由如实」,而不是「能不能跑通」:
|
||||
|
||||
- 能力不足 / 手工登记无 external_id / 资产类型不符 → 400,绝不静默 no-op
|
||||
- 适配器明确返回 ok=False → 原样带回,不能包装成成功
|
||||
- 云端调用抛异常 → 502(区别于 400 的「参数/能力问题」)
|
||||
- 每个动作都落 EventLog 审计,且审计失败不能拖垮业务动作
|
||||
- 路由层写操作必须挂 require_api_key(fail-closed)
|
||||
|
||||
运行:.venv/bin/pytest tests/test_actions.py -v
|
||||
"""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlmodel import Session, SQLModel, create_engine, select
|
||||
|
||||
import app.models.asset # noqa: F401
|
||||
import app.models.credential # noqa: F401
|
||||
import app.models.monitor # noqa: F401
|
||||
import app.models.provider # noqa: F401
|
||||
import app.models.ssl # noqa: F401
|
||||
from app.adapters.base import cloud_error_text, http_error_message
|
||||
from app.core.security import require_api_key
|
||||
from app.models.asset import Account, Asset, AssetType, DomainDetail
|
||||
from app.models.monitor import EventLog
|
||||
from app.models.provider import Provider
|
||||
from app.routers import actions as actions_router
|
||||
from app.schemas.action import DNSRecordIn, InstanceActionRequest
|
||||
from app.services import action_service
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def engine(tmp_path, monkeypatch):
|
||||
eng = create_engine(
|
||||
f"sqlite:///{tmp_path / 'actions.db'}", connect_args={"check_same_thread": False}
|
||||
)
|
||||
SQLModel.metadata.create_all(eng)
|
||||
# audit() 在函数内部 import metrics_engine,必须指到临时库,否则污染本地 data/metrics.db
|
||||
monkeypatch.setattr("app.database.metrics_engine", eng)
|
||||
yield eng
|
||||
eng.dispose()
|
||||
|
||||
|
||||
class _FakeAdapter:
|
||||
"""可控能力的假适配器"""
|
||||
|
||||
required_config: list = []
|
||||
sdk_types = ["fake-sdk"]
|
||||
|
||||
def __init__(self, caps=None, result=None, raises=None):
|
||||
self.config = {}
|
||||
self._caps = caps if caps is not None else {
|
||||
"list_vps": True, "list_domains": True, "get_account": False,
|
||||
"start_instance": True, "stop_instance": True,
|
||||
"reboot_instance": True, "renew_instance": True,
|
||||
"list_dns_records": True, "create_dns_record": True,
|
||||
"update_dns_record": True, "delete_dns_record": True,
|
||||
}
|
||||
self._result = result if result is not None else {"ok": True, "message": "done"}
|
||||
self._raises = raises
|
||||
self.calls = []
|
||||
|
||||
def capabilities(self):
|
||||
return dict(self._caps)
|
||||
|
||||
def _record(self, name, args):
|
||||
self.calls.append((name, args))
|
||||
if self._raises:
|
||||
raise self._raises
|
||||
return self._result
|
||||
|
||||
def start_instance(self, external_id):
|
||||
return self._record("start_instance", (external_id,))
|
||||
|
||||
def stop_instance(self, external_id):
|
||||
return self._record("stop_instance", (external_id,))
|
||||
|
||||
def reboot_instance(self, external_id):
|
||||
return self._record("reboot_instance", (external_id,))
|
||||
|
||||
def renew_instance(self, external_id, period_months=1):
|
||||
return self._record("renew_instance", (external_id, period_months))
|
||||
|
||||
def list_dns_records(self, zone_id):
|
||||
from app.adapters.base import NormalizedDNSRecord
|
||||
self.calls.append(("list_dns_records", (zone_id,)))
|
||||
if self._raises:
|
||||
raise self._raises
|
||||
return [NormalizedDNSRecord(record_id="r1", zone_id=zone_id, name="a.com", type="A", content="1.2.3.4")]
|
||||
|
||||
def create_dns_record(self, zone_id, record):
|
||||
return self._record("create_dns_record", (zone_id, record))
|
||||
|
||||
def update_dns_record(self, zone_id, record_id, record):
|
||||
return self._record("update_dns_record", (zone_id, record_id, record))
|
||||
|
||||
def delete_dns_record(self, zone_id, record_id):
|
||||
return self._record("delete_dns_record", (zone_id, record_id))
|
||||
|
||||
|
||||
def _seed_asset(engine, asset_type=AssetType.VPS, external_id="i-1", with_account=True,
|
||||
provider_slug="fake", sdk_type="fake-sdk", name="web-01"):
|
||||
"""建 provider + account + asset,并把适配器构造过程替换成 _FakeAdapter 工厂"""
|
||||
with Session(engine) as s:
|
||||
provider = Provider(slug=provider_slug, name=provider_slug, category="vps", sdk_type=sdk_type)
|
||||
s.add(provider)
|
||||
s.commit()
|
||||
s.refresh(provider)
|
||||
|
||||
account_id = None
|
||||
if with_account:
|
||||
account = Account(name="acc", platform=provider_slug, api_config_encrypted="enc")
|
||||
s.add(account)
|
||||
s.commit()
|
||||
s.refresh(account)
|
||||
account_id = account.id
|
||||
|
||||
asset = Asset(
|
||||
name=name, asset_type=asset_type, provider=provider_slug,
|
||||
provider_id=provider.id, external_id=external_id, account_id=account_id,
|
||||
)
|
||||
s.add(asset)
|
||||
s.commit()
|
||||
s.refresh(asset)
|
||||
return provider.id, account_id, asset.id
|
||||
|
||||
|
||||
def _use_fake(monkeypatch, engine, provider_id, adapter):
|
||||
monkeypatch.setattr(
|
||||
action_service, "_build_adapter_for_account",
|
||||
lambda session, account: (session.get(Provider, provider_id), adapter),
|
||||
)
|
||||
return adapter
|
||||
|
||||
|
||||
def _events(engine) -> list:
|
||||
with Session(engine) as s:
|
||||
return s.exec(select(EventLog)).all()
|
||||
|
||||
|
||||
# ----------------------------- 解析与鉴权 ----------------------------- #
|
||||
def test_resolve_rejects_missing_asset(engine):
|
||||
_seed_asset(engine)
|
||||
with Session(engine) as s:
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
action_service._resolve(s, 99999)
|
||||
assert ei.value.status_code == 404
|
||||
|
||||
|
||||
def test_resolve_rejects_asset_without_account(engine):
|
||||
"""手工登记、没挂账号的资产无从确定用哪套凭证——必须明确拒绝"""
|
||||
_, _, asset_id = _seed_asset(engine, with_account=False)
|
||||
with Session(engine) as s:
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
action_service._resolve(s, asset_id)
|
||||
assert ei.value.status_code == 400
|
||||
assert "未关联账号" in ei.value.detail
|
||||
|
||||
|
||||
def test_resolve_rejects_missing_credentials(engine, monkeypatch):
|
||||
"""required_config 未填齐时如实报缺哪个字段,别让用户猜"""
|
||||
provider_id, _, asset_id = _seed_asset(engine)
|
||||
adapter = _FakeAdapter()
|
||||
adapter.required_config = ["api_key", "region"]
|
||||
adapter.config = {"api_key": "k"}
|
||||
_use_fake(monkeypatch, engine, provider_id, adapter)
|
||||
with Session(engine) as s:
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
action_service._resolve(s, asset_id)
|
||||
assert ei.value.status_code == 400
|
||||
assert "region" in ei.value.detail
|
||||
|
||||
|
||||
def test_resolve_rejects_dangling_account_reference(engine, monkeypatch):
|
||||
provider_id, account_id, asset_id = _seed_asset(engine)
|
||||
_use_fake(monkeypatch, engine, provider_id, _FakeAdapter())
|
||||
with Session(engine) as s:
|
||||
s.delete(s.get(Account, account_id))
|
||||
s.commit()
|
||||
with Session(engine) as s:
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
action_service._resolve(s, asset_id)
|
||||
assert ei.value.status_code == 400
|
||||
assert "账号已不存在" in ei.value.detail
|
||||
|
||||
|
||||
# ----------------------------- 实例动作 ----------------------------- #
|
||||
def test_unknown_action_rejected(engine, monkeypatch):
|
||||
provider_id, _, asset_id = _seed_asset(engine)
|
||||
_use_fake(monkeypatch, engine, provider_id, _FakeAdapter())
|
||||
with Session(engine) as s:
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
action_service.perform_instance_action(s, asset_id, "destroy")
|
||||
assert ei.value.status_code == 400
|
||||
assert "未知动作" in ei.value.detail
|
||||
|
||||
|
||||
def test_start_success(engine, monkeypatch):
|
||||
provider_id, _, asset_id = _seed_asset(engine)
|
||||
adapter = _use_fake(monkeypatch, engine, provider_id, _FakeAdapter())
|
||||
with Session(engine) as s:
|
||||
res = action_service.perform_instance_action(s, asset_id, "start")
|
||||
assert res["ok"] is True
|
||||
assert res["action"] == "start"
|
||||
assert res["external_id"] == "i-1"
|
||||
assert res["provider"] == "fake"
|
||||
assert adapter.calls == [("start_instance", ("i-1",))]
|
||||
|
||||
|
||||
def test_renew_passes_period_months(engine, monkeypatch):
|
||||
"""续费周期必须透传,否则默认 1 个月与用户选择不符"""
|
||||
provider_id, _, asset_id = _seed_asset(engine)
|
||||
adapter = _use_fake(monkeypatch, engine, provider_id, _FakeAdapter())
|
||||
with Session(engine) as s:
|
||||
action_service.perform_instance_action(s, asset_id, "renew", period_months=12)
|
||||
assert adapter.calls == [("renew_instance", ("i-1", 12))]
|
||||
|
||||
|
||||
def test_non_vps_asset_rejected(engine, monkeypatch):
|
||||
provider_id, _, asset_id = _seed_asset(engine, asset_type=AssetType.DOMAIN, external_id="example.com")
|
||||
_use_fake(monkeypatch, engine, provider_id, _FakeAdapter())
|
||||
with Session(engine) as s:
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
action_service.perform_instance_action(s, asset_id, "start")
|
||||
assert ei.value.status_code == 400
|
||||
assert "仅 VPS" in ei.value.detail
|
||||
|
||||
|
||||
def test_manual_asset_without_external_id_rejected(engine, monkeypatch):
|
||||
"""手工登记的资产没有云端 ID,无法下发指令"""
|
||||
provider_id, _, asset_id = _seed_asset(engine, external_id=None)
|
||||
_use_fake(monkeypatch, engine, provider_id, _FakeAdapter())
|
||||
with Session(engine) as s:
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
action_service.perform_instance_action(s, asset_id, "start")
|
||||
assert ei.value.status_code == 400
|
||||
assert "external_id" in ei.value.detail
|
||||
|
||||
|
||||
def test_unsupported_capability_rejected(engine, monkeypatch):
|
||||
"""适配器没实现该动作 → 400,绝不能静默 no-op 让调用方以为成功了"""
|
||||
provider_id, _, asset_id = _seed_asset(engine)
|
||||
caps = {"renew_instance": False, "start_instance": True}
|
||||
_use_fake(monkeypatch, engine, provider_id, _FakeAdapter(caps=caps))
|
||||
with Session(engine) as s:
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
action_service.perform_instance_action(s, asset_id, "renew")
|
||||
assert ei.value.status_code == 400
|
||||
assert "不支持" in ei.value.detail
|
||||
|
||||
|
||||
def test_adapter_returning_ok_false_is_passed_through(engine, monkeypatch):
|
||||
"""云端明确拒绝(如余额不足)时如实带回 ok=False + 原因,不能包装成成功"""
|
||||
provider_id, _, asset_id = _seed_asset(engine)
|
||||
_use_fake(monkeypatch, engine, provider_id,
|
||||
_FakeAdapter(result={"ok": False, "message": "余额不足"}))
|
||||
with Session(engine) as s:
|
||||
res = action_service.perform_instance_action(s, asset_id, "start")
|
||||
assert res["ok"] is False
|
||||
assert res["message"] == "余额不足"
|
||||
|
||||
|
||||
def test_not_implemented_error_becomes_400(engine, monkeypatch):
|
||||
"""capabilities() 与真实实现不一致时的兜底:按能力问题(400)而非网关问题"""
|
||||
provider_id, _, asset_id = _seed_asset(engine)
|
||||
_use_fake(monkeypatch, engine, provider_id, _FakeAdapter(raises=NotImplementedError()))
|
||||
with Session(engine) as s:
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
action_service.perform_instance_action(s, asset_id, "start")
|
||||
assert ei.value.status_code == 400
|
||||
|
||||
|
||||
def test_cloud_error_becomes_502(engine, monkeypatch):
|
||||
"""云端调用异常是上游问题,用 502 与 400(参数/能力问题)区分开"""
|
||||
provider_id, _, asset_id = _seed_asset(engine)
|
||||
_use_fake(monkeypatch, engine, provider_id, _FakeAdapter(raises=RuntimeError("connection reset")))
|
||||
with Session(engine) as s:
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
action_service.perform_instance_action(s, asset_id, "start")
|
||||
assert ei.value.status_code == 502
|
||||
assert "connection reset" in ei.value.detail
|
||||
|
||||
|
||||
# ----------------------------- 审计 ----------------------------- #
|
||||
def test_every_action_is_audited(engine, monkeypatch):
|
||||
provider_id, _, asset_id = _seed_asset(engine)
|
||||
_use_fake(monkeypatch, engine, provider_id, _FakeAdapter())
|
||||
with Session(engine) as s:
|
||||
action_service.perform_instance_action(s, asset_id, "stop")
|
||||
events = _events(engine)
|
||||
# 下发前 + 结果,各一条
|
||||
assert len(events) == 2
|
||||
assert all(e.source == "action" for e in events)
|
||||
assert all(e.asset_id == asset_id for e in events)
|
||||
assert "关机动作下发" in events[0].message
|
||||
assert "关机成功" in events[1].message
|
||||
|
||||
|
||||
def test_failed_action_audited_as_error(engine, monkeypatch):
|
||||
provider_id, _, asset_id = _seed_asset(engine)
|
||||
_use_fake(monkeypatch, engine, provider_id, _FakeAdapter(raises=RuntimeError("boom")))
|
||||
with Session(engine) as s:
|
||||
with pytest.raises(HTTPException):
|
||||
action_service.perform_instance_action(s, asset_id, "start")
|
||||
events = _events(engine)
|
||||
assert any(e.level == "error" for e in events), "异常必须留下 error 级审计"
|
||||
|
||||
|
||||
def test_rejected_action_audited_as_error(engine, monkeypatch):
|
||||
provider_id, _, asset_id = _seed_asset(engine)
|
||||
_use_fake(monkeypatch, engine, provider_id, _FakeAdapter(result={"ok": False, "message": "被拒"}))
|
||||
with Session(engine) as s:
|
||||
action_service.perform_instance_action(s, asset_id, "start")
|
||||
assert any(e.level == "error" for e in _events(engine))
|
||||
|
||||
|
||||
def test_audit_failure_does_not_break_action(engine, monkeypatch):
|
||||
"""审计写失败绝不能让业务动作失败——云端指令可能已经下发成功,
|
||||
此时抛错会让调用方误判为「没执行」,比丢一条日志危险得多"""
|
||||
provider_id, _, asset_id = _seed_asset(engine)
|
||||
adapter = _use_fake(monkeypatch, engine, provider_id, _FakeAdapter())
|
||||
|
||||
# 让 metrics_engine 指向不可用值,触发 audit 内部 except 分支
|
||||
monkeypatch.setattr("app.database.metrics_engine", None)
|
||||
with Session(engine) as s:
|
||||
res = action_service.perform_instance_action(s, asset_id, "start")
|
||||
assert res["ok"] is True
|
||||
assert adapter.calls == [("start_instance", ("i-1",))]
|
||||
|
||||
|
||||
def test_audit_uses_own_session(engine, monkeypatch):
|
||||
"""审计走独立 Session 提交,主流程回滚时记录不能一起丢"""
|
||||
provider_id, _, asset_id = _seed_asset(engine)
|
||||
_use_fake(monkeypatch, engine, provider_id, _FakeAdapter())
|
||||
with Session(engine) as s:
|
||||
action_service.perform_instance_action(s, asset_id, "start")
|
||||
s.rollback() # 主 Session 回滚
|
||||
assert len(_events(engine)) == 2, "审计已独立提交,不应受主流程回滚影响"
|
||||
|
||||
|
||||
# ----------------------------- 能力探测 ----------------------------- #
|
||||
def test_asset_actions_for_cloud_vps(engine, monkeypatch):
|
||||
provider_id, _, asset_id = _seed_asset(engine)
|
||||
_use_fake(monkeypatch, engine, provider_id, _FakeAdapter())
|
||||
with Session(engine) as s:
|
||||
res = action_service.asset_actions(s, asset_id)
|
||||
assert res["available"] is True
|
||||
assert res["provider"] == "fake"
|
||||
assert res["actions"]["start"] is True
|
||||
assert res["actions"]["renew"] is True
|
||||
assert res["actions"]["dns_records"] is False # VPS 不该出现 DNS 按钮
|
||||
|
||||
|
||||
def test_asset_actions_for_manual_vps(engine, monkeypatch):
|
||||
"""手工登记(无 external_id)的 VPS 所有实例动作都必须是 False,
|
||||
否则用户点了才收到 400"""
|
||||
provider_id, _, asset_id = _seed_asset(engine, external_id=None)
|
||||
_use_fake(monkeypatch, engine, provider_id, _FakeAdapter())
|
||||
with Session(engine) as s:
|
||||
res = action_service.asset_actions(s, asset_id)
|
||||
assert all(v is False for v in res["actions"].values()), res["actions"]
|
||||
assert res["available"] is False
|
||||
|
||||
|
||||
def test_asset_actions_for_unsupported_provider(engine, monkeypatch):
|
||||
provider_id, _, asset_id = _seed_asset(engine)
|
||||
caps = {"start_instance": True, "stop_instance": False, "reboot_instance": False, "renew_instance": False}
|
||||
_use_fake(monkeypatch, engine, provider_id, _FakeAdapter(caps=caps))
|
||||
with Session(engine) as s:
|
||||
actions = action_service.asset_actions(s, asset_id)["actions"]
|
||||
assert actions == {"start": True, "stop": False, "reboot": False, "renew": False,
|
||||
"dns_records": False, "dns_write": False}
|
||||
|
||||
|
||||
def test_asset_actions_unresolvable_returns_reason_not_raise(engine):
|
||||
"""探测端点不能抛 500:前端要拿它决定按钮是否渲染"""
|
||||
_seed_asset(engine)
|
||||
with Session(engine) as s:
|
||||
res = action_service.asset_actions(s, 99999)
|
||||
assert res["available"] is False
|
||||
assert res["reason"]
|
||||
assert res["actions"] == {}
|
||||
|
||||
|
||||
def test_asset_actions_for_domain(engine, monkeypatch):
|
||||
provider_id, _, asset_id = _seed_asset(engine, asset_type=AssetType.DOMAIN, external_id="z-uuid")
|
||||
_use_fake(monkeypatch, engine, provider_id, _FakeAdapter())
|
||||
with Session(engine) as s:
|
||||
res = action_service.asset_actions(s, asset_id)
|
||||
assert res["actions"]["dns_records"] is True
|
||||
assert res["actions"]["dns_write"] is True
|
||||
assert res["actions"]["start"] is False # 域名不该出现启停按钮
|
||||
|
||||
|
||||
# ----------------------------- DNS ----------------------------- #
|
||||
def test_list_dns_records(engine, monkeypatch):
|
||||
provider_id, _, asset_id = _seed_asset(engine, asset_type=AssetType.DOMAIN, external_id="z-1")
|
||||
_use_fake(monkeypatch, engine, provider_id, _FakeAdapter())
|
||||
with Session(engine) as s:
|
||||
res = action_service.list_dns_records(s, asset_id)
|
||||
assert res["zone_id"] == "z-1"
|
||||
assert res["records"][0]["content"] == "1.2.3.4"
|
||||
assert isinstance(res["records"][0], dict) # 必须是可 JSON 序列化的 dict
|
||||
|
||||
|
||||
def test_zone_id_falls_back_to_domain_detail(engine, monkeypatch):
|
||||
"""external_id 为空时回退 DomainDetail.domain_name——手工登记的域名也能管 DNS"""
|
||||
provider_id, _, asset_id = _seed_asset(engine, asset_type=AssetType.DOMAIN, external_id=None, name="fallback")
|
||||
_use_fake(monkeypatch, engine, provider_id, _FakeAdapter())
|
||||
with Session(engine) as s:
|
||||
s.add(DomainDetail(asset_id=asset_id, domain_name="example.com", registrar="x"))
|
||||
s.commit()
|
||||
with Session(engine) as s:
|
||||
assert action_service.list_dns_records(s, asset_id)["zone_id"] == "example.com"
|
||||
|
||||
|
||||
def test_zone_id_falls_back_to_asset_name(engine, monkeypatch):
|
||||
provider_id, _, asset_id = _seed_asset(engine, asset_type=AssetType.DOMAIN, external_id=None, name="named.com")
|
||||
_use_fake(monkeypatch, engine, provider_id, _FakeAdapter())
|
||||
with Session(engine) as s:
|
||||
assert action_service.list_dns_records(s, asset_id)["zone_id"] == "named.com"
|
||||
|
||||
|
||||
def test_dns_on_vps_asset_rejected(engine, monkeypatch):
|
||||
provider_id, _, asset_id = _seed_asset(engine, asset_type=AssetType.VPS)
|
||||
_use_fake(monkeypatch, engine, provider_id, _FakeAdapter())
|
||||
with Session(engine) as s:
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
action_service.list_dns_records(s, asset_id)
|
||||
assert ei.value.status_code == 400
|
||||
assert "仅域名资产" in ei.value.detail
|
||||
|
||||
|
||||
def test_create_dns_record(engine, monkeypatch):
|
||||
provider_id, _, asset_id = _seed_asset(engine, asset_type=AssetType.DOMAIN, external_id="z-1")
|
||||
adapter = _use_fake(monkeypatch, engine, provider_id,
|
||||
_FakeAdapter(result={"ok": True, "message": "created", "record_id": "r9"}))
|
||||
with Session(engine) as s:
|
||||
res = action_service.create_dns_record(s, asset_id, {"type": "A", "name": "a.com", "content": "1.2.3.4"})
|
||||
assert res["ok"] is True
|
||||
assert res["record_id"] == "r9"
|
||||
assert adapter.calls[-1][0] == "create_dns_record"
|
||||
assert adapter.calls[-1][1][0] == "z-1" # zone_id 必须是第一个参数
|
||||
|
||||
|
||||
def test_update_and_delete_dns_record(engine, monkeypatch):
|
||||
provider_id, _, asset_id = _seed_asset(engine, asset_type=AssetType.DOMAIN, external_id="z-1")
|
||||
adapter = _use_fake(monkeypatch, engine, provider_id, _FakeAdapter())
|
||||
with Session(engine) as s:
|
||||
assert action_service.update_dns_record(s, asset_id, "r1", {"type": "A", "name": "a", "content": "2.2.2.2"})["ok"]
|
||||
assert action_service.delete_dns_record(s, asset_id, "r1")["ok"]
|
||||
names = [c[0] for c in adapter.calls]
|
||||
assert "update_dns_record" in names
|
||||
assert "delete_dns_record" in names
|
||||
|
||||
|
||||
def test_dns_write_audited(engine, monkeypatch):
|
||||
provider_id, _, asset_id = _seed_asset(engine, asset_type=AssetType.DOMAIN, external_id="z-1")
|
||||
_use_fake(monkeypatch, engine, provider_id, _FakeAdapter())
|
||||
with Session(engine) as s:
|
||||
action_service.delete_dns_record(s, asset_id, "r1")
|
||||
events = _events(engine)
|
||||
assert any("记录删除" in e.message and e.source == "action" for e in events)
|
||||
|
||||
|
||||
def test_dns_capability_missing_rejected(engine, monkeypatch):
|
||||
provider_id, _, asset_id = _seed_asset(engine, asset_type=AssetType.DOMAIN, external_id="z-1")
|
||||
_use_fake(monkeypatch, engine, provider_id, _FakeAdapter(caps={"list_dns_records": False}))
|
||||
with Session(engine) as s:
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
action_service.list_dns_records(s, asset_id)
|
||||
assert ei.value.status_code == 400
|
||||
|
||||
|
||||
def test_dns_cloud_error_becomes_502(engine, monkeypatch):
|
||||
provider_id, _, asset_id = _seed_asset(engine, asset_type=AssetType.DOMAIN, external_id="z-1")
|
||||
_use_fake(monkeypatch, engine, provider_id, _FakeAdapter(raises=RuntimeError("timeout")))
|
||||
with Session(engine) as s:
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
action_service.list_dns_records(s, asset_id)
|
||||
assert ei.value.status_code == 502
|
||||
|
||||
|
||||
# ----------------------------- 路由层安全 ----------------------------- #
|
||||
def _route(path_suffix: str, method: str):
|
||||
for r in actions_router.router.routes:
|
||||
if r.path.endswith(path_suffix) and method in getattr(r, "methods", set()):
|
||||
return r
|
||||
raise AssertionError(f"路由未注册:{method} {path_suffix}")
|
||||
|
||||
|
||||
def test_all_write_routes_require_api_key():
|
||||
"""动作会真实改云端资源,写端点必须 fail-closed"""
|
||||
protected = [
|
||||
("/instance", "POST"),
|
||||
("/dns", "GET"),
|
||||
("/dns", "POST"),
|
||||
("/dns/{record_id}", "PUT"),
|
||||
("/dns/{record_id}", "DELETE"),
|
||||
]
|
||||
for suffix, method in protected:
|
||||
route = _route(suffix, method)
|
||||
deps = [d.dependency for d in route.dependencies]
|
||||
assert require_api_key in deps, f"{method} {suffix} 缺少 require_api_key"
|
||||
|
||||
|
||||
def test_capabilities_route_is_read_only():
|
||||
"""能力探测无副作用,前端要在渲染按钮前调用,不该被鉴权挡住"""
|
||||
route = _route("/capabilities", "GET")
|
||||
deps = [d.dependency for d in route.dependencies]
|
||||
assert require_api_key not in deps
|
||||
|
||||
|
||||
def test_router_prefix_and_registration():
|
||||
assert actions_router.router.prefix == "/api/actions"
|
||||
from app.main import app
|
||||
paths = app.openapi()["paths"]
|
||||
assert "/api/actions/{asset_id}/instance" in paths
|
||||
assert "/api/actions/{asset_id}/dns" in paths
|
||||
assert "/api/actions/{asset_id}/capabilities" in paths
|
||||
|
||||
|
||||
# ----------------------------- 请求 Schema ----------------------------- #
|
||||
@pytest.mark.parametrize("action", ["start", "stop", "reboot", "renew"])
|
||||
def test_valid_actions_accepted(action):
|
||||
assert InstanceActionRequest(action=action).action == action
|
||||
|
||||
|
||||
def test_invalid_action_rejected():
|
||||
with pytest.raises(Exception):
|
||||
InstanceActionRequest(action="terminate")
|
||||
|
||||
|
||||
def test_period_months_defaults_to_one():
|
||||
assert InstanceActionRequest(action="renew").period_months == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("months", [0, -1, 61, 9999])
|
||||
def test_period_months_out_of_range_rejected(months):
|
||||
"""必须有上界,否则一次误传 9999 就会真实扣掉一笔巨额续费账单"""
|
||||
with pytest.raises(Exception):
|
||||
InstanceActionRequest(action="renew", period_months=months)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("months", [1, 3, 12, 60])
|
||||
def test_period_months_in_range_accepted(months):
|
||||
assert InstanceActionRequest(action="renew", period_months=months).period_months == months
|
||||
|
||||
|
||||
def test_dns_record_type_uppercased():
|
||||
assert DNSRecordIn(name="a.com", type="cname", content="b.com").type == "CNAME"
|
||||
|
||||
|
||||
def test_dns_record_invalid_type_rejected():
|
||||
with pytest.raises(Exception):
|
||||
DNSRecordIn(name="a.com", type="HINFO", content="x")
|
||||
|
||||
|
||||
def test_dns_record_requires_name_and_content():
|
||||
with pytest.raises(Exception):
|
||||
DNSRecordIn(name="", type="A", content="1.2.3.4")
|
||||
with pytest.raises(Exception):
|
||||
DNSRecordIn(name="a.com", type="A", content="")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ttl", [0, 59, 86401])
|
||||
def test_dns_ttl_out_of_range_rejected(ttl):
|
||||
with pytest.raises(Exception):
|
||||
DNSRecordIn(name="a.com", type="A", content="1.2.3.4", ttl=ttl)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("priority", [-1, 65536])
|
||||
def test_dns_priority_out_of_range_rejected(priority):
|
||||
with pytest.raises(Exception):
|
||||
DNSRecordIn(name="a.com", type="MX", content="mx.x.com", priority=priority)
|
||||
|
||||
|
||||
def test_dns_record_optional_fields_default_none():
|
||||
r = DNSRecordIn(name="a.com", type="A", content="1.2.3.4")
|
||||
assert r.ttl is None and r.priority is None and r.proxied is None and r.comment is None
|
||||
|
||||
|
||||
# ----------------------------- 云端错误可读化 ----------------------------- #
|
||||
# 动机:裸 str(httpx.HTTPStatusError) 是多行文本,末尾还挂一段 MDN 链接
|
||||
# ("For more information check: https://developer.mozilla.org/..."),
|
||||
# 直接透到前端弹窗里既难看也没法排查。各厂商把原因放的位置还都不一样。
|
||||
|
||||
|
||||
def _http_error(status_code, *, json_body=None, text=None):
|
||||
req = httpx.Request("GET", "https://api.example.com/v2/x")
|
||||
if text is not None:
|
||||
resp = httpx.Response(status_code, request=req, text=text)
|
||||
elif json_body is not None:
|
||||
resp = httpx.Response(status_code, request=req, json=json_body)
|
||||
else:
|
||||
resp = httpx.Response(status_code, request=req, content=b"")
|
||||
return httpx.HTTPStatusError("boom", request=req, response=resp)
|
||||
|
||||
|
||||
def test_linode_errors_reason_extracted():
|
||||
msg = http_error_message(_http_error(401, json_body={"errors": [{"reason": "Invalid token"}]}))
|
||||
assert "HTTP 401" in msg
|
||||
assert "Invalid token" in msg
|
||||
assert "凭证无效或已过期" in msg
|
||||
|
||||
|
||||
def test_cloudflare_errors_message_extracted():
|
||||
msg = http_error_message(_http_error(403, json_body={"errors": [{"code": 10000, "message": "Invalid zone"}]}))
|
||||
assert "Invalid zone" in msg and "HTTP 403" in msg
|
||||
|
||||
|
||||
def test_tencent_nested_response_error_extracted():
|
||||
body = {"Response": {"Error": {"Code": "AuthFailure", "Message": "签名失败"}, "RequestId": "x"}}
|
||||
msg = http_error_message(_http_error(400, json_body=body))
|
||||
assert "签名失败" in msg and "HTTP 400" in msg
|
||||
|
||||
|
||||
def test_aws_xml_message_extracted():
|
||||
body = (
|
||||
'<?xml version="1.0"?><ErrorResponse><Error>'
|
||||
"<Code>UnauthorizedOperation</Code><Message>You are not authorized</Message>"
|
||||
"</Error></ErrorResponse>"
|
||||
)
|
||||
msg = http_error_message(_http_error(403, text=body))
|
||||
assert "You are not authorized" in msg
|
||||
assert "<" not in msg # 不能把 XML 标签漏给用户
|
||||
|
||||
|
||||
def test_vultr_bare_401_falls_back_to_hint():
|
||||
"""Vultr 401 常常没有 body,此时至少给方向性提示"""
|
||||
msg = http_error_message(_http_error(401))
|
||||
assert msg == "HTTP 401:凭证无效或已过期"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("code,hint", [(403, "凭证权限不足"), (404, "资源不存在"), (429, "触发平台限流")])
|
||||
def test_status_hints(code, hint):
|
||||
assert hint in http_error_message(_http_error(code))
|
||||
|
||||
|
||||
def test_500_without_body_has_no_hint():
|
||||
assert http_error_message(_http_error(500)) == "HTTP 500"
|
||||
|
||||
|
||||
def test_long_message_truncated():
|
||||
msg = http_error_message(_http_error(400, json_body={"message": "x" * 5000}))
|
||||
assert len(msg) < 300
|
||||
|
||||
|
||||
def test_hint_not_duplicated_when_body_already_says_it():
|
||||
msg = http_error_message(_http_error(401, json_body={"message": "凭证无效或已过期,请重新生成"}))
|
||||
assert msg.count("凭证无效或已过期") == 1
|
||||
|
||||
|
||||
def test_deeply_nested_body_does_not_recurse_forever():
|
||||
node = {"leaf": "too deep"}
|
||||
for _ in range(10):
|
||||
node = {"nested": node}
|
||||
msg = http_error_message(_http_error(400, json_body=node))
|
||||
assert msg.startswith("HTTP 400")
|
||||
|
||||
|
||||
def test_cloud_error_passes_non_http_exceptions_through():
|
||||
"""超时/连接错误本来就很短,不需要加工"""
|
||||
assert cloud_error_text(RuntimeError("connection reset")) == "connection reset"
|
||||
|
||||
|
||||
def test_http_status_error_502_detail_is_readable(engine, monkeypatch):
|
||||
"""回归:浏览器里实测到的原始 httpx 文本 + MDN 链接不能透到前端"""
|
||||
provider_id, _, asset_id = _seed_asset(engine)
|
||||
err = _http_error(401, json_body={"error": {"msg": "Invalid API token"}})
|
||||
_use_fake(monkeypatch, engine, provider_id, _FakeAdapter(raises=err))
|
||||
with Session(engine) as s:
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
action_service.perform_instance_action(s, asset_id, "start")
|
||||
assert ei.value.status_code == 502
|
||||
assert "Invalid API token" in ei.value.detail
|
||||
assert "developer.mozilla.org" not in ei.value.detail
|
||||
assert "\n" not in ei.value.detail
|
||||
|
||||
|
||||
def test_dns_http_error_detail_is_readable(engine, monkeypatch):
|
||||
provider_id, _, asset_id = _seed_asset(engine, asset_type=AssetType.DOMAIN, external_id="z-1")
|
||||
err = _http_error(404, json_body={"error": {"msg": "Domain not found"}})
|
||||
_use_fake(monkeypatch, engine, provider_id, _FakeAdapter(raises=err))
|
||||
with Session(engine) as s:
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
action_service.list_dns_records(s, asset_id)
|
||||
assert ei.value.status_code == 502
|
||||
assert "Domain not found" in ei.value.detail
|
||||
assert "developer.mozilla.org" not in ei.value.detail
|
||||
|
||||
|
||||
def test_dns_write_http_error_detail_is_readable(engine, monkeypatch):
|
||||
provider_id, _, asset_id = _seed_asset(engine, asset_type=AssetType.DOMAIN, external_id="z-1")
|
||||
err = _http_error(429, json_body={"message": "rate limit exceeded"})
|
||||
_use_fake(monkeypatch, engine, provider_id, _FakeAdapter(raises=err))
|
||||
with Session(engine) as s:
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
action_service.create_dns_record(s, asset_id, {"name": "a", "type": "A", "content": "1.2.3.4"})
|
||||
assert ei.value.status_code == 502
|
||||
assert "rate limit exceeded" in ei.value.detail
|
||||
assert "触发平台限流" in ei.value.detail
|
||||
|
||||
|
||||
def test_http_error_message_is_audited_not_raw(engine, monkeypatch):
|
||||
"""审计日志里也要是可读原因,否则事后排查只看到一串 httpx repr"""
|
||||
provider_id, _, asset_id = _seed_asset(engine)
|
||||
err = _http_error(403, json_body={"errors": [{"reason": "Forbidden by policy"}]})
|
||||
_use_fake(monkeypatch, engine, provider_id, _FakeAdapter(raises=err))
|
||||
with Session(engine) as s:
|
||||
with pytest.raises(HTTPException):
|
||||
action_service.perform_instance_action(s, asset_id, "reboot")
|
||||
text = " ".join(e.message for e in _events(engine))
|
||||
assert "Forbidden by policy" in text
|
||||
assert "developer.mozilla.org" not in text
|
||||
+39
-4
@@ -13,7 +13,13 @@ from app.adapters.ai import DeepSeekAdapter, KimiAdapter, OpenAIAdapter
|
||||
from app.adapters.aliyun import AliyunAdapter
|
||||
from app.adapters.cloudflare import CloudflareAdapter
|
||||
from app.adapters.digitalocean import DigitalOceanAdapter
|
||||
from app.adapters.tencent import TencentAdapter
|
||||
from app.adapters.tencent import (
|
||||
BILLING_HOST,
|
||||
CVM_HOST,
|
||||
DNSPOD_HOST,
|
||||
TencentAdapter,
|
||||
_service_of,
|
||||
)
|
||||
from app.adapters.vultr import VultrAdapter
|
||||
|
||||
|
||||
@@ -132,7 +138,9 @@ def test_aliyun_sign_sensitive_to_secret():
|
||||
# --------------------------- 腾讯云签名 --------------------------- #
|
||||
def test_tencent_authorization_format():
|
||||
adapter = TencentAdapter({"secret_id": "SID", "secret_key": "SKEY"})
|
||||
auth = adapter._build_authorization("DescribeInstances", "{}", 1704067200, "2024-01-01")
|
||||
auth = adapter._build_authorization(
|
||||
"DescribeInstances", "{}", 1704067200, "2024-01-01", CVM_HOST, "cvm"
|
||||
)
|
||||
assert auth.startswith("TC3-HMAC-SHA256 Credential=SID/")
|
||||
assert "2024-01-01/cvm/tc3_request" in auth
|
||||
assert "SignedHeaders=content-type;host;x-tc-action" in auth
|
||||
@@ -145,11 +153,38 @@ def test_tencent_authorization_format():
|
||||
|
||||
def test_tencent_sign_sensitive_to_payload():
|
||||
adapter = TencentAdapter({"secret_id": "SID", "secret_key": "SKEY"})
|
||||
a1 = adapter._build_authorization("DescribeInstances", '{"Limit":1}', 1704067200, "2024-01-01")
|
||||
a2 = adapter._build_authorization("DescribeInstances", '{"Limit":2}', 1704067200, "2024-01-01")
|
||||
a1 = adapter._build_authorization(
|
||||
"DescribeInstances", '{"Limit":1}', 1704067200, "2024-01-01", CVM_HOST, "cvm"
|
||||
)
|
||||
a2 = adapter._build_authorization(
|
||||
"DescribeInstances", '{"Limit":2}', 1704067200, "2024-01-01", CVM_HOST, "cvm"
|
||||
)
|
||||
assert a1 != a2
|
||||
|
||||
|
||||
def test_tencent_credential_scope_follows_service():
|
||||
"""多产品复用同一套签名:credential scope 与 host 中的 canonical host 必须随 service 变化
|
||||
|
||||
若 scope 写死成 cvm,调用 billing/domain/dnspod 会全部返回 AuthFailure.SignatureFailure。
|
||||
"""
|
||||
adapter = TencentAdapter({"secret_id": "SID", "secret_key": "SKEY"})
|
||||
cvm = adapter._build_authorization(
|
||||
"DescribeInstances", "{}", 1704067200, "2024-01-01", CVM_HOST, "cvm"
|
||||
)
|
||||
billing = adapter._build_authorization(
|
||||
"DescribeAccountBalance", "{}", 1704067200, "2024-01-01", BILLING_HOST, "billing"
|
||||
)
|
||||
assert "2024-01-01/cvm/tc3_request" in cvm
|
||||
assert "2024-01-01/billing/tc3_request" in billing
|
||||
assert cvm != billing
|
||||
|
||||
|
||||
def test_tencent_service_of_host():
|
||||
assert _service_of(CVM_HOST) == "cvm"
|
||||
assert _service_of(BILLING_HOST) == "billing"
|
||||
assert _service_of(DNSPOD_HOST) == "dnspod"
|
||||
|
||||
|
||||
# --------------------------- 注册表路由 --------------------------- #
|
||||
def test_registry_supported_types():
|
||||
for t in [
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
"""资产导出 / 导入的往返一致性测试
|
||||
|
||||
export_assets 自称「用于数据迁移/备份」,那它导出的字段就必须在 import_assets
|
||||
里真的落库。external_id 是 sync_service 的去重键 (provider_id, external_id, asset_type),
|
||||
AssetCreate/AssetUpdate 不收这个字段——导入时若被 pydantic 静默丢弃,
|
||||
迁移后首次同步会把所有云端资产当成新增再建一份,台账直接翻倍。
|
||||
|
||||
运行:.venv/bin/pytest tests/test_asset_export_import.py -v
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from sqlmodel import Session, SQLModel, create_engine, select
|
||||
|
||||
import app.models.asset # noqa: F401
|
||||
import app.models.credential # noqa: F401
|
||||
import app.models.monitor # noqa: F401
|
||||
import app.models.provider # noqa: F401
|
||||
import app.models.ssl # noqa: F401
|
||||
from app.models.asset import Asset, AssetType, DomainDetail, VPSDetail
|
||||
from app.services.asset_service import export_assets, import_assets
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def engine(tmp_path, monkeypatch):
|
||||
eng = create_engine(
|
||||
f"sqlite:///{tmp_path / 'assets.db'}", connect_args={"check_same_thread": False}
|
||||
)
|
||||
SQLModel.metadata.create_all(eng)
|
||||
# _cleanup_metrics_for_asset 在函数内部取 metrics_engine,指到临时库避免污染本地 data/
|
||||
monkeypatch.setattr("app.database.metrics_engine", eng)
|
||||
yield eng
|
||||
eng.dispose()
|
||||
|
||||
|
||||
def _seed_vps(engine, name="web-01", external_id="i-abc123"):
|
||||
with Session(engine) as s:
|
||||
a = Asset(
|
||||
name=name, asset_type=AssetType.VPS, provider="vultr",
|
||||
external_id=external_id, cost=5.0, currency="USD",
|
||||
)
|
||||
s.add(a)
|
||||
s.commit()
|
||||
s.refresh(a)
|
||||
s.add(VPSDetail(asset_id=a.id, ip_address="1.2.3.4"))
|
||||
s.commit()
|
||||
return a.id
|
||||
|
||||
|
||||
def _seed_domain(engine, name="example.com", external_id="example.com"):
|
||||
with Session(engine) as s:
|
||||
a = Asset(name=name, asset_type=AssetType.DOMAIN, provider="namesilo", external_id=external_id)
|
||||
s.add(a)
|
||||
s.commit()
|
||||
s.refresh(a)
|
||||
s.add(DomainDetail(asset_id=a.id, domain_name=name))
|
||||
s.commit()
|
||||
return a.id
|
||||
|
||||
|
||||
def _external_ids(engine):
|
||||
with Session(engine) as s:
|
||||
return {a.name: a.external_id for a in s.exec(select(Asset)).all()}
|
||||
|
||||
|
||||
def _wipe(engine):
|
||||
"""清空资产与详情行
|
||||
|
||||
必须连详情一起删:SQLite 默认不强制外键,只删 Asset 会留下孤儿详情行;
|
||||
而 assets.id 是 INTEGER PRIMARY KEY(无 AUTOINCREMENT),删掉后新建资产
|
||||
必然复用同一个 id,撞上孤儿行的 UNIQUE(asset_id)。
|
||||
"""
|
||||
with Session(engine) as s:
|
||||
for model in (VPSDetail, DomainDetail):
|
||||
for row in s.exec(select(model)).all():
|
||||
s.delete(row)
|
||||
for a in s.exec(select(Asset)).all():
|
||||
s.delete(a)
|
||||
s.commit()
|
||||
|
||||
|
||||
# ----------------------------- 导出 ----------------------------- #
|
||||
def test_export_includes_external_id(engine):
|
||||
_seed_vps(engine)
|
||||
with Session(engine) as s:
|
||||
data = export_assets(s)
|
||||
assert data["count"] == 1
|
||||
assert data["assets"][0]["external_id"] == "i-abc123"
|
||||
|
||||
|
||||
def test_export_carries_detail_and_count(engine):
|
||||
_seed_vps(engine)
|
||||
_seed_domain(engine)
|
||||
with Session(engine) as s:
|
||||
data = export_assets(s)
|
||||
assert data["count"] == 2
|
||||
by_name = {a["name"]: a for a in data["assets"]}
|
||||
assert by_name["web-01"]["vps_detail"]["ip_address"] == "1.2.3.4"
|
||||
assert by_name["example.com"]["domain_detail"]["domain_name"] == "example.com"
|
||||
|
||||
|
||||
# ----------------------------- 导入落库 ----------------------------- #
|
||||
def test_import_creates_asset_with_external_id(engine):
|
||||
"""空库导入:external_id 必须落到行上,不能被 AssetCreate 静默吞掉"""
|
||||
_seed_vps(engine)
|
||||
with Session(engine) as s:
|
||||
payload = export_assets(s)["assets"]
|
||||
_wipe(engine)
|
||||
with Session(engine) as s:
|
||||
result = import_assets(s, payload)
|
||||
assert result["errors"] == []
|
||||
assert result["created"] == 1
|
||||
assert _external_ids(engine)["web-01"] == "i-abc123"
|
||||
|
||||
|
||||
def test_import_updates_existing_and_keeps_external_id(engine):
|
||||
"""同名同类型同平台 -> 走更新分支,external_id 不能丢"""
|
||||
asset_id = _seed_vps(engine)
|
||||
with Session(engine) as s:
|
||||
payload = export_assets(s)["assets"]
|
||||
with Session(engine) as s:
|
||||
a = s.get(Asset, asset_id)
|
||||
a.external_id = None # 模拟历史数据没有 external_id
|
||||
s.add(a)
|
||||
s.commit()
|
||||
with Session(engine) as s:
|
||||
result = import_assets(s, payload)
|
||||
assert result["updated"] == 1 and result["created"] == 0
|
||||
assert _external_ids(engine)["web-01"] == "i-abc123"
|
||||
|
||||
|
||||
def test_import_does_not_clobber_a_fresher_external_id(engine):
|
||||
"""已有值时不覆盖:导入包可能比线上同步到的旧,覆盖会让去重键指向已销毁的实例"""
|
||||
asset_id = _seed_vps(engine, external_id="i-old")
|
||||
with Session(engine) as s:
|
||||
payload = export_assets(s)["assets"]
|
||||
with Session(engine) as s:
|
||||
a = s.get(Asset, asset_id)
|
||||
a.external_id = "i-new"
|
||||
s.add(a)
|
||||
s.commit()
|
||||
with Session(engine) as s:
|
||||
import_assets(s, payload)
|
||||
assert _external_ids(engine)["web-01"] == "i-new"
|
||||
|
||||
|
||||
def test_import_ignores_computed_fields(engine):
|
||||
"""days_to_expiry / provider_name / account_name 是派生值,导入时必须跳过而不是写库"""
|
||||
_seed_vps(engine)
|
||||
with Session(engine) as s:
|
||||
payload = export_assets(s)["assets"]
|
||||
payload[0]["days_to_expiry"] = 999
|
||||
payload[0]["provider_name"] = "不存在的平台"
|
||||
payload[0]["account_name"] = "不存在的账号"
|
||||
_wipe(engine)
|
||||
with Session(engine) as s:
|
||||
result = import_assets(s, payload)
|
||||
assert result["errors"] == []
|
||||
assert result["created"] == 1
|
||||
|
||||
|
||||
def test_import_without_external_id_key_still_works(engine):
|
||||
"""老导出包没有这个字段,不能因此报错"""
|
||||
payload = [{
|
||||
"name": "legacy", "asset_type": "vps", "provider": "vultr",
|
||||
"vps_detail": {"ip_address": "9.9.9.9"},
|
||||
}]
|
||||
with Session(engine) as s:
|
||||
result = import_assets(s, payload)
|
||||
assert result["created"] == 1 and result["errors"] == []
|
||||
assert _external_ids(engine)["legacy"] is None
|
||||
|
||||
|
||||
def test_import_all_or_nothing_on_failure(engine):
|
||||
"""单事务语义:一条失败则整体回滚,不能留下半截数据"""
|
||||
_seed_vps(engine, name="good")
|
||||
payload = [
|
||||
{"name": "ok-1", "asset_type": "vps", "provider": "vultr",
|
||||
"external_id": "i-1", "vps_detail": {"ip_address": "1.1.1.1"}},
|
||||
{"name": "bad-1", "asset_type": "vps", "provider": "vultr"}, # 缺 vps_detail -> 400
|
||||
]
|
||||
with Session(engine) as s:
|
||||
result = import_assets(s, payload)
|
||||
assert result["created"] == 0 and result["updated"] == 0
|
||||
assert len(result["errors"]) == 1
|
||||
names = _external_ids(engine)
|
||||
assert "ok-1" not in names # 成功那条也回滚了
|
||||
assert set(names) == {"good"}
|
||||
|
||||
|
||||
def test_round_trip_preserves_everything(engine):
|
||||
"""完整往返:导出 -> 清空 -> 导入,external_id 与详情都在"""
|
||||
_seed_vps(engine, name="web-01", external_id="i-111")
|
||||
_seed_domain(engine, name="example.com", external_id="example.com")
|
||||
with Session(engine) as s:
|
||||
payload = export_assets(s)["assets"]
|
||||
_wipe(engine)
|
||||
with Session(engine) as s:
|
||||
result = import_assets(s, payload)
|
||||
assert result["errors"] == [] and result["created"] == 2
|
||||
assert _external_ids(engine) == {"web-01": "i-111", "example.com": "example.com"}
|
||||
with Session(engine) as s:
|
||||
vps = s.exec(select(Asset).where(Asset.name == "web-01")).one()
|
||||
detail = s.exec(select(VPSDetail).where(VPSDetail.asset_id == vps.id)).one()
|
||||
assert detail.ip_address == "1.2.3.4"
|
||||
@@ -0,0 +1,716 @@
|
||||
"""新增适配器测试:AWS(SigV4)/ Linode / Namesilo
|
||||
|
||||
重点覆盖三类真实会炸的问题:
|
||||
|
||||
1. AWS SigV4 的派生签名密钥必须与官方测试向量逐字节一致——手写签名没有 SDK 兜底,
|
||||
错一步就是满屏 SignatureDoesNotMatch,且无法从错误信息反推是哪一步错了。
|
||||
2. credential scope / canonical host 必须随 service 变化(ec2 / route53 / route53domains
|
||||
共用一套签名代码),写死成 ec2 会让 Route53 全线鉴权失败。
|
||||
3. EC2 查询协议转 JSON 后的驼峰包装:单元素时 AWS 返回 dict 而非 list,
|
||||
不做归一会直接 TypeError。
|
||||
|
||||
运行:.venv/bin/pytest tests/test_new_adapters.py -v
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.adapters.aws import AWSAdapter, _signing_key
|
||||
from app.adapters.linode import LinodeAdapter
|
||||
from app.adapters.namesilo import NamesiloAdapter
|
||||
|
||||
|
||||
# ----------------------------- AWS SigV4 ----------------------------- #
|
||||
def test_aws_signing_key_matches_official_vector():
|
||||
"""AWS 官方文档测试向量(Signature Version 4 派生签名密钥示例)
|
||||
|
||||
这是手写 SigV4 唯一可靠的正确性锚点:向量对得上,说明
|
||||
AWS4 前缀、四级 HMAC 链、aws4_request 终止符全部正确。
|
||||
"""
|
||||
key = _signing_key(
|
||||
"wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY",
|
||||
"20120215",
|
||||
"us-east-1",
|
||||
"iam",
|
||||
)
|
||||
assert key.hex() == "f4780e2d9f65fa895f9c67b32ce1baf0b0d8a43505a000a1a9e090d414db404d"
|
||||
|
||||
|
||||
def test_aws_signing_key_sensitive_to_every_input():
|
||||
"""任一分量变化都必须产出不同密钥(漏拼 region/service 是常见错法)"""
|
||||
base = _signing_key("secret", "20240101", "us-east-1", "ec2")
|
||||
assert base != _signing_key("secret2", "20240101", "us-east-1", "ec2")
|
||||
assert base != _signing_key("secret", "20240102", "us-east-1", "ec2")
|
||||
assert base != _signing_key("secret", "20240101", "eu-west-1", "ec2")
|
||||
assert base != _signing_key("secret", "20240101", "us-east-1", "route53")
|
||||
|
||||
|
||||
def _capture_signed_headers(adapter: AWSAdapter, **kwargs) -> dict:
|
||||
"""拦掉 httpx,捕获 _signed_request 实际发出的请求头与 body"""
|
||||
captured = {}
|
||||
|
||||
def fake_request(method, url, content=None, headers=None):
|
||||
captured.update(method=method, url=url, content=content, headers=headers)
|
||||
resp = MagicMock()
|
||||
resp.content = b"{}"
|
||||
resp.json.return_value = {}
|
||||
resp.raise_for_status.return_value = None
|
||||
return resp
|
||||
|
||||
client = MagicMock()
|
||||
client.request.side_effect = fake_request
|
||||
cm = MagicMock()
|
||||
cm.__enter__.return_value = client
|
||||
with patch("httpx.Client", return_value=cm):
|
||||
adapter._signed_request(**kwargs)
|
||||
return captured
|
||||
|
||||
|
||||
def test_aws_authorization_header_format():
|
||||
adapter = AWSAdapter({"access_key_id": "AKIDEXAMPLE", "secret_access_key": "SECRET"})
|
||||
cap = _capture_signed_headers(
|
||||
adapter, service="ec2", host="ec2.us-east-1.amazonaws.com",
|
||||
region="us-east-1", method="POST", path="/", body="Action=DescribeRegions",
|
||||
)
|
||||
auth = cap["headers"]["Authorization"]
|
||||
assert auth.startswith("AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/")
|
||||
assert "/us-east-1/ec2/aws4_request" in auth
|
||||
assert "SignedHeaders=content-type;host;x-amz-date" in auth
|
||||
signature = auth.split("Signature=")[1]
|
||||
assert len(signature) == 64
|
||||
int(signature, 16)
|
||||
|
||||
|
||||
def test_aws_credential_scope_follows_service():
|
||||
"""ec2 / route53 / route53domains 共用签名代码,scope 必须随 service 变
|
||||
|
||||
写死成 ec2 的话 Route53 全部返回 SignatureDoesNotMatch。
|
||||
"""
|
||||
adapter = AWSAdapter({"access_key_id": "AKID", "secret_access_key": "SECRET"})
|
||||
ec2 = _capture_signed_headers(
|
||||
adapter, service="ec2", host="ec2.us-east-1.amazonaws.com",
|
||||
region="us-east-1", method="POST", path="/", body="",
|
||||
)["headers"]["Authorization"]
|
||||
r53 = _capture_signed_headers(
|
||||
adapter, service="route53", host="route53.amazonaws.com",
|
||||
region="us-east-1", method="GET", path="/2013-04-01/hostedzone/Z1/rrset", body="",
|
||||
)["headers"]["Authorization"]
|
||||
assert "/us-east-1/ec2/aws4_request" in ec2
|
||||
assert "/us-east-1/route53/aws4_request" in r53
|
||||
assert ec2 != r53
|
||||
|
||||
|
||||
def test_aws_route53_uses_rest_json_content_type():
|
||||
"""Route53 的 /2013-04-01/* 是 REST-JSON(按路径路由,无 X-Amz-Target),
|
||||
用 x-amz-json-1.1 会被当成 JSON 协议服务而报 MissingAuthenticationToken"""
|
||||
adapter = AWSAdapter({"access_key_id": "AKID", "secret_access_key": "SECRET"})
|
||||
with patch.object(AWSAdapter, "_signed_request", return_value={"ResourceRecordSets": []}) as m:
|
||||
adapter.list_dns_records("Z123")
|
||||
assert m.call_args.kwargs["content_type"] == "application/json"
|
||||
assert m.call_args.kwargs["service"] == "route53"
|
||||
|
||||
|
||||
def test_aws_host_header_excluded_from_send():
|
||||
"""host 参与签名但不能手工发送,否则与 httpx 自动设置的值冲突"""
|
||||
adapter = AWSAdapter({"access_key_id": "AKID", "secret_access_key": "SECRET"})
|
||||
cap = _capture_signed_headers(
|
||||
adapter, service="ec2", host="ec2.us-east-1.amazonaws.com",
|
||||
region="us-east-1", method="POST", path="/", body="",
|
||||
)
|
||||
assert "host" not in cap["headers"]
|
||||
# 但签名串里含 host(SignedHeaders 列出了它)
|
||||
assert "host" in cap["headers"]["Authorization"]
|
||||
|
||||
|
||||
def test_aws_signature_sensitive_to_payload():
|
||||
adapter = AWSAdapter({"access_key_id": "AKID", "secret_access_key": "SECRET"})
|
||||
a = _capture_signed_headers(
|
||||
adapter, service="ec2", host="h", region="us-east-1",
|
||||
method="POST", path="/", body="Action=A",
|
||||
)["headers"]["Authorization"]
|
||||
b = _capture_signed_headers(
|
||||
adapter, service="ec2", host="h", region="us-east-1",
|
||||
method="POST", path="/", body="Action=B",
|
||||
)["headers"]["Authorization"]
|
||||
assert a != b
|
||||
|
||||
|
||||
def test_aws_region_defaults_and_override():
|
||||
assert AWSAdapter({"access_key_id": "a", "secret_access_key": "b"})._region() == "us-east-1"
|
||||
assert AWSAdapter({"region": "ap-southeast-1"})._region() == "ap-southeast-1"
|
||||
|
||||
|
||||
# ----------------------------- AWS EC2 响应解析 ----------------------------- #
|
||||
def _ec2_response(instances, token=None):
|
||||
return {
|
||||
"reservationSet": {"item": [{"instancesSet": {"item": instances}}]},
|
||||
**({"nextToken": token} if token else {}),
|
||||
}
|
||||
|
||||
|
||||
def test_aws_list_vps_unwraps_camelcase():
|
||||
adapter = AWSAdapter({"access_key_id": "a", "secret_access_key": "b"})
|
||||
payload = _ec2_response([
|
||||
{
|
||||
"instanceId": "i-123",
|
||||
"instanceState": {"name": "running"},
|
||||
"ipAddress": "1.2.3.4",
|
||||
"privateIpAddress": "10.0.0.5",
|
||||
"placement": {"availabilityZone": "us-east-1a"},
|
||||
"instanceLifecycle": "spot",
|
||||
"tagSet": {"item": [{"key": "Name", "value": "web-01"}]},
|
||||
}
|
||||
])
|
||||
with patch.object(AWSAdapter, "_ec2_call", return_value=payload):
|
||||
vps = adapter.list_vps()
|
||||
assert len(vps) == 1
|
||||
v = vps[0]
|
||||
assert v.external_id == "i-123"
|
||||
assert v.name == "web-01" # 取 tagSet 的 Name,不是 instanceId
|
||||
assert v.ip_address == "1.2.3.4" # 公网优先
|
||||
assert v.region == "us-east-1a"
|
||||
assert v.status == "active"
|
||||
assert v.charge_type == "spot"
|
||||
assert v.currency == "USD"
|
||||
|
||||
|
||||
def test_aws_list_vps_single_reservation_is_dict():
|
||||
"""EC2 查询协议转 JSON 后,单元素返回 dict 而不是 list——不归一会 TypeError"""
|
||||
adapter = AWSAdapter({"access_key_id": "a", "secret_access_key": "b"})
|
||||
payload = {
|
||||
"reservationSet": {
|
||||
"item": { # 注意:单个 reservation 时是 dict
|
||||
"instancesSet": {
|
||||
"item": { # 单个 instance 时也是 dict
|
||||
"instanceId": "i-only",
|
||||
"instanceState": {"name": "stopped"},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
with patch.object(AWSAdapter, "_ec2_call", return_value=payload):
|
||||
vps = adapter.list_vps()
|
||||
assert len(vps) == 1
|
||||
assert vps[0].external_id == "i-only"
|
||||
assert vps[0].status == "stopped"
|
||||
# 无 tagSet 时回退到 instanceId
|
||||
assert vps[0].name == "i-only"
|
||||
# 无公网 IP 时回退到私网
|
||||
assert vps[0].ip_address is None
|
||||
|
||||
|
||||
def test_aws_list_vps_filters_terminated():
|
||||
"""terminated/shutting-down 不入台账,否则台账会越堆越多已销毁实例"""
|
||||
adapter = AWSAdapter({"access_key_id": "a", "secret_access_key": "b"})
|
||||
payload = _ec2_response([
|
||||
{"instanceId": "i-live", "instanceState": {"name": "running"}},
|
||||
{"instanceId": "i-dead", "instanceState": {"name": "terminated"}},
|
||||
{"instanceId": "i-dying", "instanceState": {"name": "shutting-down"}},
|
||||
{"instanceId": "i-pending", "instanceState": {"name": "pending"}},
|
||||
])
|
||||
with patch.object(AWSAdapter, "_ec2_call", return_value=payload):
|
||||
vps = adapter.list_vps()
|
||||
ids = {v.external_id for v in vps}
|
||||
assert ids == {"i-live", "i-pending"}
|
||||
|
||||
|
||||
def test_aws_list_vps_pagination():
|
||||
adapter = AWSAdapter({"access_key_id": "a", "secret_access_key": "b"})
|
||||
pages = [
|
||||
_ec2_response([{"instanceId": "i-1", "instanceState": {"name": "running"}}], token="tok2"),
|
||||
_ec2_response([{"instanceId": "i-2", "instanceState": {"name": "running"}}]),
|
||||
]
|
||||
with patch.object(AWSAdapter, "_ec2_call", side_effect=pages) as m:
|
||||
vps = adapter.list_vps()
|
||||
assert [v.external_id for v in vps] == ["i-1", "i-2"]
|
||||
assert m.call_count == 2
|
||||
# 第二页必须带上 NextToken
|
||||
assert m.call_args_list[1].args[0]["NextToken"] == "tok2"
|
||||
|
||||
|
||||
def test_aws_instance_action_sends_instance_id_list():
|
||||
"""EC2 的启停接口参数是 InstanceId.1(列表编码),不是 InstanceId"""
|
||||
adapter = AWSAdapter({"access_key_id": "a", "secret_access_key": "b"})
|
||||
with patch.object(AWSAdapter, "_ec2_call", return_value={}) as m:
|
||||
assert adapter.start_instance("i-9")["ok"] is True
|
||||
params = m.call_args.args[0]
|
||||
assert params["Action"] == "StartInstances"
|
||||
assert params["InstanceId.1"] == "i-9"
|
||||
|
||||
|
||||
def test_aws_action_failure_returns_not_raises():
|
||||
"""动作失败要回 ok=False + 原因,不能抛异常(上层按结果渲染提示)"""
|
||||
adapter = AWSAdapter({"access_key_id": "a", "secret_access_key": "b"})
|
||||
with patch.object(AWSAdapter, "_ec2_call", side_effect=RuntimeError("boom")):
|
||||
res = adapter.reboot_instance("i-1")
|
||||
assert res["ok"] is False
|
||||
assert "boom" in res["message"]
|
||||
|
||||
|
||||
def test_aws_has_no_account_balance():
|
||||
"""刻意不实现 get_account:AWS 没有「账户余额」概念,硬凑数字比报不支持更误导"""
|
||||
caps = AWSAdapter({"access_key_id": "a", "secret_access_key": "b"}).capabilities()
|
||||
assert caps["get_account"] is False
|
||||
assert caps["list_vps"] is True
|
||||
assert caps["renew_instance"] is False # 按需计费无续费概念
|
||||
|
||||
|
||||
# ----------------------------- AWS Route53 ----------------------------- #
|
||||
def test_aws_hosted_zone_id_strips_prefix():
|
||||
assert AWSAdapter._hosted_zone_id("/hostedzone/Z123ABC") == "Z123ABC"
|
||||
assert AWSAdapter._hosted_zone_id("Z123ABC") == "Z123ABC"
|
||||
|
||||
|
||||
def test_aws_epoch_to_date():
|
||||
# 2027-01-01T00:00:00Z == 1798761600
|
||||
assert AWSAdapter._epoch_to_date(1798761600) == date(2027, 1, 1)
|
||||
assert AWSAdapter._epoch_to_date("1798761600") == date(2027, 1, 1)
|
||||
assert AWSAdapter._epoch_to_date(None) is None
|
||||
assert AWSAdapter._epoch_to_date("garbage") is None
|
||||
|
||||
|
||||
def test_aws_list_domains_from_route53domains():
|
||||
adapter = AWSAdapter({"access_key_id": "a", "secret_access_key": "b"})
|
||||
payload = {"Domains": [{"DomainName": "example.com", "Expiry": 1798761600}]}
|
||||
with patch.object(AWSAdapter, "_r53domains_call", return_value=payload):
|
||||
domains = adapter.list_domains()
|
||||
assert len(domains) == 1
|
||||
d = domains[0]
|
||||
assert d.domain_name == "example.com"
|
||||
assert isinstance(d.expiry_date, date) # 必须是 date,否则写库抛 TypeError 整批回滚
|
||||
assert d.expiry_date == date(2027, 1, 1)
|
||||
assert d.registrar == "aws-route53"
|
||||
|
||||
|
||||
def test_aws_list_dns_records_normalization():
|
||||
adapter = AWSAdapter({"access_key_id": "a", "secret_access_key": "b"})
|
||||
payload = {
|
||||
"ResourceRecordSets": [
|
||||
{
|
||||
"Name": "www.example.com.", # Route53 总是带尾点
|
||||
"Type": "A",
|
||||
"TTL": 300,
|
||||
"ResourceRecords": [{"Value": "1.2.3.4"}, {"Value": "5.6.7.8"}],
|
||||
}
|
||||
]
|
||||
}
|
||||
with patch.object(AWSAdapter, "_signed_request", return_value=payload):
|
||||
records = adapter.list_dns_records("Z1")
|
||||
assert len(records) == 1
|
||||
r = records[0]
|
||||
assert r.name == "www.example.com" # 尾点被剥掉
|
||||
assert r.record_id == "www.example.com.|A" # record_id 用原始带尾点的 Name
|
||||
assert r.content == "1.2.3.4, 5.6.7.8" # 多值合并
|
||||
assert r.ttl == 300
|
||||
|
||||
|
||||
def test_aws_dns_write_wraps_values_in_array():
|
||||
"""Route53 的 ResourceRecords 必须是数组,单值也要包一层"""
|
||||
adapter = AWSAdapter({"access_key_id": "a", "secret_access_key": "b"})
|
||||
with patch.object(AWSAdapter, "_signed_request", return_value={}) as m:
|
||||
res = adapter.create_dns_record("Z1", {"name": "a.example.com", "type": "A", "content": "1.2.3.4", "ttl": 600})
|
||||
assert res["ok"] is True
|
||||
import json as _json
|
||||
body = _json.loads(m.call_args.kwargs["body"])
|
||||
rrs = body["Changes"][0]["ResourceRecordSet"]
|
||||
assert rrs["ResourceRecords"] == [{"Value": "1.2.3.4"}]
|
||||
assert rrs["TTL"] == 600
|
||||
assert body["Changes"][0]["Action"] == "CREATE"
|
||||
|
||||
|
||||
def test_aws_delete_requires_name_type_record_id():
|
||||
"""Route53 删除必须提交完整记录集,record_id 缺 TYPE 无法构造"""
|
||||
adapter = AWSAdapter({"access_key_id": "a", "secret_access_key": "b"})
|
||||
res = adapter.delete_dns_record("Z1", "no-pipe-here")
|
||||
assert res["ok"] is False
|
||||
|
||||
|
||||
def test_aws_delete_reads_existing_record_first():
|
||||
"""DELETE 需要带上现存的值与 TTL,必须先读回记录"""
|
||||
adapter = AWSAdapter({"access_key_id": "a", "secret_access_key": "b"})
|
||||
payload = {
|
||||
"ResourceRecordSets": [
|
||||
{"Name": "a.example.com.", "Type": "A", "TTL": 300, "ResourceRecords": [{"Value": "1.2.3.4"}]}
|
||||
]
|
||||
}
|
||||
with patch.object(AWSAdapter, "_signed_request", side_effect=[payload, {}]) as m:
|
||||
res = adapter.delete_dns_record("Z1", "a.example.com.|A")
|
||||
assert res["ok"] is True
|
||||
import json as _json
|
||||
body = _json.loads(m.call_args_list[1].kwargs["body"])
|
||||
assert body["Changes"][0]["Action"] == "DELETE"
|
||||
assert body["Changes"][0]["ResourceRecordSet"]["ResourceRecords"] == [{"Value": "1.2.3.4"}]
|
||||
|
||||
|
||||
def test_aws_delete_missing_record():
|
||||
adapter = AWSAdapter({"access_key_id": "a", "secret_access_key": "b"})
|
||||
with patch.object(AWSAdapter, "_signed_request", return_value={"ResourceRecordSets": []}):
|
||||
res = adapter.delete_dns_record("Z1", "gone.example.com.|A")
|
||||
assert res["ok"] is False
|
||||
assert "不存在" in res["message"]
|
||||
|
||||
|
||||
def test_aws_test_connection_reports_region_count():
|
||||
adapter = AWSAdapter({"access_key_id": "a", "secret_access_key": "b"})
|
||||
payload = {"regionInfo": {"item": [{"regionName": "us-east-1"}, {"regionName": "eu-west-1"}]}}
|
||||
with patch.object(AWSAdapter, "_ec2_call", return_value=payload):
|
||||
res = adapter.test_connection()
|
||||
assert res["ok"] is True
|
||||
assert "2" in res["message"]
|
||||
|
||||
|
||||
def test_aws_test_connection_failure_is_graceful():
|
||||
adapter = AWSAdapter({"access_key_id": "a", "secret_access_key": "b"})
|
||||
resp = httpx.Response(403, request=httpx.Request("POST", "https://ec2.us-east-1.amazonaws.com/"))
|
||||
with patch.object(AWSAdapter, "_ec2_call", side_effect=httpx.HTTPStatusError("denied", request=resp.request, response=resp)):
|
||||
res = adapter.test_connection()
|
||||
assert res["ok"] is False
|
||||
assert "403" in res["message"]
|
||||
|
||||
|
||||
# ----------------------------- Linode ----------------------------- #
|
||||
def test_linode_list_vps_normalization():
|
||||
adapter = LinodeAdapter({"api_key": "x"})
|
||||
payload = {
|
||||
"pages": 1,
|
||||
"data": [
|
||||
{
|
||||
"id": 12345,
|
||||
"label": "prod-web",
|
||||
"status": "running",
|
||||
"region": "us-east",
|
||||
"image": "linode/ubuntu22.04",
|
||||
"ipv4": ["1.2.3.4", "10.0.0.1"],
|
||||
"specs": {"memory": 4096, "vcpus": 2, "disk": 81920},
|
||||
}
|
||||
],
|
||||
}
|
||||
with patch.object(LinodeAdapter, "_get", return_value=payload):
|
||||
vps = adapter.list_vps()
|
||||
assert len(vps) == 1
|
||||
v = vps[0]
|
||||
assert v.external_id == "12345"
|
||||
assert v.ip_address == "1.2.3.4" # 取第一个 IPv4
|
||||
assert v.memory_gb == 4.0 # MB -> GB
|
||||
assert v.cpu_cores == 2
|
||||
assert v.disk_gb == 81920
|
||||
assert v.status == "active"
|
||||
assert v.charge_type == "postpaid" # 按小时计费,不会有到期日
|
||||
assert v.expiry_date is None
|
||||
|
||||
|
||||
def test_linode_status_mapping():
|
||||
adapter = LinodeAdapter({"api_key": "x"})
|
||||
for raw, expected in (("offline", "stopped"), ("shutting_down", "stopped"), ("booting", "active")):
|
||||
payload = {"pages": 1, "data": [{"id": 1, "label": "n", "status": raw, "specs": {}}]}
|
||||
with patch.object(LinodeAdapter, "_get", return_value=payload):
|
||||
assert adapter.list_vps()[0].status == expected
|
||||
|
||||
|
||||
def test_linode_negative_balance_is_credit():
|
||||
"""Linode 的 balance 为负表示账户有预付余额,正数才是欠款——不要取绝对值"""
|
||||
adapter = LinodeAdapter({"api_key": "x"})
|
||||
with patch.object(LinodeAdapter, "_get", return_value={"balance": -25.5, "email": "me@x.com"}):
|
||||
acc = adapter.get_account()
|
||||
assert acc.balance == -25.5
|
||||
assert acc.currency == "USD"
|
||||
|
||||
|
||||
def test_linode_pagination_uses_pages():
|
||||
adapter = LinodeAdapter({"api_key": "x"})
|
||||
pages = [
|
||||
{"pages": 2, "data": [{"id": 1, "label": "a", "status": "running", "specs": {}}]},
|
||||
{"pages": 2, "data": [{"id": 2, "label": "b", "status": "running", "specs": {}}]},
|
||||
]
|
||||
with patch.object(LinodeAdapter, "_get", side_effect=pages) as m:
|
||||
vps = adapter.list_vps()
|
||||
assert [v.external_id for v in vps] == ["1", "2"]
|
||||
assert "page=2" in m.call_args_list[1].args[0]
|
||||
|
||||
|
||||
def test_linode_domains_expiry_nullable():
|
||||
"""仅 Linode 注册的域名有 expiry;纯 DNS 托管的为 null,不能炸"""
|
||||
adapter = LinodeAdapter({"api_key": "x"})
|
||||
payload = {
|
||||
"pages": 1,
|
||||
"data": [
|
||||
{"id": 1, "domain": "reg.com", "status": "active", "expiry": "2027-05-06T04:00:00"},
|
||||
{"id": 2, "domain": "dns-only.com", "status": "active", "expiry": None},
|
||||
],
|
||||
}
|
||||
with patch.object(LinodeAdapter, "_get", return_value=payload):
|
||||
domains = adapter.list_domains()
|
||||
assert domains[0].expiry_date == date(2027, 5, 6)
|
||||
assert domains[1].expiry_date is None
|
||||
|
||||
|
||||
def test_linode_error_message_reads_body_reason():
|
||||
"""Linode 把原因放在 body.errors[0].reason,裸状态码对用户没用"""
|
||||
adapter = LinodeAdapter({"api_key": "x"})
|
||||
resp = httpx.Response(
|
||||
403,
|
||||
request=httpx.Request("GET", "https://api.linode.com/v4/account"),
|
||||
json={"errors": [{"reason": "Invalid token"}]},
|
||||
)
|
||||
err = httpx.HTTPStatusError("denied", request=resp.request, response=resp)
|
||||
assert "Invalid token" in adapter._error_message(err)
|
||||
|
||||
|
||||
def test_linode_error_message_survives_unparseable_body():
|
||||
adapter = LinodeAdapter({"api_key": "x"})
|
||||
resp = httpx.Response(
|
||||
502,
|
||||
request=httpx.Request("GET", "https://api.linode.com/v4/account"),
|
||||
content=b"<html>bad gateway</html>",
|
||||
)
|
||||
err = httpx.HTTPStatusError("boom", request=resp.request, response=resp)
|
||||
assert adapter._error_message(err) == "HTTP 502"
|
||||
|
||||
|
||||
def test_linode_capabilities_no_renew():
|
||||
"""按小时计费无「续费」概念,不能声明 renew_instance"""
|
||||
caps = LinodeAdapter({"api_key": "x"}).capabilities()
|
||||
assert caps["renew_instance"] is False
|
||||
assert caps["start_instance"] is True
|
||||
assert caps["list_dns_records"] is True
|
||||
assert caps["get_account"] is True
|
||||
|
||||
|
||||
def test_linode_instance_actions_hit_right_verbs():
|
||||
adapter = LinodeAdapter({"api_key": "x"})
|
||||
for fn, verb in (("start_instance", "boot"), ("stop_instance", "shutdown"), ("reboot_instance", "reboot")):
|
||||
with patch.object(LinodeAdapter, "_request", return_value={}) as m:
|
||||
assert getattr(adapter, fn)("999")["ok"] is True
|
||||
assert m.call_args.args[1] == f"/linode/instances/999/{verb}"
|
||||
|
||||
|
||||
def test_linode_record_body_mapping():
|
||||
"""Linode 用 target/ttl_sec,不是 content/ttl"""
|
||||
body = LinodeAdapter._record_body({"type": "MX", "name": "mail", "content": "mx.example.com", "ttl": 600, "priority": 10})
|
||||
assert body == {"type": "MX", "name": "mail", "target": "mx.example.com", "ttl_sec": 600, "priority": 10}
|
||||
# priority 为 None 时不应出现在 body 里(Linode 会校验类型)
|
||||
assert "priority" not in LinodeAdapter._record_body({"type": "A", "name": "a", "content": "1.2.3.4"})
|
||||
|
||||
|
||||
def test_linode_dns_crud_paths():
|
||||
adapter = LinodeAdapter({"api_key": "x"})
|
||||
with patch.object(LinodeAdapter, "_request", return_value={"id": 555}) as m:
|
||||
assert adapter.create_dns_record("7", {"type": "A", "name": "a", "content": "1.2.3.4"})["record_id"] == "555"
|
||||
assert m.call_args.args[0] == "POST"
|
||||
assert m.call_args.args[1] == "/domains/7/records"
|
||||
|
||||
assert adapter.update_dns_record("7", "555", {"type": "A", "name": "a", "content": "1.2.3.5"})["ok"] is True
|
||||
assert m.call_args.args[0] == "PUT"
|
||||
assert m.call_args.args[1] == "/domains/7/records/555"
|
||||
|
||||
assert adapter.delete_dns_record("7", "555")["ok"] is True
|
||||
assert m.call_args.args[0] == "DELETE"
|
||||
|
||||
|
||||
def test_linode_list_dns_records_maps_target():
|
||||
adapter = LinodeAdapter({"api_key": "x"})
|
||||
payload = {"pages": 1, "data": [{"id": 1, "type": "A", "name": "www", "target": "1.2.3.4", "ttl_sec": 300}]}
|
||||
with patch.object(LinodeAdapter, "_get", return_value=payload):
|
||||
records = adapter.list_dns_records("7")
|
||||
assert records[0].content == "1.2.3.4"
|
||||
assert records[0].ttl == 300
|
||||
assert records[0].zone_id == "7"
|
||||
|
||||
|
||||
def test_linode_empty_body_handled():
|
||||
"""POST 动作类接口返回 200 + 空 body,直接 .json() 会炸"""
|
||||
adapter = LinodeAdapter({"api_key": "x"})
|
||||
resp = MagicMock()
|
||||
resp.content = b""
|
||||
resp.raise_for_status.return_value = None
|
||||
client = MagicMock()
|
||||
client.request.return_value = resp
|
||||
cm = MagicMock()
|
||||
cm.__enter__.return_value = client
|
||||
with patch("httpx.Client", return_value=cm):
|
||||
assert adapter.start_instance("1")["ok"] is True
|
||||
|
||||
|
||||
# ----------------------------- Namesilo ----------------------------- #
|
||||
def _namesilo_reply(reply: dict) -> dict:
|
||||
return {"request": {"command": "x"}, "reply": reply}
|
||||
|
||||
|
||||
def test_namesilo_success_code_300():
|
||||
adapter = NamesiloAdapter({"api_key": "k"})
|
||||
payload = _namesilo_reply({"code": 300, "detail": "success", "domains": []})
|
||||
resp = MagicMock()
|
||||
resp.json.return_value = payload
|
||||
resp.raise_for_status.return_value = None
|
||||
client = MagicMock()
|
||||
client.get.return_value = resp
|
||||
cm = MagicMock()
|
||||
cm.__enter__.return_value = client
|
||||
with patch("httpx.Client", return_value=cm):
|
||||
assert adapter._call("listDomains")["code"] == 300
|
||||
|
||||
|
||||
def test_namesilo_string_code_accepted():
|
||||
"""Namesilo 的 code 可能是 str,直接 != 300 会把成功误判成失败"""
|
||||
adapter = NamesiloAdapter({"api_key": "k"})
|
||||
resp = MagicMock()
|
||||
resp.json.return_value = _namesilo_reply({"code": "300", "detail": "success"})
|
||||
resp.raise_for_status.return_value = None
|
||||
client = MagicMock()
|
||||
client.get.return_value = resp
|
||||
cm = MagicMock()
|
||||
cm.__enter__.return_value = client
|
||||
with patch("httpx.Client", return_value=cm):
|
||||
assert adapter._call("checkApiKey")["code"] == "300"
|
||||
|
||||
|
||||
def test_namesilo_failure_raises_with_detail():
|
||||
"""301 未授权 / 400 参数错必须抛出并带上 detail,否则用户只看到一个空失败"""
|
||||
adapter = NamesiloAdapter({"api_key": "k"})
|
||||
resp = MagicMock()
|
||||
resp.json.return_value = _namesilo_reply({"code": 301, "detail": "Invalid or missing API key"})
|
||||
resp.raise_for_status.return_value = None
|
||||
client = MagicMock()
|
||||
client.get.return_value = resp
|
||||
cm = MagicMock()
|
||||
cm.__enter__.return_value = client
|
||||
with patch("httpx.Client", return_value=cm):
|
||||
with pytest.raises(RuntimeError) as ei:
|
||||
adapter._call("listDomains")
|
||||
assert "301" in str(ei.value)
|
||||
assert "Invalid or missing API key" in str(ei.value)
|
||||
|
||||
|
||||
def test_namesilo_api_key_sent_as_query_param():
|
||||
adapter = NamesiloAdapter({"api_key": "SECRETKEY"})
|
||||
resp = MagicMock()
|
||||
resp.json.return_value = _namesilo_reply({"code": 300, "username": "me"})
|
||||
resp.raise_for_status.return_value = None
|
||||
client = MagicMock()
|
||||
client.get.return_value = resp
|
||||
cm = MagicMock()
|
||||
cm.__enter__.return_value = client
|
||||
with patch("httpx.Client", return_value=cm):
|
||||
adapter.test_connection()
|
||||
query = client.get.call_args.kwargs["params"]
|
||||
assert query["key"] == "SECRETKEY"
|
||||
assert query["version"] == "1"
|
||||
assert query["type"] == "json"
|
||||
|
||||
|
||||
def test_namesilo_test_connection_reports_username():
|
||||
adapter = NamesiloAdapter({"api_key": "k"})
|
||||
with patch.object(NamesiloAdapter, "_call", return_value={"code": 300, "username": "neatstudio"}):
|
||||
res = adapter.test_connection()
|
||||
assert res["ok"] is True
|
||||
assert "neatstudio" in res["message"]
|
||||
|
||||
|
||||
def test_namesilo_list_domains_expiry():
|
||||
adapter = NamesiloAdapter({"api_key": "k"})
|
||||
reply = {"code": 300, "domains": [{"domain": "example.com", "expires": "2027-01-01"}]}
|
||||
with patch.object(NamesiloAdapter, "_call", return_value=reply):
|
||||
domains = adapter.list_domains()
|
||||
assert len(domains) == 1
|
||||
assert isinstance(domains[0].expiry_date, date)
|
||||
assert domains[0].expiry_date == date(2027, 1, 1)
|
||||
assert domains[0].registrar == "namesilo"
|
||||
|
||||
|
||||
def test_namesilo_unparseable_expiry_is_none():
|
||||
adapter = NamesiloAdapter({"api_key": "k"})
|
||||
reply = {"code": 300, "domains": [{"domain": "x.com", "expires": "not-a-date"}]}
|
||||
with patch.object(NamesiloAdapter, "_call", return_value=reply):
|
||||
assert adapter.list_domains()[0].expiry_date is None
|
||||
|
||||
|
||||
def test_namesilo_fqdn_host_roundtrip():
|
||||
"""Namesilo 存子域(空/@ 表示裸域),台账展示需要完整域名"""
|
||||
assert NamesiloAdapter._fqdn("", "example.com") == "example.com"
|
||||
assert NamesiloAdapter._fqdn("@", "example.com") == "example.com"
|
||||
assert NamesiloAdapter._fqdn("www", "example.com") == "www.example.com"
|
||||
|
||||
assert NamesiloAdapter._host("example.com", "example.com") == ""
|
||||
assert NamesiloAdapter._host("www.example.com", "example.com") == "www"
|
||||
assert NamesiloAdapter._host("", "example.com") == ""
|
||||
|
||||
|
||||
def test_namesilo_dns_record_normalization():
|
||||
adapter = NamesiloAdapter({"api_key": "k"})
|
||||
reply = {
|
||||
"code": 300,
|
||||
"resource_record": [
|
||||
{"record_id": 111, "host": "www", "type": "A", "value": "1.2.3.4", "ttl": 3600},
|
||||
{"record_id": 222, "host": "", "type": "MX", "value": "mail.example.com", "ttl": 3600, "distance": 10},
|
||||
],
|
||||
}
|
||||
with patch.object(NamesiloAdapter, "_call", return_value=reply):
|
||||
records = adapter.list_dns_records("example.com")
|
||||
assert records[0].name == "www.example.com"
|
||||
assert records[0].record_id == "111"
|
||||
assert records[0].ttl == 3600
|
||||
assert records[1].name == "example.com" # 裸域
|
||||
assert records[1].priority == 10
|
||||
|
||||
|
||||
def test_namesilo_create_omits_rr_id():
|
||||
"""dnsRecordUpdate 同时承担新增与修改:新增绝不能带 rr_id,否则改到别的记录上"""
|
||||
adapter = NamesiloAdapter({"api_key": "k"})
|
||||
with patch.object(NamesiloAdapter, "_call", return_value={"code": 300}) as m:
|
||||
res = adapter.create_dns_record("example.com", {"type": "A", "name": "www.example.com", "content": "1.2.3.4", "ttl": 600})
|
||||
assert res["ok"] is True
|
||||
assert m.call_args.args[0] == "dnsRecordUpdate"
|
||||
params = m.call_args.args[1]
|
||||
assert "rr_id" not in params
|
||||
assert params["rr_host"] == "www"
|
||||
assert params["rr_type"] == "A"
|
||||
assert params["rr_value"] == "1.2.3.4"
|
||||
assert params["rr_ttl"] == 600
|
||||
|
||||
|
||||
def test_namesilo_update_sends_rr_id():
|
||||
adapter = NamesiloAdapter({"api_key": "k"})
|
||||
with patch.object(NamesiloAdapter, "_call", return_value={"code": 300}) as m:
|
||||
res = adapter.update_dns_record("example.com", "111", {"type": "A", "name": "example.com", "content": "9.9.9.9"})
|
||||
assert res["ok"] is True
|
||||
params = m.call_args.args[1]
|
||||
assert params["rr_id"] == "111"
|
||||
assert params["rr_host"] == "" # 裸域 -> 空串
|
||||
assert params["rr_ttl"] == 3600 # 未给 ttl 时的默认值
|
||||
|
||||
|
||||
def test_namesilo_mx_sends_distance():
|
||||
adapter = NamesiloAdapter({"api_key": "k"})
|
||||
with patch.object(NamesiloAdapter, "_call", return_value={"code": 300}) as m:
|
||||
adapter.create_dns_record("example.com", {"type": "MX", "name": "example.com", "content": "mx.x.com", "priority": 10})
|
||||
assert m.call_args.args[1]["rr_distance"] == 10
|
||||
|
||||
|
||||
def test_namesilo_delete_passes_rr_id():
|
||||
adapter = NamesiloAdapter({"api_key": "k"})
|
||||
with patch.object(NamesiloAdapter, "_call", return_value={"code": 300}) as m:
|
||||
assert adapter.delete_dns_record("example.com", "111")["ok"] is True
|
||||
assert m.call_args.args[0] == "dnsRecordDelete"
|
||||
assert m.call_args.args[1] == {"domain": "example.com", "rr_id": "111"}
|
||||
|
||||
|
||||
def test_namesilo_write_failure_returns_not_raises():
|
||||
adapter = NamesiloAdapter({"api_key": "k"})
|
||||
with patch.object(NamesiloAdapter, "_call", side_effect=RuntimeError("code=301: bad key")):
|
||||
assert adapter.create_dns_record("x.com", {"type": "A", "name": "x.com", "content": "1.1.1.1"})["ok"] is False
|
||||
assert adapter.update_dns_record("x.com", "1", {"type": "A", "name": "x.com", "content": "1.1.1.1"})["ok"] is False
|
||||
assert adapter.delete_dns_record("x.com", "1")["ok"] is False
|
||||
|
||||
|
||||
def test_namesilo_capabilities_domain_only():
|
||||
"""纯域名注册商:无 VPS、无实例动作、无余额"""
|
||||
caps = NamesiloAdapter({"api_key": "k"}).capabilities()
|
||||
assert caps["list_domains"] is True
|
||||
assert caps["list_dns_records"] is True
|
||||
assert caps["list_vps"] is False
|
||||
assert caps["get_account"] is False
|
||||
assert caps["start_instance"] is False
|
||||
@@ -0,0 +1,196 @@
|
||||
"""平台注册表对齐测试
|
||||
|
||||
覆盖两处回归:
|
||||
|
||||
1. `_align_preset_providers`:seed_providers 只插入缺失 slug、不更新已有行,
|
||||
所以代码侧的能力变化必须靠迁移落地。迁移必须是「增量」的——线上 aliyun-intl
|
||||
的 services 被用户手工加过 ai_agent/other,按种子值整体覆盖会静默丢用户配置。
|
||||
2. 种子与注册表的一致性:任何预设平台都不得声明未实现的 sdk_type。
|
||||
声明了前端就会显示「API 对接: xxx」,用户建好账号点同步才撞
|
||||
「暂不支持的 SDK 类型」——声明支持却不支持,比明说不支持更糟。
|
||||
|
||||
运行:.venv/bin/pytest tests/test_provider_alignment.py -v
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
import pytest
|
||||
from sqlmodel import Session, SQLModel, create_engine, select
|
||||
|
||||
import app.models.asset # noqa: F401
|
||||
import app.models.credential # noqa: F401
|
||||
import app.models.monitor # noqa: F401
|
||||
import app.models.provider # noqa: F401
|
||||
import app.models.ssl # noqa: F401
|
||||
from app.adapters import registry
|
||||
from app.core.seed import PRESET_PROVIDERS, seed_providers
|
||||
from app.database import _align_preset_providers
|
||||
from app.models.provider import Provider
|
||||
|
||||
#: 线上 GET /api/providers 抓下来的真实状态(2026-10-11),
|
||||
#: 用它做 fixture 才能保证迁移验证的是生产形态而不是理想形态
|
||||
LIVE_PROVIDERS = [
|
||||
("agnes", None, "ai_agent"),
|
||||
("deepseek", "deepseek-api", "ai_agent"),
|
||||
("kimi", "moonshot-api", "ai_agent"),
|
||||
("minimax", "minimax-api", "ai_agent"),
|
||||
("openai", "openai-api", "ai_agent"),
|
||||
("cloudflare", "cloudflare-api", "cloudflare,dns,ssl_cert,cdn"),
|
||||
("dynadot", "dynadot-api", "domain,dns"),
|
||||
("namesilo", "namesilo-api", "domain,dns"),
|
||||
("xinwang", None, "domain,dns"),
|
||||
("aliyun", "aliyun-sdk", "vps,domain,ssl_cert,cdn,dns"),
|
||||
("aliyun-intl", "alibabacloud-sdk", "vps,domain,ssl_cert,cdn,dns,ai_agent,other"),
|
||||
("cloudcone", "cloudcone-api", "vps"),
|
||||
("digitalocean", "do-api", "vps,domain"),
|
||||
("linode", "linode-api", "vps"),
|
||||
("tencent", "tencent-sdk", "vps,domain,ssl_cert,cdn,dns"),
|
||||
("tencent-intl", "tencent-intl-sdk", "vps,domain,ssl_cert,cdn,dns"),
|
||||
("tothost", None, "vps"),
|
||||
("vultr", "vultr-api", "vps"),
|
||||
("zeabur", "zeabur-api", "vps,domain"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def engine(tmp_path):
|
||||
eng = create_engine(
|
||||
f"sqlite:///{tmp_path / 'providers.db'}", connect_args={"check_same_thread": False}
|
||||
)
|
||||
SQLModel.metadata.create_all(eng)
|
||||
yield eng
|
||||
eng.dispose()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def live_db(engine):
|
||||
"""复刻线上 providers 表"""
|
||||
with Session(engine) as s:
|
||||
for slug, sdk, svc in LIVE_PROVIDERS:
|
||||
s.add(Provider(slug=slug, name=slug, category="vps", services=svc, sdk_type=sdk))
|
||||
s.commit()
|
||||
return engine
|
||||
|
||||
|
||||
def _snapshot(engine) -> dict:
|
||||
with engine.begin() as conn:
|
||||
rows = conn.execute(sa.text("SELECT slug, sdk_type, services FROM providers")).fetchall()
|
||||
return {r[0]: (r[1], r[2]) for r in rows}
|
||||
|
||||
|
||||
def _run_migration(engine) -> None:
|
||||
with engine.begin() as conn:
|
||||
_align_preset_providers(conn)
|
||||
|
||||
|
||||
# ----------------------------- 种子 / 注册表一致性 ----------------------------- #
|
||||
def test_no_preset_declares_unimplemented_sdk_type():
|
||||
"""预设平台不得声明未实现的 sdk_type(AWS/Linode/Namesilo 落地后的不变量)"""
|
||||
supported = set(registry.supported_types())
|
||||
bogus = [(p["slug"], p["sdk_type"]) for p in PRESET_PROVIDERS
|
||||
if p.get("sdk_type") and p["sdk_type"] not in supported]
|
||||
assert bogus == [], f"这些平台声明了未实现的适配器:{bogus}"
|
||||
|
||||
|
||||
def test_new_adapters_are_registered():
|
||||
"""本轮新增的三家必须有对应注册项"""
|
||||
for sdk_type in ("aws-api", "linode-api", "namesilo-api"):
|
||||
assert registry.is_supported(sdk_type), f"{sdk_type} 未注册"
|
||||
|
||||
|
||||
def test_seed_providers_inserts_missing_slug(live_db):
|
||||
"""线上没有 aws 行,seed_providers 必须补上"""
|
||||
assert "aws" not in _snapshot(live_db)
|
||||
with Session(live_db) as s:
|
||||
added = seed_providers(s)
|
||||
snap = _snapshot(live_db)
|
||||
assert added >= 1
|
||||
assert snap["aws"][0] == "aws-api"
|
||||
assert "vps" in snap["aws"][1]
|
||||
|
||||
|
||||
def test_seed_providers_is_idempotent(live_db):
|
||||
with Session(live_db) as s:
|
||||
first = seed_providers(s)
|
||||
second = seed_providers(s)
|
||||
assert second == 0
|
||||
assert len(_snapshot(live_db)) == len(LIVE_PROVIDERS) + first
|
||||
|
||||
|
||||
# ----------------------------- 迁移行为 ----------------------------- #
|
||||
def test_migration_clears_bogus_sdk_type(live_db):
|
||||
"""cloudcone/zeabur/dynadot 声明的适配器从未实现,必须清空"""
|
||||
_run_migration(live_db)
|
||||
snap = _snapshot(live_db)
|
||||
for slug in ("cloudcone", "zeabur", "dynadot"):
|
||||
assert snap[slug][0] is None, f"{slug}.sdk_type 未清空:{snap[slug][0]!r}"
|
||||
|
||||
|
||||
def test_migration_keeps_valid_sdk_type(live_db):
|
||||
"""有效 sdk_type 不能被误伤,否则同步能力全丢"""
|
||||
_run_migration(live_db)
|
||||
snap = _snapshot(live_db)
|
||||
for slug, expected in (
|
||||
("aliyun", "aliyun-sdk"), ("tencent", "tencent-sdk"), ("vultr", "vultr-api"),
|
||||
("digitalocean", "do-api"), ("linode", "linode-api"), ("namesilo", "namesilo-api"),
|
||||
("cloudflare", "cloudflare-api"), ("openai", "openai-api"),
|
||||
):
|
||||
assert snap[slug][0] == expected
|
||||
|
||||
|
||||
def test_migration_preserves_user_customized_services(live_db):
|
||||
"""aliyun-intl 被用户手工加过 ai_agent/other,覆盖式迁移会静默丢配置"""
|
||||
_run_migration(live_db)
|
||||
tags = set(_snapshot(live_db)["aliyun-intl"][1].split(","))
|
||||
assert {"ai_agent", "other"} <= tags
|
||||
|
||||
|
||||
def test_migration_adds_missing_service_tags(live_db):
|
||||
"""适配器新增了域名/DNS 能力,services 必须跟上,
|
||||
否则 modals.js 按 services 过滤时选不到这些平台"""
|
||||
_run_migration(live_db)
|
||||
snap = _snapshot(live_db)
|
||||
assert {"domain", "dns"} <= set(snap["vultr"][1].split(","))
|
||||
assert {"domain", "dns"} <= set(snap["linode"][1].split(","))
|
||||
assert "dns" in snap["digitalocean"][1].split(",")
|
||||
|
||||
|
||||
def test_migration_ignores_user_created_providers(live_db):
|
||||
"""tothost 不在种子里,一个字都不能动"""
|
||||
before = _snapshot(live_db)["tothost"]
|
||||
_run_migration(live_db)
|
||||
assert _snapshot(live_db)["tothost"] == before
|
||||
|
||||
|
||||
def test_migration_is_idempotent(live_db):
|
||||
"""迁移跑两次结果必须一致(update.sh 每分钟可能触发重启)"""
|
||||
_run_migration(live_db)
|
||||
once = _snapshot(live_db)
|
||||
_run_migration(live_db)
|
||||
_run_migration(live_db)
|
||||
assert _snapshot(live_db) == once
|
||||
|
||||
|
||||
def test_migration_leaves_no_unimplemented_declaration(live_db):
|
||||
"""终态不变量:迁移 + seed 后,库里不存在「声明但未实现」的 sdk_type"""
|
||||
_run_migration(live_db)
|
||||
with Session(live_db) as s:
|
||||
seed_providers(s)
|
||||
_run_migration(live_db)
|
||||
|
||||
supported = set(registry.supported_types())
|
||||
snap = _snapshot(live_db)
|
||||
bad = [(slug, v[0]) for slug, v in snap.items() if v[0] and v[0] not in supported]
|
||||
assert bad == []
|
||||
|
||||
|
||||
def test_migration_result_covers_all_seed_services(live_db):
|
||||
"""终态:每个预设平台的 services 至少包含种子声明的标签"""
|
||||
_run_migration(live_db)
|
||||
snap = _snapshot(live_db)
|
||||
for data in PRESET_PROVIDERS:
|
||||
slug = data["slug"]
|
||||
if slug not in snap:
|
||||
continue
|
||||
seed_tags = {t.strip() for t in (data.get("services") or "").split(",") if t.strip()}
|
||||
cur_tags = {t.strip() for t in (snap[slug][1] or "").split(",") if t.strip()}
|
||||
assert seed_tags <= cur_tags, f"{slug} 缺标签 {sorted(seed_tags - cur_tags)}"
|
||||
@@ -0,0 +1,183 @@
|
||||
"""资产总览统计测试(get_overview)
|
||||
|
||||
覆盖的核心问题:「本年支出」是只把填了费用的资产加起来的和。cost 默认 0,
|
||||
所以没填费用和真的免费在数据上无法区分——合计因此天然是个下限,而不是准数。
|
||||
前端必须能拿到缺口大小(year_cost_missing)才能如实说明,否则用户会把下限
|
||||
当成实际支出。
|
||||
|
||||
顺带回归到期口径:已过期(overdue)与 30 天内到期(expiring_30)分开计数,
|
||||
已注销资产两边都不进。
|
||||
|
||||
运行:.venv/bin/pytest tests/test_stats_overview.py -v
|
||||
"""
|
||||
|
||||
from datetime import date, timedelta
|
||||
|
||||
import pytest
|
||||
from sqlmodel import Session, SQLModel, create_engine
|
||||
|
||||
import app.models.asset # noqa: F401
|
||||
import app.models.credential # noqa: F401
|
||||
import app.models.monitor # noqa: F401
|
||||
import app.models.provider # noqa: F401
|
||||
import app.models.ssl # noqa: F401
|
||||
from app.models.asset import Asset, AssetStatus, AssetType
|
||||
from app.services.asset_service import get_overview
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def engine(tmp_path):
|
||||
eng = create_engine(
|
||||
f"sqlite:///{tmp_path / 'stats.db'}", connect_args={"check_same_thread": False}
|
||||
)
|
||||
SQLModel.metadata.create_all(eng)
|
||||
yield eng
|
||||
eng.dispose()
|
||||
|
||||
|
||||
def _add(engine, name, *, expiry=None, cost=0.0, status=AssetStatus.ACTIVE,
|
||||
asset_type=AssetType.VPS):
|
||||
with Session(engine) as s:
|
||||
s.add(Asset(
|
||||
name=name, asset_type=asset_type, provider="vultr",
|
||||
expiry_date=expiry, cost=cost, status=status,
|
||||
))
|
||||
s.commit()
|
||||
|
||||
|
||||
def _overview(engine):
|
||||
with Session(engine) as s:
|
||||
return get_overview(s)
|
||||
|
||||
|
||||
def test_empty_db_returns_all_contract_keys(engine):
|
||||
"""空库也必须给出完整结构:/api/stats/overview 是前端总览页的数据源,
|
||||
缺键会让 dashboard.js 渲染出 undefined"""
|
||||
got = _overview(engine)
|
||||
assert got == {
|
||||
"total": 0,
|
||||
"by_type": {},
|
||||
"by_status": {},
|
||||
"abnormal_count": 0,
|
||||
"expiring_30": 0,
|
||||
"overdue_count": 0,
|
||||
"year_cost": 0,
|
||||
"month_cost": 0,
|
||||
"year_cost_missing": 0,
|
||||
}
|
||||
|
||||
|
||||
def test_year_cost_sums_only_filled_costs(engine):
|
||||
today = date.today()
|
||||
_add(engine, "paid", expiry=today, cost=12.5)
|
||||
_add(engine, "also-paid", expiry=today + timedelta(days=3), cost=7.25)
|
||||
got = _overview(engine)
|
||||
assert got["year_cost"] == 19.75
|
||||
assert got["year_cost_missing"] == 0
|
||||
|
||||
|
||||
def test_unfilled_cost_is_counted_as_gap_not_zero_spend(engine):
|
||||
"""cost 默认 0,没填的资产既不进合计、也不能悄悄消失"""
|
||||
today = date.today()
|
||||
_add(engine, "paid", expiry=today, cost=10.0)
|
||||
_add(engine, "unfilled-a", expiry=today + timedelta(days=1))
|
||||
_add(engine, "unfilled-b", expiry=today + timedelta(days=2))
|
||||
got = _overview(engine)
|
||||
assert got["year_cost"] == 10.0
|
||||
assert got["year_cost_missing"] == 2
|
||||
assert got["total"] == 3
|
||||
|
||||
|
||||
def test_assets_without_expiry_do_not_enter_cost_window(engine):
|
||||
"""没有到期日的资产不属于任何年度窗口:既不该加进合计,也不该算作缺口,
|
||||
否则「另有 N 项未填费用」会把一批跟今年无关的资产算进去"""
|
||||
_add(engine, "no-expiry-unfilled")
|
||||
_add(engine, "no-expiry-paid", cost=99.0)
|
||||
got = _overview(engine)
|
||||
assert got["year_cost"] == 0
|
||||
assert got["year_cost_missing"] == 0
|
||||
assert got["total"] == 2
|
||||
|
||||
|
||||
def test_other_year_expiry_excluded_from_cost(engine):
|
||||
"""跨年资产不进本年合计,也不进本年缺口"""
|
||||
this_year = date.today()
|
||||
other_year = date(this_year.year + 1, 6, 15)
|
||||
_add(engine, "this-year", expiry=this_year, cost=5.0)
|
||||
_add(engine, "next-year-unfilled", expiry=other_year)
|
||||
got = _overview(engine)
|
||||
assert got["year_cost"] == 5.0
|
||||
assert got["year_cost_missing"] == 0
|
||||
|
||||
|
||||
def test_month_cost_only_current_month(engine):
|
||||
today = date.today()
|
||||
# 取本月内的另一天:若今天是 1 号则往后挪,避免算出上/下月的日期
|
||||
same_month = today.replace(day=1) if today.day > 1 else today
|
||||
next_month = (today.replace(day=1) + timedelta(days=32)).replace(day=2)
|
||||
_add(engine, "this-month", expiry=same_month, cost=20.0)
|
||||
_add(engine, "next-month", expiry=next_month, cost=30.0)
|
||||
got = _overview(engine)
|
||||
assert got["month_cost"] == 20.0
|
||||
# 下月那笔仍属于本年
|
||||
assert got["year_cost"] == 50.0
|
||||
|
||||
|
||||
def test_year_cost_rounded_to_two_decimals(engine):
|
||||
"""币种最小单位是分,浮点累加的 0.1+0.2 尾巴不能漏到界面上"""
|
||||
today = date.today()
|
||||
_add(engine, "a", expiry=today, cost=0.1)
|
||||
_add(engine, "b", expiry=today, cost=0.2)
|
||||
assert _overview(engine)["year_cost"] == 0.3
|
||||
|
||||
|
||||
def test_overdue_and_expiring_counted_separately(engine):
|
||||
"""已过期比 30 天内到期更该被看见,不能因为不在未来窗口里就从统计消失"""
|
||||
today = date.today()
|
||||
_add(engine, "overdue", expiry=today - timedelta(days=1))
|
||||
_add(engine, "soon", expiry=today + timedelta(days=10))
|
||||
_add(engine, "far", expiry=today + timedelta(days=90))
|
||||
got = _overview(engine)
|
||||
assert got["overdue_count"] == 1
|
||||
assert got["expiring_30"] == 1
|
||||
assert got["total"] == 3
|
||||
|
||||
|
||||
def test_today_counts_as_expiring_not_overdue(engine):
|
||||
"""到期日就是今天:还能续费,属于倒计时而不是已错过"""
|
||||
_add(engine, "due-today", expiry=date.today())
|
||||
got = _overview(engine)
|
||||
assert got["expiring_30"] == 1
|
||||
assert got["overdue_count"] == 0
|
||||
|
||||
|
||||
def test_cancelled_assets_excluded_from_expiry_counts(engine):
|
||||
"""已注销资产不该再触发续费提醒"""
|
||||
today = date.today()
|
||||
_add(engine, "cancelled-overdue", expiry=today - timedelta(days=5),
|
||||
status=AssetStatus.CANCELLED)
|
||||
_add(engine, "cancelled-soon", expiry=today + timedelta(days=5),
|
||||
status=AssetStatus.CANCELLED)
|
||||
got = _overview(engine)
|
||||
assert got["overdue_count"] == 0
|
||||
assert got["expiring_30"] == 0
|
||||
# 但仍计入总数:台账要能查到已注销的历史资产
|
||||
assert got["total"] == 2
|
||||
|
||||
|
||||
def test_abnormal_counts_stopped_and_expired(engine):
|
||||
_add(engine, "stopped", status=AssetStatus.STOPPED)
|
||||
_add(engine, "expired", status=AssetStatus.EXPIRED)
|
||||
_add(engine, "active", status=AssetStatus.ACTIVE)
|
||||
got = _overview(engine)
|
||||
assert got["abnormal_count"] == 2
|
||||
assert got["by_status"] == {"stopped": 1, "expired": 1, "active": 1}
|
||||
|
||||
|
||||
def test_by_type_uses_enum_values_not_names(engine):
|
||||
"""前端按 'vps'/'domain' 匹配图标与文案,给出 'VPS' 会全部落到兜底分支"""
|
||||
_add(engine, "v1", asset_type=AssetType.VPS)
|
||||
_add(engine, "d1", asset_type=AssetType.DOMAIN)
|
||||
_add(engine, "d2", asset_type=AssetType.DOMAIN)
|
||||
got = _overview(engine)
|
||||
assert got["by_type"] == {"vps": 1, "domain": 2}
|
||||
@@ -0,0 +1,228 @@
|
||||
"""云资产同步链路测试:路由注册 + 定时脚本 + systemd 单元接线
|
||||
|
||||
这条链有三个环节,任一环断了都不会报错,只会「夜间同步悄悄不跑了」:
|
||||
1. /api/sync/assets 没注册 -> 手动触发按钮 404
|
||||
2. scripts/sync_all_assets.py 退出码恒为 0 -> systemd 永远记成功,失败无告警
|
||||
3. deploy/*.timer 没被 setup.sh enable -> 全新部署后定时器根本不存在
|
||||
|
||||
运行:.venv/bin/pytest tests/test_sync_chain.py -v
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import re
|
||||
from datetime import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from sqlmodel import SQLModel, create_engine
|
||||
|
||||
import app.models.asset # noqa: F401
|
||||
import app.models.credential # noqa: F401
|
||||
import app.models.monitor # noqa: F401
|
||||
import app.models.provider # noqa: F401
|
||||
import app.models.ssl # noqa: F401
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
SCRIPTS_DIR = REPO_ROOT / "scripts"
|
||||
DEPLOY_DIR = REPO_ROOT / "deploy"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 1. 路由注册
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_sync_assets_endpoint_is_registered():
|
||||
"""/api/sync/assets 必须出现在 OpenAPI 里
|
||||
|
||||
这个 FastAPI 版本把 include_router 进来的路由包在 _IncludedRouter 里延迟展开,
|
||||
遍历 app.routes 看不到,只能查 app.openapi()['paths']。
|
||||
"""
|
||||
from app.main import app
|
||||
|
||||
paths = app.openapi()["paths"]
|
||||
assert "/api/sync/assets" in paths, f"未注册;现有 sync 路由:{[p for p in paths if 'sync' in p]}"
|
||||
assert "post" in paths["/api/sync/assets"]
|
||||
|
||||
|
||||
def test_sync_assets_endpoint_is_key_protected():
|
||||
"""批量同步会解密所有账号凭证并打云端接口,不能匿名可调"""
|
||||
from app.core.security import require_api_key
|
||||
from app.routers.sync import router
|
||||
|
||||
route = next(r for r in router.routes if r.path == "/api/sync/assets")
|
||||
calls = [d.call for d in route.dependant.dependencies]
|
||||
assert require_api_key in calls, "缺少 require_api_key 依赖,端点对外裸奔"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 2. 定时脚本
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _load_script():
|
||||
"""按文件路径加载 scripts/sync_all_assets.py(scripts/ 不是包)"""
|
||||
path = SCRIPTS_DIR / "sync_all_assets.py"
|
||||
spec = importlib.util.spec_from_file_location("_sync_all_assets_under_test", path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def script(tmp_path, monkeypatch):
|
||||
eng = create_engine(
|
||||
f"sqlite:///{tmp_path / 'chain.db'}", connect_args={"check_same_thread": False}
|
||||
)
|
||||
SQLModel.metadata.create_all(eng)
|
||||
# sync_service 内部是「函数里 from app.database import assets_engine」,所以
|
||||
# 补丁打在 app.database 上才生效;而脚本自己在模块级 import 了同名对象,
|
||||
# 那份绑定必须单独换掉,否则 init_db() 会去建真的 data/*.db。
|
||||
monkeypatch.setattr("app.database.assets_engine", eng)
|
||||
mod = _load_script()
|
||||
monkeypatch.setattr(mod, "init_db", lambda: None)
|
||||
monkeypatch.setattr(mod, "assets_engine", eng)
|
||||
yield mod
|
||||
eng.dispose()
|
||||
|
||||
|
||||
def test_script_summary_prints_every_key_the_service_returns(script, capsys):
|
||||
"""端到端跑一次真实 sync_all_assets(空库),确保摘要行不因缺键 KeyError
|
||||
|
||||
脚本直接下标访问 result['skipped_no_config'] 等键;服务层返回结构一改,
|
||||
这里就会在夜间定时任务里抛 KeyError,而 timer 的失败没人看。
|
||||
"""
|
||||
rc = script.main()
|
||||
out = capsys.readouterr().out
|
||||
assert rc == 0
|
||||
assert "[云资产同步]" in out
|
||||
for fragment in ("账号 0", "成功 0", "失败 0", "新增 0", "更新 0",
|
||||
"跳过(无凭证) 0", "跳过(无适配器) 0"):
|
||||
assert fragment in out, f"摘要缺少 {fragment}:{out!r}"
|
||||
|
||||
|
||||
def test_script_exits_zero_when_nothing_failed(script, monkeypatch):
|
||||
fake = {
|
||||
"total": 2, "success": 2, "failed": 0, "created": 3, "updated": 1,
|
||||
"skipped_no_config": 0, "skipped_no_adapter": 0, "errors": [],
|
||||
"synced_at": "2026-01-01T00:00:00",
|
||||
}
|
||||
monkeypatch.setattr(script.sync_service, "sync_all_assets", lambda s, **kw: fake)
|
||||
assert script.main() == 0
|
||||
|
||||
|
||||
def test_script_exits_nonzero_when_any_account_failed(script, capsys, monkeypatch):
|
||||
"""退出码是唯一的告警通道:返回 0 的话 systemd 记成功,凭证过期也没人知道"""
|
||||
fake = {
|
||||
"total": 2, "success": 0, "failed": 2, "created": 0, "updated": 0,
|
||||
"skipped_no_config": 0, "skipped_no_adapter": 0,
|
||||
"errors": ["vultr/main: HTTP 401:凭证无效或已过期",
|
||||
"aliyun/backup: HTTP 403:凭证权限不足"],
|
||||
"synced_at": "2026-01-01T00:00:00",
|
||||
}
|
||||
monkeypatch.setattr(script.sync_service, "sync_all_assets", lambda s, **kw: fake)
|
||||
assert script.main() == 1
|
||||
out = capsys.readouterr().out
|
||||
assert "失败 2" in out
|
||||
for err in fake["errors"]:
|
||||
assert err in out, f"错误明细没打出来,journalctl 里查不到原因:{err}"
|
||||
|
||||
|
||||
def test_script_uses_session_bound_to_assets_engine(script, monkeypatch):
|
||||
"""main() 传给服务层的 Session 必须开在 assets_engine 上
|
||||
|
||||
写错成 metrics_engine 的话,账号表查不到 -> total=0 -> 退出码 0,
|
||||
表现是「同步一切正常,但一条数据都没进来」。
|
||||
"""
|
||||
seen = {}
|
||||
|
||||
def _fake(session, **kwargs):
|
||||
seen["bind"] = session.get_bind()
|
||||
return {
|
||||
"total": 0, "success": 0, "failed": 0, "created": 0, "updated": 0,
|
||||
"skipped_no_config": 0, "skipped_no_adapter": 0, "errors": [],
|
||||
"synced_at": "2026-01-01T00:00:00",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(script.sync_service, "sync_all_assets", _fake)
|
||||
script.main()
|
||||
assert seen["bind"] is script.assets_engine
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 3. systemd 单元接线
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _read(name):
|
||||
return (DEPLOY_DIR / name).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_sync_assets_service_points_at_an_existing_script():
|
||||
"""ExecStart 指向的脚本必须真实存在
|
||||
|
||||
unit 里是部署机绝对路径 /opt/vps-manager/...,本地按仓库相对路径校验。
|
||||
脚本改名后 timer 每晚会失败,但因为是 oneshot + 无人看日志,等同于静默停摆。
|
||||
"""
|
||||
m = re.search(r"^ExecStart=(?P<interp>\S+)\s+(?P<script>\S+)\s*$",
|
||||
_read("vps-sync-assets.service"), re.M)
|
||||
assert m, "vps-sync-assets.service 缺少 ExecStart"
|
||||
assert m.group("interp").endswith("/.venv/bin/python"), "必须用虚拟环境解释器,系统 python 没有项目依赖"
|
||||
rel = m.group("script").replace("/opt/vps-manager/", "")
|
||||
assert (REPO_ROOT / rel).is_file(), f"ExecStart 指向的脚本不存在:{rel}"
|
||||
|
||||
|
||||
def test_every_timer_points_at_an_existing_service():
|
||||
for timer in sorted(DEPLOY_DIR.glob("*.timer")):
|
||||
m = re.search(r"^Unit=(\S+)\s*$", timer.read_text(encoding="utf-8"), re.M)
|
||||
assert m, f"{timer.name} 缺少 Unit="
|
||||
assert (DEPLOY_DIR / m.group(1)).is_file(), f"{timer.name} 指向不存在的 {m.group(1)}"
|
||||
|
||||
|
||||
def test_every_timer_is_enabled_by_setup_sh():
|
||||
"""deploy/ 下每个 timer 都必须在 setup.sh 里 enable --now
|
||||
|
||||
只 cp 不 enable:单元文件躺在 /etc/systemd/system 里但从不触发,
|
||||
全新部署后所有夜间任务都是死的。
|
||||
"""
|
||||
enable_lines = [ln for ln in _read("setup.sh").splitlines() if "enable --now" in ln]
|
||||
assert enable_lines, "setup.sh 里找不到任何 enable --now"
|
||||
for timer in sorted(p.name for p in DEPLOY_DIR.glob("*.timer")):
|
||||
assert any(timer in ln for ln in enable_lines), f"{timer} 未在 setup.sh 中 enable --now"
|
||||
|
||||
|
||||
def test_update_sh_enables_rather_than_restarts_new_timers():
|
||||
"""自动更新同步单元时,timer 分支必须是 enable --now
|
||||
|
||||
新 timer 只 restart 不会建开机自启软链:当下能跑,宿主一重启就永久消失。
|
||||
"""
|
||||
text = _read("update.sh")
|
||||
m = re.search(r"\*\.timer\)(?P<body>.*?);;", text, re.S)
|
||||
assert m, "update.sh 没有 *.timer) 分支"
|
||||
assert "enable --now" in m.group("body"), "timer 分支未使用 enable --now"
|
||||
|
||||
|
||||
def _oncalendar(name):
|
||||
m = re.search(r"^OnCalendar=\*-\*-\*\s+(\d{2}):(\d{2}):(\d{2})\s*$", _read(name), re.M)
|
||||
assert m, f"{name} 的 OnCalendar 不是固定的每日 HH:MM:SS 格式"
|
||||
return time(int(m.group(1)), int(m.group(2)), int(m.group(3)))
|
||||
|
||||
|
||||
def test_asset_sync_runs_before_renewal_check():
|
||||
"""资产同步必须早于续费检查
|
||||
|
||||
续费提醒读的是本地库的 expiry_date。顺序反了,提醒就用上一天的旧到期日,
|
||||
当天新到期的资产不会进通知——而这正是这套台账存在的理由。
|
||||
"""
|
||||
assert _oncalendar("vps-sync-assets.timer") < _oncalendar("vps-renewal-check.timer")
|
||||
|
||||
|
||||
def test_sync_timers_are_persistent():
|
||||
"""Persistent=true:机器在预定时刻关机时,开机后补跑一次
|
||||
|
||||
没有它,一次夜间重启就会整天跳过同步。
|
||||
"""
|
||||
for name in ("vps-sync-assets.timer", "vps-renewal-check.timer"):
|
||||
assert re.search(r"^Persistent=true\s*$", _read(name), re.M), f"{name} 缺少 Persistent=true"
|
||||
|
||||
|
||||
def test_sync_assets_service_is_oneshot_and_loads_env():
|
||||
"""oneshot + EnvironmentFile:前者让 systemd 正确判定成败,
|
||||
后者提供 MASTER_KEY(缺了就解密不了任何账号凭证,同步全部失败)"""
|
||||
text = _read("vps-sync-assets.service")
|
||||
assert re.search(r"^Type=oneshot\s*$", text, re.M)
|
||||
assert re.search(r"^EnvironmentFile=-?\S*\.env\s*$", text, re.M), "未加载 .env,拿不到 MASTER_KEY"
|
||||
@@ -0,0 +1,402 @@
|
||||
"""VPS 到期日同步链路 + 批量资产同步测试
|
||||
|
||||
覆盖的核心回归:NormalizedVPS 此前没有 expiry_date 字段,阿里云/腾讯云把
|
||||
ExpiredTime 抓进 raw 后直接丢弃,_sync_vps 也从不写 expiry_date(而
|
||||
_sync_domains 一直有写)。结果是「云端同步进来的 VPS 永远进不了续费提醒」——
|
||||
而续费提醒正是这个台账存在的理由。
|
||||
|
||||
运行:.venv/bin/pytest tests/test_vps_expiry_sync.py -v
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from sqlmodel import Session, SQLModel, create_engine, select
|
||||
|
||||
import app.models.asset # noqa: F401
|
||||
import app.models.credential # noqa: F401
|
||||
import app.models.monitor # noqa: F401
|
||||
import app.models.provider # noqa: F401
|
||||
import app.models.ssl # noqa: F401
|
||||
from app.adapters.aliyun import AliyunAdapter
|
||||
from app.adapters.base import NormalizedVPS
|
||||
from app.adapters.tencent import TencentAdapter
|
||||
from app.models.asset import Account, Asset, AssetType, VPSDetail
|
||||
from app.models.provider import Provider
|
||||
from app.services import sync_service
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def engine(tmp_path, monkeypatch):
|
||||
eng = create_engine(
|
||||
f"sqlite:///{tmp_path / 'sync.db'}", connect_args={"check_same_thread": False}
|
||||
)
|
||||
SQLModel.metadata.create_all(eng)
|
||||
# sync_all_assets 的 worker 会自己开 Session(assets_engine),必须一并指到临时库
|
||||
monkeypatch.setattr("app.database.assets_engine", eng)
|
||||
yield eng
|
||||
eng.dispose()
|
||||
|
||||
|
||||
class _FakeVPSAdapter:
|
||||
"""最小适配器:只提供 _sync_vps 用到的接口"""
|
||||
|
||||
required_config: list = []
|
||||
|
||||
def __init__(self, vps_list, domains_list=None):
|
||||
self.config = {}
|
||||
self._vps = vps_list
|
||||
self._domains = domains_list or []
|
||||
|
||||
def capabilities(self):
|
||||
return {
|
||||
"list_vps": bool(self._vps),
|
||||
"list_domains": bool(self._domains),
|
||||
"get_account": False,
|
||||
}
|
||||
|
||||
def list_vps(self):
|
||||
return self._vps
|
||||
|
||||
def list_domains(self):
|
||||
return self._domains
|
||||
|
||||
|
||||
def _seed(engine, slug="aliyun", sdk_type="aliyun-sdk", api_config=None):
|
||||
with Session(engine) as s:
|
||||
provider = Provider(slug=slug, name=slug, category="vps", sdk_type=sdk_type)
|
||||
s.add(provider)
|
||||
s.commit()
|
||||
s.refresh(provider)
|
||||
account = Account(name="acc", platform=slug, api_config_encrypted=api_config)
|
||||
s.add(account)
|
||||
s.commit()
|
||||
s.refresh(account)
|
||||
return provider.id, account.id
|
||||
|
||||
|
||||
# ----------------------------- 到期日链路 ----------------------------- #
|
||||
def test_normalized_vps_has_expiry_field():
|
||||
"""契约层面先钉住:字段必须存在且默认 None(后付费平台没有到期日)"""
|
||||
v = NormalizedVPS(external_id="i-1", name="n")
|
||||
assert v.expiry_date is None
|
||||
assert v.charge_type is None
|
||||
|
||||
|
||||
def test_normalized_vps_to_dict_serializes_expiry():
|
||||
"""to_dict 对外必须是 ISO 字符串,date 对象会让 JSON 序列化炸掉"""
|
||||
v = NormalizedVPS(external_id="i-1", name="n", expiry_date=date(2027, 3, 15))
|
||||
assert v.to_dict()["expiry_date"] == "2027-03-15"
|
||||
assert NormalizedVPS(external_id="i-2", name="n").to_dict()["expiry_date"] is None
|
||||
|
||||
|
||||
def test_sync_vps_creates_with_expiry(engine):
|
||||
"""新建分支必须写 expiry_date,否则云端同步的 VPS 进不了续费提醒"""
|
||||
provider_id, account_id = _seed(engine)
|
||||
adapter = _FakeVPSAdapter([
|
||||
NormalizedVPS(
|
||||
external_id="i-prepaid", name="包年包月机", ip_address="1.2.3.4",
|
||||
status="active", expiry_date=date(2027, 3, 15), charge_type="PrePaid",
|
||||
)
|
||||
])
|
||||
with Session(engine) as s:
|
||||
result = sync_service._sync_vps(s, s.get(Provider, provider_id), adapter, account_id)
|
||||
assert result == {"created": 1, "updated": 0}
|
||||
|
||||
with Session(engine) as s:
|
||||
asset = s.exec(select(Asset)).first()
|
||||
assert asset.expiry_date == date(2027, 3, 15)
|
||||
assert asset.asset_type == AssetType.VPS
|
||||
assert asset.account_id == account_id
|
||||
detail = s.exec(select(VPSDetail)).first()
|
||||
assert detail.ip_address == "1.2.3.4"
|
||||
|
||||
|
||||
def test_sync_vps_coerces_string_expiry(engine):
|
||||
"""适配器若仍返回 ISO 字符串,写库前归一为 date(否则 SQLite 抛 TypeError)"""
|
||||
provider_id, account_id = _seed(engine)
|
||||
adapter = _FakeVPSAdapter([
|
||||
NormalizedVPS(external_id="i-1", name="n", expiry_date="2027-03-15")
|
||||
])
|
||||
with Session(engine) as s:
|
||||
sync_service._sync_vps(s, s.get(Provider, provider_id), adapter, account_id)
|
||||
with Session(engine) as s:
|
||||
assert s.exec(select(Asset)).first().expiry_date == date(2027, 3, 15)
|
||||
|
||||
|
||||
def test_sync_vps_postpaid_leaves_expiry_null(engine):
|
||||
"""后付费(AWS 按需 / Linode / DO / Vultr)没有到期日,必须是 None 而不是假日期"""
|
||||
provider_id, account_id = _seed(engine)
|
||||
adapter = _FakeVPSAdapter([
|
||||
NormalizedVPS(external_id="i-1", name="n", charge_type="postpaid", monthly_cost=12.0)
|
||||
])
|
||||
with Session(engine) as s:
|
||||
sync_service._sync_vps(s, s.get(Provider, provider_id), adapter, account_id)
|
||||
with Session(engine) as s:
|
||||
asset = s.exec(select(Asset)).first()
|
||||
assert asset.expiry_date is None
|
||||
assert asset.cost == 12.0
|
||||
|
||||
|
||||
def test_sync_vps_updates_existing_expiry(engine):
|
||||
"""重复同步走更新分支:到期日按新值刷新,不新建资产"""
|
||||
provider_id, account_id = _seed(engine)
|
||||
|
||||
def _run(expiry):
|
||||
adapter = _FakeVPSAdapter([NormalizedVPS(external_id="i-1", name="n", expiry_date=expiry)])
|
||||
with Session(engine) as s:
|
||||
return sync_service._sync_vps(s, s.get(Provider, provider_id), adapter, account_id)
|
||||
|
||||
assert _run(date(2027, 3, 15)) == {"created": 1, "updated": 0}
|
||||
assert _run(date(2028, 3, 15)) == {"created": 0, "updated": 1}
|
||||
with Session(engine) as s:
|
||||
assets = s.exec(select(Asset)).all()
|
||||
assert len(assets) == 1
|
||||
assert assets[0].expiry_date == date(2028, 3, 15)
|
||||
|
||||
|
||||
def test_sync_vps_never_clears_expiry(engine):
|
||||
"""云端返回 None 时不能把已有的到期日抹掉
|
||||
|
||||
阿里云 DescribeInstances 偶发不返回 ExpiredTime,若照搬 None 就会让
|
||||
一台明明要到期的机器从续费提醒里消失。
|
||||
"""
|
||||
provider_id, account_id = _seed(engine)
|
||||
with Session(engine) as s:
|
||||
sync_service._sync_vps(
|
||||
s, s.get(Provider, provider_id),
|
||||
_FakeVPSAdapter([NormalizedVPS(external_id="i-1", name="n", expiry_date=date(2027, 3, 15))]),
|
||||
account_id,
|
||||
)
|
||||
with Session(engine) as s:
|
||||
sync_service._sync_vps(
|
||||
s, s.get(Provider, provider_id),
|
||||
_FakeVPSAdapter([NormalizedVPS(external_id="i-1", name="n", expiry_date=None)]),
|
||||
account_id,
|
||||
)
|
||||
with Session(engine) as s:
|
||||
assert s.exec(select(Asset)).first().expiry_date == date(2027, 3, 15)
|
||||
|
||||
|
||||
def test_sync_vps_updates_cost_only_when_provided(engine):
|
||||
"""monthly_cost 为 None 时保留原值,别把台账里手填的成本冲成 0"""
|
||||
provider_id, account_id = _seed(engine)
|
||||
with Session(engine) as s:
|
||||
sync_service._sync_vps(
|
||||
s, s.get(Provider, provider_id),
|
||||
_FakeVPSAdapter([NormalizedVPS(external_id="i-1", name="n", monthly_cost=20.0)]),
|
||||
account_id,
|
||||
)
|
||||
with Session(engine) as s:
|
||||
sync_service._sync_vps(
|
||||
s, s.get(Provider, provider_id),
|
||||
_FakeVPSAdapter([NormalizedVPS(external_id="i-1", name="n", monthly_cost=None)]),
|
||||
account_id,
|
||||
)
|
||||
with Session(engine) as s:
|
||||
assert s.exec(select(Asset)).first().cost == 20.0
|
||||
|
||||
|
||||
# ----------------------------- 真实适配器的到期日映射 ----------------------------- #
|
||||
def test_aliyun_maps_expired_time_to_expiry():
|
||||
"""阿里云包年包月实例的 ExpiredTime 此前被抓进 raw 后直接丢弃"""
|
||||
adapter = AliyunAdapter({"access_key_id": "id", "access_key_secret": "sec", "region_id": "cn-hangzhou"})
|
||||
payload = {
|
||||
"Instances": {
|
||||
"Instance": [
|
||||
{
|
||||
"InstanceId": "i-ali",
|
||||
"InstanceName": "ali-web",
|
||||
"Status": "Running",
|
||||
"PublicIpAddress": {"IpAddress": ["1.2.3.4"]},
|
||||
"ExpiredTime": "2027-03-15T16:00Z",
|
||||
"InstanceChargeType": "PrePaid",
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
with patch.object(AliyunAdapter, "_call", return_value=payload):
|
||||
vps = adapter.list_vps()
|
||||
assert len(vps) == 1
|
||||
assert vps[0].expiry_date == date(2027, 3, 15)
|
||||
assert vps[0].charge_type == "prepaid" # 适配器统一归一为小写,前端好判断
|
||||
|
||||
|
||||
def test_aliyun_postpaid_has_no_expiry():
|
||||
adapter = AliyunAdapter({"access_key_id": "id", "access_key_secret": "sec"})
|
||||
payload = {
|
||||
"Instances": {
|
||||
"Instance": [
|
||||
{"InstanceId": "i-post", "InstanceName": "n", "Status": "Running",
|
||||
"InstanceChargeType": "PostPaid"}
|
||||
]
|
||||
}
|
||||
}
|
||||
with patch.object(AliyunAdapter, "_call", return_value=payload):
|
||||
vps = adapter.list_vps()
|
||||
assert vps[0].expiry_date is None
|
||||
assert vps[0].charge_type == "postpaid"
|
||||
|
||||
|
||||
def test_tencent_maps_expired_time_to_expiry():
|
||||
"""腾讯云 CVM 的 ExpiredTime 同样此前被丢弃
|
||||
|
||||
注意 _call 返回的是已剥掉 Response 信封的内层 dict(TC3 的统一约定)。
|
||||
"""
|
||||
adapter = TencentAdapter({"secret_id": "SID", "secret_key": "SKEY", "region": "ap-guangzhou"})
|
||||
payload = {
|
||||
"InstanceSet": [
|
||||
{
|
||||
"InstanceId": "ins-tx",
|
||||
"InstanceName": "tx-web",
|
||||
"InstanceState": "RUNNING",
|
||||
"PublicIpAddresses": ["5.6.7.8"],
|
||||
"ExpiredTime": "2027-03-15T16:00:00Z",
|
||||
"InstanceChargeType": "PREPAID",
|
||||
}
|
||||
],
|
||||
"TotalCount": 1,
|
||||
}
|
||||
with patch.object(TencentAdapter, "_call", return_value=payload):
|
||||
vps = adapter.list_vps()
|
||||
assert len(vps) == 1
|
||||
assert vps[0].expiry_date == date(2027, 3, 15)
|
||||
assert vps[0].charge_type == "prepaid"
|
||||
|
||||
|
||||
def test_tencent_postpaid_by_hour_has_no_expiry():
|
||||
"""腾讯云按量计费(POSTPAID_BY_HOUR)没有到期日"""
|
||||
adapter = TencentAdapter({"secret_id": "SID", "secret_key": "SKEY"})
|
||||
payload = {
|
||||
"InstanceSet": [
|
||||
{"InstanceId": "ins-h", "InstanceName": "n", "InstanceState": "RUNNING",
|
||||
"InstanceChargeType": "POSTPAID_BY_HOUR"}
|
||||
],
|
||||
"TotalCount": 1,
|
||||
}
|
||||
with patch.object(TencentAdapter, "_call", return_value=payload):
|
||||
vps = adapter.list_vps()
|
||||
assert vps[0].expiry_date is None
|
||||
assert vps[0].charge_type == "postpaid"
|
||||
|
||||
|
||||
# ----------------------------- 批量同步 ----------------------------- #
|
||||
def test_sync_all_assets_skips_accounts_without_config(engine, monkeypatch):
|
||||
"""未配凭证的账号计入 skipped 而非 failed
|
||||
|
||||
线上 12 个账号全部 has_api=False,若算 failed,每次定时同步都会报一堆错。
|
||||
"""
|
||||
_seed(engine, slug="aliyun", api_config=None)
|
||||
with Session(engine) as s:
|
||||
result = sync_service.sync_all_assets(s)
|
||||
assert result["total"] == 0
|
||||
assert result["skipped_no_config"] == 1
|
||||
assert result["failed"] == 0
|
||||
|
||||
|
||||
def test_sync_all_assets_skips_unsupported_sdk_type(engine, monkeypatch):
|
||||
"""平台没有可用适配器时计入 skipped_no_adapter,不该报成错误"""
|
||||
_seed(engine, slug="cloudcone", sdk_type=None, api_config="enc")
|
||||
with Session(engine) as s:
|
||||
result = sync_service.sync_all_assets(s)
|
||||
assert result["total"] == 0
|
||||
assert result["skipped_no_adapter"] == 1
|
||||
assert result["failed"] == 0
|
||||
|
||||
|
||||
def test_sync_all_assets_skips_unknown_platform(engine, monkeypatch):
|
||||
"""账号挂在一个不存在的平台上,不能被当成同步失败"""
|
||||
with Session(engine) as s:
|
||||
s.add(Account(name="orphan", platform="no-such-platform", api_config_encrypted="enc"))
|
||||
s.commit()
|
||||
with Session(engine) as s:
|
||||
result = sync_service.sync_all_assets(s)
|
||||
assert result["total"] == 0
|
||||
assert result["skipped_no_adapter"] == 1
|
||||
|
||||
|
||||
def test_sync_all_assets_aggregates_counts(engine, monkeypatch):
|
||||
"""成功路径:created/updated 要把 vps 与 domains 两类都累加进来"""
|
||||
_seed(engine, slug="aliyun", sdk_type="aliyun-sdk", api_config="enc")
|
||||
fake = _FakeVPSAdapter(
|
||||
[NormalizedVPS(external_id="i-1", name="n", expiry_date=date(2027, 1, 1))],
|
||||
domains_list=[],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
sync_service, "sync_account",
|
||||
lambda session, account_id: {"vps": {"created": 2, "updated": 1}, "domains": {"created": 3, "updated": 0}},
|
||||
)
|
||||
with Session(engine) as s:
|
||||
result = sync_service.sync_all_assets(s)
|
||||
assert result["total"] == 1
|
||||
assert result["success"] == 1
|
||||
assert result["failed"] == 0
|
||||
assert result["created"] == 5
|
||||
assert result["updated"] == 1
|
||||
assert fake.capabilities()["list_vps"] is True
|
||||
|
||||
|
||||
def test_sync_all_assets_surfaces_partial_failure(engine, monkeypatch):
|
||||
"""某一类资产同步失败不算账号整体失败,但必须在 errors 里如实上报"""
|
||||
_seed(engine, slug="aliyun", sdk_type="aliyun-sdk", api_config="enc")
|
||||
monkeypatch.setattr(
|
||||
sync_service, "sync_account",
|
||||
lambda session, account_id: {
|
||||
"vps": None, "vps_error": "签名失败", "domains": {"created": 1, "updated": 0},
|
||||
},
|
||||
)
|
||||
with Session(engine) as s:
|
||||
result = sync_service.sync_all_assets(s)
|
||||
assert result["success"] == 1
|
||||
assert result["failed"] == 0
|
||||
assert any("vps_error" in e for e in result["errors"])
|
||||
|
||||
|
||||
def test_sync_all_assets_all_subtasks_failed_counts_as_failed(engine, monkeypatch):
|
||||
"""每一项尝试都失败时必须算 failed
|
||||
|
||||
凭证过期的典型现场:list_vps / list_domains / get_account 全部 401。
|
||||
这时若仍计「成功」,前端汇总行会显示「成功 1,失败 0」,
|
||||
用户以为数据拉到了、实际一条都没有——比报错更危险。
|
||||
"""
|
||||
_seed(engine, slug="vultr", sdk_type="vultr-api", api_config="enc")
|
||||
monkeypatch.setattr(
|
||||
sync_service, "sync_account",
|
||||
lambda session, account_id: {
|
||||
"vps": None, "vps_error": "HTTP 401:Invalid API token.",
|
||||
"domains": None, "domains_error": "HTTP 401:Invalid API token.",
|
||||
"account_info": None, "account_info_error": "HTTP 401:Invalid API token.",
|
||||
},
|
||||
)
|
||||
with Session(engine) as s:
|
||||
result = sync_service.sync_all_assets(s)
|
||||
assert result["success"] == 0
|
||||
assert result["failed"] == 1
|
||||
assert result["created"] == 0 and result["updated"] == 0
|
||||
assert len(result["errors"]) == 3 # 三类错误都要如实列出
|
||||
|
||||
|
||||
def test_sync_all_assets_counts_hard_failure(engine, monkeypatch):
|
||||
"""sync_account 直接抛错时算 failed,且带上账号标签便于定位"""
|
||||
_seed(engine, slug="aliyun", sdk_type="aliyun-sdk", api_config="enc")
|
||||
|
||||
def _boom(session, account_id):
|
||||
raise RuntimeError("凭证解密失败")
|
||||
|
||||
monkeypatch.setattr(sync_service, "sync_account", _boom)
|
||||
with Session(engine) as s:
|
||||
result = sync_service.sync_all_assets(s)
|
||||
assert result["failed"] == 1
|
||||
assert result["success"] == 0
|
||||
assert any("凭证解密失败" in e for e in result["errors"])
|
||||
|
||||
|
||||
def test_sync_all_assets_result_shape(engine, monkeypatch):
|
||||
"""脚本 sync_all_assets.py 依赖这些键存在,改名会静默打出 None"""
|
||||
_seed(engine, slug="cloudcone", sdk_type=None, api_config=None)
|
||||
with Session(engine) as s:
|
||||
result = sync_service.sync_all_assets(s)
|
||||
for key in ("total", "success", "failed", "created", "updated",
|
||||
"skipped_no_config", "skipped_no_adapter", "errors", "synced_at"):
|
||||
assert key in result, f"返回结构缺少 {key}"
|
||||
Reference in New Issue
Block a user