Полное покрытие API
Клиент дописан до всех двадцати семи эндпоинтов платформы. Добавлено: - регионы, образы с разбивкой на системы и готовые сборки, вторая версия API образов - все действия над сервером: старт, стоп, перезагрузка, переустановка, смена тарифа, клонирование, откат, консоль, бэкапы - снимки целиком, включая ожидание готовности и поиск поднятых из них серверов - ключи доступа, IP-адреса с обратными записями, приватные сети, kubeconfig - история операций, биллинг, расход в час и в сутки - сборка и проверка slug тарифа по правилам платформы - ошибки разложены по смыслу, чтобы ловить прицельно Claude-Session: https://claude.ai/code/session_01Loz5stgQZH6ofBdMqTxiJu
Этот коммит содержится в:
+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()
|
||||
|
||||
Ссылка в новой задаче
Block a user