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:
gouki
2026-10-12 00:43:37 +00:00
parent 192d448a28
commit 46faadbcae
42 changed files with 5570 additions and 99 deletions
+251 -12
View File
@@ -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}"}
+369
View File
@@ -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
View File
@@ -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}
+96 -5
View File
@@ -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}"}
+129 -3
View File
@@ -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}"}
+213
View File
@@ -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}"}
+163
View File
@@ -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}"}
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+87
View File
@@ -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)
+9
View File
@@ -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)
+56
View File
@@ -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
+4
View File
@@ -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
+282
View File
@@ -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,
}
+18 -4
View File
@@ -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,
}
+95 -6
View File
@@ -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
View File
@@ -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
+13
View File
@@ -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
+16
View File
@@ -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
+38
View File
@@ -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())
+9
View File
@@ -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';
},
+4
View File
@@ -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();
+203
View File
@@ -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
View File
@@ -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); }
+23 -3
View File
@@ -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 };
},
};
+2
View File
@@ -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>
+2 -1
View File
@@ -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 };
},
};
+2 -2
View File
@@ -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">
+30 -2
View File
@@ -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 };
},
};
+1 -1
View File
@@ -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>
+718
View File
@@ -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
View File
@@ -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 [
+204
View File
@@ -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"
+716
View File
@@ -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
+196
View File
@@ -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)}"
+183
View File
@@ -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}
+228
View File
@@ -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"
+402
View File
@@ -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}"