Полное покрытие API

Клиент дописан до всех двадцати семи эндпоинтов платформы.

Добавлено:
- регионы, образы с разбивкой на системы и готовые сборки, вторая версия API образов
- все действия над сервером: старт, стоп, перезагрузка, переустановка, смена тарифа,
  клонирование, откат, консоль, бэкапы
- снимки целиком, включая ожидание готовности и поиск поднятых из них серверов
- ключи доступа, IP-адреса с обратными записями, приватные сети, kubeconfig
- история операций, биллинг, расход в час и в сутки
- сборка и проверка slug тарифа по правилам платформы
- ошибки разложены по смыслу, чтобы ловить прицельно

Claude-Session: https://claude.ai/code/session_01Loz5stgQZH6ofBdMqTxiJu
Этот коммит содержится в:
2026-08-23 18:30:07 +09:00
родитель a106ba5b6b
Коммит 7e798bb68f
8 изменённых файлов: 831 добавлений и 259 удалений
+18 -4
Просмотреть файл
@@ -1,5 +1,19 @@
"""Клиент к API облака REG.RU."""
from .client import RegCloud, load_state, save_state
"""Клиент к API облака REG.RU (Рег.облако).
__all__ = ["RegCloud", "load_state", "save_state"]
__version__ = "0.1.0"
Полное покрытие API: серверы, действия над ними, снимки, образы, тарифы, регионы,
SSH-ключи, IP-адреса, приватные сети, kubernetes, история операций и биллинг.
"""
from .client import RegCloud
from .errors import (AuthError, InsufficientFunds, Locked, NotFound, RegCloudError,
Timeout, ValidationError)
from .slugs import (ACTIONS, GPU_SIZES, IMAGE_TYPES, PLAN_LINES, REGIONS,
build_size, gpu_catalog, parse_size)
__all__ = [
"RegCloud",
"RegCloudError", "AuthError", "NotFound", "Locked", "ValidationError",
"InsufficientFunds", "Timeout",
"REGIONS", "PLAN_LINES", "GPU_SIZES", "IMAGE_TYPES", "ACTIONS",
"build_size", "parse_size", "gpu_catalog",
]
__version__ = "1.0.0"
+447 -192
Просмотреть файл
@@ -1,22 +1,23 @@
"""Клиент к API облака REG.RU (Рег.облако).
API устроено как у DigitalOcean, сервер там называется reglet.
Токен берётся в личном кабинете, в разделе настроек.
Покрывает всё API целиком: серверы, действия над ними, снимки, образы, тарифы,
регионы, SSH-ключи, IP-адреса, приватные сети, историю операций, биллинг.
from regcloud_api import RegCloud
api = RegCloud() # токен из REGRU_CLOUD_TOKEN
for s in api.list():
print(s["id"], s["name"], s["status"])
api = RegCloud() # токен из REGRU_CLOUD_TOKEN
Зачем обёртка, если API простое. Затем, что у него есть неочевидные места,
собранные на живых серверах и обойдённые тут.
for r in api.regions(): # где можно размещаться
print(r["slug"], r["name"])
Адрес сервера лежит в поле ip, а не в networks, оно всегда пустое.
Снимок снимается только с работающего сервера, останавливает его платформа сама по offline.
Сразу после снимка сервер сидит в locked и удаляться отказывается примерно полминуты,
поэтому удаление настойчивое, иначе забытый сервер продолжит тарифицироваться.
Метод sizes возвращает не все линейки тарифов, полный список смотрите в панели управления.
for i in api.distributions(): # какие операционные системы есть
print(i["slug"], i["name"])
srv = api.create_server(size="c2-m4-d60-hp", image="ubuntu-24-04-amd64",
region="openstack-msk1")
api.wait_active(srv["id"])
Особенности платформы, обойдённые внутри, описаны в README.
"""
from __future__ import annotations
@@ -24,204 +25,458 @@ import json
import os
import pathlib
import time
from typing import Any, Iterable
import httpx
BASE = "https://api.cloudvps.reg.ru/v1"
STATE_FILE = pathlib.Path(os.environ.get("REGCLOUD_STATE_DIR", ".")) / "regcloud_state.json"
from .errors import (AuthError, InsufficientFunds, Locked, NotFound,
RegCloudError, Timeout, ValidationError)
from .slugs import ACTIONS, IMAGE_TYPES, REGIONS, build_size, gpu_catalog, parse_size
BASE_URL = "https://api.cloudvps.reg.ru"
TOKEN_ENV = "REGRU_CLOUD_TOKEN"
class RegCloud:
def __init__(self, token: str | None = None, timeout: float = 60.0):
self.token = token or os.environ.get("REGRU_CLOUD_TOKEN", "")
"""Всё API платформы одним объектом."""
def __init__(self, token: str | None = None, base_url: str = BASE_URL,
timeout: float = 60.0, retries: int = 3):
self.token = token or os.environ.get(TOKEN_ENV, "")
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"},
)
raise AuthError(f"нет токена, передайте его явно или положите в {TOKEN_ENV}")
self.base_url = base_url.rstrip("/")
self.retries = retries
self._http = httpx.Client(timeout=timeout, headers={
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json",
})
# ---------- низкий уровень ----------
def request(self, method: str, path: str, *, version: str = "v1", **kw) -> Any:
"""Один запрос с разбором ошибок платформы в понятные исключения."""
url = f"{self.base_url}/{version}{path}"
last: Exception | None = None
for attempt in range(self.retries):
try:
r = self._http.request(method, url, **kw)
except httpx.HTTPError as exc:
last = RegCloudError(f"сеть подвела: {type(exc).__name__}: {exc}")
time.sleep(2 * (attempt + 1))
continue
if r.status_code < 400:
if not r.content:
return {}
try:
return r.json()
except json.JSONDecodeError:
return r.text
body: dict = {}
try:
body = r.json()
except json.JSONDecodeError:
body = {"message": r.text[:400]}
code = str(body.get("code") or "")
message = str(body.get("message") or body.get("detail") or r.text[:300])
if r.status_code in (401, 403):
raise AuthError(message, r.status_code, code, body)
if r.status_code == 404 or code in ("NO_SUCH_REGLET", "IMAGE_NOT_FOUND"):
raise NotFound(message, r.status_code, code, body)
if code in ("RESOURCE_DELETE_ERROR", "RESOURCE_LOCKED", "SERVER_IS_STOPPED"):
raise Locked(message, r.status_code, code, body)
if "funds" in message.lower() or "баланс" in message.lower():
raise InsufficientFunds(message, r.status_code, code, body)
if r.status_code == 400:
raise ValidationError(message, r.status_code, code, body)
last = RegCloudError(message, r.status_code, code, body)
time.sleep(2 * (attempt + 1))
raise last or RegCloudError("запрос не удался")
def get(self, path, **kw): return self.request("GET", path, **kw)
def post(self, path, **kw): return self.request("POST", path, **kw)
def put(self, path, **kw): return self.request("PUT", path, **kw)
def delete(self, path, **kw): return self.request("DELETE", path, **kw)
# ---------- справочники ----------
def sizes(self) -> list[dict]:
r = self.http.get("/sizes")
r.raise_for_status()
return r.json().get("sizes", [])
def regions(self) -> list[dict]:
"""Где можно размещать серверы.
Отдельного метода у платформы нет, список зашит и подтверждён её же
проверкой при создании сервера."""
return [{"slug": s, "name": n} for s, n in REGIONS.items()]
def sizes(self, region: str = "") -> list[dict]:
"""Тарифы. Учтите, что платформа возвращает не все линейки,
тарифы с видеокартами сюда не попадают, для них есть gpu_sizes()."""
params = {"region": region} if region else None
data = self.get("/sizes", params=params)
return data.get("sizes", []) if isinstance(data, dict) else []
def gpu_sizes(self) -> list[dict]:
"""Отбор тарифов с видеокартой, если они вам нужны.
Учтите, что sizes() возвращает не все линейки, полный список в панели управления."""
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
"""Тарифы с видеокартой. Собственные конфигурации для них платформа
не принимает, только эти готовые."""
return gpu_catalog()
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} секунд")
def size_info(self, slug: str) -> dict:
"""Разобрать slug тарифа на понятные значения."""
return parse_size(slug)
@staticmethod
def ip_of(reglet: dict) -> str:
"""Адрес лежит прямо в поле ip, а networks у этого провайдера пустует."""
if reglet.get("ip"):
return reglet["ip"]
nets = (reglet.get("networks") or {}).get("v4") or []
def make_size(cpu: int, ram: int, disk: int, line: str = "hp") -> str:
"""Собрать slug тарифа с проверкой допустимых сочетаний."""
return build_size(cpu, ram, disk, line)
def images(self, *, type: str = "", region: str = "", group: str = "",
private: bool | None = None, reglet_id: int | None = None) -> list[dict]:
"""Образы. Тип бывает distribution, application, snapshot, backup, restore."""
if type and type not in IMAGE_TYPES:
raise ValueError(f"тип бывает {', '.join(IMAGE_TYPES)}")
params = {k: v for k, v in (("type", type), ("region", region), ("group", group),
("private", private), ("reglet_id", reglet_id))
if v not in ("", None)}
data = self.get("/images", params=params or None)
return data.get("images", []) if isinstance(data, dict) else []
def distributions(self, region: str = "") -> list[dict]:
"""Только операционные системы, без готовых сборок."""
return self.images(type="distribution", region=region)
def applications(self, region: str = "") -> list[dict]:
"""Готовые сборки: панели, докер, гитлаб и прочее."""
return self.images(type="application", region=region)
def images_v2(self, region: str, *, type: str = "", page: int = 1,
per_page: int = 100) -> list[dict]:
"""Образы через вторую версию API. Там их больше и они разложены по регионам."""
params = {"region": region, "page": page, "items_per_page": per_page}
if type:
params["type"] = type
data = self.request("GET", "/images", version="v2", params=params)
return data.get("images", data.get("data", [])) if isinstance(data, dict) else []
def prices(self) -> list[dict]:
data = self.get("/prices")
return data.get("prices", []) if isinstance(data, dict) else []
def random_name(self) -> str:
"""Платформа умеет придумывать имена серверам сама."""
data = self.get("/random_reglet_name")
return data.get("name", "") if isinstance(data, dict) else str(data)
# ---------- серверы ----------
def servers(self) -> list[dict]:
data = self.get("/reglets")
return data.get("reglets", []) if isinstance(data, dict) else []
list = servers # привычное имя тоже оставим
def server(self, server_id: int) -> dict:
data = self.get(f"/reglets/{server_id}")
return data.get("reglet", data)
def removed_servers(self) -> list[dict]:
"""Удалённые серверы. Полезно, когда надо понять, что и когда снесли."""
data = self.get("/removed_servers")
return data.get("reglets", data.get("servers", [])) if isinstance(data, dict) else []
def create_server(self, *, size: str, image: str | int, name: str = "",
region: str = "", ssh_keys: Iterable[str] | None = None,
backups: bool = False, floating_ip: bool = False,
isp_license: str = "", promocode: str = "") -> dict:
"""Создать сервер.
В image можно передать как slug образа, так и номер снимка."""
if region and region not in REGIONS:
raise ValueError(f"нет региона {region}, есть {', '.join(REGIONS)}")
payload: dict[str, Any] = {"size": size, "image": image}
if name:
payload["name"] = name
if region:
payload["region_slug"] = region
if ssh_keys:
payload["ssh_keys"] = list(ssh_keys)
if backups:
payload["backups"] = True
if floating_ip:
payload["floating_ip"] = True
if isp_license:
payload["isp_license_size"] = isp_license
if promocode:
payload["promocode"] = promocode
data = self.post("/reglets", json=payload)
return data.get("reglet", data)
create = create_server
def rename_server(self, server_id: int, name: str) -> dict:
return self.put(f"/reglets/{server_id}", json={"name": name})
def destroy_server(self, server_id: int, *, patient: bool = True,
timeout: float = 900) -> bool:
"""Удалить сервер.
Сразу после снимка сервер сидит в locked и удаляться отказывается,
отпускает примерно через полминуты. По умолчанию повторяем попытки,
потому что забытый сервер продолжает тарифицироваться."""
deadline = time.time() + timeout
while True:
try:
self.delete(f"/reglets/{server_id}")
return True
except NotFound:
return True # его уже нет, это успех
except Locked:
if not patient or time.time() > deadline:
raise
time.sleep(20)
destroy = destroy_server
# ---------- действия над сервером ----------
def action(self, server_id: int, type: str, **params) -> dict:
if type not in ACTIONS:
raise ValueError(f"действие бывает {', '.join(ACTIONS)}")
data = self.post(f"/reglets/{server_id}/actions", json={"type": type, **params})
return data.get("action", data)
def start(self, server_id: int) -> dict: return self.action(server_id, "start")
def stop(self, server_id: int) -> dict: return self.action(server_id, "stop")
def reboot(self, server_id: int) -> dict: return self.action(server_id, "reboot")
def reset_password(self, server_id: int) -> dict: return self.action(server_id, "password_reset")
def rebuild(self, server_id: int, image: str | int) -> dict:
"""Переустановить систему на сервере."""
return self.action(server_id, "rebuild", image=image)
def resize(self, server_id: int, size: str) -> dict:
"""Сменить тариф."""
return self.action(server_id, "resize", size=size)
def clone(self, server_id: int, name: str = "") -> dict:
return self.action(server_id, "clone", **({"name": name} if name else {}))
def restore(self, server_id: int, image: str | int) -> dict:
"""Откатить сервер на снимок или бэкап."""
return self.action(server_id, "restore", image=image)
def vnc_link(self, server_id: int) -> dict:
"""Ссылка на консоль, когда по SSH уже не зайти."""
return self.action(server_id, "generate_vnc_link")
def set_backups(self, server_id: int, on: bool) -> dict:
return self.action(server_id, "enable_backups" if on else "disable_backups")
def snapshot(self, server_id: int, name: str, offline: bool = True) -> dict:
"""Снять образ. Сервер при этом должен работать, платформа остановит его сама,
если передан offline. С уже остановленного она снимать отказывается."""
return self.action(server_id, "snapshot", name=name, offline=1 if offline else 0)
# ---------- ожидание ----------
def wait_active(self, server_id: int, timeout: float = 900, poll: float = 5) -> dict:
"""Дождаться, пока сервер поднимется и получит адрес."""
deadline = time.time() + timeout
while time.time() < deadline:
info = self.server(server_id)
if info.get("status") == "active" and self.ip_of(info):
return info
time.sleep(poll)
raise Timeout(f"сервер {server_id} не поднялся за {timeout} секунд")
def wait_status(self, server_id: int, status: str, timeout: float = 600,
poll: float = 5) -> dict:
deadline = time.time() + timeout
while time.time() < deadline:
info = self.server(server_id)
if info.get("status") == status:
return info
time.sleep(poll)
raise Timeout(f"сервер {server_id} не пришёл в состояние {status}")
def wait_action(self, action_id: int, timeout: float = 900, poll: float = 5) -> dict:
deadline = time.time() + timeout
while time.time() < deadline:
data = self.get(f"/actions/{action_id}")
act = data.get("action", data)
if act.get("status") in ("completed", "errored"):
return act
time.sleep(poll)
raise Timeout(f"действие {action_id} не завершилось за {timeout} секунд")
def wait_snapshot(self, name: str, timeout: float = 1800, poll: float = 10) -> dict:
"""Дождаться появления снимка с таким именем."""
deadline = time.time() + timeout
while time.time() < deadline:
for s in self.snapshots():
if s.get("name") == name:
return s
time.sleep(poll)
raise Timeout(f"снимок {name} не появился за {timeout} секунд")
# ---------- снимки ----------
def snapshots(self, region: str = "") -> list[dict]:
params = {"region": region} if region else None
data = self.get("/snapshots", params=params)
return data.get("snapshots", []) if isinstance(data, dict) else []
def delete_snapshot(self, image_id: int) -> bool:
self.delete(f"/snapshots/{image_id}")
return True
def servers_from_snapshot(self, snapshot_id: int) -> list[dict]:
"""Какие серверы подняты из этого снимка."""
data = self.get(f"/reglets_for_snapshot/{snapshot_id}")
return data.get("reglets", []) if isinstance(data, dict) else []
# ---------- ключи доступа ----------
def ssh_keys(self) -> list[dict]:
data = self.get("/account/keys")
return data.get("ssh_keys", []) if isinstance(data, dict) else []
def add_ssh_key(self, name: str, public_key: str | pathlib.Path) -> dict:
"""Публичный ключ можно передать текстом или путём к файлу."""
key = public_key
path = pathlib.Path(str(public_key))
if path.exists():
key = path.read_text().strip()
data = self.post("/account/keys", json={"name": name, "public_key": str(key).strip()})
return data.get("ssh_key", data)
def rename_ssh_key(self, key_id: int, name: str) -> dict:
return self.put(f"/account/keys/{key_id}", json={"name": name})
def delete_ssh_key(self, key_id: int) -> bool:
self.delete(f"/account/keys/{key_id}")
return True
def key_fingerprint(self, name: str) -> str:
"""Отпечаток ключа по его имени, он нужен при создании сервера."""
for k in self.ssh_keys():
if k.get("name") == name:
return k.get("fingerprint", "")
raise NotFound(f"нет ключа с именем {name}")
# ---------- адреса ----------
def ips(self, server_id: int | None = None) -> list[dict]:
params = {"reglet_id": server_id} if server_id else None
data = self.get("/ips", params=params)
return data.get("ips", data) if isinstance(data, dict) else data
def add_ip(self, server_id: int, ipv4: int = 1, ipv6: int = 0) -> dict:
return self.post("/ips", json={"reglet_id": server_id,
"ipv4_count": ipv4, "ipv6_count": ipv6})
def delete_ip(self, ip: str) -> bool:
self.delete(f"/ips/{ip}")
return True
def set_ptr(self, ip: str, ptr: str) -> dict:
"""Обратная запись для адреса, нужна для почты."""
return self.put(f"/ips/{ip}", json={"ip": ip, "ptr": ptr})
# ---------- приватные сети ----------
def vpcs(self) -> list[dict]:
data = self.get("/vpcs")
return data.get("vpcs", []) if isinstance(data, dict) else []
def vpc(self, vpc_id: int) -> dict:
data = self.get(f"/vpcs/{vpc_id}")
return data.get("vpc", data)
def create_vpc(self, name: str, region: str, members: list[int] | None = None) -> dict:
payload = {"name": name, "region_slug": region}
if members:
payload["members"] = members
data = self.post("/vpcs", json=payload)
return data.get("vpc", data)
def delete_vpc(self, vpc_id: int) -> bool:
self.delete(f"/vpcs/{vpc_id}")
return True
def vpc_members(self, vpc_id: int) -> list[dict]:
data = self.get(f"/vpcs/{vpc_id}/members")
return data.get("members", []) if isinstance(data, dict) else []
def vpc_attach(self, vpc_id: int, server_id: int) -> dict:
return self.post(f"/vpcs/{vpc_id}/members", json={"resource_id": server_id})
def vpc_detach(self, vpc_id: int, server_id: int) -> bool:
self.delete(f"/vpcs/{vpc_id}/members/{server_id}")
return True
# ---------- kubernetes ----------
def kubeconfig(self, cluster_id: int) -> str:
data = self.get(f"/k8s_clusters/{cluster_id}/get_kubeconfig")
return data.get("kubeconfig", data) if isinstance(data, dict) else str(data)
# ---------- деньги и история ----------
def balance(self) -> dict:
data = self.get("/balance_data")
return data.get("balance_data", data)
def money_left(self) -> float:
return float(self.balance().get("balance") or 0)
def burn_rate(self) -> dict:
"""Сколько сейчас капает в час и в сутки по всем работающим услугам."""
b = self.balance()
per_hour = sum(float(i.get("price") or 0)
for row in b.get("detalization", []) for i in row.get("linked", []))
return {"в час": round(per_hour, 4), "в сутки": round(per_hour * 24, 2),
"в месяц": round(per_hour * 24 * 30, 2),
"баланс": float(b.get("balance") or 0),
"дней осталось": b.get("days_left")}
def billing_history(self) -> list[dict]:
data = self.get("/billing_history")
return data.get("billing_history", data) if isinstance(data, dict) else data
def history(self, limit: int = 50, offset: int = 0) -> list[dict]:
"""История операций над ресурсами."""
data = self.get("/history", params={"limit": limit, "offset": offset})
return data.get("history", data) if isinstance(data, dict) else data
# ---------- разное ----------
def settings(self, key: str) -> Any:
return self.get(f"/account/settings/{key}")
def set_setting(self, key: str, value: Any) -> Any:
return self.put(f"/account/settings/{key}", json={"value": value})
def feedback(self, message: str, email: str = "") -> dict:
return self.post("/account/feedback", json={"message": message, "user_email": email})
@staticmethod
def ip_of(server: dict) -> str:
"""Адрес сервера. Лежит в поле ip, а networks у платформы всегда пустое."""
if server.get("ip"):
return server["ip"]
nets = (server.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 close(self) -> None:
self._http.close()
# ---------- память о поднятых машинах ----------
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")
def __enter__(self): return self
def __exit__(self, *a): self.close()
+36
Просмотреть файл
@@ -0,0 +1,36 @@
"""Ошибки клиента. Разделены по смыслу, чтобы их можно было ловить прицельно."""
from __future__ import annotations
class RegCloudError(RuntimeError):
"""Общая ошибка платформы."""
def __init__(self, message: str, status: int = 0, code: str = "", payload: dict | None = None):
super().__init__(message)
self.status = status
self.code = code
self.payload = payload or {}
class AuthError(RegCloudError):
"""Токен не принят."""
class NotFound(RegCloudError):
"""Ресурса нет."""
class Locked(RegCloudError):
"""Ресурс занят другой операцией. Обычно проходит само за полминуты."""
class ValidationError(RegCloudError):
"""Платформа не приняла параметры. В payload лежит её объяснение."""
class InsufficientFunds(RegCloudError):
"""Не хватает денег на счету."""
class Timeout(RegCloudError):
"""Не дождались нужного состояния."""
+134
Просмотреть файл
@@ -0,0 +1,134 @@
"""Справочники платформы и сборка slug'ов.
Часть этих данных API не отдаёт вовсе, часть отдаёт неполно, поэтому держим
их тут. Проверено 23.08.2026, при расхождении верить панели управления,
раздел настроек, пункт со slug'ами для API и Terraform.
"""
from __future__ import annotations
# Регионы. Список подтверждён самой платформой в тексте ошибки валидации.
REGIONS = {
"openstack-msk1": "Москва",
"openstack-msk2": "Москва-2",
"openstack-msk3": "Москва-3",
"openstack-spb1": "Санкт-Петербург",
"openstack-sam1": "Самара",
"openstack-fz1": "Москва ФЗ-152",
"msk1": "Москва, архивные тарифы",
}
# Линейки обычных тарифов
PLAN_LINES = {
"std": "Стандартный",
"stdp": "Стандартный плюс",
"hp": "Производительный",
"hps": "Производительный ФЗ-152",
"ddc": "Выделенный CPU",
"high": "Высокочастотный",
}
# Допустимые сочетания ядер и памяти по линейкам
CPU_RAM = {
"std": {1: [1, 2, 3, 4], 2: [2, 3, 6, 8], 3: [3, 4, 6, 8, 10],
4: [4, 6, 8, 10, 12, 16], 6: [6, 8, 10, 12, 16],
8: [8, 10, 12, 16, 20, 24, 32], 10: [10, 12, 16, 20, 24, 32, 40],
12: [12, 16, 20, 24, 32, 40, 48], 16: [16, 20, 24, 32, 40, 48],
20: [20, 24, 32, 40, 48], 24: [24, 32, 40, 48]},
"hp": {1: [1, 2, 3, 4], 2: [2, 3, 6, 8], 3: [3, 4, 6, 8, 10],
4: [4, 6, 8, 10, 12, 16], 6: [6, 8, 10, 12, 16],
8: [8, 10, 12, 16, 20, 24, 32], 10: [10, 12, 16, 20, 24, 32, 40],
12: [12, 16, 20, 24, 32, 40, 48], 16: [16, 20, 24, 32, 40, 48],
20: [20, 24, 32, 40, 48, 56, 64], 24: [24, 32, 40, 48, 56, 64, 72],
32: [32, 40, 48, 56, 64, 72, 80], 40: [40, 48, 56, 64, 72, 80],
48: [48, 56, 64, 72, 80]},
"high": {1: [1, 2, 3, 4], 2: [2, 3, 6, 8], 3: [3, 4, 6, 8, 10],
4: [4, 6, 8, 10, 12, 16], 6: [6, 8, 10, 12, 16],
8: [8, 10, 12, 16, 20, 24, 32], 10: [10, 12, 16, 20, 24, 32],
12: [12, 16, 20, 24, 32]},
}
CPU_RAM["stdp"] = CPU_RAM["std"]
CPU_RAM["hps"] = CPU_RAM["hp"]
CPU_RAM["ddc"] = CPU_RAM["hp"]
# Диапазон диска по линейкам, гигабайты, шаг десять
DISK_RANGE = {
"std": (10, 480), "stdp": (10, 480),
"hp": (10, 960), "hps": (10, 960), "ddc": (10, 960),
"high": (20, 320),
}
# Тарифы с видеокартой. Свои конфигурации для них собирать нельзя,
# только эти готовые. Цены в рублях за час на 23.08.2026.
GPU_SIZES = {
"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 ГБ", 24, 128, 512, 111.65),
"c32-m256-d1024-g1-a5000-gpu": ("A5000 24 ГБ", 32, 256, 1024, 132.01),
"c48-m384-d1024-g2-a5000-gpu": ("2x A5000 24 ГБ", 48, 384, 1024, 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, 1024, 273.72),
"c64-m512-d1024-g2-a100-gpu": ("2x A100 80 ГБ", 64, 512, 1024, 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, 1024, 271.15),
"c32-m128-d1024-g2-rtx6000-gpu": ("2x RTX 6000 Blackwell", 32, 128, 1024, 382.58),
}
# Типы образов в API
IMAGE_TYPES = ("distribution", "application", "snapshot", "backup", "restore")
# Действия над сервером
ACTIONS = ("start", "stop", "reboot", "rebuild", "password_reset", "resize",
"generate_vnc_link", "snapshot", "enable_backups", "disable_backups",
"restore", "clone", "resize_isp_license")
ISP_LICENSES = ("isp_lite6", "isp_pro6", "isp_host6")
def build_size(cpu: int, ram: int, disk: int, line: str = "hp") -> str:
"""Собирает slug тарифа и проверяет сочетание по правилам платформы.
build_size(4, 8, 120, "hp") -> 'c4-m8-d120-hp'
"""
if line not in PLAN_LINES:
raise ValueError(f"нет линейки {line}, есть {', '.join(PLAN_LINES)}")
allowed = CPU_RAM.get(line, {})
if cpu not in allowed:
raise ValueError(f"в линейке {line} нет варианта на {cpu} ядер, "
f"доступны {sorted(allowed)}")
if ram not in allowed[cpu]:
raise ValueError(f"при {cpu} ядрах в линейке {line} память может быть "
f"{allowed[cpu]}, а не {ram}")
low, high = DISK_RANGE[line]
if not (low <= disk <= high) or disk % 10:
raise ValueError(f"диск в линейке {line} от {low} до {high} ГБ с шагом 10, а не {disk}")
return f"c{cpu}-m{ram}-d{disk}-{line}"
def parse_size(slug: str) -> dict:
"""Разбирает slug обратно в понятные значения."""
if slug in GPU_SIZES:
name, cpu, ram, disk, price = GPU_SIZES[slug]
return {"slug": slug, "gpu": name, "vcpus": cpu, "memory_gb": ram,
"disk_gb": disk, "price_hour": price, "line": "gpu"}
parts = slug.split("-")
try:
return {
"slug": slug,
"vcpus": int(parts[0].lstrip("c")),
"memory_gb": int(parts[1].lstrip("m")),
"disk_gb": int(parts[2].lstrip("d")),
"line": parts[3] if len(parts) > 3 else "",
"gpu": None,
}
except (IndexError, ValueError):
return {"slug": slug}
def gpu_catalog() -> list[dict]:
"""Тарифы с видеокартой списком, отсортированные по цене."""
out = [{"slug": s, "gpu": v[0], "vcpus": v[1], "memory_gb": v[2],
"disk_gb": v[3], "price_hour": v[4]} for s, v in GPU_SIZES.items()]
return sorted(out, key=lambda x: x["price_hour"])