Merge branch 'todo-audit-analyses'

[FIX] proxmox : l'audit, la bonne machine, et deux analyses

Seize commits dont la moitié vient d'un audit, pas de l'usage — six défauts
trouvés en relisant, et quatre écrans qui parlaient d'une machine locale
alors qu'ils pilotaient une machine distante. Même famille de faute :
l'installation partait sur la mauvaise machine, et le disque annoncé était
celui de l'hôte.

Le guide de connexion manquait à toute VM distante depuis le début : on
savait la déployer sans savoir y entrer.

Deux analyses arrivent : l'état d'une instance, lu pour l'usage qu'on en
fait, et l'auscultation d'une base qui n'est pas ici. Une troisième dit
désormais CE QUI est parti avec une pièce jointe, au lieu de compter.

Un socle commun décrit le système invité, là où chaque formulaire le
redécrivait.

--- EN ---

Sixteen commits, half of them from an audit rather than from use — six
defects found by reading, and four screens that spoke of a local machine
while driving a remote one. The same family of fault: the install went to
the wrong machine, and the disk it reported was the host's.

The connection guide had been missing from every remote VM from the start:
we knew how to deploy one without knowing how to get in.

Two analyses arrive: the state of an instance, read for what it is used
for, and the examination of a database that is not here. A third now says
WHAT left with an attachment, instead of counting.

One shared base describes the guest system, where each form used to
describe it again.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-08-25 03:28:56 -04:00
commit 687e8c614b
38 changed files with 6270 additions and 613 deletions

View file

@ -189,6 +189,20 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
- Le tableau de bord d'installation affiche la RAM de l'hôte, la RAM utilisée et l'uptime d'une VM, et depuis combien de temps un journal est muet
- Une VM accueille sa connexion SSH avec les commandes propres à sa distribution
<!-- [en] -->
## Fixed
<!-- [fr] -->
## Corrigé
<!-- [en] -->
- The mobile bundle check accepted only the pack layout, when a real build ships one tar.gz per repository. It failed on `<slug> : index.json absent` and stopped `compile_and_run.sh` before the APK — since 2026-08-20, for anyone on the current mobile main. It now accepts both layouts, and proves the presence of EVERY promised file rather than a sample of twenty: streaming all 139 archives costs 6 s, and 124 350 files are accounted for
- The bundle test guarded the ZIP entry limit by demanding a `chunk` field on every file, which is the pack layout rather than the limit itself. It now counts the entries the APK will carry — 278 against a ceiling of 65 535 — so either layout passes and a return to file-per-source still fails
<!-- [fr] -->
- Le vérificateur du transfert mobile n'acceptait que la disposition en packs, quand une compilation réelle livre un tar.gz par dépôt. Il échouait sur `<slug> : index.json absent` et arrêtait `compile_and_run.sh` avant l'APK — depuis le 2026-08-20, pour quiconque est sur le main mobile actuel. Il accepte désormais les deux dispositions, et prouve la présence de CHAQUE fichier promis plutôt qu'un échantillon de vingt : traverser les 139 archives coûte 6 s, et 124 350 fichiers sont comptés
- Le test du bundle gardait la limite d'entrées du ZIP en exigeant un champ `chunk` sur chaque fichier, c'est-à-dire la disposition en packs plutôt que la limite elle-même. Il compte maintenant les entrées que portera l'APK — 278 pour un plafond de 65 535 — si bien que les deux dispositions passent et qu'un retour au fichier-par-source échoue toujours
<!-- [en] -->
## Removed
<!-- [fr] -->

View file

@ -89,6 +89,11 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
- Le tableau de bord d'installation affiche la RAM de l'hôte, la RAM utilisée et l'uptime d'une VM, et depuis combien de temps un journal est muet
- Une VM accueille sa connexion SSH avec les commandes propres à sa distribution
## Corrigé
- Le vérificateur du transfert mobile n'acceptait que la disposition en packs, quand une compilation réelle livre un tar.gz par dépôt. Il échouait sur `<slug> : index.json absent` et arrêtait `compile_and_run.sh` avant l'APK — depuis le 2026-08-20, pour quiconque est sur le main mobile actuel. Il accepte désormais les deux dispositions, et prouve la présence de CHAQUE fichier promis plutôt qu'un échantillon de vingt : traverser les 139 archives coûte 6 s, et 124 350 fichiers sont comptés
- Le test du bundle gardait la limite d'entrées du ZIP en exigeant un champ `chunk` sur chaque fichier, c'est-à-dire la disposition en packs plutôt que la limite elle-même. Il compte maintenant les entrées que portera l'APK — 278 pour un plafond de 65 535 — si bien que les deux dispositions passent et qu'un retour au fichier-par-source échoue toujours
## Retiré
- Le support d'Ubuntu 20.04 et 22.04, sur toutes les architectures : pikepdf réclame qpdf 12.2, dont la compilation exige C++20, quand focal livre GCC 9 et ne publie pas de `g++-10` pour s390x

View file

@ -87,6 +87,11 @@ Recreating the virtual environment, use installation guide from tool `make`.
- The install dashboard shows the host RAM, a VM's used RAM and uptime, and how long a log has been silent
- A VM greets its SSH login with the distribution's own commands
## Fixed
- The mobile bundle check accepted only the pack layout, when a real build ships one tar.gz per repository. It failed on `<slug> : index.json absent` and stopped `compile_and_run.sh` before the APK — since 2026-08-20, for anyone on the current mobile main. It now accepts both layouts, and proves the presence of EVERY promised file rather than a sample of twenty: streaming all 139 archives costs 6 s, and 124 350 files are accounted for
- The bundle test guarded the ZIP entry limit by demanding a `chunk` field on every file, which is the pack layout rather than the limit itself. It now counts the entries the APK will carry — 278 against a ceiling of 65 535 — so either layout passes and a return to file-per-source still fails
## Removed
- Ubuntu 20.04 and 22.04 support, on every architecture: pikepdf needs qpdf 12.2, whose build requires C++20, while focal ships GCC 9 and publishes no `g++-10` for s390x

View file

@ -14,11 +14,14 @@ npm run build || exit 1
# Le transfert des dépôts du manifeste DANS l'application est ce qui fait
# l'intérêt de son navigateur de code hors ligne, et il peut être vide sans que
# la compilation le dise. Ces dépôts entrent en packs — un APK est un ZIP borné
# à 65535 entrées, quand un fichier par source en réclamait 123 678 — et le
# vérificateur relit un échantillon depuis les tranches pour le comparer à la
# source. Trois pannes qu'un « build OK » passe sous silence : transfert vide,
# tranche absente, index qui promet des octets que sa tranche n'a pas.
# la compilation le dise. Ces dépôts entrent dans des conteneurs — un APK est
# un ZIP borné à 65535 entrées, quand un fichier par source en réclamait
# 124 350 —
# soit une archive tar.gz par dépôt, soit des tranches pack. Le vérificateur
# accepte les deux, prouve la présence de CHAQUE fichier promis, et relit un
# échantillon octet pour octet contre la source. Quatre pannes qu'un
# « build OK » passe sous silence : transfert vide, conteneur absent, index qui
# promet un fichier que son conteneur n'a pas, octets qui diffèrent.
#
# Même vérification que l'installation d'une VM, même script : une seule
# autorité.

View file

@ -0,0 +1,376 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Dans quel état est cette instance — et pour quel usage on la destine.
Le même chiffre veut dire deux choses opposées. Zéro cron actif est le
SUCCÈS attendu d'une copie de développement, et une panne totale sur une
production. Zéro serveur de courriel rassure sur l'une, condamne l'autre.
Un rapport qui ignore cette différence crie au loup sur ce que l'on vient
de demander, et l'on cesse alors de le lire — c'est la faute que ce dépôt
a déjà corrigée trois fois.
L'attente est donc DÉCLARÉE, `--expect copy` ou `--expect live`, et
chaque contrôle dit ce qu'il juge sous l'une et sous l'autre. Ce qui n'a
pas de sens sous l'attente courante n'est pas affiché en vert : il est
affiché comme non jugé, avec la raison.
Mesuré, et c'est ce qui a fixé la conception
--------------------------------------------
Sur la base 12 d'ORIGINE et sur sa migrée 18, jamais démarrées :
crons en retard de plus d'un cycle 11 et 8
lignes db_backup 0 et 0
Le retard n'a rien à voir avec la migration : personne ne fait tourner le
cadenceur d'une base restaurée. Sous `--expect copy`, ces deux contrôles
ne sont donc pas jugés du tout. Les afficher en rouge aurait été du bruit
pur ; les afficher en vert, un mensonge.
Ce qui se juge SOUS LES DEUX
----------------------------
La neutralisation, elle, se mesure. Sur sept bases dont le nom portait
« neutralize », `database.is_neutralized` était absent des sept, avec
jusqu'à 35 crons actifs et le domaine de courriel du CLIENT en place.
On ne lit que des BOOLÉENS de présence pour tout ce qui touche à un
secret. Une clé de paiement vivante a été trouvée dans une base de test ;
un rapport finit dans un billet ou devant un agent, et n'a aucune raison
de la porter.
Lecture seule : `default_transaction_read_only=on`, imposé par le serveur.
"""
from __future__ import annotations
import os
import sys
sys.path.append(
os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
)
try:
from script.todo.todo_i18n import t
except Exception: # pragma: no cover - repli si i18n indisponible
def t(key: str) -> str:
return key
from script.analyse import lib_analyse # noqa: E402
COPY = "copy"
LIVE = "live"
ATTENTES = (COPY, LIVE)
COULEURS = {
"broken": "\033[31m",
"watch": "\033[33m",
"ok": "\033[32m",
"dim": "\033[90m",
}
RESET = "\033[0m"
def paint(texte, genre, colour):
if not colour:
return texte
return f"{COULEURS.get(genre, '')}{texte}{RESET}"
# Une politique dit COMMENT lire le nombre sous une attente donnée :
# ("zero", gravité) il doit valoir zéro
# ("nonzero", gravité) il doit être non nul
# ("info",) on montre, on ne juge pas
# ("skip", raison) sans objet ici, et l'on DIT pourquoi
CONTROLES = (
{
"key": "neutralized",
"section": "Neutralisation",
"title": "database.is_neutralized",
"sql": "SELECT count(*) FROM ir_config_parameter"
" WHERE key='database.is_neutralized'"
" AND value IN ('true','True','1')",
"copy": ("nonzero", "broken"),
"live": ("zero", "broken"),
"why_copy": "Odoo does not consider this database neutralised;"
" modules that neutralise themselves never ran.",
"why_live": "A production marked as neutralised has had its"
" scheduled actions and outgoing mail disabled.",
},
{
"key": "mail_server_open",
"section": "Neutralisation",
"title": "Mail servers that could actually send",
"sql": "SELECT count(*) FROM ir_mail_server WHERE active"
" AND coalesce(smtp_host,'') NOT IN ('invalid','localhost.invalid')",
"copy": ("zero", "broken"),
"live": ("info",),
"why_copy": "A copy that can send reaches the customer's real"
" contacts.",
"why_live": "",
},
{
"key": "mail_server_total",
"section": "Neutralisation",
"title": "Mail servers declared at all",
"sql": "SELECT count(*) FROM ir_mail_server",
"copy": ("nonzero", "watch"),
"live": ("nonzero", "watch"),
"why_copy": "With NO server, Odoo falls back to smtp_server from"
" the config file — which is why Odoo's own neutralize.sql"
" INSERTS a blocking one instead of deleting them all.",
"why_live": "With no server at all, Odoo silently uses the"
" smtp_server from the config file.",
},
{
"key": "payment_live",
"section": "Neutralisation",
"title": "Payment providers neither disabled nor in test",
"sql": "SELECT count(*) FROM payment_provider"
" WHERE state NOT IN ('disabled','test')",
"copy": ("zero", "broken"),
"live": ("info",),
"why_copy": "A copy can charge real cards.",
"why_live": "",
},
{
"key": "url_mismatch",
"section": "Neutralisation",
"title": "Mail domain that does not match the base URL",
"sql": "SELECT CASE WHEN EXISTS (SELECT 1 FROM ir_config_parameter c"
" JOIN ir_config_parameter u ON u.key='web.base.url'"
" WHERE c.key='mail.catchall.domain'"
" AND position(c.value in u.value) = 0) THEN 1 ELSE 0 END",
"copy": ("info",),
"live": ("zero", "watch"),
"why_copy": "",
"why_live": "Portal links, invoice QR codes and reply addresses"
" point somewhere else than the instance itself.",
},
{
"key": "cron_active",
"section": "Scheduler",
"title": "Scheduled actions active",
"sql": "SELECT count(*) FROM ir_cron WHERE active",
"copy": ("zero", "watch"),
"live": ("nonzero", "broken"),
"why_copy": "A copy that still runs them sends mail and posts"
" payments.",
"why_live": "Nothing at all runs on a schedule.",
},
{
"key": "cron_late",
"section": "Scheduler",
"title": "Scheduled actions late by more than one cycle",
"sql": "SELECT count(*) FROM ir_cron c WHERE c.active"
" AND c.nextcall <"
" now() - (c.interval_number || ' ' || c.interval_type)::interval",
"copy": (
"skip",
"Nobody runs the scheduler on a restored copy:"
" measured 11 late on an untouched source database.",
),
"live": ("zero", "broken"),
"why_copy": "",
"why_live": "A fixed threshold misses them — a ten-minute job one"
" hour late matters, a monthly one does not.",
},
{
"key": "backup_rows",
"section": "Backups",
"title": "Backup configurations recorded",
"sql": "SELECT count(*) FROM db_backup",
"copy": (
"skip",
"update_prod_to_dev deletes them on purpose;"
" their absence here proves nothing.",
),
"live": ("nonzero", "broken"),
"why_copy": "",
"why_live": "The backup scheduler runs and backs up nothing.",
},
{
"key": "mail_stuck",
"section": "Queues",
"title": "Messages stuck in the outgoing queue",
"sql": "SELECT count(*) FROM mail_mail"
" WHERE state IN ('outgoing','exception')",
"copy": ("info",),
"live": ("zero", "watch"),
"why_copy": "",
"why_live": "Visible to the customer, invisible to the operator.",
},
{
"key": "all_internal_are_admin",
"section": "Users",
"title": "Internal users who are ALL system administrators",
"sql": "SELECT CASE WHEN (SELECT count(*) FROM res_users u"
" WHERE u.active AND EXISTS (SELECT 1 FROM res_groups_users_rel r"
" JOIN ir_model_data d ON d.model='res.groups' AND d.res_id=r.gid"
" WHERE r.uid=u.id AND d.module='base' AND d.name='group_user'))"
" = (SELECT count(*) FROM res_users u WHERE u.active"
" AND EXISTS (SELECT 1 FROM res_groups_users_rel r"
" JOIN ir_model_data d ON d.model='res.groups' AND d.res_id=r.gid"
" WHERE r.uid=u.id AND d.module='base' AND d.name='group_system'))"
" THEN 1 ELSE 0 END",
"copy": ("zero", "watch"),
"live": ("zero", "watch"),
"why_copy": "No ordinary user exists to test visibility with — any"
" access-rights check run here proves nothing.",
"why_live": "Everyone can change everything, and no rule is ever"
" exercised.",
},
)
SECTIONS = ("Neutralisation", "Scheduler", "Backups", "Queues", "Users")
def inspect(database, config_path=None):
"""Passer chaque contrôle. Un contrôle illisible n'est PAS un zéro."""
resultats = {}
for controle in CONTROLES:
try:
brut = lib_analyse.run_psql(
database, controle["sql"], config_path=config_path
).strip()
resultats[controle["key"]] = int(brut.splitlines()[0])
except Exception as exc: # noqa: BLE001 - on rapporte, on ne meurt pas
resultats[controle["key"]] = {
"error": str(exc).splitlines()[0][:120]
}
return resultats
def verdict(controle, valeur, attente):
"""(genre, gravité, raison) pour ce contrôle sous cette attente.
`genre` vaut ok, bad, info, skip ou unreadable — jamais autre chose,
et surtout jamais « ok » par défaut : un contrôle sans politique est un
oubli, pas une bonne nouvelle.
"""
if isinstance(valeur, dict):
return "unreadable", "watch", valeur.get("error", "")
politique = controle.get(attente)
if not politique:
return "skip", "dim", ""
genre = politique[0]
if genre == "skip":
return "skip", "dim", t(politique[1])
if genre == "info":
return "info", "dim", ""
raison = t(controle.get(f"why_{attente}", "") or "")
if genre == "zero":
return (
("ok", "ok", "") if not valeur else ("bad", politique[1], raison)
)
if genre == "nonzero":
return ("ok", "ok", "") if valeur else ("bad", politique[1], raison)
return "skip", "dim", ""
def findings(resultats, attente):
"""Ce qui ne va pas, et rien d'autre — pour le code de sortie."""
mauvais = []
for controle in CONTROLES:
genre, gravite, raison = verdict(
controle, resultats.get(controle["key"]), attente
)
if genre == "bad":
mauvais.append((controle, gravite, raison))
return mauvais
def render(database, resultats, attente, colour=True):
"""Le rapport, section par section, dans l'ordre déclaré."""
entete = (
t("a development copy") if attente == COPY else t("a live instance")
)
lignes = [
f"🩺 {t('State of')} {database} — {t('read as')} {entete}",
"",
]
for section in SECTIONS:
corps = []
for controle in CONTROLES:
if controle["section"] != section:
continue
valeur = resultats.get(controle["key"])
genre, gravite, raison = verdict(controle, valeur, attente)
nombre = "?" if isinstance(valeur, dict) else str(valeur)
icone = {
"ok": "✅",
"bad": "❌" if gravite == "broken" else "⚠",
"info": "ℹ️ ",
"skip": "·",
"unreadable": "❔",
}[genre]
corps.append(
paint(
f" {icone} {nombre.rjust(5)} {t(controle['title'])}",
gravite if genre in ("bad", "ok") else "dim",
colour,
)
)
if raison:
corps.append(paint(f" {raison}", "dim", colour))
if corps:
lignes.append(t(section))
lignes.extend(corps)
lignes.append("")
return "\n".join(lignes).rstrip()
def main(argv=None):
import argparse
import json
parser = argparse.ArgumentParser(
description=t("What state an instance is in, for its intended use."),
)
parser.add_argument("-d", "--database", required=True)
parser.add_argument("-c", "--config", help="odoo config file")
parser.add_argument(
"--expect",
choices=ATTENTES,
default=COPY,
help="copy: a restored development copy. live: a running instance.",
)
parser.add_argument("--json", action="store_true")
parser.add_argument("--no-color", action="store_true")
args = parser.parse_args(argv)
try:
lib_analyse.require_odoo_database(
args.database, config_path=args.config
)
except Exception as exc: # noqa: BLE001
print(f"❌ {exc}", file=sys.stderr)
return 2
resultats = inspect(args.database, args.config)
mauvais = findings(resultats, args.expect)
if args.json:
print(
json.dumps(
{
"database": args.database,
"expect": args.expect,
"checks": resultats,
"bad": [c["key"] for c, _, _ in mauvais],
},
indent=2,
ensure_ascii=False,
)
)
else:
colour = sys.stdout.isatty() and not args.no_color
print(render(args.database, resultats, args.expect, colour))
return 1 if mauvais else 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -245,6 +245,32 @@ def inspect(database):
"SELECT model || '.' || name FROM ir_model_fields WHERE store",
)
etat["field_stored"] = sorted(ligne[0] for ligne in stockes or [])
# Le CHAMP PORTEUR de chaque pièce jointe, et s'il existe encore.
#
# Une pièce jointe dont `res_field` ne nomme aucun champ vivant est
# DÉJÀ illisible : Odoo lève un KeyError en la contrôlant. Ce ne sont
# pas des données, ce sont des débris. Mesuré sur une chaîne 12 → 18 :
# la dette naît aux paliers 13 et 14, reste gelée pendant trois
# paliers, et la 18 ramasse 452 lignes d'un coup — qui se lisent
# alors comme 452 pertes.
#
# Ni le nom ni le mimetype ici : `run_psql` découpe par LIGNE et un
# nom de fichier peut en contenir une.
portees = run_psql(
database,
"SELECT a.id, coalesce(a.res_model, ''), coalesce(a.res_field, ''),"
" coalesce(a.res_id::text, ''),"
" CASE WHEN a.res_field IS NULL OR a.res_field = '' THEN '1'"
" WHEN EXISTS (SELECT 1 FROM ir_model_fields f"
" WHERE f.model = a.res_model AND f.name = a.res_field)"
" THEN '1' ELSE '0' END"
" FROM ir_attachment a",
)
etat["attachment_row"] = {
ligne[0]: (ligne[1], ligne[2], ligne[3], ligne[4] == "1")
for ligne in portees or []
if len(ligne) >= 5 and ligne[0].isdigit()
}
# Les copies COW par leur CLÉ : c'est elle qu'on réinitialise, et
# c'est par elle qu'on les retrouve d'une version à l'autre.
copies = run_psql(
@ -710,6 +736,65 @@ def overlay_declared(
}
def render_attachment_kind(connu, colour):
"""Le détail par CAUSE d'une perte de pièces jointes.
Le rapport ne doit jamais dire « pièce jointe perdue » sans dire CE
QUI est parti avec elle : un champ déjà mort ne fait perdre rien
qu'un utilisateur ait pu voir, et cela se range en teinte calme. Le
seul cas rouge est « le champ est toujours là ».
"""
from script.todo.migration_status import paint
lignes = []
for cle, libelle, teinte in ATTACHMENT_KIND:
combien = (connu.get("buckets") or {}).get(cle) or 0
if not combien:
continue
lignes.append(
f" {paint(str(combien).rjust(5), teinte, colour)}"
f" {t(libelle)}"
)
return lignes
def classify_attachments(avant, apres):
"""Pourquoi chaque pièce jointe partie est partie. None si on ne sait.
Python pur, sur ce que `inspect` a déjà lu : aucune requête de plus.
L'ORDRE des tests compte — éprouvé dans l'autre sens, les 452 du
palier 18 se rangeaient à tort en « champ retiré à ce palier ».
"""
lignes_avant = avant.get("attachment_row")
lignes_apres = apres.get("attachment_row")
if not lignes_avant or lignes_apres is None:
return None
modeles_apres = set(apres.get("model") or [])
seaux = {cle: [] for cle, _l, _t in ATTACHMENT_KIND}
for identifiant, (modele, champ, _res, vivant) in lignes_avant.items():
if identifiant in lignes_apres:
continue
apres_vivant = None
for autre in lignes_apres.values():
if autre[0] == modele and autre[1] == champ:
apres_vivant = autre[3]
break
# L'ORDRE porte le sens. « Le champ était déjà mort » d'abord :
# c'est la raison la plus forte, et celle des 452 lignes du
# palier 18. « Le modèle a quitté la base » ensuite — plus forte
# que « le champ n'apparaît plus après », qui ne se déduit que
# d'une absence et serait vraie de toute façon.
if not vivant:
seaux["field_debt"].append(identifiant)
elif modele and modele not in modeles_apres:
seaux["model_gone"].append(identifiant)
elif apres_vivant is False or (champ and apres_vivant is None):
seaux["field_dropped"].append(identifiant)
else:
seaux["undeclared"].append(identifiant)
return seaux
def explain_loss(table, version):
"""Ce qu'Odoo a fait de cette table à cette version, ou None.
@ -761,6 +846,31 @@ def compare(avant, apres):
else None
)
lignes_perdues[index] = (table, debut, fin, {**connu, "gained": recue})
# `ir_attachment` n'a pas d'entrée dans SEMANTIC_MAP et n'en aura
# pas : la cause n'est pas la table, ce sont ces lignes-là. On la
# DÉDUIT, dans le même vocabulaire, et l'on garde en rouge le seul
# cas qui compte — une pièce jointe dont le champ est toujours là.
seaux = classify_attachments(avant, apres)
if seaux:
for index, (table, debut, fin, connu) in enumerate(lignes_perdues):
if table != "ir_attachment" or connu:
continue
lignes_perdues[index] = (
table,
debut,
fin,
{
"into": None,
"kind": "pruned",
"why": "attachments of fields and records already gone",
"gained": None,
"buckets": {
cle: len(seaux.get(cle) or [])
for cle, _l, _t in ATTACHMENT_KIND
},
"residue": len(seaux.get("undeclared") or []),
},
)
champ_avant = set(avant.get("field") or [])
champ_apres = set(apres.get("field") or [])
cow_avant = set(avant.get("cow") or [])
@ -891,6 +1001,20 @@ DECLARE_MODELES = (
# au palier 14 → 15, pour zéro octet.
SANS_DONNEE_PROPRE = ("id",)
# Pourquoi une pièce jointe a disparu — DÉDUIT, jamais déclaré.
#
# SEMANTIC_MAP ne peut pas porter ceci : elle nomme une TABLE, et la
# cause n'est pas la table, ce sont ces lignes-là. Mesuré sur une chaîne
# 12 → 18 : des 516 lignes parties au palier 18, 452 avaient perdu leur
# champ et 63 leur enregistrement. Une entrée « ir_attachment / pruned »
# aurait rangé les 516 sous « perte attendue » — et la 517e avec.
ATTACHMENT_KIND = (
("field_debt", "their field was already gone before this step", "dim"),
("field_dropped", "their field was removed at this step", "dim"),
("model_gone", "their model left the database", "dim"),
("undeclared", "the field is STILL there — look at these", "warn"),
)
DECLARE_CHAMPS = (
("del", "declared removed", "dim"),
("moved", "moved to another module", "dim"),
@ -1125,7 +1249,8 @@ def render_compare(diff, colour, limit=8):
else:
ou = t("retired from the database")
lignes.append(f" {table:<40} {avant:>8} → {apres} {ou}")
lignes.append(f" {connu['why']}")
lignes.append(f" {t(connu['why'])}")
lignes.extend(render_attachment_kind(connu, colour))
if len(connues) > limit:
lignes.append(f" … {len(connues) - limit} {t('more')}")
lignes.extend(render_declared(diff.get("declared") or {}, colour, limit))

View file

@ -0,0 +1,331 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Ce qu'une migration a laissé derrière elle, lu dans UNE seule base.
`check_migration_quality` répond à une autre question : il compare les
bases de PALIER qu'une migration locale a laissées, et il lui faut le
journal de progression. Devant la sauvegarde d'un client, ni l'une ni
l'autre n'existe. Il fallait donc un outil qui n'ait besoin que de la
base qu'on a sous la main.
Le piège, et pourquoi la moitié des contrôles évidents ont été écartés
--------------------------------------------------------------------
Un compteur absolu ne prouve rien. Mesuré sur une chaîne 12 → 18 réelle,
en comparant la base d'ORIGINE à la migrée :
champs stockés sans colonne 25 → 72 ← 25 AVANT toute migration
modèles sans table 90 → 158 ← 90 AVANT
contraintes orphelines 155 → 530 ← 155 AVANT
Ces trois-là paraissent accablants et ne le sont pas : un modèle abstrait
n'a jamais de table, un champ hérité n'a jamais sa colonne à lui. Affichés
bruts, ils font peur pour rien — et un rapport qui fait peur pour rien
finit par être ignoré en entier.
Ne restent ici que les constats qui se jugent SANS point de comparaison,
parce qu'ils sont faux en eux-mêmes. Les mêmes bases, mêmes mesures :
res_lang.active à NULL 0 → 9 un booléen NULL est un bug
ir_model_relation sans table 0 → 68 la table m2m est nommée,
elle n'existe pas
index doublés convention 17 0 → 414 Odoo 17 renomme, sans
supprimer l'ancien
liste de prix par défaut absente 0 → 1 `product` installé, son
xmlid pas là
Zéro avant, non nul après : aucun de ces quatre ne peut s'expliquer
autrement que par la migration.
Chaque constat nomme l'outil qui le répare. Un rapport qui montre un
dégât sans dire quoi lancer oblige à chercher, et on ne cherche pas.
Lecture seule : `default_transaction_read_only=on`, imposé par le serveur.
"""
from __future__ import annotations
import os
import sys
sys.path.append(
os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
)
try:
from script.todo.todo_i18n import t
except Exception: # pragma: no cover - repli si i18n indisponible
def t(key: str) -> str:
return key
from script.analyse import lib_analyse # noqa: E402
COULEURS = {
"broken": "\033[31m",
"watch": "\033[33m",
"ok": "\033[32m",
"dim": "\033[90m",
}
RESET = "\033[0m"
def paint(texte, genre, colour):
"""Teinter, ou rendre le texte tel quel quand la couleur est coupée."""
if not colour:
return texte
return f"{COULEURS.get(genre, '')}{texte}{RESET}"
# `sql` doit rendre UN nombre. `repair` nomme l'outil qui corrige, ou None
# quand il n'y en a pas encore — le dire vaut mieux que de laisser croire.
CONTROLES = (
{
"key": "stuck_modules",
"title": "Modules stuck between two states",
"why": "A migration that stopped mid-flight leaves them there;"
" Odoo will retry the transition at every start.",
"sql": "SELECT count(*) FROM ir_module_module"
" WHERE state IN ('to install','to upgrade','to remove')",
"gravity": "broken",
"repair": "script/todo/todo_upgrade.py (uninstall_one_by_one)",
},
{
"key": "lang_active_null",
"title": "Languages whose active flag is NULL",
"why": "A boolean that is neither true nor false: the language is"
" listed nowhere and cannot be re-enabled from the interface.",
"sql": "SELECT count(*) FROM res_lang WHERE active IS NULL",
"gravity": "broken",
"repair": None,
},
{
"key": "orphan_relation",
"title": "Many-to-many tables named but missing",
"why": "ir_model_relation still names a table PostgreSQL does not"
" have; the next module update tries to alter it and fails.",
"sql": "SELECT count(*) FROM ir_model_relation r WHERE NOT EXISTS"
" (SELECT 1 FROM pg_class c WHERE c.relname = r.name)",
"gravity": "broken",
"repair": "script/analyse/database_cleanup.py",
},
{
"key": "duplicate_index",
"title": "Indexes duplicated by the Odoo 17 renaming",
"why": "Odoo 17 changed the naming convention without dropping the"
" old index: both are maintained on every write. This count is a"
" cheap signal — the repair tool compares columns and"
" uniqueness, and is the one to trust.",
"sql": "SELECT count(*) FROM pg_indexes a WHERE a.schemaname='public'"
" AND a.indexname ~ '__[a-z0-9_]+_index$'"
" AND EXISTS (SELECT 1 FROM pg_indexes b WHERE b.schemaname='public'"
" AND b.tablename = a.tablename"
" AND b.indexname = replace(a.indexname, '__', '_'))",
"gravity": "watch",
"repair": "script/odoo/migration/fix_duplicate_index.py --apply",
},
{
"key": "missing_pricelist",
"title": "Default pricelist missing while product is installed",
"why": "product.list0 was declared up to Odoo 16 only; nothing"
" recreates it, and a quotation has no price list to pick.",
"sql": "SELECT CASE WHEN EXISTS (SELECT 1 FROM ir_module_module"
" WHERE name='product' AND state='installed')"
" AND NOT EXISTS (SELECT 1 FROM ir_model_data"
" WHERE module='product' AND name='list0')"
" THEN 1 ELSE 0 END",
"gravity": "broken",
"repair": "script/odoo/migration/restore_config_defaults.py --apply",
},
{
"key": "view_model_gone",
"title": "Views bound to a model that no longer exists",
"why": "Opening one raises; the menu that leads to it is a dead end.",
"sql": "SELECT count(*) FROM ir_ui_view v"
" WHERE v.model IS NOT NULL AND v.model <> ''"
" AND NOT EXISTS (SELECT 1 FROM ir_model m WHERE m.model = v.model)",
"gravity": "broken",
"repair": None,
},
{
"key": "view_parent_gone",
"title": "Views inheriting a view that is gone",
"why": "The whole inheritance chain below them stops rendering.",
"sql": "SELECT count(*) FROM ir_ui_view v WHERE v.inherit_id IS NOT"
" NULL AND NOT EXISTS"
" (SELECT 1 FROM ir_ui_view p WHERE p.id = v.inherit_id)",
"gravity": "broken",
"repair": "script/odoo/migration/fix_cow_render.py --apply",
},
{
"key": "xmlid_model_gone",
"title": "External ids pointing at a model that is gone",
"why": "Every module update that resolves one of them fails.",
"sql": "SELECT count(*) FROM ir_model_data d WHERE NOT EXISTS"
" (SELECT 1 FROM ir_model m WHERE m.model = d.model)",
"gravity": "broken",
"repair": "script/analyse/database_cleanup.py",
},
{
"key": "attachment_field_gone",
"title": "Attachments whose carrying field is gone",
"why": "Odoo raises a KeyError merely checking them, and nothing"
" will ever read them again.",
"sql": "SELECT count(*) FROM ir_attachment a"
" WHERE a.res_field IS NOT NULL AND a.res_field <> ''"
" AND NOT EXISTS (SELECT 1 FROM ir_model_fields f"
" WHERE f.model = a.res_model AND f.name = a.res_field)",
"gravity": "watch",
"repair": "script/analyse/check_filestore.py",
},
)
def inspect(database, config_path=None):
"""Passer chaque contrôle, et rendre son nombre.
Un contrôle qui ÉCHOUE n'est pas un contrôle qui rend zéro : une table
absente parce que le module n'est pas installé n'est pas un dégât. On
garde l'erreur telle quelle, et le rendu la distingue.
"""
resultats = {}
for controle in CONTROLES:
try:
brut = lib_analyse.run_psql(
database, controle["sql"], config_path=config_path
).strip()
resultats[controle["key"]] = int(brut.splitlines()[0])
except Exception as exc: # noqa: BLE001 - on rapporte, on ne meurt pas
resultats[controle["key"]] = {
"error": str(exc).splitlines()[0][:120]
}
return resultats
def judge(resultats):
"""Ne garder que ce qui compte, le plus grave d'abord.
Un contrôle illisible remonte AVEC les autres : ne pas avoir pu
regarder n'est pas la même chose que n'avoir rien trouvé, et taire la
différence est exactement ce qui fait prendre un rapport pour une
garantie.
"""
trouve, illisibles = [], []
for controle in CONTROLES:
valeur = resultats.get(controle["key"])
if isinstance(valeur, dict):
illisibles.append((controle, valeur.get("error", "")))
elif valeur:
trouve.append((controle, valeur))
trouve.sort(key=lambda pair: (pair[0]["gravity"] != "broken", -pair[1]))
return trouve, illisibles
def render(database, resultats, version=None, colour=True):
"""Le rapport lisible. Chaque constat dit quoi lancer pour le réparer."""
trouve, illisibles = judge(resultats)
lignes = [f"🚚 {t('Migration residue in')} {database}"]
if version:
lignes.append(f" {t('base module version:')} {version}")
lignes.append("")
if not trouve and not illisibles:
lignes.append(
paint(
f"✅ {t('None of the checks found anything.')}", "ok", colour
)
)
lignes.append(
paint(
f" {t('This reads one database on its own — it cannot see')}"
f" {t('what an earlier step silently dropped.')}",
"dim",
colour,
)
)
return "\n".join(lignes)
for controle, combien in trouve:
icone = "❌" if controle["gravity"] == "broken" else "⚠"
lignes.append(
paint(
f"{icone} {str(combien).rjust(6)} {t(controle['title'])}",
controle["gravity"],
colour,
)
)
lignes.append(paint(f" {t(controle['why'])}", "dim", colour))
if controle["repair"]:
lignes.append(f" → {controle['repair']}")
else:
lignes.append(
paint(f" → {t('no repair tool yet')}", "dim", colour)
)
lignes.append("")
for controle, erreur in illisibles:
lignes.append(
paint(
f"❔ {t(controle['title'])} — {t('could not read')}",
"watch",
colour,
)
)
lignes.append(paint(f" {erreur}", "dim", colour))
return "\n".join(lignes).rstrip()
def main(argv=None):
import argparse
import json
parser = argparse.ArgumentParser(
description=t("What a migration left behind, read in one database."),
)
parser.add_argument("-d", "--database", required=True)
parser.add_argument("-c", "--config", help="odoo config file")
parser.add_argument("--json", action="store_true")
parser.add_argument("--no-color", action="store_true")
args = parser.parse_args(argv)
try:
lib_analyse.require_odoo_database(
args.database, config_path=args.config
)
except Exception as exc: # noqa: BLE001
print(f"❌ {exc}", file=sys.stderr)
return 2
resultats = inspect(args.database, args.config)
try:
version = lib_analyse.database_version(
args.database, config_path=args.config
)
except Exception: # noqa: BLE001 - la version est un confort, pas le sujet
version = None
trouve, illisibles = judge(resultats)
if args.json:
print(
json.dumps(
{
"database": args.database,
"base_version": version,
"checks": resultats,
"found": [c["key"] for c, _ in trouve],
"unreadable": [c["key"] for c, _ in illisibles],
},
indent=2,
ensure_ascii=False,
)
)
else:
colour = sys.stdout.isatty() and not args.no_color
print(render(args.database, resultats, version, colour))
return 1 if trouve else 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,320 @@
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Ausculter une instance : d'où vient la base, et que peut-on y lire.
Ce module ne réanalyse rien. Les analyses existent déjà — vues
personnalisées, champs x_, restant de migration — et chacune sait parler
à une base. Ce qui manquait, c'est le chemin d'AVANT : la base n'est pas
toujours ici. Elle est dans un zip, ou chez un client, ou vivante sur un
serveur auquel on n'a qu'un identifiant.
Trois provenances, deux destins
-------------------------------
Le zip et la sauvegarde distante finissent au même endroit : restaurés en
base locale. À partir de là, tout ce que le dépôt sait faire fonctionne,
sans une ligne de plus.
L'instance VIVANTE, non. Aucun outil d'ici ne parle autre chose que psql,
et on n'a pas la base — on a une session Odoo. Ce que RPC laisse lire est
un sous-ensemble : les tables `ir_*` oui, `pg_catalog` non. Une analyse
qui compte des index ne peut donc pas tourner là, et le dire est le
travail de ce module. Une analyse muette qu'on croit rassurante est pire
que pas d'analyse du tout.
Pourquoi la TUI ne fait que CHOISIR
-----------------------------------
`run_tui` des six écrans du dépôt refuse de s'ouvrir dans une boucle
asyncio déjà en cours, et une analyse lourde appelée depuis un
gestionnaire de touche gèle l'affichage sans rien dire. Un hub qui
CONTIENT les analyses se heurte aux deux. Celui-ci demande ce qu'on veut,
se referme, et laisse l'analyse s'ouvrir chez elle, en pleine possession
du terminal.
"""
from __future__ import annotations
import os
import subprocess
import sys
sys.path.append(
os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
)
try:
from script.todo.todo_i18n import t
except Exception: # pragma: no cover - repli si i18n indisponible
def t(key: str) -> str:
return key
REPO_ROOT = os.path.normpath(
os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..")
)
# Ce qu'une provenance donne à la fin. `database` couvre le zip restauré
# comme la sauvegarde téléchargée : une fois restaurés, ils ne se
# distinguent plus.
KIND_DATABASE = "database"
KIND_LIVE = "live"
ANALYSES = (
{
"key": "migration_residue",
"title": "Migration leftovers",
"why": "What a migration left behind, judged without needing the"
" step-by-step databases.",
"script": "script/analyse/check_migration_residue.py",
"kinds": (KIND_DATABASE,),
"needs_sql": "It reads pg_catalog — indexes and real tables — which"
" no RPC session exposes.",
},
{
"key": "instance_state",
"title": "State of the instance",
"why": "Neutralisation, scheduler, backups, queues and who is an"
" administrator — read for the use you intend.",
"script": "script/analyse/check_instance_state.py",
"kinds": (KIND_DATABASE,),
"needs_sql": "Several checks read tables no RPC session exposes,"
" and the lateness of a job is computed in SQL.",
# Le même chiffre veut dire deux choses opposées selon qu'on
# ausculte une copie ou une production : l'attente ne se devine
# pas, elle se demande.
"asks_expect": True,
},
{
"key": "cow_views",
"title": "Customised views, website copies included",
"why": "Every view someone changed, and every website copy that"
" shadows a module view.",
"script": "script/analyse/analyse_view_custom.py",
"kinds": (KIND_DATABASE,),
"needs_sql": "Comparing a copy with the module view it hides is a"
" join on arch_db, done in SQL.",
},
{
"key": "custom_fields",
"title": "Studio and hand-made x_ fields",
"why": "Fields and models added outside any module, and which of"
" them have no column behind them.",
"script": "script/analyse/analyse_custom_field.py",
"kinds": (KIND_DATABASE,),
"needs_sql": "Telling a declared field from a real column means"
" reading pg_attribute.",
},
)
def analysis_by_key(key):
"""L'analyse portant cette clé, ou None."""
for analyse in ANALYSES:
if analyse["key"] == key:
return analyse
return None
def available(kind):
"""Les analyses qui savent lire CETTE provenance.
Rendre la liste complète et laisser l'appelant filtrer ferait afficher
des choix qui échoueraient à l'ouverture ; c'est le genre de menu qui
apprend à ne plus faire confiance au menu.
"""
return tuple(a for a in ANALYSES if kind in a["kinds"])
def unavailable(kind):
"""Celles qui ne le savent pas, avec la raison — pour la DIRE."""
return tuple(a for a in ANALYSES if kind not in a["kinds"])
def command_for(analyse, database, config_path=None, extra=None):
"""La ligne de commande qui lance l'analyse sur cette base."""
cmd = [sys.executable, analyse["script"], "-d", database]
if config_path:
cmd += ["-c", config_path]
if extra:
cmd += list(extra)
return cmd
def run_analysis(analyse, database, config_path=None, extra=None, env=None):
"""Lancer l'analyse et rendre son code de sortie.
Sans capture : l'analyse écrit sur le terminal, et certaines ouvrent
leur propre TUI. Les intercepter reviendrait à leur retirer l'écran.
"""
cmd = command_for(analyse, database, config_path, extra)
completed = subprocess.run(cmd, cwd=REPO_ROOT, env=env)
return completed.returncode
def describe_source(kind, target):
"""Une ligne qui dit sur QUOI l'on travaille, avant d'analyser.
Après une restauration, le nom de la base n'est plus celui du fichier —
l'annoncer évite d'analyser une base pour une autre.
"""
if kind == KIND_LIVE:
return f"🛰 {t('Live instance:')} {target}"
return f"💾 {t('Database:')} {target}"
# ── La neutralisation a-t-elle vraiment eu lieu ? ────────────────────────
#
# Mesuré sur les sept bases d'une chaîne 12 → 18 dont le nom porte pourtant
# « neutralize » : `database.is_neutralized` ABSENT dans les sept, 16 à 35
# crons actifs, et `mail.catchall.domain` toujours au domaine du CLIENT.
# Demander « voulez-vous neutraliser ? », recevoir oui, et ne rien vérifier
# reproduit exactement cette illusion.
#
# On ne lit que des booléens de PRÉSENCE. Une clé de paiement vivante a été
# trouvée dans cette base de test ; un rapport finit dans un billet ou
# devant un agent, et n'a aucune raison de la porter.
NEUTRALIZE_SQL = {
"flag": "SELECT count(*) FROM ir_config_parameter"
" WHERE key='database.is_neutralized' AND value IN ('true','True','1')",
"cron_active": "SELECT count(*) FROM ir_cron WHERE active",
"mail_server": "SELECT count(*) FROM ir_mail_server",
"payment_live": "SELECT count(*) FROM payment_provider"
" WHERE state NOT IN ('disabled','test')",
}
def neutralize_state(database, config_path=None):
"""Ce que la base dit d'elle-même après une neutralisation.
Un contrôle illisible rend None et n'est pas compté comme zéro : la
table `payment_provider` n'existe pas si le module n'est pas installé,
et cette absence n'est pas une bonne nouvelle à afficher.
"""
from script.analyse import lib_analyse
etat = {}
for cle, sql in NEUTRALIZE_SQL.items():
try:
brut = lib_analyse.run_psql(
database, sql, config_path=config_path
).strip()
etat[cle] = int(brut.splitlines()[0])
except Exception: # noqa: BLE001 - absent n'est pas nul
etat[cle] = None
return etat
def neutralize_report(etat, colour=False):
"""Dire ce qui a pris et ce qui n'a pas pris, sans rien inventer.
`ir_mail_server` à zéro n'est PAS une preuve de sûreté : Odoo retombe
alors sur le `smtp_server` du fichier de configuration — c'est
précisément pourquoi son propre `neutralize.sql` INSÈRE un serveur
bouchon au lieu de tout supprimer.
"""
lignes = []
drapeau = etat.get("flag")
if drapeau:
lignes.append(f"✅ {t('database.is_neutralized is set.')}")
else:
lignes.append(
f"❌ {t('database.is_neutralized is NOT set — Odoo does not')}"
f" {t('consider this database neutralised.')}"
)
crons = etat.get("cron_active")
if crons:
lignes.append(f"⚠ {crons} {t('scheduled actions are still active.')}")
elif crons == 0:
lignes.append(f"✅ {t('No scheduled action is active.')}")
serveurs = etat.get("mail_server")
if serveurs == 0:
lignes.append(
f"⚠ {t('No mail server at all: Odoo falls back to smtp_server')}"
f" {t('from the config file. A blocking one is safer than none.')}"
)
vivants = etat.get("payment_live")
if vivants:
lignes.append(
f"❌ {vivants} {t('payment provider(s) are neither disabled nor')}"
f" {t('in test mode.')}"
)
return "\n".join(paint_plain(lignes, colour))
def paint_plain(lignes, colour):
"""Teinter selon l'icône de tête — une seule règle, pas une par ligne."""
if not colour:
return lignes
teinte = {"❌": "\033[31m", "⚠": "\033[33m", "✅": "\033[32m"}
sorties = []
for ligne in lignes:
code = teinte.get(ligne[:1], "")
sorties.append(f"{code}{ligne}\033[0m" if code else ligne)
return sorties
# ── L'instance vivante ───────────────────────────────────────────────────
#
# Depuis Odoo 14, une CLÉ D'API se présente exactement comme un mot de
# passe à `authenticate` : même appel, même place. La différence n'est pas
# technique, elle est humaine — une clé se révoque sans changer le mot de
# passe de personne. On demande donc laquelle on donne, et l'on envoie la
# même chose.
def live_connect(base_url, database, login, secret, timeout=30):
"""Ouvrir une session XML-RPC. Rendre (uid, version), ou lever.
C'est un vrai contrôle, pas une politesse : sans lui, une faute dans
l'URL ou la clé ne se verrait qu'à la première analyse, et passerait
pour un défaut de l'analyse.
"""
import xmlrpc.client
url = base_url.rstrip("/")
common = xmlrpc.client.ServerProxy(
f"{url}/xmlrpc/2/common", allow_none=True
)
import socket
ancien = socket.getdefaulttimeout()
socket.setdefaulttimeout(timeout)
try:
version = (common.version() or {}).get("server_serie", "?")
uid = common.authenticate(database, login, secret, {})
finally:
socket.setdefaulttimeout(ancien)
if not uid:
raise PermissionError(t("The instance refused these credentials."))
return uid, version
# Ce qu'une analyse a le droit d'appeler sur une instance vivante. La liste
# est appliquée ICI et non chez l'appelant : une production n'a pas à
# dépendre de la prudence de chaque analyse à venir. `write`, `create` et
# `unlink` ne peuvent pas s'y glisser par distraction.
RPC_READ_ONLY = (
"search_read",
"search_count",
"read",
"read_group",
"fields_get",
)
def live_call(base_url, database, uid, secret, model, method, *args, **kw):
"""Un appel RPC en lecture. Tout le reste est refusé sans être tenté."""
if method not in RPC_READ_ONLY:
raise PermissionError(
f"{t('Monitoring only reads; refused method:')} {method}"
)
import xmlrpc.client
models = xmlrpc.client.ServerProxy(
f"{base_url.rstrip('/')}/xmlrpc/2/object", allow_none=True
)
return models.execute_kw(
database, uid, secret, model, method, list(args), kw
)

View file

@ -0,0 +1,191 @@
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Demander quelle auscultation lancer — et rien d'autre.
Cet écran ne CONTIENT aucune analyse. Il en choisit une, se referme, et
rend la clé choisie ; l'appelant lance ensuite l'outil, qui dispose alors
du terminal entier et peut ouvrir sa propre TUI.
Deux raisons, toutes deux mesurées dans ce dépôt. `run_tui` refuse de
s'ouvrir quand une boucle asyncio tourne déjà — un écran qui en ouvrirait
un autre afficherait « open it in its own process instead » et rien
d'autre. Et une analyse lourde appelée depuis un gestionnaire de touche
bloque la boucle d'événements : plus de rafraîchissement, plus de frappe,
un écran qui paraît planté pendant des minutes.
Les analyses que la provenance ne permet pas restent AFFICHÉES, grisées,
avec la raison. Les retirer laisserait croire qu'elles n'existent pas ;
les proposer les ferait échouer à l'ouverture.
"""
from __future__ import annotations
import os
import sys
sys.path.append(
os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
)
from script.analyse import monitoring # noqa: E402
try:
from script.todo.todo_i18n import t
except Exception: # pragma: no cover - repli si i18n indisponible
def t(key: str) -> str:
return key
CSS = """
Screen { layout: vertical; }
#head { height: 3; padding: 0 1; background: $panel; color: $text; }
#body { height: 1fr; }
#left { width: 52; border-right: solid $accent; }
#pane { width: 1fr; padding: 0 1; }
.off { color: $text-disabled; }
"""
def rows(kind):
"""Les lignes de l'écran : (clé, étiquette, utilisable, raison).
Fonction pure, et c'est délibéré — c'est elle qui décide ce qu'on peut
lancer, et une décision qui ne se teste qu'en ouvrant un terminal ne
se teste pas.
"""
lignes = []
for analyse in monitoring.ANALYSES:
utilisable = kind in analyse["kinds"]
lignes.append(
(
analyse["key"],
t(analyse["title"]),
utilisable,
"" if utilisable else t(analyse["needs_sql"]),
)
)
return lignes
def detail(key, kind):
"""Le texte du panneau de droite pour cette analyse."""
analyse = monitoring.analysis_by_key(key)
if not analyse:
return ""
morceaux = [t(analyse["title"]), "", t(analyse["why"]), ""]
if kind in analyse["kinds"]:
morceaux.append(f"▶ {t('Enter to run it.')}")
else:
morceaux.append(f"✖ {t('Not available for this source.')}")
morceaux.append(f" {t(analyse['needs_sql'])}")
morceaux.append("")
morceaux.append(f" {analyse['script']}")
return "\n".join(morceaux)
def build_app(kind, target):
"""Construire l'écran. ImportError si Textual manque — l'appelant le dit."""
from textual.app import App, ComposeResult
from textual.containers import Horizontal
from textual.widgets import Footer, Header, ListItem, ListView, Static
lignes = rows(kind)
class MonitoringChooser(App):
CSS = globals()["CSS"]
BINDINGS = [
("enter", "choose", t("Run")),
("q", "quit", t("Quit")),
("escape", "quit", t("Quit")),
]
def compose(self) -> ComposeResult:
yield Header()
yield Static(monitoring.describe_source(kind, target), id="head")
with Horizontal(id="body"):
items = []
for key, label, utilisable, _ in lignes:
marque = " " if utilisable else "✖ "
item = ListItem(Static(f"{marque}{label}"))
if not utilisable:
item.add_class("off")
items.append(item)
yield ListView(*items, id="left")
yield Static("", id="pane")
yield Footer()
def on_mount(self) -> None:
self.query_one("#left", ListView).focus()
self._refresh_pane(0)
def _refresh_pane(self, index) -> None:
if 0 <= index < len(lignes):
self.query_one("#pane", Static).update(
detail(lignes[index][0], kind)
)
def on_list_view_selected(self, event) -> None:
# `ListView` consomme Entrée pour émettre `Selected` : la
# liaison de l'application ne la voit jamais. Sans ceci,
# l'écran ne répondait pas à Entrée — et aucune fonction pure
# ne pouvait le montrer.
self.action_choose()
def on_list_view_highlighted(self, event) -> None:
self._refresh_pane(self.query_one("#left", ListView).index or 0)
def action_choose(self) -> None:
index = self.query_one("#left", ListView).index
if index is None or not (0 <= index < len(lignes)):
return
key, _, utilisable, raison = lignes[index]
if not utilisable:
# Refuser en le DISANT : un Entrée sans effet se lit comme
# un écran figé, et l'on appuie plus fort.
self.query_one("#pane", Static).update(
f"✖ {t('Not available for this source.')}\n\n {raison}"
)
return
self.exit(key)
return MonitoringChooser()
def in_event_loop():
"""Une boucle asyncio tourne-t-elle déjà dans CE processus ?"""
import asyncio
try:
asyncio.get_running_loop()
except RuntimeError:
return False
return True
def run_tui(kind, target, run_app=True):
"""Ouvrir le choix. Rendre la clé choisie, ou None — en disant pourquoi."""
try:
from script.todo import textual_setup
except Exception: # pragma: no cover - repli si l'aide manque
textual_setup = None
if textual_setup and not textual_setup.ensure():
return None
try:
app = build_app(kind, target)
except ImportError:
print(
f"ℹ️ {t('Textual is missing from this interpreter:')}"
f" {sys.executable}"
)
return None
if not run_app:
return app
if in_event_loop():
print(
f"ℹ️ {t('Already inside a running screen: open it in its own')}"
f" {t('process instead.')}"
)
return None
return app.run()

View file

@ -4,26 +4,35 @@
"""Le transfert des dépôts ERPLibre dans l'application mobile a-t-il eu lieu ?
L'application embarque le code des dépôts du manifeste pour les parcourir hors
ligne. Ils y entrent sous forme de PACKS : un APK est un ZIP borné à 65535
entrées, et ces dépôts pèsent plus de 120 000 fichiers — un fichier par source
faisait échouer la compilation sur « Too many zip entries ». Chaque dépôt a donc
un `index.json` qui dit, pour chaque fichier, dans quelle tranche il se trouve,
à quel offset et sur quelle longueur.
ligne. Un APK est un ZIP borné à 65535 entrées, et ces dépôts pèsent plus de
120 000 fichiers : un fichier par source faisait échouer la compilation sur
« Too many zip entries ». Deux dispositions résolvent cela, et ce script
accepte les DEUX — sans quoi il échoue sur la moitié du travail qui n'a pas
encore atterri, ce qui est arrivé :
- ARCHIVE : un `tar.gz` par dépôt, plus un `index.json` listant ses chemins.
Le manifeste porte alors `archive` et `indexUrl`.
- PACKS : des tranches `pack-NNN.bin` par dépôt, et un `index.json` qui dit
pour chaque fichier sa tranche, son offset et sa longueur. Un fichier s'y
relit sans décompresser le reste.
Ce script VÉRIFIE ce transfert, et il est fait pour être exécuté par
l'installation comme à la main :
./script/mobile/check_bundle_transfer.py [racine_du_dépôt_mobile]
Il échoue quand le transfert est vide, quand une tranche manque, ou quand un
index promet des octets que sa tranche n'a pas — trois pannes qu'un simple
« la compilation a réussi » ne dit pas.
Il échoue quand le transfert est vide, quand un conteneur manque, quand un
index promet un fichier que son conteneur n'a pas, ou quand les octets relus
diffèrent de la source — quatre pannes qu'un simple « la compilation a réussi »
ne dit pas. La présence est prouvée pour CHAQUE fichier promis ; seule la
relecture des octets se fait par échantillon.
"""
import argparse
import json
import random
import sys
import tarfile
from pathlib import Path
# En dessous, ce n'est plus un transfert : c'est un bundle vide qu'on aurait pris
@ -65,6 +74,126 @@ def read_from_pack(repo_dir: Path, entry: dict) -> bytes:
return data
# Le plugin de compilation ecrit les membres du tar prefixes par « ./ », quand
# l'index porte le chemin nu. Sans ce prefixe, extractfile ne trouve rien.
MEMBER_PREFIX = "./"
def index_path(base: Path, proj: dict) -> Path:
"""L'index du dépôt, dans l'une ou l'autre disposition."""
url = proj.get("indexUrl")
if url:
# `indexUrl` est relatif à dist/, quand `base` est dist/repos.
cand = base.parent / url
if cand.is_file():
return cand
cand = base / proj["slug"] / "index.json"
if cand.is_file():
return cand
raise FileNotFoundError(f"{proj['slug']} : index.json absent")
def archive_path(base: Path, proj: dict) -> Path:
"""Le tar.gz du dépôt, ou None si ce dépôt n'est pas en archive."""
rel = proj.get("archive")
if not rel:
return None
cand = base.parent / rel
return cand if cand.is_file() else None
def read_from_archive(
archive: Path, paths: list, promised: list = None
) -> dict:
"""Relit plusieurs fichiers en UNE passe de décompression.
Ouvrir une archive par fichier coûterait quelques secondes chacune sur le
dépôt odoo (150 Mo) : on traverse donc une seule fois en ramassant tout ce
qui est demandé.
`promised` : tous les chemins que l'index annonce pour cette archive. La
traversée étant déjà payée, on en profite pour vérifier qu'ils y sont TOUS
— un échantillon de vingt fichiers ne verrait pas un fantôme qu'il ne tire
pas, et c'est précisément ce que cette passe attrape gratuitement.
"""
wanted = {MEMBER_PREFIX + p: p for p in paths}
found = {}
seen = set()
with tarfile.open(archive, "r:gz") as tar:
for member in tar:
if member.isfile():
seen.add(member.name)
key = wanted.get(member.name)
if key is None:
continue
handle = tar.extractfile(member)
if handle is None:
raise ValueError(
f"{archive.name} : {key} n'est pas un fichier"
)
found[key] = handle.read()
missing = set(wanted.values()) - set(found)
if missing:
raise FileNotFoundError(
f"{archive.name} : {len(missing)} fichier(s) tiré(s) de l'index"
f" et absents de l'archive, dont {sorted(missing)[0]}"
)
if promised:
ghosts = {q for q in promised if MEMBER_PREFIX + q not in seen}
if ghosts:
raise FileNotFoundError(
f"{archive.name} : l'index promet {len(ghosts)} fichier(s) que"
f" l'archive n'a pas, dont {sorted(ghosts)[0]}"
)
return found
def _collect(base: Path, repos: list, report: dict) -> tuple:
"""Range chaque dépôt dans sa disposition, et valide ce qu'il promet.
Extraite de `check` pour la garder sous la complexité que flake8 accepte :
deux dispositions et leurs validations tenaient mal dans une fonction.
"""
pack_pool = []
arch_pool = {}
for proj in repos:
entries = _read_json(index_path(base, proj))
files = [e for e in entries if e.get("type") == "file"]
# Le manifeste annonce un compte : un index plus court est un transfert
# tronqué que rien d'autre ne signale.
promised = proj.get("fileCount")
if promised is not None and promised != len(files):
raise ValueError(
f"{proj['slug']} : le manifeste promet {promised} fichiers,"
f" l'index en porte {len(files)}"
)
report["files"] += len(files)
archive = archive_path(base, proj)
if archive is not None:
report["archives"] += 1
arch_pool.setdefault(archive, (proj, []))[1].extend(files)
continue
repo_dir = base / proj["slug"]
packs = list(repo_dir.glob("pack-*.bin"))
if files and not packs:
# Nommer la tranche ATTENDUE : « pack-*.bin » n'aide pas qui lit un
# journal d'installation et cherche un fichier précis.
chunks = sorted(
{e["chunk"] for e in files if e.get("chunk") is not None}
)
attendu = (
pack_path(repo_dir, chunks[0]).name
if chunks
else "pack-000.bin"
)
raise FileNotFoundError(
f"{proj['slug']} : ni archive, ni {attendu}"
)
report["packs"] += len(packs)
pack_pool += [(proj, repo_dir, e) for e in files if e.get("size")]
return pack_pool, arch_pool
def check(
mobile_root: Path, workspace: Path = None, min_files: int = None
) -> dict:
@ -87,25 +216,13 @@ def check(
report = {
"repos": len(repos),
"files": 0,
"archives": 0,
"packs": 0,
"present": 0,
"checked": 0,
"compared": 0,
}
sample_pool = []
for proj in repos:
repo_dir = base / proj["slug"]
index = repo_dir / "index.json"
if not index.is_file():
raise FileNotFoundError(f"{proj['slug']} : index.json absent")
entries = _read_json(index)
files = [
e
for e in entries
if e.get("type") == "file" and e.get("chunk") is not None
]
report["files"] += len(files)
report["packs"] += len(list(repo_dir.glob("pack-*.bin")))
sample_pool += [(proj, repo_dir, e) for e in files if e.get("size")]
pack_pool, arch_pool = _collect(base, repos, report)
if report["files"] < min_files:
raise ValueError(
@ -113,22 +230,53 @@ def check(
f" {report['repos']} dépôts (au moins {min_files} attendus)"
)
flat = [("pack", proj, repo_dir, e) for proj, repo_dir, e in pack_pool]
flat += [
("arch", proj, archive, e)
for archive, (proj, entries) in arch_pool.items()
for e in entries
]
random.seed(SEED)
for proj, repo_dir, entry in random.sample(
sample_pool, min(SAMPLE, len(sample_pool))
):
data = read_from_pack(repo_dir, entry)
sample = random.sample(flat, min(SAMPLE, len(flat)))
def compare(proj, entry, data):
report["checked"] += 1
if workspace is None:
continue
return
src = workspace / proj["path"] / entry["path"]
if not src.is_file():
continue
return
if data != src.read_bytes():
raise ValueError(
f"{proj['slug']} : {entry['path']} diffère de la source"
)
report["compared"] += 1
for kind, proj, container, entry in sample:
if kind == "pack":
compare(proj, entry, read_from_pack(container, entry))
drawn = {}
for kind, proj, container, entry in sample:
if kind == "arch":
drawn.setdefault(container, []).append(entry)
# Toutes les archives sont traversées, pas seulement celles que
# l'échantillon tire : la traversée coûte 6 s pour les 139 dépôts et elle
# prouve la présence de CHACUN des fichiers promis. Un échantillon de vingt
# ne verrait pas un fantôme qu'il ne tire pas. Les octets, eux, ne sont lus
# que pour les tirés — c'est la lecture qui coûte, pas la traversée.
for archive, (proj, entries) in arch_pool.items():
here = drawn.get(archive, [])
blobs = read_from_archive(
archive,
[e["path"] for e in here],
promised=[e["path"] for e in entries],
)
report["present"] += len(entries)
for entry in here:
compare(proj, entry, blobs[entry["path"]])
return report
@ -156,9 +304,16 @@ def main() -> int:
extra = (
f", {rep['compared']} comparés à la source" if rep["compared"] else ""
)
where = []
if rep["archives"]:
where.append(f"{rep['archives']} archives")
if rep["packs"]:
where.append(f"{rep['packs']} tranches")
proven = f", {rep['present']} présences prouvées" if rep["present"] else ""
print(
f" {rep['repos']} dépôts, {rep['files']} fichiers en"
f" {rep['packs']} tranches ({rep['checked']} relus{extra})"
f" {' et '.join(where) or 'aucun conteneur'}"
f" ({rep['checked']} relus{extra}{proven})"
)
return 0

View file

@ -91,6 +91,14 @@ try:
("share", "=", False),
], limit=LIMITE)
rapport["users"] = membres.mapped("login")
# Les témoins ORDINAIRES : ceux qui ne sont pas administrateurs.
# Un modèle que seuls les administrateurs peuvent lire passe au vert
# si tous les témoins en sont — c'est exactement la forme du bug DMS,
# vue d'un autre angle. Mesuré sur une base réelle : les QUATRE
# utilisateurs internes étaient membres de base.group_system.
rapport["ordinary"] = [
u.login for u in membres if not u.has_group("base.group_system")
]
if not membres:
rapport["no_user"] = True
else:
@ -99,6 +107,10 @@ try:
.search([("global", "=", True), ("active", "=", True)])
.mapped("model_id.model")
)
# La COUVERTURE : combien de modèles portent une règle globale,
# et non combien on a pu éprouver. « 45 éprouvés » sans dire
# « sur 125 » laisse croire à un examen complet.
rapport["with_rule"] = len(vus - ATTENDUS)
for nom in sorted(vus - ATTENDUS):
modele = env.get(nom)
if modele is None or modele._abstract or modele._transient:
@ -141,12 +153,24 @@ def render(rapport):
if rapport.get("no_user"):
return [f"⚠ {t('No internal user to test visibility with.')}"]
muets = rapport.get("models") or []
porteurs = rapport.get("with_rule")
sur = f" {t('out of')} {porteurs}" if porteurs else ""
lignes = [
f"🔍 {rapport.get('checked', 0)}"
f"🔍 {rapport.get('checked', 0)}{sur}"
f" {t('model(s) with a global rule and some data,')}"
f" {t('checked against')} {len(rapport.get('users') or [])}"
f" {t('internal user(s)')}"
]
# Dire ce qu'on n'a PAS pu éprouver, avant de dire ce qu'on a trouvé.
# Un vert qui prouve moins qu'il n'en a l'air est pire qu'un rouge.
if "ordinary" in rapport and not rapport["ordinary"]:
lignes.append(
f" ⚠ {t('Every witness is an administrator: a model only')}"
f" {t('admins can read passes this test.')}"
)
lignes.append(
f" ℹ {t('Reads ir_rule only — masking coded in Python escapes it.')}"
)
if not muets:
lignes.append(f" ✅ {t('Every one of them is visible to someone.')}")
return lignes

