From eb459dcccfcbf20a95d19ffcd6d1fd4597f46984 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 23 Aug 2026 18:25:41 +0300 Subject: [PATCH] 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 --- src/gpu_rent/doctor.py | 17 ++++++-- src/gpu_rent/public_net.py | 87 +++++++++++++++++++++++++------------- tests/test_public_net.py | 21 +++++++++ 3 files changed, 93 insertions(+), 32 deletions(-) create mode 100644 tests/test_public_net.py diff --git a/src/gpu_rent/doctor.py b/src/gpu_rent/doctor.py index 030cb31..f79ca10 100644 --- a/src/gpu_rent/doctor.py +++ b/src/gpu_rent/doctor.py @@ -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}")) diff --git a/src/gpu_rent/public_net.py b/src/gpu_rent/public_net.py index cddb262..6a06aa6 100644 --- a/src/gpu_rent/public_net.py +++ b/src/gpu_rent/public_net.py @@ -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://.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://.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 "]+>", " ", 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 diff --git a/tests/test_public_net.py b/tests/test_public_net.py new file mode 100644 index 0000000..c2cd410 --- /dev/null +++ b/tests/test_public_net.py @@ -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" + )