FiWiControl/tests/test_fabric_json.py

529 lines
17 KiB
Python

# Copyright (c) 2026 Umber
#
# Licensed under the Apache License, Version 2.0; see LICENSE.
from __future__ import annotations
import asyncio
import json
import logging
from pathlib import Path
from unittest.mock import AsyncMock, patch
import pytest
from fiwicontrol.fabric import Fabric
from fiwicontrol.fabric.definition_from_ini import _rrhs_with_resolved_serials, compose_definition, read_inventory_ini
from fiwicontrol.fabric.fingerprint import acroname_modules_fingerprint
from fiwicontrol.fabric.ini_merge import fabric_rrh_bindings_from_ini, merge_fabric_definition_with_ini, parse_fabric_ini_overlay
from fiwicontrol.fabric.patch_panel_json import load_patch_panel_bdf_map
from fiwicontrol.fabric.fabric import (
FabricBindingStatus,
FabricDefinition,
FabricRRHBinding,
)
from fiwicontrol.lab.discovery import AcronameModuleInfo
from fiwicontrol.lab.inventory_config import load_inventory_ini
def _sample_modules() -> list[AcronameModuleInfo]:
return [
AcronameModuleInfo(
transport="USB",
serial_number=111,
module_address=0,
model_id=42,
model_name="USBHub3p",
model_description="",
stem_class="USBHub3p",
downstream_usb_ports=4,
hub_port_entities=4,
)
]
def test_fabric_rrh_bindings_from_ini(tmp_path: Path) -> None:
ini = tmp_path / "lab.ini"
ini.write_text(
"\n".join(
[
"[host.h]",
"mode = local",
"acroname = USBHub2x4:4",
"hvpm = HVPM:0",
"[fabric.rrh.z]",
"acroname_port = 2",
"acroname_module_serial = 999",
"[fabric.rrh.a]",
"acroname_port = 0",
"patch_panel_port = 5",
]
),
encoding="utf-8",
)
rrhs = fabric_rrh_bindings_from_ini(ini)
assert [h.radio_id for h in rrhs] == ["a", "z"]
assert rrhs[0].acroname_port == 0 and rrhs[0].patch_panel_port == 5
assert rrhs[1].acroname_module_serial == 999
def test_fabric_definition_lab_ini_roundtrip(tmp_path: Path) -> None:
fp = acroname_modules_fingerprint(_sample_modules())
d = FabricDefinition(
fabric_id="t",
discovery_fingerprint=fp,
rrhs=(FabricRRHBinding(radio_id="a", acroname_port=0, acroname_module_serial=111),),
lab_ini="/tmp/lab.ini",
)
p = tmp_path / "f.json"
d.save(p)
d2 = FabricDefinition.load(p)
assert d2.lab_ini == "/tmp/lab.ini"
def test_fabric_definition_roundtrip(tmp_path: Path) -> None:
fp = acroname_modules_fingerprint(_sample_modules())
d = FabricDefinition(
fabric_id="t",
discovery_fingerprint=fp,
rrhs=(
FabricRRHBinding(radio_id="rrh-01", acroname_port=0, acroname_module_serial=111, patch_panel_port=10),
FabricRRHBinding(radio_id="rrh-02", acroname_port=1, acroname_module_serial=111),
),
concentrator_name="rig",
concentrator_ipaddr="10.0.0.1",
)
path = tmp_path / "fabric.json"
d.save(path)
d2 = FabricDefinition.load(path)
assert d2.fabric_id == "t"
assert d2.discovery_fingerprint == fp
assert len(d2.rrhs) == 2
assert d2.rrhs[0].radio_id == "rrh-01" and d2.rrhs[0].acroname_port == 0
assert d2.concentrator_ipaddr == "10.0.0.1"
raw = json.loads(path.read_text(encoding="utf-8"))
assert raw["schema_version"] == FabricDefinition.JSON_SCHEMA_VERSION
def test_fabric_from_json_path(tmp_path: Path) -> None:
fp = acroname_modules_fingerprint(_sample_modules())
FabricDefinition(
fabric_id="t2",
discovery_fingerprint=fp,
rrhs=(FabricRRHBinding(radio_id="a", acroname_port=0, acroname_module_serial=111),),
concentrator_ipaddr="192.168.1.2",
).save(tmp_path / "f.json")
f = Fabric.from_json_path(tmp_path / "f.json")
assert f.fabric_id == "t2"
assert f.rrh_power_ports == {"a": 0}
assert f.concentrator is not None and f.concentrator.ipaddr == "192.168.1.2"
assert f.expected_discovery_fingerprint == fp
def test_binding_cache_status_missing(tmp_path: Path) -> None:
assert Fabric.binding_cache_status(tmp_path / "nope.json") is FabricBindingStatus.MISSING
def test_binding_cache_status_invalid(tmp_path: Path) -> None:
p = tmp_path / "bad.json"
p.write_text("{not json", encoding="utf-8")
assert Fabric.binding_cache_status(p) is FabricBindingStatus.INVALID
def test_validate_duplicate_radio_id() -> None:
fp = acroname_modules_fingerprint(_sample_modules())
d = FabricDefinition(
fabric_id="x",
discovery_fingerprint=fp,
rrhs=(
FabricRRHBinding(radio_id="same", acroname_port=0, acroname_module_serial=111),
FabricRRHBinding(radio_id="same", acroname_port=1, acroname_module_serial=111),
),
)
with pytest.raises(ValueError, match="duplicate radio_id"):
d.validate()
def test_validate_duplicate_port_same_module() -> None:
fp = acroname_modules_fingerprint(_sample_modules())
d = FabricDefinition(
fabric_id="x",
discovery_fingerprint=fp,
rrhs=(
FabricRRHBinding(radio_id="a", acroname_port=0, acroname_module_serial=111),
FabricRRHBinding(radio_id="b", acroname_port=0, acroname_module_serial=111),
),
)
with pytest.raises(ValueError, match="duplicate"):
d.validate()
def test_rrhs_with_resolved_serials_preserves_pcie_bdf() -> None:
rrhs = (
FabricRRHBinding(
radio_id="a",
acroname_port=0,
acroname_module_serial=None,
pcie_bdf="0000:03:00.0",
),
)
out = _rrhs_with_resolved_serials(rrhs, _sample_modules())
assert out[0].acroname_module_serial == 111
assert out[0].pcie_bdf == "0000:03:00.0"
def test_merge_lab_ini_concentrator(tmp_path: Path) -> None:
fp = acroname_modules_fingerprint(_sample_modules())
ini = tmp_path / "lab.ini"
ini.write_text(
"\n".join(
[
"[site]",
"name = SiteX",
"[node.pi]",
"ipaddr = 10.0.0.7",
"sshtype = ssh",
"[host.h1]",
"mode = local",
"acroname = USBHub2x4:4",
"hvpm = HVPM:0",
"[fabric]",
"fabric_id = FromIni",
"concentrator_node = pi",
"patch_panel_ports = 48",
"[fabric.rrh.a]",
"acroname_port = 3",
]
),
encoding="utf-8",
)
FabricDefinition(
fabric_id="from-json",
discovery_fingerprint=fp,
rrhs=(
FabricRRHBinding(
radio_id="a",
acroname_port=0,
acroname_module_serial=111,
pcie_bdf="0000:01:00.0",
),
),
concentrator_ipaddr="1.1.1.1",
patch_panel_ports=8,
lab_ini=str(tmp_path / "source.ini"),
).save(tmp_path / "f.json")
merged = FabricDefinition.load_json_merged_with_ini(tmp_path / "f.json", ini)
assert merged.fabric_id == "FromIni"
assert merged.concentrator_ipaddr == "10.0.0.7"
assert merged.rrhs[0].acroname_port == 3
assert merged.rrhs[0].pcie_bdf == "0000:01:00.0"
assert merged.patch_panel_ports == 48
assert merged.lab_ini == str(tmp_path / "source.ini")
def test_merge_lab_ini_keeps_json_patch_panel_when_ini_omits(tmp_path: Path) -> None:
fp = acroname_modules_fingerprint(_sample_modules())
ini = tmp_path / "lab.ini"
ini.write_text(
"\n".join(
[
"[site]",
"name = SiteX",
"[node.pi]",
"ipaddr = 10.0.0.7",
"sshtype = ssh",
"[host.h1]",
"mode = local",
"acroname = USBHub2x4:4",
"hvpm = HVPM:0",
"[fabric]",
"concentrator_node = pi",
]
),
encoding="utf-8",
)
d = FabricDefinition(
fabric_id="j",
discovery_fingerprint=fp,
rrhs=(FabricRRHBinding(radio_id="a", acroname_port=0, acroname_module_serial=111),),
patch_panel_ports=24,
)
merged = merge_fabric_definition_with_ini(d, ini)
assert merged.patch_panel_ports == 24
def test_fabric_realize_registered_runs_all_registered() -> None:
fp = acroname_modules_fingerprint(_sample_modules())
d = FabricDefinition(
fabric_id="r",
discovery_fingerprint=fp,
rrhs=(FabricRRHBinding(radio_id="a", acroname_port=0, acroname_module_serial=111),),
)
fab = Fabric.from_definition(d)
assert fab in Fabric.get_instances()
async def _run() -> None:
with patch(
"fiwicontrol.lab.discovery.discover_acroname_modules_async",
new=AsyncMock(return_value=_sample_modules()),
):
out = await Fabric.realize_registered(strict=True, fabrics="all", timeout=30.0)
assert len(out) >= 1 and fab in out
asyncio.run(_run())
fab.destroy()
assert fab not in Fabric.get_instances()
def test_fabric_realize_accepts_matching_fingerprint() -> None:
fp = acroname_modules_fingerprint(_sample_modules())
d = FabricDefinition(
fabric_id="r",
discovery_fingerprint=fp,
rrhs=(FabricRRHBinding(radio_id="a", acroname_port=0, acroname_module_serial=111),),
)
fab = Fabric.from_definition(d)
async def _run() -> None:
with patch(
"fiwicontrol.lab.discovery.discover_acroname_modules_async",
new=AsyncMock(return_value=_sample_modules()),
):
out = await fab.realize()
assert out is fab
asyncio.run(_run())
def test_fabric_realize_strict_rejects_stale_fingerprint() -> None:
d = FabricDefinition(
fabric_id="r",
discovery_fingerprint="0" * 64,
rrhs=(FabricRRHBinding(radio_id="a", acroname_port=0, acroname_module_serial=111),),
)
fab = Fabric.from_definition(d)
assert fab.expected_discovery_fingerprint == "0" * 64
async def _run() -> None:
with patch(
"fiwicontrol.lab.discovery.discover_acroname_modules_async",
new=AsyncMock(return_value=_sample_modules()),
):
with pytest.raises(ValueError, match="fingerprint"):
await fab.realize(strict=True)
asyncio.run(_run())
def test_fabric_realize_non_strict_logs_fdir_warning(caplog: pytest.LogCaptureFixture) -> None:
d = FabricDefinition(
fabric_id="r",
discovery_fingerprint="0" * 64,
rrhs=(FabricRRHBinding(radio_id="a", acroname_port=0, acroname_module_serial=111),),
)
fab = Fabric.from_definition(d)
async def _run() -> None:
with patch(
"fiwicontrol.lab.discovery.discover_acroname_modules_async",
new=AsyncMock(return_value=_sample_modules()),
):
with caplog.at_level(logging.WARNING):
out = await fab.realize(strict=False)
assert out is fab
asyncio.run(_run())
assert "FiWi-FDIR" in caplog.text
assert "strict=False" in caplog.text
def test_fabric_realize_non_strict_ignores_mismatch() -> None:
d = FabricDefinition(
fabric_id="r",
discovery_fingerprint="0" * 64,
rrhs=(FabricRRHBinding(radio_id="a", acroname_port=0, acroname_module_serial=111),),
)
fab = Fabric.from_definition(d)
async def _run() -> None:
with patch(
"fiwicontrol.lab.discovery.discover_acroname_modules_async",
new=AsyncMock(return_value=_sample_modules()),
):
out = await fab.realize(strict=False)
assert out is fab
asyncio.run(_run())
def test_compose_definition_fills_omitted_hub_serial(tmp_path: Path) -> None:
ini = tmp_path / "lab.ini"
ini.write_text(
"\n".join(
[
"[site]",
"name = LabX",
"[host.h]",
"mode = local",
"acroname = USBHub2x4:4",
"hvpm = HVPM:0",
"[fabric]",
"fabric_id = t",
"[fabric.rrh.a]",
"acroname_port = 0",
]
),
encoding="utf-8",
)
doc, overlay, rrhs = read_inventory_ini(ini)
assert rrhs[0].acroname_module_serial is None
d = compose_definition(doc, overlay, rrhs, _sample_modules(), lab_ini=str(ini.resolve()))
assert d.rrhs[0].acroname_module_serial == 111
def test_compose_definition_rejects_ambiguous_hub_serial(tmp_path: Path) -> None:
m1 = _sample_modules()[0]
m2 = AcronameModuleInfo(
transport="USB",
serial_number=222,
module_address=0,
model_id=42,
model_name="USBHub3p",
model_description="",
stem_class="USBHub3p",
downstream_usb_ports=4,
hub_port_entities=4,
)
ini = tmp_path / "lab.ini"
ini.write_text(
"\n".join(
[
"[host.h]",
"mode = local",
"acroname = USBHub2x4:4",
"hvpm = HVPM:0",
"[fabric.rrh.a]",
"acroname_port = 0",
]
),
encoding="utf-8",
)
doc, overlay, rrhs = read_inventory_ini(ini)
with pytest.raises(ValueError, match="Multiple Acroname"):
compose_definition(doc, overlay, rrhs, [m1, m2], lab_ini=str(ini.resolve()))
def test_compose_definition_pure(tmp_path: Path) -> None:
ini = tmp_path / "lab.ini"
ini.write_text(
"\n".join(
[
"[site]",
"name = LabX",
"[host.h]",
"mode = local",
"acroname = USBHub2x4:4",
"hvpm = HVPM:0",
"[fabric]",
"fabric_id = from-overlay",
"[fabric.rrh.z]",
"acroname_port = 2",
"acroname_module_serial = 999",
]
),
encoding="utf-8",
)
doc, overlay, rrhs = read_inventory_ini(ini)
assert overlay.fabric_id == "from-overlay"
mods = _sample_modules()
d = compose_definition(
doc,
overlay,
rrhs,
mods,
lab_ini=str(ini.resolve()),
)
assert d.fabric_id == "from-overlay"
assert d.discovery_fingerprint == acroname_modules_fingerprint(mods)
assert len(d.rrhs) == 1 and d.rrhs[0].radio_id == "z"
def test_read_inventory_ini_matches_fabric_rrh_bindings_from_ini(tmp_path: Path) -> None:
ini = tmp_path / "lab.ini"
ini.write_text(
"\n".join(
[
"[host.h]",
"mode = local",
"acroname = USBHub2x4:4",
"hvpm = HVPM:0",
"[fabric.rrh.a]",
"acroname_port = 0",
]
),
encoding="utf-8",
)
doc, overlay, rrhs = read_inventory_ini(ini)
assert load_inventory_ini(ini).hosts == doc.hosts
assert parse_fabric_ini_overlay(ini).fabric_id == overlay.fabric_id
assert rrhs == fabric_rrh_bindings_from_ini(ini)
def test_parse_fabric_ini_overlay_adnacom_adapter_count(tmp_path: Path) -> None:
ini = tmp_path / "lab.ini"
ini.write_text(
"\n".join(
[
"[fabric]",
"concentrator_adnacom_adapter_count = 6",
"",
]
),
encoding="utf-8",
)
o = parse_fabric_ini_overlay(ini)
assert o.concentrator_adnacom_adapter_count == 6
ini.write_text(
"\n".join(
[
"[fabric]",
"adnacom_adapter_count = 3",
"",
]
),
encoding="utf-8",
)
assert parse_fabric_ini_overlay(ini).concentrator_adnacom_adapter_count == 3
def test_patch_panel_json_rejects_oversize_file(tmp_path: Path) -> None:
p = tmp_path / "panel.json"
inner = ",".join('"k{}":"1"'.format(i) for i in range(5000))
p.write_text('{"bdf_to_patch": {' + inner + "}}", encoding="utf-8")
assert load_patch_panel_bdf_map(p) == {}
def test_parse_fabric_ini_overlay_patch_panel_ports(tmp_path: Path) -> None:
ini = tmp_path / "lab.ini"
ini.write_text("[fabric]\npatch_panel_ports = 24\n", encoding="utf-8")
assert parse_fabric_ini_overlay(ini).patch_panel_ports == 24
ini.write_text("[fabric]\npatch_panel_port_count = 12\n", encoding="utf-8")
assert parse_fabric_ini_overlay(ini).patch_panel_ports == 12
def test_with_concentrator_override(tmp_path: Path) -> None:
fp = acroname_modules_fingerprint(_sample_modules())
FabricDefinition(
fabric_id="ov",
discovery_fingerprint=fp,
rrhs=(FabricRRHBinding(radio_id="a", acroname_port=0, acroname_module_serial=111),),
).save(tmp_path / "f.json")
f = Fabric.from_json_path(tmp_path / "f.json")
assert f.concentrator is None
f2 = f.with_concentrator_override(name="rig", ipaddr="10.0.0.5")
assert f2.concentrator is not None and f2.concentrator.ipaddr == "10.0.0.5"