diff --git a/AppImage/components/virtual-machines.tsx b/AppImage/components/virtual-machines.tsx index 3459b0d4..fe19cce1 100644 --- a/AppImage/components/virtual-machines.tsx +++ b/AppImage/components/virtual-machines.tsx @@ -3967,11 +3967,11 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
↓ - {((selectedVM.diskread || 0) / 1024 ** 2).toFixed(2)} MB + {formatBytes(selectedVM.diskread, false)}
↑ - {((selectedVM.diskwrite || 0) / 1024 ** 2).toFixed(2)} MB + {formatBytes(selectedVM.diskwrite, false)}
diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index dd65457c..789e34d4 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -1913,6 +1913,35 @@ def _vm_cache_put(cache: dict, vmid: int, value) -> None: with _vm_modal_cache_lock: cache[vmid] = (time.time(), value) +# The details and mount-point caches come from the guest's .conf, and an edit +# made from the Proxmox interface or `pct/qm set` raises no event the Monitor +# sees. Each entry remembers the .conf it was built from and is rebuilt when +# the file changes. +_vm_conf_built: dict = {} # (cache name, vmid) -> .conf signature + +def _vm_conf_signature(vmid: int): + for path in (f'/etc/pve/lxc/{int(vmid)}.conf', f'/etc/pve/qemu-server/{int(vmid)}.conf'): + try: + info = os.stat(path) + except OSError: + continue + return (info.st_mtime_ns, info.st_size) + return None + +def _vm_conf_cache_get(cache: dict, name: str, vmid: int, ttl: int): + """Cached payload for vmid only while its .conf is unchanged.""" + cached = _vm_cache_get(cache, vmid, ttl) + if cached is None: + return None + with _vm_modal_cache_lock: + built = _vm_conf_built.get((name, vmid)) + return cached if built == _vm_conf_signature(vmid) else None + +def _vm_conf_cache_put(cache: dict, name: str, vmid: int, value, signature) -> None: + _vm_cache_put(cache, vmid, value) + with _vm_modal_cache_lock: + _vm_conf_built[(name, vmid)] = signature + def _vm_cache_invalidate(vmid: int, *caches) -> None: """Drop this vmid's entries from the given caches. With no argument hits every per-VM modal cache — used by write actions that could @@ -15214,7 +15243,7 @@ def api_vms_modal_cache_all(): entry = { 'vmid': vmid, 'type': vm_type, - 'details': _vm_cache_get(_vm_details_cache, vmid, _VM_DETAILS_TTL), + 'details': _vm_conf_cache_get(_vm_details_cache, 'details', vmid, _VM_DETAILS_TTL), 'backups': _vm_cache_get(_vm_backups_cache, vmid, _VM_BACKUPS_TTL), } if vm_type == 'lxc': @@ -15225,7 +15254,7 @@ def api_vms_modal_cache_all(): _VM_APP_SUGGESTIONS_TTL, ) entry['schedule'] = _vm_cache_get(_vm_schedule_cache, vmid, _VM_SCHEDULE_TTL) - entry['mount_points'] = _vm_cache_get(_vm_mounts_cache, vmid, _VM_MOUNTS_TTL) + entry['mount_points'] = _vm_conf_cache_get(_vm_mounts_cache, 'mounts', vmid, _VM_MOUNTS_TTL) guests.append(entry) return jsonify({'guests': guests, 'ts': int(time.time())}) except Exception as e: @@ -15238,7 +15267,9 @@ def api_vms_modal_cache_all(): def get_vm_config(vmid): """Get detailed configuration for a specific VM/LXC""" try: - cached = _vm_cache_get(_vm_details_cache, vmid, _VM_DETAILS_TTL) + # Taken before reading the .conf, so an edit during the build is seen next time. + signature = _vm_conf_signature(vmid) + cached = _vm_conf_cache_get(_vm_details_cache, 'details', vmid, _VM_DETAILS_TTL) if cached is not None: return jsonify(cached) @@ -15308,7 +15339,7 @@ def get_vm_config(vmid): if hardware_info: response_data['hardware_info'] = hardware_info - _vm_cache_put(_vm_details_cache, vmid, response_data) + _vm_conf_cache_put(_vm_details_cache, 'details', vmid, response_data, signature) return jsonify(response_data) except Exception as e: @@ -15328,7 +15359,8 @@ def api_lxc_mount_points(vmid): start/stop of the guest, since config-visible fields normally only change through a guest reboot). The runtime endpoint is NEVER cached — it must reflect the live state at click time.""" - cached = _vm_cache_get(_vm_mounts_cache, vmid, _VM_MOUNTS_TTL) + signature = _vm_conf_signature(vmid) + cached = _vm_conf_cache_get(_vm_mounts_cache, 'mounts', vmid, _VM_MOUNTS_TTL) if cached is not None: return jsonify(cached) try: @@ -15339,7 +15371,7 @@ def api_lxc_mount_points(vmid): result = lxc_mount_points.get_lxc_mount_points_static(str(vmid)) if not result.get("ok"): return jsonify(result), 400 - _vm_cache_put(_vm_mounts_cache, vmid, result) + _vm_conf_cache_put(_vm_mounts_cache, 'mounts', vmid, result, signature) return jsonify(result) except Exception as e: return jsonify({"ok": False, "error": str(e)}), 500 diff --git a/AppImage/scripts/tests/test_vm_conf_cache.py b/AppImage/scripts/tests/test_vm_conf_cache.py new file mode 100644 index 00000000..32c1a11b --- /dev/null +++ b/AppImage/scripts/tests/test_vm_conf_cache.py @@ -0,0 +1,60 @@ +"""The VM/LXC modal caches notice a .conf edited outside the Monitor.""" +import ast +from pathlib import Path +import threading +import time +from types import SimpleNamespace +import unittest + +SERVER = Path(__file__).resolve().parents[1] / 'flask_server.py' +NAMES = ('_vm_cache_get', '_vm_cache_put', '_vm_conf_signature', '_vm_conf_cache_get', '_vm_conf_cache_put') + + +class ConfAwareCacheTests(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 in NAMES] + self.assertEqual(sorted(n.name for n in nodes), sorted(NAMES)) + self.files = {'/etc/pve/lxc/111.conf': (1, 300)} + + def stat(path): + if path not in self.files: + raise OSError(path) + mtime, size = self.files[path] + return SimpleNamespace(st_mtime_ns=mtime, st_size=size) + + self.scope = {'os': SimpleNamespace(stat=stat), 'time': time, '_vm_modal_cache_lock': threading.Lock(), + '_vm_conf_built': {}} + exec(compile(ast.Module(body=nodes, type_ignores=[]), str(SERVER), 'exec'), self.scope) + self.cache = {} + + def build(self, value): + signature = self.scope['_vm_conf_signature'](111) + self.scope['_vm_conf_cache_put'](self.cache, 'details', 111, value, signature) + + def get(self): + return self.scope['_vm_conf_cache_get'](self.cache, 'details', 111, 10 ** 9) + + def test_unchanged_conf_serves_the_cache(self): + self.build({'onboot': 1}) + self.assertEqual(self.get(), {'onboot': 1}) + + def test_conf_edited_outside_the_monitor_drops_the_entry(self): + self.build({'onboot': 1}) + self.files['/etc/pve/lxc/111.conf'] = (2, 300) # `pct set 111 --onboot 0` + self.assertIsNone(self.get()) + self.build({'onboot': 0}) + self.assertEqual(self.get(), {'onboot': 0}) + + def test_entry_without_a_recorded_conf_is_rebuilt(self): + self.scope['_vm_cache_put'](self.cache, 111, {'old': True}) + self.assertIsNone(self.get()) + + def test_vm_conf_path_and_missing_guest(self): + self.files = {'/etc/pve/qemu-server/200.conf': (5, 10)} + self.assertEqual(self.scope['_vm_conf_signature'](200), (5, 10)) + self.assertIsNone(self.scope['_vm_conf_signature'](999)) + + +if __name__ == '__main__': + unittest.main()