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

108 lines
4.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.
"""CloudCone 适配器(REST API v2)
API 文档:https://cloudcone-api.readme.io/reference
所需配置:{"api_key": "...", "api_hash": "..."}
认证请求头:App-Secret = API Key,Hash = API Hash
覆盖:实例清单、开机/关机/重启。
CloudCone 没有公开的账单/到期/余额接口,故不声明 get_account,
也不提供续费动作——拿不到到期日就别假装能核对。
"""
import httpx
from app.adapters.base import BaseAdapter, NormalizedVPS
from app.adapters.registry import register
#: CloudCone state.name -> 台账状态
_STATUS_MAP = {
"active": "active",
"starting": "active",
"rebooting": "active",
"stopped": "stopped",
"shutdown": "stopped",
"suspended": "stopped",
}
@register("cloudcone-api")
class CloudConeAdapter(BaseAdapter):
required_config = ["api_key", "api_hash"]
BASE = "https://api.cloudcone.com/api/v2"
def _headers(self) -> dict:
return {
"App-Secret": self.config.get("api_key", ""),
"Hash": self.config.get("api_hash", ""),
}
def _request(self, method: str, path: str) -> dict:
with httpx.Client(timeout=30) as client:
resp = client.request(method, self.BASE + path, headers=self._headers())
resp.raise_for_status()
data = resp.json() if resp.content else {}
# CloudCone 用 status=1 表示成功,0 时 message 里才有可读原因
if isinstance(data, dict) and data.get("status") not in (1, "1", None):
raise RuntimeError(data.get("message") or "CloudCone 接口返回失败")
return data
def _get(self, path: str) -> dict:
return self._request("GET", path)
def _post(self, path: str) -> dict:
return self._request("POST", path)
def test_connection(self) -> dict:
try:
instances = self.list_vps()
return {"ok": True, "message": f"连接成功,读取到 {len(instances)} 台实例"}
except httpx.HTTPStatusError as e:
return {"ok": False, "message": f"HTTP {e.response.status_code}:API Key/Hash 无效或权限不足"}
except Exception as e: # noqa: BLE001
return {"ok": False, "message": str(e)}
def list_vps(self) -> list:
data = self._get("/compute/instances")
raw_instances = (data.get("__data") or {}).get("instances") or []
result = []
for inst in raw_instances:
state = (inst.get("state") or {}).get("name") or "unknown"
ips = str(inst.get("ips") or "").strip()
result.append(
NormalizedVPS(
external_id=str(inst.get("id")),
name=inst.get("hostname") or inst.get("nodename") or str(inst.get("id")),
ip_address=ips.split(",")[0].strip() if ips else None,
region=inst.get("nodename"),
os=inst.get("distro"),
cpu_cores=inst.get("cpu"),
memory_gb=inst.get("ram"),
disk_gb=inst.get("disk"),
status=_STATUS_MAP.get(state.lower(), "unknown"),
currency="USD",
charge_type="prepaid",
raw=inst,
)
)
return result
# ---------------- 实例管理动作 ----------------
def _instance_action(self, external_id: str, verb: str, label: str) -> dict:
try:
self._post(f"/compute/{external_id}/{verb}")
return {"ok": True, "message": f"{label}指令已下发"}
except httpx.HTTPStatusError as e:
return {"ok": False, "message": f"HTTP {e.response.status_code}:{label}失败"}
except Exception as e: # noqa: BLE001
return {"ok": False, "message": f"{label}失败:{e}"}
def start_instance(self, external_id: str) -> dict:
return self._instance_action(external_id, "boot", "开机")
def stop_instance(self, external_id: str) -> dict:
return self._instance_action(external_id, "shutdown", "关机")
def reboot_instance(self, external_id: str) -> dict:
return self._instance_action(external_id, "reboot", "重启")