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:
+14
-3
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user