fix(monitor): read the node name without waiting for the Proxmox API

This commit is contained in:
MacRimi
2026-10-12 10:59:26 +02:00
parent 5f3729568e
commit 1ac1f58afa
2 changed files with 75 additions and 48 deletions
+14 -48
View File
@@ -113,16 +113,17 @@ logging.basicConfig(
# -------------------------------------------------------------------
_PROXMOX_NODE_CACHE = {"name": None, "timestamp": 0.0}
_PROXMOX_NODE_CACHE_TTL = 300 # seconds (5 minutes)
_PVE_LOCAL_NODE_LINK = "/etc/pve/local"
def get_proxmox_node_name() -> str:
"""
Retrieve the real Proxmox node name for the LOCAL node.
The name of the LOCAL node in Proxmox.
- First tries reading from: `pvesh get /nodes`
- In a cluster, matches the local hostname against the node list
- Uses an in-memory cache to avoid repeated API calls
- Falls back to the short hostname if the API call fails
`/etc/pve/local` is a link to the folder of the local node, on a single
host and on every node of a cluster, so the name is read from it without
asking the API. The short hostname is used while `/etc/pve` is not
mounted; that answer is not kept, so the link is read again next time.
"""
now = time.time()
cached_name = _PROXMOX_NODE_CACHE.get("name")
@@ -132,52 +133,17 @@ def get_proxmox_node_name() -> str:
if cached_name and (now - float(cached_ts)) < _PROXMOX_NODE_CACHE_TTL:
return str(cached_name)
# Get local hostname for matching
local_hostname = socket.gethostname().split(".", 1)[0].lower()
# Try Proxmox API
try:
result = subprocess.run(
["pvesh", "get", "/nodes", "--output-format", "json"],
capture_output=True,
text=True,
timeout=5,
check=False,
)
if result.returncode == 0 and result.stdout:
nodes = json.loads(result.stdout)
if isinstance(nodes, list) and nodes:
# In a cluster, find the node that matches local hostname
# Node names in Proxmox typically match the hostname
for node_info in nodes:
node_name = node_info.get("node", "")
if node_name.lower() == local_hostname:
_PROXMOX_NODE_CACHE["name"] = node_name
_PROXMOX_NODE_CACHE["timestamp"] = now
return node_name
# If no exact match, try partial match (hostname might be truncated)
for node_info in nodes:
node_name = node_info.get("node", "")
if local_hostname.startswith(node_name.lower()) or node_name.lower().startswith(local_hostname):
_PROXMOX_NODE_CACHE["name"] = node_name
_PROXMOX_NODE_CACHE["timestamp"] = now
return node_name
# Last resort: if single node cluster, use that node
if len(nodes) == 1:
node_name = nodes[0].get("node")
if node_name:
_PROXMOX_NODE_CACHE["name"] = node_name
_PROXMOX_NODE_CACHE["timestamp"] = now
return node_name
except Exception as exc:
logger.warning("Failed to get Proxmox node name from API: %s", exc)
node_name = os.path.basename(os.readlink(_PVE_LOCAL_NODE_LINK))
except OSError:
node_name = ""
if re.fullmatch(r"[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?", node_name):
_PROXMOX_NODE_CACHE["name"] = node_name
_PROXMOX_NODE_CACHE["timestamp"] = now
return node_name
# Fallback: short hostname (without domain)
return local_hostname
return socket.gethostname().split(".", 1)[0].lower()
# -------------------------------------------------------------------
@@ -0,0 +1,61 @@
"""The name of the local node is read from the link Proxmox keeps for it, so
finding it out never waits for the API, however slow the host."""
import ast
import os
from pathlib import Path
import re
import socket
import tempfile
import time
import unittest
SERVER = Path(__file__).resolve().parents[1] / 'flask_server.py'
class NodeNameTests(unittest.TestCase):
def setUp(self):
tree = ast.parse(SERVER.read_text(encoding='utf-8'))
nodes = [n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name == 'get_proxmox_node_name']
self.assertEqual(len(nodes), 1)
self.source = ast.get_source_segment(SERVER.read_text(encoding='utf-8'), nodes[0])
folder = tempfile.TemporaryDirectory()
self.addCleanup(folder.cleanup)
self.link = os.path.join(folder.name, 'local')
def no_subprocess(*args, **kwargs):
raise AssertionError('the node name must not run a command')
self.scope = {'os': os, 're': re, 'socket': socket, 'time': time,
'subprocess': type('S', (), {'run': staticmethod(no_subprocess)}),
'_PROXMOX_NODE_CACHE': {'name': None, 'timestamp': 0.0}, '_PROXMOX_NODE_CACHE_TTL': 300,
'_PVE_LOCAL_NODE_LINK': self.link}
exec(compile(ast.Module(body=nodes, type_ignores=[]), str(SERVER), 'exec'), self.scope)
self.name = self.scope['get_proxmox_node_name']
def test_the_name_is_the_folder_the_link_points_to(self):
os.symlink('nodes/Hades-01', self.link)
self.assertEqual(self.name(), 'Hades-01')
def test_the_api_is_not_asked(self):
self.assertNotIn('pvesh', self.source)
self.assertNotIn('subprocess', self.source)
def test_without_the_link_the_hostname_is_used_and_not_kept(self):
hostname = socket.gethostname().split('.', 1)[0].lower()
self.assertEqual(self.name(), hostname)
os.symlink('nodes/pve2', self.link)
self.assertEqual(self.name(), 'pve2')
def test_the_answer_is_kept_once_read(self):
os.symlink('nodes/pve1', self.link)
self.assertEqual(self.name(), 'pve1')
os.unlink(self.link)
self.assertEqual(self.name(), 'pve1')
def test_a_link_that_names_no_node_is_not_taken(self):
os.symlink('nodes/bad name', self.link)
self.assertEqual(self.name(), socket.gethostname().split('.', 1)[0].lower())
if __name__ == '__main__':
unittest.main()