481 lines
18 KiB
Python
481 lines
18 KiB
Python
#!/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/Wi‑Fi
|
||
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 Wi‑Fi 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 Wi‑Fi 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())
|