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:
@@ -0,0 +1,282 @@
|
||||
"""云资源管理动作服务
|
||||
|
||||
把「资产 → 账号 → 平台适配器」解析成一次真实的云端写操作。
|
||||
|
||||
与只读同步的关键区别:这里的每个动作都会改变云端真实资源——关机会中断线上服务、
|
||||
续费会真实扣款。因此三条硬约束:
|
||||
1. 路由层强制 require_api_key(外部流量无 key 一律 401)
|
||||
2. 每次动作都写 EventLog(source="action"),成功与失败都记,且用独立 Session 提交,
|
||||
避免主流程回滚时把审计记录一起丢掉
|
||||
3. 适配器不支持该动作时如实抛 400,绝不静默 no-op 让调用方以为成功了
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.adapters import registry
|
||||
from app.adapters.base import BaseAdapter, cloud_error_text
|
||||
from app.core.timeutils import utcnow
|
||||
from app.models.asset import Asset, AssetType, DomainDetail
|
||||
from app.services.sync_service import _build_adapter_for_account, _missing_config
|
||||
|
||||
logger = logging.getLogger("vps-manager.action")
|
||||
|
||||
#: 动作名 -> 适配器方法名
|
||||
INSTANCE_ACTIONS = {
|
||||
"start": "start_instance",
|
||||
"stop": "stop_instance",
|
||||
"reboot": "reboot_instance",
|
||||
"renew": "renew_instance",
|
||||
}
|
||||
|
||||
#: 各动作的中文标签,用于审计日志与前端提示
|
||||
ACTION_LABELS = {
|
||||
"start": "开机",
|
||||
"stop": "关机",
|
||||
"reboot": "重启",
|
||||
"renew": "续费",
|
||||
}
|
||||
|
||||
|
||||
def audit(asset_id: int | None, message: str, level: str = "info") -> None:
|
||||
"""写审计日志(独立 Session,主流程回滚也不丢)
|
||||
|
||||
审计失败绝不能让业务动作失败——云端指令可能已经下发成功,
|
||||
此时抛错会让调用方误判为「没执行」,比丢一条日志危险得多。
|
||||
"""
|
||||
from app.database import metrics_engine
|
||||
from app.models.monitor import EventLog
|
||||
|
||||
try:
|
||||
with Session(metrics_engine) as s:
|
||||
s.add(EventLog(level=level, source="action", asset_id=asset_id, message=message))
|
||||
s.commit()
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("审计日志写入失败 asset_id=%s err=%s", asset_id, e)
|
||||
|
||||
|
||||
def _resolve(session: Session, asset_id: int) -> tuple[Asset, BaseAdapter, str]:
|
||||
"""解析资产对应的适配器,返回 (asset, adapter, provider_label)"""
|
||||
asset = session.get(Asset, asset_id)
|
||||
if not asset:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="资产不存在")
|
||||
if not asset.account_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="该资产未关联账号,无法确定使用哪套云凭证执行动作",
|
||||
)
|
||||
from app.models.asset import Account
|
||||
|
||||
account = session.get(Account, asset.account_id)
|
||||
if not account:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="资产关联的账号已不存在"
|
||||
)
|
||||
provider, adapter = _build_adapter_for_account(session, account)
|
||||
missing = _missing_config(adapter)
|
||||
if missing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"缺少凭证配置:{', '.join(missing)}(请先在账号编辑里填写 API 配置)",
|
||||
)
|
||||
return asset, adapter, provider.slug
|
||||
|
||||
|
||||
def _require_capability(adapter: BaseAdapter, method_name: str, label: str) -> None:
|
||||
if not adapter.capabilities().get(method_name):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"{type(adapter).__name__} 不支持{label}",
|
||||
)
|
||||
|
||||
|
||||
def perform_instance_action(
|
||||
session: Session, asset_id: int, action: str, period_months: int = 1
|
||||
) -> dict:
|
||||
"""对 VPS 资产执行 开机/关机/重启/续费"""
|
||||
if action not in INSTANCE_ACTIONS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"未知动作:{action}(可选 {'/'.join(INSTANCE_ACTIONS)})",
|
||||
)
|
||||
asset, adapter, provider_slug = _resolve(session, asset_id)
|
||||
if asset.asset_type != AssetType.VPS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"仅 VPS 资产支持该动作(当前类型:{asset.asset_type.value})",
|
||||
)
|
||||
if not asset.external_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="该资产没有云端实例 ID(external_id),可能是手工登记的,无法下发指令",
|
||||
)
|
||||
|
||||
method_name = INSTANCE_ACTIONS[action]
|
||||
label = ACTION_LABELS[action]
|
||||
_require_capability(adapter, method_name, label)
|
||||
|
||||
target = f"{provider_slug}/{asset.name}({asset.external_id})"
|
||||
audit(asset_id, f"{label}动作下发:{target}" + (f" 周期={period_months}月" if action == "renew" else ""))
|
||||
try:
|
||||
method = getattr(adapter, method_name)
|
||||
result = method(asset.external_id, period_months) if action == "renew" else method(asset.external_id)
|
||||
except NotImplementedError:
|
||||
audit(asset_id, f"{label}失败:适配器未实现 {target}", level="warning")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail=f"{provider_slug} 暂不支持{label}"
|
||||
) from None
|
||||
except Exception as e: # noqa: BLE001
|
||||
msg = cloud_error_text(e)
|
||||
audit(asset_id, f"{label}异常:{target} err={msg}", level="error")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY, detail=f"{label}失败:{msg}"
|
||||
) from e
|
||||
|
||||
ok = bool(result.get("ok"))
|
||||
audit(
|
||||
asset_id,
|
||||
f"{label}{'成功' if ok else '被拒'}:{target} msg={result.get('message')}",
|
||||
level="info" if ok else "error",
|
||||
)
|
||||
return {
|
||||
"ok": ok,
|
||||
"action": action,
|
||||
"asset_id": asset_id,
|
||||
"provider": provider_slug,
|
||||
"external_id": asset.external_id,
|
||||
"message": result.get("message", ""),
|
||||
"at": utcnow().isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def _resolve_zone(session: Session, asset_id: int) -> tuple[Asset, BaseAdapter, str, str]:
|
||||
"""解析域名资产的 DNS zone 标识,返回 (asset, adapter, provider_slug, zone_id)
|
||||
|
||||
zone_id 取 Asset.external_id:Cloudflare 是 zone uuid,阿里云/腾讯云/Vultr/DO 是域名字符串,
|
||||
两者的 list_dns_records 都已按各自约定实现。
|
||||
"""
|
||||
asset, adapter, provider_slug = _resolve(session, asset_id)
|
||||
if asset.asset_type != AssetType.DOMAIN:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"仅域名资产支持 DNS 记录管理(当前类型:{asset.asset_type.value})",
|
||||
)
|
||||
zone_id = asset.external_id
|
||||
if not zone_id:
|
||||
detail = session.exec(
|
||||
select(DomainDetail).where(DomainDetail.asset_id == asset_id)
|
||||
).first()
|
||||
zone_id = (detail.domain_name if detail else None) or asset.name
|
||||
if not zone_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="无法确定该域名的 zone 标识(external_id 与 domain_name 均为空)",
|
||||
)
|
||||
return asset, adapter, provider_slug, zone_id
|
||||
|
||||
|
||||
def list_dns_records(session: Session, asset_id: int) -> dict:
|
||||
asset, adapter, provider_slug, zone_id = _resolve_zone(session, asset_id)
|
||||
_require_capability(adapter, "list_dns_records", "DNS 记录查询")
|
||||
try:
|
||||
records = [r.to_dict() for r in adapter.list_dns_records(zone_id)]
|
||||
except NotImplementedError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail=f"{provider_slug} 暂不支持 DNS 记录查询"
|
||||
) from None
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY, detail=f"查询 DNS 记录失败:{cloud_error_text(e)}"
|
||||
) from e
|
||||
return {"asset_id": asset_id, "provider": provider_slug, "zone_id": zone_id, "records": records}
|
||||
|
||||
|
||||
def _dns_write(
|
||||
session: Session,
|
||||
asset_id: int,
|
||||
method_name: str,
|
||||
label: str,
|
||||
args: tuple,
|
||||
) -> dict:
|
||||
asset, adapter, provider_slug, zone_id = _resolve_zone(session, asset_id)
|
||||
_require_capability(adapter, method_name, label)
|
||||
target = f"{provider_slug}/{zone_id}"
|
||||
audit(asset_id, f"DNS {label}下发:{target} args={args[1:]}")
|
||||
try:
|
||||
result = getattr(adapter, method_name)(*args)
|
||||
except NotImplementedError:
|
||||
audit(asset_id, f"DNS {label}失败:适配器未实现 {target}", level="warning")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail=f"{provider_slug} 暂不支持{label}"
|
||||
) from None
|
||||
except Exception as e: # noqa: BLE001
|
||||
msg = cloud_error_text(e)
|
||||
audit(asset_id, f"DNS {label}异常:{target} err={msg}", level="error")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY, detail=f"{label}失败:{msg}"
|
||||
) from e
|
||||
ok = bool(result.get("ok"))
|
||||
audit(
|
||||
asset_id,
|
||||
f"DNS {label}{'成功' if ok else '被拒'}:{target} msg={result.get('message')}",
|
||||
level="info" if ok else "error",
|
||||
)
|
||||
return {
|
||||
"ok": ok,
|
||||
"asset_id": asset_id,
|
||||
"provider": provider_slug,
|
||||
"zone_id": zone_id,
|
||||
"record_id": result.get("record_id"),
|
||||
"message": result.get("message", ""),
|
||||
}
|
||||
|
||||
|
||||
def create_dns_record(session: Session, asset_id: int, record: dict) -> dict:
|
||||
_, _, _, zone_id = _resolve_zone(session, asset_id)
|
||||
return _dns_write(session, asset_id, "create_dns_record", "记录创建", (zone_id, record))
|
||||
|
||||
|
||||
def update_dns_record(session: Session, asset_id: int, record_id: str, record: dict) -> dict:
|
||||
_, _, _, zone_id = _resolve_zone(session, asset_id)
|
||||
return _dns_write(
|
||||
session, asset_id, "update_dns_record", "记录更新", (zone_id, record_id, record)
|
||||
)
|
||||
|
||||
|
||||
def delete_dns_record(session: Session, asset_id: int, record_id: str) -> dict:
|
||||
_, _, _, zone_id = _resolve_zone(session, asset_id)
|
||||
return _dns_write(
|
||||
session, asset_id, "delete_dns_record", "记录删除", (zone_id, record_id)
|
||||
)
|
||||
|
||||
|
||||
def asset_actions(session: Session, asset_id: int) -> dict:
|
||||
"""返回某资产当前可执行的动作(前端据此渲染/禁用按钮)
|
||||
|
||||
手工登记、无 external_id、或适配器不支持的,一律标 False,
|
||||
避免用户点了才收到 400。
|
||||
"""
|
||||
try:
|
||||
asset, adapter, provider_slug = _resolve(session, asset_id)
|
||||
except HTTPException as e:
|
||||
return {"asset_id": asset_id, "available": False, "reason": e.detail, "actions": {}}
|
||||
|
||||
caps = adapter.capabilities()
|
||||
is_vps = asset.asset_type == AssetType.VPS
|
||||
is_domain = asset.asset_type == AssetType.DOMAIN
|
||||
has_external = bool(asset.external_id)
|
||||
actions = {
|
||||
name: bool(is_vps and has_external and caps.get(method))
|
||||
for name, method in INSTANCE_ACTIONS.items()
|
||||
}
|
||||
actions["dns_records"] = bool(is_domain and caps.get("list_dns_records"))
|
||||
actions["dns_write"] = bool(is_domain and caps.get("create_dns_record"))
|
||||
return {
|
||||
"asset_id": asset_id,
|
||||
"provider": provider_slug,
|
||||
"sdk_type": getattr(type(adapter), "sdk_types", []),
|
||||
"available": any(actions.values()),
|
||||
"actions": actions,
|
||||
}
|
||||
Reference in New Issue
Block a user