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()
+1
View File
@@ -336,6 +336,7 @@ Thank you to [@byGarcia](https://github.com/byGarcia) for the implementation, ve
- **The web terminals send Ctrl+D from their Ctrl menu**, which ends a text pasted in the terminal, such as a Compose file or an oci-compose in OCI manager Apps, also from a phone or a tablet. A single Ctrl+D ends the paste, also right after its last line.
- **A VM or LXC edited from Proxmox shows its current settings** the next time its modal opens: start at boot, tags, cores, memory, network and mount points.
- **An LXC changed outside the Monitor shows its current data** ([PR #441](https://github.com/MacRimi/ProxMenux/pull/441), contributed by [@Vaso73](https://github.com/Vaso73)): a new DHCP address, a setting edited in Proxmox while its modal is open, and the operating system of a restored or reinstalled container. The Updates tab marks a result older than 48 hours as outdated, and the card keeps showing the number of pending updates.
- **A guest that is destroyed takes its App tab with it**: its registered applications and scheduled updates are forgotten, also when it was removed while the Monitor was stopped, so a new container that reuses the VMID starts empty. An OCI update, which recreates its own container, keeps them.
- **Updating a stopped LXC keeps its result** ([PR #447](https://github.com/MacRimi/ProxMenux/pull/447), contributed by [@Vaso73](https://github.com/Vaso73)): its packages and applications are read before it is stopped again, so the Updates tab shows what was installed and what is still pending. A run that never got to start the container keeps the previous result.
- **An LXC update keeps running when the browser session ends** ([PR #527](https://github.com/MacRimi/ProxMenux/pull/527), contributed by [@Vaso73](https://github.com/Vaso73)): the update runs as a job of the Monitor, so closing or reloading the page leaves it running, and after signing in again its output is shown from where it was. Ctrl+C still cancels it.
- **Checking and installing LXC packages are separate steps** ([PR #448](https://github.com/MacRimi/ProxMenux/pull/448), contributed by [@Vaso73](https://github.com/Vaso73)): Check system packages refreshes the package indexes of a running container and counts what is pending without installing anything, and installing needs a recent successful check. A stopped container is asked to be started first and keeps its last result with its date.
+1
View File
@@ -335,6 +335,7 @@ Gracias a [@byGarcia](https://github.com/byGarcia) por la implementación, los d
- **Las terminales web envían Ctrl+D desde su menú Ctrl**, lo que termina un texto pegado en la terminal, como un archivo Compose o un oci-compose en OCI manager Apps, también desde un móvil o una tableta. Un solo Ctrl+D termina el pegado, también justo después de su última línea.
- **Una VM o un LXC editados desde Proxmox muestran sus ajustes actuales** la siguiente vez que se abre su modal: inicio al arrancar, etiquetas, núcleos, memoria, red y puntos de montaje.
- **Un LXC cambiado fuera del Monitor muestra sus datos actuales** ([PR #441](https://github.com/MacRimi/ProxMenux/pull/441), aportado por [@Vaso73](https://github.com/Vaso73)): una dirección DHCP nueva, un ajuste editado en Proxmox mientras su modal está abierto y el sistema operativo de un contenedor restaurado o reinstalado. La pestaña Actualizaciones marca como desactualizado un resultado de hace más de 48 horas, y la tarjeta sigue mostrando el número de actualizaciones pendientes.
- **Un contenedor o una VM que se eliminan se llevan su pestaña App**: se olvidan sus aplicaciones registradas y sus actualizaciones programadas, también si se eliminó con el Monitor parado, así que un contenedor nuevo que reutiliza el VMID empieza vacío. Una actualización de OCI, que recrea su propio contenedor, las conserva.
- **Actualizar un LXC detenido conserva su resultado** ([PR #447](https://github.com/MacRimi/ProxMenux/pull/447), aportado por [@Vaso73](https://github.com/Vaso73)): sus paquetes y aplicaciones se leen antes de volver a detenerlo, así que la pestaña Actualizaciones muestra lo que se instaló y lo que sigue pendiente. Una ejecución que no llegó a arrancar el contenedor conserva el resultado anterior.
- **La actualización de un LXC sigue en marcha aunque termine la sesión del navegador** ([PR #527](https://github.com/MacRimi/ProxMenux/pull/527), aportado por [@Vaso73](https://github.com/Vaso73)): la actualización se ejecuta como un trabajo del Monitor, así que cerrar o recargar la página no la detiene, y al volver a iniciar sesión se muestra su salida desde donde iba. Ctrl+C sigue cancelándola.
- **Comprobar e instalar paquetes de un LXC son pasos separados** ([PR #448](https://github.com/MacRimi/ProxMenux/pull/448), aportado por [@Vaso73](https://github.com/Vaso73)): Comprobar paquetes del sistema actualiza los índices de paquetes de un contenedor en marcha y cuenta lo que está pendiente sin instalar nada, e instalar requiere una comprobación correcta reciente. A un contenedor detenido se le pide arrancarlo primero, y conserva su último resultado con su fecha.