Release 0.2.0: dhcpsrv lease auto-discovery, SDS MAC pairing, preflight, flat archive
Release / build (push) Has been cancelled
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:
@@ -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
|
||||
|
||||
+3
-1
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
+180
-16
@@ -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"))
|
||||
|
||||
|
||||
|
||||
+34
-11
@@ -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 '<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:
|
||||
# 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):
|
||||
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 not isinstance(data, dict):
|
||||
return ""
|
||||
|
||||
def _serial(d: dict) -> str:
|
||||
for k in ("SerialNumber", "Serial", "serial_number", "serial"):
|
||||
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 ""
|
||||
|
||||
+27
-4
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
+49
-15
@@ -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": "Хосты",
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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:
|
||||
|
||||
+16
-36
@@ -1,26 +1,18 @@
|
||||
"""
|
||||
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/
|
||||
└── <DDMMYYYY_HHMMSS>/
|
||||
├── <bmc_ip>/
|
||||
│ ├── bmc/ collected from the BMC
|
||||
│ └── 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)
|
||||
└── <DDMMYYYY_HHMMSS>.tar.gz one-click bundle for support; opens to
|
||||
<bmc_ip>/{bmc,os}/… + vrc.log directly
|
||||
(no <ts>/ 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_<host>.tar.gz and drop
|
||||
the unpacked folder. Then pack the whole session into <session>.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 <session>.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 (`<ip>/{bmc,os}`,
|
||||
`vrc.log`), without an extra `<ts>/` 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
|
||||
|
||||
Reference in New Issue
Block a user