init
This commit is contained in:
@@ -0,0 +1,25 @@
|
|||||||
|
"""
|
||||||
|
napalm-openmediavault
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
NAPALM driver for OpenMediaVault NAS systems.
|
||||||
|
|
||||||
|
Extends LinuxDriver with OMV-specific storage capabilities:
|
||||||
|
physical disk inventory, md RAID pools, mounted filesystems,
|
||||||
|
and SMB/NFS/FTP share enumeration via the OMV config database.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
from napalm_openmediavault import OpenMediaVaultDriver
|
||||||
|
|
||||||
|
driver = OpenMediaVaultDriver("192.168.1.10", "admin", "secret")
|
||||||
|
driver.open()
|
||||||
|
print(driver.get_facts())
|
||||||
|
print(driver.get_disks())
|
||||||
|
print(driver.get_shares())
|
||||||
|
driver.close()
|
||||||
|
"""
|
||||||
|
|
||||||
|
from napalm_openmediavault.openmediavault import OpenMediaVaultDriver
|
||||||
|
|
||||||
|
__all__ = ["OpenMediaVaultDriver"]
|
||||||
@@ -0,0 +1,612 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Licensed under the Apache License, Version 2.0
|
||||||
|
|
||||||
|
"""NAPALM driver for OpenMediaVault NAS systems.
|
||||||
|
|
||||||
|
Extends LinuxDriver with OMV-specific storage management:
|
||||||
|
|
||||||
|
* Physical disk inventory via lsblk and smartctl
|
||||||
|
* md RAID array status via /proc/mdstat and mdadm
|
||||||
|
* Mounted filesystem info via df
|
||||||
|
* SMB, NFS, and FTP share enumeration via omv-confdbadm
|
||||||
|
|
||||||
|
Connects via SSH. Sudo access is required for smartctl and mdadm.
|
||||||
|
All optional_args from LinuxDriver are accepted (pkg_manager, sudo_password, …).
|
||||||
|
|
||||||
|
Tested against OpenMediaVault 6.x and 7.x (Debian Bullseye / Bookworm base).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json as _json
|
||||||
|
import re
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
from napalm_device_types.models import (
|
||||||
|
DiskPoolDict,
|
||||||
|
LogicalVolumeDict,
|
||||||
|
NASShareDict,
|
||||||
|
PhysicalDiskDict,
|
||||||
|
ReplicationJobDict,
|
||||||
|
StorageQuotaDict,
|
||||||
|
StorageServiceDict,
|
||||||
|
VolumeSnapshotDict,
|
||||||
|
)
|
||||||
|
from napalm_linux.linux import LinuxDriver
|
||||||
|
|
||||||
|
|
||||||
|
class OpenMediaVaultDriver(LinuxDriver):
|
||||||
|
"""NAPALM driver for OpenMediaVault NAS systems.
|
||||||
|
|
||||||
|
Inherits all Linux OS functionality from LinuxDriver (packages,
|
||||||
|
services, users, processes, cron, Docker) and adds OMV-specific
|
||||||
|
storage management capabilities.
|
||||||
|
|
||||||
|
NAS services are exposed via ``get_storage_services()`` to avoid a
|
||||||
|
naming collision with LinuxDriver's ``get_services()`` (which returns
|
||||||
|
the full systemd service list).
|
||||||
|
"""
|
||||||
|
|
||||||
|
driver_name = "openmediavault"
|
||||||
|
storage_device = True # signals netOrk to classify this driver as "storage"
|
||||||
|
|
||||||
|
# ── Facts override ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_facts(self) -> Dict[str, Any]:
|
||||||
|
facts = super().get_facts()
|
||||||
|
try:
|
||||||
|
ver = self._send(
|
||||||
|
"dpkg-query -W -f='${Version}' openmediavault 2>/dev/null"
|
||||||
|
).strip()
|
||||||
|
if ver:
|
||||||
|
facts["os_version"] = f"OMV {ver}"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return facts
|
||||||
|
|
||||||
|
# ── Warnings ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_device_warnings(self) -> List[Dict[str, Any]]:
|
||||||
|
"""Return warnings, ignoring packages held back by apt-get upgrade.
|
||||||
|
|
||||||
|
On OMV, kernel and OMV-specific packages are routinely held back
|
||||||
|
(they require dist-upgrade or omv-upgrade). Reporting them as
|
||||||
|
"updates available" after a successful apt-get upgrade is misleading.
|
||||||
|
We simulate the upgrade first and only warn if there are packages
|
||||||
|
that a standard apt-get upgrade would actually install.
|
||||||
|
"""
|
||||||
|
warnings: List[Dict[str, Any]] = []
|
||||||
|
try:
|
||||||
|
sim = self._sudo(
|
||||||
|
"DEBIAN_FRONTEND=noninteractive apt-get upgrade --simulate 2>&1",
|
||||||
|
read_timeout=60,
|
||||||
|
)
|
||||||
|
m = re.search(r'(\d+) upgraded', sim)
|
||||||
|
upgradable_count = int(m.group(1)) if m else 0
|
||||||
|
except Exception:
|
||||||
|
upgradable_count = -1 # unknown — fall back to parent behaviour
|
||||||
|
|
||||||
|
if upgradable_count == 0:
|
||||||
|
return [] # nothing to do with apt-get upgrade; held-back only
|
||||||
|
if upgradable_count < 0:
|
||||||
|
return super().get_device_warnings()
|
||||||
|
|
||||||
|
updates = self.get_available_updates()
|
||||||
|
if updates:
|
||||||
|
warnings.append({
|
||||||
|
"code": "updates_available",
|
||||||
|
"severity": "warning",
|
||||||
|
"action": None,
|
||||||
|
"meta": {
|
||||||
|
"count": upgradable_count,
|
||||||
|
"packages": [u.get("name", "") for u in updates[:10]],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return warnings
|
||||||
|
|
||||||
|
# ── Physical disks ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_disks(self) -> List[PhysicalDiskDict]:
|
||||||
|
"""Return physical disk inventory.
|
||||||
|
|
||||||
|
Uses ``lsblk -J`` for device enumeration and ``smartctl -iA``
|
||||||
|
(via sudo) for health, temperature, and RPM data.
|
||||||
|
|
||||||
|
Each entry contains:
|
||||||
|
|
||||||
|
* slot (string) - device path, e.g. ``"/dev/sda"``
|
||||||
|
* model (string) - drive model string
|
||||||
|
* serial (string) - drive serial number
|
||||||
|
* vendor (string) - drive manufacturer
|
||||||
|
* type (string) - ``"hdd"``, ``"ssd"``, or ``"nvme"``
|
||||||
|
* size (int) - raw capacity in bytes
|
||||||
|
* rpm (int) - rotational speed; ``0`` for SSD/NVMe
|
||||||
|
* temperature (int) - temperature in Celsius; ``-1`` if unavailable
|
||||||
|
* health (string) - ``"healthy"``, ``"failed"``, or ``"unknown"``
|
||||||
|
* pool (string) - md array name if member; empty string otherwise
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"slot": "/dev/sda",
|
||||||
|
"model": "HGST HUS726T6TALE6L4",
|
||||||
|
"serial": "K3GXXXXX",
|
||||||
|
"vendor": "HGST",
|
||||||
|
"type": "hdd",
|
||||||
|
"size": 6001175126016,
|
||||||
|
"rpm": 7200,
|
||||||
|
"temperature": 34,
|
||||||
|
"health": "healthy",
|
||||||
|
"pool": "md1",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
"""
|
||||||
|
result: List[PhysicalDiskDict] = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = self._send(
|
||||||
|
"lsblk -J -b -o NAME,SIZE,TYPE,ROTA,VENDOR,MODEL,SERIAL,TRAN 2>/dev/null"
|
||||||
|
)
|
||||||
|
lsblk = _json.loads(raw)
|
||||||
|
except Exception:
|
||||||
|
return result
|
||||||
|
|
||||||
|
for dev in lsblk.get("blockdevices", []):
|
||||||
|
if dev.get("type") != "disk":
|
||||||
|
continue
|
||||||
|
name = dev.get("name", "")
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
size = int(dev.get("size") or 0)
|
||||||
|
rota = str(dev.get("rota", "1"))
|
||||||
|
vendor = (dev.get("vendor") or "").strip()
|
||||||
|
model = (dev.get("model") or "").strip()
|
||||||
|
serial = (dev.get("serial") or "").strip()
|
||||||
|
tran = (dev.get("tran") or "").strip().lower()
|
||||||
|
|
||||||
|
if tran == "nvme":
|
||||||
|
disk_type = "nvme"
|
||||||
|
elif rota == "0":
|
||||||
|
disk_type = "ssd"
|
||||||
|
else:
|
||||||
|
disk_type = "hdd"
|
||||||
|
|
||||||
|
temperature = -1
|
||||||
|
health = "unknown"
|
||||||
|
rpm = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
smart = self._sudo(
|
||||||
|
f"smartctl -iA /dev/{name} 2>/dev/null", read_timeout=15
|
||||||
|
)
|
||||||
|
if "PASSED" in smart:
|
||||||
|
health = "healthy"
|
||||||
|
elif "FAILED" in smart:
|
||||||
|
health = "failed"
|
||||||
|
temp_m = re.search(
|
||||||
|
r"(?:Temperature_Celsius|Current Temperature|Temperature:)"
|
||||||
|
r"[^\n]*?(\d+)\s*(?:Celsius|\(|$)",
|
||||||
|
smart,
|
||||||
|
)
|
||||||
|
if temp_m:
|
||||||
|
temperature = int(temp_m.group(1))
|
||||||
|
if disk_type == "hdd":
|
||||||
|
rpm_m = re.search(r"Rotation Rate:\s+(\d+)\s+rpm", smart)
|
||||||
|
if rpm_m:
|
||||||
|
rpm = int(rpm_m.group(1))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
result.append(PhysicalDiskDict(
|
||||||
|
slot=f"/dev/{name}",
|
||||||
|
model=model,
|
||||||
|
serial=serial,
|
||||||
|
vendor=vendor,
|
||||||
|
type=disk_type,
|
||||||
|
size=size,
|
||||||
|
rpm=rpm,
|
||||||
|
temperature=temperature,
|
||||||
|
health=health,
|
||||||
|
pool=self._disk_to_pool(name),
|
||||||
|
))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _disk_to_pool(self, disk_name: str) -> str:
|
||||||
|
"""Return the md array a disk belongs to, or empty string."""
|
||||||
|
try:
|
||||||
|
mdstat = self._send("cat /proc/mdstat 2>/dev/null")
|
||||||
|
for line in mdstat.splitlines():
|
||||||
|
if disk_name in line and re.match(r'^md\d+', line):
|
||||||
|
return line.split(":")[0].strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# ── md RAID pools ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_disk_pools(self) -> Dict[str, DiskPoolDict]:
|
||||||
|
"""Return md RAID array status from ``/proc/mdstat`` and ``mdadm --detail``.
|
||||||
|
|
||||||
|
Keys are array names (e.g. ``"md1"``). Each value contains:
|
||||||
|
|
||||||
|
* name (string) - array name
|
||||||
|
* type (string) - always ``"md"``
|
||||||
|
* level (string) - RAID level, e.g. ``"raid1"``, ``"raid5"``
|
||||||
|
* status (string) - ``"online"``, ``"degraded"``, ``"faulted"``, or ``"offline"``
|
||||||
|
* total (int) - usable array size in bytes (``0`` if mdadm unavailable)
|
||||||
|
* used (int) - always ``0`` (use ``get_volumes()`` for per-filesystem usage)
|
||||||
|
* available (int) - always ``0``
|
||||||
|
* disks (list of strings) - member device names (e.g. ``["sda1", "sdb1"]``)
|
||||||
|
* auto_expand (bool) - always ``False`` (not applicable for md)
|
||||||
|
* dedup (bool) - always ``False``
|
||||||
|
* compression (string) - always ``"off"``
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
{
|
||||||
|
"md1": {
|
||||||
|
"name": "md1",
|
||||||
|
"type": "md",
|
||||||
|
"level": "raid1",
|
||||||
|
"status": "online",
|
||||||
|
"total": 2000398925824,
|
||||||
|
"used": 0,
|
||||||
|
"available": 0,
|
||||||
|
"disks": ["sda1", "sdb1"],
|
||||||
|
"auto_expand": False,
|
||||||
|
"dedup": False,
|
||||||
|
"compression": "off",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
result: Dict[str, DiskPoolDict] = {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
mdstat = self._send("cat /proc/mdstat 2>/dev/null")
|
||||||
|
except Exception:
|
||||||
|
return result
|
||||||
|
|
||||||
|
for line in mdstat.splitlines():
|
||||||
|
m = re.match(r'^(md\d+)\s*:\s+(\S+)\s+(\S+)\s+(.*)', line)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
|
||||||
|
md_name, state_word, level_raw, members_raw = m.groups()
|
||||||
|
member_disks = re.findall(r'(\w+)\[\d+\]', members_raw)
|
||||||
|
status = "online" if state_word == "active" else "offline"
|
||||||
|
total = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
detail = self._sudo(
|
||||||
|
f"mdadm --detail /dev/{md_name} 2>/dev/null", read_timeout=10
|
||||||
|
)
|
||||||
|
size_m = re.search(r"Array Size\s*:\s*(\d+)\s*\(", detail)
|
||||||
|
if size_m:
|
||||||
|
total = int(size_m.group(1)) * 1024 # kB → bytes
|
||||||
|
if "degraded" in detail.lower():
|
||||||
|
status = "degraded"
|
||||||
|
elif re.search(r"State\s*:.*(?:faulty|failed)", detail, re.IGNORECASE):
|
||||||
|
status = "faulted"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
result[md_name] = DiskPoolDict(
|
||||||
|
name=md_name,
|
||||||
|
type="md",
|
||||||
|
level=level_raw.lower(),
|
||||||
|
status=status,
|
||||||
|
total=total,
|
||||||
|
used=0,
|
||||||
|
available=0,
|
||||||
|
disks=member_disks,
|
||||||
|
auto_expand=False,
|
||||||
|
dedup=False,
|
||||||
|
compression="off",
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
# ── Logical volumes / filesystems ──────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_volumes(self) -> Dict[str, LogicalVolumeDict]:
|
||||||
|
"""Return mounted filesystem info from ``df``.
|
||||||
|
|
||||||
|
Only real block devices (``/dev/…``) are included; tmpfs, devtmpfs,
|
||||||
|
overlays, and similar pseudo-filesystems are skipped.
|
||||||
|
|
||||||
|
Keys are derived from the mountpoint (e.g. ``"srv-dev-disk-by-label-data"``
|
||||||
|
for ``/srv/dev/disk/by-label/data``; ``"root"`` for ``/``).
|
||||||
|
|
||||||
|
Each value contains:
|
||||||
|
|
||||||
|
* name (string) - key repeated for convenience
|
||||||
|
* pool (string) - device name without ``/dev/`` prefix
|
||||||
|
* type (string) - always ``"filesystem"``
|
||||||
|
* total (int) - total size in bytes
|
||||||
|
* used (int) - used space in bytes
|
||||||
|
* available (int) - available space in bytes
|
||||||
|
* mountpoint (string) - absolute mount path
|
||||||
|
* compression (string) - always ``"off"``
|
||||||
|
* dedup (bool) - always ``False``
|
||||||
|
* readonly (bool) - ``True`` if mounted ``ro``
|
||||||
|
* snapshots (int) - always ``0``
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
{
|
||||||
|
"srv-data": {
|
||||||
|
"name": "srv-data",
|
||||||
|
"pool": "md1",
|
||||||
|
"type": "filesystem",
|
||||||
|
"total": 2000398925824,
|
||||||
|
"used": 945000000000,
|
||||||
|
"available": 1055398925824,
|
||||||
|
"mountpoint": "/srv/data",
|
||||||
|
"compression": "off",
|
||||||
|
"dedup": False,
|
||||||
|
"readonly": False,
|
||||||
|
"snapshots": 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
result: Dict[str, LogicalVolumeDict] = {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
df_out = self._send("df -Pk 2>/dev/null")
|
||||||
|
except Exception:
|
||||||
|
return result
|
||||||
|
|
||||||
|
try:
|
||||||
|
mounts = self._send("cat /proc/mounts 2>/dev/null")
|
||||||
|
except Exception:
|
||||||
|
mounts = ""
|
||||||
|
|
||||||
|
for line in df_out.splitlines()[1:]:
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) < 6:
|
||||||
|
continue
|
||||||
|
device = parts[0]
|
||||||
|
total_k, used_k, avail_k = int(parts[1]), int(parts[2]), int(parts[3])
|
||||||
|
mountpoint = parts[5]
|
||||||
|
|
||||||
|
if not device.startswith("/dev/"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
key = mountpoint.lstrip("/").replace("/", "-") or "root"
|
||||||
|
pool = device.removeprefix("/dev/")
|
||||||
|
|
||||||
|
readonly = bool(re.search(
|
||||||
|
rf'^{re.escape(device)}\s+\S+\s+\S+\s+(?:[^,\s]*,)*ro(?:,|\s|$)',
|
||||||
|
mounts, re.MULTILINE,
|
||||||
|
))
|
||||||
|
|
||||||
|
result[key] = LogicalVolumeDict(
|
||||||
|
name=key,
|
||||||
|
pool=pool,
|
||||||
|
type="filesystem",
|
||||||
|
total=total_k * 1024,
|
||||||
|
used=used_k * 1024,
|
||||||
|
available=avail_k * 1024,
|
||||||
|
mountpoint=mountpoint,
|
||||||
|
compression="off",
|
||||||
|
dedup=False,
|
||||||
|
readonly=readonly,
|
||||||
|
snapshots=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
# ── NAS shares ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_shares(self) -> Dict[str, NASShareDict]:
|
||||||
|
"""Return all configured NAS shares from the OMV config database.
|
||||||
|
|
||||||
|
Covers SMB/CIFS, NFS, and FTP shares. Keys are prefixed with the
|
||||||
|
protocol (e.g. ``"smb:media"``, ``"nfs:backups"``, ``"ftp:upload"``).
|
||||||
|
|
||||||
|
Each value contains:
|
||||||
|
|
||||||
|
* name (string) - share name without protocol prefix
|
||||||
|
* protocol (string) - ``"smb"``, ``"nfs"``, or ``"ftp"``
|
||||||
|
* path (string) - absolute filesystem path
|
||||||
|
* volume (string) - last path component (leaf directory)
|
||||||
|
* enabled (bool) - whether the share is currently exported
|
||||||
|
* readonly (bool) - whether the share is exported read-only
|
||||||
|
* description (string) - human-readable comment (SMB only)
|
||||||
|
* clients (list of strings) - allowed client addresses (NFS only)
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
{
|
||||||
|
"smb:media": {
|
||||||
|
"name": "media",
|
||||||
|
"protocol": "smb",
|
||||||
|
"path": "/srv/dev/disk/by-label/data/media",
|
||||||
|
"volume": "media",
|
||||||
|
"enabled": True,
|
||||||
|
"readonly": False,
|
||||||
|
"description": "Media library",
|
||||||
|
"clients": [],
|
||||||
|
},
|
||||||
|
"nfs:backups": {
|
||||||
|
"name": "backups",
|
||||||
|
"protocol": "nfs",
|
||||||
|
"path": "/srv/dev/disk/by-label/data/backups",
|
||||||
|
"volume": "backups",
|
||||||
|
"enabled": True,
|
||||||
|
"readonly": False,
|
||||||
|
"description": "",
|
||||||
|
"clients": ["192.168.1.0/24"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
shares: Dict[str, NASShareDict] = {}
|
||||||
|
shares.update(self._smb_shares())
|
||||||
|
shares.update(self._nfs_shares())
|
||||||
|
shares.update(self._ftp_shares())
|
||||||
|
return shares
|
||||||
|
|
||||||
|
# ── Storage services ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_storage_services(self) -> Dict[str, StorageServiceDict]:
|
||||||
|
"""Return status of key NAS services (SMB, NFS, FTP, SSH, rsync).
|
||||||
|
|
||||||
|
This method is separate from ``get_services()`` (inherited from
|
||||||
|
LinuxDriver) which returns the full systemd service list.
|
||||||
|
|
||||||
|
Keys are service names. Each value contains:
|
||||||
|
|
||||||
|
* name (string) - service name
|
||||||
|
* enabled (bool) - configured to start on boot
|
||||||
|
* running (bool) - currently active
|
||||||
|
* port (int) - primary listening port
|
||||||
|
* version (string) - protocol version; empty if unknown
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
{
|
||||||
|
"smb": {"name": "smb", "enabled": True, "running": True, "port": 445, "version": ""},
|
||||||
|
"nfs": {"name": "nfs", "enabled": True, "running": True, "port": 2049, "version": ""},
|
||||||
|
"ftp": {"name": "ftp", "enabled": False,"running": False, "port": 21, "version": ""},
|
||||||
|
"ssh": {"name": "ssh", "enabled": True, "running": True, "port": 22, "version": ""},
|
||||||
|
"rsync": {"name": "rsync", "enabled": False,"running": False, "port": 873, "version": ""},
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
_SVC_MAP = [
|
||||||
|
# (key, systemd unit, default port)
|
||||||
|
("smb", "smbd", 445),
|
||||||
|
("nfs", "nfs-server", 2049),
|
||||||
|
("ftp", "proftpd", 21),
|
||||||
|
("ssh", "ssh", 22),
|
||||||
|
("rsync", "rsync", 873),
|
||||||
|
]
|
||||||
|
result: Dict[str, StorageServiceDict] = {}
|
||||||
|
for key, unit, port in _SVC_MAP:
|
||||||
|
try:
|
||||||
|
running = (
|
||||||
|
self._send(f"systemctl is-active {unit} 2>/dev/null || true").strip()
|
||||||
|
== "active"
|
||||||
|
)
|
||||||
|
enabled = (
|
||||||
|
self._send(f"systemctl is-enabled {unit} 2>/dev/null || true").strip()
|
||||||
|
== "enabled"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
running = enabled = False
|
||||||
|
result[key] = StorageServiceDict(
|
||||||
|
name=key, enabled=enabled, running=running, port=port, version="",
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
# ── Not implemented ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_volume_snapshots(self, volume: str = "") -> List[VolumeSnapshotDict]:
|
||||||
|
raise NotImplementedError(
|
||||||
|
"OpenMediaVault does not expose volume snapshots via this driver"
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_replication_jobs(self) -> List[ReplicationJobDict]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def get_quotas(self) -> List[StorageQuotaDict]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
# ── Internal helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _omv_read(self, key: str) -> list:
|
||||||
|
"""Query the OMV config database and return a list of objects.
|
||||||
|
|
||||||
|
Handles both OMV 6+ (returns a JSON array directly) and older
|
||||||
|
versions that wrap the array in a dict under a common key.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
raw = self._send(f"omv-confdbadm read {key!r} 2>/dev/null").strip()
|
||||||
|
if not raw:
|
||||||
|
return []
|
||||||
|
data = _json.loads(raw)
|
||||||
|
if isinstance(data, list):
|
||||||
|
return data
|
||||||
|
# Older OMV wraps the list: {"share": [...]}
|
||||||
|
for wrapper in ("share", "rule", "entry", "mntent", "sharedfolder"):
|
||||||
|
if wrapper in data and isinstance(data[wrapper], list):
|
||||||
|
return data[wrapper]
|
||||||
|
return []
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _resolve_shared_folder(self, uuid: str) -> str:
|
||||||
|
"""Resolve an OMV shared folder UUID to its absolute filesystem path."""
|
||||||
|
if not uuid:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
for folder in self._omv_read("conf.system.sharedfolder"):
|
||||||
|
if folder.get("uuid") != uuid:
|
||||||
|
continue
|
||||||
|
rel = folder.get("reldirpath", "").strip("/")
|
||||||
|
mnt_uuid = folder.get("mntentref", "")
|
||||||
|
mnt_dir = ""
|
||||||
|
for mntent in self._omv_read("conf.system.filesystem.mountpoint"):
|
||||||
|
if mntent.get("uuid") == mnt_uuid:
|
||||||
|
mnt_dir = mntent.get("dir", "").rstrip("/")
|
||||||
|
break
|
||||||
|
return f"{mnt_dir}/{rel}".rstrip("/") if rel else mnt_dir
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def _smb_shares(self) -> Dict[str, NASShareDict]:
|
||||||
|
out: Dict[str, NASShareDict] = {}
|
||||||
|
for s in self._omv_read("conf.service.smb.shares"):
|
||||||
|
name = s.get("name", "")
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
path = self._resolve_shared_folder(s.get("sharedfolderref", ""))
|
||||||
|
out[f"smb:{name}"] = NASShareDict(
|
||||||
|
name=name,
|
||||||
|
protocol="smb",
|
||||||
|
path=path,
|
||||||
|
volume=path.split("/")[-1] if path else "",
|
||||||
|
enabled=bool(s.get("enable", True)),
|
||||||
|
readonly=bool(s.get("readonly", False)),
|
||||||
|
description=s.get("comment", ""),
|
||||||
|
clients=[],
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _nfs_shares(self) -> Dict[str, NASShareDict]:
|
||||||
|
out: Dict[str, NASShareDict] = {}
|
||||||
|
for s in self._omv_read("conf.service.nfs.shares"):
|
||||||
|
path = self._resolve_shared_folder(s.get("sharedfolderref", ""))
|
||||||
|
name = path.split("/")[-1] or s.get("sharedfolderref", "")
|
||||||
|
clients_raw = s.get("client", "") or ""
|
||||||
|
clients = [c.strip() for c in clients_raw.split(",") if c.strip()]
|
||||||
|
opts = s.get("options", "") or ""
|
||||||
|
out[f"nfs:{name}"] = NASShareDict(
|
||||||
|
name=name,
|
||||||
|
protocol="nfs",
|
||||||
|
path=path,
|
||||||
|
volume=path.split("/")[-1] if path else "",
|
||||||
|
enabled=bool(s.get("enable", True)),
|
||||||
|
readonly="ro" in [o.strip() for o in opts.split(",")],
|
||||||
|
description="",
|
||||||
|
clients=clients,
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _ftp_shares(self) -> Dict[str, NASShareDict]:
|
||||||
|
out: Dict[str, NASShareDict] = {}
|
||||||
|
for s in self._omv_read("conf.service.ftp.shares"):
|
||||||
|
path = self._resolve_shared_folder(s.get("sharedfolderref", ""))
|
||||||
|
name = path.split("/")[-1] or s.get("sharedfolderref", "")
|
||||||
|
out[f"ftp:{name}"] = NASShareDict(
|
||||||
|
name=name,
|
||||||
|
protocol="ftp",
|
||||||
|
path=path,
|
||||||
|
volume=path.split("/")[-1] if path else "",
|
||||||
|
enabled=bool(s.get("enable", True)),
|
||||||
|
readonly=bool(s.get("readonly", False)),
|
||||||
|
description="",
|
||||||
|
clients=[],
|
||||||
|
)
|
||||||
|
return out
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "napalm-openmediavault"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "NAPALM driver for OpenMediaVault NAS (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", "nas", "openmediavault", "omv", "storage"]
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 3 - Alpha",
|
||||||
|
"Intended Audience :: Developers",
|
||||||
|
"Intended Audience :: System Administrators",
|
||||||
|
"License :: OSI Approved :: Apache Software License",
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Topic :: System :: Networking",
|
||||||
|
"Topic :: System :: Systems Administration",
|
||||||
|
]
|
||||||
|
dependencies = [
|
||||||
|
"napalm>=4.0",
|
||||||
|
"napalm-device-types>=0.3.0",
|
||||||
|
"napalm-linux>=0.1.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.entry-points."napalm.drivers"]
|
||||||
|
openmediavault = "napalm_openmediavault:OpenMediaVaultDriver"
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Repository = "https://github.com/chrismanivong/napalm-openmediavault"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["."]
|
||||||
|
include = ["napalm_openmediavault*"]
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.9"
|
||||||
|
strict = true
|
||||||
|
ignore_missing_imports = true
|
||||||
Reference in New Issue
Block a user