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 <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-23 18:55:19 +03:00
co-authored by Cursor
parent eb459dcccf
commit 9e9f72c1ac
6 changed files with 123 additions and 37 deletions
+5 -9
View File
@@ -83,15 +83,11 @@ 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_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 = 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:
base = resolve_public_net_base(conn, cfg.os_region_name)
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:
checks.append( checks.append(
@@ -99,7 +95,7 @@ def _check_direct_net(cfg: Config, conn, checks: list[Check]) -> None:
"прямой IP API", "прямой IP API",
True, True,
False, False,
f"public-network HTTP {response.status_code} ({pool_note}) — up покажет ошибку", f"public-network HTTP {response.status_code} ({pool}, {base}) — up покажет ошибку",
) )
) )
else: else:
@@ -108,7 +104,7 @@ def _check_direct_net(cfg: Config, conn, checks: list[Check]) -> None:
"прямой IP API", "прямой IP API",
True, True,
False, False,
f"public-network ok ({pool_note}, {public_net_base(cfg.os_region_name)})", f"public-network ok ({pool}, {base})",
) )
) )
except Exception as exc: except Exception as exc:
+3 -2
View File
@@ -25,12 +25,13 @@ SUBNET_CIDR = "192.168.77.0/24"
BOOT_VOLUME_SIZE_GB = 30 BOOT_VOLUME_SIZE_GB = 30
def connect(cfg: Config): def connect(cfg: Config, *, region_name: str | None = None):
try: try:
import openstack import openstack
except ImportError as exc: except ImportError as exc:
raise CloudError("Нет openstacksdk. Переустанови пакет: pip install -e .") from exc raise CloudError("Нет openstacksdk. Переустанови пакет: pip install -e .") from exc
region = (region_name or cfg.os_region_name).strip()
try: try:
conn = openstack.connect( conn = openstack.connect(
auth_url=cfg.os_auth_url, auth_url=cfg.os_auth_url,
@@ -39,7 +40,7 @@ def connect(cfg: Config):
password=cfg.os_password, password=cfg.os_password,
user_domain_name=cfg.os_user_domain_name, user_domain_name=cfg.os_user_domain_name,
project_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", identity_api_version="3",
interface="public", interface="public",
compute_api_version=COMPUTE_MICROVERSION, compute_api_version=COMPUTE_MICROVERSION,
+68 -13
View File
@@ -16,28 +16,46 @@ from typing import Any, Callable
import httpx import httpx
from gpu_rent.errors import CloudError 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] Log = Callable[[str], None]
PORT_DESCRIPTION = "gpu-rent" PORT_DESCRIPTION = "gpu-rent"
# Compute pool (OS_REGION_NAME) → pool where public-network API is deployed. # Keystone service types for direct public IP API (Selectel catalog).
# ru-6 has compute/multizone but direct-IP API is only on ru-2/ru-7/gis-1 (Moscow). PUBLIC_NETWORK_SERVICE_TYPES = ("public-net-api", "public-network", "public-net")
PUBLIC_NETWORK_API_BY_COMPUTE: dict[str, str] = {
"ru-6": "ru-7",
}
def public_network_pool(compute_region: str) -> str: 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() pool = (compute_region or "").strip()
if not pool: if not pool:
raise CloudError("OS_REGION_NAME пуст — нет пула для public-network API") 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: def public_net_base(compute_region: str) -> str:
api_pool = public_network_pool(compute_region) pool = public_network_pool(compute_region)
return f"https://{api_pool}.cloud.api.selcloud.ru/public-network" return f"https://{pool}.cloud.api.selcloud.ru/public-network"
def keystone_token(conn) -> str: def keystone_token(conn) -> str:
@@ -110,7 +128,7 @@ def public_net_request(
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(compute_region).rstrip("/") + path url = resolve_public_net_base(conn, compute_region).rstrip("/") + path
headers = { headers = {
"X-Auth-Token": keystone_token(conn), "X-Auth-Token": keystone_token(conn),
"Accept": "application/json", "Accept": "application/json",
@@ -118,6 +136,43 @@ 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)
@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: def get_public_port(conn, compute_region: str, port_id: str) -> PublicPort | None:
response = public_net_request( response = public_net_request(
conn, compute_region, "GET", f"/v1/public_ports/{port_id}" conn, compute_region, "GET", f"/v1/public_ports/{port_id}"
@@ -141,9 +196,8 @@ def create_public_port(
description: str = PORT_DESCRIPTION, description: str = PORT_DESCRIPTION,
log: Log | None = None, log: Log | None = None,
) -> PublicPort: ) -> PublicPort:
api_pool = public_network_pool(compute_region) if log:
if log and api_pool != (compute_region or "").strip(): log(f"public-network: {resolve_public_net_base(conn, compute_region)}")
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,
@@ -250,6 +304,7 @@ def allocate_public_port(
port = create_public_port( port = create_public_port(
conn, compute_region, security_group_ids=sgs, log=log conn, compute_region, security_group_ids=sgs, log=log
) )
wait_neutron_port(conn, port.id, log)
log(f"прямой публичный IP {port.ip_address}") log(f"прямой публичный IP {port.ip_address}")
return port return port
+2 -2
View File
@@ -12,7 +12,6 @@ from gpu_rent.cloud import (
ensure_data_volume, ensure_data_volume,
ensure_floating_ip, ensure_floating_ip,
ensure_keypair, ensure_keypair,
ensure_security_group,
guess_operator_cidr, guess_operator_cidr,
pick_existing_server, pick_existing_server,
server_status, server_status,
@@ -22,6 +21,7 @@ from gpu_rent.cloud import (
from gpu_rent.public_net import ( from gpu_rent.public_net import (
allocate_public_port, allocate_public_port,
drop_legacy_private_network, drop_legacy_private_network,
ensure_public_network_security_group,
wait_server_public_ip, wait_server_public_ip,
) )
from gpu_rent.bootstrap import run_bootstrap 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") log("SG SSH: GPU_RENT_SSH_CIDR=0.0.0.0/0")
else: else:
log("не удалось узнать твой IP — SG откроет SSH с 0.0.0.0/0") 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) drop_legacy_private_network(conn, log)
pub = allocate_public_port( pub = allocate_public_port(
conn, conn,
+24 -11
View File
@@ -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(): def test_public_network_pool_is_compute_region():
assert public_network_pool("ru-6") == "ru-7" assert public_network_pool("ru-6") == "ru-6"
def test_public_network_pool_passthrough():
assert public_network_pool("ru-7") == "ru-7" 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") == ( assert public_net_base("ru-7") == (
"https://ru-7.cloud.api.selcloud.ru/public-network" "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()
+21
View File
@@ -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")