mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-07-26 18:38:30 +00:00
update 1.2.2.2 beta
This commit is contained in:
@@ -3669,6 +3669,25 @@ def _get_smart_data_uncached(disk_name):
|
||||
['smartctl', '-a', '-d', 'sat,16', f'/dev/{disk_name}'], # Text SAT with 16-byte commands
|
||||
]
|
||||
|
||||
# USB-NVMe bridges (ASMedia ASM2362/ASM2464PD, JMicron JMS583/JMS586,
|
||||
# Realtek RTL9210): the plain `-a` variant answers with the *bridge*
|
||||
# identity (e.g. "ASMT 2462 NVME") and no temperature, because the
|
||||
# bridge exposes itself as generic USB storage. Only `-d snt*`
|
||||
# passes through to the actual NVMe controller and returns real
|
||||
# model, serial, temperature and health.
|
||||
#
|
||||
# For removable disks we prepend the three snt* variants so the
|
||||
# cascade tries them FIRST — otherwise the plain variant "succeeds"
|
||||
# (>50 chars of bridge chatter), the probe cache locks it in, and
|
||||
# temperature is never seen. For non-removable disks the cascade
|
||||
# is unchanged (zero regression risk on internal SATA/NVMe).
|
||||
if is_disk_removable(disk_name):
|
||||
all_commands = [
|
||||
['smartctl', '-a', '-j', '-d', 'sntasmedia', f'/dev/{disk_name}'],
|
||||
['smartctl', '-a', '-j', '-d', 'sntjmicron', f'/dev/{disk_name}'],
|
||||
['smartctl', '-a', '-j', '-d', 'sntrealtek', f'/dev/{disk_name}'],
|
||||
] + all_commands
|
||||
|
||||
# Probe-cache: if we already know which command works for this
|
||||
# disk, try that first. The fallback chain is still kept after
|
||||
# in case the cached probe stops working (kernel upgrade swapped
|
||||
@@ -13808,15 +13827,40 @@ def api_pbs_recovery_setup():
|
||||
return jsonify({'status': 'ok', 'recovery_path': _PBS_RECOVERY_ENC_PATH})
|
||||
|
||||
|
||||
def _is_pbs_keyrecovery_backup_id(bid: str) -> bool:
|
||||
"""Match both the current `hostcfg-<host>-keyrecovery` naming
|
||||
and the legacy `proxmenux-keyrecovery-<host>` naming so escrow
|
||||
blobs uploaded by pre-1.2.2.2 builds remain discoverable and
|
||||
stay hidden from the main host-backup list."""
|
||||
if not bid:
|
||||
return False
|
||||
if bid.startswith('proxmenux-keyrecovery-'):
|
||||
return True
|
||||
return bid.startswith('hostcfg-') and bid.endswith('-keyrecovery')
|
||||
|
||||
|
||||
def _pbs_keyrecovery_source_host(bid: str) -> str:
|
||||
"""Extract the source hostname from either keyrecovery naming."""
|
||||
if bid.startswith('hostcfg-') and bid.endswith('-keyrecovery'):
|
||||
return bid[len('hostcfg-'):-len('-keyrecovery')]
|
||||
if bid.startswith('proxmenux-keyrecovery-'):
|
||||
return bid[len('proxmenux-keyrecovery-'):]
|
||||
return bid
|
||||
|
||||
|
||||
@app.route('/api/host-backups/pbs-recovery/available', methods=['GET'])
|
||||
@require_auth
|
||||
def api_pbs_recovery_available():
|
||||
"""List `host/proxmenux-keyrecovery-*` snapshots across every
|
||||
configured PBS repository. The restore flow uses this when the
|
||||
local keyfile is missing — the operator picks a snapshot, types
|
||||
the recovery passphrase, and the keyfile is rebuilt from the
|
||||
decrypted blob. Returns the freshest snapshot per backup-id +
|
||||
repo so multi-host environments don't bury each other."""
|
||||
"""List keyrecovery backup groups across every configured PBS
|
||||
repository. Two naming patterns are surfaced: the current
|
||||
`hostcfg-<host>-keyrecovery` (paired with the main backup group
|
||||
for visual adjacency in the PBS UI) and the legacy
|
||||
`proxmenux-keyrecovery-<host>` used by pre-1.2.2.2 builds. The
|
||||
restore flow uses this when the local keyfile is missing — the
|
||||
operator picks a backup, types the recovery passphrase, and the
|
||||
keyfile is rebuilt from the decrypted blob. Returns the freshest
|
||||
backup per backup-id + repo so multi-host environments don't
|
||||
bury each other."""
|
||||
out: list = []
|
||||
errors: list = []
|
||||
for repo in _list_pbs_destinations():
|
||||
@@ -13851,7 +13895,7 @@ def api_pbs_recovery_available():
|
||||
if it.get('backup-type') != 'host':
|
||||
continue
|
||||
bid = it.get('backup-id') or ''
|
||||
if not bid.startswith('proxmenux-keyrecovery-'):
|
||||
if not _is_pbs_keyrecovery_backup_id(bid):
|
||||
continue
|
||||
t = it.get('backup-time', 0)
|
||||
# ISO timestamp — proxmox-backup-client rejects the
|
||||
@@ -13866,7 +13910,7 @@ def api_pbs_recovery_available():
|
||||
'repo_name': repo['name'],
|
||||
'repo_repository': repo['repository'],
|
||||
'backup_id': bid,
|
||||
'source_host': bid[len('proxmenux-keyrecovery-'):],
|
||||
'source_host': _pbs_keyrecovery_source_host(bid),
|
||||
'backup_time': t,
|
||||
'snapshot': f"host/{bid}/{iso}",
|
||||
}
|
||||
@@ -15853,10 +15897,13 @@ def _list_pbs_snapshots_for_repo(repo: dict, timeout: int = 15) -> tuple[list, s
|
||||
if backup_type != 'host':
|
||||
continue
|
||||
backup_id = it.get('backup-id', '')
|
||||
# Hide internal keyrecovery escrow snapshots — they're a
|
||||
# Hide internal keyrecovery escrow backups — they're a
|
||||
# ProxMenux implementation detail, not user-facing backups.
|
||||
# See _PBS_RECOVERY_ENC_PATH and the runner upload step.
|
||||
if backup_id.startswith('proxmenux-keyrecovery-'):
|
||||
# Matches both current `hostcfg-<host>-keyrecovery` and
|
||||
# legacy `proxmenux-keyrecovery-<host>` naming so escrow
|
||||
# blobs from either build stay hidden from the main list.
|
||||
if _is_pbs_keyrecovery_backup_id(backup_id):
|
||||
continue
|
||||
backup_time = it.get('backup-time', 0)
|
||||
# proxmox-backup-client expects the timestamp portion as an
|
||||
|
||||
@@ -58,6 +58,31 @@ def _perf_log(section: str, elapsed_ms: float):
|
||||
if DEBUG_PERF:
|
||||
print(f"[PERF] {section} = {elapsed_ms:.1f}ms")
|
||||
|
||||
|
||||
# USB-NVMe bridges (ASMedia, JMicron, Realtek) answer plain smartctl with
|
||||
# the *bridge* identity — model shows as "ASMT 2462 NVME" and there is no
|
||||
# temperature. Only `-d snt*` passes through to the actual NVMe controller
|
||||
# behind the bridge. For removable disks we try the snt* variants first
|
||||
# so both identity and health reflect the drive, not the enclosure.
|
||||
_USB_NVME_DRIVERS = ('sntasmedia', 'sntjmicron', 'sntrealtek')
|
||||
|
||||
|
||||
def _disk_base_for_sysfs(name: str) -> str:
|
||||
"""Normalize `/dev/sda` / `sda` to just `sda` for `/sys/block/<name>` lookups."""
|
||||
if name.startswith('/dev/'):
|
||||
return name[5:]
|
||||
return name
|
||||
|
||||
|
||||
def _is_disk_removable(disk_name: str) -> bool:
|
||||
"""True if `/sys/block/<disk>/removable` reads 1. USB-attached storage."""
|
||||
try:
|
||||
base = _disk_base_for_sysfs(disk_name)
|
||||
with open(f'/sys/block/{base}/removable') as f:
|
||||
return f.read().strip() == '1'
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
class HealthMonitor:
|
||||
"""
|
||||
Monitors system health across multiple components with minimal impact.
|
||||
@@ -2405,15 +2430,33 @@ class HealthMonitor:
|
||||
result = {'serial': '', 'model': '', '_fp': fp}
|
||||
try:
|
||||
dev_path = f'/dev/{disk_name}' if not disk_name.startswith('/') else disk_name
|
||||
proc = subprocess.run(
|
||||
['smartctl', '-i', '-j', dev_path],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if proc.returncode in (0, 4):
|
||||
import json as _json
|
||||
data = _json.loads(proc.stdout)
|
||||
result['serial'] = data.get('serial_number', '')
|
||||
result['model'] = data.get('model_name', '') or data.get('model_family', '')
|
||||
|
||||
# Removable disks may be USB-NVMe bridges: try the snt* driver
|
||||
# variants first so identity reflects the drive (Samsung 990 PRO)
|
||||
# rather than the enclosure (ASMT 2462 NVME). If all snt* fail,
|
||||
# fall through to the plain call — that's still correct for
|
||||
# USB-SATA sticks and non-USB devices.
|
||||
attempts = []
|
||||
if _is_disk_removable(disk_name):
|
||||
for drv in _USB_NVME_DRIVERS:
|
||||
attempts.append(['smartctl', '-i', '-j', '-d', drv, dev_path])
|
||||
attempts.append(['smartctl', '-i', '-j', dev_path])
|
||||
|
||||
import json as _json
|
||||
for cmd in attempts:
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=5)
|
||||
if proc.returncode not in (0, 4):
|
||||
continue
|
||||
try:
|
||||
data = _json.loads(proc.stdout)
|
||||
except Exception:
|
||||
continue
|
||||
serial = data.get('serial_number', '')
|
||||
model = data.get('model_name', '') or data.get('model_family', '')
|
||||
if serial or model:
|
||||
result['serial'] = serial
|
||||
result['model'] = model
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -2451,28 +2494,44 @@ class HealthMonitor:
|
||||
# from spinning up HDDs that hdparm / hd-idle just put to
|
||||
# sleep — issue #232. The "UNKNOWN" branch below correctly
|
||||
# keeps the previous cached result alive on exit code 2.
|
||||
result = subprocess.run(
|
||||
['smartctl', '-n', 'standby', '--health', '-j', dev_path],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if result.returncode == 2:
|
||||
# Drive in standby — reuse the previous health state
|
||||
# if we have one, otherwise report UNKNOWN. Either way,
|
||||
# don't refresh the cache TTL so we retry on the next
|
||||
# cycle (a drive can come out of standby at any time).
|
||||
if cached:
|
||||
return cached['result']
|
||||
return 'UNKNOWN'
|
||||
#
|
||||
# Removable disks may be USB-NVMe bridges: try snt* drivers
|
||||
# first so health reflects the actual NVMe controller. A
|
||||
# bridge that fakes "PASSED" while the drive behind it is
|
||||
# failing is exactly the false-negative we want to avoid.
|
||||
attempts = []
|
||||
if _is_disk_removable(disk_name):
|
||||
for drv in _USB_NVME_DRIVERS:
|
||||
attempts.append(['smartctl', '-n', 'standby', '--health', '-j', '-d', drv, dev_path])
|
||||
attempts.append(['smartctl', '-n', 'standby', '--health', '-j', dev_path])
|
||||
|
||||
import json as _json
|
||||
data = _json.loads(result.stdout)
|
||||
passed = data.get('smart_status', {}).get('passed', None)
|
||||
if passed is True:
|
||||
smart_result = 'PASSED'
|
||||
elif passed is False:
|
||||
smart_result = 'FAILED'
|
||||
else:
|
||||
smart_result = None
|
||||
for cmd in attempts:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=5)
|
||||
if result.returncode == 2:
|
||||
# Drive in standby — reuse the previous health state
|
||||
# if we have one, otherwise report UNKNOWN. Either way,
|
||||
# don't refresh the cache TTL so we retry on the next
|
||||
# cycle (a drive can come out of standby at any time).
|
||||
if cached:
|
||||
return cached['result']
|
||||
return 'UNKNOWN'
|
||||
try:
|
||||
data = _json.loads(result.stdout)
|
||||
except Exception:
|
||||
continue
|
||||
passed = data.get('smart_status', {}).get('passed', None)
|
||||
if passed is True:
|
||||
smart_result = 'PASSED'
|
||||
break
|
||||
if passed is False:
|
||||
smart_result = 'FAILED'
|
||||
break
|
||||
# No opinion yet — next attempt (fallthrough to plain).
|
||||
if smart_result is None:
|
||||
smart_result = 'UNKNOWN'
|
||||
|
||||
|
||||
# Cache the result with the device fingerprint for hot-swap invalidation
|
||||
self._smart_cache[cache_key] = {'result': smart_result, 'time': current_time, 'fp': fp}
|
||||
return smart_result
|
||||
|
||||
Reference in New Issue
Block a user