View file

@ -15,6 +15,13 @@
# l'annonce donc avant de commencer.
#
# ./script/test/run_unit_test.sh [fichiers...]
#
# NOMMER UN NOUVEAU FICHIER : sans argument, ce script ne prend que les
# préfixes listés plus bas (test_qemu_, test_todo_, test_proxmox_…) — le reste
# de test/ demande une base de données. Un fichier hors préfixe tombe donc dans
# le même silence qu'un fichier absent : douze tests écrits, jamais lancés, et
# un total qui n'a pas bougé. Choisir le préfixe de sa famille, ou l'ajouter
# ici.
set -uo pipefail
Red='\033[0;31m'

View file

@ -0,0 +1,480 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Ce qu'on installe DANS la VM, et qui ne regarde pas l'hyperviseur.
Type de VM, production, magasin d'applications, outils de développement,
fuseau horaire, interpréteur Python : six réglages qui décrivent le système
invité, pas la machine qui le porte. Ils valent donc mot pour mot sur libvirt
et sur Proxmox VE — et c'est exactement ce qui avait dérivé. L'écran QEMU/KVM
les portait tous, l'écran Proxmox trois : une VM créée là-bas naissait sans
bureau, sans outils et en UTC, sans que rien ne le dise.
La duplication était le mécanisme de la dérive, pas son symptôme : chaque
correctif se posait sur un seul des deux écrans. Ce module met les six ici,
une seule fois, widgets ET logique. Un formulaire hérite d'`ExtrasMixin`,
appelle `extras_init(ctx)` puis pose les fragments de `compose_*` où sa mise
en page les veut.
Le contrat côté formulaire, court exprès : l'attribut `vms` (les VM retenues)
et une case `#f_install`. Rien d'autre — ce module ne connaît ni stockage, ni
pont, ni domaine libvirt.
Chaque accès aux widgets est gardé : un formulaire n'est pas tenu de poser
tous les fragments, et lire un widget absent ne doit pas casser l'écran.
"""
from script.todo.deploy_form_lib import FREE, t
# Ce que l'écran lit du contexte. Une seule liste, parce que les deux
# formulaires doivent en recevoir autant : c'est en fournissant un
# sous-ensemble que l'écran Proxmox avait perdu la moitié des réglages.
_TABLES = (
("desktops", list, "desktops"),
("desktop_disk", int, "desktop_disk_gb"),
("app_stores", list, "app_stores"),
("snap_distros", set, "snap_distros"),
("timezones", list, "timezones"),
("timezone", str, "timezone"),
("vm_tools", list, "vm_tools"),
("tool_disk", dict, "vm_tool_disk"),
("tool_phases", dict, "vm_tool_phases"),
("tool_arches", dict, "vm_tool_arches"),
("tool_desktops", dict, "vm_tool_desktops"),
("tool_needs_desktop", dict, "vm_tool_needs_desktop"),
("tool_families", dict, "vm_tool_families"),
("distro_family", dict, "distro_family"),
("mise_arches", set, "mise_arches"),
("defaults", dict, "defaults"),
)
def extras_tables(ctx) -> dict:
"""Les tables lues du contexte, chacune ramenée à son type.
Ramenées, et non prises telles quelles : une clé absente rend un vide du
bon type, donc les prédicats plus bas n'ont aucun cas particulier à
porter. Un formulaire qui n'offre pas les outils passe simplement un
contexte sans « vm_tools »."""
ctx = ctx or {}
return {nom: kind(ctx.get(cle) or kind()) for nom, kind, cle in _TABLES}
def tools_for_vm(vm, tools, tab) -> list:
"""Outils qu'une VM donnée recevra VRAIMENT.
Le même filtre que côté déploiement, et c'est le point : une VM ARM ne
verra jamais Android Studio, une VM Cinnamon jamais les extensions GNOME,
un serveur aucun des IDE — mais un serveur reçoit bien la compilation
mobile, qui n'a rien à afficher, et une distribution sans apt ne la
reçoit pas, son installateur n'existant que là.
Filtrer ici plutôt que dans la commande distante permet d'annoncer
l'écart AVANT le déploiement, au lieu de le laisser découvrir dans un
journal d'installation d'une heure."""
out = []
for key in tools or ():
arches = tab["tool_arches"].get(key) or ()
desks = tab["tool_desktops"].get(key) or ()
fams = tab["tool_families"].get(key) or ()
if tab["tool_needs_desktop"].get(key) and not vm.get("desktop"):
continue
if arches and vm.get("arch") not in arches:
continue
if desks and vm.get("desktop") not in desks:
continue
if fams and tab["distro_family"].get(vm.get("distro"), "") not in fams:
continue
out.append(key)
return out
def app_store_needed(vms, tab) -> bool:
"""Le choix du magasin n'a de sens que pour une VM GRAPHIQUE sur une
distribution qui livre snapd. Ailleurs, rien ne tire de snap."""
return any(
vm.get("desktop") and vm.get("distro") in tab["snap_distros"]
for vm in vms or ()
)
def mise_usable(vms, tab) -> bool:
"""Au moins une VM retenue tourne sur une architecture que mise sert."""
return any(vm.get("arch") in tab["mise_arches"] for vm in vms or ())
def extras_disk_gb(vm, tools, tab) -> int:
"""Go que le bureau et les outils ajoutent au disque de CETTE VM.
De cette VM et d'elle seule : un supplément commun mentait dès que les
types différaient d'une machine à l'autre, et gonflait le disque d'un
serveur pour un bureau qu'il n'aurait pas."""
gb = tab["desktop_disk"] if vm.get("desktop") else 0
return gb + sum(
tab["tool_disk"].get(k, 0) for k in tools_for_vm(vm, tools, tab)
)
class ExtrasMixin:
"""Les six réglages du système invité, widgets et logique.
Le formulaire hôte pose les fragments `compose_*` où il veut, appelle
`render_extras()` après chaque recalcul et `extras_values()` au moment de
bâtir sa spec."""
def extras_init(self, ctx) -> None:
"""À appeler dans `__init__`, avant tout `compose`."""
self._extras = extras_tables(ctx)
# ------------------------------------------------------------------ #
# Les widgets
# ------------------------------------------------------------------ #
def compose_vm_type(self):
"""Type de VM par défaut. Serveur d'abord : c'est ce que sert une
image cloud, et un bureau ajoute une à deux heures sur une
architecture émulée. Le plan annonce le surcoût disque."""
from textual.widgets import RadioButton, RadioSet, Static
tab = self._extras
if not tab["desktops"]:
return
yield Static(t("VM type (default):"), classes="grouptitle")
with RadioSet(id="f_type"):
yield RadioButton(
t("Server (no graphical interface)"),
value=not tab["defaults"].get("desktop", ""),
)
for key, label in tab["desktops"]:
yield RadioButton(
f"{t('Graphical (server + desktop):')} {label}",
value=tab["defaults"].get("desktop", "") == key,
)
def compose_install_extras(self):
"""Ce qui pend à la case « installer » : production, magasin,
outils. Posé DANS la section « Installation », après la branche et le
profil."""
from textual.widgets import Checkbox, RadioButton, RadioSet, Static
tab = self._extras
yield Checkbox(
t("Production (/opt, confined)"),
value=tab["defaults"].get("prod", False),
id="f_prod",
)
if tab["app_stores"]:
yield Static(
t("Application store:"), id="t_store", classes="grouptitle"
)
with RadioSet(id="f_store"):
for i, (_k, label) in enumerate(tab["app_stores"]):
yield RadioButton(label, value=i == 0)
yield Static("", id="storewarn")
if tab["vm_tools"]:
# Une case par outil, et non une liste déroulante : ils sont
# indépendants, et chacun se prend ou se laisse.
yield Static(
t("Development tools:"), id="t_tools", classes="grouptitle"
)
for key, label, hint in tab["vm_tools"]:
gb = tab["tool_disk"].get(key, 0)
yield Checkbox(
f"{label} +{gb} Go — {hint}",
value=key in (tab["defaults"].get("tools") or ()),
id=f"f_tool_{key}",
)
yield Static("", id="toolwarn")
def compose_timezone(self):
"""Une liste plutôt qu'une saisie : un nom IANA mal orthographié
n'est pas refusé par cloud-init, il est IGNORÉ — la VM reste en UTC et
on ne s'en aperçoit qu'aux horodatages. « libre… » garde la porte
ouverte aux six cents autres fuseaux de la base."""
from textual.widgets import Input, Select, Static
tab = self._extras
if not tab["timezones"]:
return
null = getattr(Select, "NULL", Select.BLANK)
yield Static(t("Timezone"), classes="grouptitle")
yield Select(
[(z, z) for z in tab["timezones"]] + [(t("free value…"), FREE)],
value=(tab["timezones"][0] if tab["timezones"] else null),
allow_blank=False,
id="f_tz_sel",
)
yield Input(
value=tab["timezone"],
placeholder=t("Timezone for the VMs"),
id="f_tz",
classes="freeval",
)
def compose_python(self):
"""mise pose un CPython PRÉCOMPILÉ, pyenv le COMPILE. Grisé quand
aucune des VM retenues n'est sur une architecture que mise sert."""
from textual.widgets import RadioButton, RadioSet, Static
yield Static(
t("Python interpreter:"), id="t_python", classes="grouptitle"
)
with RadioSet(id="f_python"):
yield RadioButton(t("mise (precompiled, faster)"), value=True)
yield RadioButton(t("pyenv (compiles from source)"))
yield Static("", id="miswarn")
# ------------------------------------------------------------------ #
# Lire les widgets
# ------------------------------------------------------------------ #
def _widget(self, selector):
"""Le widget, ou None s'il n'est pas de cet écran."""
try:
return self.query_one(selector)
except Exception:
return None
def _default_desktop(self) -> str:
"""« » pour un serveur, sinon la clé de la saveur choisie. Chaque
rangée du plan peut s'en écarter."""
desktops = self._extras["desktops"]
widget = self._widget("#f_type")
if widget is None:
return ""
index = widget.pressed_index
if index is None or index < 1 or index > len(desktops):
return ""
return desktops[index - 1][0]
def _install_state(self):
"""(une installation ?, quelque chose à installer ?).
Deux réponses et non une : sans installation mais avec un bureau, il
se pose encore des paquets — le magasin d'applications et les outils
de la phase « avant » gardent un effet."""
widget = self._widget("#f_install")
installe = True if widget is None else bool(widget.value)
return installe, bool(installe or self._default_desktop())
def _vm_tools(self) -> tuple:
"""Clés des outils cochés, dans l'ordre de la liste."""
picked = []
for key, _label, _hint in self._extras["vm_tools"]:
widget = self._widget(f"#f_tool_{key}")
if widget is not None and widget.value:
picked.append(key)
return tuple(picked)
def _tools_for_vm(self, vm, tools) -> list:
return tools_for_vm(vm, tools, self._extras)
def _app_store_needed(self) -> bool:
return app_store_needed(self.vms, self._extras)
def _app_store(self) -> str:
"""Magasin retenu. Sans VM concernée, la réponse est « deb » : elle
ne change rien, et laisser passer « snap » réactiverait snapd pour
rien."""
stores = self._extras["app_stores"]
widget = self._widget("#f_store")
if not stores or widget is None or not self._app_store_needed():
return "deb"
index = widget.pressed_index
if index is None or not (0 <= index < len(stores)):
return stores[0][0]
return stores[index][0]
def _mise_usable(self) -> bool:
return mise_usable(self.vms, self._extras)
def _python_provider(self) -> str:
"""« mise », « pyenv », ou rien — c'est-à-dire « automatique ».
Rien, et surtout pas « pyenv », quand mise n'est servi par aucune
architecture retenue. « mise est indisponible » ne veut pas dire
« l'utilisateur exige pyenv » : la nuance décide de tout, puisqu'un
choix EXPLICITE écarte le Python de la distribution. Sur s390x,
renvoyer « pyenv » forçait la compilation de CPython — celle dont gcc
15.2 ne revient pas."""
widget = self._widget("#f_python")
if widget is None or not self._mise_usable():
return ""
return "pyenv" if widget.pressed_index == 1 else "mise"
def _timezone(self) -> str:
"""Le fuseau des VM. Un champ vidé retombe sur celui de l'hôte plutôt
que sur rien : sans valeur, la VM démarrerait en UTC."""
widget = self._widget("#f_tz")
saisi = widget.value.strip() if widget is not None else ""
return saisi or self._extras["timezone"]
def _extras_disk_gb(self, vm, tools) -> int:
return extras_disk_gb(vm, tools, self._extras)
# ------------------------------------------------------------------ #
# Redessiner
# ------------------------------------------------------------------ #
def _render_store(self) -> None:
"""Grise le choix quand aucune VM ne le concerne, et dit pourquoi."""
widget = self._widget("#f_store")
if widget is None:
return
needed = self._app_store_needed()
_installe, quelque_chose = self._install_state()
widget.disabled = not (needed and quelque_chose)
note = self._widget("#storewarn")
if note is not None:
note.update(
""
if needed
else f" {t('No graphical VM on a snap-based distro.')}"
)
def _render_tools(self) -> None:
"""Grise chaque case qu'AUCUNE VM retenue ne peut recevoir, et NOMME
ce qui sera écarté.
Une case par outil, et non un blocage en bloc : sur un parc de
serveurs les IDE se grisent, la compilation mobile reste offerte.
Cocher Android Studio sur un parc ARM ne produit rien — le dire ici
évite de le découvrir dans le journal d'installation."""
tab = self._extras
if not tab["vm_tools"]:
return
installe, quelque_chose = self._install_state()
for key, _label, _hint in tab["vm_tools"]:
widget = self._widget(f"#f_tool_{key}")
if widget is None:
continue
usable = any(self._tools_for_vm(vm, (key,)) for vm in self.vms)
# Un outil de la phase « après » vit DANS le dépôt ERPLibre :
# sans installation, il n'a rien où s'installer, bureau ou pas.
offert = (
installe
if tab["tool_phases"].get(key) == "after"
else quelque_chose
)
widget.disabled = not (usable and offert)
picked = self._vm_tools()
skipped = sorted(
{
vm["name"]
for vm in self.vms
for k in picked
if k not in self._tools_for_vm(vm, picked)
}
)
note = self._widget("#toolwarn")
if note is not None:
note.update(
f" ⚠ {t('Partly skipped (arch or desktop):')} "
f"{', '.join(skipped)}"
if skipped
else ""
)
def _render_mise(self) -> None:
"""Grise le choix quand aucune VM retenue n'est servie par mise, ou
quand rien ne s'installe, et NOMME les architectures qui retomberont
sur pyenv."""
widget = self._widget("#f_python")
if widget is None:
return
installe, _quelque_chose = self._install_state()
widget.disabled = not (self._mise_usable() and installe)
ecartees = sorted(
{
vm["arch"]
for vm in self.vms
if vm.get("arch") not in self._extras["mise_arches"]
}
)
note = self._widget("#miswarn")
if note is not None:
note.update(
f" ⚠ {t('mise has no binary for:')} "
f"{', '.join(ecartees)} — {t('those VMs use pyenv')}"
if ecartees
else ""
)
def render_extras(self) -> None:
"""Les trois, dans l'ordre. Appelée à la fin de chaque recalcul."""
self._render_mise()
self._render_store()
self._render_tools()
def _sync_install_deps(self) -> None:
"""Grise ce que le choix d'installation rend SANS EFFET.
Trois états et non deux, parce que la commande distante en a trois :
rien du tout, un bureau seul, ou une installation complète. Sans
installation MAIS avec un bureau, le magasin d'applications et les
outils de la phase « avant » servent encore — les griser mentirait
autant que de laisser actif ce qui ne fait rien. La branche, le
profil et l'interpréteur Python, eux, ne servent qu'à l'installation.
Le type de VM et le suivi ne sont jamais grisés : le premier est
l'autre moitié de la décision, le second regarde la VM arriver même
quand rien ne s'installe."""
installe, quelque_chose = self._install_state()
for cible, actif in (
("#f_branch", installe),
("#f_profile_install", installe),
("#f_prod", quelque_chose),
):
widget = self._widget(cible)
if widget is not None:
widget.disabled = not actif
# Le magasin, les outils et l'interpréteur Python ont leur PROPRE
# raison de se griser (architecture, bureau, distribution) : ils
# composent les deux dans « _render_* », qui a le dernier mot.
# Le titre suit ses champs : une section entière se lit inactive d'un
# coup d'œil, au lieu de se déduire de trois widgets ternes.
for cible, actif in (
("#t_store", quelque_chose),
("#t_tools", quelque_chose),
("#t_python", installe),
):
widget = self._widget(cible)
if widget is not None:
widget.set_class(not actif, "off")
# Les rangées portent les mêmes choix, par VM.
for i in range(len(getattr(self, "rows", ()))):
for cible in (f"#v{i}_branch", f"#v{i}_prof"):
widget = self._widget(cible)
if widget is not None:
widget.disabled = not installe
# ------------------------------------------------------------------ #
# Les messages, et la spec
# ------------------------------------------------------------------ #
def extras_on_select(self, event) -> bool:
"""Traite le sélecteur de fuseau. Rend True quand c'est fait, pour
que l'appelant s'arrête là.
« libre… » révèle la saisie ; un fuseau choisi la referme et y
recopie le nom, seule valeur que lit `extras_values` — un seul
endroit porte la réponse."""
if getattr(event.select, "id", "") != "f_tz_sel":
return False
free = event.value is FREE
field = self._widget("#f_tz")
if field is None:
return True
field.display = free
field.disabled = not free
if free:
field.focus()
elif isinstance(event.value, str):
field.value = event.value
return True
def extras_values(self) -> dict:
"""Le fragment de spec que ces réglages produisent."""
return {
"timezone": self._timezone(),
"desktop": self._default_desktop(),
"vm_tools": self._vm_tools(),
"python_provider": self._python_provider(),
"app_store": self._app_store(),
}

