Monitor: forget the App tab of a destroyed guest

This commit is contained in:
MacRimi
2026-10-26 18:02:30 +01:00
parent fc7c3181bc
commit b6500c147e
6 changed files with 171 additions and 0 deletions
+26
View File
@@ -2302,6 +2302,27 @@ def _handle_guest_lifecycle(vmid: str, vm_type: str, action: str) -> None:
return
if action == 'stop':
_publish_guest_modal_cache_revision(guest_id)
if action == 'destroy' and not _oci_operation_running(guest_id):
# The VMID can be reused by a new guest that must not inherit the
# applications and scheduled updates of this one. An OCI update
# destroys and recreates its own container: it keeps them.
try:
import lxc_apps
lxc_apps.forget_guest(guest_id)
_vm_cache_invalidate(guest_id, _vm_schedule_cache)
except Exception as exc:
print(f'[ProxMenux] could not forget the applications of {guest_type} {guest_id}: {exc}', flush=True)
_publish_guest_modal_cache_revision(guest_id)
def _oci_operation_running(guest_id: int) -> bool:
"""An OCI operation on the guest is running or ended moments ago."""
try:
with open(f'/run/proxmenux/oci-operations/{int(guest_id)}', encoding='utf-8') as handle:
mark = json.load(handle)
return mark.get('ended') is None or time.time() - float(mark['ended']) < 120
except (OSError, ValueError, TypeError, KeyError):
return False
# Filesystem types that never count towards VM disk usage:
# read-only image / CD formats, ram-backed pseudo-fs, kernel virtual
@@ -12424,6 +12445,11 @@ def _vm_modal_prewarmer_pass():
disappears until something genuinely expires."""
from flask import g as _flask_g
warmed = 0
try:
import lxc_apps
lxc_apps.forget_missing_guests()
except Exception as exc:
print(f'[ProxMenux] could not check the applications of removed guests: {exc}', flush=True)
resources = get_cached_pvesh_cluster_resources_vm() or []
for r in resources:
vmid = r.get('vmid')
+49
View File
@@ -28,6 +28,7 @@ from __future__ import annotations
import datetime
import copy
import concurrent.futures
import glob
import hashlib
import ipaddress
import json
@@ -48,6 +49,7 @@ from typing import Any, Optional
import local_app_icons
_APPS_DIR = "/etc/proxmenux/apps"
_PVE_NODES = "/etc/pve/nodes"
_PCT_BIN = "/usr/sbin/pct"
_PROBE_TIMEOUT_SEC = 15
_GITHUB_TIMEOUT_SEC = 15
@@ -3517,6 +3519,49 @@ def delete_app(vmid, app_id: str) -> bool:
return True
def forget_guest(vmid) -> None:
"""A destroyed guest takes its registrations with it, scheduled updates
included, so a new guest that reuses the VMID starts with nothing."""
key = str(int(vmid))
path = _sidecar_path(vmid)
with _cache_lock:
try:
os.unlink(path)
except FileNotFoundError:
pass
except OSError as exc:
print(f"[ProxMenux] lxc_apps: could not forget CT {key}: {exc}")
with _sidecar_cache_lock:
_sidecar_cache.pop(path, None)
dismissed = _oci_dismissed()
if dismissed.pop(key, None) is not None:
_save_oci_dismissed(dismissed)
with _docker_inventory_lock:
_docker_inventory_cache.pop(key, None)
for cache in (_detected_apps_cache, _port_probe_cache, _docker_web_links_cache):
cache.pop(key, None)
for probe in [probe for probe in list(_file_probe_cache) if probe[0] == key]:
_file_probe_cache.pop(probe, None)
def forget_missing_guests() -> None:
"""Registrations of guests that no node of the cluster has any more,
removed while the Monitor was not running."""
if not os.path.isdir(_PVE_NODES):
return
try:
names = os.listdir(_APPS_DIR)
except OSError:
return
for name in names:
vmid = name[:-5] if name.endswith(".json") else ""
if not vmid.isdigit():
continue
if not (glob.glob(f"{_PVE_NODES}/*/lxc/{vmid}.conf")
or glob.glob(f"{_PVE_NODES}/*/qemu-server/{vmid}.conf")):
forget_guest(vmid)
def delete_all(vmid) -> bool:
sidecar = _read_sidecar(vmid) or {}
if any(a.get("installed_via") == "oci_image" for a in sidecar.get("apps") or []):
@@ -5654,6 +5699,10 @@ def _dismiss_oci_registration(vmid) -> None:
return
data = _oci_dismissed()
data[str(int(vmid))] = record["installation_id"]
_save_oci_dismissed(data)
def _save_oci_dismissed(data) -> None:
_ensure_dir()
tmp = f"{_OCI_DISMISSED_FILE}.tmp.{os.getpid()}"
try:
+12
View File
@@ -2423,6 +2423,18 @@ class TaskWatcher:
# Get VM/CT name
vmname = self._get_vm_name(vmid) if vmid else ''
# A destroyed guest frees its VMID. It has no notification of its
# own, but the cache listener must forget what belonged to it.
if task_type in ('vzdestroy', 'qmdestroy'):
if status == 'OK' and self._guest_lifecycle_callback:
guest_type = 'lxc' if task_type == 'vzdestroy' else 'qemu'
try:
self._guest_lifecycle_callback(vmid, guest_type, 'destroy')
except Exception as exc:
print(f'[TaskWatcher] guest lifecycle callback failed for '
f'{guest_type} {vmid}: {exc}', flush=True)
return
# Map to event type
event_info = self.TASK_MAP.get(task_type)
if not event_info:
@@ -0,0 +1,82 @@
"""A destroyed guest takes its App registrations with it, so a new guest
that reuses the VMID does not inherit them."""
import json
import os
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
if str(SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPTS_DIR))
import lxc_apps
class ForgetDestroyedGuestTests(unittest.TestCase):
def setUp(self):
folder = tempfile.TemporaryDirectory()
self.addCleanup(folder.cleanup)
self.root = Path(folder.name)
self.apps = self.root / "apps"
self.nodes = self.root / "nodes"
self.apps.mkdir()
for target, value in (("_APPS_DIR", str(self.apps)), ("_PVE_NODES", str(self.nodes)),
("_OCI_DISMISSED_FILE", str(self.apps / ".oci-dismissed.json"))):
patcher = mock.patch.object(lxc_apps, target, value)
patcher.start()
self.addCleanup(patcher.stop)
def register(self, vmid):
(self.apps / f"{vmid}.json").write_text(json.dumps({
"vmid": vmid, "apps": [{"id": "portainer", "name": "Portainer"}],
"schedule": {"enabled": True, "target": "both"}}))
def guest(self, vmid, kind="lxc", node="pve1"):
folder = self.nodes / node / kind
folder.mkdir(parents=True, exist_ok=True)
(folder / f"{vmid}.conf").write_text("arch: amd64\n")
def test_a_destroyed_guest_loses_its_applications_and_schedule(self):
self.register(112)
self.assertIsNotNone(lxc_apps._read_sidecar(112))
lxc_apps._docker_inventory_cache["112"] = {"containers": ["portainer"]}
lxc_apps.forget_guest(112)
self.assertFalse((self.apps / "112.json").exists())
self.assertIsNone(lxc_apps._read_sidecar(112))
self.assertNotIn("112", lxc_apps._docker_inventory_cache)
def test_its_oci_dismissal_is_dropped_and_others_are_kept(self):
(self.apps / ".oci-dismissed.json").write_text(json.dumps({"112": "old-id", "130": "other-id"}))
lxc_apps.forget_guest(112)
self.assertEqual(json.loads((self.apps / ".oci-dismissed.json").read_text()), {"130": "other-id"})
def test_guests_removed_while_the_monitor_was_stopped_are_forgotten(self):
for vmid in (112, 113, 200):
self.register(vmid)
self.guest(113)
self.guest(200, kind="qemu-server", node="pve2")
lxc_apps.forget_missing_guests()
self.assertEqual(sorted(p.name for p in self.apps.glob("*.json")), ["113.json", "200.json"])
def test_nothing_is_forgotten_without_the_cluster_filesystem(self):
self.register(112)
lxc_apps.forget_missing_guests()
self.assertTrue((self.apps / "112.json").exists())
def test_the_task_watcher_publishes_destroy_tasks(self):
source = (SCRIPTS_DIR / "notification_events.py").read_text()
self.assertIn("if task_type in ('vzdestroy', 'qmdestroy'):", source)
self.assertLess(source.index("if task_type in ('vzdestroy', 'qmdestroy'):"),
source.index('event_info = self.TASK_MAP.get(task_type)'))
def test_the_monitor_keeps_the_applications_of_an_oci_operation(self):
source = (SCRIPTS_DIR / "flask_server.py").read_text()
self.assertIn("if action == 'destroy' and not _oci_operation_running(guest_id):", source)
self.assertIn("/run/proxmenux/oci-operations/", source)
if __name__ == "__main__":
unittest.main()