qnap-tools 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- qnap_tools/__init__.py +4 -0
- qnap_tools/claves.py +47 -0
- qnap_tools/comandos.py +139 -0
- qnap_tools/config.py +56 -0
- qnap_tools/deteccion.py +79 -0
- qnap_tools/discos/__init__.py +2 -0
- qnap_tools/discos/comandos.py +199 -0
- qnap_tools/discos/parsers.py +1121 -0
- qnap_tools/discos/servidor.py +685 -0
- qnap_tools/ejecutor.py +189 -0
- qnap_tools/flota/__init__.py +2 -0
- qnap_tools/flota/servidor.py +318 -0
- qnap_tools/inventario.py +142 -0
- qnap_tools/logs/__init__.py +2 -0
- qnap_tools/logs/comandos.py +214 -0
- qnap_tools/logs/parsers.py +604 -0
- qnap_tools/logs/servidor.py +366 -0
- qnap_tools/redes/__init__.py +2 -0
- qnap_tools/redes/comandos.py +159 -0
- qnap_tools/redes/parsers.py +719 -0
- qnap_tools/redes/servidor.py +278 -0
- qnap_tools/servidor.py +96 -0
- qnap_tools/transporte.py +262 -0
- qnap_tools/virtualizacion/__init__.py +2 -0
- qnap_tools/virtualizacion/comandos.py +186 -0
- qnap_tools/virtualizacion/parsers.py +724 -0
- qnap_tools/virtualizacion/servidor.py +525 -0
- qnap_tools-0.4.0.dist-info/METADATA +359 -0
- qnap_tools-0.4.0.dist-info/RECORD +32 -0
- qnap_tools-0.4.0.dist-info/WHEEL +4 -0
- qnap_tools-0.4.0.dist-info/entry_points.txt +6 -0
- qnap_tools-0.4.0.dist-info/licenses/LICENSE +21 -0
qnap_tools/__init__.py
ADDED
qnap_tools/claves.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Par de claves SSH de este ordenador (ed25519), en la carpeta de datos del plugin.
|
|
3
|
+
|
|
4
|
+
Cada ordenador tiene su propia clave privada, que no sale de el; lo que se instala en los
|
|
5
|
+
NAS es la parte publica. La proteccion de la privada es la de la carpeta de datos (el perfil
|
|
6
|
+
del usuario); en sistemas POSIX se crea ademas con permisos 600.
|
|
7
|
+
"""
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import socket
|
|
11
|
+
|
|
12
|
+
from cryptography.hazmat.primitives import serialization
|
|
13
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _comentario():
|
|
17
|
+
equipo = re.sub(r"[^A-Za-z0-9_.-]", "-", socket.gethostname() or "equipo")[:60]
|
|
18
|
+
return "qnap-tools@" + equipo
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def asegurar(cfg) -> str:
|
|
22
|
+
"""Crea el par si no existe y devuelve la linea publica (tipo, clave y comentario)."""
|
|
23
|
+
ruta_privada = cfg.clave
|
|
24
|
+
ruta_publica = ruta_privada.with_name(ruta_privada.name + ".pub")
|
|
25
|
+
if ruta_privada.exists():
|
|
26
|
+
privada = serialization.load_ssh_private_key(ruta_privada.read_bytes(), password=None)
|
|
27
|
+
else:
|
|
28
|
+
ruta_privada.parent.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
privada = Ed25519PrivateKey.generate()
|
|
30
|
+
cuerpo = privada.private_bytes(
|
|
31
|
+
serialization.Encoding.PEM, serialization.PrivateFormat.OpenSSH,
|
|
32
|
+
serialization.NoEncryption())
|
|
33
|
+
# O_EXCL: si otro proceso la acaba de crear, no se pisa
|
|
34
|
+
fd = os.open(str(ruta_privada), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
35
|
+
with os.fdopen(fd, "wb") as f:
|
|
36
|
+
f.write(cuerpo)
|
|
37
|
+
publica = privada.public_key().public_bytes(
|
|
38
|
+
serialization.Encoding.OpenSSH, serialization.PublicFormat.OpenSSH).decode("ascii")
|
|
39
|
+
if ruta_publica.exists():
|
|
40
|
+
# se conserva la linea ya escrita (y su comentario) mientras sea de esta clave:
|
|
41
|
+
# es la que esta instalada en los NAS
|
|
42
|
+
linea = ruta_publica.read_text(encoding="utf-8").strip()
|
|
43
|
+
if linea.startswith(publica + " "):
|
|
44
|
+
return linea
|
|
45
|
+
linea = "%s %s" % (publica, _comentario())
|
|
46
|
+
ruta_publica.write_text(linea + "\n", encoding="utf-8")
|
|
47
|
+
return linea
|
qnap_tools/comandos.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Comandos de plantilla fija: lo unico que se ejecuta en un NAS.
|
|
3
|
+
|
|
4
|
+
Cada orden que lanza una herramienta es un Comando definido en el codigo. No hay comando
|
|
5
|
+
libre. Los parametros que entran en una plantilla se validan contra un tipo estricto y se
|
|
6
|
+
entrecomillan; una plantilla que escribe en un fichero o lleva sudo no se puede ni definir
|
|
7
|
+
(el sudo lo anade el transporte en los comandos marcados).
|
|
8
|
+
"""
|
|
9
|
+
import re
|
|
10
|
+
import shlex
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Callable, Mapping
|
|
13
|
+
|
|
14
|
+
# Tipos de parametro. Se comparan con fullmatch (un $ dejaria pasar un salto de linea final)
|
|
15
|
+
# y ninguno admite empezar por "-", para que un valor no pueda colarse como opcion.
|
|
16
|
+
TIPOS = {
|
|
17
|
+
"interfaz": re.compile(r"[A-Za-z0-9][A-Za-z0-9_.@:-]{0,31}"),
|
|
18
|
+
"destino": re.compile(r"[A-Za-z0-9]([A-Za-z0-9.:-]{0,251}[A-Za-z0-9])?"),
|
|
19
|
+
"entero": re.compile(r"[0-9]{1,6}"),
|
|
20
|
+
"bahia": re.compile(r"[0-9]{1,3}"),
|
|
21
|
+
"uuid": re.compile(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"),
|
|
22
|
+
"nombre": re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}"),
|
|
23
|
+
"fecha": re.compile(r"[0-9]{4}-[0-9]{2}-[0-9]{2}"),
|
|
24
|
+
"md": re.compile(r"md[0-9]{1,3}"), # grupo RAID del kernel: /dev/md1
|
|
25
|
+
"nvme": re.compile(r"nvme[0-9]{1,2}"), # controladora NVMe: /dev/nvme0
|
|
26
|
+
# texto que se busca en un registro: letras (tambien con acento), cifras, espacio y
|
|
27
|
+
# _ . : @ / -; nada que cierre una comilla, encadene una orden o haga de comodin
|
|
28
|
+
"palabra": re.compile(r"\w[\w .:@/-]{0,38}[\w.:@/-]"),
|
|
29
|
+
"nivel": re.compile(r"[0-7]"), # nivel de un mensaje del kernel
|
|
30
|
+
}
|
|
31
|
+
SISTEMAS = ("ambos", "hero", "qts")
|
|
32
|
+
|
|
33
|
+
MARCADOR = re.compile(r"\{([a-z_][a-z0-9_]*)\}")
|
|
34
|
+
REDIRECCION_INOCUA = re.compile(r"2>&1|2>\s*/dev/null|(?<![0-9&>])>\s*/dev/null")
|
|
35
|
+
SUDO = re.compile(r"\bsudo\b")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ParametroInvalido(ValueError):
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# --- validadores de salida: None si vale, o el texto del error -------------------
|
|
43
|
+
|
|
44
|
+
def rc_cero(rc: int, salida: str):
|
|
45
|
+
if rc != 0:
|
|
46
|
+
return "el comando termino con codigo %d" % rc
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def no_vacia(rc: int, salida: str):
|
|
51
|
+
if not salida.strip():
|
|
52
|
+
return "el comando no devolvio nada"
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def todas(*validadores):
|
|
57
|
+
def valida(rc, salida):
|
|
58
|
+
for v in validadores:
|
|
59
|
+
error = v(rc, salida)
|
|
60
|
+
if error:
|
|
61
|
+
return error
|
|
62
|
+
return None
|
|
63
|
+
return valida
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def sin_centinela(*centinelas):
|
|
67
|
+
"""Error si la salida esta vacia o solo trae valores centinela (p. ej. '0 C', '--').
|
|
68
|
+
|
|
69
|
+
Varias utilidades de QNAP devuelven eso con codigo 0 cuando les falta sudo.
|
|
70
|
+
"""
|
|
71
|
+
def valida(rc, salida):
|
|
72
|
+
lineas = [linea.strip() for linea in salida.splitlines() if linea.strip()]
|
|
73
|
+
if not lineas:
|
|
74
|
+
return "el comando no devolvio nada"
|
|
75
|
+
if all(linea in centinelas for linea in lineas):
|
|
76
|
+
return "el comando devolvio un valor vacio (%s)" % lineas[0]
|
|
77
|
+
return None
|
|
78
|
+
return valida
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
estricta = todas(rc_cero, no_vacia)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True)
|
|
85
|
+
class Comando:
|
|
86
|
+
id: str
|
|
87
|
+
plantilla: str
|
|
88
|
+
params: Mapping[str, str] = field(default_factory=dict)
|
|
89
|
+
sudo: bool = False
|
|
90
|
+
timeout: int = 20
|
|
91
|
+
sistema: str = "ambos"
|
|
92
|
+
# Por defecto, codigo 0 y salida no vacia. valida=None lo desactiva de forma explicita.
|
|
93
|
+
valida: Callable[[int, str], str | None] | None = estricta
|
|
94
|
+
no_probado: bool = False
|
|
95
|
+
|
|
96
|
+
def __post_init__(self):
|
|
97
|
+
if not self.id:
|
|
98
|
+
raise ValueError("comando sin identificador")
|
|
99
|
+
p = self.plantilla
|
|
100
|
+
if "\n" in p or "\r" in p or "`" in p:
|
|
101
|
+
raise ValueError("comando %s: saltos de linea y comillas invertidas no admitidos" % self.id)
|
|
102
|
+
if SUDO.search(p):
|
|
103
|
+
raise ValueError("comando %s: la plantilla no lleva sudo; se marca con sudo=True" % self.id)
|
|
104
|
+
if ">" in REDIRECCION_INOCUA.sub(" ", p):
|
|
105
|
+
raise ValueError("comando %s: la plantilla redirige a un fichero" % self.id)
|
|
106
|
+
usados = set(MARCADOR.findall(p))
|
|
107
|
+
declarados = set(self.params)
|
|
108
|
+
if usados - declarados:
|
|
109
|
+
raise ValueError("comando %s: marcador sin declarar: %s"
|
|
110
|
+
% (self.id, ", ".join(sorted(usados - declarados))))
|
|
111
|
+
if declarados - usados:
|
|
112
|
+
raise ValueError("comando %s: parametro declarado que no se usa: %s"
|
|
113
|
+
% (self.id, ", ".join(sorted(declarados - usados))))
|
|
114
|
+
for nombre, tipo in self.params.items():
|
|
115
|
+
if tipo not in TIPOS:
|
|
116
|
+
raise ValueError("comando %s: tipo de parametro desconocido: %s" % (self.id, tipo))
|
|
117
|
+
if self.sistema not in SISTEMAS:
|
|
118
|
+
raise ValueError("comando %s: sistema desconocido: %s" % (self.id, self.sistema))
|
|
119
|
+
if type(self.timeout) is not int or self.timeout <= 0:
|
|
120
|
+
raise ValueError("comando %s: timeout no valido: %r" % (self.id, self.timeout))
|
|
121
|
+
|
|
122
|
+
def render(self, valores: Mapping[str, object] | None = None) -> str:
|
|
123
|
+
valores = dict(valores or {})
|
|
124
|
+
faltan = sorted(set(self.params) - set(valores))
|
|
125
|
+
if faltan:
|
|
126
|
+
raise ParametroInvalido("falta el parametro %s" % ", ".join(faltan))
|
|
127
|
+
sobran = sorted(set(valores) - set(self.params))
|
|
128
|
+
if sobran:
|
|
129
|
+
raise ParametroInvalido("parametro no admitido: %s" % ", ".join(sobran))
|
|
130
|
+
listos = {}
|
|
131
|
+
for nombre, tipo in self.params.items():
|
|
132
|
+
valor = valores[nombre]
|
|
133
|
+
if type(valor) is int:
|
|
134
|
+
valor = str(valor)
|
|
135
|
+
if not isinstance(valor, str) or not TIPOS[tipo].fullmatch(valor):
|
|
136
|
+
raise ParametroInvalido(
|
|
137
|
+
"parametro %s no valido (se esperaba %s): %s" % (nombre, tipo, repr(valor)[:60]))
|
|
138
|
+
listos[nombre] = shlex.quote(valor)
|
|
139
|
+
return MARCADOR.sub(lambda m: listos[m.group(1)], self.plantilla)
|
qnap_tools/config.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Configuracion de los servidores: credencial comun y carpeta de datos, leidas del entorno.
|
|
3
|
+
|
|
4
|
+
El plugin de Claude Code pasa la credencial por variables de entorno (userConfig -> env).
|
|
5
|
+
La contrasena no se escribe nunca en disco ni aparece en repr().
|
|
6
|
+
"""
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Mapping
|
|
12
|
+
|
|
13
|
+
# Un marcador ${...} que ha llegado sin sustituir no es un valor
|
|
14
|
+
SIN_SUSTITUIR = re.compile(r"\$\{[A-Za-z_][A-Za-z0-9_.]*\}")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ConfigError(Exception):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class Config:
|
|
23
|
+
usuario: str
|
|
24
|
+
contrasena: str = field(repr=False)
|
|
25
|
+
datos: Path
|
|
26
|
+
inventario: Path
|
|
27
|
+
clave: Path
|
|
28
|
+
known_hosts: Path
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _valor(entorno, nombre):
|
|
32
|
+
v = entorno.get(nombre)
|
|
33
|
+
if not v or SIN_SUSTITUIR.fullmatch(v.strip()):
|
|
34
|
+
return None
|
|
35
|
+
return v
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def cargar(entorno: Mapping[str, str] = os.environ) -> Config:
|
|
39
|
+
usuario = (_valor(entorno, "QNAP_TOOLS_USER") or "").strip()
|
|
40
|
+
contrasena = _valor(entorno, "QNAP_TOOLS_PASSWORD")
|
|
41
|
+
faltan = [n for n, v in (("QNAP_TOOLS_USER", usuario), ("QNAP_TOOLS_PASSWORD", contrasena)) if not v]
|
|
42
|
+
if faltan:
|
|
43
|
+
raise ConfigError(
|
|
44
|
+
"falta configurar %s: en Claude Code, /plugin configure qnap-tools "
|
|
45
|
+
"(usuario SSH comun de la flota y su contrasena)" % " y ".join(faltan))
|
|
46
|
+
datos = Path(_valor(entorno, "QNAP_TOOLS_DATA") or _valor(entorno, "CLAUDE_PLUGIN_DATA")
|
|
47
|
+
or Path.home() / ".qnap-tools")
|
|
48
|
+
inventario = _valor(entorno, "QNAP_TOOLS_INVENTARIO")
|
|
49
|
+
return Config(
|
|
50
|
+
usuario=usuario,
|
|
51
|
+
contrasena=contrasena,
|
|
52
|
+
datos=datos,
|
|
53
|
+
inventario=Path(inventario) if inventario else datos / "inventario.toml",
|
|
54
|
+
clave=datos / "id_ed25519",
|
|
55
|
+
known_hosts=datos / "known_hosts",
|
|
56
|
+
)
|
qnap_tools/deteccion.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Perfil de un NAS: sistema (QuTS hero o QTS), modelo, firmware y paquetes instalados.
|
|
3
|
+
|
|
4
|
+
Se averigua con una sola orden, sin sudo. Lo que cambia de un NAS a otro (sistema de
|
|
5
|
+
almacenamiento, carpeta de cada paquete) se toma de aqui y nunca se fija en el codigo.
|
|
6
|
+
"""
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
|
|
10
|
+
from .comandos import Comando
|
|
11
|
+
|
|
12
|
+
PAQUETES = ("QKVM", "container-station")
|
|
13
|
+
SISTEMAS = ("hero", "qts")
|
|
14
|
+
RUTA_VALIDA = re.compile(r"/[A-Za-z0-9_./-]{1,200}")
|
|
15
|
+
|
|
16
|
+
# La version sale de /etc/default_config: la de /etc/config puede quedarse obsoleta tras
|
|
17
|
+
# una actualizacion (visto en un QuTS hero real). hero = hay ZFS.
|
|
18
|
+
DETECCION = Comando(
|
|
19
|
+
"deteccion",
|
|
20
|
+
"echo sistema=$([ -x /sbin/zpool ] && echo hero || echo qts); "
|
|
21
|
+
"echo modelo=$(getcfg System Model -f /etc/default_config/uLinux.conf); "
|
|
22
|
+
"echo firmware=$(getcfg System Version -f /etc/default_config/uLinux.conf); "
|
|
23
|
+
"echo build=$(getcfg System 'Build Number' -f /etc/default_config/uLinux.conf); "
|
|
24
|
+
"echo nombre=$(hostname); "
|
|
25
|
+
"for q in %s; do "
|
|
26
|
+
"echo paquete.$q.enable=$(getcfg $q Enable -f /etc/config/qpkg.conf); "
|
|
27
|
+
"echo paquete.$q.ruta=$(getcfg $q Install_Path -f /etc/config/qpkg.conf); "
|
|
28
|
+
"done" % " ".join(PAQUETES),
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class DeteccionFallida(Exception):
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class Perfil:
|
|
38
|
+
sistema: str
|
|
39
|
+
modelo: str
|
|
40
|
+
firmware: str
|
|
41
|
+
build: str
|
|
42
|
+
nombre: str
|
|
43
|
+
paquetes: dict = field(default_factory=dict)
|
|
44
|
+
avisos: tuple = ()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def detectar(con) -> Perfil:
|
|
48
|
+
r = con.ejecutar(DETECCION.render(), sudo=False, timeout=DETECCION.timeout)
|
|
49
|
+
if r.agotado:
|
|
50
|
+
raise DeteccionFallida(
|
|
51
|
+
"%s: no respondio a tiempo (%d s) al identificar el sistema" % (con.destino, DETECCION.timeout))
|
|
52
|
+
campos = {}
|
|
53
|
+
for linea in r.salida.splitlines():
|
|
54
|
+
if "=" in linea:
|
|
55
|
+
clave, valor = linea.split("=", 1)
|
|
56
|
+
campos[clave.strip()] = valor.strip()
|
|
57
|
+
if campos.get("sistema") not in SISTEMAS or not campos.get("firmware"):
|
|
58
|
+
raise DeteccionFallida(
|
|
59
|
+
"%s: no se pudo identificar el sistema del NAS (no parece un QNAP con QTS o QuTS hero)"
|
|
60
|
+
% con.destino)
|
|
61
|
+
paquetes = {}
|
|
62
|
+
avisos = []
|
|
63
|
+
for nombre in PAQUETES:
|
|
64
|
+
ruta = campos.get("paquete.%s.ruta" % nombre, "")
|
|
65
|
+
if campos.get("paquete.%s.enable" % nombre, "").upper() != "TRUE" or not ruta:
|
|
66
|
+
continue
|
|
67
|
+
if not RUTA_VALIDA.fullmatch(ruta) or ".." in ruta:
|
|
68
|
+
avisos.append("paquete %s: su ruta de instalacion no es valida y no se usara" % nombre)
|
|
69
|
+
continue
|
|
70
|
+
paquetes[nombre] = ruta
|
|
71
|
+
return Perfil(
|
|
72
|
+
sistema=campos["sistema"],
|
|
73
|
+
modelo=campos.get("modelo", ""),
|
|
74
|
+
firmware=campos["firmware"],
|
|
75
|
+
build=campos.get("build", ""),
|
|
76
|
+
nombre=campos.get("nombre", ""),
|
|
77
|
+
paquetes=paquetes,
|
|
78
|
+
avisos=tuple(avisos),
|
|
79
|
+
)
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Ordenes de la pata de discos y carpetas. Todas de solo lectura.
|
|
3
|
+
|
|
4
|
+
Salen de la exploracion hecha contra NAS reales (pruebas/catalogo/discos*.json). Cada orden
|
|
5
|
+
declara para que sistema vale: "ambos", "hero" (QuTS hero, ZFS) o "qts" (QTS, md y LVM).
|
|
6
|
+
De los ficheros de configuracion se leen campos concretos, nunca el fichero entero.
|
|
7
|
+
|
|
8
|
+
Un disco se identifica por cabina y bahia, los dos numeros que usa el propio NAS en sus
|
|
9
|
+
ficheros (enclosure_<cabina>.conf, smart_<cabina>_<bahia>.info): la cabina 0 es el NAS.
|
|
10
|
+
"""
|
|
11
|
+
from ..comandos import Comando, no_vacia, rc_cero
|
|
12
|
+
|
|
13
|
+
# --- discos fisicos ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
# Siempre sin sudo: como root no termina en 40 s en un NAS con muchos discos.
|
|
16
|
+
DISCOS_LISTA = Comando("discos_lista", "qcli_storage -d", timeout=30)
|
|
17
|
+
DISCOS_REPARTO = Comando("discos_reparto", "qcli_storage -p", timeout=30)
|
|
18
|
+
|
|
19
|
+
# Ni los WWN ni las pruebas de velocidad que el NAS guarda en el mismo fichero. El ultimo
|
|
20
|
+
# fichero puede no tener discos (grep termina con 1): se valida la salida, no el codigo.
|
|
21
|
+
CAMPOS_DISCO = ("port_id", "pd_sys_name", "type", "vendor", "model", "serial_no", "revision",
|
|
22
|
+
"capacity", "sector_size", "rotation_speed", "status")
|
|
23
|
+
DISCOS_DETALLE = Comando(
|
|
24
|
+
"discos_detalle",
|
|
25
|
+
"for f in /etc/enclosure_*.conf; do echo \"== $f\"; grep -E '^\\[PhysicalDisk|^(%s) =' $f; done"
|
|
26
|
+
% "|".join(CAMPOS_DISCO),
|
|
27
|
+
valida=no_vacia)
|
|
28
|
+
|
|
29
|
+
# Veredicto SMART del propio NAS para las bahias de la cabina principal. getsysinfo no termina
|
|
30
|
+
# sus respuestas en salto de linea (de ahi un echo por valor) y sin privilegios responde
|
|
31
|
+
# "0 C" y "--" con codigo 0: siempre con sudo, y el parser comprueba esos centinelas.
|
|
32
|
+
MAX_BAHIAS = 32
|
|
33
|
+
DISCOS_SALUD = Comando(
|
|
34
|
+
"discos_salud",
|
|
35
|
+
"n=$(getsysinfo hdnum); for b in %s; do [ $b -le $n ] && echo \"== $b\""
|
|
36
|
+
" && echo modelo=$(getsysinfo hdmodel $b) && echo temperatura=$(getsysinfo hdtmp $b)"
|
|
37
|
+
" && echo smart=$(getsysinfo hdsmart $b) && echo estado=$(getsysinfo hdstatus $b); done;"
|
|
38
|
+
" [ -n \"$n\" ]" % " ".join(str(b) for b in range(1, MAX_BAHIAS + 1)),
|
|
39
|
+
sudo=True, timeout=40)
|
|
40
|
+
|
|
41
|
+
# --- SMART ------------------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
# Los ficheros que el NAS refresca cada pocos minutos con los atributos de cada disco (tambien
|
|
44
|
+
# de los NVMe y de las cabinas de expansion). La carpeta solo la lee root.
|
|
45
|
+
SMART_TODOS = Comando(
|
|
46
|
+
"smart_todos",
|
|
47
|
+
"for f in /tmp/smart/smart_*.info; do [ -e $f ] && echo \"== $f\" && cat $f; done;"
|
|
48
|
+
" ls /tmp/smart/ >/dev/null",
|
|
49
|
+
sudo=True, timeout=30)
|
|
50
|
+
SMART_ATRIBUTOS = Comando("smart_atributos", "cat /tmp/smart/smart_{cabina}_{bahia}.info",
|
|
51
|
+
{"cabina": "bahia", "bahia": "bahia"}, sudo=True)
|
|
52
|
+
# Avisos por valor que el usuario haya configurado en el NAS. Sin fichero no hay ninguno.
|
|
53
|
+
SMART_UMBRALES = Comando(
|
|
54
|
+
"smart_umbrales",
|
|
55
|
+
"[ ! -e /tmp/smart_warning.conf ] || grep -E '^\\[|^(policy|enable|value) =' /tmp/smart_warning.conf",
|
|
56
|
+
valida=rc_cero)
|
|
57
|
+
|
|
58
|
+
# Desgaste de cada SSD segun el NAS. Un NAS sin SSD no tiene ficheros, o ni la carpeta.
|
|
59
|
+
SSD_VIDA = Comando(
|
|
60
|
+
"ssd_vida",
|
|
61
|
+
"for f in /tmp/disk/disk_*.info; do [ -e $f ] && echo \"== $f\" && grep -E '^SSD_LIFE_USED' $f; done;"
|
|
62
|
+
" [ ! -d /tmp/disk ] || ls /tmp/disk/ >/dev/null",
|
|
63
|
+
valida=rc_cero)
|
|
64
|
+
|
|
65
|
+
# --- rendimiento ------------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
# Dos lecturas de los contadores del kernel, cada una con su reloj (/proc/uptime) para saber
|
|
68
|
+
# el intervalo real. Solo discos enteros: en QuTS hero el fichero trae un zd por volumen.
|
|
69
|
+
_LECTURA = "cat /proc/uptime; grep -E ' (sd[a-z]+|nvme[0-9]+n[0-9]+) ' /proc/diskstats"
|
|
70
|
+
DISKSTATS = Comando("diskstats", "%s; sleep 5; echo ==5s; %s" % (_LECTURA, _LECTURA), timeout=25)
|
|
71
|
+
|
|
72
|
+
# --- QuTS hero: pools, datasets, instantaneas y LUN ---------------------------------------------
|
|
73
|
+
|
|
74
|
+
# -H -p: sin cabecera, separado por tabuladores y con los tamanos en bytes. El ZFS de QNAP no
|
|
75
|
+
# tiene la propiedad de fragmentacion.
|
|
76
|
+
POOLS = Comando("pools", "zpool list -H -p -o name,size,alloc,free,cap,dedup,health", sistema="hero")
|
|
77
|
+
POOLS_ESTADO = Comando("pools_estado", "zpool status -v", sistema="hero", timeout=30)
|
|
78
|
+
DATASETS = Comando(
|
|
79
|
+
"datasets",
|
|
80
|
+
"zfs list -H -p -t filesystem,volume -o name,type,used,avail,refer,quota,refquota,compressratio,mountpoint,"
|
|
81
|
+
"compression,dedup,encryption,keystatus",
|
|
82
|
+
sistema="hero", timeout=30)
|
|
83
|
+
# Todas, de la mas antigua a la mas reciente: el recorte y su cuenta los pone el servidor. Sin
|
|
84
|
+
# tuberia, para que el codigo de salida sea el de zfs. Un NAS sin instantaneas no devuelve nada.
|
|
85
|
+
SNAPSHOTS_HERO = Comando("zfs_snapshots", "zfs list -H -p -t snapshot -o name,used,refer,creation -s creation",
|
|
86
|
+
sistema="hero", timeout=60, valida=rc_cero)
|
|
87
|
+
# Todo menos "description", que es texto libre.
|
|
88
|
+
CAMPOS_PROG_HERO = ("source_lv_id", "retention", "expiremin", "keepAmount", "hourAmount", "dayAmount",
|
|
89
|
+
"weekAmount", "monthAmount", "normalAmount", "Schedule", "MonthDay", "WeekDay",
|
|
90
|
+
"Dueday", "Frequency", "OnHour", "OnMinute", "OffHour", "OffMinute", "smart", "vital",
|
|
91
|
+
"immutable", "snapshot_type", "schedule_status", "ScheduleTime", "lastRetentionTime")
|
|
92
|
+
_PROG_HERO = "/etc/config/qsnapshot/zsnapshotSchedule.conf"
|
|
93
|
+
SNAPSHOTS_PROG_HERO = Comando(
|
|
94
|
+
"zfs_snapshots_prog",
|
|
95
|
+
"[ ! -s %s ] || grep -E '^\\[|^(%s) =' %s" % (_PROG_HERO, "|".join(CAMPOS_PROG_HERO), _PROG_HERO),
|
|
96
|
+
sistema="hero", valida=rc_cero)
|
|
97
|
+
# De los dos JSON de iSCSI (una clave por linea), solo estas claves: ni CHAP ni nada que no se
|
|
98
|
+
# haya visto. grep -o saca la clave y su valor, no la linea: si el fichero viniera en una sola
|
|
99
|
+
# linea no saldria nada (y la orden fallaria) en vez de salir entero. Ningun NAS de pruebas
|
|
100
|
+
# tenia destinos ni LUN: sin validar.
|
|
101
|
+
CLAVES_ISCSI_HERO = ("lun", "portal", "target", "initiator", "service_enable", "service_port",
|
|
102
|
+
"iqn_prefix", "initiator_id", "name", "alias", "[0-9]+")
|
|
103
|
+
LUNS_HERO = Comando(
|
|
104
|
+
"zfs_iscsi",
|
|
105
|
+
"for f in /etc/config/hero_zfs_lun.json /etc/config/hero_iscsi_scst.json; do"
|
|
106
|
+
" [ ! -s $f ] || echo \"== $f\"; [ ! -s $f ] ||"
|
|
107
|
+
" grep -o -E '^[[:space:]]*\"(%s)\":(\"[^\"]*\"|[a-z0-9.-]+|[{][}]?)' $f; done"
|
|
108
|
+
% "|".join(CLAVES_ISCSI_HERO),
|
|
109
|
+
sistema="hero", valida=rc_cero, no_probado=True)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# --- QTS: grupos md, volumenes, pools ligeros, instantaneas e iSCSI -----------------------------
|
|
113
|
+
|
|
114
|
+
def _campos_de(fichero, campos, secciones="^\\["):
|
|
115
|
+
"""Orden que saca de un .conf sus cabeceras de seccion y solo estos campos. Sin fichero (o
|
|
116
|
+
vacio) no devuelve nada; si existe y no se puede leer, falla."""
|
|
117
|
+
return "[ ! -s %s ] || grep -E '%s|^(%s) ?=' %s" % (fichero, secciones, "|".join(campos), fichero)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
MDSTAT = Comando("mdstat", "cat /proc/mdstat", sistema="qts")
|
|
121
|
+
# Un echo por valor: segun el nivel faltan ficheros (un raid0 no tiene sync_action) y cat
|
|
122
|
+
# los saltaria, descolocando los demas. Un valor vacio es "no lo dice", no un cero.
|
|
123
|
+
MD_SYS = Comando(
|
|
124
|
+
"md_sys",
|
|
125
|
+
"for d in /sys/block/md*/md; do echo \"== $d\"; echo nivel=$(cat $d/level 2>/dev/null);"
|
|
126
|
+
" echo estado=$(cat $d/array_state 2>/dev/null); echo accion=$(cat $d/sync_action 2>/dev/null);"
|
|
127
|
+
" echo degradado=$(cat $d/degraded 2>/dev/null);"
|
|
128
|
+
" echo discrepancias=$(cat $d/mismatch_cnt 2>/dev/null); done",
|
|
129
|
+
sistema="qts")
|
|
130
|
+
# Los grupos que QTS considera suyos (los demas md son del sistema) y sus miembros por bahia.
|
|
131
|
+
# grep -o: de "data_0 = 1, <WWN>" solo sale "data_0 = 1".
|
|
132
|
+
RAID_CONF = Comando(
|
|
133
|
+
"raid_conf",
|
|
134
|
+
"[ ! -s /etc/config/raid.conf ] || grep -o -E '^\\[[A-Za-z0-9_]+\\]|^(id|deviceName|raidLevel) = .*"
|
|
135
|
+
"|^(data|spare)_[0-9]+ = [0-9A-Fa-f]+|^globalSpare[0-9]+ = [0-9A-Fa-f]+' /etc/config/raid.conf",
|
|
136
|
+
sistema="qts", valida=rc_cero)
|
|
137
|
+
VOLUMENES_CONF = Comando(
|
|
138
|
+
"volumenes_conf",
|
|
139
|
+
_campos_de("/etc/volume.conf", ("volId", "volName", "raidId", "raidName", "encryption", "ssdCache",
|
|
140
|
+
"unclean", "mappingName", "readOnly", "status", "volType", "internal"),
|
|
141
|
+
secciones="^\\[VOL"),
|
|
142
|
+
sistema="qts", valida=rc_cero)
|
|
143
|
+
LVM_CONF = Comando(
|
|
144
|
+
"lvm_conf",
|
|
145
|
+
_campos_de("/etc/config/qlvm.conf", ("poolId", "tpId", "poolName", "threshold", "overThreshold",
|
|
146
|
+
"snapshot_reserved", "memberId", "memberName", "tpName", "tpSize",
|
|
147
|
+
"metaSize", "lvId", "lvName", "lvSize", "volName")),
|
|
148
|
+
sistema="qts", valida=rc_cero)
|
|
149
|
+
# status, nunca table: table ensena la clave de los volumenes cifrados.
|
|
150
|
+
DM_ESTADO = Comando("dm_estado", "dmsetup status", sudo=True, sistema="qts")
|
|
151
|
+
SNAPSHOTS_QTS = Comando(
|
|
152
|
+
"lvm_snapshots",
|
|
153
|
+
_campos_de("/etc/config/qsnapshot/snapshot.conf", ("Count", "snapshotId", "name", "size", "expiremin",
|
|
154
|
+
"time", "vital", "snapshot_type", "replicated")),
|
|
155
|
+
sistema="qts", timeout=30, valida=rc_cero)
|
|
156
|
+
CAMPOS_PROG_QTS = ("source_lv_id", "retention", "expiremin", "keepAmount", "hourAmount", "dayAmount",
|
|
157
|
+
"weekAmount", "monthAmount", "normalAmount", "Schedule", "MonthDay", "WeekDay", "Hour",
|
|
158
|
+
"Minute", "smart", "vital", "snapshot_type", "status", "schedule_status", "ScheduleTime",
|
|
159
|
+
"lastRetentionTime")
|
|
160
|
+
SNAPSHOTS_PROG_QTS = Comando(
|
|
161
|
+
"lvm_snapshots_prog", _campos_de("/etc/config/qsnapshot/snapshotSchedule.conf", CAMPOS_PROG_QTS),
|
|
162
|
+
sistema="qts", valida=rc_cero)
|
|
163
|
+
# Servicio, iniciadores y, de destinos y LUN, nombre y estado. Las claves CHAP y la seccion de
|
|
164
|
+
# claves de iniciador no estan en la lista. Ningun NAS de pruebas tenia destinos ni LUN: los
|
|
165
|
+
# nombres de sus campos no se han podido comprobar.
|
|
166
|
+
ISCSI_QTS = Comando(
|
|
167
|
+
"lvm_iscsi",
|
|
168
|
+
_campos_de("/etc/config/iscsi_trgt.conf", ("bServiceEnable", "servicePort", "targetIQNPrefix",
|
|
169
|
+
"initiatorIQN", "initiatorAlias", "targetIndex", "targetName",
|
|
170
|
+
"targetIQN", "targetAlias", "bTargetEnable", "targetStatus",
|
|
171
|
+
"LUNIndex", "LUNName", "LUNCapacity", "LUNStatus", "bLUNEnable")),
|
|
172
|
+
sistema="qts", valida=rc_cero, no_probado=True)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# --- carpetas compartidas y ocupacion (los dos sistemas) ----------------------------------------
|
|
176
|
+
|
|
177
|
+
# Solo los montajes de volumen (/share/<X>_DATA), una linea cada uno y en KiB. Con ellos como
|
|
178
|
+
# argumentos df no consulta los demas montajes: una carpeta remota caida lo dejaria colgado, y
|
|
179
|
+
# el df de QNAP (BusyBox) no tiene -l. Las carpetas de /share que no son un montaje salen como
|
|
180
|
+
# el tmpfs de /share y el parser las descarta. Sin ningun volumen df termina con 1 y solo da la
|
|
181
|
+
# cabecera: se valida la salida, y el parser exige esa cabecera.
|
|
182
|
+
OCUPACION = Comando("ocupacion", "df -P -k /share/*_DATA", timeout=25, valida=no_vacia)
|
|
183
|
+
# De smb.conf, las secciones y solo los campos de cada carpeta: quien entra y como. Sin este
|
|
184
|
+
# fichero no hay "ninguna carpeta": es un fallo de lectura.
|
|
185
|
+
CAMPOS_SMB = ("path", "comment", "valid users", "invalid users", "read list", "write list", "browsable",
|
|
186
|
+
"browseable", "guest ok", "recycle bin")
|
|
187
|
+
CARPETAS_SMB = Comando(
|
|
188
|
+
"carpetas_smb",
|
|
189
|
+
"grep -E '^\\[|^[[:space:]]*(%s) *=' /etc/config/smb.conf" % "|".join(CAMPOS_SMB))
|
|
190
|
+
# El uso de cada carpeta que el propio NAS calcula y guarda (no se recorre nada).
|
|
191
|
+
CARPETAS_USO = Comando(
|
|
192
|
+
"carpetas_uso",
|
|
193
|
+
"[ ! -s /etc/config/volumeStatus.conf ] || grep -E"
|
|
194
|
+
" '^\\[|^(Used Size|Directory Count|File Count|Latest Update Time) *=' /etc/config/volumeStatus.conf",
|
|
195
|
+
valida=rc_cero)
|
|
196
|
+
# Vacio o inexistente si no hay nada exportado por NFS. No lleva secretos.
|
|
197
|
+
CARPETAS_NFS = Comando("carpetas_nfs", "[ ! -s /etc/exports ] || cat /etc/exports", valida=rc_cero)
|
|
198
|
+
# Solo el primer nivel: cada carpeta compartida es un enlace a su volumen.
|
|
199
|
+
CARPETAS_ENLACES = Comando("carpetas_enlaces", "ls -l /share/")
|