Files
ProxMenux/AppImage/scripts/proxmox_storage_monitor.py
T

263 lines
9.2 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
ProxMenux - Proxmox Storage Monitor
Monitors configured Proxmox storages and tracks unavailable storages
"""
import json
import os
import re
import subprocess
import socket
import time
from typing import Dict, List, Any, Optional
def classify_storage_state(storage_type: str, status: str, total: int) -> Dict[str, Any]:
"""Classify reachability independently from reported capacity."""
normalized_status = str(status or 'unknown').strip().lower()
normalized_type = str(storage_type or 'unknown').strip().lower()
capacity_known = total > 0
if normalized_status != 'available':
return {
'status': 'error',
'status_detail': normalized_status or 'unknown',
'capacity_known': capacity_known,
}
if not capacity_known and normalized_type == 'pbs':
return {
'status': 'namespace_restricted',
'status_detail': 'namespace_restricted',
'capacity_known': False,
}
return {
'status': 'active',
'status_detail': 'available' if capacity_known else 'capacity_unreported',
'capacity_known': capacity_known,
}
class ProxmoxStorageMonitor:
"""Monitor Proxmox storage configuration and status"""
# Cache TTL: 177 seconds (~3 min) - offset to avoid sync with other processes
_CACHE_TTL = 177
_PVE_LOCAL_NODE_LINK = "/etc/pve/local"
def __init__(self):
self.configured_storages: Dict[str, Dict[str, Any]] = {}
self._node_name_cache = {'name': None, 'time': 0}
self._storage_status_cache = {'data': None, 'time': 0}
self._config_cache_time = 0 # Track when config was last loaded
self._load_configured_storages()
def _get_node_name(self) -> str:
"""The name of this node in Proxmox (cached). `/etc/pve/local` is a
link to the folder of the local node, so reading it does not wait
for the API; the hostname is used while `/etc/pve` is not mounted."""
current_time = time.time()
cache = self._node_name_cache
# Return cached result if fresh
if cache['name'] and (current_time - cache['time']) < self._CACHE_TTL:
return cache['name']
try:
name = os.path.basename(os.readlink(self._PVE_LOCAL_NODE_LINK))
except OSError:
name = ''
if re.fullmatch(r'[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?', name):
cache['name'] = name
cache['time'] = current_time
return name
return socket.gethostname()
def _load_configured_storages(self) -> None:
"""Load configured storages from Proxmox configuration"""
try:
local_node = self._get_node_name()
# Read storage configuration from pvesh
result = subprocess.run(
['pvesh', 'get', '/storage', '--output-format', 'json'],
capture_output=True,
text=True,
timeout=15
)
if result.returncode != 0:
return
storages = json.loads(result.stdout)
for storage in storages:
storage_id = storage.get('storage')
if not storage_id:
continue
# Check if storage is enabled for this node
nodes = storage.get('nodes')
if nodes and local_node not in nodes.split(','):
continue
disabled = storage.get('disable', 0)
if disabled == 1:
continue
self.configured_storages[storage_id] = {
'name': storage_id,
'type': storage.get('type', 'unknown'),
'content': storage.get('content', ''),
'path': storage.get('path', ''),
'enabled': True
}
except Exception:
pass
def get_storage_status(self) -> Dict[str, List[Dict[str, Any]]]:
"""
Get storage status, including unavailable storages (cached)
Returns:
{
'available': [...],
'unavailable': [...]
}
"""
current_time = time.time()
cache = self._storage_status_cache
# Return cached result if fresh
if cache['data'] and (current_time - cache['time']) < self._CACHE_TTL:
return cache['data']
try:
local_node = self._get_node_name()
# Get current storage status from pvesh
result = subprocess.run(
['pvesh', 'get', '/cluster/resources', '--type', 'storage', '--output-format', 'json'],
capture_output=True,
text=True,
timeout=10
)
if result.returncode != 0:
return {'available': [], 'unavailable': list(self.configured_storages.values())}
resources = json.loads(result.stdout)
# Track which configured storages are available
available_storages = []
unavailable_storages = []
seen_storage_names = set()
for resource in resources:
node = resource.get('node', '')
# Filter only local node storages
if node != local_node:
continue
name = resource.get('storage', 'unknown')
seen_storage_names.add(name)
storage_type = resource.get('plugintype', 'unknown')
status = resource.get('status', 'unknown')
try:
total = int(resource.get('maxdisk', 0))
used = int(resource.get('disk', 0))
available = total - used if total > 0 else 0
except (ValueError, TypeError):
total = 0
used = 0
available = 0
# Calculate percentage
percent = (used / total * 100) if total > 0 else 0.0
# Convert bytes to GB
total_gb = round(total / (1024**3), 2)
used_gb = round(used / (1024**3), 2)
available_gb = round(available / (1024**3), 2)
storage_info = {
'name': name,
'type': storage_type,
'total': total_gb,
'used': used_gb,
'available': available_gb,
'percent': round(percent, 2),
'node': node
}
state = classify_storage_state(storage_type, status, total)
storage_info.update(state)
if state['status'] in ('active', 'namespace_restricted'):
available_storages.append(storage_info)
else:
unavailable_storages.append(storage_info)
# Check for configured storages that are completely missing
for storage_name, storage_config in self.configured_storages.items():
if storage_name not in seen_storage_names:
unavailable_storages.append({
'name': storage_name,
'type': storage_config['type'],
'status': 'error',
'status_detail': 'not_found',
'total': 0,
'used': 0,
'available': 0,
'percent': 0,
'capacity_known': False,
'node': local_node
})
result_data = {
'available': available_storages,
'unavailable': unavailable_storages
}
# Cache the result
cache['data'] = result_data
cache['time'] = current_time
return result_data
except Exception:
return {
'available': [],
'unavailable': list(self.configured_storages.values())
}
def get_unavailable_count(self) -> int:
"""Get count of unavailable storages"""
status = self.get_storage_status()
return len(status['unavailable'])
def reload_configuration(self, force: bool = False) -> None:
"""Reload storage configuration from Proxmox (cached)
Args:
force: If True, bypass cache and force reload
"""
current_time = time.time()
# Skip reload if cache is still fresh (unless forced)
if not force and (current_time - self._config_cache_time) < self._CACHE_TTL:
return
self.configured_storages.clear()
self._load_configured_storages()
self._config_cache_time = current_time
# Global instance
proxmox_storage_monitor = ProxmoxStorageMonitor()