评估发现项目只有台账能力,缺失真正的多云管理链路。本次按缺口逐项补齐: 适配层 - 新增 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
403 lines
16 KiB
Python
403 lines
16 KiB
Python
"""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}"
|