Полное покрытие API
Клиент дописан до всех двадцати семи эндпоинтов платформы. Добавлено: - регионы, образы с разбивкой на системы и готовые сборки, вторая версия API образов - все действия над сервером: старт, стоп, перезагрузка, переустановка, смена тарифа, клонирование, откат, консоль, бэкапы - снимки целиком, включая ожидание готовности и поиск поднятых из них серверов - ключи доступа, IP-адреса с обратными записями, приватные сети, kubeconfig - история операций, биллинг, расход в час и в сутки - сборка и проверка slug тарифа по правилам платформы - ошибки разложены по смыслу, чтобы ловить прицельно Claude-Session: https://claude.ai/code/session_01Loz5stgQZH6ofBdMqTxiJu
Этот коммит содержится в:
+114
-63
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
Клиент к API облака REG.RU (Рег.облако), точка входа `https://api.cloudvps.reg.ru`.
|
Клиент к API облака REG.RU (Рег.облако), точка входа `https://api.cloudvps.reg.ru`.
|
||||||
|
|
||||||
Создание и удаление серверов, снимки, SSH-ключи, тарифы, образы, баланс и цены.
|
Полное покрытие API: серверы и все действия над ними, снимки, образы, тарифы, регионы,
|
||||||
Само API устроено как у DigitalOcean, сервер там называется reglet.
|
SSH-ключи, IP-адреса и обратные записи, приватные сети, kubeconfig кластеров,
|
||||||
|
история операций и биллинг.
|
||||||
|
|
||||||
## Установка
|
## Установка
|
||||||
|
|
||||||
@@ -12,104 +13,154 @@ pip install git+https://git.stackworks.ru/viktor/regcloud-api.git
|
|||||||
```
|
```
|
||||||
|
|
||||||
Токен берётся в личном кабинете, в разделе настроек, и кладётся в `REGRU_CLOUD_TOKEN`.
|
Токен берётся в личном кабинете, в разделе настроек, и кладётся в `REGRU_CLOUD_TOKEN`.
|
||||||
Либо передаётся в конструктор явно.
|
|
||||||
|
|
||||||
## Быстрый старт
|
## Осмотреться
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from regcloud_api import RegCloud
|
from regcloud_api import RegCloud
|
||||||
|
|
||||||
api = RegCloud()
|
api = RegCloud()
|
||||||
|
|
||||||
for s in api.list():
|
for r in api.regions(): # где можно размещаться
|
||||||
print(s["id"], s["name"], s["status"], RegCloud.ip_of(s))
|
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
|
```python
|
||||||
srv = api.create(
|
for i in api.images_v2("openstack-spb1", type="distribution"):
|
||||||
size="c2-m4-d60-hp", # 2 ядра, 4 ГБ, 60 ГБ, линейка «производительный»
|
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",
|
image="ubuntu-24-04-amd64",
|
||||||
name="web-01",
|
name=api.random_name(), # платформа умеет придумывать имена
|
||||||
region="openstack-msk1",
|
region="openstack-msk1",
|
||||||
ssh_keys=["d7:3a:b4:..."], # отпечаток ключа из api.ssh_keys()
|
ssh_keys=[key["fingerprint"]],
|
||||||
)
|
)
|
||||||
|
|
||||||
info = api.wait_active(srv["id"])
|
info = api.wait_active(srv["id"])
|
||||||
print("адрес:", RegCloud.ip_of(info))
|
print("адрес:", RegCloud.ip_of(info))
|
||||||
```
|
```
|
||||||
|
|
||||||
Снять образ и поднять из него копию.
|
Действия над сервером.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
api.snapshot(srv["id"], "web-01-base", offline=True) # сервер остановит сам
|
api.stop(id); api.start(id); api.reboot(id)
|
||||||
copy = api.create(size="c2-m4-d60-hp", image=7887485, region="openstack-msk1")
|
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
|
```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"])
|
||||||
```
|
```
|
||||||
|
|
||||||
## Методы
|
Удаление.
|
||||||
|
|
||||||
| Метод | Что делает |
|
```python
|
||||||
|---|---|
|
api.destroy_server(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)` | вытащить адрес из карточки сервера |
|
|
||||||
|
|
||||||
## Особенности 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`, которое по документации должно
|
vpc = api.create_vpc("backoffice", region="openstack-msk1")
|
||||||
его содержать, на практике всегда пустое. `ip_of()` смотрит в оба места.
|
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`** и на удаление отвечает
|
```python
|
||||||
`RESOURCE_DELETE_ERROR`. Отпускает примерно через полминуты. Поэтому `destroy()`
|
api.money_left() # сколько на счету
|
||||||
по умолчанию повторяет попытки, пока не получится, и считает `NO_SUCH_REGLET`
|
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`.
|
|
||||||
|
|
||||||
Не все образы и тарифы доступны в каждом регионе.
|
|
||||||
|
|
||||||
## Рядом
|
## Рядом
|
||||||
|
|
||||||
|
|||||||
@@ -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"])
|
||||||
@@ -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}")
|
||||||
@@ -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-ключ при создании")
|
||||||
@@ -1,5 +1,19 @@
|
|||||||
"""Клиент к API облака REG.RU."""
|
"""Клиент к API облака REG.RU (Рег.облако).
|
||||||
from .client import RegCloud, load_state, save_state
|
|
||||||
|
|
||||||
__all__ = ["RegCloud", "load_state", "save_state"]
|
Полное покрытие API: серверы, действия над ними, снимки, образы, тарифы, регионы,
|
||||||
__version__ = "0.1.0"
|
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"
|
||||||
|
|||||||
+446
-191
@@ -1,22 +1,23 @@
|
|||||||
"""Клиент к API облака REG.RU (Рег.облако).
|
"""Клиент к API облака REG.RU (Рег.облако).
|
||||||
|
|
||||||
API устроено как у DigitalOcean, сервер там называется reglet.
|
Покрывает всё API целиком: серверы, действия над ними, снимки, образы, тарифы,
|
||||||
Токен берётся в личном кабинете, в разделе настроек.
|
регионы, SSH-ключи, IP-адреса, приватные сети, историю операций, биллинг.
|
||||||
|
|
||||||
from regcloud_api import RegCloud
|
from regcloud_api import RegCloud
|
||||||
|
|
||||||
api = RegCloud() # токен из REGRU_CLOUD_TOKEN
|
api = RegCloud() # токен из REGRU_CLOUD_TOKEN
|
||||||
for s in api.list():
|
|
||||||
print(s["id"], s["name"], s["status"])
|
|
||||||
|
|
||||||
Зачем обёртка, если API простое. Затем, что у него есть неочевидные места,
|
for r in api.regions(): # где можно размещаться
|
||||||
собранные на живых серверах и обойдённые тут.
|
print(r["slug"], r["name"])
|
||||||
|
|
||||||
Адрес сервера лежит в поле ip, а не в networks, оно всегда пустое.
|
for i in api.distributions(): # какие операционные системы есть
|
||||||
Снимок снимается только с работающего сервера, останавливает его платформа сама по offline.
|
print(i["slug"], i["name"])
|
||||||
Сразу после снимка сервер сидит в locked и удаляться отказывается примерно полминуты,
|
|
||||||
поэтому удаление настойчивое, иначе забытый сервер продолжит тарифицироваться.
|
srv = api.create_server(size="c2-m4-d60-hp", image="ubuntu-24-04-amd64",
|
||||||
Метод sizes возвращает не все линейки тарифов, полный список смотрите в панели управления.
|
region="openstack-msk1")
|
||||||
|
api.wait_active(srv["id"])
|
||||||
|
|
||||||
|
Особенности платформы, обойдённые внутри, описаны в README.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -24,204 +25,458 @@ import json
|
|||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
import time
|
import time
|
||||||
|
from typing import Any, Iterable
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
BASE = "https://api.cloudvps.reg.ru/v1"
|
from .errors import (AuthError, InsufficientFunds, Locked, NotFound,
|
||||||
STATE_FILE = pathlib.Path(os.environ.get("REGCLOUD_STATE_DIR", ".")) / "regcloud_state.json"
|
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:
|
class RegCloud:
|
||||||
def __init__(self, token: str | None = None, timeout: float = 60.0):
|
"""Всё API платформы одним объектом."""
|
||||||
self.token = token or os.environ.get("REGRU_CLOUD_TOKEN", "")
|
|
||||||
|
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:
|
if not self.token:
|
||||||
raise RuntimeError("нет токена, положи REGRU_CLOUD_TOKEN в окружение")
|
raise AuthError(f"нет токена, передайте его явно или положите в {TOKEN_ENV}")
|
||||||
self.http = httpx.Client(
|
self.base_url = base_url.rstrip("/")
|
||||||
base_url=BASE, timeout=timeout,
|
self.retries = retries
|
||||||
headers={"Authorization": f"Bearer {self.token}", "Content-Type": "application/json"},
|
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]:
|
def regions(self) -> list[dict]:
|
||||||
r = self.http.get("/sizes")
|
"""Где можно размещать серверы.
|
||||||
r.raise_for_status()
|
|
||||||
return r.json().get("sizes", [])
|
Отдельного метода у платформы нет, список зашит и подтверждён её же
|
||||||
|
проверкой при создании сервера."""
|
||||||
|
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]:
|
def gpu_sizes(self) -> list[dict]:
|
||||||
"""Отбор тарифов с видеокартой, если они вам нужны.
|
"""Тарифы с видеокартой. Собственные конфигурации для них платформа
|
||||||
Учтите, что sizes() возвращает не все линейки, полный список в панели управления."""
|
не принимает, только эти готовые."""
|
||||||
out = []
|
return gpu_catalog()
|
||||||
for s in self.sizes():
|
|
||||||
blob = json.dumps(s, ensure_ascii=False).lower()
|
|
||||||
if "gpu" in blob or "nvidia" in blob or "a4000" in blob or "a5000" in blob or "a100" in blob:
|
|
||||||
out.append(s)
|
|
||||||
return out
|
|
||||||
|
|
||||||
def images(self) -> list[dict]:
|
def size_info(self, slug: str) -> dict:
|
||||||
r = self.http.get("/images")
|
"""Разобрать slug тарифа на понятные значения."""
|
||||||
r.raise_for_status()
|
return parse_size(slug)
|
||||||
return r.json().get("images", [])
|
|
||||||
|
|
||||||
def snapshots(self) -> list[dict]:
|
|
||||||
r = self.http.get("/snapshots")
|
|
||||||
r.raise_for_status()
|
|
||||||
return r.json().get("snapshots", [])
|
|
||||||
|
|
||||||
def ssh_keys(self) -> list[dict]:
|
|
||||||
r = self.http.get("/account/keys")
|
|
||||||
r.raise_for_status()
|
|
||||||
return r.json().get("ssh_keys", [])
|
|
||||||
|
|
||||||
def balance(self) -> dict:
|
|
||||||
r = self.http.get("/balance_data")
|
|
||||||
return r.json() if r.status_code == 200 else {"error": r.status_code, "text": r.text[:200]}
|
|
||||||
|
|
||||||
def prices(self) -> dict:
|
|
||||||
r = self.http.get("/prices")
|
|
||||||
return r.json() if r.status_code == 200 else {}
|
|
||||||
|
|
||||||
# ---------- машины ----------
|
|
||||||
|
|
||||||
def list(self) -> list[dict]:
|
|
||||||
r = self.http.get("/reglets")
|
|
||||||
r.raise_for_status()
|
|
||||||
return r.json().get("reglets", [])
|
|
||||||
|
|
||||||
def get(self, reglet_id: int) -> dict:
|
|
||||||
r = self.http.get(f"/reglets/{reglet_id}")
|
|
||||||
r.raise_for_status()
|
|
||||||
return r.json().get("reglet", {})
|
|
||||||
|
|
||||||
def create(self, size: str, image: str | int, name: str = "gpuhub-worker",
|
|
||||||
ssh_keys: list[str] | None = None, region: str = "") -> dict:
|
|
||||||
payload: dict = {"name": name, "size": size, "image": image}
|
|
||||||
if ssh_keys:
|
|
||||||
payload["ssh_keys"] = ssh_keys
|
|
||||||
if region:
|
|
||||||
payload["region_slug"] = region
|
|
||||||
r = self.http.post("/reglets", json=payload)
|
|
||||||
if r.status_code >= 400:
|
|
||||||
raise RuntimeError(f"не создалась машина, {r.status_code} {r.text[:300]}")
|
|
||||||
return r.json().get("reglet", {})
|
|
||||||
|
|
||||||
def destroy(self, reglet_id: int, patient: bool = True, timeout: float = 900) -> bool:
|
|
||||||
"""Удаление машины.
|
|
||||||
|
|
||||||
Сразу после снятия снимка машина сидит в locked и удаляться отказывается,
|
|
||||||
отвечая RESOURCE_DELETE_ERROR. Проверено на живой машине, отпускает примерно
|
|
||||||
через полминуты. Поэтому по умолчанию долбимся, пока не поддастся, иначе
|
|
||||||
забытая машина будет тикать по деньгам.
|
|
||||||
"""
|
|
||||||
deadline = time.time() + timeout
|
|
||||||
while True:
|
|
||||||
r = self.http.delete(f"/reglets/{reglet_id}")
|
|
||||||
if r.status_code < 400 or r.status_code == 404:
|
|
||||||
return True
|
|
||||||
body = r.text[:200]
|
|
||||||
if "NO_SUCH_REGLET" in body:
|
|
||||||
return True # её уже нет, это успех
|
|
||||||
if not patient or time.time() > deadline:
|
|
||||||
raise RuntimeError(f"не удалилась машина, {r.status_code} {body}")
|
|
||||||
time.sleep(20)
|
|
||||||
|
|
||||||
def action(self, reglet_id: int, action_type: str, **extra) -> dict:
|
|
||||||
r = self.http.post(f"/reglets/{reglet_id}/actions", json={"type": action_type, **extra})
|
|
||||||
if r.status_code >= 400:
|
|
||||||
raise RuntimeError(f"действие {action_type} не прошло, {r.status_code} {r.text[:200]}")
|
|
||||||
return r.json()
|
|
||||||
|
|
||||||
def snapshot(self, reglet_id: int, name: str, offline: bool = True) -> dict:
|
|
||||||
"""Снимок машины. Из него потом поднимаются готовые к работе клоны.
|
|
||||||
Снимаем с выключенной, так образ выходит целостным."""
|
|
||||||
return self.action(reglet_id, "snapshot", name=name, offline=offline)
|
|
||||||
|
|
||||||
def stop(self, reglet_id: int) -> dict:
|
|
||||||
"""Остановка. Важно понимать, что у большинства провайдеров остановленная машина
|
|
||||||
всё равно занимает диск и продолжает тарифицироваться. Для экономии её нужно удалять."""
|
|
||||||
return self.action(reglet_id, "stop")
|
|
||||||
|
|
||||||
def start(self, reglet_id: int) -> dict:
|
|
||||||
return self.action(reglet_id, "start")
|
|
||||||
|
|
||||||
def wait_action(self, action_id: int, timeout: float = 900) -> dict:
|
|
||||||
"""Ждём завершения долгого действия, например снятия снимка."""
|
|
||||||
deadline = time.time() + timeout
|
|
||||||
while time.time() < deadline:
|
|
||||||
r = self.http.get(f"/actions/{action_id}")
|
|
||||||
if r.status_code == 200:
|
|
||||||
data = r.json().get("action", {})
|
|
||||||
if data.get("status") in ("completed", "errored"):
|
|
||||||
return data
|
|
||||||
time.sleep(5)
|
|
||||||
raise TimeoutError(f"действие {action_id} не завершилось за {timeout} секунд")
|
|
||||||
|
|
||||||
def wait_active(self, reglet_id: int, timeout: float = 600) -> dict:
|
|
||||||
"""Ждём, пока машина поднимется и получит адрес."""
|
|
||||||
deadline = time.time() + timeout
|
|
||||||
while time.time() < deadline:
|
|
||||||
info = self.get(reglet_id)
|
|
||||||
if info.get("status") == "active" and self.ip_of(info):
|
|
||||||
return info
|
|
||||||
time.sleep(5)
|
|
||||||
raise TimeoutError(f"машина {reglet_id} не поднялась за {timeout} секунд")
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def ip_of(reglet: dict) -> str:
|
def make_size(cpu: int, ram: int, disk: int, line: str = "hp") -> str:
|
||||||
"""Адрес лежит прямо в поле ip, а networks у этого провайдера пустует."""
|
"""Собрать slug тарифа с проверкой допустимых сочетаний."""
|
||||||
if reglet.get("ip"):
|
return build_size(cpu, ram, disk, line)
|
||||||
return reglet["ip"]
|
|
||||||
nets = (reglet.get("networks") or {}).get("v4") or []
|
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:
|
for n in nets:
|
||||||
if n.get("type") == "public":
|
if n.get("type") == "public":
|
||||||
return n.get("ip_address", "")
|
return n.get("ip_address", "")
|
||||||
return nets[0].get("ip_address", "") if nets else ""
|
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()
|
||||||
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")
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
"""Не дождались нужного состояния."""
|
||||||
@@ -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"])
|
||||||
Ссылка в новой задаче
Block a user