From c0ceb6bea7492f031374a33d03afb2fb1f71fe53 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 28 Jul 2026 02:31:56 +0000 Subject: [PATCH] [IMP] script todo: add "Clean up QEMU" (orphan files) command New Manage entry that finds and lists QEMU leftovers with no matching libvirt domain, then asks before deleting: - working disks /var/lib/libvirt/images/.qcow2 - cloud-init seeds .../iso/-seed.iso - interrupted downloads (*.part) - orphan UEFI nvram files Sizes are shown human-readable with a total. Cached base cloud images (reusable) are listed and offered separately, since removing them forces a re-download; this also cleans the stale codename-named duplicates left by the switch to /releases/ version-named images. Co-Authored-By: Claude Opus 4.8 (1M context) --- script/todo/todo.py | 116 +++++++++++++++++++++++++++++++++++++++ script/todo/todo_i18n.py | 56 +++++++++++++++++++ 2 files changed, 172 insertions(+) diff --git a/script/todo/todo.py b/script/todo/todo.py index ac3d78c..8f86ed0 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -884,6 +884,7 @@ class TODO: {"prompt_description": t("Show a VM IP address")}, {"prompt_description": t("Open the console on a VM")}, {"prompt_description": t("Delete VM(s)")}, + {"prompt_description": t("Clean up QEMU (orphan files)")}, {"section": t("Catalog")}, {"prompt_description": t("List available images and specs")}, ] @@ -914,6 +915,8 @@ class TODO: elif status == "8": self._qemu_delete_vm() elif status == "9": + self._qemu_cleanup() + elif status == "10": self._qemu_list_images() else: cmd_no_found = True @@ -1194,6 +1197,119 @@ class TODO: self.execute.exec_command_live(cmd, source_erplibre=False) print(f"\n✅ {t('Deletion done.')}") + @staticmethod + def _human_size(n): + """Octets -> taille lisible (Ko/Mo/Go…).""" + size = float(n) + for unit in ("o", "Ko", "Mo", "Go", "To"): + if size < 1024: + return f"{size:.0f} {unit}" + size /= 1024 + return f"{size:.0f} Po" + + @staticmethod + def _qemu_find_files(directory, pattern): + """(taille, chemin) des fichiers du répertoire (via sudo find).""" + try: + res = subprocess.run( + [ + "sudo", + "find", + directory, + "-maxdepth", + "1", + "-type", + "f", + "-name", + pattern, + "-printf", + "%s\t%p\n", + ], + capture_output=True, + text=True, + timeout=20, + ) + except (OSError, subprocess.SubprocessError): + return [] + out = [] + for line in res.stdout.splitlines(): + if "\t" in line: + size, path = line.split("\t", 1) + out.append((int(size), path)) + return out + + def _qemu_cleanup(self): + """Repère les fichiers QEMU orphelins et propose de les effacer.""" + print(f"🧹 {t('Scanning for orphan QEMU files...')}") + disk_dir = "/var/lib/libvirt/images" + seed_dir = "/var/lib/libvirt/images/iso" + nvram_dir = "/var/lib/libvirt/qemu/nvram" + domains = set(self._qemu_list_domains()) + + orphans = [] # (taille, chemin, motif) + # Disques de travail sans domaine correspondant. + for size, path in self._qemu_find_files(disk_dir, "*.qcow2"): + stem = os.path.basename(path)[: -len(".qcow2")] + if stem not in domains: + orphans.append((size, path, t("orphan disk"))) + # Seeds cloud-init sans domaine. + for size, path in self._qemu_find_files(seed_dir, "*-seed.iso"): + stem = os.path.basename(path)[: -len("-seed.iso")] + if stem not in domains: + orphans.append((size, path, t("orphan seed"))) + # Téléchargements interrompus. + for size, path in self._qemu_find_files(seed_dir, "*.part"): + orphans.append((size, path, t("partial download"))) + # nvram UEFI sans domaine. + for size, path in self._qemu_find_files(nvram_dir, "*"): + stem = re.sub(r"(_VARS)?\.fd$", "", os.path.basename(path)) + if stem not in domains: + orphans.append((size, path, t("orphan UEFI nvram"))) + + if not orphans: + print(f"✅ {t('No orphan files found.')}") + else: + total = sum(o[0] for o in orphans) + print(f"\n{t('Orphan files:')}") + for size, path, reason in sorted(orphans, key=lambda o: -o[0]): + print(f" {self._human_size(size):>9} {path} [{reason}]") + print( + f"\n {t('Total:')} {self._human_size(total)} " + f"({len(orphans)} {t('files')})" + ) + if self._is_yes(input(t("Delete these orphan files? (y/N): "))): + paths = " ".join(shlex.quote(o[1]) for o in orphans) + cmd = f"sudo rm -f {paths}" + print(f"{t('Will execute:')} {cmd}") + self.execute.exec_command_live(cmd, source_erplibre=False) + print(f"✅ {t('Cleanup done.')}") + else: + print(t("Cancelled.")) + + # Cache d'images de base (réutilisables) : proposé séparément car les + # effacer force un re-téléchargement au prochain déploiement. + cached = [ + (s, p) + for s, p in self._qemu_find_files(seed_dir, "*") + if not p.endswith("-seed.iso") and not p.endswith(".part") + ] + if cached: + total = sum(s for s, _ in cached) + print( + f"\n{t('Cached base images (reusable):')} " + f"{self._human_size(total)}" + ) + for size, path in sorted(cached, key=lambda o: -o[0]): + print(f" {self._human_size(size):>9} {path}") + if self._is_yes( + input(t("Also delete the cached base images? (y/N): ")) + ): + paths = " ".join(shlex.quote(p) for _, p in cached) + cmd = f"sudo rm -f {paths}" + print(f"{t('Will execute:')} {cmd}") + self.execute.exec_command_live(cmd, source_erplibre=False) + print(f"✅ {t('Cleanup done.')}") + # ------------------------------------------------------------------ # # QEMU : déploiement d'un parc « infra ERPLibre » # ------------------------------------------------------------------ # diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index d9c8a20..44d8c5d 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -1214,6 +1214,62 @@ TRANSLATIONS = { "fr": "Effacement terminé.", "en": "Deletion done.", }, + "Clean up QEMU (orphan files)": { + "fr": "Nettoyer QEMU (fichiers orphelins)", + "en": "Clean up QEMU (orphan files)", + }, + "Scanning for orphan QEMU files...": { + "fr": "Recherche des fichiers QEMU orphelins...", + "en": "Scanning for orphan QEMU files...", + }, + "orphan disk": { + "fr": "disque orphelin", + "en": "orphan disk", + }, + "orphan seed": { + "fr": "seed orphelin", + "en": "orphan seed", + }, + "partial download": { + "fr": "téléchargement interrompu", + "en": "partial download", + }, + "orphan UEFI nvram": { + "fr": "nvram UEFI orpheline", + "en": "orphan UEFI nvram", + }, + "No orphan files found.": { + "fr": "Aucun fichier orphelin trouvé.", + "en": "No orphan files found.", + }, + "Orphan files:": { + "fr": "Fichiers orphelins :", + "en": "Orphan files:", + }, + "Total:": { + "fr": "Total :", + "en": "Total:", + }, + "files": { + "fr": "fichiers", + "en": "files", + }, + "Delete these orphan files? (y/N): ": { + "fr": "Effacer ces fichiers orphelins ? (o/N) : ", + "en": "Delete these orphan files? (y/N): ", + }, + "Cleanup done.": { + "fr": "Nettoyage terminé.", + "en": "Cleanup done.", + }, + "Cached base images (reusable):": { + "fr": "Images de base en cache (réutilisables) :", + "en": "Cached base images (reusable):", + }, + "Also delete the cached base images? (y/N): ": { + "fr": "Effacer aussi les images de base en cache ? (o/N) : ", + "en": "Also delete the cached base images? (y/N): ", + }, "Deploy ERPLibre infra (one minimal VM per image)": { "fr": "Déployer l'infra ERPLibre (une VM minimale par image)", "en": "Deploy ERPLibre infra (one minimal VM per image)",