"""云资源管理动作服务 把「资产 → 账号 → 平台适配器」解析成一次真实的云端写操作。 与只读同步的关键区别:这里的每个动作都会改变云端真实资源——关机会中断线上服务、 续费会真实扣款。因此三条硬约束: 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, }