diff --git a/doc/RUN.base.md b/doc/RUN.base.md index 042e8c8..d6787fa 100644 --- a/doc/RUN.base.md +++ b/doc/RUN.base.md @@ -37,6 +37,38 @@ Simplement ./run.sh ``` + +Without any argument, ERPLibre picks the database for you: with a single +one it starts on it, with several it shows a numbered menu. It only asks +when a real terminal is there — a service started by systemd behaves +exactly as before. + +Two options steer that, and neither reaches Odoo: + +| option | effect | +|---|---| +| `--auto-erplibre` | pick the database even when other arguments are given | +| `--no-cli-erplibre` | never show the menu; a lone database is still taken | + +Naming a database yourself — `-d`, `--database` — turns the whole thing +off, and so does a `db_name` set in the configuration file. + + +Sans aucun argument, ERPLibre choisit la base pour vous : s'il n'y en a +qu'une il démarre dessus, s'il y en a plusieurs il affiche un menu +numéroté. Il ne pose la question que devant un vrai terminal — un service +lancé par systemd se comporte exactement comme avant. + +Deux options le pilotent, et aucune n'arrive jusqu'à Odoo : + +| option | effet | +|---|---| +| `--auto-erplibre` | choisir la base même quand d'autres arguments sont donnés | +| `--no-cli-erplibre` | ne jamais afficher le menu ; une base seule est quand même retenue | + +Nommer soi-même une base — `-d`, `--database` — désactive tout, de même +qu'un `db_name` posé dans le fichier de configuration. + With arguments diff --git a/doc/RUN.fr.md b/doc/RUN.fr.md index f23b47d..4c1ceb7 100644 --- a/doc/RUN.fr.md +++ b/doc/RUN.fr.md @@ -17,6 +17,21 @@ Simplement ./run.sh ``` +Sans aucun argument, ERPLibre choisit la base pour vous : s'il n'y en a +qu'une il démarre dessus, s'il y en a plusieurs il affiche un menu +numéroté. Il ne pose la question que devant un vrai terminal — un service +lancé par systemd se comporte exactement comme avant. + +Deux options le pilotent, et aucune n'arrive jusqu'à Odoo : + +| option | effet | +|---|---| +| `--auto-erplibre` | choisir la base même quand d'autres arguments sont donnés | +| `--no-cli-erplibre` | ne jamais afficher le menu ; une base seule est quand même retenue | + +Nommer soi-même une base — `-d`, `--database` — désactive tout, de même +qu'un `db_name` posé dans le fichier de configuration. + Avec des arguments ```bash diff --git a/doc/RUN.md b/doc/RUN.md index 3567741..7a51151 100644 --- a/doc/RUN.md +++ b/doc/RUN.md @@ -17,6 +17,21 @@ Simply ./run.sh ``` +Without any argument, ERPLibre picks the database for you: with a single +one it starts on it, with several it shows a numbered menu. It only asks +when a real terminal is there — a service started by systemd behaves +exactly as before. + +Two options steer that, and neither reaches Odoo: + +| option | effect | +|---|---| +| `--auto-erplibre` | pick the database even when other arguments are given | +| `--no-cli-erplibre` | never show the menu; a lone database is still taken | + +Naming a database yourself — `-d`, `--database` — turns the whole thing +off, and so does a `db_name` set in the configuration file. + With arguments ```bash diff --git a/requirement/poetry.odoo15.0_python3.8.20.lock b/requirement/poetry.odoo15.0_python3.8.20.lock index c363fb7..e050fab 100644 --- a/requirement/poetry.odoo15.0_python3.8.20.lock +++ b/requirement/poetry.odoo15.0_python3.8.20.lock @@ -6322,6 +6322,17 @@ files = [ {file = "xlrd-1.2.0.tar.gz", hash = "sha256:546eb36cee8db40c3eaa46c351e67ffee6eeb5fa2650b71bc4c758a29a1b29b2"}, ] +[[package]] +name = "xlsxwriter" +version = "1.4.5" +description = "A Python module for creating Excel XLSX files." +optional = false +python-versions = "*" +files = [ + {file = "XlsxWriter-1.4.5-py2.py3-none-any.whl", hash = "sha256:f9335f1736e2c4fd80e940fe1b6d92d967bf454a1e5d639b0b7a4459ade790cc"}, + {file = "XlsxWriter-1.4.5.tar.gz", hash = "sha256:0956747859567ec01907e561a7d8413de18a7aae36860f979f9da52b9d58bc19"}, +] + [[package]] name = "xlwt" version = "1.3.0" @@ -6620,4 +6631,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = ">=3.8.20,<3.9" -content-hash = "660feb736151a54776a49b047a9dcf428145a2add97c89dddca2178be2c9d542" +content-hash = "b627b33459aa69db80c29e21da288e828d4f7f2e002bb14565d050010c1bb12b" diff --git a/requirement/pyproject.odoo15.0_python3.8.20.toml b/requirement/pyproject.odoo15.0_python3.8.20.toml index 196fac9..ac0a857 100644 --- a/requirement/pyproject.odoo15.0_python3.8.20.toml +++ b/requirement/pyproject.odoo15.0_python3.8.20.toml @@ -166,6 +166,7 @@ werkzeug = "0.16.1" wheel = "^0.45.1" win-unicode-console = "^0.5" xlrd = "1.2.0" +xlsxwriter = "1.4.5" xlwt = "1.3.0" xmlformatter = "^0.2.8" xmlrunner = "^1.7.7" diff --git a/run.sh b/run.sh index f715249..119c695 100755 --- a/run.sh +++ b/run.sh @@ -13,10 +13,55 @@ if [ ! -f "${CONFIG_PATH}" ]; then fi fi +# Deux options qui appartiennent à ERPLibre, retirées avant de passer la +# main : Odoo ne les connaît pas et mourrait sur « no such option ». +# --auto-erplibre arme le choix de la base à démarrer +# --no-cli-erplibre interdit le menu, sans interdire le choix +# Sans AUCUN argument, le choix s'arme de lui-même : c'est « make run », +# quelqu'un devant son terminal. Ce défaut-là reste timide — il exige un +# terminal des deux côtés — parce que systemd lance lui aussi run.sh sans +# argument, avec Restart=always. +EL_ARGS=() +EL_AUTO=0 +EL_AUTO_EXPLICITE=0 +EL_NO_CLI=0 +[ $# -eq 0 ] && EL_AUTO=1 +while [ $# -gt 0 ]; do + case "$1" in + --auto-erplibre) + EL_AUTO=1 + EL_AUTO_EXPLICITE=1 + ;; + --no-cli-erplibre) EL_NO_CLI=1 ;; + # Tout le reste passe tel quel, y compris les arguments vides et ceux + # qui portent des espaces : un tableau, jamais une chaîne reconstruite. + *) EL_ARGS+=("$1") ;; + esac + shift +done + +EL_DB=() +EL_LIB="./script/database/lib_db_select.sh" +if [ "${EL_AUTO}" = "1" ] && [ -f "${EL_LIB}" ]; then + # shellcheck source=script/database/lib_db_select.sh + . "${EL_LIB}" + EL_DB_NAME="$(el_db_select "${CONFIG_PATH}" "${EL_NO_CLI}" \ + "${EL_AUTO_EXPLICITE}" "${EL_ARGS[@]}")" + retSelect=$? + # 130 : on a renoncé au menu. Ne pas démarrer Odoo pour autant, et ne pas + # rendre 1 non plus — run.sh:26 réserve déjà 1 à « Odoo a échoué ». + if [ ${retSelect} -eq 130 ]; then + exit 130 + fi + if [ -n "${EL_DB_NAME}" ]; then + EL_DB=(-d "${EL_DB_NAME}") + fi +fi + if [ "$ODOO_MODE_TEST" = "true" ]; then - ./odoo_bin.sh -c "${CONFIG_PATH}" --limit-time-real 99999 --limit-time-cpu 99999 --limit-memory-hard=0 --log-level=test --test-enable --no-http --stop-after-init "$@" + ./odoo_bin.sh -c "${CONFIG_PATH}" --limit-time-real 99999 --limit-time-cpu 99999 --limit-memory-hard=0 --log-level=test --test-enable --no-http --stop-after-init "${EL_DB[@]}" "${EL_ARGS[@]}" else - ./odoo_bin.sh -c "${CONFIG_PATH}" --limit-time-real 99999 --limit-time-cpu 99999 --limit-memory-hard=0 "$@" + ./odoo_bin.sh -c "${CONFIG_PATH}" --limit-time-real 99999 --limit-time-cpu 99999 --limit-memory-hard=0 "${EL_DB[@]}" "${EL_ARGS[@]}" fi # When need more memory RAM for instance by force #python3 ./odoo/odoo-bin -c ${CONFIG_PATH} --limit-time-real 99999 --limit-time-cpu 99999 --limit-memory-soft=8589934592 --limit-memory-hard=10737418240 $@ diff --git a/script/analyse/check_module_dependency.py b/script/analyse/check_module_dependency.py new file mode 100755 index 0000000..8113cb1 --- /dev/null +++ b/script/analyse/check_module_dependency.py @@ -0,0 +1,426 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Les modules d'une base et ce qui les relie. + +`check_module_package` répond « quels modules manquent ». La question +d'à côté reste sans réponse : « celui-ci, puis-je le retirer, et +qu'entraîne-t-il avec lui ». On l'a payé cher au palier 17 → 18 — +`web_responsive` devenu incompatible avec `muk_web_theme` — où il a fallu +écrire la requête à la main pour savoir si quelqu'un en dépendait. + +Quatre relations, et non une seule liste : + + ce dont il dépend ce qu'il faut installer AVANT lui + ce qui en dépend ce qui casse si on le retire + ce qu'il entraîne la fermeture avale, dépendances des dépendances + ce qui tombe avec lui la fermeture amont + +La lecture n'écrit jamais : `check_module_package.run_psql` ouvre la +session en lecture seule côté SERVEUR, pas côté politesse. +""" + +import json +import os +import sys + +sys.path.append( + os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..")) +) + +from script.analyse import check_module_package as package # noqa: E402 + +try: + from script.todo.todo_i18n import t +except Exception: # pragma: no cover - repli si i18n indisponible + + def t(key: str) -> str: + return key + + +# Un graphe de modules Odoo est peu profond — `base` est à la racine et la +# plus longue chaîne réelle tient en une dizaine de sauts. La borne existe +# pour qu'un cycle en base, qui NE DEVRAIT pas exister, produise un +# résultat faux et visible plutôt qu'une boucle sans fin. +PROFONDEUR_MAX = 64 + +FILTRES = ( + "all", + "installed", + "pending", + "application", + "absent", + "broken", +) + +# Ce qui compte comme « en place ». « to upgrade » l'est : le module est +# chargé, il sera seulement rejoué. « to install » ne l'est pas encore. +ETATS_PRESENTS = ("installed", "to upgrade") + +# Les états qu'Odoo traverse et ne devrait pas garder. Un module qui y +# reste est une opération inachevée : mesuré sur test_neutralize_upgrade_13 +# en pleine migration, 22 modules figés en « to remove ». Ils ne sont plus +# tout à fait installés et pas encore partis, donc aucun autre filtre ne +# les montre pour ce qu'ils sont. +ETATS_EN_COURS = ("to install", "to upgrade", "to remove") + + +def reverse(depend): + """{module: [ce qui dépend de lui]} à partir de {module: [dépendances]}. + + Les modules cités comme dépendance mais absents de la base ont eux + aussi leur entrée : c'est précisément le cas qu'on cherche à voir. + """ + inverse = {} + for module, lst in depend.items(): + inverse.setdefault(module, []) + for nom in lst: + inverse.setdefault(nom, []).append(module) + return {nom: sorted(set(lst)) for nom, lst in inverse.items()} + + +def closure(nom, graphe): + """Tout ce que `nom` atteint dans `graphe`, lui-même exclu. + + En largeur et non en profondeur : la récursion sur un graphe cyclique + déborde la pile avant qu'on ait compris pourquoi. + """ + vus, bord, profondeur = set(), [nom], 0 + while bord and profondeur < PROFONDEUR_MAX: + suivant = [] + for courant in bord: + for voisin in graphe.get(courant, []): + if voisin not in vus and voisin != nom: + vus.add(voisin) + suivant.append(voisin) + bord = suivant + profondeur += 1 + return sorted(vus) + + +def present(etat): + return etat in ETATS_PRESENTS + + +def pending(etat): + """L'état est-il un passage, plutôt qu'une destination ?""" + return etat in ETATS_EN_COURS + + +def broken(recensement, depend): + """[(module, dépendance, raison)] — ce qui est installé sur du vide. + + Un module installé dont une dépendance ne l'est pas est un état + qu'Odoo ne produit jamais lui-même ; il vient d'une migration, d'une + désinstallation forcée ou d'un addons-path incomplet. Le charger + échoue, ou pire, réussit à moitié. + """ + lst = [] + for module, infos in sorted(recensement.items()): + if not present(infos[0]): + continue + for nom in sorted(set(depend.get(module, []))): + if nom not in recensement: + lst.append((module, nom, "unknown")) + elif not present(recensement[nom][0]): + lst.append((module, nom, recensement[nom][0])) + return lst + + +def survey(database): + """Tout ce que l'écran et le rapport ont besoin de savoir. + + Une seule lecture de la base pour les deux : deux assemblages + finiraient par montrer deux états contradictoires du même système. + """ + recensement = package.census(database) + if recensement is None: + return {"database": database, "unavailable": True} + depend = package.dependencies(database) + # Un module sans dépendance déclarée doit exister dans le graphe : + # sinon `closure` et l'écran le traitent comme inconnu. + depend = {nom: sorted(set(depend.get(nom, []))) for nom in recensement} + return { + "database": database, + "version": package.db_version(database), + "modules": recensement, + "depends": depend, + "dependents": reverse(depend), + "broken": broken(recensement, depend), + } + + +def verdict(rapport, nom): + infos = rapport["modules"].get(nom) + if infos is None: + return "unknown" + return package.ETAT_VERS_VERDICT.get(infos[0], "uninstallable") + + +def icon(rapport, nom): + return package.ICONE.get(verdict(rapport, nom), "·") + + +def counts(rapport, nom): + """(dépendances, dépendants, dépendants EN PLACE). + + Le troisième chiffre est celui qui décide d'un retrait. Sur une base + réelle, `web_responsive` a deux dépendants déclarés et zéro installé : + ne montrer que « 2 » ferait renoncer à un retrait sans danger. + """ + aval = rapport["dependents"].get(nom, []) + return ( + len(rapport["depends"].get(nom, [])), + len(aval), + sum(1 for autre in aval if present_of(rapport, autre)), + ) + + +def modules_cassants(rapport): + """Les modules qui apparaissent à gauche d'une dépendance cassée.""" + return {module for module, _dep, _raison in rapport.get("broken", [])} + + +def rows(rapport, filtre="all"): + """La liste de gauche : un module par ligne, déjà triée. + + Tri par nom. Trier par nombre de dépendants mettrait `base` en tête à + chaque fois — vrai, et sans intérêt : on cherche un module qu'on + nomme, pas le plus populaire. + """ + cassants = modules_cassants(rapport) + lst = [] + for nom in sorted(rapport["modules"]): + etat = rapport["modules"][nom][0] + if filtre == "installed" and not present(etat): + continue + # « non installés » exclut aussi les états de passage : un module + # en « to remove » EST encore installé, il s'en va. Le ranger avec + # ce qu'on pourrait installer était trompeur ; il a sa touche. + if filtre == "absent" and (present(etat) or pending(etat)): + continue + if filtre == "pending" and not pending(etat): + continue + # `application` est le drapeau « App » d'Odoo : ces modules-là + # sont les racines, ceux qu'on installe pour de vrai — les autres + # arrivent derrière eux. + if filtre == "application" and not rapport["modules"][nom][2]: + continue + if filtre == "broken" and nom not in cassants: + continue + amont, aval, vivants = counts(rapport, nom) + lst.append( + { + "name": nom, + "label": f"{icon(rapport, nom)} {nom}", + "detail": f"{amont}↓ {vivants}/{aval}↑", + "broken": nom in cassants, + } + ) + return lst + + +DETAILS = ("depends", "dependents", "pulls", "falls") + +TITRE_DETAIL = { + "depends": "What it needs, directly", + "dependents": "What needs it, directly", + "pulls": "Everything it pulls in", + "falls": "Everything that falls with it", +} + + +def listing(rapport, nom, mode): + """Les noms à montrer pour ce mode, dans l'ordre d'affichage.""" + if mode == "depends": + return rapport["depends"].get(nom, []) + if mode == "dependents": + return rapport["dependents"].get(nom, []) + if mode == "pulls": + return closure(nom, rapport["depends"]) + if mode == "falls": + return closure(nom, rapport["dependents"]) + return [] + + +def paint(texte, genre, colour): + from script.todo import migration_status as status + + return status.paint(texte, genre, colour) + + +def pane_text(rapport, nom, mode=None, colour=False, limit=0): + """Le panneau de droite pour le module choisi.""" + if not nom: + return t("Nothing to show yet.") + infos = rapport["modules"].get(nom) + if infos is None: + return f"❌ {nom} : {t('unknown to this database')}" + etat, resume, application, auteur = infos + lignes = [ + paint(f"{icon(rapport, nom)} {nom}", "step", colour), + f" {resume or ''}".rstrip(), + "", + f" {t('state'):<22} {etat}", + f" {t('author'):<22} {auteur or '—'}", + f" {t('application'):<22} {t('yes') if application else t('no')}", + ] + amont, aval, vivants = counts(rapport, nom) + lignes += [ + f" {t('depends on'):<22} {amont}", + f" {t('needed by'):<22} {aval}" + f" · {vivants} {t('of them installed')}", + ] + if present(etat) and aval and not vivants: + # La réponse à « puis-je le retirer », écrite plutôt que déduite + # de deux chiffres qu'il faudrait comparer soi-même. + lignes.append( + " " + paint(t("nothing installed depends on it"), "ok", colour) + ) + lignes.append("") + if mode in DETAILS: + lst = listing(rapport, nom, mode) + lignes.append(paint(f" {t(TITRE_DETAIL[mode])}", "step", colour)) + if not lst: + lignes.append(f" {t('nothing')}") + for autre in lst[: limit or None]: + marque = "" if present_of(rapport, autre) else f" ← {t('absent')}" + genre = "ok" if present_of(rapport, autre) else "warn" + lignes.append( + f" {icon(rapport, autre)} " + + paint(f"{autre}{marque}", genre, colour) + ) + if limit and len(lst) > limit: + lignes.append(f" … {len(lst) - limit} {t('more')}") + else: + lignes.append(f" {t('press d to walk the dependencies')}") + casses = [ + (dep, raison) + for module, dep, raison in rapport.get("broken", []) + if module == nom + ] + if casses: + lignes.append("") + lignes.append( + paint( + f" ❌ {t('installed on missing dependencies')}", + "fail", + colour, + ) + ) + for dep, raison in casses: + lignes.append(f" {dep} ({raison})") + return "\n".join(lignes) + + +def present_of(rapport, nom): + infos = rapport["modules"].get(nom) + return bool(infos) and present(infos[0]) + + +def head_text(rapport): + installes = sum( + 1 for infos in rapport["modules"].values() if present(infos[0]) + ) + total = len(rapport["modules"]) + casses = len(rapport.get("broken", [])) + en_cours = sum( + 1 for infos in rapport["modules"].values() if pending(infos[0]) + ) + texte = ( + f"📦 {rapport['database']}" + f" ({t('Odoo')} {rapport.get('version') or '?'})" + f" · {installes}/{total} {t('modules installed')}" + ) + if en_cours: + # Annoncé dans l'en-tête et non seulement derrière une touche : + # une opération inachevée explique des symptômes qu'on chercherait + # ailleurs pendant une heure. + texte += f" · ⏳ {en_cours} {t('in progress')}" + if casses: + texte += f" · ❌ {casses} {t('broken dependency(ies)')}" + return texte + + +def render_text(rapport, limit=0, cap=0): + """Le rapport en clair, pour un terminal qui n'ouvre pas d'écran. + + `cap` borne le NOMBRE DE MODULES, `limit` la longueur de chaque liste + de dépendances. Une base porte trois mille modules : sans borne, le + repli déverse six mille lignes dans un menu, ce qui n'est pas un + repli mais une seconde panne. + """ + if rapport.get("unavailable"): + return [f"❌ {t('Cannot read the database: ')}{rapport['database']}"] + lignes = [head_text(rapport), ""] + lst_row = rows(rapport) + for row in lst_row[: cap or None]: + nom = row["name"] + amont = rapport["depends"].get(nom, []) + lignes.append(f"{row['label']:<44} {row['detail']}") + if amont: + lignes.append(f" → {', '.join(amont[: limit or None])}") + if cap and len(lst_row) > cap: + lignes.append(f" … {len(lst_row) - cap} {t('more')}") + lst_en_cours = [ + nom + for nom, infos in sorted(rapport["modules"].items()) + if pending(infos[0]) + ] + if lst_en_cours: + lignes.append("") + lignes.append(f"⏳ {t('left in a transient state')}") + for nom in lst_en_cours[: limit or None]: + lignes.append(f" {nom} ({rapport['modules'][nom][0]})") + if limit and len(lst_en_cours) > limit: + lignes.append(f" … {len(lst_en_cours) - limit} {t('more')}") + if rapport.get("broken"): + lignes.append("") + lignes.append(f"❌ {t('installed on missing dependencies')}") + for module, dep, raison in rapport["broken"]: + lignes.append(f" {module} → {dep} ({raison})") + return lignes + + +def main(argv=None): + import argparse + + parser = argparse.ArgumentParser( + description=( + "List every module of a database and how they depend on one" + " another. Read-only." + ) + ) + parser.add_argument("-d", "--database", required=True) + parser.add_argument( + "--limit", type=int, default=0, help="cap long lists (0 = no cap)" + ) + parser.add_argument("--json", action="store_true", help="machine output") + parser.add_argument( + "--no-tui", action="store_true", help="never open the screen" + ) + config = parser.parse_args(argv) + + rapport = survey(config.database) + if rapport.get("unavailable"): + print(f"❌ {t('Cannot read the database: ')}{config.database}") + return 2 + if config.json: + print( + json.dumps(rapport, indent=2, sort_keys=True, ensure_ascii=False) + ) + return 1 if rapport["broken"] else 0 + if not config.no_tui: + try: + from script.analyse.check_module_dependency_tui import run_tui + except Exception: + run_tui = None + if run_tui and run_tui(rapport): + return 1 if rapport["broken"] else 0 + print("\n".join(render_text(rapport, limit=config.limit))) + return 1 if rapport["broken"] else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/analyse/check_module_dependency_tui.py b/script/analyse/check_module_dependency_tui.py new file mode 100644 index 0000000..37c0f8a --- /dev/null +++ b/script/analyse/check_module_dependency_tui.py @@ -0,0 +1,344 @@ +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Les modules d'une base et leurs dépendances, en plein écran. + +Une base porte trois à six cents modules. Le rapport texte les liste tous +et la question qu'on se pose — « celui-ci, qui en dépend » — se trouve +quelque part au milieu de plusieurs milliers de lignes. + +Les données viennent de `check_module_dependency`, comme le rapport +texte : un seul assemblage, donc une seule vérité sur la même base. +""" + +import os +import sys + +sys.path.append( + os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..")) +) + +from script.analyse import check_module_dependency as dependency # noqa: E402 + +try: + from script.todo.todo_i18n import t +except Exception: # pragma: no cover - repli si i18n indisponible + + def t(key: str) -> str: + return key + + +CSS = """ +Screen { layout: vertical; } +#head { height: 3; padding: 0 1; background: $panel; color: $text; } +#body { height: 1fr; } +#left { width: 46; border-right: solid $accent; } +#pane { width: 1fr; padding: 0 1; } +#find { display: none; } +#find.visible { display: block; } +""" + + +def filter_label(filtre): + return { + "all": t("all modules"), + "installed": t("installed only"), + "pending": t("in progress"), + "application": t("apps only"), + "absent": t("not installed"), + "broken": t("broken dependencies"), + }.get(filtre, filtre) + + +def mode_label(mode): + """Le nom du mode courant, pour le sous-titre. + + Un panneau qui change sans dire pourquoi se lit comme un écran cassé. + """ + if mode is None: + return t("summary") + return t(dependency.TITRE_DETAIL.get(mode, mode)) + + +def subtitle(mode, filtre, motif): + morceaux = [mode_label(mode), filter_label(filtre)] + if motif: + morceaux.append(f"« {motif} »") + return " · ".join(morceaux) + + +def matching(lst_row, motif): + """Les lignes dont le nom contient `motif`, sans égard à la casse.""" + if not motif: + return lst_row + bas = motif.lower() + return [row for row in lst_row if bas in row["name"].lower()] + + +def next_mode(mode): + """Le mode suivant : résumé → les quatre relations → résumé.""" + suite = (None,) + dependency.DETAILS + courant = mode if mode in suite else None + return suite[(suite.index(courant) + 1) % len(suite)] + + +def toggle_filter(courant, cible): + """Aller à `cible` — ou en revenir si l'on y est déjà. + + Une touche qui ne fait qu'ALLER piège : arrivé sur « en cours », il + faudrait se souvenir de quelle autre touche ramène à la liste + entière. La même touche fait donc l'aller et le retour. + """ + return "all" if courant == cible else cible + + +def next_filter(filtre): + """Le filtre suivant, en boucle.""" + suite = dependency.FILTRES + index = suite.index(filtre) if filtre in suite else 0 + return suite[(index + 1) % len(suite)] + + +def current_name(lst_row, index): + """Le module sous le curseur, ou None si la liste est vide. + + `index` peut dépasser : la table garde son curseur d'avant quand la + liste raccourcit, et lire hors bornes ferait tomber l'écran sur une + IndexError au moment précis où l'on filtre. + """ + if not lst_row or index is None or not 0 <= index < len(lst_row): + return None + return lst_row[index]["name"] + + +def cursor_for(lst_row, garde): + """Où replacer le curseur pour retrouver `garde`. None si parti. + + Changer de filtre ramenait le curseur en tête, donc on perdait le + module qu'on lisait — c'est-à-dire la raison même du filtre. + """ + if not garde: + return None + for index, row in enumerate(lst_row): + if row["name"] == garde: + return index + return None + + +def populate(table, lst_row, garde=None): + """Remplir la table, puis y remettre le curseur sur `garde`. + + Prend la table plutôt que l'App : rien ici ne dépend de Textual sauf + trois appels de méthode, donc un faux objet suffit à l'éprouver. + """ + table.clear() + for row in lst_row: + table.add_row(row["label"][:40], row["detail"]) + place = cursor_for(lst_row, garde) + if place is not None: + table.move_cursor(row=place) + return place + + +def hide_find(champ, table): + """Refermer la recherche et rendre le clavier à la liste. + + Le `focus()` explicite n'est pas une précaution : sans lui, Textual + donne le clavier au premier widget focalisable, et c'est le champ de + recherche — « display: none » ne le retire pas de ce choix-là. On + ouvrait donc l'écran en tapant dans une boîte invisible, et « d » ne + faisait rien. (La TABULATION, elle, saute bien un widget caché : + mesuré, d'où l'absence de `can_focus` ici — une ligne qu'on ne peut + pas faire échouer n'a rien à faire dans le fichier.) + + Ne vide PAS le champ : l'appelant décide s'il efface le motif, et + c'est cet effacement qui redéclenche le filtrage. + """ + champ.remove_class("visible") + table.focus() + + +def build_app(rapport): + """Textual est importé ICI : le module reste testable sans lui.""" + from rich.text import Text + from textual import on + from textual.app import App, ComposeResult + from textual.containers import Horizontal, VerticalScroll + from textual.widgets import DataTable, Footer, Header, Input, Static + + class DependencyApp(App): + CSS = globals()["CSS"] + BINDINGS = [ + ("q", "quit", t("Quit")), + ("escape", "leave", t("Quit")), + ("d", "cycle_detail", t("Dependencies")), + ("f", "cycle_filter", t("Filter")), + ("i", "set_filter('installed')", t("Installed")), + ("u", "set_filter('pending')", t("In progress")), + ("t", "set_filter('application')", t("Apps")), + ("slash", "find", t("Search")), + ] + + def __init__(self, rapport): + super().__init__() + self.rapport = rapport + self.mode = None + self.filtre = "all" + self.motif = "" + self.lst_row = [] + + def compose(self) -> ComposeResult: + yield Header() + yield Static("", id="head") + with Horizontal(id="body"): + yield DataTable(id="left", cursor_type="row") + with VerticalScroll(id="pane"): + yield Static("", id="content") + yield Input(placeholder=t("module name…"), id="find") + yield Footer() + + def on_mount(self): + self.title = t("Modules and their dependencies") + table = self.query_one("#left", DataTable) + table.add_columns(t("module"), "↓↑") + hide_find(self.query_one("#find", Input), table) + self.query_one("#head", Static).update( + dependency.head_text(self.rapport) + ) + self._fill() + + def _fill(self): + """Reconstruire la liste, en gardant le module sous le curseur. + + Sans cela, changer de filtre ramène le curseur en tête et l'on + perd le module qu'on était en train de lire — c'est-à-dire la + raison pour laquelle on a filtré. + """ + garde = self._current_name() + self.lst_row = matching( + dependency.rows(self.rapport, self.filtre), self.motif + ) + populate(self.query_one("#left", DataTable), self.lst_row, garde) + self._show() + + def _current_name(self): + table = self.query_one("#left", DataTable) + return current_name(self.lst_row, table.cursor_row) + + def _show(self): + self.sub_title = subtitle(self.mode, self.filtre, self.motif) + self.query_one("#content", Static).update( + Text.from_ansi( + dependency.pane_text( + self.rapport, + self._current_name(), + mode=self.mode, + colour=True, + ) + ) + ) + + def action_cycle_detail(self): + """Parcourir les quatre relations, puis revenir au résumé. + + « ce dont il dépend » et « ce qui en dépend » répondent à deux + questions opposées, et les fermetures disent l'ampleur réelle : + retirer un module en entraîne parfois trente. + """ + self.mode = next_mode(self.mode) + self._show() + + def action_cycle_filter(self): + self.filtre = next_filter(self.filtre) + self._fill() + + def action_set_filter(self, cible): + """Sauter droit à un filtre. `f` reste là pour les parcourir.""" + self.filtre = toggle_filter(self.filtre, cible) + self._fill() + + def action_find(self): + champ = self.query_one("#find", Input) + champ.add_class("visible") + champ.focus() + + def action_leave(self): + """Échap referme la recherche ; il ne quitte que sinon. + + Quitter parce qu'on renonce à une recherche serait une + surprise coûteuse : on a parfois filtré trois mille modules + pour arriver là. + """ + champ = self.query_one("#find", Input) + if not champ.has_class("visible"): + self.exit() + return + # Vider le champ suffit à tout refaire : l'événement + # `Input.Changed` remet le motif à zéro et reconstruit la + # liste. Le refaire ici à la main donnerait deux chemins pour + # le même état, dont un seul serait jamais éprouvé. + champ.value = "" + hide_find(champ, self.query_one("#left", DataTable)) + + @on(Input.Changed, "#find") + def _find_changed(self, event): + self.motif = event.value.strip() + self._fill() + + @on(Input.Submitted, "#find") + def _find_submitted(self, event): + # Le champ reste VISIBLE : il porte le motif en cours, et le + # cacher laisserait une liste filtrée sans dire par quoi. + self.query_one("#left", DataTable).focus() + + @on(DataTable.RowHighlighted, "#left") + def _row_changed(self, event): + self._show() + + return DependencyApp(rapport) + + +def in_event_loop(): + """Une boucle asyncio tourne-t-elle déjà dans CE processus ?""" + import asyncio + + try: + asyncio.get_running_loop() + except RuntimeError: + return False + return True + + +def run_tui(rapport, run_app=True): + """Ouvrir l'écran. False si l'on n'a pas pu — et alors on DIT pourquoi.""" + if not rapport or rapport.get("unavailable"): + return False + if not rapport.get("modules"): + return False + if not sys.stdout.isatty(): + print(f"ℹ️ {t('Not a terminal: showing the text report instead.')}") + return False + try: + from script.todo import textual_setup + except Exception: + textual_setup = None + if textual_setup and not textual_setup.ensure(): + return False + try: + app = build_app(rapport) + except ImportError: + print( + f"ℹ️ {t('Textual is missing from this interpreter:')}" + f" {sys.executable}" + ) + return False + if not run_app: + return app + if in_event_loop(): + print( + f"ℹ️ {t('Already inside a running screen: open it in its own')}" + f" {t('process instead.')}" + ) + return False + app.run() + return True diff --git a/script/database/lib_db_select.sh b/script/database/lib_db_select.sh new file mode 100644 index 0000000..6b56c56 --- /dev/null +++ b/script/database/lib_db_select.sh @@ -0,0 +1,205 @@ +#!/usr/bin/env bash +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +# +# Bibliothèque SOURÇABLE : quelle base de données démarrer. +# +# « ./run.sh » sans argument démarrait Odoo sans base, et l'on choisissait +# dans le gestionnaire web. Quand il n'y a qu'une base, la question ne se +# pose pas ; quand il y en a quinze, un menu au terminal vaut mieux qu'une +# page web. +# +# TOUT ce qui s'affiche part sur stderr. Sur stdout, UNIQUEMENT le nom +# retenu : l'appelant lit `el_db_select` par substitution de commande, et +# un menu écrit sur stdout deviendrait une partie du nom de la base. +# +# Aucun `set` ici : une bibliothèque sourcée impose ses options à son hôte, +# et run.sh lit `$ODOO_MODE_TEST` sans valeur par défaut — `set -u` le +# tuerait avant tout le reste. + +# Les couleurs peuvent déjà venir de l'hôte ; ne pas les écraser. +: "${Red:=\033[0;31m}" +: "${Yellow:=\033[0;33m}" +: "${Color_Off:=\033[0m}" + +_el_db_say() { + echo -e "$*" >&2 +} + +_el_db_tty() { + # Deux descripteurs, et le second est stderr — PAS stdout. + # + # `validate_to_continue.sh` teste stdout parce qu'il y écrit sa question. + # Ici la question part sur stderr, et l'appelant nous lit par + # substitution de commande : dans `$( … )`, stdout est TOUJOURS un tube, + # donc `-t 1` serait faux même devant un vrai terminal et le menu ne + # s'ouvrirait jamais. Ce qu'il faut savoir, c'est « la personne verra- + # t-elle l'invite », et l'invite sort par stderr. + # + # stdin compte tout autant : sans lui, `read` ne rendrait jamais rien. + # Le TUI de todo.py met stdout ET stderr en tube en laissant stdin + # intact — c'est le cas que ce garde doit refuser. + # + # Fonction isolée pour qu'un test puisse la remplacer sans pseudo-terminal. + [ -t 0 ] && [ -t 2 ] +} + +el_db_already_chosen() { + # L'appelant a-t-il déjà nommé sa base ? Les quatre formes qu'optparse + # accepte, plus « -d » nu en fin de ligne — ce que produit + # « ./run.sh -d $(bd) » de conf/make.robotlibre.Makefile quand bd est + # vide. Dans tous ces cas on se retire : c'est son intention, pas la + # nôtre, et on ne complète pas un « -d » resté sans valeur. + while [ $# -gt 0 ]; do + case "$1" in + -d | -d?* | --database | --database=*) return 0 ;; + esac + shift + done + return 1 +} + +el_db_config_has_name() { + # Une configuration de production nomme sa base. L'option de ligne de + # commande l'emporte sur le fichier : injecter « -d » par-dessus + # écraserait ce choix en silence. + local fichier="$1" + [ -f "${fichier}" ] || return 1 + grep -Eq '^[[:space:]]*db_name[[:space:]]*=[[:space:]]*[^[:space:]]' \ + "${fichier}" || return 1 + # « False » et « None » sont la façon d'Odoo d'écrire « aucune ». + if grep -Eq \ + '^[[:space:]]*db_name[[:space:]]*=[[:space:]]*(False|false|None)[[:space:]]*$' \ + "${fichier}"; then + return 1 + fi + return 0 +} + +el_db_list() { + # Les bases, une par ligne. Sortie non nulle si la SONDE a échoué — et + # alors on ne rend rien plutôt que d'inventer. + # + # stderr jeté, jamais fusionné : le CLI « db » n'appelle pas + # parse_config(), donc aucun journal n'est configuré et tout part nu sur + # stderr. Fusionner ferait de chaque ligne d'une trace un nom de base — + # le défaut que script/todo/database_manager.py a déjà dû corriger. + # + # Le mode couverture est neutralisé pour ce seul appel : sinon la sonde + # se mesure elle-même et sème un .coverage.* dans le rapport. + local brut retenu + brut="$(ODOO_MODE_COVERAGE= ./odoo_bin.sh db --list 2>/dev/null)" || return 1 + # « _cache_ » nomme les bases-modèles, pas des bases de travail. Sur une + # machine qui vient de lancer les tests, ce peut être la SEULE : sans ce + # filtre, la règle « une seule base » démarrerait Odoo sur un modèle. + retenu="$(printf '%s\n' "${brut}" \ + | tr -d '\r' \ + | grep -v '^[[:space:]]*$' \ + | grep -v '^_cache_')" + [ -n "${retenu}" ] && printf '%s\n' "${retenu}" + return 0 +} + +el_db_choose() { + # Le menu, entièrement sur stderr ; le nom retenu sur stdout. + # + # Numérotation à partir de 1 et « [0] » pour annuler : le vocabulaire + # des menus Python du projet, pour qu'on reconnaisse le même produit. + local -a bases=("$@") + local rang nom choix + while true; do + _el_db_say "" + _el_db_say "🗄 ${#bases[@]} bases de données. Laquelle démarrer ?" + rang=1 + for nom in "${bases[@]}"; do + _el_db_say " [${rang}] ${nom}" + rang=$((rang + 1)) + done + _el_db_say " [0] Annuler" + printf 'Choix : ' >&2 + # shellcheck disable=SC2162 + if ! read -r choix; then + # Ctrl-D : finir la ligne avant de partir, sinon l'invite reste + # collée au shell qui reprend la main. + _el_db_say "" + return 130 + fi + case "${choix}" in + 0) return 130 ;; + "" | *[!0-9]*) ;; + *) + if [ "${choix}" -ge 1 ] && [ "${choix}" -le "${#bases[@]}" ]; then + printf '%s\n' "${bases[$((choix - 1))]}" + return 0 + fi + ;; + esac + _el_db_say "${Red}Choix invalide${Color_Off} :" \ + "un nombre entre 0 et ${#bases[@]}." + done +} + +el_db_select() { + # Le nom de la base à démarrer, sur stdout — ou rien. 130 si l'on renonce. + # + # Les gardes vont de la moins chère à la plus chère : la sonde coûte + # 0,8 s (l'import d'Odoo, pas la requête) et ne se paie que lorsqu'elle + # peut servir. Sur tous les chemins automatisés, zéro appel. + # + # `explicite` distingue « --auto-erplibre a été demandé » de « run.sh a + # simplement reçu zéro argument ». Le défaut implicite doit rester + # timide : systemd lance « /bin/bash …/run.sh » SANS argument, avec + # Restart=always — un comportement neuf s'y déclencherait à chaque + # démarrage de production, sans que personne l'ait demandé. + local config="$1" + local sans_cli="$2" + local explicite="$3" + shift 3 + + el_db_already_chosen "$@" && return 0 + el_db_config_has_name "${config}" && return 0 + + # Sans demande explicite, il faut un terminal des deux côtés pour que la + # sélection s'arme du tout. Demandée, elle peut servir sans terminal — + # mais seulement là où aucune question n'est posée. + if [ "${explicite}" != "1" ] && ! _el_db_tty; then + return 0 + fi + + local liste + if ! liste="$(el_db_list)"; then + _el_db_say "${Yellow}⚠${Color_Off} Impossible de lister les bases ;" \ + "Odoo démarre sans base présélectionnée." + return 0 + fi + + local -a bases=() + while IFS= read -r nom; do + [ -n "${nom}" ] && bases+=("${nom}") + done <<< "${liste}" + + if [ "${#bases[@]}" -eq 0 ]; then + _el_db_say "${Yellow}⚠${Color_Off} Aucune base de données ;" \ + "Odoo démarre sur son gestionnaire web." + return 0 + fi + + if [ "${#bases[@]}" -eq 1 ]; then + _el_db_say "🗄 Une seule base : ${bases[0]}" + printf '%s\n' "${bases[0]}" + return 0 + fi + + if [ "${sans_cli}" = "1" ]; then + _el_db_say "${Yellow}⚠${Color_Off} ${#bases[@]} bases et" \ + "--no-cli-erplibre : aucune n'est présélectionnée." + return 0 + fi + if ! _el_db_tty; then + _el_db_say "${Yellow}⚠${Color_Off} ${#bases[@]} bases, mais pas de" \ + "terminal pour choisir : aucune n'est présélectionnée." + return 0 + fi + + el_db_choose "${bases[@]}" +} diff --git a/script/execute/execute.py b/script/execute/execute.py index a042323..b7826de 100644 --- a/script/execute/execute.py +++ b/script/execute/execute.py @@ -2,6 +2,12 @@ # © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +# Annotations différées : la migration charge ce module sous le Python +# d'Odoo 12 — 3.7 — où « dict | None » et « tuple[str, str] » n'existent +# pas encore. Sans ceci, l'annotation est ÉVALUÉE au chargement et la +# migration meurt sur un TypeError avant d'avoir rien fait. +from __future__ import annotations + import datetime import logging import os diff --git a/script/git/git_tool.py b/script/git/git_tool.py index ccaabe3..cbf68fd 100644 --- a/script/git/git_tool.py +++ b/script/git/git_tool.py @@ -2,6 +2,12 @@ # © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +# Annotations différées : la migration charge ce module sous le Python +# d'Odoo 12 — 3.7 — où « dict | None » et « tuple[str, str] » n'existent +# pas encore. Sans ceci, l'annotation est ÉVALUÉE au chargement et la +# migration meurt sur un TypeError avant d'avoir rien fait. +from __future__ import annotations + import os import pathlib import webbrowser diff --git a/script/git/github_api.py b/script/git/github_api.py index 9ebbadf..a806eae 100644 --- a/script/git/github_api.py +++ b/script/git/github_api.py @@ -2,6 +2,12 @@ # © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +# Annotations différées : la migration charge ce module sous le Python +# d'Odoo 12 — 3.7 — où « dict | None » et « tuple[str, str] » n'existent +# pas encore. Sans ceci, l'annotation est ÉVALUÉE au chargement et la +# migration meurt sur un TypeError avant d'avoir rien fait. +from __future__ import annotations + import git from agithub.GitHub import GitHub from colorama import Fore, Style diff --git a/script/git/repo_url.py b/script/git/repo_url.py index 99e6bc1..8bdeb84 100644 --- a/script/git/repo_url.py +++ b/script/git/repo_url.py @@ -2,6 +2,12 @@ # © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +# Annotations différées : la migration charge ce module sous le Python +# d'Odoo 12 — 3.7 — où « dict | None » et « tuple[str, str] » n'existent +# pas encore. Sans ceci, l'annotation est ÉVALUÉE au chargement et la +# migration meurt sur un TypeError avant d'avoir rien fait. +from __future__ import annotations + import os diff --git a/script/odoo/migration/fix_migration_odoo120_to_odoo130.sql b/script/odoo/migration/fix_migration_odoo120_to_odoo130.sql new file mode 100644 index 0000000..24ea722 --- /dev/null +++ b/script/odoo/migration/fix_migration_odoo120_to_odoo130.sql @@ -0,0 +1,87 @@ +-- © 2021-2026 TechnoLibre (http://www.technolibre.ca) +-- License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +-- +-- Correctifs à appliquer AVANT qu'OpenUpgrade ne migre vers Odoo 13. +-- +-- En SQL et non en Python : ce fichier tourne sur une base encore en 12, +-- que le code de la 13 ne saurait pas charger. + +-- Ancres de page encodées en pourcentage. +-- +-- Le script d'OpenUpgrade `website/migrations/13.0.1.0/post-migration.py` +-- ramasse les `href` des ancres d'une page et les RECOLLE en sélecteur CSS : +-- +-- links = doc.cssselect(r"a[href^=\#]:not([href=\#])") +-- "selector": ", ".join([link.attrib["href"] for link in links]) +-- +-- Or un `href` n'est pas un sélecteur. Une ancre française encodée — +-- `#principes-mn%C3%A9moniques` — y introduit un `%`, interdit dans un +-- identifiant CSS, et la migration meurt : +-- +-- cssselect.parser.SelectorSyntaxError: Expected selector, got +-- +-- Éprouvé avec le parseur d'Odoo 13 : `#principes-mnémoniques` passe, +-- `#principes-mn%C3%A9moniques` non. Décoder suffit donc, et un accent +-- reste un identifiant CSS valide. +-- +-- On ne touche QUE les vues qu'OpenUpgrade parcourt — celles qui portent +-- une page — et QUE les `href` d'ancre. Un `%` ailleurs dans une URL est +-- légitime et reste intact. +-- +-- Rejouable : une fois décodé il n'y a plus de `%XX` à trouver. + +-- Le décodeur vit dans `pg_temp` : il disparaît avec la session psql, et +-- ne laisse rien derrière lui dans la base du client. +CREATE FUNCTION pg_temp.el_url_decode(entree text) RETURNS text AS $decode$ +DECLARE + octets bytea = ''; + morceau text; +BEGIN + -- Deux à deux : « %C3 » devient un octet, tout autre caractère se + -- recopie tel quel. On rassemble en bytea AVANT de convertir, car un + -- caractère accenté tient sur deux octets et les décoder séparément + -- rendrait deux caractères illisibles. + FOR morceau IN + SELECT (regexp_matches(entree, '(%[0-9A-Fa-f]{2}|.)', 'g'))[1] + LOOP + IF length(morceau) = 3 AND left(morceau, 1) = '%' THEN + octets = octets || decode(substring(morceau, 2, 2), 'hex'); + ELSE + octets = octets || convert_to(morceau, 'UTF8'); + END IF; + END LOOP; + RETURN convert_from(octets, 'UTF8'); +END +$decode$ LANGUAGE plpgsql IMMUTABLE STRICT; + +DO $$ +DECLARE + ancre RECORD; + combien integer := 0; +BEGIN + IF to_regclass('ir_ui_view') IS NULL THEN + RETURN; + END IF; + FOR ancre IN + SELECT DISTINCT trouve[1] AS brut + FROM ir_ui_view v + JOIN website_page p ON p.view_id = v.id, + LATERAL regexp_matches( + v.arch_db, 'href="(#[^"]*%[0-9A-Fa-f]{2}[^"]*)"', 'g' + ) AS trouve + LOOP + UPDATE ir_ui_view + SET arch_db = replace( + arch_db, + 'href="' || ancre.brut || '"', + 'href="' || pg_temp.el_url_decode(ancre.brut) || '"' + ) + WHERE position('href="' || ancre.brut || '"' IN arch_db) > 0; + combien := combien + 1; + RAISE NOTICE 'ancre decodee : % -> %', + ancre.brut, pg_temp.el_url_decode(ancre.brut); + END LOOP; + IF combien > 0 THEN + RAISE NOTICE '% ancre(s) de page decodee(s)', combien; + END IF; +END $$; diff --git a/script/odoo/migration/uninstall_module_list_odoo140_to_odoo150.txt b/script/odoo/migration/uninstall_module_list_odoo140_to_odoo150.txt index 5917580..11c47bc 100644 --- a/script/odoo/migration/uninstall_module_list_odoo140_to_odoo150.txt +++ b/script/odoo/migration/uninstall_module_list_odoo140_to_odoo150.txt @@ -1 +1,13 @@ -muk_web_theme +# Modules à retirer AVANT de monter de 14.0 vers 15.0. +# +# Un module par ligne, une justification après « # ». + +# Raison d'origine NON consignée : la ligne arrive avec cbc43fde, sans un +# mot. Mesuré depuis, sur test_neutralize (12 → 18) : la commande part +# bien — « uninstall_addons.sh test_neutralize_upgrade_15 muk_web_theme » +# est dans le journal — et le module est pourtant « installed » de la 15 à +# la 18. Odoo ne cherche que l'état « installed » et le module traînait en +# « to remove » depuis la 13 ; il sort en 0 sans rien retirer. +# Le retrait n'a donc JAMAIS eu lieu, et rien n'a cassé pour autant. +# Avant de s'y fier, retrouver le motif — ou retirer la ligne. +muk_web_theme # motif d'origine inconnu ; retrait jamais effectif (mesuré) diff --git a/script/odoo/migration/uninstall_module_list_odoo170_to_odoo180.txt b/script/odoo/migration/uninstall_module_list_odoo170_to_odoo180.txt new file mode 100644 index 0000000..d197119 --- /dev/null +++ b/script/odoo/migration/uninstall_module_list_odoo170_to_odoo180.txt @@ -0,0 +1,15 @@ +# Modules à retirer AVANT de monter de 17.0 vers 18.0. +# +# Un module par ligne, une justification après « # ». + +# muk_web_theme n'excluait que web_enterprise en 16 et en 17 ; la 18 y +# ajoute web_responsive. Les deux cohabitaient donc légalement jusqu'ici, +# et la base arrive en 18 dans un état que la 18 interdit. Le chargement +# meurt dès que `Module.update_list()` installe un module auto_install, +# car Odoo revérifie alors toutes les exclusions : +# UserError: Les modules "MuK Backend Theme" et "Web Responsive" +# sont incompatibles. +# On retire web_responsive et l'on garde le thème MuK, qui porte +# l'apparence du back-office. L'inverse marcherait aussi : les deux +# rendent le même service, il faut simplement en choisir un. +web_responsive # exclu par muk_web_theme a partir de la 18.0 diff --git a/script/proxmox/README.base.md b/script/proxmox/README.base.md new file mode 100644 index 0000000..5c3a871 --- /dev/null +++ b/script/proxmox/README.base.md @@ -0,0 +1,188 @@ + + + + + + +# Deploying VMs on a Proxmox VE host + + +# Déployer des VM sur un hôte Proxmox VE + + +Two different things live in this directory: + +- `install_proxmox.sh` turns a Debian into a Proxmox hypervisor. See + `script/qemu/README.md`, which documents the `proxmox` distro of the + deployment catalog. +- `proxmox_deploy.py` deploys VMs **on** such a host, from + `TODO › Execute › Deploy › Proxmox VE`, right under `QEMU/KVM`. + + +Deux choses différentes vivent dans ce répertoire : + +- `install_proxmox.sh` transforme une Debian en hyperviseur Proxmox. Voir + `script/qemu/README.fr.md`, qui documente la distro `proxmox` du catalogue + de déploiement. +- `proxmox_deploy.py` déploie des VM **sur** un tel hôte, depuis + `TODO › Execute › Deploy › Proxmox VE`, juste sous `QEMU/KVM`. + + +## The whole difference: the hypervisor is elsewhere + +With QEMU/KVM, the hypervisor is the machine running the script. With Proxmox +it is somewhere else, so the first question is **which host** — and the answer +is remembered for the session. Three ways, all offered by the menu: + +1. **From the local QEMU VMs** — a `proxmox` VM deployed here. Its address + comes from the DHCP lease, nothing to retype. +2. **By address** — `user@host`, plus an optional SSH jump. +3. **From `~/.ssh/config`** — the alias already carries user, port and + ProxyJump; nothing else is asked. + + +## Toute la différence : l'hyperviseur est ailleurs + +Avec QEMU/KVM, l'hyperviseur est la machine qui exécute le script. Avec +Proxmox il est ailleurs : la première question est donc **quel hôte** — et la +réponse est retenue pour la session. Trois voies, toutes proposées par le menu : + +1. **Depuis les VM QEMU locales** — une VM `proxmox` déployée ici. Son adresse + vient du bail DHCP, rien à retaper. +2. **Par adresse** — `utilisateur@hôte`, plus un rebond SSH facultatif. +3. **Depuis `~/.ssh/config`** — l'alias porte déjà l'utilisateur, le port et le + ProxyJump ; on ne demande rien d'autre. + + +The chosen host is then **checked**, not assumed: `pveversion` proves it is a +Proxmox, `id -u` and `sudo -n true` decide whether commands need `sudo`, and an +unknown SSH host key is offered for recording (with `ssh-keyscan`, never by +disabling the check — a hypervisor is not a throwaway VM). + + +L'hôte choisi est ensuite **vérifié**, pas supposé : `pveversion` prouve que +c'en est un, `id -u` et `sudo -n true` décident s'il faut `sudo`, et une clé +d'hôte inconnue est proposée à l'enregistrement (par `ssh-keyscan`, jamais en +désactivant la vérification — un hyperviseur n'est pas une VM jetable). + + +```bash +# Ce que l'outil envoie, et qu'on peut rejouer à la main : +ssh erplibre@pve1 sudo sh -c 'qm list' +ssh erplibre@pve1 sudo sh -c 'pvesm status --content images' +``` + + +## Why SSH and `qm`, not the REST API + +The API needs a token or a ticket to create and renew. `qm` is the path every +Proxmox administrator knows, the repository already manages SSH access +(`~/.ssh/config`, ProxyJump, keys), and the commands stay readable in the log — +so they can be replayed by hand. That is how every failure of this module was +diagnosed. + +`sudo sh -c ''` and not `sudo `: these commands are +sequences and redirections. Prefixing with sudo would elevate only the first +word, and the redirection would still be the unprivileged shell's. + + +## Pourquoi SSH et `qm`, pas l'API REST + +L'API demande un jeton ou un ticket à créer et à renouveler. `qm` est la voie +que tout administrateur Proxmox connaît, le dépôt sait déjà gérer des accès SSH +(`~/.ssh/config`, ProxyJump, clés), et les commandes restent lisibles dans le +journal — donc rejouables à la main. C'est ainsi que chaque panne de ce module +a été diagnostiquée. + +`sudo sh -c ''` et non `sudo ` : ces commandes sont +des suites et des redirections. Préfixer par sudo n'élèverait que le premier +mot, et la redirection resterait celle du shell non privilégié. + + +## Four traps met on a real host + +A Proxmox installed **on Debian** has no `vmbr0` — the ISO installer creates +one, that procedure does not. And `qm create` requires a bridge. + +The menu offers an **internal** bridge (`vmbr0`, `10.10.10.1/24`, NAT through +the uplink). Never adding the physical NIC to a bridge is deliberate: that +moves the host address and cuts the SSH session in progress — remotely, there +is no way back. A LAN-facing bridge is printed as a stanza to apply from a +console. + +On an internal bridge no DHCP answers, so the address is **static**, derived +from the VMID — and therefore known before the VM boots. Looking for it +afterwards was absurd. + +The Debian cloud image does not ship `qemu-guest-agent`, so Proxmox cannot tell +the address of a DHCP guest: it does not hand out the leases. The fallback is +the host's own neighbour table (`ip neigh`), which needs nothing from the guest. + + +## Quatre pièges rencontrés sur un hôte réel + +Une Proxmox installée **sur Debian** n'a aucun `vmbr0` — l'installateur ISO en +crée un, cette procédure non. Or `qm create` exige un pont. + +Le menu propose alors un pont **interne** (`vmbr0`, `10.10.10.1/24`, NAT par la +sortie). Ne jamais ajouter l'interface physique à un pont est un choix : cela +déplace l'adresse de l'hôte et coupe la session SSH en cours — à distance, sans +retour. Pour un pont donnant sur le LAN, la strophe est affichée, à appliquer +depuis une console. + +Sur un pont interne, aucun DHCP ne répond : l'adresse est donc **fixe**, dérivée +du VMID — donc connue avant que la VM ne démarre. La chercher ensuite était +absurde. + +L'image cloud Debian n'embarque pas `qemu-guest-agent`, et Proxmox ne connaît +pas l'adresse d'un invité en DHCP : il ne distribue pas les baux. Le repli est +le voisinage de l'hôte (`ip neigh`), qui ne demande rien à l'invité. + + +## The menu, entry by entry + +The seventeen QEMU/KVM entries have their counterpart. Four of them are the +**same code**, because it is the same work: reopening the install monitoring, +the remote desktop tunnel, the Android emulator and the image catalog. They +reach Proxmox guests through the `~/.ssh/config` entries that entry 13 writes, +with the Proxmox host as ProxyJump. + + +## Le menu, entrée par entrée + +Les dix-sept entrées de QEMU/KVM ont leur équivalent. Quatre sont le **même +code**, parce que c'est le même travail : rouvrir le suivi d'installation, le +tunnel bureau distant, l'émulateur Android et le catalogue d'images. Elles +atteignent les invités Proxmox par les entrées `~/.ssh/config` que l'entrée 13 +écrit, avec l'hôte Proxmox en ProxyJump. + + +```text +[1] Déployer une VM [8] Redimensionner un disque [15] Émulateur Android * +[2] Prévisualiser [9] Effacer des VM [16] Catalogue d'images * +[3] Télécharger une image [10] Nettoyer (orphelins) [17] Exemple (dry-run) +[4] Rouvrir le suivi * [11] Tester une VM (Odoo) [18] Changer d'hôte +[5] Lister (qm list) [12] Statistiques +[6] Adresse IP d'une VM [13] Configuration SSH +[7] Console d'une VM [14] Tunnel bureau distant * + * code partagé avec le menu QEMU/KVM +``` + + +## Verified + +Deploying a VM inside a Proxmox that itself runs in a libvirt VM: image +downloaded on the host, internal bridge created, static address, cloud-init +user and key, disk resized, `qm start`. Then `ssh vm-essai` from the outside +reaches it through the jump — three nested levels. Resize `12G → 16G`, delete +with `--purge`, orphan scan: all checked against Proxmox VE 9.2.11. + + +## Vérifié + +Déploiement d'une VM dans une Proxmox qui tourne elle-même dans une VM +libvirt : image téléchargée sur l'hôte, pont interne créé, adresse fixe, +utilisateur et clé par cloud-init, disque redimensionné, `qm start`. Puis +`ssh vm-essai` depuis l'extérieur l'atteint par le rebond — trois niveaux +imbriqués. Redimensionnement `12G → 16G`, effacement avec `--purge`, recherche +d'orphelins : tout contrôlé contre Proxmox VE 9.2.11. diff --git a/script/proxmox/README.fr.md b/script/proxmox/README.fr.md new file mode 100644 index 0000000..465347e --- /dev/null +++ b/script/proxmox/README.fr.md @@ -0,0 +1,92 @@ + +# Déployer des VM sur un hôte Proxmox VE + +Deux choses différentes vivent dans ce répertoire : + +- `install_proxmox.sh` transforme une Debian en hyperviseur Proxmox. Voir + `script/qemu/README.fr.md`, qui documente la distro `proxmox` du catalogue + de déploiement. +- `proxmox_deploy.py` déploie des VM **sur** un tel hôte, depuis + `TODO › Execute › Deploy › Proxmox VE`, juste sous `QEMU/KVM`. + +## Toute la différence : l'hyperviseur est ailleurs + +Avec QEMU/KVM, l'hyperviseur est la machine qui exécute le script. Avec +Proxmox il est ailleurs : la première question est donc **quel hôte** — et la +réponse est retenue pour la session. Trois voies, toutes proposées par le menu : + +1. **Depuis les VM QEMU locales** — une VM `proxmox` déployée ici. Son adresse + vient du bail DHCP, rien à retaper. +2. **Par adresse** — `utilisateur@hôte`, plus un rebond SSH facultatif. +3. **Depuis `~/.ssh/config`** — l'alias porte déjà l'utilisateur, le port et le + ProxyJump ; on ne demande rien d'autre. + +L'hôte choisi est ensuite **vérifié**, pas supposé : `pveversion` prouve que +c'en est un, `id -u` et `sudo -n true` décident s'il faut `sudo`, et une clé +d'hôte inconnue est proposée à l'enregistrement (par `ssh-keyscan`, jamais en +désactivant la vérification — un hyperviseur n'est pas une VM jetable). + +```bash +# Ce que l'outil envoie, et qu'on peut rejouer à la main : +ssh erplibre@pve1 sudo sh -c 'qm list' +ssh erplibre@pve1 sudo sh -c 'pvesm status --content images' +``` + +## Pourquoi SSH et `qm`, pas l'API REST + +L'API demande un jeton ou un ticket à créer et à renouveler. `qm` est la voie +que tout administrateur Proxmox connaît, le dépôt sait déjà gérer des accès SSH +(`~/.ssh/config`, ProxyJump, clés), et les commandes restent lisibles dans le +journal — donc rejouables à la main. C'est ainsi que chaque panne de ce module +a été diagnostiquée. + +`sudo sh -c ''` et non `sudo ` : ces commandes sont +des suites et des redirections. Préfixer par sudo n'élèverait que le premier +mot, et la redirection resterait celle du shell non privilégié. + +## Quatre pièges rencontrés sur un hôte réel + +Une Proxmox installée **sur Debian** n'a aucun `vmbr0` — l'installateur ISO en +crée un, cette procédure non. Or `qm create` exige un pont. + +Le menu propose alors un pont **interne** (`vmbr0`, `10.10.10.1/24`, NAT par la +sortie). Ne jamais ajouter l'interface physique à un pont est un choix : cela +déplace l'adresse de l'hôte et coupe la session SSH en cours — à distance, sans +retour. Pour un pont donnant sur le LAN, la strophe est affichée, à appliquer +depuis une console. + +Sur un pont interne, aucun DHCP ne répond : l'adresse est donc **fixe**, dérivée +du VMID — donc connue avant que la VM ne démarre. La chercher ensuite était +absurde. + +L'image cloud Debian n'embarque pas `qemu-guest-agent`, et Proxmox ne connaît +pas l'adresse d'un invité en DHCP : il ne distribue pas les baux. Le repli est +le voisinage de l'hôte (`ip neigh`), qui ne demande rien à l'invité. + +## Le menu, entrée par entrée + +Les dix-sept entrées de QEMU/KVM ont leur équivalent. Quatre sont le **même +code**, parce que c'est le même travail : rouvrir le suivi d'installation, le +tunnel bureau distant, l'émulateur Android et le catalogue d'images. Elles +atteignent les invités Proxmox par les entrées `~/.ssh/config` que l'entrée 13 +écrit, avec l'hôte Proxmox en ProxyJump. + +```text +[1] Déployer une VM [8] Redimensionner un disque [15] Émulateur Android * +[2] Prévisualiser [9] Effacer des VM [16] Catalogue d'images * +[3] Télécharger une image [10] Nettoyer (orphelins) [17] Exemple (dry-run) +[4] Rouvrir le suivi * [11] Tester une VM (Odoo) [18] Changer d'hôte +[5] Lister (qm list) [12] Statistiques +[6] Adresse IP d'une VM [13] Configuration SSH +[7] Console d'une VM [14] Tunnel bureau distant * + * code partagé avec le menu QEMU/KVM +``` + +## Vérifié + +Déploiement d'une VM dans une Proxmox qui tourne elle-même dans une VM +libvirt : image téléchargée sur l'hôte, pont interne créé, adresse fixe, +utilisateur et clé par cloud-init, disque redimensionné, `qm start`. Puis +`ssh vm-essai` depuis l'extérieur l'atteint par le rebond — trois niveaux +imbriqués. Redimensionnement `12G → 16G`, effacement avec `--purge`, recherche +d'orphelins : tout contrôlé contre Proxmox VE 9.2.11. \ No newline at end of file diff --git a/script/proxmox/README.md b/script/proxmox/README.md new file mode 100644 index 0000000..aecfbc6 --- /dev/null +++ b/script/proxmox/README.md @@ -0,0 +1,91 @@ + +# Deploying VMs on a Proxmox VE host + +Two different things live in this directory: + +- `install_proxmox.sh` turns a Debian into a Proxmox hypervisor. See + `script/qemu/README.md`, which documents the `proxmox` distro of the + deployment catalog. +- `proxmox_deploy.py` deploys VMs **on** such a host, from + `TODO › Execute › Deploy › Proxmox VE`, right under `QEMU/KVM`. + +## The whole difference: the hypervisor is elsewhere + +With QEMU/KVM, the hypervisor is the machine running the script. With Proxmox +it is somewhere else, so the first question is **which host** — and the answer +is remembered for the session. Three ways, all offered by the menu: + +1. **From the local QEMU VMs** — a `proxmox` VM deployed here. Its address + comes from the DHCP lease, nothing to retype. +2. **By address** — `user@host`, plus an optional SSH jump. +3. **From `~/.ssh/config`** — the alias already carries user, port and + ProxyJump; nothing else is asked. + +The chosen host is then **checked**, not assumed: `pveversion` proves it is a +Proxmox, `id -u` and `sudo -n true` decide whether commands need `sudo`, and an +unknown SSH host key is offered for recording (with `ssh-keyscan`, never by +disabling the check — a hypervisor is not a throwaway VM). + +```bash +# Ce que l'outil envoie, et qu'on peut rejouer à la main : +ssh erplibre@pve1 sudo sh -c 'qm list' +ssh erplibre@pve1 sudo sh -c 'pvesm status --content images' +``` + +## Why SSH and `qm`, not the REST API + +The API needs a token or a ticket to create and renew. `qm` is the path every +Proxmox administrator knows, the repository already manages SSH access +(`~/.ssh/config`, ProxyJump, keys), and the commands stay readable in the log — +so they can be replayed by hand. That is how every failure of this module was +diagnosed. + +`sudo sh -c ''` and not `sudo `: these commands are +sequences and redirections. Prefixing with sudo would elevate only the first +word, and the redirection would still be the unprivileged shell's. + +## Four traps met on a real host + +A Proxmox installed **on Debian** has no `vmbr0` — the ISO installer creates +one, that procedure does not. And `qm create` requires a bridge. + +The menu offers an **internal** bridge (`vmbr0`, `10.10.10.1/24`, NAT through +the uplink). Never adding the physical NIC to a bridge is deliberate: that +moves the host address and cuts the SSH session in progress — remotely, there +is no way back. A LAN-facing bridge is printed as a stanza to apply from a +console. + +On an internal bridge no DHCP answers, so the address is **static**, derived +from the VMID — and therefore known before the VM boots. Looking for it +afterwards was absurd. + +The Debian cloud image does not ship `qemu-guest-agent`, so Proxmox cannot tell +the address of a DHCP guest: it does not hand out the leases. The fallback is +the host's own neighbour table (`ip neigh`), which needs nothing from the guest. + +## The menu, entry by entry + +The seventeen QEMU/KVM entries have their counterpart. Four of them are the +**same code**, because it is the same work: reopening the install monitoring, +the remote desktop tunnel, the Android emulator and the image catalog. They +reach Proxmox guests through the `~/.ssh/config` entries that entry 13 writes, +with the Proxmox host as ProxyJump. + +```text +[1] Déployer une VM [8] Redimensionner un disque [15] Émulateur Android * +[2] Prévisualiser [9] Effacer des VM [16] Catalogue d'images * +[3] Télécharger une image [10] Nettoyer (orphelins) [17] Exemple (dry-run) +[4] Rouvrir le suivi * [11] Tester une VM (Odoo) [18] Changer d'hôte +[5] Lister (qm list) [12] Statistiques +[6] Adresse IP d'une VM [13] Configuration SSH +[7] Console d'une VM [14] Tunnel bureau distant * + * code partagé avec le menu QEMU/KVM +``` + +## Verified + +Deploying a VM inside a Proxmox that itself runs in a libvirt VM: image +downloaded on the host, internal bridge created, static address, cloud-init +user and key, disk resized, `qm start`. Then `ssh vm-essai` from the outside +reaches it through the jump — three nested levels. Resize `12G → 16G`, delete +with `--purge`, orphan scan: all checked against Proxmox VE 9.2.11. diff --git a/script/proxmox/install_proxmox.sh b/script/proxmox/install_proxmox.sh new file mode 100755 index 0000000..4bcbd7a --- /dev/null +++ b/script/proxmox/install_proxmox.sh @@ -0,0 +1,498 @@ +#!/usr/bin/env bash +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +# +# Installe Proxmox VE — https://www.proxmox.com — SUR UNE DEBIAN existante. +# +# Pourquoi sur Debian : Proxmox ne publie aucune image cloud. Son ISO est un +# installateur qui formate le disque, ce qui ne se pilote pas depuis un +# déploiement cloud-init. La voie que l'amont documente lui-même pour ce cas +# est « Install Proxmox VE on Debian » : on part de l'image cloud Debian, on +# ajoute le dépôt pve, et les paquets font le reste. Le résultat est le même +# hyperviseur, avec le noyau Proxmox et l'interface web sur :8006. +# +# Architectures : amd64 et arm64 (arm64 officiel depuis PVE 9 — le Release de +# trixie annonce « amd64 arm64 »). PAS s390x : l'index « binary-s390x » du +# dépôt répond 404, le port n'existe pas. +# +# Réglages, par variables d'environnement : +# PVE_SUITE suite Debian visée (défaut : trixie, = PVE 9) +# PVE_REBOOT à 1, redémarre à la fin (défaut : ne redémarre PAS — +# lancé par SSH, un reboot couperait la session et ferait +# passer une installation réussie pour un échec) +# PVE_KEEP_DEBIAN_KERNEL à 1, garde le noyau Debian à côté du noyau pve +# PVE_OS_RELEASE fichier os-release à lire (défaut : /etc/os-release) ; +# sert à vérifier le script depuis une autre distribution +set -euo pipefail + +Red='\033[0;31m' +Green='\033[0;32m' +Yellow='\033[0;33m' +Color_Off='\033[0m' + +SUITE="${PVE_SUITE:-trixie}" +KEYRING=/usr/share/keyrings/proxmox-archive-keyring.gpg +SOURCES=/etc/apt/sources.list.d/pve-install-repo.sources + +# Somme relevée sur la page amont « Install Proxmox VE on Debian 13 Trixie », +# et VÉRIFIÉE contre le fichier servi. Elle vaut ceinture ET bretelle : c'est +# la clé qui authentifiera tout le reste, et la télécharger sans la contrôler +# reviendrait à faire confiance au seul transport. +KEY_URL="https://enterprise.proxmox.com/debian/proxmox-archive-keyring-${SUITE}.gpg" +KEY_SHA256_trixie=136673be77aba35dcce385b28737689ad64fd785a797e57897589aed08db6e45 + +# Ce qui a changé : décide du redémarrage final. Une installation déjà faite ne +# doit pas redémarrer la machine pour rien. +CHANGED=0 + +say() { echo -e "$@"; } +die() { say "${Red}✗${Color_Off} $*"; exit 1; } + +DRY=0 +usage() { + cat < » que pose l'image cloud Debian le +# mène droit dans le mur, et pveproxy comme pvecm s'en trouvent mal. +# « hostname --ip-address » est le test que l'amont donne lui-même. +host_ip() { + local ip="" + for ip in \ + "$(ip -4 route get 1 2>/dev/null | awk '{print $7; exit}')" \ + "$(hostname -I 2>/dev/null | awk '{print $1}')" \ + "$(ip -4 -o addr show scope global 2>/dev/null \ + | awk '{split($4, a, "/"); print a[1]; exit}')" + do + case "$ip" in + 127.*) continue ;; + [0-9]*.[0-9]*.[0-9]*.[0-9]*) echo "$ip"; return 0 ;; + esac + done + return 1 +} + +fix_hosts() { + local ip fqdn short + ip="$(host_ip)" || die "aucune adresse IPv4 routable : réseau absent ?" + short="$(hostname -s)" + fqdn="$(hostname -f 2>/dev/null || echo "${short}")" + [ "${fqdn}" = "${short}" ] && fqdn="${short}.local" + if grep -qE "^[[:space:]]*127\.0\.1\.1[[:space:]]" /etc/hosts; then + say " retrait de l'entrée 127.0.1.1 (bouclage) pour ${short}" + run sudo sed -i -E "/^[[:space:]]*127\.0\.1\.1[[:space:]]/d" \ + /etc/hosts + CHANGED=1 + fi + if ! grep -qE "^[[:space:]]*${ip//./\\.}[[:space:]]+.*\b${short}\b" \ + /etc/hosts; then + say " ${short} -> ${ip} dans /etc/hosts" + if [ "${DRY}" = "1" ]; then + say " ${Yellow}[dry-run]${Color_Off} ${ip} ${fqdn} ${short}" \ + ">> /etc/hosts" + else + printf '%s\t%s %s\n' "${ip}" "${fqdn}" "${short}" \ + | sudo tee -a /etc/hosts >/dev/null + fi + CHANGED=1 + fi + # Le test de l'amont, mot pour mot : au moins une adresse non-bouclage. + local vu routables + vu="$(hostname --ip-address 2>/dev/null || true)" + routables="$(printf '%s\n' ${vu} | grep -vE '^(127\.|::1$)' || true)" + [ -n "${routables}" ] || die \ + "« hostname --ip-address » rend « ${vu:-rien} » : le nom d'hôte ne" \ + "résout toujours pas vers une adresse routable." + say " hostname --ip-address : $(printf '%s ' ${routables})" +} + +# --- 4. Dépôt et clé -------------------------------------------------------- +add_repo() { + local attendu="KEY_SHA256_${SUITE}" + attendu="${!attendu:-}" + if [ ! -s "${KEYRING}" ]; then + say " clé du dépôt -> ${KEYRING}" + run sudo mkdir -p "$(dirname "${KEYRING}")" + run sudo wget -q "${KEY_URL}" -O "${KEYRING}" \ + || die "téléchargement de la clé impossible : ${KEY_URL}" + # Un wget qui rend 0 sans laisser de fichier — proxy captif, disque + # plein — ferait mourir la suite sur un message de sha256sum au lieu + # d'un diagnostic. + if [ "${DRY}" != "1" ]; then + sudo test -s "${KEYRING}" \ + || die "clé absente ou vide après téléchargement : ${KEYRING}" + fi + CHANGED=1 + fi + if [ -n "${attendu}" ] && [ "${DRY}" = "1" ]; then + say " clé à vérifier contre sha256 ${attendu:0:16}…" + elif [ -n "${attendu}" ]; then + local vu + vu="$(sudo sha256sum "${KEYRING}" 2>/dev/null | awk '{print $1}')" \ + || true + [ -n "${vu}" ] || die "somme de la clé illisible : ${KEYRING}" + if [ "${vu}" != "${attendu}" ]; then + # On efface la clé douteuse : la laisser en place ferait passer la + # prochaine exécution pour bonne, le fichier étant non vide. + run sudo rm -f "${KEYRING}" + die "somme de la clé inattendue :\n vue ${vu}\n" \ + " attendue ${attendu}" + fi + say " clé vérifiée (sha256 ${vu:0:16}…)" + else + say "${Yellow}⚠${Color_Off} aucune somme connue pour « ${SUITE} » :" \ + "clé acceptée sans contrôle." + fi + # Format deb822, celui que l'amont recommande sur Debian 13. + local voulu + voulu="$(printf 'Types: deb\nURIs: http://download.proxmox.com/debian/pve\nSuites: %s\nComponents: pve-no-subscription\nSigned-By: %s\n' \ + "${SUITE}" "${KEYRING}")" + if [ "$(cat "${SOURCES}" 2>/dev/null || true)" != "${voulu}" ]; then + say " dépôt pve-no-subscription -> ${SOURCES}" + if [ "${DRY}" = "1" ]; then + say " ${Yellow}[dry-run]${Color_Off} ${SOURCES} :" \ + "$(printf '%s' "${voulu}" | tr '\n' '|')" + else + printf '%s\n' "${voulu}" | sudo tee "${SOURCES}" >/dev/null + fi + CHANGED=1 + fi +} + +# --- 6. Dépôt entreprise ---------------------------------------------------- +# « proxmox-ve » installe SON dépôt entreprise (pve-enterprise.sources), qui +# exige un abonnement payant. Sans lui, tout « apt update » ultérieur échoue : +# « 401 Unauthorized » puis « The repository is not signed », et la machine ne +# peut plus rien installer — pas même une mise à jour de sécurité. Mesuré sur +# la VM d'essai, au deuxième passage du script. +# +# On le désactive au lieu de l'effacer : « Enabled: false » est la forme deb822 +# prévue pour cela, elle survit aux mises à jour du paquet, et il suffit de +# retirer la ligne le jour où un abonnement existe. +disable_enterprise() { + local f + for f in /etc/apt/sources.list.d/pve-enterprise.sources \ + /etc/apt/sources.list.d/ceph.sources; do + [ -e "${f}" ] || continue + if sudo grep -qiE '^[[:space:]]*Enabled:[[:space:]]*(false|no)' \ + "${f}"; then + continue + fi + say " dépôt entreprise désactivé : $(basename "${f}")" + if [ "${DRY}" = "1" ]; then + say " ${Yellow}[dry-run]${Color_Off} Enabled: false >> ${f}" + else + printf 'Enabled: false\n' | sudo tee -a "${f}" >/dev/null + fi + CHANGED=1 + done + # Les anciennes formes « .list », au cas où une mise à jour les remette. + for f in /etc/apt/sources.list.d/pve-enterprise.list; do + [ -e "${f}" ] || continue + sudo grep -qE '^[[:space:]]*#' "${f}" && continue + say " dépôt entreprise commenté : $(basename "${f}")" + run sudo sed -i -E 's/^([[:space:]]*deb)/#\1/' "${f}" + CHANGED=1 + done +} + +# --- 5. Les paquets --------------------------------------------------------- +# Disque d'amorçage, pour la préréponse de grub-pc. +boot_disk() { + local src + src="$(findmnt -no SOURCE /boot 2>/dev/null \ + || findmnt -no SOURCE / 2>/dev/null)" + [ -n "${src}" ] || return 1 + local dq + dq="$(lsblk -no pkname "${src}" 2>/dev/null | head -1)" + [ -n "${dq}" ] || return 1 + printf '/dev/%s\n' "${dq}" +} + +# Deux paquets posent des questions debconf, et une seule sans réponse suffit à +# faire échouer TOUTE la transaction apt : +# +# - postfix demande son type et son nom de courrier. Sans préréponse, +# l'installation attend une saisie que personne ne verra — un déploiement +# par SSH y reste pendu. +# - grub-pc demande SUR QUEL DISQUE s'installer. C'est le piège propre à +# l'image cloud : elle amorce en EFI (grub-cloud-amd64), les paquets pve +# tirent grub-pc par-dessus, et sa post-installation refuse de deviner — +# « You must correct your GRUB install devices before proceeding », mesuré, +# dpkg s'arrête et le noyau Proxmox reste à moitié configuré. +preseed_debconf() { + command -v debconf-set-selections >/dev/null 2>&1 || return 0 + local lignes disque + lignes="$(printf '%s\n' \ + "postfix postfix/main_mailer_type select Local only" \ + "postfix postfix/mailname string $(hostname -f 2>/dev/null \ + || hostname -s)")" + if disque="$(boot_disk)"; then + lignes="$(printf '%s\n%s\n' "${lignes}" \ + "grub-pc grub-pc/install_devices multiselect ${disque}")" + else + say "${Yellow}⚠${Color_Off} disque d'amorçage introuvable :" \ + "grub-pc pourrait demander où s'installer." + fi + # Pas de tube vers « run » : en dry-run il ne lirait pas son entrée, le + # printf recevrait SIGPIPE, et « set -o pipefail » emporterait le script — + # mort silencieuse sur un code 141. Vécu. + if [ "${DRY}" = "1" ]; then + say " ${Yellow}[dry-run]${Color_Off} debconf-set-selections :" \ + "$(printf '%s' "${lignes}" | tr '\n' '|')" + return 0 + fi + printf '%s\n' "${lignes}" | sudo debconf-set-selections +} + +# « DPkg::Lock::Timeout » : sur une VM fraîchement déployée, cloud-init tient +# encore le verrou d'apt — mesuré, « E: Could not get lock +# /var/lib/apt/lists/lock. It is held by process 996 (apt-get) », et le script +# mourait 40 secondes après le démarrage. apt sait ATTENDRE son tour depuis la +# 1.9 ; sans cette option il abandonne immédiatement. +apt_get() { + run sudo env DEBIAN_FRONTEND=noninteractive \ + apt-get -o Dpkg::Options::=--force-confold \ + -o DPkg::Lock::Timeout=600 -y "$@" +} + +# Attendre la fin de cloud-init AVANT de toucher à apt : c'est lui qui pose les +# paquets de la première mise en route. Son état final n'est PAS un critère — +# vérifié sur l'image Debian 13, où il rend « error » pour deux modules sans +# rapport (console-setup absent, update-locale) tout en ayant terminé son +# travail. On attend qu'il ait fini, pas qu'il soit content. +wait_cloud_init() { + command -v cloud-init >/dev/null 2>&1 || return 0 + say " attente de la fin de cloud-init…" + if [ "${DRY}" = "1" ]; then + say " ${Yellow}[dry-run]${Color_Off} cloud-init status --wait" + return 0 + fi + timeout 600 cloud-init status --wait >/dev/null 2>&1 || true + return 0 +} + +install_pve() { + wait_cloud_init + preseed_debconf + # Réparer une transaction laissée à moitié par une exécution précédente : + # sans réponse à grub-pc, dpkg s'arrête au milieu et tout apt suivant + # refuse de travailler. Inoffensif quand rien n'est cassé. + if ! sudo dpkg -C >/dev/null 2>&1; then + say " paquets à moitié configurés : dpkg --configure -a" + run sudo env DEBIAN_FRONTEND=noninteractive dpkg --configure -a \ + || true + fi + # AVANT le premier apt : sur un second passage, le dépôt entreprise posé + # par proxmox-ve ferait échouer « apt update » (401) et rien n'irait plus + # loin — pas même la désactivation, si elle attendait la fin. + disable_enterprise + say "\n---- apt update ----" + apt_get update + # Le noyau d'abord, comme l'amont le prescrit : c'est lui qui porte les + # modules dont pve a besoin, et l'installer seul laisse une machine qui + # redémarre proprement même si la suite échoue. + if ! dpkg -s proxmox-default-kernel >/dev/null 2>&1; then + say "\n---- noyau Proxmox ----" + apt_get install proxmox-default-kernel + CHANGED=1 + else + say " noyau Proxmox déjà posé" + fi + if ! dpkg -s proxmox-ve >/dev/null 2>&1; then + say "\n---- proxmox-ve, postfix, open-iscsi, chrony ----" + apt_get install proxmox-ve postfix open-iscsi chrony + CHANGED=1 + # C'est CETTE installation qui vient de poser le dépôt entreprise : + # le désactiver tout de suite, avant que le ménage ne rappelle apt. + disable_enterprise + else + say " proxmox-ve déjà posé" + fi +} + +# --- 6. Ménage -------------------------------------------------------------- +# os-prober ajoute au menu d'amorçage les systèmes trouvés sur les disques des +# VM invitées : sur un hyperviseur, c'est une liste de faux départs. +# Le noyau Debian, lui, n'a plus de raison d'être une fois celui de pve en +# place — et laissé par défaut, grub peut y revenir. +cleanup() { + if dpkg -s os-prober >/dev/null 2>&1; then + say " retrait d'os-prober" + apt_get remove os-prober + CHANGED=1 + fi + [ "${PVE_KEEP_DEBIAN_KERNEL:-0}" = "1" ] && return 0 + # Ne JAMAIS retirer le noyau Debian si celui de Proxmox n'est pas posé : + # la machine ne redémarrerait plus. Vérifié, pas supposé — une étape apt + # peut avoir échoué plus haut sans arrêter le reste. + if ! dpkg -s proxmox-default-kernel >/dev/null 2>&1; then + say " noyau Proxmox absent : le noyau Debian reste en place." + return 0 + fi + local metas + # Tout « linux-image-* » SAUF ceux de Proxmox. L'amont écrit + # « linux-image-6.12* », la version de trixie ; le motif large couvre les + # versions suivantes, et l'exclusion protège le noyau qu'on vient de + # poser — le retirer laisserait une VM qui n'amorce plus. + # « db:Status-Status » filtre les paquets RÉELLEMENT installés : dpkg + # connaît aussi ceux qu'il a désinstallés (« config-files », + # « not-installed »), et les passer à apt donnait « is not installed, so + # not removed » — puis un CHANGED=1 qui réclamait un redémarrage inutile. + metas="$(dpkg-query -W -f '${Package} ${db:Status-Status}\n' \ + 'linux-image-*' 2>/dev/null \ + | awk '$2 == "installed" {print $1}' \ + | grep -vE 'pve|proxmox' || true)" + if [ -n "${metas}" ]; then + say " retrait du noyau Debian : $(echo "${metas}" | tr '\n' ' ')" + # shellcheck disable=SC2086 + apt_get remove ${metas} + run sudo update-grub 2>/dev/null || true + CHANGED=1 + fi + return 0 +} + +# --- 6bis. Amorçage EFI de secours ----------------------------------------- +# Une image cloud amorce par le CHEMIN DE SECOURS de l'UEFI — +# \EFI\BOOT\BOOTX64.EFI — parce qu'aucune entrée NVRAM ne la nomme. Les +# paquets grub de Proxmox y recopient bien leurs binaires, mais PAS le petit +# grub.cfg qui indique où trouver la vraie configuration. GRUB s'arrête alors +# sur son invite de secours « grub> » : ni menu, ni noyau. +# +# Vécu, capture d'écran à l'appui : après le premier redémarrage, la VM brûlait +# 100 % d'un cœur SANS lire une seule fois le disque, et l'écran affichait +# « starting Boot0001 UEFI Misc Device » suivi de « grub> ». +# +# On recopie le stub que Debian a généré pour son propre chemin : il porte +# l'UUID de la racine, donc il fonctionne depuis n'importe quel chargeur. +# « grub-install --removable » ferait la même chose en réécrivant le chargeur ; +# copier un fichier est plus sûr — cela ne touche pas au chemin qui marche. +fix_efi_fallback() { + local esp="${PVE_ESP:-/boot/efi}" + local secours="${esp}/EFI/BOOT" + # sudo sur CHAQUE lecture. /boot/efi est une vfat montée « umask=077 » : + # root seul y entre, et un « [ -d ] » non privilégié y répond FAUX. Ce + # correctif ne faisait donc RIEN, en silence, et la VM retombait sur + # « grub> » au redémarrage suivant — vécu deux fois. Le glob du shell est + # aveugle pour la même raison : il faut énumérer avec sudo. + sudo test -d "${secours}" || return 0 + sudo test -e "${secours}/grub.cfg" && return 0 + local stub="" + # « || true » : quand aucun stub n'existe, grep ne trouve rien et rend 1 + # — avec « set -o pipefail », l'affectation échoue et le script s'arrête + # AVANT d'avoir dit ce qui manque. Attrapé par un test, pas sur la machine + # réelle, où un stub existait et masquait le cas. + stub="$(sudo sh -c "ls ${esp}/EFI/*/grub.cfg 2>/dev/null" \ + | grep -v '/EFI/BOOT/grub.cfg' | head -1 || true)" + if [ -z "${stub}" ]; then + say "${Yellow}⚠${Color_Off} aucun grub.cfg à recopier sous ${esp} :" \ + "vérifier l'amorçage avant de redémarrer." + return 0 + fi + say " amorçage de secours : $(dirname "${stub}" | xargs basename)/grub.cfg" \ + "-> EFI/BOOT/" + run sudo cp "${stub}" "${secours}/grub.cfg" + CHANGED=1 +} + +# --- 7. Déroulé ------------------------------------------------------------- +say "${Green}==>${Color_Off} Proxmox VE ${SUITE} sur ${ARCH}" +say "\n---- nom d'hôte et /etc/hosts ----" +fix_hosts +say "\n---- dépôt Proxmox ----" +add_repo +install_pve +say "\n---- ménage ----" +cleanup +say "\n---- amorçage ----" +fix_efi_fallback + +IP="$(host_ip || echo localhost)" +if [ "${DRY}" = "1" ]; then + # Ne rien annoncer qui n'ait eu lieu : en dry-run, rien n'a été installé. + say "\n${Yellow}✓${Color_Off} dry-run terminé : rien n'a été changé." + say " Ce qui serait joignable ensuite : https://${IP}:8006" + exit 0 +fi +say "\n${Green}✓${Color_Off} Proxmox VE installé." +say " Interface web : ${Green}https://${IP}:8006${Color_Off}" +# Proxmox authentifie par PAM : c'est le root du système qui ouvre l'interface. +# Sur une image cloud il est VERROUILLÉ (« passwd -S root » rend « L »), donc +# l'interface est inutilisable tant qu'on ne lui a pas donné un mot de passe. +# Le dire ici plutôt que de laisser chercher devant un formulaire qui refuse. +if [ "$(sudo passwd -S root 2>/dev/null | awk '{print $2}')" = "L" ]; then + say " Compte : root, ${Yellow}sans mot de passe${Color_Off} —" \ + "l'interface le refusera. À faire : ${Green}sudo passwd root${Color_Off}" +else + say " Compte : root (mot de passe du système)" +fi +say " Une VM Proxmox est un hyperviseur DANS une VM : ses propres invités" +say " demandent la virtualisation imbriquée à tous les étages." + +if [ "${CHANGED}" = "0" ]; then + say "\n Rien n'a changé : pas de redémarrage." + exit 0 +fi +if [ "${PVE_REBOOT:-0}" != "1" ]; then + say "\n${Yellow}⚠${Color_Off} Redémarrage NÉCESSAIRE pour amorcer le" \ + "noyau Proxmox : sudo reboot" + exit 0 +fi +say "\n---- redémarrage pour amorcer le noyau Proxmox ----" +run sudo systemctl reboot diff --git a/script/proxmox/proxmox_deploy.py b/script/proxmox/proxmox_deploy.py new file mode 100644 index 0000000..7074579 --- /dev/null +++ b/script/proxmox/proxmox_deploy.py @@ -0,0 +1,517 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Déploiement de VM SUR un hôte Proxmox VE, piloté à distance par SSH. + +Différence de nature avec `script/qemu/deploy_qemu.py` : là-bas, l'hyperviseur +est la machine qui exécute le script. Ici, il est AILLEURS — « on n'exécute pas +dessus ». Tout ce que ce module produit part donc sur l'hôte choisi, et rien +n'exige de privilège local. + +Pourquoi SSH et `qm` plutôt que l'API REST : l'API demande un jeton ou un +ticket à créer et à renouveler, quand `qm` est la voie que tout administrateur +Proxmox connaît, et que le dépôt sait déjà gérer des accès SSH (~/.ssh/config, +ProxyJump, clés). Les commandes restent lisibles dans le journal, donc +rejouables à la main — c'est ce qui a permis de diagnostiquer chaque panne de +ce module. + +Découpage voulu : TOUT ce qui construit une commande ou lit une sortie est une +fonction PURE, vérifiable sans hôte Proxmox. Seul `run()` parle au réseau. +""" +from __future__ import annotations + +import json +import re +import shlex +import subprocess + +# Réglages par défaut d'une VM Proxmox. Chacun a sa raison : +# +# - virtio-scsi-single : le contrôleur que Proxmox recommande depuis PVE 7, et +# le seul qui donne l'iothread par disque. +# - agent enabled=1 : sans l'agent invité, « qm guest cmd » ne rend aucune +# adresse IP et le menu ne peut pas dire où joindre la VM. +# - serial0 socket + vga serial0 : c'est ce qui rend « qm terminal » utilisable. +# Une console graphique seule obligerait à passer par l'interface web. +# - ostype l26 : Linux 2.6+, ce qui règle les horloges et les pilotes. +DEFAULT_BRIDGE = "vmbr0" +DEFAULT_STORAGE = "" # vide = on choisit d'après « pvesm status » +IMAGE_DIR = "/var/lib/vz/template/iso" +VMID_MIN = 100 + +# Stockages qui savent héberger un disque de VM. « pvesm status » liste aussi +# des stockages de sauvegarde ou d'ISO, où un disque ne peut PAS aller : les +# proposer produirait un « qm set » refusé après le téléchargement de l'image. +DISK_CONTENT = ("images", "rootdir") + + +def ssh_argv(host: dict, remote: str, tty: bool = False) -> list: + """Commande ssh complète pour exécuter `remote` sur l'hôte Proxmox. + + `host` : {"target": "root@10.0.0.5", "jump": "rebond", "port": "22"} — + « target » suffit quand l'alias vient de ~/.ssh/config, qui porte déjà + l'utilisateur, le port et le ProxyJump. + """ + argv = ["ssh"] + if not tty: + argv += ["-o", "BatchMode=yes"] + argv += ["-o", "ConnectTimeout=10"] + if host.get("port"): + argv += ["-p", str(host["port"])] + if host.get("jump"): + argv += ["-J", host["jump"]] + if tty: + argv.append("-t") + argv += [host["target"], remote] + return argv + + +def wrap_privilege(remote: str, prefix: str) -> str: + """Enveloppe la commande pour qu'elle tourne en root, si nécessaire. + + « sudo sh -c '' » et non « sudo » : les commandes de ce module + sont des SUITES (« mkdir && if … fi », une boucle for, une redirection). + Préfixer par sudo n'élèverait que le premier mot, et la redirection + resterait celle du shell non privilégié — donc « permission denied » sur + /root ou /boot/efi. + """ + if not prefix: + return remote + return "sudo sh -c " + shlex.quote(remote) + + +def run(host: dict, remote: str, timeout: int = 120) -> tuple: + """(code, sortie) de `remote` exécuté sur l'hôte. Ne lève jamais. + + `host["sudo"]` non vide -> la commande passe par sudo : « qm » exige les + privilèges, et l'accès offert par une VM du parc est celui d'`erplibre`. + """ + remote = wrap_privilege(remote, host.get("sudo") or "") + try: + res = subprocess.run( + ssh_argv(host, remote), + capture_output=True, + text=True, + timeout=timeout, + ) + except subprocess.TimeoutExpired: + return 255, "timeout" + except (OSError, subprocess.SubprocessError) as exc: + return 255, str(exc) + return res.returncode, (res.stdout or "") + (res.stderr or "") + + +# --------------------------------------------------------------------------- # +# Lecture des sorties de l'hôte — fonctions pures +# --------------------------------------------------------------------------- # +def parse_pveversion(text: str) -> str: + """« pve-manager/9.2.11/f6997e69 (running kernel: 7.0.14-12-pve) » -> 9.2.11. + + Sert de PREUVE que l'hôte est bien un Proxmox : une adresse saisie à la + main peut être n'importe quoi, et la première commande `qm` échouerait + alors sur un message qui ne dit pas pourquoi. + """ + m = re.search(r"pve-manager/(\d[\w.]*)", text or "") + return m.group(1) if m else "" + + +def parse_qm_list(text: str) -> list: + """Sortie de « qm list » -> [{vmid, name, status, mem, disk}]. + + L'en-tête et les lignes vides sont écartés. Les colonnes sont séparées par + des espaces, mais un NOM peut en contenir : on découpe donc par la + GAUCHE (vmid) et par la DROITE (status, mem, bootdisk, pid), et ce qui + reste au milieu est le nom. + """ + out = [] + for ligne in (text or "").splitlines(): + parts = ligne.split() + if len(parts) < 6 or not parts[0].isdigit(): + continue + vmid = parts[0] + pid = parts[-1] + bootdisk = parts[-2] + mem = parts[-3] + status = parts[-4] + nom = " ".join(parts[1:-4]) + out.append( + { + "vmid": int(vmid), + "name": nom, + "status": status, + "mem": mem, + "disk": bootdisk, + "pid": pid, + } + ) + return out + + +def parse_storages(text: str) -> list: + """Sortie de « pvesm status --content images » -> [{name, type, avail}].""" + out = [] + for ligne in (text or "").splitlines(): + parts = ligne.split() + if len(parts) < 6 or parts[0] == "Name": + continue + try: + avail = int(parts[5]) + except ValueError: + continue + out.append( + { + "name": parts[0], + "type": parts[1], + "actif": parts[2] == "active", + "avail": avail * 1024, # pvesm compte en Kio + } + ) + return out + + +def parse_bridges(text: str) -> list: + """Sortie de « ip -o link show type bridge » -> ['vmbr0', …].""" + ponts = [] + for ligne in (text or "").splitlines(): + parts = ligne.split(":") + if len(parts) > 1: + nom = parts[1].strip().split("@")[0] + if nom: + ponts.append(nom) + return ponts + + +def parse_guest_ips(text: str) -> list: + """Adresses IPv4 rendues par « qm guest cmd network-get-interfaces ». + + L'agent invité répond du JSON. Les adresses de bouclage sont écartées : la + question posée est « où joindre cette VM », et 127.0.0.1 n'y répond pas. + """ + try: + data = json.loads(text or "") + except (ValueError, TypeError): + return [] + ips = [] + for iface in data if isinstance(data, list) else []: + for addr in iface.get("ip-addresses") or []: + ip = addr.get("ip-address") or "" + if addr.get("ip-address-type") == "ipv4" and not ip.startswith( + "127." + ): + ips.append(ip) + return ips + + +def mac_from_config(text: str) -> str: + """MAC de net0 dans « qm config ». + + C'est le seul lien entre une VM Proxmox et son adresse IP quand l'agent + invité n'est pas là : l'image cloud Debian ne l'embarque PAS, et Proxmox ne + distribue pas les baux lui-même — il ne peut donc pas répondre. + """ + m = re.search( + r"^net0:.*?([0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){5})", + text or "", + re.M, + ) + return m.group(1).lower() if m else "" + + +def ip_from_neigh(text: str, mac: str) -> str: + """Adresse vue par le voisinage de l'hôte (« ip neigh »), pour cette MAC. + + Marche dès que la VM a émis un paquet — un bail DHCP suffit. C'est le + repli quand l'agent invité manque, et il ne demande rien à l'invité. + """ + if not mac: + return "" + cible = mac.lower() + for ligne in (text or "").splitlines(): + if cible in ligne.lower(): + parts = ligne.split() + if parts and re.match(r"^\d+\.\d+\.\d+\.\d+$", parts[0]): + return parts[0] + return "" + + +def next_vmid(existing, mini: int = VMID_MIN) -> int: + """Premier VMID libre à partir de `mini`. + + Proxmox refuse un VMID déjà pris, et le message (« CT/VM 100 already + exists ») arrive APRÈS le téléchargement de l'image : on choisit donc + avant, d'après ce que l'hôte déclare. + """ + pris = {int(v["vmid"]) for v in existing or () if str(v["vmid"]).isdigit()} + vmid = max(mini, VMID_MIN) + while vmid in pris: + vmid += 1 + return vmid + + +def pick_storage(storages, voulu: str = "") -> str: + """Stockage où poser le disque : celui demandé, sinon le plus libre. + + Aucun repli sur un nom devinné (« local-lvm » n'existe pas partout) : sans + stockage utilisable, on rend une chaîne vide et l'appelant le dit. + """ + utiles = [s for s in storages or () if s.get("actif")] + if voulu: + return voulu if any(s["name"] == voulu for s in utiles) else "" + if not utiles: + return "" + return max(utiles, key=lambda s: s.get("avail") or 0)["name"] + + +def pick_bridge(bridges, voulu: str = "") -> str: + """Pont réseau : celui demandé, sinon vmbr0, sinon le premier déclaré.""" + ponts = list(bridges or ()) + if voulu: + return voulu if voulu in ponts else "" + if DEFAULT_BRIDGE in ponts: + return DEFAULT_BRIDGE + return ponts[0] if ponts else "" + + +# --------------------------------------------------------------------------- # +# Construction des commandes — fonctions pures +# --------------------------------------------------------------------------- # +# Réseau interne proposé quand l'hôte n'a AUCUN pont. Choisi pour être sûr : +# un pont sans port physique ne peut pas couper l'accès SSH à l'hôte, alors +# qu'ajouter « bridge-ports enp1s0 » déplace l'adresse et coupe la session en +# cours — sur une machine distante, c'est un aller sans retour. +INTERNAL_BRIDGE = "vmbr0" +INTERNAL_CIDR = "10.10.10.1/24" + + +def parse_bridge_config(text: str) -> dict: + """/etc/network/interfaces -> {pont: {ports, address}}. + + Sert à savoir si un pont donne sur le LAN (il a des ports) ou s'il est + interne (« bridge-ports none ») : les VM du premier prennent leur adresse + en DHCP, celles du second n'en auraient aucune et doivent recevoir une + adresse fixe. + """ + ponts = {} + courant = "" + for ligne in (text or "").splitlines(): + nu = ligne.strip() + m = re.match(r"^iface\s+(\S+)\s", nu) + if m: + courant = m.group(1) + continue + if not courant: + continue + if nu.startswith("bridge-ports") or nu.startswith("bridge_ports"): + ports = nu.split(None, 1)[1].strip() if " " in nu else "" + ponts.setdefault(courant, {})["ports"] = ( + "" if ports in ("none", "") else ports + ) + elif nu.startswith("address"): + ponts.setdefault(courant, {})["address"] = nu.split()[1] + return ponts + + +def bridge_setup_cmds( + nom: str = INTERNAL_BRIDGE, + cidr: str = INTERNAL_CIDR, + uplink: str = "", +) -> list: + """Crée un pont INTERNE, et le masque derrière l'uplink si demandé. + + « bridge-ports none » : aucune interface physique n'est touchée, donc + l'accès à l'hôte survit. Les lignes post-up/post-down de masquerading sont + celles que documente Proxmox pour un hôte à une seule adresse routée : sans + elles les VM se parlent entre elles mais ne sortent pas. + """ + reseau = cidr.rsplit(".", 1)[0] + ".0/" + cidr.split("/")[1] + bloc = [ + "", + f"auto {nom}", + f"iface {nom} inet static", + f" address {cidr}", + " bridge-ports none", + " bridge-stp off", + " bridge-fd 0", + ] + if uplink: + bloc += [ + f" post-up iptables -t nat -A POSTROUTING -s '{reseau}'" + f" -o {uplink} -j MASQUERADE", + f" post-down iptables -t nat -D POSTROUTING -s '{reseau}'" + f" -o {uplink} -j MASQUERADE", + ] + texte = "\n".join(bloc) + "\n" + cmds = [ + # Idempotent : on n'ajoute la strophe que si le pont n'y est pas déjà. + f"grep -qE '^(auto|iface) {nom}( |$)' /etc/network/interfaces" + f" || printf '%s' {shlex.quote(texte)} >> /etc/network/interfaces", + ] + if uplink: + cmds.append( + "printf 'net.ipv4.ip_forward=1\\n' >" + " /etc/sysctl.d/99-erplibre-nat.conf && sysctl -q -p" + " /etc/sysctl.d/99-erplibre-nat.conf" + ) + # ifup plutôt qu'« ifreload -a » : recharger TOUTE la configuration d'un + # hôte distant peut emporter l'interface qui porte la session. + cmds.append(f"ifup {nom} 2>/dev/null || ifreload -a") + return cmds + + +def ipconfig_for(pont_info: dict, vmid: int) -> str: + """« ip=dhcp » sur un pont qui donne sur le LAN, adresse FIXE sur un pont + interne — où aucun serveur DHCP ne répondrait. + + L'adresse est dérivée du VMID : deux VM déployées à la suite ne peuvent pas + se retrouver avec la même, et le lien entre les deux reste lisible. + """ + info = pont_info or {} + adresse = info.get("address") or "" + if info.get("ports") or not adresse: + return "ip=dhcp" + base, _, masque = adresse.partition("/") + tronc = base.rsplit(".", 1)[0] + hote = 50 + (int(vmid) % 200) + return f"ip={tronc}.{hote}/{masque or '24'},gw={base}" + + +def ip_from_ipconfig(ipconfig: str) -> str: + """Adresse fixe d'un « ip=10.10.10.150/24,gw=… », ou '' si c'est du DHCP. + + Quand c'est NOUS qui avons attribué l'adresse, la chercher ensuite est + absurde : elle est connue avant que la VM ne démarre. La découverte (agent + invité, voisinage de l'hôte) ne sert qu'au DHCP. + """ + m = re.search(r"ip=(\d+\.\d+\.\d+\.\d+)", ipconfig or "") + return m.group(1) if m else "" + + +def image_fetch_cmd(url: str, nom: str, repertoire: str = IMAGE_DIR) -> str: + """Télécharge l'image cloud SUR l'hôte Proxmox, une seule fois. + + C'est là que le disque de la VM sera écrit : faire descendre l'image chez + soi pour la renvoyer ensuite doublerait le transfert. Le test de présence + évite de retélécharger 325 Mio à chaque VM. + """ + cible = f"{repertoire}/{nom}" + return ( + f"mkdir -p {shlex.quote(repertoire)} && " + f"if [ -s {shlex.quote(cible)} ]; then " + f'echo "image déjà présente : {cible}"; else ' + f"wget -q --show-progress -O {shlex.quote(cible)} {shlex.quote(url)}; " + f"fi" + ) + + +def create_cmds(vmid: int, spec: dict) -> list: + """Séquence complète de création d'une VM, dans l'ordre. + + Une liste et non une seule commande : chaque étape est lisible dans le + journal, et un échec nomme celle qui a échoué. C'est le contraire d'un + « qm create » géant dont on ne sait pas quel morceau a cédé. + """ + nom = spec["name"] + stockage = spec["storage"] + image = f"{spec.get('image_dir', IMAGE_DIR)}/{spec['image']}" + cmds = [ + # 1. La coquille : processeur, mémoire, réseau, contrôleur, agent. + "qm create {id} --name {nom} --memory {mem} --cores {cpu}" + " --cpu host --ostype l26 --scsihw virtio-scsi-single" + " --net0 virtio,bridge={pont} --agent enabled=1" + " --serial0 socket --vga serial0".format( + id=vmid, + nom=shlex.quote(nom), + mem=int(spec["memory"]), + cpu=int(spec["vcpus"]), + pont=spec["bridge"], + ), + # 2. Le disque, importé DEPUIS l'image cloud. « import-from » (PVE 8+) + # remplace l'ancien « qm importdisk » en une seule étape et attache + # le disque du même coup. + f"qm set {vmid} --scsi0" + f" {stockage}:0,import-from={shlex.quote(image)},discard=on,ssd=1", + # 3. Le lecteur cloud-init, et l'ordre d'amorçage. Sans « boot order », + # Proxmox laisse le disque importé hors de la liste et la VM démarre + # sur le réseau. + f"qm set {vmid} --ide2 {stockage}:cloudinit" + f" --boot order=scsi0 --bootdisk scsi0", + ] + # 4. cloud-init : utilisateur, clé, réseau. La clé est un FICHIER sur + # l'hôte — « --sshkeys » n'accepte pas la clé en ligne. + ci = ( + f"qm set {vmid} --ciuser {shlex.quote(spec.get('user') or 'erplibre')}" + ) + if spec.get("sshkey_path"): + ci += f" --sshkeys {shlex.quote(spec['sshkey_path'])}" + if spec.get("password"): + ci += f" --cipassword {shlex.quote(spec['password'])}" + ci += f" --ipconfig0 {spec.get('ipconfig') or 'ip=dhcp'}" + cmds.append(ci) + # 5. La taille. L'image cloud fait 2 Gio : sans agrandissement, il ne reste + # rien pour installer quoi que ce soit. + if spec.get("disk"): + cmds.append(f"qm resize {vmid} scsi0 {spec['disk']}") + if spec.get("start", True): + cmds.append(f"qm start {vmid}") + return cmds + + +def destroy_cmds(vmid: int, purge: bool = True) -> list: + """Arrêt puis suppression. « --purge » retire aussi les disques et les + entrées de sauvegarde : sans lui, le stockage garde des volumes orphelins + que rien ne réclame plus.""" + return [ + f"qm stop {vmid} --skiplock 1 || true", + f"qm destroy {vmid} --purge {1 if purge else 0}" + " --destroy-unreferenced-disks 1", + ] + + +def resize_cmd(vmid: int, taille: str, disque: str = "scsi0") -> str: + """« +10G » agrandit, « 40G » fixe. Proxmox REFUSE de rétrécir un disque — + le dire ici évite de croire à un bug de l'outil.""" + return f"qm resize {vmid} {disque} {taille}" + + +def status_cmd(vmid: int) -> str: + return f"qm status {vmid} --verbose" + + +def guest_ip_cmd(vmid: int) -> str: + return f"qm guest cmd {vmid} network-get-interfaces" + + +def console_cmd(vmid: int) -> str: + """Console série. `qm terminal` demande serial0, que create_cmds pose.""" + return f"qm terminal {vmid}" + + +def orphan_disks_cmd() -> str: + """Volumes de disque qui n'appartiennent à aucune VM déclarée. + + Proxmox ne les efface pas tout seul : un « qm destroy » sans « --purge », + ou une création interrompue, en laisse. On les LISTE, on n'efface rien + sans demander. + """ + return ( + "for s in $(pvesm status --content images | awk 'NR>1 {print $1}'); " + 'do pvesm list "$s" 2>/dev/null; done' + ) + + +def parse_orphans(text: str, vmids) -> list: + """[(volid, taille)] des volumes dont le VMID n'existe plus.""" + connus = {str(v) for v in vmids or ()} + out = [] + for ligne in (text or "").splitlines(): + parts = ligne.split() + if len(parts) < 5 or parts[0] == "Volid": + continue + volid, vmid = parts[0], parts[-1] + if vmid.isdigit() and vmid not in connus: + try: + taille = int(parts[3]) + except ValueError: + taille = 0 + out.append((volid, taille)) + return out diff --git a/script/qemu/README.base.md b/script/qemu/README.base.md index cf6668a..4f9afbf 100644 --- a/script/qemu/README.base.md +++ b/script/qemu/README.base.md @@ -184,10 +184,24 @@ Catalog, per architecture (`deploy_qemu.py` is the source of truth): | rocky | `9`, `10` (default) | ✔ | ✔ | ✔ | | opensuse | `16.0` (default), `tumbleweed` | ✔ | ✔ | ✔ | | arch | `latest` | ✔ | — | — | +| proxmox | `9` | ✔ | ✔ | — | Fedora builds s390x only for the current release, and on a separate tree (`fedora-secondary`) — hence the single version there. +`proxmox` is Proxmox VE, and it deserves a word: it publishes **no cloud +image** — its ISO is an installer that formats the disk. So the deployment +does what upstream itself documents for every other case, *Proxmox VE on +Debian*: it downloads the **Debian trixie** cloud image (the very same file, so +a Debian 13 and a Proxmox deployment share one download) and the `pve` +packages turn it into a hypervisor — Proxmox kernel, web UI on `:8006`. + +The version number is Proxmox's, not Debian's: PVE 9 = trixie. arm64 has been +official since PVE 9 (the upstream `trixie` Release announces `amd64 arm64`, +and the arm64 index really serves `proxmox-ve`). s390x is absent and will stay +so by this route: the repository has no `binary-s390x` index at all — the +catalog says it before the deployment rather than failing at the first `apt`. + `opensuse` covers two distinct products, not two versions of one. Leap `16.0` is numbered and stable (SLE base) and is the default. `tumbleweed` is the rolling one, kept as a bellwether for breakage to come: its snapshot drift is @@ -218,6 +232,7 @@ Catalogue, par architecture (`deploy_qemu.py` fait autorité) : | rocky | `9`, `10` (défaut) | ✔ | ✔ | ✔ | | opensuse | `16.0` (défaut), `tumbleweed` | ✔ | ✔ | ✔ | | arch | `latest` | ✔ | — | — | +| proxmox | `9` | ✔ | ✔ | — | Fedora ne construit s390x que pour la version courante, et sur une arborescence à part (`fedora-secondary`) — d'où la version unique. @@ -232,6 +247,38 @@ Les deux livrent un qpdf au-dessus du seuil de pikepdf : la compilation de qpdf, une demi-heure, ne s'y déclenche jamais — ce qui compte sous émulation s390x. +`proxmox`, c'est Proxmox VE, et il mérite un mot : il ne publie **aucune image +cloud** — son ISO est un installateur qui formate le disque. Le déploiement +fait donc ce que l'amont documente lui-même pour tous les autres cas, +*Proxmox VE sur Debian* : il télécharge l'image cloud **Debian trixie** (le +même fichier, si bien qu'un déploiement Debian 13 et un Proxmox se partagent un +seul téléchargement) et les paquets `pve` en font un hyperviseur — noyau +Proxmox, interface web sur `:8006`. + +Le numéro de version est celui de Proxmox, pas de Debian : PVE 9 = trixie. +arm64 est officiel depuis PVE 9 (le Release `trixie` de l'amont annonce +`amd64 arm64`, et l'index arm64 sert bien `proxmox-ve`). s390x est absent et le +restera par cette voie : le dépôt n'a aucun index `binary-s390x` — le catalogue +le dit avant le déploiement plutôt que d'échouer au premier `apt`. + +Une VM Proxmox est un hyperviseur DANS une VM : ses propres invités demandent +la virtualisation imbriquée à tous les étages. L'installation se fait par le +profil « Hyperviseur Proxmox VE (sans Odoo) » du menu de déploiement, ou à la +main dans la VM : + +```bash +sudo ./script/proxmox/install_proxmox.sh --dry-run # dit ce qu'il ferait +sudo ./script/proxmox/install_proxmox.sh # puis : sudo reboot +``` + +Trois pièges de l'image cloud, tous rencontrés sur une VM réelle et traités par +le script : cloud-init tient encore le verrou d'`apt` au premier démarrage ; +`grub-pc`, tiré par les paquets `pve`, demande sur quel disque s'installer et +bloque toute la transaction sans préréponse ; et le chemin de secours UEFI +(`\EFI\BOOT\`) reçoit les binaires GRUB de Proxmox mais pas le `grub.cfg` +qui dit où trouver la configuration — sans quoi la VM s'arrête sur l'invite +`grub>`, sans menu ni noyau. + Fournissez un chemin d'image en argument positionnel pour surcharger l'emplacement de téléchargement automatique. diff --git a/script/qemu/README.fr.md b/script/qemu/README.fr.md index fcff7ef..e63deef 100644 --- a/script/qemu/README.fr.md +++ b/script/qemu/README.fr.md @@ -103,6 +103,7 @@ Catalogue, par architecture (`deploy_qemu.py` fait autorité) : | rocky | `9`, `10` (défaut) | ✔ | ✔ | ✔ | | opensuse | `16.0` (défaut), `tumbleweed` | ✔ | ✔ | ✔ | | arch | `latest` | ✔ | — | — | +| proxmox | `9` | ✔ | ✔ | — | Fedora ne construit s390x que pour la version courante, et sur une arborescence à part (`fedora-secondary`) — d'où la version unique. @@ -117,6 +118,38 @@ Les deux livrent un qpdf au-dessus du seuil de pikepdf : la compilation de qpdf, une demi-heure, ne s'y déclenche jamais — ce qui compte sous émulation s390x. +`proxmox`, c'est Proxmox VE, et il mérite un mot : il ne publie **aucune image +cloud** — son ISO est un installateur qui formate le disque. Le déploiement +fait donc ce que l'amont documente lui-même pour tous les autres cas, +*Proxmox VE sur Debian* : il télécharge l'image cloud **Debian trixie** (le +même fichier, si bien qu'un déploiement Debian 13 et un Proxmox se partagent un +seul téléchargement) et les paquets `pve` en font un hyperviseur — noyau +Proxmox, interface web sur `:8006`. + +Le numéro de version est celui de Proxmox, pas de Debian : PVE 9 = trixie. +arm64 est officiel depuis PVE 9 (le Release `trixie` de l'amont annonce +`amd64 arm64`, et l'index arm64 sert bien `proxmox-ve`). s390x est absent et le +restera par cette voie : le dépôt n'a aucun index `binary-s390x` — le catalogue +le dit avant le déploiement plutôt que d'échouer au premier `apt`. + +Une VM Proxmox est un hyperviseur DANS une VM : ses propres invités demandent +la virtualisation imbriquée à tous les étages. L'installation se fait par le +profil « Hyperviseur Proxmox VE (sans Odoo) » du menu de déploiement, ou à la +main dans la VM : + +```bash +sudo ./script/proxmox/install_proxmox.sh --dry-run # dit ce qu'il ferait +sudo ./script/proxmox/install_proxmox.sh # puis : sudo reboot +``` + +Trois pièges de l'image cloud, tous rencontrés sur une VM réelle et traités par +le script : cloud-init tient encore le verrou d'`apt` au premier démarrage ; +`grub-pc`, tiré par les paquets `pve`, demande sur quel disque s'installer et +bloque toute la transaction sans préréponse ; et le chemin de secours UEFI +(`\EFI\BOOT\`) reçoit les binaires GRUB de Proxmox mais pas le `grub.cfg` +qui dit où trouver la configuration — sans quoi la VM s'arrête sur l'invite +`grub>`, sans menu ni noyau. + Fournissez un chemin d'image en argument positionnel pour surcharger l'emplacement de téléchargement automatique. diff --git a/script/qemu/README.md b/script/qemu/README.md index 38cf1e2..dca1d18 100644 --- a/script/qemu/README.md +++ b/script/qemu/README.md @@ -97,10 +97,24 @@ Catalog, per architecture (`deploy_qemu.py` is the source of truth): | rocky | `9`, `10` (default) | ✔ | ✔ | ✔ | | opensuse | `16.0` (default), `tumbleweed` | ✔ | ✔ | ✔ | | arch | `latest` | ✔ | — | — | +| proxmox | `9` | ✔ | ✔ | — | Fedora builds s390x only for the current release, and on a separate tree (`fedora-secondary`) — hence the single version there. +`proxmox` is Proxmox VE, and it deserves a word: it publishes **no cloud +image** — its ISO is an installer that formats the disk. So the deployment +does what upstream itself documents for every other case, *Proxmox VE on +Debian*: it downloads the **Debian trixie** cloud image (the very same file, so +a Debian 13 and a Proxmox deployment share one download) and the `pve` +packages turn it into a hypervisor — Proxmox kernel, web UI on `:8006`. + +The version number is Proxmox's, not Debian's: PVE 9 = trixie. arm64 has been +official since PVE 9 (the upstream `trixie` Release announces `amd64 arm64`, +and the arm64 index really serves `proxmox-ve`). s390x is absent and will stay +so by this route: the repository has no `binary-s390x` index at all — the +catalog says it before the deployment rather than failing at the first `apt`. + `opensuse` covers two distinct products, not two versions of one. Leap `16.0` is numbered and stable (SLE base) and is the default. `tumbleweed` is the rolling one, kept as a bellwether for breakage to come: its snapshot drift is diff --git a/script/qemu/deploy_qemu.py b/script/qemu/deploy_qemu.py index 86f700e..b874180 100755 --- a/script/qemu/deploy_qemu.py +++ b/script/qemu/deploy_qemu.py @@ -140,6 +140,30 @@ ARCH_VERSIONS: dict[str, tuple[str, str, int, str]] = { "latest": ("latest", "archlinux", 1024, "20G"), } +# Proxmox VE ne publie AUCUNE image cloud : son ISO est un installateur, et la +# voie que l'amont documente pour tout le reste est « Proxmox VE sur Debian ». +# La base est donc l'image cloud Debian, que les paquets pve transforment en +# hyperviseur. Le numéro est celui de PROXMOX, pas de Debian : PVE 9 = trixie. +# +# arm64 est officiel depuis PVE 9 — vérifié dans le dépôt amont, dont le +# Release de trixie annonce « amd64 arm64 » et dont l'index arm64 sert bien +# proxmox-ve. bookworm (PVE 8), lui, est amd64 seulement : d'où une seule +# version au catalogue, celle qui couvre les deux architectures. +# +# s390x n'y figure pas et n'y figurera pas par cette voie : l'index +# « binary-s390x » du dépôt répond 404. Ce n'est pas une difficulté, c'est une +# absence — et ARCH_DISTRO_SUPPORT la dit à la place d'un échec au montage. +# +# RAM/disque : 4 Gio et 32 Go, pas les 1 Gio/20 Go de Debian. Proxmox demande +# 2 Gio pour lui seul, et l'installation télécharge son propre noyau ; 20 Go ne +# laisseraient pas la place d'une seule VM invitée. +PROXMOX_VERSIONS: dict[str, tuple[str, str, int, str]] = { + "9": ("trixie", "debian13", 4096, "32G"), +} + +# Version Debian dont l'image sert de base à chaque version de Proxmox. +PROXMOX_DEBIAN_BASE: dict[str, str] = {"9": "13"} + # distro -> (table des versions, version par défaut). DISTROS: dict[str, tuple[dict[str, tuple[str, str, int, str]], str]] = { "ubuntu": (UBUNTU_VERSIONS, "24.04"), @@ -149,6 +173,7 @@ DISTROS: dict[str, tuple[dict[str, tuple[str, str, int, str]], str]] = { "rocky": (ROCKY_VERSIONS, "10"), "opensuse": (OPENSUSE_VERSIONS, "16.0"), "arch": (ARCH_VERSIONS, "latest"), + "proxmox": (PROXMOX_VERSIONS, "9"), } # Traduction de l'arch générique (amd64/arm64) vers le nom propre à la distro. @@ -235,6 +260,7 @@ ARM64_DISTROS: tuple[str, ...] = ( "almalinux", "rocky", "opensuse", + "proxmox", ) @@ -429,6 +455,13 @@ def image_candidates( f"{base}/{code}/latest/debian-{version}-genericcloud-{a}.qcow2" for base in DEBIAN_CLOUD_BASES ] + if distro == "proxmox": + # C'est bien l'image DEBIAN qu'on télécharge : « proxmox-ve », posé + # dessus, en fait l'hyperviseur. Le miroir ne connaît que le numéro de + # Debian, jamais celui de Proxmox. + return image_candidates( + "debian", code, arch, PROXMOX_DEBIAN_BASE[version], dry_run + ) if distro == "fedora": return [resolve_fedora_url(version, arch, dry_run)] if distro == "almalinux": @@ -525,6 +558,14 @@ def default_image_name(distro: str, code: str, arch: str, version: str) -> str: return f"ubuntu-{version}-server-cloudimg-{a}.img" if distro == "debian": return f"debian-{version}-genericcloud-{a}.qcow2" + if distro == "proxmox": + # Le fichier téléchargé EST celui de Debian : lui donner le même nom + # de cache fait qu'un déploiement Debian 13 et un Proxmox se + # PARTAGENT le téléchargement (325 Mio) au lieu d'en faire deux. Sans + # cette branche, le repli de fin nommait l'image « fedora-cloud-9 ». + return ( + f"debian-{PROXMOX_DEBIAN_BASE[version]}-genericcloud-{a}.qcow2" + ) if distro == "arch": return f"arch-linux-{a}-cloudimg.qcow2" if distro == "opensuse": @@ -1615,6 +1656,7 @@ DISTRO_LABELS: dict[str, str] = { "rocky": "Rocky Linux", "opensuse": "openSUSE", "arch": "Arch Linux", + "proxmox": "Proxmox VE", } # Gestionnaire de paquets de chaque distribution du catalogue. @@ -1626,6 +1668,8 @@ DISTRO_PKG: dict[str, str] = { "rocky": "dnf", "opensuse": "zypper", "arch": "pacman", + # Debian dessous : c'est apt qui sert, et le guide de connexion le dit. + "proxmox": "apt", } diff --git a/script/test/run_unit_test.sh b/script/test/run_unit_test.sh index f25c954..723f4d4 100755 --- a/script/test/run_unit_test.sh +++ b/script/test/run_unit_test.sh @@ -42,7 +42,8 @@ FILES=("$@") if [[ ${#FILES[@]} -eq 0 ]]; then # Aucun argument : tout ce que le dépôt sait tester sans base de données. mapfile -t FILES < <(ls test/test_qemu_*.py test/test_mobile_*.py \ - test/test_todo_*.py test/test_install_*.py 2>/dev/null) + test/test_todo_*.py test/test_install_*.py test/test_run_*.py \ + 2>/dev/null) fi fail=0 diff --git a/script/todo/qemu_deploy_form.py b/script/todo/qemu_deploy_form.py index 6e0d3f4..586956c 100644 --- a/script/todo/qemu_deploy_form.py +++ b/script/todo/qemu_deploy_form.py @@ -298,6 +298,10 @@ def build_spec(vms, domains, form): "python_provider": form.get("python_provider", ""), "app_store": form.get("app_store", "deb"), "install": form["install"], + # Au NIVEAU DU DÉPLOIEMENT, pas de l'installation : une VM sans + # ERPLibre se suit aussi (cloud-init, puis relevé système). Absent de + # cette assemblée, le choix du formulaire n'atteignait jamais la spec. + "monitor": form.get("monitor", True), "add_ssh_config": form["add_ssh_config"], "parallelism": form["parallelism"], } @@ -1828,6 +1832,10 @@ def run_deploy_form(ctx, run_app: bool = True): } key = self.query_one("#f_key", Input).value.strip() return { + # Le suivi est demandé au NIVEAU DU DÉPLOIEMENT, pas de + # l'installation : décocher ERPLibre emportait la case avec + # elle, et le tableau de bord ne s'ouvrait plus du tout. + "monitor": self.query_one("#f_monitor", Checkbox).value, "res_label": ( t("custom") if self.profile == "custom" diff --git a/script/todo/qemu_install_monitor.py b/script/todo/qemu_install_monitor.py index a00420c..c51e395 100644 --- a/script/todo/qemu_install_monitor.py +++ b/script/todo/qemu_install_monitor.py @@ -80,7 +80,11 @@ def list_install_runs() -> list: def _launch_one( - ip: str, remote_cmd: str, log_path: str, name: str = "" + ip: str, + remote_cmd: str, + log_path: str, + name: str = "", + installs: bool = True, ) -> None: """Lance une install SSH DÉTACHÉE : attend le sshd, exécute, journalise la sortie puis écrit le marqueur de fin avec le code de sortie.""" @@ -102,7 +106,13 @@ def _launch_one( # log reste VIDE pendant tout le boot émulé et paraît « bloqué ». msg_wait = t("Waiting for the VM to start (boot + cloud-init)") msg_slow = t("(an emulated architecture can be slow; this is normal)") - msg_ready = t("VM ready - starting the ERPLibre install") + # « installation ERPLibre en cours » sur un déploiement qui n'installe + # RIEN était un mensonge du journal : la ligne dit maintenant ce qui suit. + msg_ready = ( + t("VM ready - starting the ERPLibre install") + if installs + else t("VM ready - taking its measurements") + ) msg_giveup = t( "cloud-init still running after 20 min - install starts anyway" " (it waits for cloud-init first)" @@ -230,19 +240,26 @@ def _launch_one( def _log_header(vm: dict, branch: str, when: str) -> str: """En-tête du log : date, VM, distribution, version, architecture, branche. Permet d'identifier l'installation d'un coup d'œil (et de ne jamais laisser - le log vide pendant l'attente du boot).""" + le log vide pendant l'attente du boot). + + Sans branche, il n'y a rien à installer : le titre le dit et la ligne + « Branche » disparaît, au lieu d'annoncer une installation ERPLibre qui + n'aura pas lieu. + """ distro = vm.get("distro") or "?" version = vm.get("version") or "" arch = vm.get("arch") or "?" bar = "=" * 64 + titre = t("installation") if branch else t("VM start-up") + ligne_branche = f" Branche : {branch}\n" if branch else "" return ( f"{bar}\n" - f" ERPLibre — {t('installation')}\n" + f" ERPLibre — {titre}\n" f" Date : {when}\n" f" VM : {vm['name']}\n" f" Distribution : {distro} {version}\n" f" Architecture : {arch}\n" - f" Branche : {branch}\n" + f"{ligne_branche}" f" IP : {vm['ip']}\n" f"{bar}\n\n" ) @@ -268,7 +285,11 @@ def launch_installs(vms: list[dict], branch: str, remote_cmd: str) -> str: # plus le même pour toutes. `remote_cmd` reste le défaut, ce qui laisse # intacts les appelants qui n'en fournissent qu'une. _launch_one( - vm["ip"], vm.get("remote_cmd") or remote_cmd, log_path, vm["name"] + vm["ip"], + vm.get("remote_cmd") or remote_cmd, + log_path, + vm["name"], + installs=bool(branch), ) entries.append( { @@ -411,6 +432,10 @@ def active_run(): # avertissements connus et bénins) : on réutilise la MÊME logique de détection # que la suite de tests ERPLibre pour analyser les logs d'installation. _LST_IGNORE_WARNING = ( + # ssh annonce l'ajout d'une clé d'hôte à chaque PREMIÈRE connexion à une + # VM neuve. Ce n'est pas un avertissement d'installation : compté, il + # allumait la colonne ⚠ sur TOUTE installation, dès sa première ligne. + "Warning: Permanently added", "have the same label:", "odoo.addons.code_generator.extractor_module_file: Ignore next error about" " ALTER TABLE DROP CONSTRAINT.", @@ -1059,6 +1084,245 @@ def virsh_domstates() -> dict: return states +# Fenêtre du débit d'écriture. Dix secondes : le disque d'une VM en +# installation travaille par rafales — « apt » décompresse, « poetry » compile, +# puis plus rien pendant trois secondes. Un instantané de deux secondes affiche +# donc tantôt 0, tantôt 200 Mo/s, et ne dit rien. Sur dix secondes, le chiffre +# devient un débit qu'on peut comparer d'une VM à l'autre. +WRITE_WINDOW = 10.0 + +# Âge maximal d'un relevé du ballon mémoire. Au-delà, la valeur est TAISÉE +# plutôt que montrée : virtio-balloon ne publie ses compteurs que si une +# période de collecte est armée, et une valeur figée depuis une demi-heure +# ferait croire à une VM au repos alors qu'elle compile. +BALLOON_MAX_AGE = 30.0 + +# Les compteurs du ballon sont en kibioctets ; ceux des blocs, en octets. +_KIB = 1024 + + +def parse_domstats(text: str) -> dict: + """Sortie de « virsh domstats » -> {nom: relevé}. + + Un relevé porte : ram_used / ram_total (octets), ram_at (horodatage du + dernier rapport du ballon), wr_bytes (cumul écrit depuis le démarrage du + processus QEMU), disk_used / disk_total (octets). + + Le seed ISO est ÉCARTÉ des disques : monté en lecture seule, il n'est + jamais écrit, et sa capacité (quelques mégaoctets) l'aurait fait passer + pour le disque système sur une VM dont le qcow2 n'est pas encore alloué. + """ + out = {} + nom = None + brut = {} + + def clore(): + if nom is None: + return + rec = { + "ram_used": 0, + "ram_total": 0, + "ram_at": 0, + "wr_bytes": 0, + "disk_used": 0, + "disk_total": 0, + } + dispo = brut.get("balloon.available") + util = brut.get("balloon.usable") + if dispo: + rec["ram_total"] = dispo * _KIB + if util is not None: + rec["ram_used"] = max(0, (dispo - util)) * _KIB + rec["ram_at"] = brut.get("balloon.last-update") or 0 + # Disques : on parcourt les index déclarés par block.count. + meilleur = 0 + for i in range(int(brut.get("block.count") or 0)): + chemin = brut.get(f"block.{i}.path.str") or "" + if chemin.lower().endswith(".iso"): + continue + rec["wr_bytes"] += brut.get(f"block.{i}.wr.bytes") or 0 + cap = brut.get(f"block.{i}.capacity") or 0 + if cap >= meilleur: + meilleur = cap + rec["disk_total"] = cap + rec["disk_used"] = brut.get(f"block.{i}.allocation") or 0 + out[nom] = rec + + for ligne in (text or "").splitlines(): + ligne = ligne.strip() + if ligne.startswith("Domain:"): + clore() + nom = ligne.split("'")[1] if "'" in ligne else None + brut = {} + continue + if nom is None or "=" not in ligne: + continue + cle, _, val = ligne.partition("=") + try: + brut[cle] = int(val) + except ValueError: + # Les valeurs non numériques (chemins, noms de device) sont + # gardées à part : « block.0.path » en est une, et c'est elle qui + # démasque le seed ISO. + brut[f"{cle}.str"] = val + clore() + return out + + +class WriteWindow: + """Débit d'écriture moyen par VM, sur une fenêtre glissante.""" + + def __init__(self, window=WRITE_WINDOW): + self.window = window + self._hist = {} + + def add(self, name, wr_bytes, now): + hist = self._hist.setdefault(name, []) + # Un compteur qui RECULE veut dire que le domaine a redémarré : le + # processus QEMU est neuf, ses compteurs repartent de zéro. Sans ce + # garde, le débit affiché serait négatif, puis énorme au relevé + # suivant. Une installation redémarre la VM : le cas est la règle. + if hist and wr_bytes < hist[-1][1]: + hist.clear() + hist.append((now, wr_bytes)) + limite = now - self.window + while len(hist) > 2 and hist[1][0] < limite: + hist.pop(0) + + def rate(self, name): + """Octets/s, ou None tant que la fenêtre n'a pas de quoi conclure.""" + hist = self._hist.get(name) or [] + if len(hist) < 2: + return None + span = hist[-1][0] - hist[0][0] + if span < 1.0: + return None + return max(0.0, (hist[-1][1] - hist[0][1]) / span) + + def total(self, name): + """Écrit depuis le premier relevé de la fenêtre (octets).""" + hist = self._hist.get(name) or [] + return hist[-1][1] if hist else None + + +def fmt_rate(bps) -> str: + """Octets/s -> « 12.3M/s ». « - » tant qu'on ne sait pas.""" + return "-" if bps is None else f"{_fmt_size(int(bps))}/s" + + +def _fmt_tight(nbytes) -> str: + """Comme _fmt_size, mais sans décimale au-delà de dix unités. + + « 63G » plutôt que « 62.6G » : dans une colonne de tableau, ces deux + caractères décident si « Disque » reste visible ou sort de l'écran, et la + décimale n'apprend rien à côté d'un total de 65 Go. + """ + if nbytes is None: + return "-" + for unit, div in (("T", 1 << 40), ("G", 1 << 30), ("M", 1 << 20)): + if nbytes >= div: + val = nbytes / div + return f"{val:.0f}{unit}" if val >= 10 else f"{val:.1f}{unit}" + return f"{max(0, int(nbytes)) // 1024}K" + + +def fmt_pair(used, total) -> str: + """« 1.1G/12G », « 63G/65G ». « - » si le total manque : « ?/12G » + n'informe pas.""" + if not total: + return "-" + return f"{_fmt_tight(used)}/{_fmt_tight(total)}" + + +def fmt_pct(used, total) -> str: + return f" ({int(used / total * 100)}%)" if total else "" + + +def ram_pair(rec, now, max_age=BALLOON_MAX_AGE) -> str: + """RAM utilisée/totale de la VM, ou « - » si le relevé est PÉRIMÉ.""" + if not rec or not rec.get("ram_total"): + return "-" + at = rec.get("ram_at") or 0 + if at and now - at > max_age: + return "-" + return fmt_pair(rec.get("ram_used"), rec["ram_total"]) + + +def vm_stats_line(name, rec, bps, now, ecrit=None) -> str: + """Section statistiques d'UNE VM, en une ligne dense. + + Le tableau porte les mêmes chiffres en colonnes, pour tout le parc d'un + coup d'œil ; cette ligne les détaille pour la VM sélectionnée — celle dont + le journal et la commande SSH sont déjà affichés. + """ + if not rec: + return f" 📊 {name} · {t('no statistics yet')}" + bits = [f"✍ {fmt_rate(bps)} ({t('10s average')})"] + if ecrit: + bits.append(f"{t('total written')} {_fmt_size(ecrit)}") + ram = ram_pair(rec, now) + if ram != "-": + bits.append( + f"🧠 RAM {ram}{fmt_pct(rec['ram_used'], rec['ram_total'])}" + ) + if rec.get("disk_total"): + bits.append( + f"💾 {t('disk')} " + f"{fmt_pair(rec['disk_used'], rec['disk_total'])}" + f"{fmt_pct(rec['disk_used'], rec['disk_total'])}" + ) + return f" 📊 {name} · " + " · ".join(bits) + + +def read_domstats() -> str: + """Sortie brute de « virsh domstats --balloon --block » (tout le parc). + + UN appel pour toutes les VM — 0,03 s mesuré sur deux domaines. Le suivi + relève toutes les deux secondes : une commande par VM y coûterait N + processus à chaque tour.""" + try: + res = subprocess.run( + ["sudo", "virsh", "domstats", "--balloon", "--block"], + capture_output=True, + text=True, + timeout=15, + env={**os.environ, "LC_ALL": "C", "LANG": "C"}, + ) + except (OSError, subprocess.SubprocessError): + return "" + return res.stdout if res.returncode == 0 else "" + + +def arm_balloon(names) -> None: + """Arme la période de collecte du ballon (5 s) sur chaque VM. + + Sans elle, « balloon.available » et « balloon.usable » restent FIGÉS sur le + dernier rapport du pilote : mesuré sur une VM fraîche, 388 Mo annoncés + contre 1,1 Go réellement occupés, avec un horodatage vieux d'une + demi-heure. La période se perd quand le domaine redémarre — ce qu'une + installation fait — donc on la réarme à intervalle lent. + """ + for name in names or (): + try: + subprocess.run( + [ + "sudo", + "virsh", + "dommemstat", + name, + "--period", + "5", + "--live", + ], + capture_output=True, + text=True, + timeout=10, + env={**os.environ, "LC_ALL": "C", "LANG": "C"}, + ) + except (OSError, subprocess.SubprocessError): + continue + + # --------------------------------------------------------------------------- # # Dashboard Textual # --------------------------------------------------------------------------- # @@ -1068,13 +1332,26 @@ def virsh_domstates() -> dict: # que ce qui était affiché au départ. COL_DEFAULT_WIDTHS = { "seq": 3, - "vm": 22, - "arch": 7, + # 21 : un caractère de moins que l'ancien 22, et c'est lui qui fait tenir + # la ligne entière sur un terminal de 150 colonnes. « + » l'élargit, et + # c'est déjà la colonne visée par défaut. + "vm": 21, + # 5 : « amd64 », « s390x », « arm64 » font cinq caractères, et l'en-tête + # « Arch » quatre. Les deux de plus ne servaient rien. + "arch": 5, "err": 4, "state": 8, - "odoo": 6, - "elapsed": 7, - "disk": 8, + # 4 : une icône (🟢) ou un tiret, sous un en-tête de quatre lettres. + "odoo": 4, + # 6 : « 125:30 » est le pire cas d'une installation de deux heures. + "elapsed": 6, + # Section statistiques de la VM : ce qu'elle écrit, sa RAM, son disque. + # « 12.3M/s » tient en 7 et « 1.1G/12G » en 9 : au-delà, « Disque » sortait + # de l'écran sur un terminal de 150 colonnes, moitié prise par le journal. + "wr": 7, + # 10 et non 9 : sur une VM de 128 Go, « 1001M/128G » fait dix caractères. + "ram": 10, + "disk": 9, } @@ -1369,7 +1646,7 @@ def run_monitor(manifest_path: str, run_app: bool = True): que devinable, et « max-width » laisse la table suivre l'élargissement des colonnes sans manger tout l'écran. */ DataTable { - width: auto; max-width: 60%; height: 1fr; + width: auto; max-width: 66%; height: 1fr; overflow-x: auto; overflow-y: auto; scrollbar-size-horizontal: 1; scrollbar-size-vertical: 1; border: solid $accent; @@ -1444,6 +1721,10 @@ def run_monitor(manifest_path: str, run_app: bool = True): ) # État libvirt (running/paused/gone), rafraîchi à intervalle LENT. self._domstate = {} + # Statistiques par VM : dernier relevé libvirt, et la fenêtre + # glissante qui en tire un débit d'écriture. + self._vmstats = {} + self._wrate = WriteWindow() # Erreurs détectées dans le log à la complétion : {nom: (err, warn)}. self._errcount = {} # Sommaire de stats déplié (clic) ou non. @@ -1498,6 +1779,13 @@ def run_monitor(manifest_path: str, run_app: bool = True): table.add_column( "Durée", key="elapsed", width=COL_DEFAULT_WIDTHS["elapsed"] ) + # Statistiques de la VM. « Écrit/s » est une MOYENNE sur dix + # secondes : le disque d'une installation travaille par rafales, + # et l'instantané n'y montrait que des 0 et des pics. + table.add_column( + "Écrit/s", key="wr", width=COL_DEFAULT_WIDTHS["wr"] + ) + table.add_column("RAM", key="ram", width=COL_DEFAULT_WIDTHS["ram"]) table.add_column( "Disque", key="disk", width=COL_DEFAULT_WIDTHS["disk"] ) @@ -1511,6 +1799,8 @@ def run_monitor(manifest_path: str, run_app: bool = True): "—", "--:--", "-", + "-", + "-", key=vm["name"], ) # max_lines borne la mémoire/rendu (un install verbeux × 30 VM). @@ -1525,6 +1815,11 @@ def run_monitor(manifest_path: str, run_app: bool = True): # Sommaire de stats en CHIFFRES (cliquable -> détail). yield Static("", id="stats") yield Static("", id="statsdetail") + # Section statistiques de la VM SÉLECTIONNÉE : les mêmes chiffres + # que ses colonnes, mais détaillés (pourcentages, cumul écrit). + # Une ligne par VM du parc aurait chassé le pied de page dès cinq + # machines ; la sélection suit déjà le journal et la barre SSH. + yield Static("", id="vmstats") yield Static("", id="sshbar") yield Footer() @@ -1536,6 +1831,7 @@ def run_monitor(manifest_path: str, run_app: bool = True): ) self.sub_title = f"0/{len(vms)} {t('completed')}" self._refresh_ssh() + self._refresh_vmstats() self._load_selected_log(reset=True) # Table toutes les 2 s (30 lectures de fin de log), suivi du log # sélectionné toutes les 1 s (une seule lecture incrémentale). @@ -1551,6 +1847,25 @@ def run_monitor(manifest_path: str, run_app: bool = True): def _vm_by_name(self, name): return next((v for v in vms if v["name"] == name), None) + def _refresh_vmstats(self): + """Ligne de statistiques de la VM sélectionnée.""" + name = self._selected + if not name: + return + try: + bar = self.query_one("#vmstats", Static) + except Exception: + return + bar.update( + vm_stats_line( + name, + self._vmstats.get(name), + self._wrate.rate(name), + time.time(), + self._wrate.total(name), + ) + ) + def _refresh_ssh(self): vm = self._vm_by_name(self._selected) bar = self.query_one("#sshbar", Static) @@ -1612,9 +1927,27 @@ def run_monitor(manifest_path: str, run_app: bool = True): """(THREAD) statut + taille disque de chaque VM + télémétrie. AUCUNE mise à jour d'UI ici : uniquement des I/O bloquantes déportées.""" disks, status, errors, odoo = {}, {}, {}, {} + # UN appel virsh pour tout le parc, dans ce thread : le débit se + # calcule sur les relevés successifs, donc il faut échantillonner + # à chaque tour (2 s) et non au rythme lent des états. + stats = parse_domstats(read_domstats()) + now_s = time.time() + for name, rec in stats.items(): + self._wrate.add(name, rec["wr_bytes"], now_s) + self._vmstats = stats + wr, ram = {}, {} for vm in vms: name = vm["name"] - disks[name] = _fmt_size(disk_actual_size(vm_disk_path(vm))) + rec = stats.get(name) + wr[name] = fmt_rate(self._wrate.rate(name)) + ram[name] = ram_pair(rec, now_s) + if rec and rec.get("disk_total"): + disks[name] = fmt_pair(rec["disk_used"], rec["disk_total"]) + else: + # Domaine pas encore défini (conversion de l'image, tout + # début de l'installation) : le qcow2 existe déjà, et + # st_blocks dit ce qu'il occupe. Sans total à annoncer. + disks[name] = _fmt_size(disk_actual_size(vm_disk_path(vm))) if ( name not in self._final and self._domstate.get(name) != "gone" @@ -1634,16 +1967,22 @@ def run_monitor(manifest_path: str, run_app: bool = True): ): if _port_open(vm.get("ip"), 8069): odoo[name] = True - return disks, status, self._collect_tele(), errors, odoo + return disks, status, self._collect_tele(), errors, odoo, wr, ram async def _tick_table(self): # I/O (lectures de logs, stat disque, /proc) DÉPORTÉES en thread -> # la boucle d'événements Textual reste fluide même sous forte # charge ou disque lent. Les mises à jour d'UI restent sur la boucle. try: - disks, status, tele, errors, odoo = await asyncio.to_thread( - self._collect_table - ) + ( + disks, + status, + tele, + errors, + odoo, + wr, + ram, + ) = await asyncio.to_thread(self._collect_table) except Exception: return self._errcount.update(errors) @@ -1655,6 +1994,8 @@ def run_monitor(manifest_path: str, run_app: bool = True): for vm in vms: name = vm["name"] self._set_cell(table, name, "disk", disks.get(name, "-")) + self._set_cell(table, name, "wr", wr.get(name, "-")) + self._set_cell(table, name, "ram", ram.get(name, "-")) # Colonne Odoo : 🟢 dès que :8069 répond, sinon « — ». self._set_cell( table, @@ -1743,6 +2084,9 @@ def run_monitor(manifest_path: str, run_app: bool = True): if tele: self.query_one("#telemetry", Static).update(tele) self._update_stats() + # Les chiffres de la VM sélectionnée viennent d'être relevés : + # sa section les redit ici, détaillés. + self._refresh_vmstats() except Exception: pass @@ -1849,6 +2193,14 @@ def run_monitor(manifest_path: str, run_app: bool = True): return for vm in vms: self._domstate[vm["name"]] = states.get(vm["name"], "gone") + # Réarmer la période du ballon sur les VM qui tournent : sans elle + # la RAM affichée serait celle du dernier rapport du pilote, et une + # installation redémarre la VM — ce qui remet la période à zéro. + vivantes = [ + vm["name"] for vm in vms if states.get(vm["name"]) == "running" + ] + if vivantes: + await asyncio.to_thread(arm_balloon, vivantes) # L'adresse est relue au même rythme. Le processus détaché suivait # déjà la VM quand son bail changeait, mais les VUES gardaient celle @@ -1889,6 +2241,7 @@ def run_monitor(manifest_path: str, run_app: bool = True): return self._selected = name self._refresh_ssh() + self._refresh_vmstats() self._load_selected_log(reset=True) def action_follow(self) -> None: diff --git a/script/todo/todo.py b/script/todo/todo.py index ef32dc5..1be5ff9 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -11,10 +11,10 @@ import inspect import json import logging import os -import socket import re import shlex import shutil +import socket import subprocess import sys import time @@ -942,6 +942,11 @@ class TODO: "QEMU/KVM - Deploy an Ubuntu VM (libvirt)" ) }, + { + "prompt_description": t( + "Proxmox VE - Deploy a VM on a remote host" + ) + }, { "prompt_description": t( "Deploy - Install NTFY notification server" @@ -966,6 +971,8 @@ class TODO: elif status == "5": self.prompt_execute_qemu() elif status == "6": + self.prompt_execute_proxmox() + elif status == "7": self._deploy_ntfy_server() else: print(t("Command not found !")) @@ -1021,6 +1028,756 @@ class TODO: # ------------------------------------------------------------------ # # QEMU / KVM (libvirt) VM deployment # ------------------------------------------------------------------ # + # ----------------------------------------------------------------- # + # Proxmox VE : l'hyperviseur est AILLEURS + # ----------------------------------------------------------------- # + # Toute la différence avec QEMU/KVM tient là : ici on n'exécute rien sur + # la machine locale. Il faut donc d'abord SAVOIR OÙ, et le retenir — sans + # quoi chacune des dix-sept commandes reposerait la question. + _PVE_PREF_KEY = "proxmox_host" + + def _pve_host(self, ask=True): + """Hôte Proxmox retenu, ou None. Demande au besoin. + + Mémorisé dans les préférences : le menu compte dix-sept entrées, et + redemander l'hôte à chacune serait insupportable. Le choix reste + affiché en tête du menu, et se change par son entrée dédiée. + """ + cache = getattr(self, "_pve_host_cache", None) + if cache: + return cache + garde = todo_prefs.get(self._PVE_PREF_KEY) or {} + if garde.get("target"): + self._pve_host_cache = garde + return garde + return self._pve_pick_host() if ask else None + + def _pve_forget_host(self): + self._pve_host_cache = None + todo_prefs.set(self._PVE_PREF_KEY, {}) + + def _pve_remember_host(self, host): + self._pve_host_cache = host + todo_prefs.set(self._PVE_PREF_KEY, host) + + @staticmethod + def _pve_label(host): + """« root@10.0.0.5 (par rebond) », pour l'afficher en tête de menu.""" + if not host: + return "" + lab = host.get("target", "?") + if host.get("jump"): + lab += f" ({t('through')} {host['jump']})" + if host.get("version"): + lab += f" — PVE {host['version']}" + return lab + + def _pve_pick_host(self): + """Choisit l'hôte Proxmox : VM locale, adresse, ou ~/.ssh/config.""" + print(f"\n{t('Which Proxmox host?')}") + print(f" [1] {t('From the local QEMU VMs')}") + print(f" [2] {t('Type an address')}") + print(f" [3] {t('From ~/.ssh/config')}") + actuel = todo_prefs.get(self._PVE_PREF_KEY) or {} + if actuel.get("target"): + print(f" [4] {t('Keep')} : {self._pve_label(actuel)}") + choix = input(t("Choice: ")).strip() + if choix == "4" and actuel.get("target"): + self._pve_host_cache = actuel + return actuel + if choix == "1": + host = self._pve_host_from_qemu() + elif choix == "3": + host = self._pve_host_from_ssh_config() + elif choix == "2": + host = self._pve_host_manual() + else: + print(t("Cancelled.")) + return None + if not host: + return None + return self._pve_confirm_host(host) + + def _pve_host_manual(self): + """Saisie libre. « root@ » par défaut : « qm » exige les privilèges.""" + brut = input(t("Address (user@host, default user root): ")).strip() + if not brut: + print(t("Cancelled.")) + return None + cible = brut if "@" in brut else f"root@{brut}" + jump = input(t("SSH jump host (blank = none): ")).strip() + return {"target": cible, "jump": jump} + + def _pve_host_from_qemu(self): + """Une VM Proxmox déployée ICI, prise dans la liste libvirt. + + C'est le cas du parc : on déploie une VM « proxmox » avec le menu + QEMU/KVM, puis on déploie DEDANS. L'IP est celle du bail DHCP, pas une + adresse à retaper. + """ + noms = self._qemu_list_domains() + if not noms: + print(f"\n{t('No VM found.')}") + return None + print(f"\n{t('Local VMs:')}") + ips = {} + for i, nom in enumerate(noms, 1): + ip = self._qemu_vm_ip_now(nom) or "" + ips[nom] = ip + etat = self._qemu_domstate(nom) + print(f" [{i}] {nom:<32} {ip or '-':<16} {etat}") + sel = input(t("Selection (number): ")).strip() + if not sel.isdigit() or not 1 <= int(sel) <= len(noms): + print(t("Invalid selection!")) + return None + nom = noms[int(sel) - 1] + ip = ips.get(nom) + if not ip: + print(f" ⚠ {t('No IP for this VM: is it running?')}") + return None + return {"target": f"root@{ip}", "jump": "", "vm": nom} + + def _pve_host_from_ssh_config(self): + """Un alias de ~/.ssh/config : il porte déjà utilisateur, port et + ProxyJump — rien à redemander, et le rebond traverse.""" + entrees = self._ssh_config_entries( + os.path.expanduser("~/.ssh/config") + ) + if not entrees: + print(f"\n{t('No SSH hosts found in ~/.ssh/config')}") + return None + print() + for i, (nom, info) in enumerate(entrees, 1): + hn = info.get("hostname", nom) + u = info.get("user", "") + desc = nom + (f" ({hn})" if hn != nom else "") + print(f" [{i}] {desc}{f' [{u}]' if u else ''}") + sel = input(t("Select SSH host number: ")).strip() + if not sel.isdigit() or not 1 <= int(sel) <= len(entrees): + print(t("Invalid selection!")) + return None + alias = entrees[int(sel) - 1][0] + # L'alias SEUL : ssh y lira l'utilisateur, le port et le ProxyJump. + return {"target": alias, "jump": ""} + + @staticmethod + def _pve_hostkey_missing(sortie): + """La sortie de ssh dénonce-t-elle une clé d'hôte inconnue ou changée ?""" + bas = (sortie or "").lower() + return ( + "host key verification failed" in bas + or "authenticity of host" in bas + or "no ed25519 host key is known" in bas + ) + + def _pve_add_hostkey(self, host): + """Enregistre la clé d'hôte, après accord explicite. + + ssh-keyscan et non « StrictHostKeyChecking=no » : la clé est écrite + UNE fois dans known_hosts, et toute substitution ultérieure sera + détectée. Désactiver la vérification l'aurait masquée pour toujours. + """ + cible = host["target"].split("@")[-1] + # Un alias de ~/.ssh/config n'est pas un nom de machine : ssh seul sait + # vers quoi il pointe. + resolu = self._ssh_resolve(host["target"]) + nom = resolu.get("hostname") or cible + port = resolu.get("port") or host.get("port") or "22" + print(f"\n ⚠ {t('SSH does not know this host key yet.')}") + print(f" {t('Would record:')} ssh-keyscan -p {port} {nom}") + if not self._is_yes(input(f" {t('Record it?')} (o/N) : ")): + return False + try: + res = subprocess.run( + ["ssh-keyscan", "-p", str(port), nom], + capture_output=True, + text=True, + timeout=30, + ) + except (OSError, subprocess.SubprocessError) as exc: + print(f" ✗ ssh-keyscan : {exc}") + return False + if res.returncode != 0 or not res.stdout.strip(): + print(f" ✗ {t('No host key obtained.')}") + return False + chemin = os.path.expanduser("~/.ssh/known_hosts") + os.makedirs(os.path.dirname(chemin), exist_ok=True) + with open(chemin, "a", encoding="utf-8") as fh: + fh.write(res.stdout if res.stdout.endswith("\n") else res.stdout + "\n") + lignes = len(res.stdout.strip().splitlines()) + print(f" ✓ {lignes} {t('key(s) recorded in ~/.ssh/known_hosts')}") + return True + + def _pve_confirm_host(self, host): + """Vérifie que c'en est un, et le retient. Sinon, dit ce qu'il a vu. + + « pveversion » est la preuve : une adresse saisie à la main peut être + n'importe quelle machine, et sans ce contrôle la première commande + « qm » échouerait sur un « command not found » qui n'explique rien. + """ + from script.proxmox import proxmox_deploy as pve + + print(f"\n {t('Checking')} {host['target']}…") + code, out = pve.run(host, "pveversion", timeout=30) + version = pve.parse_pveversion(out) + if not version and self._pve_hostkey_missing(out): + # Première connexion : ssh refuse un hôte dont il n'a pas la clé. + # On ne DÉSACTIVE pas la vérification — un hyperviseur n'est pas + # une VM jetable — on propose de l'enregistrer, une fois. + if self._pve_add_hostkey(host): + code, out = pve.run(host, "pveversion", timeout=30) + version = pve.parse_pveversion(out) + if not version: + print(f" ✗ {t('Not a Proxmox host (or unreachable):')}") + premiere = (out or "").strip().splitlines() + print(f" {premiere[0] if premiere else t('no answer')}") + print(f" → {t('Check the address, the SSH access and pveversion.')}") + return None + # « qm » exige les privilèges. La voie « VM QEMU locale » donne + # l'accès d'erplibre, pas de root : il faut donc sudo, et il faut le + # VÉRIFIER — un sudo qui réclame un mot de passe bloquerait chaque + # commande du menu sur une invite que personne ne voit. + prefixe = "" + _c, qui = pve.run(host, "id -u", timeout=20) + if qui.strip() != "0": + code, _o = pve.run(host, "sudo -n true", timeout=20) + if code: + print(f" ✗ {t('qm needs root: no root, and sudo asks for a password.')}") + print(f" → {t('Connect as root@, or allow NOPASSWD sudo.')}") + return None + prefixe = "sudo " + print(f" ✓ sudo") + host = dict(host, version=version, sudo=prefixe) + print(f" ✓ Proxmox VE {version}") + self._pve_remember_host(host) + return host + + # -- Exécution sur l'hôte ------------------------------------------ # + def _pve_show(self, remote, timeout=120, quiet=False): + """Exécute `remote` sur l'hôte Proxmox et montre ce qui a été lancé. + + La commande est AFFICHÉE avant sa sortie : c'est ce qui rend chaque + étape rejouable à la main, et c'est ainsi que les pannes de ce module + ont été diagnostiquées. + """ + from script.proxmox import proxmox_deploy as pve + + host = self._pve_host() + if not host: + return 255, "" + if not quiet: + # La forme RÉELLEMENT envoyée, enrobage sudo compris : une + # commande affichée doit pouvoir être recopiée telle quelle. + reel = pve.wrap_privilege(remote, host.get("sudo") or "") + print(f"\n{t('Will execute:')} ssh {host['target']} {reel}") + code, out = pve.run(host, remote, timeout) + if out.strip() and not quiet: + print(out.rstrip()) + if code and not quiet: + print(f" ⚠ {t('exit code')} {code}") + return code, out + + def _pve_vms(self): + """[{vmid, name, status, …}] des VM de l'hôte, ou [].""" + from script.proxmox import proxmox_deploy as pve + + code, out = self._pve_show("qm list", quiet=True) + return pve.parse_qm_list(out) if code == 0 else [] + + def _pve_pick_vm(self, titre="", multiple=False): + """Choisit une VM de l'hôte (numéro de la liste, jamais le VMID à + retaper). Renvoie un dict, une liste si `multiple`, ou None.""" + vms = self._pve_vms() + if not vms: + print(f"\n{t('No VM on this Proxmox host.')}") + return [] if multiple else None + print(f"\n{titre or t('VMs on this host:')}") + for i, vm in enumerate(vms, 1): + print( + f" [{i}] {vm['vmid']:<6} {vm['name']:<28} {vm['status']}" + ) + if multiple: + print(f" [all] {t('select all')}") + brut = input(t("Selection (number): ")).strip() + if multiple: + if brut.lower() in ("all", "*"): + return vms + choisis = [] + for jeton in re.split(r"[\s,]+", brut): + if jeton.isdigit() and 1 <= int(jeton) <= len(vms): + choisis.append(vms[int(jeton) - 1]) + return choisis + if brut.isdigit() and 1 <= int(brut) <= len(vms): + return vms[int(brut) - 1] + print(t("Invalid selection!")) + return None + + # -- Les commandes du menu ----------------------------------------- # + def _pve_list(self): + """« qm list », mis en tableau avec le total.""" + vms = self._pve_vms() + if not vms: + print(f"\n{t('No VM on this Proxmox host.')}") + return + print( + f"\n{'VMID':<7} {'Nom':<30} {'État':<10} {'RAM (Mo)':>9}" + f" {'Disque':>10}" + ) + print("─" * 70) + for vm in vms: + print( + f"{vm['vmid']:<7} {vm['name'][:30]:<30} {vm['status']:<10}" + f" {vm['mem']:>9} {vm['disk']:>10}" + ) + actives = sum(1 for v in vms if v["status"] == "running") + print(f"\n {len(vms)} VM, {actives} {t('running')}") + + def _pve_vm_ip(self): + """Adresse d'une VM, par l'agent invité. + + Sans agent, Proxmox ne connaît PAS l'adresse de ses invités : il ne la + distribue pas lui-même. Le dire vaut mieux qu'afficher « rien ». + """ + from script.proxmox import proxmox_deploy as pve + + vm = self._pve_pick_vm() + if not vm: + return + # _pve_guest_ip et non l'agent seul : il enchaîne agent PUIS voisinage + # de l'hôte. L'image cloud Debian n'embarque pas qemu-guest-agent, et + # cette entrée du menu répondait « aucune adresse » alors que « ip + # neigh » la connaissait — deux chemins pour la même question, dont un + # seul savait répondre. + ip = self._pve_guest_ip(vm["vmid"], attente=20) + if ip: + print(f"\n {vm['name']} : {ip}") + print(f" ssh erplibre@{ip}") + return + print(f"\n ⚠ {t('No address for this VM.')}") + print(f" → {t('Is qemu-guest-agent installed and the VM started?')}") + print(f" → {t('A static address is visible right after creation.')}") + + def _pve_console(self): + """Console série d'une VM. Demande un terminal : on passe donc par + l'exécuteur du dépôt, qui en a un.""" + from script.proxmox import proxmox_deploy as pve + + vm = self._pve_pick_vm() + if not vm: + return + host = self._pve_host() + if not host: + return + cmd = " ".join( + shlex.quote(a) + for a in pve.ssh_argv( + host, + pve.wrap_privilege( + pve.console_cmd(vm["vmid"]), host.get("sudo") or "" + ), + tty=True, + ) + ) + print(f"\n {t('Ctrl+O to quit the serial console.')}") + print(f"\n{t('Will execute:')} {cmd}") + self.execute.exec_command_live(cmd, source_erplibre=False) + + def _pve_resize(self): + """Agrandit un disque. Proxmox REFUSE de rétrécir : on le dit avant.""" + from script.proxmox import proxmox_deploy as pve + + vm = self._pve_pick_vm() + if not vm: + return + print(f"\n ⚠ {t('Proxmox can only GROW a disk, never shrink it.')}") + taille = input( + t("Size (+10G to add, 40G for a target): ") + ).strip() + if not re.match(r"^\+?\d+[MGT]$", taille): + print(t("Invalid selection!")) + return + self._pve_show(pve.resize_cmd(vm["vmid"], taille)) + + def _pve_delete(self): + """Efface des VM, avec DOUBLE validation — « --purge » emporte les + disques et les sauvegardes, il n'y a pas de retour.""" + from script.proxmox import proxmox_deploy as pve + + vms = self._pve_pick_vm(multiple=True) + if not vms: + return + noms = ", ".join(f"{v['vmid']} ({v['name']})" for v in vms) + print(f"\n ⚠ {t('This also destroys their disks and backups.')}") + if not self._is_yes(input(f"{t('Apply:')} {noms} ? (o/N) : ")): + print(t("Cancelled.")) + return + if not self._is_yes(input(t("Confirm for real? (y/N): "))): + print(t("Cancelled.")) + return + for vm in vms: + for cmd in pve.destroy_cmds(vm["vmid"]): + self._pve_show(cmd, timeout=300) + + def _pve_cleanup(self): + """Volumes de disque qu'aucune VM ne réclame plus. + + Proxmox ne les efface pas de lui-même : une création interrompue ou un + « destroy » sans « --purge » en laisse. On les liste et on demande. + """ + from script.proxmox import proxmox_deploy as pve + + code, out = self._pve_show(pve.orphan_disks_cmd(), quiet=True) + if code: + print(f"\n ⚠ {t('exit code')} {code}") + return + vmids = [v["vmid"] for v in self._pve_vms()] + orphelins = pve.parse_orphans(out, vmids) + if not orphelins: + print(f"\n ✓ {t('Nothing orphaned.')}") + return + total = sum(t2 for _v, t2 in orphelins) + print(f"\n{t('Orphan disks:')}") + for volid, taille in orphelins: + print(f" {volid:<48} {taille / (1 << 30):>8.1f} Go") + print(f" {t('Total:')} {total / (1 << 30):.1f} Go") + if not self._is_yes(input(f"{t('Free them?')} (o/N) : ")): + print(t("Cancelled.")) + return + for volid, _taille in orphelins: + self._pve_show(f"pvesm free {shlex.quote(volid)}", timeout=300) + + def _pve_guest_ip(self, vmid, attente=120): + """Adresse d'une VM Proxmox : agent invité, sinon voisinage de l'hôte. + + Deux voies parce qu'aucune ne suffit seule. L'agent est le plus sûr, + mais l'image cloud Debian ne l'embarque pas. Le voisinage (« ip neigh » + sur l'hôte) marche dès que la VM a émis un paquet — un bail DHCP suffit + — et ne demande RIEN à l'invité. + """ + from script.proxmox import proxmox_deploy as pve + + fin = time.time() + attente + mac = "" + while True: + code, out = self._pve_show( + pve.guest_ip_cmd(vmid), timeout=30, quiet=True + ) + ips = pve.parse_guest_ips(out) if code == 0 else [] + if ips: + return ips[0] + if not mac: + _c, cfg = self._pve_show( + f"qm config {vmid}", timeout=30, quiet=True + ) + mac = pve.mac_from_config(cfg) + if mac: + _c, neigh = self._pve_show( + "ip -4 neigh show", timeout=30, quiet=True + ) + ip = pve.ip_from_neigh(neigh, mac) + if ip: + return ip + if time.time() >= fin: + return "" + time.sleep(5) + + def _pve_push_key(self, chemin_local): + """Recopie la clé publique SUR l'hôte : « qm set --sshkeys » attend un + FICHIER là-bas, pas une clé en ligne.""" + try: + with open(os.path.expanduser(chemin_local), encoding="utf-8") as fh: + cle = fh.read().strip() + except OSError as exc: + print(f" ⚠ {t('SSH key unreadable:')} {exc}") + return "" + distant = "/root/.ssh/erplibre-deploy.pub" + code, _out = self._pve_show( + "mkdir -p /root/.ssh && printf '%s\\n' " + f"{shlex.quote(cle)} > {distant}", + quiet=True, + ) + return distant if code == 0 else "" + + def _pve_offer_bridge(self): + """Aucun pont sur l'hôte : en proposer un, sans risquer l'accès. + + Une Proxmox installée SUR Debian n'a pas de vmbr0 — l'ISO en crée un, + pas la procédure sur Debian. Or « qm create » exige un pont. + + On ne propose donc PAS d'ajouter l'interface physique au pont : cela + déplace l'adresse de la machine et coupe la session SSH en cours, sans + retour possible à distance. Un pont INTERNE, lui, ne touche à rien — + les VM s'y parlent, et le masquerading leur donne l'extérieur. + """ + from script.proxmox import proxmox_deploy as pve + + print(f"\n ⚠ {t('No network bridge on this host.')}") + print(f" {t('qm create needs one. Two ways:')}") + print( + f" [1] {t('create an internal')} {pve.INTERNAL_BRIDGE}" + f" ({pve.INTERNAL_CIDR}) + NAT — {t('touches no physical NIC')}" + ) + print(f" [2] {t('do it myself (bridge-ports , needs console)')}") + if input(t("Choice: ")).strip() != "1": + print(f"\n {t('To bridge the LAN, on the host:')}") + print(" auto vmbr0") + print(" iface vmbr0 inet static") + print(" address /24") + print(" gateway ") + print(" bridge-ports ") + print(f" ⚠ {t('This moves the host address: do it from a console.')}") + return "" + _c, sortie = self._pve_show( + "ip -o -4 route show default", quiet=True + ) + uplink = "" + parts = (sortie or "").split() + if "dev" in parts: + uplink = parts[parts.index("dev") + 1] + print(f" {t('uplink for NAT')} : {uplink or t('none')}") + for cmd in pve.bridge_setup_cmds(uplink=uplink): + code, _o = self._pve_show(cmd, timeout=120) + if code: + print(f" ✗ {t('Step failed, stopping here.')}") + return "" + _c, out = self._pve_show("ip -o link show type bridge", quiet=True) + ponts = pve.parse_bridges(out) + if pve.INTERNAL_BRIDGE not in ponts: + print(f" ✗ {t('The bridge did not come up.')}") + return "" + print(f" ✓ {pve.INTERNAL_BRIDGE}") + return pve.INTERNAL_BRIDGE + + def _pve_deploy(self, dry_run=False): + """Déploie une VM SUR l'hôte Proxmox choisi. + + Le catalogue d'images est celui du dépôt (le même que QEMU/KVM) : c'est + une connaissance locale, indépendante de l'hyperviseur. Tout le reste + part sur l'hôte — téléchargement compris, puisque c'est là que le + disque sera écrit. + """ + from script.proxmox import proxmox_deploy as pve + + host = self._pve_host() + if not host: + return + mod = self._qemu_import_module() + distro = self._qemu_prompt_distro() + version = self._qemu_prompt_version(distro) + arch = "amd64" + nom = input( + t("VM name (default: erplibre-): ") + ).strip() or f"erplibre-{distro}" + memoire = ( + self._qemu_ask_ram(t("RAM in MB, blank = 4096"), 4096) or 4096 + ) + vcpus = ( + self._qemu_ask_cpu(t("vCPU, blank = 2"), 2, os.cpu_count() or 2) + or 2 + ) + disque = input(t("Disk size (default 32G): ")).strip() or "32G" + + code, _v = mod.DISTROS[distro][0][version][:2] + url = mod.image_url(distro, code, arch, version) + image = mod.default_image_name(distro, code, arch, version) + + # Stockage et pont : demandés à l'HÔTE, jamais devinés. « local-lvm » + # n'existe pas partout, et un pont inventé fait échouer « qm create ». + _c, out = self._pve_show("pvesm status --content images", quiet=True) + stockages = pve.parse_storages(out) + _c, out = self._pve_show("ip -o link show type bridge", quiet=True) + ponts = pve.parse_bridges(out) + _c, cfg_reseau = self._pve_show( + "cat /etc/network/interfaces", quiet=True + ) + infos_ponts = pve.parse_bridge_config(cfg_reseau) + stockage = pve.pick_storage(stockages) + pont = pve.pick_bridge(ponts) + if not stockage: + print(f"\n ✗ {t('No storage able to hold a VM disk.')}") + return + if not pont and not dry_run: + pont = self._pve_offer_bridge() + if not pont: + return + _c, cfg_reseau = self._pve_show( + "cat /etc/network/interfaces", quiet=True + ) + infos_ponts = pve.parse_bridge_config(cfg_reseau) + elif not pont: + pont = pve.INTERNAL_BRIDGE + # Le VMID D'ABORD : l'adresse d'un pont interne s'en déduit, et + # l'afficher avant de l'avoir choisi ne pouvait pas marcher. + vmid = pve.next_vmid(self._pve_vms()) + ipconfig = pve.ipconfig_for(infos_ponts.get(pont, {}), vmid) + print(f"\n {t('storage')} : {stockage} ({len(stockages)} {t('offered')})") + print(f" {t('bridge')} : {pont}") + print(f" {t('address')} {ipconfig}") + print(f" VMID : {vmid}") + + cle_locale = self._qemu_default_ssh_key() + spec = { + "name": nom, + "memory": memoire, + "vcpus": vcpus, + "disk": disque, + "storage": stockage, + "bridge": pont, + "image": image, + "user": "erplibre", + "sshkey_path": "/root/.ssh/erplibre-deploy.pub", + "start": True, + # DHCP sur un pont qui donne sur le LAN, adresse FIXE sur un pont + # interne : là, aucun serveur DHCP ne répondrait et la VM + # resterait muette. + "ipconfig": ipconfig, + } + etapes = [pve.image_fetch_cmd(url, image)] + pve.create_cmds( + vmid, spec + ) + if dry_run: + print(f"\n── {t('Would run on')} {host['target']} ──") + print(f" # {t('SSH key ->')} {spec['sshkey_path']}") + for cmd in etapes: + print(f" {cmd}") + return + if not self._is_yes_default_yes( + input(f"\n{t('Deploy this VM now? (Y/n): ')}") + ): + print(t("Cancelled.")) + return + if cle_locale and not self._pve_push_key(cle_locale): + print(f" ⚠ {t('SSH key not pushed: password login only.')}") + spec.pop("sshkey_path", None) + etapes = [pve.image_fetch_cmd(url, image)] + pve.create_cmds( + vmid, spec + ) + for cmd in etapes: + code, _out = self._pve_show(cmd, timeout=1800) + if code: + print(f"\n ✗ {t('Step failed, stopping here.')}") + return + # Adresse fixe : c'est nous qui l'avons donnée, inutile de la + # chercher. La découverte ne sert qu'au DHCP. + ip = pve.ip_from_ipconfig(ipconfig) + if ip: + print(f"\n {t('address given at creation:')} {ip}") + else: + print(f"\n {t('Waiting for the VM address…')}") + ip = self._pve_guest_ip(vmid) + if not ip: + print(f" ⚠ {t('No address yet. Try [6] later.')}") + return + print(f" ✓ {nom} : {ip}") + # Entrée ~/.ssh/config avec l'hôte Proxmox en REBOND : c'est ce qui + # rend la VM joignable d'ici, et c'est aussi ce qui permet au suivi + # d'installation d'y entrer (il reçoit l'alias, pas l'IP). + self._write_ssh_config_entry( + nom, + "erplibre", + ip, + identity_file=self._ssh_private_key(cle_locale), + proxy_jump=host["target"], + ) + print(f" ✓ ~/.ssh/config : ssh {nom}") + if self._is_yes_default_yes( + input(f"\n{t('Install ERPLibre on it? (Y/n): ')}") + ): + branch = self._qemu_pick_branch() + label, cmd = self._qemu_pick_install_profile(distro) + print(f" {label}") + # L'ALIAS, pas l'IP : ssh y lit le ProxyJump de ~/.ssh/config. + self._qemu_install_erplibre_monitored( + [nom], branch, {nom: nom}, cmd + ) + + def _pve_ssh_config(self): + """Écrit une entrée ~/.ssh/config par VM de l'hôte, avec l'hôte + Proxmox en ProxyJump — sans quoi ces VM ne sont joignables d'ici que + si leur réseau est routé jusqu'à nous.""" + host = self._pve_host() + if not host: + return + vms = [v for v in self._pve_vms() if v["status"] == "running"] + if not vms: + print(f"\n{t('No running VM on this Proxmox host.')}") + return + cle = self._ssh_private_key(self._qemu_default_ssh_key()) + for vm in vms: + ip = self._pve_guest_ip(vm["vmid"], attente=0) + if not ip: + print(f" ⚠ {vm['name']} : {t('no address, skipped')}") + continue + self._write_ssh_config_entry( + vm["name"], + "erplibre", + ip, + identity_file=cle, + proxy_jump=host["target"], + ) + print(f" ✓ ssh {vm['name']} ({ip} {t('through')} {host['target']})") + + def _pve_test_vm(self): + """Ouvre Odoo (:8069) d'une VM Proxmox dans un navigateur en ligne. + + Même chose que pour QEMU, à ceci près que l'adresse vient de l'hôte + Proxmox et non de libvirt — et qu'elle n'est joignable d'ici que si son + réseau l'est. On le dit plutôt que d'ouvrir une page vide. + """ + vm = self._pve_pick_vm() + if not vm: + return + ip = self._pve_guest_ip(vm["vmid"], attente=30) + if not ip: + print(f"\n ⚠ {t('No address for this VM.')}") + return + if not self._qemu_ip_reachable(ip, port=8069, timeout=3): + print(f"\n ⚠ {ip}:8069 {t('unreachable from here.')}") + print(f" → {t('Use [13] to add a ProxyJump entry, then a tunnel.')}") + return + navigateur = self._qemu_choose_cli_browser() + if not navigateur: + return + url = f"http://{ip}:8069" + print(f"→ {navigateur} {url}") + os.system(f"{navigateur} {shlex.quote(url)}") + + def _pve_example(self): + """Exemple de séquence, sans rien exécuter : de quoi voir ce que + l'outil enverrait sur l'hôte.""" + from script.proxmox import proxmox_deploy as pve + + spec = { + "name": "demo-vm", + "memory": 4096, + "vcpus": 2, + "disk": "32G", + "storage": "local-lvm", + "bridge": "vmbr0", + "image": "debian-13-genericcloud-amd64.qcow2", + "sshkey_path": "/root/.ssh/erplibre-deploy.pub", + } + print(f"\n── {t('Example: demo-vm, Debian 13, on a Proxmox host')} ──") + print(f" {pve.image_fetch_cmd('https://…/debian-13.qcow2', spec['image'])}") + for cmd in pve.create_cmds(101, spec): + print(f" {cmd}") + + def _pve_stats(self): + """État de l'hôte et de ses VM, en une page.""" + host = self._pve_host() + if not host: + return + print(f"\n══ {t('Proxmox host:')} {self._pve_label(host)} ══") + for titre, cmd in ( + (t("uptime"), "uptime"), + (t("memory"), "free -h | head -2"), + (t("storages"), "pvesm status"), + ): + code, out = self._pve_show(cmd, quiet=True) + print(f"\n── {titre} ──") + print((out or "").rstrip() if code == 0 else f" ⚠ {out.strip()}") + self._pve_list() + def _qemu_script_path(self): """Chemin absolu vers script/qemu/deploy_qemu.py.""" path = os.path.join( @@ -1052,6 +1809,10 @@ class TODO: # deploy_qemu.py, qui fait autorité sur le catalogue. "opensuse": (["16.0", "tumbleweed"], "16.0"), "arch": (["latest"], "latest"), + # Proxmox VE : le numéro est celui de PVE, pas de Debian (9 = trixie). + # Une seule version au catalogue, la seule qui couvre amd64 ET arm64 — + # voir PROXMOX_VERSIONS dans deploy_qemu.py, qui fait autorité. + "proxmox": (["9"], "9"), } def _qemu_prompt_distro(self): @@ -1280,6 +2041,120 @@ class TODO: print(f"⚠ {t('virsh still missing; a reboot may be required.')}") return False + def prompt_execute_proxmox(self): + """Sous-menu Proxmox VE : l'équivalent du menu QEMU/KVM, mais sur un + hôte DISTANT. La première question est donc « lequel ? » — et la + réponse est retenue pour toute la session.""" + print(f"🤖 {t('Deploy a virtual machine on Proxmox VE!')}") + if not self._pve_host(): + return False + choices = [ + {"section": t("Deployment")}, + {"prompt_description": t("Deploy a VM on the Proxmox host")}, + { + "prompt_description": t( + "Preview a deployment (dry-run, nothing sent)" + ) + }, + {"prompt_description": t("Download a cloud image on the host")}, + { + "prompt_description": t( + "Reopen install monitoring (last run / history)" + ) + }, + {"section": t("Manage")}, + {"prompt_description": t("List VMs (qm list)")}, + {"prompt_description": t("Show a VM IP address")}, + {"prompt_description": t("Open the console on a VM")}, + {"prompt_description": t("Resize a VM disk")}, + {"prompt_description": t("Delete VM(s)")}, + {"prompt_description": t("Clean up (orphan disks)")}, + { + "prompt_description": t( + "Test a VM (open Odoo in a CLI browser)" + ) + }, + {"prompt_description": t("Statistics (host and VMs)")}, + { + "prompt_description": t( + "SSH configuration (~/.ssh/config, ProxyJump)" + ) + }, + {"prompt_description": t("Remote desktop tunnel (VNC/RDP over SSH)")}, + {"prompt_description": t("Android emulator (start, tunnel, scrcpy)")}, + {"section": t("Catalog")}, + {"prompt_description": t("List available images and their specs")}, + {"prompt_description": t("Proxmox - example sequence (dry-run)")}, + {"section": t("Host")}, + {"prompt_description": t("Change the Proxmox host")}, + ] + help_info = self.fill_help_info(choices) + while True: + hote = self._pve_host(ask=False) + print(f"\n {t('Proxmox host:')} {self._pve_label(hote) or '-'}") + status = click.prompt(help_info) + print() + if status == "0": + return False + elif status == "1": + self._pve_deploy() + elif status == "2": + self._pve_deploy(dry_run=True) + elif status == "3": + self._pve_fetch_image() + elif status == "4": + self._qemu_reopen_monitor() + elif status == "5": + self._pve_list() + elif status == "6": + self._pve_vm_ip() + elif status == "7": + self._pve_console() + elif status == "8": + self._pve_resize() + elif status == "9": + self._pve_delete() + elif status == "10": + self._pve_cleanup() + elif status == "11": + self._pve_test_vm() + elif status == "12": + self._pve_stats() + elif status == "13": + self._pve_ssh_config() + elif status == "14": + # Les VM Proxmox sont dans ~/.ssh/config (entrée 13) : le + # tunnel du menu QEMU les y trouve, rebond compris. + self._qemu_tunnel_menu() + elif status == "15": + self._qemu_emulator_menu() + elif status == "16": + self._qemu_list_images() + elif status == "17": + self._pve_example() + elif status == "18": + self._pve_forget_host() + self._pve_pick_host() + else: + print(t("Command not found !")) + + def _pve_fetch_image(self): + """Télécharge une image cloud SUR l'hôte Proxmox. + + Là et pas ici : c'est sur l'hôte que le disque sera écrit, et faire + descendre 325 Mio chez soi pour les renvoyer doublerait le transfert. + """ + from script.proxmox import proxmox_deploy as pve + + mod = self._qemu_import_module() + distro = self._qemu_prompt_distro() + version = self._qemu_prompt_version(distro) + code = mod.DISTROS[distro][0][version][0] + url = mod.image_url(distro, code, "amd64", version) + nom = mod.default_image_name(distro, code, "amd64", version) + print(f"\n {nom}\n {url}") + self._pve_show(pve.image_fetch_cmd(url, nom), timeout=1800) + def prompt_execute_qemu(self): print(f"🤖 {t('Deploy a QEMU/KVM virtual machine (libvirt)!')}") script_path = self._qemu_script_path() @@ -2107,7 +2982,9 @@ class TODO: jump = self._ssh_proxyjump(name) domain = name.rsplit("+", 1)[-1] if not jump: - print(f"\n ⚠ {t('No ProxyJump for this host in ~/.ssh/config.')}") + print( + f"\n ⚠ {t('No ProxyJump for this host in ~/.ssh/config.')}" + ) print(f" {t('Cannot tell which machine runs its QEMU.')}") return port = self._qemu_vnc_port(domain, jump) @@ -2117,11 +2994,17 @@ class TODO: if not port: print(f"\n ⚠ {t('This VM exposes no VNC port.')}") print(f" {t('Its display is likely spice with listen=none:')}") - print(f" {pre}sudo virsh dumpxml {domain} | grep -A2 '") + print( + f" {pre}sudo virsh edit {domain} # " + ) print(f" {pre}sudo virsh start {domain}") print(f"\n {t('New VMs get this by default; see deploy_qemu.')}") return @@ -2131,13 +3014,17 @@ class TODO: host, from_ssh = self._qemu_self_address() user = os.environ.get("USER", "user") if not from_ssh: - print(f" ⚠ {t('Not in an SSH session: check the host address.')}") + print( + f" ⚠ {t('Not in an SSH session: check the host address.')}" + ) target = f"{user}@{host}" print(f"\n {t('Run this on YOUR workstation:')}") print(f"\n ssh -N -L {port}:127.0.0.1:{port} {target}\n") if jump: - print(f" {t('Target is the hypervisor')} ({jump}), " - f"{t('not the VM: the socket is QEMU-side.')}") + print( + f" {t('Target is the hypervisor')} ({jump}), " + f"{t('not the VM: the socket is QEMU-side.')}" + ) print(f" {t('then point your VNC client at')} localhost:{port}") print(f" {t('The tunnel stays open as long as that ssh runs.')}") @@ -2174,7 +3061,9 @@ class TODO: """ base = ["virsh", "--connect", "qemu:///system", "vncdisplay", domain] for argv in (base, ["sudo", "-n"] + base): - cmd = (["ssh", "-o", "BatchMode=yes", jump] + argv) if jump else argv + cmd = ( + (["ssh", "-o", "BatchMode=yes", jump] + argv) if jump else argv + ) try: res = subprocess.run( cmd, capture_output=True, text=True, timeout=25 @@ -5063,6 +5952,19 @@ class TODO: return m.group(1) return None + def _qemu_vm_ip_now(self, name): + """IP de la VM d'après le bail DHCP, SANS attendre. + + `_qemu_vm_ip` patiente jusqu'à dix minutes par VM : c'est ce qu'il faut + après un déploiement, et exactement ce qu'il ne faut pas pour AFFICHER + une liste — trois VM figeaient le menu une demi-heure. Ici on lit le + bail une fois, en préférant celui dont le hostname est le nom de la VM. + """ + cands = self._qemu_lease_candidates(name) + if not cands: + return None + return self._qemu_lease_ip_for_host(name, cands) or cands[-1] + def _qemu_vm_ip(self, name, timeout=600): """IPv4 utilisable d'une VM. Gère le cas des baux multiples (hostname changé au boot) : renvoie en priorité le bail dont le hostname == nom @@ -5339,13 +6241,25 @@ class TODO: "make install_os && make install_dev && " + self._QEMU_QEMU_PKGS, ), + ( + t("Proxmox VE hypervisor (no Odoo)"), + "./script/proxmox/install_proxmox.sh", + ), ] return profiles - def _qemu_pick_install_profile(self): + def _qemu_pick_install_profile(self, distro=""): """Choix de CE QU'ON installe sur la VM. Renvoie (label, commande - finale exécutée dans ~/git/erplibre).""" + finale exécutée dans ~/git/erplibre). + + Sur une VM Proxmox, le profil hyperviseur passe en tête : choisir + « Proxmox VE » comme système, c'est demander qu'il soit installé, et + laisser Odoo 18 en défaut ferait poser un ERP sur un hyperviseur. + """ profiles = self._qemu_install_profiles() + if distro == "proxmox": + pve = t("Proxmox VE hypervisor (no Odoo)") + profiles.sort(key=lambda p: p[0] != pve) print(f"\n{t('What to install on the VM(s)?')}") for i, (label, _cmd) in enumerate(profiles, 1): print(f" [{i}] {label}{' *' if i == 1 else ''}") @@ -5638,6 +6552,31 @@ class TODO: "fi; " ) + @staticmethod + def _qemu_vm_ready_report(): + """Relevé de mise en route, pour une VM où l'on n'installe RIEN. + + Sans lui, la commande distante valait « true » : le suivi affichait un + ✅ instantané sur un journal vide, ce qui n'apprend rien de la machine + qu'on vient de créer. Ici, il y a une fin claire (le marqueur de sortie + que pose le lanceur) et de quoi juger qu'elle est prête : système, + noyau, adresse, disque, mémoire, et le verdict de cloud-init. + """ + return ( + f'echo "===> {t("VM start-up")}"; ' + ". /etc/os-release 2>/dev/null || true; " + f'echo " {t("system:")} ${{PRETTY_NAME:-?}}"; ' + f'echo " {t("kernel:")} $(uname -r) ($(uname -m))"; ' + f'echo " {t("address:")} ' + "$(hostname -I 2>/dev/null | awk '{print $1}')\"; " + f'echo " {t("disk:")} ' + '$(df -h / | awk \'NR==2 {print $3"/"$2" ("$5")"}\')"; ' + f'echo " {t("memory:")} ' + '$(free -h 2>/dev/null | awk \'NR==2 {print $3"/"$2}\')"; ' + f'echo " {t("uptime:")} $(uptime -p 2>/dev/null || true)"; ' + f'echo "<=== {t("VM start-up")}"; ' + ) + @staticmethod def _qemu_no_auto_upgrade(prod, app_store="deb"): """Coupe les mises à jour automatiques sur une VM de DÉVELOPPEMENT. @@ -7220,7 +8159,15 @@ class TODO: # attente de cloud-init et coupure des mises à jour automatiques, # sans quoi le verrou apt ferait échouer l'installation du bureau. if not desktop: - return "true" + # Rien à installer : le suivi n'a alors qu'à regarder la VM + # ARRIVER. Un « true » rendait un journal vide et un ✅ + # instantané — et c'est pourquoi le suivi « ne marchait plus » + # dès qu'on décochait ERPLibre. + return ( + "set -e; " + + self._qemu_cloud_init_wait() + + self._qemu_vm_ready_report() + ) # Les outils de la phase « after » vivent DANS le dépôt — la # compilation mobile, l'AVD, le script Forgejo. Sans clone, ils # n'existent pas ici. Les écarter en silence laissait croire qu'une @@ -8922,11 +9869,28 @@ class TODO: ans = input( t("Install ERPLibre into ~/git/erplibre on each VM? (Y/n): ") ) + if not self._is_yes_default_yes(ans) and any( + v.get("distro") == "proxmox" for v in vms + ): + # C'est par cette étape que passe l'installation de Proxmox : sans + # elle la VM reste une Debian nue, ce qui n'est pas ce qu'on a + # demandé en choisissant « Proxmox VE » comme système. + print( + f"\n ⚠ {t('Proxmox will NOT be installed: plain Debian VM.')}" + ) + print( + f" {t('Later, in the VM:')}" + " sudo ./script/proxmox/install_proxmox.sh" + ) if self._is_yes_default_yes(ans): branch = self._qemu_pick_branch() # dev (~/git, SELinux relâché) vs prod (/opt, confiné) prod = self._qemu_ask_prod() - label, cmd = self._qemu_pick_install_profile() + label, cmd = self._qemu_pick_install_profile( + "proxmox" + if any(v.get("distro") == "proxmox" for v in vms) + else "" + ) monitor = self._is_yes_default_yes( input(t("Interactive monitoring dashboard? (y/N): ")) ) @@ -8937,6 +9901,15 @@ class TODO: "cmd": cmd, "monitor": monitor, } + else: + # Rien à installer : le suivi garde tout son sens — il regarde les + # VM arriver (cloud-init, puis relevé système) et porte le tableau + # d'état, de débit d'écriture, de RAM et de disque. La question + # était posée DANS la branche ERPLibre : refuser l'une emportait + # l'autre sans qu'on l'ait demandé. + monitor = self._is_yes_default_yes( + input(f"{t('Watch the VMs start (no install)')} ? (O/n) : ") + ) add_ssh_config = self._is_yes_default_yes( input(t("Add each VM to ~/.ssh/config? (Y/n): ")) @@ -8997,6 +9970,9 @@ class TODO: "python_provider": python_provider, "app_store": app_store, "install": install, + # Au niveau du déploiement : le suivi survit à une installation + # décochée (voir _qemu_run_spec). + "monitor": monitor, "add_ssh_config": add_ssh_config, "parallelism": parallelism, } @@ -9158,8 +10134,14 @@ class TODO: # 7) Installation ERPLibre (clone + make) et/ou bureau GNOME. Le bureau # ne dépend PAS d'ERPLibre : une VM peut être voulue graphique et nue. # Il passe par la même commande distante, donc par le même suivi. - if install or desktop: - monitor = install["monitor"] if install else True + # + # Et quand il n'y a RIEN à installer, le suivi s'ouvre quand même : la + # commande distante regarde alors la VM arriver (cloud-init puis relevé + # système). Sans cela, décocher ERPLibre faisait disparaître le tableau + # de bord — rapporté, et c'est ce qui donnait « le suivi ne fonctionne + # plus ». Le choix vient du déploiement, pas de l'installation. + monitor = install["monitor"] if install else spec.get("monitor", True) + if install or desktop or monitor: if monitor: # Installs détachées en parallèle + dashboard Textual. self._qemu_install_erplibre_monitored( @@ -9173,7 +10155,7 @@ class TODO: app_store=app_store, vm_tools=vm_tools, ) - else: + elif install: print( f"\n{t('Installing ERPLibre on each VM')} " f"({install_branch})…" @@ -9442,6 +10424,214 @@ class TODO: pass print(f"\n {t('Tunnel closed.')}") + # sshfs lit « a+b » comme un CHAÎNAGE d'hôtes — « ssh a, puis ssh b depuis + # a » — et ne consulte donc PAS ~/.ssh/config pour l'alias entier. Or c'est + # todo.py qui nomme les VM découvertes « jump+domaine » (voir la marche + # SSH) : ce sont les alias les plus utiles, et les seuls que sshfs échoue à + # monter tel quel. Vécu : « read: Connection reset by peer », parce que la + # seconde moitié du nom est un domaine libvirt, pas un alias SSH du rebond. + SSHFS_CHAIN_SEP = "+" + + # Options à rendre à sshfs quand on contourne l'alias : exactement celles + # que todo.py écrit dans l'entrée qu'il génère. Sans elles, une VM dont la + # clé d'hôte a changé — IP DHCP réutilisée — ferait échouer le montage. + SSH_FORWARD_OPTS = ( + ("port", "Port"), + ("proxyjump", "ProxyJump"), + ("identityfile", "IdentityFile"), + ("identitiesonly", "IdentitiesOnly"), + ("stricthostkeychecking", "StrictHostKeyChecking"), + ("userknownhostsfile", "UserKnownHostsFile"), + ) + + # Ce que dit stderr, et ce qu'il faut aller corriger. L'ordre compte : le + # premier motif trouvé gagne. + SSH_FAILURE_HINTS = ( + ("could not resolve hostname", "unknown host name: check HostName"), + ("name or service not known", "unknown host name: check HostName"), + ("connection timed out", "no answer: is the server up and reachable?"), + ("operation timed out", "no answer: is the server up and reachable?"), + ("no route to host", "no route: check the network or the ProxyJump"), + ("connection refused", "nothing listening on the SSH port"), + ("permission denied", "authentication refused: check User and key"), + ("host key verification failed", "host key changed for this address"), + ) + + @staticmethod + def _ssh_config_entries(path): + """[(alias, {hostname, user})] de ~/.ssh/config, dans l'ordre du fichier. + + Pendant de `_ssh_config_hosts`, qui ne rend que les NOMS : ici le menu + de montage a besoin d'afficher aussi l'adresse et l'utilisateur. + + « Host a b » déclare DEUX alias pour la même machine — c'est ce que + todo.py écrit lui-même quand une VM porte plusieurs noms. Les prendre + pour un seul nom donnait un alias « a b », que sshfs ne peut pas + monter. Les motifs génériques (« * », « web-? ») sont écartés : ils ne + désignent aucune machine. + """ + hosts = [] + noms = [] + info = {} + + def clore(): + for nom in noms: + hosts.append((nom, dict(info))) + + try: + with open(path, encoding="utf-8", errors="replace") as fh: + lignes = fh.readlines() + except OSError: + return [] + for ligne in lignes: + ligne = ligne.strip() + if ligne.lower().startswith("host "): + clore() + noms = [ + m + for m in ligne.split()[1:] + if "*" not in m and "?" not in m and not m.startswith("!") + ] + info = {} + elif noms: + paire = ligne.split(None, 1) + if len(paire) == 2 and paire[0].lower() in ( + "hostname", + "user", + ): + info[paire[0].lower()] = paire[1].strip() + clore() + return hosts + + @staticmethod + def _ssh_resolve(alias): + """Configuration RÉSOLUE de l'alias, telle que ssh la voit (ssh -G). + + On délègue à ssh au lieu de relire le fichier : lui seul connaît les + Include, les Match, l'ordre des motifs et ses propres défauts. + """ + try: + res = subprocess.run( + ["ssh", "-G", alias], + capture_output=True, + text=True, + timeout=15, + env=TODO._qemu_c_env(), + ) + except (OSError, subprocess.SubprocessError): + return {} + if res.returncode != 0: + return {} + out = {} + for ligne in res.stdout.splitlines(): + cle, _, val = ligne.strip().partition(" ") + # ssh -G répète « identityfile » : la PREMIÈRE est celle qui compte. + if cle and val and cle.lower() not in out: + out[cle.lower()] = val + return out + + def _sshfs_command(self, alias, mount_point, resolved=None): + """(commande sshfs, alias contourné ?) pour monter cet alias. + + Sans « + » dans le nom, on laisse sshfs faire : c'est ssh qui lit la + config, et rien ne vaut mieux. Avec un « + », on résout l'alias + soi-même et on rend à sshfs une cible qu'il ne peut plus mal lire. + """ + base = "sshfs -o follow_symlinks" + if self.SSHFS_CHAIN_SEP not in alias: + return f"{base} {alias}:/ {mount_point}", False + cfg = resolved if resolved is not None else self._ssh_resolve(alias) + host = cfg.get("hostname") + # Un hostname qui contient encore un « + » ne réglerait rien, et un + # alias non résolu vaut mieux qu'une cible inventée. + if not host or self.SSHFS_CHAIN_SEP in host: + return f"{base} {alias}:/ {mount_point}", False + opts = [] + for cle, nom in self.SSH_FORWARD_OPTS: + val = cfg.get(cle) + if val and val.lower() != "none": + opts.append(f"-o {nom}={val}") + user = cfg.get("user") + cible = f"{user}@{host}" if user else host + pieces = [base] + opts + [f"{cible}:/", mount_point] + return " ".join(pieces), True + + @classmethod + def _ssh_failure_hint(cls, stderr): + """Première ligne utile de stderr, et ce qu'elle désigne.""" + texte = (stderr or "").lower() + for motif, indice in cls.SSH_FAILURE_HINTS: + if motif in texte: + return indice + return "" + + @staticmethod + def _ssh_probe(alias, timeout=8): + """(code, stderr) d'un « ssh true » sans invite de mot de passe. + + BatchMode : une invite bloquerait le menu. Un refus d'authentification + se distingue donc d'un hôte injoignable, et le diagnostic le dit. + """ + try: + res = subprocess.run( + [ + "ssh", + "-o", + "BatchMode=yes", + "-o", + f"ConnectTimeout={timeout}", + alias, + "true", + ], + capture_output=True, + text=True, + timeout=timeout + 12, + env=TODO._qemu_c_env(), + ) + except subprocess.TimeoutExpired: + return 255, "Connection timed out" + except (OSError, subprocess.SubprocessError) as exc: + return 255, str(exc) + return res.returncode, res.stderr.strip() + + def _sshfs_diagnose(self, alias, mount_point, bypassed): + """Dit POURQUOI le montage a échoué, et où aller corriger. + + Le message est ciblé, pas une liste de causes possibles : on interroge + ssh, et selon qu'il passe ou non, le fautif n'est pas le même. + """ + print(f"\n ⚠ {t('sshfs mount failed.')}") + if not alias: + print(f" → {t('Check the SSH host and that the server is up.')}") + return + print(f" {t('Checking SSH access…')} ({alias})") + code, err = self._ssh_probe(alias) + if code == 0: + print(f" ✓ {t('SSH reaches this host: ~/.ssh/config is fine.')}") + if not bypassed and self.SSHFS_CHAIN_SEP in alias: + print(f" → {t('sshfs reads the « + » as host chaining.')}") + cmd, ok = self._sshfs_command(alias, mount_point) + if ok: + print(f" → {t('Run this instead:')}") + print(f" {cmd}") + else: + # Annoncer une commande puis n'en donner aucune serait + # pire que se taire : on dit ce qui manque. + print( + f" → {t('ssh -G resolved nothing: check ~/.ssh/config.')}" + ) + else: + print(f" → {t('Is sshfs (and fuse) installed here?')}") + return + indice = self._ssh_failure_hint(err) + if indice: + print(f" ✗ {t('SSH fails too:')} {t(indice)}") + else: + print( + f" ✗ {t('SSH fails too:')} {err.splitlines()[0] if err else code}" + ) + print(f" → {t('Update ~/.ssh/config, or check the server is up.')}") + def _configure_sshfs(self): import getpass import re @@ -9458,31 +10648,7 @@ class TODO: if choice == "2": ssh_config_path = os.path.expanduser("~/.ssh/config") - hosts = [] - if os.path.exists(ssh_config_path): - current_host = None - current_info = {} - with open(ssh_config_path) as f: - for line in f: - line = line.strip() - if line.lower().startswith("host "): - host_val = line.split(None, 1)[1].strip() - if host_val != "*": - if current_host: - hosts.append((current_host, current_info)) - current_host = host_val - current_info = {} - elif current_host: - key = line.split(None, 1) - if len(key) == 2: - k = key[0].lower() - v = key[1].strip() - if k == "hostname": - current_info["hostname"] = v - elif k == "user": - current_info["user"] = v - if current_host: - hosts.append((current_host, current_info)) + hosts = self._ssh_config_entries(ssh_config_path) if not hosts: print(t("No SSH hosts found in ~/.ssh/config")) @@ -9539,17 +10705,41 @@ class TODO: # une chaîne de symlinks relatifs profonds (.repo/projects -> project- # objects) que git ne peut pas traverser sur un montage sshfs par # défaut (« erreur à la lecture de .git » -> git status/commit KO). - cmd = f"sshfs -o follow_symlinks {target} {mount_point}" + # L'alias vient de ~/.ssh/config : c'est lui qui peut porter un « + », + # et lui qu'on peut interroger en cas d'échec. Une saisie manuelle est + # rendue telle quelle — si elle contient un « + », c'est un chaînage + # demandé exprès. + alias = ssh_name if choice == "2" else "" + if alias: + cmd, bypassed = self._sshfs_command(alias, mount_point) + else: + cmd, bypassed = ( + f"sshfs -o follow_symlinks {target} {mount_point}", + False, + ) print(f"{t('Mounting sshfs on: ')}{mount_point}") print(f"{t('Will execute:')} {cmd}") try: - self.execute.exec_command_live(cmd, source_erplibre=False) - print(f"{t('Mounted on: ')}{mount_point}") - print(f"mount | grep sshfs") - print(f"{t('To unmount: ')}" f"fusermount -u {mount_point}") - print(f"nautilus {mount_point}/home/{user}") + status = self.execute.exec_command_live(cmd, source_erplibre=False) except Exception as e: print(f"{t('Error mounting sshfs: ')}{e}") + status = 1 + # Le reste ne s'affiche QUE si le montage a réussi : « Monté sur … » + # après un code 1 envoyait chercher des fichiers dans un répertoire + # vide, et faisait passer l'échec pour un détail. + if status: + self._sshfs_diagnose(alias, mount_point, bypassed) + # Le point de montage n'a jamais servi : le laisser accumulerait + # un répertoire vide dans /tmp à chaque tentative. + try: + os.rmdir(mount_point) + except OSError: + pass + return + print(f"{t('Mounted on: ')}{mount_point}") + print("mount | grep sshfs") + print(f"{t('To unmount: ')}" f"fusermount -u {mount_point}") + print(f"nautilus {mount_point}/home/{user}") def _get_ssh_params(self): """Prompt for SSH connection parameters. Returns dict or None on cancel.""" @@ -10209,6 +11399,7 @@ class TODO: "Modules missing from the default package" ) }, + {"prompt_description": t("Dependencies between modules")}, {"section": t("Files")}, { "prompt_description": t( @@ -10234,6 +11425,8 @@ class TODO: elif status == "5": self.execute_analyse_module_package() elif status == "6": + self.execute_analyse_module_dependency() + elif status == "7": self.execute_analyse_filestore() else: print(t("Command not found !")) @@ -10282,6 +11475,55 @@ class TODO: handler, ) + def execute_analyse_module_dependency(self): + """Qui dépend de qui, pour savoir ce qu'on peut retirer. + + L'écran est ouvert par l'outil lui-même, qui retombe sur son + rapport texte s'il ne peut pas — terminal absent, Textual absent. + + Pas d'option « sauvegarde .zip » : les dépendances vivent dans + `ir_module_module_dependency`, qu'un zip n'expose pas sans + restauration. + """ + from script.analyse import check_module_dependency as dependency + + database = self._analyse_select_database() + if not database: + return + print(f"⧖ {t('Reading the modules and their dependencies…')}") + try: + rapport = dependency.survey(database) + except Exception as exc: + print(f"❌ {t('Analysis failed: ')}{exc}") + return + if rapport.get("unavailable"): + print(f"❌ {t('Cannot read the database: ')}{database}") + return + try: + from script.analyse.check_module_dependency_tui import run_tui + except Exception: + run_tui = None + if not (run_tui and run_tui(rapport)): + # Borné : une base porte trois mille modules, et déverser six + # mille lignes dans le menu n'est pas un repli. + print("\n".join(dependency.render_text(rapport, limit=8, cap=40))) + + def handler(rank): + if rank == 1: + print("\n".join(dependency.render_text(rapport, limit=0))) + else: + self._analyse_export_json( + rapport, os.path.basename(database), "module_dependency" + ) + + self._analyse_follow_up( + [ + {"prompt_description": t("Show every entry")}, + {"prompt_description": t("Export as JSON")}, + ], + handler, + ) + def execute_analyse_filestore(self): """Ce qui manque au filestore, et ce qu'on peut encore récupérer. diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 89918d7..6327a7a 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -1748,6 +1748,18 @@ TRANSLATIONS = { "fr": "Rien de désinstallé.", "en": "Nothing uninstalled.", }, + "The uninstall did not take:": { + "fr": "La désinstallation n'a pas pris :", + "en": "The uninstall did not take:", + }, + "Odoo exits 0 even when it removes nothing.": { + "fr": "Odoo sort en 0 même quand il ne retire rien.", + "en": "Odoo exits 0 even when it removes nothing.", + }, + "Could not verify the uninstall.": { + "fr": "Impossible de vérifier la désinstallation.", + "en": "Could not verify the uninstall.", + }, "Check the COW views that drifted": { "fr": "Vérifier les vues COW en retard sur leur vue module", "en": "Check the COW views that drifted", @@ -3103,6 +3115,441 @@ TRANSLATIONS = { "fr": "Nœud de rendu", "en": "Render node", }, + "VM ready - taking its measurements": { + "fr": "VM prête — relevé en cours", + "en": "VM ready - taking its measurements", + }, + "VM start-up": { + "fr": "Mise en route de la VM", + "en": "VM start-up", + }, + "system:": { + "fr": "système :", + "en": "system:", + }, + "kernel:": { + "fr": "noyau :", + "en": "kernel: ", + }, + "address:": { + "fr": "adresse :", + "en": "address:", + }, + "disk:": { + "fr": "disque :", + "en": "disk: ", + }, + "uptime:": { + "fr": "actif :", + "en": "uptime: ", + }, + "Watch the VMs start (no install)": { + "fr": "Suivre le démarrage des VM (sans installation)", + "en": "Watch the VMs start (no install)", + }, + "Proxmox VE - Deploy a VM on a remote host": { + "fr": "Proxmox VE - Déployer une VM sur un hôte distant", + "en": "Proxmox VE - Deploy a VM on a remote host", + }, + "Deploy a virtual machine on Proxmox VE!": { + "fr": "Déployer une machine virtuelle sur Proxmox VE !", + "en": "Deploy a virtual machine on Proxmox VE!", + }, + "Which Proxmox host?": { + "fr": "Quel hôte Proxmox ?", + "en": "Which Proxmox host?", + }, + "From the local QEMU VMs": { + "fr": "Depuis les VM QEMU locales", + "en": "From the local QEMU VMs", + }, + "Type an address": { + "fr": "Saisir une adresse", + "en": "Type an address", + }, + "Keep": { + "fr": "Garder", + "en": "Keep", + }, + "Address (user@host, default user root): ": { + "fr": "Adresse (utilisateur@hôte, utilisateur root par défaut) : ", + "en": "Address (user@host, default user root): ", + }, + "SSH jump host (blank = none): ": { + "fr": "Rebond SSH (vide = aucun) : ", + "en": "SSH jump host (blank = none): ", + }, + "Local VMs:": { + "fr": "VM locales :", + "en": "Local VMs:", + }, + "No IP for this VM: is it running?": { + "fr": "Pas d'IP pour cette VM : est-elle démarrée ?", + "en": "No IP for this VM: is it running?", + }, + "Checking": { + "fr": "Vérification de", + "en": "Checking", + }, + "Not a Proxmox host (or unreachable):": { + "fr": "Ce n'est pas un hôte Proxmox (ou il est injoignable) :", + "en": "Not a Proxmox host (or unreachable):", + }, + "Check the address, the SSH access and pveversion.": { + "fr": "Vérifier l'adresse, l'accès SSH et pveversion.", + "en": "Check the address, the SSH access and pveversion.", + }, + "Proxmox host:": { + "fr": "Hôte Proxmox :", + "en": "Proxmox host:", + }, + "through": { + "fr": "par", + "en": "through", + }, + "exit code": { + "fr": "code de retour", + "en": "exit code", + }, + "No VM on this Proxmox host.": { + "fr": "Aucune VM sur cet hôte Proxmox.", + "en": "No VM on this Proxmox host.", + }, + "No running VM on this Proxmox host.": { + "fr": "Aucune VM démarrée sur cet hôte Proxmox.", + "en": "No running VM on this Proxmox host.", + }, + "VMs on this host:": { + "fr": "VM de cet hôte :", + "en": "VMs on this host:", + }, + "Selection (number): ": { + "fr": "Sélection (numéro) : ", + "en": "Selection (number): ", + }, + "Deploy a VM on the Proxmox host": { + "fr": "Déployer une VM sur l'hôte Proxmox", + "en": "Deploy a VM on the Proxmox host", + }, + "Preview a deployment (dry-run, nothing sent)": { + "fr": "Prévisualiser un déploiement (dry-run, rien n'est envoyé)", + "en": "Preview a deployment (dry-run, nothing sent)", + }, + "Download a cloud image on the host": { + "fr": "Télécharger une image cloud sur l'hôte", + "en": "Download a cloud image on the host", + }, + "List VMs (qm list)": { + "fr": "Lister les VM (qm list)", + "en": "List VMs (qm list)", + }, + "Clean up (orphan disks)": { + "fr": "Nettoyer (disques orphelins)", + "en": "Clean up (orphan disks)", + }, + "Statistics (host and VMs)": { + "fr": "Statistiques (hôte et VM)", + "en": "Statistics (host and VMs)", + }, + "Remote desktop tunnel (VNC/RDP over SSH)": { + "fr": "Tunnel bureau distant (VNC/RDP par SSH)", + "en": "Remote desktop tunnel (VNC/RDP over SSH)", + }, + "List available images and their specs": { + "fr": "Lister les images disponibles et leurs specs", + "en": "List available images and their specs", + }, + "Proxmox - example sequence (dry-run)": { + "fr": "Proxmox - exemple de séquence (dry-run)", + "en": "Proxmox - example sequence (dry-run)", + }, + "Host": { + "fr": "Hôte", + "en": "Host", + }, + "Change the Proxmox host": { + "fr": "Changer d'hôte Proxmox", + "en": "Change the Proxmox host", + }, + "VM name (default: erplibre-): ": { + "fr": "Nom de la VM (défaut : erplibre-) : ", + "en": "VM name (default: erplibre-): ", + }, + "RAM in MB, blank = 4096": { + "fr": "RAM en Mo, vide = 4096", + "en": "RAM in MB, blank = 4096", + }, + "vCPU, blank = 2": { + "fr": "vCPU, vide = 2", + "en": "vCPU, blank = 2", + }, + "Disk size (default 32G): ": { + "fr": "Taille du disque (défaut 32G) : ", + "en": "Disk size (default 32G): ", + }, + "Size (+10G to add, 40G for a target): ": { + "fr": "Taille (+10G pour ajouter, 40G pour une cible) : ", + "en": "Size (+10G to add, 40G for a target): ", + }, + "No storage able to hold a VM disk.": { + "fr": "Aucun stockage capable d'héberger un disque de VM.", + "en": "No storage able to hold a VM disk.", + }, + "No network bridge on this host.": { + "fr": "Aucun pont réseau sur cet hôte.", + "en": "No network bridge on this host.", + }, + "offered": { + "fr": "proposés", + "en": "offered", + }, + "Would run on": { + "fr": "Serait exécuté sur", + "en": "Would run on", + }, + "SSH key ->": { + "fr": "clé SSH ->", + "en": "SSH key ->", + }, + "Deploy this VM now? (Y/n): ": { + "fr": "Déployer cette VM maintenant ? (O/n) : ", + "en": "Deploy this VM now? (Y/n): ", + }, + "SSH key unreadable:": { + "fr": "Clé SSH illisible :", + "en": "SSH key unreadable:", + }, + "SSH key not pushed: password login only.": { + "fr": "Clé SSH non transmise : connexion par mot de passe seulement.", + "en": "SSH key not pushed: password login only.", + }, + "Step failed, stopping here.": { + "fr": "Étape en échec, on s'arrête ici.", + "en": "Step failed, stopping here.", + }, + "Waiting for the VM address…": { + "fr": "Attente de l'adresse de la VM…", + "en": "Waiting for the VM address…", + }, + "No address yet. Try [6] later.": { + "fr": "Pas encore d'adresse. Réessayer avec [6] plus tard.", + "en": "No address yet. Try [6] later.", + }, + "Install ERPLibre on it? (Y/n): ": { + "fr": "Y installer ERPLibre ? (O/n) : ", + "en": "Install ERPLibre on it? (Y/n): ", + }, + "No address from the guest agent.": { + "fr": "Aucune adresse rendue par l'agent invité.", + "en": "No address from the guest agent.", + }, + "Is qemu-guest-agent installed and the VM started?": { + "fr": "qemu-guest-agent est-il installé et la VM démarrée ?", + "en": "Is qemu-guest-agent installed and the VM started?", + }, + "Ctrl+O to quit the serial console.": { + "fr": "Ctrl+O pour quitter la console série.", + "en": "Ctrl+O to quit the serial console.", + }, + "Proxmox can only GROW a disk, never shrink it.": { + "fr": "Proxmox ne sait qu'AGRANDIR un disque, jamais le rétrécir.", + "en": "Proxmox can only GROW a disk, never shrink it.", + }, + "This also destroys their disks and backups.": { + "fr": "Cela détruit aussi leurs disques et leurs sauvegardes.", + "en": "This also destroys their disks and backups.", + }, + "Nothing orphaned.": { + "fr": "Rien d'orphelin.", + "en": "Nothing orphaned.", + }, + "Orphan disks:": { + "fr": "Disques orphelins :", + "en": "Orphan disks:", + }, + "Free them?": { + "fr": "Les libérer ?", + "en": "Free them?", + }, + "no address, skipped": { + "fr": "pas d'adresse, ignorée", + "en": "no address, skipped", + }, + "unreachable from here.": { + "fr": "injoignable d'ici.", + "en": "unreachable from here.", + }, + "Use [13] to add a ProxyJump entry, then a tunnel.": { + "fr": "Utiliser [13] pour l'entrée ProxyJump, puis un tunnel.", + "en": "Use [13] to add a ProxyJump entry, then a tunnel.", + }, + "Example: demo-vm, Debian 13, on a Proxmox host": { + "fr": "Exemple : demo-vm, Debian 13, sur un hôte Proxmox", + "en": "Example: demo-vm, Debian 13, on a Proxmox host", + }, + "storages": { + "fr": "stockages", + "en": "storages", + }, + "memory": { + "fr": "mémoire", + "en": "memory", + }, + "No address for this VM.": { + "fr": "Aucune adresse pour cette VM.", + "en": "No address for this VM.", + }, + "A static address is visible right after creation.": { + "fr": "Une adresse fixe est connue dès la création.", + "en": "A static address is visible right after creation.", + }, + "address given at creation:": { + "fr": "adresse donnée à la création :", + "en": "address given at creation:", + }, + "qm create needs one. Two ways:": { + "fr": "« qm create » en exige un. Deux voies :", + "en": "qm create needs one. Two ways:", + }, + "create an internal": {"fr": "créer un pont interne", "en": "create an internal"}, + "touches no physical NIC": { + "fr": "ne touche à aucune interface physique", + "en": "touches no physical NIC", + }, + "do it myself (bridge-ports , needs console)": { + "fr": "le faire moi-même (bridge-ports , console requise)", + "en": "do it myself (bridge-ports , needs console)", + }, + "To bridge the LAN, on the host:": { + "fr": "Pour ponter le LAN, sur l'hôte :", + "en": "To bridge the LAN, on the host:", + }, + "This moves the host address: do it from a console.": { + "fr": "Cela déplace l'adresse de l'hôte : à faire depuis une console.", + "en": "This moves the host address: do it from a console.", + }, + "uplink for NAT": {"fr": "sortie pour le NAT", "en": "uplink for NAT"}, + "none": {"fr": "aucune", "en": "none"}, + "The bridge did not come up.": { + "fr": "Le pont n'est pas monté.", + "en": "The bridge did not come up.", + }, + "SSH does not know this host key yet.": { + "fr": "ssh ne connaît pas encore la clé de cet hôte.", + "en": "SSH does not know this host key yet.", + }, + "Would record:": { + "fr": "Enregistrerait :", + "en": "Would record:", + }, + "Record it?": { + "fr": "L'enregistrer ?", + "en": "Record it?", + }, + "No host key obtained.": { + "fr": "Aucune clé d'hôte obtenue.", + "en": "No host key obtained.", + }, + "key(s) recorded in ~/.ssh/known_hosts": { + "fr": "clé(s) enregistrée(s) dans ~/.ssh/known_hosts", + "en": "key(s) recorded in ~/.ssh/known_hosts", + }, + "qm needs root: no root, and sudo asks for a password.": { + "fr": "qm exige root : ni root, ni sudo sans mot de passe.", + "en": "qm needs root: no root, and sudo asks for a password.", + }, + "Connect as root@, or allow NOPASSWD sudo.": { + "fr": "Se connecter en root@, ou autoriser sudo sans mot de passe.", + "en": "Connect as root@, or allow NOPASSWD sudo.", + }, + "Proxmox VE hypervisor (no Odoo)": { + "fr": "Hyperviseur Proxmox VE (sans Odoo)", + "en": "Proxmox VE hypervisor (no Odoo)", + }, + "Proxmox will NOT be installed: plain Debian VM.": { + "fr": "Proxmox ne sera PAS installé : la VM restera une Debian nue.", + "en": "Proxmox will NOT be installed: plain Debian VM.", + }, + "Later, in the VM:": { + "fr": "Plus tard, dans la VM :", + "en": "Later, in the VM:", + }, + "sshfs mount failed.": { + "fr": "Le montage sshfs a échoué.", + "en": "sshfs mount failed.", + }, + "Checking SSH access…": { + "fr": "Vérification de l'accès SSH…", + "en": "Checking SSH access…", + }, + "SSH reaches this host: ~/.ssh/config is fine.": { + "fr": "SSH joint cet hôte : ~/.ssh/config est bon.", + "en": "SSH reaches this host: ~/.ssh/config is fine.", + }, + "sshfs reads the « + » as host chaining.": { + "fr": "sshfs lit le « + » comme un chaînage d'hôtes.", + "en": "sshfs reads the « + » as host chaining.", + }, + "Run this instead:": { + "fr": "À lancer plutôt :", + "en": "Run this instead:", + }, + "ssh -G resolved nothing: check ~/.ssh/config.": { + "fr": "« ssh -G » n'a rien résolu : vérifier ~/.ssh/config.", + "en": "ssh -G resolved nothing: check ~/.ssh/config.", + }, + "Is sshfs (and fuse) installed here?": { + "fr": "sshfs (et fuse) sont-ils installés ici ?", + "en": "Is sshfs (and fuse) installed here?", + }, + "SSH fails too:": { + "fr": "SSH échoue aussi :", + "en": "SSH fails too:", + }, + "Update ~/.ssh/config, or check the server is up.": { + "fr": "Mettre à jour ~/.ssh/config, ou vérifier que le serveur répond.", + "en": "Update ~/.ssh/config, or check the server is up.", + }, + "Check the SSH host and that the server is up.": { + "fr": "Vérifier l'hôte SSH saisi et que le serveur répond.", + "en": "Check the SSH host and that the server is up.", + }, + "unknown host name: check HostName": { + "fr": "nom d'hôte inconnu : vérifier HostName", + "en": "unknown host name: check HostName", + }, + "no answer: is the server up and reachable?": { + "fr": "aucune réponse : le serveur est-il allumé et joignable ?", + "en": "no answer: is the server up and reachable?", + }, + "no route: check the network or the ProxyJump": { + "fr": "pas de route : vérifier le réseau ou le ProxyJump", + "en": "no route: check the network or the ProxyJump", + }, + "nothing listening on the SSH port": { + "fr": "rien n'écoute sur le port SSH", + "en": "nothing listening on the SSH port", + }, + "authentication refused: check User and key": { + "fr": "authentification refusée : vérifier User et la clé", + "en": "authentication refused: check User and key", + }, + "host key changed for this address": { + "fr": "la clé d'hôte a changé pour cette adresse", + "en": "host key changed for this address", + }, + "no statistics yet": { + "fr": "pas encore de statistiques", + "en": "no statistics yet", + }, + "10s average": { + "fr": "moyenne 10 s", + "en": "10s average", + }, + "total written": { + "fr": "cumul écrit", + "en": "total written", + }, "Screens": { "fr": "Écrans", "en": "Screens", @@ -6114,6 +6561,150 @@ TRANSLATIONS = { "fr": "📦 Modules absents du package par défaut", "en": "📦 Modules missing from the default package", }, + "Dependencies between modules": { + "fr": "🔗 Dépendances entre les modules", + "en": "🔗 Dependencies between modules", + }, + "modules installed": { + "fr": "modules installés", + "en": "modules installed", + }, + "of them installed": { + "fr": "installé(s)", + "en": "of them installed", + }, + "nothing installed depends on it": { + "fr": "rien d'installé n'en dépend", + "en": "nothing installed depends on it", + }, + "Reading the modules and their dependencies…": { + "fr": "Lecture des modules et de leurs dépendances…", + "en": "Reading the modules and their dependencies…", + }, + "Modules and their dependencies": { + "fr": "Modules et leurs dépendances", + "en": "Modules and their dependencies", + }, + "unknown to this database": { + "fr": "inconnu de cette base", + "en": "unknown to this database", + }, + "state": { + "fr": "état", + "en": "state", + }, + "author": { + "fr": "auteur", + "en": "author", + }, + "application": { + "fr": "application", + "en": "application", + }, + "yes": { + "fr": "oui", + "en": "yes", + }, + "depends on": { + "fr": "dépend de", + "en": "depends on", + }, + "needed by": { + "fr": "requis par", + "en": "needed by", + }, + "absent": { + "fr": "absent", + "en": "absent", + }, + "press d to walk the dependencies": { + "fr": "appuyez sur d pour parcourir les dépendances", + "en": "press d to walk the dependencies", + }, + "installed on missing dependencies": { + "fr": "installé sur des dépendances absentes", + "en": "installed on missing dependencies", + }, + "broken dependency(ies)": { + "fr": "dépendance(s) cassée(s)", + "en": "broken dependency(ies)", + }, + "What it needs, directly": { + "fr": "Ce dont il a besoin, directement", + "en": "What it needs, directly", + }, + "What needs it, directly": { + "fr": "Ce qui en a besoin, directement", + "en": "What needs it, directly", + }, + "Everything it pulls in": { + "fr": "Tout ce qu'il entraîne", + "en": "Everything it pulls in", + }, + "Everything that falls with it": { + "fr": "Tout ce qui tombe avec lui", + "en": "Everything that falls with it", + }, + "all modules": { + "fr": "tous les modules", + "en": "all modules", + }, + "installed only": { + "fr": "installés seulement", + "en": "installed only", + }, + "not installed": { + "fr": "non installés", + "en": "not installed", + }, + "broken dependencies": { + "fr": "dépendances cassées", + "en": "broken dependencies", + }, + "in progress": { + "fr": "en cours", + "en": "in progress", + }, + "apps only": { + "fr": "applications seulement", + "en": "apps only", + }, + "left in a transient state": { + "fr": "laissés dans un état de passage", + "en": "left in a transient state", + }, + "Installed": { + "fr": "Installés", + "en": "Installed", + }, + "In progress": { + "fr": "En cours", + "en": "In progress", + }, + "Apps": { + "fr": "Applications", + "en": "Apps", + }, + "Dependencies": { + "fr": "Dépendances", + "en": "Dependencies", + }, + "Filter": { + "fr": "Filtre", + "en": "Filter", + }, + "Search": { + "fr": "Chercher", + "en": "Search", + }, + "module": { + "fr": "module", + "en": "module", + }, + "module name…": { + "fr": "nom de module…", + "en": "module name…", + }, "Show every entry": { "fr": "📜 Tout afficher", "en": "📜 Show every entry", diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index 84793da..142750b 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -2020,17 +2020,8 @@ class TodoUpgrade: # uninstall_module_list_odoo130_to_odoo140.txt is read HERE, # right before the 13 -> 14 data migration. Without this the # per-bump files existed in name only and were never read. - lst_file, lst_detail = self.read_uninstall_module_list( - next_version - 1, database_name - ) - if lst_detail: - print( - f"✨ {t('Modules to uninstall before Odoo')}" - f"{next_version} :" - ) - self.print_uninstall_reason(lst_detail) - lst_module_to_uninstall = list( - dict.fromkeys(list(lst_module_to_uninstall) + lst_file) + lst_module_to_uninstall = self.uninstall_list_for( + next_version, database_name, lst_module_to_uninstall ) if lst_module_to_uninstall: @@ -2040,9 +2031,6 @@ class TodoUpgrade: next_version - 1, ) lst_module_uninstall_module[index] = True - self.dct_progression["state_4_module_migrate_odoo_lst"] = ( - lst_module_uninstall_module - ) self.write_config() self.dct_progression["config_state_4_uninstall_module"] = ( @@ -2087,9 +2075,6 @@ class TodoUpgrade: next_version - 1, ) lst_module_install_module[index] = True - self.dct_progression["state_4_module_migrate_odoo_lst"] = ( - lst_module_install_module - ) self.write_config() self.dct_progression["config_state_4_install_module"] = ( @@ -2309,8 +2294,15 @@ class TodoUpgrade: # Duplicate database cmd_clone_database = f"./odoo_bin.sh db --clone --from_database {last_database_name} --database {database_name_upgrade}" self.todo_upgrade_execute(cmd_clone_database) + # Le clone est NEUF : ce qu'on avait retiré du + # précédent est revenu avec lui. La liste du palier + # se rejoue donc ici, avec les modules choisis. self.uninstall_from_database( - lst_module_to_delete, + self.uninstall_list_for( + next_version, + database_name, + lst_module_to_delete, + ), database_name_upgrade, next_version, ) @@ -2621,7 +2613,15 @@ class TodoUpgrade: cmd_upgrade = f".venv.{erplibre_version}/bin/python ./odoo{next_version}.0/OCA_OpenUpgrade/odoo-bin -c ./config.conf --update all --no-http --stop-after-init -d {database_name_upgrade}" else: cmd_upgrade = f"./run.sh --upgrade-path=./odoo{next_version}.0/OCA_OpenUpgrade/openupgrade_scripts/scripts --update all -c config.conf --stop-after-init --no-http --load=base,web,openupgrade_framework -d {database_name_upgrade}" - lst_upgrade_odoo[index] = cmd_upgrade + # NE PAS enregistrer la commande ici. `lst_upgrade_odoo` + # EST la liste de `dct_progression` — `.get` rend l'objet, + # pas une copie — donc la muter maintenant la fait persister + # au premier write_config() venu, y compris celui du chemin + # d'échec juste en dessous. L'étape passait alors pour faite + # et la reprise SAUTAIT OpenUpgrade : mesuré sur + # test_neutralize_upgrade_18, resté en base 17.0.1.3 avec sa + # commande 18 déjà consignée. On l'enregistre après la + # réussite, où le commentaire dit déjà qu'elle appartient. # Record the website COW views before the data migration. The # upgrade silently deletes and recreates copies (measured on @@ -2663,6 +2663,24 @@ class TodoUpgrade: self.dct_progression["state_4_clone_odoo_lst"] = ( lst_clone_odoo ) + # Tout ce qui a été fait À ce clone meurt avec lui : la + # base reconstruite est une copie neuve de la version + # précédente et réclame la même préparation. Laisser ces + # drapeaux debout sautait le SQL de pré-migration sur le + # clone neuf, et OpenUpgrade retombait sur le problème + # même que ce SQL existe pour écarter. + lst_fix_migration_odoo[index] = [] + self.dct_progression["state_4_fix_migration_odoo_lst"] = ( + lst_fix_migration_odoo + ) + lst_module_uninstall_module[index] = False + self.dct_progression["state_4_uninstall_module"] = ( + lst_module_uninstall_module + ) + lst_module_install_module[index] = False + self.dct_progression["state_4_install_module"] = ( + lst_module_install_module + ) self.write_config() print( f"\n❌ -> {t('Database migration to Odoo')}" @@ -2688,6 +2706,7 @@ class TodoUpgrade: f"after_{next_version}", ) + lst_upgrade_odoo[index] = cmd_upgrade self.dct_progression["state_4_upgrade_odoo_lst"] = ( lst_upgrade_odoo ) @@ -3198,6 +3217,34 @@ class TodoUpgrade: self.open_step_log(msg) print(f"🔷 {prefix}{sep}{t(label)}" if sep else f"🔷 {t(msg)}") + def still_installed(self, database_name, lst_module): + """Parmi ces modules, lesquels la base tient-elle ENCORE ? + + Odoo ne signale rien quand il ne retire rien : « --uninstall » ne + cherche que l'état « installed » et laisse filer en silence un module + resté en « to remove » d'une tentative précédente. Le code de sortie + vaut donc 0 pour une désinstallation qui n'a pas eu lieu — c'est ainsi + que muk_web_theme a traversé quatre paliers en étant réputé retiré. + + Rendre None, et non la liste vide, quand la base ne répond pas : + « je ne sais pas » et « rien ne reste » appellent des suites + différentes, et les confondre recrée le défaut qu'on corrige. + """ + if not lst_module: + return [] + noms = ", ".join(f"'{nom}'" for nom in sorted(set(lst_module))) + status, _cmd, output = self.todo_upgrade_execute( + f'psql -X -w -d {database_name} -tAc "SELECT name FROM' + f" ir_module_module WHERE name IN ({noms})" + " AND state <> 'uninstalled' ORDER BY name;\"", + get_output=True, + wait_at_error=False, + quiet=True, + ) + if status: + return None + return [line.strip() for line in (output or []) if line.strip()] + def installed_theme(self, database_name): """Thèmes installés, hors theme_default qui EST l'absence de thème.""" status, _cmd, output = self.todo_upgrade_execute( @@ -3747,6 +3794,32 @@ class TodoUpgrade: return [] return lst_present + def uninstall_list_for(self, next_version, database_name, extra=()): + """Ce qu'il faut retirer avant `next_version`, liste du palier comprise. + + DEUX endroits bâtissent le clone intermédiaire : l'étape + « Uninstall module », et « Choose delete missing module » qui le + jette et le refait depuis la version précédente. Le second ne + rejouait que les modules choisis là, donc la liste du palier était + perdue avec le clone : web_responsive revenait, et la 18 refusait + de charger sur l'exclusion de muk_web_theme — après que la première + désinstallation eut pourtant réussi. Une seule fonction, pour que + les deux ne puissent plus diverger. + + `dict.fromkeys` dédoublonne au passage : la liste choisie porte + parfois deux fois le même nom. + """ + lst_file, lst_detail = self.read_uninstall_module_list( + next_version - 1, database_name + ) + if lst_detail: + print( + f"✨ {t('Modules to uninstall before Odoo')}" + f"{next_version} :" + ) + self.print_uninstall_reason(lst_detail) + return list(dict.fromkeys(list(extra) + lst_file)) + def uninstall_from_database( self, lst_module_to_uninstall, database_name, actual_version ): @@ -3773,12 +3846,26 @@ class TodoUpgrade: single_source_odoo=True, ) + lst_left = self.still_installed(database_name, lst_module_to_uninstall) + if lst_left is None: + # Base illisible : on ne sait pas. Le dire, plutôt que de trancher. + print(f"⚠️ {t('Could not verify the uninstall.')}") + lst_left = [] + elif lst_left: + self.add_comment_progression( + "uninstall - still installed: " + ", ".join(lst_left) + ) + print( + f"❌ {t('The uninstall did not take:')} {', '.join(lst_left)}" + ) + print(f" {t('Odoo exits 0 even when it removes nothing.')}") + # Update list installed module — only what was REALLY uninstalled, so # a module left in place stays counted as installed. self.dct_module_per_version[actual_version] = sorted( list( set(self.dct_module_per_version[actual_version]) - - set(lst_module_to_uninstall) + - (set(lst_module_to_uninstall) - set(lst_left)) ) ) self.dct_progression["dct_module_per_version"] = ( diff --git a/test/test_check_module_dependency.py b/test/test_check_module_dependency.py new file mode 100644 index 0000000..826ca18 --- /dev/null +++ b/test/test_check_module_dependency.py @@ -0,0 +1,823 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Qui dépend de qui, et ce qu'on peut retirer sans casser. + +La question posée au palier 17 → 18 — « puis-je retirer web_responsive » +— a demandé une requête écrite à la main. L'outil y répond, et la réponse +qui compte n'est pas « deux modules en dépendent » mais « aucun de ceux +qui en dépendent n'est installé ». +""" + +import os +import sys +import unittest + +sys.path.append( + os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) +) + +from script.analyse import check_module_dependency as dep # noqa: E402 +from script.analyse import check_module_dependency_tui as tui # noqa: E402 + + +def base(modules, depend=None, apps=()): + """Un rapport minimal. `modules` : {nom: état}.""" + recensement = { + nom: (etat, f"Résumé {nom}", nom in apps, "TechnoLibre") + for nom, etat in modules.items() + } + depend = ( + {nom: sorted(depend.get(nom, [])) for nom in recensement} + if depend + else {nom: [] for nom in recensement} + ) + return { + "database": "essai", + "version": "18.0", + "modules": recensement, + "depends": depend, + "dependents": dep.reverse(depend), + "broken": dep.broken(recensement, depend), + } + + +class TestReverse(unittest.TestCase): + def test_it_inverts_the_arrows(self): + self.assertEqual( + dep.reverse({"zebre": ["alpha"], "alpha": []}), + {"alpha": ["zebre"], "zebre": []}, + ) + + def test_a_module_cited_but_absent_still_gets_an_entry(self): + # C'est le cas qu'on cherche : quelqu'un dépend d'un module que la + # base ne connaît pas. L'omettre le rendrait invisible. + self.assertEqual(dep.reverse({"a": ["fantome"]})["fantome"], ["a"]) + + def test_a_duplicate_dependency_is_counted_once(self): + self.assertEqual(dep.reverse({"a": ["b", "b"]})["b"], ["a"]) + + +class TestClosure(unittest.TestCase): + def test_it_walks_through(self): + graphe = {"zebre": ["milieu"], "milieu": ["alpha"], "alpha": []} + self.assertEqual(dep.closure("zebre", graphe), ["alpha", "milieu"]) + + def test_it_excludes_itself(self): + self.assertNotIn("a", dep.closure("a", {"a": ["b"], "b": ["a"]})) + + def test_a_cycle_terminates(self): + # Un cycle ne devrait pas exister en base ; s'il y en a un, on veut + # un résultat, pas une boucle sans fin. + self.assertEqual( + dep.closure("a", {"a": ["b"], "b": ["c"], "c": ["a"]}), + ["b", "c"], + ) + + def test_a_chain_longer_than_the_bound_is_cut_not_hung(self): + chaine = { + f"n{i}": [f"n{i + 1}"] for i in range(dep.PROFONDEUR_MAX + 8) + } + atteints = dep.closure("n0", chaine) + self.assertEqual(len(atteints), dep.PROFONDEUR_MAX) + + def test_an_unknown_module_reaches_nothing(self): + self.assertEqual(dep.closure("absent", {"a": ["b"]}), []) + + +class TestBroken(unittest.TestCase): + def test_installed_on_an_uninstalled_dependency(self): + rapport = base( + {"chef": "installed", "socle": "uninstalled"}, + {"chef": ["socle"]}, + ) + self.assertEqual(rapport["broken"], [("chef", "socle", "uninstalled")]) + + def test_installed_on_a_module_the_base_never_heard_of(self): + rapport = base({"chef": "installed"}, {"chef": ["fantome"]}) + self.assertEqual(rapport["broken"], [("chef", "fantome", "unknown")]) + + def test_an_uninstalled_module_may_depend_on_anything(self): + # Il ne tourne pas : ses dépendances ne cassent rien. + rapport = base( + {"chef": "uninstalled", "socle": "uninstalled"}, + {"chef": ["socle"]}, + ) + self.assertEqual(rapport["broken"], []) + + def test_to_upgrade_counts_as_in_place(self): + # Le module est chargé, il sera seulement rejoué. + rapport = base( + {"chef": "installed", "socle": "to upgrade"}, + {"chef": ["socle"]}, + ) + self.assertEqual(rapport["broken"], []) + + def test_to_install_is_not_in_place_yet(self): + rapport = base( + {"chef": "installed", "socle": "to install"}, + {"chef": ["socle"]}, + ) + self.assertEqual(rapport["broken"], [("chef", "socle", "to install")]) + + +class TestCounts(unittest.TestCase): + def rapport(self): + return base( + { + "web_responsive": "installed", + "erplibre_base": "uninstalled", + "web_responsive_company": "uninstalled", + "mail": "installed", + }, + { + "web_responsive": ["mail"], + "erplibre_base": ["web_responsive"], + "web_responsive_company": ["web_responsive"], + }, + ) + + def test_the_third_number_counts_only_what_is_in_place(self): + # Le cas réel : deux dépendants déclarés, aucun installé. Ne + # montrer que « 2 » ferait renoncer à un retrait sans danger. + self.assertEqual( + dep.counts(self.rapport(), "web_responsive"), (1, 2, 0) + ) + + def test_an_installed_dependent_is_counted(self): + rapport = self.rapport() + rapport["modules"]["erplibre_base"] = ("installed", "", False, "") + self.assertEqual(dep.counts(rapport, "web_responsive")[2], 1) + + def test_a_module_nobody_needs(self): + self.assertEqual( + dep.counts(self.rapport(), "erplibre_base"), (1, 0, 0) + ) + + +class TestRows(unittest.TestCase): + def rapport(self): + return base( + { + "zebre": "installed", + "alpha": "uninstalled", + "milieu": "installed", + }, + {"zebre": ["alpha"]}, + ) + + def test_they_come_out_sorted_by_name(self): + # Les noms sont posés dans un ordre qui CONTREDIT l'alphabet : + # sans tri, la liste sortirait zebre, alpha, milieu. + self.assertEqual( + [row["name"] for row in dep.rows(self.rapport())], + ["alpha", "milieu", "zebre"], + ) + + def test_the_installed_filter_keeps_only_those(self): + self.assertEqual( + [r["name"] for r in dep.rows(self.rapport(), "installed")], + ["milieu", "zebre"], + ) + + def test_the_absent_filter_is_the_complement(self): + self.assertEqual( + [r["name"] for r in dep.rows(self.rapport(), "absent")], ["alpha"] + ) + + def test_the_broken_filter_keeps_the_guilty_only(self): + # zebre est installé et dépend d'alpha qui ne l'est pas. + self.assertEqual( + [r["name"] for r in dep.rows(self.rapport(), "broken")], ["zebre"] + ) + + def test_the_detail_column_shows_both_counts(self): + row = [r for r in dep.rows(self.rapport()) if r["name"] == "alpha"][0] + self.assertEqual(row["detail"], "0↓ 1/1↑") + + def test_every_filter_is_understood(self): + # Un filtre inconnu ne doit pas vider la liste en silence. + for filtre in dep.FILTRES: + dep.rows(self.rapport(), filtre) + + +class TestTheTransientStates(unittest.TestCase): + """Un module qui reste en chemin est une opération inachevée. + + Mesuré sur test_neutralize_upgrade_13, en pleine migration : + 22 modules figés en « to remove ». Aucun autre filtre ne les montrait + pour ce qu'ils sont. + """ + + def test_the_three_transient_states_are_pending(self): + for etat in ("to install", "to upgrade", "to remove"): + self.assertTrue(dep.pending(etat), etat) + + def test_a_settled_state_is_not(self): + for etat in ("installed", "uninstalled", "uninstallable"): + self.assertFalse(dep.pending(etat), etat) + + def rapport(self): + return base( + { + "zebre": "installed", + "alpha": "to remove", + "milieu": "to install", + "bravo": "uninstalled", + "charlie": "to upgrade", + } + ) + + def test_the_pending_filter_shows_exactly_those(self): + self.assertEqual( + [r["name"] for r in dep.rows(self.rapport(), "pending")], + ["alpha", "charlie", "milieu"], + ) + + def test_a_module_on_its_way_out_is_not_listed_as_absent(self): + # « to remove » EST encore installé ; le ranger avec ce qu'on + # pourrait installer envoyait chercher au mauvais endroit. + self.assertEqual( + [r["name"] for r in dep.rows(self.rapport(), "absent")], ["bravo"] + ) + + def test_to_upgrade_is_both_installed_and_pending(self): + # Il est chargé ET il attend : les deux sont vrais, et les taire + # ferait disparaître un module d'une des deux vues. + noms = [r["name"] for r in dep.rows(self.rapport(), "installed")] + self.assertIn("charlie", noms) + self.assertIn( + "charlie", [r["name"] for r in dep.rows(self.rapport(), "pending")] + ) + + def test_the_header_announces_them(self): + self.assertIn("3 en cours", dep.head_text(self.rapport())) + + def test_the_header_stays_quiet_when_there_are_none(self): + self.assertNotIn("en cours", dep.head_text(base({"a": "installed"}))) + + def test_the_text_report_lists_them_with_their_state(self): + texte = "\n".join(dep.render_text(self.rapport())) + self.assertIn("alpha (to remove)", texte) + self.assertIn("milieu (to install)", texte) + + def bloc_en_cours(self, lignes): + """Les lignes qui suivent le titre « état de passage ».""" + debut = next(i for i, x in enumerate(lignes) if "état de passage" in x) + suite = [] + for ligne in lignes[debut + 1 :]: + if not ligne.startswith(" "): + break + suite.append(ligne) + return suite + + def test_the_text_report_bounds_that_list_too(self): + # Trois en cours, borne à deux : deux nommés, et le troisième + # ANNONCÉ. Compter les lignes, pas chercher un chiffre au hasard. + suite = self.bloc_en_cours(dep.render_text(self.rapport(), limit=2)) + nommes = [x for x in suite if x.strip().endswith(")")] + self.assertEqual(len(nommes), 2) + self.assertEqual(suite[-1].strip(), "… 1 de plus") + + def test_without_a_limit_they_are_all_named(self): + suite = self.bloc_en_cours(dep.render_text(self.rapport())) + self.assertEqual(len(suite), 3) + self.assertFalse(any("de plus" in x for x in suite)) + + +class TestTheApplicationFilter(unittest.TestCase): + def rapport(self): + return base( + { + "zebre": "installed", + "alpha": "installed", + "milieu": "uninstalled", + }, + apps=("zebre", "milieu"), + ) + + def test_it_keeps_the_apps_whatever_their_state(self): + # Un module applicatif non installé reste applicatif : filtrer + # sur « App » et sur « installé » sont deux questions. + self.assertEqual( + [r["name"] for r in dep.rows(self.rapport(), "application")], + ["milieu", "zebre"], + ) + + def test_a_plain_module_is_left_out(self): + self.assertNotIn( + "alpha", + [r["name"] for r in dep.rows(self.rapport(), "application")], + ) + + def test_the_pane_says_whether_it_is_one(self): + self.assertIn("oui", dep.pane_text(self.rapport(), "zebre")) + self.assertIn("non", dep.pane_text(self.rapport(), "alpha")) + + +class TestToggleFilter(unittest.TestCase): + def test_the_same_key_goes_and_comes_back(self): + # Sans le retour, il faudrait se souvenir de quelle AUTRE touche + # ramène à la liste entière. + self.assertEqual(tui.toggle_filter("all", "pending"), "pending") + self.assertEqual(tui.toggle_filter("pending", "pending"), "all") + + def test_another_key_replaces_rather_than_returns(self): + self.assertEqual(tui.toggle_filter("installed", "pending"), "pending") + + def test_every_direct_filter_is_a_known_one(self): + for cible in ("installed", "pending", "application"): + self.assertIn(cible, dep.FILTRES) + + +class TestListing(unittest.TestCase): + def rapport(self): + return base( + {"haut": "installed", "milieu": "installed", "bas": "installed"}, + {"haut": ["milieu"], "milieu": ["bas"]}, + ) + + def test_depends_is_direct_only(self): + self.assertEqual( + dep.listing(self.rapport(), "haut", "depends"), ["milieu"] + ) + + def test_pulls_goes_all_the_way_down(self): + self.assertEqual( + dep.listing(self.rapport(), "haut", "pulls"), ["bas", "milieu"] + ) + + def test_dependents_is_direct_only(self): + self.assertEqual( + dep.listing(self.rapport(), "bas", "dependents"), ["milieu"] + ) + + def test_falls_goes_all_the_way_up(self): + self.assertEqual( + dep.listing(self.rapport(), "bas", "falls"), ["haut", "milieu"] + ) + + def test_every_mode_has_a_title(self): + for mode in dep.DETAILS: + self.assertIn(mode, dep.TITRE_DETAIL) + + +class TestPaneText(unittest.TestCase): + def rapport(self): + return base( + {"web_responsive": "installed", "compagnon": "uninstalled"}, + {"compagnon": ["web_responsive"]}, + ) + + def test_it_answers_the_removal_question_in_words(self): + # Deux chiffres à comparer soi-même, ce n'est pas une réponse. + texte = dep.pane_text(self.rapport(), "web_responsive") + self.assertIn("rien d'installé n'en dépend", texte) + + def test_it_stays_silent_when_something_does_depend_on_it(self): + rapport = self.rapport() + rapport["modules"]["compagnon"] = ("installed", "", False, "") + self.assertNotIn( + "rien d'installé n'en dépend", + dep.pane_text(rapport, "web_responsive"), + ) + + def test_an_absent_dependent_is_marked_as_such(self): + texte = dep.pane_text( + self.rapport(), "web_responsive", mode="dependents" + ) + self.assertIn("compagnon", texte) + self.assertIn("absent", texte) + + def test_an_unknown_module_says_so(self): + self.assertIn("inconnu", dep.pane_text(self.rapport(), "jamais_vu")) + + def test_nothing_selected_is_not_a_crash(self): + self.assertTrue(dep.pane_text(self.rapport(), None)) + + def test_an_empty_listing_says_nothing_rather_than_showing_blank(self): + texte = dep.pane_text(self.rapport(), "web_responsive", mode="depends") + self.assertIn("rien", texte.lower()) + + def test_the_limit_announces_what_it_cut(self): + rapport = base( + dict( + {"chef": "installed"}, + **{f"d{i}": "installed" for i in range(9)}, + ), + {"chef": [f"d{i}" for i in range(9)]}, + ) + texte = dep.pane_text(rapport, "chef", mode="depends", limit=4) + self.assertIn("5", texte) + + +class TestRenderText(unittest.TestCase): + def rapport(self): + return base({f"m{i:02d}": "installed" for i in range(30)}) + + def test_without_a_cap_everything_is_there(self): + lignes = dep.render_text(self.rapport()) + self.assertEqual(sum(1 for x in lignes if x.startswith("✅")), 30) + + def test_the_cap_bounds_the_module_list(self): + # Sur une vraie base c'est 3035 modules et 6046 lignes : sans + # borne, le repli du menu est une seconde panne. + lignes = dep.render_text(self.rapport(), cap=5) + self.assertEqual(sum(1 for x in lignes if x.startswith("✅")), 5) + + def test_the_cap_says_how_many_it_hid(self): + self.assertTrue( + any("25" in x for x in dep.render_text(self.rapport(), cap=5)) + ) + + def test_an_unreadable_database_says_so(self): + lignes = dep.render_text({"database": "x", "unavailable": True}) + self.assertEqual(len(lignes), 1) + self.assertIn("x", lignes[0]) + + +class TestSurvey(unittest.TestCase): + def setUp(self): + from script.analyse import check_module_package as package + + self.package = package + self.vrai = (package.census, package.dependencies, package.db_version) + + def tearDown(self): + ( + self.package.census, + self.package.dependencies, + self.package.db_version, + ) = self.vrai + + def poser(self, recensement, depend): + self.package.census = lambda d: recensement + self.package.dependencies = lambda d: depend + self.package.db_version = lambda d: "18.0" + + def test_a_module_without_dependency_still_exists_in_the_graph(self): + # Sans cela, `closure` et l'écran le traitent comme inconnu. + self.poser({"seul": ("installed", "", False, "")}, {}) + self.assertEqual(dep.survey("x")["depends"], {"seul": []}) + + def test_an_unreadable_base_is_not_an_empty_one(self): + self.poser(None, {}) + self.assertTrue(dep.survey("x").get("unavailable")) + + def test_an_empty_base_is_readable(self): + self.poser({}, {}) + self.assertFalse(dep.survey("x").get("unavailable")) + + def test_it_reads_the_base_once_for_both_views(self): + appels = [] + self.poser({"a": ("installed", "", False, "")}, {}) + vrai = self.package.census + self.package.census = lambda d: (appels.append(d), vrai(d))[1] + dep.survey("x") + self.assertEqual(appels, ["x"]) + + +class TestTuiPureParts(unittest.TestCase): + def test_the_mode_cycles_through_all_of_them_and_returns(self): + vus, mode = [], None + for _ in range(len(dep.DETAILS) + 1): + mode = tui.next_mode(mode) + vus.append(mode) + self.assertEqual(vus, list(dep.DETAILS) + [None]) + + def test_an_unknown_mode_restarts_the_cycle(self): + self.assertEqual(tui.next_mode("n'importe quoi"), dep.DETAILS[0]) + + def test_the_filter_cycles(self): + self.assertEqual(tui.next_filter(dep.FILTRES[-1]), dep.FILTRES[0]) + + def test_the_cursor_out_of_range_is_not_a_crash(self): + # La table garde son curseur quand la liste raccourcit. + self.assertIsNone(tui.current_name([{"name": "a"}], 7)) + self.assertIsNone(tui.current_name([], 0)) + self.assertIsNone(tui.current_name([{"name": "a"}], None)) + + def test_the_cursor_follows_the_module_across_a_filter(self): + lst = [{"name": "alpha"}, {"name": "zebre"}] + self.assertEqual(tui.cursor_for(lst, "zebre"), 1) + + def test_a_module_filtered_away_loses_the_cursor(self): + self.assertIsNone(tui.cursor_for([{"name": "alpha"}], "zebre")) + + def test_the_search_ignores_the_case(self): + lst = [{"name": "Web_Responsive"}, {"name": "mail"}] + self.assertEqual(len(tui.matching(lst, "web")), 1) + + def test_an_empty_search_keeps_everything(self): + lst = [{"name": "a"}, {"name": "b"}] + self.assertIs(tui.matching(lst, ""), lst) + + def test_the_subtitle_names_the_three_states(self): + texte = tui.subtitle("depends", "installed", "web") + self.assertIn("directement", texte) + self.assertIn("installés", texte) + self.assertIn("web", texte) + + +class FausseTable: + """Le contrat de DataTable, réduit à ce que `populate` en utilise.""" + + def __init__(self): + self.lignes = [] + self.curseur = None + + def clear(self): + self.lignes = [] + + def add_row(self, *cellules): + self.lignes.append(cellules) + + def move_cursor(self, row=None): + self.curseur = row + + +class TestPopulate(unittest.TestCase): + def test_it_fills_and_keeps_the_cursor_on_the_module(self): + table = FausseTable() + lst = [ + {"name": "alpha", "label": "✅ alpha", "detail": "0↓ 0/0↑"}, + {"name": "zebre", "label": "✅ zebre", "detail": "1↓ 0/0↑"}, + ] + self.assertEqual(tui.populate(table, lst, "zebre"), 1) + self.assertEqual(len(table.lignes), 2) + self.assertEqual(table.curseur, 1) + + def test_it_clears_before_refilling(self): + table = FausseTable() + table.add_row("vieux") + tui.populate(table, [{"name": "a", "label": "a", "detail": ""}]) + self.assertEqual(len(table.lignes), 1) + + def test_a_vanished_module_leaves_the_cursor_alone(self): + table = FausseTable() + tui.populate( + table, [{"name": "a", "label": "a", "detail": ""}], "parti" + ) + self.assertIsNone(table.curseur) + + +class FauxChamp: + """Le contrat d'Input, réduit à ce que `hide_find` en utilise.""" + + def __init__(self): + self.value = "web" + self.classes = {"visible"} + + def remove_class(self, nom): + self.classes.discard(nom) + + def has_class(self, nom): + return nom in self.classes + + +class FausseTableFocus(FausseTable): + def __init__(self): + super().__init__() + self.focalisee = False + + def focus(self): + self.focalisee = True + + +class TestHideFind(unittest.TestCase): + def test_it_hands_the_keyboard_back_to_the_list(self): + # Le geste qui compte : sans lui, Textual laisse le clavier au + # champ de recherche et plus aucune touche n'agit. + champ, table = FauxChamp(), FausseTableFocus() + tui.hide_find(champ, table) + self.assertTrue(table.focalisee) + + def test_it_hides_the_field(self): + champ, table = FauxChamp(), FausseTableFocus() + tui.hide_find(champ, table) + self.assertFalse(champ.has_class("visible")) + + def test_it_leaves_the_text_alone(self): + # Effacer ici ferait deux chemins pour le même état : l'appelant + # vide le champ, et c'est CE geste qui redéclenche le filtrage. + champ, table = FauxChamp(), FausseTableFocus() + tui.hide_find(champ, table) + self.assertEqual(champ.value, "web") + + +class TestTheScreenRefusesWhenItCannot(unittest.TestCase): + def test_an_unreadable_report_opens_nothing(self): + self.assertFalse(tui.run_tui({"unavailable": True})) + + def test_an_empty_base_opens_nothing(self): + self.assertFalse(tui.run_tui({"modules": {}})) + + def test_nothing_at_all_opens_nothing(self): + self.assertFalse(tui.run_tui(None)) + + +def textual_present(): + try: + import textual # noqa: F401 + except Exception: + return False + return True + + +@unittest.skipUnless(textual_present(), "textual absent") +class TestTheScreenActuallyDrives(unittest.TestCase): + """Conduire l'écran, touche par touche. + + Les fonctions pures étaient toutes vertes et l'écran, lui, ne + répondait pas : le champ de recherche caché prenait le focus au + démarrage — « display: none » ne retire pas un widget du parcours du + clavier — donc « d » tapait dans une boîte invisible. Seul un test + qui PRESSE les touches pouvait le voir. + """ + + def rapport(self): + return base( + { + "web_responsive": "installed", + "mail": "installed", + "compagnon": "uninstalled", + "casse": "installed", + "zebre": "installed", + }, + { + "web_responsive": ["mail"], + "compagnon": ["web_responsive"], + "casse": ["fantome"], + "zebre": ["web_responsive"], + }, + ) + + def rapport_etats(self): + """Un jeu qui porte les trois catégories des touches i / u / t.""" + return base( + { + "sale": "installed", + "mail": "installed", + "muk_theme": "to remove", + "stock": "to install", + "vieux": "uninstalled", + }, + apps=("sale", "stock"), + ) + + def conduire(self, scenario, rapport=None): + import asyncio + + app = tui.build_app(rapport or self.rapport()) + + async def piloter(): + async with app.run_test() as pilote: + await scenario(app, pilote) + + asyncio.run(piloter()) + return app + + def test_the_keyboard_starts_on_the_list_not_in_the_search(self): + vus = [] + + async def scenario(app, pilote): + vus.append(type(app.focused).__name__) + + self.conduire(scenario) + self.assertEqual(vus, ["DataTable"]) + + def test_pressing_d_walks_every_relation_and_comes_back(self): + vus = [] + + async def scenario(app, pilote): + for _ in range(len(dep.DETAILS) + 1): + await pilote.press("d") + vus.append(app.mode) + + self.conduire(scenario) + self.assertEqual(vus, list(dep.DETAILS) + [None]) + + def test_pressing_f_narrows_the_list(self): + vus = {} + + async def scenario(app, pilote): + table = app.query_one("#left") + for _ in range(len(dep.FILTRES)): + await pilote.press("f") + vus[app.filtre] = table.row_count + + self.conduire(scenario) + self.assertEqual(vus["all"], 5) + self.assertEqual(vus["installed"], 4) + self.assertEqual(vus["absent"], 1) + self.assertEqual(vus["broken"], 1) + + def test_the_search_filters_then_gives_the_keyboard_back(self): + vus = {} + + async def scenario(app, pilote): + await pilote.press("slash") + vus["focus_ouvert"] = type(app.focused).__name__ + for lettre in "web": + await pilote.press(lettre) + await pilote.pause() + vus["lignes"] = app.query_one("#left").row_count + await pilote.press("enter") + vus["focus_rendu"] = type(app.focused).__name__ + await pilote.press("d") + vus["mode"] = app.mode + + self.conduire(scenario) + self.assertEqual(vus["focus_ouvert"], "Input") + self.assertEqual(vus["lignes"], 1) + self.assertEqual(vus["focus_rendu"], "DataTable") + # « d » doit remarcher une fois la recherche finie : sans cela on + # ne peut plus rien faire après avoir cherché. + self.assertEqual(vus["mode"], dep.DETAILS[0]) + + def test_i_u_and_t_each_jump_straight_to_their_filter(self): + vus = {} + + async def scenario(app, pilote): + for touche in ("i", "u", "t"): + await pilote.press(touche) + vus[touche] = ( + app.filtre, + sorted(row["name"] for row in app.lst_row), + ) + await pilote.press(touche) + vus[touche + "_retour"] = app.filtre + + self.conduire(scenario, self.rapport_etats()) + self.assertEqual(vus["i"], ("installed", ["mail", "sale"])) + self.assertEqual(vus["u"], ("pending", ["muk_theme", "stock"])) + self.assertEqual(vus["t"], ("application", ["sale", "stock"])) + for touche in ("i", "u", "t"): + self.assertEqual(vus[touche + "_retour"], "all") + + def test_a_second_key_replaces_the_filter_rather_than_stacking(self): + vus = {} + + async def scenario(app, pilote): + await pilote.press("i") + await pilote.press("u") + vus["filtre"] = app.filtre + vus["lignes"] = app.query_one("#left").row_count + + self.conduire(scenario, self.rapport_etats()) + self.assertEqual(vus["filtre"], "pending") + self.assertEqual(vus["lignes"], 2) + + def test_those_letters_are_typed_not_obeyed_while_searching(self): + # « i », « u » et « t » sont des lettres avant d'être des + # raccourcis : dans le champ de recherche, elles s'écrivent. + vus = {} + + async def scenario(app, pilote): + await pilote.press("slash") + for lettre in "it": + await pilote.press(lettre) + await pilote.pause() + vus["motif"] = app.motif + vus["filtre"] = app.filtre + + self.conduire(scenario, self.rapport_etats()) + self.assertEqual(vus["motif"], "it") + self.assertEqual(vus["filtre"], "all") + + def test_escape_closes_the_search_before_it_closes_the_screen(self): + vus = {} + + async def scenario(app, pilote): + await pilote.press("slash") + for lettre in "web": + await pilote.press(lettre) + await pilote.pause() + await pilote.press("escape") + await pilote.pause() + vus["motif"] = app.motif + vus["lignes"] = app.query_one("#left").row_count + vus["vivant"] = app.is_running + # La tabulation ne doit pas non plus retomber dans le champ + # refermé : « display: none » ne l'en retire pas, seul + # can_focus le fait. + await pilote.press("tab") + vus["apres_tab"] = type(app.focused).__name__ + await pilote.press("escape") + await pilote.pause() + vus["apres_second_echap"] = app.is_running + + self.conduire(scenario) + self.assertNotEqual(vus["apres_tab"], "Input") + # …et le second échap, lui, ferme bien l'écran. + self.assertFalse(vus["apres_second_echap"]) + # Renoncer à une recherche ne doit pas fermer l'écran : on a + # parfois filtré trois mille modules pour arriver là. + self.assertTrue(vus["vivant"]) + self.assertEqual(vus["motif"], "") + self.assertEqual(vus["lignes"], 5) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_fix_migration_120_to_130.py b/test/test_fix_migration_120_to_130.py new file mode 100644 index 0000000..1cbc425 --- /dev/null +++ b/test/test_fix_migration_120_to_130.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Le correctif de palier 12 → 13, exécuté contre un vrai PostgreSQL. + +Une assertion sur le TEXTE d'un fichier SQL ne voit pas ce qu'il fait. +Celui-ci décode des séquences UTF-8 en pourcentage — `%C3%A9` tient sur +DEUX octets — et seule l'exécution prouve qu'on rassemble les octets +avant de convertir, plutôt que de produire deux caractères illisibles. + +Ce qu'il répare : OpenUpgrade recolle les `href` d'ancres en sélecteur +CSS, où un `%` est interdit. La migration mourait là. +""" + +import os +import shutil +import subprocess +import sys +import unittest + +sys.path.append( + os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) +) + +RACINE = os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) +SQL = os.path.join( + RACINE, + "script", + "odoo", + "migration", + "fix_migration_odoo120_to_odoo130.sql", +) + + +class Base(unittest.TestCase): + BASE = "tmp_fix_120_130_test" + + @classmethod + def setUpClass(cls): + if not shutil.which("psql") or not shutil.which("createdb"): + raise unittest.SkipTest("PostgreSQL absent") + subprocess.run( + ["dropdb", "--if-exists", cls.BASE], capture_output=True + ) + if subprocess.run( + ["createdb", cls.BASE], capture_output=True + ).returncode: + raise unittest.SkipTest("createdb impossible") + + @classmethod + def tearDownClass(cls): + if shutil.which("dropdb"): + subprocess.run( + ["dropdb", "--if-exists", cls.BASE], capture_output=True + ) + + def sql(self, requete): + done = subprocess.run( + ["psql", "-X", "-w", "-q", "-d", self.BASE, "-tAc", requete], + capture_output=True, + text=True, + ) + self.assertEqual(done.returncode, 0, done.stderr) + return done.stdout.strip() + + def prepare(self, lignes): + """lignes = [(arch, a_une_page)]""" + self.sql("DROP TABLE IF EXISTS website_page, ir_ui_view") + self.sql( + "CREATE TABLE ir_ui_view (id serial PRIMARY KEY, arch_db text)" + ) + self.sql( + "CREATE TABLE website_page (id serial PRIMARY KEY, view_id integer)" + ) + for arch, avec_page in lignes: + vid = self.sql( + "INSERT INTO ir_ui_view (arch_db) VALUES (" + + "'" + + arch.replace("'", "''") + + "') RETURNING id" + ) + if avec_page: + self.sql(f"INSERT INTO website_page (view_id) VALUES ({vid})") + + def applique(self): + done = subprocess.run( + [ + "psql", + "-X", + "-w", + "-v", + "ON_ERROR_STOP=1", + "-d", + self.BASE, + "-f", + SQL, + ], + capture_output=True, + text=True, + ) + self.assertEqual(done.returncode, 0, done.stdout + done.stderr) + return done.stdout + done.stderr + + def arch(self, vid=1): + return self.sql(f"SELECT arch_db FROM ir_ui_view WHERE id={vid}") + + +class TestDecodingTheAnchors(Base): + def test_an_accented_anchor_is_decoded(self): + self.prepare([('x', True)]) + self.applique() + self.assertEqual(self.arch(), 'x') + + def test_two_byte_sequences_are_assembled_before_converting(self): + # `%C3%A9` est UN caractère sur DEUX octets. Les décoder + # séparément rendrait deux caractères illisibles. + self.prepare([('x', True)]) + self.applique() + self.assertEqual(self.arch(), 'x') + + def test_several_anchors_in_one_view(self): + self.prepare( + [('12', True)] + ) + self.applique() + self.assertIn("#aé", self.arch()) + self.assertIn("#bè", self.arch()) + + def test_it_is_replayable(self): + self.prepare([('x', True)]) + self.applique() + premier = self.arch() + sortie = self.applique() + self.assertEqual(self.arch(), premier) + self.assertNotIn("decodee", sortie) + + +class TestWhatItMustNotTouch(Base): + def test_a_percent_outside_an_anchor_is_left_alone(self): + # Un `%` dans une vraie URL est légitime : le décoder changerait + # une adresse qui fonctionne. + arch = 'x' + self.prepare([(arch, True)]) + self.applique() + self.assertEqual(self.arch(), arch) + + def test_a_view_without_a_page_is_left_alone(self): + # OpenUpgrade ne parcourt que les vues qui portent une page : + # toucher plus large modifierait du contenu sans raison. + arch = 'x' + self.prepare([(arch, False)]) + self.applique() + self.assertEqual(self.arch(), arch) + + def test_a_lone_percent_is_not_an_encoding(self): + # « 100% » n'est pas une séquence : seuls `%XX` hexadécimaux le + # sont, et confondre les deux abîmerait du texte. + arch = 'x' + self.prepare([(arch, True)]) + sortie = self.applique() + self.assertEqual(self.arch(), arch) + # Et il ne doit pas ANNONCER un décodage qui n'a rien changé : + # un rapport qui se félicite à vide fait douter du reste. + self.assertNotIn("decodee", sortie) + + def test_an_anchor_without_any_percent_is_untouched(self): + arch = 'x' + self.prepare([(arch, True)]) + self.applique() + self.assertEqual(self.arch(), arch) + + +class TestItLeavesNothingBehind(Base): + def test_the_decoder_does_not_survive_the_session(self): + # La fonction vit dans `pg_temp` : elle disparaît avec psql et ne + # laisse rien dans la base du client. + self.prepare([('x', True)]) + self.applique() + reste = self.sql( + "SELECT count(*) FROM pg_proc WHERE proname = 'el_url_decode'" + ) + self.assertEqual(reste, "0") + + def test_a_database_without_the_tables_does_not_crash(self): + self.sql("DROP TABLE IF EXISTS website_page, ir_ui_view") + done = subprocess.run( + [ + "psql", + "-X", + "-w", + "-v", + "ON_ERROR_STOP=1", + "-d", + self.BASE, + "-f", + SQL, + ], + capture_output=True, + text=True, + ) + self.assertEqual(done.returncode, 0, done.stdout + done.stderr) + + +class TestTheFileIsWiredIn(unittest.TestCase): + def test_the_name_matches_what_the_driver_looks_for(self): + # Le pilote compose « fix_migration_odoo{(v-1)*10}_to_odoo{v*10} ». + self.assertTrue(os.path.isfile(SQL), SQL) + self.assertTrue(SQL.endswith("fix_migration_odoo120_to_odoo130.sql")) + + def test_it_runs_before_openupgrade(self): + # Il agit sur une base encore en 12 : après OpenUpgrade, il + # serait trop tard, la migration aurait déjà échoué. + with open( + os.path.join(RACINE, "script", "todo", "todo_upgrade.py"), + encoding="utf-8", + ) as handle: + src = handle.read() + self.assertLess( + src.index("- Fix migrate code"), src.index("- Migrate database") + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_mobile_bundle_transfer.py b/test/test_mobile_bundle_transfer.py index f877e33..7193670 100644 --- a/test/test_mobile_bundle_transfer.py +++ b/test/test_mobile_bundle_transfer.py @@ -293,11 +293,27 @@ class TestTheRealBundle(unittest.TestCase): " ./mobile/install_mobile_dev.sh" ) cls.repos = MOBILE / "dist" / "repos" - if not (cls.repos / "manifest.json").is_file(): + manifeste = cls.repos / "manifest.json" + if not manifeste.is_file(): raise unittest.SkipTest( "dépôt mobile présent mais pas compilé :" " ./mobile/compile_and_run.sh (ou npm run build)" ) + # Manifeste PRÉSENT mais VIDE : l'application a été compilée sans le + # transfert des dépôts. C'est un choix légitime, pas une régression — + # et le distinguer importe, car ces tests échouaient alors sur + # « aucun dépôt à vérifier », ce qui se lit comme une panne du + # transfert. Vu le 23 août 2026 sur un build de 07:55 : manifeste à + # zéro entrée, aucun pack. + try: + entrees = json.loads(manifeste.read_text()) + except (OSError, ValueError) as exc: + raise unittest.SkipTest(f"manifeste illisible : {exc}") + if not entrees: + raise unittest.SkipTest( + "compilé SANS les dépôts (manifeste vide) :" + " relancer ./mobile/compile_and_run.sh pour les inclure" + ) def test_the_transfer_is_coherent(self): rep = cbt.check(MOBILE, REPO) diff --git a/test/test_old_python_annotations.py b/test/test_old_python_annotations.py new file mode 100644 index 0000000..acd65f2 --- /dev/null +++ b/test/test_old_python_annotations.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Ce que la migration charge doit se charger sous le plus vieux Python. + +Le pilote lance ses outils avec le venv de la version Odoo COURANTE : au +premier palier, c'est celui d'Odoo 12, en Python 3.7. Une annotation +« dict | None » (3.10) ou « tuple[str, str] » (3.9) y est ÉVALUÉE au +chargement du module et lève un TypeError avant que l'outil ait rien +fait — la migration meurt sur la restauration du zip. + +`from __future__ import annotations` (disponible depuis 3.7) diffère +l'évaluation : le module se charge partout, et le typage reste lisible. +""" + +import ast +import io +import json +import os +import re +import unittest + +RACINE = os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) +PILOTE = os.path.join(RACINE, "script", "todo", "todo_upgrade.py") +VERSIONS = os.path.join(RACINE, "conf", "supported_version_erplibre.json") + +DIFFERE = "from __future__ import annotations" +GENERIQUES = ("list", "dict", "tuple", "set", "type", "frozenset") + + +def lire(chemin): + with io.open(chemin, encoding="utf-8") as handle: + return handle.read() + + +def annotations_evaluees(arbre): + """Les annotations que Python évalue au chargement du module. + + Celles d'une signature le sont toujours. Une `x: T` de corps de + fonction ne l'est pas — la signaler produirait un faux échec. + """ + for noeud in ast.walk(arbre): + if isinstance(noeud, (ast.FunctionDef, ast.AsyncFunctionDef)): + a = noeud.args + for arg in ( + list(getattr(a, "posonlyargs", [])) + + list(a.args) + + list(a.kwonlyargs) + + [a.vararg, a.kwarg] + ): + if arg is not None and arg.annotation is not None: + yield arg.annotation + if noeud.returns is not None: + yield noeud.returns + elif isinstance(noeud, (ast.Module, ast.ClassDef)): + for petit in noeud.body: + if isinstance(petit, ast.AnnAssign): + yield petit.annotation + + +def trop_recent(source): + """Les annotations qu'un Python 3.7 ne saurait pas évaluer.""" + if DIFFERE in source: + return [] + trouves = [] + for annotation in annotations_evaluees(ast.parse(source)): + for noeud in ast.walk(annotation): + if isinstance(noeud, ast.BinOp) and isinstance( + noeud.op, ast.BitOr + ): + trouves.append(f"« a | b » ligne {noeud.lineno}") + elif ( + isinstance(noeud, ast.Subscript) + and isinstance(noeud.value, ast.Name) + and noeud.value.id in GENERIQUES + ): + trouves.append(f"« {noeud.value.id}[…] » ligne {noeud.lineno}") + return trouves + + +def module_vers_chemin(module): + chemin = os.path.join(RACINE, module.replace(".", os.sep) + ".py") + return chemin if os.path.isfile(chemin) else None + + +def importes(source): + for noeud in ast.walk(ast.parse(source)): + if isinstance(noeud, ast.ImportFrom): + if noeud.module and noeud.module.startswith("script"): + yield noeud.module + for alias in noeud.names: + yield f"{noeud.module}.{alias.name}" + elif isinstance(noeud, ast.Import): + for alias in noeud.names: + if alias.name.startswith("script"): + yield alias.name + + +def points_entree(): + """Les scripts que le pilote lance — lus dans le pilote, pas listés. + + Une liste écrite à la main vieillit en silence : le script ajouté + demain ne serait pas couvert, et c'est justement celui qui casse. + """ + trouves = set() + for ref in re.findall(r"\./script/[a-z0-9_/]+\.py", lire(PILOTE)): + chemin = os.path.join(RACINE, ref[2:]) + if os.path.isfile(chemin): + trouves.add(chemin) + return sorted(trouves) + + +def fermeture(): + vus, pile = set(), list(points_entree()) + while pile: + chemin = pile.pop() + if chemin in vus: + continue + vus.add(chemin) + for module in importes(lire(chemin)): + suivant = module_vers_chemin(module) + if suivant: + pile.append(suivant) + return sorted(vus) + + +class TestTheDetectorDetects(unittest.TestCase): + """Un détecteur qui ne détecte rien ferait passer le test à vide.""" + + def test_it_flags_a_union_in_a_signature(self): + self.assertTrue(trop_recent("def f(x: dict | None = None): pass\n")) + + def test_it_flags_a_builtin_generic_return(self): + self.assertTrue(trop_recent("def f() -> tuple[int, str]: pass\n")) + + def test_a_runtime_union_is_not_an_annotation(self): + # « set(a) | b » est une union d'ENSEMBLES, valide depuis toujours. + self.assertEqual(trop_recent("x = set('ab') | set('cd')\n"), []) + + def test_deferring_makes_it_legal(self): + self.assertEqual( + trop_recent(f"{DIFFERE}\ndef f(x: dict | None = None): pass\n"), [] + ) + + def test_a_body_annotation_is_never_evaluated(self): + self.assertEqual( + trop_recent("def f():\n x: dict | None = None\n return x\n"), + [], + ) + + +class TestTheFloorIsWhatTheProjectDeclares(unittest.TestCase): + def test_the_oldest_supported_python_is_still_pre_3_9(self): + # Le jour où la 12 et la 13 disparaissent, ce garde-fou n'a plus + # de raison d'être : qu'il le dise plutôt que de survivre seul. + pythons = [] + for valeur in json.loads(lire(VERSIONS)).values(): + if isinstance(valeur, dict) and valeur.get("python_version"): + pythons.append( + tuple( + int(x) for x in valeur["python_version"].split(".")[:2] + ) + ) + self.assertTrue(pythons) + self.assertLess( + min(pythons), + (3, 9), + "plus aucune version sous 3.9 : ce test peut disparaître", + ) + + +class TestWhatTheMigrationLoads(unittest.TestCase): + def test_the_scan_is_not_empty(self): + self.assertGreater(len(points_entree()), 10) + self.assertGreater(len(fermeture()), 15) + + def test_execute_is_in_the_closure(self): + # Le module par lequel l'incident est arrivé : s'il sortait de la + # fermeture, le test passerait sans plus rien garder. + self.assertIn( + os.path.join(RACINE, "script", "execute", "execute.py"), + fermeture(), + ) + + def test_every_loaded_module_survives_python_3_7(self): + coupables = {} + for chemin in fermeture(): + trouves = trop_recent(lire(chemin)) + if trouves: + coupables[os.path.relpath(chemin, RACINE)] = trouves + self.assertEqual( + coupables, + {}, + "annotation évaluée au chargement ; ajouter" + f" « {DIFFERE} » en tête de ces fichiers", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_proxmox_deploy.py b/test/test_proxmox_deploy.py new file mode 100644 index 0000000..0cf92c6 --- /dev/null +++ b/test/test_proxmox_deploy.py @@ -0,0 +1,408 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Déployer sur un hôte Proxmox : l'hyperviseur est AILLEURS. + +Toute la différence avec QEMU/KVM tient là. Rien ne s'exécute sur la machine +locale : il faut d'abord savoir OÙ, puis tout envoyer par SSH. Ces tests +gardent ce qui a été appris contre un hôte réel (Proxmox VE 9.2.11 dans une VM +libvirt), une panne après l'autre : + +- « qm » exige root. La voie « VM QEMU locale » ne donne que l'accès + d'erplibre : il faut sudo, et l'enrober AUTOUR de toute la commande — les + commandes de ce module sont des suites et des redirections, et « sudo cmd » + n'élèverait que le premier mot. +- Une Proxmox installée SUR Debian n'a AUCUN pont. On en propose un INTERNE : + ajouter l'interface physique à un pont déplace l'adresse de l'hôte et coupe + la session SSH — à distance, c'est sans retour. +- Sur un pont interne, aucun DHCP ne répond : l'adresse doit être fixe, et + elle est alors connue AVANT le démarrage. La chercher ensuite était absurde. +- L'agent invité n'est pas dans l'image cloud Debian : le voisinage de l'hôte + (« ip neigh ») est le seul repli, et il a trouvé l'adresse là où l'agent + répondait « not running ». +""" + +import shlex +import subprocess +import sys +import unittest +from unittest import mock + +sys.argv = ["todo.py"] +from script.proxmox import proxmox_deploy as pve # noqa: E402 +from script.todo.todo import TODO # noqa: E402 + +# Sorties RÉELLES relevées sur l'hôte d'essai. +PVEVERSION = ( + "pve-manager/9.2.11/f6997e698c7933ea (running kernel: 7.0.14-12-pve)" +) +QM_LIST = """ VMID NAME STATUS MEM(MB) BOOTDISK(GB) PID + 100 vm-essai running 2048 16.00 2726 + 101 avec un espace stopped 4096 32.00 0 +""" +PVESM = """Name Type Status Total Used Available % +local dir active 32815812 6873084 24559348 20.94% +sauvegarde dir inactive 99999999 0 99999999 0.00% +""" +NEIGH = """192.168.123.1 dev enp1s0 lladdr 52:54:00:cd:73:ef REACHABLE +10.10.10.150 dev vmbr0 lladdr bc:24:11:93:da:22 REACHABLE +""" +QM_CONFIG = """boot: order=scsi0 +memory: 2048 +net0: virtio=BC:24:11:93:DA:22,bridge=vmbr0 +scsi0: local:100/vm-100-disk-0.raw,discard=on,size=16G,ssd=1 +""" +INTERFACES = """auto lo +iface lo inet loopback + +iface enp1s0 inet manual + +auto vmbr0 +iface vmbr0 inet static + address 10.10.10.1/24 + bridge-ports none + bridge-stp off + +auto vmbr1 +iface vmbr1 inet manual + bridge-ports enp2s0 +""" + + +class TestLectureDesSorties(unittest.TestCase): + def test_the_version_proves_it_is_a_proxmox(self): + """Une adresse saisie à la main peut être n'importe quelle machine : + sans cette preuve, la première commande « qm » échouerait sur un + « command not found » qui n'explique rien.""" + self.assertEqual("9.2.11", pve.parse_pveversion(PVEVERSION)) + self.assertEqual( + "", pve.parse_pveversion("bash: pveversion: not found") + ) + + def test_a_vm_name_with_spaces_is_read_whole(self): + """« qm list » sépare par des espaces, et un nom peut en contenir : on + découpe par les deux bouts, le milieu est le nom.""" + vms = pve.parse_qm_list(QM_LIST) + self.assertEqual([100, 101], [v["vmid"] for v in vms]) + self.assertEqual("avec un espace", vms[1]["name"]) + self.assertEqual("running", vms[0]["status"]) + + def test_the_header_is_not_a_vm(self): + self.assertEqual([], pve.parse_qm_list(" VMID NAME STATUS\n")) + self.assertEqual([], pve.parse_qm_list("")) + + def test_only_active_storages_count_and_kib_become_bytes(self): + st = pve.parse_storages(PVESM) + self.assertEqual(["local", "sauvegarde"], [s["name"] for s in st]) + self.assertTrue(st[0]["actif"]) + self.assertFalse(st[1]["actif"]) + self.assertEqual(24559348 * 1024, st[0]["avail"]) + + def test_bridges_are_read_without_their_at_suffix(self): + texte = "3: vmbr0: \n4: vmbr1@if2: \n" + self.assertEqual(["vmbr0", "vmbr1"], pve.parse_bridges(texte)) + + def test_the_guest_agent_answer_drops_loopback_and_ipv6(self): + json_txt = ( + '[{"name":"lo","ip-addresses":[{"ip-address-type":"ipv4",' + '"ip-address":"127.0.0.1"}]},{"name":"eth0","ip-addresses":[' + '{"ip-address-type":"ipv4","ip-address":"10.10.10.150"},' + '{"ip-address-type":"ipv6","ip-address":"fe80::1"}]}]' + ) + self.assertEqual(["10.10.10.150"], pve.parse_guest_ips(json_txt)) + + def test_a_missing_agent_is_not_a_crash(self): + """Sa réponse n'est pas du JSON : « QEMU guest agent is not running ».""" + self.assertEqual( + [], pve.parse_guest_ips("QEMU guest agent is not running") + ) + + def test_the_mac_links_a_vm_to_its_address(self): + """Le seul lien quand l'agent manque, et l'image cloud Debian ne + l'embarque pas.""" + mac = pve.mac_from_config(QM_CONFIG) + self.assertEqual("bc:24:11:93:da:22", mac) + self.assertEqual("10.10.10.150", pve.ip_from_neigh(NEIGH, mac)) + + def test_an_unknown_mac_finds_nothing(self): + self.assertEqual("", pve.ip_from_neigh(NEIGH, "de:ad:be:ef:00:00")) + self.assertEqual("", pve.ip_from_neigh(NEIGH, "")) + + def test_a_lan_bridge_and_an_internal_one_are_told_apart(self): + ponts = pve.parse_bridge_config(INTERFACES) + self.assertEqual("", ponts["vmbr0"]["ports"]) + self.assertEqual("10.10.10.1/24", ponts["vmbr0"]["address"]) + self.assertEqual("enp2s0", ponts["vmbr1"]["ports"]) + + def test_orphans_are_the_volumes_no_vm_claims(self): + liste = ( + "Volid Format Type Size VMID\n" + "local:100/vm-100-disk-0.raw raw images 17179869184 100\n" + "local:999/vm-999-disk-0.raw raw images 8589934592 999\n" + ) + orph = pve.parse_orphans(liste, [100]) + self.assertEqual(1, len(orph)) + self.assertIn("999", orph[0][0]) + + +class TestLesChoix(unittest.TestCase): + def test_the_vmid_skips_the_taken_ones(self): + """Proxmox refuse un VMID pris, et le dit APRÈS le téléchargement de + l'image : on choisit donc avant, d'après ce que l'hôte déclare.""" + self.assertEqual( + 102, pve.next_vmid([{"vmid": 100}, {"vmid": 101}, {"vmid": 103}]) + ) + self.assertEqual(100, pve.next_vmid([])) + + def test_the_storage_is_the_freest_active_one(self): + st = pve.parse_storages(PVESM) + self.assertEqual("local", pve.pick_storage(st)) + + def test_an_unknown_storage_is_refused_not_guessed(self): + """« local-lvm » n'existe pas partout : un repli deviné ferait échouer + « qm set » après le téléchargement de l'image.""" + self.assertEqual( + "", pve.pick_storage(pve.parse_storages(PVESM), "nas") + ) + + def test_vmbr0_wins_when_it_exists(self): + self.assertEqual("vmbr0", pve.pick_bridge(["vmbr9", "vmbr0"])) + self.assertEqual("br-lan", pve.pick_bridge(["br-lan"])) + self.assertEqual("", pve.pick_bridge([])) + + +class TestLesCommandes(unittest.TestCase): + def _spec(self, **extra): + base = { + "name": "vm-essai", + "memory": 2048, + "vcpus": 2, + "disk": "12G", + "storage": "local", + "bridge": "vmbr0", + "image": "debian-13-genericcloud-amd64.qcow2", + "sshkey_path": "/root/.ssh/erplibre-deploy.pub", + "ipconfig": "ip=10.10.10.150/24,gw=10.10.10.1", + } + base.update(extra) + return base + + def test_the_sequence_is_in_the_order_proxmox_needs(self): + cmds = pve.create_cmds(100, self._spec()) + joint = "\n".join(cmds) + self.assertTrue(cmds[0].startswith("qm create 100")) + self.assertIn("import-from=", joint) + self.assertIn(":cloudinit", joint) + self.assertIn("--boot order=scsi0", joint) + self.assertIn("qm resize 100 scsi0 12G", joint) + self.assertTrue(cmds[-1].endswith("qm start 100")) + + def test_the_agent_and_the_serial_console_are_asked_for(self): + """Sans agent, aucune adresse ; sans serial0, « qm terminal » est + inutilisable et il ne reste que l'interface web.""" + cmd = pve.create_cmds(100, self._spec())[0] + self.assertIn("--agent enabled=1", cmd) + self.assertIn("--serial0 socket", cmd) + + def test_a_name_with_a_space_cannot_break_the_command(self): + """Le nom vient d'une saisie : découpée par le shell, elle doit rester + UN argument. « rm » ne doit jamais devenir une commande.""" + mechant = "vm essai; rm -rf /" + cmds = pve.create_cmds(100, self._spec(name=mechant)) + args = shlex.split(cmds[0]) + self.assertIn(mechant, args) + self.assertNotIn("rm", args) + + def test_destroy_stops_first_and_purges(self): + cmds = pve.destroy_cmds(100) + self.assertIn("qm stop 100", cmds[0]) + self.assertIn("--purge 1", cmds[1]) + + def test_the_image_is_fetched_once_on_the_host(self): + cmd = pve.image_fetch_cmd("https://x/deb.qcow2", "deb.qcow2") + self.assertIn("if [ -s", cmd) + self.assertIn("wget", cmd) + + def test_the_internal_bridge_never_touches_a_physical_nic(self): + """Le point le plus important de ce module : ajouter l'interface au + pont déplace l'adresse de l'hôte et coupe la session SSH — à distance, + sans retour.""" + cmds = pve.bridge_setup_cmds(uplink="enp1s0") + joint = "\n".join(cmds) + self.assertIn("bridge-ports none", joint) + self.assertNotIn("bridge-ports enp1s0", joint) + self.assertIn("MASQUERADE", joint) + self.assertIn("ip_forward", joint) + + def test_the_bridge_stanza_is_added_only_once(self): + cmds = pve.bridge_setup_cmds() + self.assertIn("grep -qE", cmds[0]) + self.assertIn("||", cmds[0]) + + def test_an_internal_bridge_gets_a_static_address(self): + """Aucun DHCP n'y répondrait : la VM resterait muette.""" + ponts = pve.parse_bridge_config(INTERFACES) + self.assertEqual( + "ip=10.10.10.150/24,gw=10.10.10.1", + pve.ipconfig_for(ponts["vmbr0"], 100), + ) + + def test_a_lan_bridge_gets_dhcp(self): + ponts = pve.parse_bridge_config(INTERFACES) + self.assertEqual("ip=dhcp", pve.ipconfig_for(ponts["vmbr1"], 100)) + + def test_two_vms_do_not_share_an_address(self): + ponts = pve.parse_bridge_config(INTERFACES) + a = pve.ipconfig_for(ponts["vmbr0"], 100) + b = pve.ipconfig_for(ponts["vmbr0"], 101) + self.assertNotEqual(a, b) + + def test_a_static_address_is_known_before_boot(self): + self.assertEqual( + "10.10.10.150", + pve.ip_from_ipconfig("ip=10.10.10.150/24,gw=10.10.10.1"), + ) + self.assertEqual("", pve.ip_from_ipconfig("ip=dhcp")) + + +class TestLePrivilege(unittest.TestCase): + def test_the_whole_command_is_wrapped_not_just_its_first_word(self): + """« sudo mkdir && if … fi » n'élèverait que le mkdir, et la + redirection resterait celle du shell non privilégié : « permission + denied » sur /root ou /boot/efi.""" + compose = "mkdir -p /root/.ssh && printf x > /root/.ssh/k" + enrobe = pve.wrap_privilege(compose, "sudo ") + self.assertTrue(enrobe.startswith("sudo sh -c ")) + self.assertIn("printf x > /root/.ssh/k", enrobe) + + def test_without_sudo_the_command_is_untouched(self): + self.assertEqual("qm list", pve.wrap_privilege("qm list", "")) + + def test_ssh_never_asks_a_question_it_cannot_show(self): + """BatchMode : une invite de mot de passe dans un menu bloquerait sans + rien afficher.""" + argv = pve.ssh_argv({"target": "root@h"}, "qm list") + self.assertIn("BatchMode=yes", argv) + self.assertIn("ConnectTimeout=10", " ".join(argv)) + + def test_the_jump_and_the_port_travel(self): + argv = pve.ssh_argv( + {"target": "root@h", "jump": "rebond", "port": "2222"}, "x" + ) + self.assertIn("-J", argv) + self.assertIn("rebond", argv) + self.assertIn("-p", argv) + self.assertIn("2222", argv) + + def test_a_console_gets_a_tty_and_no_batchmode(self): + argv = pve.ssh_argv({"target": "root@h"}, "qm terminal 100", tty=True) + self.assertIn("-t", argv) + self.assertNotIn("BatchMode=yes", argv) + + +class TestChoixDeLHote(unittest.TestCase): + """La question propre à Proxmox : sur QUELLE machine ?""" + + def _todo(self): + todo = TODO.__new__(TODO) + todo._pve_remember_host = lambda h: None + return todo + + def test_an_unknown_host_key_is_recognised(self): + for texte in ( + "Host key verification failed.", + "The authenticity of host '10.0.0.1' can't be established.", + "No ED25519 host key is known for 10.0.0.1", + ): + self.assertTrue(TODO._pve_hostkey_missing(texte), texte) + self.assertFalse(TODO._pve_hostkey_missing("Permission denied")) + + def _confirm(self, reponses): + """reponses : [(code, sortie)] pour chaque appel à pve.run.""" + it = iter(reponses) + with mock.patch.object( + pve, "run", side_effect=lambda *a, **k: next(it) + ): + import contextlib + import io + + out = io.StringIO() + with contextlib.redirect_stdout(out): + host = self._todo()._pve_confirm_host( + {"target": "erplibre@10.0.0.5", "jump": ""} + ) + return host, out.getvalue() + + def test_a_non_proxmox_host_is_refused_with_what_was_seen(self): + host, sortie = self._confirm([(127, "bash: pveversion: not found")]) + self.assertIsNone(host) + self.assertIn("pveversion", sortie) + + def test_a_non_root_access_gets_sudo(self): + """C'est le cas de la voie « VM QEMU locale » : cloud-init crée + erplibre, pas root.""" + host, _s = self._confirm([(0, PVEVERSION), (0, "1000\n"), (0, "")]) + self.assertEqual("sudo ", host["sudo"]) + self.assertEqual("9.2.11", host["version"]) + + def test_root_needs_no_sudo(self): + host, _s = self._confirm([(0, PVEVERSION), (0, "0\n")]) + self.assertEqual("", host["sudo"]) + + def test_without_root_nor_passwordless_sudo_it_stops(self): + """Un sudo qui réclame un mot de passe bloquerait chaque commande du + menu sur une invite que personne ne voit.""" + host, sortie = self._confirm( + [ + (0, PVEVERSION), + (0, "1000\n"), + (1, "sudo: a password is required"), + ] + ) + self.assertIsNone(host) + self.assertIn("root", sortie) + + +class TestLeMenu(unittest.TestCase): + def test_proxmox_sits_right_under_qemu_in_the_deploy_menu(self): + src = open("script/todo/todo.py", encoding="utf-8").read() + i_qemu = src.index('"QEMU/KVM - Deploy an Ubuntu VM (libvirt)"') + i_pve = src.index('"Proxmox VE - Deploy a VM on a remote host"') + i_ntfy = src.index('"Deploy - Install NTFY notification server"') + self.assertLess(i_qemu, i_pve) + self.assertLess(i_pve, i_ntfy) + + def test_the_dispatch_follows_the_list(self): + src = open("script/todo/todo.py", encoding="utf-8").read() + self.assertIn( + 'elif status == "6":\n self.prompt_execute_proxmox()', + src, + ) + self.assertIn( + 'elif status == "7":\n self._deploy_ntfy_server()', + src, + ) + + def test_every_qemu_entry_has_its_proxmox_counterpart(self): + """L'équivalent des dix-sept commandes, plus le choix de l'hôte.""" + src = open("script/todo/todo.py", encoding="utf-8").read() + debut = src.index(" def prompt_execute_proxmox(self):") + bloc = src[debut : src.index(" def _pve_fetch_image(self):")] + for n in range(1, 19): + self.assertIn(f'elif status == "{n}":', bloc, f"entrée {n}") + + def test_the_script_is_valid_python(self): + res = subprocess.run( + [ + sys.executable, + "-c", + "import ast;ast.parse(open('script/proxmox/proxmox_deploy.py',encoding='utf-8').read())", + ], + capture_output=True, + text=True, + ) + self.assertEqual(0, res.returncode, res.stderr) + + +if __name__ == "__main__": + unittest.main(verbosity=1) diff --git a/test/test_qemu_deploy_monitor.py b/test/test_qemu_deploy_monitor.py new file mode 100644 index 0000000..fb17d2c --- /dev/null +++ b/test/test_qemu_deploy_monitor.py @@ -0,0 +1,249 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Suivre un déploiement qui n'installe PAS ERPLibre. + +Symptôme rapporté : en décochant l'installation d'ERPLibre, le tableau de bord +ne s'ouvrait plus du tout. Deux causes, l'une derrière l'autre : + +- la case « suivi » vivait DANS le groupe de l'installation ERPLibre, et + `build_spec` ne la recopiait même pas dans la spec finale ; +- l'épilogue du déploiement était gardé par « if install or desktop » : sans + rien à installer, il ne se passait rien. + +Et si le suivi s'ouvrait quand même, il n'aurait rien montré : la commande +distante valait « true », donc un journal vide et un ✅ instantané. Elle +regarde maintenant la VM ARRIVER — cloud-init, puis un relevé système — ce qui +donne un début, une fin, et de quoi juger qu'elle est prête. +""" + +import contextlib +import io +import os +import subprocess +import sys +import unittest + +sys.argv = ["todo.py"] +from script.todo import qemu_install_monitor as mon # noqa: E402 +from script.todo.qemu_deploy_form import build_spec # noqa: E402 +from script.todo.todo import TODO # noqa: E402 + + +class TestLaCommandeDistante(unittest.TestCase): + def setUp(self): + self.todo = TODO.__new__(TODO) + + def _sans_rien(self): + return self.todo._qemu_erplibre_remote_cmd(None) + + def test_it_is_no_longer_a_bare_true(self): + """« true » rendait un journal vide et un ✅ instantané : le suivi + s'ouvrait sur rien.""" + self.assertNotEqual("true", self._sans_rien().strip()) + + def test_it_is_valid_shell(self): + res = subprocess.run( + ["bash", "-n"], + input=self._sans_rien(), + capture_output=True, + text=True, + ) + self.assertEqual(0, res.returncode, res.stderr) + + def test_it_waits_for_cloud_init(self): + """C'est le vrai « suivi » d'une VM neuve : tant que cloud-init tourne, + la machine n'est pas prête.""" + self.assertIn("cloud-init status --wait", self._sans_rien()) + + def test_it_frames_the_step_so_the_monitor_can_bound_it(self): + """Le tableau lit ces marqueurs pour dire où en est la VM.""" + cmd = self._sans_rien() + self.assertIn("===>", cmd) + self.assertIn("<===", cmd) + + def test_it_reports_what_says_the_vm_is_up(self): + cmd = self._sans_rien() + for morceau in ("/etc/os-release", "uname -r", "hostname -I", "df -h"): + self.assertIn(morceau, cmd, morceau) + + def test_it_runs_and_says_something(self): + """Exécutée ici, hors VM : l'attente de cloud-init est neutralisée, le + relevé doit sortir et rendre 0.""" + cmd = self._sans_rien().replace( + "sudo timeout 900 cloud-init status --wait", "true" + ) + res = subprocess.run( + ["bash", "-c", cmd], capture_output=True, text=True, timeout=60 + ) + self.assertEqual(0, res.returncode, res.stderr[-300:]) + self.assertIn("===>", res.stdout) + self.assertIn("<===", res.stdout) + # Trois lignes de relevé au moins : sans elles, le journal est creux. + self.assertGreaterEqual(len(res.stdout.strip().splitlines()), 5) + + def test_a_desktop_only_vm_still_installs_its_desktop(self): + """Le chemin qui marchait déjà ne doit pas changer de sens.""" + self.todo._qemu_desktop_remote_cmd = lambda d, s: "INSTALLE_BUREAU; " + self.todo._qemu_tools_remote_cmd = lambda *a, **k: "" + self.todo._qemu_no_auto_upgrade = lambda *a, **k: "" + cmd = self.todo._qemu_erplibre_remote_cmd(None, desktop="gnome") + self.assertIn("INSTALLE_BUREAU", cmd) + + +class TestLaSpecDuFormulaire(unittest.TestCase): + def _form(self, **extra): + base = { + "res_label": "x1", + "ssh_key": "/k.pub", + "install": None, + "add_ssh_config": False, + "parallelism": 1, + } + base.update(extra) + return base + + def test_the_choice_reaches_the_spec(self): + """Il ne la recopiait pas : le choix du formulaire n'atteignait jamais + le déploiement.""" + spec = build_spec([], [], self._form(monitor=True)) + self.assertTrue(spec["monitor"]) + spec = build_spec([], [], self._form(monitor=False)) + self.assertFalse(spec["monitor"]) + + def test_an_old_form_without_the_key_still_monitors(self): + """Compatibilité : une spec enregistrée avant ce changement ne doit pas + perdre son tableau de bord.""" + self.assertTrue(build_spec([], [], self._form())["monitor"]) + + +class TestLaDecisionDuDeploiement(unittest.TestCase): + """L'épilogue : qui est appelé, et avec quoi.""" + + def _joue(self, spec): + todo = TODO.__new__(TODO) + appels = [] + todo._qemu_install_erplibre_monitored = lambda *a, **k: appels.append( + "suivi" + ) + todo._qemu_install_erplibre_vm = lambda *a, **k: appels.append("muet") + todo._qemu_resolve_ips = lambda names, labels=None: {} + base = { + "vms": [], + "existing": ["vm-a"], + "install": None, + "add_ssh_config": False, + "parallelism": 1, + } + base.update(spec) + with contextlib.redirect_stdout(io.StringIO()): + todo._qemu_run_spec(base) + return appels + + def test_without_erplibre_the_monitor_still_opens(self): + """Le cœur du problème rapporté.""" + self.assertEqual(["suivi"], self._joue({"monitor": True})) + + def test_a_spec_without_the_key_monitors_too(self): + self.assertEqual(["suivi"], self._joue({})) + + def test_refusing_the_monitor_does_nothing_at_all(self): + """Et surtout : ne pas partir installer un profil qui n'existe pas. + L'ancien repli faisait « install['cmd'] » sur un None.""" + self.assertEqual([], self._joue({"monitor": False})) + + def test_an_install_without_the_monitor_takes_the_quiet_path(self): + appels = self._joue( + { + "monitor": False, + "install": { + "branch": "develop", + "prod": False, + "cmd": "make x", + "monitor": False, + }, + } + ) + self.assertEqual(["muet"], appels) + + def test_the_install_keeps_the_last_word_on_its_own_monitoring(self): + appels = self._joue( + { + "monitor": False, + "install": { + "branch": "develop", + "prod": False, + "cmd": "make x", + "monitor": True, + }, + } + ) + self.assertEqual(["suivi"], appels) + + +class TestLeJournal(unittest.TestCase): + """L'en-tête et le prologue ne doivent pas annoncer ce qui n'a pas lieu.""" + + def _vm(self): + return { + "name": "vm-a", + "ip": "10.0.0.9", + "distro": "debian", + "version": "13", + "arch": "amd64", + } + + def test_an_install_is_titled_an_install(self): + head = mon._log_header(self._vm(), "develop", "2026-01-01 00:00:00") + self.assertIn(mon.t("installation"), head) + self.assertIn("develop", head) + + def test_without_a_branch_it_is_not_called_an_install(self): + """« ERPLibre — installation » puis « Branche : » vide, sur un + déploiement qui n'installe rien : le journal se contredisait.""" + head = mon._log_header(self._vm(), "", "2026-01-01 00:00:00") + self.assertIn(mon.t("VM start-up"), head) + self.assertNotIn("Branche", head) + + def test_the_prologue_says_what_actually_follows(self): + """« installation ERPLibre en cours » alors que rien ne s'installe.""" + import tempfile + from pathlib import Path + + vus = [] + vrai_launch, vrai_dir = mon._launch_one, mon.session_dir + mon._launch_one = ( + lambda ip, cmd, log, name="", installs=True: vus.append(installs) + ) + # session_dir détournée : sans cela le test écrivait de VRAIES sessions + # dans ~/.erplibre/qemu-install, qui polluaient l'historique que + # « Rouvrir le suivi » propose à l'utilisateur. + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + mon.session_dir = lambda: Path(tmp.name) + try: + mon.launch_installs([self._vm()], "", "true") + mon.launch_installs([self._vm()], "develop", "true") + finally: + mon._launch_one, mon.session_dir = vrai_launch, vrai_dir + self.assertEqual([False, True], vus) + + def test_the_host_key_notice_is_not_a_warning(self): + """ssh l'écrit à CHAQUE première connexion : comptée, la colonne ⚠ + s'allumait sur toute installation, dès sa première ligne.""" + import tempfile + + with tempfile.NamedTemporaryFile( + "w", suffix=".log", delete=False, encoding="utf-8" + ) as fh: + fh.write( + "Warning: Permanently added '10.0.0.9' (ED25519) to the" + " list of known hosts.\n__ERPLIBRE_EXIT__ 0\n" + ) + chemin = fh.name + self.addCleanup(os.unlink, chemin) + self.assertEqual((0, 0), mon.scan_log_errors(chemin)) + + +if __name__ == "__main__": + unittest.main(verbosity=1) diff --git a/test/test_qemu_monitor_vmstats.py b/test/test_qemu_monitor_vmstats.py new file mode 100644 index 0000000..6066280 --- /dev/null +++ b/test/test_qemu_monitor_vmstats.py @@ -0,0 +1,372 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Statistiques par VM du suivi d'installation : écriture, RAM, disque. + +Trois chiffres par VM, tirés d'UN appel « virsh domstats » pour tout le parc : +ce qu'elle écrit (moyenne sur dix secondes), sa RAM occupée/totale, son disque +occupé/total. + +Ce que ces tests gardent, appris en les construisant contre des VM réelles : + +- Le seed ISO doit être ÉCARTÉ des disques. Monté en lecture seule, il n'est + jamais écrit, et sa capacité minuscule l'aurait fait passer pour le disque + système d'une VM dont le qcow2 n'est pas encore alloué. +- Un compteur d'écriture qui RECULE veut dire que le domaine a redémarré — + ce qu'une installation fait. Sans garde, le débit devient négatif. +- Le ballon mémoire ne publie ses compteurs que si une période de collecte est + armée. Un relevé vieux d'une demi-heure doit être TAISÉ, pas affiché : + mesuré, il annonçait 388 Mo pour une VM qui occupait 1,1 Go. +- La ligne du tableau doit tenir : trois colonnes de plus, et « Disque » + sortait de l'écran sur un terminal de 150 colonnes. +""" + +import json +import tempfile +import time +import unittest +from pathlib import Path +from unittest import mock + +from script.todo import qemu_install_monitor as mon +from script.todo.qemu_install_monitor import ( + BALLOON_MAX_AGE, + COL_DEFAULT_WIDTHS, + WRITE_WINDOW, + WriteWindow, + _fmt_tight, + fmt_pair, + fmt_rate, + parse_domstats, + ram_pair, + vm_stats_line, +) + +# Sortie RÉELLE de « virsh domstats --balloon --block » sur deux VM du parc : +# une VM de migration en travail, et son seed ISO en second périphérique. +DOMSTATS = """Domain: 'erplibre-ubuntu-2404-MIGRATION' + balloon.current=12582912 + balloon.maximum=12582912 + balloon.swap_in=0 + balloon.unused=936072 + balloon.available=12242352 + balloon.usable=11064740 + balloon.last-update=1787392761 + balloon.rss=12623896 + block.count=2 + block.0.name=vda + block.0.path=/var/lib/libvirt/images/erplibre-ubuntu-2404.qcow2 + block.0.rd.bytes=9664722944 + block.0.wr.reqs=2969857 + block.0.wr.bytes=165796498432 + block.0.allocation=67263266816 + block.0.capacity=69793218560 + block.0.physical=67265261568 + block.1.name=vdb + block.1.path=/var/lib/libvirt/images/iso/erplibre-ubuntu-2404-seed.iso + block.1.wr.bytes=4096 + block.1.allocation=380928 + block.1.capacity=380928 +Domain: 'erplibre-ubuntu-2604' + balloon.current=12582912 + balloon.available=12237092 + balloon.usable=11054184 + balloon.last-update=1787392782 + block.count=1 + block.0.name=vda + block.0.path=/var/lib/libvirt/images/erplibre-ubuntu-2604.qcow2 + block.0.wr.bytes=48117251072 + block.0.allocation=43900928000 + block.0.capacity=69793218560 +""" + +MIGRATION = "erplibre-ubuntu-2404-MIGRATION" + + +class TestLecture(unittest.TestCase): + def test_it_reads_both_domains(self): + st = parse_domstats(DOMSTATS) + self.assertEqual({MIGRATION, "erplibre-ubuntu-2604"}, set(st)) + + def test_ram_is_available_minus_usable(self): + """Calibrée en son temps contre le « free » de deux invités : c'est + « available - usable » qui suit ce qu'ils occupent, pas « unused », + qui compte le cache.""" + rec = parse_domstats(DOMSTATS)[MIGRATION] + self.assertEqual(12242352 * 1024, rec["ram_total"]) + self.assertEqual((12242352 - 11064740) * 1024, rec["ram_used"]) + + def test_the_seed_iso_is_left_out_of_the_disk(self): + """Sa capacité (372 Ko) l'emporterait sur un qcow2 pas encore alloué, + et le tableau annoncerait un disque plein à 100 %.""" + rec = parse_domstats(DOMSTATS)[MIGRATION] + self.assertEqual(69793218560, rec["disk_total"]) + self.assertEqual(67263266816, rec["disk_used"]) + + def test_the_seed_iso_writes_are_left_out_too(self): + """4096 octets écrits sur un ISO en lecture seule : anecdotique ici, + mais compter un périphérique qu'on n'affiche pas rend un débit qu'on + ne peut relier à rien.""" + rec = parse_domstats(DOMSTATS)[MIGRATION] + self.assertEqual(165796498432, rec["wr_bytes"]) + + def test_a_single_disk_vm_is_read_too(self): + rec = parse_domstats(DOMSTATS)["erplibre-ubuntu-2604"] + self.assertEqual(48117251072, rec["wr_bytes"]) + self.assertEqual(69793218560, rec["disk_total"]) + + def test_empty_or_broken_output_gives_nothing(self): + for texte in ("", "erreur: pas de connexion", None): + self.assertEqual({}, parse_domstats(texte)) + + def test_a_domain_without_balloon_stats_is_not_invented(self): + """Une VM éteinte, ou dont le pilote n'a rien publié : pas de RAM + plutôt qu'un zéro qui passerait pour une mesure.""" + rec = parse_domstats("Domain: 'x'\n block.count=0\n")["x"] + self.assertEqual(0, rec["ram_total"]) + self.assertEqual("-", ram_pair(rec, time.time())) + + +class TestFenetreEcriture(unittest.TestCase): + def test_two_samples_ten_seconds_apart_give_the_rate(self): + w = WriteWindow(10.0) + w.add("vm", 0, 1000.0) + w.add("vm", 10 << 20, 1010.0) + self.assertAlmostEqual(1 << 20, w.rate("vm"), delta=1024) + + def test_one_sample_is_not_a_rate(self): + """Au premier tour, la colonne doit dire « - » et non « 0 » : un débit + nul et un débit inconnu ne se ressemblent pas.""" + w = WriteWindow() + w.add("vm", 12345, 1000.0) + self.assertIsNone(w.rate("vm")) + self.assertEqual("-", fmt_rate(w.rate("vm"))) + + def test_samples_too_close_together_are_not_a_rate(self): + """Deux relevés à 200 ms d'intervalle donnent un chiffre absurde ; + mieux vaut attendre le tour suivant.""" + w = WriteWindow() + w.add("vm", 0, 1000.0) + w.add("vm", 1 << 20, 1000.2) + self.assertIsNone(w.rate("vm")) + + def test_the_window_keeps_about_ten_seconds(self): + """Sinon la moyenne porterait sur toute la durée de l'installation, et + ne montrerait plus ce qui se passe MAINTENANT.""" + w = WriteWindow(10.0) + # Une minute de relevés toutes les deux secondes, débit constant. + for i in range(31): + w.add("vm", i * (2 << 20), 1000.0 + 2 * i) + span = w._hist["vm"][-1][0] - w._hist["vm"][0][0] + self.assertLessEqual(span, 12.0) + self.assertGreaterEqual(span, 10.0) + self.assertAlmostEqual(1 << 20, w.rate("vm"), delta=1024) + + def test_a_restart_resets_the_window_instead_of_going_negative(self): + """Le compteur appartient au processus QEMU : un redémarrage du domaine + le remet à zéro, et une installation redémarre la VM.""" + w = WriteWindow(10.0) + w.add("vm", 500 << 20, 1000.0) + w.add("vm", 501 << 20, 1002.0) + w.add("vm", 1 << 20, 1004.0) # domaine redémarré + self.assertIsNone(w.rate("vm")) + w.add("vm", 3 << 20, 1014.0) + self.assertIsNotNone(w.rate("vm")) + self.assertGreater(w.rate("vm"), 0) + + def test_vms_do_not_mix(self): + w = WriteWindow(10.0) + w.add("a", 0, 1000.0) + w.add("b", 0, 1000.0) + w.add("a", 10 << 20, 1010.0) + w.add("b", 1 << 20, 1010.0) + self.assertAlmostEqual(1 << 20, w.rate("a"), delta=1024) + self.assertAlmostEqual(1 << 20, w.rate("b") * 10, delta=10240) + + def test_the_window_is_the_ten_seconds_announced(self): + """La colonne dit « moyenne 10 s » : la constante doit le tenir.""" + self.assertEqual(10.0, WRITE_WINDOW) + + +class TestFraicheurRam(unittest.TestCase): + def test_a_stale_balloon_report_is_hidden(self): + rec = parse_domstats(DOMSTATS)[MIGRATION] + vieux = rec["ram_at"] + BALLOON_MAX_AGE + 5 + self.assertEqual("-", ram_pair(rec, vieux)) + + def test_a_fresh_report_is_shown(self): + rec = parse_domstats(DOMSTATS)[MIGRATION] + self.assertEqual("1.1G/12G", ram_pair(rec, rec["ram_at"] + 2)) + + def test_a_report_without_a_date_is_still_shown(self): + """libvirt joint toujours « last-update » quand le ballon a parlé ; + sans elle on ne sait pas juger, et taire la valeur perdrait la seule + information disponible.""" + rec = dict(parse_domstats(DOMSTATS)[MIGRATION], ram_at=0) + self.assertNotEqual("-", ram_pair(rec, time.time())) + + +class TestFormats(unittest.TestCase): + def test_the_decimal_goes_away_above_ten_units(self): + """« 63G » plutôt que « 62.6G » : ces deux caractères décident si + « Disque » reste visible.""" + self.assertEqual("63G", _fmt_tight(67263266816)) + self.assertEqual("1.1G", _fmt_tight(1130479616)) + self.assertEqual("12G", _fmt_tight(12536168448)) + + def test_a_pair_without_a_total_says_nothing(self): + self.assertEqual("-", fmt_pair(1024, 0)) + self.assertEqual("-", fmt_pair(None, None)) + + def test_the_rate_carries_its_unit(self): + self.assertEqual("1.0M/s", fmt_rate(1 << 20)) + self.assertEqual("-", fmt_rate(None)) + + def test_the_pairs_fit_the_columns(self): + """Une valeur tronquée dans un tableau est un piège : on lit « 1001M/1 » + et on croit une VM à 1 Go.""" + for used, total, cle in ( + (1001 << 20, 128 << 30, "ram"), + (12 << 30, 128 << 30, "ram"), + (999 << 30, 1 << 40, "disk"), + (67263266816, 69793218560, "disk"), + ): + self.assertLessEqual( + len(fmt_pair(used, total)), COL_DEFAULT_WIDTHS[cle] + ) + for bps in (0, 1 << 10, 12.3 * (1 << 20), 2 << 30): + self.assertLessEqual(len(fmt_rate(bps)), COL_DEFAULT_WIDTHS["wr"]) + + +class TestSection(unittest.TestCase): + def test_it_names_the_three_measures(self): + rec = parse_domstats(DOMSTATS)[MIGRATION] + ligne = vm_stats_line( + MIGRATION, rec, 1 << 20, rec["ram_at"] + 1, rec["wr_bytes"] + ) + self.assertIn(MIGRATION, ligne) + self.assertIn("1.0M/s", ligne) + self.assertIn("1.1G/12G", ligne) + self.assertIn("63G/65G", ligne) + + def test_it_shows_percentages(self): + """« 63G/65G » ne dit pas d'un coup d'œil que le disque est à 96 %.""" + rec = parse_domstats(DOMSTATS)[MIGRATION] + ligne = vm_stats_line(MIGRATION, rec, 0, rec["ram_at"] + 1) + self.assertIn("(96%)", ligne) + self.assertIn("(9%)", ligne) + + def test_a_vm_without_stats_says_so(self): + ligne = vm_stats_line("vm-x", None, None, time.time()) + self.assertIn("vm-x", ligne) + self.assertNotIn("RAM", ligne) + + +class TestColonnes(unittest.TestCase): + def test_the_three_columns_exist(self): + for cle in ("wr", "ram", "disk"): + self.assertIn(cle, COL_DEFAULT_WIDTHS) + + def test_the_row_fits_a_150_column_terminal(self): + """La table est bornée à 66 % de l'écran, et le journal prend le reste. + Trois colonnes de plus, et « Disque » sortait du cadre : le total est + donc surveillé, padding de DataTable compris (2 par colonne).""" + besoin = sum(COL_DEFAULT_WIDTHS.values()) + 2 * len(COL_DEFAULT_WIDTHS) + self.assertLessEqual(besoin, int(150 * 0.66)) + + +class TestEcranMonte(unittest.IsolatedAsyncioTestCase): + """Le suivi monté pour de vrai : les colonnes portent-elles les chiffres ? + + Aucun appel à libvirt : « read_domstats » rend la sortie enregistrée + ci-dessus. Un test qui lance sudo ne tournerait ni en CI ni sur un poste + sans le parc. + """ + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + base = Path(self.tmp.name) + for nom in (MIGRATION, "erplibre-ubuntu-2604"): + (base / f"{nom}.log").write_text("===> install\nligne\n") + manifeste = { + "branch": "develop", + "started": time.time() - 300, + "vms": [ + { + "name": nom, + "ip": "127.0.0.9", + "arch": "amd64", + "log": str(base / f"{nom}.log"), + "ssh": f"ssh erplibre@{nom}", + "disk": str(base / f"{nom}.qcow2"), + } + for nom in (MIGRATION, "erplibre-ubuntu-2604") + ], + } + self.manifest = base / "session.json" + self.manifest.write_text(json.dumps(manifeste)) + # Le relevé du ballon doit paraître FRAIS, sinon la colonne RAM se + # taira — ce qui est le comportement voulu, mais pas ce qu'on teste. + self.stats = DOMSTATS.replace( + "balloon.last-update=1787392761", + f"balloon.last-update={int(time.time())}", + ).replace( + "balloon.last-update=1787392782", + f"balloon.last-update={int(time.time())}", + ) + + async def _monte(self, taille=(150, 24)): + from textual.widgets import DataTable, Static + + with mock.patch.object( + mon, "read_domstats", lambda: self.stats + ), mock.patch.object( + mon, "arm_balloon", lambda names: None + ), mock.patch.object( + mon, + "virsh_domstates", + lambda: {MIGRATION: "running", "erplibre-ubuntu-2604": "running"}, + ), mock.patch.object( + mon, "_port_open", lambda ip, port=8069, timeout=0.5: False + ): + app = mon.run_monitor(str(self.manifest), run_app=False) + async with app.run_test(size=taille) as pilot: + await pilot.pause() + # Le tick du tableau tourne toutes les deux secondes : on + # l'appelle plutôt que d'attendre, et on antidate un premier + # relevé pour que la fenêtre ait de quoi conclure. + app._wrate.add(MIGRATION, 100 << 20, time.time() - 10) + await app._tick_table() + await pilot.pause() + table = app.query_one("#vms", DataTable) + cellules = { + cle: str(table.get_cell(MIGRATION, cle)) + for cle in ("wr", "ram", "disk") + } + section = str(app.query_one("#vmstats", Static).content) + besoin = table.virtual_size.width + visible = table.size.width + return cellules, section, besoin, visible + + async def test_the_columns_carry_the_numbers(self): + cellules, _section, _b, _v = await self._monte() + self.assertEqual("1.1G/12G", cellules["ram"]) + self.assertEqual("63G/65G", cellules["disk"]) + self.assertTrue(cellules["wr"].endswith("/s"), cellules["wr"]) + + async def test_the_selected_vm_has_its_statistics_section(self): + _c, section, _b, _v = await self._monte() + self.assertIn(MIGRATION, section) + self.assertIn("1.1G/12G", section) + self.assertIn("63G/65G", section) + + async def test_nothing_scrolls_out_of_a_150_column_terminal(self): + """Le vrai garde-fou de la largeur : mesuré sur la table montée, pas + calculé à la main.""" + _c, _s, besoin, visible = await self._monte((150, 24)) + self.assertGreaterEqual(visible, besoin) + + +if __name__ == "__main__": + unittest.main(verbosity=1) diff --git a/test/test_qemu_proxmox.py b/test/test_qemu_proxmox.py new file mode 100644 index 0000000..d3b5e25 --- /dev/null +++ b/test/test_qemu_proxmox.py @@ -0,0 +1,392 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Proxmox VE au catalogue de déploiement : ce qui existe, et ce qui n'existe pas. + +Proxmox ne publie AUCUNE image cloud — son ISO est un installateur qui formate +le disque. La voie que l'amont documente pour tout le reste est « Proxmox VE +sur Debian » : on part de l'image cloud Debian trixie et les paquets pve en +font l'hyperviseur. Le catalogue le dit ainsi, et l'image téléchargée est +littéralement celle de Debian. + +Par architecture, vérifié dans le dépôt amont et non déduit : + +- amd64 : 1800 paquets, proxmox-ve présent. +- arm64 : 448 paquets, proxmox-ve présent — officiel depuis PVE 9, dont le + Release de trixie annonce « amd64 arm64 ». +- s390x : l'index « binary-s390x » répond 404. Ce n'est pas une difficulté, + c'est une absence, et le catalogue la dit avant le déploiement. + +Ce que ces tests gardent : + +- Le nom de cache est celui de Debian : sans lui, le repli de fin nommait + l'image « fedora-cloud-9 » et un même téléchargement se faisait deux fois. +- Les planchers RAM/disque sont ceux d'un hyperviseur, pas ceux de Debian. +- Le retrait du noyau Debian n'emporte JAMAIS celui de Proxmox — sans quoi la + VM ne redémarrerait plus. +""" + +import importlib.util +import os +import pathlib +import subprocess +import sys +import tempfile +import unittest +from unittest import mock + +RACINE = pathlib.Path(__file__).resolve().parents[1] +SCRIPT = RACINE / "script/proxmox/install_proxmox.sh" + +sys.argv = ["todo.py"] +from script.todo.todo import TODO # noqa: E402 +from script.todo.todo_i18n import t # noqa: E402 + + +def _deploy(): + spec = importlib.util.spec_from_file_location( + "deploy_qemu", RACINE / "script/qemu/deploy_qemu.py" + ) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +DQ = _deploy() + +# Somme publiée par l'amont sur « Install Proxmox VE on Debian 13 Trixie », et +# recopiée ici EXPRÈS : deux copies indépendantes, c'est ce qui donne son sens +# à un condensat épinglé. Si l'une change sans l'autre, ce test le dit. +KEY_SHA256 = "136673be77aba35dcce385b28737689ad64fd785a797e57897589aed08db6e45" + + +class TestCatalogue(unittest.TestCase): + def test_it_is_offered_as_a_distro(self): + self.assertIn("proxmox", DQ.DISTROS) + versions, defaut = DQ.DISTROS["proxmox"] + self.assertEqual("9", defaut) + self.assertIn("9", versions) + + def test_the_base_is_the_debian_trixie_cloud_image(self): + """C'est bien l'image DEBIAN qu'on télécharge : Proxmox n'en publie + aucune.""" + for arch in ("amd64", "arm64"): + url = DQ.image_url("proxmox", "trixie", arch, "9") + self.assertIn("trixie", url) + self.assertIn(f"debian-13-genericcloud-{arch}.qcow2", url) + + def test_the_cache_name_is_debians_so_the_download_is_shared(self): + """Un déploiement Debian 13 et un Proxmox visent le MÊME fichier : le + repli de fin nommait l'image « fedora-cloud-9 », et 325 Mio se + retéléchargeaient.""" + pve = DQ.default_image_name("proxmox", "trixie", "amd64", "9") + deb = DQ.default_image_name("debian", "trixie", "amd64", "13") + self.assertEqual(deb, pve) + self.assertNotIn("fedora", pve) + + def test_s390x_does_not_exist_and_is_refused(self): + """Le dépôt amont n'a pas d'index s390x (404). Le refus vaut mieux + qu'un déploiement qui échouera au premier apt.""" + self.assertNotIn("proxmox", DQ.ARCH_DISTRO_SUPPORT["s390x"]) + + def test_arm64_is_official_since_pve_9(self): + self.assertIn("proxmox", DQ.ARCH_DISTRO_SUPPORT["arm64"]) + + def test_the_floors_are_a_hypervisors_not_debians(self): + """Proxmox demande 2 Gio pour lui seul et télécharge son noyau : les + 1 Gio/20 Go de Debian donneraient une VM incapable d'héberger une + seule invitée.""" + _code, _osinfo, ram, disque = DQ.PROXMOX_VERSIONS["9"] + self.assertGreaterEqual(ram, 4096) + self.assertGreaterEqual(int(disque.rstrip("G")), 32) + deb_ram = DQ.DEBIAN_VERSIONS["13"][2] + self.assertGreater(ram, deb_ram) + + def test_it_reuses_debians_osinfo(self): + """Le système EST une Debian : libosinfo n'a pas d'entrée Proxmox, et + en inventer une ferait échouer virt-install.""" + self.assertEqual("debian13", DQ.PROXMOX_VERSIONS["9"][1]) + + def test_it_is_named_after_proxmox_not_after_the_key(self): + self.assertEqual("Proxmox VE 9", DQ.distro_label("proxmox", "9")) + + def test_the_login_guide_knows_it_is_apt(self): + self.assertEqual("apt", DQ.DISTRO_PKG["proxmox"]) + + +class TestLesDeuxCatalogues(unittest.TestCase): + """todo.py duplique le catalogue de deploy_qemu.py. Qu'ils s'accordent.""" + + def test_the_menu_offers_every_distro_of_the_catalogue(self): + menu = set(TODO._QEMU_DISTROS) + catalogue = set(DQ.DISTROS) + self.assertEqual( + catalogue, + menu, + "les deux catalogues divergent : " + f"menu seul {menu - catalogue}, deploy seul {catalogue - menu}", + ) + + def test_the_versions_agree_for_proxmox(self): + versions, defaut = TODO._QEMU_DISTROS["proxmox"] + self.assertEqual(["9"], list(versions)) + self.assertEqual("9", defaut) + + +class TestLeProfil(unittest.TestCase): + def setUp(self): + self.todo = TODO.__new__(TODO) + + def test_a_dedicated_profile_calls_the_dedicated_script(self): + profils = dict(self.todo._qemu_install_profiles()) + libelle = t("Proxmox VE hypervisor (no Odoo)") + self.assertIn(libelle, profils) + self.assertIn("install_proxmox.sh", profils[libelle]) + + def test_on_a_proxmox_vm_it_comes_first(self): + """Laisser « Odoo 18 » en défaut ferait poser un ERP sur un + hyperviseur : c'est le contraire de ce qu'on a demandé.""" + vus = [] + with mock.patch("builtins.input", lambda *a: ""): + import contextlib + import io + + with contextlib.redirect_stdout(io.StringIO()): + libelle, cmd = self.todo._qemu_pick_install_profile("proxmox") + vus.append((libelle, cmd)) + self.assertIn("install_proxmox.sh", cmd) + + def test_on_any_other_vm_odoo_stays_the_default(self): + import contextlib + import io + + with mock.patch("builtins.input", lambda *a: ""): + with contextlib.redirect_stdout(io.StringIO()): + libelle, cmd = self.todo._qemu_pick_install_profile("debian") + self.assertNotIn("install_proxmox.sh", cmd) + self.assertIn("install_odoo_18", cmd) + + +class TestLeScript(unittest.TestCase): + def test_it_is_executable_and_valid_shell(self): + self.assertTrue(os.access(SCRIPT, os.X_OK), "pas exécutable") + res = subprocess.run( + ["bash", "-n", str(SCRIPT)], capture_output=True, text=True + ) + self.assertEqual(0, res.returncode, res.stderr) + + def test_help_names_the_knobs_and_exits_clean(self): + res = subprocess.run( + ["bash", str(SCRIPT), "--help"], capture_output=True, text=True + ) + self.assertEqual(0, res.returncode) + for knob in ("--dry-run", "PVE_SUITE", "PVE_REBOOT"): + self.assertIn(knob, res.stdout) + + def test_the_pinned_key_hash_is_the_published_one(self): + texte = SCRIPT.read_text(encoding="utf-8") + self.assertIn(KEY_SHA256, texte) + self.assertIn("proxmox-archive-keyring-", texte) + + def _lance(self, args=(), stubs=None, env=None): + with tempfile.TemporaryDirectory() as tmp: + bin_dir = pathlib.Path(tmp) / "bin" + bin_dir.mkdir() + for nom, corps in (stubs or {}).items(): + (bin_dir / nom).write_text(f"#!/bin/bash\n{corps}\n") + (bin_dir / nom).chmod(0o755) + osrel = pathlib.Path(tmp) / "os-release" + osrel.write_text("ID=debian\nVERSION_CODENAME=trixie\n") + return subprocess.run( + ["bash", str(SCRIPT), *args], + capture_output=True, + text=True, + env=dict( + os.environ, + PATH=f"{bin_dir}:/usr/bin:/bin", + PVE_OS_RELEASE=str(osrel), + **(env or {}), + ), + timeout=120, + ) + + def test_an_unpublished_architecture_is_refused_by_name(self): + res = self._lance(stubs={"uname": "echo s390x"}) + self.assertNotEqual(0, res.returncode) + self.assertIn("s390x", res.stdout + res.stderr) + + def test_another_distribution_is_refused_by_name(self): + """Le script s'installe SUR Debian : le dire vaut mieux qu'un apt qui + échouera sur un dépôt introuvable.""" + with tempfile.NamedTemporaryFile( + "w", suffix=".os", delete=False + ) as fh: + fh.write("ID=ubuntu\nVERSION_CODENAME=noble\n") + chemin = fh.name + self.addCleanup(os.unlink, chemin) + res = subprocess.run( + ["bash", str(SCRIPT)], + capture_output=True, + text=True, + env=dict(os.environ, PVE_OS_RELEASE=chemin), + timeout=60, + ) + self.assertNotEqual(0, res.returncode) + self.assertIn("ubuntu", res.stdout + res.stderr) + + def test_the_dry_run_follows_the_upstream_procedure(self): + res = self._lance(["--dry-run"]) + self.assertEqual(0, res.returncode, res.stdout + res.stderr) + out = res.stdout + for attendu in ( + "proxmox-archive-keyring-trixie.gpg", + "Types: deb", + "pve-no-subscription", + "Signed-By: /usr/share/keyrings/proxmox-archive-keyring.gpg", + "proxmox-default-kernel", + "proxmox-ve postfix open-iscsi chrony", + ): + self.assertIn(attendu, out, attendu) + + def test_the_dry_run_changes_nothing_and_says_so(self): + """La leçon du montage sshfs : ne rien annoncer qui n'ait eu lieu.""" + res = self._lance(["--dry-run"]) + self.assertIn("dry-run", res.stdout) + self.assertNotIn("Proxmox VE installé", res.stdout) + + def test_postfix_is_preseeded_or_the_install_hangs(self): + """postfix pose deux questions debconf : sans préréponse, une + installation lancée par SSH reste pendue sur une saisie invisible.""" + res = self._lance(["--dry-run"]) + self.assertIn("debconf-set-selections", res.stdout) + self.assertIn("main_mailer_type", res.stdout) + + def test_it_never_removes_the_proxmox_kernel(self): + """Le noyau qu'on vient de poser : l'emporter laisserait une VM qui + n'amorce plus. dpkg-query rend ici les deux, seul celui de Debian doit + partir.""" + res = self._lance( + ["--dry-run"], + stubs={ + "uname": "echo x86_64", + "dpkg": "exit 0", + # Format réel de la requête : nom + état. dpkg connaît + # aussi les paquets DÉSINSTALLÉS (« config-files »), et les + # passer à apt réclamait un redémarrage pour rien. + "dpkg-query": ( + "printf 'linux-image-6.12.0-amd64 installed\\n" + "linux-image-amd64 config-files\\n" + "linux-image-6.14.11-1-pve installed\\n'" + ), + }, + ) + self.assertEqual(0, res.returncode, res.stdout + res.stderr) + ligne = [ + x for x in res.stdout.splitlines() if "remove linux-image" in x + ] + self.assertTrue(ligne, res.stdout) + self.assertIn("linux-image-6.12.0-amd64", ligne[0]) + self.assertNotIn("pve", ligne[0]) + # « config-files » = déjà désinstallé : ne pas le repasser à apt. + self.assertNotIn("linux-image-amd64", ligne[0]) + + def test_without_the_proxmox_kernel_nothing_is_removed(self): + """Une étape apt a pu échouer plus haut : on ne touche pas au noyau + Debian tant que celui de Proxmox n'est pas là.""" + res = self._lance( + ["--dry-run"], + stubs={"uname": "echo x86_64", "dpkg": "exit 1"}, + ) + self.assertNotIn("remove linux-image", res.stdout) + self.assertIn("noyau Debian reste en place", res.stdout) + + def test_grub_pc_is_preseeded_or_dpkg_stops(self): + """Le piège propre à l'image cloud : elle amorce en EFI, les paquets + pve tirent grub-pc, et sa post-installation refuse de deviner le + disque — « You must correct your GRUB install devices before + proceeding ». Mesuré : dpkg s'arrête et emporte la transaction.""" + res = self._lance(["--dry-run"]) + self.assertIn("grub-pc/install_devices", res.stdout) + self.assertRegex(res.stdout, r"install_devices multiselect /dev/\w+") + + def test_apt_waits_for_the_lock_instead_of_giving_up(self): + """Sur une VM fraîche, cloud-init tient encore le verrou : mesuré, + « held by process 996 (apt-get) », et le script mourait 40 secondes + après le démarrage.""" + texte = SCRIPT.read_text(encoding="utf-8") + self.assertIn("DPkg::Lock::Timeout", texte) + res = self._lance(["--dry-run"]) + self.assertIn("DPkg::Lock::Timeout", res.stdout) + + def test_it_waits_for_cloud_init_but_not_for_its_verdict(self): + """cloud-init rend « error » sur l'image Debian 13 pour deux modules + sans rapport (console-setup absent, update-locale) alors qu'il a bien + fini. On attend qu'il termine, pas qu'il soit content.""" + res = self._lance(["--dry-run"]) + self.assertIn("cloud-init status --wait", res.stdout) + # L'échec de cloud-init ne doit PAS arrêter le script : « || true » + # sur l'appel réel, pas sur l'écho du dry-run. + texte = SCRIPT.read_text(encoding="utf-8") + reel = [ + x + for x in texte.splitlines() + if "cloud-init status --wait" in x and "dry-run" not in x + ] + self.assertTrue(reel, texte) + self.assertIn("|| true", reel[0]) + + def _esp(self, avec_secours): + """Fausse partition EFI : un stub Debian, et le chemin de secours.""" + racine = tempfile.mkdtemp() + self.addCleanup( + lambda: subprocess.run(["rm", "-rf", racine], check=False) + ) + deb = pathlib.Path(racine, "EFI", "debian") + boot = pathlib.Path(racine, "EFI", "BOOT") + deb.mkdir(parents=True) + boot.mkdir(parents=True) + (deb / "grub.cfg").write_text( + "search.fs_uuid 81e2a465 root\nset prefix=($root)'/boot/grub'\n" + ) + (boot / "BOOTX64.EFI").write_text("binaire") + if avec_secours: + (boot / "grub.cfg").write_text("déjà là") + return racine + + def test_the_efi_fallback_stub_is_restored(self): + """Sans ce fichier, GRUB s'arrête sur « grub> » : ni menu ni noyau. + Vécu après le premier redémarrage — la VM brûlait 100 % d'un cœur sans + lire une seule fois le disque, capture d'écran à l'appui.""" + esp = self._esp(avec_secours=False) + res = self._lance(["--dry-run"], env={"PVE_ESP": esp}) + self.assertEqual(0, res.returncode, res.stdout + res.stderr) + self.assertIn("amorçage de secours", res.stdout) + self.assertIn("EFI/BOOT/", res.stdout) + + def test_a_working_fallback_is_left_alone(self): + """Ne pas réécrire ce qui marche : le stub en place peut avoir été + ajusté à la main.""" + esp = self._esp(avec_secours=True) + res = self._lance(["--dry-run"], env={"PVE_ESP": esp}) + self.assertNotIn("amorçage de secours", res.stdout) + + def test_an_esp_without_any_stub_is_said_not_guessed(self): + racine = tempfile.mkdtemp() + self.addCleanup( + lambda: subprocess.run(["rm", "-rf", racine], check=False) + ) + pathlib.Path(racine, "EFI", "BOOT").mkdir(parents=True) + res = self._lance(["--dry-run"], env={"PVE_ESP": racine}) + self.assertIn("aucun grub.cfg à recopier", res.stdout) + + def test_it_does_not_reboot_unless_asked(self): + """Lancé par SSH depuis le déploiement, un reboot couperait la session + et ferait passer une installation réussie pour un échec.""" + texte = SCRIPT.read_text(encoding="utf-8") + self.assertIn('PVE_REBOOT:-0}" != "1"', texte) + res = self._lance(["--dry-run"]) + self.assertNotIn("systemctl reboot", res.stdout) + + +if __name__ == "__main__": + unittest.main(verbosity=1) diff --git a/test/test_run_sh_auto_db.py b/test/test_run_sh_auto_db.py new file mode 100644 index 0000000..ba9a514 --- /dev/null +++ b/test/test_run_sh_auto_db.py @@ -0,0 +1,546 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""« ./run.sh » sans argument choisit sa base — sans jamais bloquer. + +Le danger n'est pas le menu, c'est l'endroit où il ne doit PAS s'ouvrir. +`script/systemd/install_daemon.sh:34` écrit `ExecStart=/bin/bash …/run.sh` +sans le moindre argument, avec `Restart=always` et `RestartSec=5` : une +question posée là ne serait pas une pause, ce serait une boucle de +redémarrage, chacun repayant 0,8 s d'import Odoo. + +D'où la forme des épreuves : on mesure d'abord ce qui NE doit pas arriver +— la sonde jamais lancée, aucun `-d` injecté, la ligne d'arguments intacte +à l'octet — et seulement ensuite le menu, derrière un vrai +pseudo-terminal, seul moyen de rendre `-t 0` et `-t 2` vrais ensemble. +""" + +import os +import pathlib +import pty +import select +import shutil +import subprocess +import tempfile +import time +import unittest + +RACINE = pathlib.Path(__file__).resolve().parents[1] +SCRIPT = RACINE / "run.sh" +LIB = RACINE / "script" / "database" / "lib_db_select.sh" + +DELAI = 30 + +# Le bouchon joue les DEUX rôles : la sonde (`db --list`) et le lancement +# d'Odoo. `$1` vaut « db » dans un cas et « -c » dans l'autre, le +# discriminant est donc fiable. +# +# `[%s]` par argument, et non `$*` : c'est ce format qui prouve qu'un +# argument vide et un argument à espaces traversent intacts. Une chaîne +# recollée les aurait perdus sans que rien ne le dise. +BOUCHON = """#!/usr/bin/env bash +case "$1" in + db) touch ./SONDE_APPELEE; %s ;; + *) printf 'ARGS:'; printf '[%%s]' "$@"; printf '\\n' ;; +esac +""" + +TROIS_BASES = BOUCHON % "printf '%s\\n' _cache_odoo18.0_base demo prod" +UNE_BASE = BOUCHON % "printf '%s\\n' _cache_odoo18.0_base demo" +QUE_DU_CACHE = BOUCHON % "printf '%s\\n' _cache_odoo18.0_base" +AUCUNE_BASE = BOUCHON % ":" +# Le cas qui compte pour la fusion des flux : la sonde RÉUSSIT, et parle +# quand même sur stderr. Le CLI « db » n'appelle jamais parse_config(), +# donc aucun journal n'est configuré et tout enregistrement part nu par là. +SONDE_BAVARDE = BOUCHON % ( + "printf 'WARNING odoo.modules.module: module xyz not loadable\\n' >&2;" + " printf '%s\\n' demo" +) +SONDE_CASSEE = BOUCHON % ( + "printf 'Traceback (most recent call last):\\n" + ' File \\"x.py\\", line 1\\nOperationalError\\n\' >&2; exit 1' +) + + +class Banc: + """Un dépôt jetable : run.sh ne fait aucun `cd` et résout tout du cwd.""" + + def __init__( + self, bouchon=TROIS_BASES, config="[options]\ndb_name = False\n" + ): + self.dossier = tempfile.mkdtemp(prefix="el_run_sh_") + chemin = pathlib.Path(self.dossier) + (chemin / "script" / "database").mkdir(parents=True) + shutil.copy(SCRIPT, chemin / "run.sh") + shutil.copy(LIB, chemin / "script" / "database" / "lib_db_select.sh") + (chemin / "config.conf").write_text(config, encoding="utf-8") + stub = chemin / "odoo_bin.sh" + stub.write_text(bouchon, encoding="utf-8") + stub.chmod(0o755) + self.chemin = chemin + + def sonde_appelee(self): + return (self.chemin / "SONDE_APPELEE").exists() + + def lancer(self, *argv, env=None, entree=None): + milieu = dict(os.environ) + milieu.pop("ODOO_MODE_TEST", None) + milieu.pop("ODOO_MODE_COVERAGE", None) + if env: + milieu.update(env) + return subprocess.run( + ["./run.sh", *argv], + cwd=self.dossier, + capture_output=True, + text=True, + timeout=DELAI, + input=entree if entree is not None else "", + env=milieu, + ) + + def conduire(self, *argv, frappe=""): + """Derrière un vrai pseudo-terminal — le seul `-t 0` et `-t 2` vrais.""" + maitre, esclave = pty.openpty() + proc = subprocess.Popen( + ["./run.sh", *argv], + cwd=self.dossier, + stdin=esclave, + stdout=esclave, + stderr=esclave, + close_fds=True, + ) + os.close(esclave) + if frappe: + time.sleep(0.4) + os.write(maitre, frappe.encode()) + morceaux = [] + # ÉCHÉANCE sur la LECTURE, pas seulement sur l'attente du fils. + # `os.read` sur un pseudo-terminal bloque tant que le maître est + # ouvert : si une régression ouvre un menu là où le test ne tape + # rien, la suite entière pend au lieu d'échouer. Mesuré — une + # mutation du filtre « _cache_ » a fait durer 600 s. + limite = time.monotonic() + DELAI + depasse = False + try: + while True: + reste = limite - time.monotonic() + if reste <= 0: + depasse = True + break + pret, _, _ = select.select([maitre], [], [], reste) + if not pret: + depasse = True + break + try: + bloc = os.read(maitre, 4096) + except OSError: + break + if not bloc: + break + morceaux.append(bloc) + finally: + if depasse: + proc.kill() + code = proc.wait(timeout=DELAI) + os.close(maitre) + texte = b"".join(morceaux).decode(errors="replace").replace("\r", "") + if depasse: + raise AssertionError( + f"run.sh n'a pas rendu la main en {DELAI} s — il attend" + f" sans doute une réponse. Vu :\n{texte}" + ) + return code, texte + + def nettoyer(self): + shutil.rmtree(self.dossier, ignore_errors=True) + + +class BancTest(unittest.TestCase): + bouchon = TROIS_BASES + config = "[options]\ndb_name = False\n" + + def setUp(self): + self.banc = Banc(self.bouchon, self.config) + self.addCleanup(self.banc.nettoyer) + + +class TestTheScriptsAreValid(unittest.TestCase): + def test_both_files_parse(self): + for chemin in (SCRIPT, LIB): + with self.subTest(chemin=chemin.name): + res = subprocess.run( + ["bash", "-n", str(chemin)], + capture_output=True, + text=True, + timeout=DELAI, + ) + self.assertEqual(0, res.returncode, res.stderr) + + def test_run_sh_is_still_executable(self): + self.assertTrue(os.access(SCRIPT, os.X_OK), SCRIPT) + + def test_the_library_announces_itself_sourceable(self): + # Convention du dépôt : lib_pip_provider.sh, lib_python_provider.sh. + self.assertIn( + "Bibliothèque SOURÇABLE", LIB.read_text(encoding="utf-8") + ) + + def test_the_library_sets_no_shell_option(self): + # Une bibliothèque sourcée imposerait ses options à run.sh, qui lit + # $ODOO_MODE_TEST sans valeur par défaut : `set -u` le tuerait. + for ligne in LIB.read_text(encoding="utf-8").splitlines(): + self.assertFalse( + ligne.strip().startswith("set -"), f"« {ligne.strip()} »" + ) + + +class TestNothingHappensWhereNobodyAsked(BancTest): + """Le contrat de non-régression : systemd, la migration, les scripts.""" + + def test_zero_argument_without_a_terminal_never_probes(self): + # Le cas systemd. La sonde coûte 0,8 s et se paierait à chaque + # redémarrage d'un service en boucle. + res = self.banc.lancer() + self.assertEqual(0, res.returncode, res.stderr) + self.assertFalse(self.banc.sonde_appelee(), res.stderr) + + def test_zero_argument_without_a_terminal_injects_nothing(self): + res = self.banc.lancer() + self.assertIn("ARGS:", res.stdout) + self.assertNotIn("[-d]", res.stdout) + + def test_the_forwarded_line_is_byte_for_byte_what_it_always_was(self): + res = self.banc.lancer() + self.assertEqual( + "ARGS:[-c][./config.conf][--limit-time-real][99999]" + "[--limit-time-cpu][99999][--limit-memory-hard=0]", + res.stdout.strip(), + res.stderr, + ) + + def test_an_empty_argument_and_one_with_spaces_survive(self): + # « ./run.sh -d $(bd) » de make.robotlibre produit un -d vide dès + # que bd n'est pas défini : reconstruire la ligne le perdrait. + res = self.banc.lancer("--log-handler", "a b", "-d", "") + self.assertIn("[--log-handler][a b][-d][]", res.stdout, res.stderr) + + def test_the_test_mode_branch_behaves_the_same(self): + res = self.banc.lancer(env={"ODOO_MODE_TEST": "true"}) + self.assertIn("[--test-enable]", res.stdout, res.stderr) + self.assertNotIn("[-d]", res.stdout) + self.assertFalse(self.banc.sonde_appelee()) + + def test_the_test_mode_branch_also_receives_the_chosen_database(self): + # Deux branches lancent Odoo. Oublier l'une des deux ne se voit + # nulle part ailleurs : « make test » passerait sans base et l'on + # chercherait la cause dans Odoo. + banc = Banc(UNE_BASE, self.config) + self.addCleanup(banc.nettoyer) + res = banc.lancer("--auto-erplibre", env={"ODOO_MODE_TEST": "true"}) + self.assertIn("[--test-enable]", res.stdout, res.stderr) + self.assertIn("[-d][demo]", res.stdout, res.stderr) + + def test_a_database_already_named_disarms_everything(self): + # Les quatre formes qu'optparse accepte, plus « -d » nu — ce que + # produit « ./run.sh -d $(bd) » quand bd n'est pas défini. On ne + # complète jamais le choix de l'appelant, et l'on ne paie pas la + # sonde pour rien. + for forme in ( + ["-d", "demo"], + ["-ddemo"], + ["--database", "demo"], + ["--database=demo"], + ["-d"], + ): + with self.subTest(forme=" ".join(forme)): + banc = Banc(self.bouchon, self.config) + self.addCleanup(banc.nettoyer) + res = banc.lancer("--auto-erplibre", *forme) + self.assertFalse(banc.sonde_appelee(), res.stderr) + # Rien d'ajouté : la ligne se termine sur ce que l'appelant + # a écrit, sans « -d » de plus derrière. + attendu = "".join(f"[{arg}]" for arg in forme) + self.assertTrue( + res.stdout.strip().endswith(attendu), res.stdout + ) + + +class TestTheExitContractIsUnchanged(unittest.TestCase): + """run.sh convertit tout échec d'Odoo en 1, et laisse passer le succès. + + L'analyse des options a inséré du code ENTRE le lancement d'Odoo et le + `retVal=$?` de la fin. Des commentaires ne touchent pas `$?` — vérifié + — mais une ligne exécutable ajoutée là volerait le code de sortie sans + que rien ne le dise. + """ + + def banc_qui_sort(self, code): + b = Banc(TROIS_BASES) + self.addCleanup(b.nettoyer) + (b.chemin / "odoo_bin.sh").write_text( + f"#!/usr/bin/env bash\nexit {code}\n", encoding="utf-8" + ) + (b.chemin / "odoo_bin.sh").chmod(0o755) + return b + + def test_a_failing_odoo_becomes_one(self): + res = self.banc_qui_sort(7).lancer("--workers", "0") + self.assertEqual(1, res.returncode, res.stderr) + + def test_a_successful_odoo_stays_zero(self): + res = self.banc_qui_sort(0).lancer("--workers", "0") + self.assertEqual(0, res.returncode, res.stderr) + + +class TestTheTwoOptionsNeverReachOdoo(BancTest): + def test_both_are_stripped(self): + res = self.banc.lancer( + "--auto-erplibre", "--no-cli-erplibre", "--workers", "0" + ) + self.assertNotIn("auto-erplibre", res.stdout, res.stdout) + self.assertNotIn("no-cli-erplibre", res.stdout, res.stdout) + self.assertIn("[--workers][0]", res.stdout) + + def test_a_neighbouring_option_is_not_eaten(self): + # Une correspondance trop large avalerait des options d'Odoo. + for voisine in ( + "--auto-erplibr", + "--auto", + "--no-cli", + "--auto-erplibrex", + ): + with self.subTest(voisine=voisine): + banc = Banc(self.bouchon, self.config) + self.addCleanup(banc.nettoyer) + res = banc.lancer(voisine) + self.assertIn(f"[{voisine}]", res.stdout, res.stderr) + + def test_odoo_never_sees_them_even_when_they_do_something(self): + banc = Banc(UNE_BASE, self.config) + self.addCleanup(banc.nettoyer) + res = banc.lancer("--auto-erplibre") + self.assertIn("[-d][demo]", res.stdout, res.stderr) + self.assertNotIn("erplibre]", res.stdout) + + +class TestWhatTheProbeReturns(unittest.TestCase): + def banc(self, bouchon, config="[options]\ndb_name = False\n"): + b = Banc(bouchon, config) + self.addCleanup(b.nettoyer) + return b + + def test_a_single_database_is_taken_without_asking(self): + banc = self.banc(UNE_BASE) + res = banc.lancer("--auto-erplibre") + self.assertIn("[-d][demo]", res.stdout, res.stderr) + + def test_template_databases_are_not_candidates(self): + # `_cache_…` est une base-modèle. Sur une machine qui vient de + # tester, ce peut être la seule : sans filtre, la règle « une + # seule base » démarrerait Odoo sur un modèle. + banc = self.banc(QUE_DU_CACHE) + res = banc.lancer("--auto-erplibre") + self.assertNotIn("[-d]", res.stdout, res.stderr) + self.assertNotIn("_cache_", res.stdout) + self.assertEqual(0, res.returncode) + + def test_no_database_at_all_starts_anyway(self): + banc = self.banc(AUCUNE_BASE) + res = banc.lancer("--auto-erplibre") + self.assertEqual(0, res.returncode, res.stderr) + self.assertNotIn("[-d]", res.stdout) + self.assertIn("ARGS:", res.stdout) + + def test_a_warning_on_stderr_never_becomes_a_database_name(self): + # Une sonde qui RÉUSSIT en parlant sur stderr : c'est là que la + # fusion des flux se voit. Avec `2>&1`, l'avertissement devient + # une seconde « base », la règle « une seule » ne s'applique plus + # et l'on ouvre un menu pour un texte de journal. Le défaut exact + # que script/database/db_drop_all.py porte encore. + banc = self.banc(SONDE_BAVARDE) + res = banc.lancer("--auto-erplibre") + self.assertIn("[-d][demo]", res.stdout, res.stdout) + self.assertNotIn("WARNING", res.stdout, res.stdout) + + def test_a_failing_probe_never_becomes_a_database_name(self): + # Fusionner stdout et stderr ferait de chaque ligne d'une trace un + # nom de base — le défaut que database_manager.py a déjà corrigé. + banc = self.banc(SONDE_CASSEE) + res = banc.lancer("--auto-erplibre") + self.assertEqual(0, res.returncode, res.stderr) + self.assertNotIn("[-d]", res.stdout) + self.assertNotIn("Traceback", res.stdout) + self.assertNotIn("OperationalError", res.stdout) + # …et le message doit dire « je n'ai pas pu lire », pas « il n'y + # en a pas ». Les deux mènent au même démarrage, mais pas au même + # endroit où chercher : PostgreSQL à terre n'est pas une machine + # neuve. Le CLI d'Odoo confond déjà les deux en rendant 0 sur une + # liste vide ; au moins ne pas ajouter notre propre confusion là + # où le code de sortie, lui, sait. + self.assertIn("lister", res.stderr, res.stderr) + self.assertNotIn("Aucune base", res.stderr, res.stderr) + + def test_an_empty_list_says_something_else_than_a_failure(self): + banc = self.banc(AUCUNE_BASE) + res = banc.lancer("--auto-erplibre") + self.assertIn("Aucune base", res.stderr, res.stderr) + self.assertNotIn("lister", res.stderr, res.stderr) + + def test_a_configuration_that_names_its_database_is_left_alone(self): + # L'option de ligne de commande l'emporte sur le fichier : injecter + # écraserait en silence le choix d'une production. + banc = self.banc(UNE_BASE, "[options]\ndb_name = prod\n") + res = banc.lancer("--auto-erplibre") + self.assertFalse(banc.sonde_appelee(), res.stderr) + self.assertNotIn("[-d]", res.stdout) + + def test_db_name_false_is_not_a_name(self): + for valeur in ("False", "false", "None"): + with self.subTest(valeur=valeur): + banc = self.banc(UNE_BASE, f"[options]\ndb_name = {valeur}\n") + res = banc.lancer("--auto-erplibre") + self.assertIn("[-d][demo]", res.stdout, res.stderr) + + +class TestWhenSeveralAndNoWayToAsk(unittest.TestCase): + def banc(self, bouchon=TROIS_BASES): + b = Banc(bouchon) + self.addCleanup(b.nettoyer) + return b + + def test_no_cli_erplibre_picks_nothing_and_says_so(self): + banc = self.banc() + res = banc.lancer("--auto-erplibre", "--no-cli-erplibre") + self.assertEqual(0, res.returncode, res.stderr) + self.assertNotIn("[-d]", res.stdout) + self.assertIn("no-cli-erplibre", res.stderr, res.stderr) + + def test_no_terminal_picks_nothing_and_says_so(self): + banc = self.banc() + res = banc.lancer("--auto-erplibre") + self.assertEqual(0, res.returncode, res.stderr) + self.assertNotIn("[-d]", res.stdout) + self.assertIn("2", res.stderr, res.stderr) + + def test_no_cli_erplibre_still_takes_a_lone_database(self): + # L'option interdit le MENU, pas le choix. Sinon elle ferait + # doublon avec « ne pas écrire --auto-erplibre ». + banc = self.banc(UNE_BASE) + res = banc.lancer("--auto-erplibre", "--no-cli-erplibre") + self.assertIn("[-d][demo]", res.stdout, res.stderr) + + +class TestTheMenuBehindARealTerminal(unittest.TestCase): + """Un pseudo-terminal : seul moyen de rendre `-t 0` et `-t 2` vrais.""" + + def banc(self, bouchon=TROIS_BASES): + b = Banc(bouchon) + self.addCleanup(b.nettoyer) + return b + + def test_zero_argument_opens_the_menu_and_honours_the_choice(self): + code, texte = self.banc().conduire(frappe="2\n") + self.assertEqual(0, code, texte) + self.assertIn("[1] demo", texte) + self.assertIn("[2] prod", texte) + self.assertIn("[-d][prod]", texte) + + def test_the_template_database_is_absent_from_the_menu(self): + code, texte = self.banc().conduire(frappe="1\n") + self.assertNotIn("_cache_", texte, texte) + self.assertIn("[-d][demo]", texte) + + def test_an_invalid_answer_asks_again_rather_than_guessing(self): + code, texte = self.banc().conduire(frappe="9\nzzz\n1\n") + self.assertEqual(0, code, texte) + self.assertIn("[-d][demo]", texte) + self.assertGreaterEqual(texte.count("Choix invalide"), 2, texte) + + def test_cancelling_starts_nothing_and_returns_130(self): + # 130, pas 1 : run.sh réserve déjà 1 à « Odoo a échoué ». + code, texte = self.banc().conduire(frappe="0\n") + self.assertEqual(130, code, texte) + self.assertNotIn("ARGS:", texte, texte) + + def test_end_of_input_is_a_refusal_not_a_hang(self): + code, texte = self.banc().conduire(frappe="\x04") + self.assertEqual(130, code, texte) + self.assertNotIn("ARGS:", texte) + + def test_a_lone_database_asks_nothing_at_all(self): + code, texte = self.banc(UNE_BASE).conduire(frappe="") + self.assertEqual(0, code, texte) + self.assertNotIn("Choix", texte, texte) + self.assertIn("[-d][demo]", texte) + + def test_no_cli_erplibre_silences_the_menu_even_on_a_terminal(self): + code, texte = self.banc().conduire("--no-cli-erplibre", frappe="") + self.assertEqual(0, code, texte) + self.assertNotIn("Choix", texte, texte) + self.assertNotIn("[-d]", texte) + + +class TestTheLibraryOnItsOwn(unittest.TestCase): + """Les fonctions, hors de run.sh, en remplaçant le garde de terminal.""" + + def bash(self, corps, entree="", cwd=None): + return subprocess.run( + ["bash", "-c", f". {LIB}\n{corps}"], + capture_output=True, + text=True, + timeout=DELAI, + input=entree, + cwd=cwd or str(RACINE), + ) + + def test_it_recognises_every_way_of_naming_a_database(self): + for forme in ("-d x", "-dx", "--database x", "--database=x", "-d"): + with self.subTest(forme=forme): + res = self.bash(f"el_db_already_chosen {forme} && echo OUI") + self.assertIn("OUI", res.stdout, res.stderr) + + def test_it_does_not_mistake_a_neighbour_for_a_database(self): + for forme in ("--dev x", "--data-dir /tmp", "--db-filter x", "-u all"): + with self.subTest(forme=forme): + res = self.bash(f"el_db_already_chosen {forme} || echo NON") + self.assertIn("NON", res.stdout, res.stderr) + + def test_the_menu_writes_nothing_to_stdout_but_the_name(self): + # Le menu part sur stdout et il devient une partie du nom de base. + res = self.bash("el_db_choose alpha zebre", entree="2\n") + self.assertEqual("zebre", res.stdout.strip(), res.stdout) + self.assertIn("[1] alpha", res.stderr) + + def test_the_guard_tests_stdin_and_stderr_not_stdout(self): + # Dans `$( … )`, stdout est TOUJOURS un tube : tester -t 1 fermerait + # le menu même devant un vrai terminal. Mesuré, puis corrigé. + # + # Les commentaires sont retirés : ils PARLENT de « -t 1 », et un + # test qui les lit accuserait la documentation du défaut qu'elle + # explique. + texte = LIB.read_text(encoding="utf-8") + debut = texte.index("_el_db_tty()") + corps = "\n".join( + ligne + for ligne in texte[debut : texte.index("}", debut)].splitlines() + if not ligne.strip().startswith("#") + ) + self.assertIn("-t 0", corps) + self.assertIn("-t 2", corps) + self.assertNotIn("-t 1", corps) + + def test_the_guard_refuses_a_pipe_on_stderr(self): + # Le cas du TUI de todo.py : stdout et stderr en tube, stdin intact. + res = subprocess.run( + ["bash", "-c", f". {LIB}\n_el_db_tty && echo ARME || echo REPOS"], + capture_output=True, + text=True, + timeout=DELAI, + cwd=str(RACINE), + ) + self.assertIn("REPOS", res.stdout, res.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_todo_sshfs.py b/test/test_todo_sshfs.py new file mode 100644 index 0000000..934e109 --- /dev/null +++ b/test/test_todo_sshfs.py @@ -0,0 +1,389 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Montage sshfs : ne rien annoncer qui n'ait eu lieu, et dire pourquoi. + +Le symptôme rapporté : « read: Connection reset by peer », « code : 1 », et +juste après « Monté sur /tmp/sshfs_… », la commande pour démonter et celle +pour ouvrir le répertoire dans un explorateur. Le montage n'avait pas eu lieu. + +La cause, elle, est plus profonde : sshfs lit « a+b » comme un CHAÎNAGE +d'hôtes — « ssh a, puis ssh b depuis a » — et ne consulte donc jamais +~/.ssh/config pour l'alias entier. Or c'est todo.py qui nomme les VM +découvertes « rebond+domaine », et la seconde moitié de ce nom est un domaine +libvirt, pas un alias SSH du rebond. Ces alias-là, les plus utiles, étaient +les seuls que sshfs ne pouvait pas monter. + +Ce que ces tests gardent : + +- Aucune ligne de succès après un code non nul. +- Le point de montage inutilisé est retiré, pas laissé dans /tmp. +- Un alias à « + » est résolu par ssh lui-même (« ssh -G ») et rendu à sshfs + sous une forme qu'il ne peut plus mal lire, options comprises — sans + StrictHostKeyChecking, une VM à l'IP recyclée échouerait sur sa clé d'hôte. +- « Host a b » déclare DEUX alias : c'est ce que le générateur du dépôt écrit. +""" + +import contextlib +import io +import os +import subprocess +import sys +import tempfile +import unittest +from unittest import mock + +sys.argv = ["todo.py"] +from script.todo.todo import TODO # noqa: E402 +from script.todo.todo_i18n import t # noqa: E402 + +# Entrée réelle écrite par todo.py pour une VM derrière un rebond. +CONFIG = """Host * + ServerAliveInterval 60 + +Host novipro_private + HostName 192.168.100.110 + User mathben + +Host novipro_private+ERPLibre01 + HostName 192.168.122.50 + User mathben + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + IdentityFile /home/erplibre/.ssh/id_ed25519 + IdentitiesOnly yes + ProxyJump novipro_private + +Host erplibre-ubuntu-2604 erplibre-2604-bis + HostName 192.168.123.165 + User erplibre + +Host web-? + User www +""" + +# Sortie de « ssh -G » pour l'alias à « + », réduite à ce qui compte. +SSH_G = """host novipro_private+erplibre01 +hostname 192.168.122.50 +user mathben +port 22 +proxyjump novipro_private +identityfile /home/erplibre/.ssh/id_ed25519 +identityfile ~/.ssh/id_rsa +identitiesonly yes +stricthostkeychecking false +userknownhostsfile /dev/null +""" + + +def _config(texte=CONFIG): + tmp = tempfile.NamedTemporaryFile( + "w", suffix=".config", delete=False, encoding="utf-8" + ) + tmp.write(texte) + tmp.close() + return tmp.name + + +class TestLectureConfig(unittest.TestCase): + def setUp(self): + self.chemin = _config() + self.addCleanup(os.unlink, self.chemin) + + def test_it_reads_hosts_in_file_order(self): + hosts = TODO._ssh_config_entries(self.chemin) + noms = [n for n, _i in hosts] + self.assertEqual("novipro_private", noms[0]) + self.assertIn("novipro_private+ERPLibre01", noms) + + def test_a_host_line_with_two_patterns_gives_two_aliases(self): + """C'est ce que le générateur du dépôt écrit (« Host {' '.join(names)} »). + Les prendre pour un seul nom donnait l'alias « a b », que sshfs ne peut + pas monter — et qui n'existe pour personne.""" + hosts = dict(TODO._ssh_config_entries(self.chemin)) + self.assertIn("erplibre-ubuntu-2604", hosts) + self.assertIn("erplibre-2604-bis", hosts) + self.assertEqual( + "192.168.123.165", hosts["erplibre-ubuntu-2604"]["hostname"] + ) + self.assertEqual( + hosts["erplibre-ubuntu-2604"], hosts["erplibre-2604-bis"] + ) + + def test_wildcard_patterns_are_left_out(self): + """« Host * » et « Host web-? » ne désignent aucune machine : les + proposer dans un menu de montage n'a pas de sens.""" + noms = [n for n, _i in TODO._ssh_config_entries(self.chemin)] + self.assertNotIn("*", noms) + self.assertNotIn("web-?", noms) + + def test_the_plus_alias_stays_one_name(self): + noms = [n for n, _i in TODO._ssh_config_entries(self.chemin)] + self.assertNotIn("novipro_private", noms[1:2] and []) + self.assertIn("novipro_private+ERPLibre01", noms) + + def test_hostname_and_user_are_kept(self): + hosts = dict(TODO._ssh_config_entries(self.chemin)) + info = hosts["novipro_private+ERPLibre01"] + self.assertEqual("192.168.122.50", info["hostname"]) + self.assertEqual("mathben", info["user"]) + + def test_a_missing_file_is_not_a_crash(self): + self.assertEqual([], TODO._ssh_config_entries("/nexistepas/config")) + + +class TestResolution(unittest.TestCase): + def test_it_reads_ssh_dash_g(self): + with mock.patch( + "subprocess.run", + return_value=subprocess.CompletedProcess([], 0, SSH_G, ""), + ): + cfg = TODO._ssh_resolve("novipro_private+ERPLibre01") + self.assertEqual("192.168.122.50", cfg["hostname"]) + self.assertEqual("novipro_private", cfg["proxyjump"]) + + def test_the_first_identityfile_wins(self): + """ssh -G les répète toutes ; la première est celle qu'il essaiera.""" + with mock.patch( + "subprocess.run", + return_value=subprocess.CompletedProcess([], 0, SSH_G, ""), + ): + cfg = TODO._ssh_resolve("x") + self.assertEqual("/home/erplibre/.ssh/id_ed25519", cfg["identityfile"]) + + def test_a_failing_ssh_resolves_to_nothing(self): + with mock.patch( + "subprocess.run", + return_value=subprocess.CompletedProcess([], 255, "", "bad"), + ): + self.assertEqual({}, TODO._ssh_resolve("x")) + with mock.patch("subprocess.run", side_effect=OSError): + self.assertEqual({}, TODO._ssh_resolve("x")) + + +class TestCommandeSshfs(unittest.TestCase): + def setUp(self): + # Méthode d'instance : elle interroge self._ssh_resolve quand aucune + # résolution ne lui est fournie. + self.todo = TODO.__new__(TODO) + self.todo._ssh_resolve = lambda alias: {} + + def test_an_alias_without_plus_is_handed_over_untouched(self): + """Sans « + », c'est ssh qui lit la config, et rien ne vaut mieux.""" + cmd, contourne = self.todo._sshfs_command("vm-a", "/tmp/mnt") + self.assertEqual("sshfs -o follow_symlinks vm-a:/ /tmp/mnt", cmd) + self.assertFalse(contourne) + + def _resolue(self): + return { + "hostname": "192.168.122.50", + "user": "mathben", + "port": "22", + "proxyjump": "novipro_private", + "identityfile": "/home/erplibre/.ssh/id_ed25519", + "identitiesonly": "yes", + "stricthostkeychecking": "false", + "userknownhostsfile": "/dev/null", + } + + def test_a_plus_alias_becomes_a_resolved_target(self): + cmd, contourne = self.todo._sshfs_command( + "novipro_private+ERPLibre01", "/tmp/mnt", self._resolue() + ) + self.assertTrue(contourne) + self.assertIn("mathben@192.168.122.50:/", cmd) + self.assertNotIn("+", cmd) + + def test_the_options_that_matter_travel_with_it(self): + """Sans ProxyJump, la VM est injoignable ; sans StrictHostKeyChecking, + une IP DHCP recyclée fait échouer le montage sur sa clé d'hôte.""" + cmd, _ = self.todo._sshfs_command("a+b", "/tmp/mnt", self._resolue()) + for attendu in ( + "-o ProxyJump=novipro_private", + "-o Port=22", + "-o IdentityFile=/home/erplibre/.ssh/id_ed25519", + "-o IdentitiesOnly=yes", + "-o StrictHostKeyChecking=false", + "-o UserKnownHostsFile=/dev/null", + ): + self.assertIn(attendu, cmd) + + def test_options_ssh_reports_as_none_are_not_forwarded(self): + """« ssh -G » écrit « proxyjump none » quand il n'y en a pas ; le + transmettre ferait échouer ssh sur une valeur qu'il vient d'inventer. + """ + cfg = dict(self._resolue(), proxyjump="none") + cmd, _ = self.todo._sshfs_command("a+b", "/tmp/mnt", cfg) + self.assertNotIn("ProxyJump", cmd) + + def test_an_unresolvable_alias_is_left_alone(self): + """Mieux vaut la commande d'origine, qui échouera en le disant, qu'une + cible inventée qui monterait la mauvaise machine.""" + cmd, contourne = self.todo._sshfs_command("a+b", "/tmp/mnt", {}) + self.assertEqual("sshfs -o follow_symlinks a+b:/ /tmp/mnt", cmd) + self.assertFalse(contourne) + + def test_the_mount_point_stays_last(self): + """La syntaxe de sshfs : cible puis point de montage. Une option glissée + après monterait ailleurs.""" + cmd, _ = self.todo._sshfs_command("a+b", "/tmp/mnt", self._resolue()) + self.assertTrue(cmd.endswith(" /tmp/mnt"), cmd) + + +class TestDiagnostic(unittest.TestCase): + def test_each_kind_of_failure_names_its_culprit(self): + cas = ( + ( + "ssh: Could not resolve hostname zz: Name or service not known", + "unknown host name: check HostName", + ), + ( + "ssh: connect to host x port 22: Connection timed out", + "no answer: is the server up and reachable?", + ), + ( + "ssh: connect to host x port 22: No route to host", + "no route: check the network or the ProxyJump", + ), + ( + "ssh: connect to host x port 22: Connection refused", + "nothing listening on the SSH port", + ), + ( + "mathben@x: Permission denied (publickey).", + "authentication refused: check User and key", + ), + ( + "Host key verification failed.", + "host key changed for this address", + ), + ) + for stderr, attendu in cas: + self.assertEqual(attendu, TODO._ssh_failure_hint(stderr), stderr) + + def test_an_unknown_error_is_not_guessed(self): + """Rien à dire plutôt qu'un diagnostic inventé : la ligne brute de ssh + sera affichée telle quelle.""" + self.assertEqual("", TODO._ssh_failure_hint("sshfs: fuse: bidule")) + self.assertEqual("", TODO._ssh_failure_hint("")) + + +class TestFlux(unittest.TestCase): + """Le parcours complet, exécuteur bouchonné.""" + + def _todo(self, code, probe=(0, "")): + todo = TODO.__new__(TODO) + todo.lances = [] + exe = mock.Mock() + + def lance(cmd, **kw): + todo.lances.append(cmd) + return code + + exe.exec_command_live = lance + todo.execute = exe + todo._ssh_probe = lambda alias, timeout=8: probe + todo._ssh_resolve = lambda alias: { + "hostname": "192.168.122.50", + "user": "mathben", + "proxyjump": "novipro_private", + } + return todo + + def _joue(self, todo, config, selection="2"): + chemin = _config(config) + self.addCleanup(os.unlink, chemin) + reponses = iter(["2", selection]) + crees = [] + vrai_makedirs = os.makedirs + vrai_rmdir = os.rmdir + + def makedirs(path, **kw): + crees.append(path) + return vrai_makedirs(path, **kw) + + retires = [] + + def rmdir(path): + retires.append(path) + return vrai_rmdir(path) + + out = io.StringIO() + with mock.patch( + "builtins.input", lambda *a: next(reponses, "") + ), mock.patch( + "os.path.expanduser", + lambda p: chemin if p.endswith("config") else p, + ), mock.patch( + "os.makedirs", makedirs + ), mock.patch( + "os.rmdir", rmdir + ): + with contextlib.redirect_stdout(out): + todo._configure_sshfs() + return out.getvalue(), crees, retires + + def test_a_failed_mount_announces_nothing_mounted(self): + """Le cœur du problème rapporté : « Monté sur … » après un code 1.""" + todo = self._todo(1) + sortie, _crees, _retires = self._joue(todo, CONFIG, "2") + self.assertNotIn(t("Mounted on: "), sortie) + self.assertNotIn("fusermount", sortie) + self.assertNotIn("nautilus", sortie) + self.assertIn(t("sshfs mount failed."), sortie) + + def test_a_failed_mount_leaves_no_empty_directory(self): + """Une tentative par jour pendant un mois laissait trente répertoires + vides dans /tmp.""" + todo = self._todo(1) + _s, crees, retires = self._joue(todo, CONFIG, "2") + self.assertEqual(crees, retires) + for chemin in retires: + self.assertFalse(os.path.exists(chemin)) + + def test_a_successful_mount_says_how_to_unmount(self): + todo = self._todo(0) + sortie, crees, retires = self._joue(todo, CONFIG, "2") + self.assertIn(t("Mounted on: "), sortie) + self.assertIn("fusermount -u", sortie) + self.assertEqual([], retires) + for chemin in crees: + self.addCleanup(lambda p=chemin: os.path.isdir(p) and os.rmdir(p)) + + def test_ssh_working_points_at_the_plus_not_at_the_network(self): + """Quand ssh joint l'hôte, ce n'est pas le réseau : c'est sshfs qui a + mal lu l'alias. Le message doit envoyer là, et donner la commande.""" + todo = self._todo(1, probe=(0, "")) + # L'alias à « + » est le troisième de la config (après le rebond seul). + sortie, _c, _r = self._joue(todo, CONFIG, "2") + self.assertIn( + t("SSH reaches this host: ~/.ssh/config is fine."), sortie + ) + + def test_ssh_failing_sends_to_the_config_or_the_server(self): + todo = self._todo( + 1, + probe=( + 255, + "ssh: connect to host x port 22: Connection timed out", + ), + ) + sortie, _c, _r = self._joue(todo, CONFIG, "2") + self.assertIn(t("no answer: is the server up and reachable?"), sortie) + self.assertIn( + t("Update ~/.ssh/config, or check the server is up."), sortie + ) + + def test_the_plus_alias_is_bypassed_before_being_run(self): + """Le vrai correctif : la commande lancée ne contient plus le « + ».""" + todo = self._todo(0) + # [1] novipro_private · [2] novipro_private+ERPLibre01 · [3] la VM + _s, _c, _r = self._joue(todo, CONFIG, "2") + lancee = todo.lances[0] + self.assertIn("mathben@192.168.122.50:/", lancee) + self.assertIn("-o ProxyJump=novipro_private", lancee) + self.assertNotIn("+ERPLibre01", lancee) + + +if __name__ == "__main__": + unittest.main(verbosity=1) diff --git a/test/test_uninstall_module_list.py b/test/test_uninstall_module_list.py new file mode 100644 index 0000000..8c7a4ca --- /dev/null +++ b/test/test_uninstall_module_list.py @@ -0,0 +1,267 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Les modules qu'un palier doit retirer AVANT de monter. + +Certaines incompatibilités n'existent qu'à partir d'une version donnée. +`muk_web_theme` n'excluait que `web_enterprise` en 16 et en 17 ; la 18 y +ajoute `web_responsive`. Les deux cohabitaient donc légalement, et la +base arrive en 18 dans un état que la 18 interdit — le chargement meurt +dès qu'un module auto_install est installé, car Odoo revérifie alors +toutes les exclusions. + +Le retrait doit se faire pendant qu'on est ENCORE sur l'ancienne +version, là où l'état est légal et où l'ORM fonctionne. +""" + +import ast +import io +import os +import sys +import unittest + +sys.path.append( + os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) +) + +from script.todo.todo_upgrade import TodoUpgrade # noqa: E402 + +RACINE = os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) +DOSSIER = os.path.join(RACINE, "script", "odoo", "migration") + + +def lecteur(): + return TodoUpgrade.__new__(TodoUpgrade) + + +class TestTheSeventeenToEighteenList(unittest.TestCase): + def test_the_file_exists_where_the_driver_looks(self): + chemin = os.path.join( + DOSSIER, "uninstall_module_list_odoo170_to_odoo180.txt" + ) + self.assertTrue(os.path.isfile(chemin), chemin) + + def test_it_names_web_responsive(self): + modules, _detail = lecteur().read_uninstall_module_list(17, "peu") + self.assertIn("web_responsive", modules) + + def test_the_removal_is_justified(self): + # Sans justification, on retrouve un module retiré des mois plus + # tard sans pouvoir dire pourquoi, ni s'il faut le remettre. + _m, detail = lecteur().read_uninstall_module_list(17, "peu") + raisons = {module: raison for module, raison, _f in detail} + self.assertTrue(raisons.get("web_responsive"), raisons) + + def test_muk_web_theme_is_NOT_removed_here(self): + # Les deux rendent le même service : il faut en garder un, et + # c'est le thème qui porte l'apparence du back-office. + modules, _d = lecteur().read_uninstall_module_list(17, "peu") + self.assertNotIn("muk_web_theme", modules) + + +class TestEveryListIsWellFormed(unittest.TestCase): + def fichiers(self): + import glob + + return sorted( + glob.glob(os.path.join(DOSSIER, "uninstall_module_list_*.txt")) + ) + + def test_there_is_at_least_one(self): + # Sans cette borne, le test suivant passerait en ne vérifiant + # rien le jour où le motif de nom change. + self.assertGreater(len(self.fichiers()), 0) + + def test_every_entry_is_justified(self): + for chemin in self.fichiers(): + for module, raison in TodoUpgrade.parse_module_list_file(chemin): + self.assertTrue( + raison, + f"{os.path.basename(chemin)} : {module} sans raison", + ) + + def test_no_entry_looks_like_a_stray_comment(self): + # Le parseur coupe à « # » : une ligne mal écrite produirait un + # nom de module fantôme, retiré en silence de rien du tout. + for chemin in self.fichiers(): + for module, _r in TodoUpgrade.parse_module_list_file(chemin): + self.assertRegex(module, r"^[a-z][a-z0-9_]*$", module) + + def test_the_name_encodes_the_bump_it_serves(self): + import re + + for chemin in self.fichiers(): + nom = os.path.basename(chemin) + trouve = re.match( + r"uninstall_module_list_odoo(\d+)_to_odoo(\d+)\.txt$", nom + ) + self.assertIsNotNone(trouve, nom) + depart, arrivee = (int(x) for x in trouve.groups()) + self.assertEqual(arrivee, depart + 10, nom) + + +class TestWhenItRuns(unittest.TestCase): + def test_the_uninstall_precedes_the_openupgrade_run(self): + # Retirer un module APRÈS la montée serait trop tard : c'est la + # montée elle-même qui refuse l'état. + with io.open( + os.path.join(RACINE, "script", "todo", "todo_upgrade.py"), + encoding="utf-8", + ) as handle: + src = handle.read() + self.assertLess( + src.index("- Uninstall module"), src.index("- Migrate database") + ) + + def test_a_private_list_wins_over_the_shared_one(self): + # Une base peut avoir ses propres retraits sans qu'on touche à la + # liste partagée de tout le monde. + with io.open( + os.path.join(RACINE, "script", "todo", "todo_upgrade.py"), + encoding="utf-8", + ) as handle: + source = handle.read() + debut = source.index("def read_uninstall_module_list") + fin = source.index("def split_present_missing") + bloc = source[debut:fin] + self.assertLess( + bloc.index("PATH_MIGRATION_PRIVATE"), + bloc.index("PATH_MIGRATION_GLOBAL"), + ) + + +class TestTheListFollowsTheClone(unittest.TestCase): + """Un clone refait doit rejouer la liste de son palier. + + Deux endroits bâtissent la base intermédiaire : l'étape « Uninstall + module », et « Choose delete missing module » qui la jette et la + refait depuis la version précédente. Le second ne rejouait que les + modules choisis là. Mesuré sur test_neutralize_upgrade_18 : + web_responsive retiré au rang 218, clone refait au rang 230, et il + était revenu — la 18 a refusé de charger sur l'exclusion de + muk_web_theme, alors que la désinstallation avait réussi. + """ + + def source(self): + with io.open( + os.path.join(RACINE, "script", "todo", "todo_upgrade.py"), + encoding="utf-8", + ) as handle: + return handle.read() + + def fonction(self): + import ast + + for noeud in ast.walk(ast.parse(self.source())): + if ( + isinstance(noeud, ast.FunctionDef) + and noeud.name == "execute_odoo_upgrade" + ): + return noeud + return None + + @staticmethod + def _appelle(noeud, methode): + return ( + isinstance(noeud, ast.Call) + and isinstance(noeud.func, ast.Attribute) + and noeud.func.attr == methode + ) + + def appels_uninstall(self): + import ast + + return [ + n + for n in ast.walk(self.fonction()) + if self._appelle(n, "uninstall_from_database") + ] + + def noms_venant_de_la_liste(self): + """Les variables affectées depuis `uninstall_list_for(...)`.""" + import ast + + noms = set() + for n in ast.walk(self.fonction()): + if isinstance(n, ast.Assign) and self._appelle( + n.value, "uninstall_list_for" + ): + for cible in n.targets: + if isinstance(cible, ast.Name): + noms.add(cible.id) + return noms + + def test_there_is_more_than_one_place_that_uninstalls(self): + # Sans cette borne, le test suivant passerait le jour où un des + # deux chemins disparaît — ou n'existe plus sous cette forme. + self.assertGreaterEqual(len(self.appels_uninstall()), 2) + + def test_every_uninstall_goes_through_the_shared_list(self): + import ast + + noms = self.noms_venant_de_la_liste() + self.assertTrue(noms, "aucune variable ne vient de uninstall_list_for") + for appel in self.appels_uninstall(): + premier = appel.args[0] if appel.args else None + direct = self._appelle(premier, "uninstall_list_for") + indirect = isinstance(premier, ast.Name) and premier.id in noms + self.assertTrue( + direct or indirect, + "un chemin désinstalle sans rejouer la liste du palier :" + f" ligne {appel.lineno}", + ) + + +class TestUninstallListFor(unittest.TestCase): + class Faux(TodoUpgrade): + def __init__(self, fichier=()): + self.fichier = list(fichier) + self.affiche = [] + + def read_uninstall_module_list(self, depart, database_name): + self.depart = depart + return list(self.fichier), [ + (nom, "raison", "f") for nom in self.fichier + ] + + def print_uninstall_reason(self, detail): + self.affiche.append(detail) + + def test_it_reads_the_file_of_the_step_being_left(self): + # Monter vers 18 lit le fichier 17 → 18, pas 18 → 19. + faux = self.Faux(["web_responsive"]) + faux.uninstall_list_for(18, "une_base") + self.assertEqual(faux.depart, 17) + + def test_the_chosen_modules_come_first(self): + # L'ordre compte : ce que la personne vient de choisir se lit en + # tête de la ligne de commande qui suit. + faux = self.Faux(["duFichier"]) + self.assertEqual( + faux.uninstall_list_for(18, "b", ["choisi"]), + ["choisi", "duFichier"], + ) + + def test_a_module_named_twice_is_uninstalled_once(self): + faux = self.Faux(["commun"]) + self.assertEqual( + faux.uninstall_list_for(18, "b", ["commun", "commun"]), + ["commun"], + ) + + def test_it_says_why_each_one_goes(self): + faux = self.Faux(["web_responsive"]) + faux.uninstall_list_for(18, "b") + self.assertTrue(faux.affiche) + + def test_it_stays_quiet_when_the_step_has_no_list(self): + faux = self.Faux([]) + self.assertEqual( + faux.uninstall_list_for(18, "b", ["choisi"]), ["choisi"] + ) + self.assertEqual(faux.affiche, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_uninstall_verified.py b/test/test_uninstall_verified.py new file mode 100644 index 0000000..95a7fc2 --- /dev/null +++ b/test/test_uninstall_verified.py @@ -0,0 +1,353 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Une désinstallation se MESURE, elle ne se suppose pas. + +`odoo-bin --uninstall` ne cherche que l'état « installed ». Un module resté +en « to remove » d'une tentative précédente est ignoré en silence, et Odoo +sort en 0. Le pilote tenait ce 0 pour une réussite : c'est ainsi que +muk_web_theme a traversé quatre paliers de 12 → 18 en étant réputé retiré, +alors qu'il était « installed » de la 15 à la 18. +""" + +import ast +import io +import os +import sys +import unittest + +sys.path.append( + os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) +) + +from script.todo.todo_upgrade import TodoUpgrade # noqa: E402 + +RACINE = os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) +SOURCE = os.path.join(RACINE, "script", "todo", "todo_upgrade.py") + + +class FauxPilote(TodoUpgrade): + """Un pilote qui n'exécute rien, mais respecte le contrat de retour.""" + + def __init__(self, survivants=(), lisible=True): + self.survivants = list(survivants) + self.lisible = lisible + self.commandes = [] + self.commentaires = [] + self.dct_progression = {} + self.dct_module_per_version = {} + + def todo_upgrade_execute(self, cmd, **kwargs): + self.commandes.append(cmd) + if kwargs.get("get_output"): + # Le vrai rend TROIS valeurs quand on demande la sortie, et un + # statut NON nul veut dire « raté ». + if not self.lisible: + return 1, cmd, None + return 0, cmd, list(self.survivants) + return 0, cmd + + def write_config(self): + pass + + def add_comment_progression(self, msg): + self.commentaires.append(msg) + + def split_present_missing(self, lst): + return list(lst), [] + + +class TestStillInstalled(unittest.TestCase): + def test_it_names_what_survived(self): + pilote = FauxPilote(survivants=["muk_web_theme"]) + self.assertEqual( + pilote.still_installed("db", ["muk_web_theme", "web_responsive"]), + ["muk_web_theme"], + ) + + def test_an_unreadable_database_says_UNKNOWN_not_empty(self): + # Rendre [] serait affirmer « tout est parti » sans l'avoir lu : + # exactement le défaut qu'on corrige. + pilote = FauxPilote(lisible=False) + self.assertIsNone(pilote.still_installed("db", ["web_responsive"])) + + def test_nothing_to_check_asks_the_database_nothing(self): + pilote = FauxPilote() + self.assertEqual(pilote.still_installed("db", []), []) + self.assertEqual(pilote.commandes, []) + + def test_it_counts_to_remove_as_still_there(self): + # « to remove » n'est pas « uninstalled » : c'est justement l'état + # que --uninstall refuse de traiter, donc celui qu'il faut voir. + pilote = FauxPilote() + pilote.still_installed("db", ["web_responsive"]) + self.assertIn("state <> 'uninstalled'", pilote.commandes[0]) + + def test_the_module_names_are_quoted_for_sql(self): + pilote = FauxPilote() + pilote.still_installed("db", ["web_responsive"]) + self.assertIn("'web_responsive'", pilote.commandes[0]) + + +class TestTheBookkeepingTellsTheTruth(unittest.TestCase): + def pilote(self, survivants): + pilote = FauxPilote(survivants=survivants) + pilote.dct_module_per_version = { + 17: ["web_responsive", "muk_web_theme"] + } + return pilote + + def test_a_module_left_in_place_stays_counted_as_installed(self): + pilote = self.pilote(["web_responsive"]) + pilote.uninstall_from_database(["web_responsive"], "db", 17) + self.assertIn("web_responsive", pilote.dct_module_per_version[17]) + + def test_a_module_really_gone_is_dropped(self): + pilote = self.pilote([]) + pilote.uninstall_from_database(["web_responsive"], "db", 17) + self.assertNotIn("web_responsive", pilote.dct_module_per_version[17]) + # …et sans emporter le voisin au passage. + self.assertIn("muk_web_theme", pilote.dct_module_per_version[17]) + + def test_the_survivor_is_recorded_where_someone_will_read_it(self): + pilote = self.pilote(["web_responsive"]) + pilote.uninstall_from_database(["web_responsive"], "db", 17) + trace = " ".join(pilote.commentaires) + self.assertIn("still installed", trace) + self.assertIn("web_responsive", trace) + + def test_an_unreadable_database_does_not_crash_the_migration(self): + # « je ne sais pas » revient en None : le traiter comme une liste + # ferait tomber la migration sur un TypeError, six heures après le + # départ, pour un renseignement qui n'était que confortable. + pilote = FauxPilote(lisible=False) + pilote.dct_module_per_version = {17: ["web_responsive"]} + pilote.uninstall_from_database(["web_responsive"], "db", 17) + self.assertEqual(pilote.dct_module_per_version[17], []) + + def test_a_silent_success_leaves_no_alarm(self): + pilote = self.pilote([]) + pilote.uninstall_from_database(["web_responsive"], "db", 17) + self.assertEqual( + [c for c in pilote.commentaires if "still installed" in c], [] + ) + + +class TestNoStepWritesAnotherStepsFlag(unittest.TestCase): + """Chaque drapeau `state_4_*` ne doit porter QUE sa propre liste. + + Trois étapes rangeaient leurs drapeaux sous + `state_4_module_migrate_odoo_lst`. Sans effet dans la course en cours — + la locale est lue une fois, au début — mais à la REPRISE cette clé est + relue comme « OpenUpgrade est passé », et la migration du palier est + sautée. Un test structurel se justifie ici : conduire une reprise + complète coûterait des heures, et la faute est visible dans l'écriture. + """ + + def assignations(self): + with io.open(SOURCE, encoding="utf-8") as handle: + arbre = ast.parse(handle.read()) + vues = {} + for noeud in ast.walk(arbre): + if not isinstance(noeud, ast.Assign): + continue + for cible in noeud.targets: + if not ( + isinstance(cible, ast.Subscript) + and isinstance(cible.value, ast.Attribute) + and cible.value.attr == "dct_progression" + and isinstance(cible.slice, ast.Constant) + and str(cible.slice.value).startswith("state_4_") + ): + continue + if isinstance(noeud.value, ast.Name): + vues.setdefault(cible.slice.value, set()).add( + noeud.value.id + ) + return vues + + def test_the_scan_actually_finds_something(self): + # Sans cette borne, le test suivant passerait sur un dictionnaire + # vide le jour où la forme de l'écriture change. + self.assertGreater(len(self.assignations()), 2) + + def test_each_flag_is_written_from_one_list_only(self): + for cle, noms in sorted(self.assignations().items()): + self.assertEqual( + len(noms), 1, f"{cle} écrit depuis {sorted(noms)}" + ) + + def test_the_migrate_flag_comes_from_the_migrate_list(self): + self.assertEqual( + self.assignations().get("state_4_module_migrate_odoo_lst"), + {"lst_module_migrate_odoo"}, + ) + + +class TestAFailedOpenUpgradeStaysUnrecorded(unittest.TestCase): + """Un OpenUpgrade raté ne doit pas passer pour fait. + + `lst_upgrade_odoo` n'est pas une copie : `dct_progression.get()` rend + l'objet stocké. L'affecter avant l'exécution le faisait persister au + premier `write_config()` venu — celui du chemin d'échec compris, qui + remet pourtant le drapeau de clonage à zéro pour forcer un nouvel + essai. La reprise sautait alors OpenUpgrade et laissait une base 17 + tourner sous le code 18. Mesuré sur test_neutralize_upgrade_18 : + base = 17.0.1.3, et sa commande de migration déjà consignée. + + Conduire `execute_odoo_upgrade` en vrai demanderait une migration + complète ; la faute est un ORDRE dans le source, et c'est l'ordre + qu'on mesure. + """ + + def arbre(self): + with io.open(SOURCE, encoding="utf-8") as handle: + return ast.parse(handle.read()) + + def lignes_affectation(self): + lignes = [] + for noeud in ast.walk(self.arbre()): + if not isinstance(noeud, ast.Assign): + continue + for cible in noeud.targets: + if ( + isinstance(cible, ast.Subscript) + and isinstance(cible.value, ast.Name) + and cible.value.id == "lst_upgrade_odoo" + ): + lignes.append(noeud.lineno) + return lignes + + @staticmethod + def _remet_le_clone_a_zero(noeud): + """Ce bloc renonce-t-il en redemandant un clonage neuf ? + + Le repère est l'affectation `lst_clone_odoo[index] = False` : c'est + elle qui distingue « je renonce, refais le clone » de l'étape de + clonage elle-même, qui écrit `= True` et vit ailleurs. Chercher les + seuls NOMS attrapait les deux, et l'ancre tombait 700 lignes trop + haut — le test passait alors sur n'importe quel ordre. + """ + for petit in ast.walk(noeud): + if not isinstance(petit, ast.Assign): + continue + if not ( + isinstance(petit.value, ast.Constant) + and petit.value.value is False + ): + continue + for cible in petit.targets: + if ( + isinstance(cible, ast.Subscript) + and isinstance(cible.value, ast.Name) + and cible.value.id == "lst_clone_odoo" + ): + return True + return False + + def ligne_abandon(self): + """Le `return` qui renonce après un OpenUpgrade raté.""" + lignes = [ + max(n.lineno for n in ast.walk(noeud) if isinstance(n, ast.Return)) + for noeud in ast.walk(self.arbre()) + if isinstance(noeud, ast.If) + and self._remet_le_clone_a_zero(noeud) + and any(isinstance(n, ast.Return) for n in ast.walk(noeud)) + ] + return min(lignes) if lignes else None + + def test_both_anchors_are_found(self): + # Sans cette borne, les tests suivants passeraient à vide le jour + # où l'une des deux formes change. + self.assertTrue(self.lignes_affectation()) + self.assertIsNotNone(self.ligne_abandon()) + + def test_the_step_is_recorded_only_after_the_failure_path_gave_up(self): + abandon = self.ligne_abandon() + for ligne in self.lignes_affectation(): + self.assertGreater( + ligne, + abandon, + "lst_upgrade_odoo est marqué fait avant que l'échec ait" + " pu renoncer : la reprise sautera OpenUpgrade", + ) + + def test_it_is_recorded_exactly_once(self): + # Deux affectations, et l'une repasserait devant l'échec. + self.assertEqual(len(self.lignes_affectation()), 1) + + +class TestDiscardingTheCloneDiscardsItsPreparation(unittest.TestCase): + """Rebâtir le clone annule tout ce qu'on lui avait fait. + + Quand OpenUpgrade échoue, le pilote remet le drapeau de clonage à zéro + pour que la base intermédiaire soit refaite depuis la version + précédente. Mais les drapeaux des étapes qui avaient préparé CE + clone — le SQL de pré-migration, les désinstallations, les + installations — restaient debout. La base neuve repartait donc sans + sa préparation, et OpenUpgrade retombait sur le problème même que le + SQL existe pour écarter. + """ + + PAR_CLONE = ( + "lst_fix_migration_odoo", + "lst_module_uninstall_module", + "lst_module_install_module", + ) + + def bloc_abandon(self): + with io.open(SOURCE, encoding="utf-8") as handle: + arbre = ast.parse(handle.read()) + for noeud in ast.walk(arbre): + if isinstance( + noeud, ast.If + ) and TestAFailedOpenUpgradeStaysUnrecorded._remet_le_clone_a_zero( + noeud + ): + return noeud + return None + + def remis_a_zero(self): + """Les listes que ce bloc remet à leur valeur vide.""" + noms = set() + for petit in ast.walk(self.bloc_abandon()): + if not isinstance(petit, ast.Assign): + continue + if not ( + isinstance(petit.value, ast.Constant) + and petit.value.value is False + ) and not ( + isinstance(petit.value, ast.List) and not petit.value.elts + ): + continue + for cible in petit.targets: + if isinstance(cible, ast.Subscript) and isinstance( + cible.value, ast.Name + ): + noms.add(cible.value.id) + return noms + + def test_the_failure_block_is_found(self): + self.assertIsNotNone(self.bloc_abandon()) + + def test_every_per_clone_flag_is_reset(self): + remis = self.remis_a_zero() + for nom in self.PAR_CLONE: + self.assertIn(nom, remis, f"{nom} survit à son clone") + + def test_each_reset_is_persisted(self): + # Remettre la liste à zéro sans l'écrire ne survit pas au + # processus : c'est la reprise qui relit le fichier. + corps = ast.dump(self.bloc_abandon()) + for cle in ( + "state_4_fix_migration_odoo_lst", + "state_4_uninstall_module", + "state_4_install_module", + ): + self.assertIn(cle, corps) + self.assertIn("write_config", corps) + + +if __name__ == "__main__": + unittest.main()