mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-02 10:52:22 +00:00
Merge remote-tracking branch 'origin/develop' into HEAD
This commit is contained in:
@@ -141,6 +141,7 @@ cp "$SCRIPT_DIR/oci_console_logs.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "
|
||||
cp "$SCRIPT_DIR/oci_instance_info.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ oci_instance_info.py not found"
|
||||
cp "$SCRIPT_DIR/oci_operations.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ oci_operations.py not found"
|
||||
cp "$SCRIPT_DIR/disk_temperature_history.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ disk_temperature_history.py not found"
|
||||
cp "$SCRIPT_DIR/lvm_device_scope.py" "$APP_DIR/usr/bin/"
|
||||
cp "$SCRIPT_DIR/smartctl_resolver.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ smartctl_resolver.py not found"
|
||||
cp "$SCRIPT_DIR/disk_identity.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ disk_identity.py not found"
|
||||
cp "$SCRIPT_DIR/health_thresholds.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ health_thresholds.py not found"
|
||||
|
||||
@@ -526,6 +526,16 @@ def disk_read_policy(disk_name: str, excluded: Optional[set] = None) -> str:
|
||||
return IDLE
|
||||
|
||||
|
||||
def settle_own_reads(disk_names) -> None:
|
||||
"""A reader that has just read these disks itself takes their counters as
|
||||
the new baseline, so its own I/O never counts as activity."""
|
||||
stats = _read_diskstats()
|
||||
with _cache_lock:
|
||||
for name in disk_names:
|
||||
if name in _last_io and name in stats:
|
||||
_last_io[name] = stats[name]
|
||||
|
||||
|
||||
def is_disk_idle(disk_name: str) -> bool:
|
||||
"""True while the disk is being left alone for having no I/O."""
|
||||
ts = _idle_state.get(disk_name)
|
||||
|
||||
@@ -4193,13 +4193,17 @@ def get_system_disks():
|
||||
pass
|
||||
|
||||
try:
|
||||
# 4. Check LVM physical volumes. `--devices` (added when a disk
|
||||
# is parked) keeps pvs from scanning — and waking — standby HDDs.
|
||||
# 4. Check LVM physical volumes. `--devices` keeps pvs off the disks
|
||||
# that are excluded, idle or asleep.
|
||||
dev_args = _lvm_device_args()
|
||||
result = subprocess.run(
|
||||
['pvs', *_lvm_device_args(), '--noheadings', '-o', 'pv_name,vg_name'],
|
||||
['pvs', *dev_args, '--noheadings', '-o', 'pv_name,vg_name'],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if result.returncode == 0:
|
||||
) if dev_args is not None else None
|
||||
if result is not None:
|
||||
from lvm_device_scope import settle_scan_reads
|
||||
settle_scan_reads()
|
||||
if result is not None and result.returncode == 0:
|
||||
for line in result.stdout.strip().split('\n'):
|
||||
if line.strip():
|
||||
parts = line.split()
|
||||
@@ -5009,42 +5013,10 @@ def _hdd_in_standby(disk_name: str) -> bool:
|
||||
return parked
|
||||
|
||||
|
||||
def _lvm_device_args() -> list:
|
||||
"""Build the `--devices` argument that keeps an LVM tool (pvs/lvs/
|
||||
vgs) off parked disks.
|
||||
|
||||
LVM tools scan every block device by default, which spins up a
|
||||
standby HDD (issue #232). When any rotational disk is asleep, hand
|
||||
LVM an explicit list of every block node EXCEPT those belonging to a
|
||||
parked disk, so it never opens the sleeping drives. Each partition
|
||||
must be listed too — `--devices /dev/sda` alone does not cover a PV
|
||||
on /dev/sda3. Returns [] when nothing is parked, so hosts without
|
||||
standby disks keep the plain pvs/lvs/vgs call unchanged.
|
||||
"""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
['lsblk', '-rno', 'NAME,TYPE,PKNAME'],
|
||||
capture_output=True, text=True, timeout=5)
|
||||
if r.returncode != 0:
|
||||
return []
|
||||
nodes = []
|
||||
for line in r.stdout.strip().split('\n'):
|
||||
parts = line.split()
|
||||
if len(parts) < 2 or parts[1] not in ('disk', 'part'):
|
||||
continue
|
||||
name = parts[0]
|
||||
base = name if parts[1] == 'disk' else (parts[2] if len(parts) > 2 else name)
|
||||
nodes.append((name, base))
|
||||
standby_bases = {
|
||||
name for name, base in nodes
|
||||
if name == base and _hdd_in_standby(name)
|
||||
}
|
||||
if not standby_bases:
|
||||
return []
|
||||
devices = [f'/dev/{name}' for name, base in nodes if base not in standby_bases]
|
||||
return ['--devices', ','.join(devices)] if devices else []
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
return []
|
||||
def _lvm_device_args():
|
||||
"""None means no automatic scan; [] permits LVM's normal scan."""
|
||||
from lvm_device_scope import lvm_device_args
|
||||
return lvm_device_args(_hdd_in_standby)
|
||||
|
||||
|
||||
def get_smart_data(disk_name):
|
||||
|
||||
@@ -154,36 +154,10 @@ def _hdd_in_standby(disk_name: str) -> bool:
|
||||
return parked
|
||||
|
||||
|
||||
def _lvm_device_args() -> list:
|
||||
"""`--devices` list scoping pvs/lvs/vgs off parked disks so they
|
||||
aren't spun up by the LVM scan (issue #232). Empty when nothing is
|
||||
parked, so the plain command runs unchanged. Each partition is
|
||||
listed too — a whole-disk path alone doesn't cover a PV on a
|
||||
partition."""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
['lsblk', '-rno', 'NAME,TYPE,PKNAME'],
|
||||
capture_output=True, text=True, timeout=5)
|
||||
if r.returncode != 0:
|
||||
return []
|
||||
nodes = []
|
||||
for line in r.stdout.strip().split('\n'):
|
||||
parts = line.split()
|
||||
if len(parts) < 2 or parts[1] not in ('disk', 'part'):
|
||||
continue
|
||||
name = parts[0]
|
||||
base = name if parts[1] == 'disk' else (parts[2] if len(parts) > 2 else name)
|
||||
nodes.append((name, base))
|
||||
standby_bases = {
|
||||
name for name, base in nodes
|
||||
if name == base and _hdd_in_standby(name)
|
||||
}
|
||||
if not standby_bases:
|
||||
return []
|
||||
devices = [f'/dev/{name}' for name, base in nodes if base not in standby_bases]
|
||||
return ['--devices', ','.join(devices)] if devices else []
|
||||
except Exception:
|
||||
return []
|
||||
def _lvm_device_args():
|
||||
"""None means no automatic scan; [] permits LVM's normal scan."""
|
||||
from lvm_device_scope import lvm_device_args
|
||||
return lvm_device_args(_hdd_in_standby)
|
||||
|
||||
|
||||
def _is_disk_removable(disk_name: str) -> bool:
|
||||
@@ -2305,12 +2279,17 @@ class HealthMonitor:
|
||||
|
||||
# `--devices` keeps lvs from scanning parked HDDs (issue #232).
|
||||
dev_args = _lvm_device_args()
|
||||
if dev_args is None:
|
||||
return {'status': 'INFO', 'skipped': True,
|
||||
'reason': 'Automatic LVM scan skipped by disk read policy or unavailable inventory'}
|
||||
result = subprocess.run(
|
||||
['lvs', *dev_args, '--noheadings', '--options', 'lv_name,vg_name,lv_attr'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=3
|
||||
)
|
||||
from lvm_device_scope import settle_scan_reads
|
||||
settle_scan_reads()
|
||||
|
||||
if result.returncode != 0:
|
||||
return {'status': 'WARNING', 'reason': 'lvs command failed'}
|
||||
@@ -2335,6 +2314,7 @@ class HealthMonitor:
|
||||
text=True,
|
||||
timeout=3
|
||||
)
|
||||
settle_scan_reads()
|
||||
if vg_result.returncode == 0 and vg_result.stdout.strip():
|
||||
return {'status': 'WARNING', 'reason': 'No active LVM volumes detected'}
|
||||
else:
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
# ==========================================================
|
||||
# ProxMenux Monitor - LVM disk read scope
|
||||
# Author : Jonatan
|
||||
# GitHub : github.com/jcastro
|
||||
# Maintainer : MacRimi
|
||||
# Copyright : (c) 2026 MacRimi & contributors
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.0
|
||||
# Last Updated: 16/10/2026
|
||||
# ==========================================================
|
||||
# Description:
|
||||
# Applies the automatic disk-read policy to LVM metadata scans in both
|
||||
# Monitor readers. Excluded and idle disks receive no power-mode probe;
|
||||
# every device built on them is excluded too, and the scan's own reads
|
||||
# are not taken as activity. No eligible devices means no scan.
|
||||
# ==========================================================
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import threading
|
||||
|
||||
_NAME = re.compile(r"[A-Za-z0-9._+-]+")
|
||||
_scan_lock = threading.Lock()
|
||||
_scanned_disks = set()
|
||||
|
||||
|
||||
def _topology():
|
||||
"""Every block device by kernel name: its type, its parents and whether
|
||||
it is an LVM physical volume. lsblk reads this from udev, not the disks.
|
||||
A RAID or multipath device appears once per member."""
|
||||
result = subprocess.run(["lsblk", "-J", "-l", "-o", "KNAME,TYPE,PKNAME,FSTYPE"],
|
||||
capture_output=True, text=True, timeout=5)
|
||||
if result.returncode:
|
||||
return None
|
||||
types, parents, volumes = {}, {}, set()
|
||||
for device in json.loads(result.stdout).get("blockdevices") or []:
|
||||
name, parent = device.get("kname"), device.get("pkname")
|
||||
if not isinstance(name, str) or not _NAME.fullmatch(name):
|
||||
return None
|
||||
types[name] = device.get("type")
|
||||
parents.setdefault(name, set())
|
||||
if parent is not None:
|
||||
if not isinstance(parent, str) or not _NAME.fullmatch(parent):
|
||||
return None
|
||||
parents[name].add(parent)
|
||||
if device.get("fstype") == "LVM2_member" and device.get("type") != "lvm":
|
||||
volumes.add(name)
|
||||
return types, parents, volumes
|
||||
|
||||
|
||||
def _disks_under(name, parents, seen=None):
|
||||
"""The disks a device is built on: itself, or its parents' disks."""
|
||||
seen = seen if seen is not None else set()
|
||||
if name in seen:
|
||||
return set()
|
||||
seen.add(name)
|
||||
if not parents.get(name):
|
||||
return {name}
|
||||
found = set()
|
||||
for parent in parents[name]:
|
||||
found |= _disks_under(parent, parents, seen)
|
||||
return found
|
||||
|
||||
|
||||
def lvm_device_args(standby_check):
|
||||
"""Return [] for an unrestricted scan, --devices args, or None to skip.
|
||||
|
||||
A failed inventory must never fall back to an unrestricted scan. None
|
||||
is distinct from []: if every disk is excluded/idle/asleep, callers
|
||||
must not start pvs/lvs/vgs at all.
|
||||
"""
|
||||
try:
|
||||
from disk_temperature_history import disk_read_policy, excluded_disk_names
|
||||
topology = _topology()
|
||||
if topology is None:
|
||||
return None
|
||||
types, parents, volumes = topology
|
||||
disks = {name for name, kind in types.items() if kind == "disk"}
|
||||
if not disks:
|
||||
return None
|
||||
excluded = excluded_disk_names()
|
||||
blocked = set()
|
||||
for disk in sorted(disks):
|
||||
# Test policy first: even CHECK POWER MODE is forbidden for an
|
||||
# excluded/idle disk. Some USB bridges do not honour -n standby.
|
||||
if disk_read_policy(disk, excluded) != "read" or standby_check(disk):
|
||||
blocked.add(disk)
|
||||
allowed = sorted(f"/dev/{name}" for name in volumes if not (_disks_under(name, parents) & blocked))
|
||||
with _scan_lock:
|
||||
_scanned_disks.clear()
|
||||
_scanned_disks.update(disks - blocked)
|
||||
if not blocked:
|
||||
return []
|
||||
# An explicit list makes LVM open every device in it, so only the
|
||||
# physical volumes are named: a RAID, multipath or encrypted volume
|
||||
# built on a blocked disk would read that disk too and stays out.
|
||||
if not allowed:
|
||||
return None
|
||||
return ["--devices", ",".join(allowed)]
|
||||
except Exception:
|
||||
# Unavailable exclusions, topology or policy are not permission to
|
||||
# open devices that may have been deliberately excluded.
|
||||
return None
|
||||
|
||||
|
||||
def settle_scan_reads():
|
||||
"""The LVM scan reads the disks it is allowed to. Those reads are its
|
||||
own, so they must not make an unused disk look busy at the next check."""
|
||||
try:
|
||||
from disk_temperature_history import settle_own_reads
|
||||
with _scan_lock:
|
||||
disks = set(_scanned_disks)
|
||||
settle_own_reads(disks)
|
||||
except Exception:
|
||||
pass
|
||||
Reference in New Issue
Block a user