From 26d723f4171da4cff9e37f212341a21819bde243 Mon Sep 17 00:00:00 2001 From: Christian Manivong Date: Fri, 21 Aug 2026 09:53:13 +0700 Subject: [PATCH] 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. --- .gitignore | 13 +++ CHANGELOG.md | 25 ++++++ README.md | 132 +++++++++++++++++++++++++++++ napalm_qnap_qts/__init__.py | 5 ++ napalm_qnap_qts/qnap_qts.py | 156 ++++++++++++++++++++++++++++++++++ pyproject.toml | 81 ++++++++++++++++++ tests/__init__.py | 0 tests/test_qnap_qts.py | 165 ++++++++++++++++++++++++++++++++++++ tests/test_sanitize.py | 84 ++++++++++++++++++ tools/harvest.sh | 105 +++++++++++++++++++++++ tools/sanitize.py | 127 +++++++++++++++++++++++++++ 11 files changed, 893 insertions(+) create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 README.md create mode 100644 napalm_qnap_qts/__init__.py create mode 100644 napalm_qnap_qts/qnap_qts.py create mode 100644 pyproject.toml create mode 100644 tests/__init__.py create mode 100644 tests/test_qnap_qts.py create mode 100644 tests/test_sanitize.py create mode 100755 tools/harvest.sh create mode 100755 tools/sanitize.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0516751 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +build/ +dist/ +.venv/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ +# Raw, unsanitised harvest output — never commit device data. +tools/harvest-out/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0752258 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,25 @@ +# Changelog + +All notable changes to this project are documented here. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- Initial driver scaffold: `QnapQtsDriver(StorageDriver, LinuxDriver)` with + discovery fingerprints (SNMP enterprise OID 1.3.6.1.4.1.24681, HTTP, port + specs) and the `qnap_qts` entry point. +- Explicit `DEVICE_CLASS = "storage"` so netOrk does not classify a QNAP as a + plain Linux host and hide its Storage tab. +- QTS major-version detection, Container Station docker path discovery and + Virtualization Station virsh discovery, all resolved once per session in + `open()`. +- MRO collision resolution for `get_services`, which `StorageDriver` would + otherwise shadow. +- `tools/harvest.sh` and `tools/sanitize.py` for capturing and anonymising + command output from real hardware. + +### Pending +- Storage, QPKG and VM parsers — blocked on the fixture harvest. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4d555ba --- /dev/null +++ b/README.md @@ -0,0 +1,132 @@ +# napalm-qnap-qts + +NAPALM driver for QNAP NAS systems running QTS, over SSH. + +QTS is a Linux distribution, so this driver inherits the whole OS surface from +[`napalm-linux`](https://git.netork.io/christianmanivong/napalm-linux) — +packages, services, users, processes, Docker — and adds QNAP's storage, QPKG +and virtualisation layers on top. + +Tested against QTS 4.x and QTS 5.x. + +## Status + +The class scaffold, discovery fingerprints and version/tool detection are in +place. The storage, QPKG and VM parsers are **not written yet**: they are +blocked on capturing real command output from hardware (see +[Harvesting fixtures](#harvesting-fixtures)). Writing parsers against guessed +output is how a driver ends up passing its own tests and failing on a real NAS. + +| Method | Source | Status | +|---|---|---| +| `get_facts` | `getcfg`, `getsysinfo`, `/proc/uptime` | pending harvest | +| `get_interfaces`, `get_interfaces_ip` | `ip` / `ifconfig` | pending harvest | +| `get_disks` | `qcli_storage -d`, `get_hd_smartinfo` | pending harvest | +| `get_disk_pools` | `qcli_storage -p`, `/proc/mdstat` | pending harvest | +| `get_volumes` | `qcli_storage -v`, `df` | pending harvest | +| `get_shares` | `/etc/config/smb.conf`, `/etc/exports` | pending harvest | +| `get_storage_services` | `getcfg`, `ss -lntup` | pending harvest | +| `get_disk_smart` | `get_hd_smartinfo` | pending harvest | +| `get_packages` | QPKG (`qpkg.conf` / `qpkg_cli`) | pending harvest | +| `get_vms` | `virsh` (Virtualization Station) | pending harvest | +| `start_vm`, `stop_vm`, `reboot_vm` | `virsh` | pending harvest | +| `set_service_enabled` | `setcfg`, `/etc/init.d` | pending harvest | +| `get_device_warnings` | derived from the above | pending harvest | +| `get_docker_info` | inherited, via `_docker_bin()` | ✅ | +| `get_services`, `get_users`, `get_processes` | inherited from `LinuxDriver` | ✅ | +| `get_health_metrics` | inherited (UCD-MIB over SNMP) | ✅ | +| `ping`, `ping_sweep` | inherited from `LinuxDriver` | ✅ | +| Snapshots, quotas, replication, QPKG install/remove | — | out of scope for v1 | + +## Requirements + +SSH must be enabled on the NAS: **Control Panel → Telnet/SSH → Allow SSH +connection**. The driver connects on port 22 by default; the QTS web UI on +443/8080 is not used. + +## Install + +```bash +pip install -e vendor/napalm-device-types/ -e vendor/napalm-linux/ -e vendor/napalm-qnap-qts/ +``` + +## Usage + +```python +from napalm_qnap_qts import QnapQtsDriver + +driver = QnapQtsDriver("nas.example.lan", "admin", "secret") +driver.open() +print(driver.get_facts()) +driver.close() +``` + +Recognised `optional_args`: everything `napalm-linux` accepts (`port`, +`sudo_password`, `secret`, …). Unknown keys are ignored. + +## Design notes + +### Inheritance order + +```python +class QnapQtsDriver(StorageDriver, LinuxDriver): +``` + +`StorageDriver` precedes `LinuxDriver` in the MRO, so its +`NotImplementedError` stubs shadow LinuxDriver's working implementations +wherever the names collide — `get_services`, `get_packages`, +`install_package`. Each collision is resolved with an explicit forwarding +method; `TestMroForwarding` guards that they stay resolved. + +NAS services are exposed as `get_storage_services()`, not `get_services()`: +netOrk's poller reads the former for the storage snapshot and the latter for +the OS service list. Same convention as `napalm-openmediavault`. + +### Device class + +The driver sets `DEVICE_CLASS = "storage"` explicitly. Without it netOrk's +`issubclass` chain reaches `OSDriver` before `StorageDriver` and would file a +QNAP under "linux", hiding its Storage tab. VMs and containers stay visible +through capability introspection (`supports_vms`), not through this key — a +QNAP running Virtualization Station is a NAS *and* a hypervisor, and +`device_class` only holds one of those. + +### Docker + +Container Station does not put `docker` on `PATH`; it lives under +`/share//.qpkg/container-station/`. The Docker *logic* stays in +`LinuxDriver` and only the path is overridden here, via the `_docker_bin()` +hook. + +## Harvesting fixtures + +```bash +./tools/harvest.sh admin@nas4.example.lan qts4 +./tools/harvest.sh admin@nas5.example.lan qts5 +./tools/sanitize.py tools/harvest-out/qts5 --extra-host mynas +``` + +`harvest.sh` runs the command set this driver parses over a single SSH session +and writes one file per command. `sanitize.py` replaces serial numbers, MACs, +IP addresses and hostnames with stable placeholders — cross-references between +files survive, so the fixtures still describe one coherent device. + +**Read the sanitised output before committing it.** The sanitiser catches +patterns, not judgement, and fixtures live in git forever. Raw harvest output +is gitignored and must never be committed. + +Where QTS 4 and QTS 5 differ, keep both fixtures and parametrise the test over +the pair, so the version divergence is part of the test matrix rather than a +later surprise. + +## Development + +```bash +pip install -e ".[dev]" +python -m pytest -q +ruff check . +``` + +## License + +Apache-2.0 diff --git a/napalm_qnap_qts/__init__.py b/napalm_qnap_qts/__init__.py new file mode 100644 index 0000000..653b19a --- /dev/null +++ b/napalm_qnap_qts/__init__.py @@ -0,0 +1,5 @@ +"""napalm-qnap-qts – NAPALM driver for QNAP NAS systems running QTS.""" + +from napalm_qnap_qts.qnap_qts import QnapQtsDriver + +__all__ = ["QnapQtsDriver"] diff --git a/napalm_qnap_qts/qnap_qts.py b/napalm_qnap_qts/qnap_qts.py new file mode 100644 index 0000000..3fb4c87 --- /dev/null +++ b/napalm_qnap_qts/qnap_qts.py @@ -0,0 +1,156 @@ +# Licensed under the Apache License, Version 2.0 + +"""NAPALM driver for QNAP NAS systems running QTS. + +QTS is a Linux distribution, so this driver inherits the whole OS surface from +:class:`napalm_linux.linux.LinuxDriver` — packages, services, users, processes, +Docker — and adds QNAP's own storage, QPKG and virtualisation layers on top: + +* Physical disk inventory and SMART via ``qcli_storage`` and ``get_hd_smartinfo`` +* Storage pools and volumes via ``qcli_storage`` and ``/proc/mdstat`` +* SMB/NFS/AFP/FTP shares from ``/etc/config/smb.conf`` and ``/etc/exports`` +* QPKG packages instead of a distribution package manager +* Virtualization Station guests via ``virsh`` +* Container Station via LinuxDriver's Docker support, redirected to the + QPKG-local ``docker`` binary + +Connects via SSH. SSH has to be enabled on the NAS first +(Control Panel → Telnet/SSH). Tested against QTS 4.x and QTS 5.x. +""" + +from __future__ import annotations + +import re +from typing import Any + +from napalm_device_types import FingerprintRule, PortSpec, StorageDriver +from napalm_linux.linux import LinuxDriver + +#: QNAP Systems' IANA enterprise number. Used by discovery to recognise a NAS +#: from its SNMP sysObjectID before anyone has supplied credentials. +QNAP_ENTERPRISE_OID = "1.3.6.1.4.1.24681" + +#: Where Container Station puts the docker binary. It is not on PATH, and the +#: volume name varies with which pool the app was installed on, so this is a +#: glob evaluated on the device rather than a fixed path. +_DOCKER_GLOBS = ( + "/share/*/.qpkg/container-station/bin/docker", + "/share/*/.qpkg/container-station/usr/bin/docker", +) + +_VERSION_RE = re.compile(r"(\d+)\.") + + +class QnapQtsDriver(StorageDriver, LinuxDriver): + """NAPALM driver for QNAP NAS systems running QTS. + + **Inheritance order matters.** ``StorageDriver`` precedes ``LinuxDriver`` in + the MRO, so its ``NotImplementedError`` stubs shadow LinuxDriver's working + implementations wherever the names collide — ``get_services``, + ``get_packages`` and ``install_package``. Each collision is resolved + explicitly below rather than left to the MRO; see ``TestMroForwarding``. + + NAS services are exposed as ``get_storage_services()``, not + ``get_services()``, because netOrk's poller reads the former for the storage + snapshot and the latter for the OS service list. Same convention as + napalm-openmediavault. + """ + + TYPE_LABEL = "Storage" + # Declared outright: the driver inherits LinuxDriver for the OS surface, so + # netOrk's issubclass chain would reach OSDriver first and classify a QNAP + # as "linux", hiding its Storage tab. VMs and containers stay visible + # through capability introspection, not through this key. + DEVICE_CLASS = "storage" + VENDOR = "QNAP" + DRIVER_NAME = "qnap_qts" + driver_name = "qnap_qts" + NETMIKO_DEVICE_TYPE = "linux" + + SNMP_OBJECT_ID_PREFIX = QNAP_ENTERPRISE_OID + SNMP_FINGERPRINT = [ + FingerprintRule("qnap", weight=9.0), + FingerprintRule("nas", weight=1.0), + ] + # QTS answers with a stock OpenSSH banner, so SSH alone cannot identify a + # QNAP — it only confirms the transport this driver needs. + SSH_FINGERPRINT = [ + FingerprintRule("openssh", weight=1.0), + ] + # Mandatory: without it every unidentified NAS web UI would score as a QNAP. + HTTP_FINGERPRINT = [ + FingerprintRule("qnap", weight=9.0, mandatory=True), + FingerprintRule("qts", weight=4.0), + ] + PORT_SPECS = [ + PortSpec("https", 443), + PortSpec("http", 8080), + ] + + # ── Lifecycle ───────────────────────────────────────────────────────────── + + def open(self) -> None: + """Connect, then resolve the facts that decide which code paths run. + + QTS 4 and QTS 5 differ in the output of several tools, and the QPKG apps + that provide docker and virsh live on whichever storage pool they were + installed on. Resolving all of that once per session keeps every getter + below free of discovery round trips. + """ + super().open() + self._qts_major = self._detect_qts_major() + self._docker_path = self._discover_docker_path() + self._virsh_path = self._discover_virsh_path() + + def _detect_qts_major(self) -> int | None: + """Return the QTS major version, or None when it cannot be read. + + Deliberately not fatal: a NAS that answers nothing useful here is still + worth polling, and the newer code path is the better default. + """ + try: + raw = self._send("getcfg System Version").strip() + except Exception: + return None + match = _VERSION_RE.match(raw) + return int(match.group(1)) if match else None + + def _discover_docker_path(self) -> str: + """Locate Container Station's docker binary, falling back to PATH.""" + try: + found = self._send(f"ls {' '.join(_DOCKER_GLOBS)} 2>/dev/null | head -1").strip() + except Exception: + return "docker" + return found.splitlines()[0].strip() if found else "docker" + + def _discover_virsh_path(self) -> str | None: + """Locate Virtualization Station's virsh, or None when it is not installed. + + None is a normal outcome — plenty of QNAP models never run VMs — and it + is what makes ``get_vms`` return an empty list instead of raising. + """ + try: + found = self._send( + "ls /share/*/.qpkg/QKVM/usr/bin/virsh /share/*/.qpkg/*/bin/virsh " + "2>/dev/null | head -1" + ).strip() + except Exception: + return None + return found.splitlines()[0].strip() or None if found else None + + def _docker_bin(self) -> str: + """Override LinuxDriver's hook: docker is not on PATH under QTS.""" + return getattr(self, "_docker_path", None) or "docker" + + # ── MRO collision resolution ────────────────────────────────────────────── + # + # StorageDriver comes first in the MRO and its stubs would otherwise win. + + def get_services(self) -> Any: + """OS services, not NAS services — this is what netOrk's poller reads. + + Forwarded explicitly past ``StorageDriver.get_services``, which shadows + it and returns a different shape (dict of NAS services vs. list of OS + services). + """ + return LinuxDriver.get_services(self) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..fb41a8a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,81 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "napalm-qnap-qts" +version = "0.1.0" +description = "NAPALM driver for QNAP NAS systems running QTS via SSH" +readme = "README.md" +requires-python = ">=3.9" +license = { text = "Apache-2.0" } +authors = [ + { name = "Christian Manivong", email = "christian@manivong.de" }, +] +keywords = [ + "napalm", + "network", + "automation", + "qnap", + "qts", + "nas", + "storage", + "ssh", + "driver", +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Intended Audience :: System Administrators", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: System :: Networking", + "Topic :: System :: Systems Administration", + "Typing :: Typed", +] +# napalm-linux is a hard dependency, not an optional one: QTS is a Linux system +# and this driver inherits the whole OS surface (packages, services, users, +# processes, Docker) from LinuxDriver rather than reimplementing it. +dependencies = [ + "napalm>=4.0", + "napalm-device-types>=0.5.0", + "napalm-linux>=0.1.0", + "netmiko>=4.0.0", + "paramiko>=5.0.0", # CVE-2026-44405 +] + +[project.optional-dependencies] +dev = [ + "pytest", + "pytest-cov", + "black", + "ruff", + "mypy", +] + +[project.entry-points."napalm.drivers"] +qnap_qts = "napalm_qnap_qts:QnapQtsDriver" + +[project.urls] +Repository = "https://git.netork.io/christianmanivong/napalm-qnap-qts" + +[tool.setuptools.packages.find] +where = ["."] +include = ["napalm_qnap_qts*"] + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.ruff] +line-length = 100 +target-version = "py39" + +# Same rule set the netOrk repo gates on, so this driver is held to the +# standard of the project that consumes it rather than to ruff's defaults. +[tool.ruff.lint] +select = ["E", "F", "I", "UP"] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_qnap_qts.py b/tests/test_qnap_qts.py new file mode 100644 index 0000000..eef7d5b --- /dev/null +++ b/tests/test_qnap_qts.py @@ -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" diff --git a/tests/test_sanitize.py b/tests/test_sanitize.py new file mode 100644 index 0000000..8fa6b3b --- /dev/null +++ b/tests/test_sanitize.py @@ -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" diff --git a/tools/harvest.sh b/tools/harvest.sh new file mode 100755 index 0000000..3ab36e0 --- /dev/null +++ b/tools/harvest.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# Collect the command outputs this driver parses, from a real QNAP NAS. +# +# Run once against a QTS 4 box and once against a QTS 5 box; the resulting +# files become the test fixtures. Writing parsers against guessed output is how +# a driver ends up passing its own tests and failing on hardware, so this runs +# before any parser code. +# +# ./tools/harvest.sh admin@nas1.example.lan qts4 +# ./tools/harvest.sh admin@nas2.example.lan qts5 +# +# Output lands in tools/harvest-out/