Клиент к 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 удалений
+8
Просмотреть файл
@@ -0,0 +1,8 @@
__pycache__/
*.pyc
.venv/
dist/
build/
*.egg-info/
.env
regcloud_state.json
+89
Просмотреть файл
@@ -0,0 +1,89 @@
# regcloud-api
Клиент к API облака REG.RU (Рег.облако), точка входа `https://api.cloudvps.reg.ru`.
Само API устроено как у DigitalOcean и особой сложности не представляет. Обёртка нужна
из-за неочевидных мест, на которые мы уже наступили в бою, и которые тут обойдены.
## Что тут учтено
**Адрес машины лежит в поле `ip`.** Поле `networks`, которое по документации должно
его содержать, всегда пустое. Метод `ip_of()` знает про это.
**Снимок нельзя снять с остановленной машины.** API отвечает `SERVER_IS_STOPPED`.
Останавливать её должен он сам, для этого есть параметр `offline`.
**Сразу после снимка машина сидит в `locked` и удаляться отказывается**, отвечая
`RESOURCE_DELETE_ERROR`. Отпускает примерно через полминуты. `destroy()` по умолчанию
долбится, пока не поддастся, потому что забытая машина продолжает тикать по деньгам.
**Тарифы с видеокартами метод `sizes()` не показывает вообще**, хотя создание с ними
прекрасно работает. Их список лежит в [docs_slugs.md](docs_slugs.md), там же образы,
регионы и правила составления slug'ов.
**Тарификация идёт от часа.** Двадцать минут работы списываются как полный час,
проверено на живой машине. Поднимать машину на короткие задачи невыгодно.
## Установка
```bash
pip install git+https://git.stackworks.ru/viktor/regcloud-api.git
```
Токен берётся в личном кабинете и кладётся в `REGRU_CLOUD_TOKEN`.
## Как пользоваться
```python
from regcloud_api import RegCloud
api = RegCloud()
for s in api.list():
print(s["id"], s["name"], s["status"], RegCloud.ip_of(s))
print(api.balance())
```
Поднять машину с видеокартой, снять с неё образ и удалить.
```python
srv = api.create(size="c8-m32-d128-g1-a4000-gpu",
image="ubuntu-24-04-amd64-gpu",
name="worker",
ssh_keys=["d7:3a:..."],
region="openstack-spb1")
info = api.wait_active(srv["id"])
print("адрес", RegCloud.ip_of(info))
api.snapshot(srv["id"], "worker-base", offline=True) # машину остановит сам
api.destroy(srv["id"]) # дождётся снятия locked
```
Дальше машину можно поднимать уже из снимка, передав его номер вместо имени образа.
```python
api.create(size="c8-m32-d128-g1-a4000-gpu", image=7887485, region="openstack-spb1")
```
## Что умеет
| Метод | Что делает |
|---|---|
| `list()`, `get(id)` | машины на аккаунте |
| `create(...)` | поднять машину, из образа или из снимка |
| `destroy(id)` | удалить, настойчиво |
| `stop(id)`, `start(id)` | выключить и включить |
| `action(id, type, **kw)` | любое действие из списка API |
| `snapshot(id, name)` | снять образ |
| `wait_active(id)`, `wait_action(id)` | дождаться готовности |
| `sizes()`, `gpu_sizes()` | тарифы |
| `images()`, `snapshots()` | образы и снимки |
| `ssh_keys()` | ключи доступа |
| `balance()`, `prices()` | деньги |
## Полезное рядом
- [docs_slugs.md](docs_slugs.md) — тарифы с картами, образы, регионы, правила slug'ов
- [openapi_v1.json](openapi_v1.json) — спецификация API, снятая с их сервера
+61
Просмотреть файл
@@ -0,0 +1,61 @@
# Справочник slug'ов Рег.облака
Собрано 23.08.2026. Важная особенность: `GET /v1/sizes` тарифы с картами **не показывает**,
хотя создание с ними работает. Проверено косвенно, API отвечает `WRONG_SIZE_SLUG` на выдуманный
тариф и пропускает настоящий. Так что ориентироваться надо на этот список, а не на выдачу API.
## Тарифы с видеокартой
| slug | Карта | Ядра | RAM | Диск | ₽/час |
|---|---|---|---|---|---|
| `c8-m32-d128-g1-a4000-gpu` | A4000 16 ГБ | 8 | 32 ГБ | 128 ГБ | 37.80 |
| `c16-m64-d256-g1-a4000-gpu` | A4000 16 ГБ | 16 | 64 ГБ | 256 ГБ | 51.87 |
| `c16-m64-d256-g1-a5000-gpu` | A5000 24 ГБ | 16 | 64 ГБ | 256 ГБ | 57.00 |
| `c24-m128-d512-g1-a5000-gpu` | A5000 24 ГБ | 24 | 128 ГБ | 512 ГБ | 82.79 |
| `c24-m128-d512-g2-a5000-gpu` | 2x A5000 | 24 | 128 ГБ | 512 ГБ | 111.65 |
| `c32-m256-d1024-g1-a5000-gpu` | A5000 24 ГБ | 32 | 256 ГБ | 1 ТБ | 132.01 |
| `c48-m384-d1024-g2-a5000-gpu` | 2x A5000 | 48 | 384 ГБ | 1 ТБ | 203.30 |
| `c16-m128-d512-g1-a100-gpu` | A100 80 ГБ | 16 | 128 ГБ | 512 ГБ | 222.14 |
| `c32-m256-d1024-g1-a100-gpu` | A100 80 ГБ | 32 | 256 ГБ | 1 ТБ | 273.72 |
| `c64-m512-d1024-g2-a100-gpu` | 2x A100 | 64 | 512 ГБ | 1 ТБ | 529.14 |
| `c24-m96-d384-g1-rtx6000-gpu` | RTX 6000 Blackwell | 24 | 96 ГБ | 384 ГБ | 205.07 |
| `c32-m128-d512-g1-rtx6000-gpu` | RTX 6000 Blackwell | 32 | 128 ГБ | 512 ГБ | 223.43 |
| `c64-m192-d1024-g1-rtx6000-gpu` | RTX 6000 Blackwell | 64 | 192 ГБ | 1 ТБ | 271.15 |
| `c32-m128-d1024-g2-rtx6000-gpu` | 2x RTX 6000 | 32 | 128 ГБ | 1 ТБ | 382.58 |
Свои конфигурации для карт собирать нельзя, только готовые slug'и.
## Образы под карту
| slug | Что внутри |
|---|---|
| `ubuntu-24-04-amd64-gpu` | Ubuntu 24.04 с драйверами NVIDIA |
| `ubuntu-22-04-amd64-gpu` | Ubuntu 22.04 с драйверами |
| `ubuntu-26-04-amd64-gpu` | Ubuntu 26.04 с драйверами |
| `ubuntu-24-04-amd64-vllm` | готовый инференс vLLM |
| `ubuntu-24-04-amd64-ollama` | готовый ИИ-ассистент на Ollama |
| `ubuntu-24-04-amd64-jupyterhub` | JupyterHub |
## Регионы
| slug | Где |
|---|---|
| `openstack-spb1` | Санкт-Петербург, **тут стоят карты** |
| `openstack-msk1` | Москва |
| `openstack-msk2` | Москва-2 |
| `openstack-msk3` | Москва-3 |
| `openstack-sam1` | Самара |
| `openstack-fz1` | Москва ФЗ-152 |
| `msk1` | Москва, архивные тарифы |
## Обычные тарифы, шаблон
`c<ядра>-m<память>-d<диск>-<линейка>`, например `c4-m8-d120-hp`.
Линейки: `std` стандартный, `stdp` стандартный плюс, `hp` производительный,
`hps` производительный ФЗ-152, `ddc` выделенный CPU, `high` высокочастотный.
## Terraform
Провайдер `tf.reg.cloud/regru/regcloud`, точка входа `https://api.cloudvps.reg.ru`,
токен тот же, что у API. Умеет серверы, серверы с GPU, снимки и SSH-ключи.
+28
Просмотреть файл
@@ -0,0 +1,28 @@
"""Полный цикл работы с машиной под видеокарту.
Поднимаем из готового снимка, работаем, снимаем новый образ, удаляем.
Именно так экономятся деньги: платим за часы счёта, а не за месяц простоя.
"""
import os
from regcloud_api import RegCloud
SIZE = "c8-m32-d128-g1-a4000-gpu" # A4000 16 ГБ, 37.80 р/час
REGION = "openstack-spb1" # карты стоят только в Питере
KEY_FINGERPRINT = os.environ.get("SSH_FINGERPRINT", "")
api = RegCloud()
print("баланс:", api.balance().get("balance_data", {}).get("balance"), "р")
srv = api.create(size=SIZE, image="ubuntu-24-04-amd64-gpu", name="worker",
ssh_keys=[KEY_FINGERPRINT] if KEY_FINGERPRINT else None, region=REGION)
info = api.wait_active(srv["id"])
print("машина готова:", RegCloud.ip_of(info))
# ... тут работа на машине по ssh ...
api.snapshot(srv["id"], "worker-ready", offline=True)
print("снимок поставлен в очередь")
api.destroy(srv["id"])
print("машина удалена, снимок остался")
Различия файлов скрыты, потому что одна или несколько строк слишком длинны
+14
Просмотреть файл
@@ -0,0 +1,14 @@
[project]
name = "regcloud-api"
version = "0.1.0"
description = "Клиент к API облака REG.RU (Рег.облако)"
readme = "README.md"
requires-python = ">=3.10"
dependencies = ["httpx>=0.27"]
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[tool.setuptools]
packages = ["regcloud_api"]
+5
Просмотреть файл
@@ -0,0 +1,5 @@
"""Клиент к API облака REG.RU."""
from .client import RegCloud, load_state, save_state
__all__ = ["RegCloud", "load_state", "save_state"]
__version__ = "0.1.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")