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