From 6ac7624950c7c269e94dab4e736c48a07169fc69 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 28 Jul 2026 03:12:43 +0000 Subject: [PATCH] [IMP] script todo: extend "Clean up QEMU" with 4 more sweeps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cleanup command now runs, each listed and confirmed independently: 1. orphan files (disks/seeds/.part/nvram) — as before; 2. ghost domains: libvirt domains whose disk no longer exists -> offer destroy + undefine --nvram; 3. stale codename-named Ubuntu images (noble/resolute/... left by the move to /releases/ version-named images) -> targeted delete; 4. orphan ~/.ssh/config entries: "Host erplibre-*" blocks with no matching VM (personal SSH hosts are never touched); 5. stale libvirt DHCP leases whose MAC belongs to no VM (best-effort rewrite of the dnsmasq status file + SIGHUP; they also self-expire); 6. the full base-image cache remains an explicit opt-out at the end. Detections verified read-only on the host. Co-Authored-By: Claude Opus 4.8 (1M context) --- script/todo/todo.py | 231 ++++++++++++++++++++++++++++++++++----- script/todo/todo_i18n.py | 40 +++++++ 2 files changed, 241 insertions(+), 30 deletions(-) diff --git a/script/todo/todo.py b/script/todo/todo.py index 8f86ed0..b29aa84 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -1238,37 +1238,70 @@ class TODO: out.append((int(size), path)) return out + def _cleanup_delete_files(self, title, items, prompt): + """items : [(taille, chemin)]. Liste, confirme, puis « sudo rm -f ».""" + if not items: + return + total = sum(s for s, _ in items) + print( + f"\n{title} — {self._human_size(total)}, " + f"{len(items)} {t('files')} :" + ) + for size, path in sorted(items, key=lambda o: -o[0]): + print(f" {self._human_size(size):>9} {path}") + if not self._is_yes(input(prompt)): + print(t("Cancelled.")) + return + paths = " ".join(shlex.quote(p) for _, p in items) + 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.')}") + + def _qemu_domain_macs(self): + """MACs de toutes les VM définies (pour repérer les baux périmés).""" + macs = set() + for name in self._qemu_list_domains(): + try: + res = subprocess.run( + ["sudo", "virsh", "domiflist", name], + capture_output=True, + text=True, + timeout=15, + ) + except (OSError, subprocess.SubprocessError): + continue + macs.update( + m.lower() + for m in re.findall( + r"[0-9a-f]{2}(?::[0-9a-f]{2}){5}", res.stdout + ) + ) + return macs + def _qemu_cleanup(self): - """Repère les fichiers QEMU orphelins et propose de les effacer.""" + """Repère les restes 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()) + # 1) Fichiers orphelins : disques / seeds / .part / nvram. 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: + if os.path.basename(path)[: -len(".qcow2")] 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: + if os.path.basename(path)[: -len("-seed.iso")] 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: + if orphans: 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]): @@ -1285,30 +1318,168 @@ class TODO: print(f"✅ {t('Cleanup done.')}") else: print(t("Cancelled.")) + else: + print(f"✅ {t('No orphan files found.')}") - # Cache d'images de base (réutilisables) : proposé séparément car les - # effacer force un re-téléchargement au prochain déploiement. + # 2) Domaines fantômes (définis mais disque manquant). + self._cleanup_ghost_domains() + # 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-") + ] + self._cleanup_delete_files( + t("Stale codename-named Ubuntu images (duplicates):"), + dups, + t("Delete these duplicate images? (y/N): "), + ) + # 4) Entrées ~/.ssh/config orphelines (erplibre-* sans VM). + self._cleanup_ssh_config(domains) + # 5) Baux DHCP périmés. + self._cleanup_stale_leases() + # 6) Tout le cache d'images de base (option lourde : re-téléchargement). 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): ")) + self._cleanup_delete_files( + t("All cached base images (reusable):"), + cached, + t("Delete ALL cached base images? (y/N): "), + ) + + def _cleanup_ghost_domains(self): + """VM définies dont plus aucun disque n'existe -> propose undefine.""" + ghosts = [] + for name in self._qemu_list_domains(): + try: + res = subprocess.run( + ["sudo", "virsh", "domblklist", name, "--details"], + capture_output=True, + text=True, + timeout=15, + ) + except (OSError, subprocess.SubprocessError): + continue + srcs = [] + for line in res.stdout.splitlines(): + p = line.split() + if len(p) >= 4 and p[1] == "disk" and p[3] not in ("-", ""): + srcs.append(p[3]) + if srcs and all( + subprocess.run( + ["sudo", "test", "-e", s], timeout=10 + ).returncode + != 0 + for s in srcs ): - 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.')}") + ghosts.append(name) + if not ghosts: + return + print( + f"\n{t('Ghost domains (defined but disk missing):')} " + f"{', '.join(ghosts)}" + ) + if not self._is_yes(input(t("Undefine these ghost domains? (y/N): "))): + print(t("Cancelled.")) + return + for name in ghosts: + q = shlex.quote(name) + cmd = ( + f"sudo virsh destroy {q} 2>/dev/null; " + f"sudo virsh undefine {q} --nvram 2>/dev/null " + f"|| sudo virsh undefine {q}" + ) + print(f"{t('Will execute:')} {cmd}") + self.execute.exec_command_live(cmd, source_erplibre=False) + print(f"✅ {t('Cleanup done.')}") + + def _cleanup_ssh_config(self, domains): + """Retire les blocs « Host erplibre-* » sans VM correspondante (on ne + touche jamais aux autres hôtes SSH personnels).""" + cfg = os.path.expanduser("~/.ssh/config") + if not os.path.exists(cfg): + return + with open(cfg, encoding="utf-8") as fh: + content = fh.read() + hosts = re.findall(r"(?m)^[ \t]*Host[ \t]+(\S+)", content) + orphans = [ + h for h in hosts if h.startswith("erplibre-") and h not in domains + ] + if not orphans: + return + print(f"\n{t('Orphan ~/.ssh/config entries:')} {', '.join(orphans)}") + if not self._is_yes( + input(t("Remove these ~/.ssh/config entries? (y/N): ")) + ): + print(t("Cancelled.")) + return + for h in orphans: + pat = re.compile( + rf"(?m)^[ \t]*Host[ \t]+{re.escape(h)}[ \t]*\n" + r"(?:[ \t]+[^\n]*\n?)*" + ) + content = pat.sub("", content) + content = content.strip("\n") + content = content + "\n" if content else "" + with open(cfg, "w", encoding="utf-8") as fh: + fh.write(content) + os.chmod(cfg, 0o600) + print(f"✅ {t('Cleanup done.')}") + + def _cleanup_stale_leases(self): + """Baux DHCP libvirt dont la MAC n'appartient à aucune VM (best-effort : + les baux expirent d'eux-mêmes).""" + status = "/var/lib/libvirt/dnsmasq/virbr0.status" + try: + res = subprocess.run( + ["sudo", "cat", status], + capture_output=True, + text=True, + timeout=15, + ) + leases = json.loads(res.stdout or "[]") + except (OSError, subprocess.SubprocessError, ValueError): + return + if not isinstance(leases, list) or not leases: + return + macs = self._qemu_domain_macs() + stale = [ + ln + for ln in leases + if str(ln.get("mac-address", "")).lower() not in macs + ] + if not stale: + return + print(f"\n{t('Stale DHCP leases (no matching VM):')}") + for ln in stale: + print( + f" {ln.get('ip-address', '?'):<16} " + f"{ln.get('mac-address', '?')} {ln.get('hostname', '')}" + ) + if not self._is_yes(input(t("Clear these stale leases? (y/N): "))): + print(t("Cancelled.")) + return + kept = [ln for ln in leases if ln not in stale] + tmp = os.path.join("/tmp", f"virbr0.status.{os.getpid()}.json") + with open(tmp, "w", encoding="utf-8") as fh: + json.dump(kept, fh) + cmd = ( + f"sudo cp {shlex.quote(tmp)} {status} && " + "sudo pkill -HUP -F /var/lib/libvirt/dnsmasq/virbr0.pid " + "2>/dev/null || true" + ) + print(f"{t('Will execute:')} {cmd}") + self.execute.exec_command_live(cmd, source_erplibre=False) + try: + os.unlink(tmp) + except OSError: + pass + 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 44d8c5d..47f7e30 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -1270,6 +1270,46 @@ TRANSLATIONS = { "fr": "Effacer aussi les images de base en cache ? (o/N) : ", "en": "Also delete the cached base images? (y/N): ", }, + "All cached base images (reusable):": { + "fr": "Toutes les images de base en cache (réutilisables) :", + "en": "All cached base images (reusable):", + }, + "Delete ALL cached base images? (y/N): ": { + "fr": "Effacer TOUTES les images de base en cache ? (o/N) : ", + "en": "Delete ALL cached base images? (y/N): ", + }, + "Ghost domains (defined but disk missing):": { + "fr": "Domaines fantômes (définis, disque manquant) :", + "en": "Ghost domains (defined but disk missing):", + }, + "Undefine these ghost domains? (y/N): ": { + "fr": "Supprimer la définition de ces domaines ? (o/N) : ", + "en": "Undefine these ghost domains? (y/N): ", + }, + "Stale codename-named Ubuntu images (duplicates):": { + "fr": "Images Ubuntu nommées par codename (doublons) :", + "en": "Stale codename-named Ubuntu images (duplicates):", + }, + "Delete these duplicate images? (y/N): ": { + "fr": "Effacer ces images en double ? (o/N) : ", + "en": "Delete these duplicate images? (y/N): ", + }, + "Orphan ~/.ssh/config entries:": { + "fr": "Entrées ~/.ssh/config orphelines :", + "en": "Orphan ~/.ssh/config entries:", + }, + "Remove these ~/.ssh/config entries? (y/N): ": { + "fr": "Retirer ces entrées de ~/.ssh/config ? (o/N) : ", + "en": "Remove these ~/.ssh/config entries? (y/N): ", + }, + "Stale DHCP leases (no matching VM):": { + "fr": "Baux DHCP périmés (aucune VM correspondante) :", + "en": "Stale DHCP leases (no matching VM):", + }, + "Clear these stale leases? (y/N): ": { + "fr": "Effacer ces baux périmés ? (o/N) : ", + "en": "Clear these stale leases? (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)",