View file

@ -591,7 +591,9 @@ def res_row_widgets(index, vm, presets, labels=None, null=None):
return widgets
def run_deploy_progress(jobs, parallelism, run_app: bool = True):
def run_deploy_progress(
jobs, parallelism, run_app: bool = True, ssh_cmds=None, suite=""
):
"""Déploie `jobs` = [(id, nom, argv)] en parallèle, un bloc repliable par
VM. Renvoie [(nom, rc, sortie, durée)]. `run_app=False` renvoie l'app.
@ -680,9 +682,14 @@ def run_deploy_progress(jobs, parallelism, run_app: bool = True):
self.run_job(jid, name, parts)
def _refresh_summary(self):
# CE QUI SUIT, dit ici : l'installation d'ERPLibre ne démarre
# qu'au moment où l'on quitte cet écran, et rien ne le disait —
# on attendait devant une fenêtre pourtant terminée.
fini = self._done >= len(jobs)
apres = f"\n → {suite}" if (fini and suite) else ""
self.query_one("#summary", Static).update(
f" {self._done}/{len(jobs)} — "
f"{fmt_dur(time.time() - self._t0)}"
f"{fmt_dur(time.time() - self._t0)}{apres}"
)
# `thread=True` : subprocess.run est bloquant ; le faire dans un
@ -765,10 +772,11 @@ def run_deploy_progress(jobs, parallelism, run_app: bool = True):
def action_ssh(self) -> None:
"""Entre dans la VM créée, sans quitter l'écran.
Par son NOM et non par son adresse : c'est l'entrée
~/.ssh/config que le déploiement vient d'écrire qui sait comment
l'atteindre — et pour une VM posée sur un hôte Proxmox, elle
porte le rebond, seul chemin vers son réseau interne.
La commande vient de l'APPELANT quand il sait joindre la VM : sur
Proxmox, l'entrée ~/.ssh/config n'est écrite qu'après le
déploiement, et le nom de la VM peut désigner une machine LOCALE
homonyme — « s » ouvrait alors la mauvaise. Le déploiement passe
donc « ssh -J <hôte> user@<ip> », qui ne dépend de rien.
« suspend() » rend le terminal : ssh a besoin du clavier, et
Textual le tient encore.
@ -780,8 +788,10 @@ def run_deploy_progress(jobs, parallelism, run_app: bool = True):
return
# La dernière créée : c'est celle qu'on regarde.
nom = self._reussies[-1]
cmd = (ssh_cmds or {}).get(nom) or f"ssh {shlex_quote(nom)}"
with self.suspend():
os.system(f"ssh {shlex_quote(nom)} || true")
print(f"\n→ {cmd}\n")
os.system(f"{cmd} || true")
# -- presse-papiers (OSC 52 : traverse SSH) --------------------- #
def _copy(self, text, what):

View file

@ -344,6 +344,32 @@ class PlanMixin:
rename_screen()(self.rows[index]["vm"]["name"], auto), done
)
# ---------------------------------------------------------------- #
# Le catalogue — les trois raccourcis de sélection
# ---------------------------------------------------------------- #
def _catalog(self):
from textual.widgets import SelectionList
return self.query_one("#f_catalog", SelectionList)
def action_select_all(self) -> None:
self._catalog().select_all()
def action_select_none(self) -> None:
self._catalog().deselect_all()
def action_select_main(self) -> None:
"""Une VM par distribution : la version marquée par défaut.
Ici et non dans un formulaire : les trois gestes sont les mêmes des
deux côtés, et celui-ci manquait à l'écran Proxmox — qui affiche
pourtant le même catalogue, drapeau « default » compris."""
widget = self._catalog()
widget.deselect_all()
for i, e in enumerate(self._entries()):
if e.get("default"):
widget.select(i)
def _auto_name(self, index):
"""Nom que la VM reprendrait si on effaçait le sien."""
return self._plan_entries()[index]["name"]

View file

@ -39,6 +39,7 @@ from script.todo.deploy_form_lib import (
res_row_widgets,
t,
)
from script.todo.deploy_form_extras import ExtrasMixin
from script.todo.deploy_form_plan import PlanMixin, preview_screen
# Aucun disque orphelin à craindre : les disques d'un Proxmox distant vivent
@ -99,6 +100,15 @@ def build_spec(vms, existants, form):
"start": form["start"],
"add_ssh_config": form["add_ssh_config"],
"install": form["install"],
"python_provider": form.get("python_provider") or "",
# Ce qui décrit le SYSTÈME INVITÉ, et non l'hyperviseur : il valait
# déjà sur libvirt, il vaut ici. Sans ces cinq clés, une VM créée sur
# Proxmox naissait sans bureau, sans outils et en UTC.
"timezone": form.get("timezone") or "",
"desktop": form.get("desktop") or "",
"vm_tools": tuple(form.get("vm_tools") or ()),
"app_store": form.get("app_store") or "deb",
"prod": bool((form.get("install") or {}).get("prod")),
"monitor": form["monitor"],
"parallelism": form["parallelism"],
}
@ -135,6 +145,8 @@ def run_proxmox_form(ctx, run_app: bool = True):
ctx.get("branches") or ["master"], ctx.get("branch_current")
)
profiles = ctx.get("install_profiles") or []
# {clé de saveur: suffixe de nom} — décrit par todo.py, source unique.
desktop_suffixes = dict(ctx.get("desktop_suffixes") or {})
stockages = ctx.get("storages") or []
ponts = ctx.get("bridges") or []
# {système: (libellé, commande)} — ce qu'un système impose d'installer.
@ -149,15 +161,22 @@ def run_proxmox_form(ctx, run_app: bool = True):
}
def entry_label(e):
return f"{e['distro']} {e['version']} [{e['arch']}] {e['name']}"
# L'étoile marque la version par défaut d'une distribution : c'est
# elle que « F7 » retient, et sans repère le raccourci choisissait
# sans qu'on sache quoi.
star = " *" if e.get("default") else ""
return (
f"{e['distro']} {e['version']}{star} [{e['arch']}] {e['name']}"
)
class ProxmoxForm(PlanMixin, App):
class ProxmoxForm(ExtrasMixin, PlanMixin, App):
TITLE = t("Deploy one or more ERPLibre VMs on Proxmox VE!")
BINDINGS = [
("f5", "deploy", t("Deploy")),
("f4", "clear_vm", t("Reset VM")),
("f3", "preview", t("Preview")),
("f6", "select_all", t("All")),
("f7", "select_main", t("Main versions")),
("f8", "select_none", t("None")),
("escape", "cancel", t("Cancel")),
]
@ -191,6 +210,7 @@ def run_proxmox_form(ctx, run_app: bool = True):
self._syncing = False
# La liste des ponts GRANDIT : l'écran sait en créer un.
self._ponts = list(ponts)
self.extras_init(ctx)
# ---------------------------------------------------------------- #
# L'écran
@ -294,6 +314,7 @@ def run_proxmox_form(ctx, run_app: bool = True):
value=True,
id="f_sshcfg",
)
yield from self.compose_vm_type()
# La case commande TOUTE installation — ERPLibre, Odoo,
# mais aussi l'hyperviseur Proxmox VE d'une VM imbriquée.
# Nommée « ERPLibre », elle laissait croire qu'un système
@ -322,6 +343,9 @@ def run_proxmox_form(ctx, run_app: bool = True):
allow_blank=False,
id="f_branch",
)
yield from self.compose_install_extras()
yield from self.compose_timezone()
yield from self.compose_python()
# Hors de la section « Installation » : le suivi regarde la
# VM ARRIVER, même quand rien ne s'installe. Rangé dedans,
# il se serait grisé avec elle.
@ -335,10 +359,21 @@ def run_proxmox_form(ctx, run_app: bool = True):
id="f_monitor",
)
yield Static(f" {t('Parallelism')}")
# Cochée, la case donne une exécution PAR installation :
# le plafond du nombre de CPU ne s'applique plus.
# Décochée, le nombre reprend la main, et son défaut suit
# l'HÔTE PROXMOX — l'écran restait figé à quatre choix et
# en proposait un, quel que soit le nombre de cœurs.
yield Checkbox(
t("One run per install"),
value=True,
id="f_par_all",
)
yield Select(
[(str(n), n) for n in (1, 2, 3, 4)],
value=1,
[(str(n), n) for n in range(1, ctx["host_cpu"] + 1)],
value=ctx["host_cpu"],
allow_blank=False,
disabled=True,
id="f_par",
)
with Vertical(id="right"):
@ -441,6 +476,8 @@ def run_proxmox_form(ctx, run_app: bool = True):
ctx["host_cpu"],
self.custom,
self.overrides,
self._default_desktop(),
desktop_suffixes,
)
# Ce qu'un système IMPOSE d'installer, posé sur le MODÈLE : le
# déploiement lit « install_cmd » VM par VM.
@ -453,12 +490,17 @@ def run_proxmox_form(ctx, run_app: bool = True):
)
# Le supplément d'ERPLibre ne vaut que pour les VM qui l'auront
# vraiment : une VM Proxmox ne clonera pas le dépôt.
if self.query_one("#f_install", Checkbox).value:
commun = (self._install() or {}).get("cmd") or ""
for row in self.rows:
cmd_vm = row["vm"].get("install_cmd") or commun
if cmd_vm.strip() not in no_erplibre:
row["disk_gb"] += ctx.get("extra_disk_gb", 0)
installe = self.query_one("#f_install", Checkbox).value
commun = (self._install() or {}).get("cmd") or ""
outils = self._vm_tools()
for row in self.rows:
cmd_vm = row["vm"].get("install_cmd") or commun
if installe and cmd_vm.strip() not in no_erplibre:
row["disk_gb"] += ctx.get("extra_disk_gb", 0)
# Le bureau et les outils ne pèsent que sur les VM qui les
# reçoivent RÉELLEMENT : Android Studio n'existe qu'en
# x86_64, les extensions GNOME n'ont de sens que sous GNOME.
row["disk_gb"] += self._extras_disk_gb(row["vm"], outils)
for row, entry in zip(self.rows, entries):
cle = entry_key(entry)
row["custom"] = bool(self.overrides.get(cle))
@ -472,6 +514,18 @@ def run_proxmox_form(ctx, run_app: bool = True):
),
)
self._render_plan()
self.render_extras()
def _auto_name(self, index):
"""Le nom du catalogue, suffixé du bureau : ce que la VM
reprendrait si on effaçait le sien."""
from script.todo.deploy_form_lib import vm_name
return vm_name(
self._plan_entries()[index]["name"],
self.rows[index]["vm"].get("desktop"),
desktop_suffixes,
)
def _vmid_start(self):
brut = self.query_one("#f_vmid", Input).value.strip()
@ -616,6 +670,10 @@ def run_proxmox_form(ctx, run_app: bool = True):
self.arch = arches[event.index]
self._reload_catalog()
return
if event.radio_set.id in ("f_type", "f_store"):
# Le bureau pèse sur le disque et change le nom des VM.
self._refresh_after(remonter=True)
return
if event.radio_set.id == "f_profile":
choix = ("1", "2", "3", "4", "custom")[event.index]
self.profile = choix
@ -631,27 +689,17 @@ def run_proxmox_form(ctx, run_app: bool = True):
self._clear_overrides(tuple(RES_FIELDS))
self._refresh_after(remonter=True)
def _sync_install_deps(self) -> None:
"""Grise ce que la case rend sans effet : la branche et le profil.
Le suivi n'en fait pas partie — il regarde la VM arriver même
quand rien ne s'installe."""
installe = self.query_one("#f_install", Checkbox).value
for cible in ("#f_profile_install", "#f_branch"):
try:
self.query_one(cible).disabled = not installe
except Exception:
pass
try:
self.query_one("#t_install").set_class(not installe, "off")
except Exception:
pass
def on_checkbox_changed(self, event) -> None:
if event.checkbox.id == "f_install":
# Le disque d'ERPLibre entre — ou sort — du total.
self._sync_install_deps()
self._refresh_after()
elif event.checkbox.id == "f_par_all":
self.query_one("#f_par", Select).disabled = event.value
elif str(event.checkbox.id or "").startswith("f_tool_"):
# Un IDE de plus, c'est un disque plus grand : le plan doit
# le montrer AVANT de déployer, pas après une heure.
self._refresh_after()
def on_input_changed(self, event) -> None:
if event.input.id == "f_vmid":
@ -674,6 +722,8 @@ def run_proxmox_form(ctx, run_app: bool = True):
def on_select_changed(self, event) -> None:
if self._syncing:
return
if self.extras_on_select(event):
return
ident = event.select.id or ""
# La marque de génération ne concerne QUE les widgets de rangée :
# un widget global n'en porte pas. L'exiger de tous revenait à
@ -759,6 +809,9 @@ def run_proxmox_form(ctx, run_app: bool = True):
)
return {
"branch": self.query_one("#f_branch", Select).value,
# /opt et service confiné, comme en QEMU/KVM : le choix ne
# regarde pas l'hyperviseur, il regarde le système invité.
"prod": self.query_one("#f_prod", Checkbox).value,
"label": label,
"cmd": cmd,
}
@ -775,10 +828,18 @@ def run_proxmox_form(ctx, run_app: bool = True):
"start": self.query_one("#f_start", Checkbox).value,
"add_ssh_config": self.query_one("#f_sshcfg", Checkbox).value,
"install": self._install(),
**self.extras_values(),
# Le suivi est demandé au NIVEAU DU DÉPLOIEMENT : une VM sans
# ERPLibre se suit aussi (cloud-init, puis relevé système).
"monitor": self.query_one("#f_monitor", Checkbox).value,
"parallelism": self.query_one("#f_par", Select).value,
# Une exécution par installation : le nombre de VM retenues
# fait foi. Le déploiement le borne ensuite à ce même nombre,
# donc une valeur haute ne crée aucun travailleur inutile.
"parallelism": (
max(1, len(self.vms))
if self.query_one("#f_par_all", Checkbox).value
else self.query_one("#f_par", Select).value
),
}
def action_deploy(self) -> None:
@ -817,12 +878,6 @@ def run_proxmox_form(ctx, run_app: bool = True):
self.overrides.pop(cle, None)
self._refresh_after(remonter=True)
def action_select_all(self) -> None:
self.query_one("#f_catalog", SelectionList).select_all()
def action_select_none(self) -> None:
self.query_one("#f_catalog", SelectionList).deselect_all()
def action_cancel(self) -> None:
self.result = None
self.exit()

View file

