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:
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:
response = public_net_request(conn, cfg.os_region_name, "GET", "/v1/public_ports")
if response.status_code >= 400:
@@ -93,12 +99,17 @@ def _check_direct_net(cfg: Config, conn, checks: list[Check]) -> None:
"прямой IP API",
True,
False,
f"public-net HTTP {response.status_code} в {cfg.os_region_name} — up покажет ошибку",
f"public-network HTTP {response.status_code} ({pool_note}) — up покажет ошибку",
)
)
else:
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:
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):
https://<pool>.cloud.api.selcloud.ru/public-net/v1/public_ports
Docs: https://docs.selectel.ru/api/cloud-public-network/
Go SDK example URL: https://ru-3.cloud.api.selcloud.ru/public-net/
https://<api-pool>.cloud.api.selcloud.ru/public-network/v1/public_ports
Docs: https://docs.selectel.ru/api/urls/ (public-network, not public-net)
"""
from __future__ import annotations
import ipaddress
import re
import time
from dataclasses import dataclass
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]
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:
raise CloudError("OS_REGION_NAME пуст — нет пула для public-net API")
return f"https://{pool}.cloud.api.selcloud.ru/public-net"
raise CloudError("OS_REGION_NAME пуст — нет пула для public-network API")
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:
sess = getattr(conn, "session", None)
if sess is None:
raise CloudError("нет Keystone session — не вызвать public-net API")
raise CloudError("нет Keystone session — не вызвать public-network API")
token = sess.get_token()
if not token:
authorize = getattr(conn, "authorize", None)
@@ -40,7 +51,7 @@ def keystone_token(conn) -> str:
authorize()
token = sess.get_token()
if not token:
raise CloudError("Keystone не дал токен для public-net API")
raise CloudError("Keystone не дал токен для public-network API")
return str(token)
@@ -68,7 +79,7 @@ def _parse_port(raw: dict[str, Any]) -> PublicPort:
pid = str(raw.get("id") or "")
ip = str(raw.get("ip_address") or raw.get("ipAddress") or "")
if not pid or not ip:
raise CloudError(f"public-net: неполный port в ответе: {sorted(raw)}")
raise CloudError(f"public-network: неполный port в ответе: {sorted(raw)}")
return PublicPort(
id=pid,
ip_address=ip,
@@ -78,20 +89,28 @@ def _parse_port(raw: dict[str, Any]) -> PublicPort:
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())
def public_net_request(
conn,
region: str,
compute_region: str,
method: str,
path: str,
*,
json: dict[str, Any] | None = None,
timeout: float = 30.0,
) -> httpx.Response:
url = public_net_base(region).rstrip("/") + path
url = public_net_base(compute_region).rstrip("/") + path
headers = {
"X-Auth-Token": keystone_token(conn),
"Accept": "application/json",
@@ -99,26 +118,32 @@ def public_net_request(
return httpx.request(method, url, headers=headers, json=json, timeout=timeout)
def get_public_port(conn, region: str, port_id: str) -> PublicPort | None:
response = public_net_request(conn, region, "GET", f"/v1/public_ports/{port_id}")
def get_public_port(conn, compute_region: str, port_id: str) -> PublicPort | None:
response = public_net_request(
conn, compute_region, "GET", f"/v1/public_ports/{port_id}"
)
if response.status_code == 404:
return None
if response.status_code >= 400:
_raise_http("public-net GET port", response)
_raise_http("public-network GET port", response)
data = response.json()
raw = data.get("port") if isinstance(data, dict) else None
if not isinstance(raw, dict):
raise CloudError("public-net GET: нет объекта port")
raise CloudError("public-network GET: нет объекта port")
return _parse_port(raw)
def create_public_port(
conn,
region: str,
compute_region: str,
*,
security_group_ids: list[str] | None = None,
description: str = PORT_DESCRIPTION,
log: Log | None = None,
) -> 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] = {
"description": description,
"admin_state_up": True,
@@ -128,23 +153,25 @@ def create_public_port(
pid = _project_id(conn)
if 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}:
_raise_http("public-net создать прямой IP", response)
_raise_http("public-network создать прямой IP", response)
data = response.json()
raw = data.get("port") if isinstance(data, dict) else None
if not isinstance(raw, dict):
raise CloudError("public-net create: нет объекта port")
raise CloudError("public-network create: нет объекта port")
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)."""
path = f"/v1/public_ports/{port_id}"
deadline = time.time() + 90
last = 0
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
if last in {204, 200, 404}:
if last != 404:
@@ -153,8 +180,8 @@ def delete_public_port(conn, region: str, port_id: str, log: Log) -> None:
if last == 409:
time.sleep(2)
continue
_raise_http("public-net удалить прямой IP", response)
raise CloudError(f"public-net DELETE {port_id} всё ещё {last} (порт занят?)")
_raise_http("public-network удалить прямой IP", response)
raise CloudError(f"public-network DELETE {port_id} всё ещё {last} (порт занят?)")
def is_rfc1918(addr: str) -> bool:
@@ -199,7 +226,7 @@ def neutron_port_attached(conn, port_id: str) -> bool:
def allocate_public_port(
conn,
region: str,
compute_region: str,
*,
sg_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."""
if reuse and existing_id:
try:
port = get_public_port(conn, region, existing_id)
port = get_public_port(conn, compute_region, existing_id)
except CloudError as exc:
log(f"старый прямой IP {existing_id}: {exc}")
port = None
@@ -220,7 +247,9 @@ def allocate_public_port(
log(f"прямой IP {port.ip_address} уже на NIC — используем")
return port
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}")
return port