- Introduced a local read-only Debug API accessible at `http://127.0.0.1:17821` for diagnostics and agent interactions. - Updated CLI commands to include `gpu-rent debug` for launching the Debug API. - Enhanced documentation to reflect the new Debug API features and usage. - Modified configuration to include `DEBUG_LOCAL_PORT` for easier customization. - Added tests to ensure Debug API links are correctly generated in access card outputs.
171 lines
5.8 KiB
Python
171 lines
5.8 KiB
Python
"""Unit tests for localhost Debug API sidecar."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import time
|
|
import urllib.request
|
|
from dataclasses import replace
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from gpu_rent import debug_api, debug_checks
|
|
from gpu_rent.config import load_config
|
|
from gpu_rent.state import SessionState
|
|
|
|
|
|
def _auth_env(monkeypatch, tmp_path: Path) -> None:
|
|
monkeypatch.setenv("HOME", str(tmp_path))
|
|
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
|
monkeypatch.chdir(tmp_path)
|
|
for key, val in {
|
|
"OS_AUTH_URL": "https://example/v3",
|
|
"OS_USER_DOMAIN_NAME": "default",
|
|
"OS_USERNAME": "u",
|
|
"OS_PASSWORD": "secret-password",
|
|
"OS_PROJECT_ID": "proj",
|
|
"OS_REGION_NAME": "ru-7",
|
|
"GPU_RENT_AZ": "ru-7a",
|
|
"CIVITAI_API_TOKEN": "civ-secret",
|
|
"HF_TOKEN": "hf-secret",
|
|
"GIT_TOKEN": "git-secret",
|
|
"SELECTEL_API_TOKEN": "sel-secret",
|
|
"DEBUG_LOCAL_PORT": "0", # overridden per-test with free port
|
|
}.items():
|
|
monkeypatch.setenv(key, val)
|
|
|
|
|
|
def test_redact_config_hides_secrets(monkeypatch, tmp_path):
|
|
_auth_env(monkeypatch, tmp_path)
|
|
cfg = load_config(require_auth=True)
|
|
red = debug_checks.redact_config(cfg)
|
|
assert red["os_password"] == "***"
|
|
assert red["civitai_api_token"] == "***"
|
|
assert red["hf_token"] == "***"
|
|
assert red["git_token"] == "***"
|
|
assert red["selectel_api_token"] == "***"
|
|
assert red["os_username"] == "u"
|
|
assert cfg.os_password == "secret-password" # original untouched
|
|
|
|
|
|
def test_redact_state_notes(monkeypatch, tmp_path):
|
|
_auth_env(monkeypatch, tmp_path)
|
|
st = SessionState(phase="provisioning", floating_ip="1.2.3.4")
|
|
st.notes = {"idle_killer": "armed", "api_token": "leak"}
|
|
red = debug_checks.redact_state(st)
|
|
assert red["phase"] == "provisioning"
|
|
assert red["notes"]["api_token"] == "***"
|
|
assert red["notes"]["idle_killer"] == "armed"
|
|
|
|
|
|
def test_ssh_unavailable_without_fip(monkeypatch, tmp_path):
|
|
_auth_env(monkeypatch, tmp_path)
|
|
cfg = load_config(require_auth=True)
|
|
monkeypatch.setattr(debug_checks, "load_state", lambda: SessionState(phase="idle"))
|
|
out = debug_checks.collect_logs(cfg)
|
|
assert out["ok"] is False
|
|
assert out["error"] == debug_checks.SSH_UNAVAILABLE
|
|
assert out["phase"] == "idle"
|
|
|
|
|
|
def test_debug_server_routes(monkeypatch, tmp_path):
|
|
_auth_env(monkeypatch, tmp_path)
|
|
cfg = load_config(require_auth=True)
|
|
# Free ephemeral port
|
|
import socket
|
|
|
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
sock.bind(("127.0.0.1", 0))
|
|
port = sock.getsockname()[1]
|
|
sock.close()
|
|
cfg = replace(cfg, debug_local_port=port)
|
|
|
|
logs: list[str] = []
|
|
srv = debug_api.start_debug_server(cfg, port=port, log=logs.append, print_urls=True)
|
|
assert srv is not None
|
|
assert any("openapi.json" in x for x in logs)
|
|
try:
|
|
tee = srv.wrap_log(lambda m: None)
|
|
tee("жду ready backend…")
|
|
srv.set_step("wait_backend_idle")
|
|
time.sleep(0.15)
|
|
|
|
def get(path: str) -> tuple[int, dict | str]:
|
|
with urllib.request.urlopen(f"http://127.0.0.1:{port}{path}", timeout=5) as resp:
|
|
raw = resp.read().decode("utf-8")
|
|
code = getattr(resp, "status", 200)
|
|
if path == "/":
|
|
return code, raw
|
|
return code, json.loads(raw)
|
|
|
|
code, body = get("/")
|
|
assert code == 200
|
|
assert "snapshot" in body
|
|
|
|
code, data = get("/openapi.json")
|
|
assert code == 200
|
|
assert "/health" in data["paths"]
|
|
|
|
code, data = get("/progress")
|
|
assert data["ok"] is True
|
|
assert data["step"] in {"wait_backend_idle", "starting"} or "backend" in data["step"]
|
|
|
|
code, data = get("/events?since=0")
|
|
assert data["ok"] is True
|
|
assert any("ready backend" in e["msg"] for e in data["events"])
|
|
|
|
code, data = get("/config")
|
|
assert data["config"]["os_password"] == "***"
|
|
|
|
code, data = get("/state")
|
|
assert "state" in data
|
|
|
|
code, data = get("/snapshot")
|
|
assert "phase" in data
|
|
assert "checks" in data
|
|
|
|
code, data = get("/health")
|
|
assert "uptime_sec" in data
|
|
|
|
code, data = get("/logs")
|
|
assert data["error"] == debug_checks.SSH_UNAVAILABLE
|
|
|
|
with pytest.raises(Exception):
|
|
urllib.request.urlopen(f"http://127.0.0.1:{port}/nope", timeout=2)
|
|
finally:
|
|
debug_api.stop_debug_server(srv)
|
|
|
|
|
|
def test_bind_fail_returns_none(monkeypatch, tmp_path):
|
|
_auth_env(monkeypatch, tmp_path)
|
|
cfg = load_config(require_auth=True)
|
|
import socket
|
|
|
|
blocker = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
blocker.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
blocker.bind(("127.0.0.1", 0))
|
|
port = blocker.getsockname()[1]
|
|
# Keep bound so second bind fails on Windows/Linux
|
|
try:
|
|
warns: list[str] = []
|
|
# On some platforms SO_REUSEADDR allows double-bind; force fail via invalid host trick
|
|
# by patching ThreadingHTTPServer
|
|
class Boom:
|
|
def __init__(self, *a, **k):
|
|
raise OSError("Address already in use")
|
|
|
|
monkeypatch.setattr(debug_api, "ThreadingHTTPServer", Boom)
|
|
srv = debug_api.start_debug_server(cfg, port=port, log=warns.append)
|
|
assert srv is None
|
|
assert any("не стартовал" in w for w in warns)
|
|
finally:
|
|
blocker.close()
|
|
|
|
|
|
def test_config_debug_port_default(monkeypatch, tmp_path):
|
|
_auth_env(monkeypatch, tmp_path)
|
|
monkeypatch.delenv("DEBUG_LOCAL_PORT", raising=False)
|
|
cfg = load_config(require_auth=True)
|
|
assert cfg.debug_local_port == 17821
|