new beta 1.2.4.1

This commit is contained in:
MacRimi
2026-07-23 22:55:28 +02:00
parent 37f06b9e88
commit 91d503ea67
48 changed files with 1052 additions and 328 deletions
+337 -67
View File
@@ -2903,9 +2903,10 @@ def get_system_disks():
pass
try:
# 4. Check LVM physical volumes
# 4. Check LVM physical volumes. `--devices` (added when a disk
# is parked) keeps pvs from scanning — and waking — standby HDDs.
result = subprocess.run(
['pvs', '--noheadings', '-o', 'pv_name,vg_name'],
['pvs', *_lvm_device_args(), '--noheadings', '-o', 'pv_name,vg_name'],
capture_output=True, text=True, timeout=5
)
if result.returncode == 0:
@@ -3653,11 +3654,98 @@ def _smart_data_useful(d: dict) -> bool:
)
_standby_cache: dict[str, tuple] = {}
_STANDBY_CACHE_TTL = 15
def _hdd_in_standby(disk_name: str) -> bool:
"""True if `disk_name` is a spinning disk currently parked.
The check is smartctl's CHECK POWER MODE probe (`-n standby`), which
the drive answers without spinning up. Restricted to rotational,
non-NVMe devices so SSDs and NVMe which have no standby state
skip the extra probe. Any error falls through to False so the caller
behaves exactly as before. Result cached briefly so one /api/storage
poll doesn't re-probe the same disk from several call sites. See
issue #232.
"""
if disk_name.startswith('nvme'):
return False
now = time.time()
hit = _standby_cache.get(disk_name)
if hit and now - hit[0] < _STANDBY_CACHE_TTL:
return hit[1]
try:
with open(f'/sys/block/{disk_name}/queue/rotational') as f:
if f.read().strip() != '1':
_standby_cache[disk_name] = (now, False)
return False
except OSError:
return False
parked = False
try:
r = subprocess.run(
['smartctl', '-n', 'standby', '-i', f'/dev/{disk_name}'],
capture_output=True, text=True, timeout=_SMART_TIMEOUT,
)
parked = r.returncode == 2
except (subprocess.SubprocessError, OSError):
parked = False
_standby_cache[disk_name] = (now, parked)
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 get_smart_data(disk_name):
"""Cached wrapper around the underlying smartctl probe.
Three short-circuits stack on top of the original 14-variant
Four short-circuits stack on top of the original 14-variant
fallback chain:
0. Standby guard a parked HDD is left alone. The full `smartctl
-a` probe is a media read that spins the disk back up, so on a
cache miss we first check the power mode (no spin-up) and, if
the drive is asleep, return the last good payload instead of
waking it. This is the fix for issue #232: with a browser on
the dashboard, /api/storage polls often enough that the 30 s
cache kept expiring and re-waking disks that hdparm / hd-idle
had just parked.
1. Result cache within 30 s of a successful probe, return the
memoised payload immediately. This is the by-far hottest path
because the dashboard polls /api/storage every few seconds.
@@ -3673,6 +3761,17 @@ def get_smart_data(disk_name):
if cached and now - cached[0] < _SMART_RESULT_TTL:
return dict(cached[1])
if _hdd_in_standby(disk_name):
# Keep serving the last known values (temperature blanked, since
# we don't have a fresh one) so the card stays populated while
# the disk sleeps. The Storage view paints its own Standby badge
# from the temperature poller's flag. Don't refresh the cache
# timestamp: the moment the disk is active again we re-probe.
base = dict(cached[1]) if cached else _smart_default_payload()
base['standby'] = True
base['temperature'] = 0
return base
retry_at = _smart_fail_backoff.get(disk_name, 0)
if retry_at > now:
return dict(cached[1]) if cached else _smart_default_payload()
@@ -4712,22 +4811,43 @@ def _ethtool_max_speed(iface_name):
return 0
# A bond master always exposes /sys/class/net/<x>/bonding/. This is the
# kernel's own marker, so it catches bonds whose name isn't "bondN"
# (Proxmox lets you name them freely in /etc/network/interfaces).
def _is_bond_master(iface_name):
try:
return os.path.isdir(f'/sys/class/net/{iface_name}/bonding')
except Exception:
return False
# A physical NIC has a backing device symlink. Covers PCI, USB and any
# renamed NIC (e.g. a udev-renamed "nic0"), which a name-prefix check
# would miss.
def _is_physical_dev(iface_name):
try:
return os.path.exists(f'/sys/class/net/{iface_name}/device')
except Exception:
return False
def get_interface_type(interface_name):
"""Detect the type of network interface"""
try:
# Skip loopback
if interface_name == 'lo':
return 'skip'
if interface_name.startswith(('veth', 'tap')):
return 'vm_lxc'
# Skip other virtual interfaces
if interface_name.startswith(('tun', 'vnet', 'docker', 'virbr')):
return 'skip'
# Check if it's a bond
if interface_name.startswith('bond'):
# Check if it's a bond — sysfs first (name-agnostic), then the
# "bondN" convention as fallback when /sys isn't readable.
if _is_bond_master(interface_name) or interface_name.startswith('bond'):
return 'bond'
# Check if it's a bridge (but not virbr which we skip above)
@@ -4756,36 +4876,75 @@ def get_interface_type(interface_name):
pass
return 'skip'
# Only active-backup (and the *-alb/tlb primary) have a real
# active/standby distinction. In 802.3ad and balance-rr/xor EVERY slave
# transmits, so labelling the others "standby" would be wrong — hence
# the role is only assigned for active-backup.
_BOND_FAILOVER_MODES = ('active-backup',)
def get_bond_info(bond_name):
"""Get detailed information about a bonding interface"""
"""Get detailed information about a bonding interface.
sysfs is the primary source (short mode name, live active_slave);
/proc/net/bonding/<bond> is parsed too because it's the only place
that reports per-slave MII status, which tells a genuinely dead
link apart from a healthy standby.
"""
bond_info = {
'mode': 'unknown',
'mode': 'unknown', # short kernel name, e.g. "active-backup"
'mode_detail': None, # human name, e.g. "fault-tolerance (active-backup)"
'slaves': [],
'active_slave': None
'active_slave': None,
'slave_status': {}, # {slave: 'up'|'down'}
'supports_failover': False,
}
try:
# ── sysfs (authoritative, name-agnostic) ──────────────
mode = _read_sysfs(f'/sys/class/net/{bond_name}/bonding/mode')
if mode:
# "balance-alb 6" → "balance-alb"
bond_info['mode'] = mode.split()[0]
slaves = _read_sysfs(f'/sys/class/net/{bond_name}/bonding/slaves')
if slaves:
bond_info['slaves'] = slaves.split()
active = _read_sysfs(f'/sys/class/net/{bond_name}/bonding/active_slave')
if active:
bond_info['active_slave'] = active
# ── /proc/net/bonding (per-slave MII + pretty mode) ───
bond_file = f'/proc/net/bonding/{bond_name}'
if os.path.exists(bond_file):
with open(bond_file, 'r') as f:
content = f.read()
# Parse bonding mode
for line in content.split('\n'):
if 'Bonding Mode:' in line:
bond_info['mode'] = line.split(':', 1)[1].strip()
elif 'Slave Interface:' in line:
slave_name = line.split(':', 1)[1].strip()
bond_info['slaves'].append(slave_name)
elif 'Currently Active Slave:' in line:
bond_info['active_slave'] = line.split(':', 1)[1].strip()
# print(f"[v0] Bond {bond_name} info: mode={bond_info['mode']}, slaves={bond_info['slaves']}")
pass
current_slave = None
proc_slaves = []
for line in content.split('\n'):
if 'Bonding Mode:' in line:
bond_info['mode_detail'] = line.split(':', 1)[1].strip()
elif 'Slave Interface:' in line:
current_slave = line.split(':', 1)[1].strip()
proc_slaves.append(current_slave)
elif 'Currently Active Slave:' in line:
val = line.split(':', 1)[1].strip()
if val and val.lower() != 'none' and not bond_info['active_slave']:
bond_info['active_slave'] = val
elif 'MII Status:' in line and current_slave:
bond_info['slave_status'][current_slave] = line.split(':', 1)[1].strip().lower()
# Fallbacks when sysfs was unreadable.
if not bond_info['slaves']:
bond_info['slaves'] = proc_slaves
if bond_info['mode'] == 'unknown' and bond_info['mode_detail']:
bond_info['mode'] = bond_info['mode_detail']
bond_info['supports_failover'] = bond_info['mode'] in _BOND_FAILOVER_MODES
except Exception as e:
# print(f"[v0] Error reading bond info for {bond_name}: {e}")
pass
return bond_info
def get_bridge_info(bridge_name):
@@ -4795,7 +4954,10 @@ def get_bridge_info(bridge_name):
'physical_interface': None,
'physical_duplex': 'unknown', # Added physical_duplex field
# Added bond_slaves to show physical interfaces
'bond_slaves': []
'bond_slaves': [],
# Set when the bridge port is a VLAN sub-interface (bond0.10);
# physical_interface then holds the base device (bond0).
'vlan_interface': None
}
try:
@@ -4805,19 +4967,32 @@ def get_bridge_info(bridge_name):
members = os.listdir(brif_path)
bridge_info['members'] = members
for member in members:
# Guest taps and firewall veth pairs are never the bridge's
# uplink — skipping them up front means the uplink is found
# regardless of the (arbitrary) order listdir returns.
uplinks = [m for m in members
if not m.startswith(('veth', 'tap', 'fwpr', 'fwln'))]
for member in uplinks:
# A VLAN sub-interface (bond0.10 / eno1.10) is just a tag
# on top of the real uplink — resolve to the base device
# so the topology still shows the bond or NIC underneath.
base = member.split('.', 1)[0] if '.' in member else member
if base != member:
bridge_info['vlan_interface'] = member
# Check if member is a bond first
if member.startswith('bond'):
bridge_info['physical_interface'] = member
# print(f"[v0] Bridge {bridge_name} connected to bond: {member}")
if _is_bond_master(base) or base.startswith('bond'):
bridge_info['physical_interface'] = base
# print(f"[v0] Bridge {bridge_name} connected to bond: {base}")
pass
bond_info = get_bond_info(member)
bond_info = get_bond_info(base)
if bond_info['slaves']:
bridge_info['bond_slaves'] = bond_info['slaves']
# print(f"[v0] Bond {member} slaves: {bond_info['slaves']}")
# print(f"[v0] Bond {base} slaves: {bond_info['slaves']}")
pass
# Get duplex from bond's active slave
if bond_info['active_slave']:
try:
@@ -4831,17 +5006,19 @@ def get_bridge_info(bridge_name):
# print(f"[v0] Error getting duplex for bond slave {bond_info['active_slave']}: {e}")
pass
break
# Check if member is a physical interface
elif member.startswith(('enp', 'eth', 'eno', 'ens', 'wlan', 'wlp')):
bridge_info['physical_interface'] = member
# print(f"[v0] Bridge {bridge_name} physical interface: {member}")
# Check if member is a physical interface. The device
# symlink is the reliable marker — a NIC renamed to
# "nic0" has no recognisable prefix but still has it.
elif _is_physical_dev(base) or base.startswith(('enp', 'eth', 'eno', 'ens', 'wlan', 'wlp')):
bridge_info['physical_interface'] = base
# print(f"[v0] Bridge {bridge_name} physical interface: {base}")
pass
# Get duplex from physical interface
try:
net_if_stats = psutil.net_if_stats()
if member in net_if_stats:
stats = net_if_stats[member]
if base in net_if_stats:
stats = net_if_stats[base]
bridge_info['physical_duplex'] = 'full' if stats.duplex == 2 else 'half' if stats.duplex == 1 else 'unknown'
# print(f"[v0] Physical interface {member} duplex: {bridge_info['physical_duplex']}")
pass
@@ -4866,6 +5043,10 @@ def get_network_info():
'interfaces': [],
'physical_interfaces': [], # Added separate list for physical interfaces
'bridge_interfaces': [], # Added separate list for bridge interfaces
# Bond masters, so the Network Flow diagram can draw them as
# the intermediate node between their slaves and the host.
# They stay in `interfaces` too, for backward compatibility.
'bond_interfaces': [],
'vm_lxc_interfaces': [],
'traffic': {'bytes_sent': 0, 'bytes_recv': 0, 'packets_sent': 0, 'packets_recv': 0},
# 'hostname': socket.gethostname(),
@@ -5043,9 +5224,12 @@ def get_network_info():
if interface_type == 'bond':
bond_info = get_bond_info(interface_name)
interface_info['bond_mode'] = bond_info['mode']
interface_info['bond_mode_detail'] = bond_info['mode_detail']
interface_info['bond_slaves'] = bond_info['slaves']
interface_info['bond_active_slave'] = bond_info['active_slave']
interface_info['bond_supports_failover'] = bond_info['supports_failover']
interface_info['bond_slave_status'] = bond_info['slave_status']
if interface_type == 'bridge':
bridge_info = get_bridge_info(interface_name)
interface_info['bridge_members'] = bridge_info['members']
@@ -5065,7 +5249,9 @@ def get_network_info():
else:
# Keep other types in the general interfaces list for backward compatibility
network_data['interfaces'].append(interface_info)
if interface_type == 'bond':
network_data['bond_interfaces'].append(interface_info)
network_data['physical_active_count'] = physical_active_count
network_data['physical_total_count'] = physical_total_count
network_data['bridge_active_count'] = bridge_active_count
@@ -5073,6 +5259,35 @@ def get_network_info():
network_data['vm_lxc_active_count'] = vm_lxc_active_count
network_data['vm_lxc_total_count'] = vm_lxc_total_count
# Tag every bond slave with its master and its CURRENT role. The
# role is read fresh on each poll (never pinned to a NIC), so a
# failover flips active/standby on the next refresh.
# active/standby → only for active-backup, the one mode where
# a slave really sits idle.
# member → LACP, balance-rr/xor/tlb/alb: every slave
# transmits, so no standby label.
# `bond_link` comes from /proc/net/bonding's per-slave MII status
# and distinguishes a dead link from a healthy standby.
slave_meta = {}
for bond in network_data['bond_interfaces']:
failover = bond.get('bond_supports_failover')
active = bond.get('bond_active_slave')
statuses = bond.get('bond_slave_status') or {}
for slave in bond.get('bond_slaves') or []:
if failover:
role = 'active' if slave == active else 'standby'
else:
role = 'member'
slave_meta[slave] = {
'bond_master': bond['name'],
'bond_role': role,
'bond_link': statuses.get(slave, 'unknown'),
}
for phy in network_data['physical_interfaces']:
meta = slave_meta.get(phy['name'])
if meta:
phy.update(meta)
# Map physical NICs ↔ bridges using them. For a bridge with a
# direct NIC parent, that NIC gets the bridge in its list. For
# bridges sitting on top of a bond, every bond slave gets the
@@ -5135,6 +5350,7 @@ def get_network_info():
'interfaces': [],
'physical_interfaces': [],
'bridge_interfaces': [],
'bond_interfaces': [],
'vm_lxc_interfaces': [],
'traffic': {'bytes_sent': 0, 'bytes_recv': 0, 'packets_sent': 0, 'packets_recv': 0},
'active_count': 0,
@@ -7598,29 +7814,23 @@ def _get_hardware_info_uncached():
except:
pass
# Parse SATA version from smartctl output
# SATA version + form factor from one `smartctl -i`.
# `-n standby` so a parked HDD isn't handed even an
# IDENTIFY command (issue #232) — exit code 2 leaves
# both fields None and the disk asleep. Merged from
# two separate calls that parsed the same output.
sata_version = None
form_factor = None
try:
result_smart = subprocess.run(['smartctl', '-i', f'/dev/{disk_name}'],
capture_output=True, text=True, timeout=5)
result_smart = subprocess.run(
['smartctl', '-n', 'standby', '-i', f'/dev/{disk_name}'],
capture_output=True, text=True, timeout=5)
if result_smart.returncode == 0:
for line in result_smart.stdout.split('\n'):
if 'SATA Version is:' in line:
sata_version = line.split(':', 1)[1].strip()
break
except:
pass
# Parse form factor from smartctl output
form_factor = None
try:
result_smart = subprocess.run(['smartctl', '-i', f'/dev/{disk_name}'],
capture_output=True, text=True, timeout=5)
if result_smart.returncode == 0:
for line in result_smart.stdout.split('\n'):
if 'Form Factor:' in line:
elif 'Form Factor:' in line:
form_factor = line.split(':', 1)[1].strip()
break
except:
pass
@@ -12702,6 +12912,21 @@ _BACKUP_DEFAULT_DUMP_DIRS = ('/var/lib/vz/dump',)
_BACKUP_FILENAME_RE = re.compile(r'^([A-Za-z0-9._-]+)-(\d{8}_\d{6})\.tar(\.zst|\.gz)?$')
def _shell_env_line(line: str) -> str:
"""Turn a raw ``KEY=value`` pair into a shell-safe assignment.
The job .env is `source`d by run_scheduled_backup.sh as root, so an
unquoted value is not just cosmetic: globs and spaces split the
assignment into a command, and `$(...)` or backticks in a password
run at source time. backup_scheduler.sh quotes with `printf %q`;
this is the same guarantee for the lines written from the API.
"""
if not line or line.startswith('#') or '=' not in line:
return line
key, val = line.split('=', 1)
return f'{key}={shlex.quote(val)}'
def _parse_job_env(file_path: str) -> dict:
"""Parse a /var/lib/proxmenux/backup-jobs/*.env file (shell KEY=value
format with optional quoting) into a Python dict. Returns {} on any
@@ -12716,9 +12941,22 @@ def _parse_job_env(file_path: str) -> dict:
key, val = line.split('=', 1)
key = key.strip()
val = val.strip()
# Strip shell quoting if balanced
if len(val) >= 2 and val[0] == val[-1] and val[0] in ('"', "'"):
val = val[1:-1]
# Unquote the way the shell would — `shlex` handles both
# writers, `printf %q` from the TUI and shlex.quote()
# from the API. Exactly one token means the value was
# properly quoted; anything else is a legacy unquoted
# multi-word value already on disk, kept verbatim rather
# than truncated to its first word.
try:
parts = shlex.split(val)
if len(parts) == 1:
val = parts[0]
elif not parts:
val = ''
except ValueError:
# Unbalanced quoting — fall back to a naive strip.
if len(val) >= 2 and val[0] == val[-1] and val[0] in ('"', "'"):
val = val[1:-1]
out[key] = val
except OSError:
pass
@@ -13412,7 +13650,10 @@ def _list_pbs_destinations() -> list:
repos.append({
'name': name,
'repository': repo,
'fingerprint': None,
# A manual destination never reaches
# storage.cfg, so the sidecar is the only
# place its fingerprint exists.
'fingerprint': _resolve_pbs_fingerprint(name) or None,
'source': 'manual',
})
seen.add((name, repo))
@@ -14947,6 +15188,29 @@ def _resolve_pbs_password(repo_name: str) -> str:
return ''
def _fingerprint_for_repository(repo: str) -> str:
"""Resolve the fingerprint from a `<user>@<server>:<datastore>`
repository string rather than a destination *name*.
Job payloads carry the repository, so this maps it back to the
destination before delegating to `_resolve_pbs_fingerprint`. A job
written without a `PBS_FINGERPRINT=` line fails on every run against
a self-signed PBS the client refuses the connection instead of
trusting the certificate.
"""
if not repo:
return ''
try:
for dest in _list_pbs_destinations():
if dest.get('repository') == repo:
fp = dest.get('fingerprint') or _resolve_pbs_fingerprint(dest.get('name') or '')
if fp:
return fp
except Exception:
pass
return ''
def _resolve_pbs_fingerprint(repo_name: str) -> str:
"""Return the PBS fingerprint for `repo_name`. Symmetric to
_resolve_pbs_password: first the proxmenux manual sidecar
@@ -15343,6 +15607,8 @@ def api_host_backups_job_create():
import socket
pbs_backup_id = f'hostcfg-{socket.gethostname()}'
pbs_fingerprint = (payload.get('pbs_fingerprint') or '').strip()
if not pbs_fingerprint:
pbs_fingerprint = _fingerprint_for_repository(pbs_repo)
# Client-side encryption. Three modes:
# - "none" — no --keyfile flag, plain backup
# - "new" — generate a fresh per-host keyfile (default,
@@ -15448,7 +15714,7 @@ def api_host_backups_job_create():
os.makedirs(_BACKUP_JOBS_DIR, exist_ok=True)
with open(env_file, 'w') as f:
for ln in lines:
f.write(ln + '\n')
f.write(_shell_env_line(ln) + '\n')
os.chmod(env_file, 0o600)
with open(f'{_BACKUP_JOBS_DIR}/{job_id}.paths', 'w') as f:
for p in resolved_paths:
@@ -15832,6 +16098,8 @@ def api_host_backups_job_update(job_id):
import socket
pbs_backup_id = f'hostcfg-{socket.gethostname()}'
pbs_fingerprint = (payload.get('pbs_fingerprint') or existing.get('PBS_FINGERPRINT') or '').strip()
if not pbs_fingerprint:
pbs_fingerprint = _fingerprint_for_repository(pbs_repo)
# Encryption: matches job-create + manual-run. The Web sends
# `pbs_encrypt_mode` ("none" / "new" / "existing"); older
# clients may still send the legacy `pbs_encrypt` bool.
@@ -15909,7 +16177,7 @@ def api_host_backups_job_update(job_id):
tmp_env = f'{env_file}.tmp.{os.getpid()}'
with open(tmp_env, 'w') as f:
for ln in lines:
f.write(ln + '\n')
f.write(_shell_env_line(ln) + '\n')
os.chmod(tmp_env, 0o600)
os.replace(tmp_env, env_file)
paths_file = f'{_BACKUP_JOBS_DIR}/{job_id}.paths'
@@ -16044,6 +16312,8 @@ def api_host_backups_manual_run():
# form when they need a separate retention bucket.
pbs_backup_id = f'hostcfg-{socket.gethostname()}'
pbs_fingerprint = (payload.get('pbs_fingerprint') or '').strip()
if not pbs_fingerprint:
pbs_fingerprint = _fingerprint_for_repository(pbs_repo)
# Encryption mode: matches the job-create endpoint exactly.
# Reads the modern `pbs_encrypt_mode` string ("none"/"new"/
# "existing") first, falls back to legacy `pbs_encrypt` bool
@@ -16111,7 +16381,7 @@ def api_host_backups_manual_run():
os.makedirs(_BACKUP_JOBS_DIR, exist_ok=True)
with open(env_file, 'w') as f:
for ln in lines:
f.write(ln + '\n')
f.write(_shell_env_line(ln) + '\n')
os.chmod(env_file, 0o600)
with open(paths_file, 'w') as f:
for p in resolved_paths:
+73 -2
View File
@@ -115,6 +115,75 @@ def _disk_base_for_sysfs(name: str) -> str:
return name
_standby_cache: dict = {}
_STANDBY_CACHE_TTL = 15
def _hdd_in_standby(disk_name: str) -> bool:
"""True if `disk_name` is a spinning disk currently parked.
CHECK POWER MODE probe (`smartctl -n standby`) answered without
spinning the drive up. Rotational, non-NVMe only. Mirrors the helper
in flask_server.py; see issue #232.
"""
base = _disk_base_for_sysfs(disk_name)
if base.startswith('nvme'):
return False
now = time.time()
hit = _standby_cache.get(base)
if hit and now - hit[0] < _STANDBY_CACHE_TTL:
return hit[1]
try:
with open(f'/sys/block/{base}/queue/rotational') as f:
if f.read().strip() != '1':
_standby_cache[base] = (now, False)
return False
except OSError:
return False
parked = False
try:
r = subprocess.run(
['smartctl', '-n', 'standby', '-i', f'/dev/{base}'],
capture_output=True, text=True, timeout=5)
parked = r.returncode == 2
except Exception:
parked = False
_standby_cache[base] = (now, parked)
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 _is_disk_removable(disk_name: str) -> bool:
"""True if `/sys/block/<disk>/removable` reads 1. USB-attached storage."""
try:
@@ -2199,8 +2268,10 @@ class HealthMonitor:
if result_which.returncode != 0:
return {'status': 'OK'} # LVM not installed
# `--devices` keeps lvs from scanning parked HDDs (issue #232).
dev_args = _lvm_device_args()
result = subprocess.run(
['lvs', '--noheadings', '--options', 'lv_name,vg_name,lv_attr'],
['lvs', *dev_args, '--noheadings', '--options', 'lv_name,vg_name,lv_attr'],
capture_output=True,
text=True,
timeout=3
@@ -2224,7 +2295,7 @@ class HealthMonitor:
if not volumes:
# Check if any VGs exist to determine if LVM is truly unconfigured or just inactive
vg_result = subprocess.run(
['vgs', '--noheadings', '--options', 'vg_name'],
['vgs', *dev_args, '--noheadings', '--options', 'vg_name'],
capture_output=True,
text=True,
timeout=3
+26 -4
View File
@@ -48,6 +48,9 @@ _FN_DEF_RE = re.compile(r"^(?P<name>[a-zA-Z_][a-zA-Z0-9_]*)\s*\(\)\s*\{\s*$")
_VERSION_RE = re.compile(r'local\s+FUNC_VERSION\s*=\s*"([0-9]+(?:\.[0-9]+)+)"')
_DESC_RE = re.compile(r"#\s*description\s*:\s*([^\n]+)")
_REGISTER_RE = re.compile(r'\bregister_tool\s+"([^"]+)"\s+true\b')
# Matches a heredoc opener and captures its terminator word. Handles
# `<<EOF`, `<<-EOF`, `<< EOF`, `<<'EOF'` and `<<"EOF"`.
_HEREDOC_RE = re.compile(r'<<[-~]?\s*["\']?([A-Za-z_][A-Za-z0-9_]*)["\']?')
# In-memory cache of the last scan. Sprint 12A uses a single startup scan
# plus on-demand re-scan via the API; no automatic refresh.
@@ -122,12 +125,31 @@ def parse_post_install_script(path: Path) -> dict[str, dict[str, str]]:
continue
func_name = match.group("name")
# Find the matching closing brace at column 0. Bash post-install
# scripts use the convention `}` on its own line at the start of
# the line to close top-level functions, so we scan until that.
# Find the matching closing brace at column 0. Top-level post-install
# functions close with `}` alone on a line. A bare scan for that line
# breaks when the body embeds a heredoc whose content also has `}` at
# column 0 — e.g. a logrotate stanza written via `cat >... <<EOF`.
# That truncated the body before its `register_tool` call, so tools
# like log2ram / network_optimization were never registered and never
# flagged as updatable. Track heredoc state and ignore `}` inside one.
body_start = i + 1
body_end = body_start
while body_end < len(lines) and not lines[body_end].rstrip() == "}":
heredoc_term = None
while body_end < len(lines):
current = lines[body_end]
if heredoc_term is not None:
# Inside a heredoc — only its terminator line ends it.
if current.strip() == heredoc_term:
heredoc_term = None
body_end += 1
continue
if current.rstrip() == "}":
break
# A line may open a heredoc (use the last opener on the line, so
# `cmd <<A | cmd <<B` picks B, matching shell behaviour).
openers = _HEREDOC_RE.findall(current)
if openers:
heredoc_term = openers[-1]
body_end += 1
body = "\n".join(lines[body_start:body_end])