Refactor GPU rental network handling and enhance IP management
- Renamed "FIP" to "IP" in the status output for clarity. - Updated `create_gpu_server` to accept `port_id` as an optional parameter, allowing for more flexible network configurations. - Introduced `delete_access_ip` function to manage direct public ports or floating IPs more effectively. - Enhanced session management to prioritize direct public IPs over floating IPs, improving connection reliability. - Added checks for direct network availability in the doctor command to ensure proper network functionality. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+1
-1
@@ -297,7 +297,7 @@ def status() -> None:
|
||||
table.add_row("flavor", state.flavor_name or state.flavor_id or "—")
|
||||
table.add_row("boot volume", state.boot_volume_id or "—")
|
||||
table.add_row("data volume", state.data_volume_id or "—")
|
||||
table.add_row("FIP", state.floating_ip or "—")
|
||||
table.add_row("IP", state.floating_ip or "—")
|
||||
end = preempt_window_end(state)
|
||||
if end:
|
||||
left = end - datetime.now(timezone.utc)
|
||||
|
||||
+38
-2
@@ -458,7 +458,8 @@ def create_gpu_server(
|
||||
conn,
|
||||
*,
|
||||
flavor_id: str,
|
||||
net_id: str,
|
||||
net_id: str | None = None,
|
||||
port_id: str | None = None,
|
||||
sg_name: str,
|
||||
boot_volume_id: str,
|
||||
data_volume_id: str,
|
||||
@@ -487,10 +488,16 @@ def create_gpu_server(
|
||||
tags = [RESOURCE_TAG]
|
||||
if spot:
|
||||
tags.append(PREEMPTIBLE_TAG)
|
||||
if port_id:
|
||||
nics: list[dict[str, str]] = [{"port": port_id}]
|
||||
elif net_id:
|
||||
nics = [{"uuid": net_id}]
|
||||
else:
|
||||
raise CloudError("create_gpu_server: нужен port_id (прямой IP) или net_id")
|
||||
base: dict[str, Any] = {
|
||||
"name": SERVER_NAME,
|
||||
"flavor_id": flavor_id,
|
||||
"networks": [{"uuid": net_id}],
|
||||
"networks": nics,
|
||||
"key_name": KEYPAIR_NAME,
|
||||
"availability_zone": az,
|
||||
"block_device_mapping_v2": bdm,
|
||||
@@ -607,6 +614,28 @@ def delete_floating_ip(conn, fip_id: str | None, address: str | None, log: Calla
|
||||
return
|
||||
|
||||
|
||||
def delete_access_ip(
|
||||
conn,
|
||||
region: str,
|
||||
port_or_fip_id: str | None,
|
||||
address: str | None,
|
||||
log: Callable[[str], None],
|
||||
) -> None:
|
||||
"""Drop direct public port (preferred) or leftover Neutron floating IP."""
|
||||
if port_or_fip_id and region:
|
||||
try:
|
||||
from gpu_rent.public_net import delete_public_port, get_public_port
|
||||
|
||||
if get_public_port(conn, region, port_or_fip_id) is not None:
|
||||
delete_public_port(conn, region, port_or_fip_id, log)
|
||||
return
|
||||
except CloudError as exc:
|
||||
log(f"прямой IP: {exc} — пробуем FIP")
|
||||
except Exception as exc:
|
||||
log(f"прямой IP не удалился: {exc} — пробуем FIP")
|
||||
delete_floating_ip(conn, port_or_fip_id, address, log)
|
||||
|
||||
|
||||
def delete_server(conn, server, log: Callable[[str], None]) -> None:
|
||||
sid = _oid(server)
|
||||
log(f"удаляем compute {sid} (диски оставляем)")
|
||||
@@ -648,6 +677,13 @@ def server_floating_ip(server: Any) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def server_access_ip(server: Any) -> str | None:
|
||||
"""SSH target: floating IP (legacy) or fixed public IPv4 (direct port)."""
|
||||
from gpu_rent.public_net import server_public_ip
|
||||
|
||||
return server_public_ip(server)
|
||||
|
||||
|
||||
def pick_existing_server(conn) -> Any | None:
|
||||
found = find_tagged_servers(conn)
|
||||
if len(found) > 1:
|
||||
|
||||
@@ -81,6 +81,42 @@ def _check_balance(cfg: Config, checks: list[Check]) -> None:
|
||||
checks.append(Check("баланс ₽", True, False, f"{rub:.0f} ₽"))
|
||||
|
||||
|
||||
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
|
||||
|
||||
try:
|
||||
response = public_net_request(conn, cfg.os_region_name, "GET", "/v1/public_ports")
|
||||
if response.status_code >= 400:
|
||||
checks.append(
|
||||
Check(
|
||||
"прямой IP API",
|
||||
True,
|
||||
False,
|
||||
f"public-net HTTP {response.status_code} в {cfg.os_region_name} — up покажет ошибку",
|
||||
)
|
||||
)
|
||||
else:
|
||||
checks.append(
|
||||
Check("прямой IP API", True, False, f"public-net ok ({cfg.os_region_name})")
|
||||
)
|
||||
except Exception as exc:
|
||||
checks.append(Check("прямой IP API", True, False, f"не достучались: {exc}"))
|
||||
try:
|
||||
router = conn.network.find_router(ROUTER_NAME)
|
||||
except Exception:
|
||||
router = None
|
||||
if router:
|
||||
checks.append(
|
||||
Check(
|
||||
"роутер gpu-rent",
|
||||
True,
|
||||
False,
|
||||
"ещё жив — 200 ₽/мес; up/stop удалит (SSH идёт на прямой публичный IP)",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def run_doctor() -> list[Check]:
|
||||
checks: list[Check] = []
|
||||
runtime_dir().mkdir(parents=True, exist_ok=True)
|
||||
@@ -129,6 +165,7 @@ def run_doctor() -> list[Check]:
|
||||
try:
|
||||
conn = connect(cfg)
|
||||
checks.append(Check("Keystone", True, True, "IAM-токен выдан (TTL ~24 ч, sdk обновит)"))
|
||||
_check_direct_net(cfg, conn, checks)
|
||||
except CloudError as exc:
|
||||
checks.append(Check("Keystone", False, True, str(exc)))
|
||||
_local_manifests(cfg, checks)
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
"""Selectel Public Net API: direct public IP (no router, no floating IP).
|
||||
|
||||
Endpoint (Keystone project token):
|
||||
https://<pool>.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/
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
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
|
||||
|
||||
Log = Callable[[str], None]
|
||||
PORT_DESCRIPTION = "gpu-rent"
|
||||
|
||||
|
||||
def public_net_base(region: str) -> str:
|
||||
pool = (region or "").strip()
|
||||
if not pool:
|
||||
raise CloudError("OS_REGION_NAME пуст — нет пула для public-net API")
|
||||
return f"https://{pool}.cloud.api.selcloud.ru/public-net"
|
||||
|
||||
|
||||
def keystone_token(conn) -> str:
|
||||
sess = getattr(conn, "session", None)
|
||||
if sess is None:
|
||||
raise CloudError("нет Keystone session — не вызвать public-net API")
|
||||
token = sess.get_token()
|
||||
if not token:
|
||||
authorize = getattr(conn, "authorize", None)
|
||||
if callable(authorize):
|
||||
authorize()
|
||||
token = sess.get_token()
|
||||
if not token:
|
||||
raise CloudError("Keystone не дал токен для public-net API")
|
||||
return str(token)
|
||||
|
||||
|
||||
def _project_id(conn) -> str | None:
|
||||
pid = getattr(conn, "current_project_id", None)
|
||||
if pid:
|
||||
return str(pid)
|
||||
sess = getattr(conn, "session", None)
|
||||
get = getattr(sess, "get_project_id", None) if sess is not None else None
|
||||
if callable(get):
|
||||
got = get()
|
||||
return str(got) if got else None
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class PublicPort:
|
||||
id: str
|
||||
ip_address: str
|
||||
network_id: str = ""
|
||||
gateway: str | None = None
|
||||
|
||||
|
||||
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)}")
|
||||
return PublicPort(
|
||||
id=pid,
|
||||
ip_address=ip,
|
||||
network_id=str(raw.get("network_id") or raw.get("networkId") or ""),
|
||||
gateway=str(raw.get("gateway") or "") or None,
|
||||
)
|
||||
|
||||
|
||||
def _raise_http(what: str, response: httpx.Response) -> None:
|
||||
body = (response.text or "")[:300].replace("\n", " ")
|
||||
raise CloudError(f"{what}: HTTP {response.status_code} {body}".strip())
|
||||
|
||||
|
||||
def public_net_request(
|
||||
conn,
|
||||
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
|
||||
headers = {
|
||||
"X-Auth-Token": keystone_token(conn),
|
||||
"Accept": "application/json",
|
||||
}
|
||||
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}")
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
if response.status_code >= 400:
|
||||
_raise_http("public-net 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")
|
||||
return _parse_port(raw)
|
||||
|
||||
|
||||
def create_public_port(
|
||||
conn,
|
||||
region: str,
|
||||
*,
|
||||
security_group_ids: list[str] | None = None,
|
||||
description: str = PORT_DESCRIPTION,
|
||||
) -> PublicPort:
|
||||
body: dict[str, Any] = {
|
||||
"description": description,
|
||||
"admin_state_up": True,
|
||||
}
|
||||
if security_group_ids:
|
||||
body["security_group_ids"] = security_group_ids
|
||||
pid = _project_id(conn)
|
||||
if pid:
|
||||
body["project_id"] = pid
|
||||
response = public_net_request(conn, region, "POST", "/v1/public_ports", json=body)
|
||||
if response.status_code not in {200, 201}:
|
||||
_raise_http("public-net создать прямой 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")
|
||||
return _parse_port(raw)
|
||||
|
||||
|
||||
def delete_public_port(conn, 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)
|
||||
last = response.status_code
|
||||
if last in {204, 200, 404}:
|
||||
if last != 404:
|
||||
log(f"удалён прямой публичный IP {port_id}")
|
||||
return
|
||||
if last == 409:
|
||||
time.sleep(2)
|
||||
continue
|
||||
_raise_http("public-net удалить прямой IP", response)
|
||||
raise CloudError(f"public-net DELETE {port_id} всё ещё {last} (порт занят?)")
|
||||
|
||||
|
||||
def is_rfc1918(addr: str) -> bool:
|
||||
try:
|
||||
ip = ipaddress.ip_address(addr)
|
||||
except ValueError:
|
||||
return False
|
||||
return bool(ip.version == 4 and ip.is_private)
|
||||
|
||||
|
||||
def server_public_ip(server: Any) -> str | None:
|
||||
"""Floating IP or fixed public IPv4 (direct public port)."""
|
||||
addrs = getattr(server, "addresses", None) or {}
|
||||
if not isinstance(addrs, dict):
|
||||
return None
|
||||
floating: str | None = None
|
||||
fixed_public: str | None = None
|
||||
for nets in addrs.values():
|
||||
if not isinstance(nets, list):
|
||||
continue
|
||||
for item in nets:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
addr = str(item.get("addr") or "")
|
||||
if not addr:
|
||||
continue
|
||||
if item.get("OS-EXT-IPS:type") == "floating":
|
||||
floating = addr
|
||||
elif not is_rfc1918(addr) and ":" not in addr:
|
||||
fixed_public = addr
|
||||
return floating or fixed_public
|
||||
|
||||
|
||||
def neutron_port_attached(conn, port_id: str) -> bool:
|
||||
try:
|
||||
port = conn.network.get_port(port_id)
|
||||
except Exception:
|
||||
return False
|
||||
device = getattr(port, "device_id", None) or ""
|
||||
return bool(str(device).strip())
|
||||
|
||||
|
||||
def allocate_public_port(
|
||||
conn,
|
||||
region: str,
|
||||
*,
|
||||
sg_id: str | None,
|
||||
existing_id: str | None,
|
||||
log: Log,
|
||||
reuse: bool,
|
||||
) -> PublicPort:
|
||||
"""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)
|
||||
except CloudError as exc:
|
||||
log(f"старый прямой IP {existing_id}: {exc}")
|
||||
port = None
|
||||
if port and not neutron_port_attached(conn, port.id):
|
||||
log(f"прямой публичный IP {port.ip_address} (reuse)")
|
||||
return port
|
||||
if port and neutron_port_attached(conn, port.id):
|
||||
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)
|
||||
log(f"прямой публичный IP {port.ip_address}")
|
||||
return port
|
||||
|
||||
|
||||
def wait_server_public_ip(conn, server, log: Log, *, timeout: float = 180.0) -> str:
|
||||
ip = server_public_ip(server)
|
||||
if ip:
|
||||
return ip
|
||||
deadline = time.time() + timeout
|
||||
sid = getattr(server, "id", server)
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
fresh = conn.compute.get_server(sid)
|
||||
except Exception:
|
||||
fresh = server
|
||||
ip = server_public_ip(fresh)
|
||||
if ip:
|
||||
return ip
|
||||
time.sleep(4)
|
||||
raise CloudError("сервер ACTIVE, но публичный IPv4 не появился (прямой IP / FIP)")
|
||||
|
||||
|
||||
def drop_legacy_private_network(conn, log: Log) -> None:
|
||||
"""Remove leftover gpu-rent router+net from the FIP era (200 ₽/мес)."""
|
||||
router = None
|
||||
try:
|
||||
router = conn.network.find_router(ROUTER_NAME)
|
||||
except Exception:
|
||||
router = None
|
||||
if router:
|
||||
try:
|
||||
for port in conn.network.ports(device_id=router.id):
|
||||
try:
|
||||
conn.network.remove_interface_from_router(router, port_id=port.id)
|
||||
except Exception as exc:
|
||||
log(f"router interface {getattr(port, 'id', '?')}: {exc}")
|
||||
conn.network.delete_router(router, ignore_missing=True)
|
||||
log("удалён legacy-роутер gpu-rent (больше не нужен, 200 ₽/мес)")
|
||||
except Exception as exc:
|
||||
log(f"legacy-роутер gpu-rent не удалился: {exc}")
|
||||
|
||||
net = None
|
||||
try:
|
||||
net = conn.network.find_network(NET_NAME)
|
||||
except Exception:
|
||||
net = None
|
||||
if not net:
|
||||
return
|
||||
try:
|
||||
leftover = []
|
||||
for port in conn.network.ports(network_id=net.id):
|
||||
owner = getattr(port, "device_owner", "") or ""
|
||||
if owner.startswith("network:"):
|
||||
continue
|
||||
leftover.append(port)
|
||||
if leftover:
|
||||
log(f"сеть {NET_NAME} ещё с {len(leftover)} портом(ами) — не трогаем")
|
||||
return
|
||||
for subnet in conn.network.subnets(network_id=net.id):
|
||||
try:
|
||||
conn.network.delete_subnet(subnet, ignore_missing=True)
|
||||
except Exception:
|
||||
pass
|
||||
conn.network.delete_network(net, ignore_missing=True)
|
||||
log(f"удалена legacy-сеть {NET_NAME} / {SUBNET_NAME}")
|
||||
except Exception as exc:
|
||||
log(f"legacy-сеть {NET_NAME} не удалилась: {exc}")
|
||||
+40
-13
@@ -6,13 +6,12 @@ from collections.abc import Callable
|
||||
|
||||
from gpu_rent.cloud import (
|
||||
create_gpu_server,
|
||||
delete_floating_ip,
|
||||
delete_access_ip,
|
||||
delete_server,
|
||||
ensure_boot_volume,
|
||||
ensure_data_volume,
|
||||
ensure_floating_ip,
|
||||
ensure_keypair,
|
||||
ensure_network,
|
||||
ensure_security_group,
|
||||
guess_operator_cidr,
|
||||
pick_existing_server,
|
||||
@@ -20,6 +19,11 @@ from gpu_rent.cloud import (
|
||||
unshelve,
|
||||
wait_volume,
|
||||
)
|
||||
from gpu_rent.public_net import (
|
||||
allocate_public_port,
|
||||
drop_legacy_private_network,
|
||||
wait_server_public_ip,
|
||||
)
|
||||
from gpu_rent.bootstrap import run_bootstrap
|
||||
from gpu_rent.provision import (
|
||||
ensure_data_binds,
|
||||
@@ -94,13 +98,19 @@ def _bind_access(
|
||||
phases: PhaseTimes | None = None,
|
||||
) -> SessionState:
|
||||
clock = phases or PhaseTimes()
|
||||
ip, fip_id = ensure_floating_ip(
|
||||
conn, server, state.floating_ip_id, state.floating_ip, log
|
||||
)
|
||||
state.floating_ip = ip
|
||||
if fip_id:
|
||||
state.floating_ip_id = fip_id
|
||||
save_state(state)
|
||||
try:
|
||||
ip = wait_server_public_ip(conn, server, log)
|
||||
state.floating_ip = ip
|
||||
save_state(state)
|
||||
except CloudError:
|
||||
ip, fip_id = ensure_floating_ip(
|
||||
conn, server, state.floating_ip_id, state.floating_ip, log
|
||||
)
|
||||
state.floating_ip = ip
|
||||
if fip_id:
|
||||
state.floating_ip_id = fip_id
|
||||
save_state(state)
|
||||
log("legacy FIP — следующий stop снимет, up поднимет прямой IP")
|
||||
wait_ssh(cfg, ip, log=log)
|
||||
clock.mark("SSH", log)
|
||||
log(f"SSH {cfg.ssh_user}@{ip}")
|
||||
@@ -560,7 +570,6 @@ def cmd_up(
|
||||
public_key = pub.read_text(encoding="utf-8")
|
||||
ensure_keypair(conn, public_key, log)
|
||||
|
||||
net, _subnet = ensure_network(conn, log)
|
||||
cidr = guess_operator_cidr()
|
||||
if cidr == "0.0.0.0/0":
|
||||
import os
|
||||
@@ -570,6 +579,18 @@ def cmd_up(
|
||||
else:
|
||||
log("не удалось узнать твой IP — SG откроет SSH с 0.0.0.0/0")
|
||||
sg = ensure_security_group(conn, cidr, log)
|
||||
drop_legacy_private_network(conn, log)
|
||||
pub = allocate_public_port(
|
||||
conn,
|
||||
cfg.os_region_name,
|
||||
sg_id=sg.id,
|
||||
existing_id=state.floating_ip_id if cfg.keep_floating_ip else None,
|
||||
log=log,
|
||||
reuse=cfg.keep_floating_ip,
|
||||
)
|
||||
state.floating_ip = pub.ip_address
|
||||
state.floating_ip_id = pub.id
|
||||
state.network_id = pub.network_id or None
|
||||
|
||||
boot = ensure_boot_volume(
|
||||
conn,
|
||||
@@ -595,7 +616,6 @@ def cmd_up(
|
||||
state.boot_volume_id = boot.id
|
||||
state.data_volume_id = data.id
|
||||
state.image_id = image.id
|
||||
state.network_id = net.id
|
||||
state.security_group_id = sg.id
|
||||
state.availability_zone = cfg.gpu_rent_az
|
||||
state.spot = spot
|
||||
@@ -605,7 +625,7 @@ def cmd_up(
|
||||
server = create_gpu_server(
|
||||
conn,
|
||||
flavor_id=picked.id,
|
||||
net_id=net.id,
|
||||
port_id=pub.id,
|
||||
sg_name=SG_NAME,
|
||||
boot_volume_id=boot.id,
|
||||
data_volume_id=data.id,
|
||||
@@ -663,9 +683,16 @@ def cmd_stop(
|
||||
log(f"revoke idle-killer app cred: {exc}")
|
||||
|
||||
if not cfg.keep_floating_ip:
|
||||
delete_floating_ip(conn, state.floating_ip_id, state.floating_ip, log)
|
||||
delete_access_ip(
|
||||
conn,
|
||||
cfg.os_region_name,
|
||||
state.floating_ip_id,
|
||||
state.floating_ip,
|
||||
log,
|
||||
)
|
||||
state.floating_ip = None
|
||||
state.floating_ip_id = None
|
||||
drop_legacy_private_network(conn, log)
|
||||
|
||||
if destroy_disks:
|
||||
for vid in (state.data_volume_id, state.boot_volume_id):
|
||||
|
||||
+11
-6
@@ -18,6 +18,7 @@ from gpu_rent.cloud import (
|
||||
server_status,
|
||||
unshelve,
|
||||
)
|
||||
from gpu_rent.public_net import wait_server_public_ip
|
||||
from gpu_rent.config import Config
|
||||
from gpu_rent.errors import CloudError, GpuRentError
|
||||
from gpu_rent.llm_runtime import normalize_runtime
|
||||
@@ -207,12 +208,16 @@ def _recover_unshelve(cfg: Config, log: Log) -> str:
|
||||
raise CloudError(f"после preempt статус {status} — не unshelve")
|
||||
|
||||
state = load_state()
|
||||
ip, fip_id = ensure_floating_ip(
|
||||
conn, server, state.floating_ip_id, state.floating_ip, log
|
||||
)
|
||||
state.floating_ip = ip
|
||||
if fip_id:
|
||||
state.floating_ip_id = fip_id
|
||||
try:
|
||||
ip = wait_server_public_ip(conn, server, log)
|
||||
state.floating_ip = ip
|
||||
except CloudError:
|
||||
ip, fip_id = ensure_floating_ip(
|
||||
conn, server, state.floating_ip_id, state.floating_ip, log
|
||||
)
|
||||
state.floating_ip = ip
|
||||
if fip_id:
|
||||
state.floating_ip_id = fip_id
|
||||
state.server_id = server.id
|
||||
state.unshelved_at = utc_now()
|
||||
state.phase = "ready_tunneled"
|
||||
|
||||
Reference in New Issue
Block a user