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,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)
|
||||
Reference in New Issue
Block a user