mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-14 18:56:52 +00:00
fix: improve host diagnostics, storage handling, and maintenance workflows
- add zero-downtime Proxmox TLS certificate refresh from the Security panel (#307) - classify storage availability independently from missing capacity information (#309) - update ZFS ARC sizing and safely reconcile conflicting module configurations - preserve and restore migrated ZFS settings without overwriting later administrator changes - stop memory optimization from forcing the kernel overcommit policy - correlate multi-line OOM events and identify the affected LXC, cgroup limits, swap and killed process - add selectable Bash prompt path styles and clearer shell activation guidance - protect technical names during automatic translation and correct localized terminology - update the Coral and VM/LXC Apps and Updates documentation, translations and screenshots
This commit is contained in:
@@ -1054,6 +1054,12 @@ PROXMOX_KEY_PATH = "/etc/pve/local/pve-ssl.key"
|
||||
PROXMOX_CUSTOM_CERT_PATH = "/etc/pve/local/pveproxy-ssl.pem"
|
||||
PROXMOX_CUSTOM_KEY_PATH = "/etc/pve/local/pveproxy-ssl.key"
|
||||
|
||||
_SSL_RUNTIME_LOCK = threading.RLock()
|
||||
_SSL_RUNTIME_CONTEXT = None
|
||||
_SSL_RUNTIME_FINGERPRINT = ""
|
||||
_SSL_RUNTIME_CERT_PATH = ""
|
||||
_SSL_RUNTIME_KEY_PATH = ""
|
||||
|
||||
|
||||
def load_ssl_config():
|
||||
"""Load SSL configuration from file"""
|
||||
@@ -1175,26 +1181,84 @@ def validate_certificate_files(cert_path, key_path):
|
||||
except Exception as e:
|
||||
return False, f"Error reading certificate files: {str(e)}"
|
||||
|
||||
# Verify cert and key match
|
||||
# Parse the complete chain and verify that the private key matches it.
|
||||
try:
|
||||
import subprocess
|
||||
cert_mod = subprocess.run(
|
||||
["openssl", "x509", "-noout", "-modulus", "-in", cert_path],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
key_mod = subprocess.run(
|
||||
["openssl", "rsa", "-noout", "-modulus", "-in", key_path],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if cert_mod.returncode == 0 and key_mod.returncode == 0:
|
||||
if cert_mod.stdout.strip() != key_mod.stdout.strip():
|
||||
return False, "Certificate and key do not match"
|
||||
except Exception:
|
||||
pass # Non-critical, proceed anyway
|
||||
import ssl
|
||||
test_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
test_context.load_cert_chain(cert_path, key_path)
|
||||
except Exception as e:
|
||||
return False, f"Certificate or private key is invalid: {str(e)}"
|
||||
|
||||
return True, "Certificate files are valid"
|
||||
|
||||
|
||||
def _certificate_pair_fingerprint(cert_path, key_path):
|
||||
digest = hashlib.sha256()
|
||||
for path in (cert_path, key_path):
|
||||
with open(path, "rb") as source:
|
||||
for chunk in iter(lambda: source.read(65536), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _build_server_ssl_context(cert_path, key_path):
|
||||
import ssl
|
||||
|
||||
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
context.load_cert_chain(cert_path, key_path)
|
||||
return context
|
||||
|
||||
|
||||
def create_reloadable_ssl_context(cert_path, key_path):
|
||||
"""Create the server context and register it for manual hot reloads."""
|
||||
global _SSL_RUNTIME_CONTEXT
|
||||
global _SSL_RUNTIME_FINGERPRINT
|
||||
global _SSL_RUNTIME_CERT_PATH
|
||||
global _SSL_RUNTIME_KEY_PATH
|
||||
|
||||
context = _build_server_ssl_context(cert_path, key_path)
|
||||
fingerprint = _certificate_pair_fingerprint(cert_path, key_path)
|
||||
|
||||
def _select_active_context(ssl_socket, _server_name, _initial_context):
|
||||
with _SSL_RUNTIME_LOCK:
|
||||
active_context = _SSL_RUNTIME_CONTEXT
|
||||
if active_context is not None and ssl_socket.context is not active_context:
|
||||
ssl_socket.context = active_context
|
||||
|
||||
context.sni_callback = _select_active_context
|
||||
with _SSL_RUNTIME_LOCK:
|
||||
_SSL_RUNTIME_CONTEXT = context
|
||||
_SSL_RUNTIME_FINGERPRINT = fingerprint
|
||||
_SSL_RUNTIME_CERT_PATH = cert_path
|
||||
_SSL_RUNTIME_KEY_PATH = key_path
|
||||
return context
|
||||
|
||||
|
||||
def reload_server_ssl_context(cert_path, key_path):
|
||||
"""Validate and activate a new certificate for subsequent TLS handshakes."""
|
||||
global _SSL_RUNTIME_CONTEXT
|
||||
global _SSL_RUNTIME_FINGERPRINT
|
||||
global _SSL_RUNTIME_CERT_PATH
|
||||
global _SSL_RUNTIME_KEY_PATH
|
||||
|
||||
before_fingerprint = _certificate_pair_fingerprint(cert_path, key_path)
|
||||
replacement = _build_server_ssl_context(cert_path, key_path)
|
||||
after_fingerprint = _certificate_pair_fingerprint(cert_path, key_path)
|
||||
if before_fingerprint != after_fingerprint:
|
||||
raise RuntimeError("Certificate files changed while they were being loaded")
|
||||
|
||||
with _SSL_RUNTIME_LOCK:
|
||||
if _SSL_RUNTIME_CONTEXT is None:
|
||||
raise RuntimeError("The HTTPS runtime is not initialized")
|
||||
changed = after_fingerprint != _SSL_RUNTIME_FINGERPRINT
|
||||
if changed:
|
||||
_SSL_RUNTIME_CONTEXT = replacement
|
||||
_SSL_RUNTIME_FINGERPRINT = after_fingerprint
|
||||
_SSL_RUNTIME_CERT_PATH = cert_path
|
||||
_SSL_RUNTIME_KEY_PATH = key_path
|
||||
return changed
|
||||
|
||||
|
||||
def configure_ssl(cert_path, key_path, source="custom"):
|
||||
"""
|
||||
Configure SSL with given certificate and key paths.
|
||||
|
||||
@@ -249,6 +249,78 @@ def ssl_disable():
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
|
||||
|
||||
@auth_bp.route('/api/ssl/reload', methods=['POST'])
|
||||
@require_auth
|
||||
def ssl_reload():
|
||||
"""Reload the configured certificate without restarting the Monitor."""
|
||||
config = auth_manager.load_ssl_config()
|
||||
if not config.get("enabled"):
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"code": "ssl_not_enabled",
|
||||
"message": "HTTPS is not enabled",
|
||||
}), 400
|
||||
|
||||
source = config.get("source", "custom")
|
||||
cert_info = None
|
||||
if source == "proxmox":
|
||||
detection = auth_manager.detect_proxmox_certificates()
|
||||
if not detection.get("proxmox_available"):
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"code": "certificate_unavailable",
|
||||
"message": "No Proxmox certificate was detected",
|
||||
}), 404
|
||||
cert_path = detection.get("proxmox_cert", "")
|
||||
key_path = detection.get("proxmox_key", "")
|
||||
cert_info = detection.get("cert_info")
|
||||
else:
|
||||
cert_path = config.get("cert_path", "")
|
||||
key_path = config.get("key_path", "")
|
||||
|
||||
valid, validation_message = auth_manager.validate_certificate_files(cert_path, key_path)
|
||||
if not valid:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"code": "certificate_invalid",
|
||||
"message": validation_message,
|
||||
}), 400
|
||||
|
||||
paths_changed = (
|
||||
cert_path != config.get("cert_path", "") or
|
||||
key_path != config.get("key_path", "")
|
||||
)
|
||||
if paths_changed:
|
||||
updated_config = dict(config)
|
||||
updated_config["cert_path"] = cert_path
|
||||
updated_config["key_path"] = key_path
|
||||
if not auth_manager.save_ssl_config(updated_config):
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"code": "config_save_failed",
|
||||
"message": "Failed to save the renewed certificate paths",
|
||||
}), 500
|
||||
|
||||
try:
|
||||
changed = auth_manager.reload_server_ssl_context(cert_path, key_path)
|
||||
except Exception as e:
|
||||
if paths_changed:
|
||||
auth_manager.save_ssl_config(config)
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"code": "runtime_reload_failed",
|
||||
"message": str(e),
|
||||
}), 409
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"changed": changed,
|
||||
"cert_path": cert_path,
|
||||
"key_path": key_path,
|
||||
"cert_info": cert_info,
|
||||
})
|
||||
|
||||
|
||||
def _refresh_pve_webhook_for_ssl_change():
|
||||
"""Helper used by both `ssl_configure` and `ssl_disable`.
|
||||
|
||||
|
||||
@@ -454,6 +454,7 @@ def get_remote_storages():
|
||||
'used': storage.get('used', 0),
|
||||
'available': storage.get('available', 0),
|
||||
'percent': storage.get('percent', 0),
|
||||
'capacity_known': storage.get('capacity_known', storage.get('total', 0) > 0),
|
||||
'exclude_health': exclusion.get('exclude_health', 0) == 1,
|
||||
'exclude_notifications': exclusion.get('exclude_notifications', 0) == 1,
|
||||
'excluded_at': exclusion.get('excluded_at'),
|
||||
|
||||
@@ -79,7 +79,7 @@ from smartctl_resolver import ( # noqa: E402
|
||||
)
|
||||
from flask_script_runner import script_runner
|
||||
import threading
|
||||
from proxmox_storage_monitor import proxmox_storage_monitor
|
||||
from proxmox_storage_monitor import classify_storage_state, proxmox_storage_monitor
|
||||
from flask_terminal_routes import ( # noqa: E402
|
||||
terminal_bp,
|
||||
init_terminal_routes,
|
||||
@@ -5328,25 +5328,14 @@ def _get_proxmox_storage_uncached():
|
||||
used_gb = round(used / (1024**3), 2)
|
||||
available_gb = round(available / (1024**3), 2)
|
||||
|
||||
# Determine storage status. Sprint 11.6: a remote PBS where the
|
||||
# user only has DatastoreAdmin on their own namespace reports
|
||||
# `status=available` + `total=0` — the storage IS reachable, the
|
||||
# ACL just hides the datastore size. Surface as
|
||||
# 'namespace_restricted' so the UI can render INFO instead of
|
||||
# CRITICAL. Real outages still flag (status != available).
|
||||
if total == 0 and status.lower() == "available" and storage_type == 'pbs':
|
||||
storage_status = 'namespace_restricted'
|
||||
elif total == 0:
|
||||
storage_status = 'error'
|
||||
elif status.lower() != "available":
|
||||
storage_status = 'error'
|
||||
else:
|
||||
storage_status = 'active'
|
||||
storage_state = classify_storage_state(storage_type, status, total)
|
||||
|
||||
storage_info = {
|
||||
'name': name,
|
||||
'type': storage_type,
|
||||
'status': storage_status, # Usar el status determinado (active o error)
|
||||
'status': storage_state['status'],
|
||||
'status_detail': storage_state['status_detail'],
|
||||
'capacity_known': storage_state['capacity_known'],
|
||||
'total': total_gb,
|
||||
'used': used_gb,
|
||||
'available': available_gb,
|
||||
@@ -22172,8 +22161,7 @@ if __name__ == '__main__':
|
||||
from gevent import pywsgi
|
||||
import ssl
|
||||
|
||||
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
ssl_context.load_cert_chain(ssl_cert, ssl_key)
|
||||
ssl_context = auth_manager.create_reloadable_ssl_context(ssl_cert, ssl_key)
|
||||
|
||||
# Defensive: silence the ~30-line traceback that gevent
|
||||
# prints whenever a client sends plain HTTP against this
|
||||
@@ -22242,9 +22230,7 @@ if __name__ == '__main__':
|
||||
except ImportError as e:
|
||||
print(f"[ProxMenux] gevent not available ({e})")
|
||||
# Fallback: Flask dev server with SSL - flask-sock handles WebSockets
|
||||
import ssl
|
||||
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
ssl_context.load_cert_chain(ssl_cert, ssl_key)
|
||||
ssl_context = auth_manager.create_reloadable_ssl_context(ssl_cert, ssl_key)
|
||||
print("[ProxMenux] Starting Flask server with SSL (using flask-sock for WebSockets)...")
|
||||
app.run(host='::', port=8008, debug=False, ssl_context=ssl_context, threaded=True)
|
||||
else:
|
||||
|
||||
@@ -19,6 +19,7 @@ from collections import defaultdict
|
||||
import re
|
||||
|
||||
from health_persistence import health_persistence, disk_base_name
|
||||
from proxmox_known_errors import analyze_oom_event, format_oom_diagnosis
|
||||
from smartctl_resolver import (
|
||||
is_usb_disk as resolver_is_usb_disk,
|
||||
probe_smartctl_json,
|
||||
@@ -4027,10 +4028,21 @@ class HealthMonitor:
|
||||
return reason
|
||||
|
||||
# Out of memory
|
||||
if 'out of memory' in line_lower or 'oom_kill' in line_lower:
|
||||
m = re.search(r'Killed process\s+\d+\s+\(([^)]+)\)', line)
|
||||
process = m.group(1) if m else 'unknown'
|
||||
return f'Out of memory - system killed process "{process}" to free RAM'
|
||||
if any(token in line_lower for token in (
|
||||
'out of memory', 'oom_kill', 'oom-kill', 'invoked oom-killer', 'oom_reaper'
|
||||
)):
|
||||
victim = re.search(r'Killed process\s+\d+\s+\(([^)]+)\)', line, re.IGNORECASE)
|
||||
if victim:
|
||||
return f'Memory pressure - kernel killed process "{victim.group(1)}"'
|
||||
|
||||
invoker = re.search(r'(?:kernel:\s*)?([^\s:]+)\s+invoked oom-killer', line, re.IGNORECASE)
|
||||
if invoker:
|
||||
return (
|
||||
f'Memory pressure triggered the OOM killer while "{invoker.group(1)}" '
|
||||
'requested memory; this process is not necessarily the main consumer'
|
||||
)
|
||||
|
||||
return 'Memory pressure triggered the OOM killer; inspect the complete kernel OOM block'
|
||||
|
||||
# Kernel panic
|
||||
if 'kernel panic' in line_lower:
|
||||
@@ -4152,6 +4164,9 @@ class HealthMonitor:
|
||||
if result_recent.returncode == 0:
|
||||
recent_lines = result_recent.stdout.strip().split('\n')
|
||||
previous_lines = result_previous.stdout.strip().split('\n') if result_previous.returncode == 0 else []
|
||||
recent_oom_analysis = analyze_oom_event(result_recent.stdout)
|
||||
recent_oom_reason = format_oom_diagnosis(recent_oom_analysis)
|
||||
processed_oom_patterns = set()
|
||||
|
||||
recent_patterns = defaultdict(int)
|
||||
previous_patterns = defaultdict(int)
|
||||
@@ -4172,7 +4187,22 @@ class HealthMonitor:
|
||||
continue
|
||||
|
||||
# Normalize to a pattern for grouping
|
||||
pattern = self._normalize_log_pattern(line)
|
||||
is_oom_line = any(token in line.lower() for token in (
|
||||
'out of memory', 'oom_kill', 'oom-kill',
|
||||
'invoked oom-killer', 'oom_reaper'
|
||||
))
|
||||
if is_oom_line and recent_oom_analysis:
|
||||
scope = recent_oom_analysis.get('scope') or 'unknown'
|
||||
scope_id = recent_oom_analysis.get('ctid') \
|
||||
or recent_oom_analysis.get('cgroup_path') \
|
||||
or 'unknown'
|
||||
victim = recent_oom_analysis.get('victim_process') or 'unknown'
|
||||
pattern = f'oom_event_{scope}_{scope_id}_{victim}'
|
||||
if pattern in processed_oom_patterns:
|
||||
continue
|
||||
processed_oom_patterns.add(pattern)
|
||||
else:
|
||||
pattern = self._normalize_log_pattern(line)
|
||||
|
||||
if severity == 'CRITICAL':
|
||||
pattern_hash = hashlib.md5(pattern.encode()).hexdigest()[:8]
|
||||
@@ -4213,7 +4243,10 @@ class HealthMonitor:
|
||||
if severity == 'CRITICAL':
|
||||
critical_errors_found[pattern] = line
|
||||
# Build a human-readable reason from the raw log line
|
||||
enriched_reason = self._enrich_critical_log_reason(line)
|
||||
if is_oom_line and recent_oom_reason:
|
||||
enriched_reason = recent_oom_reason
|
||||
else:
|
||||
enriched_reason = self._enrich_critical_log_reason(line)
|
||||
|
||||
# Append SMART context to the reason if we checked it
|
||||
if smart_status_for_log == 'PASSED':
|
||||
@@ -4230,9 +4263,13 @@ class HealthMonitor:
|
||||
category='logs',
|
||||
severity=severity,
|
||||
reason=enriched_reason,
|
||||
details={'pattern': pattern, 'raw_line': line[:200],
|
||||
'smart_status': smart_status_for_log,
|
||||
'dismissable': True}
|
||||
details={
|
||||
'pattern': pattern,
|
||||
'raw_line': line[:200],
|
||||
'smart_status': smart_status_for_log,
|
||||
'oom_analysis': recent_oom_analysis if is_oom_line else None,
|
||||
'dismissable': True,
|
||||
}
|
||||
)
|
||||
|
||||
# Cross-reference: filesystem errors also belong in the disks category
|
||||
|
||||
@@ -25,6 +25,8 @@ from queue import Queue
|
||||
from typing import Optional, Dict, Any, Tuple, Callable
|
||||
from pathlib import Path
|
||||
|
||||
from proxmox_known_errors import analyze_oom_event, format_oom_diagnosis
|
||||
|
||||
|
||||
# ─── Shared State for Cross-Watcher Coordination ──────────────────
|
||||
|
||||
@@ -489,6 +491,12 @@ class JournalWatcher:
|
||||
self._recent_events: Dict[str, float] = {}
|
||||
self._dedup_window = 30 # seconds
|
||||
|
||||
# Linux emits an OOM diagnosis as a multi-line kernel block. Buffer it
|
||||
# until the authoritative `Killed process` line arrives so the alert
|
||||
# can distinguish a host OOM from a memory-cgroup/LXC limit.
|
||||
self._oom_lines = []
|
||||
self._oom_started_at = 0.0
|
||||
|
||||
# 24h anti-cascade for disk I/O + filesystem errors. The dict
|
||||
# key includes a tier suffix (`sdh:warning`, `sdh:critical`)
|
||||
# so a disk in WARNING cooldown can still escalate to CRITICAL
|
||||
@@ -830,6 +838,57 @@ class JournalWatcher:
|
||||
# Only process messages from kernel or systemd (not app-level logs)
|
||||
if syslog_id and syslog_id not in ('kernel', 'systemd', 'systemd-coredump', ''):
|
||||
return
|
||||
|
||||
now = time.time()
|
||||
if self._oom_lines and now - self._oom_started_at > 15:
|
||||
self._oom_lines = []
|
||||
self._oom_started_at = 0.0
|
||||
|
||||
starts_oom_block = bool(re.search(
|
||||
r'invoked oom-killer|oom-kill:constraint=', msg, re.IGNORECASE
|
||||
))
|
||||
ends_oom_block = bool(re.search(
|
||||
r'(?:Memory cgroup )?Out of memory:\s+Killed process', msg, re.IGNORECASE
|
||||
))
|
||||
|
||||
if starts_oom_block and not self._oom_lines:
|
||||
self._oom_lines = [msg]
|
||||
self._oom_started_at = now
|
||||
return
|
||||
|
||||
if self._oom_lines:
|
||||
self._oom_lines.append(msg)
|
||||
if len(self._oom_lines) > 500:
|
||||
self._oom_lines = self._oom_lines[-500:]
|
||||
|
||||
if ends_oom_block:
|
||||
analysis = analyze_oom_event('\n'.join(self._oom_lines))
|
||||
reason = format_oom_diagnosis(analysis)
|
||||
if not reason:
|
||||
reason = f'Out of memory killer activated\n{msg[:300]}'
|
||||
|
||||
ctid = analysis.get('ctid') if analysis else ''
|
||||
victim = analysis.get('victim_process') if analysis else ''
|
||||
entity_id = f'lxc_{ctid}' if ctid else f'oom_{victim or "unknown"}'
|
||||
self._emit(
|
||||
'system_problem',
|
||||
'CRITICAL',
|
||||
{
|
||||
'reason': reason,
|
||||
'hostname': self._hostname,
|
||||
'oom_analysis': analysis or {},
|
||||
},
|
||||
entity='node',
|
||||
entity_id=entity_id,
|
||||
)
|
||||
self._oom_lines = []
|
||||
self._oom_started_at = 0.0
|
||||
return
|
||||
|
||||
# `Call Trace:` is part of the buffered OOM evidence, not a second
|
||||
# independent kernel fault requiring another notification.
|
||||
if re.search(r'^Call Trace:', msg, re.IGNORECASE):
|
||||
return
|
||||
|
||||
# Filter out normal kernel messages that are NOT problems
|
||||
_KERNEL_NOISE = [
|
||||
|
||||
@@ -18,6 +18,121 @@ Each entry includes:
|
||||
import re
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
|
||||
def analyze_oom_event(text: str) -> Optional[Dict[str, Any]]:
|
||||
"""Extract the scope and victim from a complete Linux OOM block.
|
||||
|
||||
The process on the ``invoked oom-killer`` line is only the allocation
|
||||
trigger. The authoritative scope is carried by ``constraint`` and
|
||||
``oom_memcg``; the actual victim is carried by ``Killed process``.
|
||||
"""
|
||||
if not text or not re.search(
|
||||
r'invoked oom-killer|oom-kill:|memory cgroup out of memory|out of memory: killed process',
|
||||
text,
|
||||
re.IGNORECASE,
|
||||
):
|
||||
return None
|
||||
|
||||
result: Dict[str, Any] = {
|
||||
'scope': 'unknown',
|
||||
'constraint': '',
|
||||
'cgroup_path': '',
|
||||
'ctid': '',
|
||||
'invoker': '',
|
||||
'victim_process': '',
|
||||
'victim_pid': '',
|
||||
'memory_usage_kib': None,
|
||||
'memory_limit_kib': None,
|
||||
'swap_usage_kib': None,
|
||||
'swap_limit_kib': None,
|
||||
}
|
||||
|
||||
constraint = re.search(r'constraint=([A-Z0-9_]+)', text, re.IGNORECASE)
|
||||
if constraint:
|
||||
result['constraint'] = constraint.group(1).upper()
|
||||
|
||||
cgroup = re.search(r'oom_memcg=([^,\s]+)', text, re.IGNORECASE)
|
||||
if not cgroup:
|
||||
cgroup = re.search(r'Memory cgroup stats for\s+([^:\s]+)', text, re.IGNORECASE)
|
||||
if cgroup:
|
||||
result['cgroup_path'] = cgroup.group(1)
|
||||
|
||||
ctid = re.search(r'/lxc/(\d+)\b', result['cgroup_path'] or text, re.IGNORECASE)
|
||||
if ctid:
|
||||
result['ctid'] = ctid.group(1)
|
||||
result['scope'] = 'lxc'
|
||||
elif result['constraint'] == 'CONSTRAINT_MEMCG' or re.search(
|
||||
r'memory cgroup out of memory', text, re.IGNORECASE
|
||||
):
|
||||
result['scope'] = 'memory_cgroup'
|
||||
elif result['constraint']:
|
||||
result['scope'] = 'host'
|
||||
|
||||
invoker = re.search(r'\b([A-Za-z0-9_.+/-]+)\s+invoked oom-killer', text, re.IGNORECASE)
|
||||
if invoker:
|
||||
result['invoker'] = invoker.group(1)
|
||||
|
||||
victim = re.search(r'Killed process\s+(\d+)\s+\(([^)]+)\)', text, re.IGNORECASE)
|
||||
if victim:
|
||||
result['victim_pid'] = victim.group(1)
|
||||
result['victim_process'] = victim.group(2)
|
||||
|
||||
memory = re.search(
|
||||
r'memory:\s+usage\s+(\d+)kB,\s+limit\s+(\d+)kB', text, re.IGNORECASE
|
||||
)
|
||||
if memory:
|
||||
result['memory_usage_kib'] = int(memory.group(1))
|
||||
result['memory_limit_kib'] = int(memory.group(2))
|
||||
|
||||
swap = re.search(
|
||||
r'swap:\s+usage\s+(\d+)kB,\s+limit\s+(\d+)kB', text, re.IGNORECASE
|
||||
)
|
||||
if swap:
|
||||
result['swap_usage_kib'] = int(swap.group(1))
|
||||
result['swap_limit_kib'] = int(swap.group(2))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def format_oom_diagnosis(analysis: Optional[Dict[str, Any]]) -> str:
|
||||
"""Return a concise evidence-based diagnosis for an OOM analysis."""
|
||||
if not analysis:
|
||||
return ''
|
||||
|
||||
lines: List[str] = []
|
||||
scope = analysis.get('scope')
|
||||
ctid = analysis.get('ctid')
|
||||
if scope == 'lxc' and ctid:
|
||||
lines.append(f'OOM scope: LXC {ctid} memory cgroup (not a host-wide OOM)')
|
||||
elif scope == 'memory_cgroup':
|
||||
path = analysis.get('cgroup_path') or 'unknown cgroup'
|
||||
lines.append(f'OOM scope: memory cgroup {path} (not a host-wide OOM)')
|
||||
elif scope == 'host':
|
||||
lines.append('OOM scope: host/kernel memory scope')
|
||||
else:
|
||||
lines.append('OOM scope: not established from the available log lines')
|
||||
|
||||
usage = analysis.get('memory_usage_kib')
|
||||
limit = analysis.get('memory_limit_kib')
|
||||
if usage is not None and limit is not None:
|
||||
lines.append(f'Cgroup memory: {usage / 1024:.1f} MiB used of {limit / 1024:.1f} MiB')
|
||||
|
||||
swap_usage = analysis.get('swap_usage_kib')
|
||||
swap_limit = analysis.get('swap_limit_kib')
|
||||
if swap_usage is not None and swap_limit is not None:
|
||||
lines.append(f'Cgroup swap: {swap_usage / 1024:.1f} MiB used of {swap_limit / 1024:.1f} MiB')
|
||||
|
||||
victim = analysis.get('victim_process')
|
||||
victim_pid = analysis.get('victim_pid')
|
||||
if victim:
|
||||
lines.append(f'Killed process: {victim}' + (f' (PID {victim_pid})' if victim_pid else ''))
|
||||
|
||||
invoker = analysis.get('invoker')
|
||||
if invoker and invoker != victim:
|
||||
lines.append(f'Allocation trigger: {invoker} (not necessarily the largest consumer)')
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
# Known error patterns with causes and solutions
|
||||
PROXMOX_KNOWN_ERRORS: List[Dict[str, Any]] = [
|
||||
# ==================== SUBSCRIPTION/LICENSE ====================
|
||||
@@ -169,11 +284,11 @@ PROXMOX_KNOWN_ERRORS: List[Dict[str, Any]] = [
|
||||
},
|
||||
{
|
||||
"pattern": r"out of memory|OOM.*kill|cannot allocate memory|memory.*exhausted",
|
||||
"cause": "System or VM ran out of memory",
|
||||
"cause_detailed": "The Linux OOM (Out Of Memory) killer terminated a process to free memory. This indicates memory pressure from overcommitment or memory leaks.",
|
||||
"cause": "The kernel invoked the OOM killer under memory pressure",
|
||||
"cause_detailed": "Linux could not satisfy a memory allocation in the relevant host, cgroup, cpuset or NUMA scope. The process named as having invoked the OOM killer only triggered the allocation; it is not necessarily the largest consumer or the process that was killed. The complete OOM block is required to identify the scope, victim and likely cause.",
|
||||
"severity": "critical",
|
||||
"solution": "Increase memory allocation or reduce VM memory usage",
|
||||
"solution_detailed": "1. Check what was killed: dmesg | grep -i oom\n2. Review memory usage: free -h\n3. Check balloon driver status for VMs\n4. Consider adding swap or RAM\n5. Review VM memory allocations for overcommitment",
|
||||
"solution": "Inspect the complete OOM event and current host/cgroup memory before changing allocations",
|
||||
"solution_detailed": "1. Read the complete kernel OOM block, including 'Killed process', 'Mem-Info' and task rows\n2. Determine whether it was a host-wide or memory-cgroup OOM\n3. Review free -h, swap, CommitLimit/Committed_AS and active VM/LXC allocations\n4. On ZFS hosts, compare ARC size and c_max with the configured zfs_arc_max\n5. Adjust the confirmed consumer, ARC cap or workload only after identifying the exhausted scope",
|
||||
"category": "memory"
|
||||
},
|
||||
|
||||
@@ -316,6 +431,10 @@ def get_error_context(text: str, category: Optional[str] = None, detail_level: s
|
||||
error = find_matching_error(text, category)
|
||||
if not error:
|
||||
return None
|
||||
|
||||
oom_diagnosis = ''
|
||||
if error.get('category') == 'memory':
|
||||
oom_diagnosis = format_oom_diagnosis(analyze_oom_event(text))
|
||||
|
||||
# NOTE: we intentionally do NOT emit a "Severity:" line here.
|
||||
# The catalogue's severity is the *typical* severity of a class
|
||||
@@ -329,7 +448,10 @@ def get_error_context(text: str, category: Optional[str] = None, detail_level: s
|
||||
# carried by the notification's own severity field; repeating a
|
||||
# different value here is noise at best, misinformation at worst.
|
||||
if detail_level == "minimal":
|
||||
return f"Known issue: {error['cause']}"
|
||||
result = f"Known issue: {error['cause']}"
|
||||
if oom_diagnosis:
|
||||
result += f"\n{oom_diagnosis}"
|
||||
return result
|
||||
|
||||
elif detail_level == "standard":
|
||||
lines = [
|
||||
@@ -339,6 +461,9 @@ def get_error_context(text: str, category: Optional[str] = None, detail_level: s
|
||||
]
|
||||
if error.get("url"):
|
||||
lines.append(f" Docs: {error['url']}")
|
||||
if oom_diagnosis:
|
||||
lines.append(" Event analysis:")
|
||||
lines.extend(f" {line}" for line in oom_diagnosis.splitlines())
|
||||
return "\n".join(lines)
|
||||
|
||||
else: # detailed
|
||||
@@ -349,6 +474,9 @@ def get_error_context(text: str, category: Optional[str] = None, detail_level: s
|
||||
]
|
||||
if error.get("url"):
|
||||
lines.append(f" Documentation: {error['url']}")
|
||||
if oom_diagnosis:
|
||||
lines.append(" Event analysis:")
|
||||
lines.extend(f" {line}" for line in oom_diagnosis.splitlines())
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
|
||||
@@ -12,6 +12,33 @@ 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"""
|
||||
|
||||
@@ -177,28 +204,13 @@ class ProxmoxStorageMonitor:
|
||||
'percent': round(percent, 2),
|
||||
'node': node
|
||||
}
|
||||
|
||||
# Check if storage is available.
|
||||
#
|
||||
# "jc-pbs-friendly" mode (Sprint 11.6): a remote PBS where
|
||||
# the user only has DatastoreAdmin on their own namespace
|
||||
# reports `status=available` + `total=0` — the storage IS
|
||||
# reachable, the user just can't list the datastore size.
|
||||
# Treat that combination as INFO (namespace-restricted)
|
||||
# instead of CRITICAL so we don't spam the operator with
|
||||
# "almacenamiento no disponible" every poll. Real outages
|
||||
# still flag because they come back with `status != available`.
|
||||
if total == 0 and status.lower() == "available" and storage_type == 'pbs':
|
||||
storage_info['status'] = 'namespace_restricted'
|
||||
storage_info['status_detail'] = 'namespace_restricted'
|
||||
|
||||
state = classify_storage_state(storage_type, status, total)
|
||||
storage_info.update(state)
|
||||
if state['status'] in ('active', 'namespace_restricted'):
|
||||
available_storages.append(storage_info)
|
||||
elif total == 0 or status.lower() != "available":
|
||||
storage_info['status'] = 'error'
|
||||
storage_info['status_detail'] = 'unavailable' if total == 0 else status
|
||||
unavailable_storages.append(storage_info)
|
||||
else:
|
||||
storage_info['status'] = 'active'
|
||||
available_storages.append(storage_info)
|
||||
unavailable_storages.append(storage_info)
|
||||
|
||||
# Check for configured storages that are completely missing
|
||||
for storage_name, storage_config in self.configured_storages.items():
|
||||
@@ -212,6 +224,7 @@ class ProxmoxStorageMonitor:
|
||||
'used': 0,
|
||||
'available': 0,
|
||||
'percent': 0,
|
||||
'capacity_known': False,
|
||||
'node': local_node
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user