diff --git a/napalm_linux/linux.py b/napalm_linux/linux.py index d750e9b..bfbd00f 100644 --- a/napalm_linux/linux.py +++ b/napalm_linux/linux.py @@ -85,6 +85,36 @@ _VM_DMI_VENDORS: frozenset[str] = frozenset({ "parallels software international inc.", }) +# Known ARM board model prefixes → canonical vendor name +_ARM_VENDOR_PREFIXES: list[tuple[str, str]] = [ + ("Raspberry Pi", "Raspberry Pi Foundation"), + ("NVIDIA Jetson", "NVIDIA"), + ("ODROID", "Hardkernel"), + ("Hardkernel", "Hardkernel"), + ("Rock Pi", "Radxa"), + ("ROCK Pi", "Radxa"), + ("Radxa", "Radxa"), + ("Orange Pi", "Xunlong Software"), + ("Banana Pi", "SinoVoip"), + ("NanoPi", "FriendlyElec"), + ("PINE64", "Pine64"), + ("BeagleBone", "BeagleBoard.org"), +] + + +def _arm_vendor_from_model(model: str) -> str: + """Extract vendor from an ARM device tree / cpuinfo model string.""" + for prefix, vendor in _ARM_VENDOR_PREFIXES: + if model.startswith(prefix): + return vendor + # Generic fallback: words before the first numeric token + brand = [] + for word in model.split(): + if word[0].isdigit(): + break + brand.append(word) + return " ".join(brand) + class LinuxDriver(OSDriver): """NAPALM driver for generic Linux systems. @@ -235,11 +265,23 @@ class LinuxDriver(OSDriver): "r=$(cat /sys/class/dmi/id/product_version 2>/dev/null); " "s=$(cat /sys/class/dmi/id/product_serial 2>/dev/null); " "u=$(cat /sys/class/dmi/id/product_uuid 2>/dev/null); " - "d=$(systemd-detect-virt 2>/dev/null || echo none); " - "printf '%s\\n%s\\n%s\\n%s\\n%s\\n%s\\n' \"$v\" \"$n\" \"$r\" \"$s\" \"$u\" \"$d\"" + "d=$(systemd-detect-virt 2>/dev/null); d=${d:-none}; " + "dt=$(tr -d '\\0' /dev/null); " + "cs=$(grep '^Serial' /proc/cpuinfo 2>/dev/null | head -1 | cut -d: -f2 | xargs 2>/dev/null); " + "cm=$(grep '^Model' /proc/cpuinfo 2>/dev/null | head -1 | cut -d: -f2 | xargs 2>/dev/null); " + # DMIBEGIN sentinel: _send() strips leading blank lines (ARM has no DMI + # files, so fields 0-4 are empty). The sentinel anchors the output so + # splitlines()[start+N] always maps to the correct field index. + "printf 'DMIBEGIN\\n%s\\n%s\\n%s\\n%s\\n%s\\n%s\\n%s\\n%s\\n%s\\n' " + "\"$v\" \"$n\" \"$r\" \"$s\" \"$u\" \"$d\" \"$dt\" \"$cs\" \"$cm\"" ) try: - lines = self._send(dmi_cmd).splitlines() + raw_lines = self._send(dmi_cmd).splitlines() + try: + start = raw_lines.index("DMIBEGIN") + 1 + except ValueError: + start = 0 + lines = raw_lines[start:] except Exception: return {"vendor": "", "model": "", "serial": "", "is_vm": False} @@ -253,6 +295,9 @@ class LinuxDriver(OSDriver): product_ser = _clean(3) product_uuid = _clean(4) detect_virt = lines[5].strip().lower() if len(lines) > 5 else "none" + dt_model = _clean(6) + cpuinfo_ser = _clean(7) + cpuinfo_mdl = _clean(8) is_container = detect_virt in _CONTAINER_VIRT is_vm = ( @@ -292,13 +337,26 @@ class LinuxDriver(OSDriver): } # Bare-metal: prefer product_version when it reads like a marketing name - pv_usable = product_ver and product_ver != product_name and " " in product_ver - return { - "vendor": sys_vendor, - "model": product_ver if pv_usable else product_name, - "serial": product_ser, - "is_vm": False, - } + if sys_vendor or product_name: + pv_usable = product_ver and product_ver != product_name and " " in product_ver + return { + "vendor": sys_vendor, + "model": product_ver if pv_usable else product_name, + "serial": product_ser, + "is_vm": False, + } + + # ARM/embedded fallback: no DMI, try device tree and /proc/cpuinfo + arm_model = dt_model or cpuinfo_mdl + if arm_model: + return { + "vendor": _arm_vendor_from_model(arm_model), + "model": arm_model, + "serial": cpuinfo_ser, + "is_vm": False, + } + + return {"vendor": "", "model": "", "serial": "", "is_vm": False} def get_facts(self) -> dict[str, Any]: """Return basic system facts.""" diff --git a/tests/test_linux.py b/tests/test_linux.py index e465458..aea9688 100644 --- a/tests/test_linux.py +++ b/tests/test_linux.py @@ -2,7 +2,7 @@ import pytest from unittest.mock import MagicMock, patch -from napalm_linux.linux import LinuxDriver +from napalm_linux.linux import LinuxDriver, _arm_vendor_from_model # --------------------------------------------------------------------------- @@ -368,8 +368,18 @@ def _dmi_output( product_serial: str, product_uuid: str, detect_virt: str, + dt_model: str = "", + cpuinfo_serial: str = "", + cpuinfo_model: str = "", ) -> str: - return "\n".join([sys_vendor, product_name, product_version, product_serial, product_uuid, detect_virt]) + # Mirrors the real shell output: DMIBEGIN sentinel followed by 9 fields. + # The sentinel prevents _send()'s .strip() from eating leading blank lines + # (which occur on ARM where all DMI files are absent). + return "\n".join([ + "DMIBEGIN", + sys_vendor, product_name, product_version, product_serial, + product_uuid, detect_virt, dt_model, cpuinfo_serial, cpuinfo_model, + ]) class TestCollectPlatformInfo: @@ -482,6 +492,95 @@ class TestCollectPlatformInfo: assert info["model"] == "" assert info["is_vm"] is False + def test_arm_device_tree_fallback(self, driver): + raw = _dmi_output( + "", "", "", "", "", "none", + dt_model="Raspberry Pi 4 Model B Rev 1.4", + cpuinfo_serial="100000002a6d96dc", + cpuinfo_model="Raspberry Pi 4 Model B Rev 1.4", + ) + with patch.object(driver, "_send", return_value=raw): + info = driver._collect_platform_info() + assert info["vendor"] == "Raspberry Pi Foundation" + assert info["model"] == "Raspberry Pi 4 Model B Rev 1.4" + assert info["serial"] == "100000002a6d96dc" + assert info["is_vm"] is False + + def test_arm_cpuinfo_model_when_no_dt(self, driver): + raw = _dmi_output( + "", "", "", "", "", "none", + dt_model="", + cpuinfo_serial="0000000012345678", + cpuinfo_model="Raspberry Pi 3 Model B Plus Rev 1.3", + ) + with patch.object(driver, "_send", return_value=raw): + info = driver._collect_platform_info() + assert info["vendor"] == "Raspberry Pi Foundation" + assert info["model"] == "Raspberry Pi 3 Model B Plus Rev 1.3" + assert info["serial"] == "0000000012345678" + assert info["is_vm"] is False + + def test_arm_no_fallback_data_returns_empty(self, driver): + raw = _dmi_output("", "", "", "", "", "none") + with patch.object(driver, "_send", return_value=raw): + info = driver._collect_platform_info() + assert info["vendor"] == "" + assert info["model"] == "" + assert info["is_vm"] is False + + def test_bare_metal_detect_virt_exit1_does_not_shift_arm_fields(self, driver): + # systemd-detect-virt exits 1 on bare metal, old "|| echo none" caused + # d="none\nnone" which shifted subsequent lines off by one. + # With the ${d:-none} fix this no longer happens. + raw = _dmi_output( + "", "", "", "", "", "none", + dt_model="", + cpuinfo_serial="0000000012345678", + cpuinfo_model="Raspberry Pi 3 Model B Rev 1.2", + ) + with patch.object(driver, "_send", return_value=raw): + info = driver._collect_platform_info() + assert info["vendor"] == "Raspberry Pi Foundation" + assert info["model"] == "Raspberry Pi 3 Model B Rev 1.2" + assert info["serial"] == "0000000012345678" + + def test_arm_fallback_ignored_when_dmi_present(self, driver): + raw = _dmi_output( + "Dell Inc.", "PowerEdge R720", "Not Specified", "XYZ999", + "8a2e3f00-dead-beef-0000-123456789abc", "none", + dt_model="some-dt-model", + cpuinfo_serial="deadbeef", + cpuinfo_model="some cpuinfo model", + ) + with patch.object(driver, "_send", return_value=raw): + info = driver._collect_platform_info() + assert info["vendor"] == "Dell Inc." + assert info["model"] == "PowerEdge R720" + assert info["serial"] == "XYZ999" + + +# --------------------------------------------------------------------------- +# _arm_vendor_from_model +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("model,expected", [ + ("Raspberry Pi 4 Model B Rev 1.4", "Raspberry Pi Foundation"), + ("Raspberry Pi 3 Model B Rev 1.2", "Raspberry Pi Foundation"), + ("ODROID-N2L", "Hardkernel"), + ("NVIDIA Jetson Nano Developer Kit", "NVIDIA"), + ("Rock Pi 4C", "Radxa"), + ("Orange Pi 5 Plus", "Xunlong Software"), + ("Banana Pi BPI-R3", "SinoVoip"), + ("NanoPi R4S", "FriendlyElec"), + ("PINE64 RockPro64", "Pine64"), + ("BeagleBone Black", "BeagleBoard.org"), + ("Unknown Board 1.0", "Unknown Board"), + ("SomeSingleWordBoard", "SomeSingleWordBoard"), +]) +def test_arm_vendor_from_model(model, expected): + assert _arm_vendor_from_model(model) == expected + # --------------------------------------------------------------------------- # get_facts uses _collect_platform_info