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 ADDED
@@ -0,0 +1,4 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Servidores MCP de solo lectura para consultar una flota de NAS QNAP por SSH."""
3
+
4
+ __version__ = "0.4.0"
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
+ )
@@ -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,2 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Servidor qnap-discos: discos, almacenamiento y carpetas de los NAS, en solo lectura."""
@@ -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/")