Refactor public network handling in GPU rental system

- Updated terminology from "public-net" to "public-network" for consistency across the codebase.
- Enhanced error handling and logging for public network API interactions.
- Introduced a new function to determine the public network pool based on the compute region.
- Adjusted API request functions to use the new public network terminology and improved error messages.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-23 18:25:41 +03:00
co-authored by Cursor
parent 8074426c43
commit eb459dcccf
3 changed files with 93 additions and 32 deletions
+14 -3
View File
@@ -83,8 +83,14 @@ def _check_balance(cfg: Config, checks: list[Check]) -> None:
def _check_direct_net(cfg: Config, conn, checks: list[Check]) -> None: def _check_direct_net(cfg: Config, conn, checks: list[Check]) -> None:
from gpu_rent.os_client import ROUTER_NAME from gpu_rent.os_client import ROUTER_NAME
from gpu_rent.public_net import public_net_request from gpu_rent.public_net import public_net_base, public_net_request, public_network_pool
api_pool = public_network_pool(cfg.os_region_name)
pool_note = (
f"{cfg.os_region_name}{api_pool}"
if api_pool != cfg.os_region_name
else cfg.os_region_name
)
try: try:
response = public_net_request(conn, cfg.os_region_name, "GET", "/v1/public_ports") response = public_net_request(conn, cfg.os_region_name, "GET", "/v1/public_ports")
if response.status_code >= 400: if response.status_code >= 400:
@@ -93,12 +99,17 @@ def _check_direct_net(cfg: Config, conn, checks: list[Check]) -> None:
"прямой IP API", "прямой IP API",
True, True,
False, False,
f"public-net HTTP {response.status_code} в {cfg.os_region_name} — up покажет ошибку", f"public-network HTTP {response.status_code} ({pool_note}) — up покажет ошибку",
) )
) )
else: else:
checks.append( checks.append(
Check("прямой IP API", True, False, f"public-net ok ({cfg.os_region_name})") Check(
"прямой IP API",
True,
False,
f"public-network ok ({pool_note}, {public_net_base(cfg.os_region_name)})",
)
) )
except Exception as exc: except Exception as exc:
checks.append(Check("прямой IP API", True, False, f"не достучались: {exc}")) checks.append(Check("прямой IP API", True, False, f"не достучались: {exc}"))
+58 -29
View File
@@ -1,14 +1,14 @@
"""Selectel Public Net API: direct public IP (no router, no floating IP). """Selectel Public Network API: direct public IP (no router, no floating IP).
Endpoint (Keystone project token): Endpoint (Keystone project token):
https://<pool>.cloud.api.selcloud.ru/public-net/v1/public_ports https://<api-pool>.cloud.api.selcloud.ru/public-network/v1/public_ports
Docs: https://docs.selectel.ru/api/cloud-public-network/ Docs: https://docs.selectel.ru/api/urls/ (public-network, not public-net)
Go SDK example URL: https://ru-3.cloud.api.selcloud.ru/public-net/
""" """
from __future__ import annotations from __future__ import annotations
import ipaddress import ipaddress
import re
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Callable from typing import Any, Callable
@@ -21,18 +21,29 @@ from gpu_rent.os_client import NET_NAME, ROUTER_NAME, SUBNET_NAME
Log = Callable[[str], None] Log = Callable[[str], None]
PORT_DESCRIPTION = "gpu-rent" PORT_DESCRIPTION = "gpu-rent"
# Compute pool (OS_REGION_NAME) → pool where public-network API is deployed.
# ru-6 has compute/multizone but direct-IP API is only on ru-2/ru-7/gis-1 (Moscow).
PUBLIC_NETWORK_API_BY_COMPUTE: dict[str, str] = {
"ru-6": "ru-7",
}
def public_net_base(region: str) -> str:
pool = (region or "").strip() def public_network_pool(compute_region: str) -> str:
pool = (compute_region or "").strip()
if not pool: if not pool:
raise CloudError("OS_REGION_NAME пуст — нет пула для public-net API") raise CloudError("OS_REGION_NAME пуст — нет пула для public-network API")
return f"https://{pool}.cloud.api.selcloud.ru/public-net" return PUBLIC_NETWORK_API_BY_COMPUTE.get(pool, pool)
def public_net_base(compute_region: str) -> str:
api_pool = public_network_pool(compute_region)
return f"https://{api_pool}.cloud.api.selcloud.ru/public-network"
def keystone_token(conn) -> str: def keystone_token(conn) -> str:
sess = getattr(conn, "session", None) sess = getattr(conn, "session", None)
if sess is None: if sess is None:
raise CloudError("нет Keystone session — не вызвать public-net API") raise CloudError("нет Keystone session — не вызвать public-network API")
token = sess.get_token() token = sess.get_token()
if not token: if not token:
authorize = getattr(conn, "authorize", None) authorize = getattr(conn, "authorize", None)
@@ -40,7 +51,7 @@ def keystone_token(conn) -> str:
authorize() authorize()
token = sess.get_token() token = sess.get_token()
if not token: if not token:
raise CloudError("Keystone не дал токен для public-net API") raise CloudError("Keystone не дал токен для public-network API")
return str(token) return str(token)
@@ -68,7 +79,7 @@ def _parse_port(raw: dict[str, Any]) -> PublicPort:
pid = str(raw.get("id") or "") pid = str(raw.get("id") or "")
ip = str(raw.get("ip_address") or raw.get("ipAddress") or "") ip = str(raw.get("ip_address") or raw.get("ipAddress") or "")
if not pid or not ip: if not pid or not ip:
raise CloudError(f"public-net: неполный port в ответе: {sorted(raw)}") raise CloudError(f"public-network: неполный port в ответе: {sorted(raw)}")
return PublicPort( return PublicPort(
id=pid, id=pid,
ip_address=ip, ip_address=ip,
@@ -78,20 +89,28 @@ def _parse_port(raw: dict[str, Any]) -> PublicPort:
def _raise_http(what: str, response: httpx.Response) -> None: def _raise_http(what: str, response: httpx.Response) -> None:
body = (response.text or "")[:300].replace("\n", " ") body = (response.text or "")[:300].replace("\n", " ").strip()
if response.status_code == 404 and "<html" in body.lower():
raise CloudError(
f"{what}: HTTP 404 — проверь public-network API для пула "
f"(docs/selectel.ru/api/urls/). Неверный URL или сервис недоступен в регионе."
)
if body:
body = re.sub(r"<[^>]+>", " ", body)
body = re.sub(r"\s+", " ", body).strip()[:200]
raise CloudError(f"{what}: HTTP {response.status_code} {body}".strip()) raise CloudError(f"{what}: HTTP {response.status_code} {body}".strip())
def public_net_request( def public_net_request(
conn, conn,
region: str, compute_region: str,
method: str, method: str,
path: str, path: str,
*, *,
json: dict[str, Any] | None = None, json: dict[str, Any] | None = None,
timeout: float = 30.0, timeout: float = 30.0,
) -> httpx.Response: ) -> httpx.Response:
url = public_net_base(region).rstrip("/") + path url = public_net_base(compute_region).rstrip("/") + path
headers = { headers = {
"X-Auth-Token": keystone_token(conn), "X-Auth-Token": keystone_token(conn),
"Accept": "application/json", "Accept": "application/json",
@@ -99,26 +118,32 @@ def public_net_request(
return httpx.request(method, url, headers=headers, json=json, timeout=timeout) return httpx.request(method, url, headers=headers, json=json, timeout=timeout)
def get_public_port(conn, region: str, port_id: str) -> PublicPort | None: def get_public_port(conn, compute_region: str, port_id: str) -> PublicPort | None:
response = public_net_request(conn, region, "GET", f"/v1/public_ports/{port_id}") response = public_net_request(
conn, compute_region, "GET", f"/v1/public_ports/{port_id}"
)
if response.status_code == 404: if response.status_code == 404:
return None return None
if response.status_code >= 400: if response.status_code >= 400:
_raise_http("public-net GET port", response) _raise_http("public-network GET port", response)
data = response.json() data = response.json()
raw = data.get("port") if isinstance(data, dict) else None raw = data.get("port") if isinstance(data, dict) else None
if not isinstance(raw, dict): if not isinstance(raw, dict):
raise CloudError("public-net GET: нет объекта port") raise CloudError("public-network GET: нет объекта port")
return _parse_port(raw) return _parse_port(raw)
def create_public_port( def create_public_port(
conn, conn,
region: str, compute_region: str,
*, *,
security_group_ids: list[str] | None = None, security_group_ids: list[str] | None = None,
description: str = PORT_DESCRIPTION, description: str = PORT_DESCRIPTION,
log: Log | None = None,
) -> PublicPort: ) -> PublicPort:
api_pool = public_network_pool(compute_region)
if log and api_pool != (compute_region or "").strip():
log(f"public-network API: {compute_region}{api_pool}")
body: dict[str, Any] = { body: dict[str, Any] = {
"description": description, "description": description,
"admin_state_up": True, "admin_state_up": True,
@@ -128,23 +153,25 @@ def create_public_port(
pid = _project_id(conn) pid = _project_id(conn)
if pid: if pid:
body["project_id"] = pid body["project_id"] = pid
response = public_net_request(conn, region, "POST", "/v1/public_ports", json=body) response = public_net_request(
conn, compute_region, "POST", "/v1/public_ports", json=body
)
if response.status_code not in {200, 201}: if response.status_code not in {200, 201}:
_raise_http("public-net создать прямой IP", response) _raise_http("public-network создать прямой IP", response)
data = response.json() data = response.json()
raw = data.get("port") if isinstance(data, dict) else None raw = data.get("port") if isinstance(data, dict) else None
if not isinstance(raw, dict): if not isinstance(raw, dict):
raise CloudError("public-net create: нет объекта port") raise CloudError("public-network create: нет объекта port")
return _parse_port(raw) return _parse_port(raw)
def delete_public_port(conn, region: str, port_id: str, log: Log) -> None: def delete_public_port(conn, compute_region: str, port_id: str, log: Log) -> None:
"""Detach-safe: retry 409 until Nova dropped the NIC (or 404).""" """Detach-safe: retry 409 until Nova dropped the NIC (or 404)."""
path = f"/v1/public_ports/{port_id}" path = f"/v1/public_ports/{port_id}"
deadline = time.time() + 90 deadline = time.time() + 90
last = 0 last = 0
while time.time() < deadline: while time.time() < deadline:
response = public_net_request(conn, region, "DELETE", path) response = public_net_request(conn, compute_region, "DELETE", path)
last = response.status_code last = response.status_code
if last in {204, 200, 404}: if last in {204, 200, 404}:
if last != 404: if last != 404:
@@ -153,8 +180,8 @@ def delete_public_port(conn, region: str, port_id: str, log: Log) -> None:
if last == 409: if last == 409:
time.sleep(2) time.sleep(2)
continue continue
_raise_http("public-net удалить прямой IP", response) _raise_http("public-network удалить прямой IP", response)
raise CloudError(f"public-net DELETE {port_id} всё ещё {last} (порт занят?)") raise CloudError(f"public-network DELETE {port_id} всё ещё {last} (порт занят?)")
def is_rfc1918(addr: str) -> bool: def is_rfc1918(addr: str) -> bool:
@@ -199,7 +226,7 @@ def neutron_port_attached(conn, port_id: str) -> bool:
def allocate_public_port( def allocate_public_port(
conn, conn,
region: str, compute_region: str,
*, *,
sg_id: str | None, sg_id: str | None,
existing_id: str | None, existing_id: str | None,
@@ -209,7 +236,7 @@ def allocate_public_port(
"""Create or reuse a direct public IP port. Not a Neutron floating IP.""" """Create or reuse a direct public IP port. Not a Neutron floating IP."""
if reuse and existing_id: if reuse and existing_id:
try: try:
port = get_public_port(conn, region, existing_id) port = get_public_port(conn, compute_region, existing_id)
except CloudError as exc: except CloudError as exc:
log(f"старый прямой IP {existing_id}: {exc}") log(f"старый прямой IP {existing_id}: {exc}")
port = None port = None
@@ -220,7 +247,9 @@ def allocate_public_port(
log(f"прямой IP {port.ip_address} уже на NIC — используем") log(f"прямой IP {port.ip_address} уже на NIC — используем")
return port return port
sgs = [sg_id] if sg_id else None sgs = [sg_id] if sg_id else None
port = create_public_port(conn, region, security_group_ids=sgs) port = create_public_port(
conn, compute_region, security_group_ids=sgs, log=log
)
log(f"прямой публичный IP {port.ip_address}") log(f"прямой публичный IP {port.ip_address}")
return port return port
+21
View File
@@ -0,0 +1,21 @@
"""public-network API pool mapping and base URL."""
from gpu_rent.public_net import public_net_base, public_network_pool
def test_public_network_pool_ru6_maps_to_ru7():
assert public_network_pool("ru-6") == "ru-7"
def test_public_network_pool_passthrough():
assert public_network_pool("ru-7") == "ru-7"
assert public_network_pool("ru-3") == "ru-3"
def test_public_net_base_uses_public_network_path():
assert public_net_base("ru-7") == (
"https://ru-7.cloud.api.selcloud.ru/public-network"
)
assert public_net_base("ru-6") == (
"https://ru-7.cloud.api.selcloud.ru/public-network"
)