diff --git a/CHANGELOG.base.md b/CHANGELOG.base.md index 35ae733..aad19f5 100644 --- a/CHANGELOG.base.md +++ b/CHANGELOG.base.md @@ -189,6 +189,20 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi - Le tableau de bord d'installation affiche la RAM de l'hôte, la RAM utilisée et l'uptime d'une VM, et depuis combien de temps un journal est muet - Une VM accueille sa connexion SSH avec les commandes propres à sa distribution + +## Fixed + +## Corrigé + + +- The mobile bundle check accepted only the pack layout, when a real build ships one tar.gz per repository. It failed on ` : index.json absent` and stopped `compile_and_run.sh` before the APK — since 2026-08-20, for anyone on the current mobile main. It now accepts both layouts, and proves the presence of EVERY promised file rather than a sample of twenty: streaming all 139 archives costs 6 s, and 124 350 files are accounted for +- The bundle test guarded the ZIP entry limit by demanding a `chunk` field on every file, which is the pack layout rather than the limit itself. It now counts the entries the APK will carry — 278 against a ceiling of 65 535 — so either layout passes and a return to file-per-source still fails + + + +- Le vérificateur du transfert mobile n'acceptait que la disposition en packs, quand une compilation réelle livre un tar.gz par dépôt. Il échouait sur ` : index.json absent` et arrêtait `compile_and_run.sh` avant l'APK — depuis le 2026-08-20, pour quiconque est sur le main mobile actuel. Il accepte désormais les deux dispositions, et prouve la présence de CHAQUE fichier promis plutôt qu'un échantillon de vingt : traverser les 139 archives coûte 6 s, et 124 350 fichiers sont comptés +- Le test du bundle gardait la limite d'entrées du ZIP en exigeant un champ `chunk` sur chaque fichier, c'est-à-dire la disposition en packs plutôt que la limite elle-même. Il compte maintenant les entrées que portera l'APK — 278 pour un plafond de 65 535 — si bien que les deux dispositions passent et qu'un retour au fichier-par-source échoue toujours + ## Removed diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 94d56b9..f2d1db0 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -89,6 +89,11 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi - Le tableau de bord d'installation affiche la RAM de l'hôte, la RAM utilisée et l'uptime d'une VM, et depuis combien de temps un journal est muet - Une VM accueille sa connexion SSH avec les commandes propres à sa distribution +## Corrigé + +- Le vérificateur du transfert mobile n'acceptait que la disposition en packs, quand une compilation réelle livre un tar.gz par dépôt. Il échouait sur ` : index.json absent` et arrêtait `compile_and_run.sh` avant l'APK — depuis le 2026-08-20, pour quiconque est sur le main mobile actuel. Il accepte désormais les deux dispositions, et prouve la présence de CHAQUE fichier promis plutôt qu'un échantillon de vingt : traverser les 139 archives coûte 6 s, et 124 350 fichiers sont comptés +- Le test du bundle gardait la limite d'entrées du ZIP en exigeant un champ `chunk` sur chaque fichier, c'est-à-dire la disposition en packs plutôt que la limite elle-même. Il compte maintenant les entrées que portera l'APK — 278 pour un plafond de 65 535 — si bien que les deux dispositions passent et qu'un retour au fichier-par-source échoue toujours + ## Retiré - Le support d'Ubuntu 20.04 et 22.04, sur toutes les architectures : pikepdf réclame qpdf 12.2, dont la compilation exige C++20, quand focal livre GCC 9 et ne publie pas de `g++-10` pour s390x diff --git a/CHANGELOG.md b/CHANGELOG.md index e3b92ba..8e4e37f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -87,6 +87,11 @@ Recreating the virtual environment, use installation guide from tool `make`. - The install dashboard shows the host RAM, a VM's used RAM and uptime, and how long a log has been silent - A VM greets its SSH login with the distribution's own commands +## Fixed + +- The mobile bundle check accepted only the pack layout, when a real build ships one tar.gz per repository. It failed on ` : index.json absent` and stopped `compile_and_run.sh` before the APK — since 2026-08-20, for anyone on the current mobile main. It now accepts both layouts, and proves the presence of EVERY promised file rather than a sample of twenty: streaming all 139 archives costs 6 s, and 124 350 files are accounted for +- The bundle test guarded the ZIP entry limit by demanding a `chunk` field on every file, which is the pack layout rather than the limit itself. It now counts the entries the APK will carry — 278 against a ceiling of 65 535 — so either layout passes and a return to file-per-source still fails + ## Removed - Ubuntu 20.04 and 22.04 support, on every architecture: pikepdf needs qpdf 12.2, whose build requires C++20, while focal ships GCC 9 and publishes no `g++-10` for s390x diff --git a/mobile/compile_and_run.sh b/mobile/compile_and_run.sh index 151eca1..e327197 100755 --- a/mobile/compile_and_run.sh +++ b/mobile/compile_and_run.sh @@ -14,11 +14,14 @@ npm run build || exit 1 # Le transfert des dépôts du manifeste DANS l'application est ce qui fait # l'intérêt de son navigateur de code hors ligne, et il peut être vide sans que -# la compilation le dise. Ces dépôts entrent en packs — un APK est un ZIP borné -# à 65535 entrées, quand un fichier par source en réclamait 123 678 — et le -# vérificateur relit un échantillon depuis les tranches pour le comparer à la -# source. Trois pannes qu'un « build OK » passe sous silence : transfert vide, -# tranche absente, index qui promet des octets que sa tranche n'a pas. +# la compilation le dise. Ces dépôts entrent dans des conteneurs — un APK est +# un ZIP borné à 65535 entrées, quand un fichier par source en réclamait +# 124 350 — +# soit une archive tar.gz par dépôt, soit des tranches pack. Le vérificateur +# accepte les deux, prouve la présence de CHAQUE fichier promis, et relit un +# échantillon octet pour octet contre la source. Quatre pannes qu'un +# « build OK » passe sous silence : transfert vide, conteneur absent, index qui +# promet un fichier que son conteneur n'a pas, octets qui diffèrent. # # Même vérification que l'installation d'une VM, même script : une seule # autorité. diff --git a/script/analyse/check_instance_state.py b/script/analyse/check_instance_state.py new file mode 100755 index 0000000..ae3c74e --- /dev/null +++ b/script/analyse/check_instance_state.py @@ -0,0 +1,376 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Dans quel état est cette instance — et pour quel usage on la destine. + +Le même chiffre veut dire deux choses opposées. Zéro cron actif est le +SUCCÈS attendu d'une copie de développement, et une panne totale sur une +production. Zéro serveur de courriel rassure sur l'une, condamne l'autre. +Un rapport qui ignore cette différence crie au loup sur ce que l'on vient +de demander, et l'on cesse alors de le lire — c'est la faute que ce dépôt +a déjà corrigée trois fois. + +L'attente est donc DÉCLARÉE, `--expect copy` ou `--expect live`, et +chaque contrôle dit ce qu'il juge sous l'une et sous l'autre. Ce qui n'a +pas de sens sous l'attente courante n'est pas affiché en vert : il est +affiché comme non jugé, avec la raison. + +Mesuré, et c'est ce qui a fixé la conception +-------------------------------------------- +Sur la base 12 d'ORIGINE et sur sa migrée 18, jamais démarrées : + + crons en retard de plus d'un cycle 11 et 8 + lignes db_backup 0 et 0 + +Le retard n'a rien à voir avec la migration : personne ne fait tourner le +cadenceur d'une base restaurée. Sous `--expect copy`, ces deux contrôles +ne sont donc pas jugés du tout. Les afficher en rouge aurait été du bruit +pur ; les afficher en vert, un mensonge. + +Ce qui se juge SOUS LES DEUX +---------------------------- +La neutralisation, elle, se mesure. Sur sept bases dont le nom portait +« neutralize », `database.is_neutralized` était absent des sept, avec +jusqu'à 35 crons actifs et le domaine de courriel du CLIENT en place. + +On ne lit que des BOOLÉENS de présence pour tout ce qui touche à un +secret. Une clé de paiement vivante a été trouvée dans une base de test ; +un rapport finit dans un billet ou devant un agent, et n'a aucune raison +de la porter. + +Lecture seule : `default_transaction_read_only=on`, imposé par le serveur. +""" + +from __future__ import annotations + +import os +import sys + +sys.path.append( + os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..")) +) + +try: + from script.todo.todo_i18n import t +except Exception: # pragma: no cover - repli si i18n indisponible + + def t(key: str) -> str: + return key + + +from script.analyse import lib_analyse # noqa: E402 + +COPY = "copy" +LIVE = "live" +ATTENTES = (COPY, LIVE) + +COULEURS = { + "broken": "\033[31m", + "watch": "\033[33m", + "ok": "\033[32m", + "dim": "\033[90m", +} +RESET = "\033[0m" + + +def paint(texte, genre, colour): + if not colour: + return texte + return f"{COULEURS.get(genre, '')}{texte}{RESET}" + + +# Une politique dit COMMENT lire le nombre sous une attente donnée : +# ("zero", gravité) il doit valoir zéro +# ("nonzero", gravité) il doit être non nul +# ("info",) on montre, on ne juge pas +# ("skip", raison) sans objet ici, et l'on DIT pourquoi +CONTROLES = ( + { + "key": "neutralized", + "section": "Neutralisation", + "title": "database.is_neutralized", + "sql": "SELECT count(*) FROM ir_config_parameter" + " WHERE key='database.is_neutralized'" + " AND value IN ('true','True','1')", + "copy": ("nonzero", "broken"), + "live": ("zero", "broken"), + "why_copy": "Odoo does not consider this database neutralised;" + " modules that neutralise themselves never ran.", + "why_live": "A production marked as neutralised has had its" + " scheduled actions and outgoing mail disabled.", + }, + { + "key": "mail_server_open", + "section": "Neutralisation", + "title": "Mail servers that could actually send", + "sql": "SELECT count(*) FROM ir_mail_server WHERE active" + " AND coalesce(smtp_host,'') NOT IN ('invalid','localhost.invalid')", + "copy": ("zero", "broken"), + "live": ("info",), + "why_copy": "A copy that can send reaches the customer's real" + " contacts.", + "why_live": "", + }, + { + "key": "mail_server_total", + "section": "Neutralisation", + "title": "Mail servers declared at all", + "sql": "SELECT count(*) FROM ir_mail_server", + "copy": ("nonzero", "watch"), + "live": ("nonzero", "watch"), + "why_copy": "With NO server, Odoo falls back to smtp_server from" + " the config file — which is why Odoo's own neutralize.sql" + " INSERTS a blocking one instead of deleting them all.", + "why_live": "With no server at all, Odoo silently uses the" + " smtp_server from the config file.", + }, + { + "key": "payment_live", + "section": "Neutralisation", + "title": "Payment providers neither disabled nor in test", + "sql": "SELECT count(*) FROM payment_provider" + " WHERE state NOT IN ('disabled','test')", + "copy": ("zero", "broken"), + "live": ("info",), + "why_copy": "A copy can charge real cards.", + "why_live": "", + }, + { + "key": "url_mismatch", + "section": "Neutralisation", + "title": "Mail domain that does not match the base URL", + "sql": "SELECT CASE WHEN EXISTS (SELECT 1 FROM ir_config_parameter c" + " JOIN ir_config_parameter u ON u.key='web.base.url'" + " WHERE c.key='mail.catchall.domain'" + " AND position(c.value in u.value) = 0) THEN 1 ELSE 0 END", + "copy": ("info",), + "live": ("zero", "watch"), + "why_copy": "", + "why_live": "Portal links, invoice QR codes and reply addresses" + " point somewhere else than the instance itself.", + }, + { + "key": "cron_active", + "section": "Scheduler", + "title": "Scheduled actions active", + "sql": "SELECT count(*) FROM ir_cron WHERE active", + "copy": ("zero", "watch"), + "live": ("nonzero", "broken"), + "why_copy": "A copy that still runs them sends mail and posts" + " payments.", + "why_live": "Nothing at all runs on a schedule.", + }, + { + "key": "cron_late", + "section": "Scheduler", + "title": "Scheduled actions late by more than one cycle", + "sql": "SELECT count(*) FROM ir_cron c WHERE c.active" + " AND c.nextcall <" + " now() - (c.interval_number || ' ' || c.interval_type)::interval", + "copy": ( + "skip", + "Nobody runs the scheduler on a restored copy:" + " measured 11 late on an untouched source database.", + ), + "live": ("zero", "broken"), + "why_copy": "", + "why_live": "A fixed threshold misses them — a ten-minute job one" + " hour late matters, a monthly one does not.", + }, + { + "key": "backup_rows", + "section": "Backups", + "title": "Backup configurations recorded", + "sql": "SELECT count(*) FROM db_backup", + "copy": ( + "skip", + "update_prod_to_dev deletes them on purpose;" + " their absence here proves nothing.", + ), + "live": ("nonzero", "broken"), + "why_copy": "", + "why_live": "The backup scheduler runs and backs up nothing.", + }, + { + "key": "mail_stuck", + "section": "Queues", + "title": "Messages stuck in the outgoing queue", + "sql": "SELECT count(*) FROM mail_mail" + " WHERE state IN ('outgoing','exception')", + "copy": ("info",), + "live": ("zero", "watch"), + "why_copy": "", + "why_live": "Visible to the customer, invisible to the operator.", + }, + { + "key": "all_internal_are_admin", + "section": "Users", + "title": "Internal users who are ALL system administrators", + "sql": "SELECT CASE WHEN (SELECT count(*) FROM res_users u" + " WHERE u.active AND EXISTS (SELECT 1 FROM res_groups_users_rel r" + " JOIN ir_model_data d ON d.model='res.groups' AND d.res_id=r.gid" + " WHERE r.uid=u.id AND d.module='base' AND d.name='group_user'))" + " = (SELECT count(*) FROM res_users u WHERE u.active" + " AND EXISTS (SELECT 1 FROM res_groups_users_rel r" + " JOIN ir_model_data d ON d.model='res.groups' AND d.res_id=r.gid" + " WHERE r.uid=u.id AND d.module='base' AND d.name='group_system'))" + " THEN 1 ELSE 0 END", + "copy": ("zero", "watch"), + "live": ("zero", "watch"), + "why_copy": "No ordinary user exists to test visibility with — any" + " access-rights check run here proves nothing.", + "why_live": "Everyone can change everything, and no rule is ever" + " exercised.", + }, +) + +SECTIONS = ("Neutralisation", "Scheduler", "Backups", "Queues", "Users") + + +def inspect(database, config_path=None): + """Passer chaque contrôle. Un contrôle illisible n'est PAS un zéro.""" + resultats = {} + for controle in CONTROLES: + try: + brut = lib_analyse.run_psql( + database, controle["sql"], config_path=config_path + ).strip() + resultats[controle["key"]] = int(brut.splitlines()[0]) + except Exception as exc: # noqa: BLE001 - on rapporte, on ne meurt pas + resultats[controle["key"]] = { + "error": str(exc).splitlines()[0][:120] + } + return resultats + + +def verdict(controle, valeur, attente): + """(genre, gravité, raison) pour ce contrôle sous cette attente. + + `genre` vaut ok, bad, info, skip ou unreadable — jamais autre chose, + et surtout jamais « ok » par défaut : un contrôle sans politique est un + oubli, pas une bonne nouvelle. + """ + if isinstance(valeur, dict): + return "unreadable", "watch", valeur.get("error", "") + politique = controle.get(attente) + if not politique: + return "skip", "dim", "" + genre = politique[0] + if genre == "skip": + return "skip", "dim", t(politique[1]) + if genre == "info": + return "info", "dim", "" + raison = t(controle.get(f"why_{attente}", "") or "") + if genre == "zero": + return ( + ("ok", "ok", "") if not valeur else ("bad", politique[1], raison) + ) + if genre == "nonzero": + return ("ok", "ok", "") if valeur else ("bad", politique[1], raison) + return "skip", "dim", "" + + +def findings(resultats, attente): + """Ce qui ne va pas, et rien d'autre — pour le code de sortie.""" + mauvais = [] + for controle in CONTROLES: + genre, gravite, raison = verdict( + controle, resultats.get(controle["key"]), attente + ) + if genre == "bad": + mauvais.append((controle, gravite, raison)) + return mauvais + + +def render(database, resultats, attente, colour=True): + """Le rapport, section par section, dans l'ordre déclaré.""" + entete = ( + t("a development copy") if attente == COPY else t("a live instance") + ) + lignes = [ + f"🩺 {t('State of')} {database} — {t('read as')} {entete}", + "", + ] + for section in SECTIONS: + corps = [] + for controle in CONTROLES: + if controle["section"] != section: + continue + valeur = resultats.get(controle["key"]) + genre, gravite, raison = verdict(controle, valeur, attente) + nombre = "?" if isinstance(valeur, dict) else str(valeur) + icone = { + "ok": "✅", + "bad": "❌" if gravite == "broken" else "⚠", + "info": "ℹ️ ", + "skip": "·", + "unreadable": "❔", + }[genre] + corps.append( + paint( + f" {icone} {nombre.rjust(5)} {t(controle['title'])}", + gravite if genre in ("bad", "ok") else "dim", + colour, + ) + ) + if raison: + corps.append(paint(f" {raison}", "dim", colour)) + if corps: + lignes.append(t(section)) + lignes.extend(corps) + lignes.append("") + return "\n".join(lignes).rstrip() + + +def main(argv=None): + import argparse + import json + + parser = argparse.ArgumentParser( + description=t("What state an instance is in, for its intended use."), + ) + parser.add_argument("-d", "--database", required=True) + parser.add_argument("-c", "--config", help="odoo config file") + parser.add_argument( + "--expect", + choices=ATTENTES, + default=COPY, + help="copy: a restored development copy. live: a running instance.", + ) + parser.add_argument("--json", action="store_true") + parser.add_argument("--no-color", action="store_true") + args = parser.parse_args(argv) + + try: + lib_analyse.require_odoo_database( + args.database, config_path=args.config + ) + except Exception as exc: # noqa: BLE001 + print(f"❌ {exc}", file=sys.stderr) + return 2 + + resultats = inspect(args.database, args.config) + mauvais = findings(resultats, args.expect) + + if args.json: + print( + json.dumps( + { + "database": args.database, + "expect": args.expect, + "checks": resultats, + "bad": [c["key"] for c, _, _ in mauvais], + }, + indent=2, + ensure_ascii=False, + ) + ) + else: + colour = sys.stdout.isatty() and not args.no_color + print(render(args.database, resultats, args.expect, colour)) + return 1 if mauvais else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/analyse/check_migration_quality.py b/script/analyse/check_migration_quality.py index 2bf8f76..5dba90a 100755 --- a/script/analyse/check_migration_quality.py +++ b/script/analyse/check_migration_quality.py @@ -245,6 +245,32 @@ def inspect(database): "SELECT model || '.' || name FROM ir_model_fields WHERE store", ) etat["field_stored"] = sorted(ligne[0] for ligne in stockes or []) + # Le CHAMP PORTEUR de chaque pièce jointe, et s'il existe encore. + # + # Une pièce jointe dont `res_field` ne nomme aucun champ vivant est + # DÉJÀ illisible : Odoo lève un KeyError en la contrôlant. Ce ne sont + # pas des données, ce sont des débris. Mesuré sur une chaîne 12 → 18 : + # la dette naît aux paliers 13 et 14, reste gelée pendant trois + # paliers, et la 18 ramasse 452 lignes d'un coup — qui se lisent + # alors comme 452 pertes. + # + # Ni le nom ni le mimetype ici : `run_psql` découpe par LIGNE et un + # nom de fichier peut en contenir une. + portees = run_psql( + database, + "SELECT a.id, coalesce(a.res_model, ''), coalesce(a.res_field, '')," + " coalesce(a.res_id::text, '')," + " CASE WHEN a.res_field IS NULL OR a.res_field = '' THEN '1'" + " WHEN EXISTS (SELECT 1 FROM ir_model_fields f" + " WHERE f.model = a.res_model AND f.name = a.res_field)" + " THEN '1' ELSE '0' END" + " FROM ir_attachment a", + ) + etat["attachment_row"] = { + ligne[0]: (ligne[1], ligne[2], ligne[3], ligne[4] == "1") + for ligne in portees or [] + if len(ligne) >= 5 and ligne[0].isdigit() + } # Les copies COW par leur CLÉ : c'est elle qu'on réinitialise, et # c'est par elle qu'on les retrouve d'une version à l'autre. copies = run_psql( @@ -710,6 +736,65 @@ def overlay_declared( } +def render_attachment_kind(connu, colour): + """Le détail par CAUSE d'une perte de pièces jointes. + + Le rapport ne doit jamais dire « pièce jointe perdue » sans dire CE + QUI est parti avec elle : un champ déjà mort ne fait perdre rien + qu'un utilisateur ait pu voir, et cela se range en teinte calme. Le + seul cas rouge est « le champ est toujours là ». + """ + from script.todo.migration_status import paint + + lignes = [] + for cle, libelle, teinte in ATTACHMENT_KIND: + combien = (connu.get("buckets") or {}).get(cle) or 0 + if not combien: + continue + lignes.append( + f" {paint(str(combien).rjust(5), teinte, colour)}" + f" {t(libelle)}" + ) + return lignes + + +def classify_attachments(avant, apres): + """Pourquoi chaque pièce jointe partie est partie. None si on ne sait. + + Python pur, sur ce que `inspect` a déjà lu : aucune requête de plus. + L'ORDRE des tests compte — éprouvé dans l'autre sens, les 452 du + palier 18 se rangeaient à tort en « champ retiré à ce palier ». + """ + lignes_avant = avant.get("attachment_row") + lignes_apres = apres.get("attachment_row") + if not lignes_avant or lignes_apres is None: + return None + modeles_apres = set(apres.get("model") or []) + seaux = {cle: [] for cle, _l, _t in ATTACHMENT_KIND} + for identifiant, (modele, champ, _res, vivant) in lignes_avant.items(): + if identifiant in lignes_apres: + continue + apres_vivant = None + for autre in lignes_apres.values(): + if autre[0] == modele and autre[1] == champ: + apres_vivant = autre[3] + break + # L'ORDRE porte le sens. « Le champ était déjà mort » d'abord : + # c'est la raison la plus forte, et celle des 452 lignes du + # palier 18. « Le modèle a quitté la base » ensuite — plus forte + # que « le champ n'apparaît plus après », qui ne se déduit que + # d'une absence et serait vraie de toute façon. + if not vivant: + seaux["field_debt"].append(identifiant) + elif modele and modele not in modeles_apres: + seaux["model_gone"].append(identifiant) + elif apres_vivant is False or (champ and apres_vivant is None): + seaux["field_dropped"].append(identifiant) + else: + seaux["undeclared"].append(identifiant) + return seaux + + def explain_loss(table, version): """Ce qu'Odoo a fait de cette table à cette version, ou None. @@ -761,6 +846,31 @@ def compare(avant, apres): else None ) lignes_perdues[index] = (table, debut, fin, {**connu, "gained": recue}) + # `ir_attachment` n'a pas d'entrée dans SEMANTIC_MAP et n'en aura + # pas : la cause n'est pas la table, ce sont ces lignes-là. On la + # DÉDUIT, dans le même vocabulaire, et l'on garde en rouge le seul + # cas qui compte — une pièce jointe dont le champ est toujours là. + seaux = classify_attachments(avant, apres) + if seaux: + for index, (table, debut, fin, connu) in enumerate(lignes_perdues): + if table != "ir_attachment" or connu: + continue + lignes_perdues[index] = ( + table, + debut, + fin, + { + "into": None, + "kind": "pruned", + "why": "attachments of fields and records already gone", + "gained": None, + "buckets": { + cle: len(seaux.get(cle) or []) + for cle, _l, _t in ATTACHMENT_KIND + }, + "residue": len(seaux.get("undeclared") or []), + }, + ) champ_avant = set(avant.get("field") or []) champ_apres = set(apres.get("field") or []) cow_avant = set(avant.get("cow") or []) @@ -891,6 +1001,20 @@ DECLARE_MODELES = ( # au palier 14 → 15, pour zéro octet. SANS_DONNEE_PROPRE = ("id",) +# Pourquoi une pièce jointe a disparu — DÉDUIT, jamais déclaré. +# +# SEMANTIC_MAP ne peut pas porter ceci : elle nomme une TABLE, et la +# cause n'est pas la table, ce sont ces lignes-là. Mesuré sur une chaîne +# 12 → 18 : des 516 lignes parties au palier 18, 452 avaient perdu leur +# champ et 63 leur enregistrement. Une entrée « ir_attachment / pruned » +# aurait rangé les 516 sous « perte attendue » — et la 517e avec. +ATTACHMENT_KIND = ( + ("field_debt", "their field was already gone before this step", "dim"), + ("field_dropped", "their field was removed at this step", "dim"), + ("model_gone", "their model left the database", "dim"), + ("undeclared", "the field is STILL there — look at these", "warn"), +) + DECLARE_CHAMPS = ( ("del", "declared removed", "dim"), ("moved", "moved to another module", "dim"), @@ -1125,7 +1249,8 @@ def render_compare(diff, colour, limit=8): else: ou = t("retired from the database") lignes.append(f" {table:<40} {avant:>8} → {apres} {ou}") - lignes.append(f" {connu['why']}") + lignes.append(f" {t(connu['why'])}") + lignes.extend(render_attachment_kind(connu, colour)) if len(connues) > limit: lignes.append(f" … {len(connues) - limit} {t('more')}") lignes.extend(render_declared(diff.get("declared") or {}, colour, limit)) diff --git a/script/analyse/check_migration_residue.py b/script/analyse/check_migration_residue.py new file mode 100755 index 0000000..eaef2d6 --- /dev/null +++ b/script/analyse/check_migration_residue.py @@ -0,0 +1,331 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Ce qu'une migration a laissé derrière elle, lu dans UNE seule base. + +`check_migration_quality` répond à une autre question : il compare les +bases de PALIER qu'une migration locale a laissées, et il lui faut le +journal de progression. Devant la sauvegarde d'un client, ni l'une ni +l'autre n'existe. Il fallait donc un outil qui n'ait besoin que de la +base qu'on a sous la main. + +Le piège, et pourquoi la moitié des contrôles évidents ont été écartés +-------------------------------------------------------------------- +Un compteur absolu ne prouve rien. Mesuré sur une chaîne 12 → 18 réelle, +en comparant la base d'ORIGINE à la migrée : + + champs stockés sans colonne 25 → 72 ← 25 AVANT toute migration + modèles sans table 90 → 158 ← 90 AVANT + contraintes orphelines 155 → 530 ← 155 AVANT + +Ces trois-là paraissent accablants et ne le sont pas : un modèle abstrait +n'a jamais de table, un champ hérité n'a jamais sa colonne à lui. Affichés +bruts, ils font peur pour rien — et un rapport qui fait peur pour rien +finit par être ignoré en entier. + +Ne restent ici que les constats qui se jugent SANS point de comparaison, +parce qu'ils sont faux en eux-mêmes. Les mêmes bases, mêmes mesures : + + res_lang.active à NULL 0 → 9 un booléen NULL est un bug + ir_model_relation sans table 0 → 68 la table m2m est nommée, + elle n'existe pas + index doublés convention 17 0 → 414 Odoo 17 renomme, sans + supprimer l'ancien + liste de prix par défaut absente 0 → 1 `product` installé, son + xmlid pas là + +Zéro avant, non nul après : aucun de ces quatre ne peut s'expliquer +autrement que par la migration. + +Chaque constat nomme l'outil qui le répare. Un rapport qui montre un +dégât sans dire quoi lancer oblige à chercher, et on ne cherche pas. + +Lecture seule : `default_transaction_read_only=on`, imposé par le serveur. +""" + +from __future__ import annotations + +import os +import sys + +sys.path.append( + os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..")) +) + +try: + from script.todo.todo_i18n import t +except Exception: # pragma: no cover - repli si i18n indisponible + + def t(key: str) -> str: + return key + + +from script.analyse import lib_analyse # noqa: E402 + +COULEURS = { + "broken": "\033[31m", + "watch": "\033[33m", + "ok": "\033[32m", + "dim": "\033[90m", +} +RESET = "\033[0m" + + +def paint(texte, genre, colour): + """Teinter, ou rendre le texte tel quel quand la couleur est coupée.""" + if not colour: + return texte + return f"{COULEURS.get(genre, '')}{texte}{RESET}" + + +# `sql` doit rendre UN nombre. `repair` nomme l'outil qui corrige, ou None +# quand il n'y en a pas encore — le dire vaut mieux que de laisser croire. +CONTROLES = ( + { + "key": "stuck_modules", + "title": "Modules stuck between two states", + "why": "A migration that stopped mid-flight leaves them there;" + " Odoo will retry the transition at every start.", + "sql": "SELECT count(*) FROM ir_module_module" + " WHERE state IN ('to install','to upgrade','to remove')", + "gravity": "broken", + "repair": "script/todo/todo_upgrade.py (uninstall_one_by_one)", + }, + { + "key": "lang_active_null", + "title": "Languages whose active flag is NULL", + "why": "A boolean that is neither true nor false: the language is" + " listed nowhere and cannot be re-enabled from the interface.", + "sql": "SELECT count(*) FROM res_lang WHERE active IS NULL", + "gravity": "broken", + "repair": None, + }, + { + "key": "orphan_relation", + "title": "Many-to-many tables named but missing", + "why": "ir_model_relation still names a table PostgreSQL does not" + " have; the next module update tries to alter it and fails.", + "sql": "SELECT count(*) FROM ir_model_relation r WHERE NOT EXISTS" + " (SELECT 1 FROM pg_class c WHERE c.relname = r.name)", + "gravity": "broken", + "repair": "script/analyse/database_cleanup.py", + }, + { + "key": "duplicate_index", + "title": "Indexes duplicated by the Odoo 17 renaming", + "why": "Odoo 17 changed the naming convention without dropping the" + " old index: both are maintained on every write. This count is a" + " cheap signal — the repair tool compares columns and" + " uniqueness, and is the one to trust.", + "sql": "SELECT count(*) FROM pg_indexes a WHERE a.schemaname='public'" + " AND a.indexname ~ '__[a-z0-9_]+_index$'" + " AND EXISTS (SELECT 1 FROM pg_indexes b WHERE b.schemaname='public'" + " AND b.tablename = a.tablename" + " AND b.indexname = replace(a.indexname, '__', '_'))", + "gravity": "watch", + "repair": "script/odoo/migration/fix_duplicate_index.py --apply", + }, + { + "key": "missing_pricelist", + "title": "Default pricelist missing while product is installed", + "why": "product.list0 was declared up to Odoo 16 only; nothing" + " recreates it, and a quotation has no price list to pick.", + "sql": "SELECT CASE WHEN EXISTS (SELECT 1 FROM ir_module_module" + " WHERE name='product' AND state='installed')" + " AND NOT EXISTS (SELECT 1 FROM ir_model_data" + " WHERE module='product' AND name='list0')" + " THEN 1 ELSE 0 END", + "gravity": "broken", + "repair": "script/odoo/migration/restore_config_defaults.py --apply", + }, + { + "key": "view_model_gone", + "title": "Views bound to a model that no longer exists", + "why": "Opening one raises; the menu that leads to it is a dead end.", + "sql": "SELECT count(*) FROM ir_ui_view v" + " WHERE v.model IS NOT NULL AND v.model <> ''" + " AND NOT EXISTS (SELECT 1 FROM ir_model m WHERE m.model = v.model)", + "gravity": "broken", + "repair": None, + }, + { + "key": "view_parent_gone", + "title": "Views inheriting a view that is gone", + "why": "The whole inheritance chain below them stops rendering.", + "sql": "SELECT count(*) FROM ir_ui_view v WHERE v.inherit_id IS NOT" + " NULL AND NOT EXISTS" + " (SELECT 1 FROM ir_ui_view p WHERE p.id = v.inherit_id)", + "gravity": "broken", + "repair": "script/odoo/migration/fix_cow_render.py --apply", + }, + { + "key": "xmlid_model_gone", + "title": "External ids pointing at a model that is gone", + "why": "Every module update that resolves one of them fails.", + "sql": "SELECT count(*) FROM ir_model_data d WHERE NOT EXISTS" + " (SELECT 1 FROM ir_model m WHERE m.model = d.model)", + "gravity": "broken", + "repair": "script/analyse/database_cleanup.py", + }, + { + "key": "attachment_field_gone", + "title": "Attachments whose carrying field is gone", + "why": "Odoo raises a KeyError merely checking them, and nothing" + " will ever read them again.", + "sql": "SELECT count(*) FROM ir_attachment a" + " WHERE a.res_field IS NOT NULL AND a.res_field <> ''" + " AND NOT EXISTS (SELECT 1 FROM ir_model_fields f" + " WHERE f.model = a.res_model AND f.name = a.res_field)", + "gravity": "watch", + "repair": "script/analyse/check_filestore.py", + }, +) + + +def inspect(database, config_path=None): + """Passer chaque contrôle, et rendre son nombre. + + Un contrôle qui ÉCHOUE n'est pas un contrôle qui rend zéro : une table + absente parce que le module n'est pas installé n'est pas un dégât. On + garde l'erreur telle quelle, et le rendu la distingue. + """ + resultats = {} + for controle in CONTROLES: + try: + brut = lib_analyse.run_psql( + database, controle["sql"], config_path=config_path + ).strip() + resultats[controle["key"]] = int(brut.splitlines()[0]) + except Exception as exc: # noqa: BLE001 - on rapporte, on ne meurt pas + resultats[controle["key"]] = { + "error": str(exc).splitlines()[0][:120] + } + return resultats + + +def judge(resultats): + """Ne garder que ce qui compte, le plus grave d'abord. + + Un contrôle illisible remonte AVEC les autres : ne pas avoir pu + regarder n'est pas la même chose que n'avoir rien trouvé, et taire la + différence est exactement ce qui fait prendre un rapport pour une + garantie. + """ + trouve, illisibles = [], [] + for controle in CONTROLES: + valeur = resultats.get(controle["key"]) + if isinstance(valeur, dict): + illisibles.append((controle, valeur.get("error", ""))) + elif valeur: + trouve.append((controle, valeur)) + trouve.sort(key=lambda pair: (pair[0]["gravity"] != "broken", -pair[1])) + return trouve, illisibles + + +def render(database, resultats, version=None, colour=True): + """Le rapport lisible. Chaque constat dit quoi lancer pour le réparer.""" + trouve, illisibles = judge(resultats) + lignes = [f"🚚 {t('Migration residue in')} {database}"] + if version: + lignes.append(f" {t('base module version:')} {version}") + lignes.append("") + + if not trouve and not illisibles: + lignes.append( + paint( + f"✅ {t('None of the checks found anything.')}", "ok", colour + ) + ) + lignes.append( + paint( + f" {t('This reads one database on its own — it cannot see')}" + f" {t('what an earlier step silently dropped.')}", + "dim", + colour, + ) + ) + return "\n".join(lignes) + + for controle, combien in trouve: + icone = "❌" if controle["gravity"] == "broken" else "⚠" + lignes.append( + paint( + f"{icone} {str(combien).rjust(6)} {t(controle['title'])}", + controle["gravity"], + colour, + ) + ) + lignes.append(paint(f" {t(controle['why'])}", "dim", colour)) + if controle["repair"]: + lignes.append(f" → {controle['repair']}") + else: + lignes.append( + paint(f" → {t('no repair tool yet')}", "dim", colour) + ) + lignes.append("") + + for controle, erreur in illisibles: + lignes.append( + paint( + f"❔ {t(controle['title'])} — {t('could not read')}", + "watch", + colour, + ) + ) + lignes.append(paint(f" {erreur}", "dim", colour)) + return "\n".join(lignes).rstrip() + + +def main(argv=None): + import argparse + import json + + parser = argparse.ArgumentParser( + description=t("What a migration left behind, read in one database."), + ) + parser.add_argument("-d", "--database", required=True) + parser.add_argument("-c", "--config", help="odoo config file") + parser.add_argument("--json", action="store_true") + parser.add_argument("--no-color", action="store_true") + args = parser.parse_args(argv) + + try: + lib_analyse.require_odoo_database( + args.database, config_path=args.config + ) + except Exception as exc: # noqa: BLE001 + print(f"❌ {exc}", file=sys.stderr) + return 2 + + resultats = inspect(args.database, args.config) + try: + version = lib_analyse.database_version( + args.database, config_path=args.config + ) + except Exception: # noqa: BLE001 - la version est un confort, pas le sujet + version = None + trouve, illisibles = judge(resultats) + + if args.json: + print( + json.dumps( + { + "database": args.database, + "base_version": version, + "checks": resultats, + "found": [c["key"] for c, _ in trouve], + "unreadable": [c["key"] for c, _ in illisibles], + }, + indent=2, + ensure_ascii=False, + ) + ) + else: + colour = sys.stdout.isatty() and not args.no_color + print(render(args.database, resultats, version, colour)) + return 1 if trouve else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/analyse/monitoring.py b/script/analyse/monitoring.py new file mode 100644 index 0000000..cba79aa --- /dev/null +++ b/script/analyse/monitoring.py @@ -0,0 +1,320 @@ +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Ausculter une instance : d'où vient la base, et que peut-on y lire. + +Ce module ne réanalyse rien. Les analyses existent déjà — vues +personnalisées, champs x_, restant de migration — et chacune sait parler +à une base. Ce qui manquait, c'est le chemin d'AVANT : la base n'est pas +toujours ici. Elle est dans un zip, ou chez un client, ou vivante sur un +serveur auquel on n'a qu'un identifiant. + +Trois provenances, deux destins +------------------------------- +Le zip et la sauvegarde distante finissent au même endroit : restaurés en +base locale. À partir de là, tout ce que le dépôt sait faire fonctionne, +sans une ligne de plus. + +L'instance VIVANTE, non. Aucun outil d'ici ne parle autre chose que psql, +et on n'a pas la base — on a une session Odoo. Ce que RPC laisse lire est +un sous-ensemble : les tables `ir_*` oui, `pg_catalog` non. Une analyse +qui compte des index ne peut donc pas tourner là, et le dire est le +travail de ce module. Une analyse muette qu'on croit rassurante est pire +que pas d'analyse du tout. + +Pourquoi la TUI ne fait que CHOISIR +----------------------------------- +`run_tui` des six écrans du dépôt refuse de s'ouvrir dans une boucle +asyncio déjà en cours, et une analyse lourde appelée depuis un +gestionnaire de touche gèle l'affichage sans rien dire. Un hub qui +CONTIENT les analyses se heurte aux deux. Celui-ci demande ce qu'on veut, +se referme, et laisse l'analyse s'ouvrir chez elle, en pleine possession +du terminal. +""" + +from __future__ import annotations + +import os +import subprocess +import sys + +sys.path.append( + os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..")) +) + +try: + from script.todo.todo_i18n import t +except Exception: # pragma: no cover - repli si i18n indisponible + + def t(key: str) -> str: + return key + + +REPO_ROOT = os.path.normpath( + os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..") +) + +# Ce qu'une provenance donne à la fin. `database` couvre le zip restauré +# comme la sauvegarde téléchargée : une fois restaurés, ils ne se +# distinguent plus. +KIND_DATABASE = "database" +KIND_LIVE = "live" + +ANALYSES = ( + { + "key": "migration_residue", + "title": "Migration leftovers", + "why": "What a migration left behind, judged without needing the" + " step-by-step databases.", + "script": "script/analyse/check_migration_residue.py", + "kinds": (KIND_DATABASE,), + "needs_sql": "It reads pg_catalog — indexes and real tables — which" + " no RPC session exposes.", + }, + { + "key": "instance_state", + "title": "State of the instance", + "why": "Neutralisation, scheduler, backups, queues and who is an" + " administrator — read for the use you intend.", + "script": "script/analyse/check_instance_state.py", + "kinds": (KIND_DATABASE,), + "needs_sql": "Several checks read tables no RPC session exposes," + " and the lateness of a job is computed in SQL.", + # Le même chiffre veut dire deux choses opposées selon qu'on + # ausculte une copie ou une production : l'attente ne se devine + # pas, elle se demande. + "asks_expect": True, + }, + { + "key": "cow_views", + "title": "Customised views, website copies included", + "why": "Every view someone changed, and every website copy that" + " shadows a module view.", + "script": "script/analyse/analyse_view_custom.py", + "kinds": (KIND_DATABASE,), + "needs_sql": "Comparing a copy with the module view it hides is a" + " join on arch_db, done in SQL.", + }, + { + "key": "custom_fields", + "title": "Studio and hand-made x_ fields", + "why": "Fields and models added outside any module, and which of" + " them have no column behind them.", + "script": "script/analyse/analyse_custom_field.py", + "kinds": (KIND_DATABASE,), + "needs_sql": "Telling a declared field from a real column means" + " reading pg_attribute.", + }, +) + + +def analysis_by_key(key): + """L'analyse portant cette clé, ou None.""" + for analyse in ANALYSES: + if analyse["key"] == key: + return analyse + return None + + +def available(kind): + """Les analyses qui savent lire CETTE provenance. + + Rendre la liste complète et laisser l'appelant filtrer ferait afficher + des choix qui échoueraient à l'ouverture ; c'est le genre de menu qui + apprend à ne plus faire confiance au menu. + """ + return tuple(a for a in ANALYSES if kind in a["kinds"]) + + +def unavailable(kind): + """Celles qui ne le savent pas, avec la raison — pour la DIRE.""" + return tuple(a for a in ANALYSES if kind not in a["kinds"]) + + +def command_for(analyse, database, config_path=None, extra=None): + """La ligne de commande qui lance l'analyse sur cette base.""" + cmd = [sys.executable, analyse["script"], "-d", database] + if config_path: + cmd += ["-c", config_path] + if extra: + cmd += list(extra) + return cmd + + +def run_analysis(analyse, database, config_path=None, extra=None, env=None): + """Lancer l'analyse et rendre son code de sortie. + + Sans capture : l'analyse écrit sur le terminal, et certaines ouvrent + leur propre TUI. Les intercepter reviendrait à leur retirer l'écran. + """ + cmd = command_for(analyse, database, config_path, extra) + completed = subprocess.run(cmd, cwd=REPO_ROOT, env=env) + return completed.returncode + + +def describe_source(kind, target): + """Une ligne qui dit sur QUOI l'on travaille, avant d'analyser. + + Après une restauration, le nom de la base n'est plus celui du fichier — + l'annoncer évite d'analyser une base pour une autre. + """ + if kind == KIND_LIVE: + return f"🛰 {t('Live instance:')} {target}" + return f"💾 {t('Database:')} {target}" + + +# ── La neutralisation a-t-elle vraiment eu lieu ? ──────────────────────── +# +# Mesuré sur les sept bases d'une chaîne 12 → 18 dont le nom porte pourtant +# « neutralize » : `database.is_neutralized` ABSENT dans les sept, 16 à 35 +# crons actifs, et `mail.catchall.domain` toujours au domaine du CLIENT. +# Demander « voulez-vous neutraliser ? », recevoir oui, et ne rien vérifier +# reproduit exactement cette illusion. +# +# On ne lit que des booléens de PRÉSENCE. Une clé de paiement vivante a été +# trouvée dans cette base de test ; un rapport finit dans un billet ou +# devant un agent, et n'a aucune raison de la porter. + +NEUTRALIZE_SQL = { + "flag": "SELECT count(*) FROM ir_config_parameter" + " WHERE key='database.is_neutralized' AND value IN ('true','True','1')", + "cron_active": "SELECT count(*) FROM ir_cron WHERE active", + "mail_server": "SELECT count(*) FROM ir_mail_server", + "payment_live": "SELECT count(*) FROM payment_provider" + " WHERE state NOT IN ('disabled','test')", +} + + +def neutralize_state(database, config_path=None): + """Ce que la base dit d'elle-même après une neutralisation. + + Un contrôle illisible rend None et n'est pas compté comme zéro : la + table `payment_provider` n'existe pas si le module n'est pas installé, + et cette absence n'est pas une bonne nouvelle à afficher. + """ + from script.analyse import lib_analyse + + etat = {} + for cle, sql in NEUTRALIZE_SQL.items(): + try: + brut = lib_analyse.run_psql( + database, sql, config_path=config_path + ).strip() + etat[cle] = int(brut.splitlines()[0]) + except Exception: # noqa: BLE001 - absent n'est pas nul + etat[cle] = None + return etat + + +def neutralize_report(etat, colour=False): + """Dire ce qui a pris et ce qui n'a pas pris, sans rien inventer. + + `ir_mail_server` à zéro n'est PAS une preuve de sûreté : Odoo retombe + alors sur le `smtp_server` du fichier de configuration — c'est + précisément pourquoi son propre `neutralize.sql` INSÈRE un serveur + bouchon au lieu de tout supprimer. + """ + lignes = [] + drapeau = etat.get("flag") + if drapeau: + lignes.append(f"✅ {t('database.is_neutralized is set.')}") + else: + lignes.append( + f"❌ {t('database.is_neutralized is NOT set — Odoo does not')}" + f" {t('consider this database neutralised.')}" + ) + crons = etat.get("cron_active") + if crons: + lignes.append(f"⚠ {crons} {t('scheduled actions are still active.')}") + elif crons == 0: + lignes.append(f"✅ {t('No scheduled action is active.')}") + serveurs = etat.get("mail_server") + if serveurs == 0: + lignes.append( + f"⚠ {t('No mail server at all: Odoo falls back to smtp_server')}" + f" {t('from the config file. A blocking one is safer than none.')}" + ) + vivants = etat.get("payment_live") + if vivants: + lignes.append( + f"❌ {vivants} {t('payment provider(s) are neither disabled nor')}" + f" {t('in test mode.')}" + ) + return "\n".join(paint_plain(lignes, colour)) + + +def paint_plain(lignes, colour): + """Teinter selon l'icône de tête — une seule règle, pas une par ligne.""" + if not colour: + return lignes + teinte = {"❌": "\033[31m", "⚠": "\033[33m", "✅": "\033[32m"} + sorties = [] + for ligne in lignes: + code = teinte.get(ligne[:1], "") + sorties.append(f"{code}{ligne}\033[0m" if code else ligne) + return sorties + + +# ── L'instance vivante ─────────────────────────────────────────────────── +# +# Depuis Odoo 14, une CLÉ D'API se présente exactement comme un mot de +# passe à `authenticate` : même appel, même place. La différence n'est pas +# technique, elle est humaine — une clé se révoque sans changer le mot de +# passe de personne. On demande donc laquelle on donne, et l'on envoie la +# même chose. + + +def live_connect(base_url, database, login, secret, timeout=30): + """Ouvrir une session XML-RPC. Rendre (uid, version), ou lever. + + C'est un vrai contrôle, pas une politesse : sans lui, une faute dans + l'URL ou la clé ne se verrait qu'à la première analyse, et passerait + pour un défaut de l'analyse. + """ + import xmlrpc.client + + url = base_url.rstrip("/") + common = xmlrpc.client.ServerProxy( + f"{url}/xmlrpc/2/common", allow_none=True + ) + import socket + + ancien = socket.getdefaulttimeout() + socket.setdefaulttimeout(timeout) + try: + version = (common.version() or {}).get("server_serie", "?") + uid = common.authenticate(database, login, secret, {}) + finally: + socket.setdefaulttimeout(ancien) + if not uid: + raise PermissionError(t("The instance refused these credentials.")) + return uid, version + + +# Ce qu'une analyse a le droit d'appeler sur une instance vivante. La liste +# est appliquée ICI et non chez l'appelant : une production n'a pas à +# dépendre de la prudence de chaque analyse à venir. `write`, `create` et +# `unlink` ne peuvent pas s'y glisser par distraction. +RPC_READ_ONLY = ( + "search_read", + "search_count", + "read", + "read_group", + "fields_get", +) + + +def live_call(base_url, database, uid, secret, model, method, *args, **kw): + """Un appel RPC en lecture. Tout le reste est refusé sans être tenté.""" + if method not in RPC_READ_ONLY: + raise PermissionError( + f"{t('Monitoring only reads; refused method:')} {method}" + ) + import xmlrpc.client + + models = xmlrpc.client.ServerProxy( + f"{base_url.rstrip('/')}/xmlrpc/2/object", allow_none=True + ) + return models.execute_kw( + database, uid, secret, model, method, list(args), kw + ) diff --git a/script/analyse/monitoring_tui.py b/script/analyse/monitoring_tui.py new file mode 100644 index 0000000..2802d43 --- /dev/null +++ b/script/analyse/monitoring_tui.py @@ -0,0 +1,191 @@ +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Demander quelle auscultation lancer — et rien d'autre. + +Cet écran ne CONTIENT aucune analyse. Il en choisit une, se referme, et +rend la clé choisie ; l'appelant lance ensuite l'outil, qui dispose alors +du terminal entier et peut ouvrir sa propre TUI. + +Deux raisons, toutes deux mesurées dans ce dépôt. `run_tui` refuse de +s'ouvrir quand une boucle asyncio tourne déjà — un écran qui en ouvrirait +un autre afficherait « open it in its own process instead » et rien +d'autre. Et une analyse lourde appelée depuis un gestionnaire de touche +bloque la boucle d'événements : plus de rafraîchissement, plus de frappe, +un écran qui paraît planté pendant des minutes. + +Les analyses que la provenance ne permet pas restent AFFICHÉES, grisées, +avec la raison. Les retirer laisserait croire qu'elles n'existent pas ; +les proposer les ferait échouer à l'ouverture. +""" + +from __future__ import annotations + +import os +import sys + +sys.path.append( + os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..")) +) + +from script.analyse import monitoring # 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: 52; border-right: solid $accent; } +#pane { width: 1fr; padding: 0 1; } +.off { color: $text-disabled; } +""" + + +def rows(kind): + """Les lignes de l'écran : (clé, étiquette, utilisable, raison). + + Fonction pure, et c'est délibéré — c'est elle qui décide ce qu'on peut + lancer, et une décision qui ne se teste qu'en ouvrant un terminal ne + se teste pas. + """ + lignes = [] + for analyse in monitoring.ANALYSES: + utilisable = kind in analyse["kinds"] + lignes.append( + ( + analyse["key"], + t(analyse["title"]), + utilisable, + "" if utilisable else t(analyse["needs_sql"]), + ) + ) + return lignes + + +def detail(key, kind): + """Le texte du panneau de droite pour cette analyse.""" + analyse = monitoring.analysis_by_key(key) + if not analyse: + return "" + morceaux = [t(analyse["title"]), "", t(analyse["why"]), ""] + if kind in analyse["kinds"]: + morceaux.append(f"▶ {t('Enter to run it.')}") + else: + morceaux.append(f"✖ {t('Not available for this source.')}") + morceaux.append(f" {t(analyse['needs_sql'])}") + morceaux.append("") + morceaux.append(f" {analyse['script']}") + return "\n".join(morceaux) + + +def build_app(kind, target): + """Construire l'écran. ImportError si Textual manque — l'appelant le dit.""" + from textual.app import App, ComposeResult + from textual.containers import Horizontal + from textual.widgets import Footer, Header, ListItem, ListView, Static + + lignes = rows(kind) + + class MonitoringChooser(App): + CSS = globals()["CSS"] + BINDINGS = [ + ("enter", "choose", t("Run")), + ("q", "quit", t("Quit")), + ("escape", "quit", t("Quit")), + ] + + def compose(self) -> ComposeResult: + yield Header() + yield Static(monitoring.describe_source(kind, target), id="head") + with Horizontal(id="body"): + items = [] + for key, label, utilisable, _ in lignes: + marque = " " if utilisable else "✖ " + item = ListItem(Static(f"{marque}{label}")) + if not utilisable: + item.add_class("off") + items.append(item) + yield ListView(*items, id="left") + yield Static("", id="pane") + yield Footer() + + def on_mount(self) -> None: + self.query_one("#left", ListView).focus() + self._refresh_pane(0) + + def _refresh_pane(self, index) -> None: + if 0 <= index < len(lignes): + self.query_one("#pane", Static).update( + detail(lignes[index][0], kind) + ) + + def on_list_view_selected(self, event) -> None: + # `ListView` consomme Entrée pour émettre `Selected` : la + # liaison de l'application ne la voit jamais. Sans ceci, + # l'écran ne répondait pas à Entrée — et aucune fonction pure + # ne pouvait le montrer. + self.action_choose() + + def on_list_view_highlighted(self, event) -> None: + self._refresh_pane(self.query_one("#left", ListView).index or 0) + + def action_choose(self) -> None: + index = self.query_one("#left", ListView).index + if index is None or not (0 <= index < len(lignes)): + return + key, _, utilisable, raison = lignes[index] + if not utilisable: + # Refuser en le DISANT : un Entrée sans effet se lit comme + # un écran figé, et l'on appuie plus fort. + self.query_one("#pane", Static).update( + f"✖ {t('Not available for this source.')}\n\n {raison}" + ) + return + self.exit(key) + + return MonitoringChooser() + + +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(kind, target, run_app=True): + """Ouvrir le choix. Rendre la clé choisie, ou None — en disant pourquoi.""" + try: + from script.todo import textual_setup + except Exception: # pragma: no cover - repli si l'aide manque + textual_setup = None + if textual_setup and not textual_setup.ensure(): + return None + try: + app = build_app(kind, target) + except ImportError: + print( + f"ℹ️ {t('Textual is missing from this interpreter:')}" + f" {sys.executable}" + ) + return None + 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 None + return app.run() diff --git a/script/mobile/check_bundle_transfer.py b/script/mobile/check_bundle_transfer.py index 44e8c96..7ab6d18 100755 --- a/script/mobile/check_bundle_transfer.py +++ b/script/mobile/check_bundle_transfer.py @@ -4,26 +4,35 @@ """Le transfert des dépôts ERPLibre dans l'application mobile a-t-il eu lieu ? L'application embarque le code des dépôts du manifeste pour les parcourir hors -ligne. Ils y entrent sous forme de PACKS : un APK est un ZIP borné à 65535 -entrées, et ces dépôts pèsent plus de 120 000 fichiers — un fichier par source -faisait échouer la compilation sur « Too many zip entries ». Chaque dépôt a donc -un `index.json` qui dit, pour chaque fichier, dans quelle tranche il se trouve, -à quel offset et sur quelle longueur. +ligne. Un APK est un ZIP borné à 65535 entrées, et ces dépôts pèsent plus de +120 000 fichiers : un fichier par source faisait échouer la compilation sur +« Too many zip entries ». Deux dispositions résolvent cela, et ce script +accepte les DEUX — sans quoi il échoue sur la moitié du travail qui n'a pas +encore atterri, ce qui est arrivé : + +- ARCHIVE : un `tar.gz` par dépôt, plus un `index.json` listant ses chemins. + Le manifeste porte alors `archive` et `indexUrl`. +- PACKS : des tranches `pack-NNN.bin` par dépôt, et un `index.json` qui dit + pour chaque fichier sa tranche, son offset et sa longueur. Un fichier s'y + relit sans décompresser le reste. Ce script VÉRIFIE ce transfert, et il est fait pour être exécuté par l'installation comme à la main : ./script/mobile/check_bundle_transfer.py [racine_du_dépôt_mobile] -Il échoue quand le transfert est vide, quand une tranche manque, ou quand un -index promet des octets que sa tranche n'a pas — trois pannes qu'un simple -« la compilation a réussi » ne dit pas. +Il échoue quand le transfert est vide, quand un conteneur manque, quand un +index promet un fichier que son conteneur n'a pas, ou quand les octets relus +diffèrent de la source — quatre pannes qu'un simple « la compilation a réussi » +ne dit pas. La présence est prouvée pour CHAQUE fichier promis ; seule la +relecture des octets se fait par échantillon. """ import argparse import json import random import sys +import tarfile from pathlib import Path # En dessous, ce n'est plus un transfert : c'est un bundle vide qu'on aurait pris @@ -65,6 +74,126 @@ def read_from_pack(repo_dir: Path, entry: dict) -> bytes: return data +# Le plugin de compilation ecrit les membres du tar prefixes par « ./ », quand +# l'index porte le chemin nu. Sans ce prefixe, extractfile ne trouve rien. +MEMBER_PREFIX = "./" + + +def index_path(base: Path, proj: dict) -> Path: + """L'index du dépôt, dans l'une ou l'autre disposition.""" + url = proj.get("indexUrl") + if url: + # `indexUrl` est relatif à dist/, quand `base` est dist/repos. + cand = base.parent / url + if cand.is_file(): + return cand + cand = base / proj["slug"] / "index.json" + if cand.is_file(): + return cand + raise FileNotFoundError(f"{proj['slug']} : index.json absent") + + +def archive_path(base: Path, proj: dict) -> Path: + """Le tar.gz du dépôt, ou None si ce dépôt n'est pas en archive.""" + rel = proj.get("archive") + if not rel: + return None + cand = base.parent / rel + return cand if cand.is_file() else None + + +def read_from_archive( + archive: Path, paths: list, promised: list = None +) -> dict: + """Relit plusieurs fichiers en UNE passe de décompression. + + Ouvrir une archive par fichier coûterait quelques secondes chacune sur le + dépôt odoo (150 Mo) : on traverse donc une seule fois en ramassant tout ce + qui est demandé. + + `promised` : tous les chemins que l'index annonce pour cette archive. La + traversée étant déjà payée, on en profite pour vérifier qu'ils y sont TOUS + — un échantillon de vingt fichiers ne verrait pas un fantôme qu'il ne tire + pas, et c'est précisément ce que cette passe attrape gratuitement. + """ + wanted = {MEMBER_PREFIX + p: p for p in paths} + found = {} + seen = set() + with tarfile.open(archive, "r:gz") as tar: + for member in tar: + if member.isfile(): + seen.add(member.name) + key = wanted.get(member.name) + if key is None: + continue + handle = tar.extractfile(member) + if handle is None: + raise ValueError( + f"{archive.name} : {key} n'est pas un fichier" + ) + found[key] = handle.read() + missing = set(wanted.values()) - set(found) + if missing: + raise FileNotFoundError( + f"{archive.name} : {len(missing)} fichier(s) tiré(s) de l'index" + f" et absents de l'archive, dont {sorted(missing)[0]}" + ) + if promised: + ghosts = {q for q in promised if MEMBER_PREFIX + q not in seen} + if ghosts: + raise FileNotFoundError( + f"{archive.name} : l'index promet {len(ghosts)} fichier(s) que" + f" l'archive n'a pas, dont {sorted(ghosts)[0]}" + ) + return found + + +def _collect(base: Path, repos: list, report: dict) -> tuple: + """Range chaque dépôt dans sa disposition, et valide ce qu'il promet. + + Extraite de `check` pour la garder sous la complexité que flake8 accepte : + deux dispositions et leurs validations tenaient mal dans une fonction. + """ + pack_pool = [] + arch_pool = {} + for proj in repos: + entries = _read_json(index_path(base, proj)) + files = [e for e in entries if e.get("type") == "file"] + # Le manifeste annonce un compte : un index plus court est un transfert + # tronqué que rien d'autre ne signale. + promised = proj.get("fileCount") + if promised is not None and promised != len(files): + raise ValueError( + f"{proj['slug']} : le manifeste promet {promised} fichiers," + f" l'index en porte {len(files)}" + ) + report["files"] += len(files) + archive = archive_path(base, proj) + if archive is not None: + report["archives"] += 1 + arch_pool.setdefault(archive, (proj, []))[1].extend(files) + continue + repo_dir = base / proj["slug"] + packs = list(repo_dir.glob("pack-*.bin")) + if files and not packs: + # Nommer la tranche ATTENDUE : « pack-*.bin » n'aide pas qui lit un + # journal d'installation et cherche un fichier précis. + chunks = sorted( + {e["chunk"] for e in files if e.get("chunk") is not None} + ) + attendu = ( + pack_path(repo_dir, chunks[0]).name + if chunks + else "pack-000.bin" + ) + raise FileNotFoundError( + f"{proj['slug']} : ni archive, ni {attendu}" + ) + report["packs"] += len(packs) + pack_pool += [(proj, repo_dir, e) for e in files if e.get("size")] + return pack_pool, arch_pool + + def check( mobile_root: Path, workspace: Path = None, min_files: int = None ) -> dict: @@ -87,25 +216,13 @@ def check( report = { "repos": len(repos), "files": 0, + "archives": 0, "packs": 0, + "present": 0, "checked": 0, "compared": 0, } - sample_pool = [] - for proj in repos: - repo_dir = base / proj["slug"] - index = repo_dir / "index.json" - if not index.is_file(): - raise FileNotFoundError(f"{proj['slug']} : index.json absent") - entries = _read_json(index) - files = [ - e - for e in entries - if e.get("type") == "file" and e.get("chunk") is not None - ] - report["files"] += len(files) - report["packs"] += len(list(repo_dir.glob("pack-*.bin"))) - sample_pool += [(proj, repo_dir, e) for e in files if e.get("size")] + pack_pool, arch_pool = _collect(base, repos, report) if report["files"] < min_files: raise ValueError( @@ -113,22 +230,53 @@ def check( f" {report['repos']} dépôts (au moins {min_files} attendus)" ) + flat = [("pack", proj, repo_dir, e) for proj, repo_dir, e in pack_pool] + flat += [ + ("arch", proj, archive, e) + for archive, (proj, entries) in arch_pool.items() + for e in entries + ] random.seed(SEED) - for proj, repo_dir, entry in random.sample( - sample_pool, min(SAMPLE, len(sample_pool)) - ): - data = read_from_pack(repo_dir, entry) + sample = random.sample(flat, min(SAMPLE, len(flat))) + + def compare(proj, entry, data): report["checked"] += 1 if workspace is None: - continue + return src = workspace / proj["path"] / entry["path"] if not src.is_file(): - continue + return if data != src.read_bytes(): raise ValueError( f"{proj['slug']} : {entry['path']} diffère de la source" ) report["compared"] += 1 + + for kind, proj, container, entry in sample: + if kind == "pack": + compare(proj, entry, read_from_pack(container, entry)) + + drawn = {} + for kind, proj, container, entry in sample: + if kind == "arch": + drawn.setdefault(container, []).append(entry) + + # Toutes les archives sont traversées, pas seulement celles que + # l'échantillon tire : la traversée coûte 6 s pour les 139 dépôts et elle + # prouve la présence de CHACUN des fichiers promis. Un échantillon de vingt + # ne verrait pas un fantôme qu'il ne tire pas. Les octets, eux, ne sont lus + # que pour les tirés — c'est la lecture qui coûte, pas la traversée. + for archive, (proj, entries) in arch_pool.items(): + here = drawn.get(archive, []) + blobs = read_from_archive( + archive, + [e["path"] for e in here], + promised=[e["path"] for e in entries], + ) + report["present"] += len(entries) + for entry in here: + compare(proj, entry, blobs[entry["path"]]) + return report @@ -156,9 +304,16 @@ def main() -> int: extra = ( f", {rep['compared']} comparés à la source" if rep["compared"] else "" ) + where = [] + if rep["archives"]: + where.append(f"{rep['archives']} archives") + if rep["packs"]: + where.append(f"{rep['packs']} tranches") + proven = f", {rep['present']} présences prouvées" if rep["present"] else "" print( f" {rep['repos']} dépôts, {rep['files']} fichiers en" - f" {rep['packs']} tranches ({rep['checked']} relus{extra})" + f" {' et '.join(where) or 'aucun conteneur'}" + f" ({rep['checked']} relus{extra}{proven})" ) return 0 diff --git a/script/odoo/migration/check_hidden_models.py b/script/odoo/migration/check_hidden_models.py index dc567ad..e693a15 100755 --- a/script/odoo/migration/check_hidden_models.py +++ b/script/odoo/migration/check_hidden_models.py @@ -91,6 +91,14 @@ try: ("share", "=", False), ], limit=LIMITE) rapport["users"] = membres.mapped("login") + # Les témoins ORDINAIRES : ceux qui ne sont pas administrateurs. + # Un modèle que seuls les administrateurs peuvent lire passe au vert + # si tous les témoins en sont — c'est exactement la forme du bug DMS, + # vue d'un autre angle. Mesuré sur une base réelle : les QUATRE + # utilisateurs internes étaient membres de base.group_system. + rapport["ordinary"] = [ + u.login for u in membres if not u.has_group("base.group_system") + ] if not membres: rapport["no_user"] = True else: @@ -99,6 +107,10 @@ try: .search([("global", "=", True), ("active", "=", True)]) .mapped("model_id.model") ) + # La COUVERTURE : combien de modèles portent une règle globale, + # et non combien on a pu éprouver. « 45 éprouvés » sans dire + # « sur 125 » laisse croire à un examen complet. + rapport["with_rule"] = len(vus - ATTENDUS) for nom in sorted(vus - ATTENDUS): modele = env.get(nom) if modele is None or modele._abstract or modele._transient: @@ -141,12 +153,24 @@ def render(rapport): if rapport.get("no_user"): return [f"⚠ {t('No internal user to test visibility with.')}"] muets = rapport.get("models") or [] + porteurs = rapport.get("with_rule") + sur = f" {t('out of')} {porteurs}" if porteurs else "" lignes = [ - f"🔍 {rapport.get('checked', 0)}" + f"🔍 {rapport.get('checked', 0)}{sur}" f" {t('model(s) with a global rule and some data,')}" f" {t('checked against')} {len(rapport.get('users') or [])}" f" {t('internal user(s)')}" ] + # Dire ce qu'on n'a PAS pu éprouver, avant de dire ce qu'on a trouvé. + # Un vert qui prouve moins qu'il n'en a l'air est pire qu'un rouge. + if "ordinary" in rapport and not rapport["ordinary"]: + lignes.append( + f" ⚠ {t('Every witness is an administrator: a model only')}" + f" {t('admins can read passes this test.')}" + ) + lignes.append( + f" ℹ {t('Reads ir_rule only — masking coded in Python escapes it.')}" + ) if not muets: lignes.append(f" ✅ {t('Every one of them is visible to someone.')}") return lignes diff --git a/script/test/run_unit_test.sh b/script/test/run_unit_test.sh index f5979f4..6aadf94 100755 --- a/script/test/run_unit_test.sh +++ b/script/test/run_unit_test.sh @@ -15,6 +15,13 @@ # l'annonce donc avant de commencer. # # ./script/test/run_unit_test.sh [fichiers...] +# +# NOMMER UN NOUVEAU FICHIER : sans argument, ce script ne prend que les +# préfixes listés plus bas (test_qemu_, test_todo_, test_proxmox_…) — le reste +# de test/ demande une base de données. Un fichier hors préfixe tombe donc dans +# le même silence qu'un fichier absent : douze tests écrits, jamais lancés, et +# un total qui n'a pas bougé. Choisir le préfixe de sa famille, ou l'ajouter +# ici. set -uo pipefail Red='\033[0;31m' diff --git a/script/todo/deploy_form_extras.py b/script/todo/deploy_form_extras.py new file mode 100644 index 0000000..c611a6b --- /dev/null +++ b/script/todo/deploy_form_extras.py @@ -0,0 +1,480 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Ce qu'on installe DANS la VM, et qui ne regarde pas l'hyperviseur. + +Type de VM, production, magasin d'applications, outils de développement, +fuseau horaire, interpréteur Python : six réglages qui décrivent le système +invité, pas la machine qui le porte. Ils valent donc mot pour mot sur libvirt +et sur Proxmox VE — et c'est exactement ce qui avait dérivé. L'écran QEMU/KVM +les portait tous, l'écran Proxmox trois : une VM créée là-bas naissait sans +bureau, sans outils et en UTC, sans que rien ne le dise. + +La duplication était le mécanisme de la dérive, pas son symptôme : chaque +correctif se posait sur un seul des deux écrans. Ce module met les six ici, +une seule fois, widgets ET logique. Un formulaire hérite d'`ExtrasMixin`, +appelle `extras_init(ctx)` puis pose les fragments de `compose_*` où sa mise +en page les veut. + +Le contrat côté formulaire, court exprès : l'attribut `vms` (les VM retenues) +et une case `#f_install`. Rien d'autre — ce module ne connaît ni stockage, ni +pont, ni domaine libvirt. + +Chaque accès aux widgets est gardé : un formulaire n'est pas tenu de poser +tous les fragments, et lire un widget absent ne doit pas casser l'écran. +""" + +from script.todo.deploy_form_lib import FREE, t + +# Ce que l'écran lit du contexte. Une seule liste, parce que les deux +# formulaires doivent en recevoir autant : c'est en fournissant un +# sous-ensemble que l'écran Proxmox avait perdu la moitié des réglages. +_TABLES = ( + ("desktops", list, "desktops"), + ("desktop_disk", int, "desktop_disk_gb"), + ("app_stores", list, "app_stores"), + ("snap_distros", set, "snap_distros"), + ("timezones", list, "timezones"), + ("timezone", str, "timezone"), + ("vm_tools", list, "vm_tools"), + ("tool_disk", dict, "vm_tool_disk"), + ("tool_phases", dict, "vm_tool_phases"), + ("tool_arches", dict, "vm_tool_arches"), + ("tool_desktops", dict, "vm_tool_desktops"), + ("tool_needs_desktop", dict, "vm_tool_needs_desktop"), + ("tool_families", dict, "vm_tool_families"), + ("distro_family", dict, "distro_family"), + ("mise_arches", set, "mise_arches"), + ("defaults", dict, "defaults"), +) + + +def extras_tables(ctx) -> dict: + """Les tables lues du contexte, chacune ramenée à son type. + + Ramenées, et non prises telles quelles : une clé absente rend un vide du + bon type, donc les prédicats plus bas n'ont aucun cas particulier à + porter. Un formulaire qui n'offre pas les outils passe simplement un + contexte sans « vm_tools ».""" + ctx = ctx or {} + return {nom: kind(ctx.get(cle) or kind()) for nom, kind, cle in _TABLES} + + +def tools_for_vm(vm, tools, tab) -> list: + """Outils qu'une VM donnée recevra VRAIMENT. + + Le même filtre que côté déploiement, et c'est le point : une VM ARM ne + verra jamais Android Studio, une VM Cinnamon jamais les extensions GNOME, + un serveur aucun des IDE — mais un serveur reçoit bien la compilation + mobile, qui n'a rien à afficher, et une distribution sans apt ne la + reçoit pas, son installateur n'existant que là. + + Filtrer ici plutôt que dans la commande distante permet d'annoncer + l'écart AVANT le déploiement, au lieu de le laisser découvrir dans un + journal d'installation d'une heure.""" + out = [] + for key in tools or (): + arches = tab["tool_arches"].get(key) or () + desks = tab["tool_desktops"].get(key) or () + fams = tab["tool_families"].get(key) or () + if tab["tool_needs_desktop"].get(key) and not vm.get("desktop"): + continue + if arches and vm.get("arch") not in arches: + continue + if desks and vm.get("desktop") not in desks: + continue + if fams and tab["distro_family"].get(vm.get("distro"), "") not in fams: + continue + out.append(key) + return out + + +def app_store_needed(vms, tab) -> bool: + """Le choix du magasin n'a de sens que pour une VM GRAPHIQUE sur une + distribution qui livre snapd. Ailleurs, rien ne tire de snap.""" + return any( + vm.get("desktop") and vm.get("distro") in tab["snap_distros"] + for vm in vms or () + ) + + +def mise_usable(vms, tab) -> bool: + """Au moins une VM retenue tourne sur une architecture que mise sert.""" + return any(vm.get("arch") in tab["mise_arches"] for vm in vms or ()) + + +def extras_disk_gb(vm, tools, tab) -> int: + """Go que le bureau et les outils ajoutent au disque de CETTE VM. + + De cette VM et d'elle seule : un supplément commun mentait dès que les + types différaient d'une machine à l'autre, et gonflait le disque d'un + serveur pour un bureau qu'il n'aurait pas.""" + gb = tab["desktop_disk"] if vm.get("desktop") else 0 + return gb + sum( + tab["tool_disk"].get(k, 0) for k in tools_for_vm(vm, tools, tab) + ) + + +class ExtrasMixin: + """Les six réglages du système invité, widgets et logique. + + Le formulaire hôte pose les fragments `compose_*` où il veut, appelle + `render_extras()` après chaque recalcul et `extras_values()` au moment de + bâtir sa spec.""" + + def extras_init(self, ctx) -> None: + """À appeler dans `__init__`, avant tout `compose`.""" + self._extras = extras_tables(ctx) + + # ------------------------------------------------------------------ # + # Les widgets + # ------------------------------------------------------------------ # + def compose_vm_type(self): + """Type de VM par défaut. Serveur d'abord : c'est ce que sert une + image cloud, et un bureau ajoute une à deux heures sur une + architecture émulée. Le plan annonce le surcoût disque.""" + from textual.widgets import RadioButton, RadioSet, Static + + tab = self._extras + if not tab["desktops"]: + return + yield Static(t("VM type (default):"), classes="grouptitle") + with RadioSet(id="f_type"): + yield RadioButton( + t("Server (no graphical interface)"), + value=not tab["defaults"].get("desktop", ""), + ) + for key, label in tab["desktops"]: + yield RadioButton( + f"{t('Graphical (server + desktop):')} {label}", + value=tab["defaults"].get("desktop", "") == key, + ) + + def compose_install_extras(self): + """Ce qui pend à la case « installer » : production, magasin, + outils. Posé DANS la section « Installation », après la branche et le + profil.""" + from textual.widgets import Checkbox, RadioButton, RadioSet, Static + + tab = self._extras + yield Checkbox( + t("Production (/opt, confined)"), + value=tab["defaults"].get("prod", False), + id="f_prod", + ) + if tab["app_stores"]: + yield Static( + t("Application store:"), id="t_store", classes="grouptitle" + ) + with RadioSet(id="f_store"): + for i, (_k, label) in enumerate(tab["app_stores"]): + yield RadioButton(label, value=i == 0) + yield Static("", id="storewarn") + if tab["vm_tools"]: + # Une case par outil, et non une liste déroulante : ils sont + # indépendants, et chacun se prend ou se laisse. + yield Static( + t("Development tools:"), id="t_tools", classes="grouptitle" + ) + for key, label, hint in tab["vm_tools"]: + gb = tab["tool_disk"].get(key, 0) + yield Checkbox( + f"{label} +{gb} Go — {hint}", + value=key in (tab["defaults"].get("tools") or ()), + id=f"f_tool_{key}", + ) + yield Static("", id="toolwarn") + + def compose_timezone(self): + """Une liste plutôt qu'une saisie : un nom IANA mal orthographié + n'est pas refusé par cloud-init, il est IGNORÉ — la VM reste en UTC et + on ne s'en aperçoit qu'aux horodatages. « libre… » garde la porte + ouverte aux six cents autres fuseaux de la base.""" + from textual.widgets import Input, Select, Static + + tab = self._extras + if not tab["timezones"]: + return + null = getattr(Select, "NULL", Select.BLANK) + yield Static(t("Timezone"), classes="grouptitle") + yield Select( + [(z, z) for z in tab["timezones"]] + [(t("free value…"), FREE)], + value=(tab["timezones"][0] if tab["timezones"] else null), + allow_blank=False, + id="f_tz_sel", + ) + yield Input( + value=tab["timezone"], + placeholder=t("Timezone for the VMs"), + id="f_tz", + classes="freeval", + ) + + def compose_python(self): + """mise pose un CPython PRÉCOMPILÉ, pyenv le COMPILE. Grisé quand + aucune des VM retenues n'est sur une architecture que mise sert.""" + from textual.widgets import RadioButton, RadioSet, Static + + yield Static( + t("Python interpreter:"), id="t_python", classes="grouptitle" + ) + with RadioSet(id="f_python"): + yield RadioButton(t("mise (precompiled, faster)"), value=True) + yield RadioButton(t("pyenv (compiles from source)")) + yield Static("", id="miswarn") + + # ------------------------------------------------------------------ # + # Lire les widgets + # ------------------------------------------------------------------ # + def _widget(self, selector): + """Le widget, ou None s'il n'est pas de cet écran.""" + try: + return self.query_one(selector) + except Exception: + return None + + def _default_desktop(self) -> str: + """« » pour un serveur, sinon la clé de la saveur choisie. Chaque + rangée du plan peut s'en écarter.""" + desktops = self._extras["desktops"] + widget = self._widget("#f_type") + if widget is None: + return "" + index = widget.pressed_index + if index is None or index < 1 or index > len(desktops): + return "" + return desktops[index - 1][0] + + def _install_state(self): + """(une installation ?, quelque chose à installer ?). + + Deux réponses et non une : sans installation mais avec un bureau, il + se pose encore des paquets — le magasin d'applications et les outils + de la phase « avant » gardent un effet.""" + widget = self._widget("#f_install") + installe = True if widget is None else bool(widget.value) + return installe, bool(installe or self._default_desktop()) + + def _vm_tools(self) -> tuple: + """Clés des outils cochés, dans l'ordre de la liste.""" + picked = [] + for key, _label, _hint in self._extras["vm_tools"]: + widget = self._widget(f"#f_tool_{key}") + if widget is not None and widget.value: + picked.append(key) + return tuple(picked) + + def _tools_for_vm(self, vm, tools) -> list: + return tools_for_vm(vm, tools, self._extras) + + def _app_store_needed(self) -> bool: + return app_store_needed(self.vms, self._extras) + + def _app_store(self) -> str: + """Magasin retenu. Sans VM concernée, la réponse est « deb » : elle + ne change rien, et laisser passer « snap » réactiverait snapd pour + rien.""" + stores = self._extras["app_stores"] + widget = self._widget("#f_store") + if not stores or widget is None or not self._app_store_needed(): + return "deb" + index = widget.pressed_index + if index is None or not (0 <= index < len(stores)): + return stores[0][0] + return stores[index][0] + + def _mise_usable(self) -> bool: + return mise_usable(self.vms, self._extras) + + def _python_provider(self) -> str: + """« mise », « pyenv », ou rien — c'est-à-dire « automatique ». + + Rien, et surtout pas « pyenv », quand mise n'est servi par aucune + architecture retenue. « mise est indisponible » ne veut pas dire + « l'utilisateur exige pyenv » : la nuance décide de tout, puisqu'un + choix EXPLICITE écarte le Python de la distribution. Sur s390x, + renvoyer « pyenv » forçait la compilation de CPython — celle dont gcc + 15.2 ne revient pas.""" + widget = self._widget("#f_python") + if widget is None or not self._mise_usable(): + return "" + return "pyenv" if widget.pressed_index == 1 else "mise" + + def _timezone(self) -> str: + """Le fuseau des VM. Un champ vidé retombe sur celui de l'hôte plutôt + que sur rien : sans valeur, la VM démarrerait en UTC.""" + widget = self._widget("#f_tz") + saisi = widget.value.strip() if widget is not None else "" + return saisi or self._extras["timezone"] + + def _extras_disk_gb(self, vm, tools) -> int: + return extras_disk_gb(vm, tools, self._extras) + + # ------------------------------------------------------------------ # + # Redessiner + # ------------------------------------------------------------------ # + def _render_store(self) -> None: + """Grise le choix quand aucune VM ne le concerne, et dit pourquoi.""" + widget = self._widget("#f_store") + if widget is None: + return + needed = self._app_store_needed() + _installe, quelque_chose = self._install_state() + widget.disabled = not (needed and quelque_chose) + note = self._widget("#storewarn") + if note is not None: + note.update( + "" + if needed + else f" {t('No graphical VM on a snap-based distro.')}" + ) + + def _render_tools(self) -> None: + """Grise chaque case qu'AUCUNE VM retenue ne peut recevoir, et NOMME + ce qui sera écarté. + + Une case par outil, et non un blocage en bloc : sur un parc de + serveurs les IDE se grisent, la compilation mobile reste offerte. + Cocher Android Studio sur un parc ARM ne produit rien — le dire ici + évite de le découvrir dans le journal d'installation.""" + tab = self._extras + if not tab["vm_tools"]: + return + installe, quelque_chose = self._install_state() + for key, _label, _hint in tab["vm_tools"]: + widget = self._widget(f"#f_tool_{key}") + if widget is None: + continue + usable = any(self._tools_for_vm(vm, (key,)) for vm in self.vms) + # Un outil de la phase « après » vit DANS le dépôt ERPLibre : + # sans installation, il n'a rien où s'installer, bureau ou pas. + offert = ( + installe + if tab["tool_phases"].get(key) == "after" + else quelque_chose + ) + widget.disabled = not (usable and offert) + picked = self._vm_tools() + skipped = sorted( + { + vm["name"] + for vm in self.vms + for k in picked + if k not in self._tools_for_vm(vm, picked) + } + ) + note = self._widget("#toolwarn") + if note is not None: + note.update( + f" ⚠ {t('Partly skipped (arch or desktop):')} " + f"{', '.join(skipped)}" + if skipped + else "" + ) + + def _render_mise(self) -> None: + """Grise le choix quand aucune VM retenue n'est servie par mise, ou + quand rien ne s'installe, et NOMME les architectures qui retomberont + sur pyenv.""" + widget = self._widget("#f_python") + if widget is None: + return + installe, _quelque_chose = self._install_state() + widget.disabled = not (self._mise_usable() and installe) + ecartees = sorted( + { + vm["arch"] + for vm in self.vms + if vm.get("arch") not in self._extras["mise_arches"] + } + ) + note = self._widget("#miswarn") + if note is not None: + note.update( + f" ⚠ {t('mise has no binary for:')} " + f"{', '.join(ecartees)} — {t('those VMs use pyenv')}" + if ecartees + else "" + ) + + def render_extras(self) -> None: + """Les trois, dans l'ordre. Appelée à la fin de chaque recalcul.""" + self._render_mise() + self._render_store() + self._render_tools() + + def _sync_install_deps(self) -> None: + """Grise ce que le choix d'installation rend SANS EFFET. + + Trois états et non deux, parce que la commande distante en a trois : + rien du tout, un bureau seul, ou une installation complète. Sans + installation MAIS avec un bureau, le magasin d'applications et les + outils de la phase « avant » servent encore — les griser mentirait + autant que de laisser actif ce qui ne fait rien. La branche, le + profil et l'interpréteur Python, eux, ne servent qu'à l'installation. + + Le type de VM et le suivi ne sont jamais grisés : le premier est + l'autre moitié de la décision, le second regarde la VM arriver même + quand rien ne s'installe.""" + installe, quelque_chose = self._install_state() + for cible, actif in ( + ("#f_branch", installe), + ("#f_profile_install", installe), + ("#f_prod", quelque_chose), + ): + widget = self._widget(cible) + if widget is not None: + widget.disabled = not actif + # Le magasin, les outils et l'interpréteur Python ont leur PROPRE + # raison de se griser (architecture, bureau, distribution) : ils + # composent les deux dans « _render_* », qui a le dernier mot. + # Le titre suit ses champs : une section entière se lit inactive d'un + # coup d'œil, au lieu de se déduire de trois widgets ternes. + for cible, actif in ( + ("#t_store", quelque_chose), + ("#t_tools", quelque_chose), + ("#t_python", installe), + ): + widget = self._widget(cible) + if widget is not None: + widget.set_class(not actif, "off") + # Les rangées portent les mêmes choix, par VM. + for i in range(len(getattr(self, "rows", ()))): + for cible in (f"#v{i}_branch", f"#v{i}_prof"): + widget = self._widget(cible) + if widget is not None: + widget.disabled = not installe + + # ------------------------------------------------------------------ # + # Les messages, et la spec + # ------------------------------------------------------------------ # + def extras_on_select(self, event) -> bool: + """Traite le sélecteur de fuseau. Rend True quand c'est fait, pour + que l'appelant s'arrête là. + + « libre… » révèle la saisie ; un fuseau choisi la referme et y + recopie le nom, seule valeur que lit `extras_values` — un seul + endroit porte la réponse.""" + if getattr(event.select, "id", "") != "f_tz_sel": + return False + free = event.value is FREE + field = self._widget("#f_tz") + if field is None: + return True + field.display = free + field.disabled = not free + if free: + field.focus() + elif isinstance(event.value, str): + field.value = event.value + return True + + def extras_values(self) -> dict: + """Le fragment de spec que ces réglages produisent.""" + return { + "timezone": self._timezone(), + "desktop": self._default_desktop(), + "vm_tools": self._vm_tools(), + "python_provider": self._python_provider(), + "app_store": self._app_store(), + } diff --git a/script/todo/deploy_form_lib.py b/script/todo/deploy_form_lib.py index a7e6b85..793c52a 100644 --- a/script/todo/deploy_form_lib.py +++ b/script/todo/deploy_form_lib.py @@ -591,7 +591,9 @@ def res_row_widgets(index, vm, presets, labels=None, null=None): return widgets -def run_deploy_progress(jobs, parallelism, run_app: bool = True): +def run_deploy_progress( + jobs, parallelism, run_app: bool = True, ssh_cmds=None, suite="" +): """Déploie `jobs` = [(id, nom, argv)] en parallèle, un bloc repliable par VM. Renvoie [(nom, rc, sortie, durée)]. `run_app=False` renvoie l'app. @@ -680,9 +682,14 @@ def run_deploy_progress(jobs, parallelism, run_app: bool = True): self.run_job(jid, name, parts) def _refresh_summary(self): + # CE QUI SUIT, dit ici : l'installation d'ERPLibre ne démarre + # qu'au moment où l'on quitte cet écran, et rien ne le disait — + # on attendait devant une fenêtre pourtant terminée. + fini = self._done >= len(jobs) + apres = f"\n → {suite}" if (fini and suite) else "" self.query_one("#summary", Static).update( f" {self._done}/{len(jobs)} — " - f"{fmt_dur(time.time() - self._t0)}" + f"{fmt_dur(time.time() - self._t0)}{apres}" ) # `thread=True` : subprocess.run est bloquant ; le faire dans un @@ -765,10 +772,11 @@ def run_deploy_progress(jobs, parallelism, run_app: bool = True): def action_ssh(self) -> None: """Entre dans la VM créée, sans quitter l'écran. - Par son NOM et non par son adresse : c'est l'entrée - ~/.ssh/config que le déploiement vient d'écrire qui sait comment - l'atteindre — et pour une VM posée sur un hôte Proxmox, elle - porte le rebond, seul chemin vers son réseau interne. + La commande vient de l'APPELANT quand il sait joindre la VM : sur + Proxmox, l'entrée ~/.ssh/config n'est écrite qu'après le + déploiement, et le nom de la VM peut désigner une machine LOCALE + homonyme — « s » ouvrait alors la mauvaise. Le déploiement passe + donc « ssh -J user@ », qui ne dépend de rien. « suspend() » rend le terminal : ssh a besoin du clavier, et Textual le tient encore. @@ -780,8 +788,10 @@ def run_deploy_progress(jobs, parallelism, run_app: bool = True): return # La dernière créée : c'est celle qu'on regarde. nom = self._reussies[-1] + cmd = (ssh_cmds or {}).get(nom) or f"ssh {shlex_quote(nom)}" with self.suspend(): - os.system(f"ssh {shlex_quote(nom)} || true") + print(f"\n→ {cmd}\n") + os.system(f"{cmd} || true") # -- presse-papiers (OSC 52 : traverse SSH) --------------------- # def _copy(self, text, what): diff --git a/script/todo/deploy_form_plan.py b/script/todo/deploy_form_plan.py index d90625d..a472f2b 100644 --- a/script/todo/deploy_form_plan.py +++ b/script/todo/deploy_form_plan.py @@ -344,6 +344,32 @@ class PlanMixin: rename_screen()(self.rows[index]["vm"]["name"], auto), done ) + # ---------------------------------------------------------------- # + # Le catalogue — les trois raccourcis de sélection + # ---------------------------------------------------------------- # + def _catalog(self): + from textual.widgets import SelectionList + + return self.query_one("#f_catalog", SelectionList) + + def action_select_all(self) -> None: + self._catalog().select_all() + + def action_select_none(self) -> None: + self._catalog().deselect_all() + + def action_select_main(self) -> None: + """Une VM par distribution : la version marquée par défaut. + + Ici et non dans un formulaire : les trois gestes sont les mêmes des + deux côtés, et celui-ci manquait à l'écran Proxmox — qui affiche + pourtant le même catalogue, drapeau « default » compris.""" + widget = self._catalog() + widget.deselect_all() + for i, e in enumerate(self._entries()): + if e.get("default"): + widget.select(i) + def _auto_name(self, index): """Nom que la VM reprendrait si on effaçait le sien.""" return self._plan_entries()[index]["name"] diff --git a/script/todo/proxmox_deploy_form.py b/script/todo/proxmox_deploy_form.py index c2bbc5d..80245a4 100644 --- a/script/todo/proxmox_deploy_form.py +++ b/script/todo/proxmox_deploy_form.py @@ -39,6 +39,7 @@ from script.todo.deploy_form_lib import ( res_row_widgets, t, ) +from script.todo.deploy_form_extras import ExtrasMixin from script.todo.deploy_form_plan import PlanMixin, preview_screen # Aucun disque orphelin à craindre : les disques d'un Proxmox distant vivent @@ -99,6 +100,15 @@ def build_spec(vms, existants, form): "start": form["start"], "add_ssh_config": form["add_ssh_config"], "install": form["install"], + "python_provider": form.get("python_provider") or "", + # Ce qui décrit le SYSTÈME INVITÉ, et non l'hyperviseur : il valait + # déjà sur libvirt, il vaut ici. Sans ces cinq clés, une VM créée sur + # Proxmox naissait sans bureau, sans outils et en UTC. + "timezone": form.get("timezone") or "", + "desktop": form.get("desktop") or "", + "vm_tools": tuple(form.get("vm_tools") or ()), + "app_store": form.get("app_store") or "deb", + "prod": bool((form.get("install") or {}).get("prod")), "monitor": form["monitor"], "parallelism": form["parallelism"], } @@ -135,6 +145,8 @@ def run_proxmox_form(ctx, run_app: bool = True): ctx.get("branches") or ["master"], ctx.get("branch_current") ) profiles = ctx.get("install_profiles") or [] + # {clé de saveur: suffixe de nom} — décrit par todo.py, source unique. + desktop_suffixes = dict(ctx.get("desktop_suffixes") or {}) stockages = ctx.get("storages") or [] ponts = ctx.get("bridges") or [] # {système: (libellé, commande)} — ce qu'un système impose d'installer. @@ -149,15 +161,22 @@ def run_proxmox_form(ctx, run_app: bool = True): } def entry_label(e): - return f"{e['distro']} {e['version']} [{e['arch']}] {e['name']}" + # L'étoile marque la version par défaut d'une distribution : c'est + # elle que « F7 » retient, et sans repère le raccourci choisissait + # sans qu'on sache quoi. + star = " *" if e.get("default") else "" + return ( + f"{e['distro']} {e['version']}{star} [{e['arch']}] {e['name']}" + ) - class ProxmoxForm(PlanMixin, App): + class ProxmoxForm(ExtrasMixin, PlanMixin, App): TITLE = t("Deploy one or more ERPLibre VMs on Proxmox VE!") BINDINGS = [ ("f5", "deploy", t("Deploy")), ("f4", "clear_vm", t("Reset VM")), ("f3", "preview", t("Preview")), ("f6", "select_all", t("All")), + ("f7", "select_main", t("Main versions")), ("f8", "select_none", t("None")), ("escape", "cancel", t("Cancel")), ] @@ -191,6 +210,7 @@ def run_proxmox_form(ctx, run_app: bool = True): self._syncing = False # La liste des ponts GRANDIT : l'écran sait en créer un. self._ponts = list(ponts) + self.extras_init(ctx) # ---------------------------------------------------------------- # # L'écran @@ -294,6 +314,7 @@ def run_proxmox_form(ctx, run_app: bool = True): value=True, id="f_sshcfg", ) + yield from self.compose_vm_type() # La case commande TOUTE installation — ERPLibre, Odoo, # mais aussi l'hyperviseur Proxmox VE d'une VM imbriquée. # Nommée « ERPLibre », elle laissait croire qu'un système @@ -322,6 +343,9 @@ def run_proxmox_form(ctx, run_app: bool = True): allow_blank=False, id="f_branch", ) + yield from self.compose_install_extras() + yield from self.compose_timezone() + yield from self.compose_python() # Hors de la section « Installation » : le suivi regarde la # VM ARRIVER, même quand rien ne s'installe. Rangé dedans, # il se serait grisé avec elle. @@ -335,10 +359,21 @@ def run_proxmox_form(ctx, run_app: bool = True): id="f_monitor", ) yield Static(f" {t('Parallelism')}") + # Cochée, la case donne une exécution PAR installation : + # le plafond du nombre de CPU ne s'applique plus. + # Décochée, le nombre reprend la main, et son défaut suit + # l'HÔTE PROXMOX — l'écran restait figé à quatre choix et + # en proposait un, quel que soit le nombre de cœurs. + yield Checkbox( + t("One run per install"), + value=True, + id="f_par_all", + ) yield Select( - [(str(n), n) for n in (1, 2, 3, 4)], - value=1, + [(str(n), n) for n in range(1, ctx["host_cpu"] + 1)], + value=ctx["host_cpu"], allow_blank=False, + disabled=True, id="f_par", ) with Vertical(id="right"): @@ -441,6 +476,8 @@ def run_proxmox_form(ctx, run_app: bool = True): ctx["host_cpu"], self.custom, self.overrides, + self._default_desktop(), + desktop_suffixes, ) # Ce qu'un système IMPOSE d'installer, posé sur le MODÈLE : le # déploiement lit « install_cmd » VM par VM. @@ -453,12 +490,17 @@ def run_proxmox_form(ctx, run_app: bool = True): ) # Le supplément d'ERPLibre ne vaut que pour les VM qui l'auront # vraiment : une VM Proxmox ne clonera pas le dépôt. - if self.query_one("#f_install", Checkbox).value: - commun = (self._install() or {}).get("cmd") or "" - for row in self.rows: - cmd_vm = row["vm"].get("install_cmd") or commun - if cmd_vm.strip() not in no_erplibre: - row["disk_gb"] += ctx.get("extra_disk_gb", 0) + installe = self.query_one("#f_install", Checkbox).value + commun = (self._install() or {}).get("cmd") or "" + outils = self._vm_tools() + for row in self.rows: + cmd_vm = row["vm"].get("install_cmd") or commun + if installe and cmd_vm.strip() not in no_erplibre: + row["disk_gb"] += ctx.get("extra_disk_gb", 0) + # Le bureau et les outils ne pèsent que sur les VM qui les + # reçoivent RÉELLEMENT : Android Studio n'existe qu'en + # x86_64, les extensions GNOME n'ont de sens que sous GNOME. + row["disk_gb"] += self._extras_disk_gb(row["vm"], outils) for row, entry in zip(self.rows, entries): cle = entry_key(entry) row["custom"] = bool(self.overrides.get(cle)) @@ -472,6 +514,18 @@ def run_proxmox_form(ctx, run_app: bool = True): ), ) self._render_plan() + self.render_extras() + + def _auto_name(self, index): + """Le nom du catalogue, suffixé du bureau : ce que la VM + reprendrait si on effaçait le sien.""" + from script.todo.deploy_form_lib import vm_name + + return vm_name( + self._plan_entries()[index]["name"], + self.rows[index]["vm"].get("desktop"), + desktop_suffixes, + ) def _vmid_start(self): brut = self.query_one("#f_vmid", Input).value.strip() @@ -616,6 +670,10 @@ def run_proxmox_form(ctx, run_app: bool = True): self.arch = arches[event.index] self._reload_catalog() return + if event.radio_set.id in ("f_type", "f_store"): + # Le bureau pèse sur le disque et change le nom des VM. + self._refresh_after(remonter=True) + return if event.radio_set.id == "f_profile": choix = ("1", "2", "3", "4", "custom")[event.index] self.profile = choix @@ -631,27 +689,17 @@ def run_proxmox_form(ctx, run_app: bool = True): self._clear_overrides(tuple(RES_FIELDS)) self._refresh_after(remonter=True) - def _sync_install_deps(self) -> None: - """Grise ce que la case rend sans effet : la branche et le profil. - - Le suivi n'en fait pas partie — il regarde la VM arriver même - quand rien ne s'installe.""" - installe = self.query_one("#f_install", Checkbox).value - for cible in ("#f_profile_install", "#f_branch"): - try: - self.query_one(cible).disabled = not installe - except Exception: - pass - try: - self.query_one("#t_install").set_class(not installe, "off") - except Exception: - pass - def on_checkbox_changed(self, event) -> None: if event.checkbox.id == "f_install": # Le disque d'ERPLibre entre — ou sort — du total. self._sync_install_deps() self._refresh_after() + elif event.checkbox.id == "f_par_all": + self.query_one("#f_par", Select).disabled = event.value + elif str(event.checkbox.id or "").startswith("f_tool_"): + # Un IDE de plus, c'est un disque plus grand : le plan doit + # le montrer AVANT de déployer, pas après une heure. + self._refresh_after() def on_input_changed(self, event) -> None: if event.input.id == "f_vmid": @@ -674,6 +722,8 @@ def run_proxmox_form(ctx, run_app: bool = True): def on_select_changed(self, event) -> None: if self._syncing: return + if self.extras_on_select(event): + return ident = event.select.id or "" # La marque de génération ne concerne QUE les widgets de rangée : # un widget global n'en porte pas. L'exiger de tous revenait à @@ -759,6 +809,9 @@ def run_proxmox_form(ctx, run_app: bool = True): ) return { "branch": self.query_one("#f_branch", Select).value, + # /opt et service confiné, comme en QEMU/KVM : le choix ne + # regarde pas l'hyperviseur, il regarde le système invité. + "prod": self.query_one("#f_prod", Checkbox).value, "label": label, "cmd": cmd, } @@ -775,10 +828,18 @@ def run_proxmox_form(ctx, run_app: bool = True): "start": self.query_one("#f_start", Checkbox).value, "add_ssh_config": self.query_one("#f_sshcfg", Checkbox).value, "install": self._install(), + **self.extras_values(), # Le suivi est demandé au NIVEAU DU DÉPLOIEMENT : une VM sans # ERPLibre se suit aussi (cloud-init, puis relevé système). "monitor": self.query_one("#f_monitor", Checkbox).value, - "parallelism": self.query_one("#f_par", Select).value, + # Une exécution par installation : le nombre de VM retenues + # fait foi. Le déploiement le borne ensuite à ce même nombre, + # donc une valeur haute ne crée aucun travailleur inutile. + "parallelism": ( + max(1, len(self.vms)) + if self.query_one("#f_par_all", Checkbox).value + else self.query_one("#f_par", Select).value + ), } def action_deploy(self) -> None: @@ -817,12 +878,6 @@ def run_proxmox_form(ctx, run_app: bool = True): self.overrides.pop(cle, None) self._refresh_after(remonter=True) - def action_select_all(self) -> None: - self.query_one("#f_catalog", SelectionList).select_all() - - def action_select_none(self) -> None: - self.query_one("#f_catalog", SelectionList).deselect_all() - def action_cancel(self) -> None: self.result = None self.exit() diff --git a/script/todo/proxmox_menu.py b/script/todo/proxmox_menu.py index 4dc4349..79be598 100644 --- a/script/todo/proxmox_menu.py +++ b/script/todo/proxmox_menu.py @@ -435,12 +435,17 @@ class ProxmoxMenuMixin: if not brut: print(t("Nothing selected.")) return - noms = [vm["name"] for vm in vms] if brut in ("all", "*"): choisies = list(vms) else: - retenus = set(self._parse_index_selection(brut, noms)) - choisies = [vm for vm in vms if vm["name"] in retenus] + # Par RANG, jamais par nom : deux VM du même hôte peuvent + # porter le même nom (seul le VMID est unique sur Proxmox), et + # cocher l'une éteignait les deux. + rangs = self._parse_index_selection( + brut, [str(i) for i in range(1, len(vms) + 1)] + ) + voulus = {int(r) for r in rangs if str(r).isdigit()} + choisies = [vm for i, vm in enumerate(vms, 1) if i in voulus] if not choisies: print(t("Nothing selected.")) return @@ -828,6 +833,11 @@ class ProxmoxMenuMixin: # La branche du dépôt : c'est elle qu'on déploie le plus souvent. "branch_current": self._qemu_repo_branch(), "install_profiles": self._qemu_install_profiles(), + # Type de VM, magasin d'applications, outils, fuseau, + # interpréteur Python : les réglages du système INVITÉ, qui ne + # regardent pas l'hyperviseur. Cet écran n'en portait que trois — + # une VM créée ici naissait sans bureau, sans outils et en UTC. + **self._qemu_guest_context(), # Même règle qu'en QEMU/KVM : un système peut IMPOSER ce qu'on # installe dessus. Un Proxmox imbriqué recevait sinon ERPLibre et # Odoo 18, comme l'écran d'à côté avant correction. @@ -869,6 +879,38 @@ class ProxmoxMenuMixin: code, out = self._pve_show("hostname", quiet=True) return out.strip().splitlines()[0] if code == 0 and out.strip() else "" + def _pve_disk_with_margin(self, vm, spec): + """Taille du disque à créer : celle du plan, marge comprise. + + La même règle que la voie libvirt, qui ajoute ERPLIBRE_EXTRA_DISK_GB à + la demande initiale quand ERPLibre s'installe. Ici la marge se perdait + entre l'écran et « qm resize ». + """ + from script.todo.deploy_form_extras import ( + extras_disk_gb, + extras_tables, + ) + + demande = vm.get("disk") or "" + gigs = self._parse_disk_gb(demande) + if not gigs: + return demande + install = spec.get("install") or {} + cmd = vm.get("install_cmd") or install.get("cmd") or "" + marge = 0 + if self._qemu_installs_erplibre(install.get("branch"), cmd): + marge += self.ERPLIBRE_EXTRA_DISK_GB + # Le bureau et les outils pèsent aussi, et sur la VM QUI LES REÇOIT : + # une VM ARM n'aura pas Android Studio, un serveur aucun des IDE. Le + # plan les additionne déjà à l'écran ; sans eux ici, la VM naissait + # avec le disque d'un serveur nu et GNOME le remplissait. + marge += extras_disk_gb( + dict(vm, desktop=vm.get("desktop") or spec.get("desktop") or ""), + spec.get("vm_tools") or (), + extras_tables(self._qemu_guest_context()), + ) + return f"{gigs + marge}G" if marge else demande + def _pve_vm_commands(self, mod, vm, spec): """Les commandes de création d'UNE VM, dans l'ordre : l'image puis « qm ». Sert à l'aperçu comme à l'exécution — un aperçu qui ne @@ -884,7 +926,12 @@ class ProxmoxMenuMixin: "name": vm["name"], "memory": vm["ram"], "vcpus": vm["vcpus"], - "disk": vm["disk"], + # La MARGE d'ERPLibre entre dans la taille réellement créée : le + # plan l'annonçait (« 25G » pour un catalogue à 20 G) et « qm + # resize » recevait 20 G. La VM naissait cinq gigaoctets trop + # petite pour ce qu'on venait de lui promettre — trouvé par + # l'audit, pas à l'usage. + "disk": self._pve_disk_with_margin(vm, spec), "storage": spec["storage"], "bridge": spec["bridge"], "image": image, @@ -967,7 +1014,33 @@ class ProxmoxMenuMixin: # sortie ; celle-ci les écrit, ce qui vaut mieux qu'un défilement. session = self._pve_log_dir() print(f"\n {t('Log:')} {session}") - resultats = run_deploy_progress(travaux, spec.get("parallelism") or 1) + # Comment joindre chaque VM SANS dépendre de ~/.ssh/config, qui n'est + # écrit qu'après : par le rebond de l'hôte, explicitement. C'est ce que + # « s » utilise dans la vue de progression — sans quoi il partait sur + # le nom de la VM, donc sur une locale homonyme (rapporté). + cibles_ssh = {} + for vm in spec["vms"]: + ip = pve.ip_from_ipconfig(vm.get("ipconfig") or "") + if ip: + compte = (spec.get("user") or "erplibre") + "@" + ip + cibles_ssh[vm["name"]] = ( + f"ssh -J {shlex.quote(host['target'])} " + f"{shlex.quote(compte)}" + ) + # Ce qui attend derrière cet écran : sans le dire, on reste devant + # une fenêtre « terminée » sans savoir que l'installation d'ERPLibre + # démarre en la quittant. + suite = "" + if spec.get("install"): + suite = t("Quit (q) to start the ERPLibre install") + elif spec.get("monitor", True): + suite = t("Quit (q) to follow the VM starting up") + resultats = run_deploy_progress( + travaux, + spec.get("parallelism") or 1, + ssh_cmds=cibles_ssh, + suite=suite, + ) reussies = [nom for nom, code, _o, _d in resultats if code == 0] for nom, code, sortie, duree in resultats: chemin = self._pve_write_log( @@ -990,7 +1063,8 @@ class ProxmoxMenuMixin: print(f" {ligne}") if not reussies: return - self._pve_after_create(host, spec, reussies, cle_locale) + joignables = self._pve_after_create(host, spec, reussies, cle_locale) + self._pve_print_summary(spec, joignables or [], session) @staticmethod def _pve_log_dir(): @@ -1047,6 +1121,178 @@ class ProxmoxMenuMixin: fh.write("\n".join(entete) + "\n") return chemin + def _pve_alias_names(self, nom, chaine, locaux, rebond=""): + """UN seul nom pour l'entrée ~/.ssh/config, et lequel. + + Deux noms sur la même ligne « Host » — le court et le chaîné + « hôte+vm » — étaient un doublon dès que le court était libre : ssh + n'a besoin que d'un nom, et le second n'ajoutait qu'une façon de plus + d'écrire la même adresse. Rapporté. + + Le court quand il est LIBRE, c'est celui qu'on tape ; le chaîné + sinon, car un nom déjà pris désigne une AUTRE machine — une VM locale + du même nom, ou la VM d'un autre hôte Proxmox. Vécu : « ssh » partait + vers la machine locale qui partageait le nom. + + « Pris » se juge sur le ProxyJump du bloc et non sur sa seule + présence : notre propre entrée, réécrite à chaque déploiement, se + serait autrement prise pour une rivale — et le nom aurait basculé + d'une fois sur l'autre. + + Rend (noms, volé) — `volé` nomme ce qui a forcé le nom chaîné, pour + que l'appelant le dise plutôt que de laisser la surprise.""" + if nom in locaux: + return [chaine], t("a local VM") + bloc = self._ssh_config_block(nom) + notre = ( + not bloc + or chaine in bloc.get("names", ()) + or (rebond and bloc.get("proxyjump") == rebond) + ) + return ([nom], "") if notre else ([chaine], "~/.ssh/config") + + def _pve_set_timezone(self, cible, spec): + """Pose le fuseau DANS la VM, par ssh. + + La voie libvirt le donne à cloud-init, qui écrit /etc/timezone au + premier démarrage. « qm set » n'a pas d'équivalent : le cloud-init de + Proxmox ne règle que l'utilisateur, la clé et le réseau. Une VM créée + ici restait donc en UTC — et on ne s'en aperçoit qu'aux horodatages, + parfois des jours plus tard. + + AVANT l'installation, pour que le journal porte déjà la bonne heure. + Un nom IANA, jamais un décalage : « UTC-5 » ne dit rien de l'heure + d'été, et timedatectl le refuse. + """ + fuseau = (spec.get("timezone") or "").strip() + if not fuseau: + return False + code, sortie = self._pve_ssh( + cible, f"sudo timedatectl set-timezone {shlex.quote(fuseau)}" + ) + if code: + # Nommé et non tu : la VM reste en UTC, et c'est une surprise + # qu'on veut avoir maintenant plutôt qu'au premier journal. + print(f" ⚠ {t('timezone not set')} : {fuseau} ({code})") + return False + print(f" ✓ {t('Timezone')} : {fuseau}") + return True + + def _pve_write_guide(self, cible, vm, spec, mod): + """Pose le guide de connexion et l'identité git DANS la VM. + + La voie libvirt les livre par le « write_files » de cloud-init ; + « qm set » n'offre pas cela, donc une VM Proxmox n'avait AUCUN guide — + quelle que soit sa distribution. Rapporté sur Arch. + + Même contenu, livrée par ssh une fois la VM debout : `guide_files` est + la source unique, comme sa docstring le promet. Un seul appel, tous les + fichiers. + """ + import types + + from script.todo.todo_i18n import get_lang + + install = spec.get("install") or {} + cmd_install = vm.get("install_cmd") or install.get("cmd") or "" + args = types.SimpleNamespace( + distro=vm.get("distro") or "", + version=vm.get("version") or "", + arch=vm.get("arch") or "amd64", + lang=get_lang(), + # La section ERPLibre n'apparaît que si ERPLibre y sera : un guide + # qui annonce un dépôt absent est un guide qui ment. + erplibre_dir=( + self._qemu_guide_dir(False) + if self._qemu_installs_erplibre( + install.get("branch"), cmd_install + ) + else "" + ), + erplibre_make=self._qemu_make_target(cmd_install), + desktop=bool(vm.get("desktop")), + no_git_identity=False, + user=spec.get("user") or "erplibre", + ) + try: + fichiers = mod.guide_files(args) + except Exception as exc: # pragma: no cover - dépend du module + print(f" ⚠ {t('guide not written')} : {exc}") + return False + morceaux = [] + for chemin, mode, contenu, proprio in fichiers: + q = shlex.quote(chemin) + morceaux.append( + f"printf '%s' {shlex.quote(contenu)} | sudo tee {q} " + f">/dev/null && sudo chmod {mode} {q}" + ) + if proprio: + morceaux.append(f"sudo chown {shlex.quote(proprio)}: {q}") + code, _o = self._pve_ssh(cible, " && ".join(morceaux)) + if code: + print(f" ⚠ {t('guide not written')} ({code})") + return False + print(f" ✓ {t('connection guide written')}") + return True + + @staticmethod + def _pve_ssh(cible, remote, timeout=60): + """(code, sortie) d'une commande exécutée DANS la VM, par son alias. + + Par l'alias et non par l'adresse : lui seul porte le rebond vers le + réseau interne de l'hôte.""" + from script.proxmox import proxmox_deploy as pve + + argv = [ + "ssh", + "-o", + "BatchMode=yes", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "-o", + "ConnectTimeout=10", + cible, + remote, + ] + try: + res = subprocess.run( + argv, capture_output=True, text=True, timeout=timeout + ) + except (OSError, subprocess.SubprocessError) as exc: + return 255, str(exc) + return res.returncode, pve.strip_ssh_noise( + (res.stdout or "") + (res.stderr or "") + ) + + def _pve_print_summary(self, spec, joignables, session): + """Sommaire final : ce qui existe, où, et comment y entrer. + + Le pendant de celui de QEMU/KVM. Sans lui, l'écran se refermait sur la + vue de progression et il ne restait rien à l'écran — ni l'adresse, ni + la commande ssh, ni le chemin du journal.""" + print(f"\n{'═' * 60}") + print(f" {t('TOTAL summary')}") + print( + f" {t('VMs deployed:')} {len(joignables)}/{len(spec['vms'])}" + f" {t('storage')} {spec.get('storage')}" + f" {t('bridge')} {spec.get('bridge')}" + ) + for vm in joignables: + print( + f" {vm['name']:<32} {t('VMID')} {vm.get('vmid', '?'):<6}" + f" {vm.get('adresse', '?')}" + ) + if vm.get("alias"): + print(f" ssh {vm['alias']}") + if spec.get("install"): + print( + f" {t('Install:')} {spec['install'].get('label') or ''}" + f" ({spec['install'].get('branch')})" + ) + print(f" {t('Log:')} {session}") + def _pve_confirm_spec(self, host, spec): """Récapitulatif puis confirmation, dans le TERMINAL. @@ -1081,6 +1327,25 @@ class ProxmoxMenuMixin: entrer dans une VM qui n'est pas sur notre réseau.""" from script.proxmox import proxmox_deploy as pve + # Les domaines LOCAUX : un nom partagé avec l'un d'eux fait dérailler + # l'alias ssh et le suivi d'installation. + locaux = set(self._qemu_list_domains()) + try: + mod_qemu = self._qemu_import_module() + except Exception: + mod_qemu = None + + def alias_chaine(nom): + """« hôte+vm », la convention déjà utilisée pour les VM + imbriquées : elle dit où la machine vit, et n'entre en conflit + avec rien.""" + hote = (host.get("target") or "").split("@")[-1] + hote = re.sub(r"[^A-Za-z0-9._-]", "-", hote) or "pve" + return f"{hote}+{nom}" + + # {nom de VM: alias à utiliser} — le suivi doit passer par l'alias + # qu'on a RÉELLEMENT écrit, pas par le nom. + alias = {} joignables = [] for vm in spec["vms"]: if vm["name"] not in reussies: @@ -1095,6 +1360,22 @@ class ProxmoxMenuMixin: ) continue print(f" ✓ {vm['name']} : {ip}") + # Un nom qui existe DÉJÀ comme domaine local est un piège : l'alias + # ~/.ssh/config serait volé à la VM locale, et le suivi + # d'installation — qui ré-résout par virsh — irait installer + # ERPLibre sur ELLE. Vécu : « erplibre-ubuntu-2604 » déployée sur + # Proxmox, installation partie sur la VM locale du même nom. + noms_alias, vole = self._pve_alias_names( + vm["name"], + alias_chaine(vm["name"]), + locaux, + host["target"], + ) + if vole: + print( + f" ⚠ {t('This name is already taken by')} {vole} :" + f" {t('the alias goes to')} {noms_alias[0]}" + ) # L'entrée ~/.ssh/config est le SEUL chemin vers cette VM : elle # est derrière l'hôte Proxmox (pont interne), donc son adresse # n'est pas routable d'ici et seul le rebond y mène. Décochée @@ -1106,22 +1387,34 @@ class ProxmoxMenuMixin: print(f" → {t('~/.ssh/config written anyway (install)')}") if spec.get("add_ssh_config") or besoin: self._write_ssh_config_entry( - vm["name"], + noms_alias, spec.get("user") or "erplibre", ip, identity_file=self._ssh_private_key(cle_locale), proxy_jump=host["target"], ) - print(f" ✓ ~/.ssh/config : ssh {vm['name']}") + alias[vm["name"]] = noms_alias[0] + print(f" ✓ ~/.ssh/config : ssh {noms_alias[0]}") + vm["adresse"] = ip + vm["alias"] = alias.get(vm["name"], vm["name"]) + # Le guide AVANT l'installation : il doit être là même si rien ne + # s'installe, et l'installation ne le touche pas. + if vm["alias"] and mod_qemu: + self._pve_write_guide(vm["alias"], vm, spec, mod_qemu) + if vm["alias"]: + self._pve_set_timezone(vm["alias"], spec) joignables.append(vm) install = spec.get("install") + # Rendu à l'appelant pour son sommaire : lui seul sait ce qui a été + # RÉELLEMENT joint. + resultat = list(joignables) # Le suivi vient du DÉPLOIEMENT, pas de l'installation — même règle # qu'en QEMU/KVM. Sans elle, la case « Suivre l'installation » ne # commandait rien : décochée, le tableau de bord s'ouvrait quand # même ; cochée sans rien à installer, il ne s'ouvrait jamais. suivi = spec.get("monitor", True) if not joignables or not (install or suivi): - return + return resultat noms = [vm["name"] for vm in joignables] if install: print(f" {install.get('label') or ''}") @@ -1149,6 +1442,10 @@ class ProxmoxMenuMixin: "sudo": host.get("sudo") or "", "jump": host.get("jump") or "", "vmid": vm.get("vmid"), + # L'adresse INTERNE : elle n'est pas routable d'ici, mais + # elle l'est depuis l'hôte. Avec le rebond, le tableau de + # bord entre dans la VM sans dépendre de ~/.ssh/config. + "addr": vm.get("adresse") or "", } for vm in joignables if vm.get("vmid") @@ -1156,11 +1453,28 @@ class ProxmoxMenuMixin: self._qemu_install_erplibre_monitored( noms, branche, - {n: n for n in noms}, + {n: alias.get(n, n) for n in noms}, finale, + # Les réglages du système invité, qui n'atteignaient pas la + # commande distante : la VM naissait serveur nu, sans outils. + prod=bool(spec.get("prod")), + desktop=spec.get("desktop") or "", + python_provider=spec.get("python_provider") or "", + app_store=spec.get("app_store") or "deb", + vm_tools=spec.get("vm_tools") or (), pve=cartes_pve, + # Ce que sont ces VM, pris de la SPEC. Le suivi le demandait + # à virsh, qui ne connaît que les domaines d'ici. + meta={ + vm["name"]: ( + vm.get("distro"), + vm.get("version"), + vm.get("arch") or "amd64", + ) + for vm in joignables + }, ) - return + return resultat # Sans suivi mais avec quelque chose à installer : en série, sortie à # l'écran. C'est le pendant exact de la voie QEMU/KVM. print(f"\n{t('Installing ERPLibre on each VM')} ({branche})…") @@ -1169,10 +1483,15 @@ class ProxmoxMenuMixin: vm["name"], cle_locale, branche, - pve.ip_from_ipconfig(vm.get("ipconfig") or "") or vm["name"], + alias.get(vm["name"], vm["name"]), vm.get("install_cmd") or commun, - False, + bool(spec.get("prod")), + desktop=spec.get("desktop") or "", + python_provider=spec.get("python_provider") or "", + app_store=spec.get("app_store") or "deb", + vm_tools=spec.get("vm_tools") or (), ) + return resultat def _pve_deploy_prompts(self, dry_run=False): """Déploie une VM SUR l'hôte Proxmox choisi, par questions. @@ -1297,28 +1616,55 @@ class ProxmoxMenuMixin: 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}") + # ÉPILOGUE COMMUN avec l'écran, au lieu de le redire ici : cette voie + # avait vieilli en silence — pas de protection de l'alias contre un + # domaine local homonyme, pas de guide de connexion, pas de bloc + # « pve » (donc aucune colonne vivante dans le suivi), pas de + # sommaire. Trouvé par l'audit, jamais à l'usage. + install = None 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 - ) + install = {"branch": branch, "cmd": cmd, "label": label} + spec_finale = { + "host": host, + "storage": stockage, + "bridge": pont, + "res_label": "", + "vms": [ + { + "name": nom, + "vmid": vmid, + "distro": distro, + "version": version, + "arch": arch, + "ram": memoire, + "vcpus": vcpus, + "disk": disque, + "desktop": "", + "install_cmd": "", + "ipconfig": ipconfig, + } + ], + "existing": [], + "user": "erplibre", + "ssh_key": cle_locale or "", + "add_ssh_config": True, + "install": install, + "monitor": True, + "python_provider": "", + # La voie par questions ne demande pas le fuseau — l'écran le + # fait. Sans ce défaut, elle laissait la VM en UTC, alors que la + # voie libvirt reprend le fuseau de l'hôte depuis toujours. + "timezone": self._qemu_host_timezone(), + } + joignables = self._pve_after_create( + host, spec_finale, [nom], cle_locale + ) + self._pve_print_summary(spec_finale, joignables or [], "") def _pve_ssh_config(self): """Écrit une entrée ~/.ssh/config par VM de l'hôte, avec l'hôte @@ -1332,20 +1678,36 @@ class ProxmoxMenuMixin: print(f"\n{t('No running VM on this Proxmox host.')}") return cle = self._ssh_private_key(self._qemu_default_ssh_key()) + # Les domaines LOCAUX : un nom partagé avec l'un d'eux ne doit pas lui + # voler son alias — même règle que le déploiement. + locaux = set(self._qemu_list_domains()) + hote_court = (host.get("target") or "").split("@")[-1] + hote_court = re.sub(r"[^A-Za-z0-9._-]", "-", hote_court) or "pve" 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( + noms, vole = self._pve_alias_names( vm["name"], + f"{hote_court}+{vm['name']}", + locaux, + host["target"], + ) + if vole: + print( + f" ⚠ {t('This name is already taken by')} {vole} :" + f" {t('the alias goes to')} {noms[0]}" + ) + self._write_ssh_config_entry( + noms, "erplibre", ip, identity_file=cle, proxy_jump=host["target"], ) print( - f" ✓ ssh {vm['name']} ({ip} {t('through')} {host['target']})" + f" ✓ ssh {noms[0]} ({ip} {t('through')} {host['target']})" ) def _pve_test_vm(self): diff --git a/script/todo/qemu_access.py b/script/todo/qemu_access.py index 2a3dec2..645f38d 100644 --- a/script/todo/qemu_access.py +++ b/script/todo/qemu_access.py @@ -735,6 +735,9 @@ class QemuAccessMixin: # Les commandes de réparation se lancent SUR l'hyperviseur : le préfixe # évite de les copier sur la mauvaise machine, l'erreur naturelle ici. pre = f"ssh {jump} " if jump else "" + if not port and self._hypervisor_is_proxmox(jump): + self._pve_console_hint(jump, domain) + return if not port: print(f"\n ⚠ {t('This VM exposes no VNC port.')}") print(f" {t('Its display is likely spice with listen=none:')}") @@ -772,6 +775,55 @@ class QemuAccessMixin: print(f" {t('then point your VNC client at')} localhost:{port}") print(f" {t('The tunnel stays open as long as that ssh runs.')}") + @staticmethod + def _hypervisor_is_proxmox(jump) -> bool: + """Cet hyperviseur est-il un Proxmox VE ? + + La question se pose quand « virsh vncdisplay » n'a rien rendu : un + Proxmox n'a PAS de libvirt, donc l'absence de port n'y veut pas dire + « écran fermé », elle veut dire « mauvaise question ». Sans cette + distinction, on conseillait « virsh edit » sur une machine où la + commande n'existe pas. + + « qm » et non « pveversion » : c'est le binaire dont on parle ensuite. + """ + if not jump: + # Un Proxmox n'est jamais l'hôte local ici : ce menu tourne sur le + # poste de travail, et un Proxmox se joint par ssh. + return False + try: + res = subprocess.run( + ["ssh", "-o", "BatchMode=yes", jump, "command -v qm"], + capture_output=True, + text=True, + timeout=25, + ) + except (OSError, subprocess.SubprocessError): + return False + return res.returncode == 0 and bool(res.stdout.strip()) + + def _pve_console_hint(self, jump, domain) -> None: + """Les deux vraies façons de voir l'écran d'une VM Proxmox. + + Proxmox ne sert pas son écran par un port VNC qu'on tunnelise : il le + sert par un TICKET, sur son interface web (« qm vncproxy » ouvre un + websocket authentifié, pas un socket qu'on relaie). Un « ssh -L » vers + un port VNC n'y trouve rien, quel que soit le port.""" + print( + f"\n ⚠ {t('This hypervisor is Proxmox VE: it has no libvirt.')}" + ) + print(f" {t('Its screen is served by a ticket, not by a VNC port.')}") + print(f"\n {t('Two ways in:')}") + print(f" • {t('the serial console, VMID from the Proxmox menu:')}") + print(f" ssh {jump} sudo qm list # {domain}") + print(f" ssh -t {jump} sudo qm terminal ") + print(f" • {t('the web interface, through a tunnel:')}") + print(f" ssh -N -L 8006:127.0.0.1:8006 {jump}") + print(f" https://localhost:8006 → {t('VM')} → Console") + print( + f"\n {t('TODO > Execute > Deploy > Proxmox VE does the first.')}" + ) + @staticmethod def _qemu_vnc_port(domain, jump=""): """Port VNC réel d'un domaine, localement ou sur un hyperviseur distant. diff --git a/script/todo/qemu_deploy.py b/script/todo/qemu_deploy.py index cf5c849..4d8c95a 100644 --- a/script/todo/qemu_deploy.py +++ b/script/todo/qemu_deploy.py @@ -271,6 +271,7 @@ class QemuDeployMixin: app_store="deb", vm_tools=(), pve=None, + meta=None, ): """Lance l'install ERPLibre en parallèle DÉTACHÉE sur les VM et ouvre le dashboard Textual. Quitter le dashboard n'arrête pas les installs. @@ -279,7 +280,10 @@ class QemuDeployMixin: `prod` : install /opt/erplibre + service SELinux confiné. `vm_tools` : outils cochés pour tout le parc, filtrés machine par machine (Android Studio n'existe qu'en x86_64, les extensions GNOME - n'ont pas de sens sous Cinnamon).""" + n'ont pas de sens sous Cinnamon). + `meta` : {nom: (distro, version, arch)} quand l'appelant SAIT ce que + sont ces VM. Sans elle, on le demande à virsh — juste ici, donc faux + pour une VM qui vit sur un Proxmox distant.""" from script.todo.qemu_install_monitor import ( launch_installs, run_monitor, @@ -316,7 +320,13 @@ class QemuDeployMixin: for name in names: ip = ip_map.get(name) if ip: - d, v, a = ( + # Ce que l'appelant sait d'abord. Sinon virsh — mais virsh + # ne connaît QUE les domaines d'ici : sur une VM posée sur un + # Proxmox distant il ne répond rien, ou pire, il répond pour + # un domaine local qui porte le même nom. L'architecture + # décide des outils installés : une VM ARM prise pour x86_64 + # recevait Android Studio, qui n'existe pas pour elle. + d, v, a = (meta or {}).get(name) or ( self._qemu_vm_meta(name, mod) if mod else (None, None, None) @@ -1266,7 +1276,6 @@ class QemuDeployMixin: if self._qemu_distro_profile(d) }, "ssh_key": self._qemu_default_ssh_key(), - "timezone": self._qemu_host_timezone(), "host_cpu": os.cpu_count() or 2, "free_ram": self._host_free_ram_mb(), # La place du système de fichiers qui portera les qcow2. Mesurée @@ -1279,47 +1288,7 @@ class QemuDeployMixin: "ram_presets": self._QEMU_RAM_PRESETS, "disk_presets": self._QEMU_DISK_PRESETS, "extra_disk_gb": self.ERPLIBRE_EXTRA_DISK_GB, - "desktop_disk_gb": self.QEMU_DESKTOP_EXTRA_DISK_GB, - "mise_arches": self.QEMU_MISE_ARCHES, - "app_stores": [(k, t(lbl)) for k, lbl in self.QEMU_APP_STORES], - "timezones": self._qemu_timezone_choices( - self._qemu_host_timezone() - ), - "snap_distros": self.QEMU_SNAP_DISTROS, - "vm_tools": self._qemu_vm_tool_choices(), - "vm_tool_disk": { - k: v["disk_gb"] for k, v in self._QEMU_VM_TOOLS.items() - }, - "vm_tool_arches": { - k: v["arches"] for k, v in self._QEMU_VM_TOOLS.items() - }, - "vm_tool_desktops": { - k: v["desktops"] for k, v in self._QEMU_VM_TOOLS.items() - }, - # « after » = l'outil vit DANS le dépôt ERPLibre (compilation - # mobile, AVD, script Forgejo) : sans installation, il n'existe - # pas, et la commande distante le saute en le nommant. - "vm_tool_phases": { - k: v.get("phase", "before") - for k, v in self._QEMU_VM_TOOLS.items() - }, - "vm_tool_needs_desktop": { - k: v["needs_desktop"] for k, v in self._QEMU_VM_TOOLS.items() - }, - "vm_tool_families": { - k: v["families"] for k, v in self._QEMU_VM_TOOLS.items() - }, - "distro_family": dict(self._QEMU_DISTRO_FAMILY), - "desktop_suffixes": self._qemu_desktop_suffixes(), - "desktops": [ - (k, v["label"]) for k, v in self._QEMU_DESKTOP.items() - ], - "defaults": { - "install": True, - "add_ssh_config": True, - "monitor": True, - "prod": False, - }, + **self._qemu_guest_context(), # L'aperçu passe par le MÊME constructeur que le déploiement. "build_command": lambda vm, spec, dry: " ".join( shlex.quote(p) diff --git a/script/todo/qemu_deploy_form.py b/script/todo/qemu_deploy_form.py index d0008d3..f2f81d6 100644 --- a/script/todo/qemu_deploy_form.py +++ b/script/todo/qemu_deploy_form.py @@ -70,6 +70,7 @@ from script.todo.deploy_form_lib import ( # noqa: F401 # Le socle commun aux deux formulaires (QEMU/KVM et Proxmox VE). Réexporté # tel quel : les appelants historiques importent encore ces noms ICI. +from script.todo.deploy_form_extras import ExtrasMixin from script.todo.deploy_form_plan import ( # noqa: F401 PlanMixin, preview_screen, @@ -127,33 +128,11 @@ def run_deploy_form(ctx, run_app: bool = True): total_disk = ctx.get("total_disk") or 0 base_vcpus = ctx.get("base_vcpus") or 2 extra_disk = ctx.get("extra_disk_gb") or 0 - desktop_disk = ctx.get("desktop_disk_gb") or 0 # [(clé, libellé)] — la liste vient de todo.py, source unique. desktops = list(ctx.get("desktops") or []) # {clé de saveur: suffixe de nom}, fourni par todo.py qui décrit les # saveurs — on ne le redéfinit pas ici. desktop_suffixes = dict(ctx.get("desktop_suffixes") or {}) - # Outils de développement d'une VM graphique : [(clé, libellé, indice)] et - # leurs contraintes, toutes décrites dans todo.py — le formulaire ne fait - # que les afficher et rendre les cases cochées. - vm_tools = list(ctx.get("vm_tools") or []) - tool_disk = dict(ctx.get("vm_tool_disk") or {}) - # « after » = l'outil vit DANS le dépôt ERPLibre : sans installation, il - # n'a rien où s'installer, bureau ou pas. - tool_phases = dict(ctx.get("vm_tool_phases") or {}) - tool_arches = dict(ctx.get("vm_tool_arches") or {}) - tool_desktops = dict(ctx.get("vm_tool_desktops") or {}) - tool_needs_desktop = dict(ctx.get("vm_tool_needs_desktop") or {}) - tool_families = dict(ctx.get("vm_tool_families") or {}) - distro_family = dict(ctx.get("distro_family") or {}) - # Architectures pour lesquelles mise publie un binaire. - mise_arches = set(ctx.get("mise_arches") or ()) - # [(clé, libellé)] des magasins d'applications, et les distributions qui - # livrent snapd — la question n'a de sens que pour celles-là, graphiques. - app_stores = list(ctx.get("app_stores") or []) - snap_distros = set(ctx.get("snap_distros") or ()) - # Fuseaux proposés, celui de l'hôte en tête (voir todo.py). - timezones = list(ctx.get("timezones") or []) defaults = ctx.get("defaults") or {} result = {"spec": None} @@ -169,7 +148,7 @@ def run_deploy_form(ctx, run_app: bool = True): f"RAM≥{e['ram']}Mo {e['disk']}" ) - class DeployForm(PlanMixin, App): + class DeployForm(ExtrasMixin, PlanMixin, App): # Le socle porte la mise en page et les modales ; ne reste ici que ce # qui nomme les widgets propres à QEMU/KVM. CSS = ( @@ -215,6 +194,7 @@ def run_deploy_form(ctx, run_app: bool = True): self._syncing = False # Jeu de VM actuellement monté dans le panneau droit. self._shown_ids = () + self.extras_init(ctx) # Génération du jeu de rangées monté. Les identifiants de widgets # portent un RANG, et le rang se décale quand on coche ou décoche # une entrée : un événement émis par un widget déjà détruit @@ -304,20 +284,7 @@ def run_deploy_form(ctx, run_app: bool = True): f"\n {t('A VM edited on the right (marked) keeps its own.')}", id="scopetarget", ) - # Serveur par défaut : c'est ce que sert une image cloud, - # et GNOME ajoute une à deux heures sur une architecture - # émulée. Le plan annonce le surcoût disque. - yield Static(t("VM type (default):"), classes="grouptitle") - with RadioSet(id="f_type"): - yield RadioButton( - t("Server (no graphical interface)"), - value=not defaults.get("desktop", ""), - ) - for key, label in desktops: - yield RadioButton( - f"{t('Graphical (server + desktop):')} {label}", - value=defaults.get("desktop", "") == key, - ) + yield from self.compose_vm_type() # La case commande TOUTE installation — ERPLibre, Odoo, mais # aussi l'hyperviseur Proxmox VE. Nommée « ERPLibre », elle # laissait croire qu'un système Proxmox s'installerait @@ -348,56 +315,8 @@ def run_deploy_form(ctx, run_app: bool = True): allow_blank=not profiles, id="f_profile_install", ) - yield Checkbox( - t("Production (/opt, confined)"), - value=defaults.get("prod", False), - id="f_prod", - ) - if app_stores: - yield Static( - t("Application store:"), - id="t_store", - classes="grouptitle", - ) - with RadioSet(id="f_store"): - for i, (_k, label) in enumerate(app_stores): - yield RadioButton(label, value=i == 0) - yield Static("", id="storewarn") - if vm_tools: - # Une case par outil, et non une liste déroulante : ils - # sont indépendants, et chacun se prend ou se laisse. - yield Static( - t("Development tools:"), - id="t_tools", - classes="grouptitle", - ) - for key, label, hint in vm_tools: - gb = tool_disk.get(key, 0) - yield Checkbox( - f"{label} +{gb} Go — {hint}", - value=key in (defaults.get("tools") or ()), - id=f"f_tool_{key}", - ) - yield Static("", id="toolwarn") - yield Static(t("Timezone"), classes="grouptitle") - # Une liste plutôt qu'une saisie : un nom IANA mal - # orthographié n'est pas refusé par cloud-init, il est - # IGNORÉ — la VM reste en UTC et on ne s'en aperçoit - # qu'aux horodatages. « libre… » garde la porte ouverte - # aux six cents autres fuseaux de la base. - yield Select( - [(z, z) for z in timezones] - + [(t("free value…"), FREE)], - value=(timezones[0] if timezones else SELECT_NULL), - allow_blank=False, - id="f_tz_sel", - ) - yield Input( - value=ctx.get("timezone") or "", - placeholder=t("Timezone for the VMs"), - id="f_tz", - classes="freeval", - ) + yield from self.compose_install_extras() + yield from self.compose_timezone() yield Static("SSH", classes="grouptitle") yield Input( value=ctx.get("ssh_key") or "", @@ -409,20 +328,7 @@ def run_deploy_form(ctx, run_app: bool = True): value=defaults.get("add_ssh_config", True), id="f_sshcfg", ) - # mise pose un CPython précompilé, pyenv le compile. - # Grisé quand AUCUNE des VM retenues n'est sur une - # architecture que mise sert. - yield Static( - t("Python interpreter:"), - id="t_python", - classes="grouptitle", - ) - with RadioSet(id="f_python"): - yield RadioButton( - t("mise (precompiled, faster)"), value=True - ) - yield RadioButton(t("pyenv (compiles from source)")) - yield Static("", id="miswarn") + yield from self.compose_python() # Hors de la section « Installation » : le suivi # regarde la VM ARRIVER, même quand rien ne s'installe. # Rangé dans cette section, il se serait grisé avec elle — @@ -596,17 +502,10 @@ def run_deploy_form(ctx, run_app: bool = True): ) if installe and cmd_vm.strip() not in no_erplibre: row["disk_gb"] += extra_disk - if row["vm"].get("desktop"): - row["disk_gb"] += desktop_disk - # Même règle pour les outils, et pour la même raison : ils ne - # pèsent que sur les VM qui les reçoivent réellement. Android - # Studio n'existe qu'en x86_64, les extensions GNOME n'ont de - # sens que sous GNOME — une VM qui ne les aura pas ne doit pas - # se voir gonfler son disque. - row["disk_gb"] += sum( - tool_disk.get(k, 0) - for k in self._tools_for_vm(row["vm"], tools) - ) + # Le bureau et les outils ne pèsent que sur les VM qui + # les reçoivent RÉELLEMENT : Android Studio n'existe qu'en + # x86_64, les extensions GNOME n'ont de sens que sous GNOME. + row["disk_gb"] += self._extras_disk_gb(row["vm"], tools) # Le plan doit MONTRER qu'une VM a été personnalisée : sans marque, # deux lignes aux ressources différentes n'ont aucune explication à # l'écran, et la surcharge est oubliée à la relecture. Le drapeau @@ -617,163 +516,7 @@ def run_deploy_form(ctx, run_app: bool = True): row["custom"] = bool(self.overrides.get(key)) row["locked"] = key in self.locked self._render_plan() - self._render_mise() - self._render_store() - self._render_tools() - - def _vm_tools(self): - """Clés des outils cochés, dans l'ordre de la liste.""" - picked = [] - for key, _label, _hint in vm_tools: - try: - if self.query_one(f"#f_tool_{key}", Checkbox).value: - picked.append(key) - except Exception: - continue - return tuple(picked) - - def _tools_for_vm(self, vm, tools): - """Outils qu'une VM donnée recevra vraiment. - - Même filtre que todo.py côté déploiement : une VM ARM ne verra - jamais Android Studio, une VM Cinnamon jamais les extensions GNOME, - un serveur aucun des IDE — mais un serveur reçoit bien la - compilation mobile, qui n'a rien à afficher, et une distribution - sans apt ne la reçoit pas, son installateur n'existant que là.""" - out = [] - for key in tools: - arches = tool_arches.get(key) or () - desks = tool_desktops.get(key) or () - fams = tool_families.get(key) or () - if tool_needs_desktop.get(key) and not vm.get("desktop"): - continue - if arches and vm["arch"] not in arches: - continue - if desks and vm.get("desktop") not in desks: - continue - if fams and distro_family.get(vm["distro"], "") not in fams: - continue - out.append(key) - return out - - def _render_tools(self): - """Grise chaque case qu'AUCUNE VM retenue ne peut recevoir, et - NOMME ce qui sera écarté. - - Une case par outil, et non un blocage en bloc : sur un parc de - serveurs les IDE se grisent, la compilation mobile reste offerte. - Cocher Android Studio sur un parc ARM ne produit rien — le dire ici - évite de le découvrir dans le journal d'installation.""" - if not vm_tools: - return - installe, quelque_chose = self._install_state() - for key, _label, _hint in vm_tools: - usable = any(self._tools_for_vm(vm, (key,)) for vm in self.vms) - offert = ( - installe - if tool_phases.get(key) == "after" - else quelque_chose - ) - self.query_one(f"#f_tool_{key}", Checkbox).disabled = not ( - usable and offert - ) - picked = self._vm_tools() - skipped = sorted( - { - vm["name"] - for vm in self.vms - for k in picked - if k not in self._tools_for_vm(vm, picked) - } - ) - self.query_one("#toolwarn", Static).update( - f" ⚠ {t('Partly skipped (arch or desktop):')} " - f"{', '.join(skipped)}" - if skipped - else "" - ) - - def _render_mise(self): - """Grise le choix quand aucune VM retenue n'est servie par mise, - et nomme les architectures qui retomberont sur pyenv.""" - usable = self._mise_usable() - installe, _quelque_chose = self._install_state() - self.query_one("#f_python", RadioSet).disabled = not ( - usable and installe - ) - skipped = sorted( - { - vm["arch"] - for vm in self.vms - if vm["arch"] not in mise_arches - } - ) - msg = "" - if skipped: - msg = ( - f" ⚠ {t('mise has no binary for:')} " - f"{', '.join(skipped)} — {t('those VMs use pyenv')}" - ) - self.query_one("#miswarn", Static).update(msg) - - def _python_provider(self): - """« mise », « pyenv », ou rien — c'est-à-dire « automatique ». - - Rien, et surtout pas « pyenv », quand mise n'est servi par aucune - architecture retenue. « mise est indisponible » ne veut pas dire - « l'utilisateur exige pyenv » : la nuance décide de tout, puisqu'un - choix EXPLICITE écarte le Python de la distribution. Sur s390x, - renvoyer « pyenv » forçait la compilation de CPython — celle dont - gcc 15.2 ne revient pas.""" - if not self._mise_usable(): - return "" - index = self.query_one("#f_python", RadioSet).pressed_index - return "pyenv" if index == 1 else "mise" - - def _app_store(self): - """Magasin retenu. Sans VM concernée, la réponse est « deb » : - elle ne change rien, et laisser passer « snap » réactiverait snapd - pour rien.""" - if not app_stores or not self._app_store_needed(): - return "deb" - index = self.query_one("#f_store", RadioSet).pressed_index - if index is None or not (0 <= index < len(app_stores)): - return app_stores[0][0] - return app_stores[index][0] - - def _app_store_needed(self): - """Au moins une VM graphique sur une distribution qui livre snapd.""" - return any( - vm.get("desktop") and vm["distro"] in snap_distros - for vm in self.vms - ) - - def _render_store(self): - """Grise le choix quand aucune VM ne le concerne, et dit pourquoi.""" - if not app_stores: - return - needed = self._app_store_needed() - _installe, quelque_chose = self._install_state() - self.query_one("#f_store", RadioSet).disabled = not ( - needed and quelque_chose - ) - self.query_one("#storewarn", Static).update( - "" - if needed - else f" {t('No graphical VM on a snap-based distro.')}" - ) - - def _install_state(self): - """(une installation ?, quelque chose à installer ?). - - Deux réponses et non une : sans installation mais avec un bureau, - il se pose encore des paquets — le magasin d'applications et les - outils de la phase « avant » gardent un effet.""" - installe = self.query_one("#f_install", Checkbox).value - return installe, bool(installe or self._default_desktop()) - - def _mise_usable(self): - return any(vm["arch"] in mise_arches for vm in self.vms) + self.render_extras() def _profile_cmd(self): """Commande du profil choisi en haut : le défaut de chaque VM.""" @@ -811,14 +554,6 @@ def run_deploy_form(ctx, run_app: bool = True): value = self.query_one("#f_branch", Select).value return value if isinstance(value, str) else branches[0] - def _default_desktop(self): - """« » pour un serveur, sinon la clé de la saveur choisie.""" - """Type de VM par défaut. Chaque rangée peut s'en écarter.""" - index = self.query_one("#f_type", RadioSet).pressed_index - if index is None or index < 1 or index > len(desktops): - return "" - return desktops[index - 1][0] - # -- panneau droit : une rangée de widgets par VM ---------------- # def _type_options(self): return [(t("Server"), SERVER)] + [ @@ -1000,52 +735,6 @@ def run_deploy_form(ctx, run_app: bool = True): pass self._sync_free_inputs() - def _sync_install_deps(self) -> None: - """Grise ce que le choix d'installation rend SANS EFFET. - - Trois états et non deux, parce que la commande distante en a - trois : rien du tout, un bureau seul, ou une installation - complète. Sans installation MAIS avec un bureau, le magasin - d'applications et les outils de la phase « avant » servent encore - — les griser mentirait autant que de laisser actif ce qui ne fait - rien. La branche, le profil et l'interpréteur Python, eux, ne - servent qu'à l'installation. - - Le type de VM et le suivi ne sont jamais grisés : le premier est - l'autre moitié de la décision, le second regarde la VM arriver - même quand rien ne s'installe.""" - installe, quelque_chose = self._install_state() - for cible, actif in ( - ("#f_branch", installe), - ("#f_profile_install", installe), - ("#f_prod", quelque_chose), - ): - try: - self.query_one(cible).disabled = not actif - except Exception: - pass - # Le magasin, les outils et l'interpréteur Python ont leur PROPRE - # raison de se griser (architecture, bureau, distribution) : ils - # composent les deux dans « _render_* », qui a le dernier mot. - # Le titre suit ses champs : une section entière se lit inactive - # d'un coup d'œil, au lieu de se déduire de trois widgets ternes. - for cible, actif in ( - ("#t_store", quelque_chose), - ("#t_tools", quelque_chose), - ("#t_python", installe), - ): - try: - self.query_one(cible).set_class(not actif, "off") - except Exception: - pass - # Les rangées portent les mêmes choix, par VM. - for i in range(len(self.rows)): - for cible in (f"#v{i}_branch", f"#v{i}_prof"): - try: - self.query_one(cible).disabled = not installe - except Exception: - pass - def _render_plan(self): # Le JEU de VM a-t-il changé ? Si oui on remonte les widgets, sinon # on se contente des titres : remonter à chaque frappe volerait le @@ -1231,18 +920,7 @@ def run_deploy_form(ctx, run_app: bool = True): # ci-dessous sans rien recalculer, et les rangées restaient sur # l'ancienne version. Elles n'en gardent pas de copie — « » y # veut dire « celle du formulaire » — il suffit de redessiner. - if event.select.id == "f_tz_sel": - # « libre… » révèle la saisie ; un fuseau choisi la referme et - # y recopie le nom, seule valeur que lisent _form_values et la - # spec — un seul endroit porte la réponse. - free = event.value is FREE - field = self.query_one("#f_tz", Input) - field.display = free - field.disabled = not free - if free: - field.focus() - elif isinstance(event.value, str): - field.value = event.value + if self.extras_on_select(event): return if event.select.id in ("f_branch", "f_profile_install"): self._clear_overrides( @@ -1323,20 +1001,6 @@ def run_deploy_form(ctx, run_app: bool = True): self._recompute() # -- actions ---------------------------------------------------- # - def action_select_all(self) -> None: - self.query_one("#f_catalog", SelectionList).select_all() - - def action_select_none(self) -> None: - self.query_one("#f_catalog", SelectionList).deselect_all() - - def action_select_main(self) -> None: - """Une VM par distro : la version marquée par défaut.""" - widget = self.query_one("#f_catalog", SelectionList) - widget.deselect_all() - for i, e in enumerate(self._entries()): - if e.get("default"): - widget.select(i) - def action_clear_vm(self) -> None: """Rend au profil commun la VM dont un widget a le focus. Sans cette sortie, un réglage posé par erreur ne se défaisait qu'en @@ -1377,15 +1041,7 @@ def run_deploy_form(ctx, run_app: bool = True): else f"x{self.profile}" ), "ssh_key": os.path.expanduser(key) if key else "", - # Un champ vidé retombe sur le fuseau de l'hôte plutôt que sur - # rien : sans valeur, la VM démarrerait en UTC. - "timezone": self.query_one("#f_tz", Input).value.strip() - or ctx.get("timezone") - or "", - "desktop": self._default_desktop(), - "vm_tools": self._vm_tools(), - "python_provider": self._python_provider(), - "app_store": self._app_store(), + **self.extras_values(), "install": install, "add_ssh_config": self.query_one("#f_sshcfg", Checkbox).value, # Une exécution par installation : le nombre de VM retenues diff --git a/script/todo/qemu_install.py b/script/todo/qemu_install.py index 4c474e2..d65b00b 100644 --- a/script/todo/qemu_install.py +++ b/script/todo/qemu_install.py @@ -828,6 +828,51 @@ class QemuInstallMixin: "arch": "pacman", } + def _qemu_guest_context(self): + """Ce que les DEUX écrans de déploiement doivent savoir du système + invité : type de VM, magasin d'applications, outils, fuseaux, Python. + + Une seule méthode et non deux blocs jumeaux dans les constructeurs de + contexte : c'est en n'en remplissant qu'un que l'écran Proxmox avait + perdu la moitié des réglages. Rien ici ne parle d'hyperviseur — c'est + exactement ce qui rend le bloc commun.""" + outils = self._QEMU_VM_TOOLS + return { + "desktops": [ + (k, v["label"]) for k, v in self._QEMU_DESKTOP.items() + ], + "desktop_suffixes": self._qemu_desktop_suffixes(), + "desktop_disk_gb": self.QEMU_DESKTOP_EXTRA_DISK_GB, + "app_stores": [(k, t(lbl)) for k, lbl in self.QEMU_APP_STORES], + "snap_distros": self.QEMU_SNAP_DISTROS, + "timezone": self._qemu_host_timezone(), + "timezones": self._qemu_timezone_choices( + self._qemu_host_timezone() + ), + "mise_arches": self.QEMU_MISE_ARCHES, + "vm_tools": self._qemu_vm_tool_choices(), + "vm_tool_disk": {k: v["disk_gb"] for k, v in outils.items()}, + "vm_tool_arches": {k: v["arches"] for k, v in outils.items()}, + "vm_tool_desktops": {k: v["desktops"] for k, v in outils.items()}, + # « after » = l'outil vit DANS le dépôt ERPLibre (compilation + # mobile, AVD, script Forgejo) : sans installation, il n'existe + # pas, et la commande distante le saute en le nommant. + "vm_tool_phases": { + k: v.get("phase", "before") for k, v in outils.items() + }, + "vm_tool_needs_desktop": { + k: v["needs_desktop"] for k, v in outils.items() + }, + "vm_tool_families": {k: v["families"] for k, v in outils.items()}, + "distro_family": dict(self._QEMU_DISTRO_FAMILY), + "defaults": { + "install": True, + "add_ssh_config": True, + "monitor": True, + "prod": False, + }, + } + @classmethod def _qemu_vm_tool_choices(cls): """[(clé, libellé, indice)] pour le formulaire et l'invite en ligne.""" diff --git a/script/todo/qemu_install_monitor.py b/script/todo/qemu_install_monitor.py index 5bd6a46..5e085a1 100644 --- a/script/todo/qemu_install_monitor.py +++ b/script/todo/qemu_install_monitor.py @@ -85,9 +85,18 @@ def _launch_one( log_path: str, name: str = "", installs: bool = True, + pve: bool = False, ) -> 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.""" + la sortie puis écrit le marqueur de fin avec le code de sortie. + + `pve` : la VM vit sur un hôte Proxmox. On ne RÉ-RÉSOUT alors PAS son + adresse par virsh — et c'est vital. Vécu le 24 août 2026 : une VM + « erplibre-ubuntu-2604 » déployée sur Proxmox portait le nom d'un domaine + LOCAL existant ; la ré-résolution a trouvé le domaine local et + l'installation d'ERPLibre + Odoo est partie sur la mauvaise machine, sans + que rien ne le dise. Pour une VM distante, l'alias ~/.ssh/config est la + seule vérité : il porte le rebond par l'hôte.""" # Sonde de disponibilité : on attend que sshd réponde ET que cloud-init # soit TERMINÉ, via des connexions COURTES successives (jusqu'à ~20 min : # une architecture ÉMULÉE, s390x/arm64 sur hôte x86, boote lentement). @@ -188,12 +197,12 @@ def _launch_one( f'echo " {msg_moved} $ip -> $n" >> {log_q}; fi; ' '[ -n "$n" ] && ip="$n"; ' ) - if name + if name and not pve else "" ) wrapper = ( f"ip={shlex.quote(ip)}; " - f"{vsh if name else ''}" + f"{vsh if name and not pve else ''}" f"echo {shlex.quote('== ' + msg_wait + ' ==')} >> {log_q}; " f"echo {shlex.quote(' ' + msg_slow)} >> {log_q}; " f"seen=0; " @@ -290,6 +299,7 @@ def launch_installs(vms: list[dict], branch: str, remote_cmd: str) -> str: log_path, vm["name"], installs=bool(branch), + pve=bool(vm.get("pve")), ) entree = { "name": vm["name"], @@ -1299,19 +1309,100 @@ def read_domstats() -> str: # disque et le cumul écrit de TOUTES ses VM d'un coup. Le « du » qui suit # donne la taille RÉELLEMENT occupée : sur un stockage en fichiers, Proxmox # rapporte « disk: 0 » — il ne la calcule pas. +# +# « -sB1 » et NON « -sb » : le second rend la taille APPARENTE, et un disque +# raw creux de 6 Go la donne entière. La colonne affichait donc « 6.0G/6.0G », +# un disque plein, quand l'invité n'avait écrit que 1,2 Go — rapporté. PVE_STATS_CMD = ( "pvesh get /cluster/resources --type vm --output-format json;" " echo '---ERPLIBRE-DU---';" - " du -sb /var/lib/vz/images/*/ 2>/dev/null || true" + " du -sB1 /var/lib/vz/images/*/ 2>/dev/null || true" ) # Une VM distante se relève moins souvent qu'une locale : chaque tour coûte # une poignée de main ssh (mesuré 1 s), quand « virsh domstats » coûte 0,03 s # pour tout le parc. Cinq secondes suffisent à voir une installation avancer. PVE_STATS_INTERVAL = 5.0 + +# Une VM locale DÉJÀ verte est resondée à cette cadence, pas à chaque tour. +# La sonde est un connect() TCP par VM : à chaque tour (2 s) c'est cher pour +# une réponse qui ne bouge presque jamais, jamais c'est un mensonge — Odoo +# redémarre au moins une fois pendant l'installation, et il lui arrive de +# mourir. Trente secondes bornent le mensonge à un demi-écran de journal. +ODOO_RECHECK = 30.0 + + +def odoo_reading(vm, releve, deja_vert, dernier, maintenant, sonde): + """Odoo répond-il sur cette VM ? (état ou None, sondé ?) + + None veut dire « pas de réponse ce tour-ci », et NON « Odoo est tombé » : + l'appelant garde alors le dernier état connu. C'est toute la différence + entre un hôte muet et une VM qui ne sert plus rien. + + Deux voies, parce que la sonde n'a pas le même prix. Sur une VM Proxmox + le port a été testé DEPUIS L'HÔTE, dans l'appel des statistiques — d'ici, + une adresse de pont interne ne répond jamais — donc c'est gratuit et relu + à chaque tour. Sur une VM locale c'est un connect() TCP par VM : on la + refait tant qu'elle est rouge, puis seulement toutes les ODOO_RECHECK + secondes. + + Ce qu'on ne fait plus, c'est ne jamais la refaire. « Odoo ne redescend pas + en cours d'install » était faux : le service redémarre au moins une fois, + et il lui arrive de mourir. Le 🟢 restait alors acquis pour toujours. + """ + if vm.get("pve"): + return (bool(releve.get("odoo")) if releve else None), False + if deja_vert and maintenant - dernier < ODOO_RECHECK: + return None, False + return bool(sonde(vm.get("ip"), 8069)), True + + # Proxmox dit « running » / « stopped » ; le suivi raisonne en états libvirt. # Une VM absente de la réponse de l'hôte a vraiment disparu. PVE_ETATS = {"running": "running", "stopped": "shut off", "paused": "paused"} -_PVE_CACHE = {"at": 0.0, "stats": {}} +_PVE_CACHE = {"at": 0.0, "stats": {}, "ok": False} + + +def pve_stats_cmd(adresses=()) -> str: + """PVE_STATS_CMD, plus un test du port 8069 pour les adresses données. + + Dans le MÊME appel : la colonne Odoo teste ce port depuis le poste, et une + VM sur pont interne n'y répond jamais — elle restait « — » quel que soit + l'état d'Odoo. Depuis l'hôte, elle répond. Un aller-retour ssh de plus par + tour aurait coûté une seconde ; celui-ci est déjà payé. + """ + if not adresses: + return PVE_STATS_CMD + liste = " ".join(shlex.quote(a) for a in adresses) + return ( + PVE_STATS_CMD + + "; echo '---ERPLIBRE-ODOO---'; for a in " + + liste + + '; do timeout 2 bash -c "echo > /dev/tcp/$a/8069" 2>/dev/null' + + ' && echo "ODOO $a"; done' + ) + + +def _resources_parsable(text: str) -> bool: + """La sortie porte-t-elle une LISTE de ressources lisible ? + + C'est la seule preuve que l'hôte a répondu : le code de sortie est celui + du dernier maillon de la suite, pas celui de « pvesh ». + """ + brut, _, _ = (text or "").partition("---ERPLIBRE-DU---") + try: + return isinstance(json.loads(brut.strip() or "null"), list) + except ValueError: + return False + + +def parse_odoo_probe(text: str) -> set: + """Adresses dont le port 8069 a répondu, d'après pve_stats_cmd.""" + _, _, bloc = (text or "").partition("---ERPLIBRE-ODOO---") + return { + ligne.split()[1] + for ligne in bloc.splitlines() + if ligne.startswith("ODOO ") and len(ligne.split()) == 2 + } def parse_pvestats(text: str) -> dict: @@ -1327,7 +1418,7 @@ def parse_pvestats(text: str) -> dict: ressources = json.loads(brut.strip() or "[]") except ValueError: return {} - # {vmid: octets} depuis « du -sb /var/lib/vz/images// ». + # {vmid: octets} depuis « du -sB1 /var/lib/vz/images// ». occupe = {} for ligne in tailles.splitlines(): parts = ligne.split() @@ -1360,12 +1451,35 @@ def parse_pvestats(text: str) -> dict: return out +# Combien de relevés SUCCESSIFS sans la VM avant de la déclarer effacée. Un +# seul silence ne prouve rien : l'hôte peut être occupé, la VM en train de +# démarrer, le relevé en cache d'avant sa création. Or « effacée » est un état +# TERMINAL — la ligne gèle sur 🗑 et ne revient jamais. Vécu sur une VM Arch +# déployée sur Proxmox : poubelle dès le premier tour. +PVE_ABSENCES_AVANT_EFFACEE = 3 + + +def read_pvestats_detail(vms, now=None): + """(relevés, l'hôte a-t-il répondu ?). + + La nuance décide de tout : sans réponse, on ne sait RIEN — et ne rien + savoir n'est pas la même chose que savoir que la VM a disparu. + """ + stats, ok = _read_pvestats(vms, now) + return stats, ok + + def read_pvestats(vms, now=None) -> dict: - """{nom: relevé} des VM posées sur un hôte Proxmox, ou {}. + """{nom: relevé} des VM posées sur un hôte Proxmox, ou {}.""" + return _read_pvestats(vms, now)[0] + + +def _read_pvestats(vms, now=None): + """({nom: relevé}, succès). Un appel par hôte, mis en cache + PVE_STATS_INTERVAL secondes. Les VM concernées sont celles dont le manifeste porte un bloc « pve » - (adresse de l'hôte, sudo, vmid). Un appel par hôte, mis en cache - PVE_STATS_INTERVAL secondes. + (adresse de l'hôte, sudo, vmid). """ hotes = {} for vm in vms or (): @@ -1373,25 +1487,118 @@ def read_pvestats(vms, now=None) -> dict: if info.get("target"): hotes[(info["target"], info.get("sudo") or "")] = info if not hotes: - return {} + return {}, False maintenant = now if now is not None else time.time() - if maintenant - _PVE_CACHE["at"] < PVE_STATS_INTERVAL: - return dict(_PVE_CACHE["stats"]) + # « at > 0 » explicitement : sans lui, un tout PREMIER relevé pris moins de + # cinq secondes après l'époque tombait dans un cache vide et rendait + # « l'hôte n'a pas répondu » sans avoir rien demandé. Invisible en + # production, mais c'est la logique qui est fausse. + if ( + _PVE_CACHE["at"] > 0 + and maintenant - _PVE_CACHE["at"] < PVE_STATS_INTERVAL + ): + return dict(_PVE_CACHE["stats"]), bool(_PVE_CACHE.get("ok")) try: from script.proxmox import proxmox_deploy as pve except ImportError: # pragma: no cover - le module est dans le dépôt - return {} - stats = {} + return {}, False + # {nom: adresse interne} — ce qui permet de tester Odoo depuis l'hôte. + adresses = { + vm["name"]: (vm.get("pve") or {}).get("addr") + for vm in vms or () + if (vm.get("pve") or {}).get("addr") + } + stats, ok = {}, False for (target, sudo), info in hotes.items(): + siennes = [ + a + for nom, a in adresses.items() + if ( + ( + next((v for v in vms if v["name"] == nom), {}).get("pve") + or {} + ).get("target") + == target + ) + ] code, sortie = pve.run( {"target": target, "sudo": sudo, "jump": info.get("jump", "")}, - PVE_STATS_CMD, - 30, + pve_stats_cmd(siennes), + 40, ) - if code == 0: - stats.update(parse_pvestats(sortie)) - _PVE_CACHE.update({"at": maintenant, "stats": stats}) - return dict(stats) + # « code == 0 » ne suffit PAS : la commande est une SUITE + # (pvesh ; echo ; du ; echo ; boucle), et son code est celui du DERNIER + # maillon. Un pvesh en panne rendait donc « l'hôte a répondu, la VM + # n'y est plus » — et trois tours plus tard, la poubelle. Ce qui prouve + # une réponse, c'est une LISTE de ressources analysable. + if code == 0 and _resources_parsable(sortie): + ok = True + releves = parse_pvestats(sortie) + ouverts = parse_odoo_probe(sortie) + for nom, rec in releves.items(): + rec["odoo"] = adresses.get(nom) in ouverts + stats.update(releves) + _PVE_CACHE.update({"at": maintenant, "stats": stats, "ok": ok}) + return dict(stats), ok + + +def web_tunnel_argv(info, port=18069, cible_port=8069): + """argv d'un tunnel local vers le port web d'une VM distante, ou None. + + Une VM sur pont interne n'est pas routable d'ici : un navigateur ne peut + pas l'atteindre, et la touche « w » ouvrait une page morte. Le tunnel + passe par l'hôte, dure le temps de la visite, et se referme par son PID — + « pkill -f » tuait le shell qui l'avait lancé, le motif figurant + dans sa propre ligne de commande. + """ + info = info or {} + if not (info.get("addr") and info.get("target")): + return None + argv = ["ssh", "-N", "-o", "ExitOnForwardFailure=yes"] + if info.get("jump"): + argv += ["-J", info["jump"]] + argv += ["-L", f"{port}:{info['addr']}:{cible_port}", info["target"]] + return argv + + +def vm_ssh_prefix(vm) -> str: + """« ssh … » pour entrer dans CETTE VM, adresse comprise. + + Une VM d'un hôte Proxmox vit derrière lui : son adresse n'est pas + routable d'ici, et seul le rebond y mène. On le construit explicitement + plutôt que de compter sur un alias ~/.ssh/config, qui peut ne pas exister + — ou, pire, désigner une VM LOCALE homonyme. C'est ce qui a fait ouvrir + la mauvaise machine avec « s ». + """ + info = (vm or {}).get("pve") or {} + adresse = info.get("addr") + if info.get("target") and adresse: + saut = f"-J {shlex.quote(info['jump'])} " if info.get("jump") else "" + return ( + f"ssh {SSH_OPTS} {saut}-J {shlex.quote(info['target'])} " + f"erplibre@{adresse}" + ) + return f"ssh {SSH_OPTS} erplibre@{(vm or {}).get('ip')}" + + +def pve_host_cmd(info, remote, tty=False) -> str: + """Commande shell qui exécute `remote` SUR l'hôte Proxmox d'une VM. + + Chaque action du tableau de bord qui parlait à libvirt par le NOM frappait + la mauvaise machine dès qu'un domaine local portait le même : la console + ouvrait celle de la VM locale, la pause suspendait la locale. L'hôte est + la seule autorité pour une VM distante, et le VMID son seul identifiant. + """ + sudo = (info or {}).get("sudo") or "" + cible = (info or {}).get("target") or "" + prefixe = f"{sudo}sh -c {shlex.quote(remote)}" if sudo else remote + saut = ( + f"-J {shlex.quote(info['jump'])} " if (info or {}).get("jump") else "" + ) + return ( + f"ssh {'-t ' if tty else ''}{saut}{shlex.quote(cible)} " + f"{shlex.quote(prefixe)}" + ) def arm_balloon(names) -> None: @@ -1536,6 +1743,52 @@ def restart_odoo_cmd() -> str: ) +def delete_vm_cmd_pve(info, purge: bool = True) -> str: + """Efface une VM sur son hôte PROXMOX, par son VMID. + + « virsh undefine » y aurait effacé le domaine LOCAL homonyme — le + même piège que partout ailleurs, avec la pire conséquence.""" + vmid = int((info or {}).get("vmid") or 0) + suite = ( + f"qm stop {vmid} --skiplock 1 || true; " + f"qm destroy {vmid}{' --purge 1 --destroy-unreferenced-disks 1' if purge else ''}" + ) + return pve_host_cmd(info, suite) + + +def delete_lines(vm) -> list: + """Ce qui va RÉELLEMENT disparaître, dit selon l'endroit où la VM vit. + + L'écran annonçait à toute VM « son disque qcow2 EFFACÉ », puis nommait + /var/lib/libvirt/images/.qcow2. Sur une VM Proxmox ce fichier + n'existe pas : son disque vit dans un stockage que seul l'hôte connaît, et + la ligne désignait donc un fichier local — au mieux inexistant, au pire + celui d'une autre VM du même nom. C'est exactement la peur qui a fait + remonter le nettoyage : « le nettoyage risque d'effacer des VM en + production ». + + Une confirmation doit nommer ce qu'elle détruit, sur la machine où elle + le détruit.""" + info = vm.get("pve") + if not info: + return [ + "La VM est arrêtée, sa définition retirée,", + "et son disque qcow2 EFFACÉ. Rien n'est récupérable.", + "", + f" /var/lib/libvirt/images/{vm['name']}.qcow2", + ] + hote = info.get("target") or "?" + return [ + f"Sur l'hôte Proxmox {hote}, la VM {info.get('vmid')} est arrêtée", + "puis DÉTRUITE avec ses disques. Rien n'est récupérable.", + "", + f" qm destroy {info.get('vmid')} --purge", + "", + "Aucun fichier n'est touché ici : le disque vit dans le", + "stockage de l'hôte.", + ] + + def delete_vm_cmd(name: str, with_disks: bool) -> str: """Efface la VM sur l'HÔTE. Même séquence que « TODO._qemu_delete_vm » : arrêt, retrait de la définition (nvram si UEFI, repli sinon), puis les @@ -1830,9 +2083,21 @@ def run_monitor(manifest_path: str, run_app: bool = True): self._errcount = {} # Sommaire de stats déplié (clic) ou non. self._stats_open = False - # VM dont l'UI Odoo (:8069) répond déjà : une fois détectée « up », - # on ne re-teste plus (Odoo ne redescend pas en cours d'install). + # VM dont l'UI Odoo (:8069) répond. RELUE, et non accumulée : + # « Odoo ne redescend pas en cours d'install » est faux — il + # redémarre au moins une fois (service systemd), et il lui arrive + # de mourir. Un 🟢 acquis pour toujours affirmait alors qu'une VM + # servait Odoo alors qu'elle ne servait plus rien. self._odoo_up = set() + # Dernier tour où le port d'une VM DÉJÀ verte a été retesté. Sur + # une VM locale la sonde est un connect() TCP par VM : la refaire + # à chaque tour pour rien serait cher, la refaire jamais serait + # faux. Sur Proxmox la question ne se pose pas — la réponse vient + # avec les statistiques, gratuitement. + self._odoo_revu = {} + # Relevés SUCCESSIFS sans la VM, par nom : « effacée » est un état + # terminal, il se mérite. + self._pve_absences = {} # Debounce du changement de VM : la sélection défile vite au # clavier ; on ne recharge le log qu'une fois le curseur STABILISÉ. self._pending_sel = None @@ -1896,7 +2161,11 @@ def run_monitor(manifest_path: str, run_app: bool = True): vm["name"], vm.get("arch") or "?", "", - "⏳", + # « rien encore », comme ses voisines : « ⏳ » affirmerait + # qu'on attend quelque chose, alors qu'on ne sait rien du + # tout — le journal n'a pas encore parlé. Le sablier + # apparaît dès que l'attente est constatée. + "-", "—", "--:--", "-", @@ -2036,7 +2305,7 @@ def run_monitor(manifest_path: str, run_app: bool = True): # colonnes restaient vides. Même forme de relevé, donc la suite ne # change pas d'un iota. stats.update(read_pvestats(vms)) - now_s = time.time() + now_s = maintenant = time.time() for name, rec in stats.items(): self._wrate.add(name, rec["wr_bytes"], now_s) self._vmstats = stats @@ -2064,14 +2333,22 @@ def run_monitor(manifest_path: str, run_app: bool = True): # peut contenir des erreurs passées inaperçues. if st[0] in ("done", "failed") and name not in errors: errors[name] = scan_log_errors(vm["log"]) - # Odoo up ? On ne teste que celles pas encore confirmées up - # et non effacées (test TCP court sur :8069). - if ( - name not in self._odoo_up - and self._domstate.get(name) != "gone" - ): - if _port_open(vm.get("ip"), 8069): - odoo[name] = True + # Odoo up ? La réponse est RENDUE À CHAQUE TOUR pour les + # VM sondées, pas accumulée : la colonne doit pouvoir + # redescendre à « — ». + if self._domstate.get(name) != "gone": + etat, sonde = odoo_reading( + vm, + (self._vmstats or {}).get(name) or {}, + name in self._odoo_up, + self._odoo_revu.get(name, 0.0), + maintenant, + _port_open, + ) + if sonde: + self._odoo_revu[name] = maintenant + if etat is not None: + odoo[name] = etat return disks, status, self._collect_tele(), errors, odoo, wr, ram async def _tick_table(self): @@ -2091,7 +2368,14 @@ def run_monitor(manifest_path: str, run_app: bool = True): except Exception: return self._errcount.update(errors) - self._odoo_up.update(odoo) + # Remplacement et non union : une VM sondée qui ne répond + # plus doit repasser à « — ». Les VM absentes du relevé (hôte + # muet, VM effacée) gardent leur dernier état connu. + for nom_vm, vivant in odoo.items(): + if vivant: + self._odoo_up.add(nom_vm) + else: + self._odoo_up.discard(nom_vm) try: table = self.query_one("#vms", DataTable) now = time.time() @@ -2299,15 +2583,55 @@ def run_monitor(manifest_path: str, run_app: bool = True): # Une VM posée sur un hôte Proxmox est ABSENTE de « virsh list » : # elle passait donc pour EFFACÉE, ce qui éteignait du même coup # ses colonnes vivantes. Son état vient de l'hôte. - distants = await asyncio.to_thread(read_pvestats, vms) + distants, hote_ok = await asyncio.to_thread( + read_pvestats_detail, vms + ) for vm in vms: nom = vm["name"] if vm.get("pve"): + if not hote_ok: + # L'hôte n'a pas répondu : on ne sait RIEN. Conclure + # « effacée » ici gelait la ligne sur 🗑 dès le premier + # tour, pour toujours — vécu sur une VM Arch à peine + # déployée. Et on OUBLIE les absences déjà comptées : + # elles ne prouvent une disparition que si elles se + # SUIVENT, l'hôte répondant à chaque fois. + self._pve_absences[nom] = 0 + continue releve = distants.get(nom) - self._domstate[nom] = PVE_ETATS.get( - (releve or {}).get("state"), "gone" + if releve: + self._pve_absences[nom] = 0 + # PRÉSENTE dans le relevé : elle existe, quel que soit + # le mot employé. Proxmox en a d'autres que les trois + # attendus — « prelaunch », « suspended », + # « internal-error », « hibernated » — et les traduire + # en « gone » mettait à la poubelle une VM bien vivante. + self._domstate[nom] = PVE_ETATS.get( + releve.get("state"), "running" + ) + continue + # L'hôte a répondu SANS elle : peut-être en cours de + # création, peut-être vraiment partie. On compte. + self._pve_absences[nom] = ( + self._pve_absences.get(nom, 0) + 1 ) + if self._pve_absences[nom] >= PVE_ABSENCES_AVANT_EFFACEE: + self._domstate[nom] = "gone" continue + if not states: + # « virsh list » n'a rien rendu : soit l'hôte n'a plus une + # seule VM, soit l'appel a échoué (libvirtd qui redémarre, + # sudo qui expire). On ne peut pas trancher, et conclure + # « effacées » mettait TOUT le parc local à la poubelle, + # définitivement. Même règle que pour l'hôte distant : on + # compte avant de conclure. + self._pve_absences[nom] = ( + self._pve_absences.get(nom, 0) + 1 + ) + if self._pve_absences[nom] >= PVE_ABSENCES_AVANT_EFFACEE: + self._domstate[nom] = "gone" + continue + self._pve_absences[nom] = 0 self._domstate[nom] = states.get(nom, "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 @@ -2374,8 +2698,10 @@ def run_monitor(manifest_path: str, run_app: bool = True): vm = self._vm_by_name(self._selected) if not vm: return + cmd = vm_ssh_prefix(vm) with self.suspend(): - os.system(f"ssh {SSH_OPTS} erplibre@{vm['ip']} || true") + print(f"\n→ {cmd}\n") + os.system(f"{cmd} || true") def action_console(self) -> None: """Console série de la VM, sans quitter le suivi. @@ -2392,18 +2718,33 @@ def run_monitor(manifest_path: str, run_app: bool = True): vm = self._vm_by_name(self._selected) if not vm: return - name = shlex.quote(vm["name"]) + info = vm.get("pve") + if info: + # VM d'un hôte Proxmox : sa console est « qm terminal », sur + # l'hôte. « virsh console » ouvrait celle du domaine + # LOCAL homonyme — la mauvaise machine, sans le dire. + cmd = pve_host_cmd( + info, f"qm terminal {int(info.get('vmid') or 0)}", tty=True + ) + titre = ( + f"qm terminal {info.get('vmid')} @ {info.get('target')}" + ) + sortie = "Ctrl+O" + else: + cmd = f"sudo virsh console {shlex.quote(vm['name'])}" + titre = f"virsh console {vm['name']}" + sortie = "Ctrl+]" with self.suspend(): # La console n'affiche que ce qui arrive APRÈS l'attachement : # sur une VM déjà démarrée l'écran reste noir tant qu'on n'a # rien envoyé. On le dit, plutôt que de laisser croire à un gel. - print(f"\n→ virsh console {vm['name']}") + print(f"\n→ {titre}") print( " Écran vide ? Appuyez sur Entrée : la console ne montre" " que la sortie qui suit l'attachement." ) - print(" Ctrl+] puis Entrée pour revenir au suivi.\n") - os.system(f"sudo virsh console {name} || true") + print(f" {sortie} puis Entrée pour revenir au suivi.\n") + os.system(f"{cmd} || true") def action_web(self) -> None: """Ouvre l'UI web de la VM (Odoo :8069) dans un navigateur CLI @@ -2416,10 +2757,26 @@ def run_monitor(manifest_path: str, run_app: bool = True): browser = self._choose_browser() if not browser: return - url = f"http://{vm['ip']}:8069" + port = 18069 + argv_tunnel = web_tunnel_argv(vm.get("pve"), port) + url = ( + f"http://127.0.0.1:{port}" + if argv_tunnel + else f"http://{vm['ip']}:8069" + ) with self.suspend(): + proc = None + if argv_tunnel: + print("→ " + " ".join(shlex.quote(a) for a in argv_tunnel)) + try: + proc = subprocess.Popen(argv_tunnel) + time.sleep(2) + except (OSError, subprocess.SubprocessError) as exc: + print(f" ⚠ {exc}") print(f"→ {browser} {url}") rc = os.system(f"{browser} {shlex.quote(url)}") + if proc: + proc.terminate() # Diagnostic : sinon le navigateur « clignote » et revient au # TUI sans qu'on voie l'erreur (souvent Odoo pas démarré). print(f"\n[{browser}] terminé (code {rc}).") @@ -2542,15 +2899,30 @@ def run_monitor(manifest_path: str, run_app: bool = True): # -- pause / reprise de tout le parc -------------------------------- # @staticmethod - def _virsh_bulk(action, names): - for n in names: + def _virsh_bulk(action, cibles): + """Suspend/reprend chaque VM, chacune par SON hyperviseur. + + `cibles` : [(nom, info_pve|None)]. Une VM distante se suspend par + son VMID sur son hôte — « virsh suspend » aurait mis en + pause le domaine LOCAL homonyme.""" + for nom, info in cibles: try: - subprocess.run( - ["sudo", "virsh", action, n], - capture_output=True, - text=True, - timeout=30, - ) + if info: + vmid = int(info.get("vmid") or 0) + subprocess.run( + pve_host_cmd(info, f"qm {action} {vmid}"), + shell=True, + capture_output=True, + text=True, + timeout=60, + ) + else: + subprocess.run( + ["sudo", "virsh", action, nom], + capture_output=True, + text=True, + timeout=30, + ) except (OSError, subprocess.SubprocessError): pass @@ -2691,8 +3063,7 @@ def run_monitor(manifest_path: str, run_app: bool = True): with self.suspend(): print(f"\n=== {title} — {vm['name']} ===") os.system( - f"ssh {SSH_OPTS} erplibre@{vm['ip']} " - f"{shlex.quote(cmd)} || true" + f"{vm_ssh_prefix(vm)} " f"{shlex.quote(cmd)} || true" ) input("\nEntrée pour revenir au suivi… ") @@ -2709,20 +3080,22 @@ def run_monitor(manifest_path: str, run_app: bool = True): def confirmed(yes): if not yes: return + info = vm.get("pve") + cmd = ( + delete_vm_cmd_pve(info) + if info + else delete_vm_cmd(vm["name"], True) + ) with self.suspend(): print(f"\n=== Suppression — {vm['name']} ===") - os.system(delete_vm_cmd(vm["name"], True) + " || true") + print(f"→ {cmd}\n") + os.system(cmd + " || true") input("\nEntrée pour revenir au suivi… ") self.push_screen( ConfirmScreen( f"Supprimer {vm['name']} ?", - [ - "La VM est arrêtée, sa définition retirée,", - "et son disque qcow2 EFFACÉ. Rien n'est récupérable.", - "", - f" /var/lib/libvirt/images/{vm['name']}.qcow2", - ], + delete_lines(vm), "Supprimer définitivement", ), confirmed, @@ -2741,7 +3114,7 @@ def run_monitor(manifest_path: str, run_app: bool = True): async def _bulk_worker(self, action): want = "running" if action == "suspend" else "paused" targets = [ - vm["name"] + (vm["name"], vm.get("pve")) for vm in vms if self._domstate.get(vm["name"]) == want ] diff --git a/script/todo/todo.py b/script/todo/todo.py index 5f52c1c..fef5f1c 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -1705,6 +1705,40 @@ class TODO( pass return names + @classmethod + def _ssh_config_block(cls, name): + """Le bloc « Host … » qui déclare `name`, ou {}. + + Rend ses noms ET ses directives : savoir qu'un nom est pris ne suffit + pas, il faut savoir PAR QUI. Un nom court déjà déclaré peut être notre + propre entrée qu'on réécrit — auquel cas il n'y a rien de volé — ou + celle d'une autre machine, et c'est le ProxyJump qui les distingue. + + {"names": [...], "proxyjump": "...", "hostname": "..."}.""" + path = os.path.expanduser("~/.ssh/config") + try: + with open(path, encoding="utf-8") as fh: + contenu = fh.read() + except OSError: + return {} + bloc = None + for line in contenu.splitlines(): + if re.match(r"^[ \t]*Host[ \t]+", line): + if bloc is not None: + return bloc + noms = line.split()[1:] + bloc = {"names": noms} if name in noms else None + continue + if bloc is None: + continue + # Une ligne non indentée et non vide clôt le bloc. + if line.strip() and not line[:1].isspace(): + return bloc + mots = line.split() + if len(mots) >= 2 and mots[0].lower() in ("proxyjump", "hostname"): + bloc[mots[0].lower()] = mots[1] + return bloc or {} + @staticmethod def _ssh_config_user(host): """`User` déclaré pour cet hôte dans ~/.ssh/config, ou "". @@ -2832,6 +2866,12 @@ class TODO( "Attachment files missing from the filestore" ) }, + {"section": t("Instance")}, + { + "prompt_description": t( + "Monitoring - a backup, a remote copy or a live instance" + ) + }, ] help_info = self.fill_help_info(choices) @@ -2854,6 +2894,8 @@ class TODO( self.execute_analyse_module_dependency() elif status == "7": self.execute_analyse_filestore() + elif status == "8": + self.execute_analyse_monitoring() else: print(t("Command not found !")) @@ -3515,6 +3557,244 @@ class TODO( handler, ) + def execute_analyse_monitoring(self): + """Ausculter une instance dont la base n'est pas forcément ici. + + Les analyses existent déjà ; ce qui manquait est le chemin d'avant. + Une sauvegarde, locale ou distante, se RESTAURE — après quoi tout + ce que le dépôt sait faire s'applique. Une instance vivante, non : + on n'a pas la base, on a une session, et ce que RPC laisse lire est + un sous-ensemble. L'écran de choix le dit au lieu de proposer des + analyses qui échoueraient à l'ouverture. + """ + from script.analyse import monitoring, monitoring_tui + + print(f"🛰 {t('Inspect an instance.')}") + source = self._monitoring_select_source() + if not source: + return + kind, target = source + print() + print(monitoring.describe_source(kind, target)) + print() + + choix = monitoring_tui.run_tui(kind, target) + if not choix: + return + analyse = monitoring.analysis_by_key(choix) + if not analyse: + return + if kind not in analyse["kinds"]: + print(f"✖ {t('Not available for this source.')}") + return + extra = None + if analyse.get("asks_expect"): + extra = ["--expect", self._monitoring_expect(kind)] + monitoring.run_analysis(analyse, target, extra=extra) + + def _monitoring_expect(self, kind): + """Copie de développement, ou instance en service ? + + Zéro cron actif est le SUCCÈS attendu d'une copie et une panne + totale sur une production ; zéro serveur de courriel rassure sur + l'une et condamne l'autre. Deviner à la place de l'utilisateur, + c'est afficher du rouge sur ce qu'il vient de demander — et l'on + cesse alors de lire le rapport. + """ + from script.analyse import check_instance_state, monitoring + + if kind == monitoring.KIND_LIVE: + return check_instance_state.LIVE + print() + print(f"[1] {t('A development copy (restored, neutralised)')}") + print(f"[2] {t('An instance in service')}") + answer = click.prompt(t("Command:")) + print() + return ( + check_instance_state.LIVE + if answer == "2" + else check_instance_state.COPY + ) + + def _monitoring_select_source(self): + """(genre, cible), ou None si l'on renonce. + + Le zip et la sauvegarde distante mènent tous deux à une base + restaurée : passé la restauration, ils ne se distinguent plus, et + le reste du code n'a pas à savoir d'où ils venaient. + """ + from script.analyse import monitoring + + print() + # La base locale d'abord : c'est la provenance la plus directe, et + # `_analyse_select_source` du même menu range déjà la base avant la + # sauvegarde. Deux ordres différents dans un même menu se paient en + # hésitation à chaque usage. + print(f"[1] {t('A local database')}") + print(f"[2] {t('A local backup .zip')}") + print(f"[3] {t('A remote backup (https + master password)')}") + print(f"[4] {t('A live remote instance')}") + print(f"[0] {t('Back')}") + answer = click.prompt(t("Command:")) + print() + if answer == "1": + database = self.db_manager.select_database() + return (monitoring.KIND_DATABASE, database) if database else None + if answer == "2": + path = self.db_manager.select_backup_path() + database = self._monitoring_restore(path) if path else None + return (monitoring.KIND_DATABASE, database) if database else None + if answer == "3": + status, path, _name = ( + self.db_manager.download_database_backup_cli() + ) + if status or not path or not os.path.isfile(path): + print(f"❌ {t('The download did not produce a usable file.')}") + return None + database = self._monitoring_restore(path) + return (monitoring.KIND_DATABASE, database) if database else None + if answer == "4": + return self._monitoring_live() + return None + + def _monitoring_live(self): + """Se connecter pour de bon, ou ne pas prétendre l'être. + + Une faute dans l'URL ou dans la clé ne se verrait sinon qu'à la + première analyse, et passerait pour un défaut de l'analyse. + """ + import getpass + + from script.analyse import monitoring + + base_url = input(t("Instance URL (ex. https://example.com): ")).strip() + if not base_url: + return None + database = input(t("Database name on that instance: ")).strip() + if not database: + return None + login = input(t("User login: ")).strip() + if not login: + return None + print() + print(f"[1] {t('An API key')}") + print(f"[2] {t('A password')}") + genre = click.prompt(t("Command:")) + secret = getpass.getpass( + t("API key: ") if genre == "1" else t("Password: ") + ) + if not secret: + return None + try: + uid, version = monitoring.live_connect( + base_url, database, login, secret + ) + except Exception as exc: + print(f"❌ {t('Cannot connect: ')}{exc}") + return None + print(f"✅ {t('Connected as uid')} {uid}, {t('Odoo')} {version}") + return (monitoring.KIND_LIVE, f"{base_url} · {database}") + + def _monitoring_restore(self, zip_path): + """Restaurer la sauvegarde, puis DIRE ce que la neutralisation a pris. + + Mesuré sur sept bases dont le nom portait « neutralize » : + `database.is_neutralized` absent partout, jusqu'à 35 crons actifs, + et le domaine de courriel du client toujours en place. Poser la + question, recevoir oui et ne rien vérifier reproduit exactement + cette illusion — on relit donc la base. + """ + from script.analyse import monitoring + + image = self._monitoring_image_name(zip_path) + if not image: + return None + defaut = image + database = input( + f"💬 {t('Database name (default=')}{defaut}) : " + ).strip() + database = database or defaut + + neutralise = ( + input(f"💬 {t('Neutralize the database (Y/n)? ')}").strip().lower() + ) + more_arg = "" + if neutralise != "n": + more_arg = "--neutralize " + database += "_neutralize" + + status, _ = self._execute.exec_command_live( + f"python3 ./script/database/db_restore.py -d {database} " + f"{more_arg}--ignore_cache --image {image}", + return_status_and_output=True, + single_source_erplibre=True, + source_erplibre=False, + ) + if status: + print(f"❌ {t('The restore failed.')}") + return None + if more_arg: + status, _ = self._execute.exec_command_live( + f"./script/addons/update_prod_to_dev.sh {database}", + return_status_and_output=True, + single_source_erplibre=True, + source_erplibre=False, + ) + print() + print( + monitoring.neutralize_report( + monitoring.neutralize_state(database), colour=True + ) + ) + if status: + # Le compte test/test vient de `user_test`, posé par ce + # script. S'il n'a pas fini, l'annoncer quand même enverrait + # se heurter à un refus d'authentification. + print( + f"⚠ {t('update_prod_to_dev did not finish: do not')}" + f" {t('count on the test/test account.')}" + ) + else: + print(f"ℹ️ {t('You can log in with test / test.')}") + return database + + def _monitoring_image_name(self, zip_path): + """db_restore veut un NOM sous image_db/, pas un chemin. + + Une sauvegarde qui vient d'ailleurs n'est donc pas restaurable telle + quelle. Plutôt que de recopier plusieurs gigaoctets, on propose un + lien — et on le demande, parce que cela pose un fichier dans un + répertoire qui n'est pas à nous. + """ + image_db = os.path.join(os.getcwd(), "image_db") + nom = os.path.basename(zip_path) + if nom.endswith(".zip"): + nom = nom[:-4] + if os.path.dirname(os.path.abspath(zip_path)) == image_db: + return nom + cible = os.path.join(image_db, f"{nom}.zip") + if os.path.exists(cible): + print(f"ℹ️ {t('Using the file already in image_db: ')}{cible}") + return nom + answer = ( + input( + f"💬 {t('db_restore only reads image_db/. Link it there')}" + f" ({cible}) (Y/n)? " + ) + .strip() + .lower() + ) + if answer == "n": + return None + try: + os.makedirs(image_db, exist_ok=True) + os.symlink(os.path.abspath(zip_path), cible) + except OSError as exc: + print(f"❌ {t('Cannot link into image_db: ')}{exc}") + return None + print(f"🔗 {cible}") + return nom + def prompt_execute_process(self): print(f"🤖 {t('Manage execution processes!')}") choices = [ diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 99c2844..40f038e 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -2346,6 +2346,30 @@ TRANSLATIONS = { "fr": "et non la VM : le socket est du côté de QEMU.", "en": "not the VM: the socket is QEMU-side.", }, + "This hypervisor is Proxmox VE: it has no libvirt.": { + "fr": "Cet hyperviseur est un Proxmox VE : il n'a pas de libvirt.", + "en": "This hypervisor is Proxmox VE: it has no libvirt.", + }, + "Its screen is served by a ticket, not by a VNC port.": { + "fr": "Son écran est servi par un ticket, pas par un port VNC.", + "en": "Its screen is served by a ticket, not by a VNC port.", + }, + "Two ways in:": { + "fr": "Deux chemins :", + "en": "Two ways in:", + }, + "the serial console, VMID from the Proxmox menu:": { + "fr": "la console série, le VMID venant du menu Proxmox :", + "en": "the serial console, VMID from the Proxmox menu:", + }, + "the web interface, through a tunnel:": { + "fr": "l'interface web, par un tunnel :", + "en": "the web interface, through a tunnel:", + }, + "TODO > Execute > Deploy > Proxmox VE does the first.": { + "fr": "TODO > Execute > Deploy > Proxmox VE fait le premier.", + "en": "TODO > Execute > Deploy > Proxmox VE does the first.", + }, "This VM exposes no VNC port.": { "fr": "Cette VM n'expose aucun port VNC.", "en": "This VM exposes no VNC port.", @@ -3986,6 +4010,38 @@ TRANSLATIONS = { "fr": "Fichiers orphelins :", "en": "Orphan files:", }, + "connection guide written": { + "fr": "guide de connexion écrit (/etc/motd)", + "en": "connection guide written (/etc/motd)", + }, + "guide not written": { + "fr": "guide non écrit", + "en": "guide not written", + }, + "Quit (q) to start the ERPLibre install": { + "fr": "Quitter (q) pour lancer l'installation d'ERPLibre", + "en": "Quit (q) to start the ERPLibre install", + }, + "Quit (q) to follow the VM starting up": { + "fr": "Quitter (q) pour suivre la mise en route de la VM", + "en": "Quit (q) to follow the VM starting up", + }, + "This name is already taken by": { + "fr": "Ce nom est déjà pris par", + "en": "This name is already taken by", + }, + "a local VM": { + "fr": "une VM locale", + "en": "a local VM", + }, + "A local VM already bears this name:": { + "fr": "Une VM locale porte déjà ce nom :", + "en": "A local VM already bears this name:", + }, + "the alias goes to": { + "fr": "l'alias devient", + "en": "the alias goes to", + }, "s ssh · c copy log · C copy all · q quit": { "fr": "s ssh · c copier le log · C copier tout · q quitter", "en": "s ssh · c copy log · C copy all · q quit", @@ -4499,6 +4555,10 @@ TRANSLATIONS = { "fr": "Chemin de la clé publique SSH", "en": "SSH public key path", }, + "timezone not set": { + "fr": "fuseau non posé", + "en": "timezone not set", + }, "Timezone": { "fr": "Fuseau horaire", "en": "Timezone", @@ -6789,6 +6849,42 @@ TRANSLATIONS = { "fr": "Aucun index en double.", "en": "No duplicate index.", }, + "attachments of fields and records already gone": { + "fr": "pièces jointes de champs et d'enregistrements déjà disparus", + "en": "attachments of fields and records already gone", + }, + "their field was already gone before this step": { + "fr": "leur champ porteur avait déjà disparu avant ce palier", + "en": "their field was already gone before this step", + }, + "their field was removed at this step": { + "fr": "leur champ porteur a été retiré à ce palier", + "en": "their field was removed at this step", + }, + "their model left the database": { + "fr": "leur modèle a quitté la base", + "en": "their model left the database", + }, + "the field is STILL there — look at these": { + "fr": "le champ est TOUJOURS là — à regarder", + "en": "the field is STILL there — look at these", + }, + "out of": { + "fr": "sur", + "en": "out of", + }, + "Every witness is an administrator: a model only": { + "fr": "Tous les témoins sont administrateurs : un modèle que seuls les", + "en": "Every witness is an administrator: a model only", + }, + "admins can read passes this test.": { + "fr": "administrateurs peuvent lire passe ce test.", + "en": "admins can read passes this test.", + }, + "Reads ir_rule only — masking coded in Python escapes it.": { + "fr": "Ne lit qu'ir_rule — un masquage codé en Python lui échappe.", + "en": "Reads ir_rule only — masking coded in Python escapes it.", + }, "held no data of their own — nothing to lose": { "fr": "sans donnée propre — rien à perdre", "en": "held no data of their own — nothing to lose", @@ -9228,6 +9324,326 @@ TRANSLATIONS = { "en": "no .idea: open PyCharm once, then", }, "project": {"fr": "projet", "en": "project"}, + "Instance": { + "fr": "🛰 Instance", + "en": "🛰 Instance", + }, + "Monitoring - a backup, a remote copy or a live instance": { + "fr": "🛰 Monitoring - une sauvegarde, une copie distante ou une instance vivante", + "en": "🛰 Monitoring - a backup, a remote copy or a live instance", + }, + "Inspect an instance.": { + "fr": "Ausculter une instance.", + "en": "Inspect an instance.", + }, + "A local backup .zip": { + "fr": "Une sauvegarde .zip locale", + "en": "A local backup .zip", + }, + "A remote backup (https + master password)": { + "fr": "Une sauvegarde distante (https + mot de passe maître)", + "en": "A remote backup (https + master password)", + }, + "A live remote instance": { + "fr": "Une instance vivante distante", + "en": "A live remote instance", + }, + "An API key": { + "fr": "Une clé d'API", + "en": "An API key", + }, + "A password": { + "fr": "Un mot de passe", + "en": "A password", + }, + "API key: ": { + "fr": "Clé d'API : ", + "en": "API key: ", + }, + "Password: ": { + "fr": "Mot de passe : ", + "en": "Password: ", + }, + "User login: ": { + "fr": "Identifiant : ", + "en": "User login: ", + }, + "Instance URL (ex. https://example.com): ": { + "fr": "URL de l'instance (ex. https://exemple.com) : ", + "en": "Instance URL (ex. https://example.com): ", + }, + "Database name on that instance: ": { + "fr": "Nom de la base sur cette instance : ", + "en": "Database name on that instance: ", + }, + "Cannot connect: ": { + "fr": "Connexion impossible : ", + "en": "Cannot connect: ", + }, + "Connected as uid": { + "fr": "Connecté sous l'uid", + "en": "Connected as uid", + }, + "The instance refused these credentials.": { + "fr": "L'instance a refusé ces identifiants.", + "en": "The instance refused these credentials.", + }, + "Monitoring only reads; refused method:": { + "fr": "Le monitoring ne fait que lire ; méthode refusée :", + "en": "Monitoring only reads; refused method:", + }, + "Database:": { + "fr": "Base :", + "en": "Database:", + }, + "Live instance:": { + "fr": "Instance vivante :", + "en": "Live instance:", + }, + "Database name (default=": { + "fr": "Nom de la base (défaut=", + "en": "Database name (default=", + }, + "Neutralize the database (Y/n)? ": { + "fr": "Neutraliser la base (O/n) ? ", + "en": "Neutralize the database (Y/n)? ", + }, + "The restore failed.": { + "fr": "La restauration a échoué.", + "en": "The restore failed.", + }, + "The download did not produce a usable file.": { + "fr": "Le téléchargement n'a pas produit de fichier utilisable.", + "en": "The download did not produce a usable file.", + }, + "db_restore only reads image_db/. Link it there": { + "fr": "db_restore ne lit que image_db/. Y poser un lien", + "en": "db_restore only reads image_db/. Link it there", + }, + "Using the file already in image_db: ": { + "fr": "On prend le fichier déjà dans image_db : ", + "en": "Using the file already in image_db: ", + }, + "Cannot link into image_db: ": { + "fr": "Impossible de poser le lien dans image_db : ", + "en": "Cannot link into image_db: ", + }, + "You can log in with test / test.": { + "fr": "Vous pouvez vous connecter avec test / test.", + "en": "You can log in with test / test.", + }, + "update_prod_to_dev did not finish: do not": { + "fr": "update_prod_to_dev n'a pas fini : ne comptez pas", + "en": "update_prod_to_dev did not finish: do not", + }, + "count on the test/test account.": { + "fr": "sur le compte test/test.", + "en": "count on the test/test account.", + }, + "database.is_neutralized is set.": { + "fr": "database.is_neutralized est posé.", + "en": "database.is_neutralized is set.", + }, + "database.is_neutralized is NOT set — Odoo does not": { + "fr": "database.is_neutralized n'est PAS posé — Odoo ne considère pas", + "en": "database.is_neutralized is NOT set — Odoo does not", + }, + "consider this database neutralised.": { + "fr": "cette base comme neutralisée.", + "en": "consider this database neutralised.", + }, + "scheduled actions are still active.": { + "fr": "actions planifiées sont encore actives.", + "en": "scheduled actions are still active.", + }, + "No scheduled action is active.": { + "fr": "Aucune action planifiée n'est active.", + "en": "No scheduled action is active.", + }, + "No mail server at all: Odoo falls back to smtp_server": { + "fr": "Aucun serveur de courriel : Odoo retombe sur le smtp_server", + "en": "No mail server at all: Odoo falls back to smtp_server", + }, + "from the config file. A blocking one is safer than none.": { + "fr": "du fichier de configuration. Un serveur bouchon vaut mieux que rien.", + "en": "from the config file. A blocking one is safer than none.", + }, + "payment provider(s) are neither disabled nor": { + "fr": "fournisseur(s) de paiement ne sont ni désactivés ni", + "en": "payment provider(s) are neither disabled nor", + }, + "in test mode.": { + "fr": "en mode test.", + "en": "in test mode.", + }, + "Run": { + "fr": "Lancer", + "en": "Run", + }, + "Enter to run it.": { + "fr": "Entrée pour le lancer.", + "en": "Enter to run it.", + }, + "Not available for this source.": { + "fr": "Indisponible pour cette provenance.", + "en": "Not available for this source.", + }, + "Migration leftovers": { + "fr": "🚚 Restant de migration", + "en": "🚚 Migration leftovers", + }, + "What a migration left behind, judged without needing the step-by-step databases.": { + "fr": "Ce qu'une migration a laissé derrière elle, jugé sans avoir besoin des bases de palier.", + "en": "What a migration left behind, judged without needing the step-by-step databases.", + }, + "It reads pg_catalog — indexes and real tables — which no RPC session exposes.": { + "fr": "Il lit pg_catalog — les index et les vraies tables — qu'aucune session RPC n'expose.", + "en": "It reads pg_catalog — indexes and real tables — which no RPC session exposes.", + }, + "Every view someone changed, and every website copy that shadows a module view.": { + "fr": "Chaque vue que quelqu'un a modifiée, et chaque copie de site web qui masque une vue de module.", + "en": "Every view someone changed, and every website copy that shadows a module view.", + }, + "Comparing a copy with the module view it hides is a join on arch_db, done in SQL.": { + "fr": "Comparer une copie à la vue de module qu'elle masque est une jointure sur arch_db, faite en SQL.", + "en": "Comparing a copy with the module view it hides is a join on arch_db, done in SQL.", + }, + "Fields and models added outside any module, and which of them have no column behind them.": { + "fr": "Champs et modèles ajoutés hors de tout module, et lesquels n'ont aucune colonne derrière eux.", + "en": "Fields and models added outside any module, and which of them have no column behind them.", + }, + "Telling a declared field from a real column means reading pg_attribute.": { + "fr": "Distinguer un champ déclaré d'une vraie colonne demande de lire pg_attribute.", + "en": "Telling a declared field from a real column means reading pg_attribute.", + }, + "What a migration left behind, read in one database.": { + "fr": "Ce qu'une migration a laissé derrière elle, lu dans une seule base.", + "en": "What a migration left behind, read in one database.", + }, + "Migration residue in": { + "fr": "Restant de migration dans", + "en": "Migration residue in", + }, + "base module version:": { + "fr": "version du module base :", + "en": "base module version:", + }, + "None of the checks found anything.": { + "fr": "Aucun contrôle n'a rien trouvé.", + "en": "None of the checks found anything.", + }, + "This reads one database on its own — it cannot see": { + "fr": "Ceci ne lit qu'une base isolée — il ne peut pas voir", + "en": "This reads one database on its own — it cannot see", + }, + "what an earlier step silently dropped.": { + "fr": "ce qu'un palier antérieur a laissé tomber en silence.", + "en": "what an earlier step silently dropped.", + }, + "could not read": { + "fr": "lecture impossible", + "en": "could not read", + }, + "no repair tool yet": { + "fr": "pas encore d'outil de réparation", + "en": "no repair tool yet", + }, + "Modules stuck between two states": { + "fr": "Modules figés entre deux états", + "en": "Modules stuck between two states", + }, + "A migration that stopped mid-flight leaves them there; Odoo will retry the transition at every start.": { + "fr": "Une migration arrêtée en vol les laisse ainsi ; Odoo retentera la transition à chaque démarrage.", + "en": "A migration that stopped mid-flight leaves them there; Odoo will retry the transition at every start.", + }, + "Languages whose active flag is NULL": { + "fr": "Langues dont le drapeau actif est NULL", + "en": "Languages whose active flag is NULL", + }, + "A boolean that is neither true nor false: the language is listed nowhere and cannot be re-enabled from the interface.": { + "fr": "Un booléen qui n'est ni vrai ni faux : la langue n'est listée nulle part et ne peut plus être réactivée depuis l'interface.", + "en": "A boolean that is neither true nor false: the language is listed nowhere and cannot be re-enabled from the interface.", + }, + "Many-to-many tables named but missing": { + "fr": "Tables plusieurs-à-plusieurs nommées mais absentes", + "en": "Many-to-many tables named but missing", + }, + "ir_model_relation still names a table PostgreSQL does not have; the next module update tries to alter it and fails.": { + "fr": "ir_model_relation nomme encore une table que PostgreSQL n'a pas ; la prochaine mise à jour de module tente de la modifier et échoue.", + "en": "ir_model_relation still names a table PostgreSQL does not have; the next module update tries to alter it and fails.", + }, + "Indexes duplicated by the Odoo 17 renaming": { + "fr": "Index doublés par le renommage d'Odoo 17", + "en": "Indexes duplicated by the Odoo 17 renaming", + }, + "Odoo 17 changed the naming convention without dropping the old index: both are maintained on every write. This count is a cheap signal — the repair tool compares columns and uniqueness, and is the one to trust.": { + "fr": "Odoo 17 a changé la convention de nommage sans supprimer l'ancien index : les deux sont entretenus à chaque écriture. Ce compte est un indicateur bon marché — l'outil de réparation compare les colonnes et l'unicité, c'est lui qui fait foi.", + "en": "Odoo 17 changed the naming convention without dropping the old index: both are maintained on every write. This count is a cheap signal — the repair tool compares columns and uniqueness, and is the one to trust.", + }, + "Default pricelist missing while product is installed": { + "fr": "Liste de prix par défaut absente alors que product est installé", + "en": "Default pricelist missing while product is installed", + }, + "product.list0 was declared up to Odoo 16 only; nothing recreates it, and a quotation has no price list to pick.": { + "fr": "product.list0 n'a été déclaré que jusqu'à Odoo 16 ; rien ne le recrée, et un devis n'a aucune liste de prix à choisir.", + "en": "product.list0 was declared up to Odoo 16 only; nothing recreates it, and a quotation has no price list to pick.", + }, + "Views bound to a model that no longer exists": { + "fr": "Vues liées à un modèle qui n'existe plus", + "en": "Views bound to a model that no longer exists", + }, + "Opening one raises; the menu that leads to it is a dead end.": { + "fr": "En ouvrir une lève une erreur ; le menu qui y mène est un cul-de-sac.", + "en": "Opening one raises; the menu that leads to it is a dead end.", + }, + "Views inheriting a view that is gone": { + "fr": "Vues héritant d'une vue disparue", + "en": "Views inheriting a view that is gone", + }, + "The whole inheritance chain below them stops rendering.": { + "fr": "Toute la chaîne d'héritage en dessous cesse de s'afficher.", + "en": "The whole inheritance chain below them stops rendering.", + }, + "External ids pointing at a model that is gone": { + "fr": "Identifiants externes pointant un modèle disparu", + "en": "External ids pointing at a model that is gone", + }, + "Every module update that resolves one of them fails.": { + "fr": "Chaque mise à jour de module qui en résout un échoue.", + "en": "Every module update that resolves one of them fails.", + }, + "Attachments whose carrying field is gone": { + "fr": "Pièces jointes dont le champ porteur a disparu", + "en": "Attachments whose carrying field is gone", + }, + "Odoo raises a KeyError merely checking them, and nothing will ever read them again.": { + "fr": "Odoo lève un KeyError rien qu'en les contrôlant, et plus rien ne les lira jamais.", + "en": "Odoo raises a KeyError merely checking them, and nothing will ever read them again.", + }, + "A local database": { + "fr": "Une base de données locale", + "en": "A local database", + }, + "State of the instance": { + "fr": "🩺 État de l'instance", + "en": "🩺 State of the instance", + }, + "Neutralisation, scheduler, backups, queues and who is an administrator — read for the use you intend.": { + "fr": "Neutralisation, cadenceur, sauvegardes, files et qui est administrateur — lu pour l'usage que vous en faites.", + "en": "Neutralisation, scheduler, backups, queues and who is an administrator — read for the use you intend.", + }, + "Several checks read tables no RPC session exposes, and the lateness of a job is computed in SQL.": { + "fr": "Plusieurs contrôles lisent des tables qu'aucune session RPC n'expose, et le retard d'une tâche se calcule en SQL.", + "en": "Several checks read tables no RPC session exposes, and the lateness of a job is computed in SQL.", + }, + "A development copy (restored, neutralised)": { + "fr": "Une copie de développement (restaurée, neutralisée)", + "en": "A development copy (restored, neutralised)", + }, + "An instance in service": { + "fr": "Une instance en service", + "en": "An instance in service", + }, } diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index e33b413..908099a 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -2781,6 +2781,14 @@ class TodoUpgrade: # ici : /blog//post/ et /contactus, alors que # le journal de migration n'avait rien signalé. self.prompt_database_cleanup(database_name_upgrade) + # APRÈS le nettoyage : il supprime des colonnes et des + # tables, donc leurs index avec. Avant lui, on travaillerait + # sur des index qui vont disparaître. + self.drop_duplicate_index(database_name_upgrade, next_version) + self.restore_config_defaults( + database_name_upgrade, + index == len(lst_next_version) - 1, + ) self.prompt_smoke_public_url(database_name_upgrade) # Le trou que ni les comptages ni le test de fumée ne @@ -3684,6 +3692,66 @@ class TodoUpgrade: wait_at_error=False, ) + def drop_duplicate_index(self, database_name, next_version): + """Les index qu'Odoo 17 a rebaptisés sans supprimer les anciens. + + `make_index_name` a changé de convention en 17 — `table_col_index` + est devenu `table__col_index` — et rien ne retire le premier. Les + deux restent, et PostgreSQL les entretient TOUS LES DEUX à chaque + écriture. Mesuré sur deux chaînes 12 → 18 indépendantes : 414 dans + l'une et 414 dans l'autre, à l'index près. Ce n'est pas un accident + d'exécution, c'est le chemin lui-même. + + À partir de 17 seulement : avant, la convention n'a pas changé et + l'outil ne trouverait rien — le lancer six fois pour rien ferait + du bruit dans un journal qu'on lit déjà mal. + + `wait_at_error=False` : avec `--apply`, le code 1 signifie « il en + reste », pas « je suis tombé ». Un index redondant qui survit ne + justifie pas d'arrêter une migration de six paliers ; l'outil + l'écrit, et le journal le garde. + """ + if next_version < 17: + return + outil = os.path.join(PATH_MIGRATION_GLOBAL, "fix_duplicate_index.py") + if not os.path.isfile(outil): + return + self.todo_upgrade_execute( + f"{PYTHON_BIN} ./{outil} -d {database_name} --apply", + wait_at_error=False, + ) + + def restore_config_defaults(self, database_name, is_last_version): + """Les réglages par défaut qu'aucune migration ne recrée. + + `product.list0` n'a été déclaré que jusqu'à Odoo 16 et + `account.reconciliation_model_default_rule` seulement en 12 : ils + naissent aujourd'hui d'un événement qu'une migration ne déclenche + jamais. La base arrive donc en 18 sans liste de prix par défaut, et + cela ne se découvre qu'au premier devis. Mesuré sur deux chaînes + indépendantes : absent des deux. + + AU DERNIER PALIER seulement. L'outil charge le registre Odoo — une + quarantaine de secondes — et seul l'état final compte : recréer la + liste au palier 13 pour la voir disparaître au 16 ne servirait qu'à + allonger six fois la migration. + + `wait_at_error=False` : même raison que pour les index. Le code 1 + veut dire « il en manque encore après la réparation », ce que + l'outil écrit lui-même. + """ + if not is_last_version: + return + outil = os.path.join( + PATH_MIGRATION_GLOBAL, "restore_config_defaults.py" + ) + if not os.path.isfile(outil): + return + self.todo_upgrade_execute( + f"{PYTHON_BIN} ./{outil} -d {database_name} --apply", + wait_at_error=False, + ) + def diff_cow_views(self, database_name, label_before, label_after): """Print what the version bump did to the website COW views.""" directory = os.path.join( diff --git a/test/test_check_hidden_models.py b/test/test_check_hidden_models.py index 75fe51f..f40a9e3 100644 --- a/test/test_check_hidden_models.py +++ b/test/test_check_hidden_models.py @@ -237,5 +237,99 @@ class TestTheWiring(unittest.TestCase): self.assertIn("--apply", src[debut : debut + 200]) +class TestItDeclaresWhatItCouldNotProve(unittest.TestCase): + """Un vert qui prouve moins qu'il n'en a l'air est pire qu'un rouge. + + Mesuré sur une base réelle : l'outil éprouvait 45 modèles sur 125 + portant une règle globale, contre 4 utilisateurs internes — et les + QUATRE étaient administrateurs. Un modèle que seuls les + administrateurs peuvent lire passait donc au vert. C'est exactement + la forme du bug DMS qui a motivé l'outil, vue d'un autre angle. + + L'outil ne peut pas créer un témoin ordinaire — ce serait une + écriture. Il peut dire qu'il n'en avait pas. + """ + + def rapport(self, **extra): + base = { + "checked": 45, + "with_rule": 125, + "users": ["a", "b", "c", "d"], + "ordinary": ["b"], + "models": [], + } + base.update(extra) + return base + + def tete(self, **extra): + return "\n".join(check.render(self.rapport(**extra))) + + def test_it_says_how_many_it_could_not_check(self): + # « 45 éprouvés » sans dire « sur 125 » laisse croire à un examen + # complet. + texte = self.tete() + self.assertIn("45", texte) + self.assertIn("125", texte) + + def test_without_the_coverage_it_says_only_what_it_checked(self): + # Un rapport d'une version antérieure n'a pas le renseignement. + # Inventer « sur 0 » serait pire que se taire — et « sur None » + # pire encore : c'est ce que produit un f-string sans garde. + texte = self.tete(with_rule=None) + self.assertIn("45", texte) + self.assertNotIn("125", texte) + self.assertNotIn("None", texte) + self.assertNotIn(check.t("out of"), texte.split("\n")[0]) + + def test_it_warns_when_every_witness_is_an_administrator(self): + texte = self.tete(ordinary=[]) + self.assertIn( + check.t("Every witness is an administrator: a model only"), texte + ) + + def test_it_stays_quiet_when_one_witness_is_ordinary(self): + self.assertNotIn( + check.t("Every witness is an administrator: a model only"), + self.tete(), + ) + + def test_it_declares_what_it_cannot_see_at_all(self): + # La sonde part d'ir_rule : un masquage écrit en Python lui + # échappe par construction, et le taire ferait prendre son vert + # pour une garantie. + self.assertIn( + check.t( + "Reads ir_rule only — masking coded in Python escapes it." + ), + self.tete(), + ) + + def test_the_warning_comes_before_the_verdict(self): + lignes = check.render(self.rapport(ordinary=[])) + avertissement = next( + i + for i, x in enumerate(lignes) + if "administrator" in x or "administrateur" in x + ) + verdict = next(i for i, x in enumerate(lignes) if "✅" in x) + self.assertLess(avertissement, verdict) + + +class TestTheScriptCollectsWhatTheReportNeeds(unittest.TestCase): + def corps(self): + return check.build_script() + + def test_it_counts_the_models_carrying_a_rule(self): + self.assertIn('rapport["with_rule"]', self.corps()) + + def test_it_separates_the_ordinary_witnesses(self): + corps = self.corps() + self.assertIn('rapport["ordinary"]', corps) + self.assertIn("base.group_system", corps) + + def test_the_script_is_valid_python(self): + compile(self.corps(), "script", "exec") + + if __name__ == "__main__": unittest.main() diff --git a/test/test_check_instance_state.py b/test/test_check_instance_state.py new file mode 100644 index 0000000..4166708 --- /dev/null +++ b/test/test_check_instance_state.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""La polarité : le même chiffre, deux lectures opposées. + +Zéro cron actif est le succès attendu d'une copie de développement et une +panne totale sur une production. C'est la propriété centrale de cet outil, +et c'est aussi celle qu'une refonte casserait sans bruit : il suffirait +qu'un contrôle perde une de ses deux politiques pour qu'il se taise — +sans erreur, sans rouge, en donnant l'impression d'avoir été vérifié. + +Le second sujet de ce fichier est le secret. Une clé de paiement VIVANTE +a été mesurée dans une base de test. Un rapport finit dans un billet ou +devant un agent : aucune requête ne doit lire la valeur d'un secret, et +c'est vérifié sur le texte des requêtes, pas sur l'intention. +""" + +import os +import sys +import unittest + +sys.path.insert( + 0, os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) +) + +from script.analyse import check_instance_state as etat # noqa: E402 + + +class TestEveryCheckDeclaresBothReadings(unittest.TestCase): + """Un contrôle sans politique se tairait — sans erreur ni rouge.""" + + def test_each_check_has_a_policy_for_each_expectation(self): + for controle in etat.CONTROLES: + for attente in etat.ATTENTES: + self.assertIn(attente, controle, controle["key"]) + self.assertTrue(controle[attente], controle["key"]) + + def test_each_policy_is_one_we_know_how_to_read(self): + connus = ("zero", "nonzero", "info", "skip") + for controle in etat.CONTROLES: + for attente in etat.ATTENTES: + self.assertIn(controle[attente][0], connus, controle["key"]) + + def test_a_judging_policy_carries_a_gravity_we_understand(self): + for controle in etat.CONTROLES: + for attente in etat.ATTENTES: + politique = controle[attente] + if politique[0] in ("zero", "nonzero"): + self.assertIn(politique[1], ("broken", "watch")) + + def test_a_judging_policy_explains_itself(self): + """Un rouge sans raison n'apprend rien à qui le lit.""" + for controle in etat.CONTROLES: + for attente in etat.ATTENTES: + if controle[attente][0] in ("zero", "nonzero"): + self.assertTrue( + controle.get(f"why_{attente}", "").strip(), + f"{controle['key']}/{attente}", + ) + + def test_a_skipped_check_says_why(self): + for controle in etat.CONTROLES: + for attente in etat.ATTENTES: + politique = controle[attente] + if politique[0] == "skip": + self.assertGreater(len(politique), 1, controle["key"]) + self.assertTrue(politique[1].strip()) + + def test_every_section_declared_is_actually_used(self): + utilisees = {c["section"] for c in etat.CONTROLES} + self.assertEqual(set(etat.SECTIONS), utilisees) + + def test_keys_are_unique(self): + cles = [c["key"] for c in etat.CONTROLES] + self.assertEqual(len(cles), len(set(cles))) + + +class TestThePolarityActuallyInverts(unittest.TestCase): + """La propriété centrale, épinglée sur un cas réel mesuré.""" + + def _controle(self, cle): + for controle in etat.CONTROLES: + if controle["key"] == cle: + return controle + raise AssertionError(cle) + + def test_active_crons_are_a_fault_on_a_copy_and_normal_when_live(self): + controle = self._controle("cron_active") + copie = etat.verdict(controle, 35, etat.COPY) + vivante = etat.verdict(controle, 35, etat.LIVE) + self.assertEqual(copie[0], "bad") + self.assertEqual(vivante[0], "ok") + + def test_no_cron_at_all_is_the_opposite(self): + controle = self._controle("cron_active") + self.assertEqual(etat.verdict(controle, 0, etat.COPY)[0], "ok") + self.assertEqual(etat.verdict(controle, 0, etat.LIVE)[0], "bad") + + def test_late_jobs_are_not_judged_on_a_copy(self): + """Mesuré : 11 en retard sur la base d'ORIGINE, jamais démarrée.""" + controle = self._controle("cron_late") + genre, _, raison = etat.verdict(controle, 11, etat.COPY) + self.assertEqual(genre, "skip") + self.assertTrue(raison) + self.assertEqual(etat.verdict(controle, 11, etat.LIVE)[0], "bad") + + def test_missing_backups_are_not_judged_on_a_copy(self): + """update_prod_to_dev les efface : leur absence ne prouve rien.""" + controle = self._controle("backup_rows") + self.assertEqual(etat.verdict(controle, 0, etat.COPY)[0], "skip") + self.assertEqual(etat.verdict(controle, 0, etat.LIVE)[0], "bad") + + def test_having_no_mail_server_warns_under_both_readings(self): + """Zéro serveur NE prouve PAS la sûreté : Odoo retombe sur la conf. + + C'est pourquoi le `neutralize.sql` d'Odoo INSÈRE un serveur bouchon + `invalid:1025` au lieu de tout supprimer, avec le commentaire + « prevent using fallback servers ». Notre `disable_mail_server`, lui, + fait `unlink()` — il rouvre la porte qu'Odoo ferme. + """ + controle = self._controle("mail_server_total") + for attente in etat.ATTENTES: + self.assertEqual(etat.verdict(controle, 0, attente)[0], "bad") + + def test_a_check_without_a_policy_is_skipped_never_approved(self): + """L'oubli doit se voir comme un trou, pas comme un feu vert.""" + orphelin = {"key": "x", "section": "Users", "title": "x", "sql": ""} + for attente in etat.ATTENTES: + self.assertEqual(etat.verdict(orphelin, 99, attente)[0], "skip") + + def test_an_unreadable_check_is_not_a_zero(self): + controle = self._controle("cron_active") + genre, _, raison = etat.verdict( + controle, {"error": "relation absente"}, etat.COPY + ) + self.assertEqual(genre, "unreadable") + self.assertIn("relation", raison) + + +class TestWhatTheReportSaysAndCounts(unittest.TestCase): + def _tout(self, valeur=0): + return {c["key"]: valeur for c in etat.CONTROLES} + + def test_the_header_names_the_reading(self): + for attente, mot in ( + (etat.COPY, "a development copy"), + (etat.LIVE, "a live instance"), + ): + texte = etat.render("b", self._tout(), attente, colour=False) + self.assertIn(etat.t(mot), texte) + + def test_only_faults_reach_the_exit_code(self): + resultats = self._tout() + resultats["cron_active"] = 35 # faute sous copy, normal sous live + self.assertTrue(etat.findings(resultats, etat.COPY)) + self.assertFalse( + [ + f + for f in etat.findings(resultats, etat.LIVE) + if f[0]["key"] == "cron_active" + ] + ) + + def test_a_skipped_check_shows_its_number_and_its_reason(self): + resultats = self._tout() + resultats["cron_late"] = 11 + texte = etat.render("b", resultats, etat.COPY, colour=False) + self.assertIn("11", texte) + self.assertIn( + etat.t( + "Nobody runs the scheduler on a restored copy:" + " measured 11 late on an untouched source" + " database." + ), + texte, + ) + + def test_an_unreadable_check_is_shown_as_unknown(self): + resultats = self._tout() + resultats["mail_stuck"] = {"error": "relation absente"} + texte = etat.render("b", resultats, etat.LIVE, colour=False) + self.assertIn("❔", texte) + + def test_sections_come_out_in_the_declared_order(self): + texte = etat.render("b", self._tout(), etat.COPY, colour=False) + positions = [texte.find(etat.t(s)) for s in etat.SECTIONS] + self.assertEqual(positions, sorted(positions)) + self.assertNotIn(-1, positions) + + +class TestNoQueryEverReadsASecret(unittest.TestCase): + """Une clé Stripe VIVANTE a été mesurée dans une base de test.""" + + SECRETS = ( + "secret_key", + "publishable_key", + "smtp_pass", + "password", + "sftp_password", + "database.secret", + "api_key", + ) + + def test_no_secret_column_is_selected(self): + for controle in etat.CONTROLES: + minuscule = controle["sql"].lower() + for secret in self.SECRETS: + self.assertNotIn(secret, minuscule, controle["key"]) + + def test_every_query_reads_and_returns_one_number(self): + for controle in etat.CONTROLES: + sql = " ".join(controle["sql"].split()) + self.assertTrue(sql.upper().startswith("SELECT"), controle["key"]) + majuscule = f" {sql.upper()} " + for interdit in ("INSERT", "UPDATE", "DELETE", "DROP", "ALTER"): + self.assertNotIn(f" {interdit} ", majuscule, controle["key"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_check_migration_quality.py b/test/test_check_migration_quality.py index adb5310..283c72f 100644 --- a/test/test_check_migration_quality.py +++ b/test/test_check_migration_quality.py @@ -1492,5 +1492,100 @@ class TestFieldsThatHeldNoData(TestTheOpenUpgradeOverlay): self.assertTrue(quality.t(libelle)) +class TestWhyAnAttachmentWentAway(Base): + """« 409 pièces jointes perdues » n'en recouvrait presque aucune. + + Mesuré sur une chaîne 12 → 18 : des 516 lignes parties au palier 18, + 452 avaient perdu leur CHAMP PORTEUR aux paliers 13 et 14 — elles + étaient déjà illisibles, Odoo lève un KeyError en les contrôlant. Ce + ne sont pas des données, ce sont des débris, et la 18 les ramasse. + + On ne DÉCLARE pas cette perte dans SEMANTIC_MAP : cette carte nomme + une TABLE, et la cause n'est pas la table, ce sont ces lignes-là. + Déclarée, elle rangerait toute perte future de ir_attachment sous + « changement d'Odoo » — cinq mille factures comprises. + """ + + def etat(self, lignes, modeles=("res.partner",)): + return { + "exists": True, + "attachment_row": lignes, + "model": list(modeles), + } + + def test_a_field_already_gone_is_not_a_loss(self): + avant = self.etat({"7": ("res.partner", "image", "1", False)}) + seaux = quality.classify_attachments(avant, self.etat({})) + self.assertEqual(["7"], seaux["field_debt"]) + self.assertEqual([], seaux["undeclared"]) + + def test_a_field_removed_at_this_step_is_not_a_loss_either(self): + avant = self.etat({"7": ("res.partner", "image", "1", True)}) + apres = self.etat({"8": ("res.partner", "image", "2", False)}) + seaux = quality.classify_attachments(avant, apres) + self.assertEqual(["7"], seaux["field_dropped"]) + + def test_a_model_that_left_takes_its_attachments_with_it(self): + avant = self.etat({"7": ("mail.channel", "image", "1", True)}) + apres = self.etat({}, modeles=["res.partner"]) + seaux = quality.classify_attachments(avant, apres) + self.assertEqual(["7"], seaux["model_gone"]) + + def test_a_live_field_losing_its_attachment_stays_red(self): + # LE cas qui compte : une donnée lisible a disparu. + avant = self.etat({"7": ("res.partner", "image_1920", "1", True)}) + apres = self.etat({"8": ("res.partner", "image_1920", "2", True)}) + seaux = quality.classify_attachments(avant, apres) + self.assertEqual(["7"], seaux["undeclared"]) + + def test_an_attachment_that_stayed_is_not_counted(self): + lignes = {"7": ("res.partner", "image_1920", "1", True)} + seaux = quality.classify_attachments( + self.etat(lignes), self.etat(lignes) + ) + self.assertEqual([], seaux["undeclared"]) + self.assertEqual([], seaux["field_debt"]) + + def test_without_the_information_it_says_it_does_not_know(self): + # Un instantané pris par une version antérieure n'a pas + # `attachment_row`. Rendre des seaux vides ferait croire à une + # explication complète. + self.assertIsNone( + quality.classify_attachments({"exists": True}, self.etat({})) + ) + + def test_the_red_bucket_is_the_only_one_that_warns(self): + table = {cle: teinte for cle, _l, teinte in quality.ATTACHMENT_KIND} + self.assertEqual("warn", table["undeclared"]) + for cle in ("field_debt", "field_dropped", "model_gone"): + self.assertEqual("dim", table[cle], cle) + + def test_the_report_names_each_cause(self): + connu = { + "buckets": {"field_debt": 452, "undeclared": 1}, + "why": "attachments of fields and records already gone", + } + texte = "\n".join(quality.render_attachment_kind(connu, colour=False)) + self.assertIn("452", texte) + self.assertIn("1", texte) + self.assertIn( + quality.t("their field was already gone before this step"), texte + ) + + def test_a_bucket_at_zero_is_not_printed(self): + connu = {"buckets": {"field_debt": 0, "undeclared": 3}} + texte = "\n".join(quality.render_attachment_kind(connu, colour=False)) + self.assertNotIn( + quality.t("their field was already gone before this step"), texte + ) + + def test_semantic_map_still_says_nothing_about_attachments(self): + # Une entrée déclarée rendrait l'outil aveugle : `explain_loss` + # ne compare que le nom de la table, et `pruned` ne déclenche + # aucune contre-vérification. + for entree in quality.SEMANTIC_MAP: + self.assertNotEqual("ir_attachment", entree.get("table")) + + if __name__ == "__main__": unittest.main() diff --git a/test/test_mobile_bundle_transfer.py b/test/test_mobile_bundle_transfer.py index 75d0e8f..b4c28a1 100644 --- a/test/test_mobile_bundle_transfer.py +++ b/test/test_mobile_bundle_transfer.py @@ -265,6 +265,8 @@ class TestTheCommandLine(unittest.TestCase): REPO = Path(__file__).resolve().parent.parent MOBILE = REPO / "mobile" / "erplibre_home_mobile" +# Le plafond d'entrees d'un ZIP, et donc d'un APK. +ZIP_ENTRY_LIMIT = 65535 class TestTheRealBundle(unittest.TestCase): @@ -276,10 +278,11 @@ class TestTheRealBundle(unittest.TestCase): (script/test/run_unit_test.sh) annonce la même dépendance avant de commencer. - Ce qu'ils gardent : qu'une compilation réelle produise bien des PACKS. Un - retour au fichier-par-source ferait disparaître le champ « chunk » des - index, et la limite du ZIP reviendrait — 123 678 entrées pour un plafond de - 65 535, silencieusement, jusqu'à l'APK. + Ce qu'ils gardent : qu'une compilation réelle range les sources dans des + CONTENEURS — archives tar.gz ou tranches pack, les deux dispositions + conviennent. Un retour au fichier-par-source ferait revenir la limite du + ZIP — 123 678 entrées pour un plafond de 65 535, silencieusement, jusqu'à + l'APK. C'est le nombre d'entrées qui compte, pas la forme du conteneur. """ @classmethod @@ -320,11 +323,18 @@ class TestTheRealBundle(unittest.TestCase): # ce qui se lit comme une régression du transfert alors que rien # n'était encore transféré. Un état incomplet s'IGNORE ; seule une # incohérence entre ce qui est là et le dépôt doit échouer. + # L'index se cherche par la MÊME résolution que le vérificateur : la + # chercher ici en dur, sous `/index.json`, faisait sauter ces + # tests sur toute compilation en archives — ils regardaient ailleurs au + # lieu de vérifier. for entree in entrees: - slug = entree.get("slug") if isinstance(entree, dict) else entree - if slug and not (cls.repos / str(slug) / "index.json").is_file(): + if not isinstance(entree, dict): + continue + try: + cbt.index_path(cls.repos, entree) + except (OSError, KeyError) as exc: raise unittest.SkipTest( - f"paquet incomplet ({slug} sans index.json) :" + f"paquet incomplet ({exc}) :" " relancer ./mobile/compile_and_run.sh" ) @@ -332,31 +342,46 @@ class TestTheRealBundle(unittest.TestCase): rep = cbt.check(MOBILE, REPO) self.assertGreater(rep["repos"], 1) self.assertGreater(rep["files"], cbt.MIN_FILES) - self.assertGreater(rep["packs"], 0) + # Peu importe la disposition : ce qui doit être vrai, c'est que les + # sources soient dans des conteneurs et non une entrée ZIP chacune. + self.assertGreater(rep["packs"] + rep["archives"], 0) def test_a_sample_matches_the_source(self): """La seule vérification qui prouve un transfert FIDÈLE.""" rep = cbt.check(MOBILE, REPO) self.assertGreater(rep["compared"], 0) - def test_the_indexes_are_packed_not_file_per_source(self): - """Le garde-fou de la limite du ZIP : chaque fichier doit porter sa - tranche. Sans « chunk », c'est un fichier par source, et l'APK sera - refusé — mais bien plus tard, et sans dire pourquoi.""" + def test_the_apk_stays_under_the_zip_entry_limit(self): + """Le garde-fou de la limite du ZIP, énoncé comme l'invariant qu'il est. + + Ce test exigeait un champ « chunk » sur chaque fichier, c'est-à-dire la + disposition en packs. Il échouait donc sur une compilation en archives + alors que la limite y est tenue — il testait la FORME au lieu de ce qui + importe : combien d'entrées le ZIP de l'APK va porter. Un fichier par + source en réclamerait 124 350 pour un plafond de 65 535.""" man = json.loads((self.repos / "manifest.json").read_text()) - checked = 0 - for proj in man[:5]: - index = self.repos / proj["slug"] / "index.json" - entries = json.loads(index.read_text()) - files = [e for e in entries if e.get("type") == "file"] - if not files: + entries_in_apk = 0 + files = 0 + for proj in man: + index = cbt.index_path(self.repos, proj) + listing = json.loads(index.read_text()) + files += len([e for e in listing if e.get("type") == "file"]) + entries_in_apk += 1 # l'index lui-même + if cbt.archive_path(self.repos, proj) is not None: + entries_in_apk += 1 # une archive continue - self.assertTrue( - all("chunk" in e for e in files), - f"{proj['slug']} : des fichiers sans tranche", + entries_in_apk += len( + list((self.repos / proj["slug"]).glob("pack-*.bin")) ) - checked += 1 - self.assertGreater(checked, 0, "aucun dépôt à vérifier") + self.assertGreater(files, cbt.MIN_FILES, "aucun dépôt à vérifier") + self.assertLess( + entries_in_apk, + ZIP_ENTRY_LIMIT, + f"{entries_in_apk} entrées dans l'APK pour {files} fichiers", + ) + # Et pas seulement « sous la limite » : l'écart doit rester d'un ordre + # de grandeur, sans quoi le prochain dépôt ajouté la ferait sauter. + self.assertLess(entries_in_apk, files // 10) def test_no_bundled_test_file_lingers_as_a_source(self): """Effet de bord mesuré, et il compte : empaquetés, les 1 599 fichiers diff --git a/test/test_monitoring.py b/test/test_monitoring.py new file mode 100644 index 0000000..f40ef8c --- /dev/null +++ b/test/test_monitoring.py @@ -0,0 +1,348 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Le monitoring : ce qu'il refuse, et ce qu'il avoue ne pas savoir. + +Deux propriétés valent d'être épinglées, et ce ne sont pas les plus +visibles. + +La première est un refus. Toutes les analyses du dépôt lisent par psql en +`default_transaction_read_only=on` : c'est le SERVEUR qui interdit +d'écrire. Une session RPC n'a pas d'équivalent — rien n'empêche un +`write` sur la production d'un client. La liste blanche est donc appliquée +dans le passe-plat lui-même, et non chez l'appelant, pour qu'aucune +analyse à venir ne puisse s'en dispenser par distraction. + +La seconde est un aveu. Une provenance qui ne permet pas une analyse doit +le DIRE. Une analyse muette qu'on prend pour rassurante est pire que pas +d'analyse : c'est la faute que ce dépôt a déjà corrigée trois fois. +""" + +import os +import sys +import unittest + +sys.path.insert( + 0, os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) +) + +from script.analyse import check_migration_residue as residue # noqa: E402 +from script.analyse import monitoring # noqa: E402 +from script.analyse import monitoring_tui # noqa: E402 + + +class TestWhatTheProxyRefuses(unittest.TestCase): + """Le passe-plat RPC n'accepte que la lecture.""" + + def test_a_write_is_refused_before_any_network_call(self): + # Aucune URL joignable ici : si l'appel partait, le test échouerait + # par timeout au lieu de PermissionError. C'est le contrôle qu'on + # veut — le refus vient AVANT le réseau. + for methode in ("write", "create", "unlink", "execute", "load"): + with self.assertRaises(PermissionError): + monitoring.live_call( + "http://127.0.0.1:1", "db", 1, "x", "res.partner", methode + ) + + def test_the_allowlist_holds_nothing_that_writes(self): + interdits = ("write", "create", "unlink", "copy", "load", "execute") + for methode in monitoring.RPC_READ_ONLY: + self.assertNotIn(methode, interdits) + + def test_the_refusal_names_the_method(self): + with self.assertRaises(PermissionError) as capture: + monitoring.live_call( + "http://127.0.0.1:1", "db", 1, "x", "res.partner", "unlink" + ) + self.assertIn("unlink", str(capture.exception)) + + +class TestWhatASourceAdmits(unittest.TestCase): + """Ce qu'une provenance ne permet pas doit être dit, pas caché.""" + + def test_every_analysis_says_why_it_cannot_do_live(self): + for analyse in monitoring.ANALYSES: + self.assertTrue(analyse["needs_sql"].strip(), analyse["key"]) + + def test_available_and_unavailable_cover_every_analysis(self): + for genre in (monitoring.KIND_DATABASE, monitoring.KIND_LIVE): + total = len(monitoring.available(genre)) + len( + monitoring.unavailable(genre) + ) + self.assertEqual(total, len(monitoring.ANALYSES)) + + def test_a_restored_backup_and_a_database_are_the_same_thing(self): + """Passé la restauration, la provenance ne se distingue plus.""" + self.assertTrue(monitoring.available(monitoring.KIND_DATABASE)) + + def test_an_unknown_key_gives_none_rather_than_raising(self): + self.assertIsNone(monitoring.analysis_by_key("pas_une_analyse")) + + def test_the_command_names_the_database(self): + analyse = monitoring.ANALYSES[0] + cmd = monitoring.command_for(analyse, "ma_base") + self.assertIn("-d", cmd) + self.assertEqual(cmd[cmd.index("-d") + 1], "ma_base") + self.assertIn(analyse["script"], cmd) + + +class TestWhetherTheNeutralisationTook(unittest.TestCase): + """Poser la question et ne pas vérifier, c'est l'illusion mesurée.""" + + def test_a_missing_flag_is_reported_as_not_neutralised(self): + rapport = monitoring.neutralize_report( + {"flag": 0, "cron_active": 35, "mail_server": 0, "payment_live": 1} + ) + self.assertIn("❌", rapport) + self.assertIn("35", rapport) + + def test_no_mail_server_is_a_warning_and_never_a_reassurance(self): + """Zéro serveur NE prouve PAS la sûreté : Odoo retombe sur la conf. + + C'est pour cela que le `neutralize.sql` d'Odoo INSÈRE un serveur + bouchon au lieu de tout supprimer. Compter zéro et conclure « sûr » + se tromperait dans le mauvais sens. + """ + rapport = monitoring.neutralize_report( + {"flag": 1, "cron_active": 0, "mail_server": 0, "payment_live": 0} + ) + self.assertIn("⚠", rapport) + self.assertIn("smtp_server", rapport) + + def test_a_table_that_does_not_exist_is_not_counted_as_zero(self): + """Absent n'est pas nul, et surtout pas une bonne nouvelle.""" + appels = [] + + class FauxLib: + @staticmethod + def run_psql(database, sql, config_path=None): + appels.append(sql) + raise RuntimeError("relation does not exist") + + # `from script.analyse import lib_analyse` lit l'ATTRIBUT du paquet : + # remplacer l'entrée de sys.modules ne change rien, et le vrai psql + # tourne. Le compteur d'appels est là pour le prouver — sans lui, ce + # test passait au vert avec ZÉRO appel bouchonné. + import script.analyse as paquet + + sauvegarde = paquet.lib_analyse + paquet.lib_analyse = FauxLib + try: + etat = monitoring.neutralize_state("peu_importe") + finally: + paquet.lib_analyse = sauvegarde + for valeur in etat.values(): + self.assertIsNone(valeur) + self.assertEqual(len(appels), len(monitoring.NEUTRALIZE_SQL)) + + def test_a_live_payment_provider_is_an_error_not_a_warning(self): + rapport = monitoring.neutralize_report( + {"flag": 1, "cron_active": 0, "mail_server": 1, "payment_live": 2} + ) + ligne = [ + texte + for texte in rapport.splitlines() + if "payment" in texte or "paiement" in texte + ] + self.assertTrue(ligne) + self.assertTrue(ligne[0].startswith("❌")) + + +class TestTheChooserScreen(unittest.TestCase): + """L'écran ne fait que choisir — mais il doit choisir juste.""" + + def test_every_analysis_is_shown_even_when_it_cannot_run(self): + lignes = monitoring_tui.rows(monitoring.KIND_LIVE) + self.assertEqual(len(lignes), len(monitoring.ANALYSES)) + + def test_an_unusable_line_carries_its_reason(self): + for _key, _label, utilisable, raison in monitoring_tui.rows( + monitoring.KIND_LIVE + ): + if not utilisable: + self.assertTrue(raison.strip()) + + def test_a_usable_line_carries_no_excuse(self): + for _key, _label, utilisable, raison in monitoring_tui.rows( + monitoring.KIND_DATABASE + ): + if utilisable: + self.assertEqual(raison, "") + + def test_the_detail_pane_says_when_a_source_cannot_serve(self): + texte = monitoring_tui.detail( + monitoring.ANALYSES[0]["key"], monitoring.KIND_LIVE + ) + self.assertIn("✖", texte) + + def test_the_detail_pane_of_an_unknown_key_is_empty(self): + self.assertEqual( + monitoring_tui.detail("pas_une_analyse", monitoring.KIND_DATABASE), + "", + ) + + +class TestWhatAMigrationLeftBehind(unittest.TestCase): + """Le classement, et surtout ce qu'il refuse de taire.""" + + def test_broken_is_read_before_watch(self): + resultats = {c["key"]: 0 for c in residue.CONTROLES} + resultats["duplicate_index"] = 400 # watch + resultats["lang_active_null"] = 1 # broken + trouve, _ = residue.judge(resultats) + self.assertEqual(trouve[0][0]["key"], "lang_active_null") + + def test_a_check_that_could_not_run_is_not_a_check_that_found_nothing( + self, + ): + resultats = {c["key"]: 0 for c in residue.CONTROLES} + resultats["missing_pricelist"] = {"error": "relation absente"} + trouve, illisibles = residue.judge(resultats) + self.assertEqual(trouve, []) + self.assertEqual(len(illisibles), 1) + + def test_the_report_shows_the_unreadable_ones(self): + resultats = {c["key"]: 0 for c in residue.CONTROLES} + resultats["missing_pricelist"] = {"error": "relation absente"} + texte = residue.render("base", resultats, colour=False) + self.assertIn("❔", texte) + self.assertIn("relation absente", texte) + + def test_a_clean_database_states_what_it_could_not_see(self): + """Un vert qui ne dit pas sa portée se lit comme une garantie.""" + resultats = {c["key"]: 0 for c in residue.CONTROLES} + texte = residue.render("base", resultats, colour=False) + self.assertIn("✅", texte) + self.assertIn( + residue.t("This reads one database on its own — it cannot see"), + texte, + ) + + def test_every_finding_names_a_repair_or_says_there_is_none(self): + resultats = {c["key"]: 1 for c in residue.CONTROLES} + texte = residue.render("base", resultats, colour=False) + for controle in residue.CONTROLES: + attendu = controle["repair"] or residue.t("no repair tool yet") + self.assertIn(attendu, texte) + + def test_each_check_is_declared_completely(self): + cles = set() + for controle in residue.CONTROLES: + for champ in ("key", "title", "why", "sql", "gravity"): + self.assertTrue(controle.get(champ), controle["key"]) + self.assertIn(controle["gravity"], ("broken", "watch")) + self.assertNotIn(controle["key"], cles) + cles.add(controle["key"]) + + def test_each_query_reads_and_returns_one_number(self): + """Une seule colonne, un seul SELECT : `inspect` en fait un int.""" + for controle in residue.CONTROLES: + sql = " ".join(controle["sql"].split()) + self.assertTrue(sql.upper().startswith("SELECT"), controle["key"]) + for interdit in ("INSERT", "UPDATE", "DELETE", "DROP", "ALTER"): + self.assertNotIn(f" {interdit} ", f" {sql.upper()} ") + + def test_the_exit_code_separates_nothing_from_something(self): + vide = {c["key"]: 0 for c in residue.CONTROLES} + self.assertEqual(residue.judge(vide)[0], []) + plein = dict(vide, lang_active_null=3) + self.assertTrue(residue.judge(plein)[0]) + + +if __name__ == "__main__": + unittest.main() + + +class TestTheScreenAnswersKeys(unittest.TestCase): + """Presser les touches, et pas seulement lire les fonctions pures. + + `ListView` consomme Entrée pour émettre son propre `Selected` : la + liaison de l'application ne la voit jamais. L'écran ne répondait donc + pas à Entrée, et `rows()` comme `detail()` restaient parfaitement + justes — aucun test de fonction pure ne pouvait le montrer. + """ + + def setUp(self): + try: + import textual # noqa: F401 + except ImportError: + self.skipTest("Textual absent de cet interpréteur") + + def _presser(self, kind, touches): + import asyncio + + async def jouer(): + app = monitoring_tui.build_app(kind, "ma_base") + async with app.run_test() as pilot: + for touche in touches: + await pilot.press(touche) + await pilot.pause() + return app.return_value + + return asyncio.run(jouer()) + + def test_enter_chooses_the_highlighted_analysis(self): + self.assertEqual( + self._presser(monitoring.KIND_DATABASE, ["enter"]), + monitoring.ANALYSES[0]["key"], + ) + + def test_moving_down_then_enter_chooses_the_second(self): + self.assertEqual( + self._presser(monitoring.KIND_DATABASE, ["down", "enter"]), + monitoring.ANALYSES[1]["key"], + ) + + def test_enter_on_an_unusable_line_chooses_nothing(self): + """Et ne lève pas : un écran qui plante vaut moins qu'un refus.""" + self.assertIsNone(self._presser(monitoring.KIND_LIVE, ["enter"])) + + def test_q_gives_up(self): + self.assertIsNone(self._presser(monitoring.KIND_DATABASE, ["q"])) + + +class TestTheSourceMenuIsWrittenTwice(unittest.TestCase): + """Les numéros affichés mènent-ils où ils le disent ? + + `_monitoring_select_source` imprime « [2] Une sauvegarde .zip » d'un + côté et teste `answer == "2"` de l'autre. Rien ne relie les deux : + insérer « une base locale » en tête décale tout le reste à la main. + C'est le piège exact que `MenuCoherence` garde pour les autres menus, + et celui-ci n'entre pas dans son moule — il s'imprime, il ne se + déclare pas. + """ + + def setUp(self): + import re + from pathlib import Path + + todo = ( + Path(__file__).resolve().parent.parent + / "script" + / "todo" + / "todo.py" + ) + source = todo.read_text(encoding="utf-8") + debut = source.index("def _monitoring_select_source(self):") + fin = source.index("def _monitoring_live(self):", debut) + self.corps = source[debut:fin] + self.affiches = re.findall(r'print\(f"\[(\d+)\] \{t\(', self.corps) + self.branches = re.findall(r'if answer == "(\d+)":', self.corps) + + def test_the_menu_was_actually_parsed(self): + """Sur des listes vides, tout passe : mieux vaut tomber ici.""" + self.assertGreaterEqual(len(self.affiches), 4) + + def test_every_shown_entry_has_a_branch(self): + montres = [n for n in self.affiches if n != "0"] + self.assertEqual(sorted(montres), sorted(self.branches)) + + def test_the_numbering_is_contiguous_from_one(self): + montres = sorted(int(n) for n in self.affiches if n != "0") + self.assertEqual(montres, list(range(1, len(montres) + 1))) + + def test_a_local_database_is_offered_first(self): + """La provenance la plus directe, et l'ordre du menu d'à côté.""" + self.assertEqual(self.affiches[0], "1") + self.assertIn("A local database", self.corps.split("if answer")[0]) diff --git a/test/test_proxmox_form.py b/test/test_proxmox_form.py index a4decb3..f972c72 100644 --- a/test/test_proxmox_form.py +++ b/test/test_proxmox_form.py @@ -15,6 +15,7 @@ aucun hôte Proxmox n'est joint. import asyncio import sys +import os import unittest sys.argv = ["todo.py"] @@ -467,6 +468,373 @@ class TestCreerUnPont(unittest.TestCase): self.assertIn("vmbr9", vu["choix_avant"]) +@unittest.skipUnless(TEXTUAL, "Textual absent") +class TestLInterpretePython(unittest.TestCase): + """L'écran Proxmox n'offrait pas le choix, donc envoyait toujours + « automatique » — et comme mise n'est jamais installé d'office, c'était + pyenv, qui COMPILE Python. Rapporté sur une VM Arch : « il utilise le + tar.xz pour le compiler ».""" + + def _ecran(self, gestes=None, mise_arches=("amd64", "arm64")): + from script.todo.proxmox_deploy_form import run_proxmox_form + + ctx = contexte() + ctx["mise_arches"] = mise_arches + vu = {} + + async def scenario(): + from textual.widgets import SelectionList + + app = run_proxmox_form(ctx, run_app=False) + async with app.run_test(size=(200, 60)) as pilote: + await pilote.pause() + liste = app.query_one(SelectionList) + liste.select(liste.get_option_at_index(0).value) + await pilote.pause() + await pilote.pause() + if gestes: + await gestes(app, pilote) + vu["choix"] = app._python_provider() + app.action_deploy() + vu["spec"] = app.result or {} + + asyncio.run(scenario()) + return vu + + def test_mise_is_offered_by_default(self): + # Un CPython précompilé plutôt qu'une compilation de trois minutes. + self.assertEqual(self._ecran()["choix"], "mise") + + def test_the_choice_reaches_the_spec(self): + async def gestes(app, pilote): + list(app.query("#f_python RadioButton"))[1].value = True + await pilote.pause() + + vu = self._ecran(gestes) + self.assertEqual(vu["choix"], "pyenv") + self.assertEqual(vu["spec"].get("python_provider"), "pyenv") + + def test_an_arch_mise_does_not_serve_yields_nothing(self): + # « mise indisponible » ne veut pas dire « l'utilisateur exige + # pyenv » : un choix explicite écarterait le Python de la distro. + self.assertEqual(self._ecran(mise_arches=("s390x",))["choix"], "") + + +class TestLeDisquePromis(unittest.TestCase): + """Le plan annonçait « 25G » et « qm resize » recevait 20 G. + + La voie libvirt ajoute la marge d'ERPLibre à la taille créée ; celle de + Proxmox la perdait entre l'écran et la commande. La VM naissait cinq + gigaoctets trop petite pour ce qu'on venait de lui promettre.""" + + def _taille(self, install, cmd_vm=""): + import sys + + sys.argv = ["todo.py"] + from script.todo.todo import TODO + + todo = TODO.__new__(TODO) + vm = {"disk": "20G", "install_cmd": cmd_vm} + return todo._pve_disk_with_margin(vm, {"install": install}) + + def test_the_margin_reaches_the_created_disk(self): + self.assertEqual( + self._taille( + { + "branch": "develop", + "cmd": "make install_os && make install_odoo_18", + } + ), + "25G", + ) + + def test_nothing_to_install_means_no_margin(self): + self.assertEqual(self._taille(None), "20G") + + def test_a_hypervisor_profile_gets_no_margin(self): + # Elle est réservée au dépôt ERPLibre, qu'un Proxmox ne clonera pas. + self.assertEqual( + self._taille( + { + "branch": "develop", + "cmd": "./script/proxmox/install_proxmox.sh", + } + ), + "20G", + ) + + +class TestDeuxVmDuMemeNom(unittest.TestCase): + """Sur Proxmox, seul le VMID est unique : deux VM du même hôte peuvent + porter le même nom. « Changer l'état » les choisissait par NOM — cocher + l'une éteignait les deux.""" + + def test_selecting_one_twin_takes_only_that_one(self): + import sys + + sys.argv = ["todo.py"] + from script.todo.todo import TODO + + todo = TODO.__new__(TODO) + vms = [ + {"vmid": 100, "name": "jumeau", "status": "running"}, + {"vmid": 101, "name": "jumeau", "status": "running"}, + ] + rangs = [str(i) for i in range(1, len(vms) + 1)] + for choix, attendu in ( + ("1", [100]), + ("2", [101]), + ("1,2", [100, 101]), + ): + voulus = { + int(r) + for r in todo._parse_index_selection(choix, rangs) + if str(r).isdigit() + } + self.assertEqual( + [vm["vmid"] for i, vm in enumerate(vms, 1) if i in voulus], + attendu, + choix, + ) + + +class TestLEcranDUneVmProxmox(unittest.TestCase): + """« Console de l'hyperviseur » conseillait des commandes virsh sur une + machine qui n'a pas libvirt. + + Le tunnel lit le port VNC par « virsh vncdisplay » sur l'hyperviseur. Un + Proxmox VE n'a pas de libvirt : la commande échoue, et l'absence de port + était lue « écran fermé ». On imprimait alors « sudo virsh edit » — sur un + hôte où le binaire n'existe pas. Ce n'est pas un écran fermé, c'est la + mauvaise question : Proxmox sert son écran par un ticket, sur son + interface web.""" + + def _sortie(self, qm_present, port=0): + import contextlib + import io + import sys + + sys.argv = ["todo.py"] + from script.todo.todo import TODO + + todo = TODO.__new__(TODO) + todo._ssh_proxyjump = lambda nom: "pve9" + todo._qemu_vnc_port = staticmethod(lambda d, j="": port) + todo._hypervisor_is_proxmox = lambda jump: qm_present + tampon = io.StringIO() + with contextlib.redirect_stdout(tampon): + todo._qemu_console_tunnel("pve9+vm-a", "ssh_config") + return tampon.getvalue() + + def test_a_proxmox_host_is_never_told_to_run_virsh(self): + sortie = self._sortie(qm_present=True) + self.assertNotIn("virsh", sortie) + self.assertIn("qm terminal", sortie) + self.assertIn("8006", sortie, "l'interface web est le second chemin") + + def test_a_libvirt_host_keeps_its_repair_commands(self): + # La voie libvirt ne régresse pas : sans port, ses commandes de + # réparation restent la bonne réponse. + sortie = self._sortie(qm_present=False) + self.assertIn("virsh edit", sortie) + + def test_a_working_vnc_port_still_wins(self): + # La sonde ne doit pas s'exécuter quand il y a un port : ce serait un + # aller-retour ssh pour rien. + sortie = self._sortie(qm_present=True, port=5901) + self.assertIn("-L 5901:127.0.0.1:5901", sortie) + self.assertNotIn("qm terminal", sortie) + + +class TestUnSeulNomDansSshConfig(unittest.TestCase): + """L'entrée portait DEUX noms sur sa ligne « Host » : le nom chaîné + « hôte+vm » et le nom court. + + Rapporté : « Host erplibre-proxmox-9+erplibre-arch-latest + erplibre-arch-latest ». Le second est un doublon dès que le premier + suffit — ssh n'a besoin que d'un nom, et le doubler n'ajoute qu'une façon + de plus d'écrire la même adresse. + + Un seul, donc, et le bon : le court quand il est LIBRE, le chaîné quand + il désignerait une autre machine. « Pris » se juge sur le ProxyJump du + bloc, pas sur sa seule présence — sinon notre propre entrée, réécrite à + chaque déploiement, se prendrait pour une rivale et le nom basculerait + d'une fois sur l'autre.""" + + def setUp(self): + import sys + import tempfile + + sys.argv = ["todo.py"] + from script.todo.todo import TODO + + self.maison = tempfile.mkdtemp() + os.makedirs(os.path.join(self.maison, ".ssh")) + self._vrai_home = os.environ.get("HOME") + os.environ["HOME"] = self.maison + self.todo = TODO.__new__(TODO) + + def tearDown(self): + import shutil + + if self._vrai_home is not None: + os.environ["HOME"] = self._vrai_home + shutil.rmtree(self.maison, ignore_errors=True) + + def _ecrit(self, noms, rebond=""): + self.todo._write_ssh_config_entry( + noms, "erplibre", "10.10.10.150", proxy_jump=rebond or None + ) + + def _lignes_host(self): + with open( + os.path.join(self.maison, ".ssh/config"), encoding="utf-8" + ) as fh: + return [ + ligne.rstrip() for ligne in fh if ligne.startswith("Host ") + ] + + def _choisit(self, nom, locaux=(), rebond="pve9"): + return self.todo._pve_alias_names( + nom, f"pve9+{nom}", set(locaux), rebond + ) + + def test_a_free_name_is_written_alone(self): + noms, vole = self._choisit("erplibre-arch-latest") + self.assertEqual(noms, ["erplibre-arch-latest"]) + self.assertFalse(vole) + self._ecrit(noms, "pve9") + self.assertEqual(self._lignes_host(), ["Host erplibre-arch-latest"]) + + def test_redeploying_the_same_vm_keeps_the_same_name(self): + # Le piège du correctif : notre propre bloc déclare déjà le nom. + self._ecrit(["erplibre-arch-latest"], "pve9") + noms, vole = self._choisit("erplibre-arch-latest") + self.assertEqual(noms, ["erplibre-arch-latest"], "le nom a basculé") + self.assertFalse(vole) + + def test_a_local_vm_keeps_its_name(self): + # Vécu : « ssh » partait vers la machine locale du même nom. + noms, vole = self._choisit( + "erplibre-arch-latest", locaux=("erplibre-arch-latest",) + ) + self.assertEqual(noms, ["pve9+erplibre-arch-latest"]) + self.assertTrue(vole) + + def test_another_proxmox_host_keeps_its_name(self): + self._ecrit(["erplibre-ubuntu-2604"], "pve7") + noms, vole = self._choisit("erplibre-ubuntu-2604") + self.assertEqual(noms, ["pve9+erplibre-ubuntu-2604"]) + self.assertEqual(vole, "~/.ssh/config") + self._ecrit(noms, "pve9") + # Les deux machines cohabitent, chacune sous son nom. + self.assertEqual( + self._lignes_host(), + ["Host erplibre-ubuntu-2604", "Host pve9+erplibre-ubuntu-2604"], + ) + + def test_no_deploy_path_writes_two_names_anymore(self): + import re + from pathlib import Path as P + + src = P("script/todo/proxmox_menu.py").read_text(encoding="utf-8") + self.assertIsNone( + re.search(r"noms_alias\.append|noms\.append\(vm\[.name.\]\)", src), + "le second nom ne doit plus être ajouté", + ) + + +class TestLeGuideDeConnexion(unittest.TestCase): + """Une VM Proxmox n'avait AUCUN guide, quelle que soit sa distribution. + + Rapporté sur Arch : « pas l'écran de connexion, avec le guide qui dit de + prendre pacman, comme sur ubuntu ». La voie libvirt livre /etc/motd par le + « write_files » de cloud-init ; « qm set » n'offre pas cela. Le contenu + vient de la MÊME source (`guide_files`) et part par ssh. + """ + + def _ecrit(self, vm=None, install=None, distro="arch"): + import sys + + sys.argv = ["todo.py"] + from script.todo.todo import TODO + + todo = TODO.__new__(TODO) + vus = {} + todo._pve_ssh = lambda cible, remote, timeout=60: ( + vus.update(cible=cible, remote=remote) or (0, "") + ) + mod = todo._qemu_import_module() + vm = vm or { + "name": "vm-a", + "distro": distro, + "version": "latest", + "arch": "amd64", + "desktop": "", + "install_cmd": "", + } + spec = {"user": "erplibre", "install": install} + import contextlib + import io + + with contextlib.redirect_stdout(io.StringIO()): + ok = todo._pve_write_guide("hote+vm-a", vm, spec, mod) + vus["ok"] = ok + return vus + + def test_the_guide_goes_to_etc_motd_through_the_alias(self): + vus = self._ecrit() + self.assertTrue(vus["ok"]) + # Par l'ALIAS : lui seul porte le rebond vers le réseau interne. + self.assertEqual(vus["cible"], "hote+vm-a") + self.assertIn("/etc/motd", vus["remote"]) + self.assertIn("sudo tee", vus["remote"]) + + def test_an_arch_vm_is_told_about_pacman(self): + self.assertIn("pacman", self._ecrit(distro="arch")["remote"]) + + def test_a_debian_vm_is_told_about_apt(self): + self.assertIn("apt", self._ecrit(distro="debian")["remote"]) + + def test_without_erplibre_the_guide_does_not_promise_a_repository(self): + # Un guide qui annonce un dépôt absent est un guide qui mente. + sans = self._ecrit(install=None)["remote"] + self.assertNotIn("git/erplibre", sans) + + def test_with_erplibre_it_says_where_it_lives(self): + avec = self._ecrit( + install={ + "branch": "develop", + "cmd": "make install_os && make install_odoo_18", + } + )["remote"] + self.assertIn("git/erplibre", avec) + + def test_a_failure_is_said_not_swallowed(self): + import contextlib + import io + import sys + + sys.argv = ["todo.py"] + from script.todo.todo import TODO + + todo = TODO.__new__(TODO) + todo._pve_ssh = lambda *a, **k: (255, "no route") + mod = todo._qemu_import_module() + vm = { + "name": "vm-a", + "distro": "arch", + "version": "latest", + "arch": "amd64", + "desktop": "", + "install_cmd": "", + } + with contextlib.redirect_stdout(io.StringIO()) as sortie: + ok = todo._pve_write_guide("x", vm, {"user": "erplibre"}, mod) + self.assertFalse(ok) + self.assertIn("⚠", sortie.getvalue()) + + class TestLeSuivi(unittest.TestCase): """La case « Suivre l'installation » doit commander quelque chose. @@ -552,6 +920,11 @@ class TestLeSuivi(unittest.TestCase): nom ) todo._ssh_private_key = lambda k: None + # Hermétique : le choix du nom lit ~/.ssh/config et la liste des + # domaines locaux. Sans ces deux bouchons, le test dépendrait de + # la machine qui le lance. + todo._ssh_config_block = lambda nom: {} + todo._qemu_list_domains = lambda: [] todo._pve_guest_ip = lambda vmid, attente=120: "" todo._qemu_install_erplibre_monitored = lambda *a, **k: None todo._qemu_install_erplibre_vm = lambda *a, **k: None @@ -575,14 +948,64 @@ class TestLeSuivi(unittest.TestCase): return ecrites cmd = {"branch": "develop", "cmd": "make x", "label": "X"} - # Décoché mais une installation demandée : écrite quand même. - self.assertEqual(essai(False, cmd, False), ["vm-a"]) + # UN nom : le court, puisque rien ne le porte déjà. Le chaîné + # « hôte+vm » ne sort que lorsqu'il faut départager (voir + # TestUnSeulNomDansSshConfig). + self.assertEqual(essai(False, cmd, False), [["vm-a"]]) # Décoché, suivi demandé : le suivi entre aussi par le rebond. - self.assertEqual(essai(False, None, True), ["vm-a"]) + self.assertEqual(essai(False, None, True), [["vm-a"]]) # Décoché et rien à faire dans la VM : le choix est respecté. self.assertEqual(essai(False, None, False), []) # Coché : écrite, évidemment. - self.assertEqual(essai(True, None, False), ["vm-a"]) + self.assertEqual(essai(True, None, False), [["vm-a"]]) + + def test_a_local_vm_of_the_same_name_keeps_its_alias(self): + """Le piège qui a fait installer ERPLibre sur la MAUVAISE machine. + + Une VM déployée sur Proxmox sous un nom déjà porté par un domaine + LOCAL volait son alias ~/.ssh/config, et le suivi — qui ré-résolvait + l'adresse par virsh — partait installer sur la locale.""" + import contextlib + import io + import sys + + sys.argv = ["todo.py"] + from script.todo.todo import TODO + + todo = TODO.__new__(TODO) + ecrites = [] + todo._qemu_list_domains = lambda: ["vm-a"] + todo._write_ssh_config_entry = lambda noms, *a, **k: ecrites.append( + noms + ) + todo._ssh_private_key = lambda k: None + todo._pve_guest_ip = lambda vmid, attente=120: "" + vus = {} + todo._qemu_install_erplibre_monitored = ( + lambda noms, br, ipmap, cmd, **k: vus.update(ipmap=ipmap) + ) + spec = { + "host": {"target": "erplibre@pve1"}, + "vms": [ + { + "name": "vm-a", + "vmid": 100, + "ipconfig": "ip=10.10.10.150/24,gw=10.10.10.1", + "install_cmd": "", + } + ], + "add_ssh_config": True, + "user": "erplibre", + "install": None, + "monitor": True, + } + with contextlib.redirect_stdout(io.StringIO()) as sortie: + todo._pve_after_create(spec["host"], spec, ["vm-a"], "") + # SEUL le nom chaîné est écrit : l'alias court reste à la VM locale. + self.assertEqual(ecrites, [["pve1+vm-a"]]) + # Et le suivi passe par ce nom-là, jamais par « vm-a ». + self.assertEqual(vus["ipmap"], {"vm-a": "pve1+vm-a"}) + self.assertIn("pve1+vm-a", sortie.getvalue()) def test_ticked_with_an_install_opens_it(self): vus = self._apres_creation( diff --git a/test/test_qemu_deploy_form_install.py b/test/test_qemu_deploy_form_install.py index 6f78063..9a55b1d 100644 --- a/test/test_qemu_deploy_form_install.py +++ b/test/test_qemu_deploy_form_install.py @@ -16,7 +16,6 @@ actif ce qui ne fait rien. """ import asyncio -import re import sys import unittest from pathlib import Path @@ -37,39 +36,63 @@ except Exception: # pragma: no cover - dépend de l'environnement class TestSaPlace(unittest.TestCase): - """Lu dans la source : l'ordre du panneau est une décision, pas un hasard.""" + """L'ordre du panneau est une décision, pas un hasard. + + Relevé sur l'ÉCRAN MONTÉ et non dans la source : depuis que les réglages + du système invité viennent d'un socle partagé, le fichier ne contient + plus qu'un « yield from » là où le test cherchait un identifiant. Il + passait au vert sur un écran qu'il ne lisait plus — puis au rouge sans + qu'aucun ordre ait bougé. Le DOM, lui, dit ce que l'utilisateur voit.""" @classmethod def setUpClass(cls): cls.src = FORM.read_text(encoding="utf-8") + cls.ordre = ordre_du_panneau(contexte()) if TEXTUAL else [] - def _rang(self, motif): - m = re.search(re.escape(motif), self.src) - self.assertIsNotNone(m, motif) - return m.start() + def _rang(self, ident): + self.assertIn(ident, self.ordre, ident) + return self.ordre.index(ident) + @unittest.skipUnless(TEXTUAL, "Textual absent") def test_the_install_section_sits_under_the_vm_type(self): - self.assertLess( - self._rang('with RadioSet(id="f_type")'), - self._rang('id="t_install"'), - ) + self.assertLess(self._rang("f_type"), self._rang("t_install")) + @unittest.skipUnless(TEXTUAL, "Textual absent") def test_and_before_the_sections_it_commands(self): # Magasin d'applications, outils : ils dépendent d'elle, donc ils # viennent après. - for apres in ('id="t_store"', 'id="t_tools"'): - self.assertLess(self._rang('id="t_install"'), self._rang(apres)) + for apres in ("t_store", "t_tools"): + self.assertLess(self._rang("t_install"), self._rang(apres)) def test_the_checkbox_no_longer_claims_to_be_about_erplibre(self): - self.assertIn('t("Install software in the VM")', self.src) self.assertNotIn('t("Install ERPLibre")', self.src) + @unittest.skipUnless(TEXTUAL, "Textual absent") def test_the_monitor_left_the_install_section(self): # Rangé dedans, il se serait grisé avec elle — et décocher ERPLibre # avait déjà fait disparaître le tableau de bord une fois. - self.assertLess( - self._rang('id="t_deploy"'), self._rang('id="f_monitor"') + self.assertLess(self._rang("t_deploy"), self._rang("f_monitor")) + + +def ordre_du_panneau(ctx, forme="qemu"): + """Les identifiants du panneau gauche, dans l'ordre où ils s'affichent.""" + from script.todo.proxmox_deploy_form import run_proxmox_form + from script.todo.qemu_deploy_form import run_deploy_form + + vu = [] + + async def scenario(): + app = ( + run_deploy_form(ctx, run_app=False) + if forme == "qemu" + else run_proxmox_form(ctx, run_app=False) ) + async with app.run_test(size=(200, 60)) as pilote: + await pilote.pause() + vu.extend(w.id for w in app.query("#fields *") if w.id) + + asyncio.run(scenario()) + return vu def contexte(): diff --git a/test/test_qemu_deploy_monitor.py b/test/test_qemu_deploy_monitor.py index fb17d2c..9ea30b8 100644 --- a/test/test_qemu_deploy_monitor.py +++ b/test/test_qemu_deploy_monitor.py @@ -212,8 +212,13 @@ class TestLeJournal(unittest.TestCase): vus = [] vrai_launch, vrai_dir = mon._launch_one, mon.session_dir + # « **kw » et non une liste figée : chaque paramètre ajouté au + # lanceur (comme « pve ») casserait sinon ce test, qui ne vérifie + # pourtant que le prologue du journal. mon._launch_one = ( - lambda ip, cmd, log, name="", installs=True: vus.append(installs) + lambda ip, cmd, log, name="", installs=True, **kw: 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 diff --git a/test/test_qemu_monitor_pve.py b/test/test_qemu_monitor_pve.py index 30a4ea0..d69f5ef 100644 --- a/test/test_qemu_monitor_pve.py +++ b/test/test_qemu_monitor_pve.py @@ -131,6 +131,429 @@ class TestLAppel(unittest.TestCase): self.assertEqual(mon.read_pvestats(self._vms(1), now=1.0), {}) +class TestPasDePoubelleTropTot(unittest.TestCase): + """« Effacée » est un état TERMINAL : la ligne gèle sur 🗑 pour de bon. + + Rapporté sur une VM Arch déployée sur Proxmox : poubelle dès le premier + tour, alors que la VM venait de naître. Un relevé manquant ne prouve + rien — l'hôte peut être occupé, la VM en train de démarrer, le relevé en + cache d'avant sa création. + """ + + def setUp(self): + mon._PVE_CACHE.update({"at": 0.0, "stats": {}, "ok": False}) + + def _vm(self): + return { + "name": "vm-a", + "pve": { + "target": "hote", + "sudo": "sudo ", + "vmid": 105, + "addr": "10.10.10.155", + }, + } + + def test_a_silent_host_is_not_a_deletion(self): + # (relevés, succès) : la nuance est tout le correctif. + with mock.patch( + "script.proxmox.proxmox_deploy.run", return_value=(255, "timeout") + ): + stats, ok = mon.read_pvestats_detail([self._vm()], now=1.0) + self.assertEqual(stats, {}) + self.assertFalse(ok) + + def test_a_host_that_answers_says_so(self): + with mock.patch( + "script.proxmox.proxmox_deploy.run", return_value=(0, "[]") + ): + stats, ok = mon.read_pvestats_detail([self._vm()], now=1.0) + self.assertEqual(stats, {}) + self.assertTrue(ok, "l'hôte a répondu : la VM est vraiment absente") + + def test_the_cache_keeps_the_verdict_too(self): + with mock.patch( + "script.proxmox.proxmox_deploy.run", return_value=(0, "[]") + ) as appel: + mon.read_pvestats_detail([self._vm()], now=100.0) + _stats, ok = mon.read_pvestats_detail([self._vm()], now=100.5) + self.assertEqual(appel.call_count, 1) + self.assertTrue(ok, "le cache rendait « échec » à chaque tour suivant") + + def test_it_takes_several_absences_to_conclude(self): + # Trois, pas une : le nombre est explicite, pas enfoui. + self.assertGreaterEqual(mon.PVE_ABSENCES_AVANT_EFFACEE, 2) + + def test_the_short_reader_still_returns_only_stats(self): + # `read_pvestats` reste la forme courte pour la boucle des colonnes. + with mock.patch( + "script.proxmox.proxmox_deploy.run", return_value=(0, "[]") + ): + self.assertEqual(mon.read_pvestats([self._vm()], now=7.0), {}) + + +class TestLesAutresCheminsVersLaPoubelle(unittest.TestCase): + """Trouvés par un audit, pas à l'usage : trois autres façons d'arriver au + 🗑 sur un seul incident. « Effacée » gèle la ligne pour de bon, donc + chacune valait un correctif.""" + + def test_a_broken_pvesh_is_not_an_answer(self): + # La commande est une SUITE : son code de sortie est celui du DERNIER + # maillon. Un pvesh en panne rendait « l'hôte a répondu, la VM n'y est + # plus » — et trois tours plus tard, la poubelle. + self.assertFalse( + mon._resources_parsable("permission denied\n---ERPLIBRE-DU---\n") + ) + self.assertTrue(mon._resources_parsable("[]\n---ERPLIBRE-DU---\n")) + + def test_a_failing_host_never_counts_as_an_answer(self): + mon._PVE_CACHE.update({"at": 0.0, "stats": {}, "ok": False}) + vm = {"name": "vm-a", "pve": {"target": "h", "sudo": "", "vmid": 1}} + with mock.patch( + "script.proxmox.proxmox_deploy.run", + return_value=(0, "sudo: a password is required\n"), + ): + _stats, ok = mon.read_pvestats_detail([vm], now=10.0) + self.assertFalse(ok, "code 0 ne prouve pas que pvesh a parlé") + + def test_an_unknown_proxmox_status_is_not_a_deletion(self): + # Proxmox en a d'autres que les trois attendus : « prelaunch », + # « suspended », « internal-error », « hibernated ». + for etat in ("prelaunch", "suspended", "internal-error", "hibernated"): + self.assertIsNone( + mon.PVE_ETATS.get(etat), + "l'état n'est pas dans la table : le repli doit être « la VM" + " existe », pas « gone »", + ) + + def test_the_absence_counter_is_explicit(self): + self.assertGreaterEqual(mon.PVE_ABSENCES_AVANT_EFFACEE, 2) + + +class TestCeQueLaConfirmationPromet(unittest.TestCase): + """La confirmation de suppression annonçait un fichier qcow2 local à + TOUTE VM, Proxmox comprise. + + Sur une VM Proxmox ce fichier n'existe pas : son disque vit dans un + stockage que seul l'hôte connaît. La ligne désignait donc un chemin local + — au mieux inexistant, au pire celui d'une autre VM du même nom. C'est + exactement la peur qui avait fait remonter le nettoyage : « le nettoyage + risque d'effacer des VM en production ».""" + + def test_a_proxmox_vm_never_shows_a_local_path(self): + lignes = mon.delete_lines( + {"name": "vm-a", "pve": {"target": "pve9", "vmid": 101}} + ) + texte = " ".join(lignes) + self.assertNotIn("/var/lib/libvirt", texte) + self.assertNotIn("qcow2", texte) + + def test_it_names_the_host_and_the_vmid(self): + # Le nom ne suffit pas : deux VM peuvent le porter, seul le VMID est + # unique — et il faut savoir SUR QUELLE machine ça se passe. + texte = " ".join( + mon.delete_lines( + {"name": "vm-a", "pve": {"target": "pve9", "vmid": 101}} + ) + ) + self.assertIn("pve9", texte) + self.assertIn("101", texte) + self.assertIn("qm destroy 101", texte) + + def test_a_local_vm_still_names_its_file(self): + # La voie libvirt ne régresse pas : là, le fichier EST ce qu'on efface. + texte = " ".join(mon.delete_lines({"name": "vm-a"})) + self.assertIn("/var/lib/libvirt/images/vm-a.qcow2", texte) + + +class TestQuandLeVertRedescend(unittest.TestCase): + """Le 🟢 était acquis pour toujours. + + « Odoo ne redescend pas en cours d'install » : c'était faux. Le service + redémarre au moins une fois — systemd l'active à la fin du make — et il + lui arrive de mourir. La VM restait verte en servant plus rien, et c'est + précisément le moment où on veut le savoir.""" + + def _sonde(self, reponse): + return lambda _ip, _port: reponse + + def test_a_local_vm_that_stopped_answering_goes_back(self): + etat, sonde = mon.odoo_reading( + {"ip": "10.0.0.1"}, + {}, + deja_vert=True, + dernier=0.0, + maintenant=mon.ODOO_RECHECK + 1, + sonde=self._sonde(False), + ) + self.assertIs(etat, False) + self.assertTrue(sonde) + + def test_a_green_local_vm_is_not_probed_every_tick(self): + # La sonde est un connect() TCP par VM : à chaque tour (2 s) c'est + # cher pour une réponse qui ne bouge presque jamais. + etat, sonde = mon.odoo_reading( + {"ip": "10.0.0.1"}, + {}, + deja_vert=True, + dernier=100.0, + maintenant=101.0, + sonde=self._sonde(False), + ) + self.assertIsNone(etat, "rien de neuf ne doit être affirmé") + self.assertFalse(sonde) + + def test_a_red_local_vm_is_probed_every_tick(self): + _etat, sonde = mon.odoo_reading( + {"ip": "10.0.0.1"}, + {}, + deja_vert=False, + dernier=100.0, + maintenant=101.0, + sonde=self._sonde(True), + ) + self.assertTrue(sonde) + + def test_a_proxmox_vm_is_read_every_tick_for_free(self): + # Le port est testé DEPUIS L'HÔTE, avec les statistiques : d'ici, une + # adresse de pont interne ne répond jamais. + def lu(odoo): + return mon.odoo_reading( + {"pve": {"vmid": 1}}, + {"odoo": odoo}, + deja_vert=True, + dernier=0.0, + maintenant=1.0, + sonde=self._sonde(True), + )[0] + + self.assertIs(lu(True), True) + self.assertIs(lu(False), False) + + def test_a_silent_host_is_not_a_dead_odoo(self): + # Sans relevé, on ne sait RIEN : l'appelant garde le dernier état. + etat, _s = mon.odoo_reading( + {"pve": {"vmid": 1}}, + {}, + deja_vert=True, + dernier=0.0, + maintenant=1.0, + sonde=self._sonde(True), + ) + self.assertIsNone(etat) + + +class TestLaBonneMachine(unittest.TestCase): + """Le pire défaut de la série : l'installation partie AILLEURS. + + Vécu le 24 août 2026. Une VM déployée sur Proxmox sous le nom + « erplibre-ubuntu-2604 » — nom déjà porté par un domaine LOCAL. Le + lanceur détaché ré-résout l'adresse de la VM à chaque tour par virsh, qui + a répondu avec le domaine local : ERPLibre + Odoo se sont installés sur la + MAUVAISE machine, et le journal l'affichait sans que rien n'alerte + (« → 192.168.123.118 »). + + Pour une VM distante, l'alias ~/.ssh/config est la seule vérité : il + porte le rebond par l'hôte Proxmox. + """ + + def _wrapper(self, **kw): + """Le script du lanceur, capturé sans rien exécuter.""" + vus = {} + vrai = mon.subprocess.Popen + + class FauxPopen: + def __init__(self, argv, *a, **k): + vus["argv"] = argv + + mon.subprocess.Popen = FauxPopen + try: + mon._launch_one("cible", "echo bonjour", "/dev/null", "vm-a", **kw) + finally: + mon.subprocess.Popen = vrai + return vus["argv"][-1] + + def test_a_local_vm_still_gets_its_address_refreshed(self): + # Le bail change en cours de route (cloud-init renomme l'hôte) : la + # ré-résolution est indispensable pour une VM LOCALE. + script = self._wrapper(pve=False) + self.assertIn("virsh", script) + + def test_a_proxmox_vm_is_never_re_resolved(self): + # C'est le correctif : aucun appel à virsh, donc aucun risque de + # tomber sur un domaine local homonyme. + script = self._wrapper(pve=True) + self.assertNotIn("virsh", script) + + def test_the_target_stays_the_alias(self): + script = self._wrapper(pve=True) + self.assertIn("ip=cible", script) + + +class TestLeDisque(unittest.TestCase): + """La colonne Disque annonçait un disque PLEIN qui ne l'était pas. + + Rapporté : « 6.0G/6.0G » sur une VM dont l'invité disait « 845M utilisés + sur 5.8G ». La mesure venait de « du -sb », qui rend la taille APPARENTE : + un disque raw creux la donne entière. « du -sB1 » compte les blocs + réellement occupés — 1,2 Go, ce qui correspond. + """ + + def test_the_command_counts_real_blocks(self): + self.assertIn("du -sB1", mon.PVE_STATS_CMD) + self.assertNotIn("du -sb", mon.PVE_STATS_CMD) + + def test_the_measure_is_read_per_vmid(self): + texte = ( + '[{"vmid":101,"name":"vm-a","disk":0,"maxdisk":6442450944,' + '"mem":1,"maxmem":2,"diskwrite":0,"status":"running","uptime":1}]\n' + "---ERPLIBRE-DU---\n" + "1268518912\t/var/lib/vz/images/101/\n" + "4294967296\t/var/lib/vz/images/999/\n" + ) + rec = mon.parse_pvestats(texte)["vm-a"] + self.assertEqual(rec["disk_used"], 1268518912) + self.assertEqual(rec["disk_total"], 6442450944) + self.assertEqual( + mon.fmt_pair(rec["disk_used"], rec["disk_total"]), "1.2G/6.0G" + ) + + +class TestOuVaLaCommande(unittest.TestCase): + """Chaque action doit viser la BONNE machine. + + Une VM distante et un domaine local peuvent porter le même nom : « s » + ouvrait la locale, la console ouvrait la console de la locale, et la pause + suspendait la locale. Le VMID et le rebond sont les seules désignations + qui ne trompent pas. + """ + + LOCALE = {"name": "vm-a", "ip": "192.168.123.118"} + DISTANTE = { + "name": "vm-a", + "ip": "pve1+vm-a", + "pve": { + "target": "erplibre@pve1", + "sudo": "sudo ", + "jump": "", + "vmid": 101, + "addr": "10.10.10.151", + }, + } + + def test_ssh_to_a_local_vm_uses_its_address(self): + self.assertIn( + "erplibre@192.168.123.118", mon.vm_ssh_prefix(self.LOCALE) + ) + self.assertNotIn("-J", mon.vm_ssh_prefix(self.LOCALE)) + + def test_ssh_to_a_remote_vm_goes_through_the_jump(self): + cmd = mon.vm_ssh_prefix(self.DISTANTE) + self.assertIn("-J", cmd) + self.assertIn("erplibre@pve1", cmd) + self.assertIn("erplibre@10.10.10.151", cmd) + + def test_without_an_address_it_falls_back_to_the_alias(self): + vm = {"name": "vm-a", "ip": "pve1+vm-a", "pve": {"target": "pve1"}} + self.assertIn("erplibre@pve1+vm-a", mon.vm_ssh_prefix(vm)) + + def test_the_console_of_a_remote_vm_is_qm_terminal(self): + cmd = mon.pve_host_cmd( + self.DISTANTE["pve"], "qm terminal 101", tty=True + ) + self.assertIn("ssh -t", cmd) + self.assertIn("qm terminal 101", cmd) + self.assertIn("sudo", cmd) + # Et surtout PAS virsh, qui viserait le domaine local homonyme. + self.assertNotIn("virsh", cmd) + + def test_a_host_without_sudo_is_not_wrapped(self): + cmd = mon.pve_host_cmd({"target": "root@pve1", "sudo": ""}, "qm list") + self.assertNotIn("sh -c", cmd) + + +class TestLaColonneOdoo(unittest.TestCase): + """Le port 8069 se teste DEPUIS L'HÔTE, dans l'appel déjà payé. + + Sondé depuis le poste, il ne répond jamais pour une VM sur pont interne : + la colonne restait « — » quel que soit l'état d'Odoo. Un aller-retour ssh + de plus par tour aurait coûté une seconde ; celui des statistiques est + déjà là. + """ + + def test_the_probe_rides_along_the_stats_call(self): + cmd = mon.pve_stats_cmd(["10.10.10.150", "10.10.10.151"]) + self.assertIn("pvesh get /cluster/resources", cmd) + self.assertIn("/dev/tcp/$a/8069", cmd) + self.assertIn("10.10.10.151", cmd) + + def test_without_addresses_nothing_is_added(self): + self.assertEqual(mon.pve_stats_cmd([]), mon.PVE_STATS_CMD) + + def test_the_answer_is_read_per_address(self): + sortie = ( + "[]\n---ERPLIBRE-DU---\n---ERPLIBRE-ODOO---\n" + "ODOO 10.10.10.151\n" + ) + self.assertEqual(mon.parse_odoo_probe(sortie), {"10.10.10.151"}) + + def test_a_silent_port_yields_nothing(self): + self.assertEqual( + mon.parse_odoo_probe("[]\n---ERPLIBRE-DU---\n"), set() + ) + self.assertEqual(mon.parse_odoo_probe(""), set()) + + def test_the_du_block_is_not_mistaken_for_a_probe(self): + # Les deux blocs se suivent : le lecteur doit prendre le bon. + sortie = ( + "[]\n---ERPLIBRE-DU---\n1268518912\t/var/lib/vz/images/101/\n" + "---ERPLIBRE-ODOO---\nODOO 10.10.10.151\n" + ) + self.assertEqual(mon.parse_odoo_probe(sortie), {"10.10.10.151"}) + + +class TestLeWebEtLaSuppression(unittest.TestCase): + """Les deux dernières actions qui visaient la mauvaise machine.""" + + INFO = { + "target": "erplibre-proxmox-9", + "sudo": "sudo ", + "jump": "", + "vmid": 101, + "addr": "10.10.10.151", + } + + def test_the_web_view_tunnels_through_the_host(self): + argv = mon.web_tunnel_argv(self.INFO) + self.assertEqual(argv[-1], "erplibre-proxmox-9") + self.assertIn("-L", argv) + self.assertIn("18069:10.10.10.151:8069", argv) + # Pas de « -f » : le tunnel se referme par son PID, et « pkill -f » + # tuait le shell qui l'avait lancé. + self.assertNotIn("-f", argv) + + def test_a_local_vm_needs_no_tunnel(self): + self.assertIsNone(mon.web_tunnel_argv(None)) + self.assertIsNone(mon.web_tunnel_argv({"target": "pve1"})) + + def test_the_jump_of_the_host_is_chained(self): + argv = mon.web_tunnel_argv(dict(self.INFO, jump="rebond")) + self.assertIn("-J", argv) + self.assertIn("rebond", argv) + + def test_deleting_a_remote_vm_uses_its_vmid(self): + cmd = mon.delete_vm_cmd_pve(self.INFO) + self.assertIn("qm destroy 101", cmd) + self.assertIn("--purge", cmd) + # « virsh undefine » aurait effacé le domaine LOCAL homonyme. + self.assertNotIn("virsh", cmd) + + def test_deleting_a_local_vm_is_unchanged(self): + cmd = mon.delete_vm_cmd("vm-a", True) + self.assertIn("virsh undefine", cmd) + self.assertIn("/var/lib/libvirt/images/vm-a.qcow2", cmd) + + class TestLEtat(unittest.TestCase): """Une VM absente de « virsh list » passait pour EFFACÉE.""" diff --git a/test/test_todo_deploy_form_parity.py b/test/test_todo_deploy_form_parity.py new file mode 100644 index 0000000..3402929 --- /dev/null +++ b/test/test_todo_deploy_form_parity.py @@ -0,0 +1,346 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Les deux écrans de déploiement offrent les MÊMES réglages du système invité. + +Type de VM, production, magasin d'applications, outils de développement, +fuseau horaire, interpréteur Python : six réglages qui décrivent l'invité, pas +la machine qui le porte. Ils valent donc mot pour mot sur libvirt et sur +Proxmox VE. + +C'est ce qui avait dérivé. L'écran QEMU/KVM les portait tous les six, l'écran +Proxmox trois : une VM créée là-bas naissait serveur nu, sans outils, en UTC — +et rien ne le disait. La duplication était le mécanisme de la dérive, pas son +symptôme : chaque correctif se posait sur un seul des deux écrans. + +Ce fichier teste donc la PARITÉ elle-même, et pas six comportements. Ajouter +un réglage à un seul écran le fait échouer, quel que soit ce réglage.""" + +import asyncio +import sys +import unittest + +sys.argv = ["todo.py"] +from script.todo.todo import TODO # noqa: E402 + +try: + import textual # noqa: F401 + + TEXTUAL = True +except Exception: # pragma: no cover - dépend de l'environnement + TEXTUAL = False + +# Ce que porte le socle partagé. Les identifiants, parce qu'ils sont le +# contrat : c'est par eux que la spec est lue. +REGLAGES = ("f_type", "f_prod", "f_store", "f_tools", "f_tz", "f_python") + +CATALOGUE = [ + { + "distro": "ubuntu", + "version": "26.04", + "arch": "amd64", + "name": "erplibre-ubuntu-2604", + "ram": 2048, + "disk": "20G", + }, +] + + +def todo_muet(): + todo = TODO.__new__(TODO) + todo._qemu_list_domains = lambda: [] + todo._qemu_branch_list = lambda: ["develop", "master"] + return todo + + +def contexte_proxmox(todo): + """Le contexte de l'écran Proxmox, hôte simulé — aucune VM n'est créée.""" + return dict( + host={"target": "pve", "label": "pve"}, + node="pve", + arches=["amd64"], + native="amd64", + catalog={"amd64": list(CATALOGUE)}, + names=[], + vmids=[], + next_vmid=100, + storages=["local-lvm"], + storage="local-lvm", + storage_avail={"local-lvm": 500 << 30}, + bridges=["vmbr0"], + bridge="vmbr0", + ipconfig=lambda _p, _v: "ip=dhcp", + nameservers=["1.1.1.1"], + branches=["develop", "master"], + branch_current="develop", + install_profiles=[ + ("ERPLibre + Odoo 18", "make install_os && make install_odoo_18") + ], + distro_profiles={}, + ssh_key="~/.ssh/id_ed25519.pub", + cpu_presets=(1, 2, 4), + ram_presets=(2048, 4096), + disk_presets=(20, 40), + base_vcpus=2, + host_cpu=8, + free_ram=16000, + extra_disk_gb=5, + **todo._qemu_guest_context(), + ) + + +def releve(fabrique, ctx, gestes=None): + """Monte l'écran, choisit la première entrée, et relève ce qu'il porte.""" + vu = {} + + async def scenario(): + from textual.widgets import SelectionList + + app = fabrique(ctx, run_app=False) + async with app.run_test(size=(220, 70)) as pilote: + await pilote.pause() + liste = app.query_one(SelectionList) + liste.select(liste.get_option_at_index(0).value) + await pilote.pause() + await pilote.pause() + if gestes: + await gestes(app, pilote) + vu["ids"] = {w.id for w in app.query("#fields *") if w.id} + vu["spec"] = app._form_values() + vu["noms"] = [r["vm"]["name"] for r in app.rows] + vu["disques"] = [r["disk_gb"] for r in app.rows] + + asyncio.run(scenario()) + return vu + + +@unittest.skipUnless(TEXTUAL, "Textual absent") +class TestLesDeuxEcrans(unittest.TestCase): + @classmethod + def setUpClass(cls): + from script.todo.proxmox_deploy_form import run_proxmox_form + from script.todo.qemu_deploy_form import run_deploy_form + + todo = todo_muet() + mod = todo._qemu_import_module() + cls.qemu = releve(run_deploy_form, todo._qemu_form_context(mod)) + cls.pve = releve(run_proxmox_form, contexte_proxmox(todo)) + + def _porte(self, vu, ident): + """Un réglage est là s'il a son widget — les outils sont une case par + outil, donc un préfixe.""" + if ident == "f_tools": + return any(i.startswith("f_tool_") for i in vu["ids"]) + return ident in vu["ids"] + + def test_both_screens_offer_the_same_guest_settings(self): + for ident in REGLAGES: + with self.subTest(reglage=ident): + self.assertTrue(self._porte(self.qemu, ident), "QEMU/KVM") + self.assertTrue(self._porte(self.pve, ident), "Proxmox") + + def test_both_specs_carry_the_same_guest_keys(self): + from script.todo.deploy_form_extras import ExtrasMixin + + attendues = set(ExtrasMixin.extras_values(self._faux())) + for nom, vu in (("QEMU/KVM", self.qemu), ("Proxmox", self.pve)): + with self.subTest(ecran=nom): + self.assertTrue(attendues <= set(vu["spec"]), nom) + + def _faux(self): + """Un porteur du socle sans écran : la liste des clés ne dépend pas + des widgets, et c'est justement ce qu'on veut vérifier.""" + from script.todo.deploy_form_extras import ExtrasMixin + + class Vide(ExtrasMixin): + vms = () + rows = () + + def query_one(self, _s, *_a): + raise LookupError + + vide = Vide() + vide.extras_init({}) + return vide + + def test_both_screens_bind_the_same_catalog_shortcuts(self): + # « Versions principales » (F7) manquait à l'écran Proxmox, qui + # affiche pourtant le même catalogue, drapeau « default » compris. + from script.todo.proxmox_deploy_form import run_proxmox_form + from script.todo.qemu_deploy_form import run_deploy_form + + todo = todo_muet() + mod = todo._qemu_import_module() + touches = {} + for nom, app in ( + ("QEMU/KVM", run_deploy_form(todo._qemu_form_context(mod), False)), + ("Proxmox", run_proxmox_form(contexte_proxmox(todo), False)), + ): + touches[nom] = { + b[0]: b[1] for b in app.BINDINGS if b[1].startswith("select_") + } + self.assertEqual(touches["QEMU/KVM"], touches["Proxmox"]) + self.assertIn("select_main", touches["Proxmox"].values()) + + def test_parallelism_follows_the_host_on_both_screens(self): + # L'écran Proxmox plafonnait à quatre choix et en proposait UN, quel + # que soit le nombre de cœurs de l'hôte. + for nom, vu in (("QEMU/KVM", self.qemu), ("Proxmox", self.pve)): + with self.subTest(ecran=nom): + self.assertIn("f_par_all", vu["ids"], nom) + + +@unittest.skipUnless(TEXTUAL, "Textual absent") +class TestCeQueLeBureauChange(unittest.TestCase): + """Choisir un bureau doit se voir sur les DEUX écrans, et de la même + façon : le nom prend un suffixe, le disque grossit.""" + + def _bureau(self): + async def gestes(app, pilote): + from textual.widgets import Checkbox + + list(app.query("#f_type RadioButton"))[1].value = True + await pilote.pause() + app.query_one("#f_tool_pycharm", Checkbox).value = True + await pilote.pause() + await pilote.pause() + + return gestes + + def test_the_name_gets_the_desktop_suffix(self): + from script.todo.proxmox_deploy_form import run_proxmox_form + + todo = todo_muet() + vu = releve( + run_proxmox_form, contexte_proxmox(todo), gestes=self._bureau() + ) + # Sans suffixe, une VM graphique et sa jumelle serveur portent le même + # nom : la seconde est signalée « existe déjà » et ignorée. + self.assertTrue(vu["noms"][0].endswith("-gnome"), vu["noms"]) + + def test_the_disk_grows_by_the_desktop_and_the_tools(self): + from script.todo.proxmox_deploy_form import run_proxmox_form + + todo = todo_muet() + ctx = contexte_proxmox(todo) + nu = releve(run_proxmox_form, ctx) + avec = releve(run_proxmox_form, ctx, gestes=self._bureau()) + attendu = ctx["desktop_disk_gb"] + ctx["vm_tool_disk"]["pycharm"] + self.assertEqual( + avec["disques"][0] - nu["disques"][0], + attendu, + "le plan doit annoncer le surcoût AVANT de déployer", + ) + + def test_the_created_disk_matches_what_the_plan_announced(self): + # C'est le pont entre l'écran et « qm resize » : le plan annonçait + # 36 Go et la commande en demandait 20. + todo = todo_muet() + taille = todo._pve_disk_with_margin( + {"disk": "20G", "arch": "amd64", "distro": "ubuntu"}, + { + "install": { + "branch": "develop", + "cmd": "make install_os && make install_odoo_18", + }, + "desktop": "gnome", + "vm_tools": ("pycharm",), + }, + ) + self.assertEqual(taille, "36G") + + +class TestCeQueSontCesVm(unittest.TestCase): + """L'architecture d'une VM Proxmox venait de « virsh », qui ne connaît que + les domaines d'ICI. + + Elle décide des outils : une VM ARM prise pour x86_64 recevait Android + Studio, que Google ne publie pas pour elle — l'installation s'arrête, une + heure plus tard. Même famille que le « s » qui partait vers la machine + interne : on jugeait sur le nom.""" + + def _outils(self, meta): + vu = {} + todo = TODO.__new__(TODO) + todo._qemu_import_module = lambda: object() + todo._qemu_resolve_ips = lambda noms: {n: "10.0.0.1" for n in noms} + todo._qemu_vm_meta = lambda nom, mod: ("ubuntu", "26.04", "amd64") + todo._qemu_erplibre_remote_cmd = lambda *a, **kw: "" + + def capture(vms, branche, remote): + vu["archs"] = [v["arch"] for v in vms] + raise SystemExit + + import script.todo.qemu_install_monitor as mon + + vrai = mon.launch_installs + mon.launch_installs = capture + try: + todo._qemu_install_erplibre_monitored( + ["vm-arm"], "develop", {"vm-arm": "10.0.0.1"}, meta=meta + ) + except SystemExit: + pass + finally: + mon.launch_installs = vrai + return vu.get("archs") + + def test_the_caller_knows_better_than_virsh(self): + self.assertEqual( + self._outils({"vm-arm": ("ubuntu", "26.04", "arm64")}), ["arm64"] + ) + + def test_without_it_virsh_still_answers(self): + # La voie libvirt ne régresse pas : ses domaines sont bien ici. + self.assertEqual(self._outils(None), ["amd64"]) + + +class TestLeFuseauDUneVmProxmox(unittest.TestCase): + """« qm set » ne pose pas de fuseau : le cloud-init de Proxmox ne règle + que l'utilisateur, la clé et le réseau. Une VM créée là restait en UTC, et + on ne s'en aperçoit qu'aux horodatages.""" + + def _pose(self, spec): + vu = {} + todo = TODO.__new__(TODO) + todo._pve_ssh = lambda cible, cmd, **kw: ( + vu.setdefault("cmd", cmd), + (0, ""), + )[1] + todo._pve_set_timezone("vm", spec) + return vu.get("cmd", "") + + def test_the_timezone_reaches_the_vm(self): + self.assertIn( + "sudo timedatectl set-timezone America/Montreal", + self._pose({"timezone": "America/Montreal"}), + ) + + def test_a_free_value_is_quoted(self): + # La liste laisse la saisie libre : ce qui en sort part dans une + # commande distante, donc il est cité. + self.assertIn( + "'a b; rm -rf /'", self._pose({"timezone": "a b; rm -rf /"}) + ) + + def test_no_timezone_means_no_command(self): + self.assertEqual(self._pose({}), "") + + def test_the_prompt_path_still_gets_one(self): + # La voie par questions ne demande pas le fuseau ; sans défaut, elle + # laissait la VM en UTC alors que la voie libvirt reprend celui de + # l'hôte depuis toujours. + import re + from pathlib import Path + + src = Path("script/todo/proxmox_menu.py").read_text(encoding="utf-8") + bloc = src[src.index("def _pve_deploy_prompts") :] + bloc = bloc[: bloc.index("_pve_after_create")] + self.assertTrue( + re.search(r'"timezone":\s*self\._qemu_host_timezone\(\)', bloc), + "le spec des invites doit porter un fuseau", + ) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/test/test_todo_deploy_progress.py b/test/test_todo_deploy_progress.py index c22ca8c..b4f7f14 100644 --- a/test/test_todo_deploy_progress.py +++ b/test/test_todo_deploy_progress.py @@ -142,5 +142,80 @@ class TestLaToucheSsh(unittest.TestCase): self.assertEqual(vu["reussies"], []) +@unittest.skipUnless(TEXTUAL, "Textual absent") +class TestCeQuiSuit(unittest.TestCase): + """Rapporté : on attendait devant une fenêtre « terminée » sans savoir + que l'installation d'ERPLibre démarre en la quittant.""" + + def _sommaire(self, suite, attendre=3.0): + # Un travail qui DURE : sinon il finit avant le premier relevé, et le + # test ne prouve rien de l'avant/après. + jobs = [("1", "vm-a", ["bash", "-c", "sleep 2; echo ok"])] + vu = {} + + async def scenario(): + from textual.widgets import Static + + app = run_deploy_progress(jobs, 1, run_app=False, suite=suite) + async with app.run_test(size=(140, 30)) as pilote: + await pilote.pause() + vu["pendant"] = str(app.query_one("#summary", Static).render()) + await asyncio.sleep(attendre) + await pilote.pause() + vu["apres"] = str(app.query_one("#summary", Static).render()) + + asyncio.run(scenario()) + return vu + + def test_it_says_what_follows_once_everything_is_done(self): + vu = self._sommaire("Quitter (q) pour lancer l'installation") + self.assertNotIn("Quitter", vu["pendant"]) + self.assertIn("Quitter", vu["apres"]) + + def test_nothing_is_promised_when_nothing_follows(self): + vu = self._sommaire("") + self.assertNotIn("→", vu["apres"]) + + +@unittest.skipUnless(TEXTUAL, "Textual absent") +class TestLaCibleSsh(unittest.TestCase): + """« s » utilisait le NOM de la VM. Sur Proxmox, l'entrée ~/.ssh/config + n'existe pas encore à ce moment — et ce nom peut désigner une machine + LOCALE homonyme, qui s'ouvrait alors à sa place.""" + + def _lance(self, ssh_cmds): + import contextlib + import os + + jobs = [("1", "erplibre-ubuntu-2604", ["bash", "-c", "echo ok"])] + vu = [] + + async def scenario(): + app = run_deploy_progress( + jobs, 1, run_app=False, ssh_cmds=ssh_cmds + ) + async with app.run_test(size=(140, 30)) as pilote: + await pilote.pause() + await asyncio.sleep(1.2) + await pilote.pause() + vrai = os.system + os.system = vu.append + app.suspend = lambda: contextlib.nullcontext() + try: + app.action_ssh() + finally: + os.system = vrai + + asyncio.run(scenario()) + return vu[0] if vu else "" + + def test_the_given_command_wins(self): + cible = "ssh -J erplibre-proxmox-9 erplibre@10.10.10.151" + self.assertIn(cible, self._lance({"erplibre-ubuntu-2604": cible})) + + def test_without_one_it_falls_back_to_the_name(self): + self.assertIn("ssh erplibre-ubuntu-2604", self._lance(None)) + + if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/test/test_todo_menu.py b/test/test_todo_menu.py index 1852e20..f88855f 100644 --- a/test/test_todo_menu.py +++ b/test/test_todo_menu.py @@ -354,6 +354,32 @@ class TestQemuMenuNumbering(MenuCoherence, unittest.TestCase): } +class TestAnalyseMenuNumbering(MenuCoherence, unittest.TestCase): + """Le menu Analyse, qui n'avait aucun garde. + + Il en a pourtant besoin plus que les autres : ses entrées sont + regroupées en cinq sections, et une section ne consomme pas de numéro. + Ajouter « Instance » avant la dernière entrée décalait tout ce qui + suivait sans que rien ne proteste. + """ + + SOURCE = TODO_DIR / "todo.py" + ENTRY = "def prompt_execute_analyse(self):" + END = "def execute_analyse_module_package(self):" + MINIMUM = 5 + + EXPECTED = { + "Tables and database size": "execute_analyse_schema_size", + "Customised views": "execute_analyse_view_custom", + "Studio and hand-made": "execute_analyse_custom_field", + "Quality of a migration": "execute_analyse_migration_quality", + "Modules missing": "execute_analyse_module_package", + "Dependencies between": "execute_analyse_module_dependency", + "Attachment files missing": "execute_analyse_filestore", + "Monitoring - a backup": "execute_analyse_monitoring", + } + + class TestProxmoxMenuNumbering(MenuCoherence, unittest.TestCase): """Le menu Proxmox : dix-huit entrées, le même piège. diff --git a/test/test_todo_upgrade_repairs.py b/test/test_todo_upgrade_repairs.py new file mode 100644 index 0000000..b7ce67f --- /dev/null +++ b/test/test_todo_upgrade_repairs.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Deux réparations existaient et ne tournaient jamais. + +`fix_duplicate_index.py` et `restore_config_defaults.py` étaient écrits, +éprouvés sur copie, et absents du pilote. Chaque migration refabriquait +donc ses index redondants et reperdait sa liste de prix par défaut — +mesuré à l'identique sur DEUX chaînes 12 → 18 indépendantes : 414 index +et une liste manquante dans l'une comme dans l'autre. + +Un outil qui existe sans être appelé est le défaut le plus discret de ce +dépôt : rien n'échoue, rien ne l'écrit, et l'on croit le problème réglé +parce qu'on se souvient d'avoir écrit le correctif. Ce fichier vérifie +donc les DEUX choses — que les méthodes se comportent bien, et qu'elles +sont réellement appelées depuis la boucle des paliers. + +Les docstrings de ces méthodes CITENT `wait_at_error=False` pour +l'expliquer. Un test qui lirait le texte du fichier le trouverait là et +passerait au vert sans que l'argument soit passé nulle part. On lit donc +l'arbre syntaxique, jamais le texte. +""" + +import ast +import unittest +from pathlib import Path + +from script.todo.todo_upgrade import TodoUpgrade + +PILOTE = ( + Path(__file__).resolve().parent.parent + / "script" + / "todo" + / "todo_upgrade.py" +) + + +def arbre_du_pilote(): + """L'AST du pilote. `read_text` referme le fichier.""" + return ast.parse(PILOTE.read_text(encoding="utf-8")) + + +class FauxPilote: + """Un pilote qui note ce qu'on lui demande d'exécuter.""" + + def __init__(self): + self.appels = [] + + def todo_upgrade_execute(self, cmd, wait_at_error=True, **kwargs): + self.appels.append((cmd, wait_at_error)) + return 0 + + +class TestWhenTheIndexRepairRuns(unittest.TestCase): + def _lancer(self, version): + faux = FauxPilote() + TodoUpgrade.drop_duplicate_index(faux, "ma_base", version) + return faux.appels + + def test_it_stays_quiet_before_odoo_17(self): + """La convention n'a pas changé avant : il ne trouverait rien.""" + for version in (13, 14, 15, 16): + self.assertEqual(self._lancer(version), [], version) + + def test_it_runs_from_17_onward(self): + for version in (17, 18): + self.assertEqual(len(self._lancer(version)), 1, version) + + def test_it_repairs_rather_than_reports(self): + cmd, _ = self._lancer(18)[0] + self.assertIn("--apply", cmd) + self.assertIn("fix_duplicate_index.py", cmd) + self.assertIn("-d ma_base", cmd) + + def test_a_leftover_index_does_not_halt_the_migration(self): + """Avec --apply, le code 1 veut dire « il en reste », pas « échec ».""" + _, wait_at_error = self._lancer(18)[0] + self.assertFalse(wait_at_error) + + +class TestWhenTheDefaultsRepairRuns(unittest.TestCase): + def _lancer(self, dernier): + faux = FauxPilote() + TodoUpgrade.restore_config_defaults(faux, "ma_base", dernier) + return faux.appels + + def test_it_waits_for_the_last_step(self): + """Il charge le registre Odoo : six fois pour rien coûterait cher.""" + self.assertEqual(self._lancer(False), []) + + def test_it_runs_on_the_last_step(self): + self.assertEqual(len(self._lancer(True)), 1) + + def test_it_repairs_rather_than_reports(self): + cmd, wait_at_error = self._lancer(True)[0] + self.assertIn("--apply", cmd) + self.assertIn("restore_config_defaults.py", cmd) + self.assertFalse(wait_at_error) + + +class TestTheyAreActuallyCalled(unittest.TestCase): + """Le défaut visé : une méthode écrite que personne n'appelle.""" + + @classmethod + def setUpClass(cls): + cls.arbre = arbre_du_pilote() + cls.appels = {} + for noeud in ast.walk(cls.arbre): + if isinstance(noeud, ast.Call) and isinstance( + noeud.func, ast.Attribute + ): + cls.appels.setdefault(noeud.func.attr, []).append(noeud) + + def _boucle_des_paliers(self): + for noeud in ast.walk(self.arbre): + if ( + isinstance(noeud, ast.For) + and isinstance(noeud.iter, ast.Call) + and getattr(noeud.iter.func, "id", "") == "enumerate" + and [ + t.id + for t in ast.walk(noeud.target) + if isinstance(t, ast.Name) + ] + == ["index", "next_version"] + ): + return noeud + raise AssertionError("boucle des paliers introuvable") + + def test_both_repairs_are_called_from_the_tier_loop(self): + boucle = self._boucle_des_paliers() + for nom in ("drop_duplicate_index", "restore_config_defaults"): + dedans = [ + n + for n in self.appels.get(nom, []) + if boucle.lineno < n.lineno < boucle.end_lineno + ] + self.assertEqual(len(dedans), 1, nom) + + def test_the_index_repair_comes_after_the_cleanup(self): + """Le nettoyage supprime des colonnes, donc leurs index avec.""" + nettoyage = self.appels["prompt_database_cleanup"][-1].lineno + index = self.appels["drop_duplicate_index"][0].lineno + fumee = self.appels["prompt_smoke_public_url"][-1].lineno + self.assertLess(nettoyage, index) + self.assertLess(index, fumee) + + def test_the_defaults_repair_runs_before_the_smoke_test(self): + """Le test de fumée doit voir une base cohérente.""" + defauts = self.appels["restore_config_defaults"][0].lineno + fumee = self.appels["prompt_smoke_public_url"][-1].lineno + self.assertLess(defauts, fumee) + + def test_the_last_step_is_computed_from_the_loop_itself(self): + """`is_last` doit venir de la liste, pas d'un numéro écrit en dur.""" + appel = self.appels["restore_config_defaults"][0] + source = ast.dump(appel) + self.assertIn("lst_next_version", source) + self.assertNotIn("Constant(value=18)", source) + + +class TestTheDocstringTrapIsNotWhatWeRead(unittest.TestCase): + """La preuve que ce fichier ne se paie pas de mots. + + `wait_at_error=False` apparaît dans les docstrings des deux méthodes. + Si l'argument disparaissait des APPELS, un test textuel resterait vert. + """ + + def test_the_docstrings_do_mention_it(self): + for nom in ("drop_duplicate_index", "restore_config_defaults"): + methode = getattr(TodoUpgrade, nom) + self.assertIn("wait_at_error=False", methode.__doc__ or "", nom) + + def test_and_yet_the_keyword_is_really_passed(self): + arbre = arbre_du_pilote() + for nom in ("drop_duplicate_index", "restore_config_defaults"): + corps = None + for noeud in ast.walk(arbre): + if isinstance(noeud, ast.FunctionDef) and noeud.name == nom: + corps = noeud + self.assertIsNotNone(corps, nom) + # Le corps SANS sa docstring : c'est là que doit vivre l'argument. + sans_texte = [ + n + for n in corps.body + if not ( + isinstance(n, ast.Expr) + and isinstance(n.value, ast.Constant) + and isinstance(n.value.value, str) + ) + ] + trouve = False + for noeud in sans_texte: + for interne in ast.walk(noeud): + if isinstance(interne, ast.keyword) and ( + interne.arg == "wait_at_error" + ): + self.assertIs(interne.value.value, False, nom) + trouve = True + self.assertTrue(trouve, nom) + + +if __name__ == "__main__": + unittest.main()