Release 0.2.0: dhcpsrv lease auto-discovery, SDS MAC pairing, preflight, flat archive
Release / build (push) Has been cancelled

- Auto-pick BMCs from the dhcpsrv lease table; resolve SDS by neighbouring MAC
- Preflight host-state table (power/work, BMC/UEFI/FPGA) before collection
- Single flat tar.gz bundle (no nested per-host archives); auto-open output dir
- BMC default creds admin/V36man; SDS commands run as root; clearer SSH error hints
- Pin paramiko<4 (5.x dropped ssh-rsa host keys that YADRO BMCs require)
- Timestamped crash reports under crashlogs/
This commit is contained in:
Engelgardt23
2026-06-03 17:55:38 +03:00
parent 77d02c65cf
commit 9fe967f0b2
12 changed files with 547 additions and 89 deletions
+3
View File
@@ -24,3 +24,6 @@ Thumbs.db
# Local scratch: squashfs/ISO extracts during dev inspection # Local scratch: squashfs/ISO extracts during dev inspection
.sds_inspect/ .sds_inspect/
# Stray runtime config.ini generated next to source in dev mode
dev/src/vrcx/config.ini
+3 -1
View File
@@ -9,7 +9,9 @@ readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
license = { text = "MIT" } license = { text = "MIT" }
authors = [{ name = "engelgardt" }] 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"] dynamic = ["version"]
[project.urls] [project.urls]
+1 -1
View File
@@ -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 The single source of truth for the project version. Bump this before tagging
a release; CI reads the tag, the code reads this constant.""" 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) REPO = "engel/vrcx" # self-hosted Gitea (git.engelgardt23.ru)
+180 -16
View File
@@ -24,6 +24,8 @@ from .bmc import BmcSession
from .collector import collect_host from .collector import collect_host
from .config import load_config, Config from .config import load_config, Config
from .discover import discover_sds_ip 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 .i18n import set_language, t
from .os_collector import collect_host_os from .os_collector import collect_host_os
from .platform_win import enable_vt 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)] 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 @dataclass
class Inputs: class Inputs:
hosts: list[str] hosts: list[str]
@@ -53,9 +77,9 @@ class Inputs:
sds_pass: str sds_pass: str
def _prompt_inputs(console: Console, cfg: Config) -> Inputs | None: def _enter_bmc_ips_manually(console: Console) -> list[str] | None:
console.rule(f"[bold cyan]{t('targets_rule')}")
console.print(t("enter_bmc_ips")) console.print(t("enter_bmc_ips"))
console.print(f"[dim]{t('bmc_ip_hint')}[/]")
console.print(f"[dim]{t('end_with_blank')}[/]") console.print(f"[dim]{t('end_with_blank')}[/]")
lines: list[str] = [] lines: list[str] = []
while True: while True:
@@ -69,35 +93,65 @@ def _prompt_inputs(console: Console, cfg: Config) -> Inputs | None:
if not line.strip(): if not line.strip():
continue continue
lines.append(line) 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: if not hosts:
console.print(f"[red]{t('no_ips')}[/]") console.print(f"[red]{t('no_ips')}[/]")
return None return None
console.print() console.print()
bmc_user = Prompt.ask(t("bmc_user"), default=cfg.bmc_default_user) 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() console.print()
collect_os = Confirm.ask(t("ask_collect_os"), default=cfg.collect_by_default) 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: if collect_os:
sds_user = Prompt.ask(t("sds_user"), default=cfg.sds_default_user) console.print(f"[dim]{t('os_creds_note', user=sds_user)}[/]")
sds_pass = Prompt.ask(t("sds_pass"), default="sds")
return Inputs(hosts=hosts, bmc_user=bmc_user, bmc_pass=bmc_pass, return Inputs(hosts=hosts, bmc_user=bmc_user, bmc_pass=bmc_pass,
collect_os=collect_os, sds_user=sds_user, sds_pass=sds_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, 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)}[/]") 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: try:
with BmcSession(host=host, user=bmc_user, password=bmc_pass) as bmc: bmc = BmcSession(host=host, user=bmc_user, password=bmc_pass)
ip = discover_sds_ip(bmc, do_sweep=cfg.ping_sweep) ip = discover_sds_ip(bmc, do_sweep=cfg.ping_sweep, lease_pairs=lease_pairs)
except Exception as exc: 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: if ip:
console.print(f"[green]{t('sds_resolved', host=host, ip=ip)}[/]") console.print(f"[green]{t('sds_resolved', host=host, ip=ip)}[/]")
return ip return ip
@@ -155,6 +209,7 @@ def _host_worker(host: str, bmc_user: str, bmc_pass: str,
else: else:
ui.set_status(host, "ERROR") ui.set_status(host, "ERROR")
err = (bmc_summary.get("error") or "") if not bmc_ok else (os_summary.get("error") or "") 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.set_summary(host, total_ok, total_fail, bmc_summary.get("serial", ""), err[:80])
ui.log(f"[red]{t('host_failed', host=host, err=err)}[/]") ui.log(f"[red]{t('host_failed', host=host, err=err)}[/]")
@@ -182,24 +237,119 @@ def _print_header(console: Console) -> None:
console.print() 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: 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() 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() cfg = load_config()
set_language(cfg.language) set_language(cfg.language)
console = Console(log_path=False) console = Console(log_path=False)
_print_header(console) _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: if inputs is None:
input(t("press_enter")); return input(t("press_enter")); return
_show_preflight(console, inputs.hosts, inputs.bmc_user, inputs.bmc_pass)
sds_ips: dict[str, str | None] = {} sds_ips: dict[str, str | None] = {}
if inputs.collect_os: if inputs.collect_os:
console.rule(f"[bold cyan]{t('discovery_rule')}") console.rule(f"[bold cyan]{t('discovery_rule')}")
for h in inputs.hosts: 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} enabled = {h for h, ip in sds_ips.items() if ip}
if getattr(sys, "frozen", False): if getattr(sys, "frozen", False):
@@ -214,7 +364,14 @@ def main() -> None:
hosts=inputs.hosts, os_enabled=enabled) hosts=inputs.hosts, os_enabled=enabled)
stop = threading.Event() 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() ui_thread.start()
summaries: list[dict] = [] summaries: list[dict] = []
@@ -264,7 +421,14 @@ def main() -> None:
if aborted: if aborted:
console.print(f"[yellow]{t('aborted_msg')}[/]") console.print(f"[yellow]{t('aborted_msg')}[/]")
else: 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")) input(t("press_enter"))
+33 -10
View File
@@ -106,10 +106,17 @@ class BmcSession:
# parts[2]: prompt after the second sentinel # parts[2]: prompt after the second sentinel
if len(parts) >= 3: if len(parts) >= 3:
out = parts[1] out = parts[1]
# parts[1] opens with the tail of the echoed command line
# (the closing "'" of `echo '<sentinel>'` + 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: else:
# Sentinel didn't appear twice — return what we have for diagnostics. # Sentinel didn't appear twice — return what we have for diagnostics.
out = buf 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: def shell(self, cmd: str, timeout: int = 30) -> str:
"""Raw shell command via SSH exec_command (no PTY, no YADRO CLI).""" """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: def serial_from_inventory_json(payload: str) -> str:
"""Best-effort extraction of the chassis serial from `lsinventory -j` JSON.""" """Best-effort extraction of the chassis serial from `lsinventory -j` JSON."""
try: try:
data = json.loads(payload) data = json.loads(payload.lstrip().lstrip("'").strip())
except Exception: except Exception:
return "" return ""
if isinstance(data, dict): if not isinstance(data, dict):
return ""
def _serial(d: dict) -> str:
for k in ("SerialNumber", "Serial", "serial_number", "serial"): for k in ("SerialNumber", "Serial", "serial_number", "serial"):
if k in data and data[k]: if d.get(k):
return str(data[k]) return str(d[k])
ch = data.get("chassis") or data.get("Chassis") return ""
if isinstance(ch, dict):
for k in ("SerialNumber", "Serial"): # 1) top-level serial (other BMC styles)
if ch.get(k): s = _serial(data)
return str(ch[k]) 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 "" return ""
+27 -4
View File
@@ -31,27 +31,40 @@ CONFIG_TEMPLATE = """\
language = {language} language = {language}
[BMC] [BMC]
# Default BMC username (Enter accepts this on the prompt). # Default BMC login (Enter accepts these on the prompt).
# Логин BMC по умолчанию (Enter на вопросе примет это значение). # Логин/пароль BMC по умолчанию (Enter на вопросе примет эти значения).
default_user = admin default_user = admin
default_pass = V36man
[OS] [OS]
# Pre-tick "Collect OS logs too?" prompt by default (yes/no). # Pre-tick "Collect OS logs too?" prompt by default (yes/no).
# Сразу включать вопрос «Собирать ещё и логи с ОС?» как «да» (yes/no). # Сразу включать вопрос «Собирать ещё и логи с ОС?» как «да» (yes/no).
collect_by_default = no collect_by_default = no
# Default SDS host username. # SDS host login — used automatically, no prompt. The service OS ships as sds/sds.
# Логин SDS по умолчанию. # Логин/пароль SDS — подставляются автоматически, без вопросов. Сервисная ОС идёт как sds/sds.
default_user = sds default_user = sds
default_pass = sds
[Discovery] [Discovery]
# Run a /24 ping-sweep to warm the ARP table when SDS IP is unknown (yes/no). # 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 подсети /24 для прогрева ARP, если IP SDS неизвестен (yes/no).
ping_sweep = yes 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] [Run]
# Max BMCs collected in parallel (each host also runs BMC+OS in parallel inside). # Max BMCs collected in parallel (each host also runs BMC+OS in parallel inside).
# Макс. число BMC, опрашиваемых параллельно (внутри хоста BMC+OS идут тоже параллельно). # Макс. число BMC, опрашиваемых параллельно (внутри хоста BMC+OS идут тоже параллельно).
parallel_hosts = 8 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: class Config:
language: str = DEFAULT_LANG language: str = DEFAULT_LANG
bmc_default_user: str = "admin" bmc_default_user: str = "admin"
bmc_default_pass: str = "V36man"
sds_default_user: str = "sds" sds_default_user: str = "sds"
sds_default_pass: str = "sds"
collect_by_default: bool = False collect_by_default: bool = False
ping_sweep: bool = True ping_sweep: bool = True
leases_path: str = ""
leases_max_age: int = 120
parallel_hosts: int = 8 parallel_hosts: int = 8
open_explorer: bool = True
def app_dir() -> Path: def app_dir() -> Path:
@@ -139,8 +157,13 @@ def load_config() -> Config:
return Config( return Config(
language = lang, language = lang,
bmc_default_user = cp.get("BMC", "default_user", fallback="admin").strip() or "admin", 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_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), 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), 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), parallel_hosts = _to_int (cp.get("Run", "parallel_hosts", fallback="8"), 8),
open_explorer = _to_bool(cp.get("Run", "open_explorer", fallback="yes"), True),
) )
+19 -2
View File
@@ -135,11 +135,28 @@ def arp_lookup(macs: list[str]) -> str | None:
# ----- Orchestrator ------------------------------------------------------ # ----- Orchestrator ------------------------------------------------------
def discover_sds_ip(bmc: BmcSession, do_sweep: bool = True) -> str | None: def _lookup(macs: list[str], pairs: list[tuple[str, str]]) -> str | None:
"""Best-effort: BMC → host MACs → ARP. Returns SDS IP or 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) macs = redfish_host_mac(bmc)
if not macs: if not macs:
return None return None
if lease_pairs:
hit = _lookup(macs, lease_pairs)
if hit:
return hit
hit = arp_lookup(macs) hit = arp_lookup(macs)
if hit: if hit:
return hit return hit
+49 -15
View File
@@ -25,36 +25,54 @@ STRINGS: dict[str, dict[str, str]] = {
# input prompts # input prompts
"targets_rule": "Targets", "targets_rule": "Targets",
"enter_bmc_ips": "Enter one or more BMC IP addresses, separated by spaces, commas, or newlines.", "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.)", "end_with_blank": "(End input with an empty line.)",
"no_ips": "No valid IP addresses entered.", "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_user": "BMC username",
"bmc_pass": "BMC password (visible)", "bmc_pass": "BMC password",
"ask_collect_os": "Collect OS logs too?", "ask_collect_os": "Collect OS logs too?",
"sds_user": "SDS username", "os_creds_note": "SDS IP is auto-detected via the BMC; login is automatic ({user}/{user}, see config.ini).",
"sds_pass": "SDS password (visible)",
# preflight
"preflight_rule": "Host state",
"pf_work": "Work status",
"pf_power": "Power",
# SDS discovery # SDS discovery
"discovery_rule": "SDS discovery", "discovery_rule": "SDS discovery",
"resolving_sds": "Resolving SDS IP for {host}...", "resolving_sds": "Resolving SDS IP for {host}...",
"discovery_failed": " BMC {host}: discovery failed ({err})", "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.", "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)", "manual_sds_prompt": " Enter SDS IP for {host} (empty to skip OS for this host)",
# progress / status # progress / status
"host_starting": "{host} starting...", "host_starting": "{host} starting...",
"host_step_bmc": "{host}/bmc {label}", "host_step_bmc": "{host}/bmc -> {label}",
"host_step_os": "{host}/os {label}", "host_step_os": "{host}/os -> {label}",
"host_done": "{host} done — BMC {bmc_ok}/{bmc_total} ok{os_note}.", "host_done": "{host} done — BMC {bmc_ok}/{bmc_total} ok{os_note}.",
"host_failed": "{host} FAILED — {err}", "host_failed": "{host} FAILED — {err}",
"os_note": ", OS {ok}/{total} ok", "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 # finalization
"aborted": "Aborted by user — removing the incomplete session folder...", "aborted": "Aborted by user — removing the incomplete session folder...",
"removed": "Removed: {path}", "removed": "Removed: {path}",
"bundle_ready": "Bundle ready: {path}", "bundle_ready": "Bundle ready: {path}",
"aborted_msg": "Aborted. Session folder removed.", "aborted_msg": "Aborted. Session folder removed.",
"done": "Done. Bundle: {path}", "done": "Done.",
"folder_label": "Folder",
"archive_label": "Archive",
"press_enter": "Press Enter to exit", "press_enter": "Press Enter to exit",
# UI table # UI table
@@ -78,40 +96,56 @@ STRINGS: dict[str, dict[str, str]] = {
"targets_rule": "Цели", "targets_rule": "Цели",
"enter_bmc_ips": "Введи один или несколько IP-адресов BMC через пробел, запятую или с новой строки.", "enter_bmc_ips": "Введи один или несколько IP-адресов BMC через пробел, запятую или с новой строки.",
"bmc_ip_hint": "Это только адреса BMC — IP сервисной ОС (SDS) для каждого хоста определяется автоматически через его BMC.",
"end_with_blank": "(Закончи ввод пустой строкой.)", "end_with_blank": "(Закончи ввод пустой строкой.)",
"no_ips": "Корректные IP-адреса не введены.", "no_ips": "Корректные IP-адреса не введены.",
"bmc_found": "Найдено BMC в таблице аренд dhcpsrv: {n}",
"use_found_bmc": "Использовать найденные BMC?",
"bmc_user": "Логин BMC", "bmc_user": "Логин BMC",
"bmc_pass": "Пароль BMC (виден на экране)", "bmc_pass": "Пароль BMC",
"ask_collect_os": "Собирать ещё и логи с ОС?", "ask_collect_os": "Собирать ещё и логи с ОС?",
"sds_user": "Логин SDS", "os_creds_note": "IP SDS определю сам через BMC; вход автоматический ({user}/{user}, см. config.ini).",
"sds_pass": "Пароль SDS (виден на экране)",
"preflight_rule": "Состояние хостов",
"pf_work": "Работа",
"pf_power": "Питание",
"discovery_rule": "Поиск SDS", "discovery_rule": "Поиск SDS",
"resolving_sds": "Ищу IP SDS для {host}...", "resolving_sds": "Ищу IP SDS для {host}...",
"discovery_failed": " BMC {host}: поиск не удался ({err})", "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} автоматически не найден.", "sds_not_found": " IP SDS для {host} автоматически не найден.",
"manual_sds_prompt": " Введи IP SDS для {host} (пусто — пропустить ОС для этого хоста)", "manual_sds_prompt": " Введи IP SDS для {host} (пусто — пропустить ОС для этого хоста)",
"host_starting": "{host} стартую...", "host_starting": "{host} стартую...",
"host_step_bmc": "{host}/bmc {label}", "host_step_bmc": "{host}/bmc -> {label}",
"host_step_os": "{host}/os {label}", "host_step_os": "{host}/os -> {label}",
"host_done": "{host} готово — BMC {bmc_ok}/{bmc_total} ок{os_note}.", "host_done": "{host} готово — BMC {bmc_ok}/{bmc_total} ок{os_note}.",
"host_failed": "{host} ОШИБКА — {err}", "host_failed": "{host} ОШИБКА — {err}",
"os_note": ", OS {ok}/{total} ок", "os_note": ", OS {ok}/{total} ок",
"err_auth": "неверный логин или пароль",
"err_timeout": "хост не отвечает (таймаут) — проверь IP и что он в сети",
"err_dns": "не удаётся разрешить адрес — проверь IP/имя",
"err_refused": "соединение отклонено — на этом IP нет SSH (порт 22)",
"err_banner": "нет SSH-баннера — обычно не тот хост/порт или очень медленный отклик",
"err_noroute": "сеть недоступна — нет маршрута до хоста",
"aborted": "Отменено пользователем — удаляю незавершённую сессию...", "aborted": "Отменено пользователем — удаляю незавершённую сессию...",
"removed": "Удалено: {path}", "removed": "Удалено: {path}",
"bundle_ready": "Архив готов: {path}", "bundle_ready": "Архив готов: {path}",
"aborted_msg": "Отменено. Папка сессии удалена.", "aborted_msg": "Отменено. Папка сессии удалена.",
"done": "Готово. Архив: {path}", "done": "Готово.",
"folder_label": "Папка",
"archive_label": "Архив",
"press_enter": "Нажми Enter для выхода", "press_enter": "Нажми Enter для выхода",
"col_host": "Хост", "col_host": "Хост",
"col_status": "Статус", "col_status": "Статус",
"col_step": "Шаг", "col_step": "Шаг",
"col_okfail": "ОК/Ош", "col_okfail": "ОК/Ош",
"col_serial": "Серийник", "col_serial": "Серийный номер",
"col_note": "Примечание", "col_note": "Примечание",
"events_title": "События", "events_title": "События",
"hosts_title": "Хосты", "hosts_title": "Хосты",
+109
View File
@@ -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
+99
View File
@@ -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 <uefi>" and "BMC <bmc>" lines
`host power status` -> "Current Chassis state: <power>",
"Current Host state: <work>"
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}
+8 -4
View File
@@ -62,9 +62,13 @@ class SdsSession:
return out + (f"\n[stderr]\n{err}" if err.strip() else "") return out + (f"\n[stderr]\n{err}" if err.strip() else "")
# ---------- public API ---------- # ---------- 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: def shell(self, cmd: str, timeout: int = 60) -> str:
"""Run a plain shell command, no sudo.""" """Run a shell command as root."""
return self._exec(cmd, timeout=timeout) return self.sudo(cmd, timeout=timeout)
def sudo(self, cmd: str, timeout: int = 600) -> str: def sudo(self, cmd: str, timeout: int = 600) -> str:
"""Run `cmd` as root by piping the user's password into `sudo -S`. """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) return self._exec(wrapped, timeout=timeout)
def cat(self, path: str, timeout: int = 60) -> str: 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, def journal(self, unit: str, since: str | None = None,
timeout: int = 120) -> str: timeout: int = 120) -> str:
@@ -87,7 +91,7 @@ class SdsSession:
parts += ["-u", shlex.quote(unit)] parts += ["-u", shlex.quote(unit)]
if since: if since:
parts += ["--since", shlex.quote(since)] parts += ["--since", shlex.quote(since)]
return self._exec(" ".join(parts), timeout=timeout) return self.sudo(" ".join(parts), timeout=timeout)
# ---------- file transfer ---------- # ---------- file transfer ----------
def _ensure_sftp(self) -> paramiko.SFTPClient: def _ensure_sftp(self) -> paramiko.SFTPClient:
+16 -36
View File
@@ -1,26 +1,18 @@
""" """
Output layout & tarball packaging. Output layout & tarball packaging.
Layout under `<repo>/out/`: Layout under `<repo>/out/` — the archive is the only artefact (the unpacked
session folder is removed after packing):
out/ out/
└── <DDMMYYYY_HHMMSS>/ └── <DDMMYYYY_HHMMSS>.tar.gz one-click bundle for support; opens to
├── <bmc_ip>/ <bmc_ip>/{bmc,os}/… + vrc.log directly
│ ├── bmc/ collected from the BMC (no <ts>/ wrapper, no nested archives)
│ └── os/ collected from the SDS host (optional)
├── <bmc_ip_2>/
│ ├── bmc/
│ └── os/
├── archives/
│ ├── dump_<bmc_ip>.tar.gz
│ └── dump_<bmc_ip_2>.tar.gz
├── vrc.log
├── err_out.log
└── <DDMMYYYY_HHMMSS>.tar.gz (one-click bundle for support)
""" """
from __future__ import annotations from __future__ import annotations
import re import re
import shutil
import tarfile import tarfile
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@@ -53,16 +45,6 @@ def make_per_host_dir(session_dir: Path, host: str) -> Path:
return d 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: def tar_directory(src_dir: Path, dst_tar: Path) -> None:
"""Create `dst_tar` (.tar.gz) containing `src_dir` (and its tree). The """Create `dst_tar` (.tar.gz) containing `src_dir` (and its tree). The
archive's top-level entry is the directory itself.""" 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: def finalize_session(session_dir: Path) -> Path:
"""For each per-host directory, write archives/dump_<host>.tar.gz and drop """Pack the session into a single <session>.tar.gz next to it, then remove
the unpacked folder. Then pack the whole session into <session>.tar.gz.""" the unpacked folder — the archive is the only artefact left in out/.
archives = make_archives_dir(session_dir)
for child in list(session_dir.iterdir()): The archive's top level is the session contents directly (`<ip>/{bmc,os}`,
if not child.is_dir() or child.name == "archives": `vrc.log`), without an extra `<ts>/` wrapper or nested per-host tarballs —
continue so opening it shows the hosts immediately, minimal nesting."""
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()
outer = session_dir.parent / f"{session_dir.name}.tar.gz" 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 return outer