Files
vps-manager/app/main.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

196 lines
7.1 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-manager 后端入口
- 注册资产 CRUD 与统计路由
- 挂载本地静态资源(Vue/Tailwind/前端逻辑)
- 渲染前端 SPA 入口页面
- 启用 CORS(便于外部程序/其他 AI 跨域调用写入接口)
- 版本:由 VERSION 文件驱动
"""
from contextlib import asynccontextmanager, suppress
from pathlib import Path
import asyncio
import logging
import subprocess
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, HTMLResponse
from starlette.staticfiles import StaticFiles as StarletteStaticFiles
from jinja2 import Environment, FileSystemLoader
from sqlmodel import Session
from app.core.config import settings
from app.core.seed import seed_providers
from app.database import assets_engine, init_db
from app.routers import accounts, actions, agent, assets, credentials, monitor, notify, providers, ssl, stats, sync
from app.services import cleanup_service
BASE_DIR = Path(__file__).resolve().parent.parent
STATIC_DIR = BASE_DIR / "static"
TEMPLATE_DIR = BASE_DIR / "app" / "templates"
STATIC_DIR.mkdir(parents=True, exist_ok=True)
def _asset_version() -> str:
"""静态资源版本号:取 static 目录内最新文件 mtime,代码更新后自动变更。
用于前端引用 ?v= 参数,绕开浏览器启发式缓存(旧响应无 Cache-Control
时存下的条目会被视为新鲜而不再回源验证)。
启动时计算一次并缓存:静态资源只在代码更新时变化,而更新后服务会重启,
避免每次页面请求都遍历 stat 整个 static 目录。
"""
global _ASSET_VERSION_CACHE
if _ASSET_VERSION_CACHE is None:
latest = 0
for p in STATIC_DIR.rglob("*"):
if p.is_file():
latest = max(latest, int(p.stat().st_mtime))
_ASSET_VERSION_CACHE = str(latest)
return _ASSET_VERSION_CACHE
_ASSET_VERSION_CACHE: str | None = None
def _read_app_version() -> str:
"""语义版本号:读取项目根目录 VERSION 文件(缺失时回退 dev)"""
try:
return (BASE_DIR / "VERSION").read_text(encoding="utf-8").strip() or "dev"
except OSError:
return "dev"
def _read_git_commit() -> str:
"""当前 git 短哈希:用于比对本地与线上代码是否一致(非 git 环境回退 unknown)"""
try:
out = subprocess.run(
["git", "rev-parse", "--short", "HEAD"],
cwd=BASE_DIR, capture_output=True, text=True, timeout=5,
)
return out.stdout.strip() or "unknown"
except Exception:
return "unknown"
APP_VERSION = _read_app_version()
GIT_COMMIT = _read_git_commit()
# 全局日志配置:统一格式,便于生产环境排查
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
)
# 直接使用 Jinja2 Environment 渲染(规避 Starlette Jinja2Templates 在 Python 3.14 下的缓存兼容问题)
jinja_env = Environment(loader=FileSystemLoader(TEMPLATE_DIR), autoescape=True)
def _warn_insecure_config() -> None:
"""把「暴露面 vs 密钥」的配置缺口打到启动日志里,避免静默裸奔
服务默认监听 0.0.0.0,而密钥留空时外部来源会被 fail-closed 拒绝:
这意味着 funnel/公网访问直接不可用,原因必须在日志里说清。
"""
log = logging.getLogger(__name__)
if not settings.API_KEY:
log.warning("未配置 API_KEY:外部来源的写操作一律 401,仅 tailnet/本机可用")
if not settings.AGENT_KEY:
log.warning(
"未配置 AGENT_KEY:外部来源的 Agent 上报一律 401(tailnet 内的 Agent 不受影响)"
)
@asynccontextmanager
async def lifespan(_: FastAPI):
"""应用启动时自动建表、初始化预设平台,并启动监控数据定期清理任务"""
init_db()
_warn_insecure_config()
with Session(assets_engine) as session:
seed_providers(session)
cleanup_task = asyncio.create_task(cleanup_service.cleanup_loop())
yield
cleanup_task.cancel()
# 等取消真正落地:只 cancel 不 await 的话,事件循环关闭时该 task 仍处于 pending,
# 会打出 "Task was destroyed but it is pending",也可能跳过清理协程的收尾逻辑
with suppress(asyncio.CancelledError):
await cleanup_task
app = FastAPI(title=settings.APP_NAME, version=f"{APP_VERSION} ({GIT_COMMIT})", lifespan=lifespan)
# 启动即打印版本号,部署后可通过 journalctl 快速确认线上运行版本
logging.getLogger(__name__).info("vps-manager 启动:版本 %s,commit %s", APP_VERSION, GIT_COMMIT)
app.add_middleware(
CORSMiddleware,
allow_origins=[o.strip() for o in settings.CORS_ORIGINS.split(",") if o.strip()],
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["Content-Type", "X-API-Key", "X-Agent-Key"],
allow_credentials=True,
)
app.include_router(assets.router)
app.include_router(providers.router)
app.include_router(accounts.router)
app.include_router(credentials.router)
app.include_router(stats.router)
app.include_router(agent.router)
app.include_router(monitor.router)
app.include_router(sync.router)
app.include_router(actions.router)
app.include_router(notify.router)
app.include_router(ssl.router)
# 静态资源:no-cache(每次回源校验 ETag)。避免启发式缓存导致旧版本残留,
# 与 SW 的 no-store 回源配合,保证代码更新后立即生效。
class NoCacheStaticFiles(StarletteStaticFiles):
def file_response(self, *args, **kwargs):
resp = super().file_response(*args, **kwargs)
resp.headers.setdefault("Cache-Control", "no-cache")
return resp
app.mount("/static", NoCacheStaticFiles(directory=STATIC_DIR), name="static")
@app.get("/", include_in_schema=False)
def index() -> HTMLResponse:
"""前端 SPA 入口
no-cache:HTML 必须每次回源校验,否则浏览器启发式缓存会持有旧 HTML,
其中引用的 ?v= 静态资源版本号也是旧的,导致部署后用户看到旧版。
"""
html = jinja_env.get_template("index.html").render(
app_name=settings.APP_NAME, asset_version=_asset_version(),
app_version=APP_VERSION, git_commit=GIT_COMMIT,
)
return HTMLResponse(html, headers={"Cache-Control": "no-cache"})
@app.get("/sw.js", include_in_schema=False)
def service_worker() -> FileResponse:
"""Service Worker(置于根路径以使 scope 覆盖全站)
no-cache:保证浏览器每次导航都校验 SW 是否有更新,
否则浏览器可能长时间持有旧版 SW(默认更新检查间隔长)。
"""
return FileResponse(
STATIC_DIR / "sw.js",
media_type="application/javascript",
headers={
"Service-Worker-Allowed": "/",
"Cache-Control": "no-cache",
},
)
@app.get("/health")
def health_check() -> dict:
"""健康检查(version/commit 动态读取,便于验证自动更新与比对本地版本)"""
return {
"status": "ok",
"app": "vps-manager",
"version": APP_VERSION,
"commit": GIT_COMMIT,
}