Files
club-3090/scripts/lib/profiles/estate_cli.py
T

878 lines
35 KiB
Python
Executable File

#!/usr/bin/env python3
"""Estate planner operations for club-3090.
The estate layer is intentionally a thin orchestration wrapper around the
existing compose registry and validate_estate() profile checks.
"""
from __future__ import annotations
import argparse
import concurrent.futures
import os
import re
import shlex
import subprocess
import sys
import tempfile
import time
import urllib.request
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import yaml
REPO_ROOT = Path(__file__).resolve().parents[3]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
os.environ.setdefault("CLUB3090_LOG_LEVEL", "ERROR")
from scripts.lib.profiles.canonical_scenarios import CANONICAL_SCENARIOS # noqa: E402
from scripts.lib.profiles.compat import ( # noqa: E402
InstanceSpec,
ProfileError,
calibration_status,
load_profiles,
validate_estate,
)
from scripts.lib.profiles.compose_registry import COMPOSE_REGISTRY # noqa: E402
from scripts.lib.profiles.launch_compat import _hardware_id_from_gpu, resolve_engine_pin # noqa: E402
SUPPORTED_ESTATE_SCHEMA_VERSIONS = {1}
DEFAULT_ESTATE_PATH = Path("~/.club3090/estate.yml").expanduser()
DEFAULT_BOOT_LOG_DIR = Path("/tmp/club3090-estate-boot")
BOOT_LOG_KEEP = 5
class EstateCliError(Exception):
"""User-facing estate CLI failure."""
@dataclass(frozen=True)
class GpuInfo:
index: int
name: str
mem_mib: int
sm: float
hardware_id: str
@dataclass(frozen=True)
class BootOutcome:
index: int
total: int
inst: InstanceSpec
ok: bool
elapsed_s: int
log_path: Path
error: str = ""
def utc_now() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
def load_dotenv() -> dict[str, str]:
env = dict(os.environ)
path = REPO_ROOT / ".env"
if not path.exists():
return env
for raw in path.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
value = value.strip().strip('"').strip("'")
env.setdefault(key, value)
return env
def safe_name(name: str) -> str:
out = re.sub(r"[^a-z0-9_-]+", "-", name.lower()).strip("-_")
return out or "instance"
def project_name(name: str) -> str:
return f"estate-{safe_name(name)}"
def container_name(name: str) -> str:
return f"club3090-{safe_name(name)}"
def parse_only(value: str | None) -> set[str] | None:
if not value:
return None
names = {item.strip() for item in value.split(",") if item.strip()}
return names or None
def estate_path(value: str | None) -> Path:
return Path(value).expanduser() if value else DEFAULT_ESTATE_PATH
def same_path(a: Path, b: Path) -> bool:
return a.expanduser().resolve(strict=False) == b.expanduser().resolve(strict=False)
def parse_fake_gpus(value: str) -> list[GpuInfo]:
out = []
for raw in value.split(","):
raw = raw.strip()
if not raw:
continue
try:
idx, name, mem_mib, sm = raw.split(":", 3)
except ValueError as exc:
raise EstateCliError(f"invalid CLUB3090_FAKE_GPUS entry `{raw}`") from exc
hardware_id = _hardware_id_from_gpu(name, int(mem_mib), float(sm))
out.append(GpuInfo(int(idx), name, int(mem_mib), float(sm), hardware_id))
return sorted(out, key=lambda gpu: gpu.index)
def detect_gpus_from_host() -> list[GpuInfo]:
fake = os.environ.get("CLUB3090_FAKE_GPUS")
if fake:
return parse_fake_gpus(fake)
cmd = [
"nvidia-smi",
"--query-gpu=index,name,memory.total,compute_cap",
"--format=csv,noheader,nounits",
]
proc = subprocess.run(cmd, text=True, capture_output=True, check=False)
if proc.returncode != 0:
raise EstateCliError(f"nvidia-smi GPU query failed: {proc.stderr.strip() or proc.stdout.strip()}")
out = []
for raw in proc.stdout.splitlines():
if not raw.strip():
continue
parts = [part.strip() for part in raw.split(",")]
if len(parts) != 4:
raise EstateCliError(f"could not parse nvidia-smi GPU row `{raw}`")
idx, name, mem_mib, sm = parts
hardware_id = _hardware_id_from_gpu(name, int(mem_mib), float(sm))
out.append(GpuInfo(int(idx), name, int(mem_mib), float(sm), hardware_id))
return sorted(out, key=lambda gpu: gpu.index)
def parse_estate_yaml(path: Path) -> tuple[dict[str, Any], list[InstanceSpec]]:
if not path.exists():
raise EstateCliError(f"estate file not found: {path}")
try:
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
except yaml.YAMLError as exc:
raise EstateCliError(f"failed to parse estate YAML: {exc}") from exc
version = data.get("schema_version")
if version not in SUPPORTED_ESTATE_SCHEMA_VERSIONS:
raise EstateCliError(f"unsupported estate schema_version={version}; supported={sorted(SUPPORTED_ESTATE_SCHEMA_VERSIONS)}")
estate = data.get("estate")
if not isinstance(estate, list):
raise EstateCliError("estate file must contain an `estate:` list")
instances = []
seen = set()
for i, item in enumerate(estate, start=1):
if not isinstance(item, dict):
raise EstateCliError(f"estate entry #{i} must be a mapping")
name = str(item.get("name") or "").strip()
compose = str(item.get("compose") or item.get("compose_name") or "").strip()
gpus = item.get("gpus")
port = item.get("port")
if not name:
raise EstateCliError(f"estate entry #{i} missing name")
if name in seen:
raise EstateCliError(f"duplicate estate instance name `{name}`")
seen.add(name)
if not compose:
raise EstateCliError(f"estate entry `{name}` missing compose")
if not isinstance(gpus, list) or not gpus:
raise EstateCliError(f"estate entry `{name}` must set gpus: [..]")
if not isinstance(port, int):
raise EstateCliError(f"estate entry `{name}` must set integer port")
try:
gpu_indices = tuple(int(gpu) for gpu in gpus)
except (TypeError, ValueError) as exc:
raise EstateCliError(f"estate entry `{name}` has non-integer GPU index") from exc
instances.append(InstanceSpec(name=name, compose_name=compose, gpu_indices=gpu_indices, port=port))
return data, instances
def synthesize_hardware_from_doc(data: dict[str, Any], profiles) -> list:
rig = data.get("rig") if isinstance(data.get("rig"), dict) else {}
hardware_id = rig.get("hardware_id")
gpu_count = rig.get("gpu_count")
if not hardware_id or not isinstance(gpu_count, int):
raise EstateCliError("no GPUs detected and estate file rig.hardware_id/gpu_count fallback is missing")
if hardware_id == "mixed":
raise EstateCliError("cannot synthesize mixed hardware from estate file; run on the target rig")
if hardware_id not in profiles.hardware:
raise EstateCliError(f"estate rig.hardware_id `{hardware_id}` is not a known hardware profile")
return [profiles.hardware[hardware_id] for _ in range(gpu_count)]
def hardware_for_estate(data: dict[str, Any], profiles) -> tuple[list, list[GpuInfo]]:
try:
gpus = detect_gpus_from_host()
return [profiles.hardware[gpu.hardware_id] for gpu in gpus], gpus
except EstateCliError:
hardware = synthesize_hardware_from_doc(data, profiles)
gpus = [GpuInfo(i, hardware[i].display_name, int(hardware[i].vram_gb * 1024), hardware[i].sm, hardware[i].id) for i in range(len(hardware))]
return hardware, gpus
def detect_nvlink_pairs() -> tuple[bool, list[tuple[int, int]]]:
mode = os.environ.get("NVLINK_MODE", "auto")
fake_pairs = os.environ.get("CLUB3090_FAKE_NVLINK_PAIRS", "")
if fake_pairs:
pairs = []
for item in fake_pairs.split(","):
if not item.strip():
continue
a, b = item.replace(":", "-").split("-", 1)
pairs.append(tuple(sorted((int(a), int(b)))))
return True, sorted(set(pairs))
if mode == "force_on":
gpus = detect_gpus_from_host()
if len(gpus) >= 2:
return True, [(gpus[0].index, gpus[1].index)]
return True, []
if mode == "force_off":
return False, []
proc = subprocess.run(["nvidia-smi", "topo", "-m"], text=True, capture_output=True, check=False)
if proc.returncode != 0:
return False, []
lines = [line for line in proc.stdout.splitlines() if line.strip()]
if not lines:
return False, []
header = re.findall(r"GPU(\d+)", lines[0])
pairs = set()
for line in lines[1:]:
cols = line.split()
if not cols or not cols[0].startswith("GPU"):
continue
row_idx = int(cols[0][3:])
for col_name, value in zip(header, cols[1:]):
col_idx = int(col_name)
if row_idx < col_idx and re.match(r"NV\d+", value):
pairs.add((row_idx, col_idx))
return bool(pairs), sorted(pairs)
def validate_doc(path: Path):
profiles = load_profiles()
data, instances = parse_estate_yaml(path)
hardware, gpus = hardware_for_estate(data, profiles)
nvlink_active, nvlink_pairs = detect_nvlink_pairs()
result = validate_estate(instances, hardware, profiles, nvlink_active, nvlink_pairs)
return profiles, data, instances, hardware, gpus, nvlink_active, nvlink_pairs, result
def print_validation_summary(instances: list[InstanceSpec], result) -> None:
print(f"Estate validation: {'PASS' if result.valid else 'FAIL'}")
for inst in instances:
inst_result = result.per_instance.get(inst.name)
marker = "✓" if inst_result and inst_result.valid else "✗"
print(f" {marker} {inst.name}: {inst.compose_name} GPUs={list(inst.gpu_indices)} port={inst.port}")
if inst_result and not inst_result.valid:
for reason in inst_result.reasons:
print(f" - {reason}")
if result.cross_instance_failures:
print(" Cross-instance failures:")
for failure in result.cross_instance_failures:
print(f" - {failure}")
for note in result.notes:
print(f" note: {note}")
def estate_doc(instances: list[InstanceSpec], gpus: list[GpuInfo], nvlink_active: bool, existing: dict[str, Any] | None = None) -> dict[str, Any]:
hardware_ids = {gpu.hardware_id for gpu in gpus}
now = utc_now()
return {
"schema_version": 1,
"created": (existing or {}).get("created", now),
"updated": now,
"rig": {
"hardware_id": next(iter(hardware_ids)) if len(hardware_ids) == 1 else "mixed",
"gpu_count": len(gpus),
"nvlink_active": bool(nvlink_active),
},
"estate": [
{
"name": inst.name,
"compose": inst.compose_name,
"gpus": list(inst.gpu_indices),
"port": inst.port,
}
for inst in instances
],
}
def write_estate(path: Path, data: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, prefix=".estate.", suffix=".tmp", delete=False) as fh:
yaml.safe_dump(data, fh, sort_keys=False)
tmp = Path(fh.name)
os.replace(tmp, path)
def persist_default_estate_source(path: Path, data: dict[str, Any], instances: list[InstanceSpec], gpus: list[GpuInfo], nvlink_active: bool) -> None:
if same_path(path, DEFAULT_ESTATE_PATH):
return
write_estate(DEFAULT_ESTATE_PATH, estate_doc(instances, gpus, nvlink_active, data))
print(f"[estate] wrote {DEFAULT_ESTATE_PATH} from {path}")
def compose_abs_path(compose_name: str) -> Path:
entry = COMPOSE_REGISTRY.get(compose_name)
if not entry:
raise EstateCliError(f"unknown compose `{compose_name}`")
path = REPO_ROOT / entry["compose_path"]
if not path.exists():
raise EstateCliError(f"compose file not found for `{compose_name}`: {path}")
return path
def compose_env(inst: InstanceSpec) -> dict[str, str]:
env = load_dotenv()
joined = ",".join(str(gpu) for gpu in inst.gpu_indices)
env.update(
{
"ESTATE_GPUS": joined,
"ESTATE_PORT": str(inst.port),
"ESTATE_CONTAINER": container_name(inst.name),
"CUDA_VISIBLE_DEVICES": joined,
"NVIDIA_VISIBLE_DEVICES": joined,
"PORT": str(inst.port),
}
)
entry = COMPOSE_REGISTRY.get(inst.compose_name)
if entry:
profiles = load_profiles()
engine = profiles.engines[entry["engine"]]
if engine.type == "vllm":
env.update(resolve_engine_pin(profiles, entry["engine"]))
return env
def compose_cmd() -> list[str]:
return shlex.split(os.environ.get("COMPOSE_BIN", "docker compose"))
def boot_log_dir() -> Path:
return Path(os.environ.get("CLUB3090_ESTATE_BOOT_LOG_DIR", str(DEFAULT_BOOT_LOG_DIR))).expanduser()
def instance_log_path(inst: InstanceSpec) -> Path:
return boot_log_dir() / f"{safe_name(inst.name)}.log"
def rotate_log(path: Path, keep: int = BOOT_LOG_KEEP) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
for i in range(keep - 1, 0, -1):
src = path.with_name(f"{path.name}.{i}")
dst = path.with_name(f"{path.name}.{i + 1}")
if src.exists():
src.replace(dst)
if path.exists():
path.replace(path.with_name(f"{path.name}.1"))
def prepare_instance_log(inst: InstanceSpec) -> Path:
path = instance_log_path(inst)
rotate_log(path)
path.touch(mode=0o600, exist_ok=True)
return path
def summarize_error(error: str, limit: int = 220) -> str:
line = " ".join(part.strip() for part in str(error).splitlines() if part.strip())
if not line:
line = "unknown error"
return line if len(line) <= limit else line[: limit - 1] + "…"
def append_log(path: Path, message: str) -> None:
with path.open("a", encoding="utf-8") as fh:
fh.write(message.rstrip() + "\n")
def run_compose(inst: InstanceSpec, action: str, log_path: Path | None = None) -> None:
cmd = compose_cmd() + ["-p", project_name(inst.name), "-f", str(compose_abs_path(inst.compose_name)), action]
if action == "up":
cmd.append("-d")
if log_path is not None:
append_log(log_path, f"$ {' '.join(cmd)}")
with log_path.open("a", encoding="utf-8") as fh:
proc = subprocess.run(cmd, cwd=REPO_ROOT, env=compose_env(inst), text=True, stdout=fh, stderr=subprocess.STDOUT)
else:
proc = subprocess.run(cmd, cwd=REPO_ROOT, env=compose_env(inst), text=True)
if proc.returncode != 0:
raise EstateCliError(f"`{' '.join(cmd)}` failed with exit {proc.returncode}")
def container_running(name: str) -> bool:
proc = subprocess.run(
["docker", "inspect", "-f", "{{.State.Running}}", container_name(name)],
text=True,
capture_output=True,
check=False,
)
return proc.returncode == 0 and proc.stdout.strip() == "true"
def docker_logs_tail(name: str, lines: int = 30) -> str:
proc = subprocess.run(["docker", "logs", "--tail", str(lines), container_name(name)], text=True, capture_output=True, check=False)
return (proc.stdout + proc.stderr).strip()
def endpoint_ready(port: int) -> bool:
try:
with urllib.request.urlopen(f"http://localhost:{port}/v1/models", timeout=3) as resp:
return 200 <= resp.status < 300
except Exception:
return False
def wait_ready(inst: InstanceSpec, timeout: int) -> None:
start = time.monotonic()
last_line = 0
print(f"[estate] waiting for {inst.name} http://localhost:{inst.port}/v1/models (timeout {timeout}s)...")
while True:
if endpoint_ready(inst.port):
elapsed = int(time.monotonic() - start)
print(f"[estate] ✓ {inst.name} ready after {elapsed}s")
return
if not container_running(inst.name):
logs = docker_logs_tail(inst.name)
raise EstateCliError(f"container {container_name(inst.name)} stopped during boot\n{logs}")
elapsed = int(time.monotonic() - start)
if elapsed >= timeout:
raise EstateCliError(f"timeout waiting for {inst.name} after {timeout}s; logs: docker logs {container_name(inst.name)}")
if elapsed // 30 > last_line:
last_line = elapsed // 30
print(f"[estate] {elapsed}s elapsed for {inst.name}, still waiting...")
time.sleep(4)
def wait_ready_quiet(inst: InstanceSpec, timeout: int) -> int:
start = time.monotonic()
poll_interval = max(float(os.environ.get("CLUB3090_ESTATE_POLL_INTERVAL", "4")), 0.1)
while True:
if endpoint_ready(inst.port):
return int(time.monotonic() - start)
if not container_running(inst.name):
logs = docker_logs_tail(inst.name)
raise EstateCliError(f"container {container_name(inst.name)} stopped during boot\n{logs}")
elapsed = time.monotonic() - start
if elapsed >= timeout:
raise EstateCliError(f"timeout waiting for {inst.name} after {timeout}s; logs: docker logs {container_name(inst.name)}")
time.sleep(min(poll_interval, max(timeout - elapsed, 0.1)))
def select_instances(instances: list[InstanceSpec], only: set[str] | None) -> list[InstanceSpec]:
if not only:
return instances
aliases: dict[str, InstanceSpec] = {}
for inst in instances:
aliases[inst.name] = inst
aliases[container_name(inst.name)] = inst
missing = sorted(name for name in only if name not in aliases)
if missing:
raise EstateCliError(f"--only references unknown instance(s): {', '.join(missing)}")
selected_names = {aliases[name].name for name in only}
return [inst for inst in instances if inst.name in selected_names]
def command_validate(args: argparse.Namespace) -> int:
try:
_, _, instances, _, _, _, _, result = validate_doc(estate_path(args.file))
except EstateCliError as exc:
print(f"[estate] ERROR: {exc}", file=sys.stderr)
return 2
print_validation_summary(instances, result)
return 0 if result.valid else 1
def effective_parallel_jobs(requested: int | None, total: int) -> int:
if requested is None:
return min(total, 4)
if requested < 1:
raise EstateCliError("--parallel-jobs must be >= 1")
return min(requested, total, 4)
def boot_instance_parallel(index: int, total: int, inst: InstanceSpec, timeout: int, log_path: Path) -> BootOutcome:
start = time.monotonic()
try:
append_log(log_path, f"[estate] booting {inst.name}: {inst.compose_name} GPUs={list(inst.gpu_indices)} port={inst.port}")
run_compose(inst, "up", log_path=log_path)
elapsed_s = wait_ready_quiet(inst, timeout)
append_log(log_path, f"[estate] healthy after {elapsed_s}s")
return BootOutcome(index=index, total=total, inst=inst, ok=True, elapsed_s=elapsed_s, log_path=log_path)
except Exception as exc:
elapsed_s = int(time.monotonic() - start)
summary = summarize_error(str(exc))
append_log(log_path, f"[estate] ERROR after {elapsed_s}s: {summary}")
return BootOutcome(index=index, total=total, inst=inst, ok=False, elapsed_s=elapsed_s, log_path=log_path, error=summary)
def boot_instances_parallel(selected: list[InstanceSpec], timeout: int, requested_jobs: int | None, stagger_s: float) -> int:
if stagger_s < 0:
raise EstateCliError("--parallel-stagger must be >= 0")
total = len(selected)
jobs = effective_parallel_jobs(requested_jobs, total)
print(f"[estate] parallel boot: {total} instance(s), jobs={jobs}, stagger={stagger_s:g}s")
futures: dict[concurrent.futures.Future[BootOutcome], tuple[int, InstanceSpec, Path]] = {}
with concurrent.futures.ThreadPoolExecutor(max_workers=jobs) as pool:
for i, inst in enumerate(selected, start=1):
log_path = prepare_instance_log(inst)
print(f"[estate] [{i}/{total}] booting {inst.name} on GPUs {','.join(str(g) for g in inst.gpu_indices)} port {inst.port}... (started)")
future = pool.submit(boot_instance_parallel, i, total, inst, timeout, log_path)
futures[future] = (i, inst, log_path)
if i < total and stagger_s > 0:
time.sleep(stagger_s)
outcomes = [future.result() for future in concurrent.futures.as_completed(futures)]
outcomes.sort(key=lambda outcome: outcome.index)
healthy = 0
failed: list[BootOutcome] = []
for outcome in outcomes:
if outcome.ok:
healthy += 1
print(f"[estate] [{outcome.index}/{outcome.total}] {outcome.inst.name} ✓ healthy after {outcome.elapsed_s}s")
else:
failed.append(outcome)
print(
f"[estate] [{outcome.index}/{outcome.total}] {outcome.inst.name} ✗ failed after {outcome.elapsed_s}s: {outcome.error}"
)
print(f"[estate] Summary: {healthy}/{total} healthy, {len(failed)} failed.")
for outcome in failed:
print(f"[estate] Failed instance: {outcome.inst.name}. See {outcome.log_path}")
return 0 if not failed else 1
def command_boot(args: argparse.Namespace) -> int:
path = estate_path(args.file)
try:
_, data, instances, _, gpus, nvlink_active, _, result = validate_doc(path)
print_validation_summary(instances, result)
if not result.valid:
return 1
selected = select_instances(instances, parse_only(args.only))
persist_default_estate_source(path, data, instances, gpus, nvlink_active)
if getattr(args, "parallel", False) and len(selected) > 1:
return boot_instances_parallel(
selected,
args.timeout,
getattr(args, "parallel_jobs", None),
getattr(args, "parallel_stagger", 15.0),
)
total = len(selected)
for i, inst in enumerate(selected, start=1):
print(f"[estate] [{i}/{total}] booting {inst.name}: {inst.compose_name} GPUs={list(inst.gpu_indices)} port={inst.port}")
run_compose(inst, "up")
wait_ready(inst, args.timeout)
print("[estate] all selected instances are healthy")
return 0
except EstateCliError as exc:
print(f"[estate] ERROR: {exc}", file=sys.stderr)
return 1
def command_down(args: argparse.Namespace) -> int:
path = estate_path(args.file)
try:
_, instances = parse_estate_yaml(path)
selected = select_instances(instances, parse_only(args.only))
for inst in selected:
print(f"[estate] stopping {inst.name} ({container_name(inst.name)})")
run_compose(inst, "down")
return 0
except EstateCliError as exc:
print(f"[estate] ERROR: {exc}", file=sys.stderr)
return 2
def default_gpu_block(tp: int, claimed: set[int], gpus: list[GpuInfo], fallback: tuple[int, ...] | None = None) -> tuple[int, ...]:
if fallback:
return fallback
indices = [gpu.index for gpu in gpus]
for start in indices:
block = tuple(range(start, start + tp))
if all(idx in indices and idx not in claimed for idx in block):
return block
free = [idx for idx in indices if idx not in claimed]
return tuple(free[:tp])
def default_port(base: int, slot: int, used: set[int], fallback: int | None = None) -> int:
if fallback is not None:
return fallback
port = base + 20 * slot
while port in used:
port += 1
return port
def prompt_default(prompt: str, default: str) -> str:
reply = input(f"{prompt} [{default}]: ").strip()
return reply or default
def parse_gpus_reply(reply: str) -> tuple[int, ...]:
try:
return tuple(int(part.strip()) for part in reply.split(",") if part.strip())
except ValueError as exc:
raise EstateCliError(f"invalid GPU list `{reply}`") from exc
def wizard_instance(slot: int, gpus: list[GpuInfo], existing: InstanceSpec | None, claimed: set[int], used_ports: set[int]) -> InstanceSpec:
print("")
print("[estate] Available composes:")
for name, entry in sorted(COMPOSE_REGISTRY.items()):
print(f" {name:28s} model={entry['model']} tp={entry['tp']} port={entry['default_port']}")
default_compose = existing.compose_name if existing else "llamacpp/default"
compose = prompt_default("Compose", default_compose)
if compose not in COMPOSE_REGISTRY:
raise EstateCliError(f"unknown compose `{compose}`")
entry = COMPOSE_REGISTRY[compose]
tp = int(entry["tp"])
default_gpus = default_gpu_block(tp, claimed, gpus, existing.gpu_indices if existing else None)
gpu_reply = prompt_default("GPU indices", ",".join(str(gpu) for gpu in default_gpus))
gpu_indices = parse_gpus_reply(gpu_reply)
port = int(prompt_default("Port", str(default_port(int(entry["default_port"]), slot, used_ports, existing.port if existing else None))))
name_default = existing.name if existing else f"{safe_name(compose.replace('/', '-'))}-slot{slot + 1}"
name = prompt_default("Instance name", name_default)
return InstanceSpec(name=name, compose_name=compose, gpu_indices=gpu_indices, port=port)
def command_wizard(args: argparse.Namespace) -> int:
if not sys.stdin.isatty():
print("[estate] ERROR: --estate wizard needs a TTY. Use --estate-file <path> for non-interactive boot.", file=sys.stderr)
return 2
path = estate_path(args.file)
profiles = load_profiles()
existing_data: dict[str, Any] | None = None
existing_instances: list[InstanceSpec] = []
if path.exists():
existing_data, existing_instances = parse_estate_yaml(path)
if args.replace and not existing_instances:
print(f"[estate] ERROR: --replace {args.replace} needs an existing estate file at {path}", file=sys.stderr)
return 2
try:
raw_gpus = detect_gpus_from_host()
hardware = [profiles.hardware[gpu.hardware_id] for gpu in raw_gpus]
nvlink_active, nvlink_pairs = detect_nvlink_pairs()
instances = list(existing_instances) if (args.append or args.replace) else []
boot_names: set[str] = set()
if args.replace:
target = next((inst for inst in instances if inst.name == args.replace), None)
if target is None:
raise EstateCliError(f"--replace target `{args.replace}` not found")
instances = [inst for inst in instances if inst.name != args.replace]
claimed = {gpu for inst in instances for gpu in inst.gpu_indices}
used_ports = {inst.port for inst in instances}
new_inst = wizard_instance(len(instances), raw_gpus, target, claimed, used_ports)
instances.append(new_inst)
boot_names.add(new_inst.name)
else:
while True:
claimed = {gpu for inst in instances for gpu in inst.gpu_indices}
used_ports = {inst.port for inst in instances}
new_inst = wizard_instance(len(instances), raw_gpus, None, claimed, used_ports)
instances.append(new_inst)
boot_names.add(new_inst.name)
more = prompt_default("Configure another instance? (y/n)", "n").lower()
if more not in {"y", "yes"}:
break
result = validate_estate(instances, hardware, profiles, nvlink_active, nvlink_pairs)
print_validation_summary(instances, result)
if not result.valid:
return 1
write_estate(path, estate_doc(instances, raw_gpus, nvlink_active, existing_data))
print(f"[estate] wrote {path}")
boot_now = prompt_default("Boot selected instance(s) now? (y/n)", "y").lower()
if boot_now not in {"y", "yes"}:
return 0
if args.replace:
target = next(inst for inst in existing_instances if inst.name == args.replace)
print(f"[estate] replacing {args.replace}: stopping old container after validated estate write")
run_compose(target, "down")
selected = select_instances(instances, boot_names)
for i, inst in enumerate(selected, start=1):
print(f"[estate] [{i}/{len(selected)}] booting {inst.name}: GPUs={list(inst.gpu_indices)} port={inst.port}")
run_compose(inst, "up")
wait_ready(inst, args.timeout)
return 0
except (EstateCliError, ProfileError) as exc:
print(f"[estate] ERROR: {exc}", file=sys.stderr)
return 2
def command_diagnose(args: argparse.Namespace) -> int:
path = estate_path(args.file)
print(f"Estate triage: {path}")
print("=" * (15 + len(str(path))))
try:
data, instances = parse_estate_yaml(path)
print("[1/6] Estate file parses + schema_version supported")
print(f" ✓ schema_version={data.get('schema_version')} accepted; {len(instances)} instance(s) declared")
except EstateCliError as exc:
print("[1/6] Estate file parses + schema_version supported")
print(f" ✗ {exc}")
return 2
missing = [inst.compose_name for inst in instances if inst.compose_name not in COMPOSE_REGISTRY]
print("[2/6] Each instance compose exists in COMPOSE_REGISTRY")
if missing:
for compose in missing:
print(f" ✗ {compose} missing from registry")
return 1
for inst in instances:
print(f" ✓ {inst.compose_name} → registry entry found")
profiles, _, _, hardware, _, nvlink_active, nvlink_pairs, result = validate_doc(path)
print("[3/6] Per-instance fits() PASS")
for inst in instances:
inst_result = result.per_instance[inst.name]
marker = "✓" if inst_result.valid else "✗"
passed = len(inst_result.diagnostics.get("constraints_passed", []))
failed = len(inst_result.diagnostics.get("constraints_failed", []))
print(f" {marker} {inst.name}: passed={passed}, failed={failed}, elapsed={inst_result.diagnostics.get('elapsed_ms')} ms")
for reason in inst_result.reasons:
print(f" - {reason}")
print("[4/6] Estate cross-checks E1-E4")
if result.cross_instance_failures:
for failure in result.cross_instance_failures:
print(f" ✗ {failure}")
else:
print(f" ✓ constraints passed: {', '.join(result.diagnostics.get('constraints_passed', []))}")
for note in result.notes:
print(f" ⊘ {note}")
print("[5/6] Calibration freshness")
for inst in instances:
selected = [hardware[idx] for idx in inst.gpu_indices if 0 <= idx < len(hardware)]
status, row = calibration_status(profiles, inst.compose_name, selected)
if row:
print(f" ✓ {inst.name}: {status}; {row.get('source', 'calibration row present')}")
else:
print(f" ⊘ {inst.name}: {status}; no exact calibration row")
print("[6/6] Live state (--live only)")
if not args.live:
print(" ⊘ skipped")
else:
for inst in instances:
marker = "✓" if endpoint_ready(inst.port) else "✗"
running = "running" if container_running(inst.name) else "not-running"
print(f" {marker} {inst.name}: http://localhost:{inst.port}/v1/models, container={running}")
print("")
print(f"Triage summary: {'GREEN' if result.valid else 'RED'}")
return 0 if result.valid else 1
def command_report_state(args: argparse.Namespace) -> int:
profiles = load_profiles()
print("## Profile state")
print("")
print("- **Profile schema version:** 1")
print(
"- **Profile counts:** "
f"{len(profiles.hardware)} hardware, {len(profiles.models)} models, "
f"{len(profiles.workloads)} workloads, {len(profiles.engines)} engines, "
f"{len(profiles.drafters)} drafters"
)
print(f"- **Compose registry:** {len(COMPOSE_REGISTRY)} entries")
print(f"- **Canonical scenarios:** {len(CANONICAL_SCENARIOS)}")
if profiles.calibration:
print("- **Calibration:**")
for model, cal in sorted(profiles.calibration.items()):
print(f" - {model}: {len(cal.rows)} rows")
path = estate_path(args.file)
if not path.exists():
print("- **Active estate:** none (`~/.club3090/estate.yml` not found)")
return 0
try:
_, _, instances, hardware, _, _, _, result = validate_doc(path)
except EstateCliError as exc:
print(f"- **Active estate:** present but invalid: {exc}")
return 0
claimed = sorted({gpu for inst in instances for gpu in inst.gpu_indices})
print("- **Active estate:**")
print(f" - {len(instances)} instances from `{path}`")
print(f" - Validation: {'PASS' if result.valid else 'FAIL'}")
print(f" - GPU coverage: {len(claimed)}/{len(hardware)} cards claimed ({claimed})")
for inst in instances:
print(f" - {inst.name}: {inst.compose_name}, GPUs {list(inst.gpu_indices)}, port {inst.port}")
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="club-3090 estate planner")
sub = parser.add_subparsers(dest="command", required=True)
validate = sub.add_parser("validate")
validate.add_argument("--file", default=str(DEFAULT_ESTATE_PATH))
validate.set_defaults(func=command_validate)
boot = sub.add_parser("boot")
boot.add_argument("--file", default=str(DEFAULT_ESTATE_PATH))
boot.add_argument("--only", default="")
boot.add_argument("--timeout", type=int, default=int(os.environ.get("READY_TIMEOUT", "600")))
boot.add_argument("--parallel", action="store_true")
boot.add_argument("--parallel-jobs", type=int, default=None)
boot.add_argument("--parallel-stagger", type=float, default=15.0)
boot.set_defaults(func=command_boot)
down = sub.add_parser("down")
down.add_argument("--file", default=str(DEFAULT_ESTATE_PATH))
down.add_argument("--only", default="")
down.set_defaults(func=command_down)
wizard = sub.add_parser("wizard")
wizard.add_argument("--file", default=str(DEFAULT_ESTATE_PATH))
wizard.add_argument("--append", action="store_true")
wizard.add_argument("--replace", default="")
wizard.add_argument("--timeout", type=int, default=int(os.environ.get("READY_TIMEOUT", "600")))
wizard.set_defaults(func=command_wizard)
diagnose = sub.add_parser("diagnose")
diagnose.add_argument("file", nargs="?", default=str(DEFAULT_ESTATE_PATH))
diagnose.add_argument("--live", action="store_true")
diagnose.set_defaults(func=command_diagnose)
report = sub.add_parser("report-state")
report.add_argument("--file", default=str(DEFAULT_ESTATE_PATH))
report.set_defaults(func=command_report_state)
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
return int(args.func(args))
if __name__ == "__main__":
raise SystemExit(main())