diff --git a/script/todo/qemu_manage.py b/script/todo/qemu_manage.py
index cb0dbe0..ee0542a 100644
--- a/script/todo/qemu_manage.py
+++ b/script/todo/qemu_manage.py
@@ -3,6 +3,7 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Menu QEMU/KVM : g\u00e9rer les VM existantes.\n\nLe cycle de vie apr\u00e8s la cr\u00e9ation : lister, allumer et \u00e9teindre, r\u00e9gler le\nmat\u00e9riel, redimensionner (et r\u00e9tr\u00e9cir, ce qui demande de traverser le syst\u00e8me\nde fichiers invit\u00e9 par nbd), effacer, nettoyer les restes, retrouver une\nadresse IP, rouvrir le suivi d'une installation.\n\nC'est le fichier qui appelle \u00ab virsh \u00bb le plus souvent : les helpers qui le\nfont (domstate, dumpxml, c_env) vivent donc ici."""
+import glob
import json
import os
import re
@@ -127,17 +128,25 @@ class QemuManageMixin:
self.execute.exec_command_live(cmd, source_erplibre=False)
@staticmethod
- def _qemu_dumpxml(name):
- """XML PERSISTANT du domaine, ou '' — source de son état matériel.
+ def _qemu_dumpxml(name, inactive=True):
+ """XML du domaine, ou '' — source de son état matériel.
« --inactive » n'est pas décoratif : sur une VM allumée, « dumpxml »
rend la vue VIVANTE, décorée de ce que libvirt a alloué au démarrage
(portid du réseau, vnetN, alias). C'est la définition persistante que
- virt-xml modifie, et c'est donc elle qu'il faut lire.
+ virt-xml modifie, et c'est donc elle qu'il faut lire — d'où le défaut.
+
+ `inactive=False` demande justement la vue vivante : pour SAVOIR CE QUI
+ EST OUVERT, c'est elle qui compte, un disque attaché à chaud n'existant
+ que là.
"""
+ argv = ["sudo", "virsh", "dumpxml"]
+ if inactive:
+ argv.append("--inactive")
+ argv.append(name)
try:
res = subprocess.run(
- ["sudo", "virsh", "dumpxml", "--inactive", name],
+ argv,
capture_output=True,
text=True,
timeout=20,
@@ -778,6 +787,112 @@ class QemuManageMixin:
# ------------------------------------------------------------------ #
# Redimensionnement du disque d'une VM
# ------------------------------------------------------------------ #
+ # « » couvre les disques, les cdrom ET les
+ # « » d'une chaîne. Le nvram porte un attribut
+ # « template » : l'attendre sans attribut le manquait, et c'est le
+ # fichier le plus facile à perdre.
+ _RE_SOURCE_FILE = re.compile(r"]*>([^<]+)")
+ _RE_LIBVIRT_PATH = re.compile(r"""/var/lib/libvirt/[^,\0\s'"]+""")
+
+ def _qemu_referenced_files(self, domains=None):
+ """{chemin: domaine} — TOUT ce que les domaines référencent.
+
+ L'autorité est libvirt, jamais le nom du fichier. Un domaine renommé
+ garde le nom de fichier d'avant : juger sur le nom faisait passer le
+ disque d'une VM EN MARCHE pour un orphelin. Rapporté sur
+ « erplibre-ubuntu-2404-MIGRATION », renommée depuis
+ « erplibre-ubuntu-2404 » : le nettoyage offrait ses trois fichiers —
+ disque de 63 Go, seed, nvram — au « rm -f ».
+
+ Les deux vues, persistante et vivante, pour la raison dite dans
+ `_qemu_dumpxml`.
+ """
+ refs = {}
+ for nom in (
+ domains if domains is not None else self._qemu_list_domains()
+ ):
+ for inactive in (True, False):
+ xml = self._qemu_dumpxml(nom, inactive=inactive)
+ if not xml:
+ continue
+ trouves = self._RE_SOURCE_FILE.findall(
+ xml
+ ) + self._RE_NVRAM.findall(xml)
+ for chemin in trouves:
+ refs.setdefault(chemin.strip(), nom)
+ return refs
+
+ @classmethod
+ def _qemu_files_in_use(cls):
+ """Chemins de /var/lib/libvirt cités par un processus EN COURS.
+
+ Contrôle INDÉPENDANT de libvirt : un qemu lancé à la main, ou une
+ définition que libvirt aurait perdue, tient son disque ouvert quand
+ même. Devant un « rm -f » de 63 Go, deux sources valent mieux qu'une.
+ Sans privilège : /proc//cmdline se lit, et le qemu d'une VM y
+ porte ses disques, son seed et son nvram.
+ """
+ vus = set()
+ for entree in glob.glob("/proc/[0-9]*/cmdline"):
+ try:
+ with open(entree, "rb") as fh:
+ brut = fh.read().decode("utf-8", "replace")
+ except OSError:
+ continue
+ for chemin in cls._RE_LIBVIRT_PATH.findall(brut):
+ # Un chemin qui n'existe pas n'est pas un fichier tenu
+ # ouvert : la ligne de commande d'un processus quelconque
+ # peut contenir n'importe quoi (ce script lui-même y met son
+ # motif). On ne protège que du réel.
+ if os.path.exists(chemin):
+ vus.add(chemin)
+ return vus
+
+ def _qemu_vm_own_files(self, name):
+ """Fichiers de CETTE VM, et d'elle seule : disques et seed.
+
+ Demandés à libvirt, jamais déduits du nom. Une VM renommée garde le
+ nom de fichier d'avant : « rm .qcow2 » ne trouvait alors rien et
+ laissait 63 Go derrière lui — le même défaut que dans le nettoyage,
+ pris par l'autre bout.
+
+ Un fichier partagé avec un AUTRE domaine (image de fond d'une chaîne
+ de qcow2) est écarté : l'effacer creverait la voisine. Le nvram aussi,
+ parce que « virsh undefine --nvram » s'en charge déjà.
+ """
+ miens = set(self._qemu_referenced_files([name]))
+ voisins = set(
+ self._qemu_referenced_files(
+ [d for d in self._qemu_list_domains() if d != name]
+ )
+ )
+ return sorted(
+ chemin
+ for chemin in miens - voisins
+ if chemin.startswith("/var/lib/libvirt/")
+ and not chemin.endswith(".fd")
+ )
+
+ def _qemu_split_orphans(self, candidats):
+ """(orphelins, protégés) — un fichier référencé n'est JAMAIS orphelin.
+
+ `candidats` et le retour sont des (taille, chemin, motif). Les
+ protégés portent, à la place du motif, ce qui les retient.
+ """
+ refs = self._qemu_referenced_files()
+ ouverts = self._qemu_files_in_use()
+ orphelins, proteges = [], []
+ for taille, chemin, motif in candidats:
+ porteur = refs.get(chemin)
+ if not porteur and chemin in ouverts:
+ porteur = t("a running process")
+ if porteur:
+ proteges.append((taille, chemin, porteur))
+ else:
+ orphelins.append((taille, chemin, motif))
+ return orphelins, proteges
+
@staticmethod
def _qemu_c_env():
"""Environnement forçant LC_ALL=C : la sortie des outils (virsh,
@@ -1614,10 +1729,10 @@ class QemuManageMixin:
print(t("Cancelled."))
return
- disk_dir = "/var/lib/libvirt/images"
- seed_dir = "/var/lib/libvirt/images/iso"
for name in chosen:
q = shlex.quote(name)
+ # Les fichiers AVANT l'undefine : après, plus de XML à lire.
+ fichiers = self._qemu_vm_own_files(name) if del_disks else []
# Éteindre si en cours, puis retirer la définition (+ nvram si
# UEFI ; repli sans l'option pour les vieilles versions de virsh).
cmd = (
@@ -1625,10 +1740,14 @@ class QemuManageMixin:
f"sudo virsh undefine {q} --nvram 2>/dev/null "
f"|| sudo virsh undefine {q}"
)
- if del_disks:
- disk = shlex.quote(f"{disk_dir}/{name}.qcow2")
- seed = shlex.quote(f"{seed_dir}/{name}-seed.iso")
- cmd += f"; sudo rm -f {disk} {seed}"
+ if del_disks and fichiers:
+ cmd += "; sudo rm -f " + " ".join(
+ shlex.quote(f) for f in fichiers
+ )
+ elif del_disks:
+ # Rien à effacer : le dire, plutôt que de laisser croire que
+ # la place a été rendue.
+ print(f" ⚠ {name} : {t('no disk file found for this VM')}")
print(f"\n▶ {name}: {cmd}")
self.execute.exec_command_live(cmd, source_erplibre=False)
print(f"\n✅ {t('Deletion done.')}")
@@ -1730,6 +1849,16 @@ class QemuManageMixin:
# Sauvegardes de disque laissées par un redimensionnement (.qcow2.bak).
for size, path in self._qemu_find_files(disk_dir, "*.qcow2.bak"):
orphans.append((size, path, t("disk backup (resize)")))
+ # Le nom d'un fichier ne dit RIEN de son usage : c'est libvirt qui
+ # sait. Une VM renommée garde le nom de fichier d'avant, et le
+ # nettoyage offrait alors son disque de 63 Go au « rm -f » — rapporté.
+ orphans, proteges = self._qemu_split_orphans(orphans)
+ if proteges:
+ print(f"\n{t('Kept (still attached to a VM):')}")
+ for size, path, porteur in sorted(proteges, key=lambda o: -o[0]):
+ print(
+ f" {self._human_size(size):>9} {path}" f" ← {porteur}"
+ )
if orphans:
total = sum(o[0] for o in orphans)
print(f"\n{t('Orphan files:')}")
@@ -1755,10 +1884,15 @@ class QemuManageMixin:
# 3) Doublons d'images nommées par codename (avant /releases/).
dups = [
(s, p)
- for s, p in self._qemu_find_files(
- seed_dir, "*-server-cloudimg-*.img"
- )
- if not os.path.basename(p).startswith("ubuntu-")
+ for s, p, _m in self._qemu_split_orphans(
+ [
+ (s, p, "")
+ for s, p in self._qemu_find_files(
+ seed_dir, "*-server-cloudimg-*.img"
+ )
+ if not os.path.basename(p).startswith("ubuntu-")
+ ]
+ )[0]
]
self._cleanup_delete_files(
t("Stale codename-named Ubuntu images (duplicates):"),
@@ -1770,10 +1904,17 @@ class QemuManageMixin:
# 5) Baux DHCP périmés.
self._cleanup_stale_leases()
# 6) Tout le cache d'images de base (option lourde : re-téléchargement).
+ # Une image de base peut servir de FOND à un disque (backingStore) :
+ # elle est alors référencée, et l'effacer creverait la VM.
cached = [
(s, p)
- for s, p in self._qemu_find_files(seed_dir, "*")
- if not p.endswith("-seed.iso") and not p.endswith(".part")
+ for s, p, _m in self._qemu_split_orphans(
+ [
+ (s, p, "")
+ for s, p in self._qemu_find_files(seed_dir, "*")
+ if not p.endswith("-seed.iso") and not p.endswith(".part")
+ ]
+ )[0]
]
self._cleanup_delete_files(
t("All cached base images (reusable):"),
diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py
index 6b7b072..7c68609 100644
--- a/script/todo/todo_i18n.py
+++ b/script/todo/todo_i18n.py
@@ -3982,6 +3982,18 @@ TRANSLATIONS = {
"fr": "Fichiers orphelins :",
"en": "Orphan files:",
},
+ "no disk file found for this VM": {
+ "fr": "aucun fichier de disque trouvé pour cette VM",
+ "en": "no disk file found for this VM",
+ },
+ "a running process": {
+ "fr": "un processus en cours",
+ "en": "a running process",
+ },
+ "Kept (still attached to a VM):": {
+ "fr": "Conservés (encore attachés à une VM) :",
+ "en": "Kept (still attached to a VM):",
+ },
"Total:": {
"fr": "Total :",
"en": "Total:",
@@ -6726,6 +6738,54 @@ TRANSLATIONS = {
"fr": "Aucun index en double.",
"en": "No duplicate index.",
},
+ "Configuration records a migration never recreates": {
+ "fr": "Réglages qu'une migration ne recrée jamais",
+ "en": "Configuration records a migration never recreates",
+ },
+ "Every default configuration record is there.": {
+ "fr": "Chaque réglage par défaut est en place.",
+ "en": "Every default configuration record is there.",
+ },
+ "No pricelist, and the pricelist feature is on.": {
+ "fr": "Aucune liste de prix, alors que la fonctionnalité est active.",
+ "en": "No pricelist, and the pricelist feature is on.",
+ },
+ "Every quotation opens without one: raw price, no rule.": {
+ "fr": "Tout devis s'ouvre sans liste : prix brut, aucune règle.",
+ "en": "Every quotation opens without one: raw price, no rule.",
+ },
+ "No bank reconciliation model, for": {
+ "fr": "Aucun modèle de rapprochement bancaire, pour",
+ "en": "No bank reconciliation model, for",
+ },
+ "cash/bank journal(s).": {
+ "fr": "journal(aux) de trésorerie.",
+ "en": "cash/bank journal(s).",
+ },
+ "Every statement line is matched by hand.": {
+ "fr": "Chaque ligne de relevé se lettre à la main.",
+ "en": "Every statement line is matched by hand.",
+ },
+ "Use --apply to let Odoo recreate them.": {
+ "fr": "Utiliser --apply pour laisser Odoo les recréer.",
+ "en": "Use --apply to let Odoo recreate them.",
+ },
+ "pricelists:": {
+ "fr": "listes de prix :",
+ "en": "pricelists:",
+ },
+ "reconcile models:": {
+ "fr": "modèles de rapprochement :",
+ "en": "reconcile models:",
+ },
+ "Odoo recreated them.": {
+ "fr": "Odoo les a recréés.",
+ "en": "Odoo recreated them.",
+ },
+ "Still missing after the repair.": {
+ "fr": "Il en manque encore après la réparation :",
+ "en": "Still missing after the repair.",
+ },
"duplicate index pair(s)": {
"fr": "paire(s) d'index en double",
"en": "duplicate index pair(s)",
diff --git a/test/test_qemu_cleanup.py b/test/test_qemu_cleanup.py
new file mode 100644
index 0000000..4e1568e
--- /dev/null
+++ b/test/test_qemu_cleanup.py
@@ -0,0 +1,202 @@
+#!/usr/bin/env python3
+# © 2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+"""Le nettoyage ne doit jamais proposer un fichier EN USAGE.
+
+Rapporté, et c'est le pire défaut possible ici : après avoir renommé une VM
+« erplibre-ubuntu-2404 » en « erplibre-ubuntu-2404-MIGRATION », le nettoyage
+offrait au « rm -f » son disque de 63 Go, son seed et son nvram — tous les
+trois attachés à une VM EN MARCHE. La cause : l'orphelinat se jugeait sur le
+NOM du fichier comparé aux noms de domaines.
+
+Le nom ne dit rien de l'usage. L'autorité, c'est libvirt : ce qu'un domaine
+référence n'est pas orphelin, quel que soit son nom. Et devant un « rm -f »
+de 63 Go, un second contrôle indépendant (les fichiers qu'un processus tient
+ouverts) vaut le coup.
+"""
+
+import os
+import sys
+import unittest
+
+sys.argv = ["todo.py"]
+from script.todo.todo import TODO # noqa: E402
+
+# La forme RÉELLE, relevée sur la machine : le nvram porte un attribut
+# « template », et c'est ce qui l'avait fait manquer d'un premier filtre.
+XML_MIGRATION = """
+ erplibre-ubuntu-2404-MIGRATION
+
+ /usr/share/OVMF/OVMF_CODE_4M.fd
+ /var/lib/libvirt/qemu/nvram/erplibre-ubuntu-2404_VARS.fd
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+"""
+DISQUE = "/var/lib/libvirt/images/erplibre-ubuntu-2404.qcow2"
+SEED = "/var/lib/libvirt/images/iso/erplibre-ubuntu-2404-seed.iso"
+NVRAM = "/var/lib/libvirt/qemu/nvram/erplibre-ubuntu-2404_VARS.fd"
+
+
+def todo_avec(xml_par_domaine, ouverts=()):
+ """Une instance TODO dont libvirt et /proc sont remplacés par des faits."""
+ todo = TODO.__new__(TODO)
+ todo._qemu_list_domains = lambda: list(xml_par_domaine)
+ todo._qemu_dumpxml = lambda nom, inactive=True: xml_par_domaine.get(
+ nom, ""
+ )
+ todo._qemu_files_in_use = lambda: set(ouverts)
+ return todo
+
+
+class TestCeQueLesDomainesReferencent(unittest.TestCase):
+ def setUp(self):
+ self.todo = todo_avec(
+ {"erplibre-ubuntu-2404-MIGRATION": XML_MIGRATION}
+ )
+
+ def test_a_renamed_domain_still_owns_its_files(self):
+ # Le cœur du rapport : le nom du fichier n'a plus rien à voir avec le
+ # nom du domaine, et c'est parfaitement normal après un renommage.
+ refs = self.todo._qemu_referenced_files()
+ for chemin in (DISQUE, SEED, NVRAM):
+ self.assertEqual(
+ refs.get(chemin), "erplibre-ubuntu-2404-MIGRATION", chemin
+ )
+
+ def test_the_nvram_is_read_despite_its_attribute(self):
+ # « chemin » : attendre « » nu
+ # manquait le fichier le plus facile à perdre.
+ self.assertIn(NVRAM, self.todo._qemu_referenced_files())
+
+ def test_the_network_source_is_not_a_file(self):
+ # « » ne doit pas entrer dans la liste.
+ for chemin in self.todo._qemu_referenced_files():
+ self.assertTrue(chemin.startswith("/"), chemin)
+
+ def test_a_domain_without_xml_is_simply_skipped(self):
+ todo = todo_avec({"fantome": ""})
+ self.assertEqual(todo._qemu_referenced_files(), {})
+
+
+class TestLeTri(unittest.TestCase):
+ """(orphelins, protégés) : c'est ce tri qui décide de ce qui est effacé."""
+
+ def _tri(self, candidats, xml=None, ouverts=()):
+ todo = todo_avec(
+ xml if xml is not None else {"vm-a": XML_MIGRATION}, ouverts
+ )
+ return todo._qemu_split_orphans(candidats)
+
+ def test_a_referenced_file_is_never_an_orphan(self):
+ orph, prot = self._tri([(63, DISQUE, "disque orphelin")])
+ self.assertEqual(orph, [])
+ self.assertEqual(prot[0][2], "vm-a")
+
+ def test_an_unreferenced_file_stays_an_orphan(self):
+ perdu = "/var/lib/libvirt/images/plus-personne.qcow2"
+ orph, prot = self._tri([(1, perdu, "disque orphelin")])
+ self.assertEqual([o[1] for o in orph], [perdu])
+ self.assertEqual(prot, [])
+
+ def test_a_file_held_open_is_protected_even_without_libvirt(self):
+ # Un qemu lancé à la main, ou une définition que libvirt a perdue :
+ # le fichier est ouvert, donc il n'est pas à jeter.
+ perdu = "/var/lib/libvirt/images/lancee-a-la-main.qcow2"
+ orph, prot = self._tri(
+ [(1, perdu, "disque orphelin")], xml={}, ouverts=(perdu,)
+ )
+ self.assertEqual(orph, [])
+ self.assertIn(
+ prot[0][2], (perdu, "un processus en cours", "a running process")
+ )
+
+ def test_the_three_files_of_the_report_are_all_kept(self):
+ candidats = [
+ (63_000_000_000, DISQUE, "disque orphelin"),
+ (528_000, NVRAM, "nvram orpheline"),
+ (368_000, SEED, "seed orphelin"),
+ ]
+ orph, prot = self._tri(candidats)
+ self.assertEqual(orph, [], "un fichier en usage serait effacé")
+ self.assertEqual(len(prot), 3)
+
+
+class TestEffacerUneVm(unittest.TestCase):
+ """L'autre bout du même défaut : effacer par le NOM laissait 63 Go.
+
+ « rm /var/lib/libvirt/images/.qcow2 » ne trouve rien quand la VM a
+ été renommée — la définition partait, le disque restait, et devenait un
+ vrai orphelin de 63 Go que le nettoyage n'osait plus toucher.
+ """
+
+ def test_a_renamed_vm_deletes_its_real_files(self):
+ todo = todo_avec({"erplibre-ubuntu-2404-MIGRATION": XML_MIGRATION})
+ self.assertEqual(
+ todo._qemu_vm_own_files("erplibre-ubuntu-2404-MIGRATION"),
+ [DISQUE, SEED],
+ )
+
+ def test_the_nvram_is_left_to_virsh_undefine(self):
+ # « virsh undefine --nvram » s'en charge : le lister deux fois ne
+ # sert à rien, et un « rm » sur un nvram encore utilisé serait pire.
+ todo = todo_avec({"vm-a": XML_MIGRATION})
+ self.assertNotIn(NVRAM, todo._qemu_vm_own_files("vm-a"))
+
+ def test_a_file_shared_with_a_neighbour_is_spared(self):
+ # Image de FOND d'une chaîne de qcow2 : l'effacer creverait l'autre.
+ partage = (
+ ""
+ )
+ todo = todo_avec({"vm-a": XML_MIGRATION, "vm-b": partage})
+ propres = todo._qemu_vm_own_files("vm-a")
+ self.assertNotIn(DISQUE, propres)
+ self.assertIn(SEED, propres)
+
+ def test_a_vm_without_files_yields_nothing_rather_than_a_guess(self):
+ todo = todo_avec({"vm-a": ""})
+ self.assertEqual(todo._qemu_vm_own_files("vm-a"), [])
+
+
+class TestLesFichiersOuverts(unittest.TestCase):
+ """Le contrôle indépendant : /proc, sans privilège."""
+
+ def test_it_finds_the_files_of_a_running_vm(self):
+ # Sur cette machine, si une VM tourne, son disque est cité par la
+ # ligne de commande de son qemu. Sinon, le test ne prouve rien et
+ # doit le DIRE plutôt que de passer pour rien.
+ todo = TODO.__new__(TODO)
+ domaines = todo._qemu_list_domains()
+ if not domaines:
+ self.skipTest("aucune VM définie sur cette machine")
+ ouverts = TODO._qemu_files_in_use()
+ if not ouverts:
+ self.skipTest("aucune VM démarrée sur cette machine")
+ self.assertTrue(
+ all(o.startswith("/var/lib/libvirt/") for o in ouverts)
+ )
+
+ def test_it_only_keeps_paths_that_exist(self):
+ # La ligne de commande d'un processus quelconque peut contenir
+ # n'importe quoi — ce test-ci en met la preuve dans la sienne :
+ # /var/lib/libvirt/n-existe-pas-du-tout
+ for chemin in TODO._qemu_files_in_use():
+ self.assertTrue(os.path.exists(chemin), chemin)
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)