310 lines
10 KiB
Python
310 lines
10 KiB
Python
"""US-004 integration tests: node self-configuring startup sequence."""
|
|
|
|
import json
|
|
import sys
|
|
import types
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from meshnet_node.downloader import download_shard
|
|
from meshnet_node.hardware import detect_hardware
|
|
from meshnet_node.startup import _probationary_status_line, run_startup
|
|
from meshnet_node.wallet import _b58encode, load_or_create_wallet
|
|
from meshnet_contracts import LocalSolanaContracts
|
|
from meshnet_tracker.server import TrackerServer
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Unit tests — hardware, wallet, downloader
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_detect_hardware_returns_valid_profile():
|
|
"""Hardware detection always returns a dict with required keys."""
|
|
hw = detect_hardware()
|
|
assert hw["device"] in {"cuda", "cpu"}
|
|
assert isinstance(hw.get("vram_mb"), int)
|
|
if hw["device"] == "cpu":
|
|
assert hw["gpu_name"] is None
|
|
assert hw["vram_mb"] == 0
|
|
else:
|
|
assert isinstance(hw["gpu_name"], str) and hw["gpu_name"]
|
|
assert hw["vram_mb"] > 0
|
|
|
|
|
|
def test_wallet_generates_new_keypair(tmp_path):
|
|
"""A new wallet is created when none exists, saved to disk."""
|
|
wallet_file = tmp_path / "wallet.json"
|
|
assert not wallet_file.exists()
|
|
|
|
secret, public, address = load_or_create_wallet(path=wallet_file)
|
|
|
|
assert wallet_file.exists()
|
|
assert wallet_file.stat().st_mode & 0o777 == 0o600
|
|
assert len(secret) == 32
|
|
assert len(public) == 32
|
|
assert len(address) > 20 # base58-encoded 32 bytes is 43-44 chars typically
|
|
# Solana addresses are base58 — no '0', 'O', 'I', 'l'
|
|
for ch in "0OIl":
|
|
assert ch not in address, f"Invalid base58 char {ch!r} in address {address!r}"
|
|
|
|
|
|
def test_wallet_loads_existing_keypair(tmp_path):
|
|
"""Loading the same wallet file twice returns identical keys and address."""
|
|
wallet_file = tmp_path / "wallet.json"
|
|
|
|
secret1, public1, address1 = load_or_create_wallet(path=wallet_file)
|
|
secret2, public2, address2 = load_or_create_wallet(path=wallet_file)
|
|
|
|
assert secret1 == secret2
|
|
assert public1 == public2
|
|
assert address1 == address2
|
|
|
|
|
|
def test_wallet_load_repairs_insecure_permissions(tmp_path):
|
|
"""Existing private key files are tightened to owner-only permissions."""
|
|
wallet_file = tmp_path / "wallet.json"
|
|
load_or_create_wallet(path=wallet_file)
|
|
wallet_file.chmod(0o644)
|
|
|
|
load_or_create_wallet(path=wallet_file)
|
|
|
|
assert wallet_file.stat().st_mode & 0o777 == 0o600
|
|
|
|
|
|
def test_base58_counts_only_leading_zero_bytes():
|
|
"""Zero bytes inside the public key do not become extra base58 leading ones."""
|
|
assert _b58encode(bytes([0, 1, 0])) == "15R"
|
|
|
|
|
|
def test_download_shard_stub_creates_cache(tmp_path):
|
|
"""Stub-model shard creates a local cache file without network access."""
|
|
shard_dir = download_shard("stub-model", 0, 31, cache_dir=tmp_path)
|
|
assert shard_dir.exists()
|
|
weights = shard_dir / "weights.json"
|
|
assert weights.exists()
|
|
data = json.loads(weights.read_text())
|
|
assert data["stub"] is True
|
|
assert data["shard_start"] == 0
|
|
assert data["shard_end"] == 31
|
|
|
|
|
|
def test_download_shard_uses_huggingface_when_repo_is_assigned(tmp_path, monkeypatch):
|
|
"""Non-stub shards use the HuggingFace snapshot_download path."""
|
|
calls = []
|
|
|
|
def fake_snapshot_download(repo_id, cache_dir, local_dir):
|
|
calls.append({"repo_id": repo_id, "cache_dir": cache_dir, "local_dir": local_dir})
|
|
Path(local_dir).mkdir(parents=True, exist_ok=True)
|
|
return local_dir
|
|
|
|
monkeypatch.setitem(
|
|
sys.modules,
|
|
"huggingface_hub",
|
|
types.SimpleNamespace(snapshot_download=fake_snapshot_download),
|
|
)
|
|
|
|
shard_dir = download_shard(
|
|
"tiny-llama",
|
|
0,
|
|
3,
|
|
cache_dir=tmp_path,
|
|
hf_repo="org/tiny-llama-shards",
|
|
progress=False,
|
|
)
|
|
|
|
assert shard_dir == tmp_path / "tiny-llama" / "layers_0-3"
|
|
assert calls == [{
|
|
"repo_id": "org/tiny-llama-shards",
|
|
"cache_dir": str(tmp_path),
|
|
"local_dir": str(shard_dir),
|
|
}]
|
|
|
|
|
|
def test_download_shard_stub_idempotent(tmp_path):
|
|
"""Calling download_shard twice does not error — file already exists."""
|
|
download_shard("stub-model", 0, 31, cache_dir=tmp_path, progress=False)
|
|
shard_dir = download_shard("stub-model", 0, 31, cache_dir=tmp_path, progress=False)
|
|
assert shard_dir.exists()
|
|
|
|
|
|
def test_startup_formats_probationary_jobs_remaining():
|
|
"""Startup status tells a node how many free jobs remain before earning."""
|
|
contracts = LocalSolanaContracts(probationary_job_count=50)
|
|
for _ in range(12):
|
|
contracts.registry.record_completed_job("node-wallet-a")
|
|
|
|
line = _probationary_status_line(contracts, "node-wallet-a")
|
|
|
|
assert line == "Probationary period: 38 jobs remaining before earning"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tracker assign endpoint
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _get_json(url: str) -> dict:
|
|
with urllib.request.urlopen(url, timeout=5) as r:
|
|
return json.loads(r.read())
|
|
|
|
|
|
def test_tracker_assign_returns_shard_for_empty_registry():
|
|
"""Tracker assigns the full layer range when no nodes are registered."""
|
|
tracker = TrackerServer()
|
|
port = tracker.start()
|
|
try:
|
|
resp = _get_json(
|
|
f"http://127.0.0.1:{port}/v1/nodes/assign?model=stub-model&device=cpu&vram_mb=0"
|
|
)
|
|
assert resp["shard_start"] == 0
|
|
assert resp["shard_end"] == 15
|
|
assert resp["model"] == "stub-model"
|
|
assert resp["model_layers_end"] == 31
|
|
finally:
|
|
tracker.stop()
|
|
|
|
|
|
def test_tracker_assign_fills_gap():
|
|
"""Tracker assigns the first uncovered layer range when a node is already registered."""
|
|
import json as _json
|
|
import urllib.request as _ur
|
|
|
|
tracker = TrackerServer()
|
|
port = tracker.start()
|
|
try:
|
|
# Register a node covering layers 0-15
|
|
data = _json.dumps({
|
|
"endpoint": "http://127.0.0.1:9100",
|
|
"shard_start": 0,
|
|
"shard_end": 15,
|
|
"hardware_profile": {},
|
|
"score": 1.0,
|
|
}).encode()
|
|
req = _ur.Request(
|
|
f"http://127.0.0.1:{port}/v1/nodes/register",
|
|
data=data,
|
|
headers={"Content-Type": "application/json"},
|
|
method="POST",
|
|
)
|
|
with _ur.urlopen(req) as r:
|
|
r.read()
|
|
|
|
# Assignment should fill the gap: layers 16-31
|
|
resp = _get_json(
|
|
f"http://127.0.0.1:{port}/v1/nodes/assign?model=stub-model&device=cpu&vram_mb=0"
|
|
)
|
|
assert resp["shard_start"] == 16
|
|
assert resp["shard_end"] == 31
|
|
finally:
|
|
tracker.stop()
|
|
|
|
|
|
def test_tracker_assign_returns_huggingface_repo_when_configured():
|
|
"""Tracker includes the HuggingFace repo identifier in shard assignments."""
|
|
tracker = TrackerServer(model_presets={
|
|
"tiny-llama": {"layers_start": 0, "layers_end": 7, "hf_repo": "org/tiny-llama-shards"}
|
|
})
|
|
port = tracker.start()
|
|
try:
|
|
resp = _get_json(
|
|
f"http://127.0.0.1:{port}/v1/nodes/assign?model=tiny-llama&device=cuda&vram_mb=24576"
|
|
)
|
|
assert resp["model"] == "tiny-llama"
|
|
assert resp["hf_repo"] == "org/tiny-llama-shards"
|
|
assert resp["shard_start"] == 0
|
|
assert resp["shard_end"] == 7
|
|
finally:
|
|
tracker.stop()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Full startup integration test
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_full_startup_sequence(tmp_path):
|
|
"""Full startup: hardware → wallet → assign → download → start → register."""
|
|
tracker = TrackerServer(model_presets={"stub-model": {"layers_start": 0, "layers_end": 15}})
|
|
tracker_port = tracker.start()
|
|
tracker_url = f"http://127.0.0.1:{tracker_port}"
|
|
|
|
wallet_path = tmp_path / "wallet.json"
|
|
cache_dir = tmp_path / "shards"
|
|
|
|
try:
|
|
node = run_startup(
|
|
tracker_url=tracker_url,
|
|
model="stub-model",
|
|
wallet_path=wallet_path,
|
|
cache_dir=cache_dir,
|
|
)
|
|
try:
|
|
# Wallet was created on disk
|
|
assert wallet_path.exists()
|
|
wallet_data = json.loads(wallet_path.read_text())
|
|
assert len(wallet_data) == 64
|
|
|
|
# Shard was cached
|
|
assert any(cache_dir.rglob("weights.json"))
|
|
|
|
# Node appears in tracker registry (route resolves successfully)
|
|
route_resp = _get_json(f"{tracker_url}/v1/route?model=stub-model")
|
|
assert len(route_resp["route"]) >= 1
|
|
assert node.port is not None
|
|
assert any(str(node.port) in ep for ep in route_resp["route"])
|
|
|
|
# Node accepts an inference request
|
|
payload = json.dumps({
|
|
"messages": [{"role": "user", "content": "hello"}],
|
|
}).encode()
|
|
req = urllib.request.Request(
|
|
f"http://127.0.0.1:{node.port}/v1/infer",
|
|
data=payload,
|
|
headers={"Content-Type": "application/json"},
|
|
method="POST",
|
|
)
|
|
with urllib.request.urlopen(req, timeout=5) as resp:
|
|
assert resp.status == 200
|
|
body = json.loads(resp.read())
|
|
# Last-shard node returns text; first-shard returns activations
|
|
assert "text" in body or "activations" in body
|
|
finally:
|
|
node.stop()
|
|
finally:
|
|
tracker.stop()
|
|
|
|
|
|
def test_startup_cpu_fallback(tmp_path, monkeypatch):
|
|
"""Node starts with CPU warning when no GPU is detected."""
|
|
import meshnet_node.startup as startup_mod
|
|
|
|
monkeypatch.setattr(
|
|
startup_mod,
|
|
"detect_hardware",
|
|
lambda: {"device": "cpu", "gpu_name": None, "vram_mb": 0},
|
|
)
|
|
|
|
tracker = TrackerServer(model_presets={"stub-model": {"layers_start": 0, "layers_end": 15}})
|
|
tracker_port = tracker.start()
|
|
tracker_url = f"http://127.0.0.1:{tracker_port}"
|
|
|
|
try:
|
|
node = run_startup(
|
|
tracker_url=tracker_url,
|
|
model="stub-model",
|
|
wallet_path=tmp_path / "wallet.json",
|
|
cache_dir=tmp_path / "shards",
|
|
)
|
|
try:
|
|
# Node is running even on CPU
|
|
assert node.port is not None
|
|
route_resp = _get_json(f"{tracker_url}/v1/route?model=stub-model")
|
|
assert len(route_resp["route"]) >= 1
|
|
finally:
|
|
node.stop()
|
|
finally:
|
|
tracker.stop()
|