Bump version to 0.2.0 and enhance documentation
- Updated version number in pyproject.toml and __init__.py to 0.2.0. - Revised README.md to reflect the current state of the project, including usage instructions and setup steps. - Improved CLI documentation in cli.md, adding details about new commands and their functionalities. - Enhanced the quick start section in README.md for better clarity on initial setup. - Updated local folder documentation to clarify file handling and commands. - Added a new command for listing GPU flavors and improved error handling in the CLI. - Implemented a watchdog feature in the tunnel to manage server states effectively.
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
"""Tests for hold parsing and idle-killer busy classification."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from gpu_rent.errors import GpuRentError
|
||||
from gpu_rent.hold import _parse_until
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
REMOTE_KILLER = ROOT / "src" / "gpu_rent" / "remote" / "idle_killer.py"
|
||||
|
||||
|
||||
def _load_remote():
|
||||
spec = importlib.util.spec_from_file_location("idle_killer_remote", REMOTE_KILLER)
|
||||
assert spec and spec.loader
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def test_parse_until_iso():
|
||||
ts = _parse_until("2030-01-01T00:00:00+00:00")
|
||||
assert ts == int(datetime(2030, 1, 1, tzinfo=timezone.utc).timestamp())
|
||||
|
||||
|
||||
def test_parse_until_unix():
|
||||
assert _parse_until("1700000000") == 1700000000
|
||||
|
||||
|
||||
def test_parse_until_bad():
|
||||
with pytest.raises(GpuRentError):
|
||||
_parse_until("not-a-date")
|
||||
|
||||
|
||||
def test_classify_busy_from_status(monkeypatch):
|
||||
mod = _load_remote()
|
||||
|
||||
class FakeResp:
|
||||
def __init__(self, payload):
|
||||
self._payload = payload
|
||||
|
||||
def read(self):
|
||||
import json
|
||||
|
||||
return json.dumps(self._payload).encode()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
calls = {"n": 0}
|
||||
|
||||
def fake_urlopen(req, timeout=0, context=None):
|
||||
calls["n"] += 1
|
||||
url = getattr(req, "full_url", None) or req.get_full_url()
|
||||
if "GetNewSession" in url:
|
||||
return FakeResp({"session_id": "abc"})
|
||||
return FakeResp(
|
||||
{
|
||||
"status": {"waiting_gens": 0, "live_gens": 0, "loading_models": 0},
|
||||
"backend_status": {"status": "idle"},
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setattr(mod.urllib.request, "urlopen", fake_urlopen)
|
||||
busy, detail = mod.swarm_busy("http://127.0.0.1:7801")
|
||||
assert busy is False
|
||||
assert "idle" in detail
|
||||
|
||||
|
||||
def test_classify_busy_queue(monkeypatch):
|
||||
mod = _load_remote()
|
||||
|
||||
class FakeResp:
|
||||
def __init__(self, payload):
|
||||
self._payload = payload
|
||||
|
||||
def read(self):
|
||||
import json
|
||||
|
||||
return json.dumps(self._payload).encode()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
def fake_urlopen(req, timeout=0, context=None):
|
||||
url = getattr(req, "full_url", None) or req.get_full_url()
|
||||
if "GetNewSession" in url:
|
||||
return FakeResp({"session_id": "abc"})
|
||||
return FakeResp(
|
||||
{
|
||||
"status": {"waiting_gens": 2, "live_gens": 0, "loading_models": 0},
|
||||
"backend_status": {"status": "idle"},
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setattr(mod.urllib.request, "urlopen", fake_urlopen)
|
||||
busy, detail = mod.swarm_busy("http://127.0.0.1:7801")
|
||||
assert busy is True
|
||||
assert "waiting=2" in detail
|
||||
@@ -0,0 +1,75 @@
|
||||
from gpu_rent.notify import print_mcp_snippet
|
||||
from gpu_rent.snapshot import ensure_boot_snapshot
|
||||
|
||||
|
||||
class _Cfg:
|
||||
boot_snapshot_name = "gpu-rent-boot-ok"
|
||||
swarmui_local_port = 17801
|
||||
notify_ready = True
|
||||
|
||||
|
||||
def test_ensure_boot_snapshot_skips_existing():
|
||||
class Snap:
|
||||
id = "snap1"
|
||||
name = "gpu-rent-boot-ok"
|
||||
|
||||
class Conn:
|
||||
class block_storage:
|
||||
@staticmethod
|
||||
def create_snapshot(**kwargs):
|
||||
raise AssertionError("should not create")
|
||||
|
||||
logs = []
|
||||
import gpu_rent.snapshot as snap_mod
|
||||
|
||||
# monkey via attribute
|
||||
orig = snap_mod.find_snapshot_by_name
|
||||
snap_mod.find_snapshot_by_name = lambda conn, name: Snap()
|
||||
try:
|
||||
got = ensure_boot_snapshot(Conn(), boot_volume_id="v1", cfg=_Cfg(), log=logs.append)
|
||||
assert got is not None
|
||||
assert any("уже есть" in m for m in logs)
|
||||
finally:
|
||||
snap_mod.find_snapshot_by_name = orig
|
||||
|
||||
|
||||
def test_ensure_boot_snapshot_creates(monkeypatch):
|
||||
created = {}
|
||||
|
||||
class Snap:
|
||||
def __init__(self):
|
||||
self.id = "new"
|
||||
self.status = "creating"
|
||||
|
||||
class Conn:
|
||||
class block_storage:
|
||||
@staticmethod
|
||||
def create_snapshot(**kwargs):
|
||||
created.update(kwargs)
|
||||
return Snap()
|
||||
|
||||
@staticmethod
|
||||
def get_snapshot(sid):
|
||||
s = Snap()
|
||||
s.status = "available"
|
||||
return s
|
||||
|
||||
import gpu_rent.snapshot as snap_mod
|
||||
|
||||
monkeypatch.setattr(snap_mod, "find_snapshot_by_name", lambda conn, name: None)
|
||||
monkeypatch.setattr(snap_mod.time, "sleep", lambda s: None)
|
||||
logs = []
|
||||
got = ensure_boot_snapshot(Conn(), boot_volume_id="vol-1", cfg=_Cfg(), log=logs.append)
|
||||
assert created["volume_id"] == "vol-1"
|
||||
assert created["force"] is True
|
||||
assert created["name"] == "gpu-rent-boot-ok"
|
||||
assert got.status == "available"
|
||||
|
||||
|
||||
def test_mcp_snippet(capsys):
|
||||
logs = []
|
||||
print_mcp_snippet(_Cfg(), logs.append)
|
||||
text = "\n".join(logs)
|
||||
assert "17801/mcp" in text
|
||||
assert "gpu-rent tunnel" in text
|
||||
assert "mcp.json" in text
|
||||
@@ -0,0 +1,32 @@
|
||||
import pytest
|
||||
|
||||
from gpu_rent.errors import GpuRentError
|
||||
from gpu_rent.resize import resize_data_volume
|
||||
from gpu_rent.state import SessionState, save_state
|
||||
|
||||
|
||||
class _Cfg:
|
||||
pass
|
||||
|
||||
|
||||
def test_resize_rejects_shrink(monkeypatch):
|
||||
save_state(SessionState(data_volume_id="d1", floating_ip=None))
|
||||
|
||||
class Vol:
|
||||
id = "d1"
|
||||
size = 200
|
||||
status = "in-use"
|
||||
|
||||
class Conn:
|
||||
class block_storage:
|
||||
@staticmethod
|
||||
def get_volume(vid):
|
||||
return Vol()
|
||||
|
||||
@staticmethod
|
||||
def extend_volume(*a, **k):
|
||||
raise AssertionError("no extend")
|
||||
|
||||
monkeypatch.setattr("gpu_rent.resize.connect", lambda cfg: Conn())
|
||||
with pytest.raises(GpuRentError, match="вниз нельзя"):
|
||||
resize_data_volume(_Cfg(), 100, log=lambda m: None)
|
||||
+16
-2
@@ -48,7 +48,14 @@ def test_cmd_up_does_not_create_second_gpu(monkeypatch):
|
||||
)
|
||||
monkeypatch.setattr("gpu_rent.session.wait_ssh", lambda cfg, host, timeout=420.0: None)
|
||||
monkeypatch.setattr("gpu_rent.session.run_bootstrap", lambda cfg, host, log: None)
|
||||
monkeypatch.setattr("gpu_rent.session.provision_vm", lambda cfg, host, log: None)
|
||||
monkeypatch.setattr("gpu_rent.session.provision_vm", lambda cfg, host, log, **kw: None)
|
||||
monkeypatch.setattr("gpu_rent.session.wait_backend_idle", lambda cfg, host, log, **kw: None)
|
||||
monkeypatch.setattr(
|
||||
"gpu_rent.session.ensure_boot_snapshot",
|
||||
lambda conn, boot_volume_id, cfg, log: None,
|
||||
)
|
||||
monkeypatch.setattr("gpu_rent.session.notify_ready", lambda cfg, log: None)
|
||||
monkeypatch.setattr("gpu_rent.session.print_mcp_snippet", lambda cfg, log: None)
|
||||
monkeypatch.setattr(
|
||||
"gpu_rent.session.create_gpu_server",
|
||||
lambda *a, **k: created.append("created") or Server(),
|
||||
@@ -79,7 +86,14 @@ def test_cmd_up_unshelves_expired(monkeypatch):
|
||||
)
|
||||
monkeypatch.setattr("gpu_rent.session.wait_ssh", lambda cfg, host, timeout=420.0: None)
|
||||
monkeypatch.setattr("gpu_rent.session.run_bootstrap", lambda cfg, host, log: None)
|
||||
monkeypatch.setattr("gpu_rent.session.provision_vm", lambda cfg, host, log: None)
|
||||
monkeypatch.setattr("gpu_rent.session.provision_vm", lambda cfg, host, log, **kw: None)
|
||||
monkeypatch.setattr("gpu_rent.session.wait_backend_idle", lambda cfg, host, log, **kw: None)
|
||||
monkeypatch.setattr(
|
||||
"gpu_rent.session.ensure_boot_snapshot",
|
||||
lambda conn, boot_volume_id, cfg, log: None,
|
||||
)
|
||||
monkeypatch.setattr("gpu_rent.session.notify_ready", lambda cfg, log: None)
|
||||
monkeypatch.setattr("gpu_rent.session.print_mcp_snippet", lambda cfg, log: None)
|
||||
monkeypatch.setattr(
|
||||
"gpu_rent.session.create_gpu_server",
|
||||
lambda *a, **k: created.append("created"),
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
from gpu_rent.tunnel import decide_watch
|
||||
|
||||
|
||||
def test_decide_ok_active():
|
||||
d = decide_watch("ACTIVE", tunnel_alive=True)
|
||||
assert d.kind == "ok"
|
||||
|
||||
|
||||
def test_decide_reconnect_when_tunnel_dead():
|
||||
d = decide_watch("ACTIVE", tunnel_alive=False)
|
||||
assert d.kind == "reconnect"
|
||||
|
||||
|
||||
def test_decide_unshelve_expired():
|
||||
for st in ("EXPIRED", "SHELVED", "SHELVED_OFFLOADED"):
|
||||
d = decide_watch(st, tunnel_alive=True)
|
||||
assert d.kind == "unshelve", st
|
||||
|
||||
|
||||
def test_decide_exit_error():
|
||||
d = decide_watch("ERROR", tunnel_alive=True)
|
||||
assert d.kind == "exit"
|
||||
|
||||
|
||||
def test_decide_exit_missing():
|
||||
d = decide_watch(None, tunnel_alive=False)
|
||||
assert d.kind == "exit"
|
||||
@@ -0,0 +1,36 @@
|
||||
from gpu_rent.config import load_config
|
||||
from gpu_rent.inventory import FlavorInfo
|
||||
from gpu_rent.ux import cost_and_risk_lines, format_flavor_lines
|
||||
|
||||
|
||||
def test_format_flavor_lines_marks_pick():
|
||||
ranked = [
|
||||
FlavorInfo("a", "RTX 4090 24GB", 8, 32768, False, {}, "4090-24"),
|
||||
FlavorInfo("b", "A5000", 8, 32768, False, {}, "a5000"),
|
||||
]
|
||||
picked = ranked[0]
|
||||
text = "\n".join(format_flavor_lines(ranked, picked))
|
||||
assert "выберем" in text
|
||||
assert "4090-24" in text
|
||||
|
||||
|
||||
def test_cost_lines_mention_disk_and_killer(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
monkeypatch.chdir(tmp_path)
|
||||
for key in (
|
||||
"OS_AUTH_URL",
|
||||
"OS_USER_DOMAIN_NAME",
|
||||
"OS_USERNAME",
|
||||
"OS_PASSWORD",
|
||||
"OS_PROJECT_ID",
|
||||
"OS_REGION_NAME",
|
||||
"GPU_RENT_AZ",
|
||||
):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
cfg = load_config(require_auth=False)
|
||||
lines = cost_and_risk_lines(cfg, spot=True, flavor_name="GPU 4090")
|
||||
blob = "\n".join(lines)
|
||||
assert "24/7" in blob
|
||||
assert "idle-killer" in blob
|
||||
assert "панель" in blob.lower() or "Selectel" in blob
|
||||
Reference in New Issue
Block a user