Files
vps-manager/tests/test_sync_chain.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

229 lines
9.7 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.
"""云资产同步链路测试:路由注册 + 定时脚本 + 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"