feat: ARM hardware detection via device tree and /proc/cpuinfo
On ARM boards (Raspberry Pi, ODROID, etc.) /sys/class/dmi/id/ does not
exist. _collect_platform_info() now falls back to:
- /sys/firmware/devicetree/base/model (preferred)
- /proc/cpuinfo Model: / Serial: (fallback)
Three bugs fixed in the process:
1. systemd-detect-virt exits 1 on bare metal, so the old
"|| echo none" pattern produced d="none\nnone" (two lines),
shifting all subsequent fields by one. Fixed with ${d:-none}.
2. _send() calls .strip() on output, silently eating the five leading
blank lines that represent empty DMI fields on ARM. Fixed by
prefixing the printf output with a DMIBEGIN sentinel so the parser
can locate field 0 regardless of leading whitespace.
3. Vendor was always empty for ARM, falling back to the generic "Linux"
constant. Added _ARM_VENDOR_PREFIXES lookup table and
_arm_vendor_from_model() to derive the canonical vendor name from
the model string (e.g. "Raspberry Pi Foundation" for any RPi board).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
450aa193ba
commit
1bfc8dd245
+61
-3
@@ -85,6 +85,36 @@ _VM_DMI_VENDORS: frozenset[str] = frozenset({
|
|||||||
"parallels software international inc.",
|
"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):
|
class LinuxDriver(OSDriver):
|
||||||
"""NAPALM driver for generic Linux systems.
|
"""NAPALM driver for generic Linux systems.
|
||||||
@@ -235,11 +265,23 @@ class LinuxDriver(OSDriver):
|
|||||||
"r=$(cat /sys/class/dmi/id/product_version 2>/dev/null); "
|
"r=$(cat /sys/class/dmi/id/product_version 2>/dev/null); "
|
||||||
"s=$(cat /sys/class/dmi/id/product_serial 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); "
|
"u=$(cat /sys/class/dmi/id/product_uuid 2>/dev/null); "
|
||||||
"d=$(systemd-detect-virt 2>/dev/null || echo none); "
|
"d=$(systemd-detect-virt 2>/dev/null); d=${d:-none}; "
|
||||||
"printf '%s\\n%s\\n%s\\n%s\\n%s\\n%s\\n' \"$v\" \"$n\" \"$r\" \"$s\" \"$u\" \"$d\""
|
"dt=$(tr -d '\\0' </sys/firmware/devicetree/base/model 2>/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:
|
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:
|
except Exception:
|
||||||
return {"vendor": "", "model": "", "serial": "", "is_vm": False}
|
return {"vendor": "", "model": "", "serial": "", "is_vm": False}
|
||||||
|
|
||||||
@@ -253,6 +295,9 @@ class LinuxDriver(OSDriver):
|
|||||||
product_ser = _clean(3)
|
product_ser = _clean(3)
|
||||||
product_uuid = _clean(4)
|
product_uuid = _clean(4)
|
||||||
detect_virt = lines[5].strip().lower() if len(lines) > 5 else "none"
|
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_container = detect_virt in _CONTAINER_VIRT
|
||||||
is_vm = (
|
is_vm = (
|
||||||
@@ -292,6 +337,7 @@ class LinuxDriver(OSDriver):
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Bare-metal: prefer product_version when it reads like a marketing name
|
# Bare-metal: prefer product_version when it reads like a marketing name
|
||||||
|
if sys_vendor or product_name:
|
||||||
pv_usable = product_ver and product_ver != product_name and " " in product_ver
|
pv_usable = product_ver and product_ver != product_name and " " in product_ver
|
||||||
return {
|
return {
|
||||||
"vendor": sys_vendor,
|
"vendor": sys_vendor,
|
||||||
@@ -300,6 +346,18 @@ class LinuxDriver(OSDriver):
|
|||||||
"is_vm": False,
|
"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]:
|
def get_facts(self) -> dict[str, Any]:
|
||||||
"""Return basic system facts."""
|
"""Return basic system facts."""
|
||||||
hostname = self._send("hostname -s 2>/dev/null || hostname")
|
hostname = self._send("hostname -s 2>/dev/null || hostname")
|
||||||
|
|||||||
+101
-2
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from unittest.mock import MagicMock, patch
|
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_serial: str,
|
||||||
product_uuid: str,
|
product_uuid: str,
|
||||||
detect_virt: str,
|
detect_virt: str,
|
||||||
|
dt_model: str = "",
|
||||||
|
cpuinfo_serial: str = "",
|
||||||
|
cpuinfo_model: str = "",
|
||||||
) -> 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:
|
class TestCollectPlatformInfo:
|
||||||
@@ -482,6 +492,95 @@ class TestCollectPlatformInfo:
|
|||||||
assert info["model"] == ""
|
assert info["model"] == ""
|
||||||
assert info["is_vm"] is False
|
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
|
# get_facts uses _collect_platform_info
|
||||||
|
|||||||
Reference in New Issue
Block a user