From 9e9f72c1acb116cf17403889e17ec6bad2031a91 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 23 Aug 2026 18:55:19 +0300 Subject: [PATCH] Refactor public network API handling and enhance connectivity checks - Updated the public network handling to resolve the base URL dynamically based on Keystone catalog. - Simplified the public network pool function to directly return the compute region. - Enhanced error messages in the doctor command for better clarity on public network status. - Introduced new functions for managing security groups and ensuring visibility of public ports in Neutron. Co-authored-by: Cursor --- src/gpu_rent/doctor.py | 14 +++---- src/gpu_rent/os_client.py | 5 ++- src/gpu_rent/public_net.py | 81 +++++++++++++++++++++++++++++++------ src/gpu_rent/session.py | 4 +- tests/test_public_net.py | 35 +++++++++++----- tests/test_public_net_sg.py | 21 ++++++++++ 6 files changed, 123 insertions(+), 37 deletions(-) create mode 100644 tests/test_public_net_sg.py diff --git a/src/gpu_rent/doctor.py b/src/gpu_rent/doctor.py index f79ca10..5e6ccb9 100644 --- a/src/gpu_rent/doctor.py +++ b/src/gpu_rent/doctor.py @@ -83,15 +83,11 @@ 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_base, public_net_request, public_network_pool + from gpu_rent.public_net import public_net_base, public_net_request, resolve_public_net_base - 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 - ) + pool = cfg.os_region_name try: + base = resolve_public_net_base(conn, cfg.os_region_name) response = public_net_request(conn, cfg.os_region_name, "GET", "/v1/public_ports") if response.status_code >= 400: checks.append( @@ -99,7 +95,7 @@ def _check_direct_net(cfg: Config, conn, checks: list[Check]) -> None: "прямой IP API", True, False, - f"public-network HTTP {response.status_code} ({pool_note}) — up покажет ошибку", + f"public-network HTTP {response.status_code} ({pool}, {base}) — up покажет ошибку", ) ) else: @@ -108,7 +104,7 @@ def _check_direct_net(cfg: Config, conn, checks: list[Check]) -> None: "прямой IP API", True, False, - f"public-network ok ({pool_note}, {public_net_base(cfg.os_region_name)})", + f"public-network ok ({pool}, {base})", ) ) except Exception as exc: diff --git a/src/gpu_rent/os_client.py b/src/gpu_rent/os_client.py index 8a8aae3..f836e5a 100644 --- a/src/gpu_rent/os_client.py +++ b/src/gpu_rent/os_client.py @@ -25,12 +25,13 @@ SUBNET_CIDR = "192.168.77.0/24" BOOT_VOLUME_SIZE_GB = 30 -def connect(cfg: Config): +def connect(cfg: Config, *, region_name: str | None = None): try: import openstack except ImportError as exc: raise CloudError("Нет openstacksdk. Переустанови пакет: pip install -e .") from exc + region = (region_name or cfg.os_region_name).strip() try: conn = openstack.connect( auth_url=cfg.os_auth_url, @@ -39,7 +40,7 @@ def connect(cfg: Config): password=cfg.os_password, user_domain_name=cfg.os_user_domain_name, project_domain_name=cfg.os_user_domain_name, - region_name=cfg.os_region_name, + region_name=region, identity_api_version="3", interface="public", compute_api_version=COMPUTE_MICROVERSION, diff --git a/src/gpu_rent/public_net.py b/src/gpu_rent/public_net.py index 6a06aa6..48f13f8 100644 --- a/src/gpu_rent/public_net.py +++ b/src/gpu_rent/public_net.py @@ -16,28 +16,46 @@ from typing import Any, Callable import httpx from gpu_rent.errors import CloudError -from gpu_rent.os_client import NET_NAME, ROUTER_NAME, SUBNET_NAME +from gpu_rent.os_client import NET_NAME, ROUTER_NAME, SG_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", -} +# Keystone service types for direct public IP API (Selectel catalog). +PUBLIC_NETWORK_SERVICE_TYPES = ("public-net-api", "public-network", "public-net") def public_network_pool(compute_region: str) -> str: + """Pool where the port must exist — always the compute/OS_REGION_NAME pool.""" pool = (compute_region or "").strip() if not pool: raise CloudError("OS_REGION_NAME пуст — нет пула для public-network API") - return PUBLIC_NETWORK_API_BY_COMPUTE.get(pool, pool) + return pool + + +def resolve_public_net_base(conn, compute_region: str) -> str: + """Public-network API base URL for compute pool (Keystone catalog, then default host).""" + pool = public_network_pool(compute_region) + sess = getattr(conn, "session", None) + get_ep = getattr(sess, "get_endpoint", None) if sess is not None else None + if callable(get_ep): + for stype in PUBLIC_NETWORK_SERVICE_TYPES: + try: + url = get_ep( + service_type=stype, + interface="public", + region_name=pool, + ) + except Exception: + continue + if url: + return str(url).rstrip("/") + return f"https://{pool}.cloud.api.selcloud.ru/public-network" 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" + pool = public_network_pool(compute_region) + return f"https://{pool}.cloud.api.selcloud.ru/public-network" def keystone_token(conn) -> str: @@ -110,7 +128,7 @@ def public_net_request( json: dict[str, Any] | None = None, timeout: float = 30.0, ) -> httpx.Response: - url = public_net_base(compute_region).rstrip("/") + path + url = resolve_public_net_base(conn, compute_region).rstrip("/") + path headers = { "X-Auth-Token": keystone_token(conn), "Accept": "application/json", @@ -118,6 +136,43 @@ def public_net_request( return httpx.request(method, url, headers=headers, json=json, timeout=timeout) +@dataclass +class SecurityGroupRef: + id: str + name: str = SG_NAME + + +def ensure_public_network_security_group( + conn, + cidr: str, + compute_region: str, + log: Log, +) -> SecurityGroupRef: + """SG for direct public IP — same Neutron pool as OS_REGION_NAME.""" + from gpu_rent.cloud import ensure_security_group + + _ = compute_region # port/SG pool == compute pool + sg = ensure_security_group(conn, cidr, log) + return SecurityGroupRef(id=str(sg.id), name=SG_NAME) + + +def wait_neutron_port(conn, port_id: str, log: Log, *, timeout: float = 90.0) -> None: + """Ensure Nova/Neutron in compute pool sees the public port before create_server.""" + deadline = time.time() + timeout + while time.time() < deadline: + try: + if conn.network.get_port(port_id) is not None: + return + except Exception: + pass + time.sleep(2) + region = getattr(conn, "region_name", None) or "?" + raise CloudError( + f"порт {port_id} создан в public-network, но Neutron ({region}) его не видит — " + "порт и VM должны быть в одном пуле (OS_REGION_NAME=ru-6 → порт в ru-6, не ru-7)" + ) + + 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}" @@ -141,9 +196,8 @@ def create_public_port( 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}") + if log: + log(f"public-network: {resolve_public_net_base(conn, compute_region)}") body: dict[str, Any] = { "description": description, "admin_state_up": True, @@ -250,6 +304,7 @@ def allocate_public_port( port = create_public_port( conn, compute_region, security_group_ids=sgs, log=log ) + wait_neutron_port(conn, port.id, log) log(f"прямой публичный IP {port.ip_address}") return port diff --git a/src/gpu_rent/session.py b/src/gpu_rent/session.py index 0d55649..8acb99e 100644 --- a/src/gpu_rent/session.py +++ b/src/gpu_rent/session.py @@ -12,7 +12,6 @@ from gpu_rent.cloud import ( ensure_data_volume, ensure_floating_ip, ensure_keypair, - ensure_security_group, guess_operator_cidr, pick_existing_server, server_status, @@ -22,6 +21,7 @@ from gpu_rent.cloud import ( from gpu_rent.public_net import ( allocate_public_port, drop_legacy_private_network, + ensure_public_network_security_group, wait_server_public_ip, ) from gpu_rent.bootstrap import run_bootstrap @@ -578,7 +578,7 @@ def cmd_up( log("SG SSH: GPU_RENT_SSH_CIDR=0.0.0.0/0") else: log("не удалось узнать твой IP — SG откроет SSH с 0.0.0.0/0") - sg = ensure_security_group(conn, cidr, log) + sg = ensure_public_network_security_group(conn, cidr, cfg.os_region_name, log) drop_legacy_private_network(conn, log) pub = allocate_public_port( conn, diff --git a/tests/test_public_net.py b/tests/test_public_net.py index c2cd410..78b8951 100644 --- a/tests/test_public_net.py +++ b/tests/test_public_net.py @@ -1,21 +1,34 @@ -"""public-network API pool mapping and base URL.""" +"""public-network API pool = compute pool (OS_REGION_NAME).""" -from gpu_rent.public_net import public_net_base, public_network_pool +from unittest.mock import MagicMock + +from gpu_rent.public_net import ( + public_net_base, + public_network_pool, + resolve_public_net_base, +) -def test_public_network_pool_ru6_maps_to_ru7(): - assert public_network_pool("ru-6") == "ru-7" - - -def test_public_network_pool_passthrough(): +def test_public_network_pool_is_compute_region(): + assert public_network_pool("ru-6") == "ru-6" 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(): +def test_public_net_base_uses_compute_pool(): + assert public_net_base("ru-6") == ( + "https://ru-6.cloud.api.selcloud.ru/public-network" + ) 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" + + +def test_resolve_public_net_base_from_keystone_catalog(): + conn = MagicMock() + conn.session.get_endpoint.return_value = ( + "https://ru-6.cloud.api.selcloud.ru/public-network/v1/" ) + assert resolve_public_net_base(conn, "ru-6") == ( + "https://ru-6.cloud.api.selcloud.ru/public-network/v1" + ) + conn.session.get_endpoint.assert_called() diff --git a/tests/test_public_net_sg.py b/tests/test_public_net_sg.py new file mode 100644 index 0000000..4d5b40c --- /dev/null +++ b/tests/test_public_net_sg.py @@ -0,0 +1,21 @@ +"""SG for direct public IP uses compute-pool Neutron (openstacksdk).""" + +from unittest.mock import MagicMock + +from gpu_rent.public_net import SecurityGroupRef, ensure_public_network_security_group + + +def test_public_network_sg_uses_sdk(monkeypatch): + main_conn = MagicMock() + + def fake_ensure_sg(conn, cidr, log): + assert conn is main_conn + assert cidr == "0.0.0.0/0" + return MagicMock(id="sg-ru6") + + monkeypatch.setattr("gpu_rent.cloud.ensure_security_group", fake_ensure_sg) + + sg = ensure_public_network_security_group( + main_conn, "0.0.0.0/0", "ru-6", lambda _m: None + ) + assert sg == SecurityGroupRef(id="sg-ru6")