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:
2026-08-21 09:53:13 +07:00
commit 26d723f417
11 changed files with 893 additions and 0 deletions
+156
View File
@@ -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)