feat: declare all three roles, and refuse QPKG writes deliberately

A QNAP is a NAS, a hypervisor and a Linux host. It can now say so, because role
bases in napalm-device-types v1.0 declare their methods without implementing
them:

    class QnapQtsDriver(StorageDriver, HypervisorDriver, LinuxDriver):

device_class comes from the first base, so DEVICE_CLASS is gone. The get_services
forwarder is gone with it — nothing shadows LinuxDriver's version any more — and
so is the TestMroForwarding class that guarded the collisions.

Removing the shadowing exposed something the shadowing had been hiding by
accident. This driver never implemented package management; StorageDriver's stub
for install_package covered LinuxDriver's working one, so calling it raised and
looked correct. It was a coincidence, not a decision: without the stub the call
falls through to LinuxDriver, which would run apt or dnf against a NAS that has
neither. QTS uses QPKG.

get_packages, install_package and uninstall_package are therefore overridden
here to refuse with a reason, until the device harvest supplies real QPKG
parsing. See netork#114.
This commit is contained in:
Christian Manivong
2026-08-21 12:50:27 +07:00
parent 35c47bdb24
commit 927e39bf36
2 changed files with 77 additions and 51 deletions
+45 -26
View File
@@ -11,6 +11,7 @@ from unittest.mock import MagicMock, patch
import pytest
from napalm_device_types import primary_role_of, role_keys_of
from napalm_qnap_qts import QnapQtsDriver
#: Parsers need real command output to be written against. These tests are the
@@ -62,10 +63,14 @@ class TestDriverIdentity:
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_every_role_it_fills(self):
"""A QNAP is a NAS, a hypervisor and a Linux host at once."""
assert role_keys_of(QnapQtsDriver) == ["storage", "hypervisor", "linux"]
def test_storage_leads_because_it_is_listed_first(self):
"""device_class comes from the order of the role bases, not from an
attribute restating it."""
assert primary_role_of(QnapQtsDriver) == "storage"
def test_declares_at_least_one_fingerprint_source(self):
"""Discovery silently skips a driver that declares no fingerprint data."""
@@ -83,42 +88,51 @@ class TestDriverIdentity:
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.
class TestRolesDoNotShadowLinux:
"""The role bases declare their methods; they implement none.
Before that change, ``StorageDriver`` preceded ``LinuxDriver`` in the MRO and
its ``NotImplementedError`` stubs replaced LinuxDriver's working
implementations, so this driver carried a hand-written forwarder for every
collision. There is nothing left to collide with.
"""
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."""
@pytest.mark.parametrize("method", ["get_services", "get_users", "get_docker_info"])
def test_os_surface_resolves_to_linux(self, method):
"""QTS really is Linux for these, so inheriting them is correct."""
from napalm_linux.linux import LinuxDriver
with patch.object(LinuxDriver, "get_services", return_value=[{"name": "sshd"}]) as m:
result = driver.get_services()
owner = next(k for k in QnapQtsDriver.__mro__ if method in k.__dict__)
assert owner is LinuxDriver
assert m.called
assert result == [{"name": "sshd"}]
@pytest.mark.parametrize(
"method", ["get_packages", "install_package", "uninstall_package"]
)
def test_package_surface_is_refused_deliberately(self, method):
"""QTS has no apt/dnf, so LinuxDriver's versions must not be inherited
silently -- this driver overrides them to refuse."""
owner = next(k for k in QnapQtsDriver.__mro__ if method in k.__dict__)
assert owner is QnapQtsDriver
def test_no_forwarding_methods_remain(self):
"""A forwarder here would mean the shadowing came back."""
own = {
name for name, val in vars(QnapQtsDriver).items()
if callable(val) and not name.startswith("__")
}
assert "get_services" not in own
@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."""
"""get_storage_services is what netOrk's _collect.py 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")),
("uninstall_package", ("qpkg-name",)),
],
)
def test_out_of_scope_writers_still_raise(self, driver, method, args):
@@ -127,6 +141,11 @@ class TestMroForwarding:
with pytest.raises(NotImplementedError):
getattr(driver, method)(*args)
def test_volume_snapshot_writer_is_not_implemented_yet(self):
"""Declared on StorageDriver for type checkers only, so it does not
exist until the harvest supplies a real implementation."""
assert not hasattr(QnapQtsDriver, "create_volume_snapshot")
class TestQtsVersionDetection:
def test_parses_major_version(self, driver):