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
|
# 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
@@ -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]
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
@@ -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": "Хосты",
|
||||||
|
|||||||
@@ -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 "")
|
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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user