diff --git a/.gitignore b/.gitignore index 881a36a..886b1d2 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,6 @@ Thumbs.db # Local scratch: squashfs/ISO extracts during dev inspection .sds_inspect/ + +# Stray runtime config.ini generated next to source in dev mode +dev/src/vrcx/config.ini diff --git a/dev/pyproject.toml b/dev/pyproject.toml index 56d67b3..ae527c3 100644 --- a/dev/pyproject.toml +++ b/dev/pyproject.toml @@ -9,7 +9,9 @@ readme = "README.md" requires-python = ">=3.10" license = { text = "MIT" } authors = [{ name = "engelgardt" }] -dependencies = ["rich>=13", "paramiko>=3"] +# paramiko pinned <4: 4.x/5.x dropped ssh-rsa (SHA-1) host keys, which YADRO +# Vegman BMCs only offer — newer paramiko can't negotiate a host key with them. +dependencies = ["rich>=13", "paramiko>=3,<4"] dynamic = ["version"] [project.urls] diff --git a/dev/src/vrcx/__init__.py b/dev/src/vrcx/__init__.py index 09e272f..ba4be67 100644 --- a/dev/src/vrcx/__init__.py +++ b/dev/src/vrcx/__init__.py @@ -9,5 +9,5 @@ per-host {bmc,os}/ layout, and packs everything into a single tar.gz. The single source of truth for the project version. Bump this before tagging a release; CI reads the tag, the code reads this constant.""" -__version__ = "0.2.0-dev" +__version__ = "0.2.0" REPO = "engel/vrcx" # self-hosted Gitea (git.engelgardt23.ru) diff --git a/dev/src/vrcx/app.py b/dev/src/vrcx/app.py index ea456ce..e14227a 100644 --- a/dev/src/vrcx/app.py +++ b/dev/src/vrcx/app.py @@ -24,6 +24,8 @@ from .bmc import BmcSession from .collector import collect_host from .config import load_config, Config from .discover import discover_sds_ip +from .leases_src import load_leases, mac_to_ip, bmc_candidates, sds_by_mac_neighbour +from .preflight import probe_hosts from .i18n import set_language, t from .os_collector import collect_host_os from .platform_win import enable_vt @@ -43,6 +45,28 @@ def _parse_ips(raw: str) -> list[str]: return [tok for tok in tokens if _IP_RE.match(tok)] +def _friendly_err(raw: str) -> str: + """Prefix a raw exception string with a plain-language hint at what went + wrong, so the user can tell it's their input (bad IP/creds) vs. the box.""" + low = (raw or "").lower() + hint = None + if "authentication" in low or "auth failed" in low: + hint = t("err_auth") + elif "banner" in low: + hint = t("err_banner") + elif "refused" in low: + hint = t("err_refused") + elif "getaddrinfo" in low or "name or service" in low or "nodename" in low: + hint = t("err_dns") + elif "unreachable" in low or "no route" in low: + hint = t("err_noroute") + elif "timed out" in low or "timeout" in low: + hint = t("err_timeout") + if not hint: + return raw + return f"{hint} ({raw})" if raw else hint + + @dataclass class Inputs: hosts: list[str] @@ -53,9 +77,9 @@ class Inputs: sds_pass: str -def _prompt_inputs(console: Console, cfg: Config) -> Inputs | None: - console.rule(f"[bold cyan]{t('targets_rule')}") +def _enter_bmc_ips_manually(console: Console) -> list[str] | None: console.print(t("enter_bmc_ips")) + console.print(f"[dim]{t('bmc_ip_hint')}[/]") console.print(f"[dim]{t('end_with_blank')}[/]") lines: list[str] = [] while True: @@ -69,35 +93,65 @@ def _prompt_inputs(console: Console, cfg: Config) -> Inputs | None: if not line.strip(): continue lines.append(line) - hosts = _parse_ips(" ".join(lines)) + return _parse_ips(" ".join(lines)) + + +def _prompt_inputs(console: Console, cfg: Config, + lease_entries: list[dict] | None) -> Inputs | None: + console.rule(f"[bold cyan]{t('targets_rule')}") + + hosts: list[str] | None = None + # Auto-pick up BMCs from the dhcpsrv lease table if it's there and fresh. + cands = bmc_candidates(lease_entries) if lease_entries else [] + if cands: + console.print(t("bmc_found", n=len(cands))) + for ip, host in cands: + console.print(f" [green]{ip}[/] [dim]{host}[/]") + console.print() + if Confirm.ask(t("use_found_bmc"), default=True): + hosts = [ip for ip, _ in cands] + if hosts is None: + hosts = _enter_bmc_ips_manually(console) if not hosts: console.print(f"[red]{t('no_ips')}[/]") return None console.print() bmc_user = Prompt.ask(t("bmc_user"), default=cfg.bmc_default_user) - bmc_pass = Prompt.ask(t("bmc_pass")) + bmc_pass = Prompt.ask(t("bmc_pass"), default=cfg.bmc_default_pass) console.print() collect_os = Confirm.ask(t("ask_collect_os"), default=cfg.collect_by_default) - sds_user, sds_pass = cfg.sds_default_user, "sds" + sds_user, sds_pass = cfg.sds_default_user, cfg.sds_default_pass if collect_os: - sds_user = Prompt.ask(t("sds_user"), default=cfg.sds_default_user) - sds_pass = Prompt.ask(t("sds_pass"), default="sds") + console.print(f"[dim]{t('os_creds_note', user=sds_user)}[/]") return Inputs(hosts=hosts, bmc_user=bmc_user, bmc_pass=bmc_pass, collect_os=collect_os, sds_user=sds_user, sds_pass=sds_pass) def _resolve_sds_ip(host: str, bmc_user: str, bmc_pass: str, cfg: Config, - console: Console) -> str | None: + console: Console, + lease_entries: list[dict] | None = None) -> str | None: console.print(f"[dim]{t('resolving_sds', host=host)}[/]") - ip: str | None = None + + # 1) dhcpsrv table, MAC-neighbour pairing — Redfish-free and works even + # when the BMC's TLS is unreachable (as on the current Vegman stand). + if lease_entries: + ip = sds_by_mac_neighbour(host, lease_entries) + if ip: + console.print(f"[green]{t('sds_resolved_mac', host=host, ip=ip)}[/]") + return ip + + # 2) Fallback: Redfish host-MAC → (lease table | ARP). Redfish/HTTPS only — + # don't open SSH here, or a slow/flaky BMC banner aborts before Redfish. + ip = None + lease_pairs = mac_to_ip(lease_entries) if lease_entries else None try: - with BmcSession(host=host, user=bmc_user, password=bmc_pass) as bmc: - ip = discover_sds_ip(bmc, do_sweep=cfg.ping_sweep) + bmc = BmcSession(host=host, user=bmc_user, password=bmc_pass) + ip = discover_sds_ip(bmc, do_sweep=cfg.ping_sweep, lease_pairs=lease_pairs) except Exception as exc: - console.print(f"[yellow]{t('discovery_failed', host=host, err=exc)}[/]") + console.print(f"[yellow]{t('discovery_failed', host=host, err=_friendly_err(str(exc)))}[/]") if ip: console.print(f"[green]{t('sds_resolved', host=host, ip=ip)}[/]") return ip @@ -155,6 +209,7 @@ def _host_worker(host: str, bmc_user: str, bmc_pass: str, else: ui.set_status(host, "ERROR") err = (bmc_summary.get("error") or "") if not bmc_ok else (os_summary.get("error") or "") + err = _friendly_err(err) ui.set_summary(host, total_ok, total_fail, bmc_summary.get("serial", ""), err[:80]) ui.log(f"[red]{t('host_failed', host=host, err=err)}[/]") @@ -182,24 +237,119 @@ def _print_header(console: Console) -> None: console.print() +def _show_preflight(console: Console, hosts: list[str], + user: str, password: str) -> None: + """Probe each BMC and print an at-a-glance state/version table before the + (slow) collection — is the host up, power/work state, firmware versions.""" + console.rule(f"[bold cyan]{t('preflight_rule')}") + info = probe_hosts(hosts, user, password) + tbl = Table(show_header=True, header_style="bold", expand=False) + tbl.add_column(t("col_host")) + tbl.add_column(t("pf_work")) + tbl.add_column(t("pf_power")) + tbl.add_column("BMC"); tbl.add_column("UEFI"); tbl.add_column("FPGA") + for h in hosts: + r = info[h] + if r["ok"]: + tbl.add_row(f"[green]{h}[/]", r["work"], r["power"], + r["bmc"], r["uefi"], r["fpga"]) + else: + tbl.add_row(f"[red]{h}[/]", + f"[red]{_friendly_err(r['error'])[:50]}[/]", "", "", "", "") + console.print(tbl) + console.print() + + +def _open_in_explorer(path: Path) -> None: + """Open `path` in the OS file manager (Windows: Explorer). Best-effort.""" + try: + if sys.platform.startswith("win"): + os.startfile(str(path)) # type: ignore[attr-defined] + except Exception: + pass + + +def _crash_dir() -> Path: + base = (Path(sys.executable).resolve().parent + if getattr(sys, "frozen", False) else Path(os.getcwd())) + return base / "crashlogs" + + +def _write_crash(exc: BaseException, where: str) -> Path: + """Write one self-contained, timestamped crash file into crashlogs/ — so a + colleague who hits a crash can just send that single file back for analysis.""" + import platform + import traceback + from datetime import datetime + d = _crash_dir() + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + p = d / f"vrcx_crash_{ts}.log" + try: + d.mkdir(parents=True, exist_ok=True) + with p.open("w", encoding="utf-8") as f: + f.write(f"vrcx {__version__} crash report\n") + f.write(f"when: {datetime.now():%Y-%m-%d %H:%M:%S}\n") + f.write(f"where: {where}\n") + f.write(f"os: {platform.platform()}\n") + f.write(f"python: {sys.version.splitlines()[0]}\n") + f.write(f"frozen: {bool(getattr(sys, 'frozen', False))}\n") + f.write("-" * 60 + "\n") + f.write("".join(traceback.format_exception(type(exc), exc, exc.__traceback__))) + except OSError: + pass + return p + + def main() -> None: + """Crash guard: capture any unhandled error (main or TUI thread) to a log + file next to the exe and keep the window open, so failures aren't lost when + the console closes.""" + def _thread_hook(args): + _write_crash(args.exc_value or RuntimeError("thread error"), f"thread:{args.thread.name}") + threading.excepthook = _thread_hook + try: + _run() + except KeyboardInterrupt: + pass + except BaseException as exc: # noqa: BLE001 — last-resort guard + path = _write_crash(exc, "main") + import traceback + traceback.print_exc() + try: + input(f"\n{t('press_enter')} [crash log: {path}]") + except Exception: + pass + + +def _run() -> None: enable_vt() + # paramiko's transport thread logs full tracebacks to the console on a + # flaky SSH handshake (e.g. a slow BMC banner). We surface a clean line + # ourselves, so silence its noise. + import logging + logging.getLogger("paramiko").setLevel(logging.CRITICAL) + cfg = load_config() set_language(cfg.language) console = Console(log_path=False) _print_header(console) - inputs = _prompt_inputs(console, cfg) + lease_entries = load_leases(cfg.leases_path, cfg.leases_max_age) + + inputs = _prompt_inputs(console, cfg, lease_entries) if inputs is None: input(t("press_enter")); return + _show_preflight(console, inputs.hosts, inputs.bmc_user, inputs.bmc_pass) + sds_ips: dict[str, str | None] = {} if inputs.collect_os: console.rule(f"[bold cyan]{t('discovery_rule')}") for h in inputs.hosts: - sds_ips[h] = _resolve_sds_ip(h, inputs.bmc_user, inputs.bmc_pass, cfg, console) + sds_ips[h] = _resolve_sds_ip(h, inputs.bmc_user, inputs.bmc_pass, cfg, + console, lease_entries) enabled = {h for h, ip in sds_ips.items() if ip} if getattr(sys, "frozen", False): @@ -214,7 +364,14 @@ def main() -> None: hosts=inputs.hosts, os_enabled=enabled) stop = threading.Event() - ui_thread = threading.Thread(target=ui.run, args=(stop,), daemon=True) + def _ui_run() -> None: + try: + ui.run(stop) + except BaseException as exc: # noqa: BLE001 — surface TUI-thread crashes + _write_crash(exc, "ui-thread") + stop.set() + + ui_thread = threading.Thread(target=_ui_run, daemon=True) ui_thread.start() summaries: list[dict] = [] @@ -264,7 +421,14 @@ def main() -> None: if aborted: console.print(f"[yellow]{t('aborted_msg')}[/]") else: - console.print(f"[bold green]{t('done', path=outer)}[/]") + console.print(f"[bold green]{t('done')}[/]") + if outer is not None: + # Clickable link (Ctrl+click) + plain path as visible text, so it's + # findable even where terminal links aren't supported. + console.print(f" {t('archive_label')}: [link={outer.as_uri()}]{outer}[/link]") + if cfg.open_explorer: + _open_in_explorer(outer.parent) + console.print() input(t("press_enter")) diff --git a/dev/src/vrcx/bmc.py b/dev/src/vrcx/bmc.py index d92e714..b6af425 100644 --- a/dev/src/vrcx/bmc.py +++ b/dev/src/vrcx/bmc.py @@ -106,10 +106,17 @@ class BmcSession: # parts[2]: prompt after the second sentinel if len(parts) >= 3: out = parts[1] + # parts[1] opens with the tail of the echoed command line + # (the closing "'" of `echo ''` + CRLF); the real output + # begins after that first newline. Dropping it avoids a stray + # leading quote that, e.g., breaks `inventory.json` parsing. + nl = out.find("\n") + if nl != -1: + out = out[nl + 1:] else: # Sentinel didn't appear twice — return what we have for diagnostics. out = buf - return out.lstrip("\r\n").rstrip() + "\n" + return out.strip("\r\n").rstrip() + "\n" def shell(self, cmd: str, timeout: int = 30) -> str: """Raw shell command via SSH exec_command (no PTY, no YADRO CLI).""" @@ -178,16 +185,32 @@ class BmcSession: def serial_from_inventory_json(payload: str) -> str: """Best-effort extraction of the chassis serial from `lsinventory -j` JSON.""" try: - data = json.loads(payload) + data = json.loads(payload.lstrip().lstrip("'").strip()) except Exception: return "" - if isinstance(data, dict): + if not isinstance(data, dict): + return "" + + def _serial(d: dict) -> str: for k in ("SerialNumber", "Serial", "serial_number", "serial"): - if k in data and data[k]: - return str(data[k]) - ch = data.get("chassis") or data.get("Chassis") - if isinstance(ch, dict): - for k in ("SerialNumber", "Serial"): - if ch.get(k): - return str(ch[k]) + if d.get(k): + return str(d[k]) + return "" + + # 1) top-level serial (other BMC styles) + s = _serial(data) + if s: + return s + # 2) explicit chassis sub-object + ch = data.get("chassis") or data.get("Chassis") + if isinstance(ch, dict) and _serial(ch): + return _serial(ch) + # 3) YADRO style: {component_name: {... "SerialNumber": ...}}. The + # chassis serial lives in the server/chassis component — prefer it. + for pat in ("server", "chassis", "system", "baseboard"): + for key, comp in data.items(): + if pat in key.lower() and isinstance(comp, dict): + s = _serial(comp) + if s: + return s return "" diff --git a/dev/src/vrcx/config.py b/dev/src/vrcx/config.py index e749ce9..e7276cb 100644 --- a/dev/src/vrcx/config.py +++ b/dev/src/vrcx/config.py @@ -31,27 +31,40 @@ CONFIG_TEMPLATE = """\ language = {language} [BMC] -# Default BMC username (Enter accepts this on the prompt). -# Логин BMC по умолчанию (Enter на вопросе примет это значение). +# Default BMC login (Enter accepts these on the prompt). +# Логин/пароль BMC по умолчанию (Enter на вопросе примет эти значения). default_user = admin +default_pass = V36man [OS] # Pre-tick "Collect OS logs too?" prompt by default (yes/no). # Сразу включать вопрос «Собирать ещё и логи с ОС?» как «да» (yes/no). collect_by_default = no -# Default SDS host username. -# Логин SDS по умолчанию. +# SDS host login — used automatically, no prompt. The service OS ships as sds/sds. +# Логин/пароль SDS — подставляются автоматически, без вопросов. Сервисная ОС идёт как sds/sds. default_user = sds +default_pass = sds [Discovery] # Run a /24 ping-sweep to warm the ARP table when SDS IP is unknown (yes/no). # Делать ping-sweep подсети /24 для прогрева ARP, если IP SDS неизвестен (yes/no). ping_sweep = yes +# dhcpsrv lease-table export to read (auto-list BMCs / resolve SDS). Empty = default +# %PROGRAMDATA%\\vegman\\leases.json. The file is used only if fresher than leases_max_age. +# Экспорт таблицы аренд dhcpsrv (автосписок BMC / резолв SDS). Пусто = по умолчанию +# %PROGRAMDATA%\\vegman\\leases.json. Файл берётся, только если свежее leases_max_age. +leases_path = +# Max age of leases.json in seconds to still trust it (dhcpsrv rewrites it every second). +# Макс. возраст leases.json в секундах, при котором ему ещё доверяем (dhcpsrv пишет раз в секунду). +leases_max_age = 120 [Run] # Max BMCs collected in parallel (each host also runs BMC+OS in parallel inside). # Макс. число BMC, опрашиваемых параллельно (внутри хоста BMC+OS идут тоже параллельно). parallel_hosts = 8 +# Open the output folder in Explorer when collection finishes (yes/no). +# Открывать папку с результатом в Проводнике после сбора (yes/no). +open_explorer = yes """ @@ -59,10 +72,15 @@ parallel_hosts = 8 class Config: language: str = DEFAULT_LANG bmc_default_user: str = "admin" + bmc_default_pass: str = "V36man" sds_default_user: str = "sds" + sds_default_pass: str = "sds" collect_by_default: bool = False ping_sweep: bool = True + leases_path: str = "" + leases_max_age: int = 120 parallel_hosts: int = 8 + open_explorer: bool = True def app_dir() -> Path: @@ -139,8 +157,13 @@ def load_config() -> Config: return Config( language = lang, bmc_default_user = cp.get("BMC", "default_user", fallback="admin").strip() or "admin", + bmc_default_pass = cp.get("BMC", "default_pass", fallback="V36man").strip() or "V36man", sds_default_user = cp.get("OS", "default_user", fallback="sds").strip() or "sds", + sds_default_pass = cp.get("OS", "default_pass", fallback="sds").strip() or "sds", collect_by_default = _to_bool(cp.get("OS", "collect_by_default", fallback="no"), False), ping_sweep = _to_bool(cp.get("Discovery", "ping_sweep", fallback="yes"), True), + leases_path = cp.get("Discovery", "leases_path", fallback="").strip(), + leases_max_age = _to_int (cp.get("Discovery", "leases_max_age", fallback="120"), 120), parallel_hosts = _to_int (cp.get("Run", "parallel_hosts", fallback="8"), 8), + open_explorer = _to_bool(cp.get("Run", "open_explorer", fallback="yes"), True), ) diff --git a/dev/src/vrcx/discover.py b/dev/src/vrcx/discover.py index d6d3eee..11aafc7 100644 --- a/dev/src/vrcx/discover.py +++ b/dev/src/vrcx/discover.py @@ -135,11 +135,28 @@ def arp_lookup(macs: list[str]) -> str | None: # ----- Orchestrator ------------------------------------------------------ -def discover_sds_ip(bmc: BmcSession, do_sweep: bool = True) -> str | None: - """Best-effort: BMC → host MACs → ARP. Returns SDS IP or None.""" +def _lookup(macs: list[str], pairs: list[tuple[str, str]]) -> str | None: + target = set(macs) + for ip, mac in pairs: + if mac in target: + return ip + return None + + +def discover_sds_ip(bmc: BmcSession, do_sweep: bool = True, + lease_pairs: list[tuple[str, str]] | None = None) -> str | None: + """Best-effort: BMC → host MACs → (dhcpsrv lease table | ARP). SDS IP or None. + + `lease_pairs` is an optional (ip, normalized_mac) list from dhcpsrv's + export — checked first because it's authoritative and needs no ARP warm-up. + """ macs = redfish_host_mac(bmc) if not macs: return None + if lease_pairs: + hit = _lookup(macs, lease_pairs) + if hit: + return hit hit = arp_lookup(macs) if hit: return hit diff --git a/dev/src/vrcx/i18n.py b/dev/src/vrcx/i18n.py index 0b28484..14e9e83 100644 --- a/dev/src/vrcx/i18n.py +++ b/dev/src/vrcx/i18n.py @@ -25,36 +25,54 @@ STRINGS: dict[str, dict[str, str]] = { # input prompts "targets_rule": "Targets", "enter_bmc_ips": "Enter one or more BMC IP addresses, separated by spaces, commas, or newlines.", + "bmc_ip_hint": "These are BMC addresses only — the OS (SDS) IP for each host is auto-detected via its BMC.", "end_with_blank": "(End input with an empty line.)", "no_ips": "No valid IP addresses entered.", + "bmc_found": "Found {n} BMC(s) in the dhcpsrv lease table:", + "use_found_bmc": "Use these auto-detected BMCs?", "bmc_user": "BMC username", - "bmc_pass": "BMC password (visible)", + "bmc_pass": "BMC password", "ask_collect_os": "Collect OS logs too?", - "sds_user": "SDS username", - "sds_pass": "SDS password (visible)", + "os_creds_note": "SDS IP is auto-detected via the BMC; login is automatic ({user}/{user}, see config.ini).", + + # preflight + "preflight_rule": "Host state", + "pf_work": "Work status", + "pf_power": "Power", # SDS discovery "discovery_rule": "SDS discovery", "resolving_sds": "Resolving SDS IP for {host}...", "discovery_failed": " BMC {host}: discovery failed ({err})", - "sds_resolved": " → SDS IP for {host}: {ip}", + "sds_resolved": " -> SDS IP for {host}: {ip}", + "sds_resolved_mac": " -> SDS IP for {host}: {ip} (matched by MAC)", "sds_not_found": " SDS IP for {host} not auto-discovered.", "manual_sds_prompt": " Enter SDS IP for {host} (empty to skip OS for this host)", # progress / status "host_starting": "{host} starting...", - "host_step_bmc": "{host}/bmc → {label}", - "host_step_os": "{host}/os → {label}", + "host_step_bmc": "{host}/bmc -> {label}", + "host_step_os": "{host}/os -> {label}", "host_done": "{host} done — BMC {bmc_ok}/{bmc_total} ok{os_note}.", "host_failed": "{host} FAILED — {err}", "os_note": ", OS {ok}/{total} ok", + # human-readable hints for common failures + "err_auth": "wrong username or password", + "err_timeout": "host not responding (timeout) — check the IP and that it's on the network", + "err_dns": "can't resolve the address — check the IP/hostname", + "err_refused": "connection refused — nothing listening on SSH (port 22) at this IP", + "err_banner": "no SSH banner — usually the wrong host/port or an extremely slow reply", + "err_noroute": "network unreachable — no route to this host", + # finalization "aborted": "Aborted by user — removing the incomplete session folder...", "removed": "Removed: {path}", "bundle_ready": "Bundle ready: {path}", "aborted_msg": "Aborted. Session folder removed.", - "done": "Done. Bundle: {path}", + "done": "Done.", + "folder_label": "Folder", + "archive_label": "Archive", "press_enter": "Press Enter to exit", # UI table @@ -78,40 +96,56 @@ STRINGS: dict[str, dict[str, str]] = { "targets_rule": "Цели", "enter_bmc_ips": "Введи один или несколько IP-адресов BMC через пробел, запятую или с новой строки.", + "bmc_ip_hint": "Это только адреса BMC — IP сервисной ОС (SDS) для каждого хоста определяется автоматически через его BMC.", "end_with_blank": "(Закончи ввод пустой строкой.)", "no_ips": "Корректные IP-адреса не введены.", + "bmc_found": "Найдено BMC в таблице аренд dhcpsrv: {n}", + "use_found_bmc": "Использовать найденные BMC?", "bmc_user": "Логин BMC", - "bmc_pass": "Пароль BMC (виден на экране)", + "bmc_pass": "Пароль BMC", "ask_collect_os": "Собирать ещё и логи с ОС?", - "sds_user": "Логин SDS", - "sds_pass": "Пароль SDS (виден на экране)", + "os_creds_note": "IP SDS определю сам через BMC; вход автоматический ({user}/{user}, см. config.ini).", + + "preflight_rule": "Состояние хостов", + "pf_work": "Работа", + "pf_power": "Питание", "discovery_rule": "Поиск SDS", "resolving_sds": "Ищу IP SDS для {host}...", "discovery_failed": " BMC {host}: поиск не удался ({err})", - "sds_resolved": " → IP SDS для {host}: {ip}", + "sds_resolved": " -> IP SDS для {host}: {ip}", + "sds_resolved_mac": " -> IP SDS для {host}: {ip} (по MAC)", "sds_not_found": " IP SDS для {host} автоматически не найден.", "manual_sds_prompt": " Введи IP SDS для {host} (пусто — пропустить ОС для этого хоста)", "host_starting": "{host} стартую...", - "host_step_bmc": "{host}/bmc → {label}", - "host_step_os": "{host}/os → {label}", + "host_step_bmc": "{host}/bmc -> {label}", + "host_step_os": "{host}/os -> {label}", "host_done": "{host} готово — BMC {bmc_ok}/{bmc_total} ок{os_note}.", "host_failed": "{host} ОШИБКА — {err}", "os_note": ", OS {ok}/{total} ок", + "err_auth": "неверный логин или пароль", + "err_timeout": "хост не отвечает (таймаут) — проверь IP и что он в сети", + "err_dns": "не удаётся разрешить адрес — проверь IP/имя", + "err_refused": "соединение отклонено — на этом IP нет SSH (порт 22)", + "err_banner": "нет SSH-баннера — обычно не тот хост/порт или очень медленный отклик", + "err_noroute": "сеть недоступна — нет маршрута до хоста", + "aborted": "Отменено пользователем — удаляю незавершённую сессию...", "removed": "Удалено: {path}", "bundle_ready": "Архив готов: {path}", "aborted_msg": "Отменено. Папка сессии удалена.", - "done": "Готово. Архив: {path}", + "done": "Готово.", + "folder_label": "Папка", + "archive_label": "Архив", "press_enter": "Нажми Enter для выхода", "col_host": "Хост", "col_status": "Статус", "col_step": "Шаг", "col_okfail": "ОК/Ош", - "col_serial": "Серийник", + "col_serial": "Серийный номер", "col_note": "Примечание", "events_title": "События", "hosts_title": "Хосты", diff --git a/dev/src/vrcx/leases_src.py b/dev/src/vrcx/leases_src.py new file mode 100644 index 0000000..e9757e2 --- /dev/null +++ b/dev/src/vrcx/leases_src.py @@ -0,0 +1,109 @@ +""" +Reader for the dhcpsrv lease-table contract (see dhcpsrv/leases.py). + +dhcpsrv periodically writes %PROGRAMDATA%\\vegman\\leases.json. We read it as +an optional convenience: if a fresh file is present we can auto-list BMCs and +resolve SDS IPs straight from the DHCP table. If it's missing or stale we +silently fall back to manual entry — the two tools stay fully decoupled. +""" + +from __future__ import annotations +import json +import os +import re +import time +from pathlib import Path + +_HEX = re.compile(r"[^0-9a-f]") + + +def _norm_mac(mac: str) -> str: + return _HEX.sub("", (mac or "").lower()) + + +def leases_path(configured: str | None = None) -> Path: + if configured: + return Path(configured) + base = os.environ.get("PROGRAMDATA") or os.environ.get("LOCALAPPDATA") or "." + return Path(base) / "vegman" / "leases.json" + + +def load_leases(configured: str | None, max_age_s: int) -> list[dict] | None: + """Return the lease entries if the file exists and is fresh, else None. + + Freshness is judged by file mtime (dhcpsrv rewrites it every tick), so a + crashed/stopped server's stale table is ignored rather than trusted. + """ + path = leases_path(configured) + try: + age = time.time() - path.stat().st_mtime + except OSError: + return None + if max_age_s and age > max_age_s: + return None + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + leases = data.get("leases") if isinstance(data, dict) else None + if not isinstance(leases, list): + return None + out: list[dict] = [] + for e in leases: + if not isinstance(e, dict) or not e.get("ip"): + continue + out.append({ + "ip": str(e.get("ip")), + "mac": _norm_mac(e.get("mac", "")), + "host": str(e.get("host", "")), + "ping_ok": bool(e.get("ping_ok")), + }) + return out + + +def mac_to_ip(entries: list[dict]) -> list[tuple[str, str]]: + """(ip, normalized_mac) pairs — same shape as discover.arp_table().""" + return [(e["ip"], e["mac"]) for e in entries if e["mac"]] + + +def _mac_int(mac: str) -> int | None: + return int(mac, 16) if len(mac) == 12 else None + + +def sds_by_mac_neighbour(bmc_ip: str, entries: list[dict]) -> str | None: + """Pair an SDS host to a BMC by MAC in the dhcpsrv table — Redfish-free. + + On YADRO Vegman the BMC NIC and the host (SDS) NIC differ only in the last + MAC character (e.g. ...c8:b[b] ↔ ...c8:b[d]). So the SDS is the `sds` entry + whose MAC matches the BMC's on every nibble but the last (`mac >> 4` equal). + A shared 11-nibble prefix is a 16-address block, so a mismatched pair is + practically impossible; if several somehow qualify, we don't guess. + """ + bmc_mac = next((e["mac"] for e in entries if e["ip"] == bmc_ip), "") + bi = _mac_int(bmc_mac) + if bi is None: + return None + matches = [ + e["ip"] for e in entries + if "sds" in e["host"].lower() + and (mi := _mac_int(e["mac"])) is not None + and mi != bi + and (mi >> 4) == (bi >> 4) + ] + return matches[0] if len(matches) == 1 else None + + +def bmc_candidates(entries: list[dict]) -> list[tuple[str, str]]: + """Live hosts that look like a BMC (hostname matches vegman/bmc, and is + not the SDS service OS). Returns (ip, host) sorted by IP.""" + out = [] + for e in entries: + host = e["host"].lower() + if not e["ping_ok"]: + continue + if "sds" in host: + continue + if "vegman" in host or "bmc" in host: + out.append((e["ip"], e["host"])) + out.sort(key=lambda t: tuple(int(x) for x in t[0].split(".") if x.isdigit())) + return out diff --git a/dev/src/vrcx/preflight.py b/dev/src/vrcx/preflight.py new file mode 100644 index 0000000..567ef89 --- /dev/null +++ b/dev/src/vrcx/preflight.py @@ -0,0 +1,99 @@ +""" +Pre-collection probe: a quick per-BMC snapshot shown before the long log +collection starts — is the host up, what state it's in, and the firmware +versions. Mirrors the at-a-glance table of the original VRC GUI. + +Sources (YADRO BMC): + `bmc info version` -> "Host " and "BMC " lines + `host power status` -> "Current Chassis state: ", + "Current Host state: " + FPGA version -> Redfish firmware inventory (best-effort; "—" if + unavailable, e.g. Redfish unreachable). +""" + +from __future__ import annotations +import json +from concurrent.futures import ThreadPoolExecutor + +from .bmc import BmcSession + +DASH = "—" + + +def _parse_version(text: str) -> tuple[str, str]: + """From `bmc info version` → (bmc_version, uefi/host_version).""" + bmc = uefi = "" + for line in text.splitlines(): + parts = line.split(None, 1) + if len(parts) != 2: + continue + label, value = parts[0].strip().lower(), parts[1].strip() + if label == "bmc": + bmc = value + elif label == "host": + uefi = value + return bmc, uefi + + +def _parse_power(text: str) -> tuple[str, str]: + """From `host power status` → (power/chassis state, work/host state).""" + power = work = "" + for line in text.splitlines(): + low = line.lower() + if "chassis state" in low and ":" in line: + power = line.split(":", 1)[1].strip() + elif "host state" in low and ":" in line: + work = line.split(":", 1)[1].strip() + return power, work + + +def _fpga_from_redfish(bmc: BmcSession) -> str: + """Best-effort FPGA/CPLD firmware version from Redfish. '' on any failure.""" + try: + inv = json.loads(bmc.redfish( + "/redfish/v1/UpdateService/FirmwareInventory").decode("utf-8", "replace")) + except Exception: + return "" + for member in inv.get("Members", []) or []: + mid = (member.get("@odata.id") or "") + if not any(tag in mid.lower() for tag in ("fpga", "cpld")): + continue + try: + doc = json.loads(bmc.redfish(mid.rstrip("/")).decode("utf-8", "replace")) + except Exception: + continue + ver = doc.get("Version") + if ver: + return str(ver) + return "" + + +def probe_host(host: str, user: str, password: str) -> dict: + """Connect, read versions + power/work state. Never raises.""" + info = {"host": host, "ok": False, "power": DASH, "work": DASH, + "bmc": DASH, "uefi": DASH, "fpga": DASH, "error": ""} + try: + bmc = BmcSession(host=host, user=user, password=password) + bmc.open() + try: + b, u = _parse_version(bmc.cli("bmc info version")) + p, w = _parse_power(bmc.cli("host power status")) + fpga = _fpga_from_redfish(bmc) + info.update(ok=True, + bmc=b or DASH, uefi=u or DASH, + power=p or DASH, work=w or DASH, + fpga=fpga or DASH) + finally: + bmc.close() + except Exception as exc: + info["error"] = str(exc) + return info + + +def probe_hosts(hosts: list[str], user: str, password: str, + max_workers: int = 8) -> dict[str, dict]: + """Probe all hosts in parallel. Returns {host: info}.""" + workers = min(max(1, max_workers), max(1, len(hosts))) + with ThreadPoolExecutor(max_workers=workers) as ex: + results = list(ex.map(lambda h: probe_host(h, user, password), hosts)) + return {r["host"]: r for r in results} diff --git a/dev/src/vrcx/sds.py b/dev/src/vrcx/sds.py index 4bfc1b4..a9eba07 100644 --- a/dev/src/vrcx/sds.py +++ b/dev/src/vrcx/sds.py @@ -62,9 +62,13 @@ class SdsSession: return out + (f"\n[stderr]\n{err}" if err.strip() else "") # ---------- public API ---------- + # Everything runs as root: the SDS account is a low-priv service login, + # but collection (dmidecode, dmesg, storcli, journal…) needs root. Rather + # than an interactive `sudo -s` shell, every command is funnelled through + # the password-fed `sudo -S` wrapper — same effect, robust over exec. def shell(self, cmd: str, timeout: int = 60) -> str: - """Run a plain shell command, no sudo.""" - return self._exec(cmd, timeout=timeout) + """Run a shell command as root.""" + return self.sudo(cmd, timeout=timeout) def sudo(self, cmd: str, timeout: int = 600) -> str: """Run `cmd` as root by piping the user's password into `sudo -S`. @@ -78,7 +82,7 @@ class SdsSession: return self._exec(wrapped, timeout=timeout) def cat(self, path: str, timeout: int = 60) -> str: - return self._exec(f"cat {shlex.quote(path)}", timeout=timeout) + return self.sudo(f"cat {shlex.quote(path)}", timeout=timeout) def journal(self, unit: str, since: str | None = None, timeout: int = 120) -> str: @@ -87,7 +91,7 @@ class SdsSession: parts += ["-u", shlex.quote(unit)] if since: parts += ["--since", shlex.quote(since)] - return self._exec(" ".join(parts), timeout=timeout) + return self.sudo(" ".join(parts), timeout=timeout) # ---------- file transfer ---------- def _ensure_sftp(self) -> paramiko.SFTPClient: diff --git a/dev/src/vrcx/tarball.py b/dev/src/vrcx/tarball.py index 0105db3..7af14c1 100644 --- a/dev/src/vrcx/tarball.py +++ b/dev/src/vrcx/tarball.py @@ -1,26 +1,18 @@ """ Output layout & tarball packaging. -Layout under `/out/`: +Layout under `/out/` — the archive is the only artefact (the unpacked +session folder is removed after packing): out/ - └── / - ├── / - │ ├── bmc/ collected from the BMC - │ └── os/ collected from the SDS host (optional) - ├── / - │ ├── bmc/ - │ └── os/ - ├── archives/ - │ ├── dump_.tar.gz - │ └── dump_.tar.gz - ├── vrc.log - ├── err_out.log - └── .tar.gz (one-click bundle for support) + └── .tar.gz one-click bundle for support; opens to + /{bmc,os}/… + vrc.log directly + (no / wrapper, no nested archives) """ from __future__ import annotations import re +import shutil import tarfile from datetime import datetime from pathlib import Path @@ -53,16 +45,6 @@ def make_per_host_dir(session_dir: Path, host: str) -> Path: return d -def make_archives_dir(session_dir: Path) -> Path: - d = session_dir / "archives" - d.mkdir(parents=True, exist_ok=True) - return d - - -def per_host_archive_name(host: str) -> str: - return f"dump_{safe_id(host)}.tar.gz" - - def tar_directory(src_dir: Path, dst_tar: Path) -> None: """Create `dst_tar` (.tar.gz) containing `src_dir` (and its tree). The archive's top-level entry is the directory itself.""" @@ -71,17 +53,15 @@ def tar_directory(src_dir: Path, dst_tar: Path) -> None: def finalize_session(session_dir: Path) -> Path: - """For each per-host directory, write archives/dump_.tar.gz and drop - the unpacked folder. Then pack the whole session into .tar.gz.""" - archives = make_archives_dir(session_dir) - for child in list(session_dir.iterdir()): - if not child.is_dir() or child.name == "archives": - continue - tar_directory(child, archives / per_host_archive_name(child.name)) - for p in sorted(child.rglob("*"), reverse=True): - if p.is_file(): p.unlink(missing_ok=True) - elif p.is_dir(): p.rmdir() - child.rmdir() + """Pack the session into a single .tar.gz next to it, then remove + the unpacked folder — the archive is the only artefact left in out/. + + The archive's top level is the session contents directly (`/{bmc,os}`, + `vrc.log`), without an extra `/` wrapper or nested per-host tarballs — + so opening it shows the hosts immediately, minimal nesting.""" outer = session_dir.parent / f"{session_dir.name}.tar.gz" - tar_directory(session_dir, outer) + with tarfile.open(outer, "w:gz") as tf: + for child in sorted(session_dir.iterdir()): + tf.add(child, arcname=child.name) + shutil.rmtree(session_dir, ignore_errors=True) return outer