Клиент к API облака REG.RU
Обёртка над api.cloudvps.reg.ru. Само API несложное, но у него есть неочевидные места, на которые мы наступили на живых машинах, и которые тут обойдены. - адрес машины лежит в поле ip, networks всегда пустое - снимок нельзя снять с остановленной машины, API останавливает её сам - после снимка машина сидит в locked и не удаляется, удаление настойчивое - тарифы с картами метод sizes не показывает, их список в docs_slugs.md - тарификация идёт от часа, короткие запуски невыгодны Claude-Session: https://claude.ai/code/session_01Loz5stgQZH6ofBdMqTxiJu
Этот коммит содержится в:
@@ -0,0 +1,8 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
.env
|
||||
regcloud_state.json
|
||||
@@ -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, снятая с их сервера
|
||||
@@ -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-ключи.
|
||||
@@ -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("машина удалена, снимок остался")
|
||||
Различия файлов скрыты, потому что одна или несколько строк слишком длинны
@@ -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"]
|
||||
@@ -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"
|
||||
@@ -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")
|
||||
Ссылка в новой задаче
Block a user