Files
vps-manager/tests/test_vps_expiry_sync.py
T
gouki 46faadbcae 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
2026-10-12 00:43:37 +00:00

403 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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}"