Клиент к API облака REG.RU

Обёртка над api.cloudvps.reg.ru. Само API несложное, но у него есть неочевидные
места, на которые мы наступили на живых машинах, и которые тут обойдены.

- адрес машины лежит в поле ip, networks всегда пустое
- снимок нельзя снять с остановленной машины, API останавливает её сам
- после снимка машина сидит в locked и не удаляется, удаление настойчивое
- тарифы с картами метод sizes не показывает, их список в docs_slugs.md
- тарификация идёт от часа, короткие запуски невыгодны

Claude-Session: https://claude.ai/code/session_01Loz5stgQZH6ofBdMqTxiJu
Этот коммит содержится в:
2026-08-23 18:13:57 +09:00
Коммит bb66bc1681
8 изменённых файлов: 433 добавлений и 0 удалений
+227
Просмотреть файл
@@ -0,0 +1,227 @@
"""Клиент к API облака REG.RU (Рег.облако).
API устроено как у DigitalOcean, сервер там называется reglet.
Токен берётся в личном кабинете, в разделе настроек.
from regcloud_api import RegCloud
api = RegCloud() # токен из REGRU_CLOUD_TOKEN
for s in api.list():
print(s["id"], s["name"], s["status"])
Зачем обёртка, если API простое. Затем, что у него есть неочевидные места,
на которые мы уже наступили и которые тут обойдены.
Адрес машины лежит в поле ip, а не в networks, оно всегда пустое.
Снимок нельзя снять с остановленной машины, API сам её останавливает по offline.
Сразу после снимка машина сидит в locked и удаляться отказывается примерно полминуты,
поэтому удаление тут настойчивое, иначе забытая машина будет тикать по деньгам.
Тарифы с видеокартами метод sizes не показывает вовсе, хотя создание с ними работает,
их список лежит в docs_slugs.md.
"""
from __future__ import annotations
import json
import os
import pathlib
import time
import httpx
BASE = "https://api.cloudvps.reg.ru/v1"
STATE_FILE = pathlib.Path(os.environ.get("REGCLOUD_STATE_DIR", ".")) / "regcloud_state.json"
class RegCloud:
def __init__(self, token: str | None = None, timeout: float = 60.0):
self.token = token or os.environ.get("REGRU_CLOUD_TOKEN", "")
if not self.token:
raise RuntimeError("нет токена, положи REGRU_CLOUD_TOKEN в окружение")
self.http = httpx.Client(
base_url=BASE, timeout=timeout,
headers={"Authorization": f"Bearer {self.token}", "Content-Type": "application/json"},
)
# ---------- справочники ----------
def sizes(self) -> list[dict]:
r = self.http.get("/sizes")
r.raise_for_status()
return r.json().get("sizes", [])
def gpu_sizes(self) -> list[dict]:
"""Тарифы с видеокартой. У них в описании или в названии есть упоминание gpu."""
out = []
for s in self.sizes():
blob = json.dumps(s, ensure_ascii=False).lower()
if "gpu" in blob or "nvidia" in blob or "a4000" in blob or "a5000" in blob or "a100" in blob:
out.append(s)
return out
def images(self) -> list[dict]:
r = self.http.get("/images")
r.raise_for_status()
return r.json().get("images", [])
def snapshots(self) -> list[dict]:
r = self.http.get("/snapshots")
r.raise_for_status()
return r.json().get("snapshots", [])
def ssh_keys(self) -> list[dict]:
r = self.http.get("/account/keys")
r.raise_for_status()
return r.json().get("ssh_keys", [])
def balance(self) -> dict:
r = self.http.get("/balance_data")
return r.json() if r.status_code == 200 else {"error": r.status_code, "text": r.text[:200]}
def prices(self) -> dict:
r = self.http.get("/prices")
return r.json() if r.status_code == 200 else {}
# ---------- машины ----------
def list(self) -> list[dict]:
r = self.http.get("/reglets")
r.raise_for_status()
return r.json().get("reglets", [])
def get(self, reglet_id: int) -> dict:
r = self.http.get(f"/reglets/{reglet_id}")
r.raise_for_status()
return r.json().get("reglet", {})
def create(self, size: str, image: str | int, name: str = "gpuhub-worker",
ssh_keys: list[str] | None = None, region: str = "") -> dict:
payload: dict = {"name": name, "size": size, "image": image}
if ssh_keys:
payload["ssh_keys"] = ssh_keys
if region:
payload["region_slug"] = region
r = self.http.post("/reglets", json=payload)
if r.status_code >= 400:
raise RuntimeError(f"не создалась машина, {r.status_code} {r.text[:300]}")
return r.json().get("reglet", {})
def destroy(self, reglet_id: int, patient: bool = True, timeout: float = 900) -> bool:
"""Удаление машины.
Сразу после снятия снимка машина сидит в locked и удаляться отказывается,
отвечая RESOURCE_DELETE_ERROR. Проверено на живой машине, отпускает примерно
через полминуты. Поэтому по умолчанию долбимся, пока не поддастся, иначе
забытая машина будет тикать по деньгам.
"""
deadline = time.time() + timeout
while True:
r = self.http.delete(f"/reglets/{reglet_id}")
if r.status_code < 400 or r.status_code == 404:
return True
body = r.text[:200]
if "NO_SUCH_REGLET" in body:
return True # её уже нет, это успех
if not patient or time.time() > deadline:
raise RuntimeError(f"не удалилась машина, {r.status_code} {body}")
time.sleep(20)
def action(self, reglet_id: int, action_type: str, **extra) -> dict:
r = self.http.post(f"/reglets/{reglet_id}/actions", json={"type": action_type, **extra})
if r.status_code >= 400:
raise RuntimeError(f"действие {action_type} не прошло, {r.status_code} {r.text[:200]}")
return r.json()
def snapshot(self, reglet_id: int, name: str, offline: bool = True) -> dict:
"""Снимок машины. Из него потом поднимаются готовые к работе клоны.
Снимаем с выключенной, так образ выходит целостным."""
return self.action(reglet_id, "snapshot", name=name, offline=offline)
def stop(self, reglet_id: int) -> dict:
"""Остановка. Важно понимать, что у большинства провайдеров остановленная машина
всё равно занимает диск и продолжает тарифицироваться. Для экономии её нужно удалять."""
return self.action(reglet_id, "stop")
def start(self, reglet_id: int) -> dict:
return self.action(reglet_id, "start")
def wait_action(self, action_id: int, timeout: float = 900) -> dict:
"""Ждём завершения долгого действия, например снятия снимка."""
deadline = time.time() + timeout
while time.time() < deadline:
r = self.http.get(f"/actions/{action_id}")
if r.status_code == 200:
data = r.json().get("action", {})
if data.get("status") in ("completed", "errored"):
return data
time.sleep(5)
raise TimeoutError(f"действие {action_id} не завершилось за {timeout} секунд")
def wait_active(self, reglet_id: int, timeout: float = 600) -> dict:
"""Ждём, пока машина поднимется и получит адрес."""
deadline = time.time() + timeout
while time.time() < deadline:
info = self.get(reglet_id)
if info.get("status") == "active" and self.ip_of(info):
return info
time.sleep(5)
raise TimeoutError(f"машина {reglet_id} не поднялась за {timeout} секунд")
@staticmethod
def ip_of(reglet: dict) -> str:
"""Адрес лежит прямо в поле ip, а networks у этого провайдера пустует."""
if reglet.get("ip"):
return reglet["ip"]
nets = (reglet.get("networks") or {}).get("v4") or []
for n in nets:
if n.get("type") == "public":
return n.get("ip_address", "")
return nets[0].get("ip_address", "") if nets else ""
# ---------- память о поднятых машинах ----------
def load_state() -> dict:
if STATE_FILE.exists():
try:
return json.loads(STATE_FILE.read_text())
except json.JSONDecodeError:
pass
return {"reglet_id": None, "ip": "", "started_at": 0, "idle_since": 0}
def save_state(state: dict) -> None:
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
STATE_FILE.write_text(json.dumps(state, ensure_ascii=False, indent=1))
if __name__ == "__main__":
import sys
api = RegCloud()
cmd = sys.argv[1] if len(sys.argv) > 1 else "sizes"
if cmd == "sizes":
for s in api.gpu_sizes():
price = s.get("price_hourly") or s.get("price_monthly") or "?"
print(f"{s.get('slug','?'):28s} {s.get('description','')[:50]:52s} {price}")
elif cmd == "all-sizes":
for s in api.sizes():
print(f"{s.get('slug','?'):28s} {s.get('description','')[:60]}")
elif cmd == "images":
for i in api.images()[:40]:
print(f"{str(i.get('id','')):10s} {i.get('slug','') or i.get('name',''):35s} {i.get('distribution','')}")
elif cmd == "snapshots":
for s in api.snapshots():
print(f"{s.get('id')} {s.get('name')} {s.get('size_gigabytes')} ГБ")
elif cmd == "list":
for r in api.list():
print(f"{r.get('id')} {r.get('name'):25s} {r.get('status'):10s} {RegCloud.ip_of(r)}")
elif cmd == "keys":
for k in api.ssh_keys():
print(f"{k.get('id')} {k.get('name')} {k.get('fingerprint')}")
elif cmd == "balance":
print(json.dumps(api.balance(), ensure_ascii=False, indent=1))
elif cmd == "prices":
data = api.prices()
print(json.dumps(data, ensure_ascii=False, indent=1)[:3000])
else:
print("sizes | all-sizes | images | snapshots | list | keys | balance | prices")