Files
vps-manager/app/adapters/zeabur.py
T
gouki 103f48e38b feat(adapters): 接入 CloudCone/Zeabur/Dynadot/新网/Agnes 五个平台
线上平台列表里 cloudcone、zeabur、xinwang、dynadot、agnes 的 sdk_type 仍为空,
平台卡片只能显示「未对接」,用户无法同步。按各自真实 API 能力补齐:

- CloudCone:实例清单 + 开机/关机/重启;无公开账单接口,不声明余额与续费
- Zeabur:服务器清单 + 重启;毫核/MB 归一化为核/GB,未采集时不伪造规格
- Dynadot:域名清单(含到期日)+ 账户余额;set_dns2 位置参数无法安全 CRUD
- 新网:代理 API 按域名逐个查到期日;无列表接口,需在账号里填 domains 清单
- Agnes:/models 校验 Key;无余额接口,如实标记不支持

配套改动:
- _align_preset_providers 为库中为空的 sdk_type 回填种子值(启动即生效),
  避免新适配器上线后线上仍停留在「未对接」
- 适配器可声明 plain_config,代理号/域名等非密钥字段前端按明文输入
- PRESET_PROVIDERS 五条记录填上对应 sdk_type

测试:445 passed(新增 tests/test_platform_adapters.py),vitest 62 passed。
2026-10-26 09:16:45 +00:00

120 lines
4.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.
"""Zeabur 适配器(GraphQL Public API,Bearer Token)
API 文档:https://zeabur.com/docs/zh-CN/developer/public-api
端点:https://api.zeabur.com/graphql
所需配置:{"api_token": "..."}
覆盖:团队/个人名下的服务器清单、重启服务器。
服务器列表返回 CPU(毫核)、内存/磁盘(MB)与在线状态;
Zeabur 的计费走用量结算,没有统一的「到期日/余额」查询,故不声明
get_account,也不提供续费。
"""
import httpx
from app.adapters.base import BaseAdapter, NormalizedVPS
from app.adapters.registry import register
_SERVERS_QUERY = """
query Servers {
servers {
_id
name
ip
country
city
provisioningStatus
providerInfo { code name }
hasK3s
status {
isOnline
totalCPU
totalMemory
totalDisk
vmStatus
}
}
}
"""
@register("zeabur-api")
class ZeaburAdapter(BaseAdapter):
required_config = ["api_token"]
BASE = "https://api.zeabur.com/graphql"
def _graphql(self, query: str, variables: dict | None = None) -> dict:
payload = {"query": query}
if variables:
payload["variables"] = variables
with httpx.Client(timeout=30) as client:
resp = client.post(
self.BASE,
headers={"Authorization": f"Bearer {self.config.get('api_token', '')}"},
json=payload,
)
resp.raise_for_status()
data = resp.json()
errors = data.get("errors") or []
if errors:
message = errors[0].get("message") if isinstance(errors[0], dict) else str(errors[0])
raise RuntimeError(f"Zeabur GraphQL 错误:{message}")
return data.get("data") or {}
def test_connection(self) -> dict:
try:
data = self._graphql("query Me { me { username } }")
username = (data.get("me") or {}).get("username") or "未知账户"
return {"ok": True, "message": f"连接成功,令牌属于 {username}"}
except httpx.HTTPStatusError as e:
return {"ok": False, "message": f"HTTP {e.response.status_code}:API Token 无效或权限不足"}
except Exception as e: # noqa: BLE001
return {"ok": False, "message": str(e)}
def list_vps(self) -> list:
servers = self._graphql(_SERVERS_QUERY).get("servers") or []
result = []
for server in servers:
status = server.get("status") or {}
online = bool(status.get("isOnline"))
provider = (server.get("providerInfo") or {}).get("name")
location = ", ".join(
part for part in (server.get("city"), server.get("country")) if part
)
total_cpu = int(status.get("totalCPU") or 0)
result.append(
NormalizedVPS(
external_id=str(server.get("_id")),
name=server.get("name") or server.get("ip") or str(server.get("_id")),
ip_address=server.get("ip") or None,
region=location or provider,
# hasK3s==False 才是纯净 Ubuntu;nil 是旧机器,默认装了 ZeaburOS
os="Ubuntu" if server.get("hasK3s") is False else "ZeaburOS",
# totalCPU 单位是毫核(4000 = 4 核),台账列是整数核
cpu_cores=max(1, round(total_cpu / 1000)) if total_cpu else None,
memory_gb=round(int(status.get("totalMemory") or 0) / 1024, 1) or None,
disk_gb=round(int(status.get("totalDisk") or 0) / 1024) or None,
status="active" if online else "stopped",
currency="USD",
charge_type="postpaid",
raw=server,
)
)
return result
def reboot_instance(self, external_id: str) -> dict:
mutation = """
mutation RebootServer($id: ObjectID!) {
rebootServer(_id: $id)
}
"""
try:
data = self._graphql(mutation, {"id": external_id})
if not data.get("rebootServer"):
return {"ok": False, "message": "重启请求未被接受"}
return {"ok": True, "message": "重启指令已下发"}
except httpx.HTTPStatusError as e:
return {"ok": False, "message": f"HTTP {e.response.status_code}:重启失败"}
except Exception as e: # noqa: BLE001
return {"ok": False, "message": f"重启失败:{e}"}