From 6a24687fe030583387fb0b5cb1df91b8ee4d17b0 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sat, 22 Aug 2026 06:13:45 -0400 Subject: [PATCH 01/16] =?UTF-8?q?[ADD]=20suivi=20:=20les=20statistiques=20?= =?UTF-8?q?de=20chaque=20VM=20(=C3=A9criture,=20RAM,=20disque)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le tableau disait la durée et la taille du disque. Il ne disait pas si une VM TRAVAILLAIT : une installation figée et une qui compile s'y ressemblaient. Trois chiffres par VM, d'un seul appel « virsh domstats » pour tout le parc (0,03 s) : ce qu'elle écrit, sa RAM occupée/totale, son disque occupé/total. Le débit est une moyenne sur DIX secondes — le disque d'une installation travaille par rafales, et l'instantané n'y montrait que des 0 et des pics. Le ballon mémoire est réarmé au tour lent : sans période de collecte, libvirt rend le dernier rapport du pilote, vieux d'une demi-heure. Et cinq caractères récupérés sur trois colonnes trop larges font tenir la ligne en 150 colonnes. --- EN --- The table showed elapsed time and disk size. It did not show whether a VM was WORKING: a stalled install and a compiling one looked alike. Three numbers per VM, from a single "virsh domstats" call for the whole fleet (0.03 s): bytes written, RAM used/total, disk used/total. The write figure is a TEN-second average — an install's disk works in bursts, and the snapshot showed only zeros and spikes. The memory balloon is re-armed on the slow tick: with no collection period, libvirt hands back the driver's last report, half an hour old. And five characters reclaimed from three oversized columns keep the row inside 150. Assisted-by: Claude Opus 5 --- script/todo/qemu_install_monitor.py | 350 +++++++++++++++++++++++++- script/todo/todo_i18n.py | 12 + test/test_qemu_monitor_vmstats.py | 372 ++++++++++++++++++++++++++++ 3 files changed, 723 insertions(+), 11 deletions(-) create mode 100644 test/test_qemu_monitor_vmstats.py diff --git a/script/todo/qemu_install_monitor.py b/script/todo/qemu_install_monitor.py index a00420c..17255f9 100644 --- a/script/todo/qemu_install_monitor.py +++ b/script/todo/qemu_install_monitor.py @@ -1059,6 +1059,245 @@ def virsh_domstates() -> dict: return states +# Fenêtre du débit d'écriture. Dix secondes : le disque d'une VM en +# installation travaille par rafales — « apt » décompresse, « poetry » compile, +# puis plus rien pendant trois secondes. Un instantané de deux secondes affiche +# donc tantôt 0, tantôt 200 Mo/s, et ne dit rien. Sur dix secondes, le chiffre +# devient un débit qu'on peut comparer d'une VM à l'autre. +WRITE_WINDOW = 10.0 + +# Âge maximal d'un relevé du ballon mémoire. Au-delà, la valeur est TAISÉE +# plutôt que montrée : virtio-balloon ne publie ses compteurs que si une +# période de collecte est armée, et une valeur figée depuis une demi-heure +# ferait croire à une VM au repos alors qu'elle compile. +BALLOON_MAX_AGE = 30.0 + +# Les compteurs du ballon sont en kibioctets ; ceux des blocs, en octets. +_KIB = 1024 + + +def parse_domstats(text: str) -> dict: + """Sortie de « virsh domstats » -> {nom: relevé}. + + Un relevé porte : ram_used / ram_total (octets), ram_at (horodatage du + dernier rapport du ballon), wr_bytes (cumul écrit depuis le démarrage du + processus QEMU), disk_used / disk_total (octets). + + Le seed ISO est ÉCARTÉ des disques : monté en lecture seule, il n'est + jamais écrit, et sa capacité (quelques mégaoctets) l'aurait fait passer + pour le disque système sur une VM dont le qcow2 n'est pas encore alloué. + """ + out = {} + nom = None + brut = {} + + def clore(): + if nom is None: + return + rec = { + "ram_used": 0, + "ram_total": 0, + "ram_at": 0, + "wr_bytes": 0, + "disk_used": 0, + "disk_total": 0, + } + dispo = brut.get("balloon.available") + util = brut.get("balloon.usable") + if dispo: + rec["ram_total"] = dispo * _KIB + if util is not None: + rec["ram_used"] = max(0, (dispo - util)) * _KIB + rec["ram_at"] = brut.get("balloon.last-update") or 0 + # Disques : on parcourt les index déclarés par block.count. + meilleur = 0 + for i in range(int(brut.get("block.count") or 0)): + chemin = brut.get(f"block.{i}.path.str") or "" + if chemin.lower().endswith(".iso"): + continue + rec["wr_bytes"] += brut.get(f"block.{i}.wr.bytes") or 0 + cap = brut.get(f"block.{i}.capacity") or 0 + if cap >= meilleur: + meilleur = cap + rec["disk_total"] = cap + rec["disk_used"] = brut.get(f"block.{i}.allocation") or 0 + out[nom] = rec + + for ligne in (text or "").splitlines(): + ligne = ligne.strip() + if ligne.startswith("Domain:"): + clore() + nom = ligne.split("'")[1] if "'" in ligne else None + brut = {} + continue + if nom is None or "=" not in ligne: + continue + cle, _, val = ligne.partition("=") + try: + brut[cle] = int(val) + except ValueError: + # Les valeurs non numériques (chemins, noms de device) sont + # gardées à part : « block.0.path » en est une, et c'est elle qui + # démasque le seed ISO. + brut[f"{cle}.str"] = val + clore() + return out + + +class WriteWindow: + """Débit d'écriture moyen par VM, sur une fenêtre glissante.""" + + def __init__(self, window=WRITE_WINDOW): + self.window = window + self._hist = {} + + def add(self, name, wr_bytes, now): + hist = self._hist.setdefault(name, []) + # Un compteur qui RECULE veut dire que le domaine a redémarré : le + # processus QEMU est neuf, ses compteurs repartent de zéro. Sans ce + # garde, le débit affiché serait négatif, puis énorme au relevé + # suivant. Une installation redémarre la VM : le cas est la règle. + if hist and wr_bytes < hist[-1][1]: + hist.clear() + hist.append((now, wr_bytes)) + limite = now - self.window + while len(hist) > 2 and hist[1][0] < limite: + hist.pop(0) + + def rate(self, name): + """Octets/s, ou None tant que la fenêtre n'a pas de quoi conclure.""" + hist = self._hist.get(name) or [] + if len(hist) < 2: + return None + span = hist[-1][0] - hist[0][0] + if span < 1.0: + return None + return max(0.0, (hist[-1][1] - hist[0][1]) / span) + + def total(self, name): + """Écrit depuis le premier relevé de la fenêtre (octets).""" + hist = self._hist.get(name) or [] + return hist[-1][1] if hist else None + + +def fmt_rate(bps) -> str: + """Octets/s -> « 12.3M/s ». « - » tant qu'on ne sait pas.""" + return "-" if bps is None else f"{_fmt_size(int(bps))}/s" + + +def _fmt_tight(nbytes) -> str: + """Comme _fmt_size, mais sans décimale au-delà de dix unités. + + « 63G » plutôt que « 62.6G » : dans une colonne de tableau, ces deux + caractères décident si « Disque » reste visible ou sort de l'écran, et la + décimale n'apprend rien à côté d'un total de 65 Go. + """ + if nbytes is None: + return "-" + for unit, div in (("T", 1 << 40), ("G", 1 << 30), ("M", 1 << 20)): + if nbytes >= div: + val = nbytes / div + return f"{val:.0f}{unit}" if val >= 10 else f"{val:.1f}{unit}" + return f"{max(0, int(nbytes)) // 1024}K" + + +def fmt_pair(used, total) -> str: + """« 1.1G/12G », « 63G/65G ». « - » si le total manque : « ?/12G » + n'informe pas.""" + if not total: + return "-" + return f"{_fmt_tight(used)}/{_fmt_tight(total)}" + + +def fmt_pct(used, total) -> str: + return f" ({int(used / total * 100)}%)" if total else "" + + +def ram_pair(rec, now, max_age=BALLOON_MAX_AGE) -> str: + """RAM utilisée/totale de la VM, ou « - » si le relevé est PÉRIMÉ.""" + if not rec or not rec.get("ram_total"): + return "-" + at = rec.get("ram_at") or 0 + if at and now - at > max_age: + return "-" + return fmt_pair(rec.get("ram_used"), rec["ram_total"]) + + +def vm_stats_line(name, rec, bps, now, ecrit=None) -> str: + """Section statistiques d'UNE VM, en une ligne dense. + + Le tableau porte les mêmes chiffres en colonnes, pour tout le parc d'un + coup d'œil ; cette ligne les détaille pour la VM sélectionnée — celle dont + le journal et la commande SSH sont déjà affichés. + """ + if not rec: + return f" 📊 {name} · {t('no statistics yet')}" + bits = [f"✍ {fmt_rate(bps)} ({t('10s average')})"] + if ecrit: + bits.append(f"{t('total written')} {_fmt_size(ecrit)}") + ram = ram_pair(rec, now) + if ram != "-": + bits.append( + f"🧠 RAM {ram}{fmt_pct(rec['ram_used'], rec['ram_total'])}" + ) + if rec.get("disk_total"): + bits.append( + f"💾 {t('disk')} " + f"{fmt_pair(rec['disk_used'], rec['disk_total'])}" + f"{fmt_pct(rec['disk_used'], rec['disk_total'])}" + ) + return f" 📊 {name} · " + " · ".join(bits) + + +def read_domstats() -> str: + """Sortie brute de « virsh domstats --balloon --block » (tout le parc). + + UN appel pour toutes les VM — 0,03 s mesuré sur deux domaines. Le suivi + relève toutes les deux secondes : une commande par VM y coûterait N + processus à chaque tour.""" + try: + res = subprocess.run( + ["sudo", "virsh", "domstats", "--balloon", "--block"], + capture_output=True, + text=True, + timeout=15, + env={**os.environ, "LC_ALL": "C", "LANG": "C"}, + ) + except (OSError, subprocess.SubprocessError): + return "" + return res.stdout if res.returncode == 0 else "" + + +def arm_balloon(names) -> None: + """Arme la période de collecte du ballon (5 s) sur chaque VM. + + Sans elle, « balloon.available » et « balloon.usable » restent FIGÉS sur le + dernier rapport du pilote : mesuré sur une VM fraîche, 388 Mo annoncés + contre 1,1 Go réellement occupés, avec un horodatage vieux d'une + demi-heure. La période se perd quand le domaine redémarre — ce qu'une + installation fait — donc on la réarme à intervalle lent. + """ + for name in names or (): + try: + subprocess.run( + [ + "sudo", + "virsh", + "dommemstat", + name, + "--period", + "5", + "--live", + ], + capture_output=True, + text=True, + timeout=10, + env={**os.environ, "LC_ALL": "C", "LANG": "C"}, + ) + except (OSError, subprocess.SubprocessError): + continue + + # --------------------------------------------------------------------------- # # Dashboard Textual # --------------------------------------------------------------------------- # @@ -1068,13 +1307,26 @@ def virsh_domstates() -> dict: # que ce qui était affiché au départ. COL_DEFAULT_WIDTHS = { "seq": 3, - "vm": 22, - "arch": 7, + # 21 : un caractère de moins que l'ancien 22, et c'est lui qui fait tenir + # la ligne entière sur un terminal de 150 colonnes. « + » l'élargit, et + # c'est déjà la colonne visée par défaut. + "vm": 21, + # 5 : « amd64 », « s390x », « arm64 » font cinq caractères, et l'en-tête + # « Arch » quatre. Les deux de plus ne servaient rien. + "arch": 5, "err": 4, "state": 8, - "odoo": 6, - "elapsed": 7, - "disk": 8, + # 4 : une icône (🟢) ou un tiret, sous un en-tête de quatre lettres. + "odoo": 4, + # 6 : « 125:30 » est le pire cas d'une installation de deux heures. + "elapsed": 6, + # Section statistiques de la VM : ce qu'elle écrit, sa RAM, son disque. + # « 12.3M/s » tient en 7 et « 1.1G/12G » en 9 : au-delà, « Disque » sortait + # de l'écran sur un terminal de 150 colonnes, moitié prise par le journal. + "wr": 7, + # 10 et non 9 : sur une VM de 128 Go, « 1001M/128G » fait dix caractères. + "ram": 10, + "disk": 9, } @@ -1369,7 +1621,7 @@ def run_monitor(manifest_path: str, run_app: bool = True): que devinable, et « max-width » laisse la table suivre l'élargissement des colonnes sans manger tout l'écran. */ DataTable { - width: auto; max-width: 60%; height: 1fr; + width: auto; max-width: 66%; height: 1fr; overflow-x: auto; overflow-y: auto; scrollbar-size-horizontal: 1; scrollbar-size-vertical: 1; border: solid $accent; @@ -1444,6 +1696,10 @@ def run_monitor(manifest_path: str, run_app: bool = True): ) # État libvirt (running/paused/gone), rafraîchi à intervalle LENT. self._domstate = {} + # Statistiques par VM : dernier relevé libvirt, et la fenêtre + # glissante qui en tire un débit d'écriture. + self._vmstats = {} + self._wrate = WriteWindow() # Erreurs détectées dans le log à la complétion : {nom: (err, warn)}. self._errcount = {} # Sommaire de stats déplié (clic) ou non. @@ -1498,6 +1754,13 @@ def run_monitor(manifest_path: str, run_app: bool = True): table.add_column( "Durée", key="elapsed", width=COL_DEFAULT_WIDTHS["elapsed"] ) + # Statistiques de la VM. « Écrit/s » est une MOYENNE sur dix + # secondes : le disque d'une installation travaille par rafales, + # et l'instantané n'y montrait que des 0 et des pics. + table.add_column( + "Écrit/s", key="wr", width=COL_DEFAULT_WIDTHS["wr"] + ) + table.add_column("RAM", key="ram", width=COL_DEFAULT_WIDTHS["ram"]) table.add_column( "Disque", key="disk", width=COL_DEFAULT_WIDTHS["disk"] ) @@ -1511,6 +1774,8 @@ def run_monitor(manifest_path: str, run_app: bool = True): "—", "--:--", "-", + "-", + "-", key=vm["name"], ) # max_lines borne la mémoire/rendu (un install verbeux × 30 VM). @@ -1525,6 +1790,11 @@ def run_monitor(manifest_path: str, run_app: bool = True): # Sommaire de stats en CHIFFRES (cliquable -> détail). yield Static("", id="stats") yield Static("", id="statsdetail") + # Section statistiques de la VM SÉLECTIONNÉE : les mêmes chiffres + # que ses colonnes, mais détaillés (pourcentages, cumul écrit). + # Une ligne par VM du parc aurait chassé le pied de page dès cinq + # machines ; la sélection suit déjà le journal et la barre SSH. + yield Static("", id="vmstats") yield Static("", id="sshbar") yield Footer() @@ -1536,6 +1806,7 @@ def run_monitor(manifest_path: str, run_app: bool = True): ) self.sub_title = f"0/{len(vms)} {t('completed')}" self._refresh_ssh() + self._refresh_vmstats() self._load_selected_log(reset=True) # Table toutes les 2 s (30 lectures de fin de log), suivi du log # sélectionné toutes les 1 s (une seule lecture incrémentale). @@ -1551,6 +1822,25 @@ def run_monitor(manifest_path: str, run_app: bool = True): def _vm_by_name(self, name): return next((v for v in vms if v["name"] == name), None) + def _refresh_vmstats(self): + """Ligne de statistiques de la VM sélectionnée.""" + name = self._selected + if not name: + return + try: + bar = self.query_one("#vmstats", Static) + except Exception: + return + bar.update( + vm_stats_line( + name, + self._vmstats.get(name), + self._wrate.rate(name), + time.time(), + self._wrate.total(name), + ) + ) + def _refresh_ssh(self): vm = self._vm_by_name(self._selected) bar = self.query_one("#sshbar", Static) @@ -1612,9 +1902,27 @@ def run_monitor(manifest_path: str, run_app: bool = True): """(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, errors, odoo = {}, {}, {}, {} + # UN appel virsh pour tout le parc, dans ce thread : le débit se + # calcule sur les relevés successifs, donc il faut échantillonner + # à chaque tour (2 s) et non au rythme lent des états. + stats = parse_domstats(read_domstats()) + now_s = time.time() + for name, rec in stats.items(): + self._wrate.add(name, rec["wr_bytes"], now_s) + self._vmstats = stats + wr, ram = {}, {} for vm in vms: name = vm["name"] - disks[name] = _fmt_size(disk_actual_size(vm_disk_path(vm))) + rec = stats.get(name) + wr[name] = fmt_rate(self._wrate.rate(name)) + ram[name] = ram_pair(rec, now_s) + if rec and rec.get("disk_total"): + disks[name] = fmt_pair(rec["disk_used"], rec["disk_total"]) + else: + # Domaine pas encore défini (conversion de l'image, tout + # début de l'installation) : le qcow2 existe déjà, et + # st_blocks dit ce qu'il occupe. Sans total à annoncer. + disks[name] = _fmt_size(disk_actual_size(vm_disk_path(vm))) if ( name not in self._final and self._domstate.get(name) != "gone" @@ -1634,16 +1942,22 @@ def run_monitor(manifest_path: str, run_app: bool = True): ): if _port_open(vm.get("ip"), 8069): odoo[name] = True - return disks, status, self._collect_tele(), errors, odoo + return disks, status, self._collect_tele(), errors, odoo, wr, ram 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, errors, odoo = await asyncio.to_thread( - self._collect_table - ) + ( + disks, + status, + tele, + errors, + odoo, + wr, + ram, + ) = await asyncio.to_thread(self._collect_table) except Exception: return self._errcount.update(errors) @@ -1655,6 +1969,8 @@ def run_monitor(manifest_path: str, run_app: bool = True): for vm in vms: name = vm["name"] self._set_cell(table, name, "disk", disks.get(name, "-")) + self._set_cell(table, name, "wr", wr.get(name, "-")) + self._set_cell(table, name, "ram", ram.get(name, "-")) # Colonne Odoo : 🟢 dès que :8069 répond, sinon « — ». self._set_cell( table, @@ -1743,6 +2059,9 @@ def run_monitor(manifest_path: str, run_app: bool = True): if tele: self.query_one("#telemetry", Static).update(tele) self._update_stats() + # Les chiffres de la VM sélectionnée viennent d'être relevés : + # sa section les redit ici, détaillés. + self._refresh_vmstats() except Exception: pass @@ -1849,6 +2168,14 @@ def run_monitor(manifest_path: str, run_app: bool = True): return for vm in vms: self._domstate[vm["name"]] = states.get(vm["name"], "gone") + # Réarmer la période du ballon sur les VM qui tournent : sans elle + # la RAM affichée serait celle du dernier rapport du pilote, et une + # installation redémarre la VM — ce qui remet la période à zéro. + vivantes = [ + vm["name"] for vm in vms if states.get(vm["name"]) == "running" + ] + if vivantes: + await asyncio.to_thread(arm_balloon, vivantes) # L'adresse est relue au même rythme. Le processus détaché suivait # déjà la VM quand son bail changeait, mais les VUES gardaient celle @@ -1889,6 +2216,7 @@ def run_monitor(manifest_path: str, run_app: bool = True): return self._selected = name self._refresh_ssh() + self._refresh_vmstats() self._load_selected_log(reset=True) def action_follow(self) -> None: diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 89918d7..014a81d 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -3103,6 +3103,18 @@ TRANSLATIONS = { "fr": "Nœud de rendu", "en": "Render node", }, + "no statistics yet": { + "fr": "pas encore de statistiques", + "en": "no statistics yet", + }, + "10s average": { + "fr": "moyenne 10 s", + "en": "10s average", + }, + "total written": { + "fr": "cumul écrit", + "en": "total written", + }, "Screens": { "fr": "Écrans", "en": "Screens", diff --git a/test/test_qemu_monitor_vmstats.py b/test/test_qemu_monitor_vmstats.py new file mode 100644 index 0000000..6066280 --- /dev/null +++ b/test/test_qemu_monitor_vmstats.py @@ -0,0 +1,372 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Statistiques par VM du suivi d'installation : écriture, RAM, disque. + +Trois chiffres par VM, tirés d'UN appel « virsh domstats » pour tout le parc : +ce qu'elle écrit (moyenne sur dix secondes), sa RAM occupée/totale, son disque +occupé/total. + +Ce que ces tests gardent, appris en les construisant contre des VM réelles : + +- Le seed ISO doit être ÉCARTÉ des disques. Monté en lecture seule, il n'est + jamais écrit, et sa capacité minuscule l'aurait fait passer pour le disque + système d'une VM dont le qcow2 n'est pas encore alloué. +- Un compteur d'écriture qui RECULE veut dire que le domaine a redémarré — + ce qu'une installation fait. Sans garde, le débit devient négatif. +- Le ballon mémoire ne publie ses compteurs que si une période de collecte est + armée. Un relevé vieux d'une demi-heure doit être TAISÉ, pas affiché : + mesuré, il annonçait 388 Mo pour une VM qui occupait 1,1 Go. +- La ligne du tableau doit tenir : trois colonnes de plus, et « Disque » + sortait de l'écran sur un terminal de 150 colonnes. +""" + +import json +import tempfile +import time +import unittest +from pathlib import Path +from unittest import mock + +from script.todo import qemu_install_monitor as mon +from script.todo.qemu_install_monitor import ( + BALLOON_MAX_AGE, + COL_DEFAULT_WIDTHS, + WRITE_WINDOW, + WriteWindow, + _fmt_tight, + fmt_pair, + fmt_rate, + parse_domstats, + ram_pair, + vm_stats_line, +) + +# Sortie RÉELLE de « virsh domstats --balloon --block » sur deux VM du parc : +# une VM de migration en travail, et son seed ISO en second périphérique. +DOMSTATS = """Domain: 'erplibre-ubuntu-2404-MIGRATION' + balloon.current=12582912 + balloon.maximum=12582912 + balloon.swap_in=0 + balloon.unused=936072 + balloon.available=12242352 + balloon.usable=11064740 + balloon.last-update=1787392761 + balloon.rss=12623896 + block.count=2 + block.0.name=vda + block.0.path=/var/lib/libvirt/images/erplibre-ubuntu-2404.qcow2 + block.0.rd.bytes=9664722944 + block.0.wr.reqs=2969857 + block.0.wr.bytes=165796498432 + block.0.allocation=67263266816 + block.0.capacity=69793218560 + block.0.physical=67265261568 + block.1.name=vdb + block.1.path=/var/lib/libvirt/images/iso/erplibre-ubuntu-2404-seed.iso + block.1.wr.bytes=4096 + block.1.allocation=380928 + block.1.capacity=380928 +Domain: 'erplibre-ubuntu-2604' + balloon.current=12582912 + balloon.available=12237092 + balloon.usable=11054184 + balloon.last-update=1787392782 + block.count=1 + block.0.name=vda + block.0.path=/var/lib/libvirt/images/erplibre-ubuntu-2604.qcow2 + block.0.wr.bytes=48117251072 + block.0.allocation=43900928000 + block.0.capacity=69793218560 +""" + +MIGRATION = "erplibre-ubuntu-2404-MIGRATION" + + +class TestLecture(unittest.TestCase): + def test_it_reads_both_domains(self): + st = parse_domstats(DOMSTATS) + self.assertEqual({MIGRATION, "erplibre-ubuntu-2604"}, set(st)) + + def test_ram_is_available_minus_usable(self): + """Calibrée en son temps contre le « free » de deux invités : c'est + « available - usable » qui suit ce qu'ils occupent, pas « unused », + qui compte le cache.""" + rec = parse_domstats(DOMSTATS)[MIGRATION] + self.assertEqual(12242352 * 1024, rec["ram_total"]) + self.assertEqual((12242352 - 11064740) * 1024, rec["ram_used"]) + + def test_the_seed_iso_is_left_out_of_the_disk(self): + """Sa capacité (372 Ko) l'emporterait sur un qcow2 pas encore alloué, + et le tableau annoncerait un disque plein à 100 %.""" + rec = parse_domstats(DOMSTATS)[MIGRATION] + self.assertEqual(69793218560, rec["disk_total"]) + self.assertEqual(67263266816, rec["disk_used"]) + + def test_the_seed_iso_writes_are_left_out_too(self): + """4096 octets écrits sur un ISO en lecture seule : anecdotique ici, + mais compter un périphérique qu'on n'affiche pas rend un débit qu'on + ne peut relier à rien.""" + rec = parse_domstats(DOMSTATS)[MIGRATION] + self.assertEqual(165796498432, rec["wr_bytes"]) + + def test_a_single_disk_vm_is_read_too(self): + rec = parse_domstats(DOMSTATS)["erplibre-ubuntu-2604"] + self.assertEqual(48117251072, rec["wr_bytes"]) + self.assertEqual(69793218560, rec["disk_total"]) + + def test_empty_or_broken_output_gives_nothing(self): + for texte in ("", "erreur: pas de connexion", None): + self.assertEqual({}, parse_domstats(texte)) + + def test_a_domain_without_balloon_stats_is_not_invented(self): + """Une VM éteinte, ou dont le pilote n'a rien publié : pas de RAM + plutôt qu'un zéro qui passerait pour une mesure.""" + rec = parse_domstats("Domain: 'x'\n block.count=0\n")["x"] + self.assertEqual(0, rec["ram_total"]) + self.assertEqual("-", ram_pair(rec, time.time())) + + +class TestFenetreEcriture(unittest.TestCase): + def test_two_samples_ten_seconds_apart_give_the_rate(self): + w = WriteWindow(10.0) + w.add("vm", 0, 1000.0) + w.add("vm", 10 << 20, 1010.0) + self.assertAlmostEqual(1 << 20, w.rate("vm"), delta=1024) + + def test_one_sample_is_not_a_rate(self): + """Au premier tour, la colonne doit dire « - » et non « 0 » : un débit + nul et un débit inconnu ne se ressemblent pas.""" + w = WriteWindow() + w.add("vm", 12345, 1000.0) + self.assertIsNone(w.rate("vm")) + self.assertEqual("-", fmt_rate(w.rate("vm"))) + + def test_samples_too_close_together_are_not_a_rate(self): + """Deux relevés à 200 ms d'intervalle donnent un chiffre absurde ; + mieux vaut attendre le tour suivant.""" + w = WriteWindow() + w.add("vm", 0, 1000.0) + w.add("vm", 1 << 20, 1000.2) + self.assertIsNone(w.rate("vm")) + + def test_the_window_keeps_about_ten_seconds(self): + """Sinon la moyenne porterait sur toute la durée de l'installation, et + ne montrerait plus ce qui se passe MAINTENANT.""" + w = WriteWindow(10.0) + # Une minute de relevés toutes les deux secondes, débit constant. + for i in range(31): + w.add("vm", i * (2 << 20), 1000.0 + 2 * i) + span = w._hist["vm"][-1][0] - w._hist["vm"][0][0] + self.assertLessEqual(span, 12.0) + self.assertGreaterEqual(span, 10.0) + self.assertAlmostEqual(1 << 20, w.rate("vm"), delta=1024) + + def test_a_restart_resets_the_window_instead_of_going_negative(self): + """Le compteur appartient au processus QEMU : un redémarrage du domaine + le remet à zéro, et une installation redémarre la VM.""" + w = WriteWindow(10.0) + w.add("vm", 500 << 20, 1000.0) + w.add("vm", 501 << 20, 1002.0) + w.add("vm", 1 << 20, 1004.0) # domaine redémarré + self.assertIsNone(w.rate("vm")) + w.add("vm", 3 << 20, 1014.0) + self.assertIsNotNone(w.rate("vm")) + self.assertGreater(w.rate("vm"), 0) + + def test_vms_do_not_mix(self): + w = WriteWindow(10.0) + w.add("a", 0, 1000.0) + w.add("b", 0, 1000.0) + w.add("a", 10 << 20, 1010.0) + w.add("b", 1 << 20, 1010.0) + self.assertAlmostEqual(1 << 20, w.rate("a"), delta=1024) + self.assertAlmostEqual(1 << 20, w.rate("b") * 10, delta=10240) + + def test_the_window_is_the_ten_seconds_announced(self): + """La colonne dit « moyenne 10 s » : la constante doit le tenir.""" + self.assertEqual(10.0, WRITE_WINDOW) + + +class TestFraicheurRam(unittest.TestCase): + def test_a_stale_balloon_report_is_hidden(self): + rec = parse_domstats(DOMSTATS)[MIGRATION] + vieux = rec["ram_at"] + BALLOON_MAX_AGE + 5 + self.assertEqual("-", ram_pair(rec, vieux)) + + def test_a_fresh_report_is_shown(self): + rec = parse_domstats(DOMSTATS)[MIGRATION] + self.assertEqual("1.1G/12G", ram_pair(rec, rec["ram_at"] + 2)) + + def test_a_report_without_a_date_is_still_shown(self): + """libvirt joint toujours « last-update » quand le ballon a parlé ; + sans elle on ne sait pas juger, et taire la valeur perdrait la seule + information disponible.""" + rec = dict(parse_domstats(DOMSTATS)[MIGRATION], ram_at=0) + self.assertNotEqual("-", ram_pair(rec, time.time())) + + +class TestFormats(unittest.TestCase): + def test_the_decimal_goes_away_above_ten_units(self): + """« 63G » plutôt que « 62.6G » : ces deux caractères décident si + « Disque » reste visible.""" + self.assertEqual("63G", _fmt_tight(67263266816)) + self.assertEqual("1.1G", _fmt_tight(1130479616)) + self.assertEqual("12G", _fmt_tight(12536168448)) + + def test_a_pair_without_a_total_says_nothing(self): + self.assertEqual("-", fmt_pair(1024, 0)) + self.assertEqual("-", fmt_pair(None, None)) + + def test_the_rate_carries_its_unit(self): + self.assertEqual("1.0M/s", fmt_rate(1 << 20)) + self.assertEqual("-", fmt_rate(None)) + + def test_the_pairs_fit_the_columns(self): + """Une valeur tronquée dans un tableau est un piège : on lit « 1001M/1 » + et on croit une VM à 1 Go.""" + for used, total, cle in ( + (1001 << 20, 128 << 30, "ram"), + (12 << 30, 128 << 30, "ram"), + (999 << 30, 1 << 40, "disk"), + (67263266816, 69793218560, "disk"), + ): + self.assertLessEqual( + len(fmt_pair(used, total)), COL_DEFAULT_WIDTHS[cle] + ) + for bps in (0, 1 << 10, 12.3 * (1 << 20), 2 << 30): + self.assertLessEqual(len(fmt_rate(bps)), COL_DEFAULT_WIDTHS["wr"]) + + +class TestSection(unittest.TestCase): + def test_it_names_the_three_measures(self): + rec = parse_domstats(DOMSTATS)[MIGRATION] + ligne = vm_stats_line( + MIGRATION, rec, 1 << 20, rec["ram_at"] + 1, rec["wr_bytes"] + ) + self.assertIn(MIGRATION, ligne) + self.assertIn("1.0M/s", ligne) + self.assertIn("1.1G/12G", ligne) + self.assertIn("63G/65G", ligne) + + def test_it_shows_percentages(self): + """« 63G/65G » ne dit pas d'un coup d'œil que le disque est à 96 %.""" + rec = parse_domstats(DOMSTATS)[MIGRATION] + ligne = vm_stats_line(MIGRATION, rec, 0, rec["ram_at"] + 1) + self.assertIn("(96%)", ligne) + self.assertIn("(9%)", ligne) + + def test_a_vm_without_stats_says_so(self): + ligne = vm_stats_line("vm-x", None, None, time.time()) + self.assertIn("vm-x", ligne) + self.assertNotIn("RAM", ligne) + + +class TestColonnes(unittest.TestCase): + def test_the_three_columns_exist(self): + for cle in ("wr", "ram", "disk"): + self.assertIn(cle, COL_DEFAULT_WIDTHS) + + def test_the_row_fits_a_150_column_terminal(self): + """La table est bornée à 66 % de l'écran, et le journal prend le reste. + Trois colonnes de plus, et « Disque » sortait du cadre : le total est + donc surveillé, padding de DataTable compris (2 par colonne).""" + besoin = sum(COL_DEFAULT_WIDTHS.values()) + 2 * len(COL_DEFAULT_WIDTHS) + self.assertLessEqual(besoin, int(150 * 0.66)) + + +class TestEcranMonte(unittest.IsolatedAsyncioTestCase): + """Le suivi monté pour de vrai : les colonnes portent-elles les chiffres ? + + Aucun appel à libvirt : « read_domstats » rend la sortie enregistrée + ci-dessus. Un test qui lance sudo ne tournerait ni en CI ni sur un poste + sans le parc. + """ + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + base = Path(self.tmp.name) + for nom in (MIGRATION, "erplibre-ubuntu-2604"): + (base / f"{nom}.log").write_text("===> install\nligne\n") + manifeste = { + "branch": "develop", + "started": time.time() - 300, + "vms": [ + { + "name": nom, + "ip": "127.0.0.9", + "arch": "amd64", + "log": str(base / f"{nom}.log"), + "ssh": f"ssh erplibre@{nom}", + "disk": str(base / f"{nom}.qcow2"), + } + for nom in (MIGRATION, "erplibre-ubuntu-2604") + ], + } + self.manifest = base / "session.json" + self.manifest.write_text(json.dumps(manifeste)) + # Le relevé du ballon doit paraître FRAIS, sinon la colonne RAM se + # taira — ce qui est le comportement voulu, mais pas ce qu'on teste. + self.stats = DOMSTATS.replace( + "balloon.last-update=1787392761", + f"balloon.last-update={int(time.time())}", + ).replace( + "balloon.last-update=1787392782", + f"balloon.last-update={int(time.time())}", + ) + + async def _monte(self, taille=(150, 24)): + from textual.widgets import DataTable, Static + + with mock.patch.object( + mon, "read_domstats", lambda: self.stats + ), mock.patch.object( + mon, "arm_balloon", lambda names: None + ), mock.patch.object( + mon, + "virsh_domstates", + lambda: {MIGRATION: "running", "erplibre-ubuntu-2604": "running"}, + ), mock.patch.object( + mon, "_port_open", lambda ip, port=8069, timeout=0.5: False + ): + app = mon.run_monitor(str(self.manifest), run_app=False) + async with app.run_test(size=taille) as pilot: + await pilot.pause() + # Le tick du tableau tourne toutes les deux secondes : on + # l'appelle plutôt que d'attendre, et on antidate un premier + # relevé pour que la fenêtre ait de quoi conclure. + app._wrate.add(MIGRATION, 100 << 20, time.time() - 10) + await app._tick_table() + await pilot.pause() + table = app.query_one("#vms", DataTable) + cellules = { + cle: str(table.get_cell(MIGRATION, cle)) + for cle in ("wr", "ram", "disk") + } + section = str(app.query_one("#vmstats", Static).content) + besoin = table.virtual_size.width + visible = table.size.width + return cellules, section, besoin, visible + + async def test_the_columns_carry_the_numbers(self): + cellules, _section, _b, _v = await self._monte() + self.assertEqual("1.1G/12G", cellules["ram"]) + self.assertEqual("63G/65G", cellules["disk"]) + self.assertTrue(cellules["wr"].endswith("/s"), cellules["wr"]) + + async def test_the_selected_vm_has_its_statistics_section(self): + _c, section, _b, _v = await self._monte() + self.assertIn(MIGRATION, section) + self.assertIn("1.1G/12G", section) + self.assertIn("63G/65G", section) + + async def test_nothing_scrolls_out_of_a_150_column_terminal(self): + """Le vrai garde-fou de la largeur : mesuré sur la table montée, pas + calculé à la main.""" + _c, _s, besoin, visible = await self._monte((150, 24)) + self.assertGreaterEqual(visible, besoin) + + +if __name__ == "__main__": + unittest.main(verbosity=1) From 5da0ebaed30b94c56fc10b4f56832c8ebff05c2c Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sat, 22 Aug 2026 22:32:34 -0400 Subject: [PATCH 02/16] [FIX] sshfs : n'annoncer un montage que s'il a eu lieu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Après un code 1, le menu affichait « Monté sur … », la commande pour démonter et celle pour ouvrir le répertoire : le montage n'avait pas eu lieu, et on cherchait des fichiers dans un répertoire vide. Le succès ne s'affiche plus qu'en cas de succès, et le point de montage inutilisé est retiré. L'échec, lui, avait une cause : sshfs lit « a+b » comme un chaînage d'hôtes et ne consulte jamais ~/.ssh/config pour l'alias entier. Or c'est todo.py qui nomme les VM « rebond+domaine », et cette seconde moitié est un domaine libvirt, pas un alias SSH du rebond. On résout donc l'alias par « ssh -G » et on rend à sshfs une cible qu'il ne peut plus mal lire, ProxyJump comprise. --- EN --- After exit code 1, the menu still printed "Mounted on …", the unmount command and the file-manager command: nothing had been mounted, and one went looking for files in an empty directory. The success block now only prints on success, and the unused mount point is removed. The failure itself had a cause: sshfs reads "a+b" as host chaining and never consults ~/.ssh/config for the whole alias. But todo.py is what names VMs "jump+domain", and that second half is a libvirt domain, not an SSH alias on the jump host. So we resolve the alias with "ssh -G" and hand sshfs a target it can no longer misread, ProxyJump included. Assisted-by: Claude Opus 5 --- script/todo/todo.py | 270 +++++++++++++++++++++++---- script/todo/todo_i18n.py | 64 +++++++ test/test_todo_sshfs.py | 389 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 692 insertions(+), 31 deletions(-) create mode 100644 test/test_todo_sshfs.py diff --git a/script/todo/todo.py b/script/todo/todo.py index ef32dc5..aac50f4 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -9442,6 +9442,214 @@ class TODO: pass print(f"\n {t('Tunnel closed.')}") + # sshfs lit « a+b » comme un CHAÎNAGE d'hôtes — « ssh a, puis ssh b depuis + # a » — et ne consulte donc PAS ~/.ssh/config pour l'alias entier. Or c'est + # todo.py qui nomme les VM découvertes « jump+domaine » (voir la marche + # SSH) : ce sont les alias les plus utiles, et les seuls que sshfs échoue à + # monter tel quel. Vécu : « read: Connection reset by peer », parce que la + # seconde moitié du nom est un domaine libvirt, pas un alias SSH du rebond. + SSHFS_CHAIN_SEP = "+" + + # Options à rendre à sshfs quand on contourne l'alias : exactement celles + # que todo.py écrit dans l'entrée qu'il génère. Sans elles, une VM dont la + # clé d'hôte a changé — IP DHCP réutilisée — ferait échouer le montage. + SSH_FORWARD_OPTS = ( + ("port", "Port"), + ("proxyjump", "ProxyJump"), + ("identityfile", "IdentityFile"), + ("identitiesonly", "IdentitiesOnly"), + ("stricthostkeychecking", "StrictHostKeyChecking"), + ("userknownhostsfile", "UserKnownHostsFile"), + ) + + # Ce que dit stderr, et ce qu'il faut aller corriger. L'ordre compte : le + # premier motif trouvé gagne. + SSH_FAILURE_HINTS = ( + ("could not resolve hostname", "unknown host name: check HostName"), + ("name or service not known", "unknown host name: check HostName"), + ("connection timed out", "no answer: is the server up and reachable?"), + ("operation timed out", "no answer: is the server up and reachable?"), + ("no route to host", "no route: check the network or the ProxyJump"), + ("connection refused", "nothing listening on the SSH port"), + ("permission denied", "authentication refused: check User and key"), + ("host key verification failed", "host key changed for this address"), + ) + + @staticmethod + def _ssh_config_entries(path): + """[(alias, {hostname, user})] de ~/.ssh/config, dans l'ordre du fichier. + + Pendant de `_ssh_config_hosts`, qui ne rend que les NOMS : ici le menu + de montage a besoin d'afficher aussi l'adresse et l'utilisateur. + + « Host a b » déclare DEUX alias pour la même machine — c'est ce que + todo.py écrit lui-même quand une VM porte plusieurs noms. Les prendre + pour un seul nom donnait un alias « a b », que sshfs ne peut pas + monter. Les motifs génériques (« * », « web-? ») sont écartés : ils ne + désignent aucune machine. + """ + hosts = [] + noms = [] + info = {} + + def clore(): + for nom in noms: + hosts.append((nom, dict(info))) + + try: + with open(path, encoding="utf-8", errors="replace") as fh: + lignes = fh.readlines() + except OSError: + return [] + for ligne in lignes: + ligne = ligne.strip() + if ligne.lower().startswith("host "): + clore() + noms = [ + m + for m in ligne.split()[1:] + if "*" not in m and "?" not in m and not m.startswith("!") + ] + info = {} + elif noms: + paire = ligne.split(None, 1) + if len(paire) == 2 and paire[0].lower() in ( + "hostname", + "user", + ): + info[paire[0].lower()] = paire[1].strip() + clore() + return hosts + + @staticmethod + def _ssh_resolve(alias): + """Configuration RÉSOLUE de l'alias, telle que ssh la voit (ssh -G). + + On délègue à ssh au lieu de relire le fichier : lui seul connaît les + Include, les Match, l'ordre des motifs et ses propres défauts. + """ + try: + res = subprocess.run( + ["ssh", "-G", alias], + capture_output=True, + text=True, + timeout=15, + env=TODO._qemu_c_env(), + ) + except (OSError, subprocess.SubprocessError): + return {} + if res.returncode != 0: + return {} + out = {} + for ligne in res.stdout.splitlines(): + cle, _, val = ligne.strip().partition(" ") + # ssh -G répète « identityfile » : la PREMIÈRE est celle qui compte. + if cle and val and cle.lower() not in out: + out[cle.lower()] = val + return out + + def _sshfs_command(self, alias, mount_point, resolved=None): + """(commande sshfs, alias contourné ?) pour monter cet alias. + + Sans « + » dans le nom, on laisse sshfs faire : c'est ssh qui lit la + config, et rien ne vaut mieux. Avec un « + », on résout l'alias + soi-même et on rend à sshfs une cible qu'il ne peut plus mal lire. + """ + base = "sshfs -o follow_symlinks" + if self.SSHFS_CHAIN_SEP not in alias: + return f"{base} {alias}:/ {mount_point}", False + cfg = resolved if resolved is not None else self._ssh_resolve(alias) + host = cfg.get("hostname") + # Un hostname qui contient encore un « + » ne réglerait rien, et un + # alias non résolu vaut mieux qu'une cible inventée. + if not host or self.SSHFS_CHAIN_SEP in host: + return f"{base} {alias}:/ {mount_point}", False + opts = [] + for cle, nom in self.SSH_FORWARD_OPTS: + val = cfg.get(cle) + if val and val.lower() != "none": + opts.append(f"-o {nom}={val}") + user = cfg.get("user") + cible = f"{user}@{host}" if user else host + pieces = [base] + opts + [f"{cible}:/", mount_point] + return " ".join(pieces), True + + @classmethod + def _ssh_failure_hint(cls, stderr): + """Première ligne utile de stderr, et ce qu'elle désigne.""" + texte = (stderr or "").lower() + for motif, indice in cls.SSH_FAILURE_HINTS: + if motif in texte: + return indice + return "" + + @staticmethod + def _ssh_probe(alias, timeout=8): + """(code, stderr) d'un « ssh true » sans invite de mot de passe. + + BatchMode : une invite bloquerait le menu. Un refus d'authentification + se distingue donc d'un hôte injoignable, et le diagnostic le dit. + """ + try: + res = subprocess.run( + [ + "ssh", + "-o", + "BatchMode=yes", + "-o", + f"ConnectTimeout={timeout}", + alias, + "true", + ], + capture_output=True, + text=True, + timeout=timeout + 12, + env=TODO._qemu_c_env(), + ) + except subprocess.TimeoutExpired: + return 255, "Connection timed out" + except (OSError, subprocess.SubprocessError) as exc: + return 255, str(exc) + return res.returncode, res.stderr.strip() + + def _sshfs_diagnose(self, alias, mount_point, bypassed): + """Dit POURQUOI le montage a échoué, et où aller corriger. + + Le message est ciblé, pas une liste de causes possibles : on interroge + ssh, et selon qu'il passe ou non, le fautif n'est pas le même. + """ + print(f"\n ⚠ {t('sshfs mount failed.')}") + if not alias: + print(f" → {t('Check the SSH host and that the server is up.')}") + return + print(f" {t('Checking SSH access…')} ({alias})") + code, err = self._ssh_probe(alias) + if code == 0: + print(f" ✓ {t('SSH reaches this host: ~/.ssh/config is fine.')}") + if not bypassed and self.SSHFS_CHAIN_SEP in alias: + print(f" → {t('sshfs reads the « + » as host chaining.')}") + cmd, ok = self._sshfs_command(alias, mount_point) + if ok: + print(f" → {t('Run this instead:')}") + print(f" {cmd}") + else: + # Annoncer une commande puis n'en donner aucune serait + # pire que se taire : on dit ce qui manque. + print( + f" → {t('ssh -G resolved nothing: check ~/.ssh/config.')}" + ) + else: + print(f" → {t('Is sshfs (and fuse) installed here?')}") + return + indice = self._ssh_failure_hint(err) + if indice: + print(f" ✗ {t('SSH fails too:')} {t(indice)}") + else: + print( + f" ✗ {t('SSH fails too:')} {err.splitlines()[0] if err else code}" + ) + print(f" → {t('Update ~/.ssh/config, or check the server is up.')}") + def _configure_sshfs(self): import getpass import re @@ -9458,31 +9666,7 @@ class TODO: if choice == "2": ssh_config_path = os.path.expanduser("~/.ssh/config") - hosts = [] - if os.path.exists(ssh_config_path): - current_host = None - current_info = {} - with open(ssh_config_path) as f: - for line in f: - line = line.strip() - if line.lower().startswith("host "): - host_val = line.split(None, 1)[1].strip() - if host_val != "*": - if current_host: - hosts.append((current_host, current_info)) - current_host = host_val - current_info = {} - elif current_host: - key = line.split(None, 1) - if len(key) == 2: - k = key[0].lower() - v = key[1].strip() - if k == "hostname": - current_info["hostname"] = v - elif k == "user": - current_info["user"] = v - if current_host: - hosts.append((current_host, current_info)) + hosts = self._ssh_config_entries(ssh_config_path) if not hosts: print(t("No SSH hosts found in ~/.ssh/config")) @@ -9539,17 +9723,41 @@ class TODO: # une chaîne de symlinks relatifs profonds (.repo/projects -> project- # objects) que git ne peut pas traverser sur un montage sshfs par # défaut (« erreur à la lecture de .git » -> git status/commit KO). - cmd = f"sshfs -o follow_symlinks {target} {mount_point}" + # L'alias vient de ~/.ssh/config : c'est lui qui peut porter un « + », + # et lui qu'on peut interroger en cas d'échec. Une saisie manuelle est + # rendue telle quelle — si elle contient un « + », c'est un chaînage + # demandé exprès. + alias = ssh_name if choice == "2" else "" + if alias: + cmd, bypassed = self._sshfs_command(alias, mount_point) + else: + cmd, bypassed = ( + f"sshfs -o follow_symlinks {target} {mount_point}", + False, + ) print(f"{t('Mounting sshfs on: ')}{mount_point}") print(f"{t('Will execute:')} {cmd}") try: - self.execute.exec_command_live(cmd, source_erplibre=False) - print(f"{t('Mounted on: ')}{mount_point}") - print(f"mount | grep sshfs") - print(f"{t('To unmount: ')}" f"fusermount -u {mount_point}") - print(f"nautilus {mount_point}/home/{user}") + status = self.execute.exec_command_live(cmd, source_erplibre=False) except Exception as e: print(f"{t('Error mounting sshfs: ')}{e}") + status = 1 + # Le reste ne s'affiche QUE si le montage a réussi : « Monté sur … » + # après un code 1 envoyait chercher des fichiers dans un répertoire + # vide, et faisait passer l'échec pour un détail. + if status: + self._sshfs_diagnose(alias, mount_point, bypassed) + # Le point de montage n'a jamais servi : le laisser accumulerait + # un répertoire vide dans /tmp à chaque tentative. + try: + os.rmdir(mount_point) + except OSError: + pass + return + print(f"{t('Mounted on: ')}{mount_point}") + print("mount | grep sshfs") + print(f"{t('To unmount: ')}" f"fusermount -u {mount_point}") + print(f"nautilus {mount_point}/home/{user}") def _get_ssh_params(self): """Prompt for SSH connection parameters. Returns dict or None on cancel.""" diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 014a81d..fa00db3 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -3103,6 +3103,70 @@ TRANSLATIONS = { "fr": "Nœud de rendu", "en": "Render node", }, + "sshfs mount failed.": { + "fr": "Le montage sshfs a échoué.", + "en": "sshfs mount failed.", + }, + "Checking SSH access…": { + "fr": "Vérification de l'accès SSH…", + "en": "Checking SSH access…", + }, + "SSH reaches this host: ~/.ssh/config is fine.": { + "fr": "SSH joint cet hôte : ~/.ssh/config est bon.", + "en": "SSH reaches this host: ~/.ssh/config is fine.", + }, + "sshfs reads the « + » as host chaining.": { + "fr": "sshfs lit le « + » comme un chaînage d'hôtes.", + "en": "sshfs reads the « + » as host chaining.", + }, + "Run this instead:": { + "fr": "À lancer plutôt :", + "en": "Run this instead:", + }, + "ssh -G resolved nothing: check ~/.ssh/config.": { + "fr": "« ssh -G » n'a rien résolu : vérifier ~/.ssh/config.", + "en": "ssh -G resolved nothing: check ~/.ssh/config.", + }, + "Is sshfs (and fuse) installed here?": { + "fr": "sshfs (et fuse) sont-ils installés ici ?", + "en": "Is sshfs (and fuse) installed here?", + }, + "SSH fails too:": { + "fr": "SSH échoue aussi :", + "en": "SSH fails too:", + }, + "Update ~/.ssh/config, or check the server is up.": { + "fr": "Mettre à jour ~/.ssh/config, ou vérifier que le serveur répond.", + "en": "Update ~/.ssh/config, or check the server is up.", + }, + "Check the SSH host and that the server is up.": { + "fr": "Vérifier l'hôte SSH saisi et que le serveur répond.", + "en": "Check the SSH host and that the server is up.", + }, + "unknown host name: check HostName": { + "fr": "nom d'hôte inconnu : vérifier HostName", + "en": "unknown host name: check HostName", + }, + "no answer: is the server up and reachable?": { + "fr": "aucune réponse : le serveur est-il allumé et joignable ?", + "en": "no answer: is the server up and reachable?", + }, + "no route: check the network or the ProxyJump": { + "fr": "pas de route : vérifier le réseau ou le ProxyJump", + "en": "no route: check the network or the ProxyJump", + }, + "nothing listening on the SSH port": { + "fr": "rien n'écoute sur le port SSH", + "en": "nothing listening on the SSH port", + }, + "authentication refused: check User and key": { + "fr": "authentification refusée : vérifier User et la clé", + "en": "authentication refused: check User and key", + }, + "host key changed for this address": { + "fr": "la clé d'hôte a changé pour cette adresse", + "en": "host key changed for this address", + }, "no statistics yet": { "fr": "pas encore de statistiques", "en": "no statistics yet", diff --git a/test/test_todo_sshfs.py b/test/test_todo_sshfs.py new file mode 100644 index 0000000..934e109 --- /dev/null +++ b/test/test_todo_sshfs.py @@ -0,0 +1,389 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Montage sshfs : ne rien annoncer qui n'ait eu lieu, et dire pourquoi. + +Le symptôme rapporté : « read: Connection reset by peer », « code : 1 », et +juste après « Monté sur /tmp/sshfs_… », la commande pour démonter et celle +pour ouvrir le répertoire dans un explorateur. Le montage n'avait pas eu lieu. + +La cause, elle, est plus profonde : sshfs lit « a+b » comme un CHAÎNAGE +d'hôtes — « ssh a, puis ssh b depuis a » — et ne consulte donc jamais +~/.ssh/config pour l'alias entier. Or c'est todo.py qui nomme les VM +découvertes « rebond+domaine », et la seconde moitié de ce nom est un domaine +libvirt, pas un alias SSH du rebond. Ces alias-là, les plus utiles, étaient +les seuls que sshfs ne pouvait pas monter. + +Ce que ces tests gardent : + +- Aucune ligne de succès après un code non nul. +- Le point de montage inutilisé est retiré, pas laissé dans /tmp. +- Un alias à « + » est résolu par ssh lui-même (« ssh -G ») et rendu à sshfs + sous une forme qu'il ne peut plus mal lire, options comprises — sans + StrictHostKeyChecking, une VM à l'IP recyclée échouerait sur sa clé d'hôte. +- « Host a b » déclare DEUX alias : c'est ce que le générateur du dépôt écrit. +""" + +import contextlib +import io +import os +import subprocess +import sys +import tempfile +import unittest +from unittest import mock + +sys.argv = ["todo.py"] +from script.todo.todo import TODO # noqa: E402 +from script.todo.todo_i18n import t # noqa: E402 + +# Entrée réelle écrite par todo.py pour une VM derrière un rebond. +CONFIG = """Host * + ServerAliveInterval 60 + +Host novipro_private + HostName 192.168.100.110 + User mathben + +Host novipro_private+ERPLibre01 + HostName 192.168.122.50 + User mathben + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + IdentityFile /home/erplibre/.ssh/id_ed25519 + IdentitiesOnly yes + ProxyJump novipro_private + +Host erplibre-ubuntu-2604 erplibre-2604-bis + HostName 192.168.123.165 + User erplibre + +Host web-? + User www +""" + +# Sortie de « ssh -G » pour l'alias à « + », réduite à ce qui compte. +SSH_G = """host novipro_private+erplibre01 +hostname 192.168.122.50 +user mathben +port 22 +proxyjump novipro_private +identityfile /home/erplibre/.ssh/id_ed25519 +identityfile ~/.ssh/id_rsa +identitiesonly yes +stricthostkeychecking false +userknownhostsfile /dev/null +""" + + +def _config(texte=CONFIG): + tmp = tempfile.NamedTemporaryFile( + "w", suffix=".config", delete=False, encoding="utf-8" + ) + tmp.write(texte) + tmp.close() + return tmp.name + + +class TestLectureConfig(unittest.TestCase): + def setUp(self): + self.chemin = _config() + self.addCleanup(os.unlink, self.chemin) + + def test_it_reads_hosts_in_file_order(self): + hosts = TODO._ssh_config_entries(self.chemin) + noms = [n for n, _i in hosts] + self.assertEqual("novipro_private", noms[0]) + self.assertIn("novipro_private+ERPLibre01", noms) + + def test_a_host_line_with_two_patterns_gives_two_aliases(self): + """C'est ce que le générateur du dépôt écrit (« Host {' '.join(names)} »). + Les prendre pour un seul nom donnait l'alias « a b », que sshfs ne peut + pas monter — et qui n'existe pour personne.""" + hosts = dict(TODO._ssh_config_entries(self.chemin)) + self.assertIn("erplibre-ubuntu-2604", hosts) + self.assertIn("erplibre-2604-bis", hosts) + self.assertEqual( + "192.168.123.165", hosts["erplibre-ubuntu-2604"]["hostname"] + ) + self.assertEqual( + hosts["erplibre-ubuntu-2604"], hosts["erplibre-2604-bis"] + ) + + def test_wildcard_patterns_are_left_out(self): + """« Host * » et « Host web-? » ne désignent aucune machine : les + proposer dans un menu de montage n'a pas de sens.""" + noms = [n for n, _i in TODO._ssh_config_entries(self.chemin)] + self.assertNotIn("*", noms) + self.assertNotIn("web-?", noms) + + def test_the_plus_alias_stays_one_name(self): + noms = [n for n, _i in TODO._ssh_config_entries(self.chemin)] + self.assertNotIn("novipro_private", noms[1:2] and []) + self.assertIn("novipro_private+ERPLibre01", noms) + + def test_hostname_and_user_are_kept(self): + hosts = dict(TODO._ssh_config_entries(self.chemin)) + info = hosts["novipro_private+ERPLibre01"] + self.assertEqual("192.168.122.50", info["hostname"]) + self.assertEqual("mathben", info["user"]) + + def test_a_missing_file_is_not_a_crash(self): + self.assertEqual([], TODO._ssh_config_entries("/nexistepas/config")) + + +class TestResolution(unittest.TestCase): + def test_it_reads_ssh_dash_g(self): + with mock.patch( + "subprocess.run", + return_value=subprocess.CompletedProcess([], 0, SSH_G, ""), + ): + cfg = TODO._ssh_resolve("novipro_private+ERPLibre01") + self.assertEqual("192.168.122.50", cfg["hostname"]) + self.assertEqual("novipro_private", cfg["proxyjump"]) + + def test_the_first_identityfile_wins(self): + """ssh -G les répète toutes ; la première est celle qu'il essaiera.""" + with mock.patch( + "subprocess.run", + return_value=subprocess.CompletedProcess([], 0, SSH_G, ""), + ): + cfg = TODO._ssh_resolve("x") + self.assertEqual("/home/erplibre/.ssh/id_ed25519", cfg["identityfile"]) + + def test_a_failing_ssh_resolves_to_nothing(self): + with mock.patch( + "subprocess.run", + return_value=subprocess.CompletedProcess([], 255, "", "bad"), + ): + self.assertEqual({}, TODO._ssh_resolve("x")) + with mock.patch("subprocess.run", side_effect=OSError): + self.assertEqual({}, TODO._ssh_resolve("x")) + + +class TestCommandeSshfs(unittest.TestCase): + def setUp(self): + # Méthode d'instance : elle interroge self._ssh_resolve quand aucune + # résolution ne lui est fournie. + self.todo = TODO.__new__(TODO) + self.todo._ssh_resolve = lambda alias: {} + + def test_an_alias_without_plus_is_handed_over_untouched(self): + """Sans « + », c'est ssh qui lit la config, et rien ne vaut mieux.""" + cmd, contourne = self.todo._sshfs_command("vm-a", "/tmp/mnt") + self.assertEqual("sshfs -o follow_symlinks vm-a:/ /tmp/mnt", cmd) + self.assertFalse(contourne) + + def _resolue(self): + return { + "hostname": "192.168.122.50", + "user": "mathben", + "port": "22", + "proxyjump": "novipro_private", + "identityfile": "/home/erplibre/.ssh/id_ed25519", + "identitiesonly": "yes", + "stricthostkeychecking": "false", + "userknownhostsfile": "/dev/null", + } + + def test_a_plus_alias_becomes_a_resolved_target(self): + cmd, contourne = self.todo._sshfs_command( + "novipro_private+ERPLibre01", "/tmp/mnt", self._resolue() + ) + self.assertTrue(contourne) + self.assertIn("mathben@192.168.122.50:/", cmd) + self.assertNotIn("+", cmd) + + def test_the_options_that_matter_travel_with_it(self): + """Sans ProxyJump, la VM est injoignable ; sans StrictHostKeyChecking, + une IP DHCP recyclée fait échouer le montage sur sa clé d'hôte.""" + cmd, _ = self.todo._sshfs_command("a+b", "/tmp/mnt", self._resolue()) + for attendu in ( + "-o ProxyJump=novipro_private", + "-o Port=22", + "-o IdentityFile=/home/erplibre/.ssh/id_ed25519", + "-o IdentitiesOnly=yes", + "-o StrictHostKeyChecking=false", + "-o UserKnownHostsFile=/dev/null", + ): + self.assertIn(attendu, cmd) + + def test_options_ssh_reports_as_none_are_not_forwarded(self): + """« ssh -G » écrit « proxyjump none » quand il n'y en a pas ; le + transmettre ferait échouer ssh sur une valeur qu'il vient d'inventer. + """ + cfg = dict(self._resolue(), proxyjump="none") + cmd, _ = self.todo._sshfs_command("a+b", "/tmp/mnt", cfg) + self.assertNotIn("ProxyJump", cmd) + + def test_an_unresolvable_alias_is_left_alone(self): + """Mieux vaut la commande d'origine, qui échouera en le disant, qu'une + cible inventée qui monterait la mauvaise machine.""" + cmd, contourne = self.todo._sshfs_command("a+b", "/tmp/mnt", {}) + self.assertEqual("sshfs -o follow_symlinks a+b:/ /tmp/mnt", cmd) + self.assertFalse(contourne) + + def test_the_mount_point_stays_last(self): + """La syntaxe de sshfs : cible puis point de montage. Une option glissée + après monterait ailleurs.""" + cmd, _ = self.todo._sshfs_command("a+b", "/tmp/mnt", self._resolue()) + self.assertTrue(cmd.endswith(" /tmp/mnt"), cmd) + + +class TestDiagnostic(unittest.TestCase): + def test_each_kind_of_failure_names_its_culprit(self): + cas = ( + ( + "ssh: Could not resolve hostname zz: Name or service not known", + "unknown host name: check HostName", + ), + ( + "ssh: connect to host x port 22: Connection timed out", + "no answer: is the server up and reachable?", + ), + ( + "ssh: connect to host x port 22: No route to host", + "no route: check the network or the ProxyJump", + ), + ( + "ssh: connect to host x port 22: Connection refused", + "nothing listening on the SSH port", + ), + ( + "mathben@x: Permission denied (publickey).", + "authentication refused: check User and key", + ), + ( + "Host key verification failed.", + "host key changed for this address", + ), + ) + for stderr, attendu in cas: + self.assertEqual(attendu, TODO._ssh_failure_hint(stderr), stderr) + + def test_an_unknown_error_is_not_guessed(self): + """Rien à dire plutôt qu'un diagnostic inventé : la ligne brute de ssh + sera affichée telle quelle.""" + self.assertEqual("", TODO._ssh_failure_hint("sshfs: fuse: bidule")) + self.assertEqual("", TODO._ssh_failure_hint("")) + + +class TestFlux(unittest.TestCase): + """Le parcours complet, exécuteur bouchonné.""" + + def _todo(self, code, probe=(0, "")): + todo = TODO.__new__(TODO) + todo.lances = [] + exe = mock.Mock() + + def lance(cmd, **kw): + todo.lances.append(cmd) + return code + + exe.exec_command_live = lance + todo.execute = exe + todo._ssh_probe = lambda alias, timeout=8: probe + todo._ssh_resolve = lambda alias: { + "hostname": "192.168.122.50", + "user": "mathben", + "proxyjump": "novipro_private", + } + return todo + + def _joue(self, todo, config, selection="2"): + chemin = _config(config) + self.addCleanup(os.unlink, chemin) + reponses = iter(["2", selection]) + crees = [] + vrai_makedirs = os.makedirs + vrai_rmdir = os.rmdir + + def makedirs(path, **kw): + crees.append(path) + return vrai_makedirs(path, **kw) + + retires = [] + + def rmdir(path): + retires.append(path) + return vrai_rmdir(path) + + out = io.StringIO() + with mock.patch( + "builtins.input", lambda *a: next(reponses, "") + ), mock.patch( + "os.path.expanduser", + lambda p: chemin if p.endswith("config") else p, + ), mock.patch( + "os.makedirs", makedirs + ), mock.patch( + "os.rmdir", rmdir + ): + with contextlib.redirect_stdout(out): + todo._configure_sshfs() + return out.getvalue(), crees, retires + + def test_a_failed_mount_announces_nothing_mounted(self): + """Le cœur du problème rapporté : « Monté sur … » après un code 1.""" + todo = self._todo(1) + sortie, _crees, _retires = self._joue(todo, CONFIG, "2") + self.assertNotIn(t("Mounted on: "), sortie) + self.assertNotIn("fusermount", sortie) + self.assertNotIn("nautilus", sortie) + self.assertIn(t("sshfs mount failed."), sortie) + + def test_a_failed_mount_leaves_no_empty_directory(self): + """Une tentative par jour pendant un mois laissait trente répertoires + vides dans /tmp.""" + todo = self._todo(1) + _s, crees, retires = self._joue(todo, CONFIG, "2") + self.assertEqual(crees, retires) + for chemin in retires: + self.assertFalse(os.path.exists(chemin)) + + def test_a_successful_mount_says_how_to_unmount(self): + todo = self._todo(0) + sortie, crees, retires = self._joue(todo, CONFIG, "2") + self.assertIn(t("Mounted on: "), sortie) + self.assertIn("fusermount -u", sortie) + self.assertEqual([], retires) + for chemin in crees: + self.addCleanup(lambda p=chemin: os.path.isdir(p) and os.rmdir(p)) + + def test_ssh_working_points_at_the_plus_not_at_the_network(self): + """Quand ssh joint l'hôte, ce n'est pas le réseau : c'est sshfs qui a + mal lu l'alias. Le message doit envoyer là, et donner la commande.""" + todo = self._todo(1, probe=(0, "")) + # L'alias à « + » est le troisième de la config (après le rebond seul). + sortie, _c, _r = self._joue(todo, CONFIG, "2") + self.assertIn( + t("SSH reaches this host: ~/.ssh/config is fine."), sortie + ) + + def test_ssh_failing_sends_to_the_config_or_the_server(self): + todo = self._todo( + 1, + probe=( + 255, + "ssh: connect to host x port 22: Connection timed out", + ), + ) + sortie, _c, _r = self._joue(todo, CONFIG, "2") + self.assertIn(t("no answer: is the server up and reachable?"), sortie) + self.assertIn( + t("Update ~/.ssh/config, or check the server is up."), sortie + ) + + def test_the_plus_alias_is_bypassed_before_being_run(self): + """Le vrai correctif : la commande lancée ne contient plus le « + ».""" + todo = self._todo(0) + # [1] novipro_private · [2] novipro_private+ERPLibre01 · [3] la VM + _s, _c, _r = self._joue(todo, CONFIG, "2") + lancee = todo.lances[0] + self.assertIn("mathben@192.168.122.50:/", lancee) + self.assertIn("-o ProxyJump=novipro_private", lancee) + self.assertNotIn("+ERPLibre01", lancee) + + +if __name__ == "__main__": + unittest.main(verbosity=1) From 803f1ea8ce28d5795c5fad2d9155e8b126cc12fc Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sun, 23 Aug 2026 00:00:22 -0400 Subject: [PATCH 03/16] [ADD] migration: decode percent-encoded page anchors before the 13 bump MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenUpgrade's website post-migration gathers the href of every page anchor and glues them into a CSS selector: "selector": ", ".join([link.attrib["href"] for link in links]) An href is not a selector. A French anchor written #principes-mn%C3%A9- moniques puts a % in it, which no CSS identifier may hold, and the whole migration dies on SelectorSyntaxError. Proven by replaying that exact code on the database: three views failed before, one of them with the % at position 53 -- the very position in the log. After the fix, three views processed, none failed. Decoding is enough: the parser accepts #principes-mnémoniques and refuses the encoded form. Only page views, only anchor hrefs, only %XX in hex. A % elsewhere in a URL is legitimate and stays. The decoder lives in pg_temp and leaves nothing behind. --- FR --- La post-migration website d'OpenUpgrade ramasse les href des ancres d'une page et les recolle en sélecteur CSS. Or un href n'est pas un sélecteur : une ancre française encodée y met un %, interdit dans un identifiant CSS, et la migration meurt. Prouvé en rejouant ce code exact sur la base : trois vues échouaient, dont une avec le % en position 53 — celle du journal. Après correctif, trois vues traitées, zéro échec. Décoder suffit : le parseur accepte #principes-mnémoniques et refuse la forme encodée. Seulement les vues de page, seulement les href d'ancre, seulement les %XX hexadécimaux. Un % ailleurs dans une URL est légitime et reste. Le décodeur vit dans pg_temp et ne laisse rien derrière lui. Assisted-by: Claude Opus 5 --- .../fix_migration_odoo120_to_odoo130.sql | 87 +++++++ test/test_fix_migration_120_to_130.py | 225 ++++++++++++++++++ 2 files changed, 312 insertions(+) create mode 100644 script/odoo/migration/fix_migration_odoo120_to_odoo130.sql create mode 100644 test/test_fix_migration_120_to_130.py diff --git a/script/odoo/migration/fix_migration_odoo120_to_odoo130.sql b/script/odoo/migration/fix_migration_odoo120_to_odoo130.sql new file mode 100644 index 0000000..24ea722 --- /dev/null +++ b/script/odoo/migration/fix_migration_odoo120_to_odoo130.sql @@ -0,0 +1,87 @@ +-- © 2021-2026 TechnoLibre (http://www.technolibre.ca) +-- License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +-- +-- Correctifs à appliquer AVANT qu'OpenUpgrade ne migre vers Odoo 13. +-- +-- En SQL et non en Python : ce fichier tourne sur une base encore en 12, +-- que le code de la 13 ne saurait pas charger. + +-- Ancres de page encodées en pourcentage. +-- +-- Le script d'OpenUpgrade `website/migrations/13.0.1.0/post-migration.py` +-- ramasse les `href` des ancres d'une page et les RECOLLE en sélecteur CSS : +-- +-- links = doc.cssselect(r"a[href^=\#]:not([href=\#])") +-- "selector": ", ".join([link.attrib["href"] for link in links]) +-- +-- Or un `href` n'est pas un sélecteur. Une ancre française encodée — +-- `#principes-mn%C3%A9moniques` — y introduit un `%`, interdit dans un +-- identifiant CSS, et la migration meurt : +-- +-- cssselect.parser.SelectorSyntaxError: Expected selector, got +-- +-- Éprouvé avec le parseur d'Odoo 13 : `#principes-mnémoniques` passe, +-- `#principes-mn%C3%A9moniques` non. Décoder suffit donc, et un accent +-- reste un identifiant CSS valide. +-- +-- On ne touche QUE les vues qu'OpenUpgrade parcourt — celles qui portent +-- une page — et QUE les `href` d'ancre. Un `%` ailleurs dans une URL est +-- légitime et reste intact. +-- +-- Rejouable : une fois décodé il n'y a plus de `%XX` à trouver. + +-- Le décodeur vit dans `pg_temp` : il disparaît avec la session psql, et +-- ne laisse rien derrière lui dans la base du client. +CREATE FUNCTION pg_temp.el_url_decode(entree text) RETURNS text AS $decode$ +DECLARE + octets bytea = ''; + morceau text; +BEGIN + -- Deux à deux : « %C3 » devient un octet, tout autre caractère se + -- recopie tel quel. On rassemble en bytea AVANT de convertir, car un + -- caractère accenté tient sur deux octets et les décoder séparément + -- rendrait deux caractères illisibles. + FOR morceau IN + SELECT (regexp_matches(entree, '(%[0-9A-Fa-f]{2}|.)', 'g'))[1] + LOOP + IF length(morceau) = 3 AND left(morceau, 1) = '%' THEN + octets = octets || decode(substring(morceau, 2, 2), 'hex'); + ELSE + octets = octets || convert_to(morceau, 'UTF8'); + END IF; + END LOOP; + RETURN convert_from(octets, 'UTF8'); +END +$decode$ LANGUAGE plpgsql IMMUTABLE STRICT; + +DO $$ +DECLARE + ancre RECORD; + combien integer := 0; +BEGIN + IF to_regclass('ir_ui_view') IS NULL THEN + RETURN; + END IF; + FOR ancre IN + SELECT DISTINCT trouve[1] AS brut + FROM ir_ui_view v + JOIN website_page p ON p.view_id = v.id, + LATERAL regexp_matches( + v.arch_db, 'href="(#[^"]*%[0-9A-Fa-f]{2}[^"]*)"', 'g' + ) AS trouve + LOOP + UPDATE ir_ui_view + SET arch_db = replace( + arch_db, + 'href="' || ancre.brut || '"', + 'href="' || pg_temp.el_url_decode(ancre.brut) || '"' + ) + WHERE position('href="' || ancre.brut || '"' IN arch_db) > 0; + combien := combien + 1; + RAISE NOTICE 'ancre decodee : % -> %', + ancre.brut, pg_temp.el_url_decode(ancre.brut); + END LOOP; + IF combien > 0 THEN + RAISE NOTICE '% ancre(s) de page decodee(s)', combien; + END IF; +END $$; diff --git a/test/test_fix_migration_120_to_130.py b/test/test_fix_migration_120_to_130.py new file mode 100644 index 0000000..1cbc425 --- /dev/null +++ b/test/test_fix_migration_120_to_130.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Le correctif de palier 12 → 13, exécuté contre un vrai PostgreSQL. + +Une assertion sur le TEXTE d'un fichier SQL ne voit pas ce qu'il fait. +Celui-ci décode des séquences UTF-8 en pourcentage — `%C3%A9` tient sur +DEUX octets — et seule l'exécution prouve qu'on rassemble les octets +avant de convertir, plutôt que de produire deux caractères illisibles. + +Ce qu'il répare : OpenUpgrade recolle les `href` d'ancres en sélecteur +CSS, où un `%` est interdit. La migration mourait là. +""" + +import os +import shutil +import subprocess +import sys +import unittest + +sys.path.append( + os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) +) + +RACINE = os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) +SQL = os.path.join( + RACINE, + "script", + "odoo", + "migration", + "fix_migration_odoo120_to_odoo130.sql", +) + + +class Base(unittest.TestCase): + BASE = "tmp_fix_120_130_test" + + @classmethod + def setUpClass(cls): + if not shutil.which("psql") or not shutil.which("createdb"): + raise unittest.SkipTest("PostgreSQL absent") + subprocess.run( + ["dropdb", "--if-exists", cls.BASE], capture_output=True + ) + if subprocess.run( + ["createdb", cls.BASE], capture_output=True + ).returncode: + raise unittest.SkipTest("createdb impossible") + + @classmethod + def tearDownClass(cls): + if shutil.which("dropdb"): + subprocess.run( + ["dropdb", "--if-exists", cls.BASE], capture_output=True + ) + + def sql(self, requete): + done = subprocess.run( + ["psql", "-X", "-w", "-q", "-d", self.BASE, "-tAc", requete], + capture_output=True, + text=True, + ) + self.assertEqual(done.returncode, 0, done.stderr) + return done.stdout.strip() + + def prepare(self, lignes): + """lignes = [(arch, a_une_page)]""" + self.sql("DROP TABLE IF EXISTS website_page, ir_ui_view") + self.sql( + "CREATE TABLE ir_ui_view (id serial PRIMARY KEY, arch_db text)" + ) + self.sql( + "CREATE TABLE website_page (id serial PRIMARY KEY, view_id integer)" + ) + for arch, avec_page in lignes: + vid = self.sql( + "INSERT INTO ir_ui_view (arch_db) VALUES (" + + "'" + + arch.replace("'", "''") + + "') RETURNING id" + ) + if avec_page: + self.sql(f"INSERT INTO website_page (view_id) VALUES ({vid})") + + def applique(self): + done = subprocess.run( + [ + "psql", + "-X", + "-w", + "-v", + "ON_ERROR_STOP=1", + "-d", + self.BASE, + "-f", + SQL, + ], + capture_output=True, + text=True, + ) + self.assertEqual(done.returncode, 0, done.stdout + done.stderr) + return done.stdout + done.stderr + + def arch(self, vid=1): + return self.sql(f"SELECT arch_db FROM ir_ui_view WHERE id={vid}") + + +class TestDecodingTheAnchors(Base): + def test_an_accented_anchor_is_decoded(self): + self.prepare([('x', True)]) + self.applique() + self.assertEqual(self.arch(), 'x') + + def test_two_byte_sequences_are_assembled_before_converting(self): + # `%C3%A9` est UN caractère sur DEUX octets. Les décoder + # séparément rendrait deux caractères illisibles. + self.prepare([('x', True)]) + self.applique() + self.assertEqual(self.arch(), 'x') + + def test_several_anchors_in_one_view(self): + self.prepare( + [('12', True)] + ) + self.applique() + self.assertIn("#aé", self.arch()) + self.assertIn("#bè", self.arch()) + + def test_it_is_replayable(self): + self.prepare([('x', True)]) + self.applique() + premier = self.arch() + sortie = self.applique() + self.assertEqual(self.arch(), premier) + self.assertNotIn("decodee", sortie) + + +class TestWhatItMustNotTouch(Base): + def test_a_percent_outside_an_anchor_is_left_alone(self): + # Un `%` dans une vraie URL est légitime : le décoder changerait + # une adresse qui fonctionne. + arch = 'x' + self.prepare([(arch, True)]) + self.applique() + self.assertEqual(self.arch(), arch) + + def test_a_view_without_a_page_is_left_alone(self): + # OpenUpgrade ne parcourt que les vues qui portent une page : + # toucher plus large modifierait du contenu sans raison. + arch = 'x' + self.prepare([(arch, False)]) + self.applique() + self.assertEqual(self.arch(), arch) + + def test_a_lone_percent_is_not_an_encoding(self): + # « 100% » n'est pas une séquence : seuls `%XX` hexadécimaux le + # sont, et confondre les deux abîmerait du texte. + arch = 'x' + self.prepare([(arch, True)]) + sortie = self.applique() + self.assertEqual(self.arch(), arch) + # Et il ne doit pas ANNONCER un décodage qui n'a rien changé : + # un rapport qui se félicite à vide fait douter du reste. + self.assertNotIn("decodee", sortie) + + def test_an_anchor_without_any_percent_is_untouched(self): + arch = 'x' + self.prepare([(arch, True)]) + self.applique() + self.assertEqual(self.arch(), arch) + + +class TestItLeavesNothingBehind(Base): + def test_the_decoder_does_not_survive_the_session(self): + # La fonction vit dans `pg_temp` : elle disparaît avec psql et ne + # laisse rien dans la base du client. + self.prepare([('x', True)]) + self.applique() + reste = self.sql( + "SELECT count(*) FROM pg_proc WHERE proname = 'el_url_decode'" + ) + self.assertEqual(reste, "0") + + def test_a_database_without_the_tables_does_not_crash(self): + self.sql("DROP TABLE IF EXISTS website_page, ir_ui_view") + done = subprocess.run( + [ + "psql", + "-X", + "-w", + "-v", + "ON_ERROR_STOP=1", + "-d", + self.BASE, + "-f", + SQL, + ], + capture_output=True, + text=True, + ) + self.assertEqual(done.returncode, 0, done.stdout + done.stderr) + + +class TestTheFileIsWiredIn(unittest.TestCase): + def test_the_name_matches_what_the_driver_looks_for(self): + # Le pilote compose « fix_migration_odoo{(v-1)*10}_to_odoo{v*10} ». + self.assertTrue(os.path.isfile(SQL), SQL) + self.assertTrue(SQL.endswith("fix_migration_odoo120_to_odoo130.sql")) + + def test_it_runs_before_openupgrade(self): + # Il agit sur une base encore en 12 : après OpenUpgrade, il + # serait trop tard, la migration aurait déjà échoué. + with open( + os.path.join(RACINE, "script", "todo", "todo_upgrade.py"), + encoding="utf-8", + ) as handle: + src = handle.read() + self.assertLess( + src.index("- Fix migrate code"), src.index("- Migrate database") + ) + + +if __name__ == "__main__": + unittest.main() From 827998e99ad5100ade9104d1827cadb8c78a2ad8 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sun, 23 Aug 2026 01:00:43 -0400 Subject: [PATCH 04/16] [FIX] odoo 15: declare xlsxwriter, which report_xlsx has always needed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 15 bump died on Unable to upgrade module "report_xlsx" because an external dependency is not met: Python library not installed: xlsxwriter report_xlsx declares {"python": ["xlsxwriter", "xlrd"]} and the 15 pyproject held xlrd alone: the pair arrived half. Every other version from 12 to 18 declares it -- 15 was the only gap. Version 1.4.5, as in 16 and 17, and well within what Python 3.8 supports. poetry lock --no-update touched exactly one package: nothing else moved. --- FR --- Le palier 15 mourait sur « Python library not installed: xlsxwriter ». report_xlsx déclare {"python": ["xlsxwriter", "xlrd"]} et le pyproject de la 15 ne portait que xlrd : la paire est arrivée à moitié. Toutes les autres versions de 12 à 18 le déclarent — la 15 était le seul trou. Version 1.4.5, comme en 16 et 17, largement dans ce que Python 3.8 supporte. poetry lock --no-update n'a touché qu'un paquet : rien d'autre n'a bougé. Assisted-by: Claude Opus 5 --- requirement/poetry.odoo15.0_python3.8.20.lock | 13 ++++++++++++- requirement/pyproject.odoo15.0_python3.8.20.toml | 1 + 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/requirement/poetry.odoo15.0_python3.8.20.lock b/requirement/poetry.odoo15.0_python3.8.20.lock index c363fb7..e050fab 100644 --- a/requirement/poetry.odoo15.0_python3.8.20.lock +++ b/requirement/poetry.odoo15.0_python3.8.20.lock @@ -6322,6 +6322,17 @@ files = [ {file = "xlrd-1.2.0.tar.gz", hash = "sha256:546eb36cee8db40c3eaa46c351e67ffee6eeb5fa2650b71bc4c758a29a1b29b2"}, ] +[[package]] +name = "xlsxwriter" +version = "1.4.5" +description = "A Python module for creating Excel XLSX files." +optional = false +python-versions = "*" +files = [ + {file = "XlsxWriter-1.4.5-py2.py3-none-any.whl", hash = "sha256:f9335f1736e2c4fd80e940fe1b6d92d967bf454a1e5d639b0b7a4459ade790cc"}, + {file = "XlsxWriter-1.4.5.tar.gz", hash = "sha256:0956747859567ec01907e561a7d8413de18a7aae36860f979f9da52b9d58bc19"}, +] + [[package]] name = "xlwt" version = "1.3.0" @@ -6620,4 +6631,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = ">=3.8.20,<3.9" -content-hash = "660feb736151a54776a49b047a9dcf428145a2add97c89dddca2178be2c9d542" +content-hash = "b627b33459aa69db80c29e21da288e828d4f7f2e002bb14565d050010c1bb12b" diff --git a/requirement/pyproject.odoo15.0_python3.8.20.toml b/requirement/pyproject.odoo15.0_python3.8.20.toml index 196fac9..ac0a857 100644 --- a/requirement/pyproject.odoo15.0_python3.8.20.toml +++ b/requirement/pyproject.odoo15.0_python3.8.20.toml @@ -166,6 +166,7 @@ werkzeug = "0.16.1" wheel = "^0.45.1" win-unicode-console = "^0.5" xlrd = "1.2.0" +xlsxwriter = "1.4.5" xlwt = "1.3.0" xmlformatter = "^0.2.8" xmlrunner = "^1.7.7" From c7ab378bd8f8d32a5fe6aa722cc91cc182aeac68 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sun, 23 Aug 2026 01:51:28 -0400 Subject: [PATCH 05/16] [FIX] migration: retirer web_responsive avant de monter en 18 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Monter en 18 mourait sur « MuK Backend Theme et Web Responsive sont incompatibles ». muk_web_theme n'excluait que web_enterprise en 16 et en 17 ; la 18 y ajoute web_responsive. Les deux cohabitaient donc légalement depuis la 12. On retire web_responsive au palier 17 → 18, pendant que l'état est encore légal ; rien n'en dépend. Deux défauts trouvés en le câblant. Odoo sort en 0 quand « --uninstall » ne retire rien : muk_web_theme a traversé quatre paliers en étant réputé parti. On lit maintenant l'état en base. Et les étapes désinstaller et installer rangeaient leur drapeau sous la clé de l'étape migration — à la reprise, OpenUpgrade était sauté pour ces paliers. --- EN --- Upgrading to 18 died on « MuK Backend Theme and Web Responsive are incompatible ». muk_web_theme excluded only web_enterprise in 16 and 17; 18 adds web_responsive. The pair had been legal since 12. We drop web_responsive at the 17 → 18 step, while the state is still legal; nothing depends on it. Wiring it up surfaced two defects. Odoo exits 0 when « --uninstall » removes nothing: muk_web_theme crossed four steps while believed gone. We now read the state back from the database. And the uninstall and install steps stored their flag under the migrate step's key — on resume, OpenUpgrade was skipped for those steps. Assisted-by: Claude Opus 5 --- ...install_module_list_odoo140_to_odoo150.txt | 14 +- ...install_module_list_odoo170_to_odoo180.txt | 15 ++ script/todo/todo_i18n.py | 12 ++ script/todo/todo_upgrade.py | 50 ++++- test/test_uninstall_module_list.py | 133 ++++++++++++ test/test_uninstall_verified.py | 189 ++++++++++++++++++ 6 files changed, 405 insertions(+), 8 deletions(-) create mode 100644 script/odoo/migration/uninstall_module_list_odoo170_to_odoo180.txt create mode 100644 test/test_uninstall_module_list.py create mode 100644 test/test_uninstall_verified.py diff --git a/script/odoo/migration/uninstall_module_list_odoo140_to_odoo150.txt b/script/odoo/migration/uninstall_module_list_odoo140_to_odoo150.txt index 5917580..11c47bc 100644 --- a/script/odoo/migration/uninstall_module_list_odoo140_to_odoo150.txt +++ b/script/odoo/migration/uninstall_module_list_odoo140_to_odoo150.txt @@ -1 +1,13 @@ -muk_web_theme +# Modules à retirer AVANT de monter de 14.0 vers 15.0. +# +# Un module par ligne, une justification après « # ». + +# Raison d'origine NON consignée : la ligne arrive avec cbc43fde, sans un +# mot. Mesuré depuis, sur test_neutralize (12 → 18) : la commande part +# bien — « uninstall_addons.sh test_neutralize_upgrade_15 muk_web_theme » +# est dans le journal — et le module est pourtant « installed » de la 15 à +# la 18. Odoo ne cherche que l'état « installed » et le module traînait en +# « to remove » depuis la 13 ; il sort en 0 sans rien retirer. +# Le retrait n'a donc JAMAIS eu lieu, et rien n'a cassé pour autant. +# Avant de s'y fier, retrouver le motif — ou retirer la ligne. +muk_web_theme # motif d'origine inconnu ; retrait jamais effectif (mesuré) diff --git a/script/odoo/migration/uninstall_module_list_odoo170_to_odoo180.txt b/script/odoo/migration/uninstall_module_list_odoo170_to_odoo180.txt new file mode 100644 index 0000000..d197119 --- /dev/null +++ b/script/odoo/migration/uninstall_module_list_odoo170_to_odoo180.txt @@ -0,0 +1,15 @@ +# Modules à retirer AVANT de monter de 17.0 vers 18.0. +# +# Un module par ligne, une justification après « # ». + +# muk_web_theme n'excluait que web_enterprise en 16 et en 17 ; la 18 y +# ajoute web_responsive. Les deux cohabitaient donc légalement jusqu'ici, +# et la base arrive en 18 dans un état que la 18 interdit. Le chargement +# meurt dès que `Module.update_list()` installe un module auto_install, +# car Odoo revérifie alors toutes les exclusions : +# UserError: Les modules "MuK Backend Theme" et "Web Responsive" +# sont incompatibles. +# On retire web_responsive et l'on garde le thème MuK, qui porte +# l'apparence du back-office. L'inverse marcherait aussi : les deux +# rendent le même service, il faut simplement en choisir un. +web_responsive # exclu par muk_web_theme a partir de la 18.0 diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index fa00db3..5794700 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -1748,6 +1748,18 @@ TRANSLATIONS = { "fr": "Rien de désinstallé.", "en": "Nothing uninstalled.", }, + "The uninstall did not take:": { + "fr": "La désinstallation n'a pas pris :", + "en": "The uninstall did not take:", + }, + "Odoo exits 0 even when it removes nothing.": { + "fr": "Odoo sort en 0 même quand il ne retire rien.", + "en": "Odoo exits 0 even when it removes nothing.", + }, + "Could not verify the uninstall.": { + "fr": "Impossible de vérifier la désinstallation.", + "en": "Could not verify the uninstall.", + }, "Check the COW views that drifted": { "fr": "Vérifier les vues COW en retard sur leur vue module", "en": "Check the COW views that drifted", diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index 84793da..c644526 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -2040,9 +2040,6 @@ class TodoUpgrade: next_version - 1, ) lst_module_uninstall_module[index] = True - self.dct_progression["state_4_module_migrate_odoo_lst"] = ( - lst_module_uninstall_module - ) self.write_config() self.dct_progression["config_state_4_uninstall_module"] = ( @@ -2087,9 +2084,6 @@ class TodoUpgrade: next_version - 1, ) lst_module_install_module[index] = True - self.dct_progression["state_4_module_migrate_odoo_lst"] = ( - lst_module_install_module - ) self.write_config() self.dct_progression["config_state_4_install_module"] = ( @@ -3198,6 +3192,34 @@ class TodoUpgrade: self.open_step_log(msg) print(f"🔷 {prefix}{sep}{t(label)}" if sep else f"🔷 {t(msg)}") + def still_installed(self, database_name, lst_module): + """Parmi ces modules, lesquels la base tient-elle ENCORE ? + + Odoo ne signale rien quand il ne retire rien : « --uninstall » ne + cherche que l'état « installed » et laisse filer en silence un module + resté en « to remove » d'une tentative précédente. Le code de sortie + vaut donc 0 pour une désinstallation qui n'a pas eu lieu — c'est ainsi + que muk_web_theme a traversé quatre paliers en étant réputé retiré. + + Rendre None, et non la liste vide, quand la base ne répond pas : + « je ne sais pas » et « rien ne reste » appellent des suites + différentes, et les confondre recrée le défaut qu'on corrige. + """ + if not lst_module: + return [] + noms = ", ".join(f"'{nom}'" for nom in sorted(set(lst_module))) + status, _cmd, output = self.todo_upgrade_execute( + f'psql -X -w -d {database_name} -tAc "SELECT name FROM' + f" ir_module_module WHERE name IN ({noms})" + " AND state <> 'uninstalled' ORDER BY name;\"", + get_output=True, + wait_at_error=False, + quiet=True, + ) + if status: + return None + return [line.strip() for line in (output or []) if line.strip()] + def installed_theme(self, database_name): """Thèmes installés, hors theme_default qui EST l'absence de thème.""" status, _cmd, output = self.todo_upgrade_execute( @@ -3773,12 +3795,26 @@ class TodoUpgrade: single_source_odoo=True, ) + lst_left = self.still_installed(database_name, lst_module_to_uninstall) + if lst_left is None: + # Base illisible : on ne sait pas. Le dire, plutôt que de trancher. + print(f"⚠️ {t('Could not verify the uninstall.')}") + lst_left = [] + elif lst_left: + self.add_comment_progression( + "uninstall - still installed: " + ", ".join(lst_left) + ) + print( + f"❌ {t('The uninstall did not take:')} {', '.join(lst_left)}" + ) + print(f" {t('Odoo exits 0 even when it removes nothing.')}") + # Update list installed module — only what was REALLY uninstalled, so # a module left in place stays counted as installed. self.dct_module_per_version[actual_version] = sorted( list( set(self.dct_module_per_version[actual_version]) - - set(lst_module_to_uninstall) + - (set(lst_module_to_uninstall) - set(lst_left)) ) ) self.dct_progression["dct_module_per_version"] = ( diff --git a/test/test_uninstall_module_list.py b/test/test_uninstall_module_list.py new file mode 100644 index 0000000..98b6cc2 --- /dev/null +++ b/test/test_uninstall_module_list.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Les modules qu'un palier doit retirer AVANT de monter. + +Certaines incompatibilités n'existent qu'à partir d'une version donnée. +`muk_web_theme` n'excluait que `web_enterprise` en 16 et en 17 ; la 18 y +ajoute `web_responsive`. Les deux cohabitaient donc légalement, et la +base arrive en 18 dans un état que la 18 interdit — le chargement meurt +dès qu'un module auto_install est installé, car Odoo revérifie alors +toutes les exclusions. + +Le retrait doit se faire pendant qu'on est ENCORE sur l'ancienne +version, là où l'état est légal et où l'ORM fonctionne. +""" + +import io +import os +import sys +import unittest + +sys.path.append( + os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) +) + +from script.todo.todo_upgrade import TodoUpgrade # noqa: E402 + +RACINE = os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) +DOSSIER = os.path.join(RACINE, "script", "odoo", "migration") + + +def lecteur(): + return TodoUpgrade.__new__(TodoUpgrade) + + +class TestTheSeventeenToEighteenList(unittest.TestCase): + def test_the_file_exists_where_the_driver_looks(self): + chemin = os.path.join( + DOSSIER, "uninstall_module_list_odoo170_to_odoo180.txt" + ) + self.assertTrue(os.path.isfile(chemin), chemin) + + def test_it_names_web_responsive(self): + modules, _detail = lecteur().read_uninstall_module_list(17, "peu") + self.assertIn("web_responsive", modules) + + def test_the_removal_is_justified(self): + # Sans justification, on retrouve un module retiré des mois plus + # tard sans pouvoir dire pourquoi, ni s'il faut le remettre. + _m, detail = lecteur().read_uninstall_module_list(17, "peu") + raisons = {module: raison for module, raison, _f in detail} + self.assertTrue(raisons.get("web_responsive"), raisons) + + def test_muk_web_theme_is_NOT_removed_here(self): + # Les deux rendent le même service : il faut en garder un, et + # c'est le thème qui porte l'apparence du back-office. + modules, _d = lecteur().read_uninstall_module_list(17, "peu") + self.assertNotIn("muk_web_theme", modules) + + +class TestEveryListIsWellFormed(unittest.TestCase): + def fichiers(self): + import glob + + return sorted( + glob.glob(os.path.join(DOSSIER, "uninstall_module_list_*.txt")) + ) + + def test_there_is_at_least_one(self): + # Sans cette borne, le test suivant passerait en ne vérifiant + # rien le jour où le motif de nom change. + self.assertGreater(len(self.fichiers()), 0) + + def test_every_entry_is_justified(self): + for chemin in self.fichiers(): + for module, raison in TodoUpgrade.parse_module_list_file(chemin): + self.assertTrue( + raison, + f"{os.path.basename(chemin)} : {module} sans raison", + ) + + def test_no_entry_looks_like_a_stray_comment(self): + # Le parseur coupe à « # » : une ligne mal écrite produirait un + # nom de module fantôme, retiré en silence de rien du tout. + for chemin in self.fichiers(): + for module, _r in TodoUpgrade.parse_module_list_file(chemin): + self.assertRegex(module, r"^[a-z][a-z0-9_]*$", module) + + def test_the_name_encodes_the_bump_it_serves(self): + import re + + for chemin in self.fichiers(): + nom = os.path.basename(chemin) + trouve = re.match( + r"uninstall_module_list_odoo(\d+)_to_odoo(\d+)\.txt$", nom + ) + self.assertIsNotNone(trouve, nom) + depart, arrivee = (int(x) for x in trouve.groups()) + self.assertEqual(arrivee, depart + 10, nom) + + +class TestWhenItRuns(unittest.TestCase): + def test_the_uninstall_precedes_the_openupgrade_run(self): + # Retirer un module APRÈS la montée serait trop tard : c'est la + # montée elle-même qui refuse l'état. + with io.open( + os.path.join(RACINE, "script", "todo", "todo_upgrade.py"), + encoding="utf-8", + ) as handle: + src = handle.read() + self.assertLess( + src.index("- Uninstall module"), src.index("- Migrate database") + ) + + def test_a_private_list_wins_over_the_shared_one(self): + # Une base peut avoir ses propres retraits sans qu'on touche à la + # liste partagée de tout le monde. + source = io.open( + os.path.join(RACINE, "script", "todo", "todo_upgrade.py"), + encoding="utf-8", + ).read() + debut = source.index("def read_uninstall_module_list") + fin = source.index("def split_present_missing") + bloc = source[debut:fin] + self.assertLess( + bloc.index("PATH_MIGRATION_PRIVATE"), + bloc.index("PATH_MIGRATION_GLOBAL"), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_uninstall_verified.py b/test/test_uninstall_verified.py new file mode 100644 index 0000000..a38b8f8 --- /dev/null +++ b/test/test_uninstall_verified.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Une désinstallation se MESURE, elle ne se suppose pas. + +`odoo-bin --uninstall` ne cherche que l'état « installed ». Un module resté +en « to remove » d'une tentative précédente est ignoré en silence, et Odoo +sort en 0. Le pilote tenait ce 0 pour une réussite : c'est ainsi que +muk_web_theme a traversé quatre paliers de 12 → 18 en étant réputé retiré, +alors qu'il était « installed » de la 15 à la 18. +""" + +import ast +import io +import os +import sys +import unittest + +sys.path.append( + os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) +) + +from script.todo.todo_upgrade import TodoUpgrade # noqa: E402 + +RACINE = os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) +SOURCE = os.path.join(RACINE, "script", "todo", "todo_upgrade.py") + + +class FauxPilote(TodoUpgrade): + """Un pilote qui n'exécute rien, mais respecte le contrat de retour.""" + + def __init__(self, survivants=(), lisible=True): + self.survivants = list(survivants) + self.lisible = lisible + self.commandes = [] + self.commentaires = [] + self.dct_progression = {} + self.dct_module_per_version = {} + + def todo_upgrade_execute(self, cmd, **kwargs): + self.commandes.append(cmd) + if kwargs.get("get_output"): + # Le vrai rend TROIS valeurs quand on demande la sortie, et un + # statut NON nul veut dire « raté ». + if not self.lisible: + return 1, cmd, None + return 0, cmd, list(self.survivants) + return 0, cmd + + def write_config(self): + pass + + def add_comment_progression(self, msg): + self.commentaires.append(msg) + + def split_present_missing(self, lst): + return list(lst), [] + + +class TestStillInstalled(unittest.TestCase): + def test_it_names_what_survived(self): + pilote = FauxPilote(survivants=["muk_web_theme"]) + self.assertEqual( + pilote.still_installed("db", ["muk_web_theme", "web_responsive"]), + ["muk_web_theme"], + ) + + def test_an_unreadable_database_says_UNKNOWN_not_empty(self): + # Rendre [] serait affirmer « tout est parti » sans l'avoir lu : + # exactement le défaut qu'on corrige. + pilote = FauxPilote(lisible=False) + self.assertIsNone(pilote.still_installed("db", ["web_responsive"])) + + def test_nothing_to_check_asks_the_database_nothing(self): + pilote = FauxPilote() + self.assertEqual(pilote.still_installed("db", []), []) + self.assertEqual(pilote.commandes, []) + + def test_it_counts_to_remove_as_still_there(self): + # « to remove » n'est pas « uninstalled » : c'est justement l'état + # que --uninstall refuse de traiter, donc celui qu'il faut voir. + pilote = FauxPilote() + pilote.still_installed("db", ["web_responsive"]) + self.assertIn("state <> 'uninstalled'", pilote.commandes[0]) + + def test_the_module_names_are_quoted_for_sql(self): + pilote = FauxPilote() + pilote.still_installed("db", ["web_responsive"]) + self.assertIn("'web_responsive'", pilote.commandes[0]) + + +class TestTheBookkeepingTellsTheTruth(unittest.TestCase): + def pilote(self, survivants): + pilote = FauxPilote(survivants=survivants) + pilote.dct_module_per_version = { + 17: ["web_responsive", "muk_web_theme"] + } + return pilote + + def test_a_module_left_in_place_stays_counted_as_installed(self): + pilote = self.pilote(["web_responsive"]) + pilote.uninstall_from_database(["web_responsive"], "db", 17) + self.assertIn("web_responsive", pilote.dct_module_per_version[17]) + + def test_a_module_really_gone_is_dropped(self): + pilote = self.pilote([]) + pilote.uninstall_from_database(["web_responsive"], "db", 17) + self.assertNotIn("web_responsive", pilote.dct_module_per_version[17]) + # …et sans emporter le voisin au passage. + self.assertIn("muk_web_theme", pilote.dct_module_per_version[17]) + + def test_the_survivor_is_recorded_where_someone_will_read_it(self): + pilote = self.pilote(["web_responsive"]) + pilote.uninstall_from_database(["web_responsive"], "db", 17) + trace = " ".join(pilote.commentaires) + self.assertIn("still installed", trace) + self.assertIn("web_responsive", trace) + + def test_an_unreadable_database_does_not_crash_the_migration(self): + # « je ne sais pas » revient en None : le traiter comme une liste + # ferait tomber la migration sur un TypeError, six heures après le + # départ, pour un renseignement qui n'était que confortable. + pilote = FauxPilote(lisible=False) + pilote.dct_module_per_version = {17: ["web_responsive"]} + pilote.uninstall_from_database(["web_responsive"], "db", 17) + self.assertEqual(pilote.dct_module_per_version[17], []) + + def test_a_silent_success_leaves_no_alarm(self): + pilote = self.pilote([]) + pilote.uninstall_from_database(["web_responsive"], "db", 17) + self.assertEqual( + [c for c in pilote.commentaires if "still installed" in c], [] + ) + + +class TestNoStepWritesAnotherStepsFlag(unittest.TestCase): + """Chaque drapeau `state_4_*` ne doit porter QUE sa propre liste. + + Trois étapes rangeaient leurs drapeaux sous + `state_4_module_migrate_odoo_lst`. Sans effet dans la course en cours — + la locale est lue une fois, au début — mais à la REPRISE cette clé est + relue comme « OpenUpgrade est passé », et la migration du palier est + sautée. Un test structurel se justifie ici : conduire une reprise + complète coûterait des heures, et la faute est visible dans l'écriture. + """ + + def assignations(self): + with io.open(SOURCE, encoding="utf-8") as handle: + arbre = ast.parse(handle.read()) + vues = {} + for noeud in ast.walk(arbre): + if not isinstance(noeud, ast.Assign): + continue + for cible in noeud.targets: + if not ( + isinstance(cible, ast.Subscript) + and isinstance(cible.value, ast.Attribute) + and cible.value.attr == "dct_progression" + and isinstance(cible.slice, ast.Constant) + and str(cible.slice.value).startswith("state_4_") + ): + continue + if isinstance(noeud.value, ast.Name): + vues.setdefault(cible.slice.value, set()).add( + noeud.value.id + ) + return vues + + def test_the_scan_actually_finds_something(self): + # Sans cette borne, le test suivant passerait sur un dictionnaire + # vide le jour où la forme de l'écriture change. + self.assertGreater(len(self.assignations()), 2) + + def test_each_flag_is_written_from_one_list_only(self): + for cle, noms in sorted(self.assignations().items()): + self.assertEqual( + len(noms), 1, f"{cle} écrit depuis {sorted(noms)}" + ) + + def test_the_migrate_flag_comes_from_the_migrate_list(self): + self.assertEqual( + self.assignations().get("state_4_module_migrate_odoo_lst"), + {"lst_module_migrate_odoo"}, + ) + + +if __name__ == "__main__": + unittest.main() From 24f38c79ad13db870d773be7a54603d664db157d Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sun, 23 Aug 2026 02:02:37 -0400 Subject: [PATCH 06/16] =?UTF-8?q?[FIX]=20migration:=20un=20OpenUpgrade=20r?= =?UTF-8?q?at=C3=A9=20ne=20doit=20pas=20passer=20pour=20fait?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `lst_upgrade_odoo` n'est pas une copie : `dct_progression.get()` rend l'objet stocké. La commande y était inscrite AVANT de tourner, donc le premier `write_config()` la gravait — y compris celui du chemin d'échec, qui remet pourtant le drapeau de clonage à zéro pour forcer un nouvel essai. La reprise sautait alors OpenUpgrade. Mesuré sur test_neutralize_upgrade_18, arrêté sur l'erreur des thèmes : base = 17.0.1.3, clone à refaire, et sa commande de migration 18 déjà consignée. Relancer aurait laissé une base 17 sous le code 18. On l'inscrit après la réussite, là où le commentaire la situait déjà. --- EN --- `lst_upgrade_odoo` is not a copy: `dct_progression.get()` returns the stored object. The command was recorded BEFORE it ran, so the first `write_config()` persisted it — including the one on the failure path, which resets the clone flag precisely to force a fresh attempt. A resume then skipped OpenUpgrade. Measured on test_neutralize_upgrade_18, halted on the theme error: base = 17.0.1.3, clone pending, and its 18 migration command already recorded. Resuming would have left a 17 database under 18 code. We record it after success, where the comment already placed it. Assisted-by: Claude Opus 5 --- script/todo/todo_upgrade.py | 11 +++- test/test_uninstall_verified.py | 93 +++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index c644526..07e633b 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -2615,7 +2615,15 @@ class TodoUpgrade: cmd_upgrade = f".venv.{erplibre_version}/bin/python ./odoo{next_version}.0/OCA_OpenUpgrade/odoo-bin -c ./config.conf --update all --no-http --stop-after-init -d {database_name_upgrade}" else: cmd_upgrade = f"./run.sh --upgrade-path=./odoo{next_version}.0/OCA_OpenUpgrade/openupgrade_scripts/scripts --update all -c config.conf --stop-after-init --no-http --load=base,web,openupgrade_framework -d {database_name_upgrade}" - lst_upgrade_odoo[index] = cmd_upgrade + # NE PAS enregistrer la commande ici. `lst_upgrade_odoo` + # EST la liste de `dct_progression` — `.get` rend l'objet, + # pas une copie — donc la muter maintenant la fait persister + # au premier write_config() venu, y compris celui du chemin + # d'échec juste en dessous. L'étape passait alors pour faite + # et la reprise SAUTAIT OpenUpgrade : mesuré sur + # test_neutralize_upgrade_18, resté en base 17.0.1.3 avec sa + # commande 18 déjà consignée. On l'enregistre après la + # réussite, où le commentaire dit déjà qu'elle appartient. # Record the website COW views before the data migration. The # upgrade silently deletes and recreates copies (measured on @@ -2682,6 +2690,7 @@ class TodoUpgrade: f"after_{next_version}", ) + lst_upgrade_odoo[index] = cmd_upgrade self.dct_progression["state_4_upgrade_odoo_lst"] = ( lst_upgrade_odoo ) diff --git a/test/test_uninstall_verified.py b/test/test_uninstall_verified.py index a38b8f8..a1ccc35 100644 --- a/test/test_uninstall_verified.py +++ b/test/test_uninstall_verified.py @@ -185,5 +185,98 @@ class TestNoStepWritesAnotherStepsFlag(unittest.TestCase): ) +class TestAFailedOpenUpgradeStaysUnrecorded(unittest.TestCase): + """Un OpenUpgrade raté ne doit pas passer pour fait. + + `lst_upgrade_odoo` n'est pas une copie : `dct_progression.get()` rend + l'objet stocké. L'affecter avant l'exécution le faisait persister au + premier `write_config()` venu — celui du chemin d'échec compris, qui + remet pourtant le drapeau de clonage à zéro pour forcer un nouvel + essai. La reprise sautait alors OpenUpgrade et laissait une base 17 + tourner sous le code 18. Mesuré sur test_neutralize_upgrade_18 : + base = 17.0.1.3, et sa commande de migration déjà consignée. + + Conduire `execute_odoo_upgrade` en vrai demanderait une migration + complète ; la faute est un ORDRE dans le source, et c'est l'ordre + qu'on mesure. + """ + + def arbre(self): + with io.open(SOURCE, encoding="utf-8") as handle: + return ast.parse(handle.read()) + + def lignes_affectation(self): + lignes = [] + for noeud in ast.walk(self.arbre()): + if not isinstance(noeud, ast.Assign): + continue + for cible in noeud.targets: + if ( + isinstance(cible, ast.Subscript) + and isinstance(cible.value, ast.Name) + and cible.value.id == "lst_upgrade_odoo" + ): + lignes.append(noeud.lineno) + return lignes + + @staticmethod + def _remet_le_clone_a_zero(noeud): + """Ce bloc renonce-t-il en redemandant un clonage neuf ? + + Le repère est l'affectation `lst_clone_odoo[index] = False` : c'est + elle qui distingue « je renonce, refais le clone » de l'étape de + clonage elle-même, qui écrit `= True` et vit ailleurs. Chercher les + seuls NOMS attrapait les deux, et l'ancre tombait 700 lignes trop + haut — le test passait alors sur n'importe quel ordre. + """ + for petit in ast.walk(noeud): + if not isinstance(petit, ast.Assign): + continue + if not ( + isinstance(petit.value, ast.Constant) + and petit.value.value is False + ): + continue + for cible in petit.targets: + if ( + isinstance(cible, ast.Subscript) + and isinstance(cible.value, ast.Name) + and cible.value.id == "lst_clone_odoo" + ): + return True + return False + + def ligne_abandon(self): + """Le `return` qui renonce après un OpenUpgrade raté.""" + lignes = [ + max(n.lineno for n in ast.walk(noeud) if isinstance(n, ast.Return)) + for noeud in ast.walk(self.arbre()) + if isinstance(noeud, ast.If) + and self._remet_le_clone_a_zero(noeud) + and any(isinstance(n, ast.Return) for n in ast.walk(noeud)) + ] + return min(lignes) if lignes else None + + def test_both_anchors_are_found(self): + # Sans cette borne, les tests suivants passeraient à vide le jour + # où l'une des deux formes change. + self.assertTrue(self.lignes_affectation()) + self.assertIsNotNone(self.ligne_abandon()) + + def test_the_step_is_recorded_only_after_the_failure_path_gave_up(self): + abandon = self.ligne_abandon() + for ligne in self.lignes_affectation(): + self.assertGreater( + ligne, + abandon, + "lst_upgrade_odoo est marqué fait avant que l'échec ait" + " pu renoncer : la reprise sautera OpenUpgrade", + ) + + def test_it_is_recorded_exactly_once(self): + # Deux affectations, et l'une repasserait devant l'échec. + self.assertEqual(len(self.lignes_affectation()), 1) + + if __name__ == "__main__": unittest.main() From e3b1fd48be24a3dd0de3410bbeb53293fbfdf2cd Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sun, 23 Aug 2026 02:09:06 -0400 Subject: [PATCH 07/16] =?UTF-8?q?[FIX]=20migration:=20reb=C3=A2tir=20le=20?= =?UTF-8?q?clone=20annule=20aussi=20sa=20pr=C3=A9paration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quand OpenUpgrade échoue, le pilote remet le drapeau de clonage à zéro pour que la base intermédiaire soit refaite depuis la version d'avant. Mais les étapes qui avaient préparé CE clone gardaient le leur : le SQL de pré-migration, les désinstallations, les installations. La base neuve repartait sans sa préparation, et OpenUpgrade retombait sur le problème même que ce SQL existe pour écarter. Vu sur test_neutralize_upgrade_18 : clone à refaire, et pourtant fix_migration_odoo170_to_odoo180.sql déjà consigné comme appliqué. Les trois drapeaux tombent maintenant avec le clone. --- EN --- When OpenUpgrade fails, the driver clears the clone flag so the intermediate database is rebuilt from the previous version. But the steps that had prepared THAT clone kept theirs: the pre-migration SQL, the uninstalls, the installs. The fresh database started without its preparation, and OpenUpgrade met the very problem that SQL exists to prevent. Seen on test_neutralize_upgrade_18: clone pending, yet fix_migration_odoo170_to_odoo180.sql already recorded as applied. The three flags now fall with the clone. Assisted-by: Claude Opus 5 --- script/todo/todo_upgrade.py | 18 +++++++++ test/test_uninstall_verified.py | 71 +++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index 07e633b..692b472 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -2665,6 +2665,24 @@ class TodoUpgrade: self.dct_progression["state_4_clone_odoo_lst"] = ( lst_clone_odoo ) + # Tout ce qui a été fait À ce clone meurt avec lui : la + # base reconstruite est une copie neuve de la version + # précédente et réclame la même préparation. Laisser ces + # drapeaux debout sautait le SQL de pré-migration sur le + # clone neuf, et OpenUpgrade retombait sur le problème + # même que ce SQL existe pour écarter. + lst_fix_migration_odoo[index] = [] + self.dct_progression["state_4_fix_migration_odoo_lst"] = ( + lst_fix_migration_odoo + ) + lst_module_uninstall_module[index] = False + self.dct_progression["state_4_uninstall_module"] = ( + lst_module_uninstall_module + ) + lst_module_install_module[index] = False + self.dct_progression["state_4_install_module"] = ( + lst_module_install_module + ) self.write_config() print( f"\n❌ -> {t('Database migration to Odoo')}" diff --git a/test/test_uninstall_verified.py b/test/test_uninstall_verified.py index a1ccc35..95a7fc2 100644 --- a/test/test_uninstall_verified.py +++ b/test/test_uninstall_verified.py @@ -278,5 +278,76 @@ class TestAFailedOpenUpgradeStaysUnrecorded(unittest.TestCase): self.assertEqual(len(self.lignes_affectation()), 1) +class TestDiscardingTheCloneDiscardsItsPreparation(unittest.TestCase): + """Rebâtir le clone annule tout ce qu'on lui avait fait. + + Quand OpenUpgrade échoue, le pilote remet le drapeau de clonage à zéro + pour que la base intermédiaire soit refaite depuis la version + précédente. Mais les drapeaux des étapes qui avaient préparé CE + clone — le SQL de pré-migration, les désinstallations, les + installations — restaient debout. La base neuve repartait donc sans + sa préparation, et OpenUpgrade retombait sur le problème même que le + SQL existe pour écarter. + """ + + PAR_CLONE = ( + "lst_fix_migration_odoo", + "lst_module_uninstall_module", + "lst_module_install_module", + ) + + def bloc_abandon(self): + with io.open(SOURCE, encoding="utf-8") as handle: + arbre = ast.parse(handle.read()) + for noeud in ast.walk(arbre): + if isinstance( + noeud, ast.If + ) and TestAFailedOpenUpgradeStaysUnrecorded._remet_le_clone_a_zero( + noeud + ): + return noeud + return None + + def remis_a_zero(self): + """Les listes que ce bloc remet à leur valeur vide.""" + noms = set() + for petit in ast.walk(self.bloc_abandon()): + if not isinstance(petit, ast.Assign): + continue + if not ( + isinstance(petit.value, ast.Constant) + and petit.value.value is False + ) and not ( + isinstance(petit.value, ast.List) and not petit.value.elts + ): + continue + for cible in petit.targets: + if isinstance(cible, ast.Subscript) and isinstance( + cible.value, ast.Name + ): + noms.add(cible.value.id) + return noms + + def test_the_failure_block_is_found(self): + self.assertIsNotNone(self.bloc_abandon()) + + def test_every_per_clone_flag_is_reset(self): + remis = self.remis_a_zero() + for nom in self.PAR_CLONE: + self.assertIn(nom, remis, f"{nom} survit à son clone") + + def test_each_reset_is_persisted(self): + # Remettre la liste à zéro sans l'écrire ne survit pas au + # processus : c'est la reprise qui relit le fichier. + corps = ast.dump(self.bloc_abandon()) + for cle in ( + "state_4_fix_migration_odoo_lst", + "state_4_uninstall_module", + "state_4_install_module", + ): + self.assertIn(cle, corps) + self.assertIn("write_config", corps) + + if __name__ == "__main__": unittest.main() From 770de6b0caa77f0e2c9a389ab90c38faf456a5b4 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sun, 23 Aug 2026 03:13:58 -0400 Subject: [PATCH 08/16] =?UTF-8?q?[FIX]=20script:=20diff=C3=A9rer=20les=20a?= =?UTF-8?q?nnotations,=20la=2012=20tourne=20en=20Python=203.7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La migration lance ses outils avec le venv de la version Odoo courante. Au premier palier c'est celui de la 12, en 3.7, où « dict | None » (3.10) et « tuple[str, str] » (3.9) sont ÉVALUÉS au chargement du module. La restauration du zip mourait donc sur un TypeError avant d'avoir rien fait, dans execute.py — importé par db_restore.py. `from __future__ import annotations` existe depuis 3.7 et le dépôt s'en sert déjà dans treize fichiers. Un test le vérifie maintenant sur toute la fermeture d'imports des outils que le pilote lance, points d'entrée lus dans le pilote pour que le script ajouté demain soit couvert. --- EN --- The migration runs its tools with the venv of the current Odoo version. At the first step that is 12's, on 3.7, where « dict | None » (3.10) and « tuple[str, str] » (3.9) are EVALUATED when the module loads. Restoring the zip therefore died on a TypeError before doing anything at all, in execute.py — imported by db_restore.py. `from __future__ import annotations` has existed since 3.7 and the repo already uses it in thirteen files. A test now checks the whole import closure of the tools the driver launches, with the entry points read from the driver so tomorrow's script is covered too. Assisted-by: Claude Opus 5 --- script/execute/execute.py | 6 + script/git/git_tool.py | 6 + script/git/github_api.py | 6 + script/git/repo_url.py | 6 + test/test_old_python_annotations.py | 201 ++++++++++++++++++++++++++++ 5 files changed, 225 insertions(+) create mode 100644 test/test_old_python_annotations.py diff --git a/script/execute/execute.py b/script/execute/execute.py index a042323..b7826de 100644 --- a/script/execute/execute.py +++ b/script/execute/execute.py @@ -2,6 +2,12 @@ # © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +# Annotations différées : la migration charge ce module sous le Python +# d'Odoo 12 — 3.7 — où « dict | None » et « tuple[str, str] » n'existent +# pas encore. Sans ceci, l'annotation est ÉVALUÉE au chargement et la +# migration meurt sur un TypeError avant d'avoir rien fait. +from __future__ import annotations + import datetime import logging import os diff --git a/script/git/git_tool.py b/script/git/git_tool.py index ccaabe3..cbf68fd 100644 --- a/script/git/git_tool.py +++ b/script/git/git_tool.py @@ -2,6 +2,12 @@ # © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +# Annotations différées : la migration charge ce module sous le Python +# d'Odoo 12 — 3.7 — où « dict | None » et « tuple[str, str] » n'existent +# pas encore. Sans ceci, l'annotation est ÉVALUÉE au chargement et la +# migration meurt sur un TypeError avant d'avoir rien fait. +from __future__ import annotations + import os import pathlib import webbrowser diff --git a/script/git/github_api.py b/script/git/github_api.py index 9ebbadf..a806eae 100644 --- a/script/git/github_api.py +++ b/script/git/github_api.py @@ -2,6 +2,12 @@ # © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +# Annotations différées : la migration charge ce module sous le Python +# d'Odoo 12 — 3.7 — où « dict | None » et « tuple[str, str] » n'existent +# pas encore. Sans ceci, l'annotation est ÉVALUÉE au chargement et la +# migration meurt sur un TypeError avant d'avoir rien fait. +from __future__ import annotations + import git from agithub.GitHub import GitHub from colorama import Fore, Style diff --git a/script/git/repo_url.py b/script/git/repo_url.py index 99e6bc1..8bdeb84 100644 --- a/script/git/repo_url.py +++ b/script/git/repo_url.py @@ -2,6 +2,12 @@ # © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +# Annotations différées : la migration charge ce module sous le Python +# d'Odoo 12 — 3.7 — où « dict | None » et « tuple[str, str] » n'existent +# pas encore. Sans ceci, l'annotation est ÉVALUÉE au chargement et la +# migration meurt sur un TypeError avant d'avoir rien fait. +from __future__ import annotations + import os diff --git a/test/test_old_python_annotations.py b/test/test_old_python_annotations.py new file mode 100644 index 0000000..acd65f2 --- /dev/null +++ b/test/test_old_python_annotations.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Ce que la migration charge doit se charger sous le plus vieux Python. + +Le pilote lance ses outils avec le venv de la version Odoo COURANTE : au +premier palier, c'est celui d'Odoo 12, en Python 3.7. Une annotation +« dict | None » (3.10) ou « tuple[str, str] » (3.9) y est ÉVALUÉE au +chargement du module et lève un TypeError avant que l'outil ait rien +fait — la migration meurt sur la restauration du zip. + +`from __future__ import annotations` (disponible depuis 3.7) diffère +l'évaluation : le module se charge partout, et le typage reste lisible. +""" + +import ast +import io +import json +import os +import re +import unittest + +RACINE = os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) +PILOTE = os.path.join(RACINE, "script", "todo", "todo_upgrade.py") +VERSIONS = os.path.join(RACINE, "conf", "supported_version_erplibre.json") + +DIFFERE = "from __future__ import annotations" +GENERIQUES = ("list", "dict", "tuple", "set", "type", "frozenset") + + +def lire(chemin): + with io.open(chemin, encoding="utf-8") as handle: + return handle.read() + + +def annotations_evaluees(arbre): + """Les annotations que Python évalue au chargement du module. + + Celles d'une signature le sont toujours. Une `x: T` de corps de + fonction ne l'est pas — la signaler produirait un faux échec. + """ + for noeud in ast.walk(arbre): + if isinstance(noeud, (ast.FunctionDef, ast.AsyncFunctionDef)): + a = noeud.args + for arg in ( + list(getattr(a, "posonlyargs", [])) + + list(a.args) + + list(a.kwonlyargs) + + [a.vararg, a.kwarg] + ): + if arg is not None and arg.annotation is not None: + yield arg.annotation + if noeud.returns is not None: + yield noeud.returns + elif isinstance(noeud, (ast.Module, ast.ClassDef)): + for petit in noeud.body: + if isinstance(petit, ast.AnnAssign): + yield petit.annotation + + +def trop_recent(source): + """Les annotations qu'un Python 3.7 ne saurait pas évaluer.""" + if DIFFERE in source: + return [] + trouves = [] + for annotation in annotations_evaluees(ast.parse(source)): + for noeud in ast.walk(annotation): + if isinstance(noeud, ast.BinOp) and isinstance( + noeud.op, ast.BitOr + ): + trouves.append(f"« a | b » ligne {noeud.lineno}") + elif ( + isinstance(noeud, ast.Subscript) + and isinstance(noeud.value, ast.Name) + and noeud.value.id in GENERIQUES + ): + trouves.append(f"« {noeud.value.id}[…] » ligne {noeud.lineno}") + return trouves + + +def module_vers_chemin(module): + chemin = os.path.join(RACINE, module.replace(".", os.sep) + ".py") + return chemin if os.path.isfile(chemin) else None + + +def importes(source): + for noeud in ast.walk(ast.parse(source)): + if isinstance(noeud, ast.ImportFrom): + if noeud.module and noeud.module.startswith("script"): + yield noeud.module + for alias in noeud.names: + yield f"{noeud.module}.{alias.name}" + elif isinstance(noeud, ast.Import): + for alias in noeud.names: + if alias.name.startswith("script"): + yield alias.name + + +def points_entree(): + """Les scripts que le pilote lance — lus dans le pilote, pas listés. + + Une liste écrite à la main vieillit en silence : le script ajouté + demain ne serait pas couvert, et c'est justement celui qui casse. + """ + trouves = set() + for ref in re.findall(r"\./script/[a-z0-9_/]+\.py", lire(PILOTE)): + chemin = os.path.join(RACINE, ref[2:]) + if os.path.isfile(chemin): + trouves.add(chemin) + return sorted(trouves) + + +def fermeture(): + vus, pile = set(), list(points_entree()) + while pile: + chemin = pile.pop() + if chemin in vus: + continue + vus.add(chemin) + for module in importes(lire(chemin)): + suivant = module_vers_chemin(module) + if suivant: + pile.append(suivant) + return sorted(vus) + + +class TestTheDetectorDetects(unittest.TestCase): + """Un détecteur qui ne détecte rien ferait passer le test à vide.""" + + def test_it_flags_a_union_in_a_signature(self): + self.assertTrue(trop_recent("def f(x: dict | None = None): pass\n")) + + def test_it_flags_a_builtin_generic_return(self): + self.assertTrue(trop_recent("def f() -> tuple[int, str]: pass\n")) + + def test_a_runtime_union_is_not_an_annotation(self): + # « set(a) | b » est une union d'ENSEMBLES, valide depuis toujours. + self.assertEqual(trop_recent("x = set('ab') | set('cd')\n"), []) + + def test_deferring_makes_it_legal(self): + self.assertEqual( + trop_recent(f"{DIFFERE}\ndef f(x: dict | None = None): pass\n"), [] + ) + + def test_a_body_annotation_is_never_evaluated(self): + self.assertEqual( + trop_recent("def f():\n x: dict | None = None\n return x\n"), + [], + ) + + +class TestTheFloorIsWhatTheProjectDeclares(unittest.TestCase): + def test_the_oldest_supported_python_is_still_pre_3_9(self): + # Le jour où la 12 et la 13 disparaissent, ce garde-fou n'a plus + # de raison d'être : qu'il le dise plutôt que de survivre seul. + pythons = [] + for valeur in json.loads(lire(VERSIONS)).values(): + if isinstance(valeur, dict) and valeur.get("python_version"): + pythons.append( + tuple( + int(x) for x in valeur["python_version"].split(".")[:2] + ) + ) + self.assertTrue(pythons) + self.assertLess( + min(pythons), + (3, 9), + "plus aucune version sous 3.9 : ce test peut disparaître", + ) + + +class TestWhatTheMigrationLoads(unittest.TestCase): + def test_the_scan_is_not_empty(self): + self.assertGreater(len(points_entree()), 10) + self.assertGreater(len(fermeture()), 15) + + def test_execute_is_in_the_closure(self): + # Le module par lequel l'incident est arrivé : s'il sortait de la + # fermeture, le test passerait sans plus rien garder. + self.assertIn( + os.path.join(RACINE, "script", "execute", "execute.py"), + fermeture(), + ) + + def test_every_loaded_module_survives_python_3_7(self): + coupables = {} + for chemin in fermeture(): + trouves = trop_recent(lire(chemin)) + if trouves: + coupables[os.path.relpath(chemin, RACINE)] = trouves + self.assertEqual( + coupables, + {}, + "annotation évaluée au chargement ; ajouter" + f" « {DIFFERE} » en tête de ces fichiers", + ) + + +if __name__ == "__main__": + unittest.main() From f12e79c3a937650a5f50a84da3cf9271345db169 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sun, 23 Aug 2026 03:28:03 -0400 Subject: [PATCH 09/16] =?UTF-8?q?[ADD]=20qemu=20:=20d=C3=A9ployer=20Proxmo?= =?UTF-8?q?x=20VE=20(amd64,=20arm64)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proxmox ne publie aucune image cloud : son ISO est un installateur qui formate le disque. On prend donc la voie que l'amont documente lui-même — Proxmox VE sur Debian — depuis l'image cloud trixie, partagée avec un déploiement Debian 13 au lieu d'être téléchargée deux fois. s390x n'y est pas et n'y sera pas par cette voie : le dépôt n'a aucun index binary-s390x. arm64 y est, officiel depuis PVE 9. Le catalogue le dit AVANT le déploiement, au lieu d'échouer au premier apt. Vérifié sur une VM réelle : pve-manager 9.2.11, noyau 7.0.14-12-pve, quatre services actifs, interface web en HTTP 200. --- EN --- Proxmox publishes no cloud image: its ISO is an installer that formats the disk. So we take the path upstream documents itself — Proxmox VE on Debian — from the trixie cloud image, shared with a Debian 13 deployment instead of being downloaded twice. s390x is not there and will not be by this route: the repository has no binary-s390x index. arm64 is, official since PVE 9. The catalog says so BEFORE the deployment rather than failing at the first apt. Verified on a real VM: pve-manager 9.2.11, kernel 7.0.14-12-pve, four services active, web UI answering HTTP 200. Assisted-by: Claude Opus 5 --- script/proxmox/install_proxmox.sh | 495 ++++++++++++++++++++++++++++++ script/qemu/README.base.md | 47 +++ script/qemu/README.fr.md | 33 ++ script/qemu/README.md | 14 + script/qemu/deploy_qemu.py | 44 +++ script/todo/todo.py | 39 ++- script/todo/todo_i18n.py | 12 + test/test_qemu_proxmox.py | 392 +++++++++++++++++++++++ 8 files changed, 1073 insertions(+), 3 deletions(-) create mode 100755 script/proxmox/install_proxmox.sh create mode 100644 test/test_qemu_proxmox.py diff --git a/script/proxmox/install_proxmox.sh b/script/proxmox/install_proxmox.sh new file mode 100755 index 0000000..9c6cf5e --- /dev/null +++ b/script/proxmox/install_proxmox.sh @@ -0,0 +1,495 @@ +#!/usr/bin/env bash +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +# +# Installe Proxmox VE — https://www.proxmox.com — SUR UNE DEBIAN existante. +# +# Pourquoi sur Debian : Proxmox ne publie aucune image cloud. Son ISO est un +# installateur qui formate le disque, ce qui ne se pilote pas depuis un +# déploiement cloud-init. La voie que l'amont documente lui-même pour ce cas +# est « Install Proxmox VE on Debian » : on part de l'image cloud Debian, on +# ajoute le dépôt pve, et les paquets font le reste. Le résultat est le même +# hyperviseur, avec le noyau Proxmox et l'interface web sur :8006. +# +# Architectures : amd64 et arm64 (arm64 officiel depuis PVE 9 — le Release de +# trixie annonce « amd64 arm64 »). PAS s390x : l'index « binary-s390x » du +# dépôt répond 404, le port n'existe pas. +# +# Réglages, par variables d'environnement : +# PVE_SUITE suite Debian visée (défaut : trixie, = PVE 9) +# PVE_REBOOT à 1, redémarre à la fin (défaut : ne redémarre PAS — +# lancé par SSH, un reboot couperait la session et ferait +# passer une installation réussie pour un échec) +# PVE_KEEP_DEBIAN_KERNEL à 1, garde le noyau Debian à côté du noyau pve +# PVE_OS_RELEASE fichier os-release à lire (défaut : /etc/os-release) ; +# sert à vérifier le script depuis une autre distribution +set -euo pipefail + +Red='\033[0;31m' +Green='\033[0;32m' +Yellow='\033[0;33m' +Color_Off='\033[0m' + +SUITE="${PVE_SUITE:-trixie}" +KEYRING=/usr/share/keyrings/proxmox-archive-keyring.gpg +SOURCES=/etc/apt/sources.list.d/pve-install-repo.sources + +# Somme relevée sur la page amont « Install Proxmox VE on Debian 13 Trixie », +# et VÉRIFIÉE contre le fichier servi. Elle vaut ceinture ET bretelle : c'est +# la clé qui authentifiera tout le reste, et la télécharger sans la contrôler +# reviendrait à faire confiance au seul transport. +KEY_URL="https://enterprise.proxmox.com/debian/proxmox-archive-keyring-${SUITE}.gpg" +KEY_SHA256_trixie=136673be77aba35dcce385b28737689ad64fd785a797e57897589aed08db6e45 + +# Ce qui a changé : décide du redémarrage final. Une installation déjà faite ne +# doit pas redémarrer la machine pour rien. +CHANGED=0 + +say() { echo -e "$@"; } +die() { say "${Red}✗${Color_Off} $*"; exit 1; } + +DRY=0 +usage() { + cat < » que pose l'image cloud Debian le +# mène droit dans le mur, et pveproxy comme pvecm s'en trouvent mal. +# « hostname --ip-address » est le test que l'amont donne lui-même. +host_ip() { + local ip="" + for ip in \ + "$(ip -4 route get 1 2>/dev/null | awk '{print $7; exit}')" \ + "$(hostname -I 2>/dev/null | awk '{print $1}')" \ + "$(ip -4 -o addr show scope global 2>/dev/null \ + | awk '{split($4, a, "/"); print a[1]; exit}')" + do + case "$ip" in + 127.*) continue ;; + [0-9]*.[0-9]*.[0-9]*.[0-9]*) echo "$ip"; return 0 ;; + esac + done + return 1 +} + +fix_hosts() { + local ip fqdn short + ip="$(host_ip)" || die "aucune adresse IPv4 routable : réseau absent ?" + short="$(hostname -s)" + fqdn="$(hostname -f 2>/dev/null || echo "${short}")" + [ "${fqdn}" = "${short}" ] && fqdn="${short}.local" + if grep -qE "^[[:space:]]*127\.0\.1\.1[[:space:]]" /etc/hosts; then + say " retrait de l'entrée 127.0.1.1 (bouclage) pour ${short}" + run sudo sed -i -E "/^[[:space:]]*127\.0\.1\.1[[:space:]]/d" \ + /etc/hosts + CHANGED=1 + fi + if ! grep -qE "^[[:space:]]*${ip//./\\.}[[:space:]]+.*\b${short}\b" \ + /etc/hosts; then + say " ${short} -> ${ip} dans /etc/hosts" + if [ "${DRY}" = "1" ]; then + say " ${Yellow}[dry-run]${Color_Off} ${ip} ${fqdn} ${short}" \ + ">> /etc/hosts" + else + printf '%s\t%s %s\n' "${ip}" "${fqdn}" "${short}" \ + | sudo tee -a /etc/hosts >/dev/null + fi + CHANGED=1 + fi + # Le test de l'amont, mot pour mot : au moins une adresse non-bouclage. + local vu routables + vu="$(hostname --ip-address 2>/dev/null || true)" + routables="$(printf '%s\n' ${vu} | grep -vE '^(127\.|::1$)' || true)" + [ -n "${routables}" ] || die \ + "« hostname --ip-address » rend « ${vu:-rien} » : le nom d'hôte ne" \ + "résout toujours pas vers une adresse routable." + say " hostname --ip-address : $(printf '%s ' ${routables})" +} + +# --- 4. Dépôt et clé -------------------------------------------------------- +add_repo() { + local attendu="KEY_SHA256_${SUITE}" + attendu="${!attendu:-}" + if [ ! -s "${KEYRING}" ]; then + say " clé du dépôt -> ${KEYRING}" + run sudo mkdir -p "$(dirname "${KEYRING}")" + run sudo wget -q "${KEY_URL}" -O "${KEYRING}" \ + || die "téléchargement de la clé impossible : ${KEY_URL}" + # Un wget qui rend 0 sans laisser de fichier — proxy captif, disque + # plein — ferait mourir la suite sur un message de sha256sum au lieu + # d'un diagnostic. + if [ "${DRY}" != "1" ]; then + sudo test -s "${KEYRING}" \ + || die "clé absente ou vide après téléchargement : ${KEYRING}" + fi + CHANGED=1 + fi + if [ -n "${attendu}" ] && [ "${DRY}" = "1" ]; then + say " clé à vérifier contre sha256 ${attendu:0:16}…" + elif [ -n "${attendu}" ]; then + local vu + vu="$(sudo sha256sum "${KEYRING}" 2>/dev/null | awk '{print $1}')" \ + || true + [ -n "${vu}" ] || die "somme de la clé illisible : ${KEYRING}" + if [ "${vu}" != "${attendu}" ]; then + # On efface la clé douteuse : la laisser en place ferait passer la + # prochaine exécution pour bonne, le fichier étant non vide. + run sudo rm -f "${KEYRING}" + die "somme de la clé inattendue :\n vue ${vu}\n" \ + " attendue ${attendu}" + fi + say " clé vérifiée (sha256 ${vu:0:16}…)" + else + say "${Yellow}⚠${Color_Off} aucune somme connue pour « ${SUITE} » :" \ + "clé acceptée sans contrôle." + fi + # Format deb822, celui que l'amont recommande sur Debian 13. + local voulu + voulu="$(printf 'Types: deb\nURIs: http://download.proxmox.com/debian/pve\nSuites: %s\nComponents: pve-no-subscription\nSigned-By: %s\n' \ + "${SUITE}" "${KEYRING}")" + if [ "$(cat "${SOURCES}" 2>/dev/null || true)" != "${voulu}" ]; then + say " dépôt pve-no-subscription -> ${SOURCES}" + if [ "${DRY}" = "1" ]; then + say " ${Yellow}[dry-run]${Color_Off} ${SOURCES} :" \ + "$(printf '%s' "${voulu}" | tr '\n' '|')" + else + printf '%s\n' "${voulu}" | sudo tee "${SOURCES}" >/dev/null + fi + CHANGED=1 + fi +} + +# --- 6. Dépôt entreprise ---------------------------------------------------- +# « proxmox-ve » installe SON dépôt entreprise (pve-enterprise.sources), qui +# exige un abonnement payant. Sans lui, tout « apt update » ultérieur échoue : +# « 401 Unauthorized » puis « The repository is not signed », et la machine ne +# peut plus rien installer — pas même une mise à jour de sécurité. Mesuré sur +# la VM d'essai, au deuxième passage du script. +# +# On le désactive au lieu de l'effacer : « Enabled: false » est la forme deb822 +# prévue pour cela, elle survit aux mises à jour du paquet, et il suffit de +# retirer la ligne le jour où un abonnement existe. +disable_enterprise() { + local f + for f in /etc/apt/sources.list.d/pve-enterprise.sources \ + /etc/apt/sources.list.d/ceph.sources; do + [ -e "${f}" ] || continue + if sudo grep -qiE '^[[:space:]]*Enabled:[[:space:]]*(false|no)' \ + "${f}"; then + continue + fi + say " dépôt entreprise désactivé : $(basename "${f}")" + if [ "${DRY}" = "1" ]; then + say " ${Yellow}[dry-run]${Color_Off} Enabled: false >> ${f}" + else + printf 'Enabled: false\n' | sudo tee -a "${f}" >/dev/null + fi + CHANGED=1 + done + # Les anciennes formes « .list », au cas où une mise à jour les remette. + for f in /etc/apt/sources.list.d/pve-enterprise.list; do + [ -e "${f}" ] || continue + sudo grep -qE '^[[:space:]]*#' "${f}" && continue + say " dépôt entreprise commenté : $(basename "${f}")" + run sudo sed -i -E 's/^([[:space:]]*deb)/#\1/' "${f}" + CHANGED=1 + done +} + +# --- 5. Les paquets --------------------------------------------------------- +# Disque d'amorçage, pour la préréponse de grub-pc. +boot_disk() { + local src + src="$(findmnt -no SOURCE /boot 2>/dev/null \ + || findmnt -no SOURCE / 2>/dev/null)" + [ -n "${src}" ] || return 1 + local dq + dq="$(lsblk -no pkname "${src}" 2>/dev/null | head -1)" + [ -n "${dq}" ] || return 1 + printf '/dev/%s\n' "${dq}" +} + +# Deux paquets posent des questions debconf, et une seule sans réponse suffit à +# faire échouer TOUTE la transaction apt : +# +# - postfix demande son type et son nom de courrier. Sans préréponse, +# l'installation attend une saisie que personne ne verra — un déploiement +# par SSH y reste pendu. +# - grub-pc demande SUR QUEL DISQUE s'installer. C'est le piège propre à +# l'image cloud : elle amorce en EFI (grub-cloud-amd64), les paquets pve +# tirent grub-pc par-dessus, et sa post-installation refuse de deviner — +# « You must correct your GRUB install devices before proceeding », mesuré, +# dpkg s'arrête et le noyau Proxmox reste à moitié configuré. +preseed_debconf() { + command -v debconf-set-selections >/dev/null 2>&1 || return 0 + local lignes disque + lignes="$(printf '%s\n' \ + "postfix postfix/main_mailer_type select Local only" \ + "postfix postfix/mailname string $(hostname -f 2>/dev/null \ + || hostname -s)")" + if disque="$(boot_disk)"; then + lignes="$(printf '%s\n%s\n' "${lignes}" \ + "grub-pc grub-pc/install_devices multiselect ${disque}")" + else + say "${Yellow}⚠${Color_Off} disque d'amorçage introuvable :" \ + "grub-pc pourrait demander où s'installer." + fi + # Pas de tube vers « run » : en dry-run il ne lirait pas son entrée, le + # printf recevrait SIGPIPE, et « set -o pipefail » emporterait le script — + # mort silencieuse sur un code 141. Vécu. + if [ "${DRY}" = "1" ]; then + say " ${Yellow}[dry-run]${Color_Off} debconf-set-selections :" \ + "$(printf '%s' "${lignes}" | tr '\n' '|')" + return 0 + fi + printf '%s\n' "${lignes}" | sudo debconf-set-selections +} + +# « DPkg::Lock::Timeout » : sur une VM fraîchement déployée, cloud-init tient +# encore le verrou d'apt — mesuré, « E: Could not get lock +# /var/lib/apt/lists/lock. It is held by process 996 (apt-get) », et le script +# mourait 40 secondes après le démarrage. apt sait ATTENDRE son tour depuis la +# 1.9 ; sans cette option il abandonne immédiatement. +apt_get() { + run sudo env DEBIAN_FRONTEND=noninteractive \ + apt-get -o Dpkg::Options::=--force-confold \ + -o DPkg::Lock::Timeout=600 -y "$@" +} + +# Attendre la fin de cloud-init AVANT de toucher à apt : c'est lui qui pose les +# paquets de la première mise en route. Son état final n'est PAS un critère — +# vérifié sur l'image Debian 13, où il rend « error » pour deux modules sans +# rapport (console-setup absent, update-locale) tout en ayant terminé son +# travail. On attend qu'il ait fini, pas qu'il soit content. +wait_cloud_init() { + command -v cloud-init >/dev/null 2>&1 || return 0 + say " attente de la fin de cloud-init…" + if [ "${DRY}" = "1" ]; then + say " ${Yellow}[dry-run]${Color_Off} cloud-init status --wait" + return 0 + fi + timeout 600 cloud-init status --wait >/dev/null 2>&1 || true + return 0 +} + +install_pve() { + wait_cloud_init + preseed_debconf + # Réparer une transaction laissée à moitié par une exécution précédente : + # sans réponse à grub-pc, dpkg s'arrête au milieu et tout apt suivant + # refuse de travailler. Inoffensif quand rien n'est cassé. + if ! sudo dpkg -C >/dev/null 2>&1; then + say " paquets à moitié configurés : dpkg --configure -a" + run sudo env DEBIAN_FRONTEND=noninteractive dpkg --configure -a \ + || true + fi + # AVANT le premier apt : sur un second passage, le dépôt entreprise posé + # par proxmox-ve ferait échouer « apt update » (401) et rien n'irait plus + # loin — pas même la désactivation, si elle attendait la fin. + disable_enterprise + say "\n---- apt update ----" + apt_get update + # Le noyau d'abord, comme l'amont le prescrit : c'est lui qui porte les + # modules dont pve a besoin, et l'installer seul laisse une machine qui + # redémarre proprement même si la suite échoue. + if ! dpkg -s proxmox-default-kernel >/dev/null 2>&1; then + say "\n---- noyau Proxmox ----" + apt_get install proxmox-default-kernel + CHANGED=1 + else + say " noyau Proxmox déjà posé" + fi + if ! dpkg -s proxmox-ve >/dev/null 2>&1; then + say "\n---- proxmox-ve, postfix, open-iscsi, chrony ----" + apt_get install proxmox-ve postfix open-iscsi chrony + CHANGED=1 + # C'est CETTE installation qui vient de poser le dépôt entreprise : + # le désactiver tout de suite, avant que le ménage ne rappelle apt. + disable_enterprise + else + say " proxmox-ve déjà posé" + fi +} + +# --- 6. Ménage -------------------------------------------------------------- +# os-prober ajoute au menu d'amorçage les systèmes trouvés sur les disques des +# VM invitées : sur un hyperviseur, c'est une liste de faux départs. +# Le noyau Debian, lui, n'a plus de raison d'être une fois celui de pve en +# place — et laissé par défaut, grub peut y revenir. +cleanup() { + if dpkg -s os-prober >/dev/null 2>&1; then + say " retrait d'os-prober" + apt_get remove os-prober + CHANGED=1 + fi + [ "${PVE_KEEP_DEBIAN_KERNEL:-0}" = "1" ] && return 0 + # Ne JAMAIS retirer le noyau Debian si celui de Proxmox n'est pas posé : + # la machine ne redémarrerait plus. Vérifié, pas supposé — une étape apt + # peut avoir échoué plus haut sans arrêter le reste. + if ! dpkg -s proxmox-default-kernel >/dev/null 2>&1; then + say " noyau Proxmox absent : le noyau Debian reste en place." + return 0 + fi + local metas + # Tout « linux-image-* » SAUF ceux de Proxmox. L'amont écrit + # « linux-image-6.12* », la version de trixie ; le motif large couvre les + # versions suivantes, et l'exclusion protège le noyau qu'on vient de + # poser — le retirer laisserait une VM qui n'amorce plus. + # « db:Status-Status » filtre les paquets RÉELLEMENT installés : dpkg + # connaît aussi ceux qu'il a désinstallés (« config-files », + # « not-installed »), et les passer à apt donnait « is not installed, so + # not removed » — puis un CHANGED=1 qui réclamait un redémarrage inutile. + metas="$(dpkg-query -W -f '${Package} ${db:Status-Status}\n' \ + 'linux-image-*' 2>/dev/null \ + | awk '$2 == "installed" {print $1}' \ + | grep -vE 'pve|proxmox' || true)" + if [ -n "${metas}" ]; then + say " retrait du noyau Debian : $(echo "${metas}" | tr '\n' ' ')" + # shellcheck disable=SC2086 + apt_get remove ${metas} + run sudo update-grub 2>/dev/null || true + CHANGED=1 + fi + return 0 +} + +# --- 6bis. Amorçage EFI de secours ----------------------------------------- +# Une image cloud amorce par le CHEMIN DE SECOURS de l'UEFI — +# \EFI\BOOT\BOOTX64.EFI — parce qu'aucune entrée NVRAM ne la nomme. Les +# paquets grub de Proxmox y recopient bien leurs binaires, mais PAS le petit +# grub.cfg qui indique où trouver la vraie configuration. GRUB s'arrête alors +# sur son invite de secours « grub> » : ni menu, ni noyau. +# +# Vécu, capture d'écran à l'appui : après le premier redémarrage, la VM brûlait +# 100 % d'un cœur SANS lire une seule fois le disque, et l'écran affichait +# « starting Boot0001 UEFI Misc Device » suivi de « grub> ». +# +# On recopie le stub que Debian a généré pour son propre chemin : il porte +# l'UUID de la racine, donc il fonctionne depuis n'importe quel chargeur. +# « grub-install --removable » ferait la même chose en réécrivant le chargeur ; +# copier un fichier est plus sûr — cela ne touche pas au chemin qui marche. +fix_efi_fallback() { + local esp="${PVE_ESP:-/boot/efi}" + local secours="${esp}/EFI/BOOT" + [ -d "${secours}" ] || return 0 + [ -e "${secours}/grub.cfg" ] && return 0 + local stub="" candidat="" + for candidat in "${esp}"/EFI/*/grub.cfg; do + [ -e "${candidat}" ] || continue + case "${candidat}" in + */EFI/BOOT/grub.cfg) continue ;; + esac + stub="${candidat}" + break + done + if [ -z "${stub}" ]; then + say "${Yellow}⚠${Color_Off} aucun grub.cfg à recopier sous ${esp} :" \ + "vérifier l'amorçage avant de redémarrer." + return 0 + fi + say " amorçage de secours : $(dirname "${stub}" | xargs basename)/grub.cfg" \ + "-> EFI/BOOT/" + run sudo cp "${stub}" "${secours}/grub.cfg" + CHANGED=1 +} + +# --- 7. Déroulé ------------------------------------------------------------- +say "${Green}==>${Color_Off} Proxmox VE ${SUITE} sur ${ARCH}" +say "\n---- nom d'hôte et /etc/hosts ----" +fix_hosts +say "\n---- dépôt Proxmox ----" +add_repo +install_pve +say "\n---- ménage ----" +cleanup +say "\n---- amorçage ----" +fix_efi_fallback + +IP="$(host_ip || echo localhost)" +if [ "${DRY}" = "1" ]; then + # Ne rien annoncer qui n'ait eu lieu : en dry-run, rien n'a été installé. + say "\n${Yellow}✓${Color_Off} dry-run terminé : rien n'a été changé." + say " Ce qui serait joignable ensuite : https://${IP}:8006" + exit 0 +fi +say "\n${Green}✓${Color_Off} Proxmox VE installé." +say " Interface web : ${Green}https://${IP}:8006${Color_Off}" +# Proxmox authentifie par PAM : c'est le root du système qui ouvre l'interface. +# Sur une image cloud il est VERROUILLÉ (« passwd -S root » rend « L »), donc +# l'interface est inutilisable tant qu'on ne lui a pas donné un mot de passe. +# Le dire ici plutôt que de laisser chercher devant un formulaire qui refuse. +if [ "$(sudo passwd -S root 2>/dev/null | awk '{print $2}')" = "L" ]; then + say " Compte : root, ${Yellow}sans mot de passe${Color_Off} —" \ + "l'interface le refusera. À faire : ${Green}sudo passwd root${Color_Off}" +else + say " Compte : root (mot de passe du système)" +fi +say " Une VM Proxmox est un hyperviseur DANS une VM : ses propres invités" +say " demandent la virtualisation imbriquée à tous les étages." + +if [ "${CHANGED}" = "0" ]; then + say "\n Rien n'a changé : pas de redémarrage." + exit 0 +fi +if [ "${PVE_REBOOT:-0}" != "1" ]; then + say "\n${Yellow}⚠${Color_Off} Redémarrage NÉCESSAIRE pour amorcer le" \ + "noyau Proxmox : sudo reboot" + exit 0 +fi +say "\n---- redémarrage pour amorcer le noyau Proxmox ----" +run sudo systemctl reboot diff --git a/script/qemu/README.base.md b/script/qemu/README.base.md index cf6668a..4f9afbf 100644 --- a/script/qemu/README.base.md +++ b/script/qemu/README.base.md @@ -184,10 +184,24 @@ Catalog, per architecture (`deploy_qemu.py` is the source of truth): | rocky | `9`, `10` (default) | ✔ | ✔ | ✔ | | opensuse | `16.0` (default), `tumbleweed` | ✔ | ✔ | ✔ | | arch | `latest` | ✔ | — | — | +| proxmox | `9` | ✔ | ✔ | — | Fedora builds s390x only for the current release, and on a separate tree (`fedora-secondary`) — hence the single version there. +`proxmox` is Proxmox VE, and it deserves a word: it publishes **no cloud +image** — its ISO is an installer that formats the disk. So the deployment +does what upstream itself documents for every other case, *Proxmox VE on +Debian*: it downloads the **Debian trixie** cloud image (the very same file, so +a Debian 13 and a Proxmox deployment share one download) and the `pve` +packages turn it into a hypervisor — Proxmox kernel, web UI on `:8006`. + +The version number is Proxmox's, not Debian's: PVE 9 = trixie. arm64 has been +official since PVE 9 (the upstream `trixie` Release announces `amd64 arm64`, +and the arm64 index really serves `proxmox-ve`). s390x is absent and will stay +so by this route: the repository has no `binary-s390x` index at all — the +catalog says it before the deployment rather than failing at the first `apt`. + `opensuse` covers two distinct products, not two versions of one. Leap `16.0` is numbered and stable (SLE base) and is the default. `tumbleweed` is the rolling one, kept as a bellwether for breakage to come: its snapshot drift is @@ -218,6 +232,7 @@ Catalogue, par architecture (`deploy_qemu.py` fait autorité) : | rocky | `9`, `10` (défaut) | ✔ | ✔ | ✔ | | opensuse | `16.0` (défaut), `tumbleweed` | ✔ | ✔ | ✔ | | arch | `latest` | ✔ | — | — | +| proxmox | `9` | ✔ | ✔ | — | Fedora ne construit s390x que pour la version courante, et sur une arborescence à part (`fedora-secondary`) — d'où la version unique. @@ -232,6 +247,38 @@ Les deux livrent un qpdf au-dessus du seuil de pikepdf : la compilation de qpdf, une demi-heure, ne s'y déclenche jamais — ce qui compte sous émulation s390x. +`proxmox`, c'est Proxmox VE, et il mérite un mot : il ne publie **aucune image +cloud** — son ISO est un installateur qui formate le disque. Le déploiement +fait donc ce que l'amont documente lui-même pour tous les autres cas, +*Proxmox VE sur Debian* : il télécharge l'image cloud **Debian trixie** (le +même fichier, si bien qu'un déploiement Debian 13 et un Proxmox se partagent un +seul téléchargement) et les paquets `pve` en font un hyperviseur — noyau +Proxmox, interface web sur `:8006`. + +Le numéro de version est celui de Proxmox, pas de Debian : PVE 9 = trixie. +arm64 est officiel depuis PVE 9 (le Release `trixie` de l'amont annonce +`amd64 arm64`, et l'index arm64 sert bien `proxmox-ve`). s390x est absent et le +restera par cette voie : le dépôt n'a aucun index `binary-s390x` — le catalogue +le dit avant le déploiement plutôt que d'échouer au premier `apt`. + +Une VM Proxmox est un hyperviseur DANS une VM : ses propres invités demandent +la virtualisation imbriquée à tous les étages. L'installation se fait par le +profil « Hyperviseur Proxmox VE (sans Odoo) » du menu de déploiement, ou à la +main dans la VM : + +```bash +sudo ./script/proxmox/install_proxmox.sh --dry-run # dit ce qu'il ferait +sudo ./script/proxmox/install_proxmox.sh # puis : sudo reboot +``` + +Trois pièges de l'image cloud, tous rencontrés sur une VM réelle et traités par +le script : cloud-init tient encore le verrou d'`apt` au premier démarrage ; +`grub-pc`, tiré par les paquets `pve`, demande sur quel disque s'installer et +bloque toute la transaction sans préréponse ; et le chemin de secours UEFI +(`\EFI\BOOT\`) reçoit les binaires GRUB de Proxmox mais pas le `grub.cfg` +qui dit où trouver la configuration — sans quoi la VM s'arrête sur l'invite +`grub>`, sans menu ni noyau. + Fournissez un chemin d'image en argument positionnel pour surcharger l'emplacement de téléchargement automatique. diff --git a/script/qemu/README.fr.md b/script/qemu/README.fr.md index fcff7ef..e63deef 100644 --- a/script/qemu/README.fr.md +++ b/script/qemu/README.fr.md @@ -103,6 +103,7 @@ Catalogue, par architecture (`deploy_qemu.py` fait autorité) : | rocky | `9`, `10` (défaut) | ✔ | ✔ | ✔ | | opensuse | `16.0` (défaut), `tumbleweed` | ✔ | ✔ | ✔ | | arch | `latest` | ✔ | — | — | +| proxmox | `9` | ✔ | ✔ | — | Fedora ne construit s390x que pour la version courante, et sur une arborescence à part (`fedora-secondary`) — d'où la version unique. @@ -117,6 +118,38 @@ Les deux livrent un qpdf au-dessus du seuil de pikepdf : la compilation de qpdf, une demi-heure, ne s'y déclenche jamais — ce qui compte sous émulation s390x. +`proxmox`, c'est Proxmox VE, et il mérite un mot : il ne publie **aucune image +cloud** — son ISO est un installateur qui formate le disque. Le déploiement +fait donc ce que l'amont documente lui-même pour tous les autres cas, +*Proxmox VE sur Debian* : il télécharge l'image cloud **Debian trixie** (le +même fichier, si bien qu'un déploiement Debian 13 et un Proxmox se partagent un +seul téléchargement) et les paquets `pve` en font un hyperviseur — noyau +Proxmox, interface web sur `:8006`. + +Le numéro de version est celui de Proxmox, pas de Debian : PVE 9 = trixie. +arm64 est officiel depuis PVE 9 (le Release `trixie` de l'amont annonce +`amd64 arm64`, et l'index arm64 sert bien `proxmox-ve`). s390x est absent et le +restera par cette voie : le dépôt n'a aucun index `binary-s390x` — le catalogue +le dit avant le déploiement plutôt que d'échouer au premier `apt`. + +Une VM Proxmox est un hyperviseur DANS une VM : ses propres invités demandent +la virtualisation imbriquée à tous les étages. L'installation se fait par le +profil « Hyperviseur Proxmox VE (sans Odoo) » du menu de déploiement, ou à la +main dans la VM : + +```bash +sudo ./script/proxmox/install_proxmox.sh --dry-run # dit ce qu'il ferait +sudo ./script/proxmox/install_proxmox.sh # puis : sudo reboot +``` + +Trois pièges de l'image cloud, tous rencontrés sur une VM réelle et traités par +le script : cloud-init tient encore le verrou d'`apt` au premier démarrage ; +`grub-pc`, tiré par les paquets `pve`, demande sur quel disque s'installer et +bloque toute la transaction sans préréponse ; et le chemin de secours UEFI +(`\EFI\BOOT\`) reçoit les binaires GRUB de Proxmox mais pas le `grub.cfg` +qui dit où trouver la configuration — sans quoi la VM s'arrête sur l'invite +`grub>`, sans menu ni noyau. + Fournissez un chemin d'image en argument positionnel pour surcharger l'emplacement de téléchargement automatique. diff --git a/script/qemu/README.md b/script/qemu/README.md index 38cf1e2..dca1d18 100644 --- a/script/qemu/README.md +++ b/script/qemu/README.md @@ -97,10 +97,24 @@ Catalog, per architecture (`deploy_qemu.py` is the source of truth): | rocky | `9`, `10` (default) | ✔ | ✔ | ✔ | | opensuse | `16.0` (default), `tumbleweed` | ✔ | ✔ | ✔ | | arch | `latest` | ✔ | — | — | +| proxmox | `9` | ✔ | ✔ | — | Fedora builds s390x only for the current release, and on a separate tree (`fedora-secondary`) — hence the single version there. +`proxmox` is Proxmox VE, and it deserves a word: it publishes **no cloud +image** — its ISO is an installer that formats the disk. So the deployment +does what upstream itself documents for every other case, *Proxmox VE on +Debian*: it downloads the **Debian trixie** cloud image (the very same file, so +a Debian 13 and a Proxmox deployment share one download) and the `pve` +packages turn it into a hypervisor — Proxmox kernel, web UI on `:8006`. + +The version number is Proxmox's, not Debian's: PVE 9 = trixie. arm64 has been +official since PVE 9 (the upstream `trixie` Release announces `amd64 arm64`, +and the arm64 index really serves `proxmox-ve`). s390x is absent and will stay +so by this route: the repository has no `binary-s390x` index at all — the +catalog says it before the deployment rather than failing at the first `apt`. + `opensuse` covers two distinct products, not two versions of one. Leap `16.0` is numbered and stable (SLE base) and is the default. `tumbleweed` is the rolling one, kept as a bellwether for breakage to come: its snapshot drift is diff --git a/script/qemu/deploy_qemu.py b/script/qemu/deploy_qemu.py index 86f700e..b874180 100755 --- a/script/qemu/deploy_qemu.py +++ b/script/qemu/deploy_qemu.py @@ -140,6 +140,30 @@ ARCH_VERSIONS: dict[str, tuple[str, str, int, str]] = { "latest": ("latest", "archlinux", 1024, "20G"), } +# Proxmox VE ne publie AUCUNE image cloud : son ISO est un installateur, et la +# voie que l'amont documente pour tout le reste est « Proxmox VE sur Debian ». +# La base est donc l'image cloud Debian, que les paquets pve transforment en +# hyperviseur. Le numéro est celui de PROXMOX, pas de Debian : PVE 9 = trixie. +# +# arm64 est officiel depuis PVE 9 — vérifié dans le dépôt amont, dont le +# Release de trixie annonce « amd64 arm64 » et dont l'index arm64 sert bien +# proxmox-ve. bookworm (PVE 8), lui, est amd64 seulement : d'où une seule +# version au catalogue, celle qui couvre les deux architectures. +# +# s390x n'y figure pas et n'y figurera pas par cette voie : l'index +# « binary-s390x » du dépôt répond 404. Ce n'est pas une difficulté, c'est une +# absence — et ARCH_DISTRO_SUPPORT la dit à la place d'un échec au montage. +# +# RAM/disque : 4 Gio et 32 Go, pas les 1 Gio/20 Go de Debian. Proxmox demande +# 2 Gio pour lui seul, et l'installation télécharge son propre noyau ; 20 Go ne +# laisseraient pas la place d'une seule VM invitée. +PROXMOX_VERSIONS: dict[str, tuple[str, str, int, str]] = { + "9": ("trixie", "debian13", 4096, "32G"), +} + +# Version Debian dont l'image sert de base à chaque version de Proxmox. +PROXMOX_DEBIAN_BASE: dict[str, str] = {"9": "13"} + # distro -> (table des versions, version par défaut). DISTROS: dict[str, tuple[dict[str, tuple[str, str, int, str]], str]] = { "ubuntu": (UBUNTU_VERSIONS, "24.04"), @@ -149,6 +173,7 @@ DISTROS: dict[str, tuple[dict[str, tuple[str, str, int, str]], str]] = { "rocky": (ROCKY_VERSIONS, "10"), "opensuse": (OPENSUSE_VERSIONS, "16.0"), "arch": (ARCH_VERSIONS, "latest"), + "proxmox": (PROXMOX_VERSIONS, "9"), } # Traduction de l'arch générique (amd64/arm64) vers le nom propre à la distro. @@ -235,6 +260,7 @@ ARM64_DISTROS: tuple[str, ...] = ( "almalinux", "rocky", "opensuse", + "proxmox", ) @@ -429,6 +455,13 @@ def image_candidates( f"{base}/{code}/latest/debian-{version}-genericcloud-{a}.qcow2" for base in DEBIAN_CLOUD_BASES ] + if distro == "proxmox": + # C'est bien l'image DEBIAN qu'on télécharge : « proxmox-ve », posé + # dessus, en fait l'hyperviseur. Le miroir ne connaît que le numéro de + # Debian, jamais celui de Proxmox. + return image_candidates( + "debian", code, arch, PROXMOX_DEBIAN_BASE[version], dry_run + ) if distro == "fedora": return [resolve_fedora_url(version, arch, dry_run)] if distro == "almalinux": @@ -525,6 +558,14 @@ def default_image_name(distro: str, code: str, arch: str, version: str) -> str: return f"ubuntu-{version}-server-cloudimg-{a}.img" if distro == "debian": return f"debian-{version}-genericcloud-{a}.qcow2" + if distro == "proxmox": + # Le fichier téléchargé EST celui de Debian : lui donner le même nom + # de cache fait qu'un déploiement Debian 13 et un Proxmox se + # PARTAGENT le téléchargement (325 Mio) au lieu d'en faire deux. Sans + # cette branche, le repli de fin nommait l'image « fedora-cloud-9 ». + return ( + f"debian-{PROXMOX_DEBIAN_BASE[version]}-genericcloud-{a}.qcow2" + ) if distro == "arch": return f"arch-linux-{a}-cloudimg.qcow2" if distro == "opensuse": @@ -1615,6 +1656,7 @@ DISTRO_LABELS: dict[str, str] = { "rocky": "Rocky Linux", "opensuse": "openSUSE", "arch": "Arch Linux", + "proxmox": "Proxmox VE", } # Gestionnaire de paquets de chaque distribution du catalogue. @@ -1626,6 +1668,8 @@ DISTRO_PKG: dict[str, str] = { "rocky": "dnf", "opensuse": "zypper", "arch": "pacman", + # Debian dessous : c'est apt qui sert, et le guide de connexion le dit. + "proxmox": "apt", } diff --git a/script/todo/todo.py b/script/todo/todo.py index aac50f4..7010111 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -1052,6 +1052,10 @@ class TODO: # deploy_qemu.py, qui fait autorité sur le catalogue. "opensuse": (["16.0", "tumbleweed"], "16.0"), "arch": (["latest"], "latest"), + # Proxmox VE : le numéro est celui de PVE, pas de Debian (9 = trixie). + # Une seule version au catalogue, la seule qui couvre amd64 ET arm64 — + # voir PROXMOX_VERSIONS dans deploy_qemu.py, qui fait autorité. + "proxmox": (["9"], "9"), } def _qemu_prompt_distro(self): @@ -5339,13 +5343,25 @@ class TODO: "make install_os && make install_dev && " + self._QEMU_QEMU_PKGS, ), + ( + t("Proxmox VE hypervisor (no Odoo)"), + "./script/proxmox/install_proxmox.sh", + ), ] return profiles - def _qemu_pick_install_profile(self): + def _qemu_pick_install_profile(self, distro=""): """Choix de CE QU'ON installe sur la VM. Renvoie (label, commande - finale exécutée dans ~/git/erplibre).""" + finale exécutée dans ~/git/erplibre). + + Sur une VM Proxmox, le profil hyperviseur passe en tête : choisir + « Proxmox VE » comme système, c'est demander qu'il soit installé, et + laisser Odoo 18 en défaut ferait poser un ERP sur un hyperviseur. + """ profiles = self._qemu_install_profiles() + if distro == "proxmox": + pve = t("Proxmox VE hypervisor (no Odoo)") + profiles.sort(key=lambda p: p[0] != pve) print(f"\n{t('What to install on the VM(s)?')}") for i, (label, _cmd) in enumerate(profiles, 1): print(f" [{i}] {label}{' *' if i == 1 else ''}") @@ -8922,11 +8938,28 @@ class TODO: ans = input( t("Install ERPLibre into ~/git/erplibre on each VM? (Y/n): ") ) + if not self._is_yes_default_yes(ans) and any( + v.get("distro") == "proxmox" for v in vms + ): + # C'est par cette étape que passe l'installation de Proxmox : sans + # elle la VM reste une Debian nue, ce qui n'est pas ce qu'on a + # demandé en choisissant « Proxmox VE » comme système. + print( + f"\n ⚠ {t('Proxmox will NOT be installed: plain Debian VM.')}" + ) + print( + f" {t('Later, in the VM:')}" + " sudo ./script/proxmox/install_proxmox.sh" + ) if self._is_yes_default_yes(ans): branch = self._qemu_pick_branch() # dev (~/git, SELinux relâché) vs prod (/opt, confiné) prod = self._qemu_ask_prod() - label, cmd = self._qemu_pick_install_profile() + label, cmd = self._qemu_pick_install_profile( + "proxmox" + if any(v.get("distro") == "proxmox" for v in vms) + else "" + ) monitor = self._is_yes_default_yes( input(t("Interactive monitoring dashboard? (y/N): ")) ) diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 5794700..e6b1af2 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -3115,6 +3115,18 @@ TRANSLATIONS = { "fr": "Nœud de rendu", "en": "Render node", }, + "Proxmox VE hypervisor (no Odoo)": { + "fr": "Hyperviseur Proxmox VE (sans Odoo)", + "en": "Proxmox VE hypervisor (no Odoo)", + }, + "Proxmox will NOT be installed: plain Debian VM.": { + "fr": "Proxmox ne sera PAS installé : la VM restera une Debian nue.", + "en": "Proxmox will NOT be installed: plain Debian VM.", + }, + "Later, in the VM:": { + "fr": "Plus tard, dans la VM :", + "en": "Later, in the VM:", + }, "sshfs mount failed.": { "fr": "Le montage sshfs a échoué.", "en": "sshfs mount failed.", diff --git a/test/test_qemu_proxmox.py b/test/test_qemu_proxmox.py new file mode 100644 index 0000000..d3b5e25 --- /dev/null +++ b/test/test_qemu_proxmox.py @@ -0,0 +1,392 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Proxmox VE au catalogue de déploiement : ce qui existe, et ce qui n'existe pas. + +Proxmox ne publie AUCUNE image cloud — son ISO est un installateur qui formate +le disque. La voie que l'amont documente pour tout le reste est « Proxmox VE +sur Debian » : on part de l'image cloud Debian trixie et les paquets pve en +font l'hyperviseur. Le catalogue le dit ainsi, et l'image téléchargée est +littéralement celle de Debian. + +Par architecture, vérifié dans le dépôt amont et non déduit : + +- amd64 : 1800 paquets, proxmox-ve présent. +- arm64 : 448 paquets, proxmox-ve présent — officiel depuis PVE 9, dont le + Release de trixie annonce « amd64 arm64 ». +- s390x : l'index « binary-s390x » répond 404. Ce n'est pas une difficulté, + c'est une absence, et le catalogue la dit avant le déploiement. + +Ce que ces tests gardent : + +- Le nom de cache est celui de Debian : sans lui, le repli de fin nommait + l'image « fedora-cloud-9 » et un même téléchargement se faisait deux fois. +- Les planchers RAM/disque sont ceux d'un hyperviseur, pas ceux de Debian. +- Le retrait du noyau Debian n'emporte JAMAIS celui de Proxmox — sans quoi la + VM ne redémarrerait plus. +""" + +import importlib.util +import os +import pathlib +import subprocess +import sys +import tempfile +import unittest +from unittest import mock + +RACINE = pathlib.Path(__file__).resolve().parents[1] +SCRIPT = RACINE / "script/proxmox/install_proxmox.sh" + +sys.argv = ["todo.py"] +from script.todo.todo import TODO # noqa: E402 +from script.todo.todo_i18n import t # noqa: E402 + + +def _deploy(): + spec = importlib.util.spec_from_file_location( + "deploy_qemu", RACINE / "script/qemu/deploy_qemu.py" + ) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +DQ = _deploy() + +# Somme publiée par l'amont sur « Install Proxmox VE on Debian 13 Trixie », et +# recopiée ici EXPRÈS : deux copies indépendantes, c'est ce qui donne son sens +# à un condensat épinglé. Si l'une change sans l'autre, ce test le dit. +KEY_SHA256 = "136673be77aba35dcce385b28737689ad64fd785a797e57897589aed08db6e45" + + +class TestCatalogue(unittest.TestCase): + def test_it_is_offered_as_a_distro(self): + self.assertIn("proxmox", DQ.DISTROS) + versions, defaut = DQ.DISTROS["proxmox"] + self.assertEqual("9", defaut) + self.assertIn("9", versions) + + def test_the_base_is_the_debian_trixie_cloud_image(self): + """C'est bien l'image DEBIAN qu'on télécharge : Proxmox n'en publie + aucune.""" + for arch in ("amd64", "arm64"): + url = DQ.image_url("proxmox", "trixie", arch, "9") + self.assertIn("trixie", url) + self.assertIn(f"debian-13-genericcloud-{arch}.qcow2", url) + + def test_the_cache_name_is_debians_so_the_download_is_shared(self): + """Un déploiement Debian 13 et un Proxmox visent le MÊME fichier : le + repli de fin nommait l'image « fedora-cloud-9 », et 325 Mio se + retéléchargeaient.""" + pve = DQ.default_image_name("proxmox", "trixie", "amd64", "9") + deb = DQ.default_image_name("debian", "trixie", "amd64", "13") + self.assertEqual(deb, pve) + self.assertNotIn("fedora", pve) + + def test_s390x_does_not_exist_and_is_refused(self): + """Le dépôt amont n'a pas d'index s390x (404). Le refus vaut mieux + qu'un déploiement qui échouera au premier apt.""" + self.assertNotIn("proxmox", DQ.ARCH_DISTRO_SUPPORT["s390x"]) + + def test_arm64_is_official_since_pve_9(self): + self.assertIn("proxmox", DQ.ARCH_DISTRO_SUPPORT["arm64"]) + + def test_the_floors_are_a_hypervisors_not_debians(self): + """Proxmox demande 2 Gio pour lui seul et télécharge son noyau : les + 1 Gio/20 Go de Debian donneraient une VM incapable d'héberger une + seule invitée.""" + _code, _osinfo, ram, disque = DQ.PROXMOX_VERSIONS["9"] + self.assertGreaterEqual(ram, 4096) + self.assertGreaterEqual(int(disque.rstrip("G")), 32) + deb_ram = DQ.DEBIAN_VERSIONS["13"][2] + self.assertGreater(ram, deb_ram) + + def test_it_reuses_debians_osinfo(self): + """Le système EST une Debian : libosinfo n'a pas d'entrée Proxmox, et + en inventer une ferait échouer virt-install.""" + self.assertEqual("debian13", DQ.PROXMOX_VERSIONS["9"][1]) + + def test_it_is_named_after_proxmox_not_after_the_key(self): + self.assertEqual("Proxmox VE 9", DQ.distro_label("proxmox", "9")) + + def test_the_login_guide_knows_it_is_apt(self): + self.assertEqual("apt", DQ.DISTRO_PKG["proxmox"]) + + +class TestLesDeuxCatalogues(unittest.TestCase): + """todo.py duplique le catalogue de deploy_qemu.py. Qu'ils s'accordent.""" + + def test_the_menu_offers_every_distro_of_the_catalogue(self): + menu = set(TODO._QEMU_DISTROS) + catalogue = set(DQ.DISTROS) + self.assertEqual( + catalogue, + menu, + "les deux catalogues divergent : " + f"menu seul {menu - catalogue}, deploy seul {catalogue - menu}", + ) + + def test_the_versions_agree_for_proxmox(self): + versions, defaut = TODO._QEMU_DISTROS["proxmox"] + self.assertEqual(["9"], list(versions)) + self.assertEqual("9", defaut) + + +class TestLeProfil(unittest.TestCase): + def setUp(self): + self.todo = TODO.__new__(TODO) + + def test_a_dedicated_profile_calls_the_dedicated_script(self): + profils = dict(self.todo._qemu_install_profiles()) + libelle = t("Proxmox VE hypervisor (no Odoo)") + self.assertIn(libelle, profils) + self.assertIn("install_proxmox.sh", profils[libelle]) + + def test_on_a_proxmox_vm_it_comes_first(self): + """Laisser « Odoo 18 » en défaut ferait poser un ERP sur un + hyperviseur : c'est le contraire de ce qu'on a demandé.""" + vus = [] + with mock.patch("builtins.input", lambda *a: ""): + import contextlib + import io + + with contextlib.redirect_stdout(io.StringIO()): + libelle, cmd = self.todo._qemu_pick_install_profile("proxmox") + vus.append((libelle, cmd)) + self.assertIn("install_proxmox.sh", cmd) + + def test_on_any_other_vm_odoo_stays_the_default(self): + import contextlib + import io + + with mock.patch("builtins.input", lambda *a: ""): + with contextlib.redirect_stdout(io.StringIO()): + libelle, cmd = self.todo._qemu_pick_install_profile("debian") + self.assertNotIn("install_proxmox.sh", cmd) + self.assertIn("install_odoo_18", cmd) + + +class TestLeScript(unittest.TestCase): + def test_it_is_executable_and_valid_shell(self): + self.assertTrue(os.access(SCRIPT, os.X_OK), "pas exécutable") + res = subprocess.run( + ["bash", "-n", str(SCRIPT)], capture_output=True, text=True + ) + self.assertEqual(0, res.returncode, res.stderr) + + def test_help_names_the_knobs_and_exits_clean(self): + res = subprocess.run( + ["bash", str(SCRIPT), "--help"], capture_output=True, text=True + ) + self.assertEqual(0, res.returncode) + for knob in ("--dry-run", "PVE_SUITE", "PVE_REBOOT"): + self.assertIn(knob, res.stdout) + + def test_the_pinned_key_hash_is_the_published_one(self): + texte = SCRIPT.read_text(encoding="utf-8") + self.assertIn(KEY_SHA256, texte) + self.assertIn("proxmox-archive-keyring-", texte) + + def _lance(self, args=(), stubs=None, env=None): + with tempfile.TemporaryDirectory() as tmp: + bin_dir = pathlib.Path(tmp) / "bin" + bin_dir.mkdir() + for nom, corps in (stubs or {}).items(): + (bin_dir / nom).write_text(f"#!/bin/bash\n{corps}\n") + (bin_dir / nom).chmod(0o755) + osrel = pathlib.Path(tmp) / "os-release" + osrel.write_text("ID=debian\nVERSION_CODENAME=trixie\n") + return subprocess.run( + ["bash", str(SCRIPT), *args], + capture_output=True, + text=True, + env=dict( + os.environ, + PATH=f"{bin_dir}:/usr/bin:/bin", + PVE_OS_RELEASE=str(osrel), + **(env or {}), + ), + timeout=120, + ) + + def test_an_unpublished_architecture_is_refused_by_name(self): + res = self._lance(stubs={"uname": "echo s390x"}) + self.assertNotEqual(0, res.returncode) + self.assertIn("s390x", res.stdout + res.stderr) + + def test_another_distribution_is_refused_by_name(self): + """Le script s'installe SUR Debian : le dire vaut mieux qu'un apt qui + échouera sur un dépôt introuvable.""" + with tempfile.NamedTemporaryFile( + "w", suffix=".os", delete=False + ) as fh: + fh.write("ID=ubuntu\nVERSION_CODENAME=noble\n") + chemin = fh.name + self.addCleanup(os.unlink, chemin) + res = subprocess.run( + ["bash", str(SCRIPT)], + capture_output=True, + text=True, + env=dict(os.environ, PVE_OS_RELEASE=chemin), + timeout=60, + ) + self.assertNotEqual(0, res.returncode) + self.assertIn("ubuntu", res.stdout + res.stderr) + + def test_the_dry_run_follows_the_upstream_procedure(self): + res = self._lance(["--dry-run"]) + self.assertEqual(0, res.returncode, res.stdout + res.stderr) + out = res.stdout + for attendu in ( + "proxmox-archive-keyring-trixie.gpg", + "Types: deb", + "pve-no-subscription", + "Signed-By: /usr/share/keyrings/proxmox-archive-keyring.gpg", + "proxmox-default-kernel", + "proxmox-ve postfix open-iscsi chrony", + ): + self.assertIn(attendu, out, attendu) + + def test_the_dry_run_changes_nothing_and_says_so(self): + """La leçon du montage sshfs : ne rien annoncer qui n'ait eu lieu.""" + res = self._lance(["--dry-run"]) + self.assertIn("dry-run", res.stdout) + self.assertNotIn("Proxmox VE installé", res.stdout) + + def test_postfix_is_preseeded_or_the_install_hangs(self): + """postfix pose deux questions debconf : sans préréponse, une + installation lancée par SSH reste pendue sur une saisie invisible.""" + res = self._lance(["--dry-run"]) + self.assertIn("debconf-set-selections", res.stdout) + self.assertIn("main_mailer_type", res.stdout) + + def test_it_never_removes_the_proxmox_kernel(self): + """Le noyau qu'on vient de poser : l'emporter laisserait une VM qui + n'amorce plus. dpkg-query rend ici les deux, seul celui de Debian doit + partir.""" + res = self._lance( + ["--dry-run"], + stubs={ + "uname": "echo x86_64", + "dpkg": "exit 0", + # Format réel de la requête : nom + état. dpkg connaît + # aussi les paquets DÉSINSTALLÉS (« config-files »), et les + # passer à apt réclamait un redémarrage pour rien. + "dpkg-query": ( + "printf 'linux-image-6.12.0-amd64 installed\\n" + "linux-image-amd64 config-files\\n" + "linux-image-6.14.11-1-pve installed\\n'" + ), + }, + ) + self.assertEqual(0, res.returncode, res.stdout + res.stderr) + ligne = [ + x for x in res.stdout.splitlines() if "remove linux-image" in x + ] + self.assertTrue(ligne, res.stdout) + self.assertIn("linux-image-6.12.0-amd64", ligne[0]) + self.assertNotIn("pve", ligne[0]) + # « config-files » = déjà désinstallé : ne pas le repasser à apt. + self.assertNotIn("linux-image-amd64", ligne[0]) + + def test_without_the_proxmox_kernel_nothing_is_removed(self): + """Une étape apt a pu échouer plus haut : on ne touche pas au noyau + Debian tant que celui de Proxmox n'est pas là.""" + res = self._lance( + ["--dry-run"], + stubs={"uname": "echo x86_64", "dpkg": "exit 1"}, + ) + self.assertNotIn("remove linux-image", res.stdout) + self.assertIn("noyau Debian reste en place", res.stdout) + + def test_grub_pc_is_preseeded_or_dpkg_stops(self): + """Le piège propre à l'image cloud : elle amorce en EFI, les paquets + pve tirent grub-pc, et sa post-installation refuse de deviner le + disque — « You must correct your GRUB install devices before + proceeding ». Mesuré : dpkg s'arrête et emporte la transaction.""" + res = self._lance(["--dry-run"]) + self.assertIn("grub-pc/install_devices", res.stdout) + self.assertRegex(res.stdout, r"install_devices multiselect /dev/\w+") + + def test_apt_waits_for_the_lock_instead_of_giving_up(self): + """Sur une VM fraîche, cloud-init tient encore le verrou : mesuré, + « held by process 996 (apt-get) », et le script mourait 40 secondes + après le démarrage.""" + texte = SCRIPT.read_text(encoding="utf-8") + self.assertIn("DPkg::Lock::Timeout", texte) + res = self._lance(["--dry-run"]) + self.assertIn("DPkg::Lock::Timeout", res.stdout) + + def test_it_waits_for_cloud_init_but_not_for_its_verdict(self): + """cloud-init rend « error » sur l'image Debian 13 pour deux modules + sans rapport (console-setup absent, update-locale) alors qu'il a bien + fini. On attend qu'il termine, pas qu'il soit content.""" + res = self._lance(["--dry-run"]) + self.assertIn("cloud-init status --wait", res.stdout) + # L'échec de cloud-init ne doit PAS arrêter le script : « || true » + # sur l'appel réel, pas sur l'écho du dry-run. + texte = SCRIPT.read_text(encoding="utf-8") + reel = [ + x + for x in texte.splitlines() + if "cloud-init status --wait" in x and "dry-run" not in x + ] + self.assertTrue(reel, texte) + self.assertIn("|| true", reel[0]) + + def _esp(self, avec_secours): + """Fausse partition EFI : un stub Debian, et le chemin de secours.""" + racine = tempfile.mkdtemp() + self.addCleanup( + lambda: subprocess.run(["rm", "-rf", racine], check=False) + ) + deb = pathlib.Path(racine, "EFI", "debian") + boot = pathlib.Path(racine, "EFI", "BOOT") + deb.mkdir(parents=True) + boot.mkdir(parents=True) + (deb / "grub.cfg").write_text( + "search.fs_uuid 81e2a465 root\nset prefix=($root)'/boot/grub'\n" + ) + (boot / "BOOTX64.EFI").write_text("binaire") + if avec_secours: + (boot / "grub.cfg").write_text("déjà là") + return racine + + def test_the_efi_fallback_stub_is_restored(self): + """Sans ce fichier, GRUB s'arrête sur « grub> » : ni menu ni noyau. + Vécu après le premier redémarrage — la VM brûlait 100 % d'un cœur sans + lire une seule fois le disque, capture d'écran à l'appui.""" + esp = self._esp(avec_secours=False) + res = self._lance(["--dry-run"], env={"PVE_ESP": esp}) + self.assertEqual(0, res.returncode, res.stdout + res.stderr) + self.assertIn("amorçage de secours", res.stdout) + self.assertIn("EFI/BOOT/", res.stdout) + + def test_a_working_fallback_is_left_alone(self): + """Ne pas réécrire ce qui marche : le stub en place peut avoir été + ajusté à la main.""" + esp = self._esp(avec_secours=True) + res = self._lance(["--dry-run"], env={"PVE_ESP": esp}) + self.assertNotIn("amorçage de secours", res.stdout) + + def test_an_esp_without_any_stub_is_said_not_guessed(self): + racine = tempfile.mkdtemp() + self.addCleanup( + lambda: subprocess.run(["rm", "-rf", racine], check=False) + ) + pathlib.Path(racine, "EFI", "BOOT").mkdir(parents=True) + res = self._lance(["--dry-run"], env={"PVE_ESP": racine}) + self.assertIn("aucun grub.cfg à recopier", res.stdout) + + def test_it_does_not_reboot_unless_asked(self): + """Lancé par SSH depuis le déploiement, un reboot couperait la session + et ferait passer une installation réussie pour un échec.""" + texte = SCRIPT.read_text(encoding="utf-8") + self.assertIn('PVE_REBOOT:-0}" != "1"', texte) + res = self._lance(["--dry-run"]) + self.assertNotIn("systemctl reboot", res.stdout) + + +if __name__ == "__main__": + unittest.main(verbosity=1) From 7923e37e4ffb73505463b2ee9b709f5e2e274e62 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sun, 23 Aug 2026 03:46:08 -0400 Subject: [PATCH 10/16] =?UTF-8?q?[ADD]=20analyse:=20qui=20d=C3=A9pend=20de?= =?UTF-8?q?=20qui,=20=C3=A0=20l'=C3=A9cran?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le menu Analyse disait quels modules manquent, jamais qui dépend de qui. « Puis-je retirer celui-ci » se réglait donc à la main : au palier 17 → 18, il a fallu écrire la requête pour savoir si web_responsive pouvait partir. L'écran liste les modules et « d » parcourt quatre relations : ce dont il dépend, ce qui en dépend, tout ce qu'il entraîne, tout ce qui tombe avec lui. « f » filtre, « / » cherche — une base en porte trois mille. La réponse qui compte est écrite en toutes lettres : deux dépendants déclarés dont zéro installé, c'est un retrait sans danger. --- EN --- The Analyse menu told which modules were missing, never which depends on which. « Can I remove this one » was therefore answered by hand: at the 17 → 18 step we had to write the query to learn whether web_responsive could go. The screen lists the modules and « d » walks four relations: what it needs, what needs it, everything it pulls in, everything that falls with it. « f » filters, « / » searches — a database holds three thousand of them. The answer that matters is spelled out: two declared dependents of which zero installed means removal is safe. Assisted-by: Claude Opus 5 --- script/analyse/check_module_dependency.py | 377 +++++++++++ script/analyse/check_module_dependency_tui.py | 324 +++++++++ script/todo/todo.py | 52 ++ script/todo/todo_i18n.py | 120 ++++ test/test_check_module_dependency.py | 631 ++++++++++++++++++ 5 files changed, 1504 insertions(+) create mode 100755 script/analyse/check_module_dependency.py create mode 100644 script/analyse/check_module_dependency_tui.py create mode 100644 test/test_check_module_dependency.py diff --git a/script/analyse/check_module_dependency.py b/script/analyse/check_module_dependency.py new file mode 100755 index 0000000..93f9ff1 --- /dev/null +++ b/script/analyse/check_module_dependency.py @@ -0,0 +1,377 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Les modules d'une base et ce qui les relie. + +`check_module_package` répond « quels modules manquent ». La question +d'à côté reste sans réponse : « celui-ci, puis-je le retirer, et +qu'entraîne-t-il avec lui ». On l'a payé cher au palier 17 → 18 — +`web_responsive` devenu incompatible avec `muk_web_theme` — où il a fallu +écrire la requête à la main pour savoir si quelqu'un en dépendait. + +Quatre relations, et non une seule liste : + + ce dont il dépend ce qu'il faut installer AVANT lui + ce qui en dépend ce qui casse si on le retire + ce qu'il entraîne la fermeture avale, dépendances des dépendances + ce qui tombe avec lui la fermeture amont + +La lecture n'écrit jamais : `check_module_package.run_psql` ouvre la +session en lecture seule côté SERVEUR, pas côté politesse. +""" + +import json +import os +import sys + +sys.path.append( + os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..")) +) + +from script.analyse import check_module_package as package # noqa: E402 + +try: + from script.todo.todo_i18n import t +except Exception: # pragma: no cover - repli si i18n indisponible + + def t(key: str) -> str: + return key + + +# Un graphe de modules Odoo est peu profond — `base` est à la racine et la +# plus longue chaîne réelle tient en une dizaine de sauts. La borne existe +# pour qu'un cycle en base, qui NE DEVRAIT pas exister, produise un +# résultat faux et visible plutôt qu'une boucle sans fin. +PROFONDEUR_MAX = 64 + +FILTRES = ("all", "installed", "absent", "broken") + +# Ce qui compte comme « en place ». « to upgrade » l'est : le module est +# chargé, il sera seulement rejoué. « to install » ne l'est pas encore. +ETATS_PRESENTS = ("installed", "to upgrade") + + +def reverse(depend): + """{module: [ce qui dépend de lui]} à partir de {module: [dépendances]}. + + Les modules cités comme dépendance mais absents de la base ont eux + aussi leur entrée : c'est précisément le cas qu'on cherche à voir. + """ + inverse = {} + for module, lst in depend.items(): + inverse.setdefault(module, []) + for nom in lst: + inverse.setdefault(nom, []).append(module) + return {nom: sorted(set(lst)) for nom, lst in inverse.items()} + + +def closure(nom, graphe): + """Tout ce que `nom` atteint dans `graphe`, lui-même exclu. + + En largeur et non en profondeur : la récursion sur un graphe cyclique + déborde la pile avant qu'on ait compris pourquoi. + """ + vus, bord, profondeur = set(), [nom], 0 + while bord and profondeur < PROFONDEUR_MAX: + suivant = [] + for courant in bord: + for voisin in graphe.get(courant, []): + if voisin not in vus and voisin != nom: + vus.add(voisin) + suivant.append(voisin) + bord = suivant + profondeur += 1 + return sorted(vus) + + +def present(etat): + return etat in ETATS_PRESENTS + + +def broken(recensement, depend): + """[(module, dépendance, raison)] — ce qui est installé sur du vide. + + Un module installé dont une dépendance ne l'est pas est un état + qu'Odoo ne produit jamais lui-même ; il vient d'une migration, d'une + désinstallation forcée ou d'un addons-path incomplet. Le charger + échoue, ou pire, réussit à moitié. + """ + lst = [] + for module, infos in sorted(recensement.items()): + if not present(infos[0]): + continue + for nom in sorted(set(depend.get(module, []))): + if nom not in recensement: + lst.append((module, nom, "unknown")) + elif not present(recensement[nom][0]): + lst.append((module, nom, recensement[nom][0])) + return lst + + +def survey(database): + """Tout ce que l'écran et le rapport ont besoin de savoir. + + Une seule lecture de la base pour les deux : deux assemblages + finiraient par montrer deux états contradictoires du même système. + """ + recensement = package.census(database) + if recensement is None: + return {"database": database, "unavailable": True} + depend = package.dependencies(database) + # Un module sans dépendance déclarée doit exister dans le graphe : + # sinon `closure` et l'écran le traitent comme inconnu. + depend = {nom: sorted(set(depend.get(nom, []))) for nom in recensement} + return { + "database": database, + "version": package.db_version(database), + "modules": recensement, + "depends": depend, + "dependents": reverse(depend), + "broken": broken(recensement, depend), + } + + +def verdict(rapport, nom): + infos = rapport["modules"].get(nom) + if infos is None: + return "unknown" + return package.ETAT_VERS_VERDICT.get(infos[0], "uninstallable") + + +def icon(rapport, nom): + return package.ICONE.get(verdict(rapport, nom), "·") + + +def counts(rapport, nom): + """(dépendances, dépendants, dépendants EN PLACE). + + Le troisième chiffre est celui qui décide d'un retrait. Sur une base + réelle, `web_responsive` a deux dépendants déclarés et zéro installé : + ne montrer que « 2 » ferait renoncer à un retrait sans danger. + """ + aval = rapport["dependents"].get(nom, []) + return ( + len(rapport["depends"].get(nom, [])), + len(aval), + sum(1 for autre in aval if present_of(rapport, autre)), + ) + + +def modules_cassants(rapport): + """Les modules qui apparaissent à gauche d'une dépendance cassée.""" + return {module for module, _dep, _raison in rapport.get("broken", [])} + + +def rows(rapport, filtre="all"): + """La liste de gauche : un module par ligne, déjà triée. + + Tri par nom. Trier par nombre de dépendants mettrait `base` en tête à + chaque fois — vrai, et sans intérêt : on cherche un module qu'on + nomme, pas le plus populaire. + """ + cassants = modules_cassants(rapport) + lst = [] + for nom in sorted(rapport["modules"]): + etat = rapport["modules"][nom][0] + if filtre == "installed" and not present(etat): + continue + if filtre == "absent" and present(etat): + continue + if filtre == "broken" and nom not in cassants: + continue + amont, aval, vivants = counts(rapport, nom) + lst.append( + { + "name": nom, + "label": f"{icon(rapport, nom)} {nom}", + "detail": f"{amont}↓ {vivants}/{aval}↑", + "broken": nom in cassants, + } + ) + return lst + + +DETAILS = ("depends", "dependents", "pulls", "falls") + +TITRE_DETAIL = { + "depends": "What it needs, directly", + "dependents": "What needs it, directly", + "pulls": "Everything it pulls in", + "falls": "Everything that falls with it", +} + + +def listing(rapport, nom, mode): + """Les noms à montrer pour ce mode, dans l'ordre d'affichage.""" + if mode == "depends": + return rapport["depends"].get(nom, []) + if mode == "dependents": + return rapport["dependents"].get(nom, []) + if mode == "pulls": + return closure(nom, rapport["depends"]) + if mode == "falls": + return closure(nom, rapport["dependents"]) + return [] + + +def paint(texte, genre, colour): + from script.todo import migration_status as status + + return status.paint(texte, genre, colour) + + +def pane_text(rapport, nom, mode=None, colour=False, limit=0): + """Le panneau de droite pour le module choisi.""" + if not nom: + return t("Nothing to show yet.") + infos = rapport["modules"].get(nom) + if infos is None: + return f"❌ {nom} : {t('unknown to this database')}" + etat, resume, application, auteur = infos + lignes = [ + paint(f"{icon(rapport, nom)} {nom}", "step", colour), + f" {resume or ''}".rstrip(), + "", + f" {t('state'):<22} {etat}", + f" {t('author'):<22} {auteur or '—'}", + f" {t('application'):<22} {t('yes') if application else t('no')}", + ] + amont, aval, vivants = counts(rapport, nom) + lignes += [ + f" {t('depends on'):<22} {amont}", + f" {t('needed by'):<22} {aval}" + f" · {vivants} {t('of them installed')}", + ] + if present(etat) and aval and not vivants: + # La réponse à « puis-je le retirer », écrite plutôt que déduite + # de deux chiffres qu'il faudrait comparer soi-même. + lignes.append( + " " + paint(t("nothing installed depends on it"), "ok", colour) + ) + lignes.append("") + if mode in DETAILS: + lst = listing(rapport, nom, mode) + lignes.append(paint(f" {t(TITRE_DETAIL[mode])}", "step", colour)) + if not lst: + lignes.append(f" {t('nothing')}") + for autre in lst[: limit or None]: + marque = "" if present_of(rapport, autre) else f" ← {t('absent')}" + genre = "ok" if present_of(rapport, autre) else "warn" + lignes.append( + f" {icon(rapport, autre)} " + + paint(f"{autre}{marque}", genre, colour) + ) + if limit and len(lst) > limit: + lignes.append(f" … {len(lst) - limit} {t('more')}") + else: + lignes.append(f" {t('press d to walk the dependencies')}") + casses = [ + (dep, raison) + for module, dep, raison in rapport.get("broken", []) + if module == nom + ] + if casses: + lignes.append("") + lignes.append( + paint( + f" ❌ {t('installed on missing dependencies')}", + "fail", + colour, + ) + ) + for dep, raison in casses: + lignes.append(f" {dep} ({raison})") + return "\n".join(lignes) + + +def present_of(rapport, nom): + infos = rapport["modules"].get(nom) + return bool(infos) and present(infos[0]) + + +def head_text(rapport): + installes = sum( + 1 for infos in rapport["modules"].values() if present(infos[0]) + ) + total = len(rapport["modules"]) + casses = len(rapport.get("broken", [])) + texte = ( + f"📦 {rapport['database']}" + f" ({t('Odoo')} {rapport.get('version') or '?'})" + f" · {installes}/{total} {t('modules installed')}" + ) + if casses: + texte += f" · ❌ {casses} {t('broken dependency(ies)')}" + return texte + + +def render_text(rapport, limit=0, cap=0): + """Le rapport en clair, pour un terminal qui n'ouvre pas d'écran. + + `cap` borne le NOMBRE DE MODULES, `limit` la longueur de chaque liste + de dépendances. Une base porte trois mille modules : sans borne, le + repli déverse six mille lignes dans un menu, ce qui n'est pas un + repli mais une seconde panne. + """ + if rapport.get("unavailable"): + return [f"❌ {t('Cannot read the database: ')}{rapport['database']}"] + lignes = [head_text(rapport), ""] + lst_row = rows(rapport) + for row in lst_row[: cap or None]: + nom = row["name"] + amont = rapport["depends"].get(nom, []) + lignes.append(f"{row['label']:<44} {row['detail']}") + if amont: + lignes.append(f" → {', '.join(amont[: limit or None])}") + if cap and len(lst_row) > cap: + lignes.append(f" … {len(lst_row) - cap} {t('more')}") + if rapport.get("broken"): + lignes.append("") + lignes.append(f"❌ {t('installed on missing dependencies')}") + for module, dep, raison in rapport["broken"]: + lignes.append(f" {module} → {dep} ({raison})") + return lignes + + +def main(argv=None): + import argparse + + parser = argparse.ArgumentParser( + description=( + "List every module of a database and how they depend on one" + " another. Read-only." + ) + ) + parser.add_argument("-d", "--database", required=True) + parser.add_argument( + "--limit", type=int, default=0, help="cap long lists (0 = no cap)" + ) + parser.add_argument("--json", action="store_true", help="machine output") + parser.add_argument( + "--no-tui", action="store_true", help="never open the screen" + ) + config = parser.parse_args(argv) + + rapport = survey(config.database) + if rapport.get("unavailable"): + print(f"❌ {t('Cannot read the database: ')}{config.database}") + return 2 + if config.json: + print( + json.dumps(rapport, indent=2, sort_keys=True, ensure_ascii=False) + ) + return 1 if rapport["broken"] else 0 + if not config.no_tui: + try: + from script.analyse.check_module_dependency_tui import run_tui + except Exception: + run_tui = None + if run_tui and run_tui(rapport): + return 1 if rapport["broken"] else 0 + print("\n".join(render_text(rapport, limit=config.limit))) + return 1 if rapport["broken"] else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/analyse/check_module_dependency_tui.py b/script/analyse/check_module_dependency_tui.py new file mode 100644 index 0000000..a5e7f81 --- /dev/null +++ b/script/analyse/check_module_dependency_tui.py @@ -0,0 +1,324 @@ +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Les modules d'une base et leurs dépendances, en plein écran. + +Une base porte trois à six cents modules. Le rapport texte les liste tous +et la question qu'on se pose — « celui-ci, qui en dépend » — se trouve +quelque part au milieu de plusieurs milliers de lignes. + +Les données viennent de `check_module_dependency`, comme le rapport +texte : un seul assemblage, donc une seule vérité sur la même base. +""" + +import os +import sys + +sys.path.append( + os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..")) +) + +from script.analyse import check_module_dependency as dependency # noqa: E402 + +try: + from script.todo.todo_i18n import t +except Exception: # pragma: no cover - repli si i18n indisponible + + def t(key: str) -> str: + return key + + +CSS = """ +Screen { layout: vertical; } +#head { height: 3; padding: 0 1; background: $panel; color: $text; } +#body { height: 1fr; } +#left { width: 46; border-right: solid $accent; } +#pane { width: 1fr; padding: 0 1; } +#find { display: none; } +#find.visible { display: block; } +""" + + +def filter_label(filtre): + return { + "all": t("all modules"), + "installed": t("installed only"), + "absent": t("not installed"), + "broken": t("broken dependencies"), + }.get(filtre, filtre) + + +def mode_label(mode): + """Le nom du mode courant, pour le sous-titre. + + Un panneau qui change sans dire pourquoi se lit comme un écran cassé. + """ + if mode is None: + return t("summary") + return t(dependency.TITRE_DETAIL.get(mode, mode)) + + +def subtitle(mode, filtre, motif): + morceaux = [mode_label(mode), filter_label(filtre)] + if motif: + morceaux.append(f"« {motif} »") + return " · ".join(morceaux) + + +def matching(lst_row, motif): + """Les lignes dont le nom contient `motif`, sans égard à la casse.""" + if not motif: + return lst_row + bas = motif.lower() + return [row for row in lst_row if bas in row["name"].lower()] + + +def next_mode(mode): + """Le mode suivant : résumé → les quatre relations → résumé.""" + suite = (None,) + dependency.DETAILS + courant = mode if mode in suite else None + return suite[(suite.index(courant) + 1) % len(suite)] + + +def next_filter(filtre): + """Le filtre suivant, en boucle.""" + suite = dependency.FILTRES + index = suite.index(filtre) if filtre in suite else 0 + return suite[(index + 1) % len(suite)] + + +def current_name(lst_row, index): + """Le module sous le curseur, ou None si la liste est vide. + + `index` peut dépasser : la table garde son curseur d'avant quand la + liste raccourcit, et lire hors bornes ferait tomber l'écran sur une + IndexError au moment précis où l'on filtre. + """ + if not lst_row or index is None or not 0 <= index < len(lst_row): + return None + return lst_row[index]["name"] + + +def cursor_for(lst_row, garde): + """Où replacer le curseur pour retrouver `garde`. None si parti. + + Changer de filtre ramenait le curseur en tête, donc on perdait le + module qu'on lisait — c'est-à-dire la raison même du filtre. + """ + if not garde: + return None + for index, row in enumerate(lst_row): + if row["name"] == garde: + return index + return None + + +def populate(table, lst_row, garde=None): + """Remplir la table, puis y remettre le curseur sur `garde`. + + Prend la table plutôt que l'App : rien ici ne dépend de Textual sauf + trois appels de méthode, donc un faux objet suffit à l'éprouver. + """ + table.clear() + for row in lst_row: + table.add_row(row["label"][:40], row["detail"]) + place = cursor_for(lst_row, garde) + if place is not None: + table.move_cursor(row=place) + return place + + +def hide_find(champ, table): + """Refermer la recherche et rendre le clavier à la liste. + + Le `focus()` explicite n'est pas une précaution : sans lui, Textual + donne le clavier au premier widget focalisable, et c'est le champ de + recherche — « display: none » ne le retire pas de ce choix-là. On + ouvrait donc l'écran en tapant dans une boîte invisible, et « d » ne + faisait rien. (La TABULATION, elle, saute bien un widget caché : + mesuré, d'où l'absence de `can_focus` ici — une ligne qu'on ne peut + pas faire échouer n'a rien à faire dans le fichier.) + + Ne vide PAS le champ : l'appelant décide s'il efface le motif, et + c'est cet effacement qui redéclenche le filtrage. + """ + champ.remove_class("visible") + table.focus() + + +def build_app(rapport): + """Textual est importé ICI : le module reste testable sans lui.""" + from rich.text import Text + from textual import on + from textual.app import App, ComposeResult + from textual.containers import Horizontal, VerticalScroll + from textual.widgets import DataTable, Footer, Header, Input, Static + + class DependencyApp(App): + CSS = globals()["CSS"] + BINDINGS = [ + ("q", "quit", t("Quit")), + ("escape", "leave", t("Quit")), + ("d", "cycle_detail", t("Dependencies")), + ("f", "cycle_filter", t("Filter")), + ("slash", "find", t("Search")), + ] + + def __init__(self, rapport): + super().__init__() + self.rapport = rapport + self.mode = None + self.filtre = "all" + self.motif = "" + self.lst_row = [] + + def compose(self) -> ComposeResult: + yield Header() + yield Static("", id="head") + with Horizontal(id="body"): + yield DataTable(id="left", cursor_type="row") + with VerticalScroll(id="pane"): + yield Static("", id="content") + yield Input(placeholder=t("module name…"), id="find") + yield Footer() + + def on_mount(self): + self.title = t("Modules and their dependencies") + table = self.query_one("#left", DataTable) + table.add_columns(t("module"), "↓↑") + hide_find(self.query_one("#find", Input), table) + self.query_one("#head", Static).update( + dependency.head_text(self.rapport) + ) + self._fill() + + def _fill(self): + """Reconstruire la liste, en gardant le module sous le curseur. + + Sans cela, changer de filtre ramène le curseur en tête et l'on + perd le module qu'on était en train de lire — c'est-à-dire la + raison pour laquelle on a filtré. + """ + garde = self._current_name() + self.lst_row = matching( + dependency.rows(self.rapport, self.filtre), self.motif + ) + populate(self.query_one("#left", DataTable), self.lst_row, garde) + self._show() + + def _current_name(self): + table = self.query_one("#left", DataTable) + return current_name(self.lst_row, table.cursor_row) + + def _show(self): + self.sub_title = subtitle(self.mode, self.filtre, self.motif) + self.query_one("#content", Static).update( + Text.from_ansi( + dependency.pane_text( + self.rapport, + self._current_name(), + mode=self.mode, + colour=True, + ) + ) + ) + + def action_cycle_detail(self): + """Parcourir les quatre relations, puis revenir au résumé. + + « ce dont il dépend » et « ce qui en dépend » répondent à deux + questions opposées, et les fermetures disent l'ampleur réelle : + retirer un module en entraîne parfois trente. + """ + self.mode = next_mode(self.mode) + self._show() + + def action_cycle_filter(self): + self.filtre = next_filter(self.filtre) + self._fill() + + def action_find(self): + champ = self.query_one("#find", Input) + champ.add_class("visible") + champ.focus() + + def action_leave(self): + """Échap referme la recherche ; il ne quitte que sinon. + + Quitter parce qu'on renonce à une recherche serait une + surprise coûteuse : on a parfois filtré trois mille modules + pour arriver là. + """ + champ = self.query_one("#find", Input) + if not champ.has_class("visible"): + self.exit() + return + # Vider le champ suffit à tout refaire : l'événement + # `Input.Changed` remet le motif à zéro et reconstruit la + # liste. Le refaire ici à la main donnerait deux chemins pour + # le même état, dont un seul serait jamais éprouvé. + champ.value = "" + hide_find(champ, self.query_one("#left", DataTable)) + + @on(Input.Changed, "#find") + def _find_changed(self, event): + self.motif = event.value.strip() + self._fill() + + @on(Input.Submitted, "#find") + def _find_submitted(self, event): + # Le champ reste VISIBLE : il porte le motif en cours, et le + # cacher laisserait une liste filtrée sans dire par quoi. + self.query_one("#left", DataTable).focus() + + @on(DataTable.RowHighlighted, "#left") + def _row_changed(self, event): + self._show() + + return DependencyApp(rapport) + + +def in_event_loop(): + """Une boucle asyncio tourne-t-elle déjà dans CE processus ?""" + import asyncio + + try: + asyncio.get_running_loop() + except RuntimeError: + return False + return True + + +def run_tui(rapport, run_app=True): + """Ouvrir l'écran. False si l'on n'a pas pu — et alors on DIT pourquoi.""" + if not rapport or rapport.get("unavailable"): + return False + if not rapport.get("modules"): + return False + if not sys.stdout.isatty(): + print(f"ℹ️ {t('Not a terminal: showing the text report instead.')}") + return False + try: + from script.todo import textual_setup + except Exception: + textual_setup = None + if textual_setup and not textual_setup.ensure(): + return False + try: + app = build_app(rapport) + except ImportError: + print( + f"ℹ️ {t('Textual is missing from this interpreter:')}" + f" {sys.executable}" + ) + return False + if not run_app: + return app + if in_event_loop(): + print( + f"ℹ️ {t('Already inside a running screen: open it in its own')}" + f" {t('process instead.')}" + ) + return False + app.run() + return True diff --git a/script/todo/todo.py b/script/todo/todo.py index 7010111..8b958e0 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -10450,6 +10450,7 @@ class TODO: "Modules missing from the default package" ) }, + {"prompt_description": t("Dependencies between modules")}, {"section": t("Files")}, { "prompt_description": t( @@ -10475,6 +10476,8 @@ class TODO: elif status == "5": self.execute_analyse_module_package() elif status == "6": + self.execute_analyse_module_dependency() + elif status == "7": self.execute_analyse_filestore() else: print(t("Command not found !")) @@ -10523,6 +10526,55 @@ class TODO: handler, ) + def execute_analyse_module_dependency(self): + """Qui dépend de qui, pour savoir ce qu'on peut retirer. + + L'écran est ouvert par l'outil lui-même, qui retombe sur son + rapport texte s'il ne peut pas — terminal absent, Textual absent. + + Pas d'option « sauvegarde .zip » : les dépendances vivent dans + `ir_module_module_dependency`, qu'un zip n'expose pas sans + restauration. + """ + from script.analyse import check_module_dependency as dependency + + database = self._analyse_select_database() + if not database: + return + print(f"⧖ {t('Reading the modules and their dependencies…')}") + try: + rapport = dependency.survey(database) + except Exception as exc: + print(f"❌ {t('Analysis failed: ')}{exc}") + return + if rapport.get("unavailable"): + print(f"❌ {t('Cannot read the database: ')}{database}") + return + try: + from script.analyse.check_module_dependency_tui import run_tui + except Exception: + run_tui = None + if not (run_tui and run_tui(rapport)): + # Borné : une base porte trois mille modules, et déverser six + # mille lignes dans le menu n'est pas un repli. + print("\n".join(dependency.render_text(rapport, limit=8, cap=40))) + + def handler(rank): + if rank == 1: + print("\n".join(dependency.render_text(rapport, limit=0))) + else: + self._analyse_export_json( + rapport, os.path.basename(database), "module_dependency" + ) + + self._analyse_follow_up( + [ + {"prompt_description": t("Show every entry")}, + {"prompt_description": t("Export as JSON")}, + ], + handler, + ) + def execute_analyse_filestore(self): """Ce qui manque au filestore, et ce qu'on peut encore récupérer. diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index e6b1af2..84b46b4 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -6214,6 +6214,126 @@ TRANSLATIONS = { "fr": "📦 Modules absents du package par défaut", "en": "📦 Modules missing from the default package", }, + "Dependencies between modules": { + "fr": "🔗 Dépendances entre les modules", + "en": "🔗 Dependencies between modules", + }, + "modules installed": { + "fr": "modules installés", + "en": "modules installed", + }, + "of them installed": { + "fr": "installé(s)", + "en": "of them installed", + }, + "nothing installed depends on it": { + "fr": "rien d'installé n'en dépend", + "en": "nothing installed depends on it", + }, + "Reading the modules and their dependencies…": { + "fr": "Lecture des modules et de leurs dépendances…", + "en": "Reading the modules and their dependencies…", + }, + "Modules and their dependencies": { + "fr": "Modules et leurs dépendances", + "en": "Modules and their dependencies", + }, + "unknown to this database": { + "fr": "inconnu de cette base", + "en": "unknown to this database", + }, + "state": { + "fr": "état", + "en": "state", + }, + "author": { + "fr": "auteur", + "en": "author", + }, + "application": { + "fr": "application", + "en": "application", + }, + "yes": { + "fr": "oui", + "en": "yes", + }, + "depends on": { + "fr": "dépend de", + "en": "depends on", + }, + "needed by": { + "fr": "requis par", + "en": "needed by", + }, + "absent": { + "fr": "absent", + "en": "absent", + }, + "press d to walk the dependencies": { + "fr": "appuyez sur d pour parcourir les dépendances", + "en": "press d to walk the dependencies", + }, + "installed on missing dependencies": { + "fr": "installé sur des dépendances absentes", + "en": "installed on missing dependencies", + }, + "broken dependency(ies)": { + "fr": "dépendance(s) cassée(s)", + "en": "broken dependency(ies)", + }, + "What it needs, directly": { + "fr": "Ce dont il a besoin, directement", + "en": "What it needs, directly", + }, + "What needs it, directly": { + "fr": "Ce qui en a besoin, directement", + "en": "What needs it, directly", + }, + "Everything it pulls in": { + "fr": "Tout ce qu'il entraîne", + "en": "Everything it pulls in", + }, + "Everything that falls with it": { + "fr": "Tout ce qui tombe avec lui", + "en": "Everything that falls with it", + }, + "all modules": { + "fr": "tous les modules", + "en": "all modules", + }, + "installed only": { + "fr": "installés seulement", + "en": "installed only", + }, + "not installed": { + "fr": "non installés", + "en": "not installed", + }, + "broken dependencies": { + "fr": "dépendances cassées", + "en": "broken dependencies", + }, + "Dependencies": { + "fr": "Dépendances", + "en": "Dependencies", + }, + "Filter": { + "fr": "Filtre", + "en": "Filter", + }, + "Search": { + "fr": "Chercher", + "en": "Search", + }, + "module": { + "fr": "module", + "en": "module", + }, + "module name…": { + "fr": "nom de module…", + "en": "module name…", + }, "Show every entry": { "fr": "📜 Tout afficher", "en": "📜 Show every entry", diff --git a/test/test_check_module_dependency.py b/test/test_check_module_dependency.py new file mode 100644 index 0000000..596c797 --- /dev/null +++ b/test/test_check_module_dependency.py @@ -0,0 +1,631 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Qui dépend de qui, et ce qu'on peut retirer sans casser. + +La question posée au palier 17 → 18 — « puis-je retirer web_responsive » +— a demandé une requête écrite à la main. L'outil y répond, et la réponse +qui compte n'est pas « deux modules en dépendent » mais « aucun de ceux +qui en dépendent n'est installé ». +""" + +import os +import sys +import unittest + +sys.path.append( + os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) +) + +from script.analyse import check_module_dependency as dep # noqa: E402 +from script.analyse import check_module_dependency_tui as tui # noqa: E402 + + +def base(modules, depend=None): + """Un rapport minimal. `modules` : {nom: état}.""" + recensement = { + nom: (etat, f"Résumé {nom}", False, "TechnoLibre") + for nom, etat in modules.items() + } + depend = ( + {nom: sorted(depend.get(nom, [])) for nom in recensement} + if depend + else {nom: [] for nom in recensement} + ) + return { + "database": "essai", + "version": "18.0", + "modules": recensement, + "depends": depend, + "dependents": dep.reverse(depend), + "broken": dep.broken(recensement, depend), + } + + +class TestReverse(unittest.TestCase): + def test_it_inverts_the_arrows(self): + self.assertEqual( + dep.reverse({"zebre": ["alpha"], "alpha": []}), + {"alpha": ["zebre"], "zebre": []}, + ) + + def test_a_module_cited_but_absent_still_gets_an_entry(self): + # C'est le cas qu'on cherche : quelqu'un dépend d'un module que la + # base ne connaît pas. L'omettre le rendrait invisible. + self.assertEqual(dep.reverse({"a": ["fantome"]})["fantome"], ["a"]) + + def test_a_duplicate_dependency_is_counted_once(self): + self.assertEqual(dep.reverse({"a": ["b", "b"]})["b"], ["a"]) + + +class TestClosure(unittest.TestCase): + def test_it_walks_through(self): + graphe = {"zebre": ["milieu"], "milieu": ["alpha"], "alpha": []} + self.assertEqual(dep.closure("zebre", graphe), ["alpha", "milieu"]) + + def test_it_excludes_itself(self): + self.assertNotIn("a", dep.closure("a", {"a": ["b"], "b": ["a"]})) + + def test_a_cycle_terminates(self): + # Un cycle ne devrait pas exister en base ; s'il y en a un, on veut + # un résultat, pas une boucle sans fin. + self.assertEqual( + dep.closure("a", {"a": ["b"], "b": ["c"], "c": ["a"]}), + ["b", "c"], + ) + + def test_a_chain_longer_than_the_bound_is_cut_not_hung(self): + chaine = { + f"n{i}": [f"n{i + 1}"] for i in range(dep.PROFONDEUR_MAX + 8) + } + atteints = dep.closure("n0", chaine) + self.assertEqual(len(atteints), dep.PROFONDEUR_MAX) + + def test_an_unknown_module_reaches_nothing(self): + self.assertEqual(dep.closure("absent", {"a": ["b"]}), []) + + +class TestBroken(unittest.TestCase): + def test_installed_on_an_uninstalled_dependency(self): + rapport = base( + {"chef": "installed", "socle": "uninstalled"}, + {"chef": ["socle"]}, + ) + self.assertEqual(rapport["broken"], [("chef", "socle", "uninstalled")]) + + def test_installed_on_a_module_the_base_never_heard_of(self): + rapport = base({"chef": "installed"}, {"chef": ["fantome"]}) + self.assertEqual(rapport["broken"], [("chef", "fantome", "unknown")]) + + def test_an_uninstalled_module_may_depend_on_anything(self): + # Il ne tourne pas : ses dépendances ne cassent rien. + rapport = base( + {"chef": "uninstalled", "socle": "uninstalled"}, + {"chef": ["socle"]}, + ) + self.assertEqual(rapport["broken"], []) + + def test_to_upgrade_counts_as_in_place(self): + # Le module est chargé, il sera seulement rejoué. + rapport = base( + {"chef": "installed", "socle": "to upgrade"}, + {"chef": ["socle"]}, + ) + self.assertEqual(rapport["broken"], []) + + def test_to_install_is_not_in_place_yet(self): + rapport = base( + {"chef": "installed", "socle": "to install"}, + {"chef": ["socle"]}, + ) + self.assertEqual(rapport["broken"], [("chef", "socle", "to install")]) + + +class TestCounts(unittest.TestCase): + def rapport(self): + return base( + { + "web_responsive": "installed", + "erplibre_base": "uninstalled", + "web_responsive_company": "uninstalled", + "mail": "installed", + }, + { + "web_responsive": ["mail"], + "erplibre_base": ["web_responsive"], + "web_responsive_company": ["web_responsive"], + }, + ) + + def test_the_third_number_counts_only_what_is_in_place(self): + # Le cas réel : deux dépendants déclarés, aucun installé. Ne + # montrer que « 2 » ferait renoncer à un retrait sans danger. + self.assertEqual( + dep.counts(self.rapport(), "web_responsive"), (1, 2, 0) + ) + + def test_an_installed_dependent_is_counted(self): + rapport = self.rapport() + rapport["modules"]["erplibre_base"] = ("installed", "", False, "") + self.assertEqual(dep.counts(rapport, "web_responsive")[2], 1) + + def test_a_module_nobody_needs(self): + self.assertEqual( + dep.counts(self.rapport(), "erplibre_base"), (1, 0, 0) + ) + + +class TestRows(unittest.TestCase): + def rapport(self): + return base( + { + "zebre": "installed", + "alpha": "uninstalled", + "milieu": "installed", + }, + {"zebre": ["alpha"]}, + ) + + def test_they_come_out_sorted_by_name(self): + # Les noms sont posés dans un ordre qui CONTREDIT l'alphabet : + # sans tri, la liste sortirait zebre, alpha, milieu. + self.assertEqual( + [row["name"] for row in dep.rows(self.rapport())], + ["alpha", "milieu", "zebre"], + ) + + def test_the_installed_filter_keeps_only_those(self): + self.assertEqual( + [r["name"] for r in dep.rows(self.rapport(), "installed")], + ["milieu", "zebre"], + ) + + def test_the_absent_filter_is_the_complement(self): + self.assertEqual( + [r["name"] for r in dep.rows(self.rapport(), "absent")], ["alpha"] + ) + + def test_the_broken_filter_keeps_the_guilty_only(self): + # zebre est installé et dépend d'alpha qui ne l'est pas. + self.assertEqual( + [r["name"] for r in dep.rows(self.rapport(), "broken")], ["zebre"] + ) + + def test_the_detail_column_shows_both_counts(self): + row = [r for r in dep.rows(self.rapport()) if r["name"] == "alpha"][0] + self.assertEqual(row["detail"], "0↓ 1/1↑") + + def test_every_filter_is_understood(self): + # Un filtre inconnu ne doit pas vider la liste en silence. + for filtre in dep.FILTRES: + dep.rows(self.rapport(), filtre) + + +class TestListing(unittest.TestCase): + def rapport(self): + return base( + {"haut": "installed", "milieu": "installed", "bas": "installed"}, + {"haut": ["milieu"], "milieu": ["bas"]}, + ) + + def test_depends_is_direct_only(self): + self.assertEqual( + dep.listing(self.rapport(), "haut", "depends"), ["milieu"] + ) + + def test_pulls_goes_all_the_way_down(self): + self.assertEqual( + dep.listing(self.rapport(), "haut", "pulls"), ["bas", "milieu"] + ) + + def test_dependents_is_direct_only(self): + self.assertEqual( + dep.listing(self.rapport(), "bas", "dependents"), ["milieu"] + ) + + def test_falls_goes_all_the_way_up(self): + self.assertEqual( + dep.listing(self.rapport(), "bas", "falls"), ["haut", "milieu"] + ) + + def test_every_mode_has_a_title(self): + for mode in dep.DETAILS: + self.assertIn(mode, dep.TITRE_DETAIL) + + +class TestPaneText(unittest.TestCase): + def rapport(self): + return base( + {"web_responsive": "installed", "compagnon": "uninstalled"}, + {"compagnon": ["web_responsive"]}, + ) + + def test_it_answers_the_removal_question_in_words(self): + # Deux chiffres à comparer soi-même, ce n'est pas une réponse. + texte = dep.pane_text(self.rapport(), "web_responsive") + self.assertIn("rien d'installé n'en dépend", texte) + + def test_it_stays_silent_when_something_does_depend_on_it(self): + rapport = self.rapport() + rapport["modules"]["compagnon"] = ("installed", "", False, "") + self.assertNotIn( + "rien d'installé n'en dépend", + dep.pane_text(rapport, "web_responsive"), + ) + + def test_an_absent_dependent_is_marked_as_such(self): + texte = dep.pane_text( + self.rapport(), "web_responsive", mode="dependents" + ) + self.assertIn("compagnon", texte) + self.assertIn("absent", texte) + + def test_an_unknown_module_says_so(self): + self.assertIn("inconnu", dep.pane_text(self.rapport(), "jamais_vu")) + + def test_nothing_selected_is_not_a_crash(self): + self.assertTrue(dep.pane_text(self.rapport(), None)) + + def test_an_empty_listing_says_nothing_rather_than_showing_blank(self): + texte = dep.pane_text(self.rapport(), "web_responsive", mode="depends") + self.assertIn("rien", texte.lower()) + + def test_the_limit_announces_what_it_cut(self): + rapport = base( + dict( + {"chef": "installed"}, + **{f"d{i}": "installed" for i in range(9)}, + ), + {"chef": [f"d{i}" for i in range(9)]}, + ) + texte = dep.pane_text(rapport, "chef", mode="depends", limit=4) + self.assertIn("5", texte) + + +class TestRenderText(unittest.TestCase): + def rapport(self): + return base({f"m{i:02d}": "installed" for i in range(30)}) + + def test_without_a_cap_everything_is_there(self): + lignes = dep.render_text(self.rapport()) + self.assertEqual(sum(1 for x in lignes if x.startswith("✅")), 30) + + def test_the_cap_bounds_the_module_list(self): + # Sur une vraie base c'est 3035 modules et 6046 lignes : sans + # borne, le repli du menu est une seconde panne. + lignes = dep.render_text(self.rapport(), cap=5) + self.assertEqual(sum(1 for x in lignes if x.startswith("✅")), 5) + + def test_the_cap_says_how_many_it_hid(self): + self.assertTrue( + any("25" in x for x in dep.render_text(self.rapport(), cap=5)) + ) + + def test_an_unreadable_database_says_so(self): + lignes = dep.render_text({"database": "x", "unavailable": True}) + self.assertEqual(len(lignes), 1) + self.assertIn("x", lignes[0]) + + +class TestSurvey(unittest.TestCase): + def setUp(self): + from script.analyse import check_module_package as package + + self.package = package + self.vrai = (package.census, package.dependencies, package.db_version) + + def tearDown(self): + ( + self.package.census, + self.package.dependencies, + self.package.db_version, + ) = self.vrai + + def poser(self, recensement, depend): + self.package.census = lambda d: recensement + self.package.dependencies = lambda d: depend + self.package.db_version = lambda d: "18.0" + + def test_a_module_without_dependency_still_exists_in_the_graph(self): + # Sans cela, `closure` et l'écran le traitent comme inconnu. + self.poser({"seul": ("installed", "", False, "")}, {}) + self.assertEqual(dep.survey("x")["depends"], {"seul": []}) + + def test_an_unreadable_base_is_not_an_empty_one(self): + self.poser(None, {}) + self.assertTrue(dep.survey("x").get("unavailable")) + + def test_an_empty_base_is_readable(self): + self.poser({}, {}) + self.assertFalse(dep.survey("x").get("unavailable")) + + def test_it_reads_the_base_once_for_both_views(self): + appels = [] + self.poser({"a": ("installed", "", False, "")}, {}) + vrai = self.package.census + self.package.census = lambda d: (appels.append(d), vrai(d))[1] + dep.survey("x") + self.assertEqual(appels, ["x"]) + + +class TestTuiPureParts(unittest.TestCase): + def test_the_mode_cycles_through_all_of_them_and_returns(self): + vus, mode = [], None + for _ in range(len(dep.DETAILS) + 1): + mode = tui.next_mode(mode) + vus.append(mode) + self.assertEqual(vus, list(dep.DETAILS) + [None]) + + def test_an_unknown_mode_restarts_the_cycle(self): + self.assertEqual(tui.next_mode("n'importe quoi"), dep.DETAILS[0]) + + def test_the_filter_cycles(self): + self.assertEqual(tui.next_filter(dep.FILTRES[-1]), dep.FILTRES[0]) + + def test_the_cursor_out_of_range_is_not_a_crash(self): + # La table garde son curseur quand la liste raccourcit. + self.assertIsNone(tui.current_name([{"name": "a"}], 7)) + self.assertIsNone(tui.current_name([], 0)) + self.assertIsNone(tui.current_name([{"name": "a"}], None)) + + def test_the_cursor_follows_the_module_across_a_filter(self): + lst = [{"name": "alpha"}, {"name": "zebre"}] + self.assertEqual(tui.cursor_for(lst, "zebre"), 1) + + def test_a_module_filtered_away_loses_the_cursor(self): + self.assertIsNone(tui.cursor_for([{"name": "alpha"}], "zebre")) + + def test_the_search_ignores_the_case(self): + lst = [{"name": "Web_Responsive"}, {"name": "mail"}] + self.assertEqual(len(tui.matching(lst, "web")), 1) + + def test_an_empty_search_keeps_everything(self): + lst = [{"name": "a"}, {"name": "b"}] + self.assertIs(tui.matching(lst, ""), lst) + + def test_the_subtitle_names_the_three_states(self): + texte = tui.subtitle("depends", "installed", "web") + self.assertIn("directement", texte) + self.assertIn("installés", texte) + self.assertIn("web", texte) + + +class FausseTable: + """Le contrat de DataTable, réduit à ce que `populate` en utilise.""" + + def __init__(self): + self.lignes = [] + self.curseur = None + + def clear(self): + self.lignes = [] + + def add_row(self, *cellules): + self.lignes.append(cellules) + + def move_cursor(self, row=None): + self.curseur = row + + +class TestPopulate(unittest.TestCase): + def test_it_fills_and_keeps_the_cursor_on_the_module(self): + table = FausseTable() + lst = [ + {"name": "alpha", "label": "✅ alpha", "detail": "0↓ 0/0↑"}, + {"name": "zebre", "label": "✅ zebre", "detail": "1↓ 0/0↑"}, + ] + self.assertEqual(tui.populate(table, lst, "zebre"), 1) + self.assertEqual(len(table.lignes), 2) + self.assertEqual(table.curseur, 1) + + def test_it_clears_before_refilling(self): + table = FausseTable() + table.add_row("vieux") + tui.populate(table, [{"name": "a", "label": "a", "detail": ""}]) + self.assertEqual(len(table.lignes), 1) + + def test_a_vanished_module_leaves_the_cursor_alone(self): + table = FausseTable() + tui.populate( + table, [{"name": "a", "label": "a", "detail": ""}], "parti" + ) + self.assertIsNone(table.curseur) + + +class FauxChamp: + """Le contrat d'Input, réduit à ce que `hide_find` en utilise.""" + + def __init__(self): + self.value = "web" + self.classes = {"visible"} + + def remove_class(self, nom): + self.classes.discard(nom) + + def has_class(self, nom): + return nom in self.classes + + +class FausseTableFocus(FausseTable): + def __init__(self): + super().__init__() + self.focalisee = False + + def focus(self): + self.focalisee = True + + +class TestHideFind(unittest.TestCase): + def test_it_hands_the_keyboard_back_to_the_list(self): + # Le geste qui compte : sans lui, Textual laisse le clavier au + # champ de recherche et plus aucune touche n'agit. + champ, table = FauxChamp(), FausseTableFocus() + tui.hide_find(champ, table) + self.assertTrue(table.focalisee) + + def test_it_hides_the_field(self): + champ, table = FauxChamp(), FausseTableFocus() + tui.hide_find(champ, table) + self.assertFalse(champ.has_class("visible")) + + def test_it_leaves_the_text_alone(self): + # Effacer ici ferait deux chemins pour le même état : l'appelant + # vide le champ, et c'est CE geste qui redéclenche le filtrage. + champ, table = FauxChamp(), FausseTableFocus() + tui.hide_find(champ, table) + self.assertEqual(champ.value, "web") + + +class TestTheScreenRefusesWhenItCannot(unittest.TestCase): + def test_an_unreadable_report_opens_nothing(self): + self.assertFalse(tui.run_tui({"unavailable": True})) + + def test_an_empty_base_opens_nothing(self): + self.assertFalse(tui.run_tui({"modules": {}})) + + def test_nothing_at_all_opens_nothing(self): + self.assertFalse(tui.run_tui(None)) + + +def textual_present(): + try: + import textual # noqa: F401 + except Exception: + return False + return True + + +@unittest.skipUnless(textual_present(), "textual absent") +class TestTheScreenActuallyDrives(unittest.TestCase): + """Conduire l'écran, touche par touche. + + Les fonctions pures étaient toutes vertes et l'écran, lui, ne + répondait pas : le champ de recherche caché prenait le focus au + démarrage — « display: none » ne retire pas un widget du parcours du + clavier — donc « d » tapait dans une boîte invisible. Seul un test + qui PRESSE les touches pouvait le voir. + """ + + def rapport(self): + return base( + { + "web_responsive": "installed", + "mail": "installed", + "compagnon": "uninstalled", + "casse": "installed", + "zebre": "installed", + }, + { + "web_responsive": ["mail"], + "compagnon": ["web_responsive"], + "casse": ["fantome"], + "zebre": ["web_responsive"], + }, + ) + + def conduire(self, scenario): + import asyncio + + app = tui.build_app(self.rapport()) + + async def piloter(): + async with app.run_test() as pilote: + await scenario(app, pilote) + + asyncio.run(piloter()) + return app + + def test_the_keyboard_starts_on_the_list_not_in_the_search(self): + vus = [] + + async def scenario(app, pilote): + vus.append(type(app.focused).__name__) + + self.conduire(scenario) + self.assertEqual(vus, ["DataTable"]) + + def test_pressing_d_walks_every_relation_and_comes_back(self): + vus = [] + + async def scenario(app, pilote): + for _ in range(len(dep.DETAILS) + 1): + await pilote.press("d") + vus.append(app.mode) + + self.conduire(scenario) + self.assertEqual(vus, list(dep.DETAILS) + [None]) + + def test_pressing_f_narrows_the_list(self): + vus = {} + + async def scenario(app, pilote): + table = app.query_one("#left") + for _ in range(len(dep.FILTRES)): + await pilote.press("f") + vus[app.filtre] = table.row_count + + self.conduire(scenario) + self.assertEqual(vus["all"], 5) + self.assertEqual(vus["installed"], 4) + self.assertEqual(vus["absent"], 1) + self.assertEqual(vus["broken"], 1) + + def test_the_search_filters_then_gives_the_keyboard_back(self): + vus = {} + + async def scenario(app, pilote): + await pilote.press("slash") + vus["focus_ouvert"] = type(app.focused).__name__ + for lettre in "web": + await pilote.press(lettre) + await pilote.pause() + vus["lignes"] = app.query_one("#left").row_count + await pilote.press("enter") + vus["focus_rendu"] = type(app.focused).__name__ + await pilote.press("d") + vus["mode"] = app.mode + + self.conduire(scenario) + self.assertEqual(vus["focus_ouvert"], "Input") + self.assertEqual(vus["lignes"], 1) + self.assertEqual(vus["focus_rendu"], "DataTable") + # « d » doit remarcher une fois la recherche finie : sans cela on + # ne peut plus rien faire après avoir cherché. + self.assertEqual(vus["mode"], dep.DETAILS[0]) + + def test_escape_closes_the_search_before_it_closes_the_screen(self): + vus = {} + + async def scenario(app, pilote): + await pilote.press("slash") + for lettre in "web": + await pilote.press(lettre) + await pilote.pause() + await pilote.press("escape") + await pilote.pause() + vus["motif"] = app.motif + vus["lignes"] = app.query_one("#left").row_count + vus["vivant"] = app.is_running + # La tabulation ne doit pas non plus retomber dans le champ + # refermé : « display: none » ne l'en retire pas, seul + # can_focus le fait. + await pilote.press("tab") + vus["apres_tab"] = type(app.focused).__name__ + await pilote.press("escape") + await pilote.pause() + vus["apres_second_echap"] = app.is_running + + self.conduire(scenario) + self.assertNotEqual(vus["apres_tab"], "Input") + # …et le second échap, lui, ferme bien l'écran. + self.assertFalse(vus["apres_second_echap"]) + # Renoncer à une recherche ne doit pas fermer l'écran : on a + # parfois filtré trois mille modules pour arriver là. + self.assertTrue(vus["vivant"]) + self.assertEqual(vus["motif"], "") + self.assertEqual(vus["lignes"], 5) + + +if __name__ == "__main__": + unittest.main() From 3c10ca2eb2ac1e550dc1e7ebe393c172bc0a0c7b Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sun, 23 Aug 2026 03:50:57 -0400 Subject: [PATCH 11/16] =?UTF-8?q?[FIX]=20d=C3=A9ploiement=20:=20suivre=20u?= =?UTF-8?q?ne=20VM=20m=C3=AAme=20sans=20installation=20ERPLibre?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Décocher l'installation d'ERPLibre faisait disparaître le tableau de bord. La case « suivi » vivait DANS le groupe de l'installation, build_spec ne la recopiait même pas dans la spec, et l'épilogue était gardé par « if install or desktop » : sans rien à installer, il ne se passait rien. Le suivi devient un choix du DÉPLOIEMENT. Et sans rien à installer, la commande distante ne vaut plus « true » — journal vide, ✅ instantané : elle regarde la VM ARRIVER, attend cloud-init, puis relève système, noyau, adresse, disque et mémoire. Le journal cesse aussi d'annoncer une installation ERPLibre qui n'a pas lieu. --- EN --- Unchecking the ERPLibre install made the dashboard vanish. The "monitoring" checkbox lived INSIDE the install group, build_spec did not even copy it into the spec, and the deploy epilogue was gated by "if install or desktop": with nothing to install, nothing happened. Monitoring is now a DEPLOYMENT-level choice. And with nothing to install, the remote command is no longer "true" — empty log, instant ✅: it watches the VM ARRIVE, waits for cloud-init, then reports system, kernel, address, disk and memory. The log also stops announcing an ERPLibre install that never happens. Assisted-by: Claude Opus 5 --- script/todo/qemu_deploy_form.py | 8 + script/todo/qemu_install_monitor.py | 37 ++++- script/todo/todo.py | 61 ++++++- script/todo/todo_i18n.py | 32 ++++ test/test_qemu_deploy_monitor.py | 249 ++++++++++++++++++++++++++++ 5 files changed, 377 insertions(+), 10 deletions(-) create mode 100644 test/test_qemu_deploy_monitor.py diff --git a/script/todo/qemu_deploy_form.py b/script/todo/qemu_deploy_form.py index 6e0d3f4..586956c 100644 --- a/script/todo/qemu_deploy_form.py +++ b/script/todo/qemu_deploy_form.py @@ -298,6 +298,10 @@ def build_spec(vms, domains, form): "python_provider": form.get("python_provider", ""), "app_store": form.get("app_store", "deb"), "install": form["install"], + # Au NIVEAU DU DÉPLOIEMENT, pas de l'installation : une VM sans + # ERPLibre se suit aussi (cloud-init, puis relevé système). Absent de + # cette assemblée, le choix du formulaire n'atteignait jamais la spec. + "monitor": form.get("monitor", True), "add_ssh_config": form["add_ssh_config"], "parallelism": form["parallelism"], } @@ -1828,6 +1832,10 @@ def run_deploy_form(ctx, run_app: bool = True): } key = self.query_one("#f_key", Input).value.strip() return { + # Le suivi est demandé au NIVEAU DU DÉPLOIEMENT, pas de + # l'installation : décocher ERPLibre emportait la case avec + # elle, et le tableau de bord ne s'ouvrait plus du tout. + "monitor": self.query_one("#f_monitor", Checkbox).value, "res_label": ( t("custom") if self.profile == "custom" diff --git a/script/todo/qemu_install_monitor.py b/script/todo/qemu_install_monitor.py index 17255f9..c51e395 100644 --- a/script/todo/qemu_install_monitor.py +++ b/script/todo/qemu_install_monitor.py @@ -80,7 +80,11 @@ def list_install_runs() -> list: def _launch_one( - ip: str, remote_cmd: str, log_path: str, name: str = "" + ip: str, + remote_cmd: str, + log_path: str, + name: str = "", + installs: bool = True, ) -> None: """Lance une install SSH DÉTACHÉE : attend le sshd, exécute, journalise la sortie puis écrit le marqueur de fin avec le code de sortie.""" @@ -102,7 +106,13 @@ def _launch_one( # log reste VIDE pendant tout le boot émulé et paraît « bloqué ». msg_wait = t("Waiting for the VM to start (boot + cloud-init)") msg_slow = t("(an emulated architecture can be slow; this is normal)") - msg_ready = t("VM ready - starting the ERPLibre install") + # « installation ERPLibre en cours » sur un déploiement qui n'installe + # RIEN était un mensonge du journal : la ligne dit maintenant ce qui suit. + msg_ready = ( + t("VM ready - starting the ERPLibre install") + if installs + else t("VM ready - taking its measurements") + ) msg_giveup = t( "cloud-init still running after 20 min - install starts anyway" " (it waits for cloud-init first)" @@ -230,19 +240,26 @@ def _launch_one( def _log_header(vm: dict, branch: str, when: str) -> str: """En-tête du log : date, VM, distribution, version, architecture, branche. Permet d'identifier l'installation d'un coup d'œil (et de ne jamais laisser - le log vide pendant l'attente du boot).""" + le log vide pendant l'attente du boot). + + Sans branche, il n'y a rien à installer : le titre le dit et la ligne + « Branche » disparaît, au lieu d'annoncer une installation ERPLibre qui + n'aura pas lieu. + """ distro = vm.get("distro") or "?" version = vm.get("version") or "" arch = vm.get("arch") or "?" bar = "=" * 64 + titre = t("installation") if branch else t("VM start-up") + ligne_branche = f" Branche : {branch}\n" if branch else "" return ( f"{bar}\n" - f" ERPLibre — {t('installation')}\n" + f" ERPLibre — {titre}\n" f" Date : {when}\n" f" VM : {vm['name']}\n" f" Distribution : {distro} {version}\n" f" Architecture : {arch}\n" - f" Branche : {branch}\n" + f"{ligne_branche}" f" IP : {vm['ip']}\n" f"{bar}\n\n" ) @@ -268,7 +285,11 @@ def launch_installs(vms: list[dict], branch: str, remote_cmd: str) -> str: # plus le même pour toutes. `remote_cmd` reste le défaut, ce qui laisse # intacts les appelants qui n'en fournissent qu'une. _launch_one( - vm["ip"], vm.get("remote_cmd") or remote_cmd, log_path, vm["name"] + vm["ip"], + vm.get("remote_cmd") or remote_cmd, + log_path, + vm["name"], + installs=bool(branch), ) entries.append( { @@ -411,6 +432,10 @@ def active_run(): # 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 = ( + # ssh annonce l'ajout d'une clé d'hôte à chaque PREMIÈRE connexion à une + # VM neuve. Ce n'est pas un avertissement d'installation : compté, il + # allumait la colonne ⚠ sur TOUTE installation, dès sa première ligne. + "Warning: Permanently added", "have the same label:", "odoo.addons.code_generator.extractor_module_file: Ignore next error about" " ALTER TABLE DROP CONSTRAINT.", diff --git a/script/todo/todo.py b/script/todo/todo.py index 8b958e0..5a823f9 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -5654,6 +5654,31 @@ class TODO: "fi; " ) + @staticmethod + def _qemu_vm_ready_report(): + """Relevé de mise en route, pour une VM où l'on n'installe RIEN. + + Sans lui, la commande distante valait « true » : le suivi affichait un + ✅ instantané sur un journal vide, ce qui n'apprend rien de la machine + qu'on vient de créer. Ici, il y a une fin claire (le marqueur de sortie + que pose le lanceur) et de quoi juger qu'elle est prête : système, + noyau, adresse, disque, mémoire, et le verdict de cloud-init. + """ + return ( + f'echo "===> {t("VM start-up")}"; ' + ". /etc/os-release 2>/dev/null || true; " + f'echo " {t("system:")} ${{PRETTY_NAME:-?}}"; ' + f'echo " {t("kernel:")} $(uname -r) ($(uname -m))"; ' + f'echo " {t("address:")} ' + "$(hostname -I 2>/dev/null | awk '{print $1}')\"; " + f'echo " {t("disk:")} ' + "$(df -h / | awk 'NR==2 {print $3\"/\"$2\" (\"$5\")\"}')\"; " + f'echo " {t("memory:")} ' + "$(free -h 2>/dev/null | awk 'NR==2 {print $3\"/\"$2}')\"; " + f'echo " {t("uptime:")} $(uptime -p 2>/dev/null || true)"; ' + f'echo "<=== {t("VM start-up")}"; ' + ) + @staticmethod def _qemu_no_auto_upgrade(prod, app_store="deb"): """Coupe les mises à jour automatiques sur une VM de DÉVELOPPEMENT. @@ -7236,7 +7261,15 @@ class TODO: # attente de cloud-init et coupure des mises à jour automatiques, # sans quoi le verrou apt ferait échouer l'installation du bureau. if not desktop: - return "true" + # Rien à installer : le suivi n'a alors qu'à regarder la VM + # ARRIVER. Un « true » rendait un journal vide et un ✅ + # instantané — et c'est pourquoi le suivi « ne marchait plus » + # dès qu'on décochait ERPLibre. + return ( + "set -e; " + + self._qemu_cloud_init_wait() + + self._qemu_vm_ready_report() + ) # Les outils de la phase « after » vivent DANS le dépôt — la # compilation mobile, l'AVD, le script Forgejo. Sans clone, ils # n'existent pas ici. Les écarter en silence laissait croire qu'une @@ -8970,6 +9003,15 @@ class TODO: "cmd": cmd, "monitor": monitor, } + else: + # Rien à installer : le suivi garde tout son sens — il regarde les + # VM arriver (cloud-init, puis relevé système) et porte le tableau + # d'état, de débit d'écriture, de RAM et de disque. La question + # était posée DANS la branche ERPLibre : refuser l'une emportait + # l'autre sans qu'on l'ait demandé. + monitor = self._is_yes_default_yes( + input(f"{t('Watch the VMs start (no install)')} ? (O/n) : ") + ) add_ssh_config = self._is_yes_default_yes( input(t("Add each VM to ~/.ssh/config? (Y/n): ")) @@ -9030,6 +9072,9 @@ class TODO: "python_provider": python_provider, "app_store": app_store, "install": install, + # Au niveau du déploiement : le suivi survit à une installation + # décochée (voir _qemu_run_spec). + "monitor": monitor, "add_ssh_config": add_ssh_config, "parallelism": parallelism, } @@ -9191,8 +9236,16 @@ class TODO: # 7) Installation ERPLibre (clone + make) et/ou bureau GNOME. Le bureau # ne dépend PAS d'ERPLibre : une VM peut être voulue graphique et nue. # Il passe par la même commande distante, donc par le même suivi. - if install or desktop: - monitor = install["monitor"] if install else True + # + # Et quand il n'y a RIEN à installer, le suivi s'ouvre quand même : la + # commande distante regarde alors la VM arriver (cloud-init puis relevé + # système). Sans cela, décocher ERPLibre faisait disparaître le tableau + # de bord — rapporté, et c'est ce qui donnait « le suivi ne fonctionne + # plus ». Le choix vient du déploiement, pas de l'installation. + monitor = ( + install["monitor"] if install else spec.get("monitor", True) + ) + if install or desktop or monitor: if monitor: # Installs détachées en parallèle + dashboard Textual. self._qemu_install_erplibre_monitored( @@ -9206,7 +9259,7 @@ class TODO: app_store=app_store, vm_tools=vm_tools, ) - else: + elif install: print( f"\n{t('Installing ERPLibre on each VM')} " f"({install_branch})…" diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 84b46b4..e71c5d9 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -3115,6 +3115,38 @@ TRANSLATIONS = { "fr": "Nœud de rendu", "en": "Render node", }, + "VM ready - taking its measurements": { + "fr": "VM prête — relevé en cours", + "en": "VM ready - taking its measurements", + }, + "VM start-up": { + "fr": "Mise en route de la VM", + "en": "VM start-up", + }, + "system:": { + "fr": "système :", + "en": "system:", + }, + "kernel:": { + "fr": "noyau :", + "en": "kernel: ", + }, + "address:": { + "fr": "adresse :", + "en": "address:", + }, + "disk:": { + "fr": "disque :", + "en": "disk: ", + }, + "uptime:": { + "fr": "actif :", + "en": "uptime: ", + }, + "Watch the VMs start (no install)": { + "fr": "Suivre le démarrage des VM (sans installation)", + "en": "Watch the VMs start (no install)", + }, "Proxmox VE hypervisor (no Odoo)": { "fr": "Hyperviseur Proxmox VE (sans Odoo)", "en": "Proxmox VE hypervisor (no Odoo)", diff --git a/test/test_qemu_deploy_monitor.py b/test/test_qemu_deploy_monitor.py new file mode 100644 index 0000000..fb17d2c --- /dev/null +++ b/test/test_qemu_deploy_monitor.py @@ -0,0 +1,249 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Suivre un déploiement qui n'installe PAS ERPLibre. + +Symptôme rapporté : en décochant l'installation d'ERPLibre, le tableau de bord +ne s'ouvrait plus du tout. Deux causes, l'une derrière l'autre : + +- la case « suivi » vivait DANS le groupe de l'installation ERPLibre, et + `build_spec` ne la recopiait même pas dans la spec finale ; +- l'épilogue du déploiement était gardé par « if install or desktop » : sans + rien à installer, il ne se passait rien. + +Et si le suivi s'ouvrait quand même, il n'aurait rien montré : la commande +distante valait « true », donc un journal vide et un ✅ instantané. Elle +regarde maintenant la VM ARRIVER — cloud-init, puis un relevé système — ce qui +donne un début, une fin, et de quoi juger qu'elle est prête. +""" + +import contextlib +import io +import os +import subprocess +import sys +import unittest + +sys.argv = ["todo.py"] +from script.todo import qemu_install_monitor as mon # noqa: E402 +from script.todo.qemu_deploy_form import build_spec # noqa: E402 +from script.todo.todo import TODO # noqa: E402 + + +class TestLaCommandeDistante(unittest.TestCase): + def setUp(self): + self.todo = TODO.__new__(TODO) + + def _sans_rien(self): + return self.todo._qemu_erplibre_remote_cmd(None) + + def test_it_is_no_longer_a_bare_true(self): + """« true » rendait un journal vide et un ✅ instantané : le suivi + s'ouvrait sur rien.""" + self.assertNotEqual("true", self._sans_rien().strip()) + + def test_it_is_valid_shell(self): + res = subprocess.run( + ["bash", "-n"], + input=self._sans_rien(), + capture_output=True, + text=True, + ) + self.assertEqual(0, res.returncode, res.stderr) + + def test_it_waits_for_cloud_init(self): + """C'est le vrai « suivi » d'une VM neuve : tant que cloud-init tourne, + la machine n'est pas prête.""" + self.assertIn("cloud-init status --wait", self._sans_rien()) + + def test_it_frames_the_step_so_the_monitor_can_bound_it(self): + """Le tableau lit ces marqueurs pour dire où en est la VM.""" + cmd = self._sans_rien() + self.assertIn("===>", cmd) + self.assertIn("<===", cmd) + + def test_it_reports_what_says_the_vm_is_up(self): + cmd = self._sans_rien() + for morceau in ("/etc/os-release", "uname -r", "hostname -I", "df -h"): + self.assertIn(morceau, cmd, morceau) + + def test_it_runs_and_says_something(self): + """Exécutée ici, hors VM : l'attente de cloud-init est neutralisée, le + relevé doit sortir et rendre 0.""" + cmd = self._sans_rien().replace( + "sudo timeout 900 cloud-init status --wait", "true" + ) + res = subprocess.run( + ["bash", "-c", cmd], capture_output=True, text=True, timeout=60 + ) + self.assertEqual(0, res.returncode, res.stderr[-300:]) + self.assertIn("===>", res.stdout) + self.assertIn("<===", res.stdout) + # Trois lignes de relevé au moins : sans elles, le journal est creux. + self.assertGreaterEqual(len(res.stdout.strip().splitlines()), 5) + + def test_a_desktop_only_vm_still_installs_its_desktop(self): + """Le chemin qui marchait déjà ne doit pas changer de sens.""" + self.todo._qemu_desktop_remote_cmd = lambda d, s: "INSTALLE_BUREAU; " + self.todo._qemu_tools_remote_cmd = lambda *a, **k: "" + self.todo._qemu_no_auto_upgrade = lambda *a, **k: "" + cmd = self.todo._qemu_erplibre_remote_cmd(None, desktop="gnome") + self.assertIn("INSTALLE_BUREAU", cmd) + + +class TestLaSpecDuFormulaire(unittest.TestCase): + def _form(self, **extra): + base = { + "res_label": "x1", + "ssh_key": "/k.pub", + "install": None, + "add_ssh_config": False, + "parallelism": 1, + } + base.update(extra) + return base + + def test_the_choice_reaches_the_spec(self): + """Il ne la recopiait pas : le choix du formulaire n'atteignait jamais + le déploiement.""" + spec = build_spec([], [], self._form(monitor=True)) + self.assertTrue(spec["monitor"]) + spec = build_spec([], [], self._form(monitor=False)) + self.assertFalse(spec["monitor"]) + + def test_an_old_form_without_the_key_still_monitors(self): + """Compatibilité : une spec enregistrée avant ce changement ne doit pas + perdre son tableau de bord.""" + self.assertTrue(build_spec([], [], self._form())["monitor"]) + + +class TestLaDecisionDuDeploiement(unittest.TestCase): + """L'épilogue : qui est appelé, et avec quoi.""" + + def _joue(self, spec): + todo = TODO.__new__(TODO) + appels = [] + todo._qemu_install_erplibre_monitored = lambda *a, **k: appels.append( + "suivi" + ) + todo._qemu_install_erplibre_vm = lambda *a, **k: appels.append("muet") + todo._qemu_resolve_ips = lambda names, labels=None: {} + base = { + "vms": [], + "existing": ["vm-a"], + "install": None, + "add_ssh_config": False, + "parallelism": 1, + } + base.update(spec) + with contextlib.redirect_stdout(io.StringIO()): + todo._qemu_run_spec(base) + return appels + + def test_without_erplibre_the_monitor_still_opens(self): + """Le cœur du problème rapporté.""" + self.assertEqual(["suivi"], self._joue({"monitor": True})) + + def test_a_spec_without_the_key_monitors_too(self): + self.assertEqual(["suivi"], self._joue({})) + + def test_refusing_the_monitor_does_nothing_at_all(self): + """Et surtout : ne pas partir installer un profil qui n'existe pas. + L'ancien repli faisait « install['cmd'] » sur un None.""" + self.assertEqual([], self._joue({"monitor": False})) + + def test_an_install_without_the_monitor_takes_the_quiet_path(self): + appels = self._joue( + { + "monitor": False, + "install": { + "branch": "develop", + "prod": False, + "cmd": "make x", + "monitor": False, + }, + } + ) + self.assertEqual(["muet"], appels) + + def test_the_install_keeps_the_last_word_on_its_own_monitoring(self): + appels = self._joue( + { + "monitor": False, + "install": { + "branch": "develop", + "prod": False, + "cmd": "make x", + "monitor": True, + }, + } + ) + self.assertEqual(["suivi"], appels) + + +class TestLeJournal(unittest.TestCase): + """L'en-tête et le prologue ne doivent pas annoncer ce qui n'a pas lieu.""" + + def _vm(self): + return { + "name": "vm-a", + "ip": "10.0.0.9", + "distro": "debian", + "version": "13", + "arch": "amd64", + } + + def test_an_install_is_titled_an_install(self): + head = mon._log_header(self._vm(), "develop", "2026-01-01 00:00:00") + self.assertIn(mon.t("installation"), head) + self.assertIn("develop", head) + + def test_without_a_branch_it_is_not_called_an_install(self): + """« ERPLibre — installation » puis « Branche : » vide, sur un + déploiement qui n'installe rien : le journal se contredisait.""" + head = mon._log_header(self._vm(), "", "2026-01-01 00:00:00") + self.assertIn(mon.t("VM start-up"), head) + self.assertNotIn("Branche", head) + + def test_the_prologue_says_what_actually_follows(self): + """« installation ERPLibre en cours » alors que rien ne s'installe.""" + import tempfile + from pathlib import Path + + vus = [] + vrai_launch, vrai_dir = mon._launch_one, mon.session_dir + mon._launch_one = ( + lambda ip, cmd, log, name="", installs=True: vus.append(installs) + ) + # session_dir détournée : sans cela le test écrivait de VRAIES sessions + # dans ~/.erplibre/qemu-install, qui polluaient l'historique que + # « Rouvrir le suivi » propose à l'utilisateur. + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + mon.session_dir = lambda: Path(tmp.name) + try: + mon.launch_installs([self._vm()], "", "true") + mon.launch_installs([self._vm()], "develop", "true") + finally: + mon._launch_one, mon.session_dir = vrai_launch, vrai_dir + self.assertEqual([False, True], vus) + + def test_the_host_key_notice_is_not_a_warning(self): + """ssh l'écrit à CHAQUE première connexion : comptée, la colonne ⚠ + s'allumait sur toute installation, dès sa première ligne.""" + import tempfile + + with tempfile.NamedTemporaryFile( + "w", suffix=".log", delete=False, encoding="utf-8" + ) as fh: + fh.write( + "Warning: Permanently added '10.0.0.9' (ED25519) to the" + " list of known hosts.\n__ERPLIBRE_EXIT__ 0\n" + ) + chemin = fh.name + self.addCleanup(os.unlink, chemin) + self.assertEqual((0, 0), mon.scan_log_errors(chemin)) + + +if __name__ == "__main__": + unittest.main(verbosity=1) From bcd7f9ae884669b22ddd953186800a38759f60ab Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sun, 23 Aug 2026 04:03:18 -0400 Subject: [PATCH 12/16] =?UTF-8?q?[ADD]=20analyse:=20i,=20u=20et=20t=20?= =?UTF-8?q?=E2=80=94=20install=C3=A9s,=20en=20cours,=20applications?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trois filtres sous la main plutôt qu'un cycle à parcourir. « u » est le seul qui montrait quelque chose d'invisible jusqu'ici : les états de passage — to install, to upgrade, to remove — qu'Odoo traverse et ne devrait pas garder. Mesuré sur test_neutralize_upgrade_13 en pleine migration : 22 modules figés en « to remove ». L'en-tête les annonce, le rapport texte les nomme avec leur état, et « non installés » ne les ramasse plus : un module en « to remove » EST encore installé, il s'en va. La même touche fait l'aller et le retour. --- EN --- Three filters under the hand rather than a cycle to walk. « u » is the only one showing something invisible until now: the transient states — to install, to upgrade, to remove — that Odoo passes through and should not keep. Measured on test_neutralize_upgrade_13 mid-migration: 22 modules frozen in « to remove ». The header announces them, the text report names them with their state, and « not installed » no longer collects them: a module in « to remove » IS still installed, it is on its way out. The same key goes and returns. Assisted-by: Claude Opus 5 --- script/analyse/check_module_dependency.py | 53 ++++- script/analyse/check_module_dependency_tui.py | 20 ++ script/todo/todo_i18n.py | 24 +++ test/test_check_module_dependency.py | 200 +++++++++++++++++- 4 files changed, 291 insertions(+), 6 deletions(-) diff --git a/script/analyse/check_module_dependency.py b/script/analyse/check_module_dependency.py index 93f9ff1..8113cb1 100755 --- a/script/analyse/check_module_dependency.py +++ b/script/analyse/check_module_dependency.py @@ -45,12 +45,26 @@ except Exception: # pragma: no cover - repli si i18n indisponible # résultat faux et visible plutôt qu'une boucle sans fin. PROFONDEUR_MAX = 64 -FILTRES = ("all", "installed", "absent", "broken") +FILTRES = ( + "all", + "installed", + "pending", + "application", + "absent", + "broken", +) # Ce qui compte comme « en place ». « to upgrade » l'est : le module est # chargé, il sera seulement rejoué. « to install » ne l'est pas encore. ETATS_PRESENTS = ("installed", "to upgrade") +# Les états qu'Odoo traverse et ne devrait pas garder. Un module qui y +# reste est une opération inachevée : mesuré sur test_neutralize_upgrade_13 +# en pleine migration, 22 modules figés en « to remove ». Ils ne sont plus +# tout à fait installés et pas encore partis, donc aucun autre filtre ne +# les montre pour ce qu'ils sont. +ETATS_EN_COURS = ("to install", "to upgrade", "to remove") + def reverse(depend): """{module: [ce qui dépend de lui]} à partir de {module: [dépendances]}. @@ -89,6 +103,11 @@ def present(etat): return etat in ETATS_PRESENTS +def pending(etat): + """L'état est-il un passage, plutôt qu'une destination ?""" + return etat in ETATS_EN_COURS + + def broken(recensement, depend): """[(module, dépendance, raison)] — ce qui est installé sur du vide. @@ -176,7 +195,17 @@ def rows(rapport, filtre="all"): etat = rapport["modules"][nom][0] if filtre == "installed" and not present(etat): continue - if filtre == "absent" and present(etat): + # « non installés » exclut aussi les états de passage : un module + # en « to remove » EST encore installé, il s'en va. Le ranger avec + # ce qu'on pourrait installer était trompeur ; il a sa touche. + if filtre == "absent" and (present(etat) or pending(etat)): + continue + if filtre == "pending" and not pending(etat): + continue + # `application` est le drapeau « App » d'Odoo : ces modules-là + # sont les racines, ceux qu'on installe pour de vrai — les autres + # arrivent derrière eux. + if filtre == "application" and not rapport["modules"][nom][2]: continue if filtre == "broken" and nom not in cassants: continue @@ -296,11 +325,19 @@ def head_text(rapport): ) total = len(rapport["modules"]) casses = len(rapport.get("broken", [])) + en_cours = sum( + 1 for infos in rapport["modules"].values() if pending(infos[0]) + ) texte = ( f"📦 {rapport['database']}" f" ({t('Odoo')} {rapport.get('version') or '?'})" f" · {installes}/{total} {t('modules installed')}" ) + if en_cours: + # Annoncé dans l'en-tête et non seulement derrière une touche : + # une opération inachevée explique des symptômes qu'on chercherait + # ailleurs pendant une heure. + texte += f" · ⏳ {en_cours} {t('in progress')}" if casses: texte += f" · ❌ {casses} {t('broken dependency(ies)')}" return texte @@ -326,6 +363,18 @@ def render_text(rapport, limit=0, cap=0): lignes.append(f" → {', '.join(amont[: limit or None])}") if cap and len(lst_row) > cap: lignes.append(f" … {len(lst_row) - cap} {t('more')}") + lst_en_cours = [ + nom + for nom, infos in sorted(rapport["modules"].items()) + if pending(infos[0]) + ] + if lst_en_cours: + lignes.append("") + lignes.append(f"⏳ {t('left in a transient state')}") + for nom in lst_en_cours[: limit or None]: + lignes.append(f" {nom} ({rapport['modules'][nom][0]})") + if limit and len(lst_en_cours) > limit: + lignes.append(f" … {len(lst_en_cours) - limit} {t('more')}") if rapport.get("broken"): lignes.append("") lignes.append(f"❌ {t('installed on missing dependencies')}") diff --git a/script/analyse/check_module_dependency_tui.py b/script/analyse/check_module_dependency_tui.py index a5e7f81..37c0f8a 100644 --- a/script/analyse/check_module_dependency_tui.py +++ b/script/analyse/check_module_dependency_tui.py @@ -43,6 +43,8 @@ def filter_label(filtre): return { "all": t("all modules"), "installed": t("installed only"), + "pending": t("in progress"), + "application": t("apps only"), "absent": t("not installed"), "broken": t("broken dependencies"), }.get(filtre, filtre) @@ -80,6 +82,16 @@ def next_mode(mode): return suite[(suite.index(courant) + 1) % len(suite)] +def toggle_filter(courant, cible): + """Aller à `cible` — ou en revenir si l'on y est déjà. + + Une touche qui ne fait qu'ALLER piège : arrivé sur « en cours », il + faudrait se souvenir de quelle autre touche ramène à la liste + entière. La même touche fait donc l'aller et le retour. + """ + return "all" if courant == cible else cible + + def next_filter(filtre): """Le filtre suivant, en boucle.""" suite = dependency.FILTRES @@ -161,6 +173,9 @@ def build_app(rapport): ("escape", "leave", t("Quit")), ("d", "cycle_detail", t("Dependencies")), ("f", "cycle_filter", t("Filter")), + ("i", "set_filter('installed')", t("Installed")), + ("u", "set_filter('pending')", t("In progress")), + ("t", "set_filter('application')", t("Apps")), ("slash", "find", t("Search")), ] @@ -237,6 +252,11 @@ def build_app(rapport): self.filtre = next_filter(self.filtre) self._fill() + def action_set_filter(self, cible): + """Sauter droit à un filtre. `f` reste là pour les parcourir.""" + self.filtre = toggle_filter(self.filtre, cible) + self._fill() + def action_find(self): champ = self.query_one("#find", Input) champ.add_class("visible") diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index e71c5d9..4fd87eb 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -6346,6 +6346,30 @@ TRANSLATIONS = { "fr": "dépendances cassées", "en": "broken dependencies", }, + "in progress": { + "fr": "en cours", + "en": "in progress", + }, + "apps only": { + "fr": "applications seulement", + "en": "apps only", + }, + "left in a transient state": { + "fr": "laissés dans un état de passage", + "en": "left in a transient state", + }, + "Installed": { + "fr": "Installés", + "en": "Installed", + }, + "In progress": { + "fr": "En cours", + "en": "In progress", + }, + "Apps": { + "fr": "Applications", + "en": "Apps", + }, "Dependencies": { "fr": "Dépendances", "en": "Dependencies", diff --git a/test/test_check_module_dependency.py b/test/test_check_module_dependency.py index 596c797..826ca18 100644 --- a/test/test_check_module_dependency.py +++ b/test/test_check_module_dependency.py @@ -22,10 +22,10 @@ from script.analyse import check_module_dependency as dep # noqa: E402 from script.analyse import check_module_dependency_tui as tui # noqa: E402 -def base(modules, depend=None): +def base(modules, depend=None, apps=()): """Un rapport minimal. `modules` : {nom: état}.""" recensement = { - nom: (etat, f"Résumé {nom}", False, "TechnoLibre") + nom: (etat, f"Résumé {nom}", nom in apps, "TechnoLibre") for nom, etat in modules.items() } depend = ( @@ -202,6 +202,135 @@ class TestRows(unittest.TestCase): dep.rows(self.rapport(), filtre) +class TestTheTransientStates(unittest.TestCase): + """Un module qui reste en chemin est une opération inachevée. + + Mesuré sur test_neutralize_upgrade_13, en pleine migration : + 22 modules figés en « to remove ». Aucun autre filtre ne les montrait + pour ce qu'ils sont. + """ + + def test_the_three_transient_states_are_pending(self): + for etat in ("to install", "to upgrade", "to remove"): + self.assertTrue(dep.pending(etat), etat) + + def test_a_settled_state_is_not(self): + for etat in ("installed", "uninstalled", "uninstallable"): + self.assertFalse(dep.pending(etat), etat) + + def rapport(self): + return base( + { + "zebre": "installed", + "alpha": "to remove", + "milieu": "to install", + "bravo": "uninstalled", + "charlie": "to upgrade", + } + ) + + def test_the_pending_filter_shows_exactly_those(self): + self.assertEqual( + [r["name"] for r in dep.rows(self.rapport(), "pending")], + ["alpha", "charlie", "milieu"], + ) + + def test_a_module_on_its_way_out_is_not_listed_as_absent(self): + # « to remove » EST encore installé ; le ranger avec ce qu'on + # pourrait installer envoyait chercher au mauvais endroit. + self.assertEqual( + [r["name"] for r in dep.rows(self.rapport(), "absent")], ["bravo"] + ) + + def test_to_upgrade_is_both_installed_and_pending(self): + # Il est chargé ET il attend : les deux sont vrais, et les taire + # ferait disparaître un module d'une des deux vues. + noms = [r["name"] for r in dep.rows(self.rapport(), "installed")] + self.assertIn("charlie", noms) + self.assertIn( + "charlie", [r["name"] for r in dep.rows(self.rapport(), "pending")] + ) + + def test_the_header_announces_them(self): + self.assertIn("3 en cours", dep.head_text(self.rapport())) + + def test_the_header_stays_quiet_when_there_are_none(self): + self.assertNotIn("en cours", dep.head_text(base({"a": "installed"}))) + + def test_the_text_report_lists_them_with_their_state(self): + texte = "\n".join(dep.render_text(self.rapport())) + self.assertIn("alpha (to remove)", texte) + self.assertIn("milieu (to install)", texte) + + def bloc_en_cours(self, lignes): + """Les lignes qui suivent le titre « état de passage ».""" + debut = next(i for i, x in enumerate(lignes) if "état de passage" in x) + suite = [] + for ligne in lignes[debut + 1 :]: + if not ligne.startswith(" "): + break + suite.append(ligne) + return suite + + def test_the_text_report_bounds_that_list_too(self): + # Trois en cours, borne à deux : deux nommés, et le troisième + # ANNONCÉ. Compter les lignes, pas chercher un chiffre au hasard. + suite = self.bloc_en_cours(dep.render_text(self.rapport(), limit=2)) + nommes = [x for x in suite if x.strip().endswith(")")] + self.assertEqual(len(nommes), 2) + self.assertEqual(suite[-1].strip(), "… 1 de plus") + + def test_without_a_limit_they_are_all_named(self): + suite = self.bloc_en_cours(dep.render_text(self.rapport())) + self.assertEqual(len(suite), 3) + self.assertFalse(any("de plus" in x for x in suite)) + + +class TestTheApplicationFilter(unittest.TestCase): + def rapport(self): + return base( + { + "zebre": "installed", + "alpha": "installed", + "milieu": "uninstalled", + }, + apps=("zebre", "milieu"), + ) + + def test_it_keeps_the_apps_whatever_their_state(self): + # Un module applicatif non installé reste applicatif : filtrer + # sur « App » et sur « installé » sont deux questions. + self.assertEqual( + [r["name"] for r in dep.rows(self.rapport(), "application")], + ["milieu", "zebre"], + ) + + def test_a_plain_module_is_left_out(self): + self.assertNotIn( + "alpha", + [r["name"] for r in dep.rows(self.rapport(), "application")], + ) + + def test_the_pane_says_whether_it_is_one(self): + self.assertIn("oui", dep.pane_text(self.rapport(), "zebre")) + self.assertIn("non", dep.pane_text(self.rapport(), "alpha")) + + +class TestToggleFilter(unittest.TestCase): + def test_the_same_key_goes_and_comes_back(self): + # Sans le retour, il faudrait se souvenir de quelle AUTRE touche + # ramène à la liste entière. + self.assertEqual(tui.toggle_filter("all", "pending"), "pending") + self.assertEqual(tui.toggle_filter("pending", "pending"), "all") + + def test_another_key_replaces_rather_than_returns(self): + self.assertEqual(tui.toggle_filter("installed", "pending"), "pending") + + def test_every_direct_filter_is_a_known_one(self): + for cible in ("installed", "pending", "application"): + self.assertIn(cible, dep.FILTRES) + + class TestListing(unittest.TestCase): def rapport(self): return base( @@ -524,10 +653,23 @@ class TestTheScreenActuallyDrives(unittest.TestCase): }, ) - def conduire(self, scenario): + def rapport_etats(self): + """Un jeu qui porte les trois catégories des touches i / u / t.""" + return base( + { + "sale": "installed", + "mail": "installed", + "muk_theme": "to remove", + "stock": "to install", + "vieux": "uninstalled", + }, + apps=("sale", "stock"), + ) + + def conduire(self, scenario, rapport=None): import asyncio - app = tui.build_app(self.rapport()) + app = tui.build_app(rapport or self.rapport()) async def piloter(): async with app.run_test() as pilote: @@ -594,6 +736,56 @@ class TestTheScreenActuallyDrives(unittest.TestCase): # ne peut plus rien faire après avoir cherché. self.assertEqual(vus["mode"], dep.DETAILS[0]) + def test_i_u_and_t_each_jump_straight_to_their_filter(self): + vus = {} + + async def scenario(app, pilote): + for touche in ("i", "u", "t"): + await pilote.press(touche) + vus[touche] = ( + app.filtre, + sorted(row["name"] for row in app.lst_row), + ) + await pilote.press(touche) + vus[touche + "_retour"] = app.filtre + + self.conduire(scenario, self.rapport_etats()) + self.assertEqual(vus["i"], ("installed", ["mail", "sale"])) + self.assertEqual(vus["u"], ("pending", ["muk_theme", "stock"])) + self.assertEqual(vus["t"], ("application", ["sale", "stock"])) + for touche in ("i", "u", "t"): + self.assertEqual(vus[touche + "_retour"], "all") + + def test_a_second_key_replaces_the_filter_rather_than_stacking(self): + vus = {} + + async def scenario(app, pilote): + await pilote.press("i") + await pilote.press("u") + vus["filtre"] = app.filtre + vus["lignes"] = app.query_one("#left").row_count + + self.conduire(scenario, self.rapport_etats()) + self.assertEqual(vus["filtre"], "pending") + self.assertEqual(vus["lignes"], 2) + + def test_those_letters_are_typed_not_obeyed_while_searching(self): + # « i », « u » et « t » sont des lettres avant d'être des + # raccourcis : dans le champ de recherche, elles s'écrivent. + vus = {} + + async def scenario(app, pilote): + await pilote.press("slash") + for lettre in "it": + await pilote.press(lettre) + await pilote.pause() + vus["motif"] = app.motif + vus["filtre"] = app.filtre + + self.conduire(scenario, self.rapport_etats()) + self.assertEqual(vus["motif"], "it") + self.assertEqual(vus["filtre"], "all") + def test_escape_closes_the_search_before_it_closes_the_screen(self): vus = {} From 422094ca0710424b55ba26069c6537ffbdbb30cf Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sun, 23 Aug 2026 04:17:34 -0400 Subject: [PATCH 13/16] [UPD] format todo --- script/todo/todo.py | 42 +++++++++++++++++++++++++++--------------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/script/todo/todo.py b/script/todo/todo.py index 5a823f9..ffaeba7 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -11,10 +11,10 @@ import inspect import json import logging import os -import socket import re import shlex import shutil +import socket import subprocess import sys import time @@ -2111,7 +2111,9 @@ class TODO: jump = self._ssh_proxyjump(name) domain = name.rsplit("+", 1)[-1] if not jump: - print(f"\n ⚠ {t('No ProxyJump for this host in ~/.ssh/config.')}") + print( + f"\n ⚠ {t('No ProxyJump for this host in ~/.ssh/config.')}" + ) print(f" {t('Cannot tell which machine runs its QEMU.')}") return port = self._qemu_vnc_port(domain, jump) @@ -2121,11 +2123,17 @@ class TODO: if not port: print(f"\n ⚠ {t('This VM exposes no VNC port.')}") print(f" {t('Its display is likely spice with listen=none:')}") - print(f" {pre}sudo virsh dumpxml {domain} | grep -A2 '") + print( + f" {pre}sudo virsh edit {domain} # " + ) print(f" {pre}sudo virsh start {domain}") print(f"\n {t('New VMs get this by default; see deploy_qemu.')}") return @@ -2135,13 +2143,17 @@ class TODO: host, from_ssh = self._qemu_self_address() user = os.environ.get("USER", "user") if not from_ssh: - print(f" ⚠ {t('Not in an SSH session: check the host address.')}") + print( + f" ⚠ {t('Not in an SSH session: check the host address.')}" + ) target = f"{user}@{host}" print(f"\n {t('Run this on YOUR workstation:')}") print(f"\n ssh -N -L {port}:127.0.0.1:{port} {target}\n") if jump: - print(f" {t('Target is the hypervisor')} ({jump}), " - f"{t('not the VM: the socket is QEMU-side.')}") + print( + f" {t('Target is the hypervisor')} ({jump}), " + f"{t('not the VM: the socket is QEMU-side.')}" + ) print(f" {t('then point your VNC client at')} localhost:{port}") print(f" {t('The tunnel stays open as long as that ssh runs.')}") @@ -2178,7 +2190,9 @@ class TODO: """ base = ["virsh", "--connect", "qemu:///system", "vncdisplay", domain] for argv in (base, ["sudo", "-n"] + base): - cmd = (["ssh", "-o", "BatchMode=yes", jump] + argv) if jump else argv + cmd = ( + (["ssh", "-o", "BatchMode=yes", jump] + argv) if jump else argv + ) try: res = subprocess.run( cmd, capture_output=True, text=True, timeout=25 @@ -5672,9 +5686,9 @@ class TODO: f'echo " {t("address:")} ' "$(hostname -I 2>/dev/null | awk '{print $1}')\"; " f'echo " {t("disk:")} ' - "$(df -h / | awk 'NR==2 {print $3\"/\"$2\" (\"$5\")\"}')\"; " + '$(df -h / | awk \'NR==2 {print $3"/"$2" ("$5")"}\')"; ' f'echo " {t("memory:")} ' - "$(free -h 2>/dev/null | awk 'NR==2 {print $3\"/\"$2}')\"; " + '$(free -h 2>/dev/null | awk \'NR==2 {print $3"/"$2}\')"; ' f'echo " {t("uptime:")} $(uptime -p 2>/dev/null || true)"; ' f'echo "<=== {t("VM start-up")}"; ' ) @@ -9242,9 +9256,7 @@ class TODO: # système). Sans cela, décocher ERPLibre faisait disparaître le tableau # de bord — rapporté, et c'est ce qui donnait « le suivi ne fonctionne # plus ». Le choix vient du déploiement, pas de l'installation. - monitor = ( - install["monitor"] if install else spec.get("monitor", True) - ) + monitor = install["monitor"] if install else spec.get("monitor", True) if install or desktop or monitor: if monitor: # Installs détachées en parallèle + dashboard Textual. From 9cb954749c03c1d4ef0d506b25b95e7f597515b6 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sun, 23 Aug 2026 04:35:58 -0400 Subject: [PATCH 14/16] [FIX] migration: un clone refait rejoue la liste de son palier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit web_responsive avait bien été retiré, et il est revenu. Deux endroits bâtissent la base intermédiaire : l'étape « Uninstall module », et « Choose delete missing module » qui la jette et la refait depuis la version précédente. Le second ne rejouait que les modules choisis là. Relevé sur test_neutralize_upgrade_18 : retrait au rang 218, clone refait au rang 230, et la 18 a refusé de charger sur l'exclusion de muk_web_theme. Les deux chemins passent maintenant par `uninstall_list_for`, donc ils ne peuvent plus diverger ; un test le tient par l'AST, pour tout chemin à venir. --- EN --- web_responsive had indeed been removed, and it came back. Two places build the intermediate database: the « Uninstall module » step, and « Choose delete missing module » which throws it away and rebuilds it from the previous version. The second replayed only the modules chosen there. Traced on test_neutralize_upgrade_18: removal at rank 218, clone rebuilt at rank 230, and 18 refused to load on muk_web_theme's exclusion. Both paths now go through `uninstall_list_for`, so they cannot diverge again; a test holds that through the AST, for any future path. Assisted-by: Claude Opus 5 --- script/todo/todo_upgrade.py | 48 +++++++--- test/test_uninstall_module_list.py | 138 ++++++++++++++++++++++++++++- 2 files changed, 172 insertions(+), 14 deletions(-) diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index 692b472..142750b 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -2020,17 +2020,8 @@ class TodoUpgrade: # uninstall_module_list_odoo130_to_odoo140.txt is read HERE, # right before the 13 -> 14 data migration. Without this the # per-bump files existed in name only and were never read. - lst_file, lst_detail = self.read_uninstall_module_list( - next_version - 1, database_name - ) - if lst_detail: - print( - f"✨ {t('Modules to uninstall before Odoo')}" - f"{next_version} :" - ) - self.print_uninstall_reason(lst_detail) - lst_module_to_uninstall = list( - dict.fromkeys(list(lst_module_to_uninstall) + lst_file) + lst_module_to_uninstall = self.uninstall_list_for( + next_version, database_name, lst_module_to_uninstall ) if lst_module_to_uninstall: @@ -2303,8 +2294,15 @@ class TodoUpgrade: # Duplicate database cmd_clone_database = f"./odoo_bin.sh db --clone --from_database {last_database_name} --database {database_name_upgrade}" self.todo_upgrade_execute(cmd_clone_database) + # Le clone est NEUF : ce qu'on avait retiré du + # précédent est revenu avec lui. La liste du palier + # se rejoue donc ici, avec les modules choisis. self.uninstall_from_database( - lst_module_to_delete, + self.uninstall_list_for( + next_version, + database_name, + lst_module_to_delete, + ), database_name_upgrade, next_version, ) @@ -3796,6 +3794,32 @@ class TodoUpgrade: return [] return lst_present + def uninstall_list_for(self, next_version, database_name, extra=()): + """Ce qu'il faut retirer avant `next_version`, liste du palier comprise. + + DEUX endroits bâtissent le clone intermédiaire : l'étape + « Uninstall module », et « Choose delete missing module » qui le + jette et le refait depuis la version précédente. Le second ne + rejouait que les modules choisis là, donc la liste du palier était + perdue avec le clone : web_responsive revenait, et la 18 refusait + de charger sur l'exclusion de muk_web_theme — après que la première + désinstallation eut pourtant réussi. Une seule fonction, pour que + les deux ne puissent plus diverger. + + `dict.fromkeys` dédoublonne au passage : la liste choisie porte + parfois deux fois le même nom. + """ + lst_file, lst_detail = self.read_uninstall_module_list( + next_version - 1, database_name + ) + if lst_detail: + print( + f"✨ {t('Modules to uninstall before Odoo')}" + f"{next_version} :" + ) + self.print_uninstall_reason(lst_detail) + return list(dict.fromkeys(list(extra) + lst_file)) + def uninstall_from_database( self, lst_module_to_uninstall, database_name, actual_version ): diff --git a/test/test_uninstall_module_list.py b/test/test_uninstall_module_list.py index 98b6cc2..8c7a4ca 100644 --- a/test/test_uninstall_module_list.py +++ b/test/test_uninstall_module_list.py @@ -15,6 +15,7 @@ Le retrait doit se faire pendant qu'on est ENCORE sur l'ancienne version, là où l'état est légal et où l'ORM fonctionne. """ +import ast import io import os import sys @@ -116,10 +117,11 @@ class TestWhenItRuns(unittest.TestCase): def test_a_private_list_wins_over_the_shared_one(self): # Une base peut avoir ses propres retraits sans qu'on touche à la # liste partagée de tout le monde. - source = io.open( + with io.open( os.path.join(RACINE, "script", "todo", "todo_upgrade.py"), encoding="utf-8", - ).read() + ) as handle: + source = handle.read() debut = source.index("def read_uninstall_module_list") fin = source.index("def split_present_missing") bloc = source[debut:fin] @@ -129,5 +131,137 @@ class TestWhenItRuns(unittest.TestCase): ) +class TestTheListFollowsTheClone(unittest.TestCase): + """Un clone refait doit rejouer la liste de son palier. + + Deux endroits bâtissent la base intermédiaire : l'étape « Uninstall + module », et « Choose delete missing module » qui la jette et la + refait depuis la version précédente. Le second ne rejouait que les + modules choisis là. Mesuré sur test_neutralize_upgrade_18 : + web_responsive retiré au rang 218, clone refait au rang 230, et il + était revenu — la 18 a refusé de charger sur l'exclusion de + muk_web_theme, alors que la désinstallation avait réussi. + """ + + def source(self): + with io.open( + os.path.join(RACINE, "script", "todo", "todo_upgrade.py"), + encoding="utf-8", + ) as handle: + return handle.read() + + def fonction(self): + import ast + + for noeud in ast.walk(ast.parse(self.source())): + if ( + isinstance(noeud, ast.FunctionDef) + and noeud.name == "execute_odoo_upgrade" + ): + return noeud + return None + + @staticmethod + def _appelle(noeud, methode): + return ( + isinstance(noeud, ast.Call) + and isinstance(noeud.func, ast.Attribute) + and noeud.func.attr == methode + ) + + def appels_uninstall(self): + import ast + + return [ + n + for n in ast.walk(self.fonction()) + if self._appelle(n, "uninstall_from_database") + ] + + def noms_venant_de_la_liste(self): + """Les variables affectées depuis `uninstall_list_for(...)`.""" + import ast + + noms = set() + for n in ast.walk(self.fonction()): + if isinstance(n, ast.Assign) and self._appelle( + n.value, "uninstall_list_for" + ): + for cible in n.targets: + if isinstance(cible, ast.Name): + noms.add(cible.id) + return noms + + def test_there_is_more_than_one_place_that_uninstalls(self): + # Sans cette borne, le test suivant passerait le jour où un des + # deux chemins disparaît — ou n'existe plus sous cette forme. + self.assertGreaterEqual(len(self.appels_uninstall()), 2) + + def test_every_uninstall_goes_through_the_shared_list(self): + import ast + + noms = self.noms_venant_de_la_liste() + self.assertTrue(noms, "aucune variable ne vient de uninstall_list_for") + for appel in self.appels_uninstall(): + premier = appel.args[0] if appel.args else None + direct = self._appelle(premier, "uninstall_list_for") + indirect = isinstance(premier, ast.Name) and premier.id in noms + self.assertTrue( + direct or indirect, + "un chemin désinstalle sans rejouer la liste du palier :" + f" ligne {appel.lineno}", + ) + + +class TestUninstallListFor(unittest.TestCase): + class Faux(TodoUpgrade): + def __init__(self, fichier=()): + self.fichier = list(fichier) + self.affiche = [] + + def read_uninstall_module_list(self, depart, database_name): + self.depart = depart + return list(self.fichier), [ + (nom, "raison", "f") for nom in self.fichier + ] + + def print_uninstall_reason(self, detail): + self.affiche.append(detail) + + def test_it_reads_the_file_of_the_step_being_left(self): + # Monter vers 18 lit le fichier 17 → 18, pas 18 → 19. + faux = self.Faux(["web_responsive"]) + faux.uninstall_list_for(18, "une_base") + self.assertEqual(faux.depart, 17) + + def test_the_chosen_modules_come_first(self): + # L'ordre compte : ce que la personne vient de choisir se lit en + # tête de la ligne de commande qui suit. + faux = self.Faux(["duFichier"]) + self.assertEqual( + faux.uninstall_list_for(18, "b", ["choisi"]), + ["choisi", "duFichier"], + ) + + def test_a_module_named_twice_is_uninstalled_once(self): + faux = self.Faux(["commun"]) + self.assertEqual( + faux.uninstall_list_for(18, "b", ["commun", "commun"]), + ["commun"], + ) + + def test_it_says_why_each_one_goes(self): + faux = self.Faux(["web_responsive"]) + faux.uninstall_list_for(18, "b") + self.assertTrue(faux.affiche) + + def test_it_stays_quiet_when_the_step_has_no_list(self): + faux = self.Faux([]) + self.assertEqual( + faux.uninstall_list_for(18, "b", ["choisi"]), ["choisi"] + ) + self.assertEqual(faux.affiche, []) + + if __name__ == "__main__": unittest.main() From 01b77dfa80843b17c5e71422517cd3e5720bfcbc Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sun, 23 Aug 2026 05:54:38 -0400 Subject: [PATCH 15/16] =?UTF-8?q?[ADD]=20proxmox=20:=20d=C3=A9ployer=20des?= =?UTF-8?q?=20VM=20sur=20un=20h=C3=B4te=20Proxmox=20distant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nouvelle entrée sous QEMU/KVM, avec l'équivalent de ses dix-sept commandes. Toute la différence tient en une phrase : l'hyperviseur est ailleurs. On choisit donc l'hôte — VM QEMU locale, adresse, ou ~/.ssh/config — et on le vérifie : pveversion le prouve, id/sudo décident du privilège, et une clé d'hôte inconnue s'enregistre par ssh-keyscan plutôt qu'en désactivant le contrôle. Quatre pièges trouvés sur un hôte réel. Une Proxmox installée sur Debian n'a aucun pont : on en propose un INTERNE, car ajouter l'interface physique déplace l'adresse de l'hôte et coupe la session — à distance, sans retour. --- EN --- A new entry under QEMU/KVM, with the counterpart of its seventeen commands. The whole difference fits in one sentence: the hypervisor is elsewhere. So the host is chosen — local QEMU VM, address, or ~/.ssh/config — and then checked: pveversion proves it, id/sudo decide about privilege, and an unknown host key is recorded with ssh-keyscan rather than by disabling the check. Four traps found on a real host. A Proxmox installed on Debian has no bridge: we offer an INTERNAL one, because adding the physical NIC moves the host address and cuts the session — remotely, with no way back. Assisted-by: Claude Opus 5 --- script/proxmox/README.base.md | 188 ++++++ script/proxmox/README.fr.md | 92 +++ script/proxmox/README.md | 91 +++ script/proxmox/install_proxmox.sh | 25 +- script/proxmox/proxmox_deploy.py | 517 ++++++++++++++++ script/todo/todo.py | 884 ++++++++++++++++++++++++++++ script/todo/todo_i18n.py | 315 ++++++++++ test/test_mobile_bundle_transfer.py | 18 +- test/test_proxmox_deploy.py | 408 +++++++++++++ 9 files changed, 2526 insertions(+), 12 deletions(-) create mode 100644 script/proxmox/README.base.md create mode 100644 script/proxmox/README.fr.md create mode 100644 script/proxmox/README.md create mode 100644 script/proxmox/proxmox_deploy.py create mode 100644 test/test_proxmox_deploy.py diff --git a/script/proxmox/README.base.md b/script/proxmox/README.base.md new file mode 100644 index 0000000..5c3a871 --- /dev/null +++ b/script/proxmox/README.base.md @@ -0,0 +1,188 @@ + + + + + + +# Deploying VMs on a Proxmox VE host + + +# Déployer des VM sur un hôte Proxmox VE + + +Two different things live in this directory: + +- `install_proxmox.sh` turns a Debian into a Proxmox hypervisor. See + `script/qemu/README.md`, which documents the `proxmox` distro of the + deployment catalog. +- `proxmox_deploy.py` deploys VMs **on** such a host, from + `TODO › Execute › Deploy › Proxmox VE`, right under `QEMU/KVM`. + + +Deux choses différentes vivent dans ce répertoire : + +- `install_proxmox.sh` transforme une Debian en hyperviseur Proxmox. Voir + `script/qemu/README.fr.md`, qui documente la distro `proxmox` du catalogue + de déploiement. +- `proxmox_deploy.py` déploie des VM **sur** un tel hôte, depuis + `TODO › Execute › Deploy › Proxmox VE`, juste sous `QEMU/KVM`. + + +## The whole difference: the hypervisor is elsewhere + +With QEMU/KVM, the hypervisor is the machine running the script. With Proxmox +it is somewhere else, so the first question is **which host** — and the answer +is remembered for the session. Three ways, all offered by the menu: + +1. **From the local QEMU VMs** — a `proxmox` VM deployed here. Its address + comes from the DHCP lease, nothing to retype. +2. **By address** — `user@host`, plus an optional SSH jump. +3. **From `~/.ssh/config`** — the alias already carries user, port and + ProxyJump; nothing else is asked. + + +## Toute la différence : l'hyperviseur est ailleurs + +Avec QEMU/KVM, l'hyperviseur est la machine qui exécute le script. Avec +Proxmox il est ailleurs : la première question est donc **quel hôte** — et la +réponse est retenue pour la session. Trois voies, toutes proposées par le menu : + +1. **Depuis les VM QEMU locales** — une VM `proxmox` déployée ici. Son adresse + vient du bail DHCP, rien à retaper. +2. **Par adresse** — `utilisateur@hôte`, plus un rebond SSH facultatif. +3. **Depuis `~/.ssh/config`** — l'alias porte déjà l'utilisateur, le port et le + ProxyJump ; on ne demande rien d'autre. + + +The chosen host is then **checked**, not assumed: `pveversion` proves it is a +Proxmox, `id -u` and `sudo -n true` decide whether commands need `sudo`, and an +unknown SSH host key is offered for recording (with `ssh-keyscan`, never by +disabling the check — a hypervisor is not a throwaway VM). + + +L'hôte choisi est ensuite **vérifié**, pas supposé : `pveversion` prouve que +c'en est un, `id -u` et `sudo -n true` décident s'il faut `sudo`, et une clé +d'hôte inconnue est proposée à l'enregistrement (par `ssh-keyscan`, jamais en +désactivant la vérification — un hyperviseur n'est pas une VM jetable). + + +```bash +# Ce que l'outil envoie, et qu'on peut rejouer à la main : +ssh erplibre@pve1 sudo sh -c 'qm list' +ssh erplibre@pve1 sudo sh -c 'pvesm status --content images' +``` + + +## Why SSH and `qm`, not the REST API + +The API needs a token or a ticket to create and renew. `qm` is the path every +Proxmox administrator knows, the repository already manages SSH access +(`~/.ssh/config`, ProxyJump, keys), and the commands stay readable in the log — +so they can be replayed by hand. That is how every failure of this module was +diagnosed. + +`sudo sh -c ''` and not `sudo `: these commands are +sequences and redirections. Prefixing with sudo would elevate only the first +word, and the redirection would still be the unprivileged shell's. + + +## Pourquoi SSH et `qm`, pas l'API REST + +L'API demande un jeton ou un ticket à créer et à renouveler. `qm` est la voie +que tout administrateur Proxmox connaît, le dépôt sait déjà gérer des accès SSH +(`~/.ssh/config`, ProxyJump, clés), et les commandes restent lisibles dans le +journal — donc rejouables à la main. C'est ainsi que chaque panne de ce module +a été diagnostiquée. + +`sudo sh -c ''` et non `sudo ` : ces commandes sont +des suites et des redirections. Préfixer par sudo n'élèverait que le premier +mot, et la redirection resterait celle du shell non privilégié. + + +## Four traps met on a real host + +A Proxmox installed **on Debian** has no `vmbr0` — the ISO installer creates +one, that procedure does not. And `qm create` requires a bridge. + +The menu offers an **internal** bridge (`vmbr0`, `10.10.10.1/24`, NAT through +the uplink). Never adding the physical NIC to a bridge is deliberate: that +moves the host address and cuts the SSH session in progress — remotely, there +is no way back. A LAN-facing bridge is printed as a stanza to apply from a +console. + +On an internal bridge no DHCP answers, so the address is **static**, derived +from the VMID — and therefore known before the VM boots. Looking for it +afterwards was absurd. + +The Debian cloud image does not ship `qemu-guest-agent`, so Proxmox cannot tell +the address of a DHCP guest: it does not hand out the leases. The fallback is +the host's own neighbour table (`ip neigh`), which needs nothing from the guest. + + +## Quatre pièges rencontrés sur un hôte réel + +Une Proxmox installée **sur Debian** n'a aucun `vmbr0` — l'installateur ISO en +crée un, cette procédure non. Or `qm create` exige un pont. + +Le menu propose alors un pont **interne** (`vmbr0`, `10.10.10.1/24`, NAT par la +sortie). Ne jamais ajouter l'interface physique à un pont est un choix : cela +déplace l'adresse de l'hôte et coupe la session SSH en cours — à distance, sans +retour. Pour un pont donnant sur le LAN, la strophe est affichée, à appliquer +depuis une console. + +Sur un pont interne, aucun DHCP ne répond : l'adresse est donc **fixe**, dérivée +du VMID — donc connue avant que la VM ne démarre. La chercher ensuite était +absurde. + +L'image cloud Debian n'embarque pas `qemu-guest-agent`, et Proxmox ne connaît +pas l'adresse d'un invité en DHCP : il ne distribue pas les baux. Le repli est +le voisinage de l'hôte (`ip neigh`), qui ne demande rien à l'invité. + + +## The menu, entry by entry + +The seventeen QEMU/KVM entries have their counterpart. Four of them are the +**same code**, because it is the same work: reopening the install monitoring, +the remote desktop tunnel, the Android emulator and the image catalog. They +reach Proxmox guests through the `~/.ssh/config` entries that entry 13 writes, +with the Proxmox host as ProxyJump. + + +## Le menu, entrée par entrée + +Les dix-sept entrées de QEMU/KVM ont leur équivalent. Quatre sont le **même +code**, parce que c'est le même travail : rouvrir le suivi d'installation, le +tunnel bureau distant, l'émulateur Android et le catalogue d'images. Elles +atteignent les invités Proxmox par les entrées `~/.ssh/config` que l'entrée 13 +écrit, avec l'hôte Proxmox en ProxyJump. + + +```text +[1] Déployer une VM [8] Redimensionner un disque [15] Émulateur Android * +[2] Prévisualiser [9] Effacer des VM [16] Catalogue d'images * +[3] Télécharger une image [10] Nettoyer (orphelins) [17] Exemple (dry-run) +[4] Rouvrir le suivi * [11] Tester une VM (Odoo) [18] Changer d'hôte +[5] Lister (qm list) [12] Statistiques +[6] Adresse IP d'une VM [13] Configuration SSH +[7] Console d'une VM [14] Tunnel bureau distant * + * code partagé avec le menu QEMU/KVM +``` + + +## Verified + +Deploying a VM inside a Proxmox that itself runs in a libvirt VM: image +downloaded on the host, internal bridge created, static address, cloud-init +user and key, disk resized, `qm start`. Then `ssh vm-essai` from the outside +reaches it through the jump — three nested levels. Resize `12G → 16G`, delete +with `--purge`, orphan scan: all checked against Proxmox VE 9.2.11. + + +## Vérifié + +Déploiement d'une VM dans une Proxmox qui tourne elle-même dans une VM +libvirt : image téléchargée sur l'hôte, pont interne créé, adresse fixe, +utilisateur et clé par cloud-init, disque redimensionné, `qm start`. Puis +`ssh vm-essai` depuis l'extérieur l'atteint par le rebond — trois niveaux +imbriqués. Redimensionnement `12G → 16G`, effacement avec `--purge`, recherche +d'orphelins : tout contrôlé contre Proxmox VE 9.2.11. diff --git a/script/proxmox/README.fr.md b/script/proxmox/README.fr.md new file mode 100644 index 0000000..465347e --- /dev/null +++ b/script/proxmox/README.fr.md @@ -0,0 +1,92 @@ + +# Déployer des VM sur un hôte Proxmox VE + +Deux choses différentes vivent dans ce répertoire : + +- `install_proxmox.sh` transforme une Debian en hyperviseur Proxmox. Voir + `script/qemu/README.fr.md`, qui documente la distro `proxmox` du catalogue + de déploiement. +- `proxmox_deploy.py` déploie des VM **sur** un tel hôte, depuis + `TODO › Execute › Deploy › Proxmox VE`, juste sous `QEMU/KVM`. + +## Toute la différence : l'hyperviseur est ailleurs + +Avec QEMU/KVM, l'hyperviseur est la machine qui exécute le script. Avec +Proxmox il est ailleurs : la première question est donc **quel hôte** — et la +réponse est retenue pour la session. Trois voies, toutes proposées par le menu : + +1. **Depuis les VM QEMU locales** — une VM `proxmox` déployée ici. Son adresse + vient du bail DHCP, rien à retaper. +2. **Par adresse** — `utilisateur@hôte`, plus un rebond SSH facultatif. +3. **Depuis `~/.ssh/config`** — l'alias porte déjà l'utilisateur, le port et le + ProxyJump ; on ne demande rien d'autre. + +L'hôte choisi est ensuite **vérifié**, pas supposé : `pveversion` prouve que +c'en est un, `id -u` et `sudo -n true` décident s'il faut `sudo`, et une clé +d'hôte inconnue est proposée à l'enregistrement (par `ssh-keyscan`, jamais en +désactivant la vérification — un hyperviseur n'est pas une VM jetable). + +```bash +# Ce que l'outil envoie, et qu'on peut rejouer à la main : +ssh erplibre@pve1 sudo sh -c 'qm list' +ssh erplibre@pve1 sudo sh -c 'pvesm status --content images' +``` + +## Pourquoi SSH et `qm`, pas l'API REST + +L'API demande un jeton ou un ticket à créer et à renouveler. `qm` est la voie +que tout administrateur Proxmox connaît, le dépôt sait déjà gérer des accès SSH +(`~/.ssh/config`, ProxyJump, clés), et les commandes restent lisibles dans le +journal — donc rejouables à la main. C'est ainsi que chaque panne de ce module +a été diagnostiquée. + +`sudo sh -c ''` et non `sudo ` : ces commandes sont +des suites et des redirections. Préfixer par sudo n'élèverait que le premier +mot, et la redirection resterait celle du shell non privilégié. + +## Quatre pièges rencontrés sur un hôte réel + +Une Proxmox installée **sur Debian** n'a aucun `vmbr0` — l'installateur ISO en +crée un, cette procédure non. Or `qm create` exige un pont. + +Le menu propose alors un pont **interne** (`vmbr0`, `10.10.10.1/24`, NAT par la +sortie). Ne jamais ajouter l'interface physique à un pont est un choix : cela +déplace l'adresse de l'hôte et coupe la session SSH en cours — à distance, sans +retour. Pour un pont donnant sur le LAN, la strophe est affichée, à appliquer +depuis une console. + +Sur un pont interne, aucun DHCP ne répond : l'adresse est donc **fixe**, dérivée +du VMID — donc connue avant que la VM ne démarre. La chercher ensuite était +absurde. + +L'image cloud Debian n'embarque pas `qemu-guest-agent`, et Proxmox ne connaît +pas l'adresse d'un invité en DHCP : il ne distribue pas les baux. Le repli est +le voisinage de l'hôte (`ip neigh`), qui ne demande rien à l'invité. + +## Le menu, entrée par entrée + +Les dix-sept entrées de QEMU/KVM ont leur équivalent. Quatre sont le **même +code**, parce que c'est le même travail : rouvrir le suivi d'installation, le +tunnel bureau distant, l'émulateur Android et le catalogue d'images. Elles +atteignent les invités Proxmox par les entrées `~/.ssh/config` que l'entrée 13 +écrit, avec l'hôte Proxmox en ProxyJump. + +```text +[1] Déployer une VM [8] Redimensionner un disque [15] Émulateur Android * +[2] Prévisualiser [9] Effacer des VM [16] Catalogue d'images * +[3] Télécharger une image [10] Nettoyer (orphelins) [17] Exemple (dry-run) +[4] Rouvrir le suivi * [11] Tester une VM (Odoo) [18] Changer d'hôte +[5] Lister (qm list) [12] Statistiques +[6] Adresse IP d'une VM [13] Configuration SSH +[7] Console d'une VM [14] Tunnel bureau distant * + * code partagé avec le menu QEMU/KVM +``` + +## Vérifié + +Déploiement d'une VM dans une Proxmox qui tourne elle-même dans une VM +libvirt : image téléchargée sur l'hôte, pont interne créé, adresse fixe, +utilisateur et clé par cloud-init, disque redimensionné, `qm start`. Puis +`ssh vm-essai` depuis l'extérieur l'atteint par le rebond — trois niveaux +imbriqués. Redimensionnement `12G → 16G`, effacement avec `--purge`, recherche +d'orphelins : tout contrôlé contre Proxmox VE 9.2.11. \ No newline at end of file diff --git a/script/proxmox/README.md b/script/proxmox/README.md new file mode 100644 index 0000000..aecfbc6 --- /dev/null +++ b/script/proxmox/README.md @@ -0,0 +1,91 @@ + +# Deploying VMs on a Proxmox VE host + +Two different things live in this directory: + +- `install_proxmox.sh` turns a Debian into a Proxmox hypervisor. See + `script/qemu/README.md`, which documents the `proxmox` distro of the + deployment catalog. +- `proxmox_deploy.py` deploys VMs **on** such a host, from + `TODO › Execute › Deploy › Proxmox VE`, right under `QEMU/KVM`. + +## The whole difference: the hypervisor is elsewhere + +With QEMU/KVM, the hypervisor is the machine running the script. With Proxmox +it is somewhere else, so the first question is **which host** — and the answer +is remembered for the session. Three ways, all offered by the menu: + +1. **From the local QEMU VMs** — a `proxmox` VM deployed here. Its address + comes from the DHCP lease, nothing to retype. +2. **By address** — `user@host`, plus an optional SSH jump. +3. **From `~/.ssh/config`** — the alias already carries user, port and + ProxyJump; nothing else is asked. + +The chosen host is then **checked**, not assumed: `pveversion` proves it is a +Proxmox, `id -u` and `sudo -n true` decide whether commands need `sudo`, and an +unknown SSH host key is offered for recording (with `ssh-keyscan`, never by +disabling the check — a hypervisor is not a throwaway VM). + +```bash +# Ce que l'outil envoie, et qu'on peut rejouer à la main : +ssh erplibre@pve1 sudo sh -c 'qm list' +ssh erplibre@pve1 sudo sh -c 'pvesm status --content images' +``` + +## Why SSH and `qm`, not the REST API + +The API needs a token or a ticket to create and renew. `qm` is the path every +Proxmox administrator knows, the repository already manages SSH access +(`~/.ssh/config`, ProxyJump, keys), and the commands stay readable in the log — +so they can be replayed by hand. That is how every failure of this module was +diagnosed. + +`sudo sh -c ''` and not `sudo `: these commands are +sequences and redirections. Prefixing with sudo would elevate only the first +word, and the redirection would still be the unprivileged shell's. + +## Four traps met on a real host + +A Proxmox installed **on Debian** has no `vmbr0` — the ISO installer creates +one, that procedure does not. And `qm create` requires a bridge. + +The menu offers an **internal** bridge (`vmbr0`, `10.10.10.1/24`, NAT through +the uplink). Never adding the physical NIC to a bridge is deliberate: that +moves the host address and cuts the SSH session in progress — remotely, there +is no way back. A LAN-facing bridge is printed as a stanza to apply from a +console. + +On an internal bridge no DHCP answers, so the address is **static**, derived +from the VMID — and therefore known before the VM boots. Looking for it +afterwards was absurd. + +The Debian cloud image does not ship `qemu-guest-agent`, so Proxmox cannot tell +the address of a DHCP guest: it does not hand out the leases. The fallback is +the host's own neighbour table (`ip neigh`), which needs nothing from the guest. + +## The menu, entry by entry + +The seventeen QEMU/KVM entries have their counterpart. Four of them are the +**same code**, because it is the same work: reopening the install monitoring, +the remote desktop tunnel, the Android emulator and the image catalog. They +reach Proxmox guests through the `~/.ssh/config` entries that entry 13 writes, +with the Proxmox host as ProxyJump. + +```text +[1] Déployer une VM [8] Redimensionner un disque [15] Émulateur Android * +[2] Prévisualiser [9] Effacer des VM [16] Catalogue d'images * +[3] Télécharger une image [10] Nettoyer (orphelins) [17] Exemple (dry-run) +[4] Rouvrir le suivi * [11] Tester une VM (Odoo) [18] Changer d'hôte +[5] Lister (qm list) [12] Statistiques +[6] Adresse IP d'une VM [13] Configuration SSH +[7] Console d'une VM [14] Tunnel bureau distant * + * code partagé avec le menu QEMU/KVM +``` + +## Verified + +Deploying a VM inside a Proxmox that itself runs in a libvirt VM: image +downloaded on the host, internal bridge created, static address, cloud-init +user and key, disk resized, `qm start`. Then `ssh vm-essai` from the outside +reaches it through the jump — three nested levels. Resize `12G → 16G`, delete +with `--purge`, orphan scan: all checked against Proxmox VE 9.2.11. diff --git a/script/proxmox/install_proxmox.sh b/script/proxmox/install_proxmox.sh index 9c6cf5e..4bcbd7a 100755 --- a/script/proxmox/install_proxmox.sh +++ b/script/proxmox/install_proxmox.sh @@ -426,17 +426,20 @@ cleanup() { fix_efi_fallback() { local esp="${PVE_ESP:-/boot/efi}" local secours="${esp}/EFI/BOOT" - [ -d "${secours}" ] || return 0 - [ -e "${secours}/grub.cfg" ] && return 0 - local stub="" candidat="" - for candidat in "${esp}"/EFI/*/grub.cfg; do - [ -e "${candidat}" ] || continue - case "${candidat}" in - */EFI/BOOT/grub.cfg) continue ;; - esac - stub="${candidat}" - break - done + # sudo sur CHAQUE lecture. /boot/efi est une vfat montée « umask=077 » : + # root seul y entre, et un « [ -d ] » non privilégié y répond FAUX. Ce + # correctif ne faisait donc RIEN, en silence, et la VM retombait sur + # « grub> » au redémarrage suivant — vécu deux fois. Le glob du shell est + # aveugle pour la même raison : il faut énumérer avec sudo. + sudo test -d "${secours}" || return 0 + sudo test -e "${secours}/grub.cfg" && return 0 + local stub="" + # « || true » : quand aucun stub n'existe, grep ne trouve rien et rend 1 + # — avec « set -o pipefail », l'affectation échoue et le script s'arrête + # AVANT d'avoir dit ce qui manque. Attrapé par un test, pas sur la machine + # réelle, où un stub existait et masquait le cas. + stub="$(sudo sh -c "ls ${esp}/EFI/*/grub.cfg 2>/dev/null" \ + | grep -v '/EFI/BOOT/grub.cfg' | head -1 || true)" if [ -z "${stub}" ]; then say "${Yellow}⚠${Color_Off} aucun grub.cfg à recopier sous ${esp} :" \ "vérifier l'amorçage avant de redémarrer." diff --git a/script/proxmox/proxmox_deploy.py b/script/proxmox/proxmox_deploy.py new file mode 100644 index 0000000..7074579 --- /dev/null +++ b/script/proxmox/proxmox_deploy.py @@ -0,0 +1,517 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Déploiement de VM SUR un hôte Proxmox VE, piloté à distance par SSH. + +Différence de nature avec `script/qemu/deploy_qemu.py` : là-bas, l'hyperviseur +est la machine qui exécute le script. Ici, il est AILLEURS — « on n'exécute pas +dessus ». Tout ce que ce module produit part donc sur l'hôte choisi, et rien +n'exige de privilège local. + +Pourquoi SSH et `qm` plutôt que l'API REST : l'API demande un jeton ou un +ticket à créer et à renouveler, quand `qm` est la voie que tout administrateur +Proxmox connaît, et que le dépôt sait déjà gérer des accès SSH (~/.ssh/config, +ProxyJump, clés). Les commandes restent lisibles dans le journal, donc +rejouables à la main — c'est ce qui a permis de diagnostiquer chaque panne de +ce module. + +Découpage voulu : TOUT ce qui construit une commande ou lit une sortie est une +fonction PURE, vérifiable sans hôte Proxmox. Seul `run()` parle au réseau. +""" +from __future__ import annotations + +import json +import re +import shlex +import subprocess + +# Réglages par défaut d'une VM Proxmox. Chacun a sa raison : +# +# - virtio-scsi-single : le contrôleur que Proxmox recommande depuis PVE 7, et +# le seul qui donne l'iothread par disque. +# - agent enabled=1 : sans l'agent invité, « qm guest cmd » ne rend aucune +# adresse IP et le menu ne peut pas dire où joindre la VM. +# - serial0 socket + vga serial0 : c'est ce qui rend « qm terminal » utilisable. +# Une console graphique seule obligerait à passer par l'interface web. +# - ostype l26 : Linux 2.6+, ce qui règle les horloges et les pilotes. +DEFAULT_BRIDGE = "vmbr0" +DEFAULT_STORAGE = "" # vide = on choisit d'après « pvesm status » +IMAGE_DIR = "/var/lib/vz/template/iso" +VMID_MIN = 100 + +# Stockages qui savent héberger un disque de VM. « pvesm status » liste aussi +# des stockages de sauvegarde ou d'ISO, où un disque ne peut PAS aller : les +# proposer produirait un « qm set » refusé après le téléchargement de l'image. +DISK_CONTENT = ("images", "rootdir") + + +def ssh_argv(host: dict, remote: str, tty: bool = False) -> list: + """Commande ssh complète pour exécuter `remote` sur l'hôte Proxmox. + + `host` : {"target": "root@10.0.0.5", "jump": "rebond", "port": "22"} — + « target » suffit quand l'alias vient de ~/.ssh/config, qui porte déjà + l'utilisateur, le port et le ProxyJump. + """ + argv = ["ssh"] + if not tty: + argv += ["-o", "BatchMode=yes"] + argv += ["-o", "ConnectTimeout=10"] + if host.get("port"): + argv += ["-p", str(host["port"])] + if host.get("jump"): + argv += ["-J", host["jump"]] + if tty: + argv.append("-t") + argv += [host["target"], remote] + return argv + + +def wrap_privilege(remote: str, prefix: str) -> str: + """Enveloppe la commande pour qu'elle tourne en root, si nécessaire. + + « sudo sh -c '' » et non « sudo » : les commandes de ce module + sont des SUITES (« mkdir && if … fi », une boucle for, une redirection). + Préfixer par sudo n'élèverait que le premier mot, et la redirection + resterait celle du shell non privilégié — donc « permission denied » sur + /root ou /boot/efi. + """ + if not prefix: + return remote + return "sudo sh -c " + shlex.quote(remote) + + +def run(host: dict, remote: str, timeout: int = 120) -> tuple: + """(code, sortie) de `remote` exécuté sur l'hôte. Ne lève jamais. + + `host["sudo"]` non vide -> la commande passe par sudo : « qm » exige les + privilèges, et l'accès offert par une VM du parc est celui d'`erplibre`. + """ + remote = wrap_privilege(remote, host.get("sudo") or "") + try: + res = subprocess.run( + ssh_argv(host, remote), + capture_output=True, + text=True, + timeout=timeout, + ) + except subprocess.TimeoutExpired: + return 255, "timeout" + except (OSError, subprocess.SubprocessError) as exc: + return 255, str(exc) + return res.returncode, (res.stdout or "") + (res.stderr or "") + + +# --------------------------------------------------------------------------- # +# Lecture des sorties de l'hôte — fonctions pures +# --------------------------------------------------------------------------- # +def parse_pveversion(text: str) -> str: + """« pve-manager/9.2.11/f6997e69 (running kernel: 7.0.14-12-pve) » -> 9.2.11. + + Sert de PREUVE que l'hôte est bien un Proxmox : une adresse saisie à la + main peut être n'importe quoi, et la première commande `qm` échouerait + alors sur un message qui ne dit pas pourquoi. + """ + m = re.search(r"pve-manager/(\d[\w.]*)", text or "") + return m.group(1) if m else "" + + +def parse_qm_list(text: str) -> list: + """Sortie de « qm list » -> [{vmid, name, status, mem, disk}]. + + L'en-tête et les lignes vides sont écartés. Les colonnes sont séparées par + des espaces, mais un NOM peut en contenir : on découpe donc par la + GAUCHE (vmid) et par la DROITE (status, mem, bootdisk, pid), et ce qui + reste au milieu est le nom. + """ + out = [] + for ligne in (text or "").splitlines(): + parts = ligne.split() + if len(parts) < 6 or not parts[0].isdigit(): + continue + vmid = parts[0] + pid = parts[-1] + bootdisk = parts[-2] + mem = parts[-3] + status = parts[-4] + nom = " ".join(parts[1:-4]) + out.append( + { + "vmid": int(vmid), + "name": nom, + "status": status, + "mem": mem, + "disk": bootdisk, + "pid": pid, + } + ) + return out + + +def parse_storages(text: str) -> list: + """Sortie de « pvesm status --content images » -> [{name, type, avail}].""" + out = [] + for ligne in (text or "").splitlines(): + parts = ligne.split() + if len(parts) < 6 or parts[0] == "Name": + continue + try: + avail = int(parts[5]) + except ValueError: + continue + out.append( + { + "name": parts[0], + "type": parts[1], + "actif": parts[2] == "active", + "avail": avail * 1024, # pvesm compte en Kio + } + ) + return out + + +def parse_bridges(text: str) -> list: + """Sortie de « ip -o link show type bridge » -> ['vmbr0', …].""" + ponts = [] + for ligne in (text or "").splitlines(): + parts = ligne.split(":") + if len(parts) > 1: + nom = parts[1].strip().split("@")[0] + if nom: + ponts.append(nom) + return ponts + + +def parse_guest_ips(text: str) -> list: + """Adresses IPv4 rendues par « qm guest cmd network-get-interfaces ». + + L'agent invité répond du JSON. Les adresses de bouclage sont écartées : la + question posée est « où joindre cette VM », et 127.0.0.1 n'y répond pas. + """ + try: + data = json.loads(text or "") + except (ValueError, TypeError): + return [] + ips = [] + for iface in data if isinstance(data, list) else []: + for addr in iface.get("ip-addresses") or []: + ip = addr.get("ip-address") or "" + if addr.get("ip-address-type") == "ipv4" and not ip.startswith( + "127." + ): + ips.append(ip) + return ips + + +def mac_from_config(text: str) -> str: + """MAC de net0 dans « qm config ». + + C'est le seul lien entre une VM Proxmox et son adresse IP quand l'agent + invité n'est pas là : l'image cloud Debian ne l'embarque PAS, et Proxmox ne + distribue pas les baux lui-même — il ne peut donc pas répondre. + """ + m = re.search( + r"^net0:.*?([0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){5})", + text or "", + re.M, + ) + return m.group(1).lower() if m else "" + + +def ip_from_neigh(text: str, mac: str) -> str: + """Adresse vue par le voisinage de l'hôte (« ip neigh »), pour cette MAC. + + Marche dès que la VM a émis un paquet — un bail DHCP suffit. C'est le + repli quand l'agent invité manque, et il ne demande rien à l'invité. + """ + if not mac: + return "" + cible = mac.lower() + for ligne in (text or "").splitlines(): + if cible in ligne.lower(): + parts = ligne.split() + if parts and re.match(r"^\d+\.\d+\.\d+\.\d+$", parts[0]): + return parts[0] + return "" + + +def next_vmid(existing, mini: int = VMID_MIN) -> int: + """Premier VMID libre à partir de `mini`. + + Proxmox refuse un VMID déjà pris, et le message (« CT/VM 100 already + exists ») arrive APRÈS le téléchargement de l'image : on choisit donc + avant, d'après ce que l'hôte déclare. + """ + pris = {int(v["vmid"]) for v in existing or () if str(v["vmid"]).isdigit()} + vmid = max(mini, VMID_MIN) + while vmid in pris: + vmid += 1 + return vmid + + +def pick_storage(storages, voulu: str = "") -> str: + """Stockage où poser le disque : celui demandé, sinon le plus libre. + + Aucun repli sur un nom devinné (« local-lvm » n'existe pas partout) : sans + stockage utilisable, on rend une chaîne vide et l'appelant le dit. + """ + utiles = [s for s in storages or () if s.get("actif")] + if voulu: + return voulu if any(s["name"] == voulu for s in utiles) else "" + if not utiles: + return "" + return max(utiles, key=lambda s: s.get("avail") or 0)["name"] + + +def pick_bridge(bridges, voulu: str = "") -> str: + """Pont réseau : celui demandé, sinon vmbr0, sinon le premier déclaré.""" + ponts = list(bridges or ()) + if voulu: + return voulu if voulu in ponts else "" + if DEFAULT_BRIDGE in ponts: + return DEFAULT_BRIDGE + return ponts[0] if ponts else "" + + +# --------------------------------------------------------------------------- # +# Construction des commandes — fonctions pures +# --------------------------------------------------------------------------- # +# Réseau interne proposé quand l'hôte n'a AUCUN pont. Choisi pour être sûr : +# un pont sans port physique ne peut pas couper l'accès SSH à l'hôte, alors +# qu'ajouter « bridge-ports enp1s0 » déplace l'adresse et coupe la session en +# cours — sur une machine distante, c'est un aller sans retour. +INTERNAL_BRIDGE = "vmbr0" +INTERNAL_CIDR = "10.10.10.1/24" + + +def parse_bridge_config(text: str) -> dict: + """/etc/network/interfaces -> {pont: {ports, address}}. + + Sert à savoir si un pont donne sur le LAN (il a des ports) ou s'il est + interne (« bridge-ports none ») : les VM du premier prennent leur adresse + en DHCP, celles du second n'en auraient aucune et doivent recevoir une + adresse fixe. + """ + ponts = {} + courant = "" + for ligne in (text or "").splitlines(): + nu = ligne.strip() + m = re.match(r"^iface\s+(\S+)\s", nu) + if m: + courant = m.group(1) + continue + if not courant: + continue + if nu.startswith("bridge-ports") or nu.startswith("bridge_ports"): + ports = nu.split(None, 1)[1].strip() if " " in nu else "" + ponts.setdefault(courant, {})["ports"] = ( + "" if ports in ("none", "") else ports + ) + elif nu.startswith("address"): + ponts.setdefault(courant, {})["address"] = nu.split()[1] + return ponts + + +def bridge_setup_cmds( + nom: str = INTERNAL_BRIDGE, + cidr: str = INTERNAL_CIDR, + uplink: str = "", +) -> list: + """Crée un pont INTERNE, et le masque derrière l'uplink si demandé. + + « bridge-ports none » : aucune interface physique n'est touchée, donc + l'accès à l'hôte survit. Les lignes post-up/post-down de masquerading sont + celles que documente Proxmox pour un hôte à une seule adresse routée : sans + elles les VM se parlent entre elles mais ne sortent pas. + """ + reseau = cidr.rsplit(".", 1)[0] + ".0/" + cidr.split("/")[1] + bloc = [ + "", + f"auto {nom}", + f"iface {nom} inet static", + f" address {cidr}", + " bridge-ports none", + " bridge-stp off", + " bridge-fd 0", + ] + if uplink: + bloc += [ + f" post-up iptables -t nat -A POSTROUTING -s '{reseau}'" + f" -o {uplink} -j MASQUERADE", + f" post-down iptables -t nat -D POSTROUTING -s '{reseau}'" + f" -o {uplink} -j MASQUERADE", + ] + texte = "\n".join(bloc) + "\n" + cmds = [ + # Idempotent : on n'ajoute la strophe que si le pont n'y est pas déjà. + f"grep -qE '^(auto|iface) {nom}( |$)' /etc/network/interfaces" + f" || printf '%s' {shlex.quote(texte)} >> /etc/network/interfaces", + ] + if uplink: + cmds.append( + "printf 'net.ipv4.ip_forward=1\\n' >" + " /etc/sysctl.d/99-erplibre-nat.conf && sysctl -q -p" + " /etc/sysctl.d/99-erplibre-nat.conf" + ) + # ifup plutôt qu'« ifreload -a » : recharger TOUTE la configuration d'un + # hôte distant peut emporter l'interface qui porte la session. + cmds.append(f"ifup {nom} 2>/dev/null || ifreload -a") + return cmds + + +def ipconfig_for(pont_info: dict, vmid: int) -> str: + """« ip=dhcp » sur un pont qui donne sur le LAN, adresse FIXE sur un pont + interne — où aucun serveur DHCP ne répondrait. + + L'adresse est dérivée du VMID : deux VM déployées à la suite ne peuvent pas + se retrouver avec la même, et le lien entre les deux reste lisible. + """ + info = pont_info or {} + adresse = info.get("address") or "" + if info.get("ports") or not adresse: + return "ip=dhcp" + base, _, masque = adresse.partition("/") + tronc = base.rsplit(".", 1)[0] + hote = 50 + (int(vmid) % 200) + return f"ip={tronc}.{hote}/{masque or '24'},gw={base}" + + +def ip_from_ipconfig(ipconfig: str) -> str: + """Adresse fixe d'un « ip=10.10.10.150/24,gw=… », ou '' si c'est du DHCP. + + Quand c'est NOUS qui avons attribué l'adresse, la chercher ensuite est + absurde : elle est connue avant que la VM ne démarre. La découverte (agent + invité, voisinage de l'hôte) ne sert qu'au DHCP. + """ + m = re.search(r"ip=(\d+\.\d+\.\d+\.\d+)", ipconfig or "") + return m.group(1) if m else "" + + +def image_fetch_cmd(url: str, nom: str, repertoire: str = IMAGE_DIR) -> str: + """Télécharge l'image cloud SUR l'hôte Proxmox, une seule fois. + + C'est là que le disque de la VM sera écrit : faire descendre l'image chez + soi pour la renvoyer ensuite doublerait le transfert. Le test de présence + évite de retélécharger 325 Mio à chaque VM. + """ + cible = f"{repertoire}/{nom}" + return ( + f"mkdir -p {shlex.quote(repertoire)} && " + f"if [ -s {shlex.quote(cible)} ]; then " + f'echo "image déjà présente : {cible}"; else ' + f"wget -q --show-progress -O {shlex.quote(cible)} {shlex.quote(url)}; " + f"fi" + ) + + +def create_cmds(vmid: int, spec: dict) -> list: + """Séquence complète de création d'une VM, dans l'ordre. + + Une liste et non une seule commande : chaque étape est lisible dans le + journal, et un échec nomme celle qui a échoué. C'est le contraire d'un + « qm create » géant dont on ne sait pas quel morceau a cédé. + """ + nom = spec["name"] + stockage = spec["storage"] + image = f"{spec.get('image_dir', IMAGE_DIR)}/{spec['image']}" + cmds = [ + # 1. La coquille : processeur, mémoire, réseau, contrôleur, agent. + "qm create {id} --name {nom} --memory {mem} --cores {cpu}" + " --cpu host --ostype l26 --scsihw virtio-scsi-single" + " --net0 virtio,bridge={pont} --agent enabled=1" + " --serial0 socket --vga serial0".format( + id=vmid, + nom=shlex.quote(nom), + mem=int(spec["memory"]), + cpu=int(spec["vcpus"]), + pont=spec["bridge"], + ), + # 2. Le disque, importé DEPUIS l'image cloud. « import-from » (PVE 8+) + # remplace l'ancien « qm importdisk » en une seule étape et attache + # le disque du même coup. + f"qm set {vmid} --scsi0" + f" {stockage}:0,import-from={shlex.quote(image)},discard=on,ssd=1", + # 3. Le lecteur cloud-init, et l'ordre d'amorçage. Sans « boot order », + # Proxmox laisse le disque importé hors de la liste et la VM démarre + # sur le réseau. + f"qm set {vmid} --ide2 {stockage}:cloudinit" + f" --boot order=scsi0 --bootdisk scsi0", + ] + # 4. cloud-init : utilisateur, clé, réseau. La clé est un FICHIER sur + # l'hôte — « --sshkeys » n'accepte pas la clé en ligne. + ci = ( + f"qm set {vmid} --ciuser {shlex.quote(spec.get('user') or 'erplibre')}" + ) + if spec.get("sshkey_path"): + ci += f" --sshkeys {shlex.quote(spec['sshkey_path'])}" + if spec.get("password"): + ci += f" --cipassword {shlex.quote(spec['password'])}" + ci += f" --ipconfig0 {spec.get('ipconfig') or 'ip=dhcp'}" + cmds.append(ci) + # 5. La taille. L'image cloud fait 2 Gio : sans agrandissement, il ne reste + # rien pour installer quoi que ce soit. + if spec.get("disk"): + cmds.append(f"qm resize {vmid} scsi0 {spec['disk']}") + if spec.get("start", True): + cmds.append(f"qm start {vmid}") + return cmds + + +def destroy_cmds(vmid: int, purge: bool = True) -> list: + """Arrêt puis suppression. « --purge » retire aussi les disques et les + entrées de sauvegarde : sans lui, le stockage garde des volumes orphelins + que rien ne réclame plus.""" + return [ + f"qm stop {vmid} --skiplock 1 || true", + f"qm destroy {vmid} --purge {1 if purge else 0}" + " --destroy-unreferenced-disks 1", + ] + + +def resize_cmd(vmid: int, taille: str, disque: str = "scsi0") -> str: + """« +10G » agrandit, « 40G » fixe. Proxmox REFUSE de rétrécir un disque — + le dire ici évite de croire à un bug de l'outil.""" + return f"qm resize {vmid} {disque} {taille}" + + +def status_cmd(vmid: int) -> str: + return f"qm status {vmid} --verbose" + + +def guest_ip_cmd(vmid: int) -> str: + return f"qm guest cmd {vmid} network-get-interfaces" + + +def console_cmd(vmid: int) -> str: + """Console série. `qm terminal` demande serial0, que create_cmds pose.""" + return f"qm terminal {vmid}" + + +def orphan_disks_cmd() -> str: + """Volumes de disque qui n'appartiennent à aucune VM déclarée. + + Proxmox ne les efface pas tout seul : un « qm destroy » sans « --purge », + ou une création interrompue, en laisse. On les LISTE, on n'efface rien + sans demander. + """ + return ( + "for s in $(pvesm status --content images | awk 'NR>1 {print $1}'); " + 'do pvesm list "$s" 2>/dev/null; done' + ) + + +def parse_orphans(text: str, vmids) -> list: + """[(volid, taille)] des volumes dont le VMID n'existe plus.""" + connus = {str(v) for v in vmids or ()} + out = [] + for ligne in (text or "").splitlines(): + parts = ligne.split() + if len(parts) < 5 or parts[0] == "Volid": + continue + volid, vmid = parts[0], parts[-1] + if vmid.isdigit() and vmid not in connus: + try: + taille = int(parts[3]) + except ValueError: + taille = 0 + out.append((volid, taille)) + return out diff --git a/script/todo/todo.py b/script/todo/todo.py index ffaeba7..1be5ff9 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -942,6 +942,11 @@ class TODO: "QEMU/KVM - Deploy an Ubuntu VM (libvirt)" ) }, + { + "prompt_description": t( + "Proxmox VE - Deploy a VM on a remote host" + ) + }, { "prompt_description": t( "Deploy - Install NTFY notification server" @@ -966,6 +971,8 @@ class TODO: elif status == "5": self.prompt_execute_qemu() elif status == "6": + self.prompt_execute_proxmox() + elif status == "7": self._deploy_ntfy_server() else: print(t("Command not found !")) @@ -1021,6 +1028,756 @@ class TODO: # ------------------------------------------------------------------ # # QEMU / KVM (libvirt) VM deployment # ------------------------------------------------------------------ # + # ----------------------------------------------------------------- # + # Proxmox VE : l'hyperviseur est AILLEURS + # ----------------------------------------------------------------- # + # Toute la différence avec QEMU/KVM tient là : ici on n'exécute rien sur + # la machine locale. Il faut donc d'abord SAVOIR OÙ, et le retenir — sans + # quoi chacune des dix-sept commandes reposerait la question. + _PVE_PREF_KEY = "proxmox_host" + + def _pve_host(self, ask=True): + """Hôte Proxmox retenu, ou None. Demande au besoin. + + Mémorisé dans les préférences : le menu compte dix-sept entrées, et + redemander l'hôte à chacune serait insupportable. Le choix reste + affiché en tête du menu, et se change par son entrée dédiée. + """ + cache = getattr(self, "_pve_host_cache", None) + if cache: + return cache + garde = todo_prefs.get(self._PVE_PREF_KEY) or {} + if garde.get("target"): + self._pve_host_cache = garde + return garde + return self._pve_pick_host() if ask else None + + def _pve_forget_host(self): + self._pve_host_cache = None + todo_prefs.set(self._PVE_PREF_KEY, {}) + + def _pve_remember_host(self, host): + self._pve_host_cache = host + todo_prefs.set(self._PVE_PREF_KEY, host) + + @staticmethod + def _pve_label(host): + """« root@10.0.0.5 (par rebond) », pour l'afficher en tête de menu.""" + if not host: + return "" + lab = host.get("target", "?") + if host.get("jump"): + lab += f" ({t('through')} {host['jump']})" + if host.get("version"): + lab += f" — PVE {host['version']}" + return lab + + def _pve_pick_host(self): + """Choisit l'hôte Proxmox : VM locale, adresse, ou ~/.ssh/config.""" + print(f"\n{t('Which Proxmox host?')}") + print(f" [1] {t('From the local QEMU VMs')}") + print(f" [2] {t('Type an address')}") + print(f" [3] {t('From ~/.ssh/config')}") + actuel = todo_prefs.get(self._PVE_PREF_KEY) or {} + if actuel.get("target"): + print(f" [4] {t('Keep')} : {self._pve_label(actuel)}") + choix = input(t("Choice: ")).strip() + if choix == "4" and actuel.get("target"): + self._pve_host_cache = actuel + return actuel + if choix == "1": + host = self._pve_host_from_qemu() + elif choix == "3": + host = self._pve_host_from_ssh_config() + elif choix == "2": + host = self._pve_host_manual() + else: + print(t("Cancelled.")) + return None + if not host: + return None + return self._pve_confirm_host(host) + + def _pve_host_manual(self): + """Saisie libre. « root@ » par défaut : « qm » exige les privilèges.""" + brut = input(t("Address (user@host, default user root): ")).strip() + if not brut: + print(t("Cancelled.")) + return None + cible = brut if "@" in brut else f"root@{brut}" + jump = input(t("SSH jump host (blank = none): ")).strip() + return {"target": cible, "jump": jump} + + def _pve_host_from_qemu(self): + """Une VM Proxmox déployée ICI, prise dans la liste libvirt. + + C'est le cas du parc : on déploie une VM « proxmox » avec le menu + QEMU/KVM, puis on déploie DEDANS. L'IP est celle du bail DHCP, pas une + adresse à retaper. + """ + noms = self._qemu_list_domains() + if not noms: + print(f"\n{t('No VM found.')}") + return None + print(f"\n{t('Local VMs:')}") + ips = {} + for i, nom in enumerate(noms, 1): + ip = self._qemu_vm_ip_now(nom) or "" + ips[nom] = ip + etat = self._qemu_domstate(nom) + print(f" [{i}] {nom:<32} {ip or '-':<16} {etat}") + sel = input(t("Selection (number): ")).strip() + if not sel.isdigit() or not 1 <= int(sel) <= len(noms): + print(t("Invalid selection!")) + return None + nom = noms[int(sel) - 1] + ip = ips.get(nom) + if not ip: + print(f" ⚠ {t('No IP for this VM: is it running?')}") + return None + return {"target": f"root@{ip}", "jump": "", "vm": nom} + + def _pve_host_from_ssh_config(self): + """Un alias de ~/.ssh/config : il porte déjà utilisateur, port et + ProxyJump — rien à redemander, et le rebond traverse.""" + entrees = self._ssh_config_entries( + os.path.expanduser("~/.ssh/config") + ) + if not entrees: + print(f"\n{t('No SSH hosts found in ~/.ssh/config')}") + return None + print() + for i, (nom, info) in enumerate(entrees, 1): + hn = info.get("hostname", nom) + u = info.get("user", "") + desc = nom + (f" ({hn})" if hn != nom else "") + print(f" [{i}] {desc}{f' [{u}]' if u else ''}") + sel = input(t("Select SSH host number: ")).strip() + if not sel.isdigit() or not 1 <= int(sel) <= len(entrees): + print(t("Invalid selection!")) + return None + alias = entrees[int(sel) - 1][0] + # L'alias SEUL : ssh y lira l'utilisateur, le port et le ProxyJump. + return {"target": alias, "jump": ""} + + @staticmethod + def _pve_hostkey_missing(sortie): + """La sortie de ssh dénonce-t-elle une clé d'hôte inconnue ou changée ?""" + bas = (sortie or "").lower() + return ( + "host key verification failed" in bas + or "authenticity of host" in bas + or "no ed25519 host key is known" in bas + ) + + def _pve_add_hostkey(self, host): + """Enregistre la clé d'hôte, après accord explicite. + + ssh-keyscan et non « StrictHostKeyChecking=no » : la clé est écrite + UNE fois dans known_hosts, et toute substitution ultérieure sera + détectée. Désactiver la vérification l'aurait masquée pour toujours. + """ + cible = host["target"].split("@")[-1] + # Un alias de ~/.ssh/config n'est pas un nom de machine : ssh seul sait + # vers quoi il pointe. + resolu = self._ssh_resolve(host["target"]) + nom = resolu.get("hostname") or cible + port = resolu.get("port") or host.get("port") or "22" + print(f"\n ⚠ {t('SSH does not know this host key yet.')}") + print(f" {t('Would record:')} ssh-keyscan -p {port} {nom}") + if not self._is_yes(input(f" {t('Record it?')} (o/N) : ")): + return False + try: + res = subprocess.run( + ["ssh-keyscan", "-p", str(port), nom], + capture_output=True, + text=True, + timeout=30, + ) + except (OSError, subprocess.SubprocessError) as exc: + print(f" ✗ ssh-keyscan : {exc}") + return False + if res.returncode != 0 or not res.stdout.strip(): + print(f" ✗ {t('No host key obtained.')}") + return False + chemin = os.path.expanduser("~/.ssh/known_hosts") + os.makedirs(os.path.dirname(chemin), exist_ok=True) + with open(chemin, "a", encoding="utf-8") as fh: + fh.write(res.stdout if res.stdout.endswith("\n") else res.stdout + "\n") + lignes = len(res.stdout.strip().splitlines()) + print(f" ✓ {lignes} {t('key(s) recorded in ~/.ssh/known_hosts')}") + return True + + def _pve_confirm_host(self, host): + """Vérifie que c'en est un, et le retient. Sinon, dit ce qu'il a vu. + + « pveversion » est la preuve : une adresse saisie à la main peut être + n'importe quelle machine, et sans ce contrôle la première commande + « qm » échouerait sur un « command not found » qui n'explique rien. + """ + from script.proxmox import proxmox_deploy as pve + + print(f"\n {t('Checking')} {host['target']}…") + code, out = pve.run(host, "pveversion", timeout=30) + version = pve.parse_pveversion(out) + if not version and self._pve_hostkey_missing(out): + # Première connexion : ssh refuse un hôte dont il n'a pas la clé. + # On ne DÉSACTIVE pas la vérification — un hyperviseur n'est pas + # une VM jetable — on propose de l'enregistrer, une fois. + if self._pve_add_hostkey(host): + code, out = pve.run(host, "pveversion", timeout=30) + version = pve.parse_pveversion(out) + if not version: + print(f" ✗ {t('Not a Proxmox host (or unreachable):')}") + premiere = (out or "").strip().splitlines() + print(f" {premiere[0] if premiere else t('no answer')}") + print(f" → {t('Check the address, the SSH access and pveversion.')}") + return None + # « qm » exige les privilèges. La voie « VM QEMU locale » donne + # l'accès d'erplibre, pas de root : il faut donc sudo, et il faut le + # VÉRIFIER — un sudo qui réclame un mot de passe bloquerait chaque + # commande du menu sur une invite que personne ne voit. + prefixe = "" + _c, qui = pve.run(host, "id -u", timeout=20) + if qui.strip() != "0": + code, _o = pve.run(host, "sudo -n true", timeout=20) + if code: + print(f" ✗ {t('qm needs root: no root, and sudo asks for a password.')}") + print(f" → {t('Connect as root@, or allow NOPASSWD sudo.')}") + return None + prefixe = "sudo " + print(f" ✓ sudo") + host = dict(host, version=version, sudo=prefixe) + print(f" ✓ Proxmox VE {version}") + self._pve_remember_host(host) + return host + + # -- Exécution sur l'hôte ------------------------------------------ # + def _pve_show(self, remote, timeout=120, quiet=False): + """Exécute `remote` sur l'hôte Proxmox et montre ce qui a été lancé. + + La commande est AFFICHÉE avant sa sortie : c'est ce qui rend chaque + étape rejouable à la main, et c'est ainsi que les pannes de ce module + ont été diagnostiquées. + """ + from script.proxmox import proxmox_deploy as pve + + host = self._pve_host() + if not host: + return 255, "" + if not quiet: + # La forme RÉELLEMENT envoyée, enrobage sudo compris : une + # commande affichée doit pouvoir être recopiée telle quelle. + reel = pve.wrap_privilege(remote, host.get("sudo") or "") + print(f"\n{t('Will execute:')} ssh {host['target']} {reel}") + code, out = pve.run(host, remote, timeout) + if out.strip() and not quiet: + print(out.rstrip()) + if code and not quiet: + print(f" ⚠ {t('exit code')} {code}") + return code, out + + def _pve_vms(self): + """[{vmid, name, status, …}] des VM de l'hôte, ou [].""" + from script.proxmox import proxmox_deploy as pve + + code, out = self._pve_show("qm list", quiet=True) + return pve.parse_qm_list(out) if code == 0 else [] + + def _pve_pick_vm(self, titre="", multiple=False): + """Choisit une VM de l'hôte (numéro de la liste, jamais le VMID à + retaper). Renvoie un dict, une liste si `multiple`, ou None.""" + vms = self._pve_vms() + if not vms: + print(f"\n{t('No VM on this Proxmox host.')}") + return [] if multiple else None + print(f"\n{titre or t('VMs on this host:')}") + for i, vm in enumerate(vms, 1): + print( + f" [{i}] {vm['vmid']:<6} {vm['name']:<28} {vm['status']}" + ) + if multiple: + print(f" [all] {t('select all')}") + brut = input(t("Selection (number): ")).strip() + if multiple: + if brut.lower() in ("all", "*"): + return vms + choisis = [] + for jeton in re.split(r"[\s,]+", brut): + if jeton.isdigit() and 1 <= int(jeton) <= len(vms): + choisis.append(vms[int(jeton) - 1]) + return choisis + if brut.isdigit() and 1 <= int(brut) <= len(vms): + return vms[int(brut) - 1] + print(t("Invalid selection!")) + return None + + # -- Les commandes du menu ----------------------------------------- # + def _pve_list(self): + """« qm list », mis en tableau avec le total.""" + vms = self._pve_vms() + if not vms: + print(f"\n{t('No VM on this Proxmox host.')}") + return + print( + f"\n{'VMID':<7} {'Nom':<30} {'État':<10} {'RAM (Mo)':>9}" + f" {'Disque':>10}" + ) + print("─" * 70) + for vm in vms: + print( + f"{vm['vmid']:<7} {vm['name'][:30]:<30} {vm['status']:<10}" + f" {vm['mem']:>9} {vm['disk']:>10}" + ) + actives = sum(1 for v in vms if v["status"] == "running") + print(f"\n {len(vms)} VM, {actives} {t('running')}") + + def _pve_vm_ip(self): + """Adresse d'une VM, par l'agent invité. + + Sans agent, Proxmox ne connaît PAS l'adresse de ses invités : il ne la + distribue pas lui-même. Le dire vaut mieux qu'afficher « rien ». + """ + from script.proxmox import proxmox_deploy as pve + + vm = self._pve_pick_vm() + if not vm: + return + # _pve_guest_ip et non l'agent seul : il enchaîne agent PUIS voisinage + # de l'hôte. L'image cloud Debian n'embarque pas qemu-guest-agent, et + # cette entrée du menu répondait « aucune adresse » alors que « ip + # neigh » la connaissait — deux chemins pour la même question, dont un + # seul savait répondre. + ip = self._pve_guest_ip(vm["vmid"], attente=20) + if ip: + print(f"\n {vm['name']} : {ip}") + print(f" ssh erplibre@{ip}") + return + print(f"\n ⚠ {t('No address for this VM.')}") + print(f" → {t('Is qemu-guest-agent installed and the VM started?')}") + print(f" → {t('A static address is visible right after creation.')}") + + def _pve_console(self): + """Console série d'une VM. Demande un terminal : on passe donc par + l'exécuteur du dépôt, qui en a un.""" + from script.proxmox import proxmox_deploy as pve + + vm = self._pve_pick_vm() + if not vm: + return + host = self._pve_host() + if not host: + return + cmd = " ".join( + shlex.quote(a) + for a in pve.ssh_argv( + host, + pve.wrap_privilege( + pve.console_cmd(vm["vmid"]), host.get("sudo") or "" + ), + tty=True, + ) + ) + print(f"\n {t('Ctrl+O to quit the serial console.')}") + print(f"\n{t('Will execute:')} {cmd}") + self.execute.exec_command_live(cmd, source_erplibre=False) + + def _pve_resize(self): + """Agrandit un disque. Proxmox REFUSE de rétrécir : on le dit avant.""" + from script.proxmox import proxmox_deploy as pve + + vm = self._pve_pick_vm() + if not vm: + return + print(f"\n ⚠ {t('Proxmox can only GROW a disk, never shrink it.')}") + taille = input( + t("Size (+10G to add, 40G for a target): ") + ).strip() + if not re.match(r"^\+?\d+[MGT]$", taille): + print(t("Invalid selection!")) + return + self._pve_show(pve.resize_cmd(vm["vmid"], taille)) + + def _pve_delete(self): + """Efface des VM, avec DOUBLE validation — « --purge » emporte les + disques et les sauvegardes, il n'y a pas de retour.""" + from script.proxmox import proxmox_deploy as pve + + vms = self._pve_pick_vm(multiple=True) + if not vms: + return + noms = ", ".join(f"{v['vmid']} ({v['name']})" for v in vms) + print(f"\n ⚠ {t('This also destroys their disks and backups.')}") + if not self._is_yes(input(f"{t('Apply:')} {noms} ? (o/N) : ")): + print(t("Cancelled.")) + return + if not self._is_yes(input(t("Confirm for real? (y/N): "))): + print(t("Cancelled.")) + return + for vm in vms: + for cmd in pve.destroy_cmds(vm["vmid"]): + self._pve_show(cmd, timeout=300) + + def _pve_cleanup(self): + """Volumes de disque qu'aucune VM ne réclame plus. + + Proxmox ne les efface pas de lui-même : une création interrompue ou un + « destroy » sans « --purge » en laisse. On les liste et on demande. + """ + from script.proxmox import proxmox_deploy as pve + + code, out = self._pve_show(pve.orphan_disks_cmd(), quiet=True) + if code: + print(f"\n ⚠ {t('exit code')} {code}") + return + vmids = [v["vmid"] for v in self._pve_vms()] + orphelins = pve.parse_orphans(out, vmids) + if not orphelins: + print(f"\n ✓ {t('Nothing orphaned.')}") + return + total = sum(t2 for _v, t2 in orphelins) + print(f"\n{t('Orphan disks:')}") + for volid, taille in orphelins: + print(f" {volid:<48} {taille / (1 << 30):>8.1f} Go") + print(f" {t('Total:')} {total / (1 << 30):.1f} Go") + if not self._is_yes(input(f"{t('Free them?')} (o/N) : ")): + print(t("Cancelled.")) + return + for volid, _taille in orphelins: + self._pve_show(f"pvesm free {shlex.quote(volid)}", timeout=300) + + def _pve_guest_ip(self, vmid, attente=120): + """Adresse d'une VM Proxmox : agent invité, sinon voisinage de l'hôte. + + Deux voies parce qu'aucune ne suffit seule. L'agent est le plus sûr, + mais l'image cloud Debian ne l'embarque pas. Le voisinage (« ip neigh » + sur l'hôte) marche dès que la VM a émis un paquet — un bail DHCP suffit + — et ne demande RIEN à l'invité. + """ + from script.proxmox import proxmox_deploy as pve + + fin = time.time() + attente + mac = "" + while True: + code, out = self._pve_show( + pve.guest_ip_cmd(vmid), timeout=30, quiet=True + ) + ips = pve.parse_guest_ips(out) if code == 0 else [] + if ips: + return ips[0] + if not mac: + _c, cfg = self._pve_show( + f"qm config {vmid}", timeout=30, quiet=True + ) + mac = pve.mac_from_config(cfg) + if mac: + _c, neigh = self._pve_show( + "ip -4 neigh show", timeout=30, quiet=True + ) + ip = pve.ip_from_neigh(neigh, mac) + if ip: + return ip + if time.time() >= fin: + return "" + time.sleep(5) + + def _pve_push_key(self, chemin_local): + """Recopie la clé publique SUR l'hôte : « qm set --sshkeys » attend un + FICHIER là-bas, pas une clé en ligne.""" + try: + with open(os.path.expanduser(chemin_local), encoding="utf-8") as fh: + cle = fh.read().strip() + except OSError as exc: + print(f" ⚠ {t('SSH key unreadable:')} {exc}") + return "" + distant = "/root/.ssh/erplibre-deploy.pub" + code, _out = self._pve_show( + "mkdir -p /root/.ssh && printf '%s\\n' " + f"{shlex.quote(cle)} > {distant}", + quiet=True, + ) + return distant if code == 0 else "" + + def _pve_offer_bridge(self): + """Aucun pont sur l'hôte : en proposer un, sans risquer l'accès. + + Une Proxmox installée SUR Debian n'a pas de vmbr0 — l'ISO en crée un, + pas la procédure sur Debian. Or « qm create » exige un pont. + + On ne propose donc PAS d'ajouter l'interface physique au pont : cela + déplace l'adresse de la machine et coupe la session SSH en cours, sans + retour possible à distance. Un pont INTERNE, lui, ne touche à rien — + les VM s'y parlent, et le masquerading leur donne l'extérieur. + """ + from script.proxmox import proxmox_deploy as pve + + print(f"\n ⚠ {t('No network bridge on this host.')}") + print(f" {t('qm create needs one. Two ways:')}") + print( + f" [1] {t('create an internal')} {pve.INTERNAL_BRIDGE}" + f" ({pve.INTERNAL_CIDR}) + NAT — {t('touches no physical NIC')}" + ) + print(f" [2] {t('do it myself (bridge-ports , needs console)')}") + if input(t("Choice: ")).strip() != "1": + print(f"\n {t('To bridge the LAN, on the host:')}") + print(" auto vmbr0") + print(" iface vmbr0 inet static") + print(" address /24") + print(" gateway ") + print(" bridge-ports ") + print(f" ⚠ {t('This moves the host address: do it from a console.')}") + return "" + _c, sortie = self._pve_show( + "ip -o -4 route show default", quiet=True + ) + uplink = "" + parts = (sortie or "").split() + if "dev" in parts: + uplink = parts[parts.index("dev") + 1] + print(f" {t('uplink for NAT')} : {uplink or t('none')}") + for cmd in pve.bridge_setup_cmds(uplink=uplink): + code, _o = self._pve_show(cmd, timeout=120) + if code: + print(f" ✗ {t('Step failed, stopping here.')}") + return "" + _c, out = self._pve_show("ip -o link show type bridge", quiet=True) + ponts = pve.parse_bridges(out) + if pve.INTERNAL_BRIDGE not in ponts: + print(f" ✗ {t('The bridge did not come up.')}") + return "" + print(f" ✓ {pve.INTERNAL_BRIDGE}") + return pve.INTERNAL_BRIDGE + + def _pve_deploy(self, dry_run=False): + """Déploie une VM SUR l'hôte Proxmox choisi. + + Le catalogue d'images est celui du dépôt (le même que QEMU/KVM) : c'est + une connaissance locale, indépendante de l'hyperviseur. Tout le reste + part sur l'hôte — téléchargement compris, puisque c'est là que le + disque sera écrit. + """ + from script.proxmox import proxmox_deploy as pve + + host = self._pve_host() + if not host: + return + mod = self._qemu_import_module() + distro = self._qemu_prompt_distro() + version = self._qemu_prompt_version(distro) + arch = "amd64" + nom = input( + t("VM name (default: erplibre-): ") + ).strip() or f"erplibre-{distro}" + memoire = ( + self._qemu_ask_ram(t("RAM in MB, blank = 4096"), 4096) or 4096 + ) + vcpus = ( + self._qemu_ask_cpu(t("vCPU, blank = 2"), 2, os.cpu_count() or 2) + or 2 + ) + disque = input(t("Disk size (default 32G): ")).strip() or "32G" + + code, _v = mod.DISTROS[distro][0][version][:2] + url = mod.image_url(distro, code, arch, version) + image = mod.default_image_name(distro, code, arch, version) + + # Stockage et pont : demandés à l'HÔTE, jamais devinés. « local-lvm » + # n'existe pas partout, et un pont inventé fait échouer « qm create ». + _c, out = self._pve_show("pvesm status --content images", quiet=True) + stockages = pve.parse_storages(out) + _c, out = self._pve_show("ip -o link show type bridge", quiet=True) + ponts = pve.parse_bridges(out) + _c, cfg_reseau = self._pve_show( + "cat /etc/network/interfaces", quiet=True + ) + infos_ponts = pve.parse_bridge_config(cfg_reseau) + stockage = pve.pick_storage(stockages) + pont = pve.pick_bridge(ponts) + if not stockage: + print(f"\n ✗ {t('No storage able to hold a VM disk.')}") + return + if not pont and not dry_run: + pont = self._pve_offer_bridge() + if not pont: + return + _c, cfg_reseau = self._pve_show( + "cat /etc/network/interfaces", quiet=True + ) + infos_ponts = pve.parse_bridge_config(cfg_reseau) + elif not pont: + pont = pve.INTERNAL_BRIDGE + # Le VMID D'ABORD : l'adresse d'un pont interne s'en déduit, et + # l'afficher avant de l'avoir choisi ne pouvait pas marcher. + vmid = pve.next_vmid(self._pve_vms()) + ipconfig = pve.ipconfig_for(infos_ponts.get(pont, {}), vmid) + print(f"\n {t('storage')} : {stockage} ({len(stockages)} {t('offered')})") + print(f" {t('bridge')} : {pont}") + print(f" {t('address')} {ipconfig}") + print(f" VMID : {vmid}") + + cle_locale = self._qemu_default_ssh_key() + spec = { + "name": nom, + "memory": memoire, + "vcpus": vcpus, + "disk": disque, + "storage": stockage, + "bridge": pont, + "image": image, + "user": "erplibre", + "sshkey_path": "/root/.ssh/erplibre-deploy.pub", + "start": True, + # DHCP sur un pont qui donne sur le LAN, adresse FIXE sur un pont + # interne : là, aucun serveur DHCP ne répondrait et la VM + # resterait muette. + "ipconfig": ipconfig, + } + etapes = [pve.image_fetch_cmd(url, image)] + pve.create_cmds( + vmid, spec + ) + if dry_run: + print(f"\n── {t('Would run on')} {host['target']} ──") + print(f" # {t('SSH key ->')} {spec['sshkey_path']}") + for cmd in etapes: + print(f" {cmd}") + return + if not self._is_yes_default_yes( + input(f"\n{t('Deploy this VM now? (Y/n): ')}") + ): + print(t("Cancelled.")) + return + if cle_locale and not self._pve_push_key(cle_locale): + print(f" ⚠ {t('SSH key not pushed: password login only.')}") + spec.pop("sshkey_path", None) + etapes = [pve.image_fetch_cmd(url, image)] + pve.create_cmds( + vmid, spec + ) + for cmd in etapes: + code, _out = self._pve_show(cmd, timeout=1800) + if code: + print(f"\n ✗ {t('Step failed, stopping here.')}") + return + # Adresse fixe : c'est nous qui l'avons donnée, inutile de la + # chercher. La découverte ne sert qu'au DHCP. + ip = pve.ip_from_ipconfig(ipconfig) + if ip: + print(f"\n {t('address given at creation:')} {ip}") + else: + print(f"\n {t('Waiting for the VM address…')}") + ip = self._pve_guest_ip(vmid) + if not ip: + print(f" ⚠ {t('No address yet. Try [6] later.')}") + return + print(f" ✓ {nom} : {ip}") + # Entrée ~/.ssh/config avec l'hôte Proxmox en REBOND : c'est ce qui + # rend la VM joignable d'ici, et c'est aussi ce qui permet au suivi + # d'installation d'y entrer (il reçoit l'alias, pas l'IP). + self._write_ssh_config_entry( + nom, + "erplibre", + ip, + identity_file=self._ssh_private_key(cle_locale), + proxy_jump=host["target"], + ) + print(f" ✓ ~/.ssh/config : ssh {nom}") + if self._is_yes_default_yes( + input(f"\n{t('Install ERPLibre on it? (Y/n): ')}") + ): + branch = self._qemu_pick_branch() + label, cmd = self._qemu_pick_install_profile(distro) + print(f" {label}") + # L'ALIAS, pas l'IP : ssh y lit le ProxyJump de ~/.ssh/config. + self._qemu_install_erplibre_monitored( + [nom], branch, {nom: nom}, cmd + ) + + def _pve_ssh_config(self): + """Écrit une entrée ~/.ssh/config par VM de l'hôte, avec l'hôte + Proxmox en ProxyJump — sans quoi ces VM ne sont joignables d'ici que + si leur réseau est routé jusqu'à nous.""" + host = self._pve_host() + if not host: + return + vms = [v for v in self._pve_vms() if v["status"] == "running"] + if not vms: + print(f"\n{t('No running VM on this Proxmox host.')}") + return + cle = self._ssh_private_key(self._qemu_default_ssh_key()) + for vm in vms: + ip = self._pve_guest_ip(vm["vmid"], attente=0) + if not ip: + print(f" ⚠ {vm['name']} : {t('no address, skipped')}") + continue + self._write_ssh_config_entry( + vm["name"], + "erplibre", + ip, + identity_file=cle, + proxy_jump=host["target"], + ) + print(f" ✓ ssh {vm['name']} ({ip} {t('through')} {host['target']})") + + def _pve_test_vm(self): + """Ouvre Odoo (:8069) d'une VM Proxmox dans un navigateur en ligne. + + Même chose que pour QEMU, à ceci près que l'adresse vient de l'hôte + Proxmox et non de libvirt — et qu'elle n'est joignable d'ici que si son + réseau l'est. On le dit plutôt que d'ouvrir une page vide. + """ + vm = self._pve_pick_vm() + if not vm: + return + ip = self._pve_guest_ip(vm["vmid"], attente=30) + if not ip: + print(f"\n ⚠ {t('No address for this VM.')}") + return + if not self._qemu_ip_reachable(ip, port=8069, timeout=3): + print(f"\n ⚠ {ip}:8069 {t('unreachable from here.')}") + print(f" → {t('Use [13] to add a ProxyJump entry, then a tunnel.')}") + return + navigateur = self._qemu_choose_cli_browser() + if not navigateur: + return + url = f"http://{ip}:8069" + print(f"→ {navigateur} {url}") + os.system(f"{navigateur} {shlex.quote(url)}") + + def _pve_example(self): + """Exemple de séquence, sans rien exécuter : de quoi voir ce que + l'outil enverrait sur l'hôte.""" + from script.proxmox import proxmox_deploy as pve + + spec = { + "name": "demo-vm", + "memory": 4096, + "vcpus": 2, + "disk": "32G", + "storage": "local-lvm", + "bridge": "vmbr0", + "image": "debian-13-genericcloud-amd64.qcow2", + "sshkey_path": "/root/.ssh/erplibre-deploy.pub", + } + print(f"\n── {t('Example: demo-vm, Debian 13, on a Proxmox host')} ──") + print(f" {pve.image_fetch_cmd('https://…/debian-13.qcow2', spec['image'])}") + for cmd in pve.create_cmds(101, spec): + print(f" {cmd}") + + def _pve_stats(self): + """État de l'hôte et de ses VM, en une page.""" + host = self._pve_host() + if not host: + return + print(f"\n══ {t('Proxmox host:')} {self._pve_label(host)} ══") + for titre, cmd in ( + (t("uptime"), "uptime"), + (t("memory"), "free -h | head -2"), + (t("storages"), "pvesm status"), + ): + code, out = self._pve_show(cmd, quiet=True) + print(f"\n── {titre} ──") + print((out or "").rstrip() if code == 0 else f" ⚠ {out.strip()}") + self._pve_list() + def _qemu_script_path(self): """Chemin absolu vers script/qemu/deploy_qemu.py.""" path = os.path.join( @@ -1284,6 +2041,120 @@ class TODO: print(f"⚠ {t('virsh still missing; a reboot may be required.')}") return False + def prompt_execute_proxmox(self): + """Sous-menu Proxmox VE : l'équivalent du menu QEMU/KVM, mais sur un + hôte DISTANT. La première question est donc « lequel ? » — et la + réponse est retenue pour toute la session.""" + print(f"🤖 {t('Deploy a virtual machine on Proxmox VE!')}") + if not self._pve_host(): + return False + choices = [ + {"section": t("Deployment")}, + {"prompt_description": t("Deploy a VM on the Proxmox host")}, + { + "prompt_description": t( + "Preview a deployment (dry-run, nothing sent)" + ) + }, + {"prompt_description": t("Download a cloud image on the host")}, + { + "prompt_description": t( + "Reopen install monitoring (last run / history)" + ) + }, + {"section": t("Manage")}, + {"prompt_description": t("List VMs (qm list)")}, + {"prompt_description": t("Show a VM IP address")}, + {"prompt_description": t("Open the console on a VM")}, + {"prompt_description": t("Resize a VM disk")}, + {"prompt_description": t("Delete VM(s)")}, + {"prompt_description": t("Clean up (orphan disks)")}, + { + "prompt_description": t( + "Test a VM (open Odoo in a CLI browser)" + ) + }, + {"prompt_description": t("Statistics (host and VMs)")}, + { + "prompt_description": t( + "SSH configuration (~/.ssh/config, ProxyJump)" + ) + }, + {"prompt_description": t("Remote desktop tunnel (VNC/RDP over SSH)")}, + {"prompt_description": t("Android emulator (start, tunnel, scrcpy)")}, + {"section": t("Catalog")}, + {"prompt_description": t("List available images and their specs")}, + {"prompt_description": t("Proxmox - example sequence (dry-run)")}, + {"section": t("Host")}, + {"prompt_description": t("Change the Proxmox host")}, + ] + help_info = self.fill_help_info(choices) + while True: + hote = self._pve_host(ask=False) + print(f"\n {t('Proxmox host:')} {self._pve_label(hote) or '-'}") + status = click.prompt(help_info) + print() + if status == "0": + return False + elif status == "1": + self._pve_deploy() + elif status == "2": + self._pve_deploy(dry_run=True) + elif status == "3": + self._pve_fetch_image() + elif status == "4": + self._qemu_reopen_monitor() + elif status == "5": + self._pve_list() + elif status == "6": + self._pve_vm_ip() + elif status == "7": + self._pve_console() + elif status == "8": + self._pve_resize() + elif status == "9": + self._pve_delete() + elif status == "10": + self._pve_cleanup() + elif status == "11": + self._pve_test_vm() + elif status == "12": + self._pve_stats() + elif status == "13": + self._pve_ssh_config() + elif status == "14": + # Les VM Proxmox sont dans ~/.ssh/config (entrée 13) : le + # tunnel du menu QEMU les y trouve, rebond compris. + self._qemu_tunnel_menu() + elif status == "15": + self._qemu_emulator_menu() + elif status == "16": + self._qemu_list_images() + elif status == "17": + self._pve_example() + elif status == "18": + self._pve_forget_host() + self._pve_pick_host() + else: + print(t("Command not found !")) + + def _pve_fetch_image(self): + """Télécharge une image cloud SUR l'hôte Proxmox. + + Là et pas ici : c'est sur l'hôte que le disque sera écrit, et faire + descendre 325 Mio chez soi pour les renvoyer doublerait le transfert. + """ + from script.proxmox import proxmox_deploy as pve + + mod = self._qemu_import_module() + distro = self._qemu_prompt_distro() + version = self._qemu_prompt_version(distro) + code = mod.DISTROS[distro][0][version][0] + url = mod.image_url(distro, code, "amd64", version) + nom = mod.default_image_name(distro, code, "amd64", version) + print(f"\n {nom}\n {url}") + self._pve_show(pve.image_fetch_cmd(url, nom), timeout=1800) + def prompt_execute_qemu(self): print(f"🤖 {t('Deploy a QEMU/KVM virtual machine (libvirt)!')}") script_path = self._qemu_script_path() @@ -5081,6 +5952,19 @@ class TODO: return m.group(1) return None + def _qemu_vm_ip_now(self, name): + """IP de la VM d'après le bail DHCP, SANS attendre. + + `_qemu_vm_ip` patiente jusqu'à dix minutes par VM : c'est ce qu'il faut + après un déploiement, et exactement ce qu'il ne faut pas pour AFFICHER + une liste — trois VM figeaient le menu une demi-heure. Ici on lit le + bail une fois, en préférant celui dont le hostname est le nom de la VM. + """ + cands = self._qemu_lease_candidates(name) + if not cands: + return None + return self._qemu_lease_ip_for_host(name, cands) or cands[-1] + def _qemu_vm_ip(self, name, timeout=600): """IPv4 utilisable d'une VM. Gère le cas des baux multiples (hostname changé au boot) : renvoie en priorité le bail dont le hostname == nom diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 4fd87eb..6327a7a 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -3147,6 +3147,321 @@ TRANSLATIONS = { "fr": "Suivre le démarrage des VM (sans installation)", "en": "Watch the VMs start (no install)", }, + "Proxmox VE - Deploy a VM on a remote host": { + "fr": "Proxmox VE - Déployer une VM sur un hôte distant", + "en": "Proxmox VE - Deploy a VM on a remote host", + }, + "Deploy a virtual machine on Proxmox VE!": { + "fr": "Déployer une machine virtuelle sur Proxmox VE !", + "en": "Deploy a virtual machine on Proxmox VE!", + }, + "Which Proxmox host?": { + "fr": "Quel hôte Proxmox ?", + "en": "Which Proxmox host?", + }, + "From the local QEMU VMs": { + "fr": "Depuis les VM QEMU locales", + "en": "From the local QEMU VMs", + }, + "Type an address": { + "fr": "Saisir une adresse", + "en": "Type an address", + }, + "Keep": { + "fr": "Garder", + "en": "Keep", + }, + "Address (user@host, default user root): ": { + "fr": "Adresse (utilisateur@hôte, utilisateur root par défaut) : ", + "en": "Address (user@host, default user root): ", + }, + "SSH jump host (blank = none): ": { + "fr": "Rebond SSH (vide = aucun) : ", + "en": "SSH jump host (blank = none): ", + }, + "Local VMs:": { + "fr": "VM locales :", + "en": "Local VMs:", + }, + "No IP for this VM: is it running?": { + "fr": "Pas d'IP pour cette VM : est-elle démarrée ?", + "en": "No IP for this VM: is it running?", + }, + "Checking": { + "fr": "Vérification de", + "en": "Checking", + }, + "Not a Proxmox host (or unreachable):": { + "fr": "Ce n'est pas un hôte Proxmox (ou il est injoignable) :", + "en": "Not a Proxmox host (or unreachable):", + }, + "Check the address, the SSH access and pveversion.": { + "fr": "Vérifier l'adresse, l'accès SSH et pveversion.", + "en": "Check the address, the SSH access and pveversion.", + }, + "Proxmox host:": { + "fr": "Hôte Proxmox :", + "en": "Proxmox host:", + }, + "through": { + "fr": "par", + "en": "through", + }, + "exit code": { + "fr": "code de retour", + "en": "exit code", + }, + "No VM on this Proxmox host.": { + "fr": "Aucune VM sur cet hôte Proxmox.", + "en": "No VM on this Proxmox host.", + }, + "No running VM on this Proxmox host.": { + "fr": "Aucune VM démarrée sur cet hôte Proxmox.", + "en": "No running VM on this Proxmox host.", + }, + "VMs on this host:": { + "fr": "VM de cet hôte :", + "en": "VMs on this host:", + }, + "Selection (number): ": { + "fr": "Sélection (numéro) : ", + "en": "Selection (number): ", + }, + "Deploy a VM on the Proxmox host": { + "fr": "Déployer une VM sur l'hôte Proxmox", + "en": "Deploy a VM on the Proxmox host", + }, + "Preview a deployment (dry-run, nothing sent)": { + "fr": "Prévisualiser un déploiement (dry-run, rien n'est envoyé)", + "en": "Preview a deployment (dry-run, nothing sent)", + }, + "Download a cloud image on the host": { + "fr": "Télécharger une image cloud sur l'hôte", + "en": "Download a cloud image on the host", + }, + "List VMs (qm list)": { + "fr": "Lister les VM (qm list)", + "en": "List VMs (qm list)", + }, + "Clean up (orphan disks)": { + "fr": "Nettoyer (disques orphelins)", + "en": "Clean up (orphan disks)", + }, + "Statistics (host and VMs)": { + "fr": "Statistiques (hôte et VM)", + "en": "Statistics (host and VMs)", + }, + "Remote desktop tunnel (VNC/RDP over SSH)": { + "fr": "Tunnel bureau distant (VNC/RDP par SSH)", + "en": "Remote desktop tunnel (VNC/RDP over SSH)", + }, + "List available images and their specs": { + "fr": "Lister les images disponibles et leurs specs", + "en": "List available images and their specs", + }, + "Proxmox - example sequence (dry-run)": { + "fr": "Proxmox - exemple de séquence (dry-run)", + "en": "Proxmox - example sequence (dry-run)", + }, + "Host": { + "fr": "Hôte", + "en": "Host", + }, + "Change the Proxmox host": { + "fr": "Changer d'hôte Proxmox", + "en": "Change the Proxmox host", + }, + "VM name (default: erplibre-): ": { + "fr": "Nom de la VM (défaut : erplibre-) : ", + "en": "VM name (default: erplibre-): ", + }, + "RAM in MB, blank = 4096": { + "fr": "RAM en Mo, vide = 4096", + "en": "RAM in MB, blank = 4096", + }, + "vCPU, blank = 2": { + "fr": "vCPU, vide = 2", + "en": "vCPU, blank = 2", + }, + "Disk size (default 32G): ": { + "fr": "Taille du disque (défaut 32G) : ", + "en": "Disk size (default 32G): ", + }, + "Size (+10G to add, 40G for a target): ": { + "fr": "Taille (+10G pour ajouter, 40G pour une cible) : ", + "en": "Size (+10G to add, 40G for a target): ", + }, + "No storage able to hold a VM disk.": { + "fr": "Aucun stockage capable d'héberger un disque de VM.", + "en": "No storage able to hold a VM disk.", + }, + "No network bridge on this host.": { + "fr": "Aucun pont réseau sur cet hôte.", + "en": "No network bridge on this host.", + }, + "offered": { + "fr": "proposés", + "en": "offered", + }, + "Would run on": { + "fr": "Serait exécuté sur", + "en": "Would run on", + }, + "SSH key ->": { + "fr": "clé SSH ->", + "en": "SSH key ->", + }, + "Deploy this VM now? (Y/n): ": { + "fr": "Déployer cette VM maintenant ? (O/n) : ", + "en": "Deploy this VM now? (Y/n): ", + }, + "SSH key unreadable:": { + "fr": "Clé SSH illisible :", + "en": "SSH key unreadable:", + }, + "SSH key not pushed: password login only.": { + "fr": "Clé SSH non transmise : connexion par mot de passe seulement.", + "en": "SSH key not pushed: password login only.", + }, + "Step failed, stopping here.": { + "fr": "Étape en échec, on s'arrête ici.", + "en": "Step failed, stopping here.", + }, + "Waiting for the VM address…": { + "fr": "Attente de l'adresse de la VM…", + "en": "Waiting for the VM address…", + }, + "No address yet. Try [6] later.": { + "fr": "Pas encore d'adresse. Réessayer avec [6] plus tard.", + "en": "No address yet. Try [6] later.", + }, + "Install ERPLibre on it? (Y/n): ": { + "fr": "Y installer ERPLibre ? (O/n) : ", + "en": "Install ERPLibre on it? (Y/n): ", + }, + "No address from the guest agent.": { + "fr": "Aucune adresse rendue par l'agent invité.", + "en": "No address from the guest agent.", + }, + "Is qemu-guest-agent installed and the VM started?": { + "fr": "qemu-guest-agent est-il installé et la VM démarrée ?", + "en": "Is qemu-guest-agent installed and the VM started?", + }, + "Ctrl+O to quit the serial console.": { + "fr": "Ctrl+O pour quitter la console série.", + "en": "Ctrl+O to quit the serial console.", + }, + "Proxmox can only GROW a disk, never shrink it.": { + "fr": "Proxmox ne sait qu'AGRANDIR un disque, jamais le rétrécir.", + "en": "Proxmox can only GROW a disk, never shrink it.", + }, + "This also destroys their disks and backups.": { + "fr": "Cela détruit aussi leurs disques et leurs sauvegardes.", + "en": "This also destroys their disks and backups.", + }, + "Nothing orphaned.": { + "fr": "Rien d'orphelin.", + "en": "Nothing orphaned.", + }, + "Orphan disks:": { + "fr": "Disques orphelins :", + "en": "Orphan disks:", + }, + "Free them?": { + "fr": "Les libérer ?", + "en": "Free them?", + }, + "no address, skipped": { + "fr": "pas d'adresse, ignorée", + "en": "no address, skipped", + }, + "unreachable from here.": { + "fr": "injoignable d'ici.", + "en": "unreachable from here.", + }, + "Use [13] to add a ProxyJump entry, then a tunnel.": { + "fr": "Utiliser [13] pour l'entrée ProxyJump, puis un tunnel.", + "en": "Use [13] to add a ProxyJump entry, then a tunnel.", + }, + "Example: demo-vm, Debian 13, on a Proxmox host": { + "fr": "Exemple : demo-vm, Debian 13, sur un hôte Proxmox", + "en": "Example: demo-vm, Debian 13, on a Proxmox host", + }, + "storages": { + "fr": "stockages", + "en": "storages", + }, + "memory": { + "fr": "mémoire", + "en": "memory", + }, + "No address for this VM.": { + "fr": "Aucune adresse pour cette VM.", + "en": "No address for this VM.", + }, + "A static address is visible right after creation.": { + "fr": "Une adresse fixe est connue dès la création.", + "en": "A static address is visible right after creation.", + }, + "address given at creation:": { + "fr": "adresse donnée à la création :", + "en": "address given at creation:", + }, + "qm create needs one. Two ways:": { + "fr": "« qm create » en exige un. Deux voies :", + "en": "qm create needs one. Two ways:", + }, + "create an internal": {"fr": "créer un pont interne", "en": "create an internal"}, + "touches no physical NIC": { + "fr": "ne touche à aucune interface physique", + "en": "touches no physical NIC", + }, + "do it myself (bridge-ports , needs console)": { + "fr": "le faire moi-même (bridge-ports , console requise)", + "en": "do it myself (bridge-ports , needs console)", + }, + "To bridge the LAN, on the host:": { + "fr": "Pour ponter le LAN, sur l'hôte :", + "en": "To bridge the LAN, on the host:", + }, + "This moves the host address: do it from a console.": { + "fr": "Cela déplace l'adresse de l'hôte : à faire depuis une console.", + "en": "This moves the host address: do it from a console.", + }, + "uplink for NAT": {"fr": "sortie pour le NAT", "en": "uplink for NAT"}, + "none": {"fr": "aucune", "en": "none"}, + "The bridge did not come up.": { + "fr": "Le pont n'est pas monté.", + "en": "The bridge did not come up.", + }, + "SSH does not know this host key yet.": { + "fr": "ssh ne connaît pas encore la clé de cet hôte.", + "en": "SSH does not know this host key yet.", + }, + "Would record:": { + "fr": "Enregistrerait :", + "en": "Would record:", + }, + "Record it?": { + "fr": "L'enregistrer ?", + "en": "Record it?", + }, + "No host key obtained.": { + "fr": "Aucune clé d'hôte obtenue.", + "en": "No host key obtained.", + }, + "key(s) recorded in ~/.ssh/known_hosts": { + "fr": "clé(s) enregistrée(s) dans ~/.ssh/known_hosts", + "en": "key(s) recorded in ~/.ssh/known_hosts", + }, + "qm needs root: no root, and sudo asks for a password.": { + "fr": "qm exige root : ni root, ni sudo sans mot de passe.", + "en": "qm needs root: no root, and sudo asks for a password.", + }, + "Connect as root@, or allow NOPASSWD sudo.": { + "fr": "Se connecter en root@, ou autoriser sudo sans mot de passe.", + "en": "Connect as root@, or allow NOPASSWD sudo.", + }, "Proxmox VE hypervisor (no Odoo)": { "fr": "Hyperviseur Proxmox VE (sans Odoo)", "en": "Proxmox VE hypervisor (no Odoo)", diff --git a/test/test_mobile_bundle_transfer.py b/test/test_mobile_bundle_transfer.py index f877e33..7193670 100644 --- a/test/test_mobile_bundle_transfer.py +++ b/test/test_mobile_bundle_transfer.py @@ -293,11 +293,27 @@ class TestTheRealBundle(unittest.TestCase): " ./mobile/install_mobile_dev.sh" ) cls.repos = MOBILE / "dist" / "repos" - if not (cls.repos / "manifest.json").is_file(): + manifeste = cls.repos / "manifest.json" + if not manifeste.is_file(): raise unittest.SkipTest( "dépôt mobile présent mais pas compilé :" " ./mobile/compile_and_run.sh (ou npm run build)" ) + # Manifeste PRÉSENT mais VIDE : l'application a été compilée sans le + # transfert des dépôts. C'est un choix légitime, pas une régression — + # et le distinguer importe, car ces tests échouaient alors sur + # « aucun dépôt à vérifier », ce qui se lit comme une panne du + # transfert. Vu le 23 août 2026 sur un build de 07:55 : manifeste à + # zéro entrée, aucun pack. + try: + entrees = json.loads(manifeste.read_text()) + except (OSError, ValueError) as exc: + raise unittest.SkipTest(f"manifeste illisible : {exc}") + if not entrees: + raise unittest.SkipTest( + "compilé SANS les dépôts (manifeste vide) :" + " relancer ./mobile/compile_and_run.sh pour les inclure" + ) def test_the_transfer_is_coherent(self): rep = cbt.check(MOBILE, REPO) diff --git a/test/test_proxmox_deploy.py b/test/test_proxmox_deploy.py new file mode 100644 index 0000000..0cf92c6 --- /dev/null +++ b/test/test_proxmox_deploy.py @@ -0,0 +1,408 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Déployer sur un hôte Proxmox : l'hyperviseur est AILLEURS. + +Toute la différence avec QEMU/KVM tient là. Rien ne s'exécute sur la machine +locale : il faut d'abord savoir OÙ, puis tout envoyer par SSH. Ces tests +gardent ce qui a été appris contre un hôte réel (Proxmox VE 9.2.11 dans une VM +libvirt), une panne après l'autre : + +- « qm » exige root. La voie « VM QEMU locale » ne donne que l'accès + d'erplibre : il faut sudo, et l'enrober AUTOUR de toute la commande — les + commandes de ce module sont des suites et des redirections, et « sudo cmd » + n'élèverait que le premier mot. +- Une Proxmox installée SUR Debian n'a AUCUN pont. On en propose un INTERNE : + ajouter l'interface physique à un pont déplace l'adresse de l'hôte et coupe + la session SSH — à distance, c'est sans retour. +- Sur un pont interne, aucun DHCP ne répond : l'adresse doit être fixe, et + elle est alors connue AVANT le démarrage. La chercher ensuite était absurde. +- L'agent invité n'est pas dans l'image cloud Debian : le voisinage de l'hôte + (« ip neigh ») est le seul repli, et il a trouvé l'adresse là où l'agent + répondait « not running ». +""" + +import shlex +import subprocess +import sys +import unittest +from unittest import mock + +sys.argv = ["todo.py"] +from script.proxmox import proxmox_deploy as pve # noqa: E402 +from script.todo.todo import TODO # noqa: E402 + +# Sorties RÉELLES relevées sur l'hôte d'essai. +PVEVERSION = ( + "pve-manager/9.2.11/f6997e698c7933ea (running kernel: 7.0.14-12-pve)" +) +QM_LIST = """ VMID NAME STATUS MEM(MB) BOOTDISK(GB) PID + 100 vm-essai running 2048 16.00 2726 + 101 avec un espace stopped 4096 32.00 0 +""" +PVESM = """Name Type Status Total Used Available % +local dir active 32815812 6873084 24559348 20.94% +sauvegarde dir inactive 99999999 0 99999999 0.00% +""" +NEIGH = """192.168.123.1 dev enp1s0 lladdr 52:54:00:cd:73:ef REACHABLE +10.10.10.150 dev vmbr0 lladdr bc:24:11:93:da:22 REACHABLE +""" +QM_CONFIG = """boot: order=scsi0 +memory: 2048 +net0: virtio=BC:24:11:93:DA:22,bridge=vmbr0 +scsi0: local:100/vm-100-disk-0.raw,discard=on,size=16G,ssd=1 +""" +INTERFACES = """auto lo +iface lo inet loopback + +iface enp1s0 inet manual + +auto vmbr0 +iface vmbr0 inet static + address 10.10.10.1/24 + bridge-ports none + bridge-stp off + +auto vmbr1 +iface vmbr1 inet manual + bridge-ports enp2s0 +""" + + +class TestLectureDesSorties(unittest.TestCase): + def test_the_version_proves_it_is_a_proxmox(self): + """Une adresse saisie à la main peut être n'importe quelle machine : + sans cette preuve, la première commande « qm » échouerait sur un + « command not found » qui n'explique rien.""" + self.assertEqual("9.2.11", pve.parse_pveversion(PVEVERSION)) + self.assertEqual( + "", pve.parse_pveversion("bash: pveversion: not found") + ) + + def test_a_vm_name_with_spaces_is_read_whole(self): + """« qm list » sépare par des espaces, et un nom peut en contenir : on + découpe par les deux bouts, le milieu est le nom.""" + vms = pve.parse_qm_list(QM_LIST) + self.assertEqual([100, 101], [v["vmid"] for v in vms]) + self.assertEqual("avec un espace", vms[1]["name"]) + self.assertEqual("running", vms[0]["status"]) + + def test_the_header_is_not_a_vm(self): + self.assertEqual([], pve.parse_qm_list(" VMID NAME STATUS\n")) + self.assertEqual([], pve.parse_qm_list("")) + + def test_only_active_storages_count_and_kib_become_bytes(self): + st = pve.parse_storages(PVESM) + self.assertEqual(["local", "sauvegarde"], [s["name"] for s in st]) + self.assertTrue(st[0]["actif"]) + self.assertFalse(st[1]["actif"]) + self.assertEqual(24559348 * 1024, st[0]["avail"]) + + def test_bridges_are_read_without_their_at_suffix(self): + texte = "3: vmbr0: \n4: vmbr1@if2: \n" + self.assertEqual(["vmbr0", "vmbr1"], pve.parse_bridges(texte)) + + def test_the_guest_agent_answer_drops_loopback_and_ipv6(self): + json_txt = ( + '[{"name":"lo","ip-addresses":[{"ip-address-type":"ipv4",' + '"ip-address":"127.0.0.1"}]},{"name":"eth0","ip-addresses":[' + '{"ip-address-type":"ipv4","ip-address":"10.10.10.150"},' + '{"ip-address-type":"ipv6","ip-address":"fe80::1"}]}]' + ) + self.assertEqual(["10.10.10.150"], pve.parse_guest_ips(json_txt)) + + def test_a_missing_agent_is_not_a_crash(self): + """Sa réponse n'est pas du JSON : « QEMU guest agent is not running ».""" + self.assertEqual( + [], pve.parse_guest_ips("QEMU guest agent is not running") + ) + + def test_the_mac_links_a_vm_to_its_address(self): + """Le seul lien quand l'agent manque, et l'image cloud Debian ne + l'embarque pas.""" + mac = pve.mac_from_config(QM_CONFIG) + self.assertEqual("bc:24:11:93:da:22", mac) + self.assertEqual("10.10.10.150", pve.ip_from_neigh(NEIGH, mac)) + + def test_an_unknown_mac_finds_nothing(self): + self.assertEqual("", pve.ip_from_neigh(NEIGH, "de:ad:be:ef:00:00")) + self.assertEqual("", pve.ip_from_neigh(NEIGH, "")) + + def test_a_lan_bridge_and_an_internal_one_are_told_apart(self): + ponts = pve.parse_bridge_config(INTERFACES) + self.assertEqual("", ponts["vmbr0"]["ports"]) + self.assertEqual("10.10.10.1/24", ponts["vmbr0"]["address"]) + self.assertEqual("enp2s0", ponts["vmbr1"]["ports"]) + + def test_orphans_are_the_volumes_no_vm_claims(self): + liste = ( + "Volid Format Type Size VMID\n" + "local:100/vm-100-disk-0.raw raw images 17179869184 100\n" + "local:999/vm-999-disk-0.raw raw images 8589934592 999\n" + ) + orph = pve.parse_orphans(liste, [100]) + self.assertEqual(1, len(orph)) + self.assertIn("999", orph[0][0]) + + +class TestLesChoix(unittest.TestCase): + def test_the_vmid_skips_the_taken_ones(self): + """Proxmox refuse un VMID pris, et le dit APRÈS le téléchargement de + l'image : on choisit donc avant, d'après ce que l'hôte déclare.""" + self.assertEqual( + 102, pve.next_vmid([{"vmid": 100}, {"vmid": 101}, {"vmid": 103}]) + ) + self.assertEqual(100, pve.next_vmid([])) + + def test_the_storage_is_the_freest_active_one(self): + st = pve.parse_storages(PVESM) + self.assertEqual("local", pve.pick_storage(st)) + + def test_an_unknown_storage_is_refused_not_guessed(self): + """« local-lvm » n'existe pas partout : un repli deviné ferait échouer + « qm set » après le téléchargement de l'image.""" + self.assertEqual( + "", pve.pick_storage(pve.parse_storages(PVESM), "nas") + ) + + def test_vmbr0_wins_when_it_exists(self): + self.assertEqual("vmbr0", pve.pick_bridge(["vmbr9", "vmbr0"])) + self.assertEqual("br-lan", pve.pick_bridge(["br-lan"])) + self.assertEqual("", pve.pick_bridge([])) + + +class TestLesCommandes(unittest.TestCase): + def _spec(self, **extra): + base = { + "name": "vm-essai", + "memory": 2048, + "vcpus": 2, + "disk": "12G", + "storage": "local", + "bridge": "vmbr0", + "image": "debian-13-genericcloud-amd64.qcow2", + "sshkey_path": "/root/.ssh/erplibre-deploy.pub", + "ipconfig": "ip=10.10.10.150/24,gw=10.10.10.1", + } + base.update(extra) + return base + + def test_the_sequence_is_in_the_order_proxmox_needs(self): + cmds = pve.create_cmds(100, self._spec()) + joint = "\n".join(cmds) + self.assertTrue(cmds[0].startswith("qm create 100")) + self.assertIn("import-from=", joint) + self.assertIn(":cloudinit", joint) + self.assertIn("--boot order=scsi0", joint) + self.assertIn("qm resize 100 scsi0 12G", joint) + self.assertTrue(cmds[-1].endswith("qm start 100")) + + def test_the_agent_and_the_serial_console_are_asked_for(self): + """Sans agent, aucune adresse ; sans serial0, « qm terminal » est + inutilisable et il ne reste que l'interface web.""" + cmd = pve.create_cmds(100, self._spec())[0] + self.assertIn("--agent enabled=1", cmd) + self.assertIn("--serial0 socket", cmd) + + def test_a_name_with_a_space_cannot_break_the_command(self): + """Le nom vient d'une saisie : découpée par le shell, elle doit rester + UN argument. « rm » ne doit jamais devenir une commande.""" + mechant = "vm essai; rm -rf /" + cmds = pve.create_cmds(100, self._spec(name=mechant)) + args = shlex.split(cmds[0]) + self.assertIn(mechant, args) + self.assertNotIn("rm", args) + + def test_destroy_stops_first_and_purges(self): + cmds = pve.destroy_cmds(100) + self.assertIn("qm stop 100", cmds[0]) + self.assertIn("--purge 1", cmds[1]) + + def test_the_image_is_fetched_once_on_the_host(self): + cmd = pve.image_fetch_cmd("https://x/deb.qcow2", "deb.qcow2") + self.assertIn("if [ -s", cmd) + self.assertIn("wget", cmd) + + def test_the_internal_bridge_never_touches_a_physical_nic(self): + """Le point le plus important de ce module : ajouter l'interface au + pont déplace l'adresse de l'hôte et coupe la session SSH — à distance, + sans retour.""" + cmds = pve.bridge_setup_cmds(uplink="enp1s0") + joint = "\n".join(cmds) + self.assertIn("bridge-ports none", joint) + self.assertNotIn("bridge-ports enp1s0", joint) + self.assertIn("MASQUERADE", joint) + self.assertIn("ip_forward", joint) + + def test_the_bridge_stanza_is_added_only_once(self): + cmds = pve.bridge_setup_cmds() + self.assertIn("grep -qE", cmds[0]) + self.assertIn("||", cmds[0]) + + def test_an_internal_bridge_gets_a_static_address(self): + """Aucun DHCP n'y répondrait : la VM resterait muette.""" + ponts = pve.parse_bridge_config(INTERFACES) + self.assertEqual( + "ip=10.10.10.150/24,gw=10.10.10.1", + pve.ipconfig_for(ponts["vmbr0"], 100), + ) + + def test_a_lan_bridge_gets_dhcp(self): + ponts = pve.parse_bridge_config(INTERFACES) + self.assertEqual("ip=dhcp", pve.ipconfig_for(ponts["vmbr1"], 100)) + + def test_two_vms_do_not_share_an_address(self): + ponts = pve.parse_bridge_config(INTERFACES) + a = pve.ipconfig_for(ponts["vmbr0"], 100) + b = pve.ipconfig_for(ponts["vmbr0"], 101) + self.assertNotEqual(a, b) + + def test_a_static_address_is_known_before_boot(self): + self.assertEqual( + "10.10.10.150", + pve.ip_from_ipconfig("ip=10.10.10.150/24,gw=10.10.10.1"), + ) + self.assertEqual("", pve.ip_from_ipconfig("ip=dhcp")) + + +class TestLePrivilege(unittest.TestCase): + def test_the_whole_command_is_wrapped_not_just_its_first_word(self): + """« sudo mkdir && if … fi » n'élèverait que le mkdir, et la + redirection resterait celle du shell non privilégié : « permission + denied » sur /root ou /boot/efi.""" + compose = "mkdir -p /root/.ssh && printf x > /root/.ssh/k" + enrobe = pve.wrap_privilege(compose, "sudo ") + self.assertTrue(enrobe.startswith("sudo sh -c ")) + self.assertIn("printf x > /root/.ssh/k", enrobe) + + def test_without_sudo_the_command_is_untouched(self): + self.assertEqual("qm list", pve.wrap_privilege("qm list", "")) + + def test_ssh_never_asks_a_question_it_cannot_show(self): + """BatchMode : une invite de mot de passe dans un menu bloquerait sans + rien afficher.""" + argv = pve.ssh_argv({"target": "root@h"}, "qm list") + self.assertIn("BatchMode=yes", argv) + self.assertIn("ConnectTimeout=10", " ".join(argv)) + + def test_the_jump_and_the_port_travel(self): + argv = pve.ssh_argv( + {"target": "root@h", "jump": "rebond", "port": "2222"}, "x" + ) + self.assertIn("-J", argv) + self.assertIn("rebond", argv) + self.assertIn("-p", argv) + self.assertIn("2222", argv) + + def test_a_console_gets_a_tty_and_no_batchmode(self): + argv = pve.ssh_argv({"target": "root@h"}, "qm terminal 100", tty=True) + self.assertIn("-t", argv) + self.assertNotIn("BatchMode=yes", argv) + + +class TestChoixDeLHote(unittest.TestCase): + """La question propre à Proxmox : sur QUELLE machine ?""" + + def _todo(self): + todo = TODO.__new__(TODO) + todo._pve_remember_host = lambda h: None + return todo + + def test_an_unknown_host_key_is_recognised(self): + for texte in ( + "Host key verification failed.", + "The authenticity of host '10.0.0.1' can't be established.", + "No ED25519 host key is known for 10.0.0.1", + ): + self.assertTrue(TODO._pve_hostkey_missing(texte), texte) + self.assertFalse(TODO._pve_hostkey_missing("Permission denied")) + + def _confirm(self, reponses): + """reponses : [(code, sortie)] pour chaque appel à pve.run.""" + it = iter(reponses) + with mock.patch.object( + pve, "run", side_effect=lambda *a, **k: next(it) + ): + import contextlib + import io + + out = io.StringIO() + with contextlib.redirect_stdout(out): + host = self._todo()._pve_confirm_host( + {"target": "erplibre@10.0.0.5", "jump": ""} + ) + return host, out.getvalue() + + def test_a_non_proxmox_host_is_refused_with_what_was_seen(self): + host, sortie = self._confirm([(127, "bash: pveversion: not found")]) + self.assertIsNone(host) + self.assertIn("pveversion", sortie) + + def test_a_non_root_access_gets_sudo(self): + """C'est le cas de la voie « VM QEMU locale » : cloud-init crée + erplibre, pas root.""" + host, _s = self._confirm([(0, PVEVERSION), (0, "1000\n"), (0, "")]) + self.assertEqual("sudo ", host["sudo"]) + self.assertEqual("9.2.11", host["version"]) + + def test_root_needs_no_sudo(self): + host, _s = self._confirm([(0, PVEVERSION), (0, "0\n")]) + self.assertEqual("", host["sudo"]) + + def test_without_root_nor_passwordless_sudo_it_stops(self): + """Un sudo qui réclame un mot de passe bloquerait chaque commande du + menu sur une invite que personne ne voit.""" + host, sortie = self._confirm( + [ + (0, PVEVERSION), + (0, "1000\n"), + (1, "sudo: a password is required"), + ] + ) + self.assertIsNone(host) + self.assertIn("root", sortie) + + +class TestLeMenu(unittest.TestCase): + def test_proxmox_sits_right_under_qemu_in_the_deploy_menu(self): + src = open("script/todo/todo.py", encoding="utf-8").read() + i_qemu = src.index('"QEMU/KVM - Deploy an Ubuntu VM (libvirt)"') + i_pve = src.index('"Proxmox VE - Deploy a VM on a remote host"') + i_ntfy = src.index('"Deploy - Install NTFY notification server"') + self.assertLess(i_qemu, i_pve) + self.assertLess(i_pve, i_ntfy) + + def test_the_dispatch_follows_the_list(self): + src = open("script/todo/todo.py", encoding="utf-8").read() + self.assertIn( + 'elif status == "6":\n self.prompt_execute_proxmox()', + src, + ) + self.assertIn( + 'elif status == "7":\n self._deploy_ntfy_server()', + src, + ) + + def test_every_qemu_entry_has_its_proxmox_counterpart(self): + """L'équivalent des dix-sept commandes, plus le choix de l'hôte.""" + src = open("script/todo/todo.py", encoding="utf-8").read() + debut = src.index(" def prompt_execute_proxmox(self):") + bloc = src[debut : src.index(" def _pve_fetch_image(self):")] + for n in range(1, 19): + self.assertIn(f'elif status == "{n}":', bloc, f"entrée {n}") + + def test_the_script_is_valid_python(self): + res = subprocess.run( + [ + sys.executable, + "-c", + "import ast;ast.parse(open('script/proxmox/proxmox_deploy.py',encoding='utf-8').read())", + ], + capture_output=True, + text=True, + ) + self.assertEqual(0, res.returncode, res.stderr) + + +if __name__ == "__main__": + unittest.main(verbosity=1) From 292a03be8dfaf5d9e2381ae2ad911c7558cda8bd Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sun, 23 Aug 2026 17:26:41 -0400 Subject: [PATCH 16/16] =?UTF-8?q?[ADD]=20run:=20choisir=20la=20base=20au?= =?UTF-8?q?=20d=C3=A9marrage,=20sans=20jamais=20bloquer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit « ./run.sh » sans argument partait sans base et l'on choisissait dans le gestionnaire web. Il choisit maintenant : une seule base, il démarre dessus ; plusieurs, il affiche un menu numéroté. Ce qui décide n'est pas le menu mais l'endroit où il ne doit PAS s'ouvrir. systemd écrit « ExecStart=/bin/bash …/run.sh » sans argument, avec Restart=always : une question posée là serait une boucle de redémarrage. Le défaut implicite exige donc un terminal sur stdin ET sur stderr — pas stdout, qui est toujours un tube dans la substitution qui nous lit. Mesuré : sans terminal, la ligne d'arguments est identique à l'octet et la sonde, qui coûte 0,8 s, n'est jamais appelée. --- EN --- « ./run.sh » with no argument started without a database and one picked in the web manager. It now picks: a single database, it starts on it; several, it shows a numbered menu. What decides is not the menu but where it must NOT open. systemd writes « ExecStart=/bin/bash …/run.sh » with no argument and Restart=always: a question asked there would be a restart loop. The implicit default therefore requires a terminal on stdin AND on stderr — not stdout, which is always a pipe inside the substitution that reads us. Measured: with no terminal the argument line is byte-for-byte identical and the 0.8 s probe is never called. Assisted-by: Claude Opus 5 --- doc/RUN.base.md | 32 ++ doc/RUN.fr.md | 15 + doc/RUN.md | 15 + run.sh | 49 ++- script/database/lib_db_select.sh | 205 ++++++++++++ script/test/run_unit_test.sh | 3 +- test/test_run_sh_auto_db.py | 546 +++++++++++++++++++++++++++++++ 7 files changed, 862 insertions(+), 3 deletions(-) create mode 100644 script/database/lib_db_select.sh create mode 100644 test/test_run_sh_auto_db.py diff --git a/doc/RUN.base.md b/doc/RUN.base.md index 042e8c8..d6787fa 100644 --- a/doc/RUN.base.md +++ b/doc/RUN.base.md @@ -37,6 +37,38 @@ Simplement ./run.sh ``` + +Without any argument, ERPLibre picks the database for you: with a single +one it starts on it, with several it shows a numbered menu. It only asks +when a real terminal is there — a service started by systemd behaves +exactly as before. + +Two options steer that, and neither reaches Odoo: + +| option | effect | +|---|---| +| `--auto-erplibre` | pick the database even when other arguments are given | +| `--no-cli-erplibre` | never show the menu; a lone database is still taken | + +Naming a database yourself — `-d`, `--database` — turns the whole thing +off, and so does a `db_name` set in the configuration file. + + +Sans aucun argument, ERPLibre choisit la base pour vous : s'il n'y en a +qu'une il démarre dessus, s'il y en a plusieurs il affiche un menu +numéroté. Il ne pose la question que devant un vrai terminal — un service +lancé par systemd se comporte exactement comme avant. + +Deux options le pilotent, et aucune n'arrive jusqu'à Odoo : + +| option | effet | +|---|---| +| `--auto-erplibre` | choisir la base même quand d'autres arguments sont donnés | +| `--no-cli-erplibre` | ne jamais afficher le menu ; une base seule est quand même retenue | + +Nommer soi-même une base — `-d`, `--database` — désactive tout, de même +qu'un `db_name` posé dans le fichier de configuration. + With arguments diff --git a/doc/RUN.fr.md b/doc/RUN.fr.md index f23b47d..4c1ceb7 100644 --- a/doc/RUN.fr.md +++ b/doc/RUN.fr.md @@ -17,6 +17,21 @@ Simplement ./run.sh ``` +Sans aucun argument, ERPLibre choisit la base pour vous : s'il n'y en a +qu'une il démarre dessus, s'il y en a plusieurs il affiche un menu +numéroté. Il ne pose la question que devant un vrai terminal — un service +lancé par systemd se comporte exactement comme avant. + +Deux options le pilotent, et aucune n'arrive jusqu'à Odoo : + +| option | effet | +|---|---| +| `--auto-erplibre` | choisir la base même quand d'autres arguments sont donnés | +| `--no-cli-erplibre` | ne jamais afficher le menu ; une base seule est quand même retenue | + +Nommer soi-même une base — `-d`, `--database` — désactive tout, de même +qu'un `db_name` posé dans le fichier de configuration. + Avec des arguments ```bash diff --git a/doc/RUN.md b/doc/RUN.md index 3567741..7a51151 100644 --- a/doc/RUN.md +++ b/doc/RUN.md @@ -17,6 +17,21 @@ Simply ./run.sh ``` +Without any argument, ERPLibre picks the database for you: with a single +one it starts on it, with several it shows a numbered menu. It only asks +when a real terminal is there — a service started by systemd behaves +exactly as before. + +Two options steer that, and neither reaches Odoo: + +| option | effect | +|---|---| +| `--auto-erplibre` | pick the database even when other arguments are given | +| `--no-cli-erplibre` | never show the menu; a lone database is still taken | + +Naming a database yourself — `-d`, `--database` — turns the whole thing +off, and so does a `db_name` set in the configuration file. + With arguments ```bash diff --git a/run.sh b/run.sh index f715249..119c695 100755 --- a/run.sh +++ b/run.sh @@ -13,10 +13,55 @@ if [ ! -f "${CONFIG_PATH}" ]; then fi fi +# Deux options qui appartiennent à ERPLibre, retirées avant de passer la +# main : Odoo ne les connaît pas et mourrait sur « no such option ». +# --auto-erplibre arme le choix de la base à démarrer +# --no-cli-erplibre interdit le menu, sans interdire le choix +# Sans AUCUN argument, le choix s'arme de lui-même : c'est « make run », +# quelqu'un devant son terminal. Ce défaut-là reste timide — il exige un +# terminal des deux côtés — parce que systemd lance lui aussi run.sh sans +# argument, avec Restart=always. +EL_ARGS=() +EL_AUTO=0 +EL_AUTO_EXPLICITE=0 +EL_NO_CLI=0 +[ $# -eq 0 ] && EL_AUTO=1 +while [ $# -gt 0 ]; do + case "$1" in + --auto-erplibre) + EL_AUTO=1 + EL_AUTO_EXPLICITE=1 + ;; + --no-cli-erplibre) EL_NO_CLI=1 ;; + # Tout le reste passe tel quel, y compris les arguments vides et ceux + # qui portent des espaces : un tableau, jamais une chaîne reconstruite. + *) EL_ARGS+=("$1") ;; + esac + shift +done + +EL_DB=() +EL_LIB="./script/database/lib_db_select.sh" +if [ "${EL_AUTO}" = "1" ] && [ -f "${EL_LIB}" ]; then + # shellcheck source=script/database/lib_db_select.sh + . "${EL_LIB}" + EL_DB_NAME="$(el_db_select "${CONFIG_PATH}" "${EL_NO_CLI}" \ + "${EL_AUTO_EXPLICITE}" "${EL_ARGS[@]}")" + retSelect=$? + # 130 : on a renoncé au menu. Ne pas démarrer Odoo pour autant, et ne pas + # rendre 1 non plus — run.sh:26 réserve déjà 1 à « Odoo a échoué ». + if [ ${retSelect} -eq 130 ]; then + exit 130 + fi + if [ -n "${EL_DB_NAME}" ]; then + EL_DB=(-d "${EL_DB_NAME}") + fi +fi + if [ "$ODOO_MODE_TEST" = "true" ]; then - ./odoo_bin.sh -c "${CONFIG_PATH}" --limit-time-real 99999 --limit-time-cpu 99999 --limit-memory-hard=0 --log-level=test --test-enable --no-http --stop-after-init "$@" + ./odoo_bin.sh -c "${CONFIG_PATH}" --limit-time-real 99999 --limit-time-cpu 99999 --limit-memory-hard=0 --log-level=test --test-enable --no-http --stop-after-init "${EL_DB[@]}" "${EL_ARGS[@]}" else - ./odoo_bin.sh -c "${CONFIG_PATH}" --limit-time-real 99999 --limit-time-cpu 99999 --limit-memory-hard=0 "$@" + ./odoo_bin.sh -c "${CONFIG_PATH}" --limit-time-real 99999 --limit-time-cpu 99999 --limit-memory-hard=0 "${EL_DB[@]}" "${EL_ARGS[@]}" fi # When need more memory RAM for instance by force #python3 ./odoo/odoo-bin -c ${CONFIG_PATH} --limit-time-real 99999 --limit-time-cpu 99999 --limit-memory-soft=8589934592 --limit-memory-hard=10737418240 $@ diff --git a/script/database/lib_db_select.sh b/script/database/lib_db_select.sh new file mode 100644 index 0000000..6b56c56 --- /dev/null +++ b/script/database/lib_db_select.sh @@ -0,0 +1,205 @@ +#!/usr/bin/env bash +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +# +# Bibliothèque SOURÇABLE : quelle base de données démarrer. +# +# « ./run.sh » sans argument démarrait Odoo sans base, et l'on choisissait +# dans le gestionnaire web. Quand il n'y a qu'une base, la question ne se +# pose pas ; quand il y en a quinze, un menu au terminal vaut mieux qu'une +# page web. +# +# TOUT ce qui s'affiche part sur stderr. Sur stdout, UNIQUEMENT le nom +# retenu : l'appelant lit `el_db_select` par substitution de commande, et +# un menu écrit sur stdout deviendrait une partie du nom de la base. +# +# Aucun `set` ici : une bibliothèque sourcée impose ses options à son hôte, +# et run.sh lit `$ODOO_MODE_TEST` sans valeur par défaut — `set -u` le +# tuerait avant tout le reste. + +# Les couleurs peuvent déjà venir de l'hôte ; ne pas les écraser. +: "${Red:=\033[0;31m}" +: "${Yellow:=\033[0;33m}" +: "${Color_Off:=\033[0m}" + +_el_db_say() { + echo -e "$*" >&2 +} + +_el_db_tty() { + # Deux descripteurs, et le second est stderr — PAS stdout. + # + # `validate_to_continue.sh` teste stdout parce qu'il y écrit sa question. + # Ici la question part sur stderr, et l'appelant nous lit par + # substitution de commande : dans `$( … )`, stdout est TOUJOURS un tube, + # donc `-t 1` serait faux même devant un vrai terminal et le menu ne + # s'ouvrirait jamais. Ce qu'il faut savoir, c'est « la personne verra- + # t-elle l'invite », et l'invite sort par stderr. + # + # stdin compte tout autant : sans lui, `read` ne rendrait jamais rien. + # Le TUI de todo.py met stdout ET stderr en tube en laissant stdin + # intact — c'est le cas que ce garde doit refuser. + # + # Fonction isolée pour qu'un test puisse la remplacer sans pseudo-terminal. + [ -t 0 ] && [ -t 2 ] +} + +el_db_already_chosen() { + # L'appelant a-t-il déjà nommé sa base ? Les quatre formes qu'optparse + # accepte, plus « -d » nu en fin de ligne — ce que produit + # « ./run.sh -d $(bd) » de conf/make.robotlibre.Makefile quand bd est + # vide. Dans tous ces cas on se retire : c'est son intention, pas la + # nôtre, et on ne complète pas un « -d » resté sans valeur. + while [ $# -gt 0 ]; do + case "$1" in + -d | -d?* | --database | --database=*) return 0 ;; + esac + shift + done + return 1 +} + +el_db_config_has_name() { + # Une configuration de production nomme sa base. L'option de ligne de + # commande l'emporte sur le fichier : injecter « -d » par-dessus + # écraserait ce choix en silence. + local fichier="$1" + [ -f "${fichier}" ] || return 1 + grep -Eq '^[[:space:]]*db_name[[:space:]]*=[[:space:]]*[^[:space:]]' \ + "${fichier}" || return 1 + # « False » et « None » sont la façon d'Odoo d'écrire « aucune ». + if grep -Eq \ + '^[[:space:]]*db_name[[:space:]]*=[[:space:]]*(False|false|None)[[:space:]]*$' \ + "${fichier}"; then + return 1 + fi + return 0 +} + +el_db_list() { + # Les bases, une par ligne. Sortie non nulle si la SONDE a échoué — et + # alors on ne rend rien plutôt que d'inventer. + # + # stderr jeté, jamais fusionné : le CLI « db » n'appelle pas + # parse_config(), donc aucun journal n'est configuré et tout part nu sur + # stderr. Fusionner ferait de chaque ligne d'une trace un nom de base — + # le défaut que script/todo/database_manager.py a déjà dû corriger. + # + # Le mode couverture est neutralisé pour ce seul appel : sinon la sonde + # se mesure elle-même et sème un .coverage.* dans le rapport. + local brut retenu + brut="$(ODOO_MODE_COVERAGE= ./odoo_bin.sh db --list 2>/dev/null)" || return 1 + # « _cache_ » nomme les bases-modèles, pas des bases de travail. Sur une + # machine qui vient de lancer les tests, ce peut être la SEULE : sans ce + # filtre, la règle « une seule base » démarrerait Odoo sur un modèle. + retenu="$(printf '%s\n' "${brut}" \ + | tr -d '\r' \ + | grep -v '^[[:space:]]*$' \ + | grep -v '^_cache_')" + [ -n "${retenu}" ] && printf '%s\n' "${retenu}" + return 0 +} + +el_db_choose() { + # Le menu, entièrement sur stderr ; le nom retenu sur stdout. + # + # Numérotation à partir de 1 et « [0] » pour annuler : le vocabulaire + # des menus Python du projet, pour qu'on reconnaisse le même produit. + local -a bases=("$@") + local rang nom choix + while true; do + _el_db_say "" + _el_db_say "🗄 ${#bases[@]} bases de données. Laquelle démarrer ?" + rang=1 + for nom in "${bases[@]}"; do + _el_db_say " [${rang}] ${nom}" + rang=$((rang + 1)) + done + _el_db_say " [0] Annuler" + printf 'Choix : ' >&2 + # shellcheck disable=SC2162 + if ! read -r choix; then + # Ctrl-D : finir la ligne avant de partir, sinon l'invite reste + # collée au shell qui reprend la main. + _el_db_say "" + return 130 + fi + case "${choix}" in + 0) return 130 ;; + "" | *[!0-9]*) ;; + *) + if [ "${choix}" -ge 1 ] && [ "${choix}" -le "${#bases[@]}" ]; then + printf '%s\n' "${bases[$((choix - 1))]}" + return 0 + fi + ;; + esac + _el_db_say "${Red}Choix invalide${Color_Off} :" \ + "un nombre entre 0 et ${#bases[@]}." + done +} + +el_db_select() { + # Le nom de la base à démarrer, sur stdout — ou rien. 130 si l'on renonce. + # + # Les gardes vont de la moins chère à la plus chère : la sonde coûte + # 0,8 s (l'import d'Odoo, pas la requête) et ne se paie que lorsqu'elle + # peut servir. Sur tous les chemins automatisés, zéro appel. + # + # `explicite` distingue « --auto-erplibre a été demandé » de « run.sh a + # simplement reçu zéro argument ». Le défaut implicite doit rester + # timide : systemd lance « /bin/bash …/run.sh » SANS argument, avec + # Restart=always — un comportement neuf s'y déclencherait à chaque + # démarrage de production, sans que personne l'ait demandé. + local config="$1" + local sans_cli="$2" + local explicite="$3" + shift 3 + + el_db_already_chosen "$@" && return 0 + el_db_config_has_name "${config}" && return 0 + + # Sans demande explicite, il faut un terminal des deux côtés pour que la + # sélection s'arme du tout. Demandée, elle peut servir sans terminal — + # mais seulement là où aucune question n'est posée. + if [ "${explicite}" != "1" ] && ! _el_db_tty; then + return 0 + fi + + local liste + if ! liste="$(el_db_list)"; then + _el_db_say "${Yellow}⚠${Color_Off} Impossible de lister les bases ;" \ + "Odoo démarre sans base présélectionnée." + return 0 + fi + + local -a bases=() + while IFS= read -r nom; do + [ -n "${nom}" ] && bases+=("${nom}") + done <<< "${liste}" + + if [ "${#bases[@]}" -eq 0 ]; then + _el_db_say "${Yellow}⚠${Color_Off} Aucune base de données ;" \ + "Odoo démarre sur son gestionnaire web." + return 0 + fi + + if [ "${#bases[@]}" -eq 1 ]; then + _el_db_say "🗄 Une seule base : ${bases[0]}" + printf '%s\n' "${bases[0]}" + return 0 + fi + + if [ "${sans_cli}" = "1" ]; then + _el_db_say "${Yellow}⚠${Color_Off} ${#bases[@]} bases et" \ + "--no-cli-erplibre : aucune n'est présélectionnée." + return 0 + fi + if ! _el_db_tty; then + _el_db_say "${Yellow}⚠${Color_Off} ${#bases[@]} bases, mais pas de" \ + "terminal pour choisir : aucune n'est présélectionnée." + return 0 + fi + + el_db_choose "${bases[@]}" +} diff --git a/script/test/run_unit_test.sh b/script/test/run_unit_test.sh index f25c954..723f4d4 100755 --- a/script/test/run_unit_test.sh +++ b/script/test/run_unit_test.sh @@ -42,7 +42,8 @@ FILES=("$@") if [[ ${#FILES[@]} -eq 0 ]]; then # Aucun argument : tout ce que le dépôt sait tester sans base de données. mapfile -t FILES < <(ls test/test_qemu_*.py test/test_mobile_*.py \ - test/test_todo_*.py test/test_install_*.py 2>/dev/null) + test/test_todo_*.py test/test_install_*.py test/test_run_*.py \ + 2>/dev/null) fi fail=0 diff --git a/test/test_run_sh_auto_db.py b/test/test_run_sh_auto_db.py new file mode 100644 index 0000000..ba9a514 --- /dev/null +++ b/test/test_run_sh_auto_db.py @@ -0,0 +1,546 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""« ./run.sh » sans argument choisit sa base — sans jamais bloquer. + +Le danger n'est pas le menu, c'est l'endroit où il ne doit PAS s'ouvrir. +`script/systemd/install_daemon.sh:34` écrit `ExecStart=/bin/bash …/run.sh` +sans le moindre argument, avec `Restart=always` et `RestartSec=5` : une +question posée là ne serait pas une pause, ce serait une boucle de +redémarrage, chacun repayant 0,8 s d'import Odoo. + +D'où la forme des épreuves : on mesure d'abord ce qui NE doit pas arriver +— la sonde jamais lancée, aucun `-d` injecté, la ligne d'arguments intacte +à l'octet — et seulement ensuite le menu, derrière un vrai +pseudo-terminal, seul moyen de rendre `-t 0` et `-t 2` vrais ensemble. +""" + +import os +import pathlib +import pty +import select +import shutil +import subprocess +import tempfile +import time +import unittest + +RACINE = pathlib.Path(__file__).resolve().parents[1] +SCRIPT = RACINE / "run.sh" +LIB = RACINE / "script" / "database" / "lib_db_select.sh" + +DELAI = 30 + +# Le bouchon joue les DEUX rôles : la sonde (`db --list`) et le lancement +# d'Odoo. `$1` vaut « db » dans un cas et « -c » dans l'autre, le +# discriminant est donc fiable. +# +# `[%s]` par argument, et non `$*` : c'est ce format qui prouve qu'un +# argument vide et un argument à espaces traversent intacts. Une chaîne +# recollée les aurait perdus sans que rien ne le dise. +BOUCHON = """#!/usr/bin/env bash +case "$1" in + db) touch ./SONDE_APPELEE; %s ;; + *) printf 'ARGS:'; printf '[%%s]' "$@"; printf '\\n' ;; +esac +""" + +TROIS_BASES = BOUCHON % "printf '%s\\n' _cache_odoo18.0_base demo prod" +UNE_BASE = BOUCHON % "printf '%s\\n' _cache_odoo18.0_base demo" +QUE_DU_CACHE = BOUCHON % "printf '%s\\n' _cache_odoo18.0_base" +AUCUNE_BASE = BOUCHON % ":" +# Le cas qui compte pour la fusion des flux : la sonde RÉUSSIT, et parle +# quand même sur stderr. Le CLI « db » n'appelle jamais parse_config(), +# donc aucun journal n'est configuré et tout enregistrement part nu par là. +SONDE_BAVARDE = BOUCHON % ( + "printf 'WARNING odoo.modules.module: module xyz not loadable\\n' >&2;" + " printf '%s\\n' demo" +) +SONDE_CASSEE = BOUCHON % ( + "printf 'Traceback (most recent call last):\\n" + ' File \\"x.py\\", line 1\\nOperationalError\\n\' >&2; exit 1' +) + + +class Banc: + """Un dépôt jetable : run.sh ne fait aucun `cd` et résout tout du cwd.""" + + def __init__( + self, bouchon=TROIS_BASES, config="[options]\ndb_name = False\n" + ): + self.dossier = tempfile.mkdtemp(prefix="el_run_sh_") + chemin = pathlib.Path(self.dossier) + (chemin / "script" / "database").mkdir(parents=True) + shutil.copy(SCRIPT, chemin / "run.sh") + shutil.copy(LIB, chemin / "script" / "database" / "lib_db_select.sh") + (chemin / "config.conf").write_text(config, encoding="utf-8") + stub = chemin / "odoo_bin.sh" + stub.write_text(bouchon, encoding="utf-8") + stub.chmod(0o755) + self.chemin = chemin + + def sonde_appelee(self): + return (self.chemin / "SONDE_APPELEE").exists() + + def lancer(self, *argv, env=None, entree=None): + milieu = dict(os.environ) + milieu.pop("ODOO_MODE_TEST", None) + milieu.pop("ODOO_MODE_COVERAGE", None) + if env: + milieu.update(env) + return subprocess.run( + ["./run.sh", *argv], + cwd=self.dossier, + capture_output=True, + text=True, + timeout=DELAI, + input=entree if entree is not None else "", + env=milieu, + ) + + def conduire(self, *argv, frappe=""): + """Derrière un vrai pseudo-terminal — le seul `-t 0` et `-t 2` vrais.""" + maitre, esclave = pty.openpty() + proc = subprocess.Popen( + ["./run.sh", *argv], + cwd=self.dossier, + stdin=esclave, + stdout=esclave, + stderr=esclave, + close_fds=True, + ) + os.close(esclave) + if frappe: + time.sleep(0.4) + os.write(maitre, frappe.encode()) + morceaux = [] + # ÉCHÉANCE sur la LECTURE, pas seulement sur l'attente du fils. + # `os.read` sur un pseudo-terminal bloque tant que le maître est + # ouvert : si une régression ouvre un menu là où le test ne tape + # rien, la suite entière pend au lieu d'échouer. Mesuré — une + # mutation du filtre « _cache_ » a fait durer 600 s. + limite = time.monotonic() + DELAI + depasse = False + try: + while True: + reste = limite - time.monotonic() + if reste <= 0: + depasse = True + break + pret, _, _ = select.select([maitre], [], [], reste) + if not pret: + depasse = True + break + try: + bloc = os.read(maitre, 4096) + except OSError: + break + if not bloc: + break + morceaux.append(bloc) + finally: + if depasse: + proc.kill() + code = proc.wait(timeout=DELAI) + os.close(maitre) + texte = b"".join(morceaux).decode(errors="replace").replace("\r", "") + if depasse: + raise AssertionError( + f"run.sh n'a pas rendu la main en {DELAI} s — il attend" + f" sans doute une réponse. Vu :\n{texte}" + ) + return code, texte + + def nettoyer(self): + shutil.rmtree(self.dossier, ignore_errors=True) + + +class BancTest(unittest.TestCase): + bouchon = TROIS_BASES + config = "[options]\ndb_name = False\n" + + def setUp(self): + self.banc = Banc(self.bouchon, self.config) + self.addCleanup(self.banc.nettoyer) + + +class TestTheScriptsAreValid(unittest.TestCase): + def test_both_files_parse(self): + for chemin in (SCRIPT, LIB): + with self.subTest(chemin=chemin.name): + res = subprocess.run( + ["bash", "-n", str(chemin)], + capture_output=True, + text=True, + timeout=DELAI, + ) + self.assertEqual(0, res.returncode, res.stderr) + + def test_run_sh_is_still_executable(self): + self.assertTrue(os.access(SCRIPT, os.X_OK), SCRIPT) + + def test_the_library_announces_itself_sourceable(self): + # Convention du dépôt : lib_pip_provider.sh, lib_python_provider.sh. + self.assertIn( + "Bibliothèque SOURÇABLE", LIB.read_text(encoding="utf-8") + ) + + def test_the_library_sets_no_shell_option(self): + # Une bibliothèque sourcée imposerait ses options à run.sh, qui lit + # $ODOO_MODE_TEST sans valeur par défaut : `set -u` le tuerait. + for ligne in LIB.read_text(encoding="utf-8").splitlines(): + self.assertFalse( + ligne.strip().startswith("set -"), f"« {ligne.strip()} »" + ) + + +class TestNothingHappensWhereNobodyAsked(BancTest): + """Le contrat de non-régression : systemd, la migration, les scripts.""" + + def test_zero_argument_without_a_terminal_never_probes(self): + # Le cas systemd. La sonde coûte 0,8 s et se paierait à chaque + # redémarrage d'un service en boucle. + res = self.banc.lancer() + self.assertEqual(0, res.returncode, res.stderr) + self.assertFalse(self.banc.sonde_appelee(), res.stderr) + + def test_zero_argument_without_a_terminal_injects_nothing(self): + res = self.banc.lancer() + self.assertIn("ARGS:", res.stdout) + self.assertNotIn("[-d]", res.stdout) + + def test_the_forwarded_line_is_byte_for_byte_what_it_always_was(self): + res = self.banc.lancer() + self.assertEqual( + "ARGS:[-c][./config.conf][--limit-time-real][99999]" + "[--limit-time-cpu][99999][--limit-memory-hard=0]", + res.stdout.strip(), + res.stderr, + ) + + def test_an_empty_argument_and_one_with_spaces_survive(self): + # « ./run.sh -d $(bd) » de make.robotlibre produit un -d vide dès + # que bd n'est pas défini : reconstruire la ligne le perdrait. + res = self.banc.lancer("--log-handler", "a b", "-d", "") + self.assertIn("[--log-handler][a b][-d][]", res.stdout, res.stderr) + + def test_the_test_mode_branch_behaves_the_same(self): + res = self.banc.lancer(env={"ODOO_MODE_TEST": "true"}) + self.assertIn("[--test-enable]", res.stdout, res.stderr) + self.assertNotIn("[-d]", res.stdout) + self.assertFalse(self.banc.sonde_appelee()) + + def test_the_test_mode_branch_also_receives_the_chosen_database(self): + # Deux branches lancent Odoo. Oublier l'une des deux ne se voit + # nulle part ailleurs : « make test » passerait sans base et l'on + # chercherait la cause dans Odoo. + banc = Banc(UNE_BASE, self.config) + self.addCleanup(banc.nettoyer) + res = banc.lancer("--auto-erplibre", env={"ODOO_MODE_TEST": "true"}) + self.assertIn("[--test-enable]", res.stdout, res.stderr) + self.assertIn("[-d][demo]", res.stdout, res.stderr) + + def test_a_database_already_named_disarms_everything(self): + # Les quatre formes qu'optparse accepte, plus « -d » nu — ce que + # produit « ./run.sh -d $(bd) » quand bd n'est pas défini. On ne + # complète jamais le choix de l'appelant, et l'on ne paie pas la + # sonde pour rien. + for forme in ( + ["-d", "demo"], + ["-ddemo"], + ["--database", "demo"], + ["--database=demo"], + ["-d"], + ): + with self.subTest(forme=" ".join(forme)): + banc = Banc(self.bouchon, self.config) + self.addCleanup(banc.nettoyer) + res = banc.lancer("--auto-erplibre", *forme) + self.assertFalse(banc.sonde_appelee(), res.stderr) + # Rien d'ajouté : la ligne se termine sur ce que l'appelant + # a écrit, sans « -d » de plus derrière. + attendu = "".join(f"[{arg}]" for arg in forme) + self.assertTrue( + res.stdout.strip().endswith(attendu), res.stdout + ) + + +class TestTheExitContractIsUnchanged(unittest.TestCase): + """run.sh convertit tout échec d'Odoo en 1, et laisse passer le succès. + + L'analyse des options a inséré du code ENTRE le lancement d'Odoo et le + `retVal=$?` de la fin. Des commentaires ne touchent pas `$?` — vérifié + — mais une ligne exécutable ajoutée là volerait le code de sortie sans + que rien ne le dise. + """ + + def banc_qui_sort(self, code): + b = Banc(TROIS_BASES) + self.addCleanup(b.nettoyer) + (b.chemin / "odoo_bin.sh").write_text( + f"#!/usr/bin/env bash\nexit {code}\n", encoding="utf-8" + ) + (b.chemin / "odoo_bin.sh").chmod(0o755) + return b + + def test_a_failing_odoo_becomes_one(self): + res = self.banc_qui_sort(7).lancer("--workers", "0") + self.assertEqual(1, res.returncode, res.stderr) + + def test_a_successful_odoo_stays_zero(self): + res = self.banc_qui_sort(0).lancer("--workers", "0") + self.assertEqual(0, res.returncode, res.stderr) + + +class TestTheTwoOptionsNeverReachOdoo(BancTest): + def test_both_are_stripped(self): + res = self.banc.lancer( + "--auto-erplibre", "--no-cli-erplibre", "--workers", "0" + ) + self.assertNotIn("auto-erplibre", res.stdout, res.stdout) + self.assertNotIn("no-cli-erplibre", res.stdout, res.stdout) + self.assertIn("[--workers][0]", res.stdout) + + def test_a_neighbouring_option_is_not_eaten(self): + # Une correspondance trop large avalerait des options d'Odoo. + for voisine in ( + "--auto-erplibr", + "--auto", + "--no-cli", + "--auto-erplibrex", + ): + with self.subTest(voisine=voisine): + banc = Banc(self.bouchon, self.config) + self.addCleanup(banc.nettoyer) + res = banc.lancer(voisine) + self.assertIn(f"[{voisine}]", res.stdout, res.stderr) + + def test_odoo_never_sees_them_even_when_they_do_something(self): + banc = Banc(UNE_BASE, self.config) + self.addCleanup(banc.nettoyer) + res = banc.lancer("--auto-erplibre") + self.assertIn("[-d][demo]", res.stdout, res.stderr) + self.assertNotIn("erplibre]", res.stdout) + + +class TestWhatTheProbeReturns(unittest.TestCase): + def banc(self, bouchon, config="[options]\ndb_name = False\n"): + b = Banc(bouchon, config) + self.addCleanup(b.nettoyer) + return b + + def test_a_single_database_is_taken_without_asking(self): + banc = self.banc(UNE_BASE) + res = banc.lancer("--auto-erplibre") + self.assertIn("[-d][demo]", res.stdout, res.stderr) + + def test_template_databases_are_not_candidates(self): + # `_cache_…` est une base-modèle. Sur une machine qui vient de + # tester, ce peut être la seule : sans filtre, la règle « une + # seule base » démarrerait Odoo sur un modèle. + banc = self.banc(QUE_DU_CACHE) + res = banc.lancer("--auto-erplibre") + self.assertNotIn("[-d]", res.stdout, res.stderr) + self.assertNotIn("_cache_", res.stdout) + self.assertEqual(0, res.returncode) + + def test_no_database_at_all_starts_anyway(self): + banc = self.banc(AUCUNE_BASE) + res = banc.lancer("--auto-erplibre") + self.assertEqual(0, res.returncode, res.stderr) + self.assertNotIn("[-d]", res.stdout) + self.assertIn("ARGS:", res.stdout) + + def test_a_warning_on_stderr_never_becomes_a_database_name(self): + # Une sonde qui RÉUSSIT en parlant sur stderr : c'est là que la + # fusion des flux se voit. Avec `2>&1`, l'avertissement devient + # une seconde « base », la règle « une seule » ne s'applique plus + # et l'on ouvre un menu pour un texte de journal. Le défaut exact + # que script/database/db_drop_all.py porte encore. + banc = self.banc(SONDE_BAVARDE) + res = banc.lancer("--auto-erplibre") + self.assertIn("[-d][demo]", res.stdout, res.stdout) + self.assertNotIn("WARNING", res.stdout, res.stdout) + + def test_a_failing_probe_never_becomes_a_database_name(self): + # Fusionner stdout et stderr ferait de chaque ligne d'une trace un + # nom de base — le défaut que database_manager.py a déjà corrigé. + banc = self.banc(SONDE_CASSEE) + res = banc.lancer("--auto-erplibre") + self.assertEqual(0, res.returncode, res.stderr) + self.assertNotIn("[-d]", res.stdout) + self.assertNotIn("Traceback", res.stdout) + self.assertNotIn("OperationalError", res.stdout) + # …et le message doit dire « je n'ai pas pu lire », pas « il n'y + # en a pas ». Les deux mènent au même démarrage, mais pas au même + # endroit où chercher : PostgreSQL à terre n'est pas une machine + # neuve. Le CLI d'Odoo confond déjà les deux en rendant 0 sur une + # liste vide ; au moins ne pas ajouter notre propre confusion là + # où le code de sortie, lui, sait. + self.assertIn("lister", res.stderr, res.stderr) + self.assertNotIn("Aucune base", res.stderr, res.stderr) + + def test_an_empty_list_says_something_else_than_a_failure(self): + banc = self.banc(AUCUNE_BASE) + res = banc.lancer("--auto-erplibre") + self.assertIn("Aucune base", res.stderr, res.stderr) + self.assertNotIn("lister", res.stderr, res.stderr) + + def test_a_configuration_that_names_its_database_is_left_alone(self): + # L'option de ligne de commande l'emporte sur le fichier : injecter + # écraserait en silence le choix d'une production. + banc = self.banc(UNE_BASE, "[options]\ndb_name = prod\n") + res = banc.lancer("--auto-erplibre") + self.assertFalse(banc.sonde_appelee(), res.stderr) + self.assertNotIn("[-d]", res.stdout) + + def test_db_name_false_is_not_a_name(self): + for valeur in ("False", "false", "None"): + with self.subTest(valeur=valeur): + banc = self.banc(UNE_BASE, f"[options]\ndb_name = {valeur}\n") + res = banc.lancer("--auto-erplibre") + self.assertIn("[-d][demo]", res.stdout, res.stderr) + + +class TestWhenSeveralAndNoWayToAsk(unittest.TestCase): + def banc(self, bouchon=TROIS_BASES): + b = Banc(bouchon) + self.addCleanup(b.nettoyer) + return b + + def test_no_cli_erplibre_picks_nothing_and_says_so(self): + banc = self.banc() + res = banc.lancer("--auto-erplibre", "--no-cli-erplibre") + self.assertEqual(0, res.returncode, res.stderr) + self.assertNotIn("[-d]", res.stdout) + self.assertIn("no-cli-erplibre", res.stderr, res.stderr) + + def test_no_terminal_picks_nothing_and_says_so(self): + banc = self.banc() + res = banc.lancer("--auto-erplibre") + self.assertEqual(0, res.returncode, res.stderr) + self.assertNotIn("[-d]", res.stdout) + self.assertIn("2", res.stderr, res.stderr) + + def test_no_cli_erplibre_still_takes_a_lone_database(self): + # L'option interdit le MENU, pas le choix. Sinon elle ferait + # doublon avec « ne pas écrire --auto-erplibre ». + banc = self.banc(UNE_BASE) + res = banc.lancer("--auto-erplibre", "--no-cli-erplibre") + self.assertIn("[-d][demo]", res.stdout, res.stderr) + + +class TestTheMenuBehindARealTerminal(unittest.TestCase): + """Un pseudo-terminal : seul moyen de rendre `-t 0` et `-t 2` vrais.""" + + def banc(self, bouchon=TROIS_BASES): + b = Banc(bouchon) + self.addCleanup(b.nettoyer) + return b + + def test_zero_argument_opens_the_menu_and_honours_the_choice(self): + code, texte = self.banc().conduire(frappe="2\n") + self.assertEqual(0, code, texte) + self.assertIn("[1] demo", texte) + self.assertIn("[2] prod", texte) + self.assertIn("[-d][prod]", texte) + + def test_the_template_database_is_absent_from_the_menu(self): + code, texte = self.banc().conduire(frappe="1\n") + self.assertNotIn("_cache_", texte, texte) + self.assertIn("[-d][demo]", texte) + + def test_an_invalid_answer_asks_again_rather_than_guessing(self): + code, texte = self.banc().conduire(frappe="9\nzzz\n1\n") + self.assertEqual(0, code, texte) + self.assertIn("[-d][demo]", texte) + self.assertGreaterEqual(texte.count("Choix invalide"), 2, texte) + + def test_cancelling_starts_nothing_and_returns_130(self): + # 130, pas 1 : run.sh réserve déjà 1 à « Odoo a échoué ». + code, texte = self.banc().conduire(frappe="0\n") + self.assertEqual(130, code, texte) + self.assertNotIn("ARGS:", texte, texte) + + def test_end_of_input_is_a_refusal_not_a_hang(self): + code, texte = self.banc().conduire(frappe="\x04") + self.assertEqual(130, code, texte) + self.assertNotIn("ARGS:", texte) + + def test_a_lone_database_asks_nothing_at_all(self): + code, texte = self.banc(UNE_BASE).conduire(frappe="") + self.assertEqual(0, code, texte) + self.assertNotIn("Choix", texte, texte) + self.assertIn("[-d][demo]", texte) + + def test_no_cli_erplibre_silences_the_menu_even_on_a_terminal(self): + code, texte = self.banc().conduire("--no-cli-erplibre", frappe="") + self.assertEqual(0, code, texte) + self.assertNotIn("Choix", texte, texte) + self.assertNotIn("[-d]", texte) + + +class TestTheLibraryOnItsOwn(unittest.TestCase): + """Les fonctions, hors de run.sh, en remplaçant le garde de terminal.""" + + def bash(self, corps, entree="", cwd=None): + return subprocess.run( + ["bash", "-c", f". {LIB}\n{corps}"], + capture_output=True, + text=True, + timeout=DELAI, + input=entree, + cwd=cwd or str(RACINE), + ) + + def test_it_recognises_every_way_of_naming_a_database(self): + for forme in ("-d x", "-dx", "--database x", "--database=x", "-d"): + with self.subTest(forme=forme): + res = self.bash(f"el_db_already_chosen {forme} && echo OUI") + self.assertIn("OUI", res.stdout, res.stderr) + + def test_it_does_not_mistake_a_neighbour_for_a_database(self): + for forme in ("--dev x", "--data-dir /tmp", "--db-filter x", "-u all"): + with self.subTest(forme=forme): + res = self.bash(f"el_db_already_chosen {forme} || echo NON") + self.assertIn("NON", res.stdout, res.stderr) + + def test_the_menu_writes_nothing_to_stdout_but_the_name(self): + # Le menu part sur stdout et il devient une partie du nom de base. + res = self.bash("el_db_choose alpha zebre", entree="2\n") + self.assertEqual("zebre", res.stdout.strip(), res.stdout) + self.assertIn("[1] alpha", res.stderr) + + def test_the_guard_tests_stdin_and_stderr_not_stdout(self): + # Dans `$( … )`, stdout est TOUJOURS un tube : tester -t 1 fermerait + # le menu même devant un vrai terminal. Mesuré, puis corrigé. + # + # Les commentaires sont retirés : ils PARLENT de « -t 1 », et un + # test qui les lit accuserait la documentation du défaut qu'elle + # explique. + texte = LIB.read_text(encoding="utf-8") + debut = texte.index("_el_db_tty()") + corps = "\n".join( + ligne + for ligne in texte[debut : texte.index("}", debut)].splitlines() + if not ligne.strip().startswith("#") + ) + self.assertIn("-t 0", corps) + self.assertIn("-t 2", corps) + self.assertNotIn("-t 1", corps) + + def test_the_guard_refuses_a_pipe_on_stderr(self): + # Le cas du TUI de todo.py : stdout et stderr en tube, stdin intact. + res = subprocess.run( + ["bash", "-c", f". {LIB}\n_el_db_tty && echo ARME || echo REPOS"], + capture_output=True, + text=True, + timeout=DELAI, + cwd=str(RACINE), + ) + self.assertIn("REPOS", res.stdout, res.stdout) + + +if __name__ == "__main__": + unittest.main()