feat: initial QNAP QTS driver scaffold
QTS is a Linux distribution, so the driver inherits the OS surface from LinuxDriver and adds QNAP's storage, QPKG and virtualisation layers. What is here: the class with its discovery fingerprints, per-session detection of the QTS major version and of the QPKG-local docker and virsh binaries, and explicit resolution of the MRO collisions that StorageDriver creates over LinuxDriver. What is not: the storage, QPKG and VM parsers. Those need real command output from QTS 4 and QTS 5 hardware to be written against, which is what tools/harvest.sh collects and tools/sanitize.py anonymises. Two tests are marked xfail(strict) as the specification for that work.
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
"""Unit tests for the QNAP QTS driver.
|
||||
|
||||
Command-output fixtures are captured from real hardware via tools/harvest.sh —
|
||||
one QTS 4 box and one QTS 5 box — and pasted in as module constants. Parsers are
|
||||
written against those, never against guessed output.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from napalm_qnap_qts import QnapQtsDriver
|
||||
|
||||
#: Parsers need real command output to be written against. These tests are the
|
||||
#: specification for the work that tools/harvest.sh unblocks. strict=True means
|
||||
#: the suite goes red the moment one starts passing, which is the reminder to
|
||||
#: delete the marker rather than leave it lying around.
|
||||
_PENDING_HARVEST = "blocked on fixture harvest from real QTS 4 / QTS 5 hardware"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def driver():
|
||||
"""A driver with its transport mocked out, bypassing __init__.
|
||||
|
||||
Same shape as napalm-linux's fixture: nothing in __init__ needs patching,
|
||||
so constructing the object by hand is cheaper and clearer than mocking
|
||||
ConnectHandler.
|
||||
"""
|
||||
d = QnapQtsDriver.__new__(QnapQtsDriver)
|
||||
d.hostname = "testnas"
|
||||
d.username = "admin"
|
||||
d.password = "pass" # noqa: S105
|
||||
d.timeout = 60
|
||||
d.port = 22
|
||||
d._secret = "pass" # noqa: S105
|
||||
d._forced_pkg_manager = None
|
||||
d._pkg_manager = None
|
||||
d._sudo_password = None
|
||||
d.netmiko_optional_args = {}
|
||||
d._device = MagicMock()
|
||||
d._qts_major = 5
|
||||
d._docker_path = "docker"
|
||||
d._virsh_path = None
|
||||
return d
|
||||
|
||||
|
||||
class TestDriverIdentity:
|
||||
"""Read without a connection by netOrk's discovery and /drivers endpoint."""
|
||||
|
||||
def test_driver_name(self):
|
||||
assert QnapQtsDriver.DRIVER_NAME == "qnap_qts"
|
||||
|
||||
def test_lowercase_driver_name_alias_matches(self):
|
||||
"""netOrk's register_driver path reads the lowercase attribute."""
|
||||
assert QnapQtsDriver.driver_name == QnapQtsDriver.DRIVER_NAME
|
||||
|
||||
def test_vendor(self):
|
||||
assert QnapQtsDriver.VENDOR == "QNAP"
|
||||
|
||||
def test_type_label_is_storage(self):
|
||||
assert QnapQtsDriver.TYPE_LABEL == "Storage"
|
||||
|
||||
def test_declares_device_class_explicitly(self):
|
||||
"""Inheriting LinuxDriver would otherwise get it classified as "linux",
|
||||
and netOrk would hide the Storage tab."""
|
||||
assert QnapQtsDriver.DEVICE_CLASS == "storage"
|
||||
|
||||
def test_declares_at_least_one_fingerprint_source(self):
|
||||
"""Discovery silently skips a driver that declares no fingerprint data."""
|
||||
assert (
|
||||
QnapQtsDriver.HTTP_FINGERPRINT
|
||||
or QnapQtsDriver.SNMP_FINGERPRINT
|
||||
or QnapQtsDriver.SSH_FINGERPRINT
|
||||
)
|
||||
|
||||
def test_snmp_object_id_is_the_qnap_enterprise_oid(self):
|
||||
assert QnapQtsDriver.SNMP_OBJECT_ID_PREFIX == "1.3.6.1.4.1.24681"
|
||||
|
||||
def test_http_fingerprint_is_mandatory_to_avoid_matching_any_nas(self):
|
||||
patterns = {r.pattern: r for r in QnapQtsDriver.HTTP_FINGERPRINT}
|
||||
assert patterns["qnap"].mandatory is True
|
||||
|
||||
|
||||
class TestMroForwarding:
|
||||
"""StorageDriver precedes LinuxDriver in the MRO, so its NotImplementedError
|
||||
stubs shadow LinuxDriver's working implementations. Every collision has to be
|
||||
resolved deliberately — this is the class of bug that makes a driver look
|
||||
fine until it runs against hardware.
|
||||
"""
|
||||
|
||||
def test_get_services_returns_the_linux_os_service_list(self, driver):
|
||||
"""netOrk's poller expects a list here (OS services). StorageDriver's
|
||||
stub would return a dict of NAS services, if it returned anything."""
|
||||
from napalm_linux.linux import LinuxDriver
|
||||
|
||||
with patch.object(LinuxDriver, "get_services", return_value=[{"name": "sshd"}]) as m:
|
||||
result = driver.get_services()
|
||||
|
||||
assert m.called
|
||||
assert result == [{"name": "sshd"}]
|
||||
|
||||
@pytest.mark.xfail(strict=True, reason=_PENDING_HARVEST)
|
||||
def test_nas_services_live_under_a_separate_name(self):
|
||||
"""get_storage_services is what netOrk's _collect.py actually reads for
|
||||
the storage snapshot — get_services is the OS list."""
|
||||
assert hasattr(QnapQtsDriver, "get_storage_services")
|
||||
|
||||
@pytest.mark.xfail(strict=True, reason=_PENDING_HARVEST)
|
||||
def test_get_packages_is_not_the_storage_stub(self):
|
||||
from napalm_device_types import StorageDriver
|
||||
|
||||
assert QnapQtsDriver.get_packages is not StorageDriver.get_packages
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("method", "args"),
|
||||
[
|
||||
("install_package", ("qpkg-name",)),
|
||||
("remove_package", ("qpkg-name",)),
|
||||
("snapshot_create", ("DataVol1", "snap1")),
|
||||
],
|
||||
)
|
||||
def test_out_of_scope_writers_still_raise(self, driver, method, args):
|
||||
"""v1 is read-only plus safe actions. These must fail loudly rather than
|
||||
appear supported — netOrk's poller catches NotImplementedError."""
|
||||
with pytest.raises(NotImplementedError):
|
||||
getattr(driver, method)(*args)
|
||||
|
||||
|
||||
class TestQtsVersionDetection:
|
||||
def test_parses_major_version(self, driver):
|
||||
with patch.object(driver, "_send", return_value="5.1.5"):
|
||||
assert driver._detect_qts_major() == 5
|
||||
|
||||
def test_parses_qts_four(self, driver):
|
||||
with patch.object(driver, "_send", return_value="4.5.4"):
|
||||
assert driver._detect_qts_major() == 4
|
||||
|
||||
def test_tolerates_a_build_suffix(self, driver):
|
||||
with patch.object(driver, "_send", return_value="5.2.0.2782 (2026/03/14)"):
|
||||
assert driver._detect_qts_major() == 5
|
||||
|
||||
def test_unreadable_version_does_not_raise(self, driver):
|
||||
"""An unknown version must degrade to the newer code path, not abort the
|
||||
poll — a NAS answering nothing useful here is still worth polling."""
|
||||
with patch.object(driver, "_send", return_value=""):
|
||||
assert driver._detect_qts_major() is None
|
||||
|
||||
|
||||
class TestDockerBinDiscovery:
|
||||
"""Container Station does not put docker on PATH."""
|
||||
|
||||
def test_uses_the_container_station_path_when_present(self, driver):
|
||||
found = "/share/CACHEDEV1_DATA/.qpkg/container-station/bin/docker"
|
||||
with patch.object(driver, "_send", return_value=found):
|
||||
assert driver._discover_docker_path() == found
|
||||
|
||||
def test_falls_back_to_plain_docker_when_nothing_is_found(self, driver):
|
||||
with patch.object(driver, "_send", return_value=""):
|
||||
assert driver._discover_docker_path() == "docker"
|
||||
|
||||
def test_docker_bin_hook_returns_the_discovered_path(self, driver):
|
||||
driver._docker_path = "/opt/docker"
|
||||
assert driver._docker_bin() == "/opt/docker"
|
||||
@@ -0,0 +1,84 @@
|
||||
"""The harvest sanitiser (tools/sanitize.py).
|
||||
|
||||
Fixtures are committed forever, so a miss here puts a real NAS's serial numbers
|
||||
and addresses into git history. Worth testing even though it is a dev tool.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import pathlib
|
||||
|
||||
import pytest
|
||||
|
||||
_SPEC = importlib.util.spec_from_file_location(
|
||||
"qnap_sanitize", pathlib.Path(__file__).parent.parent / "tools" / "sanitize.py"
|
||||
)
|
||||
assert _SPEC and _SPEC.loader
|
||||
sanitize = importlib.util.module_from_spec(_SPEC)
|
||||
_SPEC.loader.exec_module(sanitize)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def scrubber():
|
||||
return sanitize.Scrubber()
|
||||
|
||||
|
||||
class TestSerials:
|
||||
def test_labelled_serial_is_replaced(self, scrubber):
|
||||
out = scrubber.scrub("Serial Number: WD-WCC4N7RTKZ9P")
|
||||
assert "WD-WCC4N7RTKZ9P" not in out
|
||||
assert "SERIAL001XXXX" in out
|
||||
|
||||
def test_lsblk_serial_field_is_replaced(self, scrubber):
|
||||
out = scrubber.scrub('NAME="sda" MODEL="WD40EFRX" SERIAL="WD-WCC4N7RTKZ9P"')
|
||||
assert "WD-WCC4N7RTKZ9P" not in out
|
||||
assert 'MODEL="WD40EFRX"' in out, "model names are not identifying and must survive"
|
||||
|
||||
def test_same_serial_maps_to_the_same_placeholder(self, scrubber):
|
||||
"""A disk serial appears in several harvested files; the cross-reference
|
||||
has to survive or the fixtures stop describing one coherent device."""
|
||||
first = scrubber.scrub("serial: ABC123456")
|
||||
second = scrubber.scrub('SERIAL="ABC123456"')
|
||||
assert "SERIAL001XXXX" in first
|
||||
assert "SERIAL001XXXX" in second
|
||||
|
||||
def test_distinct_serials_get_distinct_placeholders(self, scrubber):
|
||||
out = scrubber.scrub("sn: AAA111222\nsn: BBB333444")
|
||||
assert "SERIAL001XXXX" in out
|
||||
assert "SERIAL002XXXX" in out
|
||||
|
||||
|
||||
class TestNetworkIdentifiers:
|
||||
def test_mac_is_replaced(self, scrubber):
|
||||
out = scrubber.scrub("link/ether 24:5e:be:11:22:33 brd ff:ff:ff:ff:ff:ff")
|
||||
assert "24:5e:be:11:22:33" not in out
|
||||
assert "00:11:22:33:44:" in out
|
||||
|
||||
def test_ipv4_is_replaced(self, scrubber):
|
||||
out = scrubber.scrub("inet 10.7.224.12/24")
|
||||
assert "10.7.224.12" not in out
|
||||
assert "192.0.2.1" in out
|
||||
|
||||
def test_loopback_and_wildcard_survive(self, scrubber):
|
||||
"""Replacing these makes fixtures unreadable and reveals nothing."""
|
||||
out = scrubber.scrub("0.0.0.0:445 127.0.0.1:22")
|
||||
assert "0.0.0.0" in out
|
||||
assert "127.0.0.1" in out
|
||||
|
||||
def test_same_ip_maps_consistently(self, scrubber):
|
||||
out = scrubber.scrub("gw 10.0.0.1\nvia 10.0.0.1")
|
||||
assert out.count("192.0.2.1") == 2
|
||||
|
||||
|
||||
class TestHostnames:
|
||||
def test_extra_host_is_replaced_case_insensitively(self):
|
||||
s = sanitize.Scrubber(["MyNAS"])
|
||||
out = s.scrub("Server Name = mynas\nhost MYNAS ok")
|
||||
assert "mynas" not in out.lower().replace("testnas", "")
|
||||
assert out.count("testnas") == 2
|
||||
|
||||
def test_empty_host_entry_is_ignored(self):
|
||||
"""An unset --extra-host must not turn into a regex that matches everything."""
|
||||
s = sanitize.Scrubber(["", None])
|
||||
assert s.scrub("untouched text") == "untouched text"
|
||||
Reference in New Issue
Block a user