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
+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"