@ -435,12 +435,17 @@ class ProxmoxMenuMixin:
if not brut:
print(t("Nothing selected."))
return
noms = [vm["name"] for vm in vms]
if brut in ("all", "*"):
choisies = list(vms)
else:
retenus = set(self._parse_index_selection(brut, noms))
choisies = [vm for vm in vms if vm["name"] in retenus]
# Par RANG, jamais par nom : deux VM du même hôte peuvent
# porter le même nom (seul le VMID est unique sur Proxmox), et
# cocher l'une éteignait les deux.
rangs = self._parse_index_selection(
brut, [str(i) for i in range(1, len(vms) + 1)]
)
voulus = {int(r) for r in rangs if str(r).isdigit()}
choisies = [vm for i, vm in enumerate(vms, 1) if i in voulus]
if not choisies:
print(t("Nothing selected."))
return
@ -828,6 +833,11 @@ class ProxmoxMenuMixin:
# La branche du dépôt : c'est elle qu'on déploie le plus souvent.
"branch_current": self._qemu_repo_branch(),
"install_profiles": self._qemu_install_profiles(),
# Type de VM, magasin d'applications, outils, fuseau,
# interpréteur Python : les réglages du système INVITÉ, qui ne
# regardent pas l'hyperviseur. Cet écran n'en portait que trois —
# une VM créée ici naissait sans bureau, sans outils et en UTC.
**self._qemu_guest_context(),
# Même règle qu'en QEMU/KVM : un système peut IMPOSER ce qu'on
# installe dessus. Un Proxmox imbriqué recevait sinon ERPLibre et
# Odoo 18, comme l'écran d'à côté avant correction.
@ -869,6 +879,38 @@ class ProxmoxMenuMixin:
code, out = self._pve_show("hostname", quiet=True)
return out.strip().splitlines()[0] if code == 0 and out.strip() else ""
def _pve_disk_with_margin(self, vm, spec):
"""Taille du disque à créer : celle du plan, marge comprise.
La même règle que la voie libvirt, qui ajoute ERPLIBRE_EXTRA_DISK_GB à
la demande initiale quand ERPLibre s'installe. Ici la marge se perdait
entre l'écran et « qm resize ».
"""
from script.todo.deploy_form_extras import (
extras_disk_gb,
extras_tables,
)
demande = vm.get("disk") or ""
gigs = self._parse_disk_gb(demande)
if not gigs:
return demande
install = spec.get("install") or {}
cmd = vm.get("install_cmd") or install.get("cmd") or ""
marge = 0
if self._qemu_installs_erplibre(install.get("branch"), cmd):
marge += self.ERPLIBRE_EXTRA_DISK_GB
# Le bureau et les outils pèsent aussi, et sur la VM QUI LES REÇOIT :
# une VM ARM n'aura pas Android Studio, un serveur aucun des IDE. Le
# plan les additionne déjà à l'écran ; sans eux ici, la VM naissait
# avec le disque d'un serveur nu et GNOME le remplissait.
marge += extras_disk_gb(
dict(vm, desktop=vm.get("desktop") or spec.get("desktop") or ""),
spec.get("vm_tools") or (),
extras_tables(self._qemu_guest_context()),
)
return f"{gigs + marge}G" if marge else demande
def _pve_vm_commands(self, mod, vm, spec):
"""Les commandes de création d'UNE VM, dans l'ordre : l'image puis
« qm ». Sert à l'aperçu comme à l'exécution — un aperçu qui ne
@ -884,7 +926,12 @@ class ProxmoxMenuMixin:
"name": vm["name"],
"memory": vm["ram"],
"vcpus": vm["vcpus"],
"disk": vm["disk"],
# La MARGE d'ERPLibre entre dans la taille réellement créée : le
# plan l'annonçait (« 25G » pour un catalogue à 20 G) et « qm
# resize » recevait 20 G. La VM naissait cinq gigaoctets trop
# petite pour ce qu'on venait de lui promettre — trouvé par
# l'audit, pas à l'usage.
"disk": self._pve_disk_with_margin(vm, spec),
"storage": spec["storage"],
"bridge": spec["bridge"],
"image": image,
@ -967,7 +1014,33 @@ class ProxmoxMenuMixin:
# sortie ; celle-ci les écrit, ce qui vaut mieux qu'un défilement.
session = self._pve_log_dir()
print(f"\n {t('Log:')} {session}")
resultats = run_deploy_progress(travaux, spec.get("parallelism") or 1)
# Comment joindre chaque VM SANS dépendre de ~/.ssh/config, qui n'est
# écrit qu'après : par le rebond de l'hôte, explicitement. C'est ce que
# « s » utilise dans la vue de progression — sans quoi il partait sur
# le nom de la VM, donc sur une locale homonyme (rapporté).
cibles_ssh = {}
for vm in spec["vms"]:
ip = pve.ip_from_ipconfig(vm.get("ipconfig") or "")
if ip:
compte = (spec.get("user") or "erplibre") + "@" + ip
cibles_ssh[vm["name"]] = (
f"ssh -J {shlex.quote(host['target'])} "
f"{shlex.quote(compte)}"
)
# Ce qui attend derrière cet écran : sans le dire, on reste devant
# une fenêtre « terminée » sans savoir que l'installation d'ERPLibre
# démarre en la quittant.
suite = ""
if spec.get("install"):
suite = t("Quit (q) to start the ERPLibre install")
elif spec.get("monitor", True):
suite = t("Quit (q) to follow the VM starting up")
resultats = run_deploy_progress(
travaux,
spec.get("parallelism") or 1,
ssh_cmds=cibles_ssh,
suite=suite,
)
reussies = [nom for nom, code, _o, _d in resultats if code == 0]
for nom, code, sortie, duree in resultats:
chemin = self._pve_write_log(
@ -990,7 +1063,8 @@ class ProxmoxMenuMixin:
print(f" {ligne}")
if not reussies:
return
self._pve_after_create(host, spec, reussies, cle_locale)
joignables = self._pve_after_create(host, spec, reussies, cle_locale)
self._pve_print_summary(spec, joignables or [], session)
@staticmethod
def _pve_log_dir():
@ -1047,6 +1121,178 @@ class ProxmoxMenuMixin:
fh.write("\n".join(entete) + "\n")
return chemin
def _pve_alias_names(self, nom, chaine, locaux, rebond=""):
"""UN seul nom pour l'entrée ~/.ssh/config, et lequel.
Deux noms sur la même ligne « Host » — le court et le chaîné
« hôte+vm » — étaient un doublon dès que le court était libre : ssh
n'a besoin que d'un nom, et le second n'ajoutait qu'une façon de plus
d'écrire la même adresse. Rapporté.
Le court quand il est LIBRE, c'est celui qu'on tape ; le chaîné
sinon, car un nom déjà pris désigne une AUTRE machine — une VM locale
du même nom, ou la VM d'un autre hôte Proxmox. Vécu : « ssh » partait
vers la machine locale qui partageait le nom.
« Pris » se juge sur le ProxyJump du bloc et non sur sa seule
présence : notre propre entrée, réécrite à chaque déploiement, se
serait autrement prise pour une rivale — et le nom aurait basculé
d'une fois sur l'autre.
Rend (noms, volé) — `volé` nomme ce qui a forcé le nom chaîné, pour
que l'appelant le dise plutôt que de laisser la surprise."""
if nom in locaux:
return [chaine], t("a local VM")
bloc = self._ssh_config_block(nom)
notre = (
not bloc
or chaine in bloc.get("names", ())
or (rebond and bloc.get("proxyjump") == rebond)
)
return ([nom], "") if notre else ([chaine], "~/.ssh/config")
def _pve_set_timezone(self, cible, spec):
"""Pose le fuseau DANS la VM, par ssh.
La voie libvirt le donne à cloud-init, qui écrit /etc/timezone au
premier démarrage. « qm set » n'a pas d'équivalent : le cloud-init de
Proxmox ne règle que l'utilisateur, la clé et le réseau. Une VM créée
ici restait donc en UTC — et on ne s'en aperçoit qu'aux horodatages,
parfois des jours plus tard.
AVANT l'installation, pour que le journal porte déjà la bonne heure.
Un nom IANA, jamais un décalage : « UTC-5 » ne dit rien de l'heure
d'été, et timedatectl le refuse.
"""
fuseau = (spec.get("timezone") or "").strip()
if not fuseau:
return False
code, sortie = self._pve_ssh(
cible, f"sudo timedatectl set-timezone {shlex.quote(fuseau)}"
)
if code:
# Nommé et non tu : la VM reste en UTC, et c'est une surprise
# qu'on veut avoir maintenant plutôt qu'au premier journal.
print(f" ⚠ {t('timezone not set')} : {fuseau} ({code})")
return False
print(f" ✓ {t('Timezone')} : {fuseau}")
return True
def _pve_write_guide(self, cible, vm, spec, mod):
"""Pose le guide de connexion et l'identité git DANS la VM.
La voie libvirt les livre par le « write_files » de cloud-init ;
« qm set » n'offre pas cela, donc une VM Proxmox n'avait AUCUN guide —
quelle que soit sa distribution. Rapporté sur Arch.
Même contenu, livrée par ssh une fois la VM debout : `guide_files` est
la source unique, comme sa docstring le promet. Un seul appel, tous les
fichiers.
"""
import types
from script.todo.todo_i18n import get_lang
install = spec.get("install") or {}
cmd_install = vm.get("install_cmd") or install.get("cmd") or ""
args = types.SimpleNamespace(
distro=vm.get("distro") or "",
version=vm.get("version") or "",
arch=vm.get("arch") or "amd64",
lang=get_lang(),
# La section ERPLibre n'apparaît que si ERPLibre y sera : un guide
# qui annonce un dépôt absent est un guide qui ment.
erplibre_dir=(
self._qemu_guide_dir(False)
if self._qemu_installs_erplibre(
install.get("branch"), cmd_install
)
else ""
),
erplibre_make=self._qemu_make_target(cmd_install),
desktop=bool(vm.get("desktop")),
no_git_identity=False,
user=spec.get("user") or "erplibre",
)
try:
fichiers = mod.guide_files(args)
except Exception as exc: # pragma: no cover - dépend du module
print(f" ⚠ {t('guide not written')} : {exc}")
return False
morceaux = []
for chemin, mode, contenu, proprio in fichiers:
q = shlex.quote(chemin)
morceaux.append(
f"printf '%s' {shlex.quote(contenu)} | sudo tee {q} "
f">/dev/null && sudo chmod {mode} {q}"
)
if proprio:
morceaux.append(f"sudo chown {shlex.quote(proprio)}: {q}")
code, _o = self._pve_ssh(cible, " && ".join(morceaux))
if code:
print(f" ⚠ {t('guide not written')} ({code})")
return False
print(f" ✓ {t('connection guide written')}")
return True
@staticmethod
def _pve_ssh(cible, remote, timeout=60):
"""(code, sortie) d'une commande exécutée DANS la VM, par son alias.
Par l'alias et non par l'adresse : lui seul porte le rebond vers le
réseau interne de l'hôte."""
from script.proxmox import proxmox_deploy as pve
argv = [
"ssh",
"-o",
"BatchMode=yes",
"-o",
"StrictHostKeyChecking=no",
"-o",
"UserKnownHostsFile=/dev/null",
"-o",
"ConnectTimeout=10",
cible,
remote,
]
try:
res = subprocess.run(
argv, capture_output=True, text=True, timeout=timeout
)
except (OSError, subprocess.SubprocessError) as exc:
return 255, str(exc)
return res.returncode, pve.strip_ssh_noise(
(res.stdout or "") + (res.stderr or "")
)
def _pve_print_summary(self, spec, joignables, session):
"""Sommaire final : ce qui existe, où, et comment y entrer.
Le pendant de celui de QEMU/KVM. Sans lui, l'écran se refermait sur la
vue de progression et il ne restait rien à l'écran — ni l'adresse, ni
la commande ssh, ni le chemin du journal."""
print(f"\n{'═' * 60}")
print(f" {t('TOTAL summary')}")
print(
f" {t('VMs deployed:')} {len(joignables)}/{len(spec['vms'])}"
f" {t('storage')} {spec.get('storage')}"
f" {t('bridge')} {spec.get('bridge')}"
)
for vm in joignables:
print(
f" {vm['name']:<32} {t('VMID')} {vm.get('vmid', '?'):<6}"
f" {vm.get('adresse', '?')}"
)
if vm.get("alias"):
print(f" ssh {vm['alias']}")
if spec.get("install"):
print(
f" {t('Install:')} {spec['install'].get('label') or ''}"
f" ({spec['install'].get('branch')})"
)
print(f" {t('Log:')} {session}")
def _pve_confirm_spec(self, host, spec):
"""Récapitulatif puis confirmation, dans le TERMINAL.
@ -1081,6 +1327,25 @@ class ProxmoxMenuMixin:
entrer dans une VM qui n'est pas sur notre réseau."""
from script.proxmox import proxmox_deploy as pve
# Les domaines LOCAUX : un nom partagé avec l'un d'eux fait dérailler
# l'alias ssh et le suivi d'installation.
locaux = set(self._qemu_list_domains())
try:
mod_qemu = self._qemu_import_module()
except Exception:
mod_qemu = None
def alias_chaine(nom):
"""« hôte+vm », la convention déjà utilisée pour les VM
imbriquées : elle dit où la machine vit, et n'entre en conflit
avec rien."""
hote = (host.get("target") or "").split("@")[-1]
hote = re.sub(r"[^A-Za-z0-9._-]", "-", hote) or "pve"
return f"{hote}+{nom}"
# {nom de VM: alias à utiliser} — le suivi doit passer par l'alias
# qu'on a RÉELLEMENT écrit, pas par le nom.
alias = {}
joignables = []
for vm in spec["vms"]:
if vm["name"] not in reussies:
@ -1095,6 +1360,22 @@ class ProxmoxMenuMixin:
)
continue
print(f" ✓ {vm['name']} : {ip}")
# Un nom qui existe DÉJÀ comme domaine local est un piège : l'alias
# ~/.ssh/config serait volé à la VM locale, et le suivi
# d'installation — qui ré-résout par virsh — irait installer
# ERPLibre sur ELLE. Vécu : « erplibre-ubuntu-2604 » déployée sur
# Proxmox, installation partie sur la VM locale du même nom.
noms_alias, vole = self._pve_alias_names(
vm["name"],
alias_chaine(vm["name"]),
locaux,
host["target"],
)
if vole:
print(
f" ⚠ {t('This name is already taken by')} {vole} :"
f" {t('the alias goes to')} {noms_alias[0]}"
)
# L'entrée ~/.ssh/config est le SEUL chemin vers cette VM : elle
# est derrière l'hôte Proxmox (pont interne), donc son adresse
# n'est pas routable d'ici et seul le rebond y mène. Décochée
@ -1106,22 +1387,34 @@ class ProxmoxMenuMixin:
print(f" → {t('~/.ssh/config written anyway (install)')}")
if spec.get("add_ssh_config") or besoin:
self._write_ssh_config_entry(
vm["name"],
noms_alias,
spec.get("user") or "erplibre",
ip,
identity_file=self._ssh_private_key(cle_locale),
proxy_jump=host["target"],
)
print(f" ✓ ~/.ssh/config : ssh {vm['name']}")
alias[vm["name"]] = noms_alias[0]
print(f" ✓ ~/.ssh/config : ssh {noms_alias[0]}")
vm["adresse"] = ip
vm["alias"] = alias.get(vm["name"], vm["name"])
# Le guide AVANT l'installation : il doit être là même si rien ne
# s'installe, et l'installation ne le touche pas.
if vm["alias"] and mod_qemu:
self._pve_write_guide(vm["alias"], vm, spec, mod_qemu)
if vm["alias"]:
self._pve_set_timezone(vm["alias"], spec)
joignables.append(vm)
install = spec.get("install")
# Rendu à l'appelant pour son sommaire : lui seul sait ce qui a été
# RÉELLEMENT joint.
resultat = list(joignables)
# Le suivi vient du DÉPLOIEMENT, pas de l'installation — même règle
# qu'en QEMU/KVM. Sans elle, la case « Suivre l'installation » ne
# commandait rien : décochée, le tableau de bord s'ouvrait quand
# même ; cochée sans rien à installer, il ne s'ouvrait jamais.
suivi = spec.get("monitor", True)
if not joignables or not (install or suivi):
return
return resultat
noms = [vm["name"] for vm in joignables]
if install:
print(f" {install.get('label') or ''}")
@ -1149,6 +1442,10 @@ class ProxmoxMenuMixin:
"sudo": host.get("sudo") or "",
"jump": host.get("jump") or "",
"vmid": vm.get("vmid"),
# L'adresse INTERNE : elle n'est pas routable d'ici, mais
# elle l'est depuis l'hôte. Avec le rebond, le tableau de
# bord entre dans la VM sans dépendre de ~/.ssh/config.
"addr": vm.get("adresse") or "",
}
for vm in joignables
if vm.get("vmid")
@ -1156,11 +1453,28 @@ class ProxmoxMenuMixin:
self._qemu_install_erplibre_monitored(
noms,
branche,
{n: n for n in noms},
{n: alias.get(n, n) for n in noms},
finale,
# Les réglages du système invité, qui n'atteignaient pas la
# commande distante : la VM naissait serveur nu, sans outils.
prod=bool(spec.get("prod")),
desktop=spec.get("desktop") or "",
python_provider=spec.get("python_provider") or "",
app_store=spec.get("app_store") or "deb",
vm_tools=spec.get("vm_tools") or (),
pve=cartes_pve,
# Ce que sont ces VM, pris de la SPEC. Le suivi le demandait
# à virsh, qui ne connaît que les domaines d'ici.
meta={
vm["name"]: (
vm.get("distro"),
vm.get("version"),
vm.get("arch") or "amd64",
)
for vm in joignables
},
)
return
return resultat
# Sans suivi mais avec quelque chose à installer : en série, sortie à
# l'écran. C'est le pendant exact de la voie QEMU/KVM.
print(f"\n{t('Installing ERPLibre on each VM')} ({branche})…")
@ -1169,10 +1483,15 @@ class ProxmoxMenuMixin:
vm["name"],
cle_locale,
branche,
pve.ip_from_ipconfig(vm.get("ipconfig") or "") or vm["name"],
alias.get(vm["name"], vm["name"]),
vm.get("install_cmd") or commun,
False,
bool(spec.get("prod")),
desktop=spec.get("desktop") or "",
python_provider=spec.get("python_provider") or "",
app_store=spec.get("app_store") or "deb",
vm_tools=spec.get("vm_tools") or (),
)
return resultat
def _pve_deploy_prompts(self, dry_run=False):
"""Déploie une VM SUR l'hôte Proxmox choisi, par questions.
@ -1297,28 +1616,55 @@ class ProxmoxMenuMixin:
if not ip:
print(f" ⚠ {t('No address yet. Try [6] later.')}")
return
print(f" ✓ {nom} : {ip}")
# Entrée ~/.ssh/config avec l'hôte Proxmox en REBOND : c'est ce qui
# rend la VM joignable d'ici, et c'est aussi ce qui permet au suivi
# d'installation d'y entrer (il reçoit l'alias, pas l'IP).
self._write_ssh_config_entry(
nom,
"erplibre",
ip,
identity_file=self._ssh_private_key(cle_locale),
proxy_jump=host["target"],
)
print(f" ✓ ~/.ssh/config : ssh {nom}")
# ÉPILOGUE COMMUN avec l'écran, au lieu de le redire ici : cette voie
# avait vieilli en silence — pas de protection de l'alias contre un
# domaine local homonyme, pas de guide de connexion, pas de bloc
# « pve » (donc aucune colonne vivante dans le suivi), pas de
# sommaire. Trouvé par l'audit, jamais à l'usage.
install = None
if self._is_yes_default_yes(
input(f"\n{t('Install ERPLibre on it? (Y/n): ')}")
):
branch = self._qemu_pick_branch()
label, cmd = self._qemu_pick_install_profile(distro)
print(f" {label}")
# L'ALIAS, pas l'IP : ssh y lit le ProxyJump de ~/.ssh/config.
self._qemu_install_erplibre_monitored(
[nom], branch, {nom: nom}, cmd
)
install = {"branch": branch, "cmd": cmd, "label": label}
spec_finale = {
"host": host,
"storage": stockage,
"bridge": pont,
"res_label": "",
"vms": [
{
"name": nom,
"vmid": vmid,
"distro": distro,
"version": version,
"arch": arch,
"ram": memoire,
"vcpus": vcpus,
"disk": disque,
"desktop": "",
"install_cmd": "",
"ipconfig": ipconfig,
}
],
"existing": [],
"user": "erplibre",
"ssh_key": cle_locale or "",
"add_ssh_config": True,
"install": install,
"monitor": True,
"python_provider": "",
# La voie par questions ne demande pas le fuseau — l'écran le
# fait. Sans ce défaut, elle laissait la VM en UTC, alors que la
# voie libvirt reprend le fuseau de l'hôte depuis toujours.
"timezone": self._qemu_host_timezone(),
}
joignables = self._pve_after_create(
host, spec_finale, [nom], cle_locale
)
self._pve_print_summary(spec_finale, joignables or [], "")
def _pve_ssh_config(self):
"""Écrit une entrée ~/.ssh/config par VM de l'hôte, avec l'hôte
@ -1332,20 +1678,36 @@ class ProxmoxMenuMixin:
print(f"\n{t('No running VM on this Proxmox host.')}")
return
cle = self._ssh_private_key(self._qemu_default_ssh_key())
# Les domaines LOCAUX : un nom partagé avec l'un d'eux ne doit pas lui
# voler son alias — même règle que le déploiement.
locaux = set(self._qemu_list_domains())
hote_court = (host.get("target") or "").split("@")[-1]
hote_court = re.sub(r"[^A-Za-z0-9._-]", "-", hote_court) or "pve"
for vm in vms:
ip = self._pve_guest_ip(vm["vmid"], attente=0)
if not ip:
print(f" ⚠ {vm['name']} : {t('no address, skipped')}")
continue
self._write_ssh_config_entry(
noms, vole = self._pve_alias_names(
vm["name"],
f"{hote_court}+{vm['name']}",
locaux,
host["target"],
)
if vole:
print(
f" ⚠ {t('This name is already taken by')} {vole} :"
f" {t('the alias goes to')} {noms[0]}"
)
self._write_ssh_config_entry(
noms,
"erplibre",
ip,
identity_file=cle,
proxy_jump=host["target"],
)
print(
f" ✓ ssh {vm['name']} ({ip} {t('through')} {host['target']})"
f" ✓ ssh {noms[0]} ({ip} {t('through')} {host['target']})"
)
def _pve_test_vm(self):

View file

@ -735,6 +735,9 @@ class QemuAccessMixin:
# Les commandes de réparation se lancent SUR l'hyperviseur : le préfixe
# évite de les copier sur la mauvaise machine, l'erreur naturelle ici.
pre = f"ssh {jump} " if jump else ""
if not port and self._hypervisor_is_proxmox(jump):
self._pve_console_hint(jump, domain)
return
if not port:
print(f"\n ⚠ {t('This VM exposes no VNC port.')}")
print(f" {t('Its display is likely spice with listen=none:')}")
@ -772,6 +775,55 @@ class QemuAccessMixin:
print(f" {t('then point your VNC client at')} localhost:{port}")
print(f" {t('The tunnel stays open as long as that ssh runs.')}")
@staticmethod
def _hypervisor_is_proxmox(jump) -> bool:
"""Cet hyperviseur est-il un Proxmox VE ?
La question se pose quand « virsh vncdisplay » n'a rien rendu : un
Proxmox n'a PAS de libvirt, donc l'absence de port n'y veut pas dire
« écran fermé », elle veut dire « mauvaise question ». Sans cette
distinction, on conseillait « virsh edit » sur une machine où la
commande n'existe pas.
« qm » et non « pveversion » : c'est le binaire dont on parle ensuite.
"""
if not jump:
# Un Proxmox n'est jamais l'hôte local ici : ce menu tourne sur le
# poste de travail, et un Proxmox se joint par ssh.
return False
try:
res = subprocess.run(
["ssh", "-o", "BatchMode=yes", jump, "command -v qm"],
capture_output=True,
text=True,
timeout=25,
)
except (OSError, subprocess.SubprocessError):
return False
return res.returncode == 0 and bool(res.stdout.strip())
def _pve_console_hint(self, jump, domain) -> None:
"""Les deux vraies façons de voir l'écran d'une VM Proxmox.
Proxmox ne sert pas son écran par un port VNC qu'on tunnelise : il le
sert par un TICKET, sur son interface web (« qm vncproxy » ouvre un
websocket authentifié, pas un socket qu'on relaie). Un « ssh -L » vers
un port VNC n'y trouve rien, quel que soit le port."""
print(
f"\n ⚠ {t('This hypervisor is Proxmox VE: it has no libvirt.')}"
)
print(f" {t('Its screen is served by a ticket, not by a VNC port.')}")
print(f"\n {t('Two ways in:')}")
print(f" • {t('the serial console, VMID from the Proxmox menu:')}")
print(f" ssh {jump} sudo qm list # {domain}")
print(f" ssh -t {jump} sudo qm terminal <VMID>")
print(f" • {t('the web interface, through a tunnel:')}")
print(f" ssh -N -L 8006:127.0.0.1:8006 {jump}")
print(f" https://localhost:8006 → {t('VM')} → Console")
print(
f"\n {t('TODO > Execute > Deploy > Proxmox VE does the first.')}"
)
@staticmethod
def _qemu_vnc_port(domain, jump=""):
"""Port VNC réel d'un domaine, localement ou sur un hyperviseur distant.

View file

@ -271,6 +271,7 @@ class QemuDeployMixin:
app_store="deb",
vm_tools=(),
pve=None,
meta=None,
):
"""Lance l'install ERPLibre en parallèle DÉTACHÉE sur les VM et ouvre
le dashboard Textual. Quitter le dashboard n'arrête pas les installs.
@ -279,7 +280,10 @@ class QemuDeployMixin:
`prod` : install /opt/erplibre + service SELinux confiné.
`vm_tools` : outils cochés pour tout le parc, filtrés machine par
machine (Android Studio n'existe qu'en x86_64, les extensions GNOME
n'ont pas de sens sous Cinnamon)."""
n'ont pas de sens sous Cinnamon).
`meta` : {nom: (distro, version, arch)} quand l'appelant SAIT ce que
sont ces VM. Sans elle, on le demande à virsh — juste ici, donc faux
pour une VM qui vit sur un Proxmox distant."""
from script.todo.qemu_install_monitor import (
launch_installs,
run_monitor,
@ -316,7 +320,13 @@ class QemuDeployMixin:
for name in names:
ip = ip_map.get(name)
if ip:
d, v, a = (
# Ce que l'appelant sait d'abord. Sinon virsh — mais virsh
# ne connaît QUE les domaines d'ici : sur une VM posée sur un
# Proxmox distant il ne répond rien, ou pire, il répond pour
# un domaine local qui porte le même nom. L'architecture
# décide des outils installés : une VM ARM prise pour x86_64
# recevait Android Studio, qui n'existe pas pour elle.
d, v, a = (meta or {}).get(name) or (
self._qemu_vm_meta(name, mod)
if mod
else (None, None, None)
@ -1266,7 +1276,6 @@ class QemuDeployMixin:
if self._qemu_distro_profile(d)
},
"ssh_key": self._qemu_default_ssh_key(),
"timezone": self._qemu_host_timezone(),
"host_cpu": os.cpu_count() or 2,
"free_ram": self._host_free_ram_mb(),
# La place du système de fichiers qui portera les qcow2. Mesurée
@ -1279,47 +1288,7 @@ class QemuDeployMixin:
"ram_presets": self._QEMU_RAM_PRESETS,
"disk_presets": self._QEMU_DISK_PRESETS,
"extra_disk_gb": self.ERPLIBRE_EXTRA_DISK_GB,
"desktop_disk_gb": self.QEMU_DESKTOP_EXTRA_DISK_GB,
"mise_arches": self.QEMU_MISE_ARCHES,
"app_stores": [(k, t(lbl)) for k, lbl in self.QEMU_APP_STORES],
"timezones": self._qemu_timezone_choices(
self._qemu_host_timezone()
),
"snap_distros": self.QEMU_SNAP_DISTROS,
"vm_tools": self._qemu_vm_tool_choices(),
"vm_tool_disk": {
k: v["disk_gb"] for k, v in self._QEMU_VM_TOOLS.items()
},
"vm_tool_arches": {
k: v["arches"] for k, v in self._QEMU_VM_TOOLS.items()
},
"vm_tool_desktops": {
k: v["desktops"] for k, v in self._QEMU_VM_TOOLS.items()
},
# « after » = l'outil vit DANS le dépôt ERPLibre (compilation
# mobile, AVD, script Forgejo) : sans installation, il n'existe
# pas, et la commande distante le saute en le nommant.
"vm_tool_phases": {
k: v.get("phase", "before")
for k, v in self._QEMU_VM_TOOLS.items()
},
"vm_tool_needs_desktop": {
k: v["needs_desktop"] for k, v in self._QEMU_VM_TOOLS.items()
},
"vm_tool_families": {
k: v["families"] for k, v in self._QEMU_VM_TOOLS.items()
},
"distro_family": dict(self._QEMU_DISTRO_FAMILY),
"desktop_suffixes": self._qemu_desktop_suffixes(),
"desktops": [
(k, v["label"]) for k, v in self._QEMU_DESKTOP.items()
],
"defaults": {
"install": True,
"add_ssh_config": True,
"monitor": True,
"prod": False,
},
**self._qemu_guest_context(),
# L'aperçu passe par le MÊME constructeur que le déploiement.
"build_command": lambda vm, spec, dry: " ".join(
shlex.quote(p)

View file

@ -70,6 +70,7 @@ from script.todo.deploy_form_lib import ( # noqa: F401
# Le socle commun aux deux formulaires (QEMU/KVM et Proxmox VE). Réexporté
# tel quel : les appelants historiques importent encore ces noms ICI.
from script.todo.deploy_form_extras import ExtrasMixin
from script.todo.deploy_form_plan import ( # noqa: F401
PlanMixin,
preview_screen,
@ -127,33 +128,11 @@ def run_deploy_form(ctx, run_app: bool = True):
total_disk = ctx.get("total_disk") or 0
base_vcpus = ctx.get("base_vcpus") or 2
extra_disk = ctx.get("extra_disk_gb") or 0
desktop_disk = ctx.get("desktop_disk_gb") or 0
# [(clé, libellé)] — la liste vient de todo.py, source unique.
desktops = list(ctx.get("desktops") or [])
# {clé de saveur: suffixe de nom}, fourni par todo.py qui décrit les
# saveurs — on ne le redéfinit pas ici.
desktop_suffixes = dict(ctx.get("desktop_suffixes") or {})
# Outils de développement d'une VM graphique : [(clé, libellé, indice)] et
# leurs contraintes, toutes décrites dans todo.py — le formulaire ne fait
# que les afficher et rendre les cases cochées.
vm_tools = list(ctx.get("vm_tools") or [])
tool_disk = dict(ctx.get("vm_tool_disk") or {})
# « after » = l'outil vit DANS le dépôt ERPLibre : sans installation, il
# n'a rien où s'installer, bureau ou pas.
tool_phases = dict(ctx.get("vm_tool_phases") or {})
tool_arches = dict(ctx.get("vm_tool_arches") or {})
tool_desktops = dict(ctx.get("vm_tool_desktops") or {})
tool_needs_desktop = dict(ctx.get("vm_tool_needs_desktop") or {})
tool_families = dict(ctx.get("vm_tool_families") or {})
distro_family = dict(ctx.get("distro_family") or {})
# Architectures pour lesquelles mise publie un binaire.
mise_arches = set(ctx.get("mise_arches") or ())
# [(clé, libellé)] des magasins d'applications, et les distributions qui
# livrent snapd — la question n'a de sens que pour celles-là, graphiques.
app_stores = list(ctx.get("app_stores") or [])
snap_distros = set(ctx.get("snap_distros") or ())
# Fuseaux proposés, celui de l'hôte en tête (voir todo.py).
timezones = list(ctx.get("timezones") or [])
defaults = ctx.get("defaults") or {}
result = {"spec": None}
@ -169,7 +148,7 @@ def run_deploy_form(ctx, run_app: bool = True):
f"RAM≥{e['ram']}Mo {e['disk']}"
)
class DeployForm(PlanMixin, App):
class DeployForm(ExtrasMixin, PlanMixin, App):
# Le socle porte la mise en page et les modales ; ne reste ici que ce
# qui nomme les widgets propres à QEMU/KVM.
CSS = (
@ -215,6 +194,7 @@ def run_deploy_form(ctx, run_app: bool = True):
self._syncing = False
# Jeu de VM actuellement monté dans le panneau droit.
self._shown_ids = ()
self.extras_init(ctx)
# Génération du jeu de rangées monté. Les identifiants de widgets
# portent un RANG, et le rang se décale quand on coche ou décoche
# une entrée : un événement émis par un widget déjà détruit
@ -304,20 +284,7 @@ def run_deploy_form(ctx, run_app: bool = True):
f"\n {t('A VM edited on the right (marked) keeps its own.')}",
id="scopetarget",
)
# Serveur par défaut : c'est ce que sert une image cloud,
# et GNOME ajoute une à deux heures sur une architecture
# émulée. Le plan annonce le surcoût disque.
yield Static(t("VM type (default):"), classes="grouptitle")
with RadioSet(id="f_type"):
yield RadioButton(
t("Server (no graphical interface)"),
value=not defaults.get("desktop", ""),
)
for key, label in desktops:
yield RadioButton(
f"{t('Graphical (server + desktop):')} {label}",
value=defaults.get("desktop", "") == key,
)
yield from self.compose_vm_type()
# La case commande TOUTE installation — ERPLibre, Odoo, mais
# aussi l'hyperviseur Proxmox VE. Nommée « ERPLibre », elle
# laissait croire qu'un système Proxmox s'installerait
@ -348,56 +315,8 @@ def run_deploy_form(ctx, run_app: bool = True):
allow_blank=not profiles,
id="f_profile_install",
)
yield Checkbox(
t("Production (/opt, confined)"),
value=defaults.get("prod", False),
id="f_prod",
)
if app_stores:
yield Static(
t("Application store:"),
id="t_store",
classes="grouptitle",
)
with RadioSet(id="f_store"):
for i, (_k, label) in enumerate(app_stores):
yield RadioButton(label, value=i == 0)
yield Static("", id="storewarn")
if vm_tools:
# Une case par outil, et non une liste déroulante : ils
# sont indépendants, et chacun se prend ou se laisse.
yield Static(
t("Development tools:"),
id="t_tools",
classes="grouptitle",
)
for key, label, hint in vm_tools:
gb = tool_disk.get(key, 0)
yield Checkbox(
f"{label} +{gb} Go — {hint}",
value=key in (defaults.get("tools") or ()),
id=f"f_tool_{key}",
)
yield Static("", id="toolwarn")
yield Static(t("Timezone"), classes="grouptitle")
# Une liste plutôt qu'une saisie : un nom IANA mal
# orthographié n'est pas refusé par cloud-init, il est
# IGNORÉ — la VM reste en UTC et on ne s'en aperçoit
# qu'aux horodatages. « libre… » garde la porte ouverte
# aux six cents autres fuseaux de la base.
yield Select(
[(z, z) for z in timezones]
+ [(t("free value…"), FREE)],
value=(timezones[0] if timezones else SELECT_NULL),
allow_blank=False,
id="f_tz_sel",
)
yield Input(
value=ctx.get("timezone") or "",
placeholder=t("Timezone for the VMs"),
id="f_tz",
classes="freeval",
)
yield from self.compose_install_extras()
yield from self.compose_timezone()
yield Static("SSH", classes="grouptitle")
yield Input(
value=ctx.get("ssh_key") or "",
@ -409,20 +328,7 @@ def run_deploy_form(ctx, run_app: bool = True):
value=defaults.get("add_ssh_config", True),
id="f_sshcfg",
)
# mise pose un CPython précompilé, pyenv le compile.
# Grisé quand AUCUNE des VM retenues n'est sur une
# architecture que mise sert.
yield Static(
t("Python interpreter:"),
id="t_python",
classes="grouptitle",
)
with RadioSet(id="f_python"):
yield RadioButton(
t("mise (precompiled, faster)"), value=True
)
yield RadioButton(t("pyenv (compiles from source)"))
yield Static("", id="miswarn")
yield from self.compose_python()
# Hors de la section « Installation » : le suivi
# regarde la VM ARRIVER, même quand rien ne s'installe.
# Rangé dans cette section, il se serait grisé avec elle —
@ -596,17 +502,10 @@ def run_deploy_form(ctx, run_app: bool = True):
)
if installe and cmd_vm.strip() not in no_erplibre:
row["disk_gb"] += extra_disk
if row["vm"].get("desktop"):
row["disk_gb"] += desktop_disk
# Même règle pour les outils, et pour la même raison : ils ne
# pèsent que sur les VM qui les reçoivent réellement. Android
# Studio n'existe qu'en x86_64, les extensions GNOME n'ont de
# sens que sous GNOME — une VM qui ne les aura pas ne doit pas
# se voir gonfler son disque.
row["disk_gb"] += sum(
tool_disk.get(k, 0)
for k in self._tools_for_vm(row["vm"], tools)
)
# Le bureau et les outils ne pèsent que sur les VM qui
# les reçoivent RÉELLEMENT : Android Studio n'existe qu'en
# x86_64, les extensions GNOME n'ont de sens que sous GNOME.
row["disk_gb"] += self._extras_disk_gb(row["vm"], tools)
# Le plan doit MONTRER qu'une VM a été personnalisée : sans marque,
# deux lignes aux ressources différentes n'ont aucune explication à
# l'écran, et la surcharge est oubliée à la relecture. Le drapeau
@ -617,163 +516,7 @@ def run_deploy_form(ctx, run_app: bool = True):
row["custom"] = bool(self.overrides.get(key))
row["locked"] = key in self.locked
self._render_plan()
self._render_mise()
self._render_store()
self._render_tools()
def _vm_tools(self):
"""Clés des outils cochés, dans l'ordre de la liste."""
picked = []
for key, _label, _hint in vm_tools:
try:
if self.query_one(f"#f_tool_{key}", Checkbox).value:
picked.append(key)
except Exception:
continue
return tuple(picked)
def _tools_for_vm(self, vm, tools):
"""Outils qu'une VM donnée recevra vraiment.
Même filtre que todo.py côté déploiement : une VM ARM ne verra
jamais Android Studio, une VM Cinnamon jamais les extensions GNOME,
un serveur aucun des IDE — mais un serveur reçoit bien la
compilation mobile, qui n'a rien à afficher, et une distribution
sans apt ne la reçoit pas, son installateur n'existant que là."""
out = []
for key in tools:
arches = tool_arches.get(key) or ()
desks = tool_desktops.get(key) or ()
fams = tool_families.get(key) or ()
if tool_needs_desktop.get(key) and not vm.get("desktop"):
continue
if arches and vm["arch"] not in arches:
continue
if desks and vm.get("desktop") not in desks:
continue
if fams and distro_family.get(vm["distro"], "") not in fams:
continue
out.append(key)
return out
def _render_tools(self):
"""Grise chaque case qu'AUCUNE VM retenue ne peut recevoir, et
NOMME ce qui sera écarté.
Une case par outil, et non un blocage en bloc : sur un parc de
serveurs les IDE se grisent, la compilation mobile reste offerte.
Cocher Android Studio sur un parc ARM ne produit rien — le dire ici
évite de le découvrir dans le journal d'installation."""
if not vm_tools:
return
installe, quelque_chose = self._install_state()
for key, _label, _hint in vm_tools:
usable = any(self._tools_for_vm(vm, (key,)) for vm in self.vms)
offert = (
installe
if tool_phases.get(key) == "after"
else quelque_chose
)
self.query_one(f"#f_tool_{key}", Checkbox).disabled = not (
usable and offert
)
picked = self._vm_tools()
skipped = sorted(
{
vm["name"]
for vm in self.vms
for k in picked
if k not in self._tools_for_vm(vm, picked)
}
)
self.query_one("#toolwarn", Static).update(
f" ⚠ {t('Partly skipped (arch or desktop):')} "
f"{', '.join(skipped)}"
if skipped
else ""
)
def _render_mise(self):
"""Grise le choix quand aucune VM retenue n'est servie par mise,
et nomme les architectures qui retomberont sur pyenv."""
usable = self._mise_usable()
installe, _quelque_chose = self._install_state()
self.query_one("#f_python", RadioSet).disabled = not (
usable and installe
)
skipped = sorted(
{
vm["arch"]
for vm in self.vms
if vm["arch"] not in mise_arches
}
)
msg = ""
if skipped:
msg = (
f" ⚠ {t('mise has no binary for:')} "
f"{', '.join(skipped)} — {t('those VMs use pyenv')}"
)
self.query_one("#miswarn", Static).update(msg)
def _python_provider(self):
"""« mise », « pyenv », ou rien — c'est-à-dire « automatique ».
Rien, et surtout pas « pyenv », quand mise n'est servi par aucune
architecture retenue. « mise est indisponible » ne veut pas dire
« l'utilisateur exige pyenv » : la nuance décide de tout, puisqu'un
choix EXPLICITE écarte le Python de la distribution. Sur s390x,
renvoyer « pyenv » forçait la compilation de CPython — celle dont
gcc 15.2 ne revient pas."""
if not self._mise_usable():
return ""
index = self.query_one("#f_python", RadioSet).pressed_index
return "pyenv" if index == 1 else "mise"
def _app_store(self):
"""Magasin retenu. Sans VM concernée, la réponse est « deb » :
elle ne change rien, et laisser passer « snap » réactiverait snapd
pour rien."""
if not app_stores or not self._app_store_needed():
return "deb"
index = self.query_one("#f_store", RadioSet).pressed_index
if index is None or not (0 <= index < len(app_stores)):
return app_stores[0][0]
return app_stores[index][0]
def _app_store_needed(self):
"""Au moins une VM graphique sur une distribution qui livre snapd."""
return any(
vm.get("desktop") and vm["distro"] in snap_distros
for vm in self.vms
)
def _render_store(self):
"""Grise le choix quand aucune VM ne le concerne, et dit pourquoi."""
if not app_stores:
return
needed = self._app_store_needed()
_installe, quelque_chose = self._install_state()
self.query_one("#f_store", RadioSet).disabled = not (
needed and quelque_chose
)
self.query_one("#storewarn", Static).update(
""
if needed
else f" {t('No graphical VM on a snap-based distro.')}"
)
def _install_state(self):
"""(une installation ?, quelque chose à installer ?).
Deux réponses et non une : sans installation mais avec un bureau,
il se pose encore des paquets — le magasin d'applications et les
outils de la phase « avant » gardent un effet."""
installe = self.query_one("#f_install", Checkbox).value
return installe, bool(installe or self._default_desktop())
def _mise_usable(self):
return any(vm["arch"] in mise_arches for vm in self.vms)
self.render_extras()
def _profile_cmd(self):
"""Commande du profil choisi en haut : le défaut de chaque VM."""
@ -811,14 +554,6 @@ def run_deploy_form(ctx, run_app: bool = True):
value = self.query_one("#f_branch", Select).value
return value if isinstance(value, str) else branches[0]
def _default_desktop(self):
"""« » pour un serveur, sinon la clé de la saveur choisie."""
"""Type de VM par défaut. Chaque rangée peut s'en écarter."""
index = self.query_one("#f_type", RadioSet).pressed_index
if index is None or index < 1 or index > len(desktops):
return ""
return desktops[index - 1][0]
# -- panneau droit : une rangée de widgets par VM ---------------- #
def _type_options(self):
return [(t("Server"), SERVER)] + [
@ -1000,52 +735,6 @@ def run_deploy_form(ctx, run_app: bool = True):
pass
self._sync_free_inputs()
def _sync_install_deps(self) -> None:
"""Grise ce que le choix d'installation rend SANS EFFET.
Trois états et non deux, parce que la commande distante en a
trois : rien du tout, un bureau seul, ou une installation
complète. Sans installation MAIS avec un bureau, le magasin
d'applications et les outils de la phase « avant » servent encore
— les griser mentirait autant que de laisser actif ce qui ne fait
rien. La branche, le profil et l'interpréteur Python, eux, ne
servent qu'à l'installation.
Le type de VM et le suivi ne sont jamais grisés : le premier est
l'autre moitié de la décision, le second regarde la VM arriver
même quand rien ne s'installe."""
installe, quelque_chose = self._install_state()
for cible, actif in (
("#f_branch", installe),
("#f_profile_install", installe),
("#f_prod", quelque_chose),
):
try:
self.query_one(cible).disabled = not actif
except Exception:
pass
# Le magasin, les outils et l'interpréteur Python ont leur PROPRE
# raison de se griser (architecture, bureau, distribution) : ils
# composent les deux dans « _render_* », qui a le dernier mot.
# Le titre suit ses champs : une section entière se lit inactive
# d'un coup d'œil, au lieu de se déduire de trois widgets ternes.
for cible, actif in (
("#t_store", quelque_chose),
("#t_tools", quelque_chose),
("#t_python", installe),
):
try:
self.query_one(cible).set_class(not actif, "off")
except Exception:
pass
# Les rangées portent les mêmes choix, par VM.
for i in range(len(self.rows)):
for cible in (f"#v{i}_branch", f"#v{i}_prof"):
try:
self.query_one(cible).disabled = not installe
except Exception:
pass
def _render_plan(self):
# Le JEU de VM a-t-il changé ? Si oui on remonte les widgets, sinon
# on se contente des titres : remonter à chaque frappe volerait le
@ -1231,18 +920,7 @@ def run_deploy_form(ctx, run_app: bool = True):
# ci-dessous sans rien recalculer, et les rangées restaient sur
# l'ancienne version. Elles n'en gardent pas de copie — « » y
# veut dire « celle du formulaire » — il suffit de redessiner.
if event.select.id == "f_tz_sel":
# « libre… » révèle la saisie ; un fuseau choisi la referme et
# y recopie le nom, seule valeur que lisent _form_values et la
# spec — un seul endroit porte la réponse.
free = event.value is FREE
field = self.query_one("#f_tz", Input)
field.display = free
field.disabled = not free
if free:
field.focus()
elif isinstance(event.value, str):
field.value = event.value
if self.extras_on_select(event):
return
if event.select.id in ("f_branch", "f_profile_install"):
self._clear_overrides(
@ -1323,20 +1001,6 @@ def run_deploy_form(ctx, run_app: bool = True):
self._recompute()
# -- actions ---------------------------------------------------- #
def action_select_all(self) -> None:
self.query_one("#f_catalog", SelectionList).select_all()
def action_select_none(self) -> None:
self.query_one("#f_catalog", SelectionList).deselect_all()
def action_select_main(self) -> None:
"""Une VM par distro : la version marquée par défaut."""
widget = self.query_one("#f_catalog", SelectionList)
widget.deselect_all()
for i, e in enumerate(self._entries()):
if e.get("default"):
widget.select(i)
def action_clear_vm(self) -> None:
"""Rend au profil commun la VM dont un widget a le focus. Sans
cette sortie, un réglage posé par erreur ne se défaisait qu'en
@ -1377,15 +1041,7 @@ def run_deploy_form(ctx, run_app: bool = True):
else f"x{self.profile}"
),
"ssh_key": os.path.expanduser(key) if key else "",
# Un champ vidé retombe sur le fuseau de l'hôte plutôt que sur
# rien : sans valeur, la VM démarrerait en UTC.
"timezone": self.query_one("#f_tz", Input).value.strip()
or ctx.get("timezone")
or "",
"desktop": self._default_desktop(),
"vm_tools": self._vm_tools(),
"python_provider": self._python_provider(),
"app_store": self._app_store(),
**self.extras_values(),
"install": install,
"add_ssh_config": self.query_one("#f_sshcfg", Checkbox).value,
# Une exécution par installation : le nombre de VM retenues

View file

@ -828,6 +828,51 @@ class QemuInstallMixin:
"arch": "pacman",
}
def _qemu_guest_context(self):
"""Ce que les DEUX écrans de déploiement doivent savoir du système
invité : type de VM, magasin d'applications, outils, fuseaux, Python.
Une seule méthode et non deux blocs jumeaux dans les constructeurs de
contexte : c'est en n'en remplissant qu'un que l'écran Proxmox avait
perdu la moitié des réglages. Rien ici ne parle d'hyperviseur — c'est
exactement ce qui rend le bloc commun."""
outils = self._QEMU_VM_TOOLS
return {
"desktops": [
(k, v["label"]) for k, v in self._QEMU_DESKTOP.items()
],
"desktop_suffixes": self._qemu_desktop_suffixes(),
"desktop_disk_gb": self.QEMU_DESKTOP_EXTRA_DISK_GB,
"app_stores": [(k, t(lbl)) for k, lbl in self.QEMU_APP_STORES],
"snap_distros": self.QEMU_SNAP_DISTROS,
"timezone": self._qemu_host_timezone(),
"timezones": self._qemu_timezone_choices(
self._qemu_host_timezone()
),
"mise_arches": self.QEMU_MISE_ARCHES,
"vm_tools": self._qemu_vm_tool_choices(),
"vm_tool_disk": {k: v["disk_gb"] for k, v in outils.items()},
"vm_tool_arches": {k: v["arches"] for k, v in outils.items()},
"vm_tool_desktops": {k: v["desktops"] for k, v in outils.items()},
# « after » = l'outil vit DANS le dépôt ERPLibre (compilation
# mobile, AVD, script Forgejo) : sans installation, il n'existe
# pas, et la commande distante le saute en le nommant.
"vm_tool_phases": {
k: v.get("phase", "before") for k, v in outils.items()
},
"vm_tool_needs_desktop": {
k: v["needs_desktop"] for k, v in outils.items()
},
"vm_tool_families": {k: v["families"] for k, v in outils.items()},
"distro_family": dict(self._QEMU_DISTRO_FAMILY),
"defaults": {
"install": True,
"add_ssh_config": True,
"monitor": True,
"prod": False,
},
}
@classmethod
def _qemu_vm_tool_choices(cls):
"""[(clé, libellé, indice)] pour le formulaire et l'invite en ligne."""

View file

@ -85,9 +85,18 @@ def _launch_one(
log_path: str,
name: str = "",
installs: bool = True,
pve: bool = False,
) -> None:
"""Lance une install SSH DÉTACHÉE : attend le sshd, exécute, journalise
la sortie puis écrit le marqueur de fin avec le code de sortie."""
la sortie puis écrit le marqueur de fin avec le code de sortie.
`pve` : la VM vit sur un hôte Proxmox. On ne RÉ-RÉSOUT alors PAS son
adresse par virsh — et c'est vital. Vécu le 24 août 2026 : une VM
« erplibre-ubuntu-2604 » déployée sur Proxmox portait le nom d'un domaine
LOCAL existant ; la ré-résolution a trouvé le domaine local et
l'installation d'ERPLibre + Odoo est partie sur la mauvaise machine, sans
que rien ne le dise. Pour une VM distante, l'alias ~/.ssh/config est la
seule vérité : il porte le rebond par l'hôte."""
# Sonde de disponibilité : on attend que sshd réponde ET que cloud-init
# soit TERMINÉ, via des connexions COURTES successives (jusqu'à ~20 min :
# une architecture ÉMULÉE, s390x/arm64 sur hôte x86, boote lentement).
@ -188,12 +197,12 @@ def _launch_one(
f'echo " {msg_moved} $ip -> $n" >> {log_q}; fi; '
'[ -n "$n" ] && ip="$n"; '
)
if name
if name and not pve
else ""
)
wrapper = (
f"ip={shlex.quote(ip)}; "
f"{vsh if name else ''}"
f"{vsh if name and not pve else ''}"
f"echo {shlex.quote('== ' + msg_wait + ' ==')} >> {log_q}; "
f"echo {shlex.quote(' ' + msg_slow)} >> {log_q}; "
f"seen=0; "
@ -290,6 +299,7 @@ def launch_installs(vms: list[dict], branch: str, remote_cmd: str) -> str:
log_path,
vm["name"],
installs=bool(branch),
pve=bool(vm.get("pve")),
)
entree = {
"name": vm["name"],
@ -1299,19 +1309,100 @@ def read_domstats() -> str:
# disque et le cumul écrit de TOUTES ses VM d'un coup. Le « du » qui suit
# donne la taille RÉELLEMENT occupée : sur un stockage en fichiers, Proxmox
# rapporte « disk: 0 » — il ne la calcule pas.
#
# « -sB1 » et NON « -sb » : le second rend la taille APPARENTE, et un disque
# raw creux de 6 Go la donne entière. La colonne affichait donc « 6.0G/6.0G »,
# un disque plein, quand l'invité n'avait écrit que 1,2 Go — rapporté.
PVE_STATS_CMD = (
"pvesh get /cluster/resources --type vm --output-format json;"
" echo '---ERPLIBRE-DU---';"
" du -sb /var/lib/vz/images/*/ 2>/dev/null || true"
" du -sB1 /var/lib/vz/images/*/ 2>/dev/null || true"
)
# Une VM distante se relève moins souvent qu'une locale : chaque tour coûte
# une poignée de main ssh (mesuré 1 s), quand « virsh domstats » coûte 0,03 s
# pour tout le parc. Cinq secondes suffisent à voir une installation avancer.
PVE_STATS_INTERVAL = 5.0
# Une VM locale DÉJÀ verte est resondée à cette cadence, pas à chaque tour.
# La sonde est un connect() TCP par VM : à chaque tour (2 s) c'est cher pour
# une réponse qui ne bouge presque jamais, jamais c'est un mensonge — Odoo
# redémarre au moins une fois pendant l'installation, et il lui arrive de
# mourir. Trente secondes bornent le mensonge à un demi-écran de journal.
ODOO_RECHECK = 30.0
def odoo_reading(vm, releve, deja_vert, dernier, maintenant, sonde):
"""Odoo répond-il sur cette VM ? (état ou None, sondé ?)
None veut dire « pas de réponse ce tour-ci », et NON « Odoo est tombé » :
l'appelant garde alors le dernier état connu. C'est toute la différence
entre un hôte muet et une VM qui ne sert plus rien.
Deux voies, parce que la sonde n'a pas le même prix. Sur une VM Proxmox
le port a été testé DEPUIS L'HÔTE, dans l'appel des statistiques — d'ici,
une adresse de pont interne ne répond jamais — donc c'est gratuit et relu
à chaque tour. Sur une VM locale c'est un connect() TCP par VM : on la
refait tant qu'elle est rouge, puis seulement toutes les ODOO_RECHECK
secondes.
Ce qu'on ne fait plus, c'est ne jamais la refaire. « Odoo ne redescend pas
en cours d'install » était faux : le service redémarre au moins une fois,
et il lui arrive de mourir. Le 🟢 restait alors acquis pour toujours.
"""
if vm.get("pve"):
return (bool(releve.get("odoo")) if releve else None), False
if deja_vert and maintenant - dernier < ODOO_RECHECK:
return None, False
return bool(sonde(vm.get("ip"), 8069)), True
# Proxmox dit « running » / « stopped » ; le suivi raisonne en états libvirt.
# Une VM absente de la réponse de l'hôte a vraiment disparu.
PVE_ETATS = {"running": "running", "stopped": "shut off", "paused": "paused"}
_PVE_CACHE = {"at": 0.0, "stats": {}}
_PVE_CACHE = {"at": 0.0, "stats": {}, "ok": False}
def pve_stats_cmd(adresses=()) -> str:
"""PVE_STATS_CMD, plus un test du port 8069 pour les adresses données.
Dans le MÊME appel : la colonne Odoo teste ce port depuis le poste, et une
VM sur pont interne n'y répond jamais — elle restait « — » quel que soit
l'état d'Odoo. Depuis l'hôte, elle répond. Un aller-retour ssh de plus par
tour aurait coûté une seconde ; celui-ci est déjà payé.
"""
if not adresses:
return PVE_STATS_CMD
liste = " ".join(shlex.quote(a) for a in adresses)
return (
PVE_STATS_CMD
+ "; echo '---ERPLIBRE-ODOO---'; for a in "
+ liste
+ '; do timeout 2 bash -c "echo > /dev/tcp/$a/8069" 2>/dev/null'
+ ' && echo "ODOO $a"; done'
)
def _resources_parsable(text: str) -> bool:
"""La sortie porte-t-elle une LISTE de ressources lisible ?
C'est la seule preuve que l'hôte a répondu : le code de sortie est celui
du dernier maillon de la suite, pas celui de « pvesh ».
"""
brut, _, _ = (text or "").partition("---ERPLIBRE-DU---")
try:
return isinstance(json.loads(brut.strip() or "null"), list)
except ValueError:
return False
def parse_odoo_probe(text: str) -> set:
"""Adresses dont le port 8069 a répondu, d'après pve_stats_cmd."""
_, _, bloc = (text or "").partition("---ERPLIBRE-ODOO---")
return {
ligne.split()[1]
for ligne in bloc.splitlines()
if ligne.startswith("ODOO ") and len(ligne.split()) == 2
}
def parse_pvestats(text: str) -> dict:
@ -1327,7 +1418,7 @@ def parse_pvestats(text: str) -> dict:
ressources = json.loads(brut.strip() or "[]")
except ValueError:
return {}
# {vmid: octets} depuis « du -sb /var/lib/vz/images/<vmid>/ ».
# {vmid: octets} depuis « du -sB1 /var/lib/vz/images/<vmid>/ ».
occupe = {}
for ligne in tailles.splitlines():
parts = ligne.split()
@ -1360,12 +1451,35 @@ def parse_pvestats(text: str) -> dict:
return out
# Combien de relevés SUCCESSIFS sans la VM avant de la déclarer effacée. Un
# seul silence ne prouve rien : l'hôte peut être occupé, la VM en train de
# démarrer, le relevé en cache d'avant sa création. Or « effacée » est un état
# TERMINAL — la ligne gèle sur 🗑 et ne revient jamais. Vécu sur une VM Arch
# déployée sur Proxmox : poubelle dès le premier tour.
PVE_ABSENCES_AVANT_EFFACEE = 3
def read_pvestats_detail(vms, now=None):
"""(relevés, l'hôte a-t-il répondu ?).
La nuance décide de tout : sans réponse, on ne sait RIEN — et ne rien
savoir n'est pas la même chose que savoir que la VM a disparu.
"""
stats, ok = _read_pvestats(vms, now)
return stats, ok
def read_pvestats(vms, now=None) -> dict:
"""{nom: relevé} des VM posées sur un hôte Proxmox, ou {}.
"""{nom: relevé} des VM posées sur un hôte Proxmox, ou {}."""
return _read_pvestats(vms, now)[0]
def _read_pvestats(vms, now=None):
"""({nom: relevé}, succès). Un appel par hôte, mis en cache
PVE_STATS_INTERVAL secondes.
Les VM concernées sont celles dont le manifeste porte un bloc « pve »
(adresse de l'hôte, sudo, vmid). Un appel par hôte, mis en cache
PVE_STATS_INTERVAL secondes.
(adresse de l'hôte, sudo, vmid).
"""
hotes = {}
for vm in vms or ():
@ -1373,25 +1487,118 @@ def read_pvestats(vms, now=None) -> dict:
if info.get("target"):
hotes[(info["target"], info.get("sudo") or "")] = info
if not hotes:
return {}
return {}, False
maintenant = now if now is not None else time.time()
if maintenant - _PVE_CACHE["at"] < PVE_STATS_INTERVAL:
return dict(_PVE_CACHE["stats"])
# « at > 0 » explicitement : sans lui, un tout PREMIER relevé pris moins de
# cinq secondes après l'époque tombait dans un cache vide et rendait
# « l'hôte n'a pas répondu » sans avoir rien demandé. Invisible en
# production, mais c'est la logique qui est fausse.
if (
_PVE_CACHE["at"] > 0
and maintenant - _PVE_CACHE["at"] < PVE_STATS_INTERVAL
):
return dict(_PVE_CACHE["stats"]), bool(_PVE_CACHE.get("ok"))
try:
from script.proxmox import proxmox_deploy as pve
except ImportError: # pragma: no cover - le module est dans le dépôt
return {}
stats = {}
return {}, False
# {nom: adresse interne} — ce qui permet de tester Odoo depuis l'hôte.
adresses = {
vm["name"]: (vm.get("pve") or {}).get("addr")
for vm in vms or ()
if (vm.get("pve") or {}).get("addr")
}
stats, ok = {}, False
for (target, sudo), info in hotes.items():
siennes = [
a
for nom, a in adresses.items()
if (
(
next((v for v in vms if v["name"] == nom), {}).get("pve")
or {}
).get("target")
== target
)
]
code, sortie = pve.run(
{"target": target, "sudo": sudo, "jump": info.get("jump", "")},
PVE_STATS_CMD,
30,
pve_stats_cmd(siennes),
40,
)
if code == 0:
stats.update(parse_pvestats(sortie))
_PVE_CACHE.update({"at": maintenant, "stats": stats})
return dict(stats)
# « code == 0 » ne suffit PAS : la commande est une SUITE
# (pvesh ; echo ; du ; echo ; boucle), et son code est celui du DERNIER
# maillon. Un pvesh en panne rendait donc « l'hôte a répondu, la VM
# n'y est plus » — et trois tours plus tard, la poubelle. Ce qui prouve
# une réponse, c'est une LISTE de ressources analysable.
if code == 0 and _resources_parsable(sortie):
ok = True
releves = parse_pvestats(sortie)
ouverts = parse_odoo_probe(sortie)
for nom, rec in releves.items():
rec["odoo"] = adresses.get(nom) in ouverts
stats.update(releves)
_PVE_CACHE.update({"at": maintenant, "stats": stats, "ok": ok})
return dict(stats), ok
def web_tunnel_argv(info, port=18069, cible_port=8069):
"""argv d'un tunnel local vers le port web d'une VM distante, ou None.
Une VM sur pont interne n'est pas routable d'ici : un navigateur ne peut
pas l'atteindre, et la touche « w » ouvrait une page morte. Le tunnel
passe par l'hôte, dure le temps de la visite, et se referme par son PID —
« pkill -f <motif> » tuait le shell qui l'avait lancé, le motif figurant
dans sa propre ligne de commande.
"""
info = info or {}
if not (info.get("addr") and info.get("target")):
return None
argv = ["ssh", "-N", "-o", "ExitOnForwardFailure=yes"]
if info.get("jump"):
argv += ["-J", info["jump"]]
argv += ["-L", f"{port}:{info['addr']}:{cible_port}", info["target"]]
return argv
def vm_ssh_prefix(vm) -> str:
"""« ssh … » pour entrer dans CETTE VM, adresse comprise.
Une VM d'un hôte Proxmox vit derrière lui : son adresse n'est pas
routable d'ici, et seul le rebond y mène. On le construit explicitement
plutôt que de compter sur un alias ~/.ssh/config, qui peut ne pas exister
— ou, pire, désigner une VM LOCALE homonyme. C'est ce qui a fait ouvrir
la mauvaise machine avec « s ».
"""
info = (vm or {}).get("pve") or {}
adresse = info.get("addr")
if info.get("target") and adresse:
saut = f"-J {shlex.quote(info['jump'])} " if info.get("jump") else ""
return (
f"ssh {SSH_OPTS} {saut}-J {shlex.quote(info['target'])} "
f"erplibre@{adresse}"
)
return f"ssh {SSH_OPTS} erplibre@{(vm or {}).get('ip')}"
def pve_host_cmd(info, remote, tty=False) -> str:
"""Commande shell qui exécute `remote` SUR l'hôte Proxmox d'une VM.
Chaque action du tableau de bord qui parlait à libvirt par le NOM frappait
la mauvaise machine dès qu'un domaine local portait le même : la console
ouvrait celle de la VM locale, la pause suspendait la locale. L'hôte est
la seule autorité pour une VM distante, et le VMID son seul identifiant.
"""
sudo = (info or {}).get("sudo") or ""
cible = (info or {}).get("target") or ""
prefixe = f"{sudo}sh -c {shlex.quote(remote)}" if sudo else remote
saut = (
f"-J {shlex.quote(info['jump'])} " if (info or {}).get("jump") else ""
)
return (
f"ssh {'-t ' if tty else ''}{saut}{shlex.quote(cible)} "
f"{shlex.quote(prefixe)}"
)
def arm_balloon(names) -> None:
@ -1536,6 +1743,52 @@ def restart_odoo_cmd() -> str:
)
def delete_vm_cmd_pve(info, purge: bool = True) -> str:
"""Efface une VM sur son hôte PROXMOX, par son VMID.
« virsh undefine <nom> » y aurait effacé le domaine LOCAL homonyme — le
même piège que partout ailleurs, avec la pire conséquence."""
vmid = int((info or {}).get("vmid") or 0)
suite = (
f"qm stop {vmid} --skiplock 1 || true; "
f"qm destroy {vmid}{' --purge 1 --destroy-unreferenced-disks 1' if purge else ''}"
)
return pve_host_cmd(info, suite)
def delete_lines(vm) -> list:
"""Ce qui va RÉELLEMENT disparaître, dit selon l'endroit où la VM vit.
L'écran annonçait à toute VM « son disque qcow2 EFFACÉ », puis nommait
/var/lib/libvirt/images/<nom>.qcow2. Sur une VM Proxmox ce fichier
n'existe pas : son disque vit dans un stockage que seul l'hôte connaît, et
la ligne désignait donc un fichier local — au mieux inexistant, au pire
celui d'une autre VM du même nom. C'est exactement la peur qui a fait
remonter le nettoyage : « le nettoyage risque d'effacer des VM en
production ».
Une confirmation doit nommer ce qu'elle détruit, sur la machine où elle
le détruit."""
info = vm.get("pve")
if not info:
return [
"La VM est arrêtée, sa définition retirée,",
"et son disque qcow2 EFFACÉ. Rien n'est récupérable.",
"",
f" /var/lib/libvirt/images/{vm['name']}.qcow2",
]
hote = info.get("target") or "?"
return [
f"Sur l'hôte Proxmox {hote}, la VM {info.get('vmid')} est arrêtée",
"puis DÉTRUITE avec ses disques. Rien n'est récupérable.",
"",
f" qm destroy {info.get('vmid')} --purge",
"",
"Aucun fichier n'est touché ici : le disque vit dans le",
"stockage de l'hôte.",
]
def delete_vm_cmd(name: str, with_disks: bool) -> str:
"""Efface la VM sur l'HÔTE. Même séquence que « TODO._qemu_delete_vm » :
arrêt, retrait de la définition (nvram si UEFI, repli sinon), puis les
@ -1830,9 +2083,21 @@ def run_monitor(manifest_path: str, run_app: bool = True):
self._errcount = {}
# Sommaire de stats déplié (clic) ou non.
self._stats_open = False
# VM dont l'UI Odoo (:8069) répond déjà : une fois détectée « up »,
# on ne re-teste plus (Odoo ne redescend pas en cours d'install).
# VM dont l'UI Odoo (:8069) répond. RELUE, et non accumulée :
# « Odoo ne redescend pas en cours d'install » est faux — il
# redémarre au moins une fois (service systemd), et il lui arrive
# de mourir. Un 🟢 acquis pour toujours affirmait alors qu'une VM
# servait Odoo alors qu'elle ne servait plus rien.
self._odoo_up = set()
# Dernier tour où le port d'une VM DÉJÀ verte a été retesté. Sur
# une VM locale la sonde est un connect() TCP par VM : la refaire
# à chaque tour pour rien serait cher, la refaire jamais serait
# faux. Sur Proxmox la question ne se pose pas — la réponse vient
# avec les statistiques, gratuitement.
self._odoo_revu = {}
# Relevés SUCCESSIFS sans la VM, par nom : « effacée » est un état
# terminal, il se mérite.
self._pve_absences = {}
# Debounce du changement de VM : la sélection défile vite au
# clavier ; on ne recharge le log qu'une fois le curseur STABILISÉ.
self._pending_sel = None
@ -1896,7 +2161,11 @@ def run_monitor(manifest_path: str, run_app: bool = True):
vm["name"],
vm.get("arch") or "?",
"",
"⏳",
# « rien encore », comme ses voisines : « ⏳ » affirmerait
# qu'on attend quelque chose, alors qu'on ne sait rien du
# tout — le journal n'a pas encore parlé. Le sablier
# apparaît dès que l'attente est constatée.
"-",
"—",
"--:--",
"-",
@ -2036,7 +2305,7 @@ def run_monitor(manifest_path: str, run_app: bool = True):
# colonnes restaient vides. Même forme de relevé, donc la suite ne
# change pas d'un iota.
stats.update(read_pvestats(vms))
now_s = time.time()
now_s = maintenant = time.time()
for name, rec in stats.items():
self._wrate.add(name, rec["wr_bytes"], now_s)
self._vmstats = stats
@ -2064,14 +2333,22 @@ def run_monitor(manifest_path: str, run_app: bool = True):
# peut contenir des erreurs passées inaperçues.
if st[0] in ("done", "failed") and name not in errors:
errors[name] = scan_log_errors(vm["log"])
# Odoo up ? On ne teste que celles pas encore confirmées up
# et non effacées (test TCP court sur :8069).
if (
name not in self._odoo_up
and self._domstate.get(name) != "gone"
):
if _port_open(vm.get("ip"), 8069):
odoo[name] = True
# Odoo up ? La réponse est RENDUE À CHAQUE TOUR pour les
# VM sondées, pas accumulée : la colonne doit pouvoir
# redescendre à « — ».
if self._domstate.get(name) != "gone":
etat, sonde = odoo_reading(
vm,
(self._vmstats or {}).get(name) or {},
name in self._odoo_up,
self._odoo_revu.get(name, 0.0),
maintenant,
_port_open,
)
if sonde:
self._odoo_revu[name] = maintenant
if etat is not None:
odoo[name] = etat
return disks, status, self._collect_tele(), errors, odoo, wr, ram
async def _tick_table(self):
@ -2091,7 +2368,14 @@ def run_monitor(manifest_path: str, run_app: bool = True):
except Exception:
return
self._errcount.update(errors)
self._odoo_up.update(odoo)
# Remplacement et non union : une VM sondée qui ne répond
# plus doit repasser à « — ». Les VM absentes du relevé (hôte
# muet, VM effacée) gardent leur dernier état connu.
for nom_vm, vivant in odoo.items():
if vivant:
self._odoo_up.add(nom_vm)
else:
self._odoo_up.discard(nom_vm)
try:
table = self.query_one("#vms", DataTable)
now = time.time()
@ -2299,15 +2583,55 @@ def run_monitor(manifest_path: str, run_app: bool = True):
# Une VM posée sur un hôte Proxmox est ABSENTE de « virsh list » :
# elle passait donc pour EFFACÉE, ce qui éteignait du même coup
# ses colonnes vivantes. Son état vient de l'hôte.
distants = await asyncio.to_thread(read_pvestats, vms)
distants, hote_ok = await asyncio.to_thread(
read_pvestats_detail, vms
)
for vm in vms:
nom = vm["name"]
if vm.get("pve"):
if not hote_ok:
# L'hôte n'a pas répondu : on ne sait RIEN. Conclure
# « effacée » ici gelait la ligne sur 🗑 dès le premier
# tour, pour toujours — vécu sur une VM Arch à peine
# déployée. Et on OUBLIE les absences déjà comptées :
# elles ne prouvent une disparition que si elles se
# SUIVENT, l'hôte répondant à chaque fois.
self._pve_absences[nom] = 0
continue
releve = distants.get(nom)
self._domstate[nom] = PVE_ETATS.get(
(releve or {}).get("state"), "gone"
if releve:
self._pve_absences[nom] = 0
# PRÉSENTE dans le relevé : elle existe, quel que soit
# le mot employé. Proxmox en a d'autres que les trois
# attendus — « prelaunch », « suspended »,
# « internal-error », « hibernated » — et les traduire
# en « gone » mettait à la poubelle une VM bien vivante.
self._domstate[nom] = PVE_ETATS.get(
releve.get("state"), "running"
)
continue
# L'hôte a répondu SANS elle : peut-être en cours de
# création, peut-être vraiment partie. On compte.
self._pve_absences[nom] = (
self._pve_absences.get(nom, 0) + 1
)
if self._pve_absences[nom] >= PVE_ABSENCES_AVANT_EFFACEE:
self._domstate[nom] = "gone"
continue
if not states:
# « virsh list » n'a rien rendu : soit l'hôte n'a plus une
# seule VM, soit l'appel a échoué (libvirtd qui redémarre,
# sudo qui expire). On ne peut pas trancher, et conclure
# « effacées » mettait TOUT le parc local à la poubelle,
# définitivement. Même règle que pour l'hôte distant : on
# compte avant de conclure.
self._pve_absences[nom] = (
self._pve_absences.get(nom, 0) + 1
)
if self._pve_absences[nom] >= PVE_ABSENCES_AVANT_EFFACEE:
self._domstate[nom] = "gone"
continue
self._pve_absences[nom] = 0
self._domstate[nom] = states.get(nom, "gone")
# Réarmer la période du ballon sur les VM qui tournent : sans elle
# la RAM affichée serait celle du dernier rapport du pilote, et une
@ -2374,8 +2698,10 @@ def run_monitor(manifest_path: str, run_app: bool = True):
vm = self._vm_by_name(self._selected)
if not vm:
return
cmd = vm_ssh_prefix(vm)
with self.suspend():
os.system(f"ssh {SSH_OPTS} erplibre@{vm['ip']} || true")
print(f"\n→ {cmd}\n")
os.system(f"{cmd} || true")
def action_console(self) -> None:
"""Console série de la VM, sans quitter le suivi.
@ -2392,18 +2718,33 @@ def run_monitor(manifest_path: str, run_app: bool = True):
vm = self._vm_by_name(self._selected)
if not vm:
return
name = shlex.quote(vm["name"])
info = vm.get("pve")
if info:
# VM d'un hôte Proxmox : sa console est « qm terminal », sur
# l'hôte. « virsh console <nom> » ouvrait celle du domaine
# LOCAL homonyme — la mauvaise machine, sans le dire.
cmd = pve_host_cmd(
info, f"qm terminal {int(info.get('vmid') or 0)}", tty=True
)
titre = (
f"qm terminal {info.get('vmid')} @ {info.get('target')}"
)
sortie = "Ctrl+O"
else:
cmd = f"sudo virsh console {shlex.quote(vm['name'])}"
titre = f"virsh console {vm['name']}"
sortie = "Ctrl+]"
with self.suspend():
# La console n'affiche que ce qui arrive APRÈS l'attachement :
# sur une VM déjà démarrée l'écran reste noir tant qu'on n'a
# rien envoyé. On le dit, plutôt que de laisser croire à un gel.
print(f"\n→ virsh console {vm['name']}")
print(f"\n→ {titre}")
print(
" Écran vide ? Appuyez sur Entrée : la console ne montre"
" que la sortie qui suit l'attachement."
)
print(" Ctrl+] puis Entrée pour revenir au suivi.\n")
os.system(f"sudo virsh console {name} || true")
print(f" {sortie} puis Entrée pour revenir au suivi.\n")
os.system(f"{cmd} || true")
def action_web(self) -> None:
"""Ouvre l'UI web de la VM (Odoo :8069) dans un navigateur CLI
@ -2416,10 +2757,26 @@ def run_monitor(manifest_path: str, run_app: bool = True):
browser = self._choose_browser()
if not browser:
return
url = f"http://{vm['ip']}:8069"
port = 18069
argv_tunnel = web_tunnel_argv(vm.get("pve"), port)
url = (
f"http://127.0.0.1:{port}"
if argv_tunnel
else f"http://{vm['ip']}:8069"
)
with self.suspend():
proc = None
if argv_tunnel:
print("→ " + " ".join(shlex.quote(a) for a in argv_tunnel))
try:
proc = subprocess.Popen(argv_tunnel)
time.sleep(2)
except (OSError, subprocess.SubprocessError) as exc:
print(f" ⚠ {exc}")
print(f"→ {browser} {url}")
rc = os.system(f"{browser} {shlex.quote(url)}")
if proc:
proc.terminate()
# Diagnostic : sinon le navigateur « clignote » et revient au
# TUI sans qu'on voie l'erreur (souvent Odoo pas démarré).
print(f"\n[{browser}] terminé (code {rc}).")
@ -2542,15 +2899,30 @@ def run_monitor(manifest_path: str, run_app: bool = True):
# -- pause / reprise de tout le parc -------------------------------- #
@staticmethod
def _virsh_bulk(action, names):
for n in names:
def _virsh_bulk(action, cibles):
"""Suspend/reprend chaque VM, chacune par SON hyperviseur.
`cibles` : [(nom, info_pve|None)]. Une VM distante se suspend par
son VMID sur son hôte — « virsh suspend <nom> » aurait mis en
pause le domaine LOCAL homonyme."""
for nom, info in cibles:
try:
subprocess.run(
["sudo", "virsh", action, n],
capture_output=True,
text=True,
timeout=30,
)
if info:
vmid = int(info.get("vmid") or 0)
subprocess.run(
pve_host_cmd(info, f"qm {action} {vmid}"),
shell=True,
capture_output=True,
text=True,
timeout=60,
)
else:
subprocess.run(
["sudo", "virsh", action, nom],
capture_output=True,
text=True,
timeout=30,
)
except (OSError, subprocess.SubprocessError):
pass
@ -2691,8 +3063,7 @@ def run_monitor(manifest_path: str, run_app: bool = True):
with self.suspend():
print(f"\n=== {title} — {vm['name']} ===")
os.system(
f"ssh {SSH_OPTS} erplibre@{vm['ip']} "
f"{shlex.quote(cmd)} || true"
f"{vm_ssh_prefix(vm)} " f"{shlex.quote(cmd)} || true"
)
input("\nEntrée pour revenir au suivi… ")
@ -2709,20 +3080,22 @@ def run_monitor(manifest_path: str, run_app: bool = True):
def confirmed(yes):
if not yes:
return
info = vm.get("pve")
cmd = (
delete_vm_cmd_pve(info)
if info
else delete_vm_cmd(vm["name"], True)
)
with self.suspend():
print(f"\n=== Suppression — {vm['name']} ===")
os.system(delete_vm_cmd(vm["name"], True) + " || true")
print(f"→ {cmd}\n")
os.system(cmd + " || true")
input("\nEntrée pour revenir au suivi… ")
self.push_screen(
ConfirmScreen(
f"Supprimer {vm['name']} ?",
[
"La VM est arrêtée, sa définition retirée,",
"et son disque qcow2 EFFACÉ. Rien n'est récupérable.",
"",
f" /var/lib/libvirt/images/{vm['name']}.qcow2",
],
delete_lines(vm),
"Supprimer définitivement",
),
confirmed,
@ -2741,7 +3114,7 @@ def run_monitor(manifest_path: str, run_app: bool = True):
async def _bulk_worker(self, action):
want = "running" if action == "suspend" else "paused"
targets = [
vm["name"]
(vm["name"], vm.get("pve"))
for vm in vms
if self._domstate.get(vm["name"]) == want
]

View file

@ -1705,6 +1705,40 @@ class TODO(
pass
return names
@classmethod
def _ssh_config_block(cls, name):
"""Le bloc « Host … » qui déclare `name`, ou {}.
Rend ses noms ET ses directives : savoir qu'un nom est pris ne suffit
pas, il faut savoir PAR QUI. Un nom court déjà déclaré peut être notre
propre entrée qu'on réécrit — auquel cas il n'y a rien de volé — ou
celle d'une autre machine, et c'est le ProxyJump qui les distingue.
{"names": [...], "proxyjump": "...", "hostname": "..."}."""
path = os.path.expanduser("~/.ssh/config")
try:
with open(path, encoding="utf-8") as fh:
contenu = fh.read()
except OSError:
return {}
bloc = None
for line in contenu.splitlines():
if re.match(r"^[ \t]*Host[ \t]+", line):
if bloc is not None:
return bloc
noms = line.split()[1:]
bloc = {"names": noms} if name in noms else None
continue
if bloc is None:
continue
# Une ligne non indentée et non vide clôt le bloc.
if line.strip() and not line[:1].isspace():
return bloc
mots = line.split()
if len(mots) >= 2 and mots[0].lower() in ("proxyjump", "hostname"):
bloc[mots[0].lower()] = mots[1]
return bloc or {}
@staticmethod
def _ssh_config_user(host):
"""`User` déclaré pour cet hôte dans ~/.ssh/config, ou "".
@ -2832,6 +2866,12 @@ class TODO(
"Attachment files missing from the filestore"
)
},
{"section": t("Instance")},
{
"prompt_description": t(
"Monitoring - a backup, a remote copy or a live instance"
)
},
]
help_info = self.fill_help_info(choices)
@ -2854,6 +2894,8 @@ class TODO(
self.execute_analyse_module_dependency()
elif status == "7":
self.execute_analyse_filestore()
elif status == "8":
self.execute_analyse_monitoring()
else:
print(t("Command not found !"))
@ -3515,6 +3557,244 @@ class TODO(
handler,
)
def execute_analyse_monitoring(self):
"""Ausculter une instance dont la base n'est pas forcément ici.
Les analyses existent déjà ; ce qui manquait est le chemin d'avant.
Une sauvegarde, locale ou distante, se RESTAURE — après quoi tout
ce que le dépôt sait faire s'applique. Une instance vivante, non :
on n'a pas la base, on a une session, et ce que RPC laisse lire est
un sous-ensemble. L'écran de choix le dit au lieu de proposer des
analyses qui échoueraient à l'ouverture.
"""
from script.analyse import monitoring, monitoring_tui
print(f"🛰 {t('Inspect an instance.')}")
source = self._monitoring_select_source()
if not source:
return
kind, target = source
print()
print(monitoring.describe_source(kind, target))
print()
choix = monitoring_tui.run_tui(kind, target)
if not choix:
return
analyse = monitoring.analysis_by_key(choix)
if not analyse:
return
if kind not in analyse["kinds"]:
print(f"✖ {t('Not available for this source.')}")
return
extra = None
if analyse.get("asks_expect"):
extra = ["--expect", self._monitoring_expect(kind)]
monitoring.run_analysis(analyse, target, extra=extra)
def _monitoring_expect(self, kind):
"""Copie de développement, ou instance en service ?
Zéro cron actif est le SUCCÈS attendu d'une copie et une panne
totale sur une production ; zéro serveur de courriel rassure sur
l'une et condamne l'autre. Deviner à la place de l'utilisateur,
c'est afficher du rouge sur ce qu'il vient de demander — et l'on
cesse alors de lire le rapport.
"""
from script.analyse import check_instance_state, monitoring
if kind == monitoring.KIND_LIVE:
return check_instance_state.LIVE
print()
print(f"[1] {t('A development copy (restored, neutralised)')}")
print(f"[2] {t('An instance in service')}")
answer = click.prompt(t("Command:"))
print()
return (
check_instance_state.LIVE
if answer == "2"
else check_instance_state.COPY
)
def _monitoring_select_source(self):
"""(genre, cible), ou None si l'on renonce.
Le zip et la sauvegarde distante mènent tous deux à une base
restaurée : passé la restauration, ils ne se distinguent plus, et
le reste du code n'a pas à savoir d'où ils venaient.
"""
from script.analyse import monitoring
print()
# La base locale d'abord : c'est la provenance la plus directe, et
# `_analyse_select_source` du même menu range déjà la base avant la
# sauvegarde. Deux ordres différents dans un même menu se paient en
# hésitation à chaque usage.
print(f"[1] {t('A local database')}")
print(f"[2] {t('A local backup .zip')}")
print(f"[3] {t('A remote backup (https + master password)')}")
print(f"[4] {t('A live remote instance')}")
print(f"[0] {t('Back')}")
answer = click.prompt(t("Command:"))
print()
if answer == "1":
database = self.db_manager.select_database()
return (monitoring.KIND_DATABASE, database) if database else None
if answer == "2":
path = self.db_manager.select_backup_path()
database = self._monitoring_restore(path) if path else None
return (monitoring.KIND_DATABASE, database) if database else None
if answer == "3":
status, path, _name = (
self.db_manager.download_database_backup_cli()
)
if status or not path or not os.path.isfile(path):
print(f"❌ {t('The download did not produce a usable file.')}")
return None
database = self._monitoring_restore(path)
return (monitoring.KIND_DATABASE, database) if database else None
if answer == "4":
return self._monitoring_live()
return None
def _monitoring_live(self):
"""Se connecter pour de bon, ou ne pas prétendre l'être.
Une faute dans l'URL ou dans la clé ne se verrait sinon qu'à la
première analyse, et passerait pour un défaut de l'analyse.
"""
import getpass
from script.analyse import monitoring
base_url = input(t("Instance URL (ex. https://example.com): ")).strip()
if not base_url:
return None
database = input(t("Database name on that instance: ")).strip()
if not database:
return None
login = input(t("User login: ")).strip()
if not login:
return None
print()
print(f"[1] {t('An API key')}")
print(f"[2] {t('A password')}")
genre = click.prompt(t("Command:"))
secret = getpass.getpass(
t("API key: ") if genre == "1" else t("Password: ")
)
if not secret:
return None
try:
uid, version = monitoring.live_connect(
base_url, database, login, secret
)
except Exception as exc:
print(f"❌ {t('Cannot connect: ')}{exc}")
return None
print(f"✅ {t('Connected as uid')} {uid}, {t('Odoo')} {version}")
return (monitoring.KIND_LIVE, f"{base_url} · {database}")
def _monitoring_restore(self, zip_path):
"""Restaurer la sauvegarde, puis DIRE ce que la neutralisation a pris.
Mesuré sur sept bases dont le nom portait « neutralize » :
`database.is_neutralized` absent partout, jusqu'à 35 crons actifs,
et le domaine de courriel du client toujours en place. Poser la
question, recevoir oui et ne rien vérifier reproduit exactement
cette illusion — on relit donc la base.
"""
from script.analyse import monitoring
image = self._monitoring_image_name(zip_path)
if not image:
return None
defaut = image
database = input(
f"💬 {t('Database name (default=')}{defaut}) : "
).strip()
database = database or defaut
neutralise = (
input(f"💬 {t('Neutralize the database (Y/n)? ')}").strip().lower()
)
more_arg = ""
if neutralise != "n":
more_arg = "--neutralize "
database += "_neutralize"
status, _ = self._execute.exec_command_live(
f"python3 ./script/database/db_restore.py -d {database} "
f"{more_arg}--ignore_cache --image {image}",
return_status_and_output=True,
single_source_erplibre=True,
source_erplibre=False,
)
if status:
print(f"❌ {t('The restore failed.')}")
return None
if more_arg:
status, _ = self._execute.exec_command_live(
f"./script/addons/update_prod_to_dev.sh {database}",
return_status_and_output=True,
single_source_erplibre=True,
source_erplibre=False,
)
print()
print(
monitoring.neutralize_report(
monitoring.neutralize_state(database), colour=True
)
)
if status:
# Le compte test/test vient de `user_test`, posé par ce
# script. S'il n'a pas fini, l'annoncer quand même enverrait
# se heurter à un refus d'authentification.
print(
f"⚠ {t('update_prod_to_dev did not finish: do not')}"
f" {t('count on the test/test account.')}"
)
else:
print(f"ℹ️ {t('You can log in with test / test.')}")
return database
def _monitoring_image_name(self, zip_path):
"""db_restore veut un NOM sous image_db/, pas un chemin.
Une sauvegarde qui vient d'ailleurs n'est donc pas restaurable telle
quelle. Plutôt que de recopier plusieurs gigaoctets, on propose un
lien — et on le demande, parce que cela pose un fichier dans un
répertoire qui n'est pas à nous.
"""
image_db = os.path.join(os.getcwd(), "image_db")
nom = os.path.basename(zip_path)
if nom.endswith(".zip"):
nom = nom[:-4]
if os.path.dirname(os.path.abspath(zip_path)) == image_db:
return nom
cible = os.path.join(image_db, f"{nom}.zip")
if os.path.exists(cible):
print(f"ℹ️ {t('Using the file already in image_db: ')}{cible}")
return nom
answer = (
input(
f"💬 {t('db_restore only reads image_db/. Link it there')}"
f" ({cible}) (Y/n)? "
)
.strip()
.lower()
)
if answer == "n":
return None
try:
os.makedirs(image_db, exist_ok=True)
os.symlink(os.path.abspath(zip_path), cible)
except OSError as exc:
print(f"❌ {t('Cannot link into image_db: ')}{exc}")
return None
print(f"🔗 {cible}")
return nom
def prompt_execute_process(self):
print(f"🤖 {t('Manage execution processes!')}")
choices = [

View file

@ -2346,6 +2346,30 @@ TRANSLATIONS = {
"fr": "et non la VM : le socket est du côté de QEMU.",
"en": "not the VM: the socket is QEMU-side.",
},
"This hypervisor is Proxmox VE: it has no libvirt.": {
"fr": "Cet hyperviseur est un Proxmox VE : il n'a pas de libvirt.",
"en": "This hypervisor is Proxmox VE: it has no libvirt.",
},
"Its screen is served by a ticket, not by a VNC port.": {
"fr": "Son écran est servi par un ticket, pas par un port VNC.",
"en": "Its screen is served by a ticket, not by a VNC port.",
},
"Two ways in:": {
"fr": "Deux chemins :",
"en": "Two ways in:",
},
"the serial console, VMID from the Proxmox menu:": {
"fr": "la console série, le VMID venant du menu Proxmox :",
"en": "the serial console, VMID from the Proxmox menu:",
},
"the web interface, through a tunnel:": {
"fr": "l'interface web, par un tunnel :",
"en": "the web interface, through a tunnel:",
},
"TODO > Execute > Deploy > Proxmox VE does the first.": {
"fr": "TODO > Execute > Deploy > Proxmox VE fait le premier.",
"en": "TODO > Execute > Deploy > Proxmox VE does the first.",
},
"This VM exposes no VNC port.": {
"fr": "Cette VM n'expose aucun port VNC.",
"en": "This VM exposes no VNC port.",
@ -3986,6 +4010,38 @@ TRANSLATIONS = {
"fr": "Fichiers orphelins :",
"en": "Orphan files:",
},
"connection guide written": {
"fr": "guide de connexion écrit (/etc/motd)",
"en": "connection guide written (/etc/motd)",
},
"guide not written": {
"fr": "guide non écrit",
"en": "guide not written",
},
"Quit (q) to start the ERPLibre install": {
"fr": "Quitter (q) pour lancer l'installation d'ERPLibre",
"en": "Quit (q) to start the ERPLibre install",
},
"Quit (q) to follow the VM starting up": {
"fr": "Quitter (q) pour suivre la mise en route de la VM",
"en": "Quit (q) to follow the VM starting up",
},
"This name is already taken by": {
"fr": "Ce nom est déjà pris par",
"en": "This name is already taken by",
},
"a local VM": {
"fr": "une VM locale",
"en": "a local VM",
},
"A local VM already bears this name:": {
"fr": "Une VM locale porte déjà ce nom :",
"en": "A local VM already bears this name:",
},
"the alias goes to": {
"fr": "l'alias devient",
"en": "the alias goes to",
},
"s ssh · c copy log · C copy all · q quit": {
"fr": "s ssh · c copier le log · C copier tout · q quitter",
"en": "s ssh · c copy log · C copy all · q quit",
@ -4499,6 +4555,10 @@ TRANSLATIONS = {
"fr": "Chemin de la clé publique SSH",
"en": "SSH public key path",
},
"timezone not set": {
"fr": "fuseau non posé",
"en": "timezone not set",
},
"Timezone": {
"fr": "Fuseau horaire",
"en": "Timezone",
@ -6789,6 +6849,42 @@ TRANSLATIONS = {
"fr": "Aucun index en double.",
"en": "No duplicate index.",
},
"attachments of fields and records already gone": {
"fr": "pièces jointes de champs et d'enregistrements déjà disparus",
"en": "attachments of fields and records already gone",
},
"their field was already gone before this step": {
"fr": "leur champ porteur avait déjà disparu avant ce palier",
"en": "their field was already gone before this step",
},
"their field was removed at this step": {
"fr": "leur champ porteur a été retiré à ce palier",
"en": "their field was removed at this step",
},
"their model left the database": {
"fr": "leur modèle a quitté la base",
"en": "their model left the database",
},
"the field is STILL there — look at these": {
"fr": "le champ est TOUJOURS là — à regarder",
"en": "the field is STILL there — look at these",
},
"out of": {
"fr": "sur",
"en": "out of",
},
"Every witness is an administrator: a model only": {
"fr": "Tous les témoins sont administrateurs : un modèle que seuls les",
"en": "Every witness is an administrator: a model only",
},
"admins can read passes this test.": {
"fr": "administrateurs peuvent lire passe ce test.",
"en": "admins can read passes this test.",
},
"Reads ir_rule only — masking coded in Python escapes it.": {
"fr": "Ne lit qu'ir_rule — un masquage codé en Python lui échappe.",
"en": "Reads ir_rule only — masking coded in Python escapes it.",
},
"held no data of their own — nothing to lose": {
"fr": "sans donnée propre — rien à perdre",
"en": "held no data of their own — nothing to lose",
@ -9228,6 +9324,326 @@ TRANSLATIONS = {
"en": "no .idea: open PyCharm once, then",
},
"project": {"fr": "projet", "en": "project"},
"Instance": {
"fr": "🛰 Instance",
"en": "🛰 Instance",
},
"Monitoring - a backup, a remote copy or a live instance": {
"fr": "🛰 Monitoring - une sauvegarde, une copie distante ou une instance vivante",
"en": "🛰 Monitoring - a backup, a remote copy or a live instance",
},
"Inspect an instance.": {
"fr": "Ausculter une instance.",
"en": "Inspect an instance.",
},
"A local backup .zip": {
"fr": "Une sauvegarde .zip locale",
"en": "A local backup .zip",
},
"A remote backup (https + master password)": {
"fr": "Une sauvegarde distante (https + mot de passe maître)",
"en": "A remote backup (https + master password)",
},
"A live remote instance": {
"fr": "Une instance vivante distante",
"en": "A live remote instance",
},
"An API key": {
"fr": "Une clé d'API",
"en": "An API key",
},
"A password": {
"fr": "Un mot de passe",
"en": "A password",
},
"API key: ": {
"fr": "Clé d'API : ",
"en": "API key: ",
},
"Password: ": {
"fr": "Mot de passe : ",
"en": "Password: ",
},
"User login: ": {
"fr": "Identifiant : ",
"en": "User login: ",
},
"Instance URL (ex. https://example.com): ": {
"fr": "URL de l'instance (ex. https://exemple.com) : ",
"en": "Instance URL (ex. https://example.com): ",
},
"Database name on that instance: ": {
"fr": "Nom de la base sur cette instance : ",
"en": "Database name on that instance: ",
},
"Cannot connect: ": {
"fr": "Connexion impossible : ",
"en": "Cannot connect: ",
},
"Connected as uid": {
"fr": "Connecté sous l'uid",
"en": "Connected as uid",
},
"The instance refused these credentials.": {
"fr": "L'instance a refusé ces identifiants.",
"en": "The instance refused these credentials.",
},
"Monitoring only reads; refused method:": {
"fr": "Le monitoring ne fait que lire ; méthode refusée :",
"en": "Monitoring only reads; refused method:",
},
"Database:": {
"fr": "Base :",
"en": "Database:",
},
"Live instance:": {
"fr": "Instance vivante :",
"en": "Live instance:",
},
"Database name (default=": {
"fr": "Nom de la base (défaut=",
"en": "Database name (default=",
},
"Neutralize the database (Y/n)? ": {
"fr": "Neutraliser la base (O/n) ? ",
"en": "Neutralize the database (Y/n)? ",
},
"The restore failed.": {
"fr": "La restauration a échoué.",
"en": "The restore failed.",
},
"The download did not produce a usable file.": {
"fr": "Le téléchargement n'a pas produit de fichier utilisable.",
"en": "The download did not produce a usable file.",
},
"db_restore only reads image_db/. Link it there": {
"fr": "db_restore ne lit que image_db/. Y poser un lien",
"en": "db_restore only reads image_db/. Link it there",
},
"Using the file already in image_db: ": {
"fr": "On prend le fichier déjà dans image_db : ",
"en": "Using the file already in image_db: ",
},
"Cannot link into image_db: ": {
"fr": "Impossible de poser le lien dans image_db : ",
"en": "Cannot link into image_db: ",
},
"You can log in with test / test.": {
"fr": "Vous pouvez vous connecter avec test / test.",
"en": "You can log in with test / test.",
},
"update_prod_to_dev did not finish: do not": {
"fr": "update_prod_to_dev n'a pas fini : ne comptez pas",
"en": "update_prod_to_dev did not finish: do not",
},
"count on the test/test account.": {
"fr": "sur le compte test/test.",
"en": "count on the test/test account.",
},
"database.is_neutralized is set.": {
"fr": "database.is_neutralized est posé.",
"en": "database.is_neutralized is set.",
},
"database.is_neutralized is NOT set — Odoo does not": {
"fr": "database.is_neutralized n'est PAS posé — Odoo ne considère pas",
"en": "database.is_neutralized is NOT set — Odoo does not",
},
"consider this database neutralised.": {
"fr": "cette base comme neutralisée.",
"en": "consider this database neutralised.",
},
"scheduled actions are still active.": {
"fr": "actions planifiées sont encore actives.",
"en": "scheduled actions are still active.",
},
"No scheduled action is active.": {
"fr": "Aucune action planifiée n'est active.",
"en": "No scheduled action is active.",
},
"No mail server at all: Odoo falls back to smtp_server": {
"fr": "Aucun serveur de courriel : Odoo retombe sur le smtp_server",
"en": "No mail server at all: Odoo falls back to smtp_server",
},
"from the config file. A blocking one is safer than none.": {
"fr": "du fichier de configuration. Un serveur bouchon vaut mieux que rien.",
"en": "from the config file. A blocking one is safer than none.",
},
"payment provider(s) are neither disabled nor": {
"fr": "fournisseur(s) de paiement ne sont ni désactivés ni",
"en": "payment provider(s) are neither disabled nor",
},
"in test mode.": {
"fr": "en mode test.",
"en": "in test mode.",
},
"Run": {
"fr": "Lancer",
"en": "Run",
},
"Enter to run it.": {
"fr": "Entrée pour le lancer.",
"en": "Enter to run it.",
},
"Not available for this source.": {
"fr": "Indisponible pour cette provenance.",
"en": "Not available for this source.",
},
"Migration leftovers": {
"fr": "🚚 Restant de migration",
"en": "🚚 Migration leftovers",
},
"What a migration left behind, judged without needing the step-by-step databases.": {
"fr": "Ce qu'une migration a laissé derrière elle, jugé sans avoir besoin des bases de palier.",
"en": "What a migration left behind, judged without needing the step-by-step databases.",
},
"It reads pg_catalog — indexes and real tables — which no RPC session exposes.": {
"fr": "Il lit pg_catalog — les index et les vraies tables — qu'aucune session RPC n'expose.",
"en": "It reads pg_catalog — indexes and real tables — which no RPC session exposes.",
},
"Every view someone changed, and every website copy that shadows a module view.": {
"fr": "Chaque vue que quelqu'un a modifiée, et chaque copie de site web qui masque une vue de module.",
"en": "Every view someone changed, and every website copy that shadows a module view.",
},
"Comparing a copy with the module view it hides is a join on arch_db, done in SQL.": {
"fr": "Comparer une copie à la vue de module qu'elle masque est une jointure sur arch_db, faite en SQL.",
"en": "Comparing a copy with the module view it hides is a join on arch_db, done in SQL.",
},
"Fields and models added outside any module, and which of them have no column behind them.": {
"fr": "Champs et modèles ajoutés hors de tout module, et lesquels n'ont aucune colonne derrière eux.",
"en": "Fields and models added outside any module, and which of them have no column behind them.",
},
"Telling a declared field from a real column means reading pg_attribute.": {
"fr": "Distinguer un champ déclaré d'une vraie colonne demande de lire pg_attribute.",
"en": "Telling a declared field from a real column means reading pg_attribute.",
},
"What a migration left behind, read in one database.": {
"fr": "Ce qu'une migration a laissé derrière elle, lu dans une seule base.",
"en": "What a migration left behind, read in one database.",
},
"Migration residue in": {
"fr": "Restant de migration dans",
"en": "Migration residue in",
},
"base module version:": {
"fr": "version du module base :",
"en": "base module version:",
},
"None of the checks found anything.": {
"fr": "Aucun contrôle n'a rien trouvé.",
"en": "None of the checks found anything.",
},
"This reads one database on its own — it cannot see": {
"fr": "Ceci ne lit qu'une base isolée — il ne peut pas voir",
"en": "This reads one database on its own — it cannot see",
},
"what an earlier step silently dropped.": {
"fr": "ce qu'un palier antérieur a laissé tomber en silence.",
"en": "what an earlier step silently dropped.",
},
"could not read": {
"fr": "lecture impossible",
"en": "could not read",
},
"no repair tool yet": {
"fr": "pas encore d'outil de réparation",
"en": "no repair tool yet",
},
"Modules stuck between two states": {
"fr": "Modules figés entre deux états",
"en": "Modules stuck between two states",
},
"A migration that stopped mid-flight leaves them there; Odoo will retry the transition at every start.": {
"fr": "Une migration arrêtée en vol les laisse ainsi ; Odoo retentera la transition à chaque démarrage.",
"en": "A migration that stopped mid-flight leaves them there; Odoo will retry the transition at every start.",
},
"Languages whose active flag is NULL": {
"fr": "Langues dont le drapeau actif est NULL",
"en": "Languages whose active flag is NULL",
},
"A boolean that is neither true nor false: the language is listed nowhere and cannot be re-enabled from the interface.": {
"fr": "Un booléen qui n'est ni vrai ni faux : la langue n'est listée nulle part et ne peut plus être réactivée depuis l'interface.",
"en": "A boolean that is neither true nor false: the language is listed nowhere and cannot be re-enabled from the interface.",
},
"Many-to-many tables named but missing": {
"fr": "Tables plusieurs-à-plusieurs nommées mais absentes",
"en": "Many-to-many tables named but missing",
},
"ir_model_relation still names a table PostgreSQL does not have; the next module update tries to alter it and fails.": {
"fr": "ir_model_relation nomme encore une table que PostgreSQL n'a pas ; la prochaine mise à jour de module tente de la modifier et échoue.",
"en": "ir_model_relation still names a table PostgreSQL does not have; the next module update tries to alter it and fails.",
},
"Indexes duplicated by the Odoo 17 renaming": {
"fr": "Index doublés par le renommage d'Odoo 17",
"en": "Indexes duplicated by the Odoo 17 renaming",
},
"Odoo 17 changed the naming convention without dropping the old index: both are maintained on every write. This count is a cheap signal — the repair tool compares columns and uniqueness, and is the one to trust.": {
"fr": "Odoo 17 a changé la convention de nommage sans supprimer l'ancien index : les deux sont entretenus à chaque écriture. Ce compte est un indicateur bon marché — l'outil de réparation compare les colonnes et l'unicité, c'est lui qui fait foi.",
"en": "Odoo 17 changed the naming convention without dropping the old index: both are maintained on every write. This count is a cheap signal — the repair tool compares columns and uniqueness, and is the one to trust.",
},
"Default pricelist missing while product is installed": {
"fr": "Liste de prix par défaut absente alors que product est installé",
"en": "Default pricelist missing while product is installed",
},
"product.list0 was declared up to Odoo 16 only; nothing recreates it, and a quotation has no price list to pick.": {
"fr": "product.list0 n'a été déclaré que jusqu'à Odoo 16 ; rien ne le recrée, et un devis n'a aucune liste de prix à choisir.",
"en": "product.list0 was declared up to Odoo 16 only; nothing recreates it, and a quotation has no price list to pick.",
},
"Views bound to a model that no longer exists": {
"fr": "Vues liées à un modèle qui n'existe plus",
"en": "Views bound to a model that no longer exists",
},
"Opening one raises; the menu that leads to it is a dead end.": {
"fr": "En ouvrir une lève une erreur ; le menu qui y mène est un cul-de-sac.",
"en": "Opening one raises; the menu that leads to it is a dead end.",
},
"Views inheriting a view that is gone": {
"fr": "Vues héritant d'une vue disparue",
"en": "Views inheriting a view that is gone",
},
"The whole inheritance chain below them stops rendering.": {
"fr": "Toute la chaîne d'héritage en dessous cesse de s'afficher.",
"en": "The whole inheritance chain below them stops rendering.",
},
"External ids pointing at a model that is gone": {
"fr": "Identifiants externes pointant un modèle disparu",
"en": "External ids pointing at a model that is gone",
},
"Every module update that resolves one of them fails.": {
"fr": "Chaque mise à jour de module qui en résout un échoue.",
"en": "Every module update that resolves one of them fails.",
},
"Attachments whose carrying field is gone": {
"fr": "Pièces jointes dont le champ porteur a disparu",
"en": "Attachments whose carrying field is gone",
},
"Odoo raises a KeyError merely checking them, and nothing will ever read them again.": {
"fr": "Odoo lève un KeyError rien qu'en les contrôlant, et plus rien ne les lira jamais.",
"en": "Odoo raises a KeyError merely checking them, and nothing will ever read them again.",
},
"A local database": {
"fr": "Une base de données locale",
"en": "A local database",
},
"State of the instance": {
"fr": "🩺 État de l'instance",
"en": "🩺 State of the instance",
},
"Neutralisation, scheduler, backups, queues and who is an administrator — read for the use you intend.": {
"fr": "Neutralisation, cadenceur, sauvegardes, files et qui est administrateur — lu pour l'usage que vous en faites.",
"en": "Neutralisation, scheduler, backups, queues and who is an administrator — read for the use you intend.",
},
"Several checks read tables no RPC session exposes, and the lateness of a job is computed in SQL.": {
"fr": "Plusieurs contrôles lisent des tables qu'aucune session RPC n'expose, et le retard d'une tâche se calcule en SQL.",
"en": "Several checks read tables no RPC session exposes, and the lateness of a job is computed in SQL.",
},
"A development copy (restored, neutralised)": {
"fr": "Une copie de développement (restaurée, neutralisée)",
"en": "A development copy (restored, neutralised)",
},
"An instance in service": {
"fr": "Une instance en service",
"en": "An instance in service",
},
}

View file

@ -2781,6 +2781,14 @@ class TodoUpgrade:
# ici : /blog/<blog>/post/<billet> et /contactus, alors que
# le journal de migration n'avait rien signalé.
self.prompt_database_cleanup(database_name_upgrade)
# APRÈS le nettoyage : il supprime des colonnes et des
# tables, donc leurs index avec. Avant lui, on travaillerait
# sur des index qui vont disparaître.
self.drop_duplicate_index(database_name_upgrade, next_version)
self.restore_config_defaults(
database_name_upgrade,
index == len(lst_next_version) - 1,
)
self.prompt_smoke_public_url(database_name_upgrade)
# Le trou que ni les comptages ni le test de fumée ne
@ -3684,6 +3692,66 @@ class TodoUpgrade:
wait_at_error=False,
)
def drop_duplicate_index(self, database_name, next_version):
"""Les index qu'Odoo 17 a rebaptisés sans supprimer les anciens.
`make_index_name` a changé de convention en 17 — `table_col_index`
est devenu `table__col_index` — et rien ne retire le premier. Les
deux restent, et PostgreSQL les entretient TOUS LES DEUX à chaque
écriture. Mesuré sur deux chaînes 12 → 18 indépendantes : 414 dans
l'une et 414 dans l'autre, à l'index près. Ce n'est pas un accident
d'exécution, c'est le chemin lui-même.
À partir de 17 seulement : avant, la convention n'a pas changé et
l'outil ne trouverait rien — le lancer six fois pour rien ferait
du bruit dans un journal qu'on lit déjà mal.
`wait_at_error=False` : avec `--apply`, le code 1 signifie « il en
reste », pas « je suis tombé ». Un index redondant qui survit ne
justifie pas d'arrêter une migration de six paliers ; l'outil
l'écrit, et le journal le garde.
"""
if next_version < 17:
return
outil = os.path.join(PATH_MIGRATION_GLOBAL, "fix_duplicate_index.py")
if not os.path.isfile(outil):
return
self.todo_upgrade_execute(
f"{PYTHON_BIN} ./{outil} -d {database_name} --apply",
wait_at_error=False,
)
def restore_config_defaults(self, database_name, is_last_version):
"""Les réglages par défaut qu'aucune migration ne recrée.
`product.list0` n'a été déclaré que jusqu'à Odoo 16 et
`account.reconciliation_model_default_rule` seulement en 12 : ils
naissent aujourd'hui d'un événement qu'une migration ne déclenche
jamais. La base arrive donc en 18 sans liste de prix par défaut, et
cela ne se découvre qu'au premier devis. Mesuré sur deux chaînes
indépendantes : absent des deux.
AU DERNIER PALIER seulement. L'outil charge le registre Odoo — une
quarantaine de secondes — et seul l'état final compte : recréer la
liste au palier 13 pour la voir disparaître au 16 ne servirait qu'à
allonger six fois la migration.
`wait_at_error=False` : même raison que pour les index. Le code 1
veut dire « il en manque encore après la réparation », ce que
l'outil écrit lui-même.
"""
if not is_last_version:
return
outil = os.path.join(
PATH_MIGRATION_GLOBAL, "restore_config_defaults.py"
)
if not os.path.isfile(outil):
return
self.todo_upgrade_execute(
f"{PYTHON_BIN} ./{outil} -d {database_name} --apply",
wait_at_error=False,
)
def diff_cow_views(self, database_name, label_before, label_after):
"""Print what the version bump did to the website COW views."""
directory = os.path.join(

View file

@ -237,5 +237,99 @@ class TestTheWiring(unittest.TestCase):
self.assertIn("--apply", src[debut : debut + 200])
class TestItDeclaresWhatItCouldNotProve(unittest.TestCase):
"""Un vert qui prouve moins qu'il n'en a l'air est pire qu'un rouge.
Mesuré sur une base réelle : l'outil éprouvait 45 modèles sur 125
portant une règle globale, contre 4 utilisateurs internes — et les
QUATRE étaient administrateurs. Un modèle que seuls les
administrateurs peuvent lire passait donc au vert. C'est exactement
la forme du bug DMS qui a motivé l'outil, vue d'un autre angle.
L'outil ne peut pas créer un témoin ordinaire — ce serait une
écriture. Il peut dire qu'il n'en avait pas.
"""
def rapport(self, **extra):
base = {
"checked": 45,
"with_rule": 125,
"users": ["a", "b", "c", "d"],
"ordinary": ["b"],
"models": [],
}
base.update(extra)
return base
def tete(self, **extra):
return "\n".join(check.render(self.rapport(**extra)))
def test_it_says_how_many_it_could_not_check(self):
# « 45 éprouvés » sans dire « sur 125 » laisse croire à un examen
# complet.
texte = self.tete()
self.assertIn("45", texte)
self.assertIn("125", texte)
def test_without_the_coverage_it_says_only_what_it_checked(self):
# Un rapport d'une version antérieure n'a pas le renseignement.
# Inventer « sur 0 » serait pire que se taire — et « sur None »
# pire encore : c'est ce que produit un f-string sans garde.
texte = self.tete(with_rule=None)
self.assertIn("45", texte)
self.assertNotIn("125", texte)
self.assertNotIn("None", texte)
self.assertNotIn(check.t("out of"), texte.split("\n")[0])
def test_it_warns_when_every_witness_is_an_administrator(self):
texte = self.tete(ordinary=[])
self.assertIn(
check.t("Every witness is an administrator: a model only"), texte
)
def test_it_stays_quiet_when_one_witness_is_ordinary(self):
self.assertNotIn(
check.t("Every witness is an administrator: a model only"),
self.tete(),
)
def test_it_declares_what_it_cannot_see_at_all(self):
# La sonde part d'ir_rule : un masquage écrit en Python lui
# échappe par construction, et le taire ferait prendre son vert
# pour une garantie.
self.assertIn(
check.t(
"Reads ir_rule only — masking coded in Python escapes it."
),
self.tete(),
)
def test_the_warning_comes_before_the_verdict(self):
lignes = check.render(self.rapport(ordinary=[]))
avertissement = next(
i
for i, x in enumerate(lignes)
if "administrator" in x or "administrateur" in x
)
verdict = next(i for i, x in enumerate(lignes) if "✅" in x)
self.assertLess(avertissement, verdict)
class TestTheScriptCollectsWhatTheReportNeeds(unittest.TestCase):
def corps(self):
return check.build_script()
def test_it_counts_the_models_carrying_a_rule(self):
self.assertIn('rapport["with_rule"]', self.corps())
def test_it_separates_the_ordinary_witnesses(self):
corps = self.corps()
self.assertIn('rapport["ordinary"]', corps)
self.assertIn("base.group_system", corps)
def test_the_script_is_valid_python(self):
compile(self.corps(), "script", "exec")
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,221 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""La polarité : le même chiffre, deux lectures opposées.
Zéro cron actif est le succès attendu d'une copie de développement et une
panne totale sur une production. C'est la propriété centrale de cet outil,
et c'est aussi celle qu'une refonte casserait sans bruit : il suffirait
qu'un contrôle perde une de ses deux politiques pour qu'il se taise —
sans erreur, sans rouge, en donnant l'impression d'avoir été vérifié.
Le second sujet de ce fichier est le secret. Une clé de paiement VIVANTE
a été mesurée dans une base de test. Un rapport finit dans un billet ou
devant un agent : aucune requête ne doit lire la valeur d'un secret, et
c'est vérifié sur le texte des requêtes, pas sur l'intention.
"""
import os
import sys
import unittest
sys.path.insert(
0, os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
)
from script.analyse import check_instance_state as etat # noqa: E402
class TestEveryCheckDeclaresBothReadings(unittest.TestCase):
"""Un contrôle sans politique se tairait — sans erreur ni rouge."""
def test_each_check_has_a_policy_for_each_expectation(self):
for controle in etat.CONTROLES:
for attente in etat.ATTENTES:
self.assertIn(attente, controle, controle["key"])
self.assertTrue(controle[attente], controle["key"])
def test_each_policy_is_one_we_know_how_to_read(self):
connus = ("zero", "nonzero", "info", "skip")
for controle in etat.CONTROLES:
for attente in etat.ATTENTES:
self.assertIn(controle[attente][0], connus, controle["key"])
def test_a_judging_policy_carries_a_gravity_we_understand(self):
for controle in etat.CONTROLES:
for attente in etat.ATTENTES:
politique = controle[attente]
if politique[0] in ("zero", "nonzero"):
self.assertIn(politique[1], ("broken", "watch"))
def test_a_judging_policy_explains_itself(self):
"""Un rouge sans raison n'apprend rien à qui le lit."""
for controle in etat.CONTROLES:
for attente in etat.ATTENTES:
if controle[attente][0] in ("zero", "nonzero"):
self.assertTrue(
controle.get(f"why_{attente}", "").strip(),
f"{controle['key']}/{attente}",
)
def test_a_skipped_check_says_why(self):
for controle in etat.CONTROLES:
for attente in etat.ATTENTES:
politique = controle[attente]
if politique[0] == "skip":
self.assertGreater(len(politique), 1, controle["key"])
self.assertTrue(politique[1].strip())
def test_every_section_declared_is_actually_used(self):
utilisees = {c["section"] for c in etat.CONTROLES}
self.assertEqual(set(etat.SECTIONS), utilisees)
def test_keys_are_unique(self):
cles = [c["key"] for c in etat.CONTROLES]
self.assertEqual(len(cles), len(set(cles)))
class TestThePolarityActuallyInverts(unittest.TestCase):
"""La propriété centrale, épinglée sur un cas réel mesuré."""
def _controle(self, cle):
for controle in etat.CONTROLES:
if controle["key"] == cle:
return controle
raise AssertionError(cle)
def test_active_crons_are_a_fault_on_a_copy_and_normal_when_live(self):
controle = self._controle("cron_active")
copie = etat.verdict(controle, 35, etat.COPY)
vivante = etat.verdict(controle, 35, etat.LIVE)
self.assertEqual(copie[0], "bad")
self.assertEqual(vivante[0], "ok")
def test_no_cron_at_all_is_the_opposite(self):
controle = self._controle("cron_active")
self.assertEqual(etat.verdict(controle, 0, etat.COPY)[0], "ok")
self.assertEqual(etat.verdict(controle, 0, etat.LIVE)[0], "bad")
def test_late_jobs_are_not_judged_on_a_copy(self):
"""Mesuré : 11 en retard sur la base d'ORIGINE, jamais démarrée."""
controle = self._controle("cron_late")
genre, _, raison = etat.verdict(controle, 11, etat.COPY)
self.assertEqual(genre, "skip")
self.assertTrue(raison)
self.assertEqual(etat.verdict(controle, 11, etat.LIVE)[0], "bad")
def test_missing_backups_are_not_judged_on_a_copy(self):
"""update_prod_to_dev les efface : leur absence ne prouve rien."""
controle = self._controle("backup_rows")
self.assertEqual(etat.verdict(controle, 0, etat.COPY)[0], "skip")
self.assertEqual(etat.verdict(controle, 0, etat.LIVE)[0], "bad")
def test_having_no_mail_server_warns_under_both_readings(self):
"""Zéro serveur NE prouve PAS la sûreté : Odoo retombe sur la conf.
C'est pourquoi le `neutralize.sql` d'Odoo INSÈRE un serveur bouchon
`invalid:1025` au lieu de tout supprimer, avec le commentaire
« prevent using fallback servers ». Notre `disable_mail_server`, lui,
fait `unlink()` — il rouvre la porte qu'Odoo ferme.
"""
controle = self._controle("mail_server_total")
for attente in etat.ATTENTES:
self.assertEqual(etat.verdict(controle, 0, attente)[0], "bad")
def test_a_check_without_a_policy_is_skipped_never_approved(self):
"""L'oubli doit se voir comme un trou, pas comme un feu vert."""
orphelin = {"key": "x", "section": "Users", "title": "x", "sql": ""}
for attente in etat.ATTENTES:
self.assertEqual(etat.verdict(orphelin, 99, attente)[0], "skip")
def test_an_unreadable_check_is_not_a_zero(self):
controle = self._controle("cron_active")
genre, _, raison = etat.verdict(
controle, {"error": "relation absente"}, etat.COPY
)
self.assertEqual(genre, "unreadable")
self.assertIn("relation", raison)
class TestWhatTheReportSaysAndCounts(unittest.TestCase):
def _tout(self, valeur=0):
return {c["key"]: valeur for c in etat.CONTROLES}
def test_the_header_names_the_reading(self):
for attente, mot in (
(etat.COPY, "a development copy"),
(etat.LIVE, "a live instance"),
):
texte = etat.render("b", self._tout(), attente, colour=False)
self.assertIn(etat.t(mot), texte)
def test_only_faults_reach_the_exit_code(self):
resultats = self._tout()
resultats["cron_active"] = 35 # faute sous copy, normal sous live
self.assertTrue(etat.findings(resultats, etat.COPY))
self.assertFalse(
[
f
for f in etat.findings(resultats, etat.LIVE)
if f[0]["key"] == "cron_active"
]
)
def test_a_skipped_check_shows_its_number_and_its_reason(self):
resultats = self._tout()
resultats["cron_late"] = 11
texte = etat.render("b", resultats, etat.COPY, colour=False)
self.assertIn("11", texte)
self.assertIn(
etat.t(
"Nobody runs the scheduler on a restored copy:"
" measured 11 late on an untouched source"
" database."
),
texte,
)
def test_an_unreadable_check_is_shown_as_unknown(self):
resultats = self._tout()
resultats["mail_stuck"] = {"error": "relation absente"}
texte = etat.render("b", resultats, etat.LIVE, colour=False)
self.assertIn("❔", texte)
def test_sections_come_out_in_the_declared_order(self):
texte = etat.render("b", self._tout(), etat.COPY, colour=False)
positions = [texte.find(etat.t(s)) for s in etat.SECTIONS]
self.assertEqual(positions, sorted(positions))
self.assertNotIn(-1, positions)
class TestNoQueryEverReadsASecret(unittest.TestCase):
"""Une clé Stripe VIVANTE a été mesurée dans une base de test."""
SECRETS = (
"secret_key",
"publishable_key",
"smtp_pass",
"password",
"sftp_password",
"database.secret",
"api_key",
)
def test_no_secret_column_is_selected(self):
for controle in etat.CONTROLES:
minuscule = controle["sql"].lower()
for secret in self.SECRETS:
self.assertNotIn(secret, minuscule, controle["key"])
def test_every_query_reads_and_returns_one_number(self):
for controle in etat.CONTROLES:
sql = " ".join(controle["sql"].split())
self.assertTrue(sql.upper().startswith("SELECT"), controle["key"])
majuscule = f" {sql.upper()} "
for interdit in ("INSERT", "UPDATE", "DELETE", "DROP", "ALTER"):
self.assertNotIn(f" {interdit} ", majuscule, controle["key"])
if __name__ == "__main__":
unittest.main()

View file

@ -1492,5 +1492,100 @@ class TestFieldsThatHeldNoData(TestTheOpenUpgradeOverlay):
self.assertTrue(quality.t(libelle))
class TestWhyAnAttachmentWentAway(Base):
"""« 409 pièces jointes perdues » n'en recouvrait presque aucune.
Mesuré sur une chaîne 12 → 18 : des 516 lignes parties au palier 18,
452 avaient perdu leur CHAMP PORTEUR aux paliers 13 et 14 — elles
étaient déjà illisibles, Odoo lève un KeyError en les contrôlant. Ce
ne sont pas des données, ce sont des débris, et la 18 les ramasse.
On ne DÉCLARE pas cette perte dans SEMANTIC_MAP : cette carte nomme
une TABLE, et la cause n'est pas la table, ce sont ces lignes-là.
Déclarée, elle rangerait toute perte future de ir_attachment sous
« changement d'Odoo » — cinq mille factures comprises.
"""
def etat(self, lignes, modeles=("res.partner",)):
return {
"exists": True,
"attachment_row": lignes,
"model": list(modeles),
}
def test_a_field_already_gone_is_not_a_loss(self):
avant = self.etat({"7": ("res.partner", "image", "1", False)})
seaux = quality.classify_attachments(avant, self.etat({}))
self.assertEqual(["7"], seaux["field_debt"])
self.assertEqual([], seaux["undeclared"])
def test_a_field_removed_at_this_step_is_not_a_loss_either(self):
avant = self.etat({"7": ("res.partner", "image", "1", True)})
apres = self.etat({"8": ("res.partner", "image", "2", False)})
seaux = quality.classify_attachments(avant, apres)
self.assertEqual(["7"], seaux["field_dropped"])
def test_a_model_that_left_takes_its_attachments_with_it(self):
avant = self.etat({"7": ("mail.channel", "image", "1", True)})
apres = self.etat({}, modeles=["res.partner"])
seaux = quality.classify_attachments(avant, apres)
self.assertEqual(["7"], seaux["model_gone"])
def test_a_live_field_losing_its_attachment_stays_red(self):
# LE cas qui compte : une donnée lisible a disparu.
avant = self.etat({"7": ("res.partner", "image_1920", "1", True)})
apres = self.etat({"8": ("res.partner", "image_1920", "2", True)})
seaux = quality.classify_attachments(avant, apres)
self.assertEqual(["7"], seaux["undeclared"])
def test_an_attachment_that_stayed_is_not_counted(self):
lignes = {"7": ("res.partner", "image_1920", "1", True)}
seaux = quality.classify_attachments(
self.etat(lignes), self.etat(lignes)
)
self.assertEqual([], seaux["undeclared"])
self.assertEqual([], seaux["field_debt"])
def test_without_the_information_it_says_it_does_not_know(self):
# Un instantané pris par une version antérieure n'a pas
# `attachment_row`. Rendre des seaux vides ferait croire à une
# explication complète.
self.assertIsNone(
quality.classify_attachments({"exists": True}, self.etat({}))
)
def test_the_red_bucket_is_the_only_one_that_warns(self):
table = {cle: teinte for cle, _l, teinte in quality.ATTACHMENT_KIND}
self.assertEqual("warn", table["undeclared"])
for cle in ("field_debt", "field_dropped", "model_gone"):
self.assertEqual("dim", table[cle], cle)
def test_the_report_names_each_cause(self):
connu = {
"buckets": {"field_debt": 452, "undeclared": 1},
"why": "attachments of fields and records already gone",
}
texte = "\n".join(quality.render_attachment_kind(connu, colour=False))
self.assertIn("452", texte)
self.assertIn("1", texte)
self.assertIn(
quality.t("their field was already gone before this step"), texte
)
def test_a_bucket_at_zero_is_not_printed(self):
connu = {"buckets": {"field_debt": 0, "undeclared": 3}}
texte = "\n".join(quality.render_attachment_kind(connu, colour=False))
self.assertNotIn(
quality.t("their field was already gone before this step"), texte
)
def test_semantic_map_still_says_nothing_about_attachments(self):
# Une entrée déclarée rendrait l'outil aveugle : `explain_loss`
# ne compare que le nom de la table, et `pruned` ne déclenche
# aucune contre-vérification.
for entree in quality.SEMANTIC_MAP:
self.assertNotEqual("ir_attachment", entree.get("table"))
if __name__ == "__main__":
unittest.main()

View file

@ -265,6 +265,8 @@ class TestTheCommandLine(unittest.TestCase):
REPO = Path(__file__).resolve().parent.parent
MOBILE = REPO / "mobile" / "erplibre_home_mobile"
# Le plafond d'entrees d'un ZIP, et donc d'un APK.
ZIP_ENTRY_LIMIT = 65535
class TestTheRealBundle(unittest.TestCase):
@ -276,10 +278,11 @@ class TestTheRealBundle(unittest.TestCase):
(script/test/run_unit_test.sh) annonce la même dépendance avant de
commencer.
Ce qu'ils gardent : qu'une compilation réelle produise bien des PACKS. Un
retour au fichier-par-source ferait disparaître le champ « chunk » des
index, et la limite du ZIP reviendrait — 123 678 entrées pour un plafond de
65 535, silencieusement, jusqu'à l'APK.
Ce qu'ils gardent : qu'une compilation réelle range les sources dans des
CONTENEURS — archives tar.gz ou tranches pack, les deux dispositions
conviennent. Un retour au fichier-par-source ferait revenir la limite du
ZIP — 123 678 entrées pour un plafond de 65 535, silencieusement, jusqu'à
l'APK. C'est le nombre d'entrées qui compte, pas la forme du conteneur.
"""
@classmethod
@ -320,11 +323,18 @@ class TestTheRealBundle(unittest.TestCase):
# ce qui se lit comme une régression du transfert alors que rien
# n'était encore transféré. Un état incomplet s'IGNORE ; seule une
# incohérence entre ce qui est là et le dépôt doit échouer.
# L'index se cherche par la MÊME résolution que le vérificateur : la
# chercher ici en dur, sous `<slug>/index.json`, faisait sauter ces
# tests sur toute compilation en archives — ils regardaient ailleurs au
# lieu de vérifier.
for entree in entrees:
slug = entree.get("slug") if isinstance(entree, dict) else entree
if slug and not (cls.repos / str(slug) / "index.json").is_file():
if not isinstance(entree, dict):
continue
try:
cbt.index_path(cls.repos, entree)
except (OSError, KeyError) as exc:
raise unittest.SkipTest(
f"paquet incomplet ({slug} sans index.json) :"
f"paquet incomplet ({exc}) :"
" relancer ./mobile/compile_and_run.sh"
)
@ -332,31 +342,46 @@ class TestTheRealBundle(unittest.TestCase):
rep = cbt.check(MOBILE, REPO)
self.assertGreater(rep["repos"], 1)
self.assertGreater(rep["files"], cbt.MIN_FILES)
self.assertGreater(rep["packs"], 0)
# Peu importe la disposition : ce qui doit être vrai, c'est que les
# sources soient dans des conteneurs et non une entrée ZIP chacune.
self.assertGreater(rep["packs"] + rep["archives"], 0)
def test_a_sample_matches_the_source(self):
"""La seule vérification qui prouve un transfert FIDÈLE."""
rep = cbt.check(MOBILE, REPO)
self.assertGreater(rep["compared"], 0)
def test_the_indexes_are_packed_not_file_per_source(self):
"""Le garde-fou de la limite du ZIP : chaque fichier doit porter sa
tranche. Sans « chunk », c'est un fichier par source, et l'APK sera
refusé — mais bien plus tard, et sans dire pourquoi."""
def test_the_apk_stays_under_the_zip_entry_limit(self):
"""Le garde-fou de la limite du ZIP, énoncé comme l'invariant qu'il est.
Ce test exigeait un champ « chunk » sur chaque fichier, c'est-à-dire la
disposition en packs. Il échouait donc sur une compilation en archives
alors que la limite y est tenue — il testait la FORME au lieu de ce qui
importe : combien d'entrées le ZIP de l'APK va porter. Un fichier par
source en réclamerait 124 350 pour un plafond de 65 535."""
man = json.loads((self.repos / "manifest.json").read_text())
checked = 0
for proj in man[:5]:
index = self.repos / proj["slug"] / "index.json"
entries = json.loads(index.read_text())
files = [e for e in entries if e.get("type") == "file"]
if not files:
entries_in_apk = 0
files = 0
for proj in man:
index = cbt.index_path(self.repos, proj)
listing = json.loads(index.read_text())
files += len([e for e in listing if e.get("type") == "file"])
entries_in_apk += 1 # l'index lui-même
if cbt.archive_path(self.repos, proj) is not None:
entries_in_apk += 1 # une archive
continue
self.assertTrue(
all("chunk" in e for e in files),
f"{proj['slug']} : des fichiers sans tranche",
entries_in_apk += len(
list((self.repos / proj["slug"]).glob("pack-*.bin"))
)
checked += 1
self.assertGreater(checked, 0, "aucun dépôt à vérifier")
self.assertGreater(files, cbt.MIN_FILES, "aucun dépôt à vérifier")
self.assertLess(
entries_in_apk,
ZIP_ENTRY_LIMIT,
f"{entries_in_apk} entrées dans l'APK pour {files} fichiers",
)
# Et pas seulement « sous la limite » : l'écart doit rester d'un ordre
# de grandeur, sans quoi le prochain dépôt ajouté la ferait sauter.
self.assertLess(entries_in_apk, files // 10)
def test_no_bundled_test_file_lingers_as_a_source(self):
"""Effet de bord mesuré, et il compte : empaquetés, les 1 599 fichiers

348
test/test_monitoring.py Normal file
View file

@ -0,0 +1,348 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Le monitoring : ce qu'il refuse, et ce qu'il avoue ne pas savoir.
Deux propriétés valent d'être épinglées, et ce ne sont pas les plus
visibles.
La première est un refus. Toutes les analyses du dépôt lisent par psql en
`default_transaction_read_only=on` : c'est le SERVEUR qui interdit
d'écrire. Une session RPC n'a pas d'équivalent — rien n'empêche un
`write` sur la production d'un client. La liste blanche est donc appliquée
dans le passe-plat lui-même, et non chez l'appelant, pour qu'aucune
analyse à venir ne puisse s'en dispenser par distraction.
La seconde est un aveu. Une provenance qui ne permet pas une analyse doit
le DIRE. Une analyse muette qu'on prend pour rassurante est pire que pas
d'analyse : c'est la faute que ce dépôt a déjà corrigée trois fois.
"""
import os
import sys
import unittest
sys.path.insert(
0, os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
)
from script.analyse import check_migration_residue as residue # noqa: E402
from script.analyse import monitoring # noqa: E402
from script.analyse import monitoring_tui # noqa: E402
class TestWhatTheProxyRefuses(unittest.TestCase):
"""Le passe-plat RPC n'accepte que la lecture."""
def test_a_write_is_refused_before_any_network_call(self):
# Aucune URL joignable ici : si l'appel partait, le test échouerait
# par timeout au lieu de PermissionError. C'est le contrôle qu'on
# veut — le refus vient AVANT le réseau.
for methode in ("write", "create", "unlink", "execute", "load"):
with self.assertRaises(PermissionError):
monitoring.live_call(
"http://127.0.0.1:1", "db", 1, "x", "res.partner", methode
)
def test_the_allowlist_holds_nothing_that_writes(self):
interdits = ("write", "create", "unlink", "copy", "load", "execute")
for methode in monitoring.RPC_READ_ONLY:
self.assertNotIn(methode, interdits)
def test_the_refusal_names_the_method(self):
with self.assertRaises(PermissionError) as capture:
monitoring.live_call(
"http://127.0.0.1:1", "db", 1, "x", "res.partner", "unlink"
)
self.assertIn("unlink", str(capture.exception))
class TestWhatASourceAdmits(unittest.TestCase):
"""Ce qu'une provenance ne permet pas doit être dit, pas caché."""
def test_every_analysis_says_why_it_cannot_do_live(self):
for analyse in monitoring.ANALYSES:
self.assertTrue(analyse["needs_sql"].strip(), analyse["key"])
def test_available_and_unavailable_cover_every_analysis(self):
for genre in (monitoring.KIND_DATABASE, monitoring.KIND_LIVE):
total = len(monitoring.available(genre)) + len(
monitoring.unavailable(genre)
)
self.assertEqual(total, len(monitoring.ANALYSES))
def test_a_restored_backup_and_a_database_are_the_same_thing(self):
"""Passé la restauration, la provenance ne se distingue plus."""
self.assertTrue(monitoring.available(monitoring.KIND_DATABASE))
def test_an_unknown_key_gives_none_rather_than_raising(self):
self.assertIsNone(monitoring.analysis_by_key("pas_une_analyse"))
def test_the_command_names_the_database(self):
analyse = monitoring.ANALYSES[0]
cmd = monitoring.command_for(analyse, "ma_base")
self.assertIn("-d", cmd)
self.assertEqual(cmd[cmd.index("-d") + 1], "ma_base")
self.assertIn(analyse["script"], cmd)
class TestWhetherTheNeutralisationTook(unittest.TestCase):
"""Poser la question et ne pas vérifier, c'est l'illusion mesurée."""
def test_a_missing_flag_is_reported_as_not_neutralised(self):
rapport = monitoring.neutralize_report(
{"flag": 0, "cron_active": 35, "mail_server": 0, "payment_live": 1}
)
self.assertIn("❌", rapport)
self.assertIn("35", rapport)
def test_no_mail_server_is_a_warning_and_never_a_reassurance(self):
"""Zéro serveur NE prouve PAS la sûreté : Odoo retombe sur la conf.
C'est pour cela que le `neutralize.sql` d'Odoo INSÈRE un serveur
bouchon au lieu de tout supprimer. Compter zéro et conclure « sûr »
se tromperait dans le mauvais sens.
"""
rapport = monitoring.neutralize_report(
{"flag": 1, "cron_active": 0, "mail_server": 0, "payment_live": 0}
)
self.assertIn("⚠", rapport)
self.assertIn("smtp_server", rapport)
def test_a_table_that_does_not_exist_is_not_counted_as_zero(self):
"""Absent n'est pas nul, et surtout pas une bonne nouvelle."""
appels = []
class FauxLib:
@staticmethod
def run_psql(database, sql, config_path=None):
appels.append(sql)
raise RuntimeError("relation does not exist")
# `from script.analyse import lib_analyse` lit l'ATTRIBUT du paquet :
# remplacer l'entrée de sys.modules ne change rien, et le vrai psql
# tourne. Le compteur d'appels est là pour le prouver — sans lui, ce
# test passait au vert avec ZÉRO appel bouchonné.
import script.analyse as paquet
sauvegarde = paquet.lib_analyse
paquet.lib_analyse = FauxLib
try:
etat = monitoring.neutralize_state("peu_importe")
finally:
paquet.lib_analyse = sauvegarde
for valeur in etat.values():
self.assertIsNone(valeur)
self.assertEqual(len(appels), len(monitoring.NEUTRALIZE_SQL))
def test_a_live_payment_provider_is_an_error_not_a_warning(self):
rapport = monitoring.neutralize_report(
{"flag": 1, "cron_active": 0, "mail_server": 1, "payment_live": 2}
)
ligne = [
texte
for texte in rapport.splitlines()
if "payment" in texte or "paiement" in texte
]
self.assertTrue(ligne)
self.assertTrue(ligne[0].startswith("❌"))
class TestTheChooserScreen(unittest.TestCase):
"""L'écran ne fait que choisir — mais il doit choisir juste."""
def test_every_analysis_is_shown_even_when_it_cannot_run(self):
lignes = monitoring_tui.rows(monitoring.KIND_LIVE)
self.assertEqual(len(lignes), len(monitoring.ANALYSES))
def test_an_unusable_line_carries_its_reason(self):
for _key, _label, utilisable, raison in monitoring_tui.rows(
monitoring.KIND_LIVE
):
if not utilisable:
self.assertTrue(raison.strip())
def test_a_usable_line_carries_no_excuse(self):
for _key, _label, utilisable, raison in monitoring_tui.rows(
monitoring.KIND_DATABASE
):
if utilisable:
self.assertEqual(raison, "")
def test_the_detail_pane_says_when_a_source_cannot_serve(self):
texte = monitoring_tui.detail(
monitoring.ANALYSES[0]["key"], monitoring.KIND_LIVE
)
self.assertIn("✖", texte)
def test_the_detail_pane_of_an_unknown_key_is_empty(self):
self.assertEqual(
monitoring_tui.detail("pas_une_analyse", monitoring.KIND_DATABASE),
"",
)
class TestWhatAMigrationLeftBehind(unittest.TestCase):
"""Le classement, et surtout ce qu'il refuse de taire."""
def test_broken_is_read_before_watch(self):
resultats = {c["key"]: 0 for c in residue.CONTROLES}
resultats["duplicate_index"] = 400 # watch
resultats["lang_active_null"] = 1 # broken
trouve, _ = residue.judge(resultats)
self.assertEqual(trouve[0][0]["key"], "lang_active_null")
def test_a_check_that_could_not_run_is_not_a_check_that_found_nothing(
self,
):
resultats = {c["key"]: 0 for c in residue.CONTROLES}
resultats["missing_pricelist"] = {"error": "relation absente"}
trouve, illisibles = residue.judge(resultats)
self.assertEqual(trouve, [])
self.assertEqual(len(illisibles), 1)
def test_the_report_shows_the_unreadable_ones(self):
resultats = {c["key"]: 0 for c in residue.CONTROLES}
resultats["missing_pricelist"] = {"error": "relation absente"}
texte = residue.render("base", resultats, colour=False)
self.assertIn("❔", texte)
self.assertIn("relation absente", texte)
def test_a_clean_database_states_what_it_could_not_see(self):
"""Un vert qui ne dit pas sa portée se lit comme une garantie."""
resultats = {c["key"]: 0 for c in residue.CONTROLES}
texte = residue.render("base", resultats, colour=False)
self.assertIn("✅", texte)
self.assertIn(
residue.t("This reads one database on its own — it cannot see"),
texte,
)
def test_every_finding_names_a_repair_or_says_there_is_none(self):
resultats = {c["key"]: 1 for c in residue.CONTROLES}
texte = residue.render("base", resultats, colour=False)
for controle in residue.CONTROLES:
attendu = controle["repair"] or residue.t("no repair tool yet")
self.assertIn(attendu, texte)
def test_each_check_is_declared_completely(self):
cles = set()
for controle in residue.CONTROLES:
for champ in ("key", "title", "why", "sql", "gravity"):
self.assertTrue(controle.get(champ), controle["key"])
self.assertIn(controle["gravity"], ("broken", "watch"))
self.assertNotIn(controle["key"], cles)
cles.add(controle["key"])
def test_each_query_reads_and_returns_one_number(self):
"""Une seule colonne, un seul SELECT : `inspect` en fait un int."""
for controle in residue.CONTROLES:
sql = " ".join(controle["sql"].split())
self.assertTrue(sql.upper().startswith("SELECT"), controle["key"])
for interdit in ("INSERT", "UPDATE", "DELETE", "DROP", "ALTER"):
self.assertNotIn(f" {interdit} ", f" {sql.upper()} ")
def test_the_exit_code_separates_nothing_from_something(self):
vide = {c["key"]: 0 for c in residue.CONTROLES}
self.assertEqual(residue.judge(vide)[0], [])
plein = dict(vide, lang_active_null=3)
self.assertTrue(residue.judge(plein)[0])
if __name__ == "__main__":
unittest.main()
class TestTheScreenAnswersKeys(unittest.TestCase):
"""Presser les touches, et pas seulement lire les fonctions pures.
`ListView` consomme Entrée pour émettre son propre `Selected` : la
liaison de l'application ne la voit jamais. L'écran ne répondait donc
pas à Entrée, et `rows()` comme `detail()` restaient parfaitement
justes — aucun test de fonction pure ne pouvait le montrer.
"""
def setUp(self):
try:
import textual # noqa: F401
except ImportError:
self.skipTest("Textual absent de cet interpréteur")
def _presser(self, kind, touches):
import asyncio
async def jouer():
app = monitoring_tui.build_app(kind, "ma_base")
async with app.run_test() as pilot:
for touche in touches:
await pilot.press(touche)
await pilot.pause()
return app.return_value
return asyncio.run(jouer())
def test_enter_chooses_the_highlighted_analysis(self):
self.assertEqual(
self._presser(monitoring.KIND_DATABASE, ["enter"]),
monitoring.ANALYSES[0]["key"],
)
def test_moving_down_then_enter_chooses_the_second(self):
self.assertEqual(
self._presser(monitoring.KIND_DATABASE, ["down", "enter"]),
monitoring.ANALYSES[1]["key"],
)
def test_enter_on_an_unusable_line_chooses_nothing(self):
"""Et ne lève pas : un écran qui plante vaut moins qu'un refus."""
self.assertIsNone(self._presser(monitoring.KIND_LIVE, ["enter"]))
def test_q_gives_up(self):
self.assertIsNone(self._presser(monitoring.KIND_DATABASE, ["q"]))
class TestTheSourceMenuIsWrittenTwice(unittest.TestCase):
"""Les numéros affichés mènent-ils où ils le disent ?
`_monitoring_select_source` imprime « [2] Une sauvegarde .zip » d'un
côté et teste `answer == "2"` de l'autre. Rien ne relie les deux :
insérer « une base locale » en tête décale tout le reste à la main.
C'est le piège exact que `MenuCoherence` garde pour les autres menus,
et celui-ci n'entre pas dans son moule — il s'imprime, il ne se
déclare pas.
"""
def setUp(self):
import re
from pathlib import Path
todo = (
Path(__file__).resolve().parent.parent
/ "script"
/ "todo"
/ "todo.py"
)
source = todo.read_text(encoding="utf-8")
debut = source.index("def _monitoring_select_source(self):")
fin = source.index("def _monitoring_live(self):", debut)
self.corps = source[debut:fin]
self.affiches = re.findall(r'print\(f"\[(\d+)\] \{t\(', self.corps)
self.branches = re.findall(r'if answer == "(\d+)":', self.corps)
def test_the_menu_was_actually_parsed(self):
"""Sur des listes vides, tout passe : mieux vaut tomber ici."""
self.assertGreaterEqual(len(self.affiches), 4)
def test_every_shown_entry_has_a_branch(self):
montres = [n for n in self.affiches if n != "0"]
self.assertEqual(sorted(montres), sorted(self.branches))
def test_the_numbering_is_contiguous_from_one(self):
montres = sorted(int(n) for n in self.affiches if n != "0")
self.assertEqual(montres, list(range(1, len(montres) + 1)))
def test_a_local_database_is_offered_first(self):
"""La provenance la plus directe, et l'ordre du menu d'à côté."""
self.assertEqual(self.affiches[0], "1")
self.assertIn("A local database", self.corps.split("if answer")[0])

View file

@ -15,6 +15,7 @@ aucun hôte Proxmox n'est joint.
import asyncio
import sys
import os
import unittest
sys.argv = ["todo.py"]
@ -467,6 +468,373 @@ class TestCreerUnPont(unittest.TestCase):
self.assertIn("vmbr9", vu["choix_avant"])
@unittest.skipUnless(TEXTUAL, "Textual absent")
class TestLInterpretePython(unittest.TestCase):
"""L'écran Proxmox n'offrait pas le choix, donc envoyait toujours
« automatique » — et comme mise n'est jamais installé d'office, c'était
pyenv, qui COMPILE Python. Rapporté sur une VM Arch : « il utilise le
tar.xz pour le compiler »."""
def _ecran(self, gestes=None, mise_arches=("amd64", "arm64")):
from script.todo.proxmox_deploy_form import run_proxmox_form
ctx = contexte()
ctx["mise_arches"] = mise_arches
vu = {}
async def scenario():
from textual.widgets import SelectionList
app = run_proxmox_form(ctx, run_app=False)
async with app.run_test(size=(200, 60)) as pilote:
await pilote.pause()
liste = app.query_one(SelectionList)
liste.select(liste.get_option_at_index(0).value)
await pilote.pause()
await pilote.pause()
if gestes:
await gestes(app, pilote)
vu["choix"] = app._python_provider()
app.action_deploy()
vu["spec"] = app.result or {}
asyncio.run(scenario())
return vu
def test_mise_is_offered_by_default(self):
# Un CPython précompilé plutôt qu'une compilation de trois minutes.
self.assertEqual(self._ecran()["choix"], "mise")
def test_the_choice_reaches_the_spec(self):
async def gestes(app, pilote):
list(app.query("#f_python RadioButton"))[1].value = True
await pilote.pause()
vu = self._ecran(gestes)
self.assertEqual(vu["choix"], "pyenv")
self.assertEqual(vu["spec"].get("python_provider"), "pyenv")
def test_an_arch_mise_does_not_serve_yields_nothing(self):
# « mise indisponible » ne veut pas dire « l'utilisateur exige
# pyenv » : un choix explicite écarterait le Python de la distro.
self.assertEqual(self._ecran(mise_arches=("s390x",))["choix"], "")
class TestLeDisquePromis(unittest.TestCase):
"""Le plan annonçait « 25G » et « qm resize » recevait 20 G.
La voie libvirt ajoute la marge d'ERPLibre à la taille créée ; celle de
Proxmox la perdait entre l'écran et la commande. La VM naissait cinq
gigaoctets trop petite pour ce qu'on venait de lui promettre."""
def _taille(self, install, cmd_vm=""):
import sys
sys.argv = ["todo.py"]
from script.todo.todo import TODO
todo = TODO.__new__(TODO)
vm = {"disk": "20G", "install_cmd": cmd_vm}
return todo._pve_disk_with_margin(vm, {"install": install})
def test_the_margin_reaches_the_created_disk(self):
self.assertEqual(
self._taille(
{
"branch": "develop",
"cmd": "make install_os && make install_odoo_18",
}
),
"25G",
)
def test_nothing_to_install_means_no_margin(self):
self.assertEqual(self._taille(None), "20G")
def test_a_hypervisor_profile_gets_no_margin(self):
# Elle est réservée au dépôt ERPLibre, qu'un Proxmox ne clonera pas.
self.assertEqual(
self._taille(
{
"branch": "develop",
"cmd": "./script/proxmox/install_proxmox.sh",
}
),
"20G",
)
class TestDeuxVmDuMemeNom(unittest.TestCase):
"""Sur Proxmox, seul le VMID est unique : deux VM du même hôte peuvent
porter le même nom. « Changer l'état » les choisissait par NOM — cocher
l'une éteignait les deux."""
def test_selecting_one_twin_takes_only_that_one(self):
import sys
sys.argv = ["todo.py"]
from script.todo.todo import TODO
todo = TODO.__new__(TODO)
vms = [
{"vmid": 100, "name": "jumeau", "status": "running"},
{"vmid": 101, "name": "jumeau", "status": "running"},
]
rangs = [str(i) for i in range(1, len(vms) + 1)]
for choix, attendu in (
("1", [100]),
("2", [101]),
("1,2", [100, 101]),
):
voulus = {
int(r)
for r in todo._parse_index_selection(choix, rangs)
if str(r).isdigit()
}
self.assertEqual(
[vm["vmid"] for i, vm in enumerate(vms, 1) if i in voulus],
attendu,
choix,
)
class TestLEcranDUneVmProxmox(unittest.TestCase):
"""« Console de l'hyperviseur » conseillait des commandes virsh sur une
machine qui n'a pas libvirt.
Le tunnel lit le port VNC par « virsh vncdisplay » sur l'hyperviseur. Un
Proxmox VE n'a pas de libvirt : la commande échoue, et l'absence de port
était lue « écran fermé ». On imprimait alors « sudo virsh edit » — sur un
hôte où le binaire n'existe pas. Ce n'est pas un écran fermé, c'est la
mauvaise question : Proxmox sert son écran par un ticket, sur son
interface web."""
def _sortie(self, qm_present, port=0):
import contextlib
import io
import sys
sys.argv = ["todo.py"]
from script.todo.todo import TODO
todo = TODO.__new__(TODO)
todo._ssh_proxyjump = lambda nom: "pve9"
todo._qemu_vnc_port = staticmethod(lambda d, j="": port)
todo._hypervisor_is_proxmox = lambda jump: qm_present
tampon = io.StringIO()
with contextlib.redirect_stdout(tampon):
todo._qemu_console_tunnel("pve9+vm-a", "ssh_config")
return tampon.getvalue()
def test_a_proxmox_host_is_never_told_to_run_virsh(self):
sortie = self._sortie(qm_present=True)
self.assertNotIn("virsh", sortie)
self.assertIn("qm terminal", sortie)
self.assertIn("8006", sortie, "l'interface web est le second chemin")
def test_a_libvirt_host_keeps_its_repair_commands(self):
# La voie libvirt ne régresse pas : sans port, ses commandes de
# réparation restent la bonne réponse.
sortie = self._sortie(qm_present=False)
self.assertIn("virsh edit", sortie)
def test_a_working_vnc_port_still_wins(self):
# La sonde ne doit pas s'exécuter quand il y a un port : ce serait un
# aller-retour ssh pour rien.
sortie = self._sortie(qm_present=True, port=5901)
self.assertIn("-L 5901:127.0.0.1:5901", sortie)
self.assertNotIn("qm terminal", sortie)
class TestUnSeulNomDansSshConfig(unittest.TestCase):
"""L'entrée portait DEUX noms sur sa ligne « Host » : le nom chaîné
« hôte+vm » et le nom court.
Rapporté : « Host erplibre-proxmox-9+erplibre-arch-latest
erplibre-arch-latest ». Le second est un doublon dès que le premier
suffit — ssh n'a besoin que d'un nom, et le doubler n'ajoute qu'une façon
de plus d'écrire la même adresse.
Un seul, donc, et le bon : le court quand il est LIBRE, le chaîné quand
il désignerait une autre machine. « Pris » se juge sur le ProxyJump du
bloc, pas sur sa seule présence — sinon notre propre entrée, réécrite à
chaque déploiement, se prendrait pour une rivale et le nom basculerait
d'une fois sur l'autre."""
def setUp(self):
import sys
import tempfile
sys.argv = ["todo.py"]
from script.todo.todo import TODO
self.maison = tempfile.mkdtemp()
os.makedirs(os.path.join(self.maison, ".ssh"))
self._vrai_home = os.environ.get("HOME")
os.environ["HOME"] = self.maison
self.todo = TODO.__new__(TODO)
def tearDown(self):
import shutil
if self._vrai_home is not None:
os.environ["HOME"] = self._vrai_home
shutil.rmtree(self.maison, ignore_errors=True)
def _ecrit(self, noms, rebond=""):
self.todo._write_ssh_config_entry(
noms, "erplibre", "10.10.10.150", proxy_jump=rebond or None
)
def _lignes_host(self):
with open(
os.path.join(self.maison, ".ssh/config"), encoding="utf-8"
) as fh:
return [
ligne.rstrip() for ligne in fh if ligne.startswith("Host ")
]
def _choisit(self, nom, locaux=(), rebond="pve9"):
return self.todo._pve_alias_names(
nom, f"pve9+{nom}", set(locaux), rebond
)
def test_a_free_name_is_written_alone(self):
noms, vole = self._choisit("erplibre-arch-latest")
self.assertEqual(noms, ["erplibre-arch-latest"])
self.assertFalse(vole)
self._ecrit(noms, "pve9")
self.assertEqual(self._lignes_host(), ["Host erplibre-arch-latest"])
def test_redeploying_the_same_vm_keeps_the_same_name(self):
# Le piège du correctif : notre propre bloc déclare déjà le nom.
self._ecrit(["erplibre-arch-latest"], "pve9")
noms, vole = self._choisit("erplibre-arch-latest")
self.assertEqual(noms, ["erplibre-arch-latest"], "le nom a basculé")
self.assertFalse(vole)
def test_a_local_vm_keeps_its_name(self):
# Vécu : « ssh » partait vers la machine locale du même nom.
noms, vole = self._choisit(
"erplibre-arch-latest", locaux=("erplibre-arch-latest",)
)
self.assertEqual(noms, ["pve9+erplibre-arch-latest"])
self.assertTrue(vole)
def test_another_proxmox_host_keeps_its_name(self):
self._ecrit(["erplibre-ubuntu-2604"], "pve7")
noms, vole = self._choisit("erplibre-ubuntu-2604")
self.assertEqual(noms, ["pve9+erplibre-ubuntu-2604"])
self.assertEqual(vole, "~/.ssh/config")
self._ecrit(noms, "pve9")
# Les deux machines cohabitent, chacune sous son nom.
self.assertEqual(
self._lignes_host(),
["Host erplibre-ubuntu-2604", "Host pve9+erplibre-ubuntu-2604"],
)
def test_no_deploy_path_writes_two_names_anymore(self):
import re
from pathlib import Path as P
src = P("script/todo/proxmox_menu.py").read_text(encoding="utf-8")
self.assertIsNone(
re.search(r"noms_alias\.append|noms\.append\(vm\[.name.\]\)", src),
"le second nom ne doit plus être ajouté",
)
class TestLeGuideDeConnexion(unittest.TestCase):
"""Une VM Proxmox n'avait AUCUN guide, quelle que soit sa distribution.
Rapporté sur Arch : « pas l'écran de connexion, avec le guide qui dit de
prendre pacman, comme sur ubuntu ». La voie libvirt livre /etc/motd par le
« write_files » de cloud-init ; « qm set » n'offre pas cela. Le contenu
vient de la MÊME source (`guide_files`) et part par ssh.
"""
def _ecrit(self, vm=None, install=None, distro="arch"):
import sys
sys.argv = ["todo.py"]
from script.todo.todo import TODO
todo = TODO.__new__(TODO)
vus = {}
todo._pve_ssh = lambda cible, remote, timeout=60: (
vus.update(cible=cible, remote=remote) or (0, "")
)
mod = todo._qemu_import_module()
vm = vm or {
"name": "vm-a",
"distro": distro,
"version": "latest",
"arch": "amd64",
"desktop": "",
"install_cmd": "",
}
spec = {"user": "erplibre", "install": install}
import contextlib
import io
with contextlib.redirect_stdout(io.StringIO()):
ok = todo._pve_write_guide("hote+vm-a", vm, spec, mod)
vus["ok"] = ok
return vus
def test_the_guide_goes_to_etc_motd_through_the_alias(self):
vus = self._ecrit()
self.assertTrue(vus["ok"])
# Par l'ALIAS : lui seul porte le rebond vers le réseau interne.
self.assertEqual(vus["cible"], "hote+vm-a")
self.assertIn("/etc/motd", vus["remote"])
self.assertIn("sudo tee", vus["remote"])
def test_an_arch_vm_is_told_about_pacman(self):
self.assertIn("pacman", self._ecrit(distro="arch")["remote"])
def test_a_debian_vm_is_told_about_apt(self):
self.assertIn("apt", self._ecrit(distro="debian")["remote"])
def test_without_erplibre_the_guide_does_not_promise_a_repository(self):
# Un guide qui annonce un dépôt absent est un guide qui mente.
sans = self._ecrit(install=None)["remote"]
self.assertNotIn("git/erplibre", sans)
def test_with_erplibre_it_says_where_it_lives(self):
avec = self._ecrit(
install={
"branch": "develop",
"cmd": "make install_os && make install_odoo_18",
}
)["remote"]
self.assertIn("git/erplibre", avec)
def test_a_failure_is_said_not_swallowed(self):
import contextlib
import io
import sys
sys.argv = ["todo.py"]
from script.todo.todo import TODO
todo = TODO.__new__(TODO)
todo._pve_ssh = lambda *a, **k: (255, "no route")
mod = todo._qemu_import_module()
vm = {
"name": "vm-a",
"distro": "arch",
"version": "latest",
"arch": "amd64",
"desktop": "",
"install_cmd": "",
}
with contextlib.redirect_stdout(io.StringIO()) as sortie:
ok = todo._pve_write_guide("x", vm, {"user": "erplibre"}, mod)
self.assertFalse(ok)
self.assertIn("⚠", sortie.getvalue())
class TestLeSuivi(unittest.TestCase):
"""La case « Suivre l'installation » doit commander quelque chose.
@ -552,6 +920,11 @@ class TestLeSuivi(unittest.TestCase):
nom
)
todo._ssh_private_key = lambda k: None
# Hermétique : le choix du nom lit ~/.ssh/config et la liste des
# domaines locaux. Sans ces deux bouchons, le test dépendrait de
# la machine qui le lance.
todo._ssh_config_block = lambda nom: {}
todo._qemu_list_domains = lambda: []
todo._pve_guest_ip = lambda vmid, attente=120: ""
todo._qemu_install_erplibre_monitored = lambda *a, **k: None
todo._qemu_install_erplibre_vm = lambda *a, **k: None
@ -575,14 +948,64 @@ class TestLeSuivi(unittest.TestCase):
return ecrites
cmd = {"branch": "develop", "cmd": "make x", "label": "X"}
# Décoché mais une installation demandée : écrite quand même.
self.assertEqual(essai(False, cmd, False), ["vm-a"])
# UN nom : le court, puisque rien ne le porte déjà. Le chaîné
# « hôte+vm » ne sort que lorsqu'il faut départager (voir
# TestUnSeulNomDansSshConfig).
self.assertEqual(essai(False, cmd, False), [["vm-a"]])
# Décoché, suivi demandé : le suivi entre aussi par le rebond.
self.assertEqual(essai(False, None, True), ["vm-a"])
self.assertEqual(essai(False, None, True), [["vm-a"]])
# Décoché et rien à faire dans la VM : le choix est respecté.
self.assertEqual(essai(False, None, False), [])
# Coché : écrite, évidemment.
self.assertEqual(essai(True, None, False), ["vm-a"])
self.assertEqual(essai(True, None, False), [["vm-a"]])
def test_a_local_vm_of_the_same_name_keeps_its_alias(self):
"""Le piège qui a fait installer ERPLibre sur la MAUVAISE machine.
Une VM déployée sur Proxmox sous un nom déjà porté par un domaine
LOCAL volait son alias ~/.ssh/config, et le suivi — qui ré-résolvait
l'adresse par virsh — partait installer sur la locale."""
import contextlib
import io
import sys
sys.argv = ["todo.py"]
from script.todo.todo import TODO
todo = TODO.__new__(TODO)
ecrites = []
todo._qemu_list_domains = lambda: ["vm-a"]
todo._write_ssh_config_entry = lambda noms, *a, **k: ecrites.append(
noms
)
todo._ssh_private_key = lambda k: None
todo._pve_guest_ip = lambda vmid, attente=120: ""
vus = {}
todo._qemu_install_erplibre_monitored = (
lambda noms, br, ipmap, cmd, **k: vus.update(ipmap=ipmap)
)
spec = {
"host": {"target": "erplibre@pve1"},
"vms": [
{
"name": "vm-a",
"vmid": 100,
"ipconfig": "ip=10.10.10.150/24,gw=10.10.10.1",
"install_cmd": "",
}
],
"add_ssh_config": True,
"user": "erplibre",
"install": None,
"monitor": True,
}
with contextlib.redirect_stdout(io.StringIO()) as sortie:
todo._pve_after_create(spec["host"], spec, ["vm-a"], "")
# SEUL le nom chaîné est écrit : l'alias court reste à la VM locale.
self.assertEqual(ecrites, [["pve1+vm-a"]])
# Et le suivi passe par ce nom-là, jamais par « vm-a ».
self.assertEqual(vus["ipmap"], {"vm-a": "pve1+vm-a"})
self.assertIn("pve1+vm-a", sortie.getvalue())
def test_ticked_with_an_install_opens_it(self):
vus = self._apres_creation(

View file

@ -16,7 +16,6 @@ actif ce qui ne fait rien.
"""
import asyncio
import re
import sys
import unittest
from pathlib import Path
@ -37,39 +36,63 @@ except Exception: # pragma: no cover - dépend de l'environnement
class TestSaPlace(unittest.TestCase):
"""Lu dans la source : l'ordre du panneau est une décision, pas un hasard."""
"""L'ordre du panneau est une décision, pas un hasard.
Relevé sur l'ÉCRAN MONTÉ et non dans la source : depuis que les réglages
du système invité viennent d'un socle partagé, le fichier ne contient
plus qu'un « yield from » là où le test cherchait un identifiant. Il
passait au vert sur un écran qu'il ne lisait plus — puis au rouge sans
qu'aucun ordre ait bougé. Le DOM, lui, dit ce que l'utilisateur voit."""
@classmethod
def setUpClass(cls):
cls.src = FORM.read_text(encoding="utf-8")
cls.ordre = ordre_du_panneau(contexte()) if TEXTUAL else []
def _rang(self, motif):
m = re.search(re.escape(motif), self.src)
self.assertIsNotNone(m, motif)
return m.start()
def _rang(self, ident):
self.assertIn(ident, self.ordre, ident)
return self.ordre.index(ident)
@unittest.skipUnless(TEXTUAL, "Textual absent")
def test_the_install_section_sits_under_the_vm_type(self):
self.assertLess(
self._rang('with RadioSet(id="f_type")'),
self._rang('id="t_install"'),
)
self.assertLess(self._rang("f_type"), self._rang("t_install"))
@unittest.skipUnless(TEXTUAL, "Textual absent")
def test_and_before_the_sections_it_commands(self):
# Magasin d'applications, outils : ils dépendent d'elle, donc ils
# viennent après.
for apres in ('id="t_store"', 'id="t_tools"'):
self.assertLess(self._rang('id="t_install"'), self._rang(apres))
for apres in ("t_store", "t_tools"):
self.assertLess(self._rang("t_install"), self._rang(apres))
def test_the_checkbox_no_longer_claims_to_be_about_erplibre(self):
self.assertIn('t("Install software in the VM")', self.src)
self.assertNotIn('t("Install ERPLibre")', self.src)
@unittest.skipUnless(TEXTUAL, "Textual absent")
def test_the_monitor_left_the_install_section(self):
# Rangé dedans, il se serait grisé avec elle — et décocher ERPLibre
# avait déjà fait disparaître le tableau de bord une fois.
self.assertLess(
self._rang('id="t_deploy"'), self._rang('id="f_monitor"')
self.assertLess(self._rang("t_deploy"), self._rang("f_monitor"))
def ordre_du_panneau(ctx, forme="qemu"):
"""Les identifiants du panneau gauche, dans l'ordre où ils s'affichent."""
from script.todo.proxmox_deploy_form import run_proxmox_form
from script.todo.qemu_deploy_form import run_deploy_form
vu = []
async def scenario():
app = (
run_deploy_form(ctx, run_app=False)
if forme == "qemu"
else run_proxmox_form(ctx, run_app=False)
)
async with app.run_test(size=(200, 60)) as pilote:
await pilote.pause()
vu.extend(w.id for w in app.query("#fields *") if w.id)
asyncio.run(scenario())
return vu
def contexte():

View file

@ -212,8 +212,13 @@ class TestLeJournal(unittest.TestCase):
vus = []
vrai_launch, vrai_dir = mon._launch_one, mon.session_dir
# « **kw » et non une liste figée : chaque paramètre ajouté au
# lanceur (comme « pve ») casserait sinon ce test, qui ne vérifie
# pourtant que le prologue du journal.
mon._launch_one = (
lambda ip, cmd, log, name="", installs=True: vus.append(installs)
lambda ip, cmd, log, name="", installs=True, **kw: vus.append(
installs
)
)
# session_dir détournée : sans cela le test écrivait de VRAIES sessions
# dans ~/.erplibre/qemu-install, qui polluaient l'historique que

View file

@ -131,6 +131,429 @@ class TestLAppel(unittest.TestCase):
self.assertEqual(mon.read_pvestats(self._vms(1), now=1.0), {})
class TestPasDePoubelleTropTot(unittest.TestCase):
"""« Effacée » est un état TERMINAL : la ligne gèle sur 🗑 pour de bon.
Rapporté sur une VM Arch déployée sur Proxmox : poubelle dès le premier
tour, alors que la VM venait de naître. Un relevé manquant ne prouve
rien — l'hôte peut être occupé, la VM en train de démarrer, le relevé en
cache d'avant sa création.
"""
def setUp(self):
mon._PVE_CACHE.update({"at": 0.0, "stats": {}, "ok": False})
def _vm(self):
return {
"name": "vm-a",
"pve": {
"target": "hote",
"sudo": "sudo ",
"vmid": 105,
"addr": "10.10.10.155",
},
}
def test_a_silent_host_is_not_a_deletion(self):
# (relevés, succès) : la nuance est tout le correctif.
with mock.patch(
"script.proxmox.proxmox_deploy.run", return_value=(255, "timeout")
):
stats, ok = mon.read_pvestats_detail([self._vm()], now=1.0)
self.assertEqual(stats, {})
self.assertFalse(ok)
def test_a_host_that_answers_says_so(self):
with mock.patch(
"script.proxmox.proxmox_deploy.run", return_value=(0, "[]")
):
stats, ok = mon.read_pvestats_detail([self._vm()], now=1.0)
self.assertEqual(stats, {})
self.assertTrue(ok, "l'hôte a répondu : la VM est vraiment absente")
def test_the_cache_keeps_the_verdict_too(self):
with mock.patch(
"script.proxmox.proxmox_deploy.run", return_value=(0, "[]")
) as appel:
mon.read_pvestats_detail([self._vm()], now=100.0)
_stats, ok = mon.read_pvestats_detail([self._vm()], now=100.5)
self.assertEqual(appel.call_count, 1)
self.assertTrue(ok, "le cache rendait « échec » à chaque tour suivant")
def test_it_takes_several_absences_to_conclude(self):
# Trois, pas une : le nombre est explicite, pas enfoui.
self.assertGreaterEqual(mon.PVE_ABSENCES_AVANT_EFFACEE, 2)
def test_the_short_reader_still_returns_only_stats(self):
# `read_pvestats` reste la forme courte pour la boucle des colonnes.
with mock.patch(
"script.proxmox.proxmox_deploy.run", return_value=(0, "[]")
):
self.assertEqual(mon.read_pvestats([self._vm()], now=7.0), {})
class TestLesAutresCheminsVersLaPoubelle(unittest.TestCase):
"""Trouvés par un audit, pas à l'usage : trois autres façons d'arriver au
🗑 sur un seul incident. « Effacée » gèle la ligne pour de bon, donc
chacune valait un correctif."""
def test_a_broken_pvesh_is_not_an_answer(self):
# La commande est une SUITE : son code de sortie est celui du DERNIER
# maillon. Un pvesh en panne rendait « l'hôte a répondu, la VM n'y est
# plus » — et trois tours plus tard, la poubelle.
self.assertFalse(
mon._resources_parsable("permission denied\n---ERPLIBRE-DU---\n")
)
self.assertTrue(mon._resources_parsable("[]\n---ERPLIBRE-DU---\n"))
def test_a_failing_host_never_counts_as_an_answer(self):
mon._PVE_CACHE.update({"at": 0.0, "stats": {}, "ok": False})
vm = {"name": "vm-a", "pve": {"target": "h", "sudo": "", "vmid": 1}}
with mock.patch(
"script.proxmox.proxmox_deploy.run",
return_value=(0, "sudo: a password is required\n"),
):
_stats, ok = mon.read_pvestats_detail([vm], now=10.0)
self.assertFalse(ok, "code 0 ne prouve pas que pvesh a parlé")
def test_an_unknown_proxmox_status_is_not_a_deletion(self):
# Proxmox en a d'autres que les trois attendus : « prelaunch »,
# « suspended », « internal-error », « hibernated ».
for etat in ("prelaunch", "suspended", "internal-error", "hibernated"):
self.assertIsNone(
mon.PVE_ETATS.get(etat),
"l'état n'est pas dans la table : le repli doit être « la VM"
" existe », pas « gone »",
)
def test_the_absence_counter_is_explicit(self):
self.assertGreaterEqual(mon.PVE_ABSENCES_AVANT_EFFACEE, 2)
class TestCeQueLaConfirmationPromet(unittest.TestCase):
"""La confirmation de suppression annonçait un fichier qcow2 local à
TOUTE VM, Proxmox comprise.
Sur une VM Proxmox ce fichier n'existe pas : son disque vit dans un
stockage que seul l'hôte connaît. La ligne désignait donc un chemin local
— au mieux inexistant, au pire celui d'une autre VM du même nom. C'est
exactement la peur qui avait fait remonter le nettoyage : « le nettoyage
risque d'effacer des VM en production »."""
def test_a_proxmox_vm_never_shows_a_local_path(self):
lignes = mon.delete_lines(
{"name": "vm-a", "pve": {"target": "pve9", "vmid": 101}}
)
texte = " ".join(lignes)
self.assertNotIn("/var/lib/libvirt", texte)
self.assertNotIn("qcow2", texte)
def test_it_names_the_host_and_the_vmid(self):
# Le nom ne suffit pas : deux VM peuvent le porter, seul le VMID est
# unique — et il faut savoir SUR QUELLE machine ça se passe.
texte = " ".join(
mon.delete_lines(
{"name": "vm-a", "pve": {"target": "pve9", "vmid": 101}}
)
)
self.assertIn("pve9", texte)
self.assertIn("101", texte)
self.assertIn("qm destroy 101", texte)
def test_a_local_vm_still_names_its_file(self):
# La voie libvirt ne régresse pas : là, le fichier EST ce qu'on efface.
texte = " ".join(mon.delete_lines({"name": "vm-a"}))
self.assertIn("/var/lib/libvirt/images/vm-a.qcow2", texte)
class TestQuandLeVertRedescend(unittest.TestCase):
"""Le 🟢 était acquis pour toujours.
« Odoo ne redescend pas en cours d'install » : c'était faux. Le service
redémarre au moins une fois — systemd l'active à la fin du make — et il
lui arrive de mourir. La VM restait verte en servant plus rien, et c'est
précisément le moment où on veut le savoir."""
def _sonde(self, reponse):
return lambda _ip, _port: reponse
def test_a_local_vm_that_stopped_answering_goes_back(self):
etat, sonde = mon.odoo_reading(
{"ip": "10.0.0.1"},
{},
deja_vert=True,
dernier=0.0,
maintenant=mon.ODOO_RECHECK + 1,
sonde=self._sonde(False),
)
self.assertIs(etat, False)
self.assertTrue(sonde)
def test_a_green_local_vm_is_not_probed_every_tick(self):
# La sonde est un connect() TCP par VM : à chaque tour (2 s) c'est
# cher pour une réponse qui ne bouge presque jamais.
etat, sonde = mon.odoo_reading(
{"ip": "10.0.0.1"},
{},
deja_vert=True,
dernier=100.0,
maintenant=101.0,
sonde=self._sonde(False),
)
self.assertIsNone(etat, "rien de neuf ne doit être affirmé")
self.assertFalse(sonde)
def test_a_red_local_vm_is_probed_every_tick(self):
_etat, sonde = mon.odoo_reading(
{"ip": "10.0.0.1"},
{},
deja_vert=False,
dernier=100.0,
maintenant=101.0,
sonde=self._sonde(True),
)
self.assertTrue(sonde)
def test_a_proxmox_vm_is_read_every_tick_for_free(self):
# Le port est testé DEPUIS L'HÔTE, avec les statistiques : d'ici, une
# adresse de pont interne ne répond jamais.
def lu(odoo):
return mon.odoo_reading(
{"pve": {"vmid": 1}},
{"odoo": odoo},
deja_vert=True,
dernier=0.0,
maintenant=1.0,
sonde=self._sonde(True),
)[0]
self.assertIs(lu(True), True)
self.assertIs(lu(False), False)
def test_a_silent_host_is_not_a_dead_odoo(self):
# Sans relevé, on ne sait RIEN : l'appelant garde le dernier état.
etat, _s = mon.odoo_reading(
{"pve": {"vmid": 1}},
{},
deja_vert=True,
dernier=0.0,
maintenant=1.0,
sonde=self._sonde(True),
)
self.assertIsNone(etat)
class TestLaBonneMachine(unittest.TestCase):
"""Le pire défaut de la série : l'installation partie AILLEURS.
Vécu le 24 août 2026. Une VM déployée sur Proxmox sous le nom
« erplibre-ubuntu-2604 » — nom déjà porté par un domaine LOCAL. Le
lanceur détaché ré-résout l'adresse de la VM à chaque tour par virsh, qui
a répondu avec le domaine local : ERPLibre + Odoo se sont installés sur la
MAUVAISE machine, et le journal l'affichait sans que rien n'alerte
(« → 192.168.123.118 »).
Pour une VM distante, l'alias ~/.ssh/config est la seule vérité : il
porte le rebond par l'hôte Proxmox.
"""
def _wrapper(self, **kw):
"""Le script du lanceur, capturé sans rien exécuter."""
vus = {}
vrai = mon.subprocess.Popen
class FauxPopen:
def __init__(self, argv, *a, **k):
vus["argv"] = argv
mon.subprocess.Popen = FauxPopen
try:
mon._launch_one("cible", "echo bonjour", "/dev/null", "vm-a", **kw)
finally:
mon.subprocess.Popen = vrai
return vus["argv"][-1]
def test_a_local_vm_still_gets_its_address_refreshed(self):
# Le bail change en cours de route (cloud-init renomme l'hôte) : la
# ré-résolution est indispensable pour une VM LOCALE.
script = self._wrapper(pve=False)
self.assertIn("virsh", script)
def test_a_proxmox_vm_is_never_re_resolved(self):
# C'est le correctif : aucun appel à virsh, donc aucun risque de
# tomber sur un domaine local homonyme.
script = self._wrapper(pve=True)
self.assertNotIn("virsh", script)
def test_the_target_stays_the_alias(self):
script = self._wrapper(pve=True)
self.assertIn("ip=cible", script)
class TestLeDisque(unittest.TestCase):
"""La colonne Disque annonçait un disque PLEIN qui ne l'était pas.
Rapporté : « 6.0G/6.0G » sur une VM dont l'invité disait « 845M utilisés
sur 5.8G ». La mesure venait de « du -sb », qui rend la taille APPARENTE :
un disque raw creux la donne entière. « du -sB1 » compte les blocs
réellement occupés — 1,2 Go, ce qui correspond.
"""
def test_the_command_counts_real_blocks(self):
self.assertIn("du -sB1", mon.PVE_STATS_CMD)
self.assertNotIn("du -sb", mon.PVE_STATS_CMD)
def test_the_measure_is_read_per_vmid(self):
texte = (
'[{"vmid":101,"name":"vm-a","disk":0,"maxdisk":6442450944,'
'"mem":1,"maxmem":2,"diskwrite":0,"status":"running","uptime":1}]\n'
"---ERPLIBRE-DU---\n"
"1268518912\t/var/lib/vz/images/101/\n"
"4294967296\t/var/lib/vz/images/999/\n"
)
rec = mon.parse_pvestats(texte)["vm-a"]
self.assertEqual(rec["disk_used"], 1268518912)
self.assertEqual(rec["disk_total"], 6442450944)
self.assertEqual(
mon.fmt_pair(rec["disk_used"], rec["disk_total"]), "1.2G/6.0G"
)
class TestOuVaLaCommande(unittest.TestCase):
"""Chaque action doit viser la BONNE machine.
Une VM distante et un domaine local peuvent porter le même nom : « s »
ouvrait la locale, la console ouvrait la console de la locale, et la pause
suspendait la locale. Le VMID et le rebond sont les seules désignations
qui ne trompent pas.
"""
LOCALE = {"name": "vm-a", "ip": "192.168.123.118"}
DISTANTE = {
"name": "vm-a",
"ip": "pve1+vm-a",
"pve": {
"target": "erplibre@pve1",
"sudo": "sudo ",
"jump": "",
"vmid": 101,
"addr": "10.10.10.151",
},
}
def test_ssh_to_a_local_vm_uses_its_address(self):
self.assertIn(
"erplibre@192.168.123.118", mon.vm_ssh_prefix(self.LOCALE)
)
self.assertNotIn("-J", mon.vm_ssh_prefix(self.LOCALE))
def test_ssh_to_a_remote_vm_goes_through_the_jump(self):
cmd = mon.vm_ssh_prefix(self.DISTANTE)
self.assertIn("-J", cmd)
self.assertIn("erplibre@pve1", cmd)
self.assertIn("erplibre@10.10.10.151", cmd)
def test_without_an_address_it_falls_back_to_the_alias(self):
vm = {"name": "vm-a", "ip": "pve1+vm-a", "pve": {"target": "pve1"}}
self.assertIn("erplibre@pve1+vm-a", mon.vm_ssh_prefix(vm))
def test_the_console_of_a_remote_vm_is_qm_terminal(self):
cmd = mon.pve_host_cmd(
self.DISTANTE["pve"], "qm terminal 101", tty=True
)
self.assertIn("ssh -t", cmd)
self.assertIn("qm terminal 101", cmd)
self.assertIn("sudo", cmd)
# Et surtout PAS virsh, qui viserait le domaine local homonyme.
self.assertNotIn("virsh", cmd)
def test_a_host_without_sudo_is_not_wrapped(self):
cmd = mon.pve_host_cmd({"target": "root@pve1", "sudo": ""}, "qm list")
self.assertNotIn("sh -c", cmd)
class TestLaColonneOdoo(unittest.TestCase):
"""Le port 8069 se teste DEPUIS L'HÔTE, dans l'appel déjà payé.
Sondé depuis le poste, il ne répond jamais pour une VM sur pont interne :
la colonne restait « — » quel que soit l'état d'Odoo. Un aller-retour ssh
de plus par tour aurait coûté une seconde ; celui des statistiques est
déjà là.
"""
def test_the_probe_rides_along_the_stats_call(self):
cmd = mon.pve_stats_cmd(["10.10.10.150", "10.10.10.151"])
self.assertIn("pvesh get /cluster/resources", cmd)
self.assertIn("/dev/tcp/$a/8069", cmd)
self.assertIn("10.10.10.151", cmd)
def test_without_addresses_nothing_is_added(self):
self.assertEqual(mon.pve_stats_cmd([]), mon.PVE_STATS_CMD)
def test_the_answer_is_read_per_address(self):
sortie = (
"[]\n---ERPLIBRE-DU---\n---ERPLIBRE-ODOO---\n"
"ODOO 10.10.10.151\n"
)
self.assertEqual(mon.parse_odoo_probe(sortie), {"10.10.10.151"})
def test_a_silent_port_yields_nothing(self):
self.assertEqual(
mon.parse_odoo_probe("[]\n---ERPLIBRE-DU---\n"), set()
)
self.assertEqual(mon.parse_odoo_probe(""), set())
def test_the_du_block_is_not_mistaken_for_a_probe(self):
# Les deux blocs se suivent : le lecteur doit prendre le bon.
sortie = (
"[]\n---ERPLIBRE-DU---\n1268518912\t/var/lib/vz/images/101/\n"
"---ERPLIBRE-ODOO---\nODOO 10.10.10.151\n"
)
self.assertEqual(mon.parse_odoo_probe(sortie), {"10.10.10.151"})
class TestLeWebEtLaSuppression(unittest.TestCase):
"""Les deux dernières actions qui visaient la mauvaise machine."""
INFO = {
"target": "erplibre-proxmox-9",
"sudo": "sudo ",
"jump": "",
"vmid": 101,
"addr": "10.10.10.151",
}
def test_the_web_view_tunnels_through_the_host(self):
argv = mon.web_tunnel_argv(self.INFO)
self.assertEqual(argv[-1], "erplibre-proxmox-9")
self.assertIn("-L", argv)
self.assertIn("18069:10.10.10.151:8069", argv)
# Pas de « -f » : le tunnel se referme par son PID, et « pkill -f »
# tuait le shell qui l'avait lancé.
self.assertNotIn("-f", argv)
def test_a_local_vm_needs_no_tunnel(self):
self.assertIsNone(mon.web_tunnel_argv(None))
self.assertIsNone(mon.web_tunnel_argv({"target": "pve1"}))
def test_the_jump_of_the_host_is_chained(self):
argv = mon.web_tunnel_argv(dict(self.INFO, jump="rebond"))
self.assertIn("-J", argv)
self.assertIn("rebond", argv)
def test_deleting_a_remote_vm_uses_its_vmid(self):
cmd = mon.delete_vm_cmd_pve(self.INFO)
self.assertIn("qm destroy 101", cmd)
self.assertIn("--purge", cmd)
# « virsh undefine <nom> » aurait effacé le domaine LOCAL homonyme.
self.assertNotIn("virsh", cmd)
def test_deleting_a_local_vm_is_unchanged(self):
cmd = mon.delete_vm_cmd("vm-a", True)
self.assertIn("virsh undefine", cmd)
self.assertIn("/var/lib/libvirt/images/vm-a.qcow2", cmd)
class TestLEtat(unittest.TestCase):
"""Une VM absente de « virsh list » passait pour EFFACÉE."""

View file

@ -0,0 +1,346 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Les deux écrans de déploiement offrent les MÊMES réglages du système invité.
Type de VM, production, magasin d'applications, outils de développement,
fuseau horaire, interpréteur Python : six réglages qui décrivent l'invité, pas
la machine qui le porte. Ils valent donc mot pour mot sur libvirt et sur
Proxmox VE.
C'est ce qui avait dérivé. L'écran QEMU/KVM les portait tous les six, l'écran
Proxmox trois : une VM créée là-bas naissait serveur nu, sans outils, en UTC —
et rien ne le disait. La duplication était le mécanisme de la dérive, pas son
symptôme : chaque correctif se posait sur un seul des deux écrans.
Ce fichier teste donc la PARITÉ elle-même, et pas six comportements. Ajouter
un réglage à un seul écran le fait échouer, quel que soit ce réglage."""
import asyncio
import sys
import unittest
sys.argv = ["todo.py"]
from script.todo.todo import TODO # noqa: E402
try:
import textual # noqa: F401
TEXTUAL = True
except Exception: # pragma: no cover - dépend de l'environnement
TEXTUAL = False
# Ce que porte le socle partagé. Les identifiants, parce qu'ils sont le
# contrat : c'est par eux que la spec est lue.
REGLAGES = ("f_type", "f_prod", "f_store", "f_tools", "f_tz", "f_python")
CATALOGUE = [
{
"distro": "ubuntu",
"version": "26.04",
"arch": "amd64",
"name": "erplibre-ubuntu-2604",
"ram": 2048,
"disk": "20G",
},
]
def todo_muet():
todo = TODO.__new__(TODO)
todo._qemu_list_domains = lambda: []
todo._qemu_branch_list = lambda: ["develop", "master"]
return todo
def contexte_proxmox(todo):
"""Le contexte de l'écran Proxmox, hôte simulé — aucune VM n'est créée."""
return dict(
host={"target": "pve", "label": "pve"},
node="pve",
arches=["amd64"],
native="amd64",
catalog={"amd64": list(CATALOGUE)},
names=[],
vmids=[],
next_vmid=100,
storages=["local-lvm"],
storage="local-lvm",
storage_avail={"local-lvm": 500 << 30},
bridges=["vmbr0"],
bridge="vmbr0",
ipconfig=lambda _p, _v: "ip=dhcp",
nameservers=["1.1.1.1"],
branches=["develop", "master"],
branch_current="develop",
install_profiles=[
("ERPLibre + Odoo 18", "make install_os && make install_odoo_18")
],
distro_profiles={},
ssh_key="~/.ssh/id_ed25519.pub",
cpu_presets=(1, 2, 4),
ram_presets=(2048, 4096),
disk_presets=(20, 40),
base_vcpus=2,
host_cpu=8,
free_ram=16000,
extra_disk_gb=5,
**todo._qemu_guest_context(),
)
def releve(fabrique, ctx, gestes=None):
"""Monte l'écran, choisit la première entrée, et relève ce qu'il porte."""
vu = {}
async def scenario():
from textual.widgets import SelectionList
app = fabrique(ctx, run_app=False)
async with app.run_test(size=(220, 70)) as pilote:
await pilote.pause()
liste = app.query_one(SelectionList)
liste.select(liste.get_option_at_index(0).value)
await pilote.pause()
await pilote.pause()
if gestes:
await gestes(app, pilote)
vu["ids"] = {w.id for w in app.query("#fields *") if w.id}
vu["spec"] = app._form_values()
vu["noms"] = [r["vm"]["name"] for r in app.rows]
vu["disques"] = [r["disk_gb"] for r in app.rows]
asyncio.run(scenario())
return vu
@unittest.skipUnless(TEXTUAL, "Textual absent")
class TestLesDeuxEcrans(unittest.TestCase):
@classmethod
def setUpClass(cls):
from script.todo.proxmox_deploy_form import run_proxmox_form
from script.todo.qemu_deploy_form import run_deploy_form
todo = todo_muet()
mod = todo._qemu_import_module()
cls.qemu = releve(run_deploy_form, todo._qemu_form_context(mod))
cls.pve = releve(run_proxmox_form, contexte_proxmox(todo))
def _porte(self, vu, ident):
"""Un réglage est là s'il a son widget — les outils sont une case par
outil, donc un préfixe."""
if ident == "f_tools":
return any(i.startswith("f_tool_") for i in vu["ids"])
return ident in vu["ids"]
def test_both_screens_offer_the_same_guest_settings(self):
for ident in REGLAGES:
with self.subTest(reglage=ident):
self.assertTrue(self._porte(self.qemu, ident), "QEMU/KVM")
self.assertTrue(self._porte(self.pve, ident), "Proxmox")
def test_both_specs_carry_the_same_guest_keys(self):
from script.todo.deploy_form_extras import ExtrasMixin
attendues = set(ExtrasMixin.extras_values(self._faux()))
for nom, vu in (("QEMU/KVM", self.qemu), ("Proxmox", self.pve)):
with self.subTest(ecran=nom):
self.assertTrue(attendues <= set(vu["spec"]), nom)
def _faux(self):
"""Un porteur du socle sans écran : la liste des clés ne dépend pas
des widgets, et c'est justement ce qu'on veut vérifier."""
from script.todo.deploy_form_extras import ExtrasMixin
class Vide(ExtrasMixin):
vms = ()
rows = ()
def query_one(self, _s, *_a):
raise LookupError
vide = Vide()
vide.extras_init({})
return vide
def test_both_screens_bind_the_same_catalog_shortcuts(self):
# « Versions principales » (F7) manquait à l'écran Proxmox, qui
# affiche pourtant le même catalogue, drapeau « default » compris.
from script.todo.proxmox_deploy_form import run_proxmox_form
from script.todo.qemu_deploy_form import run_deploy_form
todo = todo_muet()
mod = todo._qemu_import_module()
touches = {}
for nom, app in (
("QEMU/KVM", run_deploy_form(todo._qemu_form_context(mod), False)),
("Proxmox", run_proxmox_form(contexte_proxmox(todo), False)),
):
touches[nom] = {
b[0]: b[1] for b in app.BINDINGS if b[1].startswith("select_")
}
self.assertEqual(touches["QEMU/KVM"], touches["Proxmox"])
self.assertIn("select_main", touches["Proxmox"].values())
def test_parallelism_follows_the_host_on_both_screens(self):
# L'écran Proxmox plafonnait à quatre choix et en proposait UN, quel
# que soit le nombre de cœurs de l'hôte.
for nom, vu in (("QEMU/KVM", self.qemu), ("Proxmox", self.pve)):
with self.subTest(ecran=nom):
self.assertIn("f_par_all", vu["ids"], nom)
@unittest.skipUnless(TEXTUAL, "Textual absent")
class TestCeQueLeBureauChange(unittest.TestCase):
"""Choisir un bureau doit se voir sur les DEUX écrans, et de la même
façon : le nom prend un suffixe, le disque grossit."""
def _bureau(self):
async def gestes(app, pilote):
from textual.widgets import Checkbox
list(app.query("#f_type RadioButton"))[1].value = True
await pilote.pause()
app.query_one("#f_tool_pycharm", Checkbox).value = True
await pilote.pause()
await pilote.pause()
return gestes
def test_the_name_gets_the_desktop_suffix(self):
from script.todo.proxmox_deploy_form import run_proxmox_form
todo = todo_muet()
vu = releve(
run_proxmox_form, contexte_proxmox(todo), gestes=self._bureau()
)
# Sans suffixe, une VM graphique et sa jumelle serveur portent le même
# nom : la seconde est signalée « existe déjà » et ignorée.
self.assertTrue(vu["noms"][0].endswith("-gnome"), vu["noms"])
def test_the_disk_grows_by_the_desktop_and_the_tools(self):
from script.todo.proxmox_deploy_form import run_proxmox_form
todo = todo_muet()
ctx = contexte_proxmox(todo)
nu = releve(run_proxmox_form, ctx)
avec = releve(run_proxmox_form, ctx, gestes=self._bureau())
attendu = ctx["desktop_disk_gb"] + ctx["vm_tool_disk"]["pycharm"]
self.assertEqual(
avec["disques"][0] - nu["disques"][0],
attendu,
"le plan doit annoncer le surcoût AVANT de déployer",
)
def test_the_created_disk_matches_what_the_plan_announced(self):
# C'est le pont entre l'écran et « qm resize » : le plan annonçait
# 36 Go et la commande en demandait 20.
todo = todo_muet()
taille = todo._pve_disk_with_margin(
{"disk": "20G", "arch": "amd64", "distro": "ubuntu"},
{
"install": {
"branch": "develop",
"cmd": "make install_os && make install_odoo_18",
},
"desktop": "gnome",
"vm_tools": ("pycharm",),
},
)
self.assertEqual(taille, "36G")
class TestCeQueSontCesVm(unittest.TestCase):
"""L'architecture d'une VM Proxmox venait de « virsh », qui ne connaît que
les domaines d'ICI.
Elle décide des outils : une VM ARM prise pour x86_64 recevait Android
Studio, que Google ne publie pas pour elle — l'installation s'arrête, une
heure plus tard. Même famille que le « s » qui partait vers la machine
interne : on jugeait sur le nom."""
def _outils(self, meta):
vu = {}
todo = TODO.__new__(TODO)
todo._qemu_import_module = lambda: object()
todo._qemu_resolve_ips = lambda noms: {n: "10.0.0.1" for n in noms}
todo._qemu_vm_meta = lambda nom, mod: ("ubuntu", "26.04", "amd64")
todo._qemu_erplibre_remote_cmd = lambda *a, **kw: ""
def capture(vms, branche, remote):
vu["archs"] = [v["arch"] for v in vms]
raise SystemExit
import script.todo.qemu_install_monitor as mon
vrai = mon.launch_installs
mon.launch_installs = capture
try:
todo._qemu_install_erplibre_monitored(
["vm-arm"], "develop", {"vm-arm": "10.0.0.1"}, meta=meta
)
except SystemExit:
pass
finally:
mon.launch_installs = vrai
return vu.get("archs")
def test_the_caller_knows_better_than_virsh(self):
self.assertEqual(
self._outils({"vm-arm": ("ubuntu", "26.04", "arm64")}), ["arm64"]
)
def test_without_it_virsh_still_answers(self):
# La voie libvirt ne régresse pas : ses domaines sont bien ici.
self.assertEqual(self._outils(None), ["amd64"])
class TestLeFuseauDUneVmProxmox(unittest.TestCase):
"""« qm set » ne pose pas de fuseau : le cloud-init de Proxmox ne règle
que l'utilisateur, la clé et le réseau. Une VM créée là restait en UTC, et
on ne s'en aperçoit qu'aux horodatages."""
def _pose(self, spec):
vu = {}
todo = TODO.__new__(TODO)
todo._pve_ssh = lambda cible, cmd, **kw: (
vu.setdefault("cmd", cmd),
(0, ""),
)[1]
todo._pve_set_timezone("vm", spec)
return vu.get("cmd", "")
def test_the_timezone_reaches_the_vm(self):
self.assertIn(
"sudo timedatectl set-timezone America/Montreal",
self._pose({"timezone": "America/Montreal"}),
)
def test_a_free_value_is_quoted(self):
# La liste laisse la saisie libre : ce qui en sort part dans une
# commande distante, donc il est cité.
self.assertIn(
"'a b; rm -rf /'", self._pose({"timezone": "a b; rm -rf /"})
)
def test_no_timezone_means_no_command(self):
self.assertEqual(self._pose({}), "")
def test_the_prompt_path_still_gets_one(self):
# La voie par questions ne demande pas le fuseau ; sans défaut, elle
# laissait la VM en UTC alors que la voie libvirt reprend celui de
# l'hôte depuis toujours.
import re
from pathlib import Path
src = Path("script/todo/proxmox_menu.py").read_text(encoding="utf-8")
bloc = src[src.index("def _pve_deploy_prompts") :]
bloc = bloc[: bloc.index("_pve_after_create")]
self.assertTrue(
re.search(r'"timezone":\s*self\._qemu_host_timezone\(\)', bloc),
"le spec des invites doit porter un fuseau",
)
if __name__ == "__main__":
unittest.main(verbosity=2)

View file

@ -142,5 +142,80 @@ class TestLaToucheSsh(unittest.TestCase):
self.assertEqual(vu["reussies"], [])
@unittest.skipUnless(TEXTUAL, "Textual absent")
class TestCeQuiSuit(unittest.TestCase):
"""Rapporté : on attendait devant une fenêtre « terminée » sans savoir
que l'installation d'ERPLibre démarre en la quittant."""
def _sommaire(self, suite, attendre=3.0):
# Un travail qui DURE : sinon il finit avant le premier relevé, et le
# test ne prouve rien de l'avant/après.
jobs = [("1", "vm-a", ["bash", "-c", "sleep 2; echo ok"])]
vu = {}
async def scenario():
from textual.widgets import Static
app = run_deploy_progress(jobs, 1, run_app=False, suite=suite)
async with app.run_test(size=(140, 30)) as pilote:
await pilote.pause()
vu["pendant"] = str(app.query_one("#summary", Static).render())
await asyncio.sleep(attendre)
await pilote.pause()
vu["apres"] = str(app.query_one("#summary", Static).render())
asyncio.run(scenario())
return vu
def test_it_says_what_follows_once_everything_is_done(self):
vu = self._sommaire("Quitter (q) pour lancer l'installation")
self.assertNotIn("Quitter", vu["pendant"])
self.assertIn("Quitter", vu["apres"])
def test_nothing_is_promised_when_nothing_follows(self):
vu = self._sommaire("")
self.assertNotIn("→", vu["apres"])
@unittest.skipUnless(TEXTUAL, "Textual absent")
class TestLaCibleSsh(unittest.TestCase):
"""« s » utilisait le NOM de la VM. Sur Proxmox, l'entrée ~/.ssh/config
n'existe pas encore à ce moment — et ce nom peut désigner une machine
LOCALE homonyme, qui s'ouvrait alors à sa place."""
def _lance(self, ssh_cmds):
import contextlib
import os
jobs = [("1", "erplibre-ubuntu-2604", ["bash", "-c", "echo ok"])]
vu = []
async def scenario():
app = run_deploy_progress(
jobs, 1, run_app=False, ssh_cmds=ssh_cmds
)
async with app.run_test(size=(140, 30)) as pilote:
await pilote.pause()
await asyncio.sleep(1.2)
await pilote.pause()
vrai = os.system
os.system = vu.append
app.suspend = lambda: contextlib.nullcontext()
try:
app.action_ssh()
finally:
os.system = vrai
asyncio.run(scenario())
return vu[0] if vu else ""
def test_the_given_command_wins(self):
cible = "ssh -J erplibre-proxmox-9 erplibre@10.10.10.151"
self.assertIn(cible, self._lance({"erplibre-ubuntu-2604": cible}))
def test_without_one_it_falls_back_to_the_name(self):
self.assertIn("ssh erplibre-ubuntu-2604", self._lance(None))
if __name__ == "__main__":
unittest.main(verbosity=2)

View file

@ -354,6 +354,32 @@ class TestQemuMenuNumbering(MenuCoherence, unittest.TestCase):
}
class TestAnalyseMenuNumbering(MenuCoherence, unittest.TestCase):
"""Le menu Analyse, qui n'avait aucun garde.
Il en a pourtant besoin plus que les autres : ses entrées sont
regroupées en cinq sections, et une section ne consomme pas de numéro.
Ajouter « Instance » avant la dernière entrée décalait tout ce qui
suivait sans que rien ne proteste.
"""
SOURCE = TODO_DIR / "todo.py"
ENTRY = "def prompt_execute_analyse(self):"
END = "def execute_analyse_module_package(self):"
MINIMUM = 5
EXPECTED = {
"Tables and database size": "execute_analyse_schema_size",
"Customised views": "execute_analyse_view_custom",
"Studio and hand-made": "execute_analyse_custom_field",
"Quality of a migration": "execute_analyse_migration_quality",
"Modules missing": "execute_analyse_module_package",
"Dependencies between": "execute_analyse_module_dependency",
"Attachment files missing": "execute_analyse_filestore",
"Monitoring - a backup": "execute_analyse_monitoring",
}
class TestProxmoxMenuNumbering(MenuCoherence, unittest.TestCase):
"""Le menu Proxmox : dix-huit entrées, le même piège.

View file

@ -0,0 +1,205 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Deux réparations existaient et ne tournaient jamais.
`fix_duplicate_index.py` et `restore_config_defaults.py` étaient écrits,
éprouvés sur copie, et absents du pilote. Chaque migration refabriquait
donc ses index redondants et reperdait sa liste de prix par défaut —
mesuré à l'identique sur DEUX chaînes 12 → 18 indépendantes : 414 index
et une liste manquante dans l'une comme dans l'autre.
Un outil qui existe sans être appelé est le défaut le plus discret de ce
dépôt : rien n'échoue, rien ne l'écrit, et l'on croit le problème réglé
parce qu'on se souvient d'avoir écrit le correctif. Ce fichier vérifie
donc les DEUX choses — que les méthodes se comportent bien, et qu'elles
sont réellement appelées depuis la boucle des paliers.
Les docstrings de ces méthodes CITENT `wait_at_error=False` pour
l'expliquer. Un test qui lirait le texte du fichier le trouverait là et
passerait au vert sans que l'argument soit passé nulle part. On lit donc
l'arbre syntaxique, jamais le texte.
"""
import ast
import unittest
from pathlib import Path
from script.todo.todo_upgrade import TodoUpgrade
PILOTE = (
Path(__file__).resolve().parent.parent
/ "script"
/ "todo"
/ "todo_upgrade.py"
)
def arbre_du_pilote():
"""L'AST du pilote. `read_text` referme le fichier."""
return ast.parse(PILOTE.read_text(encoding="utf-8"))
class FauxPilote:
"""Un pilote qui note ce qu'on lui demande d'exécuter."""
def __init__(self):
self.appels = []
def todo_upgrade_execute(self, cmd, wait_at_error=True, **kwargs):
self.appels.append((cmd, wait_at_error))
return 0
class TestWhenTheIndexRepairRuns(unittest.TestCase):
def _lancer(self, version):
faux = FauxPilote()
TodoUpgrade.drop_duplicate_index(faux, "ma_base", version)
return faux.appels
def test_it_stays_quiet_before_odoo_17(self):
"""La convention n'a pas changé avant : il ne trouverait rien."""
for version in (13, 14, 15, 16):
self.assertEqual(self._lancer(version), [], version)
def test_it_runs_from_17_onward(self):
for version in (17, 18):
self.assertEqual(len(self._lancer(version)), 1, version)
def test_it_repairs_rather_than_reports(self):
cmd, _ = self._lancer(18)[0]
self.assertIn("--apply", cmd)
self.assertIn("fix_duplicate_index.py", cmd)
self.assertIn("-d ma_base", cmd)
def test_a_leftover_index_does_not_halt_the_migration(self):
"""Avec --apply, le code 1 veut dire « il en reste », pas « échec »."""
_, wait_at_error = self._lancer(18)[0]
self.assertFalse(wait_at_error)
class TestWhenTheDefaultsRepairRuns(unittest.TestCase):
def _lancer(self, dernier):
faux = FauxPilote()
TodoUpgrade.restore_config_defaults(faux, "ma_base", dernier)
return faux.appels
def test_it_waits_for_the_last_step(self):
"""Il charge le registre Odoo : six fois pour rien coûterait cher."""
self.assertEqual(self._lancer(False), [])
def test_it_runs_on_the_last_step(self):
self.assertEqual(len(self._lancer(True)), 1)
def test_it_repairs_rather_than_reports(self):
cmd, wait_at_error = self._lancer(True)[0]
self.assertIn("--apply", cmd)
self.assertIn("restore_config_defaults.py", cmd)
self.assertFalse(wait_at_error)
class TestTheyAreActuallyCalled(unittest.TestCase):
"""Le défaut visé : une méthode écrite que personne n'appelle."""
@classmethod
def setUpClass(cls):
cls.arbre = arbre_du_pilote()
cls.appels = {}
for noeud in ast.walk(cls.arbre):
if isinstance(noeud, ast.Call) and isinstance(
noeud.func, ast.Attribute
):
cls.appels.setdefault(noeud.func.attr, []).append(noeud)
def _boucle_des_paliers(self):
for noeud in ast.walk(self.arbre):
if (
isinstance(noeud, ast.For)
and isinstance(noeud.iter, ast.Call)
and getattr(noeud.iter.func, "id", "") == "enumerate"
and [
t.id
for t in ast.walk(noeud.target)
if isinstance(t, ast.Name)
]
== ["index", "next_version"]
):
return noeud
raise AssertionError("boucle des paliers introuvable")
def test_both_repairs_are_called_from_the_tier_loop(self):
boucle = self._boucle_des_paliers()
for nom in ("drop_duplicate_index", "restore_config_defaults"):
dedans = [
n
for n in self.appels.get(nom, [])
if boucle.lineno < n.lineno < boucle.end_lineno
]
self.assertEqual(len(dedans), 1, nom)
def test_the_index_repair_comes_after_the_cleanup(self):
"""Le nettoyage supprime des colonnes, donc leurs index avec."""
nettoyage = self.appels["prompt_database_cleanup"][-1].lineno
index = self.appels["drop_duplicate_index"][0].lineno
fumee = self.appels["prompt_smoke_public_url"][-1].lineno
self.assertLess(nettoyage, index)
self.assertLess(index, fumee)
def test_the_defaults_repair_runs_before_the_smoke_test(self):
"""Le test de fumée doit voir une base cohérente."""
defauts = self.appels["restore_config_defaults"][0].lineno
fumee = self.appels["prompt_smoke_public_url"][-1].lineno
self.assertLess(defauts, fumee)
def test_the_last_step_is_computed_from_the_loop_itself(self):
"""`is_last` doit venir de la liste, pas d'un numéro écrit en dur."""
appel = self.appels["restore_config_defaults"][0]
source = ast.dump(appel)
self.assertIn("lst_next_version", source)
self.assertNotIn("Constant(value=18)", source)
class TestTheDocstringTrapIsNotWhatWeRead(unittest.TestCase):
"""La preuve que ce fichier ne se paie pas de mots.
`wait_at_error=False` apparaît dans les docstrings des deux méthodes.
Si l'argument disparaissait des APPELS, un test textuel resterait vert.
"""
def test_the_docstrings_do_mention_it(self):
for nom in ("drop_duplicate_index", "restore_config_defaults"):
methode = getattr(TodoUpgrade, nom)
self.assertIn("wait_at_error=False", methode.__doc__ or "", nom)
def test_and_yet_the_keyword_is_really_passed(self):
arbre = arbre_du_pilote()
for nom in ("drop_duplicate_index", "restore_config_defaults"):
corps = None
for noeud in ast.walk(arbre):
if isinstance(noeud, ast.FunctionDef) and noeud.name == nom:
corps = noeud
self.assertIsNotNone(corps, nom)
# Le corps SANS sa docstring : c'est là que doit vivre l'argument.
sans_texte = [
n
for n in corps.body
if not (
isinstance(n, ast.Expr)
and isinstance(n.value, ast.Constant)
and isinstance(n.value.value, str)
)
]
trouve = False
for noeud in sans_texte:
for interne in ast.walk(noeud):
if isinstance(interne, ast.keyword) and (
interne.arg == "wait_at_error"
):
self.assertIs(interne.value.value, False, nom)
trouve = True
self.assertTrue(trouve, nom)
if __name__ == "__main__":
unittest.main()