FiWiControl/scripts/system/fabric_realize.py

481 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# Copyright (c) 2026 Umber
#
# Licensed under the Apache License, Version 2.0; see LICENSE.
#
# CLI for :mod:`fiwicontrol.fabric.definition_from_ini` — lab INI + local USB → ``FabricDefinition``.
# See docs/fabric-builder.md
"""Compose a ``FabricDefinition`` from an INI and **local** Acroname USB discovery.
Uses :func:`fiwicontrol.fabric.read_inventory_ini` and :func:`fiwicontrol.fabric.compose_definition`.
**Modes**
- **Default:** compose definition, then print a **human** lab report (or ``--json`` / ``-o`` for
definition JSON only — no :class:`~fiwicontrol.fabric.fabric.Fabric.realize` call).
- **``--realize``:** same compose path, then build :class:`~fiwicontrol.fabric.fabric.Fabric`, run
``await Fabric.realize()`` (live USB fingerprint vs definition), print **OK** + ``print(fabric)``
(or ``--json`` for definition JSON after a successful realize).
Requires:
- At least one lab machine row: ``[machine.*]`` and/or paired ``[node.*]`` + ``[host.*]`` (see ``load_lab_ini``)
- Optional ``[fabric]`` keys such as ``fabric_id``, ``concentrator`` / ``concentrator_node`` (for the composed definition)
- One ``[fabric.rrh.<radio_id>]`` per RRH with ``acroname_port = <int>`` (required)
- Optional per RRH: ``acroname_module_serial``, ``patch_panel_port``, ``pcie_bdf``
If ``acroname_module_serial`` is omitted on any RRH row, exactly one Acroname module must be
visible on this machine's USB bus (serial is filled in automatically).
**Output:** By default prints a **human-readable report** to stdout: fabric summary, INI fabric
metadata, **local Acroname USB modules** discovered on this host, then the **concentrator
workstation snapshot** (same layout as ``scripts/system/dump_concentrator.py`` — CPU, PCIe/WiFi
tables, ``lspci -tv`` excerpt, DMI when available). With ``--json``, prints only
``FabricDefinition`` JSON (stdout or ``-o PATH``); concentrator flags are ignored. Status lines go
to stderr.
Examples:
python3 scripts/system/fabric_realize.py -c configs/default.ini
python3 scripts/system/fabric_realize.py -c configs/default.ini --pci-all
python3 scripts/system/fabric_realize.py -c configs/default.ini --json > configs/my-fabric.json
python3 scripts/system/fabric_realize.py -c configs/default.ini --json -o configs/my-fabric.json
python3 scripts/system/fabric_realize.py -c configs/default.ini --realize
python3 scripts/system/fabric_realize.py -c configs/default.ini --realize --json > /tmp/fabric.json
Human reports (without ``--json``) can merge **patch panel labels** into the WiFi PCIe table when a
mapping file exists: by default ``<lab_ini_dir>/<lab_ini_stem>_panel.json`` (e.g. ``default.ini`` →
``default_panel.json``), or pass ``--patch-panel-json PATH``.
"""
from __future__ import annotations
import argparse
import asyncio
import sys
from pathlib import Path
from typing import Any
_REPO_ROOT = __file__.rsplit("/scripts/system/", 1)[0]
if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT + "/src")
from fiwicontrol.fabric import RRH_BINDING_HELP, compose_definition, read_inventory_ini
from fiwicontrol.fabric.fdir import FabricExitCode, eprint_fdir
from fiwicontrol.fabric.fabric import FabricDefinition
from fiwicontrol.fabric.ini_merge import FabricIniOverlay
from fiwicontrol.fabric.patch_panel_json import resolve_patch_panel_json_path
from fiwicontrol.lab.inventory_config import default_lab_ini_path
def _human_report(
definition: FabricDefinition,
*,
ini: Path,
site_name: str | None,
modules: list[Any],
overlay: FabricIniOverlay,
concentrator_kwargs: dict[str, Any],
panel_json_path: Path | None = None,
) -> str:
lines = [
"FabricDefinition (lab INI + local Acroname USB discovery)",
"INI: {}".format(ini.resolve()),
]
if site_name:
lines.append("Site: {}".format(site_name))
lines.extend(
[
"fabric_id: {}".format(definition.fabric_id),
"discovery_fingerprint: {}".format(definition.discovery_fingerprint),
"concentrator_name: {}".format(definition.concentrator_name or ""),
"concentrator_ipaddr: {}".format(definition.concentrator_ipaddr or ""),
"lab_ini (audit): {}".format(definition.lab_ini or ""),
]
)
if overlay.patch_panel_ports is not None:
lines.append("patch_panel_ports (INI): {}".format(overlay.patch_panel_ports))
if overlay.concentrator_adnacom_adapter_count is not None:
lines.append(
"concentrator_adnacom_adapter_count (INI): {}".format(
overlay.concentrator_adnacom_adapter_count
)
)
if overlay.rrh_overrides:
lines.append(
"fabric.rrh.* overlay keys: {}".format(", ".join(sorted(overlay.rrh_overrides.keys())))
)
lines.extend(
[
"",
"RRH bindings in FabricDefinition ({}): ".format(len(definition.rrhs)),
]
)
for i, h in enumerate(definition.rrhs, start=1):
pcie = getattr(h, "pcie_bdf", None) or ""
lines.append(
" {:>2} radio_id={!r} acroname_port={} acroname_module_serial={} patch_panel_port={} pcie_bdf={}".format(
i,
h.radio_id,
h.acroname_port,
h.acroname_module_serial if h.acroname_module_serial is not None else "",
h.patch_panel_port if h.patch_panel_port is not None else "",
pcie,
)
)
lines.extend(["", "Local Acroname USB (this host; BrainStem enumeration):", ""])
hdr = "{:>3} {:>12} {:<14} {:<28} {:>6} {:>9}".format(
"#", "serial", "stem", "model", "ds_usb", "port_ent",
)
lines.append(hdr)
lines.append("-" * len(hdr))
for i, m in enumerate(modules, start=1):
stem = (m.stem_class or m.model_name or "?")[:14]
model = (m.model_name or "")[:28]
ds = m.downstream_usb_ports if m.downstream_usb_ports is not None else "-"
pe = m.hub_port_entities if m.hub_port_entities is not None else "-"
lines.append(
"{:>3} {:>12} {:<14} {:<28} {:>6} {:>9}".format(
i,
m.serial_number,
stem,
model,
ds,
pe,
)
)
lines.extend(["", "" * 72, ""])
try:
from fiwicontrol.concentrator import (
ConcentratorPlatform,
format_concentrator_platform_snapshot_human,
)
except ImportError as exc:
lines.append("(Concentrator snapshot skipped: {})\n".format(exc))
return "\n".join(lines)
label = str(concentrator_kwargs.get("label") or "").strip() or definition.fabric_id or "default"
from fiwicontrol.fabric.patch_panel_json import build_wifi_patch_by_bdf
try:
snap = ConcentratorPlatform(label=label).snapshot(
proc_cpuinfo=concentrator_kwargs.get("proc_cpuinfo"),
host_probe=concentrator_kwargs.get("host_probe", True),
pci_devices_sysdir=concentrator_kwargs.get("pci_sysdir"),
)
wifi_patch = build_wifi_patch_by_bdf(panel_json_path, fabric_rrhs=definition.rrhs)
if panel_json_path is not None:
lines.append("patch panel map JSON: {}".format(panel_json_path.resolve()))
lines.append("")
lines.append(
format_concentrator_platform_snapshot_human(
snap,
label=label,
pci_max_rows=max(1, int(concentrator_kwargs.get("pci_max_rows", 40))),
lspci_max_lines=max(0, int(concentrator_kwargs.get("lspci_max_lines", 18))),
include_pci_extra=bool(concentrator_kwargs.get("include_pci_extra", False)),
wifi_patch_by_bdf=wifi_patch,
)
)
except (OSError, PermissionError, ValueError, RuntimeError) as exc:
lines.append("(Concentrator snapshot or formatting failed: {})\n".format(exc))
return "\n".join(lines)
def _run_realize_cli(
definition: FabricDefinition,
*,
strict: bool,
verbose: bool,
print_json: bool,
discovery_timeout: float,
) -> int:
"""Build :class:`~fiwicontrol.fabric.fabric.Fabric`, ``realize``, print result; always destroys fabric."""
async def _go() -> int:
from fiwicontrol.fabric import Fabric
fab = Fabric.from_definition(definition)
try:
if verbose:
print("Fabric (before realize):", fab, file=sys.stderr)
await fab.realize(strict=strict, discovery_timeout=discovery_timeout)
except ValueError as exc:
eprint_fdir("realize fingerprint:", exc)
return FabricExitCode.FABRIC_STALE
except RuntimeError as exc:
eprint_fdir("realize discovery:", exc)
return FabricExitCode.FABRIC_DISCOVERY_FAULT
else:
if print_json:
sys.stdout.write(definition.to_json_text())
else:
print("OK: compose_definition + realize(strict={})".format(strict))
print(fab)
finally:
fab.destroy()
return FabricExitCode.SUCCESS
return asyncio.run(_go())
def main() -> int:
epilog = """Exit codes (FDIR — see docs/fdir.md):
{success} Success
{e1} Environment / discovery (missing [power], USB enumeration failed, no modules)
{e2} Configuration (bad INI/JSON, validation, strict-ini without --force, no RRH rows)
{e3} Fabric stale (--realize, strict fingerprint mismatch)
{e4} Fabric discovery fault (--realize, timeout or discovery error)
""".format(
success=FabricExitCode.SUCCESS,
e1=FabricExitCode.ENVIRONMENT_OR_DISCOVERY,
e2=FabricExitCode.CONFIGURATION,
e3=FabricExitCode.FABRIC_STALE,
e4=FabricExitCode.FABRIC_DISCOVERY_FAULT,
)
p = argparse.ArgumentParser(
description=__doc__,
epilog=epilog,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
p.add_argument(
"-c",
"--lab-ini",
type=Path,
default=None,
help="INI path (default: configs/default.ini)",
)
p.add_argument(
"-o",
"--output",
type=Path,
default=None,
metavar="PATH",
help="With --json: write JSON to this path instead of stdout. Without --json: write human report to this path",
)
p.add_argument(
"--json",
action="store_true",
help="Emit FabricDefinition JSON (default is human-readable text)",
)
p.add_argument(
"--realize",
action="store_true",
help=(
"After compose_definition: build Fabric, await Fabric.realize() (fingerprint vs live USB), "
"then print OK + fabric summary (or --json for definition JSON). Skips the concentrator human report."
),
)
p.add_argument(
"--no-strict",
action="store_true",
help="With --realize: pass strict=False to Fabric.realize()",
)
p.add_argument(
"-v",
"--verbose",
action="store_true",
help="With --realize: print discovery / fabric lines on stderr",
)
p.add_argument(
"--realize-discovery-timeout",
type=float,
default=120.0,
metavar="SEC",
help="With --realize: max seconds for Acroname USB discovery (default 120)",
)
conc = p.add_argument_group(
"concentrator human report (ignored with --json; same probes as dump_concentrator.py)",
)
conc.add_argument(
"--concentrator-label",
default=None,
metavar="NAME",
help="Label in concentrator header (default: fabric_id from composed definition)",
)
conc.add_argument(
"--no-host-probe",
action="store_true",
help="Concentrator block: CPU only; skip lspci, sysfs PCIe, dmidecode",
)
conc.add_argument(
"--pci-sysdir",
type=Path,
default=None,
help="Concentrator block: override /sys/bus/pci/devices",
)
conc.add_argument(
"--proc-cpuinfo",
type=Path,
default=None,
help="Concentrator block: override /proc/cpuinfo path",
)
conc.add_argument(
"--pci-all",
action="store_true",
help="Concentrator block: add second non-wireless PCIe table (see dump_concentrator.py)",
)
conc.add_argument(
"--pci-max-rows",
type=int,
default=40,
metavar="N",
help="Concentrator block: max rows for extra PCIe table (default 40)",
)
conc.add_argument(
"--lspci-lines",
type=int,
default=18,
metavar="N",
help="Concentrator block: first N lines of lspci -tv (0=omit; default 18)",
)
p.add_argument(
"--strict-ini",
action="store_true",
help="Compare discovery to INI machine rows before compose / output",
)
p.add_argument("--force", action="store_true", help="With --strict-ini, emit JSON even if checks fail")
p.add_argument(
"--ssh-controlmaster",
action="store_true",
help="Use ControlMaster for remote discovery checks (--strict-ini)",
)
p.add_argument(
"--patch-panel-json",
type=Path,
default=None,
metavar="PATH",
help=(
"Optional BDF→patch label map JSON for the concentrator WiFi table in the human report "
"(ignored with --json). If omitted, uses <lab_ini_stem>_panel.json beside the INI when present."
),
)
args = p.parse_args()
ini = args.lab_ini if args.lab_ini is not None else default_lab_ini_path().expanduser()
if not ini.is_file():
eprint_fdir("INI not found:", ini)
return FabricExitCode.CONFIGURATION
try:
from fiwicontrol.lab.discovery import discover_acroname_modules
except ImportError:
eprint_fdir("Install: pip install -e '.[power]'")
return FabricExitCode.ENVIRONMENT_OR_DISCOVERY
if args.strict_ini:
from fiwicontrol.lab.inventory_verify import verify_inventory_ini_sync
errs = verify_inventory_ini_sync(ini, ssh_controlmaster=args.ssh_controlmaster)
if errs:
for e in errs:
print(e, file=sys.stderr)
if not args.force:
print("Use --force to emit anyway.", file=sys.stderr)
return FabricExitCode.CONFIGURATION
try:
doc, overlay, rrhs = read_inventory_ini(ini)
except (OSError, ValueError) as exc:
eprint_fdir(exc)
return FabricExitCode.CONFIGURATION
if not rrhs:
print(RRH_BINDING_HELP, file=sys.stderr)
return FabricExitCode.CONFIGURATION
try:
modules = discover_acroname_modules()
except Exception as exc:
eprint_fdir("Discovery failed:", exc)
return FabricExitCode.ENVIRONMENT_OR_DISCOVERY
if not modules:
eprint_fdir("No Acroname modules on USB.")
return FabricExitCode.ENVIRONMENT_OR_DISCOVERY
try:
definition = compose_definition(
doc,
overlay,
rrhs,
modules,
lab_ini=str(ini.resolve()),
)
except ValueError as exc:
eprint_fdir(exc)
return FabricExitCode.CONFIGURATION
if args.realize:
if args.verbose:
from fiwicontrol.fabric.fingerprint import acroname_modules_fingerprint
live_fp = acroname_modules_fingerprint(modules)
print("discovery: {} module(s)".format(len(modules)), file=sys.stderr)
print("discovery_fingerprint: {}".format(live_fp), file=sys.stderr)
rc = _run_realize_cli(
definition,
strict=not args.no_strict,
verbose=args.verbose,
print_json=args.json,
discovery_timeout=float(args.realize_discovery_timeout),
)
if rc != FabricExitCode.SUCCESS:
return rc
if args.json:
print("Emitted fabric JSON after realize ({} RRHs).".format(len(rrhs)), file=sys.stderr)
return FabricExitCode.SUCCESS
out = args.output
if args.json:
text = definition.to_json_text()
if out is None or str(out) == "-":
try:
sys.stdout.write(text)
sys.stdout.flush()
except BrokenPipeError:
return FabricExitCode.SUCCESS
print("Emitted fabric JSON for {} RRHs (stdout).".format(len(rrhs)), file=sys.stderr)
else:
definition.save(out)
print("Wrote {} ({} RRHs)".format(out.resolve(), len(rrhs)), file=sys.stderr)
else:
conc_kw: dict[str, Any] = {
"label": (args.concentrator_label or "").strip() or definition.fabric_id or "default",
"proc_cpuinfo": args.proc_cpuinfo,
"host_probe": not args.no_host_probe,
"pci_sysdir": args.pci_sysdir,
"pci_max_rows": args.pci_max_rows,
"lspci_max_lines": args.lspci_lines,
"include_pci_extra": args.pci_all,
}
panel_json = resolve_patch_panel_json_path(ini, args.patch_panel_json)
if args.patch_panel_json is not None and panel_json is None:
print(
"Patch panel JSON not found or not a file: {}".format(args.patch_panel_json),
file=sys.stderr,
)
text = _human_report(
definition,
ini=ini.resolve(),
site_name=doc.site_name,
modules=modules,
overlay=overlay,
concentrator_kwargs=conc_kw,
panel_json_path=panel_json,
)
if out is None or str(out) == "-":
try:
sys.stdout.write(text)
sys.stdout.flush()
except BrokenPipeError:
return FabricExitCode.SUCCESS
print("Emitted human fabric report (stdout).", file=sys.stderr)
else:
out.expanduser().resolve().parent.mkdir(parents=True, exist_ok=True)
out.expanduser().resolve().write_text(text, encoding="utf-8")
print("Wrote human fabric report to {}".format(out.resolve()), file=sys.stderr)
return FabricExitCode.SUCCESS
if __name__ == "__main__":
raise SystemExit(main())