Files
gpu-rent/tests/test_debug_api.py
T
Leonid Pershin 5847eaab3f Add read-only Debug API support and update documentation
- 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.
2026-08-23 06:11:20 +03:00

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