diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index 0df590f5..bb4dc9ca 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -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() # ------------------------------------------------------------------- diff --git a/AppImage/scripts/tests/test_proxmox_node_name.py b/AppImage/scripts/tests/test_proxmox_node_name.py new file mode 100644 index 00000000..9812a107 --- /dev/null +++ b/AppImage/scripts/tests/test_proxmox_node_name.py @@ -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()