diff --git a/README.md b/README.md index 2870130..bc78c97 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,9 @@ Клиент к API облака REG.RU (Рег.облако), точка входа `https://api.cloudvps.reg.ru`. -Создание и удаление серверов, снимки, SSH-ключи, тарифы, образы, баланс и цены. -Само API устроено как у DigitalOcean, сервер там называется reglet. +Полное покрытие API: серверы и все действия над ними, снимки, образы, тарифы, регионы, +SSH-ключи, IP-адреса и обратные записи, приватные сети, kubeconfig кластеров, +история операций и биллинг. ## Установка @@ -12,104 +13,154 @@ 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)) +for r in api.regions(): # где можно размещаться + print(r["slug"], r["name"]) -print(api.balance()) +for i in api.distributions(): # операционные системы + print(i["slug"], i["distribution"]) + +for i in api.applications(): # готовые сборки: панели, докер, гитлаб + print(i["slug"]) + +for s in api.sizes(): # тарифы + print(s["slug"], s["vcpus"], "ядер", s["price"], "р/час") ``` -Поднять сервер и дождаться готовности. +Во второй версии API образов заметно больше и они разложены по регионам. ```python -srv = api.create( - size="c2-m4-d60-hp", # 2 ядра, 4 ГБ, 60 ГБ, линейка «производительный» +for i in api.images_v2("openstack-spb1", type="distribution"): + print(i["id"], i["name"]) +``` + +## Тарифы + +Slug собирается по шаблону `c<ядра>-m<память>-d<диск>-<линейка>`. Клиент умеет +собирать его сам и заодно проверять, что такое сочетание вообще существует. + +```python +from regcloud_api import build_size, parse_size + +RegCloud.make_size(4, 8, 120, "hp") # 'c4-m8-d120-hp' +build_size(4, 5, 120, "hp") # ValueError, при 4 ядрах памяти 5 ГБ не бывает + +parse_size("c8-m32-d128-g1-a4000-gpu") +# {'gpu': 'A4000 16 ГБ', 'vcpus': 8, 'memory_gb': 32, 'disk_gb': 128, 'price_hour': 37.8} +``` + +Линейки: `std` стандартный, `stdp` стандартный плюс, `hp` производительный, +`hps` производительный ФЗ-152, `ddc` выделенный CPU, `high` высокочастотный. + +## Жизненный цикл сервера + +```python +key = api.add_ssh_key("deploy", "~/.ssh/id_ed25519.pub") # можно путём к файлу + +srv = api.create_server( + size="c2-m4-d60-hp", image="ubuntu-24-04-amd64", - name="web-01", + name=api.random_name(), # платформа умеет придумывать имена region="openstack-msk1", - ssh_keys=["d7:3a:b4:..."], # отпечаток ключа из api.ssh_keys() + ssh_keys=[key["fingerprint"]], ) info = api.wait_active(srv["id"]) print("адрес:", RegCloud.ip_of(info)) ``` -Снять образ и поднять из него копию. +Действия над сервером. ```python -api.snapshot(srv["id"], "web-01-base", offline=True) # сервер остановит сам -copy = api.create(size="c2-m4-d60-hp", image=7887485, region="openstack-msk1") +api.stop(id); api.start(id); api.reboot(id) +api.resize(id, "c4-m8-d120-hp") # сменить тариф +api.rebuild(id, "debian-12-amd64") # переустановить систему +api.reset_password(id) +api.vnc_link(id) # консоль, когда по ssh уже не зайти +api.set_backups(id, True) +api.clone(id, "копия") ``` -Удалить. +Снимки. ```python -api.destroy(srv["id"]) +api.snapshot(id, "base", offline=True) # платформа сама остановит сервер +snap = api.wait_snapshot("base") +api.create_server(size="c2-m4-d60-hp", image=snap["id"], region="openstack-msk1") +api.servers_from_snapshot(snap["id"]) # кто уже поднят из этого снимка +api.delete_snapshot(snap["id"]) ``` -## Методы +Удаление. -| Метод | Что делает | -|---|---| -| `list()`, `get(id)` | серверы на аккаунте | -| `create(size, image, name, ssh_keys, region)` | создать сервер, из образа или из снимка | -| `destroy(id)` | удалить | -| `stop(id)`, `start(id)` | выключить, включить | -| `action(id, type, **kw)` | любое действие: reboot, rebuild, resize, clone, restore и прочие | -| `snapshot(id, name, offline)` | снять образ | -| `wait_active(id)`, `wait_action(id)` | дождаться готовности сервера или завершения действия | -| `sizes()`, `gpu_sizes()` | тарифы | -| `images()`, `snapshots()` | образы и снимки | -| `ssh_keys()` | ключи доступа | -| `balance()`, `prices()` | деньги | -| `RegCloud.ip_of(server)` | вытащить адрес из карточки сервера | +```python +api.destroy_server(id) # дождётся снятия блокировки +``` -## Особенности API, которые тут уже обойдены +## Адреса и сети -Это не претензии к платформе, просто места, где поведение отличается от ожидаемого. -Собрано на живых серверах, чтобы вы на них не наступали. +```python +api.ips(server_id) +api.add_ip(server_id, ipv4=1) +api.set_ptr("1.2.3.4", "mail.example.com") +api.delete_ip("1.2.3.4") -**Адрес сервера лежит в поле `ip`.** Поле `networks`, которое по документации должно -его содержать, на практике всегда пустое. `ip_of()` смотрит в оба места. +vpc = api.create_vpc("backoffice", region="openstack-msk1") +api.vpc_attach(vpc["id"], server_id) +api.vpc_members(vpc["id"]) +api.vpc_detach(vpc["id"], server_id) +``` -**Снимок снимается только с работающего сервера.** У остановленного API отвечает -`SERVER_IS_STOPPED`. Останавливать его для снимка должна сама платформа, -для этого есть параметр `offline`. +## Деньги -**Сразу после снимка сервер сидит в `locked`** и на удаление отвечает -`RESOURCE_DELETE_ERROR`. Отпускает примерно через полминуты. Поэтому `destroy()` -по умолчанию повторяет попытки, пока не получится, и считает `NO_SUCH_REGLET` -успехом. Забытый сервер продолжает тарифицироваться, так что молча сдаваться тут нельзя. +```python +api.money_left() # сколько на счету +api.burn_rate() # сколько капает в час, в сутки и в месяц +api.billing_history() +api.history(limit=50) # что и когда делали с ресурсами +api.prices() +``` -**`sizes()` показывает не все тарифы.** Часть линеек метод не возвращает, хотя создание -с ними работает нормально. Полный список смотрите в панели управления, в разделе -настроек, пункт со slug'ами для API и Terraform. Наша выборка лежит в [docs_slugs.md](docs_slugs.md). +## Ошибки + +Все ошибки платформы разложены по смыслу, чтобы ловить прицельно. + +```python +from regcloud_api import NotFound, Locked, ValidationError, InsufficientFunds + +try: + api.destroy_server(123) +except NotFound: + pass # его уже нет, и хорошо +except Locked: + pass # занят другой операцией +``` + +## Особенности платформы, обойдённые внутри + +Собрано на живых серверах, чтобы вы на это не наступали. + +**Адрес сервера лежит в поле `ip`.** Поле `networks` из документации всегда пустое. +`RegCloud.ip_of()` смотрит в оба места. + +**Снимок снимается только с работающего сервера.** У остановленного платформа отвечает +`SERVER_IS_STOPPED`, останавливать его должна она сама по параметру `offline`. + +**После снимка сервер сидит в `locked`** и на удаление отвечает `RESOURCE_DELETE_ERROR`. +Отпускает примерно через полминуты, поэтому `destroy_server()` повторяет попытки. +Забытый сервер продолжает тарифицироваться, молча сдаваться тут нельзя. + +**`sizes()` возвращает не все линейки.** Тарифы с видеокартами в выдачу не попадают, +хотя создание с ними работает. Их список лежит в `gpu_sizes()` и в [docs_slugs.md](docs_slugs.md). **Тарификация идёт от часа.** Двадцать минут работы списываются как полный час. -Короткие запуски невыгодны, задачи лучше копить и выполнять пачкой. - -## Составление slug тарифа - -`c<ядра>-m<память>-d<диск>-<линейка>`, например `c4-m8-d120-hp`. - -Линейки: `std` стандартный, `stdp` стандартный плюс, `hp` производительный, -`hps` производительный ФЗ-152, `ddc` выделенный CPU, `high` высокочастотный. -Допустимые сочетания ядер, памяти и диска смотрите в [docs_slugs.md](docs_slugs.md). - -## Регионы - -`openstack-msk1`, `openstack-msk2`, `openstack-msk3`, `openstack-spb1`, -`openstack-sam1`, `openstack-fz1` и архивный `msk1`. - -Не все образы и тарифы доступны в каждом регионе. ## Рядом diff --git a/examples/golden_image.py b/examples/golden_image.py new file mode 100644 index 0000000..982a794 --- /dev/null +++ b/examples/golden_image.py @@ -0,0 +1,25 @@ +"""Эталонный образ: настроить машину один раз, снять снимок, дальше клонировать. + +Так разворачивание клиента занимает минуты вместо часа настройки. +""" +from regcloud_api import RegCloud + +api = RegCloud() +REGION = "openstack-msk1" +SIZE = "c2-m4-d60-hp" + +srv = api.create_server(size=SIZE, image="ubuntu-24-04-amd64", name="golden", region=REGION) +info = api.wait_active(srv["id"]) +print("настраивайте машину по адресу", RegCloud.ip_of(info)) +input("нажмите Enter, когда закончите...") + +api.snapshot(srv["id"], "golden-v1", offline=True) +snap = api.wait_snapshot("golden-v1") +print("снимок готов:", snap["id"], snap.get("size_gigabytes"), "ГБ") + +api.destroy_server(srv["id"]) +print("эталон удалён, снимок остался") + +for n in range(1, 4): + copy = api.create_server(size=SIZE, image=snap["id"], name=f"client-{n:02d}", region=REGION) + print("поднял копию", copy["id"]) diff --git a/examples/inventory.py b/examples/inventory.py new file mode 100644 index 0000000..909300e --- /dev/null +++ b/examples/inventory.py @@ -0,0 +1,26 @@ +"""Осмотреться на аккаунте: что где размещается, что запущено, куда уходят деньги.""" +from regcloud_api import RegCloud + +api = RegCloud() + +print("РЕГИОНЫ") +for r in api.regions(): + print(f" {r['slug']:18s} {r['name']}") + +print("\nСЕРВЕРЫ") +for s in api.servers(): + info = api.size_info(s.get("size_slug", "")) + print(f" {s['id']} {s['name']:20s} {s['status']:8s} {RegCloud.ip_of(s):16s} " + f"{info.get('vcpus','?')} ядер, {info.get('memory_gb','?')} ГБ") + +print("\nСНИМКИ") +for s in api.snapshots(): + print(f" {s['id']} {s['name']:20s} {s.get('size_gigabytes')} ГБ") + +print("\nКЛЮЧИ") +for k in api.ssh_keys(): + print(f" {k['id']} {k['name']:15s} {k['fingerprint']}") + +print("\nДЕНЬГИ") +for k, v in api.burn_rate().items(): + print(f" {k}: {v}") diff --git a/examples/provision.py b/examples/provision.py new file mode 100644 index 0000000..65b7381 --- /dev/null +++ b/examples/provision.py @@ -0,0 +1,31 @@ +"""Развернуть сервер под клиента: выбрать регион и систему, поднять, отдать доступы. + +Пример под перепродажу хостинга. Тариф собирается из требований, а не берётся +из готового списка, так что можно продавать любую конфигурацию. +""" +import sys +from regcloud_api import RegCloud, ValidationError + +api = RegCloud() + +CPU, RAM, DISK, LINE = 2, 4, 60, "hp" +REGION = "openstack-msk1" +OS = "ubuntu-24-04-amd64" + +size = RegCloud.make_size(CPU, RAM, DISK, LINE) # проверит сочетание сам +print("тариф:", size, api.size_info(size)) + +available = {i.get("slug") for i in api.distributions()} +if OS not in available: + print(f"системы {OS} нет, доступны:", ", ".join(sorted(available)[:8])) + sys.exit(1) + +try: + srv = api.create_server(size=size, image=OS, name=api.random_name(), region=REGION) +except ValidationError as exc: + print("платформа не приняла заказ:", exc) + sys.exit(1) + +info = api.wait_active(srv["id"]) +print(f"готово. сервер {info['id']}, адрес {RegCloud.ip_of(info)}") +print("пароль root приходит на почту, либо задайте ssh-ключ при создании") diff --git a/regcloud_api/__init__.py b/regcloud_api/__init__.py index 97af898..200bf70 100644 --- a/regcloud_api/__init__.py +++ b/regcloud_api/__init__.py @@ -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" diff --git a/regcloud_api/client.py b/regcloud_api/client.py index 58d0773..9395938 100644 --- a/regcloud_api/client.py +++ b/regcloud_api/client.py @@ -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() diff --git a/regcloud_api/errors.py b/regcloud_api/errors.py new file mode 100644 index 0000000..0e5f386 --- /dev/null +++ b/regcloud_api/errors.py @@ -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): + """Не дождались нужного состояния.""" diff --git a/regcloud_api/slugs.py b/regcloud_api/slugs.py new file mode 100644 index 0000000..2236031 --- /dev/null +++ b/regcloud_api/slugs.py @@ -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"])