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

197 lines
7.5 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.
"""平台注册表对齐测试
覆盖两处回归:
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)}"