From ab8bfec113a6ea53a00f2871cd4cd6f933af6a5c Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 29 Jul 2026 11:40:43 +0000 Subject: [PATCH] =?UTF-8?q?[IMP]=20qemu:=20guest-agent=20au=20d=C3=A9ploie?= =?UTF-8?q?ment=20+=20suivi=20(erreurs,=20stats,=20pause=20parc)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Provisioning (deploy_qemu.py) : - qemu-guest-agent installé + activé dans le runcmd cloud-init (APRÈS sshd, « || true » : ne bloque pas le boot si le réseau est lent). - Canal virtio org.qemu.guest_agent.0 ajouté à virt-install : virsh peut piloter la VM SANS réseau. - Extension du FS invité (todo.py) : nouveau tier AGENT INVITÉ (_qemu_guest_exec via qemu-agent-command guest-exec) entre SSH et la console série -> étend le FS même sans IP. Suivi d'installation (qemu_install_monitor.py) : - Détection d'erreurs dans le log à la complétion (succès OU échec) en réutilisant la logique de script/test/run_parallel_test.py (sous-chaîne error/warning + listes d'ignore). Nouvelle colonne « ⚠ » À GAUCHE d'État (⚠N erreurs / ⚡N avert. / ✓ propre). - Sommaire de stats EN CHIFFRES (📊 total · ✅ · ❌ · ⏳ · ⏸ · 🗑 · ⚠ · ⚡) ; CLIC pour déplier le détail (VM en erreur + durées). - Boutons « p » Pause tout (virsh suspend des VM running) et « o » Reprendre tout (virsh resume) — les logs continuent (offsets conservés). Validé headless : succès-avec-erreur -> ⚠1, stats/détail, pause du parc. Co-Authored-By: Claude Opus 4.8 (1M context) --- script/qemu/deploy_qemu.py | 15 ++ script/todo/qemu_install_monitor.py | 210 +++++++++++++++++++++++++++- script/todo/todo.py | 81 ++++++++++- script/todo/todo_i18n.py | 44 ++++++ 4 files changed, 342 insertions(+), 8 deletions(-) diff --git a/script/qemu/deploy_qemu.py b/script/qemu/deploy_qemu.py index 55ff342..ff05976 100755 --- a/script/qemu/deploy_qemu.py +++ b/script/qemu/deploy_qemu.py @@ -970,6 +970,16 @@ def build_cloud_config( "runcmd:", " - systemctl enable --now ssh 2>/dev/null" " || systemctl enable --now sshd 2>/dev/null || true", + # qemu-guest-agent : installé + activé APRÈS sshd (donc SSH reste + # disponible tout de suite, sans attendre le réseau). Tout est + # « || true » : si l'installation échoue (réseau lent/absent), le boot + # n'est pas bloqué. La plupart des images cloud l'incluent déjà. + " - (command -v apt-get >/dev/null && apt-get install -y" + " qemu-guest-agent) || (command -v dnf >/dev/null && dnf install -y" + " qemu-guest-agent) || (command -v pacman >/dev/null && pacman -S" + " --noconfirm qemu-guest-agent) || true", + " - systemctl enable --now qemu-guest-agent 2>/dev/null" + " || systemctl enable --now qemu-ga 2>/dev/null || true", ] return "\n".join(lines) + "\n" @@ -1159,6 +1169,11 @@ def virt_install( args.graphics, "--console", f"pty,target_type={console_target}", + # Canal virtio de l'agent invité (org.qemu.guest_agent.0) : permet à + # virsh de piloter la VM SANS réseau (ex. étendre le FS invité après + # un redimensionnement de disque). Inoffensif si l'agent est absent. + "--channel", + "unix,target.type=virtio,target.name=org.qemu.guest_agent.0", ] if args.arch == "s390x": # s390x (IBM Z) : machine s390-ccw-virtio, amorçage IPL/zipl depuis le diff --git a/script/todo/qemu_install_monitor.py b/script/todo/qemu_install_monitor.py index 67b939d..62c96de 100644 --- a/script/todo/qemu_install_monitor.py +++ b/script/todo/qemu_install_monitor.py @@ -176,6 +176,54 @@ def read_status(log_path: str) -> tuple[str, int | None]: return "running", None +# Listes d'ignore reprises de script/test/run_parallel_test.py (erreurs/ +# avertissements connus et bénins) : on réutilise la MÊME logique de détection +# que la suite de tests ERPLibre pour analyser les logs d'installation. +_LST_IGNORE_WARNING = ( + "have the same label:", + "odoo.addons.code_generator.extractor_module_file: Ignore next error about" + " ALTER TABLE DROP CONSTRAINT.", +) +_LST_IGNORE_ERROR = ( + "fetchmail_notify_error_to_sender", + 'odoo.sql_db: bad query: ALTER TABLE "db_backup" DROP CONSTRAINT' + ' "db_backup_db_backup_name_unique"', + 'ERROR: constraint "db_backup_db_backup_name_unique" of relation' + ' "db_backup" does not exist', + 'odoo.sql_db: bad query: ALTER TABLE "db_backup" DROP CONSTRAINT' + ' "db_backup_db_backup_days_to_keep_positive"', + 'ERROR: constraint "db_backup_db_backup_days_to_keep_positive" of relation' + ' "db_backup" does not exist', + "odoo.addons.code_generator.extractor_module_file: Ignore next error about" + " ALTER TABLE DROP CONSTRAINT.", +) + + +def scan_log_errors(log_path: str) -> tuple[int, int]: + """(nb_erreurs, nb_avertissements) dans un log d'installation, en + réutilisant la détection de la suite de tests ERPLibre : sous-chaîne + « error »/« warning » (insensible à la casse) moins les listes d'ignore. + Lit le fichier COMPLET (appelé une seule fois, à la complétion d'une VM).""" + try: + text = Path(log_path).read_text(errors="replace") + except OSError: + return 0, 0 + nerr = nwarn = 0 + for line in text.splitlines(): + low = line.lower() + if EXIT_MARKER in line: + continue + if "error" in low and not any( + ig in line for ig in _LST_IGNORE_ERROR + ): + nerr += 1 + if "warning" in low and not any( + ig in line for ig in _LST_IGNORE_WARNING + ): + nwarn += 1 + return nerr, nwarn + + def _read_new(path: str, offset: int) -> tuple[str, int]: """Lit le log à partir de `offset` (lecture incrémentale). Renvoie (nouveau_texte, nouvel_offset). Bloquant -> à appeler dans un thread.""" @@ -425,6 +473,8 @@ def run_monitor(manifest_path: str, run_app: bool = True): DataTable { width: 58; border: solid $accent; } RichLog { border: solid $accent; } #telemetry { height: 1; color: $text-muted; } + #stats { height: 1; color: $accent; } + #statsdetail { display: none; height: auto; color: $text-muted; } #sshbar { height: 2; color: $text-muted; } """ BINDINGS = [ @@ -433,6 +483,8 @@ def run_monitor(manifest_path: str, run_app: bool = True): ("w", "web", "Web (navigateur CLI)"), ("f", "follow", "Suivre"), ("c", "copy_log", "Copier log"), + ("p", "pause_all", "Pause tout"), + ("o", "resume_all", "Reprendre tout"), ] def __init__(self): @@ -452,6 +504,10 @@ def run_monitor(manifest_path: str, run_app: bool = True): self._disk_dir = os.path.dirname(vm_disk_path(vms[0])) if vms else "/" # État libvirt (running/paused/gone), rafraîchi à intervalle LENT. self._domstate = {} + # Erreurs détectées dans le log à la complétion : {nom: (err, warn)}. + self._errcount = {} + # Sommaire de stats déplié (clic) ou non. + self._stats_open = False @staticmethod def _fmt(secs): @@ -469,12 +525,16 @@ def run_monitor(manifest_path: str, run_app: bool = True): yield Header(show_clock=True) table = DataTable(id="vms", cursor_type="row") # Clés de colonnes explicites : update_cell() les référence. + # La colonne « ⚠ » (erreurs détectées) est à GAUCHE d'« État ». table.add_column("VM", key="vm") + table.add_column("⚠", key="err") table.add_column("État", key="state") table.add_column("Durée", key="elapsed") table.add_column("Disque", key="disk") for vm in vms: - table.add_row(vm["name"], "⏳", "--:--", "-", key=vm["name"]) + table.add_row( + vm["name"], "", "⏳", "--:--", "-", key=vm["name"] + ) # max_lines borne la mémoire/rendu (un install verbeux × 30 VM). self._log = RichLog( id="log", highlight=False, markup=False, max_lines=5000 @@ -484,6 +544,9 @@ def run_monitor(manifest_path: str, run_app: bool = True): yield self._log # Barre de télémétrie hôte (CPU, disque, ETA parc). yield Static("", id="telemetry") + # Sommaire de stats en CHIFFRES (cliquable -> détail). + yield Static("", id="stats") + yield Static("", id="statsdetail") yield Static("", id="sshbar") yield Footer() @@ -554,7 +617,7 @@ def run_monitor(manifest_path: str, run_app: bool = True): def _collect_table(self): """(THREAD) statut + taille disque de chaque VM + télémétrie. AUCUNE mise à jour d'UI ici : uniquement des I/O bloquantes déportées.""" - disks, status = {}, {} + disks, status, errors = {}, {}, {} for vm in vms: name = vm["name"] disks[name] = _fmt_size(disk_actual_size(vm_disk_path(vm))) @@ -562,19 +625,26 @@ def run_monitor(manifest_path: str, run_app: bool = True): name not in self._final and self._domstate.get(name) != "gone" ): - status[name] = read_status(vm["log"]) - return disks, status, self._collect_tele() + st = read_status(vm["log"]) + status[name] = st + # À la complétion (succès OU échec), on ANALYSE le log + # complet pour signaler les erreurs — même un « succès » + # peut contenir des erreurs passées inaperçues. + if st[0] in ("done", "failed") and name not in errors: + errors[name] = scan_log_errors(vm["log"]) + return disks, status, self._collect_tele(), errors async def _tick_table(self): # I/O (lectures de logs, stat disque, /proc) DÉPORTÉES en thread -> # la boucle d'événements Textual reste fluide même sous forte # charge ou disque lent. Les mises à jour d'UI restent sur la boucle. try: - disks, status, tele = await asyncio.to_thread( + disks, status, tele, errors = await asyncio.to_thread( self._collect_table ) except Exception: return + self._errcount.update(errors) try: table = self.query_one("#vms", DataTable) now = time.time() @@ -611,6 +681,12 @@ def run_monitor(manifest_path: str, run_app: bool = True): self._set_cell( table, name, "elapsed", self._fmt(elapsed) ) + # Colonne ⚠ (à gauche d'État) : erreurs détectées dans + # le log, y compris pour un « succès ». + self._set_cell( + table, name, "err", + self._err_label(self._errcount.get(name)), + ) elif ds == "paused": self._set_cell( table, name, "state", f"⏸ {t('paused')}" @@ -630,9 +706,81 @@ def run_monitor(manifest_path: str, run_app: bool = True): ) if tele: self.query_one("#telemetry", Static).update(tele) + self._update_stats() except Exception: pass + @staticmethod + def _err_label(counts): + """Libellé de la colonne ⚠ : « ⚠N » si erreurs, « ⚡N » si seulement + des avertissements, « ✓ » si log propre.""" + if not counts: + return "" + nerr, nwarn = counts + if nerr: + return f"⚠{nerr}" + if nwarn: + return f"⚡{nwarn}" + return "✓" + + def _stats_counts(self): + """Compte par catégorie (terminées, échecs, erreurs, etc.).""" + done = fail = deleted = err_vms = warn_vms = 0 + for name, (state, _code, _el) in self._final.items(): + if state == "done": + done += 1 + elif state == "failed": + fail += 1 + elif state == "deleted": + deleted += 1 + counts = self._errcount.get(name) + if counts: + if counts[0]: + err_vms += 1 + elif counts[1]: + warn_vms += 1 + running = paused = 0 + for vm in vms: + if vm["name"] in self._final: + continue + if self._domstate.get(vm["name"]) == "paused": + paused += 1 + else: + running += 1 + return { + "total": len(vms), "done": done, "fail": fail, + "deleted": deleted, "running": running, "paused": paused, + "err_vms": err_vms, "warn_vms": warn_vms, + } + + def _update_stats(self): + c = self._stats_counts() + line = ( + f" 📊 {c['total']} VM · ✅ {c['done']} · ❌ {c['fail']} · " + f"⏳ {c['running']} · ⏸ {c['paused']} · 🗑 {c['deleted']} · " + f"⚠ {c['err_vms']} · ⚡ {c['warn_vms']} " + f"({t('click to expand')})" + ) + self.query_one("#stats", Static).update(line) + if self._stats_open: + self.query_one("#statsdetail", Static).update( + self._render_stats_detail() + ) + + def _render_stats_detail(self): + """Détail déplié : VM avec erreurs/avertissements + moyennes hist.""" + lines = [] + for name, (state, code, el) in self._final.items(): + counts = self._errcount.get(name) + if counts and (counts[0] or counts[1]): + lines.append( + f" • {name}: ⚠{counts[0]} erreurs, " + f"⚡{counts[1]} avert. ({self._fmt(el)})" + ) + if not lines: + lines.append(f" {t('No error detected.')}") + return "\n".join(lines) + async def _tick_log(self): if not self._follow: return @@ -767,6 +915,58 @@ def run_monitor(manifest_path: str, run_app: bool = True): title="Presse-papiers", ) + def on_click(self, event) -> None: + # Clic sur le sommaire de stats -> déplie / replie le détail. + w = getattr(event, "widget", None) + if w is not None and getattr(w, "id", None) == "stats": + self._stats_open = not self._stats_open + self.query_one("#statsdetail").display = self._stats_open + self._update_stats() + + # -- pause / reprise de tout le parc -------------------------------- # + @staticmethod + def _virsh_bulk(action, names): + for n in names: + try: + subprocess.run( + ["sudo", "virsh", action, n], + capture_output=True, + text=True, + timeout=30, + ) + except (OSError, subprocess.SubprocessError): + pass + + def action_pause_all(self) -> None: + """Met en PAUSE (virsh suspend) toutes les VM en cours d'exécution. + L'install reprend là où elle en était après « Reprendre ».""" + self.run_worker(self._bulk_worker("suspend"), exclusive=False) + + def action_resume_all(self) -> None: + """REPREND (virsh resume) toutes les VM en pause ; le suivi des + logs continue automatiquement (offsets conservés).""" + self.run_worker(self._bulk_worker("resume"), exclusive=False) + + async def _bulk_worker(self, action): + want = "running" if action == "suspend" else "paused" + targets = [ + vm["name"] + for vm in vms + if self._domstate.get(vm["name"]) == want + ] + if not targets: + self.notify( + t("No running VM to pause.") + if action == "suspend" + else t("No paused VM to resume.") + ) + return + await asyncio.to_thread(self._virsh_bulk, action, targets) + verb = t("paused") if action == "suspend" else t("resumed") + self.notify(f"{len(targets)} VM {verb}.") + # Rafraîchit tout de suite l'état libvirt (pause/reprise visible). + self.run_worker(self._tick_domstate(), exclusive=False) + app = Monitor() if run_app: app.run() diff --git a/script/todo/todo.py b/script/todo/todo.py index 585fbd8..fee956f 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -1611,7 +1611,7 @@ class TODO: d'échec SSH, propose le repli par CONSOLE SÉRIE (commande à coller).""" remote = self._GROW_FS_REMOTE real = self._qemu_domname(name) - # Résolution d'IP robuste (parallèle + battement toutes les 30 s) + # 1) SSH : IP résolue avec BATTEMENT (parallèle, boot émulé lent) # plutôt qu'un simple timeout court qui abandonnait trop tôt. ip = self._qemu_resolve_ips([real], timeout=300).get(real) if ip: @@ -1623,11 +1623,86 @@ class TODO: print(f"{t('Will execute:')} {cmd}") if self.execute.exec_command_live(cmd, source_erplibre=False) == 0: return - print(f"⚠ {t('SSH grow failed; falling back to serial console.')}") + print(f"⚠ {t('SSH grow failed; trying the guest agent.')}") else: - print(t("No IP; falling back to serial console.")) + print(t("No IP; trying the guest agent (no network).")) + # 2) Agent invité (virtio, SANS réseau) — nécessite qemu-guest-agent + # dans la VM (installé au déploiement) + guest-exec autorisé. + res = self._qemu_guest_exec(real, remote) + if res is not None: + rc, out = res + if out.strip(): + print(out.rstrip()) + if rc == 0: + print(f"✅ {t('Guest filesystem grown via guest agent.')}") + return + print(f"⚠ {t('Guest agent grow failed; falling back to console.')}") + else: + print(t("Guest agent unavailable; falling back to serial console.")) + # 3) Console série (commande prête à coller, login interactif). self._qemu_grow_via_console(real, remote) + def _qemu_guest_exec(self, name, script, wait=180): + """Exécute `script` (sh -c) DANS la VM via l'AGENT INVITÉ (canal + virtio, sans réseau). Renvoie (code_sortie, sortie) ou None si l'agent + est indisponible / guest-exec refusé.""" + import base64 + + def agent(payload): + try: + res = subprocess.run( + [ + "sudo", "virsh", "qemu-agent-command", + name, json.dumps(payload), + ], + capture_output=True, + text=True, + timeout=30, + ) + except (OSError, subprocess.SubprocessError): + return None + if res.returncode != 0: + return None + try: + return json.loads(res.stdout).get("return") + except ValueError: + return None + + if agent({"execute": "guest-ping"}) is None: + return None + start = agent( + { + "execute": "guest-exec", + "arguments": { + "path": "/bin/sh", + "arg": ["-c", script], + "capture-output": True, + }, + } + ) + if not start or "pid" not in start: + return None + pid = start["pid"] + deadline = time.time() + wait + print(t("Running via guest agent (no network)…")) + while time.time() < deadline: + st = agent( + {"execute": "guest-exec-status", "arguments": {"pid": pid}} + ) + if st and st.get("exited"): + out = "" + for k in ("out-data", "err-data"): + if st.get(k): + try: + out += base64.b64decode(st[k]).decode( + errors="replace" + ) + except Exception: + pass + return st.get("exitcode", 0), out + time.sleep(2) + return None + def _qemu_grow_via_console(self, name, remote): """Repli console série : affiche la commande prête à coller puis ouvre la console (login interactif erplibre/erplibre — pas d'automatisation diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 487b3ed..1be05e5 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -1424,6 +1424,50 @@ TRANSLATIONS = { "fr": "Impossible de lire la taille actuelle du disque ; abandon.", "en": "Could not read current disk size; aborting.", }, + "click to expand": { + "fr": "cliquer pour déplier", + "en": "click to expand", + }, + "No error detected.": { + "fr": "Aucune erreur détectée.", + "en": "No error detected.", + }, + "No running VM to pause.": { + "fr": "Aucune VM en cours à mettre en pause.", + "en": "No running VM to pause.", + }, + "No paused VM to resume.": { + "fr": "Aucune VM en pause à reprendre.", + "en": "No paused VM to resume.", + }, + "resumed": { + "fr": "reprise(s)", + "en": "resumed", + }, + "SSH grow failed; trying the guest agent.": { + "fr": "Échec SSH ; tentative via l'agent invité.", + "en": "SSH grow failed; trying the guest agent.", + }, + "No IP; trying the guest agent (no network).": { + "fr": "Pas d'IP ; tentative via l'agent invité (sans réseau).", + "en": "No IP; trying the guest agent (no network).", + }, + "Guest filesystem grown via guest agent.": { + "fr": "FS invité étendu via l'agent invité.", + "en": "Guest filesystem grown via guest agent.", + }, + "Guest agent grow failed; falling back to console.": { + "fr": "Échec de l'agent invité ; repli sur la console.", + "en": "Guest agent grow failed; falling back to console.", + }, + "Guest agent unavailable; falling back to serial console.": { + "fr": "Agent invité indisponible ; repli sur la console série.", + "en": "Guest agent unavailable; falling back to serial console.", + }, + "Running via guest agent (no network)…": { + "fr": "Exécution via l'agent invité (sans réseau)…", + "en": "Running via guest agent (no network)…", + }, "SSH grow failed; falling back to serial console.": { "fr": "Échec de l'extension via SSH ; repli sur la console série.", "en": "SSH grow failed; falling back to serial console.",