Клиент дописан до всех двадцати семи эндпоинтов платформы. Добавлено: - регионы, образы с разбивкой на системы и готовые сборки, вторая версия API образов - все действия над сервером: старт, стоп, перезагрузка, переустановка, смена тарифа, клонирование, откат, консоль, бэкапы - снимки целиком, включая ожидание готовности и поиск поднятых из них серверов - ключи доступа, IP-адреса с обратными записями, приватные сети, kubeconfig - история операций, биллинг, расход в час и в сутки - сборка и проверка slug тарифа по правилам платформы - ошибки разложены по смыслу, чтобы ловить прицельно Claude-Session: https://claude.ai/code/session_01Loz5stgQZH6ofBdMqTxiJu
483 строки
22 KiB
Python
483 строки
22 KiB
Python
"""Клиент к API облака REG.RU (Рег.облако).
|
|
|
|
Покрывает всё API целиком: серверы, действия над ними, снимки, образы, тарифы,
|
|
регионы, SSH-ключи, IP-адреса, приватные сети, историю операций, биллинг.
|
|
|
|
from regcloud_api import RegCloud
|
|
|
|
api = RegCloud() # токен из REGRU_CLOUD_TOKEN
|
|
|
|
for r in api.regions(): # где можно размещаться
|
|
print(r["slug"], r["name"])
|
|
|
|
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
|
|
|
|
import json
|
|
import os
|
|
import pathlib
|
|
import time
|
|
from typing import Any, Iterable
|
|
|
|
import httpx
|
|
|
|
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:
|
|
"""Всё 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 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 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]:
|
|
"""Тарифы с видеокартой. Собственные конфигурации для них платформа
|
|
не принимает, только эти готовые."""
|
|
return gpu_catalog()
|
|
|
|
def size_info(self, slug: str) -> dict:
|
|
"""Разобрать slug тарифа на понятные значения."""
|
|
return parse_size(slug)
|
|
|
|
@staticmethod
|
|
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 __enter__(self): return self
|
|
def __exit__(self, *a): self.close()
|