Merge branch 'fix_migration_and_fix_proxmox_qemu_pont'
[ADD] migration : réparer les copies de site, durcir Proxmox Seize commits, deux fronts qui avancent en parallèle. Côté migration, ce sont des dégâts qu'un palier laisse et qu'aucun événement ne répare : des copies de site qui ne savent plus se rendre, les index qu'Odoo 17 crée en double, des réglages qu'aucun hook ne remet. Une prédiction s'y ajoute — dire AVANT le palier quelle copie rendra 500 après — parce que le constater après coûte une restauration. Côté Proxmox, l'écran crée le pont manquant, suit une VM distante et change son état. Et le rebond devient le seul chemin vers la VM : viser l'hôte directement marchait tant que la VM avait une adresse routable, ce qui n'est pas le cas général. Un module fautif n'emporte plus tout le lot : une désinstallation qui échoue arrêtait la liste entière. --- EN --- Sixteen commits, two fronts moving in parallel. On migration, these are the wounds a bump leaves and no event heals: site copies that no longer render, the indexes Odoo 17 creates twice, settings no hook restores. A prediction joins them — saying BEFORE the bump which copy will answer 500 after it — because finding out afterwards costs a restore. On Proxmox, the screen creates the missing bridge, follows a remote VM and changes its state. And the jump host becomes the only route to the VM: aiming at the host directly worked as long as the VM had a routable address, which is not the general case. A faulty module no longer takes the whole batch down: one failed uninstall used to stop the entire list. Assisted-by: Claude Opus 5
This commit is contained in:
commit
ba4dc92ec5
37 changed files with 5535 additions and 149 deletions
|
|
@ -233,6 +233,18 @@ def inspect(database):
|
||||||
if len(ligne) >= 2:
|
if len(ligne) >= 2:
|
||||||
dct_origine.setdefault(ligne[0], set()).add(ligne[1])
|
dct_origine.setdefault(ligne[0], set()).add(ligne[1])
|
||||||
etat["field_module"] = {cle: sorted(v) for cle, v in dct_origine.items()}
|
etat["field_module"] = {cle: sorted(v) for cle, v in dct_origine.items()}
|
||||||
|
# Lesquels PORTENT une donnée. Un champ `store=false` n'a jamais eu
|
||||||
|
# de colonne : sa disparition ne perd rien, et il pesait jusqu'à 90 %
|
||||||
|
# du seau « non déclarés par OpenUpgrade ». Mesuré au palier 16 → 17 :
|
||||||
|
# `__last_update` à lui seul comptait pour 397 des 565.
|
||||||
|
#
|
||||||
|
# `store` existe de la 12 à la 18 et n'est jamais NULL — vérifié sur
|
||||||
|
# les sept bases — donc le renseignement est fiable partout.
|
||||||
|
stockes = run_psql(
|
||||||
|
database,
|
||||||
|
"SELECT model || '.' || name FROM ir_model_fields WHERE store",
|
||||||
|
)
|
||||||
|
etat["field_stored"] = sorted(ligne[0] for ligne in stockes or [])
|
||||||
# Les copies COW par leur CLÉ : c'est elle qu'on réinitialise, et
|
# 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.
|
# c'est par elle qu'on les retrouve d'une version à l'autre.
|
||||||
copies = run_psql(
|
copies = run_psql(
|
||||||
|
|
@ -609,7 +621,11 @@ def declared_index(version):
|
||||||
|
|
||||||
|
|
||||||
def overlay_declared(
|
def overlay_declared(
|
||||||
version, modeles_perdus, champs_perdus, origine_champ=None
|
version,
|
||||||
|
modeles_perdus,
|
||||||
|
champs_perdus,
|
||||||
|
origine_champ=None,
|
||||||
|
champs_stockes=None,
|
||||||
):
|
):
|
||||||
"""Le THÉORIQUE posé sur le PRATIQUE : qui avait été annoncé ?
|
"""Le THÉORIQUE posé sur le PRATIQUE : qui avait été annoncé ?
|
||||||
|
|
||||||
|
|
@ -645,6 +661,7 @@ def overlay_declared(
|
||||||
"company_dependent": [],
|
"company_dependent": [],
|
||||||
"moved": [],
|
"moved": [],
|
||||||
"model_gone": [],
|
"model_gone": [],
|
||||||
|
"no_data": [],
|
||||||
"not_analysed": [],
|
"not_analysed": [],
|
||||||
"undeclared": [],
|
"undeclared": [],
|
||||||
}
|
}
|
||||||
|
|
@ -664,6 +681,22 @@ def overlay_declared(
|
||||||
if partis and cle.startswith(partis):
|
if partis and cle.startswith(partis):
|
||||||
champs["model_gone"].append(cle)
|
champs["model_gone"].append(cle)
|
||||||
continue
|
continue
|
||||||
|
# AVANT « non analysé » : « sans donnée propre » est une raison
|
||||||
|
# plus forte que « hors du champ d'OpenUpgrade ». Mesuré au
|
||||||
|
# palier 16 → 17, le placement avant fait tomber `not_analysed`
|
||||||
|
# de 181 à 47 sans changer `undeclared` — le seau résiduel se
|
||||||
|
# réduit alors au risque réel : des champs qui AVAIENT des
|
||||||
|
# données, dans des modules dont OpenUpgrade ne peut rien dire.
|
||||||
|
#
|
||||||
|
# La garde `is not None` compte : un instantané sans
|
||||||
|
# `field_stored` — une base lue par une version antérieure de cet
|
||||||
|
# outil — verserait TOUT ici et n'annoncerait plus rien.
|
||||||
|
if champs_stockes is not None and (
|
||||||
|
cle.rsplit(".", 1)[-1] in SANS_DONNEE_PROPRE
|
||||||
|
or cle not in champs_stockes
|
||||||
|
):
|
||||||
|
champs["no_data"].append(cle)
|
||||||
|
continue
|
||||||
origines = set((origine_champ or {}).get(cle) or [])
|
origines = set((origine_champ or {}).get(cle) or [])
|
||||||
if origines and not (origines & analyses):
|
if origines and not (origines & analyses):
|
||||||
champs["not_analysed"].append(cle)
|
champs["not_analysed"].append(cle)
|
||||||
|
|
@ -743,6 +776,7 @@ def compare(avant, apres):
|
||||||
modeles_perdus,
|
modeles_perdus,
|
||||||
champs_perdus,
|
champs_perdus,
|
||||||
avant.get("field_module"),
|
avant.get("field_module"),
|
||||||
|
set(avant.get("field_stored") or []) or None,
|
||||||
),
|
),
|
||||||
"fields_lost": champs_perdus,
|
"fields_lost": champs_perdus,
|
||||||
"fields_gained": sorted(champ_apres - champ_avant),
|
"fields_gained": sorted(champ_apres - champ_avant),
|
||||||
|
|
@ -851,6 +885,12 @@ DECLARE_MODELES = (
|
||||||
|
|
||||||
# L'ordre range du plus rassurant au plus inquiétant : ce qu'on doit
|
# L'ordre range du plus rassurant au plus inquiétant : ce qu'on doit
|
||||||
# regarder finit la liste, donc reste sous les yeux.
|
# regarder finit la liste, donc reste sous les yeux.
|
||||||
|
# `id` porte une donnée mais pas la SIENNE : elle appartient à la ligne.
|
||||||
|
# Il figure dans ir_model_fields de tout modèle, y compris abstrait, et
|
||||||
|
# Odoo 15 cesse de l'inscrire sur ceux-là — d'où 65 « pertes » d'un coup
|
||||||
|
# au palier 14 → 15, pour zéro octet.
|
||||||
|
SANS_DONNEE_PROPRE = ("id",)
|
||||||
|
|
||||||
DECLARE_CHAMPS = (
|
DECLARE_CHAMPS = (
|
||||||
("del", "declared removed", "dim"),
|
("del", "declared removed", "dim"),
|
||||||
("moved", "moved to another module", "dim"),
|
("moved", "moved to another module", "dim"),
|
||||||
|
|
@ -860,6 +900,7 @@ DECLARE_CHAMPS = (
|
||||||
"computed now — the field remains, the column does not",
|
"computed now — the field remains, the column does not",
|
||||||
"ok",
|
"ok",
|
||||||
),
|
),
|
||||||
|
("no_data", "held no data of their own — nothing to lose", "dim"),
|
||||||
("model_gone", "their model went away too", "dim"),
|
("model_gone", "their model went away too", "dim"),
|
||||||
("not_analysed", "in a module OpenUpgrade does not analyse", "dim"),
|
("not_analysed", "in a module OpenUpgrade does not analyse", "dim"),
|
||||||
("undeclared", "NOT declared by OpenUpgrade", "warn"),
|
("undeclared", "NOT declared by OpenUpgrade", "warn"),
|
||||||
|
|
@ -920,10 +961,12 @@ def render_declared(declare, colour, limit=8):
|
||||||
f" {paint(str(len(trouves)).rjust(5), teinte, colour)}"
|
f" {paint(str(len(trouves)).rjust(5), teinte, colour)}"
|
||||||
f" {t(libelle)}"
|
f" {t(libelle)}"
|
||||||
)
|
)
|
||||||
if cle not in ("undeclared", "unstored"):
|
if cle not in ("undeclared", "unstored", "no_data"):
|
||||||
# Ces deux-là seuls méritent des noms : l'un parce qu'il
|
# Ces trois-là seuls méritent des noms : le premier
|
||||||
# faut aller voir, l'autre parce qu'on croirait à une
|
# parce qu'il faut aller voir, les deux autres parce
|
||||||
# perte. Nommer les autres noierait le rapport.
|
# qu'on croirait à une perte — et « __last_update ×
|
||||||
|
# 518 modèles » sur une ligne explique à lui seul le
|
||||||
|
# gros chiffre. Nommer le reste noierait le rapport.
|
||||||
continue
|
continue
|
||||||
plats = [
|
plats = [
|
||||||
element[0] if isinstance(element, tuple) else element
|
element[0] if isinstance(element, tuple) else element
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,9 @@ import getpass
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import uuid
|
||||||
from subprocess import check_output
|
from subprocess import check_output
|
||||||
|
|
||||||
sys.path.append(
|
sys.path.append(
|
||||||
|
|
@ -104,12 +105,32 @@ def password_refused(sortie):
|
||||||
return "AccessDenied" in (sortie or "")
|
return "AccessDenied" in (sortie or "")
|
||||||
|
|
||||||
|
|
||||||
def probe_master_password(arg_base, mot):
|
def probe_name():
|
||||||
"""(accepté, sortie) — éprouver le mot de passe sur `--list`.
|
"""Un nom de base qui ne peut appartenir à personne.
|
||||||
|
|
||||||
La commande la plus inoffensive : elle ne touche à rien et rend le
|
La sonde DEMANDE une suppression : si le nom désignait une vraie
|
||||||
même refus qu'une restauration. Valider ici évite d'échouer à
|
base et que le mot de passe était bon, on la perdrait. Un uuid4 rend
|
||||||
mi-parcours, une fois la base déjà supprimée.
|
la collision impossible en pratique, et le préfixe dit d'où il vient
|
||||||
|
à qui le verrait passer dans un journal.
|
||||||
|
"""
|
||||||
|
return f"el_probe_{uuid.uuid4().hex}"
|
||||||
|
|
||||||
|
|
||||||
|
def probe_master_password(arg_base, mot):
|
||||||
|
"""(accepté, sortie) — éprouver le mot de passe pour de vrai.
|
||||||
|
|
||||||
|
`--list` ne lit JAMAIS le mot de passe : mesuré,
|
||||||
|
`MASTER_PWD="ceci_est_faux" odoo-bin db --list` sort en 0. La sonde
|
||||||
|
d'avant acceptait donc le premier mot saisi, juste ou faux, et la
|
||||||
|
boucle des dix essais ne servait à rien — le refus n'arrivait qu'au
|
||||||
|
`--restore`, une fois la base déjà supprimée.
|
||||||
|
|
||||||
|
Seule l'action `drop` consulte le mot de passe, et elle le fait
|
||||||
|
AVANT de regarder la base : `check_super` d'abord, `db_exists`
|
||||||
|
ensuite. Sur un nom qui n'existe pas, elle ne touche donc rien et
|
||||||
|
répond quand même. Mesuré sur la machine d'essai : mauvais mot de
|
||||||
|
passe → code 1 et `AccessDenied` dans la trace ; bon mot de passe →
|
||||||
|
code 0, silence, et les huit bases toujours là.
|
||||||
|
|
||||||
Le secret passe par l'environnement, jamais par argv :
|
Le secret passe par l'environnement, jamais par argv :
|
||||||
/proc/<pid>/cmdline est lisible par tout utilisateur de la machine.
|
/proc/<pid>/cmdline est lisible par tout utilisateur de la machine.
|
||||||
|
|
@ -117,7 +138,7 @@ def probe_master_password(arg_base, mot):
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
env["MASTER_PWD"] = mot
|
env["MASTER_PWD"] = mot
|
||||||
done = subprocess.run(
|
done = subprocess.run(
|
||||||
f"{arg_base} --list".split(" "),
|
f"{arg_base} --drop --database {probe_name()}".split(" "),
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
env=env,
|
env=env,
|
||||||
|
|
|
||||||
|
|
@ -201,6 +201,187 @@ def declared_view_shape(module_dir, template_id):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def run_sql(database, sql):
|
||||||
|
"""Le texte brut d'une requête. RuntimeError si la base ne répond pas."""
|
||||||
|
result = subprocess.run(
|
||||||
|
["psql", "-X", "-w", "-d", database, "-tA", "-c", sql],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if result.returncode:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Cannot read '{database}': {result.stderr.strip()}"
|
||||||
|
)
|
||||||
|
return result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def query_cow_archs(database):
|
||||||
|
"""[(id, clé, mode, website_id, {langue: arch})] — arch ENTIÈRES.
|
||||||
|
|
||||||
|
`query_cow_views` tronque à 400 caractères : assez pour lire la
|
||||||
|
première balise, pas pour évaluer un xpath — un fragment coupé n'est
|
||||||
|
même pas du XML. Et `arch_db` est un jsonb par langue depuis la 17 :
|
||||||
|
une page cassée en fr_CA et saine en en_US, cela existe, on l'a vu.
|
||||||
|
"""
|
||||||
|
jsonb = "jsonb" in run_sql(
|
||||||
|
database,
|
||||||
|
"SELECT data_type FROM information_schema.columns"
|
||||||
|
" WHERE table_name='ir_ui_view' AND column_name='arch_db'",
|
||||||
|
)
|
||||||
|
expr = "arch_db" if jsonb else "json_build_object('', arch_db)"
|
||||||
|
brut = run_sql(
|
||||||
|
database,
|
||||||
|
"SELECT coalesce(json_agg(json_build_object("
|
||||||
|
"'id', id, 'key', coalesce(key, ''), 'mode', mode,"
|
||||||
|
f" 'website_id', website_id, 'arch', {expr})), '[]')"
|
||||||
|
" FROM ir_ui_view WHERE website_id IS NOT NULL",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
lignes = json.loads(brut.strip() or "[]")
|
||||||
|
except ValueError:
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
(
|
||||||
|
ligne["id"],
|
||||||
|
ligne["key"],
|
||||||
|
ligne["mode"],
|
||||||
|
ligne["website_id"],
|
||||||
|
ligne["arch"] if isinstance(ligne["arch"], dict) else {},
|
||||||
|
)
|
||||||
|
for ligne in lignes
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def installed_modules(database):
|
||||||
|
"""Les modules installés. Sans eux le balayage des sources est vain.
|
||||||
|
|
||||||
|
Un enfant peut vivre dans N'IMPORTE quel module — `website_crm` hérite
|
||||||
|
de `website.contactus` — donc il faut balayer large. Mais balayer TOUT
|
||||||
|
l'arbre cible coûterait des milliers de fichiers pour rien : seuls les
|
||||||
|
modules installés produiront des vues.
|
||||||
|
"""
|
||||||
|
output = run_sql(
|
||||||
|
database,
|
||||||
|
"SELECT name FROM ir_module_module WHERE state IN"
|
||||||
|
" ('installed', 'to upgrade')",
|
||||||
|
)
|
||||||
|
return [ligne.strip() for ligne in output.splitlines() if ligne.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def full_key(module_name, valeur):
|
||||||
|
"""« contactus » dans le module website devient « website.contactus »."""
|
||||||
|
valeur = (valeur or "").strip()
|
||||||
|
if not valeur:
|
||||||
|
return None
|
||||||
|
return valeur if "." in valeur else f"{module_name}.{valeur}"
|
||||||
|
|
||||||
|
|
||||||
|
def scan_target_views(target_version, lst_module):
|
||||||
|
"""(déclarés, héritages) tels que la version CIBLE les livre.
|
||||||
|
|
||||||
|
`déclarés` : les clés de gabarit que la cible fournit. Sert à repérer
|
||||||
|
un `t-call` vers un gabarit qui n'existe plus.
|
||||||
|
|
||||||
|
`héritages` : {clé parente: [expressions xpath]}. C'est le manque qui
|
||||||
|
a coûté quatre paliers de silence — la cible ajoute un ancrage dans
|
||||||
|
une vue module, sa vue héritière le vise, et la copie de site, qui
|
||||||
|
n'est jamais réécrite, ne l'a pas.
|
||||||
|
"""
|
||||||
|
declares = set()
|
||||||
|
heritages = {}
|
||||||
|
for module_name in lst_module:
|
||||||
|
module_dir = find_module_dir(target_version, module_name)
|
||||||
|
if module_dir is None:
|
||||||
|
continue
|
||||||
|
motif = os.path.join(module_dir, "**", "*.xml")
|
||||||
|
for file_path in sorted(glob.glob(motif, recursive=True)):
|
||||||
|
try:
|
||||||
|
racine = ET.parse(file_path).getroot()
|
||||||
|
except ET.ParseError:
|
||||||
|
continue
|
||||||
|
for element in racine.iter("template"):
|
||||||
|
cle = full_key(module_name, element.get("id"))
|
||||||
|
if cle:
|
||||||
|
declares.add(cle)
|
||||||
|
parent = full_key(module_name, element.get("inherit_id"))
|
||||||
|
if not parent:
|
||||||
|
continue
|
||||||
|
exprs = [
|
||||||
|
noeud.get("expr")
|
||||||
|
for noeud in element.iter("xpath")
|
||||||
|
if noeud.get("expr")
|
||||||
|
]
|
||||||
|
if exprs:
|
||||||
|
heritages.setdefault(parent, []).extend(exprs)
|
||||||
|
return declares, heritages
|
||||||
|
|
||||||
|
|
||||||
|
def will_not_render(database, target_version):
|
||||||
|
"""Les copies qui passeront la migration et rendront 500 ensuite.
|
||||||
|
|
||||||
|
Deux ruptures que `analyse` ne voit pas, parce qu'elles ne touchent
|
||||||
|
pas la FORME de la copie :
|
||||||
|
|
||||||
|
ancrage manquant un enfant de la CIBLE vise `//t[@t-set='x']` que
|
||||||
|
la copie n'a pas.
|
||||||
|
t-call pendant la copie appelle un gabarit que la cible ne
|
||||||
|
livre plus.
|
||||||
|
|
||||||
|
Celles-là ne se neutralisent PAS : la copie porte une page écrite par
|
||||||
|
quelqu'un, et la mettre de côté l'effacerait du site. Elles se
|
||||||
|
réparent — `fix_cow_render.py` remet l'ancrage depuis la vue module
|
||||||
|
et retire l'appel mort, sans toucher au contenu.
|
||||||
|
"""
|
||||||
|
from fix_cow_render import dangling_calls, locates
|
||||||
|
|
||||||
|
copies = query_cow_archs(database)
|
||||||
|
if not copies:
|
||||||
|
return []
|
||||||
|
declares, heritages = scan_target_views(
|
||||||
|
target_version, installed_modules(database)
|
||||||
|
)
|
||||||
|
connus = declares | {cle for _i, cle, _m, _w, _a in copies if cle}
|
||||||
|
risques = []
|
||||||
|
for view_id, key, mode, website_id, langues in copies:
|
||||||
|
if not key:
|
||||||
|
continue
|
||||||
|
# Chaque langue : une page peut être cassée en fr_CA et saine en
|
||||||
|
# en_US, et c'est celle du site qui décide de ce qu'on voit.
|
||||||
|
manques = set()
|
||||||
|
pendants = []
|
||||||
|
for texte in langues.values():
|
||||||
|
for expr in heritages.get(key, []):
|
||||||
|
if not locates(texte, expr):
|
||||||
|
manques.add(expr)
|
||||||
|
for nom in dangling_calls(texte, connus):
|
||||||
|
if nom not in pendants:
|
||||||
|
pendants.append(nom)
|
||||||
|
for expr in sorted(manques):
|
||||||
|
risques.append(
|
||||||
|
(
|
||||||
|
view_id,
|
||||||
|
key,
|
||||||
|
mode,
|
||||||
|
mode,
|
||||||
|
website_id,
|
||||||
|
f"a child of the target needs an anchor this copy lacks:"
|
||||||
|
f" {expr}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for nom in pendants:
|
||||||
|
risques.append(
|
||||||
|
(
|
||||||
|
view_id,
|
||||||
|
key,
|
||||||
|
mode,
|
||||||
|
mode,
|
||||||
|
website_id,
|
||||||
|
f"calls a template the target no longer ships: {nom}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return risques
|
||||||
|
|
||||||
|
|
||||||
def analyse(database, target_version):
|
def analyse(database, target_version):
|
||||||
"""Sort COW views into three buckets by comparing with the target sources.
|
"""Sort COW views into three buckets by comparing with the target sources.
|
||||||
|
|
||||||
|
|
@ -304,9 +485,29 @@ def main():
|
||||||
lst_at_risk, lst_module_absent, lst_no_counterpart = analyse(
|
lst_at_risk, lst_module_absent, lst_no_counterpart = analyse(
|
||||||
config.database, config.target_version
|
config.database, config.target_version
|
||||||
)
|
)
|
||||||
|
# L'autre famille de rupture : la copie garde sa forme et cesse de se
|
||||||
|
# RENDRE. Elle ne se neutralise pas — la copie porte une page écrite
|
||||||
|
# par quelqu'un — elle se répare.
|
||||||
|
lst_no_render = will_not_render(config.database, config.target_version)
|
||||||
database = config.database
|
database = config.database
|
||||||
target_version = config.target_version
|
target_version = config.target_version
|
||||||
|
|
||||||
|
if lst_no_render:
|
||||||
|
print(
|
||||||
|
f"⚠️ {len(lst_no_render)}"
|
||||||
|
f" {t('website COW view(s) will survive the bump and then fail')}"
|
||||||
|
f" {t('to render:')}"
|
||||||
|
)
|
||||||
|
for view_id, key, _mode, _cible, website_id, raison in lst_no_render:
|
||||||
|
print(f" - id={view_id} website={website_id} {key}")
|
||||||
|
print(f" {t(raison)}")
|
||||||
|
print(
|
||||||
|
f" {t('Repair rather than neutralize — the copy holds a page')}"
|
||||||
|
f" {t('someone wrote:')}"
|
||||||
|
f" ./script/odoo/migration/fix_cow_render.py -d {config.database}"
|
||||||
|
)
|
||||||
|
print("")
|
||||||
|
|
||||||
if not lst_at_risk:
|
if not lst_at_risk:
|
||||||
print(
|
print(
|
||||||
f"✅ -> {t('No website COW view changes shape in')}"
|
f"✅ -> {t('No website COW view changes shape in')}"
|
||||||
|
|
@ -378,7 +579,9 @@ def main():
|
||||||
# 0 = rien à signaler, 1 = des copies casseront, 2 = l'outil a échoué.
|
# 0 = rien à signaler, 1 = des copies casseront, 2 = l'outil a échoué.
|
||||||
# Le pilote lisait le texte anglais de cette sortie pour savoir s'il
|
# Le pilote lisait le texte anglais de cette sortie pour savoir s'il
|
||||||
# devait poser sa question : traduire le message le rendait aveugle.
|
# devait poser sa question : traduire le message le rendait aveugle.
|
||||||
return 1 if lst_at_risk else 0
|
# Les deux familles comptent : une copie qui rendra 500 est une
|
||||||
|
# trouvaille, même si la migration, elle, ne s'arrêtera pas.
|
||||||
|
return 1 if (lst_at_risk or lst_no_render) else 0
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
577
script/odoo/migration/fix_cow_render.py
Executable file
577
script/odoo/migration/fix_cow_render.py
Executable file
|
|
@ -0,0 +1,577 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
"""Les copies de site web qui ne savent plus se rendre après une montée.
|
||||||
|
|
||||||
|
`check_cow_views` prédit UN mode de rupture : la FORME que l'arch doit
|
||||||
|
avoir change entre deux versions — template autonome d'un côté, spec
|
||||||
|
d'héritage de l'autre. C'est celui qui arrête la migration elle-même, et
|
||||||
|
c'est pour cela qu'on le cherche AVANT de partir.
|
||||||
|
|
||||||
|
Deux autres modes la laissent finir sans un mot et n'apparaissent qu'à
|
||||||
|
l'ouverture de la page. Mesurés sur une migration 12 → 18 réelle :
|
||||||
|
|
||||||
|
ancrage manquant une vue héritière cherche `//t[@t-set='x']` dans la
|
||||||
|
copie. Le module a gagné cet ancrage en chemin — ici
|
||||||
|
au palier 14 → 15 — et sa propre vue héritière a
|
||||||
|
suivi ; la copie, elle, n'est jamais réécrite,
|
||||||
|
c'est une page d'utilisateur. Quatre paliers plus
|
||||||
|
tard /contact rendait 500, et rien ne l'avait dit.
|
||||||
|
|
||||||
|
t-call pendant la copie appelle un gabarit que la version cible ne
|
||||||
|
livre plus. `website.company_description` a disparu
|
||||||
|
en 18 : les deux copies qui l'appelaient rendaient
|
||||||
|
500, l'une d'elles pour CETTE raison seulement une
|
||||||
|
fois son ancrage remis.
|
||||||
|
|
||||||
|
Le premier se répare : l'ancrage existe dans la vue module de même clé,
|
||||||
|
on le remet là où le module le place. Le second ne se répare pas — un
|
||||||
|
gabarit disparu ne se réinvente pas — on retire l'appel, et la personne
|
||||||
|
décide si le bloc doit revenir autrement.
|
||||||
|
|
||||||
|
Lecture seule par défaut. `--apply` écrit, puis RELIT pour vérifier :
|
||||||
|
annoncer « corrigé » sans regarder ferait rouvrir la même page cassée.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def run_psql(database, sql, read_only=True):
|
||||||
|
"""Interroger la base. None si elle ne répond pas.
|
||||||
|
|
||||||
|
Lecture seule garantie par le SERVEUR et non par la politesse du
|
||||||
|
code : une réparation qui se trompe de base doit être refusée par
|
||||||
|
PostgreSQL, pas par une relecture attentive.
|
||||||
|
"""
|
||||||
|
# PGOPTIONS et non un « SET » en tête : celui-ci ÉCRIT une ligne
|
||||||
|
# « SET » dans la sortie, que le json.loads d'à côté prend en pleine
|
||||||
|
# figure. Même forme que fix_view_type.py, pour la même raison.
|
||||||
|
env = os.environ.copy()
|
||||||
|
if read_only:
|
||||||
|
env["PGOPTIONS"] = "-c default_transaction_read_only=on"
|
||||||
|
env["PSQLRC"] = ""
|
||||||
|
result = subprocess.run(
|
||||||
|
["psql", "-X", "-w", "-d", database, "-tA", "-c", sql],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
if result.returncode:
|
||||||
|
return None
|
||||||
|
return result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def arch_is_jsonb(database):
|
||||||
|
"""La 16 stocke l'arch en varchar, la 17 et la 18 en jsonb."""
|
||||||
|
sortie = run_psql(
|
||||||
|
database,
|
||||||
|
"SELECT data_type FROM information_schema.columns"
|
||||||
|
" WHERE table_name='ir_ui_view' AND column_name='arch_db'",
|
||||||
|
)
|
||||||
|
return bool(sortie) and "jsonb" in sortie
|
||||||
|
|
||||||
|
|
||||||
|
# `arch_db` est un jsonb d'UNE ENTRÉE PAR LANGUE depuis la 17. N'en lire
|
||||||
|
# qu'une — et surtout n'en écrire qu'une — laisse la page cassée dans
|
||||||
|
# toutes les autres : mesuré sur /contact, réparé en en_US et toujours en
|
||||||
|
# 500 parce que le site rend en fr_CA.
|
||||||
|
def arch_expr(jsonb):
|
||||||
|
return "arch_db" if jsonb else "json_build_object('', arch_db)"
|
||||||
|
|
||||||
|
|
||||||
|
def langs_of(arch):
|
||||||
|
"""{langue: texte}. La clé vide quand la version ne traduit pas l'arch."""
|
||||||
|
if isinstance(arch, dict):
|
||||||
|
return {k: v for k, v in arch.items() if isinstance(v, str)}
|
||||||
|
return {"": arch} if isinstance(arch, str) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def fetch(database, sql):
|
||||||
|
"""Rendre une liste de dictionnaires, ou None si la base se tait.
|
||||||
|
|
||||||
|
Un seul aller-retour et du JSON plutôt qu'un découpage par lignes :
|
||||||
|
un arch contient des sauts de ligne, et toute lecture ligne à ligne
|
||||||
|
finirait par couper une vue en deux sans le dire.
|
||||||
|
"""
|
||||||
|
sortie = run_psql(
|
||||||
|
database, f"SELECT coalesce(json_agg(x), '[]') FROM ({sql}) x"
|
||||||
|
)
|
||||||
|
if sortie is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(sortie.strip() or "[]")
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def cow_copies(database, jsonb):
|
||||||
|
"""Les copies faites par le site : website_id posé, aucun fichier.
|
||||||
|
|
||||||
|
`arch_fs` vide est le discriminant : une copie qui garde un fichier
|
||||||
|
est réécrite par la mise à jour du module, celle qui n'en a pas est
|
||||||
|
du contenu d'utilisateur et ne l'est jamais.
|
||||||
|
"""
|
||||||
|
return fetch(
|
||||||
|
database,
|
||||||
|
f"SELECT id, key, {arch_expr(jsonb)} AS arch FROM ir_ui_view"
|
||||||
|
" WHERE website_id IS NOT NULL AND coalesce(arch_fs, '') = ''"
|
||||||
|
" AND active",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def children_of(database, jsonb, lst_id):
|
||||||
|
"""{parent: [enfants]} — les vues qui héritent d'une de ces copies."""
|
||||||
|
if not lst_id:
|
||||||
|
return {}
|
||||||
|
ids = ",".join(str(i) for i in lst_id)
|
||||||
|
lignes = fetch(
|
||||||
|
database,
|
||||||
|
f"SELECT id, key, inherit_id, {arch_expr(jsonb)} AS arch"
|
||||||
|
f" FROM ir_ui_view WHERE inherit_id IN ({ids}) AND active",
|
||||||
|
)
|
||||||
|
dct = {}
|
||||||
|
for ligne in lignes or []:
|
||||||
|
dct.setdefault(ligne["inherit_id"], []).append(ligne)
|
||||||
|
return dct
|
||||||
|
|
||||||
|
|
||||||
|
def module_twin(database, jsonb, cle):
|
||||||
|
"""L'arch de la vue MODULE de même clé, celle dont la copie est née."""
|
||||||
|
lignes = fetch(
|
||||||
|
database,
|
||||||
|
f"SELECT {arch_expr(jsonb)} AS arch FROM ir_ui_view"
|
||||||
|
f" WHERE key = '{cle}' AND website_id IS NULL"
|
||||||
|
" AND coalesce(arch_fs, '') <> '' LIMIT 1",
|
||||||
|
)
|
||||||
|
return (lignes or [{}])[0].get("arch")
|
||||||
|
|
||||||
|
|
||||||
|
def known_keys(database):
|
||||||
|
sortie = run_psql(
|
||||||
|
database, "SELECT key FROM ir_ui_view WHERE key IS NOT NULL"
|
||||||
|
)
|
||||||
|
if sortie is None:
|
||||||
|
return None
|
||||||
|
return {ligne.strip() for ligne in sortie.splitlines() if ligne.strip()}
|
||||||
|
|
||||||
|
|
||||||
|
def parse(arch):
|
||||||
|
"""L'arbre, ou None si l'arch ne s'analyse pas."""
|
||||||
|
from lxml import etree
|
||||||
|
|
||||||
|
if not arch:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return etree.fromstring(arch.encode("utf-8"))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def anchors_wanted(arch_enfant):
|
||||||
|
"""Les expressions xpath qu'une vue héritière cherche dans son parent."""
|
||||||
|
arbre = parse(arch_enfant)
|
||||||
|
if arbre is None:
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
noeud.get("expr") for noeud in arbre.iter("xpath") if noeud.get("expr")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def locates(arch, expr):
|
||||||
|
"""L'expression trouve-t-elle quelque chose dans cet arch ?
|
||||||
|
|
||||||
|
Une expression illisible compte comme TROUVÉE : elle ne relève pas de
|
||||||
|
cet outil, et la signaler comme un ancrage manquant enverrait
|
||||||
|
réparer une vue qui n'a rien.
|
||||||
|
"""
|
||||||
|
arbre = parse(arch)
|
||||||
|
if arbre is None:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
return bool(arbre.xpath(expr))
|
||||||
|
except Exception:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def dangling_calls(arch, connus):
|
||||||
|
"""Les `t-call` de cette arch vers un gabarit que la base n'a pas.
|
||||||
|
|
||||||
|
Seuls les appels LITTÉRAUX : un `t-call` calculé se résout à
|
||||||
|
l'exécution et l'on ne peut rien en dire ici.
|
||||||
|
"""
|
||||||
|
arbre = parse(arch)
|
||||||
|
if arbre is None:
|
||||||
|
return []
|
||||||
|
manquants = []
|
||||||
|
for noeud in arbre.iter():
|
||||||
|
nom = noeud.get("t-call")
|
||||||
|
if not nom or "{" in nom or nom.startswith("#"):
|
||||||
|
continue
|
||||||
|
if nom not in connus and nom not in manquants:
|
||||||
|
manquants.append(nom)
|
||||||
|
return manquants
|
||||||
|
|
||||||
|
|
||||||
|
def signature(noeud):
|
||||||
|
"""De quoi reconnaître un élément d'un arch dans l'autre."""
|
||||||
|
for attribut in ("t-call", "id", "t-name"):
|
||||||
|
if noeud.get(attribut):
|
||||||
|
return (noeud.tag, attribut, noeud.get(attribut))
|
||||||
|
return (noeud.tag, None, None)
|
||||||
|
|
||||||
|
|
||||||
|
def find_by_signature(arbre, sig):
|
||||||
|
tag, attribut, valeur = sig
|
||||||
|
for noeud in arbre.iter(tag):
|
||||||
|
if attribut is None:
|
||||||
|
return noeud
|
||||||
|
if noeud.get(attribut) == valeur:
|
||||||
|
return noeud
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def copie_de(noeud):
|
||||||
|
from lxml import etree
|
||||||
|
|
||||||
|
return etree.fromstring(etree.tostring(noeud))
|
||||||
|
|
||||||
|
|
||||||
|
def place_before(parent, ancre, accueil):
|
||||||
|
"""À quel rang poser l'ancrage dans `accueil`.
|
||||||
|
|
||||||
|
On cherche le premier frère SUIVANT de l'ancrage que la copie possède
|
||||||
|
aussi, et l'on se place juste devant. Sans aucun repère, en tête :
|
||||||
|
un `t-set` en tête est toujours valide, jamais en retard.
|
||||||
|
"""
|
||||||
|
suivants = list(parent)[list(parent).index(ancre) + 1 :]
|
||||||
|
for frere in suivants:
|
||||||
|
jumeau = find_by_signature(accueil, signature(frere))
|
||||||
|
if jumeau is not None and jumeau.getparent() is accueil:
|
||||||
|
return list(accueil).index(jumeau)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def repair_anchor(arch_copie, arch_module, expr):
|
||||||
|
"""Remettre l'ancrage là où le module le place. None si impossible.
|
||||||
|
|
||||||
|
On ne devine pas l'endroit : on lit dans le module quel élément
|
||||||
|
CONTIENT l'ancrage, on retrouve le même dans la copie par sa
|
||||||
|
signature, et l'on y insère en tête. Sans correspondance, on rend
|
||||||
|
None — mieux vaut une réparation refusée qu'un bloc posé au hasard
|
||||||
|
au milieu de la page de quelqu'un.
|
||||||
|
"""
|
||||||
|
from lxml import etree
|
||||||
|
|
||||||
|
module = parse(arch_module)
|
||||||
|
copie = parse(arch_copie)
|
||||||
|
if module is None or copie is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
cibles = module.xpath(expr)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if not cibles:
|
||||||
|
return None
|
||||||
|
ancre = cibles[0]
|
||||||
|
parent = ancre.getparent()
|
||||||
|
if parent is None:
|
||||||
|
return None
|
||||||
|
accueil = find_by_signature(copie, signature(parent))
|
||||||
|
if accueil is None:
|
||||||
|
return None
|
||||||
|
# AVANT le même frère suivant, et non au même rang : la copie n'a
|
||||||
|
# pas forcément les frères qui précèdent l'ancrage dans le module, et
|
||||||
|
# « rang 1 » y désignerait alors une tout autre place. Ce qui doit
|
||||||
|
# être respecté, c'est l'ORDRE — QWeb évalue dans l'ordre du
|
||||||
|
# document, donc un `t-set` posé après le contenu qui s'en sert n'est
|
||||||
|
# pas vu, et l'enfant qui s'insère « after » l'ancrage le suit.
|
||||||
|
accueil.insert(place_before(parent, ancre, accueil), copie_de(ancre))
|
||||||
|
return etree.tostring(copie, encoding="unicode")
|
||||||
|
|
||||||
|
|
||||||
|
def repair_call(arch_copie, nom):
|
||||||
|
"""Retirer l'appel au gabarit disparu. None si rien à retirer."""
|
||||||
|
from lxml import etree
|
||||||
|
|
||||||
|
copie = parse(arch_copie)
|
||||||
|
if copie is None:
|
||||||
|
return None
|
||||||
|
retires = 0
|
||||||
|
for noeud in list(copie.iter()):
|
||||||
|
if noeud.get("t-call") != nom:
|
||||||
|
continue
|
||||||
|
parent = noeud.getparent()
|
||||||
|
if parent is None:
|
||||||
|
continue
|
||||||
|
parent.remove(noeud)
|
||||||
|
retires += 1
|
||||||
|
if not retires:
|
||||||
|
return None
|
||||||
|
return etree.tostring(copie, encoding="unicode")
|
||||||
|
|
||||||
|
|
||||||
|
def any_lang(arch):
|
||||||
|
"""N'importe laquelle : les xpath d'une vue ne sont pas traduits."""
|
||||||
|
langues = langs_of(arch)
|
||||||
|
return langues.get("en_US") or (list(langues.values()) or [""])[0]
|
||||||
|
|
||||||
|
|
||||||
|
def audit(database):
|
||||||
|
"""Ce que chaque copie a de cassé. None si la base ne répond pas."""
|
||||||
|
jsonb = arch_is_jsonb(database)
|
||||||
|
copies = cow_copies(database, jsonb)
|
||||||
|
connus = known_keys(database)
|
||||||
|
if copies is None or connus is None:
|
||||||
|
return None
|
||||||
|
enfants = children_of(database, jsonb, [c["id"] for c in copies])
|
||||||
|
rapport = []
|
||||||
|
for copie in copies:
|
||||||
|
manque = []
|
||||||
|
pendants = []
|
||||||
|
for langue, texte in sorted(langs_of(copie["arch"]).items()):
|
||||||
|
for enfant in enfants.get(copie["id"], []):
|
||||||
|
# L'enfant a lui aussi ses langues ; une seule suffit à
|
||||||
|
# connaître ses xpath, ils ne sont pas traduits.
|
||||||
|
for expr in anchors_wanted(any_lang(enfant["arch"])):
|
||||||
|
if not locates(texte, expr):
|
||||||
|
manque.append(
|
||||||
|
{
|
||||||
|
"enfant": enfant["id"],
|
||||||
|
"expr": expr,
|
||||||
|
"langue": langue,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
for nom in dangling_calls(texte, connus):
|
||||||
|
if nom not in pendants:
|
||||||
|
pendants.append(nom)
|
||||||
|
if manque or pendants:
|
||||||
|
rapport.append(
|
||||||
|
{
|
||||||
|
"id": copie["id"],
|
||||||
|
"key": copie["key"],
|
||||||
|
"anchors": manque,
|
||||||
|
"calls": pendants,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"database": database, "jsonb": jsonb, "views": rapport}
|
||||||
|
|
||||||
|
|
||||||
|
def write_arch_sql(vue_id, langues, jsonb):
|
||||||
|
"""La mise à jour, TOUTES les langues d'un coup.
|
||||||
|
|
||||||
|
Réécrire une seule entrée du jsonb laisse la page cassée dans les
|
||||||
|
autres : mesuré sur /contact, réparé en en_US et toujours en 500
|
||||||
|
parce que le site rend en fr_CA.
|
||||||
|
|
||||||
|
Dollar-quoting, parce qu'un arch porte une apostrophe à presque
|
||||||
|
chaque attribut. Le marqueur est assez long pour qu'aucun gabarit ne
|
||||||
|
le contienne par accident.
|
||||||
|
"""
|
||||||
|
if not jsonb:
|
||||||
|
texte = langues.get("") or next(iter(langues.values()), "")
|
||||||
|
return (
|
||||||
|
f"UPDATE ir_ui_view SET arch_db = $elcow${texte}$elcow$"
|
||||||
|
f" WHERE id = {vue_id};"
|
||||||
|
)
|
||||||
|
paires = ", ".join(
|
||||||
|
f"$elcow${langue}$elcow$, $elcow${texte}$elcow$"
|
||||||
|
for langue, texte in sorted(langues.items())
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f"UPDATE ir_ui_view SET arch_db = jsonb_build_object({paires})"
|
||||||
|
f" WHERE id = {vue_id};"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def plan(database, rapport):
|
||||||
|
"""[(id, clé, geste, quoi, sql)] — ce que `--apply` ferait, rien de plus.
|
||||||
|
|
||||||
|
Les réparations s'enchaînent sur le MÊME texte, langue par langue, et
|
||||||
|
une seule écriture les porte toutes : deux UPDATE sur la même vue et
|
||||||
|
le second effacerait le premier.
|
||||||
|
"""
|
||||||
|
jsonb = rapport["jsonb"]
|
||||||
|
gestes = []
|
||||||
|
for vue in rapport["views"]:
|
||||||
|
ligne = cow_copies_one(database, jsonb, vue["id"])
|
||||||
|
if not ligne:
|
||||||
|
continue
|
||||||
|
langues = langs_of(ligne["arch"])
|
||||||
|
module = module_twin(database, jsonb, vue["key"])
|
||||||
|
arch_module = any_lang(module) if module else None
|
||||||
|
touche = False
|
||||||
|
for langue in sorted(langues):
|
||||||
|
texte = langues[langue]
|
||||||
|
for manque in vue["anchors"]:
|
||||||
|
if locates(texte, manque["expr"]):
|
||||||
|
continue
|
||||||
|
neuf_texte = (
|
||||||
|
repair_anchor(texte, arch_module, manque["expr"])
|
||||||
|
if arch_module
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if neuf_texte is None:
|
||||||
|
gestes.append(
|
||||||
|
(
|
||||||
|
vue["id"],
|
||||||
|
vue["key"],
|
||||||
|
"anchor-impossible",
|
||||||
|
f"{manque['expr']} [{langue or '—'}]",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
texte = neuf_texte
|
||||||
|
touche = True
|
||||||
|
gestes.append(
|
||||||
|
(
|
||||||
|
vue["id"],
|
||||||
|
vue["key"],
|
||||||
|
"anchor",
|
||||||
|
f"{manque['expr']} [{langue or '—'}]",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for nom in vue["calls"]:
|
||||||
|
neuf_texte = repair_call(texte, nom)
|
||||||
|
if neuf_texte is None:
|
||||||
|
continue
|
||||||
|
texte = neuf_texte
|
||||||
|
touche = True
|
||||||
|
gestes.append(
|
||||||
|
(
|
||||||
|
vue["id"],
|
||||||
|
vue["key"],
|
||||||
|
"t-call",
|
||||||
|
f"{nom} [{langue or '—'}]",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
langues[langue] = texte
|
||||||
|
if touche:
|
||||||
|
gestes.append(
|
||||||
|
(
|
||||||
|
vue["id"],
|
||||||
|
vue["key"],
|
||||||
|
"write",
|
||||||
|
f"{len(langues)} langue(s)",
|
||||||
|
write_arch_sql(vue["id"], langues, jsonb),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return gestes
|
||||||
|
|
||||||
|
|
||||||
|
def cow_copies_one(database, jsonb, vue_id):
|
||||||
|
lignes = fetch(
|
||||||
|
database,
|
||||||
|
f"SELECT id, key, {arch_expr(jsonb)} AS arch FROM ir_ui_view"
|
||||||
|
f" WHERE id = {vue_id}",
|
||||||
|
)
|
||||||
|
return (lignes or [None])[0]
|
||||||
|
|
||||||
|
|
||||||
|
def render(rapport, gestes):
|
||||||
|
if not rapport["views"]:
|
||||||
|
return [f"✅ {t('Every website copy still renders.')}"]
|
||||||
|
lignes = [
|
||||||
|
f"🖼 {t('Website copies that can no longer render')}"
|
||||||
|
f" — {rapport['database']}",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
for vue in rapport["views"]:
|
||||||
|
lignes.append(f" [{vue['id']}] {vue['key']}")
|
||||||
|
for manque in vue["anchors"]:
|
||||||
|
lignes.append(
|
||||||
|
f" ⚓ {t('anchor missing for view')} {manque['enfant']} :"
|
||||||
|
f" {manque['expr']}"
|
||||||
|
)
|
||||||
|
for nom in vue["calls"]:
|
||||||
|
lignes.append(
|
||||||
|
f" ☎ {t('calls a template that is gone:')} {nom}"
|
||||||
|
)
|
||||||
|
impossibles = [g for g in gestes if g[2] == "anchor-impossible"]
|
||||||
|
faisables = [g for g in gestes if g[2] in ("anchor", "t-call")]
|
||||||
|
lignes.append("")
|
||||||
|
lignes.append(
|
||||||
|
f" {len(faisables)} {t('repair(s) available with --apply')}"
|
||||||
|
)
|
||||||
|
if impossibles:
|
||||||
|
lignes.append(
|
||||||
|
f" ⚠ {len(impossibles)} {t('cannot be repaired automatically')}"
|
||||||
|
)
|
||||||
|
if any(g[2] == "t-call" for g in faisables):
|
||||||
|
lignes.append(
|
||||||
|
f" {t('Removing a call removes its block from the page.')}"
|
||||||
|
)
|
||||||
|
return lignes
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description=(
|
||||||
|
"Report website COW copies that can no longer render after a"
|
||||||
|
" version bump, and optionally repair them."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
parser.add_argument("-d", "--database", required=True)
|
||||||
|
parser.add_argument(
|
||||||
|
"--apply",
|
||||||
|
action="store_true",
|
||||||
|
help="actually repair (default: report only)",
|
||||||
|
)
|
||||||
|
parser.add_argument("--json", action="store_true", help="machine output")
|
||||||
|
config = parser.parse_args(argv)
|
||||||
|
|
||||||
|
rapport = audit(config.database)
|
||||||
|
if rapport is None:
|
||||||
|
print(f"❌ {t('Cannot read the database: ')}{config.database}")
|
||||||
|
return 2
|
||||||
|
gestes = plan(config.database, rapport)
|
||||||
|
if config.json:
|
||||||
|
print(
|
||||||
|
json.dumps(rapport, indent=2, sort_keys=True, ensure_ascii=False)
|
||||||
|
)
|
||||||
|
return 1 if rapport["views"] else 0
|
||||||
|
if not rapport["views"]:
|
||||||
|
print("\n".join(render(rapport, gestes)))
|
||||||
|
return 0
|
||||||
|
if not config.apply:
|
||||||
|
print("\n".join(render(rapport, gestes)))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
for _id, _cle, genre, _quoi, sql in gestes:
|
||||||
|
if sql and run_psql(config.database, sql, read_only=False) is None:
|
||||||
|
print(f"❌ {t('The correction failed.')}")
|
||||||
|
return 2
|
||||||
|
# RELIRE : annoncer « corrigé » sans regarder ferait rouvrir la même
|
||||||
|
# page cassée en croyant le problème derrière soi.
|
||||||
|
apres = audit(config.database)
|
||||||
|
if apres is None:
|
||||||
|
print(f"❌ {t('Cannot read the database: ')}{config.database}")
|
||||||
|
return 2
|
||||||
|
if apres["views"]:
|
||||||
|
print("\n".join(render(apres, plan(config.database, apres))))
|
||||||
|
print(f"⚠️ {t('Some copies still cannot render.')}")
|
||||||
|
return 1
|
||||||
|
print(f"✅ {t('Every website copy renders again.')}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
287
script/odoo/migration/fix_duplicate_index.py
Executable file
287
script/odoo/migration/fix_duplicate_index.py
Executable file
|
|
@ -0,0 +1,287 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
"""Les index que la 17 a créés en double et n'a jamais retirés.
|
||||||
|
|
||||||
|
Odoo 17 a changé sa convention de nommage : `make_index_name` rend
|
||||||
|
désormais `{table}__{colonne}_index` — deux soulignés — là où les
|
||||||
|
versions d'avant écrivaient `{table}_{colonne}_index`. Le nouvel index
|
||||||
|
est créé, l'ancien reste. Mesuré sur une chaîne 12 → 18 :
|
||||||
|
|
||||||
|
test_neutralize (12) 1 paire
|
||||||
|
_16 3
|
||||||
|
_17 370 ← la bascule
|
||||||
|
_18 365
|
||||||
|
|
||||||
|
Inerte à la lecture, coûteux à l'écriture : chaque INSERT et chaque
|
||||||
|
UPDATE sur ces tables entretient deux arbres B identiques. Ici 9,6 Mo et
|
||||||
|
une base vide ; sur une production le coût croît avec les lignes. C'est
|
||||||
|
le seul défaut de cette famille qui empire tout seul.
|
||||||
|
|
||||||
|
Ce que l'outil REFUSE de toucher, et pourquoi :
|
||||||
|
|
||||||
|
adossé à une contrainte PostgreSQL le recréerait, ou la contrainte
|
||||||
|
tomberait avec lui.
|
||||||
|
clé primaire même raison, en pire.
|
||||||
|
index partiel ou calculé `indpred` / `indexprs` : deux index sur les
|
||||||
|
mêmes colonnes n'y font pas le même travail.
|
||||||
|
méthode différente un gin et un btree sur la même colonne ne se
|
||||||
|
remplacent pas.
|
||||||
|
convention ambiguë aucun des deux noms — ou les deux — suit la
|
||||||
|
convention de la cible : on ne devine pas
|
||||||
|
lequel Odoo recréera.
|
||||||
|
|
||||||
|
Lecture seule par défaut. `--apply` supprime, puis RELIT pour vérifier.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# Les paires strictement identiques : même table, mêmes colonnes, même
|
||||||
|
# unicité, même méthode d'accès, mêmes classes d'opérateurs. Sans un seul
|
||||||
|
# de ces critères on comparerait des index qui ne font pas le même travail.
|
||||||
|
DETECTION = """
|
||||||
|
WITH idx AS (
|
||||||
|
SELECT i.indexrelid,
|
||||||
|
i.indrelid,
|
||||||
|
i.indkey::text AS cols,
|
||||||
|
i.indclass::text AS classes,
|
||||||
|
i.indisunique AS unique_,
|
||||||
|
am.amname AS methode,
|
||||||
|
c.relname AS nom,
|
||||||
|
t.relname AS tbl,
|
||||||
|
pg_relation_size(i.indexrelid) AS taille,
|
||||||
|
EXISTS (SELECT 1 FROM pg_constraint k
|
||||||
|
WHERE k.conindid = i.indexrelid) AS contrainte
|
||||||
|
FROM pg_index i
|
||||||
|
JOIN pg_class c ON c.oid = i.indexrelid
|
||||||
|
JOIN pg_class t ON t.oid = i.indrelid
|
||||||
|
JOIN pg_am am ON am.oid = c.relam
|
||||||
|
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||||
|
WHERE n.nspname = 'public'
|
||||||
|
AND i.indpred IS NULL
|
||||||
|
AND i.indexprs IS NULL
|
||||||
|
AND NOT i.indisprimary
|
||||||
|
AND i.indisvalid
|
||||||
|
)
|
||||||
|
SELECT coalesce(json_agg(json_build_object(
|
||||||
|
'table', a.tbl,
|
||||||
|
'a', a.nom, 'a_size', a.taille, 'a_ctr', a.contrainte,
|
||||||
|
'b', b.nom, 'b_size', b.taille, 'b_ctr', b.contrainte
|
||||||
|
)), '[]')
|
||||||
|
FROM idx a
|
||||||
|
JOIN idx b
|
||||||
|
ON a.indrelid = b.indrelid
|
||||||
|
AND a.cols = b.cols
|
||||||
|
AND a.classes = b.classes
|
||||||
|
AND a.unique_ = b.unique_
|
||||||
|
AND a.methode = b.methode
|
||||||
|
AND a.indexrelid < b.indexrelid
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def run_psql(database, sql, read_only=True):
|
||||||
|
"""Interroger la base. None si elle ne répond pas.
|
||||||
|
|
||||||
|
Lecture seule garantie par le SERVEUR : une suppression qui se trompe
|
||||||
|
de base doit être refusée par PostgreSQL, pas par une relecture.
|
||||||
|
"""
|
||||||
|
env = os.environ.copy()
|
||||||
|
if read_only:
|
||||||
|
env["PGOPTIONS"] = "-c default_transaction_read_only=on"
|
||||||
|
env["PSQLRC"] = ""
|
||||||
|
done = subprocess.run(
|
||||||
|
["psql", "-X", "-w", "-d", database, "-tA", "-c", sql],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
if done.returncode:
|
||||||
|
return None
|
||||||
|
return done.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def modern_name(table, nom):
|
||||||
|
"""Ce nom suit-il la convention de la cible ?
|
||||||
|
|
||||||
|
`make_index_name` d'Odoo 18 rend `{table}__{colonne}_index`. Un nom
|
||||||
|
trop long est tronqué et suffixé d'un condensat ; celui-là ne se
|
||||||
|
reconnaît pas, et l'on préfère s'abstenir que deviner.
|
||||||
|
"""
|
||||||
|
return nom.startswith(f"{table}__")
|
||||||
|
|
||||||
|
|
||||||
|
def classify(paire):
|
||||||
|
"""(verdict, à garder, à supprimer).
|
||||||
|
|
||||||
|
« safe » exige que l'un des deux noms — et un seul — suive la
|
||||||
|
convention : c'est le seul cas où l'on sait lequel Odoo recréera.
|
||||||
|
"""
|
||||||
|
if paire["a_ctr"] or paire["b_ctr"]:
|
||||||
|
return "constraint", None, None
|
||||||
|
table = paire["table"]
|
||||||
|
a_moderne = modern_name(table, paire["a"])
|
||||||
|
b_moderne = modern_name(table, paire["b"])
|
||||||
|
if a_moderne == b_moderne:
|
||||||
|
return "ambiguous", None, None
|
||||||
|
if a_moderne:
|
||||||
|
return "safe", paire["a"], paire["b"]
|
||||||
|
return "safe", paire["b"], paire["a"]
|
||||||
|
|
||||||
|
|
||||||
|
def find(database):
|
||||||
|
"""[(verdict, table, garder, supprimer, octets)]. None si base muette."""
|
||||||
|
sortie = run_psql(database, DETECTION)
|
||||||
|
if sortie is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
paires = json.loads(sortie.strip() or "[]")
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
lst = []
|
||||||
|
for paire in paires:
|
||||||
|
verdict, garder, supprimer = classify(paire)
|
||||||
|
octets = (
|
||||||
|
paire["b_size"] if supprimer == paire["b"] else paire["a_size"]
|
||||||
|
)
|
||||||
|
lst.append(
|
||||||
|
(verdict, paire["table"], garder, supprimer, octets or 0, paire)
|
||||||
|
)
|
||||||
|
return sorted(lst, key=lambda x: (x[0], x[1], x[3] or ""))
|
||||||
|
|
||||||
|
|
||||||
|
def safe_only(lst):
|
||||||
|
return [item for item in lst if item[0] == "safe"]
|
||||||
|
|
||||||
|
|
||||||
|
def drop_sql(lst):
|
||||||
|
"""Les suppressions, une par ligne. Vide s'il n'y a rien de sûr.
|
||||||
|
|
||||||
|
`IF EXISTS` : deux paires peuvent nommer le même index à supprimer
|
||||||
|
quand une table en porte trois copies, et la deuxième passe ne doit
|
||||||
|
pas échouer sur ce qui vient de partir.
|
||||||
|
"""
|
||||||
|
noms = []
|
||||||
|
for _v, _tbl, _garder, supprimer, _o, _p in safe_only(lst):
|
||||||
|
if supprimer and supprimer not in noms:
|
||||||
|
noms.append(supprimer)
|
||||||
|
return "\n".join(f'DROP INDEX IF EXISTS "{nom}";' for nom in noms)
|
||||||
|
|
||||||
|
|
||||||
|
def render(lst, applique=False):
|
||||||
|
if not lst:
|
||||||
|
return [f"✅ {t('No duplicate index.')}"]
|
||||||
|
surs = safe_only(lst)
|
||||||
|
octets = sum(item[4] for item in surs)
|
||||||
|
lignes = [
|
||||||
|
f"🗂 {len(lst)} {t('duplicate index pair(s)')}"
|
||||||
|
f" — {len(surs)} {t('safe to drop')}"
|
||||||
|
f" ({octets // 1024} kB)",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
for _v, table, garder, supprimer, taille, _p in surs[:20]:
|
||||||
|
lignes.append(
|
||||||
|
f" {table} : {t('drop')} {supprimer}"
|
||||||
|
f" ({taille // 1024} kB), {t('keep')} {garder}"
|
||||||
|
)
|
||||||
|
if len(surs) > 20:
|
||||||
|
lignes.append(f" … {len(surs) - 20} {t('more')}")
|
||||||
|
for genre, texte in (
|
||||||
|
("constraint", t("backed by a constraint — left alone")),
|
||||||
|
("ambiguous", t("neither name follows the convention — your call")),
|
||||||
|
):
|
||||||
|
lot = [item for item in lst if item[0] == genre]
|
||||||
|
if not lot:
|
||||||
|
continue
|
||||||
|
lignes.append("")
|
||||||
|
lignes.append(f" ⚠ {len(lot)} {texte}")
|
||||||
|
for _v, table, _g, _s, _o, paire in lot[:6]:
|
||||||
|
lignes.append(f" {table} : {paire['a']} ↔ {paire['b']}")
|
||||||
|
if not applique and surs:
|
||||||
|
lignes.append("")
|
||||||
|
lignes.append(f" {t('Use --apply to drop them.')}")
|
||||||
|
return lignes
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description=(
|
||||||
|
"Report the duplicate indexes Odoo 17 left behind when it"
|
||||||
|
" renamed its index convention, and optionally drop them."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
parser.add_argument("-d", "--database", required=True)
|
||||||
|
parser.add_argument(
|
||||||
|
"--apply",
|
||||||
|
action="store_true",
|
||||||
|
help="actually drop the safe duplicates (default: report only)",
|
||||||
|
)
|
||||||
|
parser.add_argument("--json", action="store_true", help="machine output")
|
||||||
|
config = parser.parse_args(argv)
|
||||||
|
|
||||||
|
lst = find(config.database)
|
||||||
|
if lst is None:
|
||||||
|
print(f"❌ {t('Cannot read the database: ')}{config.database}")
|
||||||
|
return 2
|
||||||
|
if config.json:
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"verdict": v,
|
||||||
|
"table": tbl,
|
||||||
|
"keep": g,
|
||||||
|
"drop": s,
|
||||||
|
"bytes": o,
|
||||||
|
}
|
||||||
|
for v, tbl, g, s, o, _p in lst
|
||||||
|
],
|
||||||
|
indent=2,
|
||||||
|
sort_keys=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return 1 if lst else 0
|
||||||
|
if not lst:
|
||||||
|
print("\n".join(render(lst)))
|
||||||
|
return 0
|
||||||
|
if not config.apply:
|
||||||
|
print("\n".join(render(lst)))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
sql = drop_sql(lst)
|
||||||
|
if sql and run_psql(config.database, sql, read_only=False) is None:
|
||||||
|
print(f"❌ {t('The correction failed.')}")
|
||||||
|
return 2
|
||||||
|
# RELIRE : annoncer « supprimé » sans regarder ferait croire le
|
||||||
|
# problème réglé alors qu'un verrou a pu refuser la suppression.
|
||||||
|
apres = find(config.database)
|
||||||
|
if apres is None:
|
||||||
|
print(f"❌ {t('Cannot read the database: ')}{config.database}")
|
||||||
|
return 2
|
||||||
|
if safe_only(apres):
|
||||||
|
print("\n".join(render(apres, applique=True)))
|
||||||
|
print(f"⚠️ {t('Some duplicates are still there.')}")
|
||||||
|
return 1
|
||||||
|
print("\n".join(render(apres, applique=True)))
|
||||||
|
print(f"✅ {t('Duplicate indexes dropped.')}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
262
script/odoo/migration/restore_config_defaults.py
Executable file
262
script/odoo/migration/restore_config_defaults.py
Executable file
|
|
@ -0,0 +1,262 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
"""Les réglages qu'Odoo ne recrée qu'à des moments qu'une migration évite.
|
||||||
|
|
||||||
|
Certains enregistrements de configuration ont cessé d'être LIVRÉS en
|
||||||
|
données et sont devenus le produit d'un geste : créer une société,
|
||||||
|
changer de devise, cocher une case, charger un plan comptable. Une
|
||||||
|
migration ne fait aucun de ces gestes. L'enregistrement part avec le
|
||||||
|
nettoyage des orphelins et rien ne le remet.
|
||||||
|
|
||||||
|
Deux cas mesurés sur une chaîne 12 → 18 réelle :
|
||||||
|
|
||||||
|
liste de prix `product.list0` est déclaré jusqu'en 16 et plus
|
||||||
|
après. Odoo 18 crée les listes par défaut dans
|
||||||
|
`_activate_or_create_pricelists()`, appelée à la
|
||||||
|
création d'une société, au changement de devise
|
||||||
|
et au basculement du réglage. Résultat : 1 liste
|
||||||
|
en 16, 0 en 17 et en 18, alors que le groupe
|
||||||
|
`product.group_product_pricelist` compte six
|
||||||
|
membres. Toute commande s'ouvre alors sans liste
|
||||||
|
de prix — elle ne casse rien, elle facture faux.
|
||||||
|
|
||||||
|
rapprochement bancaire `account.reconciliation_model_default_rule` est
|
||||||
|
déclaré en 12 et plus dès la 13 : la 18 le crée
|
||||||
|
depuis le PLAN COMPTABLE
|
||||||
|
(`_get_account_reconcile_model`), qu'une montée
|
||||||
|
de version ne recharge jamais. Résultat : 1 en
|
||||||
|
12, 0 partout ensuite, et plus aucun lettrage
|
||||||
|
automatique sur les journaux de trésorerie.
|
||||||
|
|
||||||
|
On n'invente rien : on appelle les méthodes d'Odoo, celles-là mêmes que
|
||||||
|
le geste manquant aurait appelées. `_load_data` est rejoué avec
|
||||||
|
`ignore_duplicates`, donc l'outil se relance sans risque.
|
||||||
|
|
||||||
|
Lecture seule par défaut. `--apply` écrit, puis RELIT pour vérifier.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
sys.path.append(
|
||||||
|
os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
|
||||||
|
)
|
||||||
|
|
||||||
|
import database_cleanup # 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
|
||||||
|
|
||||||
|
|
||||||
|
# Les sentinelles de `run_shell`, pas les nôtres : il les impose, et en
|
||||||
|
# poser d'autres ferait lire un rapport vide.
|
||||||
|
DEBUT = database_cleanup.START
|
||||||
|
FIN = database_cleanup.END
|
||||||
|
|
||||||
|
SCRIPT = """
|
||||||
|
import json
|
||||||
|
DRY = {dry}
|
||||||
|
rapport = {{"dry_run": DRY}}
|
||||||
|
try:
|
||||||
|
Societe = env["res.company"].sudo()
|
||||||
|
societes = Societe.search([])
|
||||||
|
rapport["companies"] = len(societes)
|
||||||
|
|
||||||
|
# ── Listes de prix ──────────────────────────────────────────────
|
||||||
|
if "product.pricelist" in env:
|
||||||
|
Liste = env["product.pricelist"].sudo().with_context(active_test=False)
|
||||||
|
rapport["pricelist_before"] = Liste.search_count([])
|
||||||
|
# Le groupe décide : sans lui la fonctionnalité est masquée et
|
||||||
|
# l'absence de liste est normale, pas une perte.
|
||||||
|
rapport["pricelist_group"] = env.user.has_group(
|
||||||
|
"product.group_product_pricelist"
|
||||||
|
)
|
||||||
|
if not DRY and rapport["pricelist_group"]:
|
||||||
|
Societe._activate_or_create_pricelists()
|
||||||
|
env.cr.commit()
|
||||||
|
rapport["pricelist_after"] = Liste.search_count([])
|
||||||
|
else:
|
||||||
|
rapport["pricelist_absent"] = True
|
||||||
|
|
||||||
|
# ── Modèles de rapprochement bancaire ───────────────────────────
|
||||||
|
if "account.reconcile.model" in env:
|
||||||
|
Modele = env["account.reconcile.model"].sudo()
|
||||||
|
rapport["reconcile_before"] = Modele.search_count([])
|
||||||
|
# Sans journal de trésorerie il n'y a rien à rapprocher : créer
|
||||||
|
# des modèles là serait du bruit dans un menu qu'on n'ouvre pas.
|
||||||
|
rapport["cash_journals"] = env["account.journal"].sudo().search_count(
|
||||||
|
[("type", "in", ("bank", "cash"))]
|
||||||
|
)
|
||||||
|
rapport["charts"] = sorted(
|
||||||
|
{{s.chart_template for s in societes if s.chart_template}}
|
||||||
|
)
|
||||||
|
if not DRY and rapport["cash_journals"] and rapport["charts"]:
|
||||||
|
Gabarit = env["account.chart.template"].sudo()
|
||||||
|
for societe in societes:
|
||||||
|
if not societe.chart_template:
|
||||||
|
continue
|
||||||
|
donnees = Gabarit._get_account_reconcile_model(
|
||||||
|
societe.chart_template
|
||||||
|
)
|
||||||
|
if not donnees:
|
||||||
|
continue
|
||||||
|
# `_load_data` est le chemin d'Odoo : il pose les xmlid
|
||||||
|
# préfixés par la société, donc un rechargement futur du
|
||||||
|
# plan comptable ne fera pas de doublon.
|
||||||
|
Gabarit.with_company(societe)._load_data(
|
||||||
|
{{"account.reconcile.model": donnees}},
|
||||||
|
ignore_duplicates=True,
|
||||||
|
)
|
||||||
|
env.cr.commit()
|
||||||
|
rapport["reconcile_after"] = Modele.search_count([])
|
||||||
|
else:
|
||||||
|
rapport["reconcile_absent"] = True
|
||||||
|
except Exception as exc:
|
||||||
|
rapport["error"] = "%s: %s" % (type(exc).__name__, exc)
|
||||||
|
print({debut!r})
|
||||||
|
print(json.dumps(rapport))
|
||||||
|
print({fin!r})
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def build_script(dry_run):
|
||||||
|
return SCRIPT.format(
|
||||||
|
dry="True" if dry_run else "False", debut=DEBUT, fin=FIN
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def pricelist_missing(rapport, apres=False):
|
||||||
|
"""Une liste manque-t-elle vraiment ?
|
||||||
|
|
||||||
|
Pas de liste ET le groupe actif : la fonctionnalité est offerte aux
|
||||||
|
utilisateurs et ne répond rien. Groupe éteint, l'absence est normale.
|
||||||
|
|
||||||
|
`apres` regarde le compte D'APRÈS la réparation. Sans ce choix, un
|
||||||
|
`--apply` réussi se conclurait toujours par « il en manque encore » :
|
||||||
|
le compte d'avant, lui, reste à zéro pour l'éternité.
|
||||||
|
"""
|
||||||
|
if rapport.get("pricelist_absent"):
|
||||||
|
return False
|
||||||
|
if not rapport.get("pricelist_group"):
|
||||||
|
return False
|
||||||
|
cle = "pricelist_after" if apres else "pricelist_before"
|
||||||
|
return not rapport.get(cle)
|
||||||
|
|
||||||
|
|
||||||
|
def reconcile_missing(rapport, apres=False):
|
||||||
|
"""Aucun modèle alors qu'il y a de quoi rapprocher."""
|
||||||
|
if rapport.get("reconcile_absent"):
|
||||||
|
return False
|
||||||
|
if not rapport.get("cash_journals"):
|
||||||
|
return False
|
||||||
|
cle = "reconcile_after" if apres else "reconcile_before"
|
||||||
|
return not rapport.get(cle)
|
||||||
|
|
||||||
|
|
||||||
|
def findings(rapport, apres=False):
|
||||||
|
lst = []
|
||||||
|
if pricelist_missing(rapport, apres):
|
||||||
|
lst.append("pricelist")
|
||||||
|
if reconcile_missing(rapport, apres):
|
||||||
|
lst.append("reconcile")
|
||||||
|
return lst
|
||||||
|
|
||||||
|
|
||||||
|
def render(rapport, dry_run):
|
||||||
|
lignes = []
|
||||||
|
if not findings(rapport):
|
||||||
|
return [f"✅ {t('Every default configuration record is there.')}"]
|
||||||
|
lignes.append(
|
||||||
|
f"🧾 {t('Configuration records a migration never recreates')}"
|
||||||
|
)
|
||||||
|
lignes.append("")
|
||||||
|
if pricelist_missing(rapport):
|
||||||
|
lignes.append(
|
||||||
|
f" 💲 {t('No pricelist, and the pricelist feature is on.')}"
|
||||||
|
)
|
||||||
|
lignes.append(
|
||||||
|
f" {t('Every quotation opens without one: raw price, no rule.')}"
|
||||||
|
)
|
||||||
|
if reconcile_missing(rapport):
|
||||||
|
lignes.append(
|
||||||
|
f" 🏦 {t('No bank reconciliation model, for')}"
|
||||||
|
f" {rapport.get('cash_journals', 0)} {t('cash/bank journal(s).')}"
|
||||||
|
)
|
||||||
|
lignes.append(f" {t('Every statement line is matched by hand.')}")
|
||||||
|
lignes.append("")
|
||||||
|
if dry_run:
|
||||||
|
lignes.append(f" {t('Use --apply to let Odoo recreate them.')}")
|
||||||
|
else:
|
||||||
|
lignes.append(
|
||||||
|
f" {t('pricelists:')} {rapport.get('pricelist_before', 0)}"
|
||||||
|
f" → {rapport.get('pricelist_after', 0)}"
|
||||||
|
f" · {t('reconcile models:')}"
|
||||||
|
f" {rapport.get('reconcile_before', 0)}"
|
||||||
|
f" → {rapport.get('reconcile_after', 0)}"
|
||||||
|
)
|
||||||
|
return lignes
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description=(
|
||||||
|
"Report the default configuration records a version bump drops"
|
||||||
|
" and no event recreates, and optionally let Odoo recreate them."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
parser.add_argument("-d", "--database", required=True)
|
||||||
|
parser.add_argument("-c", "--config", default="config.conf")
|
||||||
|
parser.add_argument(
|
||||||
|
"--apply",
|
||||||
|
action="store_true",
|
||||||
|
help="actually recreate them (default: report only)",
|
||||||
|
)
|
||||||
|
config = parser.parse_args(argv)
|
||||||
|
|
||||||
|
# Le même garde-fou que partout : un Odoo d'une autre version ÉCRIT
|
||||||
|
# dans la base avant d'échouer.
|
||||||
|
souci = database_cleanup.require_matching_version(config.database)
|
||||||
|
if souci:
|
||||||
|
print(f"❌ {souci}")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
try:
|
||||||
|
rapport = database_cleanup.run_shell(
|
||||||
|
config.database,
|
||||||
|
config.config,
|
||||||
|
build_script(not config.apply),
|
||||||
|
echo=lambda texte: print(f"⧖ {texte}", flush=True),
|
||||||
|
)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
print(f"❌ {exc}")
|
||||||
|
return 2
|
||||||
|
if rapport.get("error"):
|
||||||
|
print(f"❌ {rapport['error']}")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
print("\n".join(render(rapport, not config.apply)))
|
||||||
|
if not findings(rapport):
|
||||||
|
return 0
|
||||||
|
if not config.apply:
|
||||||
|
return 1
|
||||||
|
# Juger sur le compte D'APRÈS : le script a recompté dans la même
|
||||||
|
# session. Annoncer « recréé » sans regarder ferait découvrir
|
||||||
|
# l'absence au premier devis.
|
||||||
|
reste = findings(rapport, apres=True)
|
||||||
|
if reste:
|
||||||
|
print(f"⚠️ {t('Still missing after the repair.')} {', '.join(reste)}")
|
||||||
|
return 1
|
||||||
|
print(f"✅ {t('Odoo recreated them.')}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
# Modules à retirer AVANT de monter de 12.0 vers 13.0.
|
||||||
|
#
|
||||||
|
# Un module par ligne, une justification après « # ».
|
||||||
|
|
||||||
|
# Les huit n'ont de code qu'en 12 : `find odoo{13..18}.0 -name <nom>` ne
|
||||||
|
# rend rien. Sans cette liste, l'étape « Uninstall module » n'a rien à
|
||||||
|
# faire et ils montent en 13 « installed » sans rien pour les charger.
|
||||||
|
# Odoo les écarte alors du graphe — « not installable, skipped » — et
|
||||||
|
# signale des états incohérents ; le ménage ne vient qu'en fin de palier,
|
||||||
|
# par database_cleanup, qui s'arrête à mi-course : les données partent,
|
||||||
|
# l'état reste « to remove ». Or « --uninstall » ne cherche que
|
||||||
|
# « installed » et sort 0 sans rien faire sur « to remove » : le module
|
||||||
|
# ne se retire alors plus JAMAIS. C'est ainsi que muk_web_theme a
|
||||||
|
# traversé quatre paliers en étant réputé parti.
|
||||||
|
#
|
||||||
|
# Mesuré sur une copie de test_neutralize avec le code de la 12 : les
|
||||||
|
# huit se retirent en un appel, 139 modules installés → 131, et le diff
|
||||||
|
# de ir_module_module ne montre que ces huit lignes. Aucune donnée
|
||||||
|
# d'affaires ne part — les tables sinerkia sont vides, aucune page ne
|
||||||
|
# pose les snippets maison, et le fuseau America/Montreal survit dans
|
||||||
|
# ir_default, que la désinstallation ne touche pas.
|
||||||
|
erplibre_base # code absent dès la 13
|
||||||
|
erplibre_website_snippets_basic_html # code absent dès la 13
|
||||||
|
erplibre_website_snippets_cards # code absent dès la 13
|
||||||
|
erplibre_website_snippets_jitsi # code absent dès la 13
|
||||||
|
erplibre_website_snippets_structures # code absent dès la 13
|
||||||
|
erplibre_website_snippets_timelines # code absent dès la 13
|
||||||
|
partner_quebec_tz # code absent dès la 13 ; le fuseau reste dans ir_default
|
||||||
|
sinerkia_jitsi_meet # code absent dès la 13 ; ses tables sont vides
|
||||||
|
|
@ -80,6 +80,67 @@ def wrap_privilege(remote: str, prefix: str) -> str:
|
||||||
return "sudo sh -c " + shlex.quote(remote)
|
return "sudo sh -c " + shlex.quote(remote)
|
||||||
|
|
||||||
|
|
||||||
|
# Ce que ssh écrit de lui-même, et qui n'est pas la réponse de l'hôte. Retiré
|
||||||
|
# à la source : un avertissement laissé dans la sortie a été pris pour un nom
|
||||||
|
# de pont par `parse_bridges`, et « (ED25519) » s'est retrouvé dans un
|
||||||
|
# « qm create » enrobé de « sudo sh -c » — d'où le « sh: 1: Syntax error:
|
||||||
|
# "(" unexpected » rapporté. Filtrer chez chaque lecteur aurait laissé le
|
||||||
|
# suivant retomber dans le piège.
|
||||||
|
_BRUIT_SSH = (
|
||||||
|
"Warning: Permanently added",
|
||||||
|
"Pseudo-terminal will not be allocated",
|
||||||
|
"Connection to ",
|
||||||
|
"Shared connection to ",
|
||||||
|
"Killed by signal",
|
||||||
|
"mesg: ttyname failed",
|
||||||
|
"stdin: is not a tty",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def strip_ssh_noise(text: str) -> str:
|
||||||
|
"""La sortie de l'hôte, débarrassée de ce que ssh y a ajouté.
|
||||||
|
|
||||||
|
Ce sont des lignes de ssh lui-même (clé d'hôte enregistrée, pseudo-terminal
|
||||||
|
refusé, connexion fermée) : elles n'apprennent rien sur la commande et
|
||||||
|
n'ont donc rien à faire dans ce qu'on analyse ou affiche.
|
||||||
|
"""
|
||||||
|
gardees = [
|
||||||
|
ligne
|
||||||
|
for ligne in (text or "").splitlines()
|
||||||
|
if not ligne.strip().startswith(_BRUIT_SSH)
|
||||||
|
]
|
||||||
|
return "\n".join(gardees) + ("\n" if gardees else "")
|
||||||
|
|
||||||
|
|
||||||
|
# Les lignes d'AVANCEMENT : « transferred 1.2 GiB of 3.0 GiB (40%) » répété
|
||||||
|
# cent fois par « qm set --import-from », les points de wget. Elles ne disent
|
||||||
|
# qu'une chose, et la dernière la dit aussi bien.
|
||||||
|
_RE_PROGRES = re.compile(
|
||||||
|
r"^\s*(transferred\s+[\d.]+|\d+K\s+\.|.*\.{10}.*\d+%)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def collapse_progress(text: str) -> str:
|
||||||
|
"""Ne garde que la DERNIÈRE ligne de chaque salve d'avancement.
|
||||||
|
|
||||||
|
Le journal du premier essai réel faisait 136 lignes, dont cent
|
||||||
|
« transferred … » : l'erreur utile se lisait au chausse-pied. Un
|
||||||
|
avancement compte pendant qu'il défile, pas dans un fichier qu'on relit.
|
||||||
|
"""
|
||||||
|
sortie, salve = [], 0
|
||||||
|
for ligne in (text or "").splitlines():
|
||||||
|
if _RE_PROGRES.match(ligne):
|
||||||
|
salve += 1
|
||||||
|
continue
|
||||||
|
if salve:
|
||||||
|
sortie.append(f" … {salve} lignes d'avancement …")
|
||||||
|
salve = 0
|
||||||
|
sortie.append(ligne)
|
||||||
|
if salve:
|
||||||
|
sortie.append(f" … {salve} lignes d'avancement …")
|
||||||
|
return "\n".join(sortie)
|
||||||
|
|
||||||
|
|
||||||
def run(host: dict, remote: str, timeout: int = 120) -> tuple:
|
def run(host: dict, remote: str, timeout: int = 120) -> tuple:
|
||||||
"""(code, sortie) de `remote` exécuté sur l'hôte. Ne lève jamais.
|
"""(code, sortie) de `remote` exécuté sur l'hôte. Ne lève jamais.
|
||||||
|
|
||||||
|
|
@ -98,7 +159,9 @@ def run(host: dict, remote: str, timeout: int = 120) -> tuple:
|
||||||
return 255, "timeout"
|
return 255, "timeout"
|
||||||
except (OSError, subprocess.SubprocessError) as exc:
|
except (OSError, subprocess.SubprocessError) as exc:
|
||||||
return 255, str(exc)
|
return 255, str(exc)
|
||||||
return res.returncode, (res.stdout or "") + (res.stderr or "")
|
return res.returncode, strip_ssh_noise(
|
||||||
|
(res.stdout or "") + (res.stderr or "")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
@ -115,6 +178,46 @@ def parse_pveversion(text: str) -> str:
|
||||||
return m.group(1) if m else ""
|
return m.group(1) if m else ""
|
||||||
|
|
||||||
|
|
||||||
|
# D'abord le fichier de systemd-resolved, qui porte les serveurs RÉELS :
|
||||||
|
# /etc/resolv.conf n'y renvoie qu'un stub sur 127.0.0.53, inutilisable pour un
|
||||||
|
# invité. On tente les deux, dans cet ordre.
|
||||||
|
RESOLV_CMD = (
|
||||||
|
"cat /run/systemd/resolve/resolv.conf 2>/dev/null || cat /etc/resolv.conf"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_nameservers(text: str) -> list:
|
||||||
|
"""Résolveurs UTILISABLES PAR UN INVITÉ, tirés d'un resolv.conf.
|
||||||
|
|
||||||
|
Les adresses de boucle sont écartées : « nameserver 127.0.0.53 » est le
|
||||||
|
stub de systemd-resolved, qui n'existe que sur l'hôte. Une VM qui le
|
||||||
|
reçoit n'a pas de DNS — mesuré, la VM d'essai ne résolvait rien alors que
|
||||||
|
le NAT marchait, et « apt update » aurait échoué sans rien expliquer.
|
||||||
|
"""
|
||||||
|
serveurs = []
|
||||||
|
for ligne in (text or "").splitlines():
|
||||||
|
parts = ligne.split()
|
||||||
|
if len(parts) >= 2 and parts[0] == "nameserver":
|
||||||
|
adresse = parts[1].strip()
|
||||||
|
if adresse.startswith("127.") or adresse in ("::1", "localhost"):
|
||||||
|
continue
|
||||||
|
if adresse not in serveurs:
|
||||||
|
serveurs.append(adresse)
|
||||||
|
return serveurs
|
||||||
|
|
||||||
|
|
||||||
|
def parse_kernel(text: str) -> str:
|
||||||
|
"""Noyau ANNONCÉ par pveversion, ou ''.
|
||||||
|
|
||||||
|
« pve-manager/9.2.11/abc (running kernel: 6.12.95+deb13-cloud-amd64) » ->
|
||||||
|
« 6.12.95+deb13-cloud-amd64 ». Ce n'est pas un détail : tant que l'hôte
|
||||||
|
tourne le noyau de la distribution, il n'a ni le module bridge ni la table
|
||||||
|
NAT, donc pas de pont et pas de VM.
|
||||||
|
"""
|
||||||
|
trouve = re.search(r"running kernel:\s*([^)\s]+)", text or "")
|
||||||
|
return trouve.group(1) if trouve else ""
|
||||||
|
|
||||||
|
|
||||||
def parse_qm_list(text: str) -> list:
|
def parse_qm_list(text: str) -> list:
|
||||||
"""Sortie de « qm list » -> [{vmid, name, status, mem, disk}].
|
"""Sortie de « qm list » -> [{vmid, name, status, mem, disk}].
|
||||||
|
|
||||||
|
|
@ -169,15 +272,24 @@ def parse_storages(text: str) -> list:
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# « 2: vmbr0: <BROADCAST,MULTICAST,UP> mtu 1500 … » — l'index, le nom, les
|
||||||
|
# drapeaux. Exiger cette forme, et pas « quelque chose avant deux-points » :
|
||||||
|
# n'importe quelle ligne de bruit devenait sinon un nom de pont.
|
||||||
|
_RE_LIEN = re.compile(r"^\s*\d+:\s*([A-Za-z0-9][A-Za-z0-9._@-]*):\s*<")
|
||||||
|
|
||||||
|
|
||||||
def parse_bridges(text: str) -> list:
|
def parse_bridges(text: str) -> list:
|
||||||
"""Sortie de « ip -o link show type bridge » -> ['vmbr0', …]."""
|
"""Sortie de « ip -o link show type bridge » -> ['vmbr0', …].
|
||||||
|
|
||||||
|
Rien d'autre ne passe : un avertissement de ssh a déjà été pris pour un
|
||||||
|
pont, et son « (ED25519) » a fait échouer le « qm create » qui suivait sur
|
||||||
|
une erreur de syntaxe shell incompréhensible.
|
||||||
|
"""
|
||||||
ponts = []
|
ponts = []
|
||||||
for ligne in (text or "").splitlines():
|
for ligne in (text or "").splitlines():
|
||||||
parts = ligne.split(":")
|
trouve = _RE_LIEN.match(ligne)
|
||||||
if len(parts) > 1:
|
if trouve:
|
||||||
nom = parts[1].strip().split("@")[0]
|
ponts.append(trouve.group(1).split("@")[0])
|
||||||
if nom:
|
|
||||||
ponts.append(nom)
|
|
||||||
return ponts
|
return ponts
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -354,7 +466,18 @@ def bridge_setup_cmds(
|
||||||
)
|
)
|
||||||
# ifup plutôt qu'« ifreload -a » : recharger TOUTE la configuration d'un
|
# ifup plutôt qu'« ifreload -a » : recharger TOUTE la configuration d'un
|
||||||
# hôte distant peut emporter l'interface qui porte la session.
|
# hôte distant peut emporter l'interface qui porte la session.
|
||||||
cmds.append(f"ifup {nom} 2>/dev/null || ifreload -a")
|
#
|
||||||
|
# « mkdir -p /run/network » d'abord : ifupdown2 y pose son verrou, et
|
||||||
|
# quand le répertoire manque il annonce « Another instance of this program
|
||||||
|
# is already running » — son lockFile() attrape aussi le fichier
|
||||||
|
# introuvable. Le message est un MENSONGE, et il a caché deux heures la
|
||||||
|
# vraie panne. Sur une Debian installée en image cloud, networking.service
|
||||||
|
# n'a jamais démarré, donc personne n'a créé le répertoire.
|
||||||
|
#
|
||||||
|
# Et l'erreur d'ifup n'est PAS masquée : « 2>/dev/null » cachait
|
||||||
|
# « operation failed with 'Operation not supported' » — le noyau cloud n'a
|
||||||
|
# pas le module bridge, et c'est ce qu'il fallait lire.
|
||||||
|
cmds.append(f"mkdir -p /run/network; ifup {nom} || ifreload -a")
|
||||||
return cmds
|
return cmds
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -398,7 +521,7 @@ def image_fetch_cmd(url: str, nom: str, repertoire: str = IMAGE_DIR) -> str:
|
||||||
f"mkdir -p {shlex.quote(repertoire)} && "
|
f"mkdir -p {shlex.quote(repertoire)} && "
|
||||||
f"if [ -s {shlex.quote(cible)} ]; then "
|
f"if [ -s {shlex.quote(cible)} ]; then "
|
||||||
f'echo "image déjà présente : {cible}"; else '
|
f'echo "image déjà présente : {cible}"; else '
|
||||||
f"wget -q --show-progress -O {shlex.quote(cible)} {shlex.quote(url)}; "
|
f"wget -nv -O {shlex.quote(cible)} {shlex.quote(url)}; "
|
||||||
f"fi"
|
f"fi"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -446,6 +569,11 @@ def create_cmds(vmid: int, spec: dict) -> list:
|
||||||
if spec.get("password"):
|
if spec.get("password"):
|
||||||
ci += f" --cipassword {shlex.quote(spec['password'])}"
|
ci += f" --cipassword {shlex.quote(spec['password'])}"
|
||||||
ci += f" --ipconfig0 {spec.get('ipconfig') or 'ip=dhcp'}"
|
ci += f" --ipconfig0 {spec.get('ipconfig') or 'ip=dhcp'}"
|
||||||
|
# « --ipconfig0 » ne porte PAS le DNS : une VM en adresse fixe n'a alors
|
||||||
|
# aucun résolveur, et rien ne le dit. En DHCP le bail s'en charge.
|
||||||
|
serveurs = [s for s in (spec.get("nameservers") or ()) if s]
|
||||||
|
if serveurs and "dhcp" not in (spec.get("ipconfig") or "dhcp"):
|
||||||
|
ci += f" --nameserver {shlex.quote(' '.join(serveurs))}"
|
||||||
cmds.append(ci)
|
cmds.append(ci)
|
||||||
# 5. La taille. L'image cloud fait 2 Gio : sans agrandissement, il ne reste
|
# 5. La taille. L'image cloud fait 2 Gio : sans agrandissement, il ne reste
|
||||||
# rien pour installer quoi que ce soit.
|
# rien pour installer quoi que ce soit.
|
||||||
|
|
|
||||||
|
|
@ -291,6 +291,53 @@ def plan_rows(vms, domains, extra_disk_gb=0, orphelin=None):
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
# Les branches qu'on veut voir en premier. « ls-remote » les rend par ordre
|
||||||
|
# alphabétique : la liste commençait donc par « dependabot/pip/aiobotocore »,
|
||||||
|
# et c'est CELLE-LÀ qui était proposée par défaut — rapporté.
|
||||||
|
BRANCHES_EN_TETE = ("develop", "master")
|
||||||
|
|
||||||
|
|
||||||
|
def branch_default(branches, courante=""):
|
||||||
|
"""La branche à proposer : celle du DÉPÔT d'abord.
|
||||||
|
|
||||||
|
Déployer avec la branche qu'on a sous les yeux est le cas courant ; à
|
||||||
|
défaut « develop », puis « master ». Le premier de la liste alphabétique
|
||||||
|
n'a aucune raison d'être un bon défaut, et c'était une branche de
|
||||||
|
dependabot.
|
||||||
|
"""
|
||||||
|
liste = list(branches or ())
|
||||||
|
if not liste:
|
||||||
|
return ""
|
||||||
|
for candidate in (courante,) + BRANCHES_EN_TETE:
|
||||||
|
if candidate and candidate in liste:
|
||||||
|
return candidate
|
||||||
|
return liste[0]
|
||||||
|
|
||||||
|
|
||||||
|
def branch_order(branches, courante=""):
|
||||||
|
"""La liste réordonnée : la branche du dépôt, develop, master, puis le
|
||||||
|
reste, et les branches de robot à la fin.
|
||||||
|
|
||||||
|
Une liste de soixante entrées qui commence par six « dependabot/pip/… »
|
||||||
|
demande de dérouler pour trouver ce qu'on cherche.
|
||||||
|
"""
|
||||||
|
liste = list(branches or ())
|
||||||
|
# Dédoublonnée : la branche du dépôt EST souvent « develop », et la voir
|
||||||
|
# deux fois de suite ferait douter de la liste.
|
||||||
|
tete, vues = [], set()
|
||||||
|
for b in (courante,) + BRANCHES_EN_TETE:
|
||||||
|
if b and b in liste and b not in vues:
|
||||||
|
tete.append(b)
|
||||||
|
vues.add(b)
|
||||||
|
milieu, robots = [], []
|
||||||
|
for b in liste:
|
||||||
|
if b in vues:
|
||||||
|
continue
|
||||||
|
vues.add(b)
|
||||||
|
(robots if b.startswith("dependabot/") else milieu).append(b)
|
||||||
|
return tete + milieu + robots
|
||||||
|
|
||||||
|
|
||||||
def gib(nbytes) -> int:
|
def gib(nbytes) -> int:
|
||||||
"""Octets -> Gio entiers. Le plan compte en Go partout ailleurs : mêler
|
"""Octets -> Gio entiers. Le plan compte en Go partout ailleurs : mêler
|
||||||
des unités sur la même ligne de totaux la rendrait illisible."""
|
des unités sur la même ligne de totaux la rendrait illisible."""
|
||||||
|
|
@ -550,7 +597,10 @@ def run_deploy_progress(jobs, parallelism, run_app: bool = True):
|
||||||
|
|
||||||
Un bloc reste DÉPLIÉ tant que la VM tourne, se replie dès qu'elle réussit
|
Un bloc reste DÉPLIÉ tant que la VM tourne, se replie dès qu'elle réussit
|
||||||
— et reste ouvert si elle échoue, puisque c'est ce qu'on veut lire."""
|
— et reste ouvert si elle échoue, puisque c'est ce qu'on veut lire."""
|
||||||
|
import shlex
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
|
shlex_quote = shlex.quote
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
from textual.app import App, ComposeResult
|
from textual.app import App, ComposeResult
|
||||||
|
|
@ -579,6 +629,10 @@ def run_deploy_progress(jobs, parallelism, run_app: bool = True):
|
||||||
#hint { height: auto; color: $text-muted; padding: 0 1; }
|
#hint { height: auto; color: $text-muted; padding: 0 1; }
|
||||||
"""
|
"""
|
||||||
BINDINGS = [
|
BINDINGS = [
|
||||||
|
# « s » comme dans le tableau de bord : une VM qui vient d'être
|
||||||
|
# créée est souvent joignable tout de suite, et on veut y entrer
|
||||||
|
# sans quitter l'écran. Rapporté comme manquant.
|
||||||
|
("s", "ssh", t("SSH")),
|
||||||
("c", "copy_current", t("Copy log")),
|
("c", "copy_current", t("Copy log")),
|
||||||
("C", "copy_all", t("Copy all logs")),
|
("C", "copy_all", t("Copy all logs")),
|
||||||
("q", "quit", t("Quit")),
|
("q", "quit", t("Quit")),
|
||||||
|
|
@ -587,6 +641,10 @@ def run_deploy_progress(jobs, parallelism, run_app: bool = True):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self._out = {name: "" for _jid, name, _p in jobs}
|
self._out = {name: "" for _jid, name, _p in jobs}
|
||||||
|
# Ce qui a DÉJÀ été écrit à l'écran, pour ne pas le doubler.
|
||||||
|
self._ecrites = {}
|
||||||
|
# Les VM créées sans erreur : celles où « s » peut entrer.
|
||||||
|
self._reussies = []
|
||||||
self._done = 0
|
self._done = 0
|
||||||
self._t0 = time.time()
|
self._t0 = time.time()
|
||||||
self._slots = threading.Semaphore(max(1, parallelism))
|
self._slots = threading.Semaphore(max(1, parallelism))
|
||||||
|
|
@ -609,7 +667,8 @@ def run_deploy_progress(jobs, parallelism, run_app: bool = True):
|
||||||
with Vertical():
|
with Vertical():
|
||||||
yield Static("", id="summary")
|
yield Static("", id="summary")
|
||||||
yield Static(
|
yield Static(
|
||||||
f" {t('c copy log · C copy all · q quit')}", id="hint"
|
f" {t('s ssh · c copy log · C copy all · q quit')}",
|
||||||
|
id="hint",
|
||||||
)
|
)
|
||||||
yield Button(t("Copy all logs"), id="copyall")
|
yield Button(t("Copy all logs"), id="copyall")
|
||||||
yield Footer()
|
yield Footer()
|
||||||
|
|
@ -634,27 +693,65 @@ def run_deploy_progress(jobs, parallelism, run_app: bool = True):
|
||||||
def _job() -> None:
|
def _job() -> None:
|
||||||
with self._slots:
|
with self._slots:
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
|
lignes = []
|
||||||
try:
|
try:
|
||||||
res = subprocess.run(
|
# « Popen » et LECTURE LIGNE À LIGNE, pas
|
||||||
parts, capture_output=True, text=True
|
# « subprocess.run » : celui-ci ne rend la sortie
|
||||||
|
# qu'à la fin du travail, et le bloc restait vide
|
||||||
|
# pendant tout le déploiement — rapporté. Sur Proxmox,
|
||||||
|
# une VM demande le téléchargement d'une image de
|
||||||
|
# 325 Mio puis l'import du disque : plusieurs minutes
|
||||||
|
# de silence, et rien à l'écran.
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
parts,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
text=True,
|
||||||
|
bufsize=1,
|
||||||
)
|
)
|
||||||
rc = res.returncode
|
|
||||||
out = (res.stdout or "") + (res.stderr or "")
|
|
||||||
except (OSError, subprocess.SubprocessError) as exc:
|
except (OSError, subprocess.SubprocessError) as exc:
|
||||||
rc, out = 1, str(exc)
|
self.call_from_thread(
|
||||||
|
self._finish,
|
||||||
|
jid,
|
||||||
|
name,
|
||||||
|
1,
|
||||||
|
str(exc),
|
||||||
|
time.time() - t0,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
for ligne in proc.stdout:
|
||||||
|
ligne = ligne.rstrip("\n")
|
||||||
|
lignes.append(ligne)
|
||||||
|
self.call_from_thread(self._ecrire, name, ligne)
|
||||||
|
rc = proc.wait()
|
||||||
|
out = "\n".join(lignes)
|
||||||
self.call_from_thread(
|
self.call_from_thread(
|
||||||
self._finish, jid, name, rc, out, time.time() - t0
|
self._finish, jid, name, rc, out, time.time() - t0
|
||||||
)
|
)
|
||||||
|
|
||||||
self.run_worker(_job, thread=True, group="deploy", exclusive=False)
|
self.run_worker(_job, thread=True, group="deploy", exclusive=False)
|
||||||
|
|
||||||
|
def _ecrire(self, name, ligne):
|
||||||
|
"""Une ligne, dès qu'elle arrive. Appelée depuis le fil."""
|
||||||
|
self._ecrites[name] = True
|
||||||
|
try:
|
||||||
|
self.query_one(f"#log_{slug(name)}", RichLog).write(ligne)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def _finish(self, jid, name, rc, out, secs):
|
def _finish(self, jid, name, rc, out, secs):
|
||||||
self._out[name] = out
|
self._out[name] = out
|
||||||
results.append((name, rc, out, secs))
|
results.append((name, rc, out, secs))
|
||||||
self._done += 1
|
self._done += 1
|
||||||
log = self.query_one(f"#log_{slug(name)}", RichLog)
|
if rc == 0:
|
||||||
for line in out.strip().splitlines():
|
self._reussies.append(name)
|
||||||
log.write(line)
|
# La sortie est déjà à l'écran, ligne par ligne : la réécrire
|
||||||
|
# la doublerait. Seul un échec avant même le démarrage du
|
||||||
|
# processus n'a rien écrit.
|
||||||
|
if not self._ecrites.get(name):
|
||||||
|
log = self.query_one(f"#log_{slug(name)}", RichLog)
|
||||||
|
for line in out.strip().splitlines():
|
||||||
|
log.write(line)
|
||||||
block = self.query_one(f"#{slug(name)}", Collapsible)
|
block = self.query_one(f"#{slug(name)}", Collapsible)
|
||||||
mark = "✅" if rc == 0 else "❌"
|
mark = "✅" if rc == 0 else "❌"
|
||||||
block.title = f"{mark} [{jid}] {name} · {fmt_dur(secs)}" + (
|
block.title = f"{mark} [{jid}] {name} · {fmt_dur(secs)}" + (
|
||||||
|
|
@ -665,6 +762,27 @@ def run_deploy_progress(jobs, parallelism, run_app: bool = True):
|
||||||
block.collapsed = rc == 0
|
block.collapsed = rc == 0
|
||||||
self._refresh_summary()
|
self._refresh_summary()
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
« suspend() » rend le terminal : ssh a besoin du clavier, et
|
||||||
|
Textual le tient encore.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
if not self._reussies:
|
||||||
|
self.notify(t("No VM created yet."), severity="warning")
|
||||||
|
return
|
||||||
|
# La dernière créée : c'est celle qu'on regarde.
|
||||||
|
nom = self._reussies[-1]
|
||||||
|
with self.suspend():
|
||||||
|
os.system(f"ssh {shlex_quote(nom)} || true")
|
||||||
|
|
||||||
# -- presse-papiers (OSC 52 : traverse SSH) --------------------- #
|
# -- presse-papiers (OSC 52 : traverse SSH) --------------------- #
|
||||||
def _copy(self, text, what):
|
def _copy(self, text, what):
|
||||||
payload, cut = clip_payload(text)
|
payload, cut = clip_payload(text)
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,8 @@ from script.todo.deploy_form_lib import (
|
||||||
FREE,
|
FREE,
|
||||||
RES_FIELDS,
|
RES_FIELDS,
|
||||||
SELECT_TO_FIELD,
|
SELECT_TO_FIELD,
|
||||||
|
branch_default,
|
||||||
|
branch_order,
|
||||||
build_vms,
|
build_vms,
|
||||||
disk_note,
|
disk_note,
|
||||||
entry_key,
|
entry_key,
|
||||||
|
|
@ -43,6 +45,11 @@ from script.todo.deploy_form_plan import PlanMixin, preview_screen
|
||||||
# dans un stockage que seul l'hôte connaît, jamais dans /var/lib/libvirt.
|
# dans un stockage que seul l'hôte connaît, jamais dans /var/lib/libvirt.
|
||||||
PAS_D_ORPHELIN = None
|
PAS_D_ORPHELIN = None
|
||||||
|
|
||||||
|
# Dernier choix du sélecteur de pont : il ne désigne pas un pont, il en crée
|
||||||
|
# un. Rapporté — l'écran refusait de déployer « aucun pont sur l'hôte » sans
|
||||||
|
# offrir le moindre moyen d'en avoir un.
|
||||||
|
CREER_PONT = "__creer_pont__"
|
||||||
|
|
||||||
|
|
||||||
def assign_vmids(rows, used, start, ipconfig):
|
def assign_vmids(rows, used, start, ipconfig):
|
||||||
"""Pose un VMID libre et son adresse sur chaque VM À CRÉER.
|
"""Pose un VMID libre et son adresse sur chaque VM À CRÉER.
|
||||||
|
|
@ -81,6 +88,9 @@ def build_spec(vms, existants, form):
|
||||||
"host": form["host"],
|
"host": form["host"],
|
||||||
"storage": form["storage"],
|
"storage": form["storage"],
|
||||||
"bridge": form["bridge"],
|
"bridge": form["bridge"],
|
||||||
|
# Les résolveurs de l'hôte suivent la spec : une VM en adresse fixe
|
||||||
|
# n'a pas de DNS sans eux.
|
||||||
|
"nameservers": form.get("nameservers") or (),
|
||||||
"res_label": form["res_label"],
|
"res_label": form["res_label"],
|
||||||
"vms": [vm for vm in vms if vm["name"] not in connus],
|
"vms": [vm for vm in vms if vm["name"] not in connus],
|
||||||
"existing": [vm["name"] for vm in vms if vm["name"] in connus],
|
"existing": [vm["name"] for vm in vms if vm["name"] in connus],
|
||||||
|
|
@ -118,7 +128,12 @@ def run_proxmox_form(ctx, run_app: bool = True):
|
||||||
catalog = ctx["catalog"]
|
catalog = ctx["catalog"]
|
||||||
noms_pris = ctx["names"]
|
noms_pris = ctx["names"]
|
||||||
vmids_pris = ctx["vmids"]
|
vmids_pris = ctx["vmids"]
|
||||||
branches = ctx.get("branches") or ["master"]
|
# Ordre et défaut partagés avec le formulaire QEMU/KVM : la liste vient
|
||||||
|
# de « git ls-remote », alphabétique, et commençait donc par une branche
|
||||||
|
# de dependabot — proposée par défaut. Rapporté.
|
||||||
|
branches = branch_order(
|
||||||
|
ctx.get("branches") or ["master"], ctx.get("branch_current")
|
||||||
|
)
|
||||||
profiles = ctx.get("install_profiles") or []
|
profiles = ctx.get("install_profiles") or []
|
||||||
stockages = ctx.get("storages") or []
|
stockages = ctx.get("storages") or []
|
||||||
ponts = ctx.get("bridges") or []
|
ponts = ctx.get("bridges") or []
|
||||||
|
|
@ -174,6 +189,8 @@ def run_proxmox_form(ctx, run_app: bool = True):
|
||||||
self._gen = 0
|
self._gen = 0
|
||||||
self._shown_ids = ()
|
self._shown_ids = ()
|
||||||
self._syncing = False
|
self._syncing = False
|
||||||
|
# La liste des ponts GRANDIT : l'écran sait en créer un.
|
||||||
|
self._ponts = list(ponts)
|
||||||
|
|
||||||
# ---------------------------------------------------------------- #
|
# ---------------------------------------------------------------- #
|
||||||
# L'écran
|
# L'écran
|
||||||
|
|
@ -245,7 +262,7 @@ def run_proxmox_form(ctx, run_app: bool = True):
|
||||||
id="f_storage",
|
id="f_storage",
|
||||||
)
|
)
|
||||||
yield Select(
|
yield Select(
|
||||||
[(b, b) for b in ponts],
|
self._choix_ponts(),
|
||||||
value=(
|
value=(
|
||||||
ctx.get("bridge")
|
ctx.get("bridge")
|
||||||
or (ponts[0] if ponts else SELECT_NULL)
|
or (ponts[0] if ponts else SELECT_NULL)
|
||||||
|
|
@ -299,7 +316,9 @@ def run_proxmox_form(ctx, run_app: bool = True):
|
||||||
)
|
)
|
||||||
yield Select(
|
yield Select(
|
||||||
[(b, b) for b in branches],
|
[(b, b) for b in branches],
|
||||||
value=branches[0],
|
value=branch_default(
|
||||||
|
branches, ctx.get("branch_current")
|
||||||
|
),
|
||||||
allow_blank=False,
|
allow_blank=False,
|
||||||
id="f_branch",
|
id="f_branch",
|
||||||
)
|
)
|
||||||
|
|
@ -331,6 +350,57 @@ def run_proxmox_form(ctx, run_app: bool = True):
|
||||||
yield Button(t("Cancel"), id="no")
|
yield Button(t("Cancel"), id="no")
|
||||||
yield Footer()
|
yield Footer()
|
||||||
|
|
||||||
|
def _choix_ponts(self):
|
||||||
|
"""Les ponts de l'hôte, plus « en créer un ».
|
||||||
|
|
||||||
|
L'entrée de création reste offerte même quand des ponts existent :
|
||||||
|
sur un hôte qui n'en a qu'un, sur le LAN, on peut vouloir un
|
||||||
|
réseau interne pour un parc d'essai."""
|
||||||
|
choix = [(b, b) for b in self._ponts]
|
||||||
|
if ctx.get("make_bridge"):
|
||||||
|
nom, cidr = ctx.get("internal_bridge") or ("vmbr0", "")
|
||||||
|
choix.append(
|
||||||
|
(
|
||||||
|
f"➕ {t('create an internal')} {nom} ({cidr}) + NAT",
|
||||||
|
CREER_PONT,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return choix
|
||||||
|
|
||||||
|
def _creer_pont(self) -> None:
|
||||||
|
"""Crée le pont sur l'hôte, dans un FIL : l'appel dure des
|
||||||
|
secondes, et l'écran doit rester vivant pendant ce temps."""
|
||||||
|
self.notify(t("Creating the bridge on the host…"))
|
||||||
|
|
||||||
|
def travail():
|
||||||
|
nom, raison = ctx["make_bridge"]()
|
||||||
|
self.call_from_thread(self._pont_cree, nom, raison)
|
||||||
|
|
||||||
|
self.run_worker(travail, thread=True)
|
||||||
|
|
||||||
|
def _pont_cree(self, nom, raison) -> None:
|
||||||
|
"""Retour du fil. Le sélecteur est remis d'aplomb dans les DEUX
|
||||||
|
cas : laissé sur « créer », il ne désignerait aucun pont."""
|
||||||
|
selecteur = self.query_one("#f_bridge", Select)
|
||||||
|
if not nom:
|
||||||
|
self.notify(
|
||||||
|
f"{t('The bridge did not come up.')} {raison}",
|
||||||
|
severity="error",
|
||||||
|
timeout=12,
|
||||||
|
)
|
||||||
|
selecteur.value = (
|
||||||
|
self._ponts[0] if self._ponts else SELECT_NULL
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if nom not in self._ponts:
|
||||||
|
self._ponts.append(nom)
|
||||||
|
self._syncing = True
|
||||||
|
selecteur.set_options(self._choix_ponts())
|
||||||
|
selecteur.value = nom
|
||||||
|
self._syncing = False
|
||||||
|
self.notify(f"✓ {nom}")
|
||||||
|
self._refresh_after()
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
self._reload_catalog()
|
self._reload_catalog()
|
||||||
self._sync_install_deps()
|
self._sync_install_deps()
|
||||||
|
|
@ -411,7 +481,11 @@ def run_proxmox_form(ctx, run_app: bool = True):
|
||||||
|
|
||||||
def _bridge(self):
|
def _bridge(self):
|
||||||
valeur = self.query_one("#f_bridge", Select).value
|
valeur = self.query_one("#f_bridge", Select).value
|
||||||
return "" if valeur is SELECT_NULL else valeur
|
if valeur is SELECT_NULL or valeur == CREER_PONT:
|
||||||
|
# La sentinelle n'est pas un pont : la rendre ferait déployer
|
||||||
|
# une VM sur « __creer_pont__ ».
|
||||||
|
return ""
|
||||||
|
return valeur
|
||||||
|
|
||||||
def _storage(self):
|
def _storage(self):
|
||||||
valeur = self.query_one("#f_storage", Select).value
|
valeur = self.query_one("#f_storage", Select).value
|
||||||
|
|
@ -609,6 +683,9 @@ def run_proxmox_form(ctx, run_app: bool = True):
|
||||||
event.select
|
event.select
|
||||||
):
|
):
|
||||||
return
|
return
|
||||||
|
if ident == "f_bridge" and event.value == CREER_PONT:
|
||||||
|
self._creer_pont()
|
||||||
|
return
|
||||||
if ident in ("f_storage", "f_bridge"):
|
if ident in ("f_storage", "f_bridge"):
|
||||||
self._refresh_after()
|
self._refresh_after()
|
||||||
return
|
return
|
||||||
|
|
@ -692,6 +769,7 @@ def run_proxmox_form(ctx, run_app: bool = True):
|
||||||
"host": ctx["host"],
|
"host": ctx["host"],
|
||||||
"storage": self._storage(),
|
"storage": self._storage(),
|
||||||
"bridge": self._bridge(),
|
"bridge": self._bridge(),
|
||||||
|
"nameservers": ctx.get("nameservers") or (),
|
||||||
"res_label": res_label(self.profile),
|
"res_label": res_label(self.profile),
|
||||||
"ssh_key": os.path.expanduser(cle) if cle else "",
|
"ssh_key": os.path.expanduser(cle) if cle else "",
|
||||||
"start": self.query_one("#f_start", Checkbox).value,
|
"start": self.query_one("#f_start", Checkbox).value,
|
||||||
|
|
|
||||||
|
|
@ -316,6 +316,16 @@ class ProxmoxMenuMixin:
|
||||||
print(f" ✓ sudo")
|
print(f" ✓ sudo")
|
||||||
host = dict(host, version=version, sudo=prefixe)
|
host = dict(host, version=version, sudo=prefixe)
|
||||||
print(f" ✓ Proxmox VE {version}")
|
print(f" ✓ Proxmox VE {version}")
|
||||||
|
# Le noyau DÉCIDE de ce qui marche : sans le noyau Proxmox, ni module
|
||||||
|
# bridge ni table NAT — donc aucun pont à créer et aucune VM à
|
||||||
|
# démarrer. Vécu sur l'hôte d'essai, où ifupdown2 répondait
|
||||||
|
# « Another instance of this program is already running » au lieu de
|
||||||
|
# « Operation not supported ». On le dit ici, une fois, plutôt que de
|
||||||
|
# laisser chercher.
|
||||||
|
noyau = pve.parse_kernel(out)
|
||||||
|
if noyau and "-pve" not in noyau:
|
||||||
|
print(f" ⚠ {t('Still on the distribution kernel:')} {noyau}")
|
||||||
|
print(f" → {t('Reboot the host: no bridge, no NAT until then.')}")
|
||||||
self._pve_remember_host(host)
|
self._pve_remember_host(host)
|
||||||
return host
|
return host
|
||||||
|
|
||||||
|
|
@ -396,6 +406,69 @@ class ProxmoxMenuMixin:
|
||||||
)
|
)
|
||||||
actives = sum(1 for v in vms if v["status"] == "running")
|
actives = sum(1 for v in vms if v["status"] == "running")
|
||||||
print(f"\n {len(vms)} VM, {actives} {t('running')}")
|
print(f"\n {len(vms)} VM, {actives} {t('running')}")
|
||||||
|
# Le pendant du sous-menu de QEMU/KVM : la liste est le bon endroit
|
||||||
|
# pour agir sur ce qu'on vient de lire.
|
||||||
|
print(f"\n [1] {t('Change the state of one or more VMs')}")
|
||||||
|
if input(t("Choice (blank = back): ")).strip() == "1":
|
||||||
|
self._pve_change_state(vms)
|
||||||
|
|
||||||
|
def _pve_change_state(self, vms=None):
|
||||||
|
"""Démarre ou éteint des VM de l'hôte, avec double validation.
|
||||||
|
|
||||||
|
« shutdown » et non « stop » : on demande à l'invité de s'arrêter, ce
|
||||||
|
qui laisse Odoo fermer ses connexions PostgreSQL. « stop » coupe le
|
||||||
|
courant — il est offert en second, nommé pour ce qu'il est.
|
||||||
|
"""
|
||||||
|
from script.proxmox import proxmox_deploy as pve
|
||||||
|
|
||||||
|
vms = vms if vms is not None else self._pve_vms()
|
||||||
|
if not vms:
|
||||||
|
print(f"\n{t('No VM on this Proxmox host.')}")
|
||||||
|
return
|
||||||
|
print(f"\n{t('Available VMs:')}")
|
||||||
|
for i, vm in enumerate(vms, 1):
|
||||||
|
print(
|
||||||
|
f" [{i}] {vm['vmid']:<7} {vm['name'][:34]:<34} {vm['status']}"
|
||||||
|
)
|
||||||
|
print(f" [all] {t('select all')}")
|
||||||
|
brut = input(t("Selection (numbers, or 'all'): ")).strip().lower()
|
||||||
|
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]
|
||||||
|
if not choisies:
|
||||||
|
print(t("Nothing selected."))
|
||||||
|
return
|
||||||
|
print(
|
||||||
|
f"\n [1] {t('start')} [2] {t('shutdown (clean)')}"
|
||||||
|
f" [3] {t('stop (pulls the plug)')}"
|
||||||
|
)
|
||||||
|
geste = input(t("Choice: ")).strip()
|
||||||
|
verbe = {"1": "start", "2": "shutdown", "3": "stop"}.get(geste)
|
||||||
|
if not verbe:
|
||||||
|
print(t("Cancelled."))
|
||||||
|
return
|
||||||
|
print(
|
||||||
|
f"\n {verbe} : "
|
||||||
|
+ ", ".join(f"{vm['name']} ({vm['vmid']})" for vm in choisies)
|
||||||
|
)
|
||||||
|
if not self._is_yes(input(t("Confirm? (y/N): "))):
|
||||||
|
print(t("Cancelled."))
|
||||||
|
return
|
||||||
|
for vm in choisies:
|
||||||
|
code, _o = self._pve_show(f"qm {verbe} {vm['vmid']}", timeout=300)
|
||||||
|
marque = "✓" if code == 0 else "✗"
|
||||||
|
print(f" {marque} {vm['name']}")
|
||||||
|
# L'état APRÈS : c'est la seule preuve que le geste a porté.
|
||||||
|
_c, out = self._pve_show("qm list", quiet=True)
|
||||||
|
for vm in pve.parse_qm_list(out):
|
||||||
|
if any(vm["vmid"] == c["vmid"] for c in choisies):
|
||||||
|
print(f" {vm['name']:<34} {vm['status']}")
|
||||||
|
|
||||||
def _pve_vm_ip(self):
|
def _pve_vm_ip(self):
|
||||||
"""Adresse d'une VM, par l'agent invité.
|
"""Adresse d'une VM, par l'agent invité.
|
||||||
|
|
@ -561,6 +634,40 @@ class ProxmoxMenuMixin:
|
||||||
)
|
)
|
||||||
return distant if code == 0 else ""
|
return distant if code == 0 else ""
|
||||||
|
|
||||||
|
def _pve_uplink(self):
|
||||||
|
"""Interface qui porte la route par défaut, ou '' — la sortie du NAT.
|
||||||
|
|
||||||
|
Sans elle, le pont interne existe mais ses VM ne voient pas Internet.
|
||||||
|
"""
|
||||||
|
_c, sortie = self._pve_show("ip -o -4 route show default", quiet=True)
|
||||||
|
parts = (sortie or "").split()
|
||||||
|
return parts[parts.index("dev") + 1] if "dev" in parts else ""
|
||||||
|
|
||||||
|
def _pve_make_internal_bridge(self):
|
||||||
|
"""Crée le pont INTERNE et le rend, ou ('', raison). SANS rien demander.
|
||||||
|
|
||||||
|
Appelable depuis l'écran de déploiement, où Textual tient le terminal :
|
||||||
|
aucune invite, aucune sortie imprimée, tout est capturé. C'est possible
|
||||||
|
parce que ce pont ne touche AUCUNE interface physique — il n'y a donc
|
||||||
|
rien à faire arbitrer. Un pont sur le LAN, lui, déplace l'adresse de
|
||||||
|
l'hôte et coupe la session : il reste manuel, et l'écran le dit.
|
||||||
|
"""
|
||||||
|
from script.proxmox import proxmox_deploy as pve
|
||||||
|
|
||||||
|
host = self._pve_host(ask=False)
|
||||||
|
if not host:
|
||||||
|
return "", t("No Proxmox host.")
|
||||||
|
uplink = self._pve_uplink()
|
||||||
|
for cmd in pve.bridge_setup_cmds(uplink=uplink):
|
||||||
|
code, sortie = pve.run(host, cmd, 180)
|
||||||
|
if code:
|
||||||
|
lignes = pve.strip_ssh_noise(sortie).strip().splitlines()
|
||||||
|
return "", (lignes[-1] if lignes else t("Step failed"))
|
||||||
|
_c, out = pve.run(host, "ip -o link show type bridge", 30)
|
||||||
|
if pve.INTERNAL_BRIDGE not in pve.parse_bridges(out):
|
||||||
|
return "", t("The bridge did not come up.")
|
||||||
|
return pve.INTERNAL_BRIDGE, ""
|
||||||
|
|
||||||
def _pve_offer_bridge(self):
|
def _pve_offer_bridge(self):
|
||||||
"""Aucun pont sur l'hôte : en proposer un, sans risquer l'accès.
|
"""Aucun pont sur l'hôte : en proposer un, sans risquer l'accès.
|
||||||
|
|
||||||
|
|
@ -592,11 +699,7 @@ class ProxmoxMenuMixin:
|
||||||
f" ⚠ {t('This moves the host address: do it from a console.')}"
|
f" ⚠ {t('This moves the host address: do it from a console.')}"
|
||||||
)
|
)
|
||||||
return ""
|
return ""
|
||||||
_c, sortie = self._pve_show("ip -o -4 route show default", quiet=True)
|
uplink = self._pve_uplink()
|
||||||
uplink = ""
|
|
||||||
parts = (sortie or "").split()
|
|
||||||
if "dev" in parts:
|
|
||||||
uplink = parts[parts.index("dev") + 1]
|
|
||||||
print(f" {t('uplink for NAT')} : {uplink or t('none')}")
|
print(f" {t('uplink for NAT')} : {uplink or t('none')}")
|
||||||
for cmd in pve.bridge_setup_cmds(uplink=uplink):
|
for cmd in pve.bridge_setup_cmds(uplink=uplink):
|
||||||
code, _o = self._pve_show(cmd, timeout=120)
|
code, _o = self._pve_show(cmd, timeout=120)
|
||||||
|
|
@ -670,9 +773,23 @@ class ProxmoxMenuMixin:
|
||||||
stockages = pve.parse_storages(out)
|
stockages = pve.parse_storages(out)
|
||||||
_c, out = self._pve_show("ip -o link show type bridge", quiet=True)
|
_c, out = self._pve_show("ip -o link show type bridge", quiet=True)
|
||||||
ponts = pve.parse_bridges(out)
|
ponts = pve.parse_bridges(out)
|
||||||
|
if not ponts:
|
||||||
|
# Le terminal est encore à nous : c'est ICI qu'on peut poser la
|
||||||
|
# question. L'écran sait aussi le faire, mais sans pouvoir
|
||||||
|
# expliquer les deux voies ni montrer ce qu'il exécute.
|
||||||
|
if self._pve_offer_bridge():
|
||||||
|
_c, out = self._pve_show(
|
||||||
|
"ip -o link show type bridge", quiet=True
|
||||||
|
)
|
||||||
|
ponts = pve.parse_bridges(out)
|
||||||
_c, cfg = self._pve_show("cat /etc/network/interfaces", quiet=True)
|
_c, cfg = self._pve_show("cat /etc/network/interfaces", quiet=True)
|
||||||
infos = pve.parse_bridge_config(cfg)
|
infos = pve.parse_bridge_config(cfg)
|
||||||
cpu, ram_libre = self._pve_capacity()
|
cpu, ram_libre = self._pve_capacity()
|
||||||
|
# Le DNS de l'hôte, pour les VM en adresse fixe : sans lui elles
|
||||||
|
# routent mais ne résolvent rien, et « apt update » échoue sans que
|
||||||
|
# rien ne l'explique. Mesuré sur la VM d'essai.
|
||||||
|
_c, resolv = self._pve_show(pve.RESOLV_CMD, quiet=True)
|
||||||
|
serveurs_dns = pve.parse_nameservers(resolv)
|
||||||
|
|
||||||
def ipconfig(pont, vmid):
|
def ipconfig(pont, vmid):
|
||||||
return pve.ipconfig_for(infos.get(pont, {}), vmid)
|
return pve.ipconfig_for(infos.get(pont, {}), vmid)
|
||||||
|
|
@ -701,8 +818,15 @@ class ProxmoxMenuMixin:
|
||||||
"bridges": ponts,
|
"bridges": ponts,
|
||||||
"bridge": pve.pick_bridge(ponts),
|
"bridge": pve.pick_bridge(ponts),
|
||||||
"ipconfig": ipconfig,
|
"ipconfig": ipconfig,
|
||||||
|
"nameservers": serveurs_dns,
|
||||||
|
# De quoi créer le pont DEPUIS l'écran, sans invite : le pont
|
||||||
|
# interne ne touche à aucune interface physique.
|
||||||
|
"make_bridge": self._pve_make_internal_bridge,
|
||||||
|
"internal_bridge": (pve.INTERNAL_BRIDGE, pve.INTERNAL_CIDR),
|
||||||
"build_command": build_command,
|
"build_command": build_command,
|
||||||
"branches": self._qemu_branch_list() or ["master"],
|
"branches": self._qemu_branch_list() or ["master"],
|
||||||
|
# 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(),
|
"install_profiles": self._qemu_install_profiles(),
|
||||||
# Même règle qu'en QEMU/KVM : un système peut IMPOSER ce qu'on
|
# 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
|
# installe dessus. Un Proxmox imbriqué recevait sinon ERPLibre et
|
||||||
|
|
@ -767,6 +891,9 @@ class ProxmoxMenuMixin:
|
||||||
"user": spec.get("user") or "erplibre",
|
"user": spec.get("user") or "erplibre",
|
||||||
"start": spec.get("start", True),
|
"start": spec.get("start", True),
|
||||||
"ipconfig": vm.get("ipconfig") or "ip=dhcp",
|
"ipconfig": vm.get("ipconfig") or "ip=dhcp",
|
||||||
|
# Le DNS de l'hôte : « --ipconfig0 » ne le porte pas, et une VM
|
||||||
|
# en adresse fixe se retrouvait sans résolveur.
|
||||||
|
"nameservers": spec.get("nameservers") or (),
|
||||||
}
|
}
|
||||||
if spec.get("sshkey_path"):
|
if spec.get("sshkey_path"):
|
||||||
detail["sshkey_path"] = spec["sshkey_path"]
|
detail["sshkey_path"] = spec["sshkey_path"]
|
||||||
|
|
@ -784,6 +911,17 @@ class ProxmoxMenuMixin:
|
||||||
from script.proxmox import proxmox_deploy as pve
|
from script.proxmox import proxmox_deploy as pve
|
||||||
from script.todo.deploy_form_lib import run_deploy_progress
|
from script.todo.deploy_form_lib import run_deploy_progress
|
||||||
|
|
||||||
|
# Le stockage et le pont AVANT tout : l'écran les vérifie déjà, mais
|
||||||
|
# cette méthode s'appelle aussi d'ailleurs. Sans ce garde-fou, on
|
||||||
|
# téléchargeait 350 Mio d'image pour finir sur « net0: invalid format
|
||||||
|
# - missing key » — vécu sur l'hôte d'essai.
|
||||||
|
for valeur, message in (
|
||||||
|
(spec.get("storage"), t("No storage able to hold a VM disk.")),
|
||||||
|
(spec.get("bridge"), t("No bridge on the host.")),
|
||||||
|
):
|
||||||
|
if not valeur:
|
||||||
|
print(f"\n ✗ {message}")
|
||||||
|
return
|
||||||
if not dry_run and not self._pve_confirm_spec(host, spec):
|
if not dry_run and not self._pve_confirm_spec(host, spec):
|
||||||
print(t("Cancelled."))
|
print(t("Cancelled."))
|
||||||
return
|
return
|
||||||
|
|
@ -793,9 +931,10 @@ class ProxmoxMenuMixin:
|
||||||
spec["sshkey_path"] = "/root/.ssh/erplibre-deploy.pub"
|
spec["sshkey_path"] = "/root/.ssh/erplibre-deploy.pub"
|
||||||
else:
|
else:
|
||||||
print(f" ⚠ {t('SSH key not pushed: password login only.')}")
|
print(f" ⚠ {t('SSH key not pushed: password login only.')}")
|
||||||
travaux = []
|
travaux, commandes = [], {}
|
||||||
for vm in spec["vms"]:
|
for vm in spec["vms"]:
|
||||||
cmds = self._pve_vm_commands(mod, vm, spec)
|
cmds = self._pve_vm_commands(mod, vm, spec)
|
||||||
|
commandes[vm["name"]] = cmds
|
||||||
if dry_run:
|
if dry_run:
|
||||||
print(f"\n── {vm['name']} ({t('VMID')} {vm['vmid']}) ──")
|
print(f"\n── {vm['name']} ({t('VMID')} {vm['vmid']}) ──")
|
||||||
for cmd in cmds:
|
for cmd in cmds:
|
||||||
|
|
@ -821,16 +960,93 @@ class ProxmoxMenuMixin:
|
||||||
print(f" ⏭ {t('already there')} : {', '.join(spec['existing'])}")
|
print(f" ⏭ {t('already there')} : {', '.join(spec['existing'])}")
|
||||||
if not travaux:
|
if not travaux:
|
||||||
return
|
return
|
||||||
|
# Le journal AVANT de lancer : la vue de progression se referme et
|
||||||
|
# emporte tout ce qu'elle montrait. Rapporté — « il manque plein
|
||||||
|
# d'informations qu'il y avait avant, où est le fichier de log ? ».
|
||||||
|
# L'ancienne voie par questions imprimait chaque commande et sa
|
||||||
|
# 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)
|
resultats = run_deploy_progress(travaux, spec.get("parallelism") or 1)
|
||||||
reussies = [nom for nom, code, _o, _d in resultats if code == 0]
|
reussies = [nom for nom, code, _o, _d in resultats if code == 0]
|
||||||
for nom, code, sortie, _duree in resultats:
|
for nom, code, sortie, duree in resultats:
|
||||||
|
chemin = self._pve_write_log(
|
||||||
|
session,
|
||||||
|
nom,
|
||||||
|
spec,
|
||||||
|
commandes.get(nom) or [],
|
||||||
|
code,
|
||||||
|
sortie,
|
||||||
|
duree,
|
||||||
|
)
|
||||||
|
marque = "✓" if code == 0 else "✗"
|
||||||
|
print(f" {marque} {nom} : {chemin}")
|
||||||
if code:
|
if code:
|
||||||
print(f"\n ✗ {nom} : {t('exit code')} {code}")
|
print(f" {t('exit code')} {code}")
|
||||||
print("\n".join(sortie.rstrip().splitlines()[-12:]))
|
# Les dernières lignes à l'écran, le reste dans le journal :
|
||||||
|
# c'est l'échec qu'on veut lire tout de suite.
|
||||||
|
propre = pve.collapse_progress(pve.strip_ssh_noise(sortie))
|
||||||
|
for ligne in propre.rstrip().splitlines()[-12:]:
|
||||||
|
print(f" {ligne}")
|
||||||
if not reussies:
|
if not reussies:
|
||||||
return
|
return
|
||||||
self._pve_after_create(host, spec, reussies, cle_locale)
|
self._pve_after_create(host, spec, reussies, cle_locale)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _pve_log_dir():
|
||||||
|
"""Répertoire de journaux de CE déploiement, créé au besoin.
|
||||||
|
|
||||||
|
Même esprit que ~/.erplibre/qemu-install : une session par
|
||||||
|
déploiement, un fichier par VM. La vue de progression se referme ; le
|
||||||
|
journal reste, et c'est lui qu'on relit quand une étape a cédé."""
|
||||||
|
session = os.path.join(
|
||||||
|
os.path.expanduser("~/.erplibre/proxmox-deploy"),
|
||||||
|
time.strftime("%Y%m%d-%H%M%S"),
|
||||||
|
)
|
||||||
|
os.makedirs(session, exist_ok=True)
|
||||||
|
return session
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _pve_write_log(session, nom, spec, cmds, code, sortie, duree):
|
||||||
|
"""Écrit le journal d'UNE VM et rend son chemin.
|
||||||
|
|
||||||
|
Les commandes AVANT leur sortie : c'est ce qui rend l'étape rejouable
|
||||||
|
à la main, et c'est ainsi que les pannes de ce module ont été
|
||||||
|
diagnostiquées."""
|
||||||
|
from script.proxmox import proxmox_deploy as pve
|
||||||
|
|
||||||
|
chemin = os.path.join(session, f"{nom}.log")
|
||||||
|
hote = (spec.get("host") or {}).get("target", "?")
|
||||||
|
vm = next((v for v in spec.get("vms") or [] if v["name"] == nom), {})
|
||||||
|
entete = [
|
||||||
|
"=" * 64,
|
||||||
|
" ERPLibre — création d'une VM sur Proxmox VE",
|
||||||
|
f" Date : {time.strftime('%Y-%m-%d %H:%M:%S')}",
|
||||||
|
f" VM : {nom} VMID {vm.get('vmid', '?')}",
|
||||||
|
f" Hôte : {hote}",
|
||||||
|
f" Stockage : {spec.get('storage')} "
|
||||||
|
f"{t('bridge')} : {spec.get('bridge')}",
|
||||||
|
f" Adresse : {(vm.get('ipconfig') or '').replace('ip=', '')}",
|
||||||
|
f" Ressources: {vm.get('vcpus', '?')} vCPU "
|
||||||
|
f"{vm.get('ram', '?')} Mo {vm.get('disk', '?')}",
|
||||||
|
"=" * 64,
|
||||||
|
"",
|
||||||
|
"---- commandes ----",
|
||||||
|
]
|
||||||
|
entete += [f" {c}" for c in cmds]
|
||||||
|
propre = pve.collapse_progress(pve.strip_ssh_noise(sortie or ""))
|
||||||
|
entete += ["", "---- sortie ----", propre.rstrip(), ""]
|
||||||
|
entete += [
|
||||||
|
(
|
||||||
|
f"---- fin : code {code}, {duree:.0f} s ----"
|
||||||
|
if isinstance(duree, (int, float))
|
||||||
|
else f"---- fin : code {code} ----"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
with open(chemin, "w", encoding="utf-8") as fh:
|
||||||
|
fh.write("\n".join(entete) + "\n")
|
||||||
|
return chemin
|
||||||
|
|
||||||
def _pve_confirm_spec(self, host, spec):
|
def _pve_confirm_spec(self, host, spec):
|
||||||
"""Récapitulatif puis confirmation, dans le TERMINAL.
|
"""Récapitulatif puis confirmation, dans le TERMINAL.
|
||||||
|
|
||||||
|
|
@ -879,7 +1095,16 @@ class ProxmoxMenuMixin:
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
print(f" ✓ {vm['name']} : {ip}")
|
print(f" ✓ {vm['name']} : {ip}")
|
||||||
if spec.get("add_ssh_config"):
|
# 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
|
||||||
|
# alors qu'une installation est demandée, l'installation suivie ne
|
||||||
|
# pouvait pas entrer — elle est donc écrite quand même, et on le
|
||||||
|
# dit. Sans installation ni suivi, le choix est respecté.
|
||||||
|
besoin = bool(spec.get("install")) or spec.get("monitor", True)
|
||||||
|
if not spec.get("add_ssh_config") and besoin:
|
||||||
|
print(f" → {t('~/.ssh/config written anyway (install)')}")
|
||||||
|
if spec.get("add_ssh_config") or besoin:
|
||||||
self._write_ssh_config_entry(
|
self._write_ssh_config_entry(
|
||||||
vm["name"],
|
vm["name"],
|
||||||
spec.get("user") or "erplibre",
|
spec.get("user") or "erplibre",
|
||||||
|
|
@ -890,23 +1115,64 @@ class ProxmoxMenuMixin:
|
||||||
print(f" ✓ ~/.ssh/config : ssh {vm['name']}")
|
print(f" ✓ ~/.ssh/config : ssh {vm['name']}")
|
||||||
joignables.append(vm)
|
joignables.append(vm)
|
||||||
install = spec.get("install")
|
install = spec.get("install")
|
||||||
if not install or not 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
|
||||||
noms = [vm["name"] for vm in joignables]
|
noms = [vm["name"] for vm in joignables]
|
||||||
print(f" {install.get('label') or ''}")
|
if install:
|
||||||
|
print(f" {install.get('label') or ''}")
|
||||||
# Une commande PAR VM dès qu'elles diffèrent : un Proxmox imbriqué
|
# Une commande PAR VM dès qu'elles diffèrent : un Proxmox imbriqué
|
||||||
# installe son hyperviseur, ses voisines ERPLibre. Une commande
|
# installe son hyperviseur, ses voisines ERPLibre. Une commande
|
||||||
# unique en aurait imposé une aux deux.
|
# unique en aurait imposé une aux deux.
|
||||||
commun = install.get("cmd") or ""
|
commun = (install or {}).get("cmd") or ""
|
||||||
cartes = {
|
cartes = {
|
||||||
vm["name"]: (vm.get("install_cmd") or commun) for vm in joignables
|
vm["name"]: (vm.get("install_cmd") or commun) for vm in joignables
|
||||||
}
|
}
|
||||||
self._qemu_install_erplibre_monitored(
|
finale = cartes if self._qemu_per_vm(cartes, commun) else commun
|
||||||
noms,
|
branche = (install or {}).get("branch") or ""
|
||||||
install.get("branch") or "master",
|
if suivi:
|
||||||
{n: n for n in noms},
|
# Rien à installer ? La commande distante regarde alors la VM
|
||||||
cartes if self._qemu_per_vm(cartes, commun) else commun,
|
# ARRIVER (cloud-init, puis relevé système) : c'est ce que le
|
||||||
)
|
# tableau de bord montre.
|
||||||
|
#
|
||||||
|
# La carte des hôtes suit : sans elle, les colonnes vivantes
|
||||||
|
# (état, durée, écrit/s, RAM, disque) restaient VIDES pour une VM
|
||||||
|
# posée sur un Proxmox distant — elles viennent de virsh, qui ne
|
||||||
|
# connaît pas cet hôte.
|
||||||
|
cartes_pve = {
|
||||||
|
vm["name"]: {
|
||||||
|
"target": host["target"],
|
||||||
|
"sudo": host.get("sudo") or "",
|
||||||
|
"jump": host.get("jump") or "",
|
||||||
|
"vmid": vm.get("vmid"),
|
||||||
|
}
|
||||||
|
for vm in joignables
|
||||||
|
if vm.get("vmid")
|
||||||
|
}
|
||||||
|
self._qemu_install_erplibre_monitored(
|
||||||
|
noms,
|
||||||
|
branche,
|
||||||
|
{n: n for n in noms},
|
||||||
|
finale,
|
||||||
|
pve=cartes_pve,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
# 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})…")
|
||||||
|
for vm in joignables:
|
||||||
|
self._qemu_install_erplibre_vm(
|
||||||
|
vm["name"],
|
||||||
|
cle_locale,
|
||||||
|
branche,
|
||||||
|
pve.ip_from_ipconfig(vm.get("ipconfig") or "") or vm["name"],
|
||||||
|
vm.get("install_cmd") or commun,
|
||||||
|
False,
|
||||||
|
)
|
||||||
|
|
||||||
def _pve_deploy_prompts(self, dry_run=False):
|
def _pve_deploy_prompts(self, dry_run=False):
|
||||||
"""Déploie une VM SUR l'hôte Proxmox choisi, par questions.
|
"""Déploie une VM SUR l'hôte Proxmox choisi, par questions.
|
||||||
|
|
@ -1202,6 +1468,11 @@ class ProxmoxMenuMixin:
|
||||||
{"section": t("Host")},
|
{"section": t("Host")},
|
||||||
{"prompt_description": t("Change the Proxmox host")},
|
{"prompt_description": t("Change the Proxmox host")},
|
||||||
]
|
]
|
||||||
|
# Même extension que le menu QEMU/KVM : ce que todo.json ajoute
|
||||||
|
# s'affiche à la suite et se lance par son numéro.
|
||||||
|
supplement = self.config_file.get_config("proxmox_from_makefile")
|
||||||
|
if supplement:
|
||||||
|
choices.extend(supplement)
|
||||||
help_info = self.fill_help_info(choices)
|
help_info = self.fill_help_info(choices)
|
||||||
while True:
|
while True:
|
||||||
hote = self._pve_host(ask=False)
|
hote = self._pve_host(ask=False)
|
||||||
|
|
@ -1250,7 +1521,18 @@ class ProxmoxMenuMixin:
|
||||||
self._pve_forget_host()
|
self._pve_forget_host()
|
||||||
self._pve_pick_host()
|
self._pve_pick_host()
|
||||||
else:
|
else:
|
||||||
print(t("Command not found !"))
|
introuvable = True
|
||||||
|
try:
|
||||||
|
numero = int(status)
|
||||||
|
# Les sections ne comptent pas dans la numérotation.
|
||||||
|
reelles = [c for c in choices if not c.get("section")]
|
||||||
|
if 0 < numero <= len(reelles):
|
||||||
|
introuvable = False
|
||||||
|
self.execute_from_configuration(reelles[numero - 1])
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
if introuvable:
|
||||||
|
print(t("Command not found !"))
|
||||||
|
|
||||||
def _pve_fetch_image(self):
|
def _pve_fetch_image(self):
|
||||||
"""Télécharge une image cloud SUR l'hôte Proxmox.
|
"""Télécharge une image cloud SUR l'hôte Proxmox.
|
||||||
|
|
|
||||||
|
|
@ -270,6 +270,7 @@ class QemuDeployMixin:
|
||||||
python_provider="",
|
python_provider="",
|
||||||
app_store="deb",
|
app_store="deb",
|
||||||
vm_tools=(),
|
vm_tools=(),
|
||||||
|
pve=None,
|
||||||
):
|
):
|
||||||
"""Lance l'install ERPLibre en parallèle DÉTACHÉE sur les VM et ouvre
|
"""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.
|
le dashboard Textual. Quitter le dashboard n'arrête pas les installs.
|
||||||
|
|
@ -327,6 +328,11 @@ class QemuDeployMixin:
|
||||||
"version": v,
|
"version": v,
|
||||||
"arch": a,
|
"arch": a,
|
||||||
}
|
}
|
||||||
|
# {nom: {target, sudo, vmid}} quand la VM vit sur un hôte
|
||||||
|
# Proxmox : le suivi lui demandera son état, virsh ne le
|
||||||
|
# connaît pas.
|
||||||
|
if (pve or {}).get(name):
|
||||||
|
entry["pve"] = pve[name]
|
||||||
# Les outils imposent une commande PAR VM même quand tout le
|
# Les outils imposent une commande PAR VM même quand tout le
|
||||||
# reste est commun : ils dépendent de l'architecture de la
|
# reste est commun : ils dépendent de l'architecture de la
|
||||||
# machine et de sa saveur de bureau, que seule cette boucle
|
# machine et de sa saveur de bureau, que seule cette boucle
|
||||||
|
|
@ -1248,6 +1254,8 @@ class QemuDeployMixin:
|
||||||
"native": native,
|
"native": native,
|
||||||
"domains": self._qemu_list_domains(),
|
"domains": self._qemu_list_domains(),
|
||||||
"branches": self._qemu_branch_list() or ["master"],
|
"branches": self._qemu_branch_list() or ["master"],
|
||||||
|
# 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(),
|
"install_profiles": self._qemu_install_profiles(),
|
||||||
# Les systèmes qui IMPOSENT ce qu'on installe dessus. Sans cette
|
# Les systèmes qui IMPOSENT ce qu'on installe dessus. Sans cette
|
||||||
# table, le formulaire posait ERPLibre + Odoo 18 sur une VM
|
# table, le formulaire posait ERPLibre + Odoo 18 sur une VM
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,8 @@ from script.todo.deploy_form_lib import ( # noqa: F401
|
||||||
SELECT_TO_FIELD,
|
SELECT_TO_FIELD,
|
||||||
apply_overrides,
|
apply_overrides,
|
||||||
apply_profile,
|
apply_profile,
|
||||||
|
branch_default,
|
||||||
|
branch_order,
|
||||||
build_spec,
|
build_spec,
|
||||||
build_vms,
|
build_vms,
|
||||||
clean_hostname,
|
clean_hostname,
|
||||||
|
|
@ -116,7 +118,9 @@ def run_deploy_form(ctx, run_app: bool = True):
|
||||||
no_erplibre = {
|
no_erplibre = {
|
||||||
impose[1].strip() for impose in distro_profiles.values() if impose
|
impose[1].strip() for impose in distro_profiles.values() if impose
|
||||||
}
|
}
|
||||||
branches = ctx.get("branches") or ["master"]
|
branches = branch_order(
|
||||||
|
ctx.get("branches") or ["master"], ctx.get("branch_current")
|
||||||
|
)
|
||||||
host_cpu = ctx.get("host_cpu") or 2
|
host_cpu = ctx.get("host_cpu") or 2
|
||||||
free_ram = ctx.get("free_ram") or 0
|
free_ram = ctx.get("free_ram") or 0
|
||||||
free_disk = ctx.get("free_disk") or 0
|
free_disk = ctx.get("free_disk") or 0
|
||||||
|
|
@ -332,8 +336,8 @@ def run_deploy_form(ctx, run_app: bool = True):
|
||||||
)
|
)
|
||||||
yield Select(
|
yield Select(
|
||||||
[(b, b) for b in branches],
|
[(b, b) for b in branches],
|
||||||
value=(
|
value=branch_default(
|
||||||
"develop" if "develop" in branches else branches[0]
|
branches, ctx.get("branch_current")
|
||||||
),
|
),
|
||||||
allow_blank=False,
|
allow_blank=False,
|
||||||
id="f_branch",
|
id="f_branch",
|
||||||
|
|
|
||||||
|
|
@ -291,17 +291,19 @@ def launch_installs(vms: list[dict], branch: str, remote_cmd: str) -> str:
|
||||||
vm["name"],
|
vm["name"],
|
||||||
installs=bool(branch),
|
installs=bool(branch),
|
||||||
)
|
)
|
||||||
entries.append(
|
entree = {
|
||||||
{
|
"name": vm["name"],
|
||||||
"name": vm["name"],
|
"ip": vm["ip"],
|
||||||
"ip": vm["ip"],
|
"distro": vm.get("distro"),
|
||||||
"distro": vm.get("distro"),
|
"version": vm.get("version"),
|
||||||
"version": vm.get("version"),
|
"arch": vm.get("arch"),
|
||||||
"arch": vm.get("arch"),
|
"log": log_path,
|
||||||
"log": log_path,
|
"ssh": f"ssh erplibre@{vm['ip']}",
|
||||||
"ssh": f"ssh erplibre@{vm['ip']}",
|
}
|
||||||
}
|
# Une VM posée sur un hôte Proxmox : c'est LUI qui connaît son état.
|
||||||
)
|
if vm.get("pve"):
|
||||||
|
entree["pve"] = vm["pve"]
|
||||||
|
entries.append(entree)
|
||||||
manifest = {
|
manifest = {
|
||||||
"branch": branch,
|
"branch": branch,
|
||||||
"started": time.time(),
|
"started": time.time(),
|
||||||
|
|
@ -1293,6 +1295,105 @@ def read_domstats() -> str:
|
||||||
return res.stdout if res.returncode == 0 else ""
|
return res.stdout if res.returncode == 0 else ""
|
||||||
|
|
||||||
|
|
||||||
|
# UN appel par HÔTE : « /cluster/resources » rend l'état, la mémoire, le
|
||||||
|
# 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.
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
# 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
|
||||||
|
# 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": {}}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_pvestats(text: str) -> dict:
|
||||||
|
"""Sortie de PVE_STATS_CMD -> {nom: relevé}, même forme que domstats.
|
||||||
|
|
||||||
|
Même forme exprès : les colonnes, le débit d'écriture et la RAM se
|
||||||
|
calculent alors sans savoir d'où vient la mesure. Une VM sur un hôte
|
||||||
|
Proxmox distant n'avait aucune de ces colonnes — elles viennent de virsh,
|
||||||
|
qui ne sait rien de cet hôte.
|
||||||
|
"""
|
||||||
|
brut, _, tailles = (text or "").partition("---ERPLIBRE-DU---")
|
||||||
|
try:
|
||||||
|
ressources = json.loads(brut.strip() or "[]")
|
||||||
|
except ValueError:
|
||||||
|
return {}
|
||||||
|
# {vmid: octets} depuis « du -sb /var/lib/vz/images/<vmid>/ ».
|
||||||
|
occupe = {}
|
||||||
|
for ligne in tailles.splitlines():
|
||||||
|
parts = ligne.split()
|
||||||
|
if len(parts) == 2 and parts[0].isdigit():
|
||||||
|
vmid = parts[1].rstrip("/").rsplit("/", 1)[-1]
|
||||||
|
if vmid.isdigit():
|
||||||
|
occupe[int(vmid)] = int(parts[0])
|
||||||
|
out = {}
|
||||||
|
maintenant = time.time()
|
||||||
|
for r in ressources if isinstance(ressources, list) else ():
|
||||||
|
nom = r.get("name")
|
||||||
|
if not nom:
|
||||||
|
continue
|
||||||
|
total = int(r.get("maxdisk") or 0)
|
||||||
|
utilise = int(r.get("disk") or 0) or occupe.get(
|
||||||
|
int(r.get("vmid") or 0), 0
|
||||||
|
)
|
||||||
|
out[nom] = {
|
||||||
|
"ram_used": int(r.get("mem") or 0),
|
||||||
|
"ram_total": int(r.get("maxmem") or 0),
|
||||||
|
# Le relevé vient d'être fait : il n'est pas périmé, et c'est ce
|
||||||
|
# que `ram_pair` vérifie avant d'afficher quoi que ce soit.
|
||||||
|
"ram_at": maintenant,
|
||||||
|
"wr_bytes": int(r.get("diskwrite") or 0),
|
||||||
|
"disk_used": utilise,
|
||||||
|
"disk_total": total,
|
||||||
|
"state": r.get("status") or "",
|
||||||
|
"uptime": int(r.get("uptime") or 0),
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def read_pvestats(vms, now=None) -> dict:
|
||||||
|
"""{nom: relevé} des VM posées sur un hôte Proxmox, ou {}.
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
hotes = {}
|
||||||
|
for vm in vms or ():
|
||||||
|
info = vm.get("pve") or {}
|
||||||
|
if info.get("target"):
|
||||||
|
hotes[(info["target"], info.get("sudo") or "")] = info
|
||||||
|
if not hotes:
|
||||||
|
return {}
|
||||||
|
maintenant = now if now is not None else time.time()
|
||||||
|
if maintenant - _PVE_CACHE["at"] < PVE_STATS_INTERVAL:
|
||||||
|
return dict(_PVE_CACHE["stats"])
|
||||||
|
try:
|
||||||
|
from script.proxmox import proxmox_deploy as pve
|
||||||
|
except ImportError: # pragma: no cover - le module est dans le dépôt
|
||||||
|
return {}
|
||||||
|
stats = {}
|
||||||
|
for (target, sudo), info in hotes.items():
|
||||||
|
code, sortie = pve.run(
|
||||||
|
{"target": target, "sudo": sudo, "jump": info.get("jump", "")},
|
||||||
|
PVE_STATS_CMD,
|
||||||
|
30,
|
||||||
|
)
|
||||||
|
if code == 0:
|
||||||
|
stats.update(parse_pvestats(sortie))
|
||||||
|
_PVE_CACHE.update({"at": maintenant, "stats": stats})
|
||||||
|
return dict(stats)
|
||||||
|
|
||||||
|
|
||||||
def arm_balloon(names) -> None:
|
def arm_balloon(names) -> None:
|
||||||
"""Arme la période de collecte du ballon (5 s) sur chaque VM.
|
"""Arme la période de collecte du ballon (5 s) sur chaque VM.
|
||||||
|
|
||||||
|
|
@ -1931,6 +2032,10 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
||||||
# calcule sur les relevés successifs, donc il faut échantillonner
|
# calcule sur les relevés successifs, donc il faut échantillonner
|
||||||
# à chaque tour (2 s) et non au rythme lent des états.
|
# à chaque tour (2 s) et non au rythme lent des états.
|
||||||
stats = parse_domstats(read_domstats())
|
stats = parse_domstats(read_domstats())
|
||||||
|
# Les VM d'un hôte Proxmox distant : virsh ne les voit pas, leurs
|
||||||
|
# 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 = time.time()
|
||||||
for name, rec in stats.items():
|
for name, rec in stats.items():
|
||||||
self._wrate.add(name, rec["wr_bytes"], now_s)
|
self._wrate.add(name, rec["wr_bytes"], now_s)
|
||||||
|
|
@ -2191,13 +2296,28 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
||||||
states = await asyncio.to_thread(virsh_domstates)
|
states = await asyncio.to_thread(virsh_domstates)
|
||||||
except Exception:
|
except Exception:
|
||||||
return
|
return
|
||||||
|
# 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)
|
||||||
for vm in vms:
|
for vm in vms:
|
||||||
self._domstate[vm["name"]] = states.get(vm["name"], "gone")
|
nom = vm["name"]
|
||||||
|
if vm.get("pve"):
|
||||||
|
releve = distants.get(nom)
|
||||||
|
self._domstate[nom] = PVE_ETATS.get(
|
||||||
|
(releve or {}).get("state"), "gone"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
self._domstate[nom] = states.get(nom, "gone")
|
||||||
# Réarmer la période du ballon sur les VM qui tournent : sans elle
|
# 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
|
# la RAM affichée serait celle du dernier rapport du pilote, et une
|
||||||
# installation redémarre la VM — ce qui remet la période à zéro.
|
# installation redémarre la VM — ce qui remet la période à zéro.
|
||||||
|
# Les VM distantes en sont exclues : « virsh dommemstat » ne les
|
||||||
|
# atteint pas, et l'hôte donne déjà leur mémoire.
|
||||||
vivantes = [
|
vivantes = [
|
||||||
vm["name"] for vm in vms if states.get(vm["name"]) == "running"
|
vm["name"]
|
||||||
|
for vm in vms
|
||||||
|
if not vm.get("pve") and states.get(vm["name"]) == "running"
|
||||||
]
|
]
|
||||||
if vivantes:
|
if vivantes:
|
||||||
await asyncio.to_thread(arm_balloon, vivantes)
|
await asyncio.to_thread(arm_balloon, vivantes)
|
||||||
|
|
@ -2210,7 +2330,10 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
||||||
# appel virsh par VM — celui-ci est déjà le relevé lent.
|
# appel virsh par VM — celui-ci est déjà le relevé lent.
|
||||||
changed = False
|
changed = False
|
||||||
for vm in vms:
|
for vm in vms:
|
||||||
if self._domstate.get(vm["name"]) == "gone":
|
if self._domstate.get(vm["name"]) == "gone" or vm.get("pve"):
|
||||||
|
# Une VM distante n'a pas de bail chez nous : son adresse
|
||||||
|
# est celle que cloud-init a posée, et virsh ne la voit
|
||||||
|
# pas. La chercher rendrait « gone » à chaque tour.
|
||||||
continue
|
continue
|
||||||
ip = await asyncio.to_thread(virsh_ip, vm["name"])
|
ip = await asyncio.to_thread(virsh_ip, vm["name"])
|
||||||
if ip and ip != vm.get("ip"):
|
if ip and ip != vm.get("ip"):
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||||
"""Menu QEMU/KVM : g\u00e9rer les VM existantes.\n\nLe cycle de vie apr\u00e8s la cr\u00e9ation : lister, allumer et \u00e9teindre, r\u00e9gler le\nmat\u00e9riel, redimensionner (et r\u00e9tr\u00e9cir, ce qui demande de traverser le syst\u00e8me\nde fichiers invit\u00e9 par nbd), effacer, nettoyer les restes, retrouver une\nadresse IP, rouvrir le suivi d'une installation.\n\nC'est le fichier qui appelle \u00ab virsh \u00bb le plus souvent : les helpers qui le\nfont (domstate, dumpxml, c_env) vivent donc ici."""
|
"""Menu QEMU/KVM : g\u00e9rer les VM existantes.\n\nLe cycle de vie apr\u00e8s la cr\u00e9ation : lister, allumer et \u00e9teindre, r\u00e9gler le\nmat\u00e9riel, redimensionner (et r\u00e9tr\u00e9cir, ce qui demande de traverser le syst\u00e8me\nde fichiers invit\u00e9 par nbd), effacer, nettoyer les restes, retrouver une\nadresse IP, rouvrir le suivi d'une installation.\n\nC'est le fichier qui appelle \u00ab virsh \u00bb le plus souvent : les helpers qui le\nfont (domstate, dumpxml, c_env) vivent donc ici."""
|
||||||
|
|
||||||
|
import glob
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
|
@ -127,17 +128,25 @@ class QemuManageMixin:
|
||||||
self.execute.exec_command_live(cmd, source_erplibre=False)
|
self.execute.exec_command_live(cmd, source_erplibre=False)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _qemu_dumpxml(name):
|
def _qemu_dumpxml(name, inactive=True):
|
||||||
"""XML PERSISTANT du domaine, ou '' — source de son état matériel.
|
"""XML du domaine, ou '' — source de son état matériel.
|
||||||
|
|
||||||
« --inactive » n'est pas décoratif : sur une VM allumée, « dumpxml »
|
« --inactive » n'est pas décoratif : sur une VM allumée, « dumpxml »
|
||||||
rend la vue VIVANTE, décorée de ce que libvirt a alloué au démarrage
|
rend la vue VIVANTE, décorée de ce que libvirt a alloué au démarrage
|
||||||
(portid du réseau, vnetN, alias). C'est la définition persistante que
|
(portid du réseau, vnetN, alias). C'est la définition persistante que
|
||||||
virt-xml modifie, et c'est donc elle qu'il faut lire.
|
virt-xml modifie, et c'est donc elle qu'il faut lire — d'où le défaut.
|
||||||
|
|
||||||
|
`inactive=False` demande justement la vue vivante : pour SAVOIR CE QUI
|
||||||
|
EST OUVERT, c'est elle qui compte, un disque attaché à chaud n'existant
|
||||||
|
que là.
|
||||||
"""
|
"""
|
||||||
|
argv = ["sudo", "virsh", "dumpxml"]
|
||||||
|
if inactive:
|
||||||
|
argv.append("--inactive")
|
||||||
|
argv.append(name)
|
||||||
try:
|
try:
|
||||||
res = subprocess.run(
|
res = subprocess.run(
|
||||||
["sudo", "virsh", "dumpxml", "--inactive", name],
|
argv,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=20,
|
timeout=20,
|
||||||
|
|
@ -778,6 +787,112 @@ class QemuManageMixin:
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Redimensionnement du disque d'une VM
|
# Redimensionnement du disque d'une VM
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
# « <source file='…'/> » couvre les disques, les cdrom ET les
|
||||||
|
# « <backingStore> » d'une chaîne. Le nvram porte un attribut
|
||||||
|
# « template » : l'attendre sans attribut le manquait, et c'est le
|
||||||
|
# fichier le plus facile à perdre.
|
||||||
|
_RE_SOURCE_FILE = re.compile(r"<source file='([^']+)'")
|
||||||
|
_RE_NVRAM = re.compile(r"<nvram[^>]*>([^<]+)</nvram>")
|
||||||
|
_RE_LIBVIRT_PATH = re.compile(r"""/var/lib/libvirt/[^,\0\s'"]+""")
|
||||||
|
|
||||||
|
def _qemu_referenced_files(self, domains=None):
|
||||||
|
"""{chemin: domaine} — TOUT ce que les domaines référencent.
|
||||||
|
|
||||||
|
L'autorité est libvirt, jamais le nom du fichier. Un domaine renommé
|
||||||
|
garde le nom de fichier d'avant : juger sur le nom faisait passer le
|
||||||
|
disque d'une VM EN MARCHE pour un orphelin. Rapporté sur
|
||||||
|
« erplibre-ubuntu-2404-MIGRATION », renommée depuis
|
||||||
|
« erplibre-ubuntu-2404 » : le nettoyage offrait ses trois fichiers —
|
||||||
|
disque de 63 Go, seed, nvram — au « rm -f ».
|
||||||
|
|
||||||
|
Les deux vues, persistante et vivante, pour la raison dite dans
|
||||||
|
`_qemu_dumpxml`.
|
||||||
|
"""
|
||||||
|
refs = {}
|
||||||
|
for nom in (
|
||||||
|
domains if domains is not None else self._qemu_list_domains()
|
||||||
|
):
|
||||||
|
for inactive in (True, False):
|
||||||
|
xml = self._qemu_dumpxml(nom, inactive=inactive)
|
||||||
|
if not xml:
|
||||||
|
continue
|
||||||
|
trouves = self._RE_SOURCE_FILE.findall(
|
||||||
|
xml
|
||||||
|
) + self._RE_NVRAM.findall(xml)
|
||||||
|
for chemin in trouves:
|
||||||
|
refs.setdefault(chemin.strip(), nom)
|
||||||
|
return refs
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _qemu_files_in_use(cls):
|
||||||
|
"""Chemins de /var/lib/libvirt cités par un processus EN COURS.
|
||||||
|
|
||||||
|
Contrôle INDÉPENDANT de libvirt : un qemu lancé à la main, ou une
|
||||||
|
définition que libvirt aurait perdue, tient son disque ouvert quand
|
||||||
|
même. Devant un « rm -f » de 63 Go, deux sources valent mieux qu'une.
|
||||||
|
Sans privilège : /proc/<pid>/cmdline se lit, et le qemu d'une VM y
|
||||||
|
porte ses disques, son seed et son nvram.
|
||||||
|
"""
|
||||||
|
vus = set()
|
||||||
|
for entree in glob.glob("/proc/[0-9]*/cmdline"):
|
||||||
|
try:
|
||||||
|
with open(entree, "rb") as fh:
|
||||||
|
brut = fh.read().decode("utf-8", "replace")
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
for chemin in cls._RE_LIBVIRT_PATH.findall(brut):
|
||||||
|
# Un chemin qui n'existe pas n'est pas un fichier tenu
|
||||||
|
# ouvert : la ligne de commande d'un processus quelconque
|
||||||
|
# peut contenir n'importe quoi (ce script lui-même y met son
|
||||||
|
# motif). On ne protège que du réel.
|
||||||
|
if os.path.exists(chemin):
|
||||||
|
vus.add(chemin)
|
||||||
|
return vus
|
||||||
|
|
||||||
|
def _qemu_vm_own_files(self, name):
|
||||||
|
"""Fichiers de CETTE VM, et d'elle seule : disques et seed.
|
||||||
|
|
||||||
|
Demandés à libvirt, jamais déduits du nom. Une VM renommée garde le
|
||||||
|
nom de fichier d'avant : « rm <nom>.qcow2 » ne trouvait alors rien et
|
||||||
|
laissait 63 Go derrière lui — le même défaut que dans le nettoyage,
|
||||||
|
pris par l'autre bout.
|
||||||
|
|
||||||
|
Un fichier partagé avec un AUTRE domaine (image de fond d'une chaîne
|
||||||
|
de qcow2) est écarté : l'effacer creverait la voisine. Le nvram aussi,
|
||||||
|
parce que « virsh undefine --nvram » s'en charge déjà.
|
||||||
|
"""
|
||||||
|
miens = set(self._qemu_referenced_files([name]))
|
||||||
|
voisins = set(
|
||||||
|
self._qemu_referenced_files(
|
||||||
|
[d for d in self._qemu_list_domains() if d != name]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return sorted(
|
||||||
|
chemin
|
||||||
|
for chemin in miens - voisins
|
||||||
|
if chemin.startswith("/var/lib/libvirt/")
|
||||||
|
and not chemin.endswith(".fd")
|
||||||
|
)
|
||||||
|
|
||||||
|
def _qemu_split_orphans(self, candidats):
|
||||||
|
"""(orphelins, protégés) — un fichier référencé n'est JAMAIS orphelin.
|
||||||
|
|
||||||
|
`candidats` et le retour sont des (taille, chemin, motif). Les
|
||||||
|
protégés portent, à la place du motif, ce qui les retient.
|
||||||
|
"""
|
||||||
|
refs = self._qemu_referenced_files()
|
||||||
|
ouverts = self._qemu_files_in_use()
|
||||||
|
orphelins, proteges = [], []
|
||||||
|
for taille, chemin, motif in candidats:
|
||||||
|
porteur = refs.get(chemin)
|
||||||
|
if not porteur and chemin in ouverts:
|
||||||
|
porteur = t("a running process")
|
||||||
|
if porteur:
|
||||||
|
proteges.append((taille, chemin, porteur))
|
||||||
|
else:
|
||||||
|
orphelins.append((taille, chemin, motif))
|
||||||
|
return orphelins, proteges
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _qemu_c_env():
|
def _qemu_c_env():
|
||||||
"""Environnement forçant LC_ALL=C : la sortie des outils (virsh,
|
"""Environnement forçant LC_ALL=C : la sortie des outils (virsh,
|
||||||
|
|
@ -1614,10 +1729,10 @@ class QemuManageMixin:
|
||||||
print(t("Cancelled."))
|
print(t("Cancelled."))
|
||||||
return
|
return
|
||||||
|
|
||||||
disk_dir = "/var/lib/libvirt/images"
|
|
||||||
seed_dir = "/var/lib/libvirt/images/iso"
|
|
||||||
for name in chosen:
|
for name in chosen:
|
||||||
q = shlex.quote(name)
|
q = shlex.quote(name)
|
||||||
|
# Les fichiers AVANT l'undefine : après, plus de XML à lire.
|
||||||
|
fichiers = self._qemu_vm_own_files(name) if del_disks else []
|
||||||
# Éteindre si en cours, puis retirer la définition (+ nvram si
|
# Éteindre si en cours, puis retirer la définition (+ nvram si
|
||||||
# UEFI ; repli sans l'option pour les vieilles versions de virsh).
|
# UEFI ; repli sans l'option pour les vieilles versions de virsh).
|
||||||
cmd = (
|
cmd = (
|
||||||
|
|
@ -1625,10 +1740,14 @@ class QemuManageMixin:
|
||||||
f"sudo virsh undefine {q} --nvram 2>/dev/null "
|
f"sudo virsh undefine {q} --nvram 2>/dev/null "
|
||||||
f"|| sudo virsh undefine {q}"
|
f"|| sudo virsh undefine {q}"
|
||||||
)
|
)
|
||||||
if del_disks:
|
if del_disks and fichiers:
|
||||||
disk = shlex.quote(f"{disk_dir}/{name}.qcow2")
|
cmd += "; sudo rm -f " + " ".join(
|
||||||
seed = shlex.quote(f"{seed_dir}/{name}-seed.iso")
|
shlex.quote(f) for f in fichiers
|
||||||
cmd += f"; sudo rm -f {disk} {seed}"
|
)
|
||||||
|
elif del_disks:
|
||||||
|
# Rien à effacer : le dire, plutôt que de laisser croire que
|
||||||
|
# la place a été rendue.
|
||||||
|
print(f" ⚠ {name} : {t('no disk file found for this VM')}")
|
||||||
print(f"\n▶ {name}: {cmd}")
|
print(f"\n▶ {name}: {cmd}")
|
||||||
self.execute.exec_command_live(cmd, source_erplibre=False)
|
self.execute.exec_command_live(cmd, source_erplibre=False)
|
||||||
print(f"\n✅ {t('Deletion done.')}")
|
print(f"\n✅ {t('Deletion done.')}")
|
||||||
|
|
@ -1730,6 +1849,16 @@ class QemuManageMixin:
|
||||||
# Sauvegardes de disque laissées par un redimensionnement (.qcow2.bak).
|
# Sauvegardes de disque laissées par un redimensionnement (.qcow2.bak).
|
||||||
for size, path in self._qemu_find_files(disk_dir, "*.qcow2.bak"):
|
for size, path in self._qemu_find_files(disk_dir, "*.qcow2.bak"):
|
||||||
orphans.append((size, path, t("disk backup (resize)")))
|
orphans.append((size, path, t("disk backup (resize)")))
|
||||||
|
# Le nom d'un fichier ne dit RIEN de son usage : c'est libvirt qui
|
||||||
|
# sait. Une VM renommée garde le nom de fichier d'avant, et le
|
||||||
|
# nettoyage offrait alors son disque de 63 Go au « rm -f » — rapporté.
|
||||||
|
orphans, proteges = self._qemu_split_orphans(orphans)
|
||||||
|
if proteges:
|
||||||
|
print(f"\n{t('Kept (still attached to a VM):')}")
|
||||||
|
for size, path, porteur in sorted(proteges, key=lambda o: -o[0]):
|
||||||
|
print(
|
||||||
|
f" {self._human_size(size):>9} {path}" f" ← {porteur}"
|
||||||
|
)
|
||||||
if orphans:
|
if orphans:
|
||||||
total = sum(o[0] for o in orphans)
|
total = sum(o[0] for o in orphans)
|
||||||
print(f"\n{t('Orphan files:')}")
|
print(f"\n{t('Orphan files:')}")
|
||||||
|
|
@ -1755,10 +1884,15 @@ class QemuManageMixin:
|
||||||
# 3) Doublons d'images nommées par codename (avant /releases/).
|
# 3) Doublons d'images nommées par codename (avant /releases/).
|
||||||
dups = [
|
dups = [
|
||||||
(s, p)
|
(s, p)
|
||||||
for s, p in self._qemu_find_files(
|
for s, p, _m in self._qemu_split_orphans(
|
||||||
seed_dir, "*-server-cloudimg-*.img"
|
[
|
||||||
)
|
(s, p, "")
|
||||||
if not os.path.basename(p).startswith("ubuntu-")
|
for s, p in self._qemu_find_files(
|
||||||
|
seed_dir, "*-server-cloudimg-*.img"
|
||||||
|
)
|
||||||
|
if not os.path.basename(p).startswith("ubuntu-")
|
||||||
|
]
|
||||||
|
)[0]
|
||||||
]
|
]
|
||||||
self._cleanup_delete_files(
|
self._cleanup_delete_files(
|
||||||
t("Stale codename-named Ubuntu images (duplicates):"),
|
t("Stale codename-named Ubuntu images (duplicates):"),
|
||||||
|
|
@ -1770,10 +1904,17 @@ class QemuManageMixin:
|
||||||
# 5) Baux DHCP périmés.
|
# 5) Baux DHCP périmés.
|
||||||
self._cleanup_stale_leases()
|
self._cleanup_stale_leases()
|
||||||
# 6) Tout le cache d'images de base (option lourde : re-téléchargement).
|
# 6) Tout le cache d'images de base (option lourde : re-téléchargement).
|
||||||
|
# Une image de base peut servir de FOND à un disque (backingStore) :
|
||||||
|
# elle est alors référencée, et l'effacer creverait la VM.
|
||||||
cached = [
|
cached = [
|
||||||
(s, p)
|
(s, p)
|
||||||
for s, p in self._qemu_find_files(seed_dir, "*")
|
for s, p, _m in self._qemu_split_orphans(
|
||||||
if not p.endswith("-seed.iso") and not p.endswith(".part")
|
[
|
||||||
|
(s, p, "")
|
||||||
|
for s, p in self._qemu_find_files(seed_dir, "*")
|
||||||
|
if not p.endswith("-seed.iso") and not p.endswith(".part")
|
||||||
|
]
|
||||||
|
)[0]
|
||||||
]
|
]
|
||||||
self._cleanup_delete_files(
|
self._cleanup_delete_files(
|
||||||
t("All cached base images (reusable):"),
|
t("All cached base images (reusable):"),
|
||||||
|
|
@ -1828,18 +1969,74 @@ class QemuManageMixin:
|
||||||
self.execute.exec_command_live(cmd, source_erplibre=False)
|
self.execute.exec_command_live(cmd, source_erplibre=False)
|
||||||
print(f"✅ {t('Cleanup done.')}")
|
print(f"✅ {t('Cleanup done.')}")
|
||||||
|
|
||||||
|
def _ssh_entry_alive(self, content, nom, domains, adresses, distantes):
|
||||||
|
"""Cette entrée mène-t-elle encore quelque part ? (raison, ou '')
|
||||||
|
|
||||||
|
Le NOM ne suffit pas à en juger — c'est le défaut qui a coûté cher :
|
||||||
|
après un renommage de VM, l'alias garde l'ancien nom et se faisait
|
||||||
|
effacer alors qu'il menait à une machine EN MARCHE. Trois autres
|
||||||
|
preuves valent mieux :
|
||||||
|
|
||||||
|
* son adresse est celle d'un domaine vivant ;
|
||||||
|
* elle porte un ProxyJump, donc elle a été écrite pour une VM
|
||||||
|
imbriquée ou distante, que virsh ne connaîtra jamais ;
|
||||||
|
* son nom est celui d'une VM de l'hôte Proxmox retenu.
|
||||||
|
"""
|
||||||
|
if nom in domains:
|
||||||
|
return nom
|
||||||
|
bloc = re.search(
|
||||||
|
rf"(?ms)^[ \t]*Host[ \t]+{re.escape(nom)}[ \t]*\n"
|
||||||
|
r"((?:[ \t]+[^\n]*\n?)*)",
|
||||||
|
content,
|
||||||
|
)
|
||||||
|
corps = bloc.group(1) if bloc else ""
|
||||||
|
ip = re.search(r"(?mi)^[ \t]*HostName[ \t]+(\S+)", corps)
|
||||||
|
if ip and ip.group(1) in adresses:
|
||||||
|
return adresses[ip.group(1)]
|
||||||
|
if re.search(r"(?mi)^[ \t]*ProxyJump[ \t]+\S+", corps):
|
||||||
|
return t("reached through a jump host")
|
||||||
|
if nom in distantes:
|
||||||
|
return t("a VM of the Proxmox host")
|
||||||
|
return ""
|
||||||
|
|
||||||
def _cleanup_ssh_config(self, domains):
|
def _cleanup_ssh_config(self, domains):
|
||||||
"""Retire les blocs « Host erplibre-* » sans VM correspondante (on ne
|
"""Retire les blocs « Host erplibre-* » qui ne mènent plus à rien (on
|
||||||
touche jamais aux autres hôtes SSH personnels)."""
|
ne touche jamais aux autres hôtes SSH personnels)."""
|
||||||
cfg = os.path.expanduser("~/.ssh/config")
|
cfg = os.path.expanduser("~/.ssh/config")
|
||||||
if not os.path.exists(cfg):
|
if not os.path.exists(cfg):
|
||||||
return
|
return
|
||||||
with open(cfg, encoding="utf-8") as fh:
|
with open(cfg, encoding="utf-8") as fh:
|
||||||
content = fh.read()
|
content = fh.read()
|
||||||
|
# Les adresses des domaines vivants, et les VM de l'hôte Proxmox
|
||||||
|
# retenu : deux preuves qu'une entrée sert encore, que le nom ignore.
|
||||||
|
adresses = {}
|
||||||
|
for nom in domains:
|
||||||
|
ip = self._qemu_vm_ip_now(nom)
|
||||||
|
if ip:
|
||||||
|
adresses[ip] = nom
|
||||||
|
distantes = set()
|
||||||
|
try:
|
||||||
|
hote = self._pve_host(ask=False)
|
||||||
|
if hote:
|
||||||
|
distantes = {
|
||||||
|
v["name"] for v in self._pve_vms() if v.get("name")
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
hosts = re.findall(r"(?m)^[ \t]*Host[ \t]+(\S+)", content)
|
hosts = re.findall(r"(?m)^[ \t]*Host[ \t]+(\S+)", content)
|
||||||
orphans = [
|
orphans, gardes = [], []
|
||||||
h for h in hosts if h.startswith("erplibre-") and h not in domains
|
for h in hosts:
|
||||||
]
|
if not h.startswith("erplibre-"):
|
||||||
|
continue
|
||||||
|
raison = self._ssh_entry_alive(
|
||||||
|
content, h, domains, adresses, distantes
|
||||||
|
)
|
||||||
|
(gardes if raison else orphans).append((h, raison))
|
||||||
|
if gardes:
|
||||||
|
print(f"\n{t('Kept (still leads somewhere):')}")
|
||||||
|
for h, raison in gardes:
|
||||||
|
print(f" {h} ← {raison}")
|
||||||
|
orphans = [h for h, _r in orphans]
|
||||||
if not orphans:
|
if not orphans:
|
||||||
return
|
return
|
||||||
print(f"\n{t('Orphan ~/.ssh/config entries:')} {', '.join(orphans)}")
|
print(f"\n{t('Orphan ~/.ssh/config entries:')} {', '.join(orphans)}")
|
||||||
|
|
@ -1954,6 +2151,58 @@ class QemuManageMixin:
|
||||||
except (OSError, subprocess.SubprocessError):
|
except (OSError, subprocess.SubprocessError):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _qemu_host_addresses():
|
||||||
|
"""Adresses IPv4 de L'HÔTE, à écarter des candidates d'une VM.
|
||||||
|
|
||||||
|
« virsh domifaddr --source arp » remonte la table ARP, où figurent les
|
||||||
|
passerelles des ponts libvirt (192.168.122.1, 192.168.123.1…). Une VM
|
||||||
|
n'a jamais l'adresse de son hôte : sans ce filtre, une VM RENOMMÉE —
|
||||||
|
dont le bail porte encore l'ancien nom d'hôte, donc sans
|
||||||
|
correspondance — se voyait attribuer la passerelle. Vécu sur
|
||||||
|
« erplibre-ubuntu-2404-MIGRATION », annoncée en 192.168.122.1 au lieu
|
||||||
|
de 192.168.123.170.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
res = subprocess.run(
|
||||||
|
["ip", "-4", "-o", "addr", "show"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
return set()
|
||||||
|
return set(re.findall(r"inet (\d+\.\d+\.\d+\.\d+)", res.stdout))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _qemu_candidates_by_source(name):
|
||||||
|
"""{source: [ip]} — les candidates SANS mélanger les sources.
|
||||||
|
|
||||||
|
Le bail dit ce que dnsmasq a donné, l'agent ce que la VM voit,
|
||||||
|
l'ARP ce qui a parlé sur le réseau (passerelles comprises). Les
|
||||||
|
garder séparées permet de choisir la plus sûre quand le nom d'hôte
|
||||||
|
ne tranche pas.
|
||||||
|
"""
|
||||||
|
siennes = QemuManageMixin._qemu_host_addresses()
|
||||||
|
out = {}
|
||||||
|
for source in ("lease", "agent", "arp"):
|
||||||
|
try:
|
||||||
|
res = subprocess.run(
|
||||||
|
["sudo", "virsh", "domifaddr", name, "--source", source],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
continue
|
||||||
|
vues = []
|
||||||
|
for ip in re.findall(r"(\d+\.\d+\.\d+\.\d+)", res.stdout):
|
||||||
|
if ip != "127.0.0.1" and ip not in siennes and ip not in vues:
|
||||||
|
vues.append(ip)
|
||||||
|
if vues:
|
||||||
|
out[source] = vues
|
||||||
|
return out
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _qemu_lease_candidates(name):
|
def _qemu_lease_candidates(name):
|
||||||
"""Toutes les IPv4 candidates de la VM, agrégées de PLUSIEURS sources :
|
"""Toutes les IPv4 candidates de la VM, agrégées de PLUSIEURS sources :
|
||||||
|
|
@ -1967,6 +2216,7 @@ class QemuManageMixin:
|
||||||
(cas observé : 30 VM émulées, bail dnsmasq vide alors que la VM a une
|
(cas observé : 30 VM émulées, bail dnsmasq vide alors que la VM a une
|
||||||
IP)."""
|
IP)."""
|
||||||
ips = []
|
ips = []
|
||||||
|
siennes = QemuManageMixin._qemu_host_addresses()
|
||||||
for source in ("lease", "agent", "arp"):
|
for source in ("lease", "agent", "arp"):
|
||||||
try:
|
try:
|
||||||
res = subprocess.run(
|
res = subprocess.run(
|
||||||
|
|
@ -1978,8 +2228,10 @@ class QemuManageMixin:
|
||||||
except (OSError, subprocess.SubprocessError):
|
except (OSError, subprocess.SubprocessError):
|
||||||
continue
|
continue
|
||||||
for ip in re.findall(r"(\d+\.\d+\.\d+\.\d+)", res.stdout):
|
for ip in re.findall(r"(\d+\.\d+\.\d+\.\d+)", res.stdout):
|
||||||
# Ignore la loopback (remontée par --source agent).
|
# Ignore la loopback (remontée par --source agent) et les
|
||||||
if ip != "127.0.0.1" and ip not in ips:
|
# adresses de l'HÔTE (passerelles des ponts, remontées par
|
||||||
|
# --source arp) : une VM n'a jamais celles-là.
|
||||||
|
if ip != "127.0.0.1" and ip not in ips and ip not in siennes:
|
||||||
ips.append(ip)
|
ips.append(ip)
|
||||||
return ips
|
return ips
|
||||||
|
|
||||||
|
|
@ -2042,10 +2294,22 @@ class QemuManageMixin:
|
||||||
une liste — trois VM figeaient le menu une demi-heure. Ici on lit le
|
une liste — trois VM figeaient le menu une demi-heure. Ici on lit le
|
||||||
bail une fois, en préférant celui dont le hostname est le nom de la VM.
|
bail une fois, en préférant celui dont le hostname est le nom de la VM.
|
||||||
"""
|
"""
|
||||||
cands = self._qemu_lease_candidates(name)
|
par_source = self._qemu_candidates_by_source(name)
|
||||||
|
cands = [ip for ips in par_source.values() for ip in ips]
|
||||||
if not cands:
|
if not cands:
|
||||||
return None
|
return None
|
||||||
return self._qemu_lease_ip_for_host(name, cands) or cands[-1]
|
trouve = self._qemu_lease_ip_for_host(name, cands)
|
||||||
|
if trouve:
|
||||||
|
return trouve
|
||||||
|
# Sans correspondance de nom d'hôte — le cas d'une VM RENOMMÉE, dont
|
||||||
|
# le bail porte encore l'ancien nom — on prend la source la plus
|
||||||
|
# sûre : le bail, puis l'agent, puis la table ARP. Celle-ci contient
|
||||||
|
# les passerelles des ponts, et « la dernière candidate » y tombait :
|
||||||
|
# la VM était annoncée en 192.168.122.1.
|
||||||
|
for source in ("lease", "agent", "arp"):
|
||||||
|
if par_source.get(source):
|
||||||
|
return par_source[source][-1]
|
||||||
|
return cands[-1]
|
||||||
|
|
||||||
def _qemu_vm_ip(self, name, timeout=600):
|
def _qemu_vm_ip(self, name, timeout=600):
|
||||||
"""IPv4 utilisable d'une VM. Gère le cas des baux multiples (hostname
|
"""IPv4 utilisable d'une VM. Gère le cas des baux multiples (hostname
|
||||||
|
|
@ -2163,6 +2427,24 @@ class QemuManageMixin:
|
||||||
pass
|
pass
|
||||||
return None, None, arch
|
return None, None, arch
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _qemu_repo_branch():
|
||||||
|
"""Branche du dépôt COURANT, ou '' — le défaut des formulaires.
|
||||||
|
|
||||||
|
On déploie le plus souvent ce qu'on a sous les yeux ; le premier nom
|
||||||
|
de la liste alphabétique, lui, n'a aucune raison d'être bon."""
|
||||||
|
try:
|
||||||
|
res = subprocess.run(
|
||||||
|
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
return ""
|
||||||
|
nom = (res.stdout or "").strip()
|
||||||
|
return "" if res.returncode or nom == "HEAD" else nom
|
||||||
|
|
||||||
def _qemu_branch_list(self):
|
def _qemu_branch_list(self):
|
||||||
"""Branches distantes d'ERPLibre, triées. Vide si le réseau manque.
|
"""Branches distantes d'ERPLibre, triées. Vide si le réseau manque.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -608,6 +608,7 @@ class TODO(
|
||||||
"prompt_execute_deploy": "Deploy",
|
"prompt_execute_deploy": "Deploy",
|
||||||
"prompt_execute_deploy_ssh": "SSH",
|
"prompt_execute_deploy_ssh": "SSH",
|
||||||
"prompt_execute_qemu": "QEMU/KVM",
|
"prompt_execute_qemu": "QEMU/KVM",
|
||||||
|
"prompt_execute_proxmox": "Proxmox VE",
|
||||||
"prompt_configuration": "Configuration",
|
"prompt_configuration": "Configuration",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1756,6 +1756,10 @@ TRANSLATIONS = {
|
||||||
"fr": "Rien de désinstallé.",
|
"fr": "Rien de désinstallé.",
|
||||||
"en": "Nothing uninstalled.",
|
"en": "Nothing uninstalled.",
|
||||||
},
|
},
|
||||||
|
"Retrying alone:": {
|
||||||
|
"fr": "Reprise seul :",
|
||||||
|
"en": "Retrying alone:",
|
||||||
|
},
|
||||||
"The uninstall did not take:": {
|
"The uninstall did not take:": {
|
||||||
"fr": "La désinstallation n'a pas pris :",
|
"fr": "La désinstallation n'a pas pris :",
|
||||||
"en": "The uninstall did not take:",
|
"en": "The uninstall did not take:",
|
||||||
|
|
@ -2290,6 +2294,18 @@ TRANSLATIONS = {
|
||||||
"fr": "Aucun affichage ici ; à lancer sur VOTRE poste :",
|
"fr": "Aucun affichage ici ; à lancer sur VOTRE poste :",
|
||||||
"en": "No display here; run this on YOUR workstation:",
|
"en": "No display here; run this on YOUR workstation:",
|
||||||
},
|
},
|
||||||
|
"~/.ssh/config written anyway (install)": {
|
||||||
|
"fr": "entrée ~/.ssh/config écrite quand même : l'installation passe par le rebond",
|
||||||
|
"en": "~/.ssh/config written anyway: the install needs the jump",
|
||||||
|
},
|
||||||
|
"Still on the distribution kernel:": {
|
||||||
|
"fr": "Encore sur le noyau de la distribution :",
|
||||||
|
"en": "Still on the distribution kernel:",
|
||||||
|
},
|
||||||
|
"Reboot the host: no bridge, no NAT until then.": {
|
||||||
|
"fr": "Redémarrer l'hôte : ni pont ni NAT avant cela.",
|
||||||
|
"en": "Reboot the host: no bridge, no NAT until then.",
|
||||||
|
},
|
||||||
"A ~/.ssh/config alias works there too.": {
|
"A ~/.ssh/config alias works there too.": {
|
||||||
"fr": "Un alias de ~/.ssh/config y fonctionne aussi.",
|
"fr": "Un alias de ~/.ssh/config y fonctionne aussi.",
|
||||||
"en": "A ~/.ssh/config alias works there too.",
|
"en": "A ~/.ssh/config alias works there too.",
|
||||||
|
|
@ -3531,7 +3547,10 @@ TRANSLATIONS = {
|
||||||
"fr": "« qm create » en exige un. Deux voies :",
|
"fr": "« qm create » en exige un. Deux voies :",
|
||||||
"en": "qm create needs one. Two ways:",
|
"en": "qm create needs one. Two ways:",
|
||||||
},
|
},
|
||||||
"create an internal": {"fr": "créer un pont interne", "en": "create an internal"},
|
"create an internal": {
|
||||||
|
"fr": "créer un pont interne",
|
||||||
|
"en": "create an internal",
|
||||||
|
},
|
||||||
"touches no physical NIC": {
|
"touches no physical NIC": {
|
||||||
"fr": "ne touche à aucune interface physique",
|
"fr": "ne touche à aucune interface physique",
|
||||||
"en": "touches no physical NIC",
|
"en": "touches no physical NIC",
|
||||||
|
|
@ -3967,6 +3986,53 @@ TRANSLATIONS = {
|
||||||
"fr": "Fichiers orphelins :",
|
"fr": "Fichiers orphelins :",
|
||||||
"en": "Orphan files:",
|
"en": "Orphan files:",
|
||||||
},
|
},
|
||||||
|
"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",
|
||||||
|
},
|
||||||
|
"No VM created yet.": {
|
||||||
|
"fr": "Aucune VM créée pour l'instant.",
|
||||||
|
"en": "No VM created yet.",
|
||||||
|
},
|
||||||
|
# « models » manquait alors que ses trois voisines de la même ligne
|
||||||
|
# étaient traduites : le rapport de qualité de migration sortait
|
||||||
|
# « 42 modules · 812 models · 1 204 vues ».
|
||||||
|
"models": {
|
||||||
|
"fr": "modèles",
|
||||||
|
"en": "models",
|
||||||
|
},
|
||||||
|
"shutdown (clean)": {
|
||||||
|
"fr": "éteindre (proprement)",
|
||||||
|
"en": "shutdown (clean)",
|
||||||
|
},
|
||||||
|
"stop (pulls the plug)": {
|
||||||
|
"fr": "arrêter (coupe le courant)",
|
||||||
|
"en": "stop (pulls the plug)",
|
||||||
|
},
|
||||||
|
"Choice (blank = back): ": {
|
||||||
|
"fr": "Choix (vide = retour) : ",
|
||||||
|
"en": "Choice (blank = back): ",
|
||||||
|
},
|
||||||
|
"Confirm? (y/N): ": {
|
||||||
|
"fr": "Confirmer ? (o/N) : ",
|
||||||
|
"en": "Confirm? (y/N): ",
|
||||||
|
},
|
||||||
|
"Creating the bridge on the host…": {
|
||||||
|
"fr": "Création du pont sur l'hôte…",
|
||||||
|
"en": "Creating the bridge on the host…",
|
||||||
|
},
|
||||||
|
"no disk file found for this VM": {
|
||||||
|
"fr": "aucun fichier de disque trouvé pour cette VM",
|
||||||
|
"en": "no disk file found for this VM",
|
||||||
|
},
|
||||||
|
"a running process": {
|
||||||
|
"fr": "un processus en cours",
|
||||||
|
"en": "a running process",
|
||||||
|
},
|
||||||
|
"Kept (still attached to a VM):": {
|
||||||
|
"fr": "Conservés (encore attachés à une VM) :",
|
||||||
|
"en": "Kept (still attached to a VM):",
|
||||||
|
},
|
||||||
"Total:": {
|
"Total:": {
|
||||||
"fr": "Total :",
|
"fr": "Total :",
|
||||||
"en": "Total:",
|
"en": "Total:",
|
||||||
|
|
@ -4019,6 +4085,18 @@ TRANSLATIONS = {
|
||||||
"fr": "Entrées ~/.ssh/config orphelines :",
|
"fr": "Entrées ~/.ssh/config orphelines :",
|
||||||
"en": "Orphan ~/.ssh/config entries:",
|
"en": "Orphan ~/.ssh/config entries:",
|
||||||
},
|
},
|
||||||
|
"Kept (still leads somewhere):": {
|
||||||
|
"fr": "Conservées (mènent encore quelque part) :",
|
||||||
|
"en": "Kept (still leads somewhere):",
|
||||||
|
},
|
||||||
|
"reached through a jump host": {
|
||||||
|
"fr": "joignable par un rebond",
|
||||||
|
"en": "reached through a jump host",
|
||||||
|
},
|
||||||
|
"a VM of the Proxmox host": {
|
||||||
|
"fr": "une VM de l'hôte Proxmox",
|
||||||
|
"en": "a VM of the Proxmox host",
|
||||||
|
},
|
||||||
"Remove these ~/.ssh/config entries? (y/N): ": {
|
"Remove these ~/.ssh/config entries? (y/N): ": {
|
||||||
"fr": "Retirer ces entrées de ~/.ssh/config ? (o/N, défaut : non) : ",
|
"fr": "Retirer ces entrées de ~/.ssh/config ? (o/N, défaut : non) : ",
|
||||||
"en": "Remove these ~/.ssh/config entries? (y/N, default: no): ",
|
"en": "Remove these ~/.ssh/config entries? (y/N, default: no): ",
|
||||||
|
|
@ -6699,6 +6777,146 @@ TRANSLATIONS = {
|
||||||
"fr": "🔗 Dépendances entre les modules",
|
"fr": "🔗 Dépendances entre les modules",
|
||||||
"en": "🔗 Dependencies between modules",
|
"en": "🔗 Dependencies between modules",
|
||||||
},
|
},
|
||||||
|
"Website copies that can no longer render": {
|
||||||
|
"fr": "Copies de site qui ne savent plus se rendre",
|
||||||
|
"en": "Website copies that can no longer render",
|
||||||
|
},
|
||||||
|
"Every website copy still renders.": {
|
||||||
|
"fr": "Chaque copie de site sait encore se rendre.",
|
||||||
|
"en": "Every website copy still renders.",
|
||||||
|
},
|
||||||
|
"No duplicate index.": {
|
||||||
|
"fr": "Aucun index en double.",
|
||||||
|
"en": "No duplicate index.",
|
||||||
|
},
|
||||||
|
"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",
|
||||||
|
},
|
||||||
|
"Configuration records a migration never recreates": {
|
||||||
|
"fr": "Réglages qu'une migration ne recrée jamais",
|
||||||
|
"en": "Configuration records a migration never recreates",
|
||||||
|
},
|
||||||
|
"Every default configuration record is there.": {
|
||||||
|
"fr": "Chaque réglage par défaut est en place.",
|
||||||
|
"en": "Every default configuration record is there.",
|
||||||
|
},
|
||||||
|
"No pricelist, and the pricelist feature is on.": {
|
||||||
|
"fr": "Aucune liste de prix, alors que la fonctionnalité est active.",
|
||||||
|
"en": "No pricelist, and the pricelist feature is on.",
|
||||||
|
},
|
||||||
|
"Every quotation opens without one: raw price, no rule.": {
|
||||||
|
"fr": "Tout devis s'ouvre sans liste : prix brut, aucune règle.",
|
||||||
|
"en": "Every quotation opens without one: raw price, no rule.",
|
||||||
|
},
|
||||||
|
"No bank reconciliation model, for": {
|
||||||
|
"fr": "Aucun modèle de rapprochement bancaire, pour",
|
||||||
|
"en": "No bank reconciliation model, for",
|
||||||
|
},
|
||||||
|
"cash/bank journal(s).": {
|
||||||
|
"fr": "journal(aux) de trésorerie.",
|
||||||
|
"en": "cash/bank journal(s).",
|
||||||
|
},
|
||||||
|
"Every statement line is matched by hand.": {
|
||||||
|
"fr": "Chaque ligne de relevé se lettre à la main.",
|
||||||
|
"en": "Every statement line is matched by hand.",
|
||||||
|
},
|
||||||
|
"Use --apply to let Odoo recreate them.": {
|
||||||
|
"fr": "Utiliser --apply pour laisser Odoo les recréer.",
|
||||||
|
"en": "Use --apply to let Odoo recreate them.",
|
||||||
|
},
|
||||||
|
"pricelists:": {
|
||||||
|
"fr": "listes de prix :",
|
||||||
|
"en": "pricelists:",
|
||||||
|
},
|
||||||
|
"reconcile models:": {
|
||||||
|
"fr": "modèles de rapprochement :",
|
||||||
|
"en": "reconcile models:",
|
||||||
|
},
|
||||||
|
"Odoo recreated them.": {
|
||||||
|
"fr": "Odoo les a recréés.",
|
||||||
|
"en": "Odoo recreated them.",
|
||||||
|
},
|
||||||
|
"Still missing after the repair.": {
|
||||||
|
"fr": "Il en manque encore après la réparation :",
|
||||||
|
"en": "Still missing after the repair.",
|
||||||
|
},
|
||||||
|
"duplicate index pair(s)": {
|
||||||
|
"fr": "paire(s) d'index en double",
|
||||||
|
"en": "duplicate index pair(s)",
|
||||||
|
},
|
||||||
|
"safe to drop": {
|
||||||
|
"fr": "sans risque à supprimer",
|
||||||
|
"en": "safe to drop",
|
||||||
|
},
|
||||||
|
"drop": {
|
||||||
|
"fr": "supprimer",
|
||||||
|
"en": "drop",
|
||||||
|
},
|
||||||
|
"backed by a constraint — left alone": {
|
||||||
|
"fr": "adossée(s) à une contrainte — laissée(s) en place",
|
||||||
|
"en": "backed by a constraint — left alone",
|
||||||
|
},
|
||||||
|
"neither name follows the convention — your call": {
|
||||||
|
"fr": "sans convention reconnaissable — à vous de juger",
|
||||||
|
"en": "neither name follows the convention — your call",
|
||||||
|
},
|
||||||
|
"Use --apply to drop them.": {
|
||||||
|
"fr": "Utiliser --apply pour les supprimer.",
|
||||||
|
"en": "Use --apply to drop them.",
|
||||||
|
},
|
||||||
|
"Duplicate indexes dropped.": {
|
||||||
|
"fr": "Index en double supprimés.",
|
||||||
|
"en": "Duplicate indexes dropped.",
|
||||||
|
},
|
||||||
|
"Some duplicates are still there.": {
|
||||||
|
"fr": "Des doublons subsistent.",
|
||||||
|
"en": "Some duplicates are still there.",
|
||||||
|
},
|
||||||
|
"website COW view(s) will survive the bump and then fail": {
|
||||||
|
"fr": "vue(s) COW de site passeront le palier puis échoueront",
|
||||||
|
"en": "website COW view(s) will survive the bump and then fail",
|
||||||
|
},
|
||||||
|
"to render:": {
|
||||||
|
"fr": "à se rendre :",
|
||||||
|
"en": "to render:",
|
||||||
|
},
|
||||||
|
"Repair rather than neutralize — the copy holds a page": {
|
||||||
|
"fr": "Réparer plutôt que neutraliser — la copie porte une page",
|
||||||
|
"en": "Repair rather than neutralize — the copy holds a page",
|
||||||
|
},
|
||||||
|
"someone wrote:": {
|
||||||
|
"fr": "écrite par quelqu'un :",
|
||||||
|
"en": "someone wrote:",
|
||||||
|
},
|
||||||
|
"Every website copy renders again.": {
|
||||||
|
"fr": "Chaque copie de site sait de nouveau se rendre.",
|
||||||
|
"en": "Every website copy renders again.",
|
||||||
|
},
|
||||||
|
"Some copies still cannot render.": {
|
||||||
|
"fr": "Des copies ne savent toujours pas se rendre.",
|
||||||
|
"en": "Some copies still cannot render.",
|
||||||
|
},
|
||||||
|
"anchor missing for view": {
|
||||||
|
"fr": "ancrage absent pour la vue",
|
||||||
|
"en": "anchor missing for view",
|
||||||
|
},
|
||||||
|
"calls a template that is gone:": {
|
||||||
|
"fr": "appelle un gabarit disparu :",
|
||||||
|
"en": "calls a template that is gone:",
|
||||||
|
},
|
||||||
|
"repair(s) available with --apply": {
|
||||||
|
"fr": "réparation(s) possible(s) avec --apply",
|
||||||
|
"en": "repair(s) available with --apply",
|
||||||
|
},
|
||||||
|
"cannot be repaired automatically": {
|
||||||
|
"fr": "ne se réparent pas toutes seules",
|
||||||
|
"en": "cannot be repaired automatically",
|
||||||
|
},
|
||||||
|
"Removing a call removes its block from the page.": {
|
||||||
|
"fr": "Retirer un appel retire son bloc de la page.",
|
||||||
|
"en": "Removing a call removes its block from the page.",
|
||||||
|
},
|
||||||
"modules installed": {
|
"modules installed": {
|
||||||
"fr": "modules installés",
|
"fr": "modules installés",
|
||||||
"en": "modules installed",
|
"en": "modules installed",
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import asyncio
|
||||||
import glob
|
import glob
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
|
|
@ -180,8 +181,7 @@ def _dispatch(func) -> dict:
|
||||||
kwargs = {
|
kwargs = {
|
||||||
kw.arg: kw.value.value
|
kw.arg: kw.value.value
|
||||||
for kw in n.keywords
|
for kw in n.keywords
|
||||||
if kw.arg
|
if kw.arg and isinstance(kw.value, ast.Constant)
|
||||||
and isinstance(kw.value, ast.Constant)
|
|
||||||
}
|
}
|
||||||
found = (n.func.attr, kwargs)
|
found = (n.func.attr, kwargs)
|
||||||
break
|
break
|
||||||
|
|
@ -311,6 +311,7 @@ def _choices_children(func, todo_dir):
|
||||||
liste de (label, méthode, kwargs) dans l'ordre affiché, ou None si le motif
|
liste de (label, méthode, kwargs) dans l'ordre affiché, ou None si le motif
|
||||||
ne s'applique pas. Les entrées de CONFIG rejouent via
|
ne s'applique pas. Les entrées de CONFIG rejouent via
|
||||||
execute_from_configuration ; les entrées APPENDÉES via leur méthode."""
|
execute_from_configuration ; les entrées APPENDÉES via leur méthode."""
|
||||||
|
|
||||||
def _dict_label(dnode):
|
def _dict_label(dnode):
|
||||||
d = {}
|
d = {}
|
||||||
for k, v in zip(dnode.keys, dnode.values):
|
for k, v in zip(dnode.keys, dnode.values):
|
||||||
|
|
@ -361,9 +362,9 @@ def _choices_children(func, todo_dir):
|
||||||
dnode = (
|
dnode = (
|
||||||
arg
|
arg
|
||||||
if isinstance(arg, ast.Dict)
|
if isinstance(arg, ast.Dict)
|
||||||
else var_dict.get(arg.id)
|
else (
|
||||||
if isinstance(arg, ast.Name)
|
var_dict.get(arg.id) if isinstance(arg, ast.Name) else None
|
||||||
else None
|
)
|
||||||
)
|
)
|
||||||
if dnode is not None:
|
if dnode is not None:
|
||||||
lab = _dict_label(dnode)
|
lab = _dict_label(dnode)
|
||||||
|
|
@ -378,7 +379,9 @@ def _choices_children(func, todo_dir):
|
||||||
or entry.get("prompt_description")
|
or entry.get("prompt_description")
|
||||||
or "?"
|
or "?"
|
||||||
)
|
)
|
||||||
children.append((lab, "execute_from_configuration", {"instance": entry}))
|
children.append(
|
||||||
|
(lab, "execute_from_configuration", {"instance": entry})
|
||||||
|
)
|
||||||
len_disp = _dispatch_len(func)
|
len_disp = _dispatch_len(func)
|
||||||
n_config = len(children) # figé : `children` grossit dans la boucle
|
n_config = len(children) # figé : `children` grossit dans la boucle
|
||||||
n_total = n_config + len(appended)
|
n_total = n_config + len(appended)
|
||||||
|
|
@ -389,6 +392,28 @@ def _choices_children(func, todo_dir):
|
||||||
return children
|
return children
|
||||||
|
|
||||||
|
|
||||||
|
def _mixin_files(todo_py: Path) -> list:
|
||||||
|
"""Les fichiers des mixins que todo.py assemble sur la classe TODO.
|
||||||
|
|
||||||
|
Lus dans ses propres imports « from script.todo.X import YMixin » : la
|
||||||
|
liste suit le code au lieu d'être recopiée ici, et un mixin ajouté demain
|
||||||
|
apparaîtra sans qu'on y pense.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
source = todo_py.read_text(encoding="utf-8")
|
||||||
|
except OSError:
|
||||||
|
return []
|
||||||
|
noms = re.findall(
|
||||||
|
r"^from script\.todo\.(\w+) import \w*Mixin", source, re.MULTILINE
|
||||||
|
)
|
||||||
|
fichiers = []
|
||||||
|
for nom in noms:
|
||||||
|
chemin = todo_py.parent / f"{nom}.py"
|
||||||
|
if chemin.exists():
|
||||||
|
fichiers.append(chemin)
|
||||||
|
return fichiers
|
||||||
|
|
||||||
|
|
||||||
def build_code_tree(todo_path=None) -> dict | None:
|
def build_code_tree(todo_path=None) -> dict | None:
|
||||||
"""Construit l'arbre des menus/commandes EN LISANT le code de todo.py
|
"""Construit l'arbre des menus/commandes EN LISANT le code de todo.py
|
||||||
(AST). Chaque menu (méthode de _MENU_LABELS) devient un nœud ; ses branches
|
(AST). Chaque menu (méthode de _MENU_LABELS) devient un nœud ; ses branches
|
||||||
|
|
@ -400,14 +425,25 @@ def build_code_tree(todo_path=None) -> dict | None:
|
||||||
mod = ast.parse(p.read_text(encoding="utf-8"))
|
mod = ast.parse(p.read_text(encoding="utf-8"))
|
||||||
except (OSError, SyntaxError):
|
except (OSError, SyntaxError):
|
||||||
return None
|
return None
|
||||||
cls = next(
|
cls = next((n for n in ast.walk(mod) if isinstance(n, ast.ClassDef)), None)
|
||||||
(n for n in ast.walk(mod) if isinstance(n, ast.ClassDef)), None
|
|
||||||
)
|
|
||||||
if cls is None:
|
if cls is None:
|
||||||
return None
|
return None
|
||||||
methods = {
|
methods = {n.name: n for n in cls.body if isinstance(n, ast.FunctionDef)}
|
||||||
n.name: n for n in cls.body if isinstance(n, ast.FunctionDef)
|
# ET les mixins. Ce lecteur est STATIQUE : il ne voit pas la classe
|
||||||
}
|
# assemblée, seulement le fichier qu'on lui donne. Depuis que les menus
|
||||||
|
# QEMU/KVM et Proxmox vivent dans des mixins, leur colonne avait disparu
|
||||||
|
# de cet écran — la commande s'exécutait toujours, mais on ne pouvait plus
|
||||||
|
# la lancer d'ici ni la lire. Les fichiers sont ceux que todo.py importe,
|
||||||
|
# donc rien à tenir à jour à la main.
|
||||||
|
for voisin in _mixin_files(p):
|
||||||
|
try:
|
||||||
|
frere = ast.parse(voisin.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, SyntaxError):
|
||||||
|
continue
|
||||||
|
for autre in (n for n in frere.body if isinstance(n, ast.ClassDef)):
|
||||||
|
for membre in autre.body:
|
||||||
|
if isinstance(membre, ast.FunctionDef):
|
||||||
|
methods.setdefault(membre.name, membre)
|
||||||
labels = _menu_labels(cls)
|
labels = _menu_labels(cls)
|
||||||
if "run" not in methods or not labels:
|
if "run" not in methods or not labels:
|
||||||
return None
|
return None
|
||||||
|
|
@ -431,9 +467,7 @@ def build_code_tree(todo_path=None) -> dict | None:
|
||||||
node["children"].append(build(target))
|
node["children"].append(build(target))
|
||||||
else: # commande (feuille) exécutable
|
else: # commande (feuille) exécutable
|
||||||
entry = (
|
entry = (
|
||||||
centries[num - 1]
|
centries[num - 1] if 0 <= num - 1 < len(centries) else None
|
||||||
if 0 <= num - 1 < len(centries)
|
|
||||||
else None
|
|
||||||
)
|
)
|
||||||
lab = (
|
lab = (
|
||||||
entry["label"]
|
entry["label"]
|
||||||
|
|
@ -629,7 +663,10 @@ def system_snapshot(prev, full=True):
|
||||||
m = {}
|
m = {}
|
||||||
if cpu and pcpu and cpu[1] > pcpu[1]:
|
if cpu and pcpu and cpu[1] > pcpu[1]:
|
||||||
m["cpu"] = max(
|
m["cpu"] = max(
|
||||||
0, min(100, round(100 * (1 - (cpu[0] - pcpu[0]) / (cpu[1] - pcpu[1]))))
|
0,
|
||||||
|
min(
|
||||||
|
100, round(100 * (1 - (cpu[0] - pcpu[0]) / (cpu[1] - pcpu[1])))
|
||||||
|
),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
m["cpu"] = None
|
m["cpu"] = None
|
||||||
|
|
@ -733,7 +770,8 @@ def run_tui(run_app: bool = True, state: dict | None = None):
|
||||||
"""TUI de télémétrie : vue Arbre (issue du code) et vue Kanban (F3), la
|
"""TUI de télémétrie : vue Arbre (issue du code) et vue Kanban (F3), la
|
||||||
disposition du Kanban défilant par F4 (colonnes / swimlanes / grille).
|
disposition du Kanban défilant par F4 (colonnes / swimlanes / grille).
|
||||||
Sélectionner une COMMANDE = l'exécuter. `state` restaure la vue + le
|
Sélectionner une COMMANDE = l'exécuter. `state` restaure la vue + le
|
||||||
curseur au retour. Renvoie (action|None, state) ; run_app=False -> l'app."""
|
curseur au retour. Renvoie (action|None, state) ; run_app=False -> l'app.
|
||||||
|
"""
|
||||||
from textual.app import App, ComposeResult
|
from textual.app import App, ComposeResult
|
||||||
from textual.containers import (
|
from textual.containers import (
|
||||||
Container,
|
Container,
|
||||||
|
|
@ -828,10 +866,8 @@ def run_tui(run_app: bool = True, state: dict | None = None):
|
||||||
# Vue Liste : tous les menus empilés verticalement, chaque menu
|
# Vue Liste : tous les menus empilés verticalement, chaque menu
|
||||||
# avec ses sections et ses commandes (icônes), pour aider le choix.
|
# avec ses sections et ses commandes (icônes), pour aider le choix.
|
||||||
blocks = []
|
blocks = []
|
||||||
for (label, path, cnt, cmds) in columns:
|
for label, path, cnt, cmds in columns:
|
||||||
blocks.append(
|
blocks.append(Static(f"▸ {path} ({cnt})", classes="listmenu"))
|
||||||
Static(f"▸ {path} ({cnt})", classes="listmenu")
|
|
||||||
)
|
|
||||||
for section, group in _group_by_section(cmds):
|
for section, group in _group_by_section(cmds):
|
||||||
if section:
|
if section:
|
||||||
blocks.append(
|
blocks.append(
|
||||||
|
|
@ -868,7 +904,7 @@ def run_tui(run_app: bool = True, state: dict | None = None):
|
||||||
if self._kanban_mode == "swimlanes":
|
if self._kanban_mode == "swimlanes":
|
||||||
# Une rangée (swimlane) par menu de NIVEAU 1 (Execute, …).
|
# Une rangée (swimlane) par menu de NIVEAU 1 (Execute, …).
|
||||||
groups, order = {}, []
|
groups, order = {}, []
|
||||||
for (label, path, cnt, cmds) in columns:
|
for label, path, cnt, cmds in columns:
|
||||||
parts = path.split(" › ")
|
parts = path.split(" › ")
|
||||||
g = parts[1] if len(parts) > 1 else "TODO"
|
g = parts[1] if len(parts) > 1 else "TODO"
|
||||||
if g not in groups:
|
if g not in groups:
|
||||||
|
|
@ -878,7 +914,8 @@ def run_tui(run_app: bool = True, state: dict | None = None):
|
||||||
lanes = []
|
lanes = []
|
||||||
for g in order:
|
for g in order:
|
||||||
lane_cols = [
|
lane_cols = [
|
||||||
self._col_widget(lb, cn, cm) for (lb, cn, cm) in groups[g]
|
self._col_widget(lb, cn, cm)
|
||||||
|
for (lb, cn, cm) in groups[g]
|
||||||
]
|
]
|
||||||
lanes.append(
|
lanes.append(
|
||||||
Vertical(
|
Vertical(
|
||||||
|
|
@ -986,9 +1023,7 @@ def run_tui(run_app: bool = True, state: dict | None = None):
|
||||||
def _update_summary(self):
|
def _update_summary(self):
|
||||||
total = sum(paths.values())
|
total = sum(paths.values())
|
||||||
src = t("tree from code") if code_tree else t("visited paths only")
|
src = t("tree from code") if code_tree else t("visited paths only")
|
||||||
extra = (
|
extra = f" [{self._kanban_mode}]" if self._mode == "kanban" else ""
|
||||||
f" [{self._kanban_mode}]" if self._mode == "kanban" else ""
|
|
||||||
)
|
|
||||||
cur_lbl = VIEW_LABELS.get(self._mode, self._mode)
|
cur_lbl = VIEW_LABELS.get(self._mode, self._mode)
|
||||||
nxt_lbl = VIEW_LABELS.get(self._next_view(), self._next_view())
|
nxt_lbl = VIEW_LABELS.get(self._next_view(), self._next_view())
|
||||||
self._update_f3_hint()
|
self._update_f3_hint()
|
||||||
|
|
@ -1018,7 +1053,9 @@ def run_tui(run_app: bool = True, state: dict | None = None):
|
||||||
def _render_system(self, m):
|
def _render_system(self, m):
|
||||||
lines = []
|
lines = []
|
||||||
load = (
|
load = (
|
||||||
" · " + t("load") + " "
|
" · "
|
||||||
|
+ t("load")
|
||||||
|
+ " "
|
||||||
+ "/".join(f"{x:.1f}" for x in m["load"])
|
+ "/".join(f"{x:.1f}" for x in m["load"])
|
||||||
if m["load"]
|
if m["load"]
|
||||||
else ""
|
else ""
|
||||||
|
|
@ -1055,9 +1092,7 @@ def run_tui(run_app: bool = True, state: dict | None = None):
|
||||||
)
|
)
|
||||||
if m["battery"]:
|
if m["battery"]:
|
||||||
cap, status = m["battery"]
|
cap, status = m["battery"]
|
||||||
lines.append(
|
lines.append(f" 🔋 {t('Battery')} : {cap} % ({status})")
|
||||||
f" 🔋 {t('Battery')} : {cap} % ({status})"
|
|
||||||
)
|
|
||||||
temp = m["temp"]
|
temp = m["temp"]
|
||||||
if temp:
|
if temp:
|
||||||
lines.append(
|
lines.append(
|
||||||
|
|
@ -1096,9 +1131,9 @@ def run_tui(run_app: bool = True, state: dict | None = None):
|
||||||
print(f"{t('Proposed install command:')}")
|
print(f"{t('Proposed install command:')}")
|
||||||
print(f" {printable}")
|
print(f" {printable}")
|
||||||
print(" sudo sensors-detect --auto")
|
print(" sudo sensors-detect --auto")
|
||||||
ans = input(
|
ans = (
|
||||||
t("Install lm-sensors now? (y/N): ")
|
input(t("Install lm-sensors now? (y/N): ")).strip().lower()
|
||||||
).strip().lower()
|
)
|
||||||
if ans in ("o", "oui", "y", "yes"):
|
if ans in ("o", "oui", "y", "yes"):
|
||||||
os.system(printable + " || true")
|
os.system(printable + " || true")
|
||||||
os.system("sudo sensors-detect --auto || true")
|
os.system("sudo sensors-detect --auto || true")
|
||||||
|
|
|
||||||
|
|
@ -2705,6 +2705,10 @@ class TodoUpgrade:
|
||||||
f"before_{next_version}",
|
f"before_{next_version}",
|
||||||
f"after_{next_version}",
|
f"after_{next_version}",
|
||||||
)
|
)
|
||||||
|
# ICI et pas avant : la réparation prend l'ancrage manquant
|
||||||
|
# dans la vue MODULE, qui ne porte l'arch de la cible
|
||||||
|
# qu'une fois la migration passée.
|
||||||
|
self.repair_cow_render(database_name_upgrade)
|
||||||
|
|
||||||
lst_upgrade_odoo[index] = cmd_upgrade
|
lst_upgrade_odoo[index] = cmd_upgrade
|
||||||
self.dct_progression["state_4_upgrade_odoo_lst"] = (
|
self.dct_progression["state_4_upgrade_odoo_lst"] = (
|
||||||
|
|
@ -3656,6 +3660,30 @@ class TodoUpgrade:
|
||||||
f" -d {database_name} --list"
|
f" -d {database_name} --list"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def repair_cow_render(self, database_name):
|
||||||
|
"""Les copies de site qui passent le palier et rendent 500 après.
|
||||||
|
|
||||||
|
Deux ruptures qu'aucune autre étape ne voit : un ancrage que la
|
||||||
|
vue héritière de la CIBLE réclame et que la copie n'a jamais eu,
|
||||||
|
et un `t-call` vers un gabarit que la cible ne livre plus. Mesuré
|
||||||
|
sur une chaîne 12 → 18 : /contact rendait 500 depuis le palier
|
||||||
|
14 → 15, et rien ne l'a dit avant le test de fumée final.
|
||||||
|
|
||||||
|
On ne neutralise pas : la copie porte une page écrite par
|
||||||
|
quelqu'un. On répare, et le contenu reste.
|
||||||
|
|
||||||
|
`wait_at_error=False` : le code 1 de cet outil veut dire « j'ai
|
||||||
|
trouvé », pas « je suis tombé ». Sans cela le pilote ouvrirait son
|
||||||
|
menu d'erreur sur une réparation qui s'est bien passée.
|
||||||
|
"""
|
||||||
|
outil = os.path.join(PATH_MIGRATION_GLOBAL, "fix_cow_render.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):
|
def diff_cow_views(self, database_name, label_before, label_after):
|
||||||
"""Print what the version bump did to the website COW views."""
|
"""Print what the version bump did to the website COW views."""
|
||||||
directory = os.path.join(
|
directory = os.path.join(
|
||||||
|
|
@ -3847,6 +3875,17 @@ class TodoUpgrade:
|
||||||
)
|
)
|
||||||
|
|
||||||
lst_left = self.still_installed(database_name, lst_module_to_uninstall)
|
lst_left = self.still_installed(database_name, lst_module_to_uninstall)
|
||||||
|
# UN SEUL nom fautif emporte tout le lot : « --uninstall » prend
|
||||||
|
# une liste, et Odoo annule la transaction entière au premier
|
||||||
|
# échec. Mesuré sur une chaîne 12 → 18 : `crm_phone` échoue sur
|
||||||
|
# une colonne absente de res_users et fait tomber les 22 autres
|
||||||
|
# avec lui — dont huit modules sans code en 13, qui sont alors
|
||||||
|
# montés d'un palier « installed » sans rien pour les charger.
|
||||||
|
#
|
||||||
|
# On reprend donc un par un : ce qui peut partir part, et l'on
|
||||||
|
# nomme précisément ce qui résiste.
|
||||||
|
if lst_left and len(lst_module_to_uninstall) > 1:
|
||||||
|
lst_left = self.uninstall_one_by_one(database_name, lst_left)
|
||||||
if lst_left is None:
|
if lst_left is None:
|
||||||
# Base illisible : on ne sait pas. Le dire, plutôt que de trancher.
|
# Base illisible : on ne sait pas. Le dire, plutôt que de trancher.
|
||||||
print(f"⚠️ {t('Could not verify the uninstall.')}")
|
print(f"⚠️ {t('Could not verify the uninstall.')}")
|
||||||
|
|
@ -3873,6 +3912,27 @@ class TodoUpgrade:
|
||||||
)
|
)
|
||||||
self.write_config()
|
self.write_config()
|
||||||
|
|
||||||
|
def uninstall_one_by_one(self, database_name, lst_module):
|
||||||
|
"""Reprendre module par module, et rendre ce qui résiste encore.
|
||||||
|
|
||||||
|
Appelée seulement quand le lot a échoué : une commande par nom
|
||||||
|
coûte un démarrage d'Odoo chacune, ce qu'on ne paie pas pour
|
||||||
|
rien. Ce qu'on y gagne est qu'un module fautif cesse de protéger
|
||||||
|
les autres.
|
||||||
|
"""
|
||||||
|
reste = []
|
||||||
|
for nom in lst_module:
|
||||||
|
print(f"⧖ {t('Retrying alone:')} {nom}")
|
||||||
|
self.todo_upgrade_execute(
|
||||||
|
f"./script/addons/uninstall_addons.sh {database_name} {nom}",
|
||||||
|
single_source_odoo=True,
|
||||||
|
wait_at_error=False,
|
||||||
|
)
|
||||||
|
encore = self.still_installed(database_name, [nom])
|
||||||
|
if encore is None or encore:
|
||||||
|
reste.append(nom)
|
||||||
|
return reste
|
||||||
|
|
||||||
def install_from_database(
|
def install_from_database(
|
||||||
self, lst_module_to_install, database_name, actual_version
|
self, lst_module_to_install, database_name, actual_version
|
||||||
):
|
):
|
||||||
|
|
|
||||||
266
test/test_check_cow_views_render.py
Normal file
266
test/test_check_cow_views_render.py
Normal file
|
|
@ -0,0 +1,266 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
"""Prédire, AVANT le palier, la copie qui rendra 500 après.
|
||||||
|
|
||||||
|
`check_cow_views` cherchait la rupture qui ARRÊTE la migration : la forme
|
||||||
|
que l'arch doit avoir change. Deux autres la laissent finir et ne se
|
||||||
|
voient qu'à l'ouverture de la page — et personne n'ouvre les pages avant
|
||||||
|
la fin.
|
||||||
|
|
||||||
|
Mesuré sur une chaîne 12 → 18 réelle : /contact rendait 500 depuis le
|
||||||
|
palier 14 → 15. Rejouée avec ce contrôle, la prédiction nomme le défaut
|
||||||
|
dès le palier 13 → 14, cinq paliers avant que quiconque s'en aperçoive.
|
||||||
|
|
||||||
|
Ces copies-là ne se NEUTRALISENT pas : chacune porte une page écrite par
|
||||||
|
quelqu'un, et la mettre de côté l'effacerait du site. Elles se réparent.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
sys.path.append(
|
||||||
|
os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
)
|
||||||
|
sys.path.append(
|
||||||
|
os.path.normpath(
|
||||||
|
os.path.join(
|
||||||
|
os.path.dirname(__file__), "..", "script", "odoo", "migration"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
import check_cow_views as cow # noqa: E402
|
||||||
|
|
||||||
|
# La copie du client : sa page, sans l'ancrage que la cible attend, et
|
||||||
|
# avec un appel vers un gabarit que la cible ne livre plus.
|
||||||
|
ARCH_COPIE = (
|
||||||
|
"<t t-name='website.contactus'>"
|
||||||
|
"<t t-call='website.layout'>"
|
||||||
|
"<div id='wrap'><h1>Parlons de votre projet</h1>"
|
||||||
|
"<t t-call='website.company_description'/></div>"
|
||||||
|
"</t></t>"
|
||||||
|
)
|
||||||
|
|
||||||
|
MODULE_ENFANT = """<odoo>
|
||||||
|
<template id="contactus_form" inherit_id="website.contactus">
|
||||||
|
<xpath expr="//t[@t-set='contactus_form_values']" position="after">
|
||||||
|
<t t-set="autre" t-value="2"/>
|
||||||
|
</xpath>
|
||||||
|
</template>
|
||||||
|
</odoo>
|
||||||
|
"""
|
||||||
|
|
||||||
|
MODULE_PARENT = """<odoo>
|
||||||
|
<template id="contactus" name="Contact">
|
||||||
|
<t t-call="website.layout"><div id="wrap"/></t>
|
||||||
|
</template>
|
||||||
|
<template id="layout" name="Layout"><div/></template>
|
||||||
|
</odoo>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class TestFullKey(unittest.TestCase):
|
||||||
|
def test_a_bare_id_takes_its_module(self):
|
||||||
|
self.assertEqual(
|
||||||
|
cow.full_key("website", "contactus"), "website.contactus"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_a_qualified_id_is_left_alone(self):
|
||||||
|
self.assertEqual(
|
||||||
|
cow.full_key("website_crm", "website.contactus"),
|
||||||
|
"website.contactus",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_nothing_stays_nothing(self):
|
||||||
|
self.assertIsNone(cow.full_key("website", ""))
|
||||||
|
self.assertIsNone(cow.full_key("website", None))
|
||||||
|
|
||||||
|
|
||||||
|
class TestScanningTheTargetSources(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.racine = tempfile.mkdtemp(prefix="el_cow_")
|
||||||
|
self.addCleanup(shutil.rmtree, self.racine, True)
|
||||||
|
for module, contenu in (
|
||||||
|
("website", MODULE_PARENT),
|
||||||
|
("website_crm", MODULE_ENFANT),
|
||||||
|
):
|
||||||
|
chemin = os.path.join(self.racine, "odoo", "addons", module)
|
||||||
|
os.makedirs(chemin)
|
||||||
|
with open(
|
||||||
|
os.path.join(chemin, "views.xml"), "w", encoding="utf-8"
|
||||||
|
) as f:
|
||||||
|
f.write(contenu)
|
||||||
|
|
||||||
|
def scan(self, modules=("website", "website_crm")):
|
||||||
|
return cow.scan_target_views(self.racine, list(modules))
|
||||||
|
|
||||||
|
def test_it_lists_what_the_target_ships(self):
|
||||||
|
declares, _ = self.scan()
|
||||||
|
self.assertIn("website.contactus", declares)
|
||||||
|
self.assertIn("website.layout", declares)
|
||||||
|
|
||||||
|
def test_it_maps_a_child_xpath_to_the_parent_it_needs(self):
|
||||||
|
# C'est LE renseignement qui manquait : quelle vue de la cible
|
||||||
|
# exige quel ancrage, et dans quel parent.
|
||||||
|
_, heritages = self.scan()
|
||||||
|
self.assertEqual(
|
||||||
|
heritages["website.contactus"],
|
||||||
|
["//t[@t-set='contactus_form_values']"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_a_module_absent_from_the_target_is_skipped(self):
|
||||||
|
declares, _ = self.scan(["website", "jamais_livre"])
|
||||||
|
self.assertIn("website.contactus", declares)
|
||||||
|
|
||||||
|
def test_a_template_without_xpath_adds_no_requirement(self):
|
||||||
|
_, heritages = self.scan(["website"])
|
||||||
|
self.assertEqual(heritages, {})
|
||||||
|
|
||||||
|
|
||||||
|
class TestWillNotRender(unittest.TestCase):
|
||||||
|
"""Le verdict, en remplaçant ce qui touche la base et le disque."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.vrai = (
|
||||||
|
cow.query_cow_archs,
|
||||||
|
cow.installed_modules,
|
||||||
|
cow.scan_target_views,
|
||||||
|
)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
(
|
||||||
|
cow.query_cow_archs,
|
||||||
|
cow.installed_modules,
|
||||||
|
cow.scan_target_views,
|
||||||
|
) = self.vrai
|
||||||
|
|
||||||
|
def poser(self, copies, declares, heritages):
|
||||||
|
cow.query_cow_archs = lambda d: copies
|
||||||
|
cow.installed_modules = lambda d: ["website"]
|
||||||
|
cow.scan_target_views = lambda v, m: (declares, heritages)
|
||||||
|
|
||||||
|
def copie(self, arch=ARCH_COPIE, langues=None):
|
||||||
|
return [
|
||||||
|
(
|
||||||
|
1228,
|
||||||
|
"website.contactus",
|
||||||
|
"primary",
|
||||||
|
1,
|
||||||
|
langues or {"en_US": arch},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_it_names_the_anchor_a_target_child_will_need(self):
|
||||||
|
self.poser(
|
||||||
|
self.copie(),
|
||||||
|
{
|
||||||
|
"website.contactus",
|
||||||
|
"website.layout",
|
||||||
|
"website.company_description",
|
||||||
|
},
|
||||||
|
{"website.contactus": ["//t[@t-set='contactus_form_values']"]},
|
||||||
|
)
|
||||||
|
risques = cow.will_not_render("db", "odoo18.0")
|
||||||
|
self.assertEqual(1, len(risques), risques)
|
||||||
|
self.assertIn("contactus_form_values", risques[0][5])
|
||||||
|
|
||||||
|
def test_an_anchor_the_copy_already_has_is_not_reported(self):
|
||||||
|
arch = ARCH_COPIE.replace(
|
||||||
|
"<div id='wrap'>",
|
||||||
|
"<t t-set='contactus_form_values' t-value='1'/><div id='wrap'>",
|
||||||
|
)
|
||||||
|
self.poser(
|
||||||
|
self.copie(arch),
|
||||||
|
{
|
||||||
|
"website.contactus",
|
||||||
|
"website.layout",
|
||||||
|
"website.company_description",
|
||||||
|
},
|
||||||
|
{"website.contactus": ["//t[@t-set='contactus_form_values']"]},
|
||||||
|
)
|
||||||
|
self.assertEqual([], cow.will_not_render("db", "odoo18.0"))
|
||||||
|
|
||||||
|
def test_it_names_a_template_the_target_no_longer_ships(self):
|
||||||
|
self.poser(
|
||||||
|
self.copie(),
|
||||||
|
{"website.contactus", "website.layout"},
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
risques = cow.will_not_render("db", "odoo18.0")
|
||||||
|
self.assertEqual(1, len(risques), risques)
|
||||||
|
self.assertIn("company_description", risques[0][5])
|
||||||
|
|
||||||
|
def test_another_copy_counts_as_a_known_template(self):
|
||||||
|
# Un gabarit peut n'exister qu'en base : une copie de site qui en
|
||||||
|
# appelle une autre n'est pas cassée pour autant.
|
||||||
|
copies = self.copie() + [
|
||||||
|
(
|
||||||
|
99,
|
||||||
|
"website.company_description",
|
||||||
|
"primary",
|
||||||
|
1,
|
||||||
|
{"en_US": "<t/>"},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
self.poser(copies, {"website.contactus", "website.layout"}, {})
|
||||||
|
self.assertEqual([], cow.will_not_render("db", "odoo18.0"))
|
||||||
|
|
||||||
|
def test_a_copy_broken_in_one_language_only_is_still_reported(self):
|
||||||
|
# Le site rend dans SA langue : réparer l'anglais et laisser le
|
||||||
|
# français cassé donne une page en 500 et un rapport vert.
|
||||||
|
sain = ARCH_COPIE.replace(
|
||||||
|
"<div id='wrap'>",
|
||||||
|
"<t t-set='contactus_form_values' t-value='1'/><div id='wrap'>",
|
||||||
|
)
|
||||||
|
self.poser(
|
||||||
|
self.copie(langues={"en_US": sain, "fr_CA": ARCH_COPIE}),
|
||||||
|
{
|
||||||
|
"website.contactus",
|
||||||
|
"website.layout",
|
||||||
|
"website.company_description",
|
||||||
|
},
|
||||||
|
{"website.contactus": ["//t[@t-set='contactus_form_values']"]},
|
||||||
|
)
|
||||||
|
risques = cow.will_not_render("db", "odoo18.0")
|
||||||
|
self.assertEqual(1, len(risques), risques)
|
||||||
|
|
||||||
|
def test_a_copy_without_a_key_is_left_alone(self):
|
||||||
|
self.poser(
|
||||||
|
[(7, "", "primary", 1, {"en_US": ARCH_COPIE})],
|
||||||
|
{"website.company_description"},
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
self.assertEqual([], cow.will_not_render("db", "odoo18.0"))
|
||||||
|
|
||||||
|
def test_no_copy_at_all_is_not_an_error(self):
|
||||||
|
self.poser([], set(), {})
|
||||||
|
self.assertEqual([], cow.will_not_render("db", "odoo18.0"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestItStaysOutOfTheNeutralizer(unittest.TestCase):
|
||||||
|
"""Ces copies se réparent ; les neutraliser effacerait la page."""
|
||||||
|
|
||||||
|
def test_the_neutralizer_only_acts_on_the_shape_bucket(self):
|
||||||
|
with open(
|
||||||
|
os.path.join(
|
||||||
|
os.path.dirname(__file__),
|
||||||
|
"..",
|
||||||
|
"script",
|
||||||
|
"odoo",
|
||||||
|
"migration",
|
||||||
|
"neutralize_cow_views.py",
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
) as handle:
|
||||||
|
source = handle.read()
|
||||||
|
self.assertIn("lst_at_risk, _, _ = analyse(", source)
|
||||||
|
self.assertNotIn("will_not_render", source)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
@ -1330,5 +1330,167 @@ class TestGroupingByFieldName(unittest.TestCase):
|
||||||
self.assertEqual(groupes[0], ("seul", 1, "account.account.seul"))
|
self.assertEqual(groupes[0], ("seul", 1, "account.account.seul"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestFieldsThatHeldNoData(TestTheOpenUpgradeOverlay):
|
||||||
|
"""Un champ sans colonne n'a rien perdu — et il noyait le rapport.
|
||||||
|
|
||||||
|
Mesuré sur une chaîne 12 → 18 : le seau « NON déclarés par
|
||||||
|
OpenUpgrade » comptait 565 champs au palier 16 → 17, dont 397
|
||||||
|
`__last_update` — un champ magique qu'Odoo 17 cesse d'inscrire et
|
||||||
|
qui n'a jamais eu de colonne. Un chiffre de tête qui fait peur pour
|
||||||
|
rien fait ignorer le rapport entier.
|
||||||
|
|
||||||
|
Après la règle : 565 → 57, et les 508 autres sont NOMMÉS sous
|
||||||
|
« sans donnée propre », en une ligne par nom de champ.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def declare(self, perdus, stockes, origines=None):
|
||||||
|
# Le montage de la classe parente pose l'index déclaré en
|
||||||
|
# cache et restreint les modules analysés : sans lui
|
||||||
|
# `overlay_declared` sort tout de suite et rien n'est mesuré.
|
||||||
|
return quality.overlay_declared(18, [], perdus, origines, stockes)
|
||||||
|
|
||||||
|
def test_an_unstored_field_is_set_aside(self):
|
||||||
|
# `store=false` : le champ n'a jamais eu de colonne. Sa
|
||||||
|
# disparition ne coûte pas un octet.
|
||||||
|
res = self.declare(["res.partner.calcule"], set())
|
||||||
|
self.assertEqual(["res.partner.calcule"], res["fields"]["no_data"])
|
||||||
|
self.assertEqual([], res["fields"]["undeclared"])
|
||||||
|
|
||||||
|
def test_a_stored_field_stays_a_finding(self):
|
||||||
|
res = self.declare(["res.partner.vrai"], {"res.partner.vrai"})
|
||||||
|
self.assertEqual(["res.partner.vrai"], res["fields"]["undeclared"])
|
||||||
|
self.assertEqual([], res["fields"]["no_data"])
|
||||||
|
|
||||||
|
def test_id_is_set_aside_even_though_it_is_stored(self):
|
||||||
|
# `id` porte une donnée, mais pas la SIENNE : elle appartient à
|
||||||
|
# la ligne. Odoo 15 cesse de l'inscrire sur les modèles
|
||||||
|
# abstraits — 65 « pertes » d'un coup, zéro octet.
|
||||||
|
res = self.declare(["res.partner.id"], {"res.partner.id"})
|
||||||
|
self.assertEqual(["res.partner.id"], res["fields"]["no_data"])
|
||||||
|
|
||||||
|
def test_a_field_named_id_on_another_model_too(self):
|
||||||
|
res = self.declare(["mail.thread.id"], {"mail.thread.id"})
|
||||||
|
self.assertEqual(["mail.thread.id"], res["fields"]["no_data"])
|
||||||
|
|
||||||
|
def test_without_the_information_nothing_is_set_aside(self):
|
||||||
|
# Un instantané pris par une version antérieure de l'outil n'a
|
||||||
|
# pas `field_stored`. Tout verser dans le seau calme ferait
|
||||||
|
# taire le rapport au lieu de l'éclaircir.
|
||||||
|
res = quality.overlay_declared(18, [], ["res.partner.x"], None, None)
|
||||||
|
self.assertEqual([], res["fields"]["no_data"])
|
||||||
|
self.assertEqual(["res.partner.x"], res["fields"]["undeclared"])
|
||||||
|
|
||||||
|
def test_it_comes_before_the_not_analysed_bucket(self):
|
||||||
|
# « sans donnée propre » est une raison plus forte que « hors du
|
||||||
|
# champ d'OpenUpgrade ». Mesuré : le placement avant fait tomber
|
||||||
|
# `not_analysed` de 181 à 47 au palier 16 → 17, sans changer
|
||||||
|
# `undeclared` — le seau résiduel se réduit au risque réel.
|
||||||
|
res = self.declare(
|
||||||
|
["oca_module.model.champ"],
|
||||||
|
set(),
|
||||||
|
{"oca_module.model.champ": ["un_module_oca_inconnu"]},
|
||||||
|
)
|
||||||
|
self.assertEqual(["oca_module.model.champ"], res["fields"]["no_data"])
|
||||||
|
self.assertEqual([], res["fields"]["not_analysed"])
|
||||||
|
|
||||||
|
def test_the_model_gone_bucket_still_wins(self):
|
||||||
|
# Un champ dont le MODÈLE a disparu reste rangé là : c'est UNE
|
||||||
|
# trouvaille, pas une par champ.
|
||||||
|
res = quality.overlay_declared(
|
||||||
|
18, ["res.parti"], ["res.parti.champ"], None, set()
|
||||||
|
)
|
||||||
|
self.assertEqual(["res.parti.champ"], res["fields"]["model_gone"])
|
||||||
|
self.assertEqual([], res["fields"]["no_data"])
|
||||||
|
|
||||||
|
def test_inspect_reads_which_fields_hold_data(self):
|
||||||
|
# La règle ne vaut que si le renseignement est LU. En bouchonnant
|
||||||
|
# psql on éprouve la requête elle-même : c'est `store` qui
|
||||||
|
# décide, et lui seul.
|
||||||
|
vues = []
|
||||||
|
|
||||||
|
def faux_psql(database, sql, **kwargs):
|
||||||
|
vues.append(" ".join(sql.split()))
|
||||||
|
if "WHERE store" in " ".join(sql.split()):
|
||||||
|
return [["res.partner.vrai"]]
|
||||||
|
if "FROM ir_model_fields ORDER BY" in " ".join(sql.split()):
|
||||||
|
return [["res.partner.vrai"], ["res.partner.calcule"]]
|
||||||
|
return []
|
||||||
|
|
||||||
|
vrai = quality.run_psql
|
||||||
|
self.addCleanup(setattr, quality, "run_psql", vrai)
|
||||||
|
quality.run_psql = faux_psql
|
||||||
|
etat = quality.inspect("essai")
|
||||||
|
self.assertEqual(["res.partner.vrai"], etat["field_stored"])
|
||||||
|
self.assertTrue(
|
||||||
|
any("WHERE store" in v for v in vues),
|
||||||
|
"la requête doit trancher sur `store`, pas sur autre chose",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_the_names_are_shown_grouped_by_field(self):
|
||||||
|
# C'est la LIGNE « __last_update × 101 modèle(s) » qui explique le
|
||||||
|
# gros chiffre. Sans elle on remplace un nombre effrayant par un
|
||||||
|
# nombre opaque, et le lecteur reste sans réponse.
|
||||||
|
declare = {
|
||||||
|
"available": True,
|
||||||
|
"modules": 400,
|
||||||
|
"models": {"obsolete": [], "renamed": [], "undeclared": []},
|
||||||
|
"fields": {
|
||||||
|
"del": [],
|
||||||
|
"unstored": [],
|
||||||
|
"company_dependent": [],
|
||||||
|
"moved": [],
|
||||||
|
"model_gone": [],
|
||||||
|
"no_data": [
|
||||||
|
"a.__last_update",
|
||||||
|
"b.__last_update",
|
||||||
|
"c.__last_update",
|
||||||
|
],
|
||||||
|
"not_analysed": [],
|
||||||
|
"undeclared": [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
texte = "\n".join(quality.render_declared(declare, colour=False))
|
||||||
|
self.assertIn("__last_update", texte)
|
||||||
|
self.assertIn("3", texte)
|
||||||
|
|
||||||
|
def test_compare_passes_the_stored_fields_along(self):
|
||||||
|
# La règle vit dans `overlay_declared`, mais c'est `compare` qui
|
||||||
|
# lui donne de quoi trancher : sans la transmission, le seau
|
||||||
|
# reste vide et le rapport annonce toujours son gros chiffre.
|
||||||
|
commun = {
|
||||||
|
"exists": True,
|
||||||
|
"installed": [],
|
||||||
|
"model": ["res.partner"],
|
||||||
|
"table": {},
|
||||||
|
"view": 0,
|
||||||
|
"view_cow": 0,
|
||||||
|
"menu": 0,
|
||||||
|
"attachment": 0,
|
||||||
|
"cow": [],
|
||||||
|
"odoo": "18.0",
|
||||||
|
"database": "x",
|
||||||
|
}
|
||||||
|
avant = dict(
|
||||||
|
commun,
|
||||||
|
field=["res.partner.calcule", "res.partner.vrai"],
|
||||||
|
field_stored=["res.partner.vrai"],
|
||||||
|
)
|
||||||
|
apres = dict(commun, field=[])
|
||||||
|
diff = quality.compare(avant, apres)
|
||||||
|
champs = diff["declared"]["fields"]
|
||||||
|
self.assertEqual(["res.partner.calcule"], champs["no_data"])
|
||||||
|
self.assertEqual(["res.partner.vrai"], champs["undeclared"])
|
||||||
|
|
||||||
|
def test_the_bucket_has_a_label_and_a_quiet_tint(self):
|
||||||
|
table = dict(
|
||||||
|
(cle, (libelle, teinte))
|
||||||
|
for cle, libelle, teinte in quality.DECLARE_CHAMPS
|
||||||
|
)
|
||||||
|
self.assertIn("no_data", table)
|
||||||
|
libelle, teinte = table["no_data"]
|
||||||
|
self.assertEqual("dim", teinte, "il doit rassurer, pas attirer l'œil")
|
||||||
|
self.assertTrue(quality.t(libelle))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
439
test/test_fix_cow_render.py
Normal file
439
test/test_fix_cow_render.py
Normal file
|
|
@ -0,0 +1,439 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
"""Une copie de site web qui ne sait plus se rendre après la montée.
|
||||||
|
|
||||||
|
`check_cow_views` cherche la rupture qui ARRÊTE la migration : la forme
|
||||||
|
que l'arch doit avoir a changé. Deux autres la laissent finir sans un mot
|
||||||
|
et ne se voient qu'à l'ouverture de la page.
|
||||||
|
|
||||||
|
Mesuré sur une migration 12 → 18 réelle : /contact rendait 500 depuis le
|
||||||
|
palier 14 → 15 — quatre paliers de silence. La copie COW de
|
||||||
|
`website.contactus` n'a jamais eu le `t-set='contactus_form_values'` que
|
||||||
|
le module a gagné en chemin, et sur lequel sa vue héritière fait un
|
||||||
|
xpath. Les deux copies appelaient en plus `website.company_description`,
|
||||||
|
gabarit disparu en 18.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
sys.path.append(
|
||||||
|
os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
)
|
||||||
|
|
||||||
|
from script.odoo.migration import fix_cow_render as cow # noqa: E402
|
||||||
|
|
||||||
|
# La forme réelle, réduite : le module porte l'ancrage, la copie ne l'a
|
||||||
|
# jamais eu, et toutes deux appellent le gabarit disparu.
|
||||||
|
ARCH_MODULE = (
|
||||||
|
"<t t-name='website.contactus'>"
|
||||||
|
"<t t-call='website.layout'>"
|
||||||
|
"<t t-set='logged_partner' t-value='1'/>"
|
||||||
|
"<t t-set='contactus_form_values' t-value=\"{'a': 1}\"/>"
|
||||||
|
"<div id='wrap'><h1>Contact us</h1></div>"
|
||||||
|
"</t></t>"
|
||||||
|
)
|
||||||
|
ARCH_COPIE = (
|
||||||
|
"<t t-name='website.contactus'>"
|
||||||
|
"<t t-call='website.layout'>"
|
||||||
|
"<div id='wrap'><h1>Parlons de votre projet</h1>"
|
||||||
|
"<t t-call='website.company_description'/></div>"
|
||||||
|
"</t></t>"
|
||||||
|
)
|
||||||
|
ARCH_ENFANT = (
|
||||||
|
"<data><xpath expr=\"//t[@t-set='contactus_form_values']\" position='after'>"
|
||||||
|
"<t t-set='autre' t-value='2'/></xpath></data>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestWhatAChildAsksFor(unittest.TestCase):
|
||||||
|
def test_it_reads_the_xpath_of_a_child(self):
|
||||||
|
self.assertEqual(
|
||||||
|
cow.anchors_wanted(ARCH_ENFANT),
|
||||||
|
["//t[@t-set='contactus_form_values']"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_a_child_without_xpath_asks_for_nothing(self):
|
||||||
|
self.assertEqual(
|
||||||
|
cow.anchors_wanted("<data><field name='x'/></data>"), []
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_an_unparsable_arch_asks_for_nothing_rather_than_crashing(self):
|
||||||
|
self.assertEqual(cow.anchors_wanted("<data><oops"), [])
|
||||||
|
|
||||||
|
|
||||||
|
class TestLocating(unittest.TestCase):
|
||||||
|
def test_the_module_has_the_anchor(self):
|
||||||
|
self.assertTrue(
|
||||||
|
cow.locates(ARCH_MODULE, "//t[@t-set='contactus_form_values']")
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_the_copy_does_not(self):
|
||||||
|
self.assertFalse(
|
||||||
|
cow.locates(ARCH_COPIE, "//t[@t-set='contactus_form_values']")
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_an_illegible_expression_counts_as_found(self):
|
||||||
|
# Elle ne relève pas de cet outil ; la signaler enverrait réparer
|
||||||
|
# une vue qui n'a rien.
|
||||||
|
self.assertTrue(cow.locates(ARCH_COPIE, "//[[["))
|
||||||
|
|
||||||
|
def test_an_unparsable_arch_counts_as_found(self):
|
||||||
|
self.assertTrue(cow.locates("<t><oops", "//t"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestDanglingCalls(unittest.TestCase):
|
||||||
|
CONNUS = {"website.layout", "website.contactus"}
|
||||||
|
|
||||||
|
def test_it_names_the_template_that_is_gone(self):
|
||||||
|
self.assertEqual(
|
||||||
|
cow.dangling_calls(ARCH_COPIE, self.CONNUS),
|
||||||
|
["website.company_description"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_a_known_template_is_not_reported(self):
|
||||||
|
self.assertNotIn(
|
||||||
|
"website.layout", cow.dangling_calls(ARCH_COPIE, self.CONNUS)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_a_computed_call_is_left_alone(self):
|
||||||
|
# Il se résout à l'exécution : on ne peut rien en dire ici.
|
||||||
|
arch = "<t><t t-call='{{ nom }}'/><t t-call='#x'/></t>"
|
||||||
|
self.assertEqual(cow.dangling_calls(arch, self.CONNUS), [])
|
||||||
|
|
||||||
|
def test_the_same_missing_template_is_named_once(self):
|
||||||
|
arch = "<t><t t-call='parti'/><div><t t-call='parti'/></div></t>"
|
||||||
|
self.assertEqual(cow.dangling_calls(arch, self.CONNUS), ["parti"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestRepairingTheAnchor(unittest.TestCase):
|
||||||
|
def test_it_puts_the_anchor_back_where_the_module_holds_it(self):
|
||||||
|
neuf = cow.repair_anchor(
|
||||||
|
ARCH_COPIE, ARCH_MODULE, "//t[@t-set='contactus_form_values']"
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(neuf)
|
||||||
|
self.assertTrue(
|
||||||
|
cow.locates(neuf, "//t[@t-set='contactus_form_values']")
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_the_anchor_lands_inside_its_host_not_at_the_root(self):
|
||||||
|
# `//t[@t-set='x']` le trouverait n'importe où : ce n'est donc pas
|
||||||
|
# la preuve. Ce qui compte est la PLACE — le `t-set` porte une
|
||||||
|
# portée, et l'enfant s'insère « after » l'ancrage. Posé à la
|
||||||
|
# racine, le formulaire atterrirait hors de la mise en page.
|
||||||
|
from lxml import etree
|
||||||
|
|
||||||
|
neuf = cow.repair_anchor(
|
||||||
|
ARCH_COPIE, ARCH_MODULE, "//t[@t-set='contactus_form_values']"
|
||||||
|
)
|
||||||
|
arbre = etree.fromstring(neuf.encode("utf-8"))
|
||||||
|
ancre = arbre.xpath("//t[@t-set='contactus_form_values']")[0]
|
||||||
|
self.assertEqual(
|
||||||
|
"website.layout",
|
||||||
|
ancre.getparent().get("t-call"),
|
||||||
|
etree.tostring(arbre, encoding="unicode"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_the_anchor_keeps_the_rank_the_module_gives_it(self):
|
||||||
|
# QWeb évalue dans l'ordre du document : un `t-set` posé après le
|
||||||
|
# contenu qui s'en sert n'est pas vu, et l'enfant qui s'insère
|
||||||
|
# « after » l'ancrage le suit où qu'il aille.
|
||||||
|
from lxml import etree
|
||||||
|
|
||||||
|
neuf = cow.repair_anchor(
|
||||||
|
ARCH_COPIE, ARCH_MODULE, "//t[@t-set='contactus_form_values']"
|
||||||
|
)
|
||||||
|
arbre = etree.fromstring(neuf.encode("utf-8"))
|
||||||
|
hote = arbre.xpath("//t[@t-call='website.layout']")[0]
|
||||||
|
rangs = {
|
||||||
|
enfant.get("t-set") or enfant.get("id"): rang
|
||||||
|
for rang, enfant in enumerate(hote)
|
||||||
|
}
|
||||||
|
self.assertLess(rangs["contactus_form_values"], rangs["wrap"], neuf)
|
||||||
|
|
||||||
|
def test_it_goes_before_the_sibling_it_precedes_in_the_module(self):
|
||||||
|
# Le jeu précédent ne prouve rien : la bonne place y VAUT zéro,
|
||||||
|
# donc un repli en tête passerait pour une réussite. Ici la copie
|
||||||
|
# porte déjà un élément avant le frère repère, et la bonne place
|
||||||
|
# est 1 — la seule que le repli ne trouve pas.
|
||||||
|
from lxml import etree
|
||||||
|
|
||||||
|
copie = (
|
||||||
|
"<t t-name='website.contactus'>"
|
||||||
|
"<t t-call='website.layout'>"
|
||||||
|
"<t t-set='deja_la' t-value='0'/>"
|
||||||
|
"<div id='wrap'><h1>Ma page</h1></div>"
|
||||||
|
"</t></t>"
|
||||||
|
)
|
||||||
|
neuf = cow.repair_anchor(
|
||||||
|
copie, ARCH_MODULE, "//t[@t-set='contactus_form_values']"
|
||||||
|
)
|
||||||
|
hote = etree.fromstring(neuf.encode("utf-8")).xpath(
|
||||||
|
"//t[@t-call='website.layout']"
|
||||||
|
)[0]
|
||||||
|
noms = [e.get("t-set") or e.get("id") for e in hote]
|
||||||
|
self.assertEqual(
|
||||||
|
["deja_la", "contactus_form_values", "wrap"], noms, neuf
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_it_keeps_the_page_written_by_the_customer(self):
|
||||||
|
# C'est tout l'enjeu : réinitialiser la vue rendrait la page
|
||||||
|
# fonctionnelle et effacerait son contenu.
|
||||||
|
neuf = cow.repair_anchor(
|
||||||
|
ARCH_COPIE, ARCH_MODULE, "//t[@t-set='contactus_form_values']"
|
||||||
|
)
|
||||||
|
self.assertIn("Parlons de votre projet", neuf)
|
||||||
|
|
||||||
|
def test_it_refuses_when_the_host_element_has_no_twin(self):
|
||||||
|
# Mieux vaut une réparation refusée qu'un bloc posé au hasard au
|
||||||
|
# milieu de la page de quelqu'un.
|
||||||
|
etrangere = "<t t-name='x'><section id='rien'/></t>"
|
||||||
|
self.assertIsNone(
|
||||||
|
cow.repair_anchor(
|
||||||
|
etrangere, ARCH_MODULE, "//t[@t-set='contactus_form_values']"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_it_refuses_when_the_module_has_no_anchor_either(self):
|
||||||
|
self.assertIsNone(
|
||||||
|
cow.repair_anchor(ARCH_COPIE, ARCH_COPIE, "//t[@t-set='absent']")
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_it_refuses_an_unparsable_arch(self):
|
||||||
|
self.assertIsNone(cow.repair_anchor("<t><oops", ARCH_MODULE, "//t"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestRemovingTheCall(unittest.TestCase):
|
||||||
|
def test_the_call_goes_and_the_rest_stays(self):
|
||||||
|
neuf = cow.repair_call(ARCH_COPIE, "website.company_description")
|
||||||
|
self.assertIsNotNone(neuf)
|
||||||
|
self.assertNotIn("company_description", neuf)
|
||||||
|
self.assertIn("Parlons de votre projet", neuf)
|
||||||
|
self.assertIn("website.layout", neuf)
|
||||||
|
|
||||||
|
def test_nothing_to_remove_means_no_change_proposed(self):
|
||||||
|
self.assertIsNone(cow.repair_call(ARCH_COPIE, "jamais.vu"))
|
||||||
|
|
||||||
|
def test_every_occurrence_goes(self):
|
||||||
|
arch = "<t><t t-call='parti'/><div><t t-call='parti'/></div></t>"
|
||||||
|
self.assertNotIn("parti", cow.repair_call(arch, "parti"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestLanguages(unittest.TestCase):
|
||||||
|
"""`arch_db` porte UNE ENTRÉE PAR LANGUE depuis la 17.
|
||||||
|
|
||||||
|
N'en lire qu'une déclare la vue saine alors qu'une autre est cassée ;
|
||||||
|
n'en écrire qu'une laisse la page en 500. Les deux me sont arrivés :
|
||||||
|
/contact réparé en en_US rendait toujours 500, parce que le site rend
|
||||||
|
en fr_CA.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_it_sees_every_language(self):
|
||||||
|
self.assertEqual(
|
||||||
|
sorted(cow.langs_of({"en_US": "<t/>", "fr_CA": "<u/>"})),
|
||||||
|
["en_US", "fr_CA"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_the_query_never_pins_one_language(self):
|
||||||
|
# C'est ICI que le défaut est né : `arch_db->>'en_US'` ramenait
|
||||||
|
# une seule langue, l'audit déclarait la vue saine et /contact
|
||||||
|
# restait en 500 parce que le site rend en fr_CA. La requête doit
|
||||||
|
# ramener l'OBJET entier.
|
||||||
|
requete = cow.arch_expr(jsonb=True)
|
||||||
|
self.assertNotIn("en_US", requete)
|
||||||
|
self.assertNotIn("->>", requete)
|
||||||
|
self.assertIn("arch_db", requete)
|
||||||
|
|
||||||
|
def test_a_varchar_column_is_wrapped_as_one_nameless_language(self):
|
||||||
|
# Une seule forme en aval : le reste du code n'a pas à savoir si
|
||||||
|
# la version traduit l'arch ou non.
|
||||||
|
requete = cow.arch_expr(jsonb=False)
|
||||||
|
self.assertIn("json_build_object", requete)
|
||||||
|
|
||||||
|
def test_a_plain_string_is_the_language_less_case(self):
|
||||||
|
self.assertEqual(cow.langs_of("<t/>"), {"": "<t/>"})
|
||||||
|
|
||||||
|
def test_a_non_string_value_is_ignored(self):
|
||||||
|
self.assertEqual(
|
||||||
|
cow.langs_of({"en_US": "<t/>", "x": 3}), {"en_US": "<t/>"}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_the_xpath_of_a_child_is_read_from_any_language(self):
|
||||||
|
# Les xpath ne sont pas traduits : n'importe quelle langue suffit.
|
||||||
|
self.assertEqual(
|
||||||
|
cow.anchors_wanted(cow.any_lang({"fr_CA": ARCH_ENFANT})),
|
||||||
|
["//t[@t-set='contactus_form_values']"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTheSqlItWrites(unittest.TestCase):
|
||||||
|
def test_it_writes_all_the_languages_in_one_update(self):
|
||||||
|
# Deux UPDATE sur la même vue et le second effacerait le premier ;
|
||||||
|
# un seul portant une langue laisserait l'autre cassée.
|
||||||
|
sql = cow.write_arch_sql(
|
||||||
|
7, {"en_US": "<t a='1'/>", "fr_CA": "<t b='2'/>"}, jsonb=True
|
||||||
|
)
|
||||||
|
self.assertEqual(1, sql.count("UPDATE"), sql)
|
||||||
|
self.assertIn("en_US", sql)
|
||||||
|
self.assertIn("fr_CA", sql)
|
||||||
|
self.assertIn("<t a='1'/>", sql)
|
||||||
|
self.assertIn("<t b='2'/>", sql)
|
||||||
|
self.assertIn("WHERE id = 7", sql)
|
||||||
|
|
||||||
|
def test_the_varchar_case_writes_the_text_itself(self):
|
||||||
|
sql = cow.write_arch_sql(7, {"": "<t/>"}, jsonb=False)
|
||||||
|
self.assertNotIn("jsonb_build_object", sql)
|
||||||
|
self.assertIn("<t/>", sql)
|
||||||
|
|
||||||
|
def test_both_branches_survive_an_apostrophe(self):
|
||||||
|
# La 16 stocke en varchar, la 17 et la 18 en jsonb : les DEUX
|
||||||
|
# chemins écrivent, et n'éprouver que l'un laisse l'autre casser
|
||||||
|
# au premier attribut cité.
|
||||||
|
arch = "<t t-value=\"{'a': 1}\"/>"
|
||||||
|
for jsonb, langues in ((True, {"en_US": arch}), (False, {"": arch})):
|
||||||
|
with self.subTest(jsonb=jsonb):
|
||||||
|
sql = cow.write_arch_sql(7, langues, jsonb=jsonb)
|
||||||
|
self.assertIn("$elcow$" + arch + "$elcow$", sql)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTheReport(unittest.TestCase):
|
||||||
|
def rapport(self):
|
||||||
|
return {
|
||||||
|
"database": "essai",
|
||||||
|
"jsonb": True,
|
||||||
|
"views": [
|
||||||
|
{
|
||||||
|
"id": 1228,
|
||||||
|
"key": "website.contactus",
|
||||||
|
"anchors": [{"enfant": 2782, "expr": "//t[@t-set='x']"}],
|
||||||
|
"calls": ["website.company_description"],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_a_clean_database_says_so(self):
|
||||||
|
texte = "\n".join(
|
||||||
|
cow.render({"database": "x", "jsonb": True, "views": []}, [])
|
||||||
|
)
|
||||||
|
self.assertIn("✅", texte)
|
||||||
|
|
||||||
|
def test_it_names_the_view_the_child_and_the_template(self):
|
||||||
|
texte = "\n".join(cow.render(self.rapport(), []))
|
||||||
|
self.assertIn("1228", texte)
|
||||||
|
self.assertIn("2782", texte)
|
||||||
|
self.assertIn("website.company_description", texte)
|
||||||
|
|
||||||
|
def test_it_warns_that_removing_a_call_removes_a_block(self):
|
||||||
|
# Le message passe par `t()` : le comparer à un mot anglais en
|
||||||
|
# dur le fait tomber dès qu'on le traduit. On demande la MÊME
|
||||||
|
# traduction que le code, ce qui éprouve l'avertissement sans
|
||||||
|
# épouser une langue.
|
||||||
|
gestes = [(1228, "k", "t-call", "website.company_description", "SQL")]
|
||||||
|
texte = "\n".join(cow.render(self.rapport(), gestes))
|
||||||
|
self.assertIn(
|
||||||
|
cow.t("Removing a call removes its block from the page."), texte
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_it_stays_silent_about_blocks_when_nothing_is_removed(self):
|
||||||
|
gestes = [(1228, "k", "anchor", "//t", "SQL")]
|
||||||
|
texte = "\n".join(cow.render(self.rapport(), gestes))
|
||||||
|
self.assertNotIn(
|
||||||
|
cow.t("Removing a call removes its block from the page."), texte
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_it_says_when_a_repair_is_out_of_reach(self):
|
||||||
|
gestes = [(1228, "k", "anchor-impossible", "//t", None)]
|
||||||
|
texte = "\n".join(cow.render(self.rapport(), gestes))
|
||||||
|
self.assertIn("1", texte)
|
||||||
|
self.assertIn("⚠", texte)
|
||||||
|
|
||||||
|
|
||||||
|
class TestItRunsInsideTheMigration(unittest.TestCase):
|
||||||
|
"""La réparation doit tourner APRÈS OpenUpgrade, pas avant.
|
||||||
|
|
||||||
|
Elle prend l'ancrage manquant dans la vue MODULE, qui ne porte l'arch
|
||||||
|
de la version cible qu'une fois la migration passée. Lancée avant,
|
||||||
|
elle recopierait l'ancien ancrage — ou rien.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def pilote(self):
|
||||||
|
chemin = os.path.join(
|
||||||
|
os.path.dirname(__file__),
|
||||||
|
"..",
|
||||||
|
"script",
|
||||||
|
"todo",
|
||||||
|
"todo_upgrade.py",
|
||||||
|
)
|
||||||
|
with open(chemin, encoding="utf-8") as handle:
|
||||||
|
return handle.read()
|
||||||
|
|
||||||
|
def corps_de_la_reparation(self):
|
||||||
|
"""Le CODE de la méthode, docstring retirée.
|
||||||
|
|
||||||
|
La docstring nomme `wait_at_error=False` pour l'expliquer : un
|
||||||
|
test qui lit tout le texte le trouve même quand l'argument a
|
||||||
|
disparu, et croit garder ce qu'il a laissé partir. Mesuré.
|
||||||
|
"""
|
||||||
|
import ast
|
||||||
|
|
||||||
|
for noeud in ast.walk(ast.parse(self.pilote())):
|
||||||
|
if (
|
||||||
|
isinstance(noeud, ast.FunctionDef)
|
||||||
|
and noeud.name == "repair_cow_render"
|
||||||
|
):
|
||||||
|
sans_texte = [
|
||||||
|
n
|
||||||
|
for n in noeud.body
|
||||||
|
if not (
|
||||||
|
isinstance(n, ast.Expr)
|
||||||
|
and isinstance(n.value, ast.Constant)
|
||||||
|
and isinstance(n.value.value, str)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
return ast.dump(ast.Module(body=sans_texte, type_ignores=[]))
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def test_the_scan_finds_the_method_at_all(self):
|
||||||
|
# Sans cette borne, les tests suivants passeraient sur une chaîne
|
||||||
|
# vide le jour où la méthode est renommée.
|
||||||
|
self.assertTrue(self.corps_de_la_reparation())
|
||||||
|
|
||||||
|
def test_the_driver_calls_the_repair(self):
|
||||||
|
self.assertIn("repair_cow_render", self.pilote())
|
||||||
|
|
||||||
|
def test_it_applies_rather_than_only_reporting(self):
|
||||||
|
corps = self.corps_de_la_reparation()
|
||||||
|
self.assertIn("fix_cow_render.py", corps)
|
||||||
|
self.assertIn("--apply", corps)
|
||||||
|
|
||||||
|
def test_a_finding_does_not_open_the_error_menu(self):
|
||||||
|
# Le code 1 de cet outil veut dire « j'ai trouvé », pas « je suis
|
||||||
|
# tombé ». Sans ce drapeau le pilote s'arrête sur une réparation
|
||||||
|
# réussie.
|
||||||
|
corps = self.corps_de_la_reparation()
|
||||||
|
self.assertIn("wait_at_error", corps)
|
||||||
|
self.assertIn("value=False", corps)
|
||||||
|
|
||||||
|
def test_it_is_called_after_the_data_migration(self):
|
||||||
|
source = self.pilote()
|
||||||
|
migration = source.index("cmd_upgrade,\n new_env=")
|
||||||
|
appel = source.index("self.repair_cow_render(")
|
||||||
|
self.assertLess(
|
||||||
|
migration,
|
||||||
|
appel,
|
||||||
|
"la réparation doit suivre OpenUpgrade : avant, la vue module"
|
||||||
|
" porte encore l'arch de l'ancienne version",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_a_missing_tool_is_not_a_crash(self):
|
||||||
|
# Un vieux checkout n'a pas le fichier ; la migration ne doit pas
|
||||||
|
# s'arrêter pour autant.
|
||||||
|
corps = self.corps_de_la_reparation()
|
||||||
|
self.assertIn("isfile", corps)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
235
test/test_fix_duplicate_index.py
Normal file
235
test/test_fix_duplicate_index.py
Normal file
|
|
@ -0,0 +1,235 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
"""Les index qu'Odoo 17 a créés en double et n'a jamais retirés.
|
||||||
|
|
||||||
|
`make_index_name` rend `{table}__{colonne}_index` depuis la 17 — deux
|
||||||
|
soulignés — là où les versions d'avant écrivaient un seul. Le nouvel
|
||||||
|
index est créé, l'ancien reste. Mesuré sur une chaîne 12 → 18 : 1 paire
|
||||||
|
en 12, 3 en 16, 370 en 17, 365 en 18.
|
||||||
|
|
||||||
|
Ce qui compte ici n'est pas de supprimer, c'est de S'ABSTENIR au bon
|
||||||
|
endroit. Éprouvé sur une copie de la base réelle : 364 index retirés,
|
||||||
|
10 Mo libérés, les 6301 contraintes identiques au nom près, Odoo charge
|
||||||
|
sans une ligne de journal, et un « -u all » complet n'en recrée aucun.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
sys.path.append(
|
||||||
|
os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
)
|
||||||
|
|
||||||
|
from script.odoo.migration import fix_duplicate_index as idx # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def paire(table, a, b, a_ctr=False, b_ctr=False, a_size=100, b_size=90):
|
||||||
|
return {
|
||||||
|
"table": table,
|
||||||
|
"a": a,
|
||||||
|
"a_size": a_size,
|
||||||
|
"a_ctr": a_ctr,
|
||||||
|
"b": b,
|
||||||
|
"b_size": b_size,
|
||||||
|
"b_ctr": b_ctr,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestTheNamingConvention(unittest.TestCase):
|
||||||
|
def test_the_modern_name_carries_two_underscores(self):
|
||||||
|
# `make_index_name` d'Odoo 18 : f"{table}__{colonne}_index".
|
||||||
|
self.assertTrue(
|
||||||
|
idx.modern_name("res_partner", "res_partner__name_index")
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_the_legacy_name_does_not(self):
|
||||||
|
self.assertFalse(
|
||||||
|
idx.modern_name("res_partner", "res_partner_name_index")
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_a_name_from_another_table_is_not_modern_here(self):
|
||||||
|
self.assertFalse(
|
||||||
|
idx.modern_name("res_partner", "res_users__name_index")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestWhatItAgreesToDrop(unittest.TestCase):
|
||||||
|
def test_it_keeps_the_modern_one_and_drops_the_legacy_one(self):
|
||||||
|
verdict, garder, supprimer = idx.classify(
|
||||||
|
paire(
|
||||||
|
"res_partner",
|
||||||
|
"res_partner__name_index",
|
||||||
|
"res_partner_name_index",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertEqual("safe", verdict)
|
||||||
|
self.assertEqual("res_partner__name_index", garder)
|
||||||
|
self.assertEqual("res_partner_name_index", supprimer)
|
||||||
|
|
||||||
|
def test_the_order_of_the_pair_does_not_decide(self):
|
||||||
|
# PostgreSQL rend les deux dans l'ordre de leur oid : se fier à
|
||||||
|
# « le premier » supprimerait le moderne une fois sur deux.
|
||||||
|
verdict, garder, _s = idx.classify(
|
||||||
|
paire(
|
||||||
|
"res_partner",
|
||||||
|
"res_partner_name_index",
|
||||||
|
"res_partner__name_index",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertEqual("safe", verdict)
|
||||||
|
self.assertEqual("res_partner__name_index", garder)
|
||||||
|
|
||||||
|
|
||||||
|
class TestWhatItRefusesToTouch(unittest.TestCase):
|
||||||
|
"""S'abstenir est le cœur de cet outil, pas supprimer."""
|
||||||
|
|
||||||
|
def test_an_index_backing_a_constraint_is_left_alone(self):
|
||||||
|
# PostgreSQL le recréerait, ou la contrainte tomberait avec lui.
|
||||||
|
for a_ctr, b_ctr in ((True, False), (False, True), (True, True)):
|
||||||
|
with self.subTest(a=a_ctr, b=b_ctr):
|
||||||
|
verdict, garder, supprimer = idx.classify(
|
||||||
|
paire("m", "m__x_index", "m_x_index", a_ctr, b_ctr)
|
||||||
|
)
|
||||||
|
self.assertEqual("constraint", verdict)
|
||||||
|
self.assertIsNone(supprimer)
|
||||||
|
|
||||||
|
def test_two_legacy_names_are_a_human_decision(self):
|
||||||
|
verdict, _g, supprimer = idx.classify(
|
||||||
|
paire(
|
||||||
|
"mail_notification",
|
||||||
|
"mail_notification_email_status_index",
|
||||||
|
"mail_notification_notification_status_ind",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertEqual("ambiguous", verdict)
|
||||||
|
self.assertIsNone(supprimer)
|
||||||
|
|
||||||
|
def test_two_modern_names_are_a_human_decision(self):
|
||||||
|
# Vu en vrai : deux noms tronqués et suffixés d'un condensat, ou
|
||||||
|
# deux colonnes différentes tombées sur le même rang. On ne devine
|
||||||
|
# pas lequel Odoo recréera.
|
||||||
|
verdict, _g, supprimer = idx.classify(
|
||||||
|
paire(
|
||||||
|
"stock_move",
|
||||||
|
"stock_move__location_dest_id_index",
|
||||||
|
"stock_move__location_final_id_index",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertEqual("ambiguous", verdict)
|
||||||
|
self.assertIsNone(supprimer)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTheQueryItself(unittest.TestCase):
|
||||||
|
"""Les gardes vivent dans le SQL ; les retirer ne se verrait pas."""
|
||||||
|
|
||||||
|
def test_it_ignores_partial_and_computed_indexes(self):
|
||||||
|
# Deux index sur les mêmes colonnes n'y font pas le même travail.
|
||||||
|
self.assertIn("indpred IS NULL", idx.DETECTION)
|
||||||
|
self.assertIn("indexprs IS NULL", idx.DETECTION)
|
||||||
|
|
||||||
|
def test_it_never_looks_at_a_primary_key(self):
|
||||||
|
self.assertIn("NOT i.indisprimary", idx.DETECTION)
|
||||||
|
|
||||||
|
def test_it_compares_the_access_method(self):
|
||||||
|
# Un gin et un btree sur la même colonne ne se remplacent pas.
|
||||||
|
self.assertIn("amname", idx.DETECTION)
|
||||||
|
self.assertIn("a.methode = b.methode", idx.DETECTION)
|
||||||
|
|
||||||
|
def test_it_compares_the_operator_classes(self):
|
||||||
|
self.assertIn("indclass", idx.DETECTION)
|
||||||
|
self.assertIn("a.classes = b.classes", idx.DETECTION)
|
||||||
|
|
||||||
|
def test_it_compares_uniqueness(self):
|
||||||
|
self.assertIn("a.unique_ = b.unique_", idx.DETECTION)
|
||||||
|
|
||||||
|
def test_it_only_looks_at_valid_indexes(self):
|
||||||
|
self.assertIn("i.indisvalid", idx.DETECTION)
|
||||||
|
|
||||||
|
def test_it_pairs_each_couple_once(self):
|
||||||
|
# Sans cela chaque paire sortirait deux fois, et le compte
|
||||||
|
# annoncé serait le double du vrai.
|
||||||
|
self.assertIn("a.indexrelid < b.indexrelid", idx.DETECTION)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTheSqlItWrites(unittest.TestCase):
|
||||||
|
def lot(self):
|
||||||
|
return [
|
||||||
|
("safe", "m", "m__a_index", "m_a_index", 100, {}),
|
||||||
|
("safe", "m", "m__b_index", "m_b_index", 100, {}),
|
||||||
|
("constraint", "m", None, None, 0, {}),
|
||||||
|
("ambiguous", "m", None, None, 0, {}),
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_it_only_drops_what_it_called_safe(self):
|
||||||
|
sql = idx.drop_sql(self.lot())
|
||||||
|
self.assertEqual(2, sql.count("DROP INDEX"), sql)
|
||||||
|
self.assertIn("m_a_index", sql)
|
||||||
|
self.assertIn("m_b_index", sql)
|
||||||
|
|
||||||
|
def test_a_named_but_unsafe_entry_is_still_not_dropped(self):
|
||||||
|
# `classify` rend aujourd'hui (None, None) pour tout ce qui n'est
|
||||||
|
# pas sûr, ce qui masque la garde. Le jour où il nommera les
|
||||||
|
# ambiguës pour les AFFICHER, `drop_sql` ne doit pas se mettre à
|
||||||
|
# les supprimer : c'est le verdict qui décide, pas la présence
|
||||||
|
# d'un nom.
|
||||||
|
lot = [
|
||||||
|
("ambiguous", "m", "m__a_index", "m_a_index", 100, {}),
|
||||||
|
("constraint", "m", "m__b_index", "m_b_index", 100, {}),
|
||||||
|
]
|
||||||
|
self.assertEqual("", idx.drop_sql(lot))
|
||||||
|
|
||||||
|
def test_it_never_drops_what_it_keeps(self):
|
||||||
|
sql = idx.drop_sql(self.lot())
|
||||||
|
self.assertNotIn('"m__a_index"', sql)
|
||||||
|
|
||||||
|
def test_it_tolerates_an_index_already_gone(self):
|
||||||
|
# Une table à trois copies produit deux paires nommant le même
|
||||||
|
# index à supprimer ; la seconde passe ne doit pas échouer.
|
||||||
|
self.assertIn("IF EXISTS", idx.drop_sql(self.lot()))
|
||||||
|
|
||||||
|
def test_the_same_index_is_dropped_once(self):
|
||||||
|
lot = [
|
||||||
|
("safe", "m", "m__a_index", "m_a_index", 100, {}),
|
||||||
|
("safe", "m", "m__a2_index", "m_a_index", 100, {}),
|
||||||
|
]
|
||||||
|
self.assertEqual(1, idx.drop_sql(lot).count("DROP INDEX"))
|
||||||
|
|
||||||
|
def test_nothing_safe_writes_nothing(self):
|
||||||
|
self.assertEqual(
|
||||||
|
"", idx.drop_sql([("constraint", "m", None, None, 0, {})])
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTheReport(unittest.TestCase):
|
||||||
|
def test_a_clean_database_says_so(self):
|
||||||
|
self.assertIn("✅", "\n".join(idx.render([])))
|
||||||
|
|
||||||
|
def test_it_separates_the_safe_from_the_rest(self):
|
||||||
|
lot = [
|
||||||
|
("safe", "m", "m__a_index", "m_a_index", 16384, {}),
|
||||||
|
(
|
||||||
|
"constraint",
|
||||||
|
"m",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
0,
|
||||||
|
{"a": "x_uniq", "b": "name_uniq"},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
texte = "\n".join(idx.render(lot))
|
||||||
|
self.assertIn("m_a_index", texte)
|
||||||
|
self.assertIn("name_uniq", texte)
|
||||||
|
self.assertIn("2", texte)
|
||||||
|
|
||||||
|
def test_it_offers_the_flag_only_when_there_is_work(self):
|
||||||
|
rien = [("constraint", "m", None, None, 0, {"a": "x", "b": "y"})]
|
||||||
|
self.assertNotIn("--apply", "\n".join(idx.render(rien)))
|
||||||
|
travail = [("safe", "m", "m__a_index", "m_a_index", 0, {})]
|
||||||
|
self.assertIn("--apply", "\n".join(idx.render(travail)))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
@ -131,8 +131,10 @@ class TestTheRetryLoop(unittest.TestCase):
|
||||||
self.assertIn("OperationalError", "\n".join(journal.output))
|
self.assertIn("OperationalError", "\n".join(journal.output))
|
||||||
|
|
||||||
def test_the_probe_carries_the_password_and_touches_nothing(self):
|
def test_the_probe_carries_the_password_and_touches_nothing(self):
|
||||||
# `--list` ne modifie rien : valider ici évite d'échouer à
|
# Valider ici évite d'échouer à mi-parcours, une fois la base
|
||||||
# mi-parcours, une fois la base déjà supprimée.
|
# déjà supprimée. La sonde demande un `drop` sur un nom qui
|
||||||
|
# n'existe pas : `check_super` s'exécute AVANT `db_exists`, donc
|
||||||
|
# elle répond sans rien toucher.
|
||||||
self.branche(["bon"], [(True, "db1")])
|
self.branche(["bon"], [(True, "db1")])
|
||||||
self.lance()
|
self.lance()
|
||||||
self.assertEqual(len(self.sondes), 1)
|
self.assertEqual(len(self.sondes), 1)
|
||||||
|
|
@ -164,18 +166,90 @@ class TestTheWiring(unittest.TestCase):
|
||||||
src = self.source()
|
src = self.source()
|
||||||
self.assertIn("master_password = ask_master_password(arg_base)", src)
|
self.assertIn("master_password = ask_master_password(arg_base)", src)
|
||||||
|
|
||||||
def test_the_probe_uses_list_which_changes_nothing(self):
|
def bloc_sonde(self):
|
||||||
src = self.source()
|
"""Le CODE de la sonde, docstring retirée.
|
||||||
debut = src.index("def probe_master_password")
|
|
||||||
fin = src.index("def ask_master_password")
|
La docstring EXPLIQUE pourquoi `--list` et `--restore` ne
|
||||||
bloc = src[debut:fin]
|
conviennent pas : un test qui lit tout le texte les y trouve et
|
||||||
self.assertIn("--list", bloc)
|
se croit trompé. Troisième fois que ce piège se referme sur moi
|
||||||
for danger in ("--drop", "--restore", "--clone"):
|
dans ce dépôt — voir tasks/lessons.md.
|
||||||
|
"""
|
||||||
|
import ast
|
||||||
|
|
||||||
|
for noeud in ast.walk(ast.parse(self.source())):
|
||||||
|
if (
|
||||||
|
isinstance(noeud, ast.FunctionDef)
|
||||||
|
and noeud.name == "probe_master_password"
|
||||||
|
):
|
||||||
|
sans_texte = [
|
||||||
|
n
|
||||||
|
for n in noeud.body
|
||||||
|
if not (
|
||||||
|
isinstance(n, ast.Expr)
|
||||||
|
and isinstance(n.value, ast.Constant)
|
||||||
|
and isinstance(n.value.value, str)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
return ast.dump(ast.Module(body=sans_texte, type_ignores=[]))
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def test_the_scan_finds_the_probe_at_all(self):
|
||||||
|
# Sans cette borne, les tests suivants passeraient sur une chaîne
|
||||||
|
# vide le jour où la fonction est renommée.
|
||||||
|
self.assertTrue(self.bloc_sonde())
|
||||||
|
|
||||||
|
def test_the_probe_uses_the_only_action_that_reads_the_password(self):
|
||||||
|
# `--list` ne LIT jamais le mot de passe : mesuré,
|
||||||
|
# MASTER_PWD="ceci_est_faux" … db --list sort en 0. La sonde
|
||||||
|
# d'avant acceptait donc le premier mot saisi, et la boucle des
|
||||||
|
# dix essais ne servait à rien. Seul `drop` consulte le secret.
|
||||||
|
bloc = self.bloc_sonde()
|
||||||
|
self.assertIn("--drop", bloc)
|
||||||
|
self.assertNotIn("--list", bloc)
|
||||||
|
|
||||||
|
def test_it_never_names_a_database_that_could_exist(self):
|
||||||
|
# La sonde DEMANDE une suppression : sur un vrai nom et avec un
|
||||||
|
# bon mot de passe, on perdrait la base. Le nom vient d'un uuid.
|
||||||
|
bloc = self.bloc_sonde()
|
||||||
|
# L'arbre nomme les appels : `Call(func=Name(id='probe_name'))`.
|
||||||
|
self.assertIn("id='probe_name'", bloc)
|
||||||
|
# …et jamais la base que l'appelant vise.
|
||||||
|
self.assertNotIn("id='database'", bloc)
|
||||||
|
|
||||||
|
def test_it_never_asks_to_restore_or_clone(self):
|
||||||
|
bloc = self.bloc_sonde()
|
||||||
|
for danger in ("--restore", "--clone"):
|
||||||
self.assertNotIn(danger, bloc)
|
self.assertNotIn(danger, bloc)
|
||||||
|
|
||||||
def test_the_bound_is_ten(self):
|
def test_the_bound_is_ten(self):
|
||||||
self.assertEqual(db_restore.MAX_ESSAIS_MOT_DE_PASSE, 10)
|
self.assertEqual(db_restore.MAX_ESSAIS_MOT_DE_PASSE, 10)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTheProbeName(unittest.TestCase):
|
||||||
|
"""Le nom que la sonde demande de supprimer.
|
||||||
|
|
||||||
|
C'est le seul endroit de cet outil où une erreur coûterait une base.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_it_is_prefixed_so_a_log_says_where_it_came_from(self):
|
||||||
|
self.assertTrue(db_restore.probe_name().startswith("el_probe_"))
|
||||||
|
|
||||||
|
def test_two_calls_never_give_the_same_name(self):
|
||||||
|
noms = {db_restore.probe_name() for _ in range(50)}
|
||||||
|
self.assertEqual(50, len(noms))
|
||||||
|
|
||||||
|
def test_it_is_long_enough_to_be_unguessable(self):
|
||||||
|
# Un uuid4 en hexadécimal : 32 caractères après le préfixe.
|
||||||
|
self.assertGreaterEqual(
|
||||||
|
len(db_restore.probe_name()), len("el_probe_") + 32
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_it_is_a_legal_postgresql_identifier(self):
|
||||||
|
# Un nom que PostgreSQL refuserait ferait échouer la sonde pour
|
||||||
|
# une raison qui n'a rien à voir avec le mot de passe, et l'on
|
||||||
|
# redemanderait dix fois un secret parfaitement bon.
|
||||||
|
self.assertRegex(db_restore.probe_name(), r"^[a-z][a-z0-9_]{0,62}$")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
|
|
@ -218,11 +218,30 @@ class TestTheExitCodesTheDriverRelieson(unittest.TestCase):
|
||||||
def test_check_cow_views_reports_findings_with_1(self):
|
def test_check_cow_views_reports_findings_with_1(self):
|
||||||
# Le chemin qui compte demande une base ; on vérifie la décision
|
# Le chemin qui compte demande une base ; on vérifie la décision
|
||||||
# elle-même, à la source.
|
# elle-même, à la source.
|
||||||
|
#
|
||||||
|
# On lit le RETURN, pas une ligne recopiée : l'outil a gagné une
|
||||||
|
# seconde famille de trouvailles (les copies qui rendront 500),
|
||||||
|
# et figer le texte faisait échouer le test sur un ajout juste.
|
||||||
|
# Ce qui doit rester vrai, c'est que CHAQUE famille pèse sur le
|
||||||
|
# code de sortie.
|
||||||
|
import ast
|
||||||
|
|
||||||
path = os.path.join(
|
path = os.path.join(
|
||||||
REPO, "script", "odoo", "migration", "check_cow_views.py"
|
REPO, "script", "odoo", "migration", "check_cow_views.py"
|
||||||
)
|
)
|
||||||
with open(path) as handle:
|
with open(path) as handle:
|
||||||
self.assertIn("return 1 if lst_at_risk else 0", handle.read())
|
arbre = ast.parse(handle.read())
|
||||||
|
retours = [
|
||||||
|
ast.dump(noeud)
|
||||||
|
for fonction in ast.walk(arbre)
|
||||||
|
if isinstance(fonction, ast.FunctionDef)
|
||||||
|
and fonction.name == "main"
|
||||||
|
for noeud in ast.walk(fonction)
|
||||||
|
if isinstance(noeud, ast.Return)
|
||||||
|
]
|
||||||
|
decision = [r for r in retours if "lst_at_risk" in r]
|
||||||
|
self.assertEqual(1, len(decision), retours)
|
||||||
|
self.assertIn("lst_no_render", decision[0])
|
||||||
|
|
||||||
|
|
||||||
class TestAllToolsKeepWorkingStandalone(unittest.TestCase):
|
class TestAllToolsKeepWorkingStandalone(unittest.TestCase):
|
||||||
|
|
|
||||||
|
|
@ -314,6 +314,19 @@ class TestTheRealBundle(unittest.TestCase):
|
||||||
"compilé SANS les dépôts (manifeste vide) :"
|
"compilé SANS les dépôts (manifeste vide) :"
|
||||||
" relancer ./mobile/compile_and_run.sh pour les inclure"
|
" relancer ./mobile/compile_and_run.sh pour les inclure"
|
||||||
)
|
)
|
||||||
|
# Manifeste PLEIN mais index MANQUANTS : un transfert interrompu, ou
|
||||||
|
# un build qui a écrit le manifeste avant les paquets. Vu le
|
||||||
|
# 24 août 2026 — « <slug> : index.json absent » remontait en ERREUR,
|
||||||
|
# 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.
|
||||||
|
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():
|
||||||
|
raise unittest.SkipTest(
|
||||||
|
f"paquet incomplet ({slug} sans index.json) :"
|
||||||
|
" relancer ./mobile/compile_and_run.sh"
|
||||||
|
)
|
||||||
|
|
||||||
def test_the_transfer_is_coherent(self):
|
def test_the_transfer_is_coherent(self):
|
||||||
rep = cbt.check(MOBILE, REPO)
|
rep = cbt.check(MOBILE, REPO)
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ from unittest import mock
|
||||||
sys.argv = ["todo.py"]
|
sys.argv = ["todo.py"]
|
||||||
from script.proxmox import proxmox_deploy as pve # noqa: E402
|
from script.proxmox import proxmox_deploy as pve # noqa: E402
|
||||||
from script.todo.todo import TODO # noqa: E402
|
from script.todo.todo import TODO # noqa: E402
|
||||||
|
from script.todo.todo_i18n import t # noqa: E402
|
||||||
|
|
||||||
# Sorties RÉELLES relevées sur l'hôte d'essai.
|
# Sorties RÉELLES relevées sur l'hôte d'essai.
|
||||||
PVEVERSION = (
|
PVEVERSION = (
|
||||||
|
|
@ -42,6 +43,17 @@ AVERTISSEMENT = (
|
||||||
"Warning: Permanently added '192.168.123.227' (ED25519) to the list "
|
"Warning: Permanently added '192.168.123.227' (ED25519) to the list "
|
||||||
"of known hosts.\n"
|
"of known hosts.\n"
|
||||||
)
|
)
|
||||||
|
SPEC_VM = {
|
||||||
|
"name": "essai",
|
||||||
|
"memory": 512,
|
||||||
|
"vcpus": 1,
|
||||||
|
"disk": "4G",
|
||||||
|
"storage": "local",
|
||||||
|
"bridge": "vmbr0",
|
||||||
|
"image": "debian-13.qcow2",
|
||||||
|
"user": "erplibre",
|
||||||
|
"start": True,
|
||||||
|
}
|
||||||
QM_LIST = """ VMID NAME STATUS MEM(MB) BOOTDISK(GB) PID
|
QM_LIST = """ VMID NAME STATUS MEM(MB) BOOTDISK(GB) PID
|
||||||
100 vm-essai running 2048 16.00 2726
|
100 vm-essai running 2048 16.00 2726
|
||||||
101 avec un espace stopped 4096 32.00 0
|
101 avec un espace stopped 4096 32.00 0
|
||||||
|
|
@ -151,6 +163,143 @@ class TestLectureDesSorties(unittest.TestCase):
|
||||||
self.assertIn("999", orph[0][0])
|
self.assertIn("999", orph[0][0])
|
||||||
|
|
||||||
|
|
||||||
|
class TestLeBruitDeSsh(unittest.TestCase):
|
||||||
|
"""Ce que ssh ajoute n'est pas la réponse de l'hôte.
|
||||||
|
|
||||||
|
Le cas vécu, du début à la fin : « ip -o link show type bridge » ne rend
|
||||||
|
RIEN sur un hôte sans pont, la sortie ne contient donc que
|
||||||
|
l'avertissement de ssh sur la clé — que `parse_bridges` a pris pour un nom
|
||||||
|
de pont. « (ED25519) » s'est retrouvé dans « --net0 virtio,bridge=… »,
|
||||||
|
enrobé de « sudo sh -c », et dash a répondu :
|
||||||
|
|
||||||
|
sh: 1: Syntax error: "(" unexpected
|
||||||
|
|
||||||
|
Trois lignes de code entre la cause et un message incompréhensible.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_the_warning_never_becomes_a_bridge(self):
|
||||||
|
self.assertEqual(pve.parse_bridges(AVERTISSEMENT), [])
|
||||||
|
|
||||||
|
def test_real_bridges_are_still_read(self):
|
||||||
|
vrai = (
|
||||||
|
"2: vmbr0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc "
|
||||||
|
"noqueue state UP mode DEFAULT group default qlen 1000\\ "
|
||||||
|
"link/ether bc:24:11:00:00:01\n"
|
||||||
|
"3: vmbr1: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN\n"
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
pve.parse_bridges(AVERTISSEMENT + vrai), ["vmbr0", "vmbr1"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_a_veth_pair_keeps_only_its_own_name(self):
|
||||||
|
ligne = "7: fwln100i0@fwpr100p0: <BROADCAST,MULTICAST,UP> mtu 1500\n"
|
||||||
|
self.assertEqual(pve.parse_bridges(ligne), ["fwln100i0"])
|
||||||
|
|
||||||
|
def test_the_noise_is_stripped_at_the_source(self):
|
||||||
|
for bruit in (
|
||||||
|
AVERTISSEMENT,
|
||||||
|
"Pseudo-terminal will not be allocated because stdin is not a terminal.\n",
|
||||||
|
"Connection to 10.0.0.5 closed.\n",
|
||||||
|
"Shared connection to 10.0.0.5 closed.\n",
|
||||||
|
"mesg: ttyname failed: Inappropriate ioctl for device\n",
|
||||||
|
):
|
||||||
|
self.assertEqual(pve.strip_ssh_noise(bruit), "")
|
||||||
|
self.assertEqual(
|
||||||
|
pve.strip_ssh_noise(AVERTISSEMENT + "vmbr0\n"), "vmbr0\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_the_answer_survives_the_filter(self):
|
||||||
|
# Un filtre qui mange la réponse serait pire que le bruit.
|
||||||
|
self.assertIn(
|
||||||
|
"pve-manager", pve.strip_ssh_noise(AVERTISSEMENT + PVEVERSION)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLeNoyau(unittest.TestCase):
|
||||||
|
"""Tant que l'hôte tourne le noyau de la distribution, il n'a ni module
|
||||||
|
bridge ni table NAT : ifupdown2 répond « Operation not supported », et
|
||||||
|
quand /run/network manque il répond même « Another instance of this
|
||||||
|
program is already running » — un mensonge. Vécu sur l'hôte d'essai."""
|
||||||
|
|
||||||
|
def test_the_running_kernel_is_read_from_pveversion(self):
|
||||||
|
self.assertEqual(
|
||||||
|
pve.parse_kernel(
|
||||||
|
"pve-manager/9.2.11/abc (running kernel: 6.12.95+deb13-cloud-amd64)"
|
||||||
|
),
|
||||||
|
"6.12.95+deb13-cloud-amd64",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
pve.parse_kernel(
|
||||||
|
"pve-manager/9.2.11/abc (running kernel: 7.0.14-12-pve)"
|
||||||
|
),
|
||||||
|
"7.0.14-12-pve",
|
||||||
|
)
|
||||||
|
self.assertEqual(pve.parse_kernel("n'importe quoi"), "")
|
||||||
|
|
||||||
|
def test_the_bridge_creates_the_lock_directory_first(self):
|
||||||
|
# Sans /run/network, ifupdown2 accuse une autre instance et le pont
|
||||||
|
# ne monte jamais.
|
||||||
|
montee = pve.bridge_setup_cmds("vmbr0", "10.10.10.1/24", "enp1s0")[-1]
|
||||||
|
self.assertIn("mkdir -p /run/network", montee)
|
||||||
|
# Et l'erreur d'ifup n'est plus masquée : c'est elle qui explique.
|
||||||
|
self.assertNotIn("2>/dev/null", montee)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLeDns(unittest.TestCase):
|
||||||
|
"""« --ipconfig0 » ne porte pas le DNS : une VM en adresse fixe se
|
||||||
|
retrouvait sans résolveur. Mesuré sur la VM d'essai — le NAT routait, mais
|
||||||
|
« getent hosts deb.debian.org » ne rendait rien."""
|
||||||
|
|
||||||
|
def test_the_resolved_stub_is_useless_to_a_guest(self):
|
||||||
|
self.assertEqual(pve.parse_nameservers("nameserver 127.0.0.53"), [])
|
||||||
|
|
||||||
|
def test_real_resolvers_are_kept_in_order(self):
|
||||||
|
self.assertEqual(
|
||||||
|
pve.parse_nameservers(
|
||||||
|
"nameserver 192.168.123.1\nnameserver 1.1.1.1\n"
|
||||||
|
"nameserver 192.168.123.1\n"
|
||||||
|
),
|
||||||
|
["192.168.123.1", "1.1.1.1"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_a_static_address_gets_the_resolvers(self):
|
||||||
|
spec = dict(
|
||||||
|
SPEC_VM,
|
||||||
|
ipconfig="ip=10.10.10.150/24,gw=10.10.10.1",
|
||||||
|
nameservers=["192.168.123.1"],
|
||||||
|
)
|
||||||
|
ci = [c for c in pve.create_cmds(100, spec) if "--ciuser" in c][0]
|
||||||
|
self.assertIn("--nameserver 192.168.123.1", ci)
|
||||||
|
|
||||||
|
def test_dhcp_needs_none(self):
|
||||||
|
# Le bail DHCP porte déjà le DNS.
|
||||||
|
spec = dict(SPEC_VM, ipconfig="ip=dhcp", nameservers=["192.168.123.1"])
|
||||||
|
ci = [c for c in pve.create_cmds(100, spec) if "--ciuser" in c][0]
|
||||||
|
self.assertNotIn("--nameserver", ci)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLAvancement(unittest.TestCase):
|
||||||
|
"""Cent lignes « transferred … » enterraient l'erreur utile : le journal du
|
||||||
|
premier essai réel faisait 136 lignes pour 34 utiles."""
|
||||||
|
|
||||||
|
def test_a_burst_collapses_to_one_line(self):
|
||||||
|
texte = (
|
||||||
|
"Formatting 'disk.raw'\n"
|
||||||
|
+ "".join(
|
||||||
|
f"transferred {i}.0 MiB of 3.0 GiB ({i}%)\n" for i in range(50)
|
||||||
|
)
|
||||||
|
+ "scsi0: successfully created disk\n"
|
||||||
|
)
|
||||||
|
propre = pve.collapse_progress(texte)
|
||||||
|
self.assertIn("50 lignes d'avancement", propre)
|
||||||
|
self.assertIn("successfully created disk", propre)
|
||||||
|
self.assertLess(len(propre.splitlines()), 6)
|
||||||
|
|
||||||
|
def test_what_is_not_progress_is_untouched(self):
|
||||||
|
texte = "400 Parameter verification failed.\nnet0: invalid format\n"
|
||||||
|
self.assertEqual(pve.collapse_progress(texte).strip(), texte.strip())
|
||||||
|
|
||||||
|
|
||||||
class TestLesChoix(unittest.TestCase):
|
class TestLesChoix(unittest.TestCase):
|
||||||
def test_the_vmid_skips_the_taken_ones(self):
|
def test_the_vmid_skips_the_taken_ones(self):
|
||||||
"""Proxmox refuse un VMID pris, et le dit APRÈS le téléchargement de
|
"""Proxmox refuse un VMID pris, et le dit APRÈS le téléchargement de
|
||||||
|
|
@ -359,10 +508,13 @@ class TestChoixDeLHote(unittest.TestCase):
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
self.assertIsNone(host)
|
self.assertIsNone(host)
|
||||||
self.assertIn("joignable", sortie.lower())
|
# Comparé à la TRADUCTION, pas à un mot français : la langue de
|
||||||
|
# l'interface se change (EL_LANG), et un test qui la suppose échoue
|
||||||
|
# pour une raison qui n'a rien à voir avec ce qu'il vérifie.
|
||||||
|
self.assertIn(t("Reachable, but Proxmox VE is not there:"), sortie)
|
||||||
self.assertIn("install_proxmox.sh", sortie)
|
self.assertIn("install_proxmox.sh", sortie)
|
||||||
# Et surtout : ne plus envoyer chercher un problème de réseau.
|
# Et surtout : ne plus envoyer chercher un problème de réseau.
|
||||||
self.assertNotIn("SSH ne passe pas", sortie)
|
self.assertNotIn(t("SSH does not get through:"), sortie)
|
||||||
|
|
||||||
def test_an_unreachable_machine_says_ssh_does_not_get_through(self):
|
def test_an_unreachable_machine_says_ssh_does_not_get_through(self):
|
||||||
panne = "ssh: connect to host 10.0.0.9 port 22: No route to host"
|
panne = "ssh: connect to host 10.0.0.9 port 22: No route to host"
|
||||||
|
|
|
||||||
|
|
@ -400,5 +400,197 @@ class TestEcran(unittest.TestCase):
|
||||||
self.assertIsNone(self._rendu(gestes).result)
|
self.assertIsNone(self._rendu(gestes).result)
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(TEXTUAL, "Textual absent")
|
||||||
|
class TestCreerUnPont(unittest.TestCase):
|
||||||
|
"""Sans pont, « qm create » est impossible — et l'écran refusait de
|
||||||
|
déployer sans offrir le moindre moyen d'en avoir un. Rapporté.
|
||||||
|
|
||||||
|
Le pont INTERNE se crée depuis l'écran parce qu'il ne touche à aucune
|
||||||
|
interface physique : il n'y a rien à faire arbitrer. Un pont sur le LAN
|
||||||
|
déplace l'adresse de l'hôte et coupe la session : il reste manuel.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _ecran(self, fabrique, ponts=()):
|
||||||
|
from script.todo.proxmox_deploy_form import (
|
||||||
|
CREER_PONT,
|
||||||
|
run_proxmox_form,
|
||||||
|
)
|
||||||
|
|
||||||
|
ctx = contexte()
|
||||||
|
ctx["bridges"] = list(ponts)
|
||||||
|
ctx["bridge"] = ponts[0] if ponts else ""
|
||||||
|
ctx["make_bridge"] = fabrique
|
||||||
|
ctx["internal_bridge"] = ("vmbr0", "10.10.10.1/24")
|
||||||
|
vu = {}
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
from textual.widgets import Select
|
||||||
|
|
||||||
|
app = run_proxmox_form(ctx, run_app=False)
|
||||||
|
async with app.run_test(size=(200, 55)) as pilote:
|
||||||
|
await pilote.pause()
|
||||||
|
selecteur = app.query_one("#f_bridge", Select)
|
||||||
|
vu["choix_avant"] = [str(o[1]) for o in selecteur._options]
|
||||||
|
selecteur.value = CREER_PONT
|
||||||
|
for _ in range(30):
|
||||||
|
await pilote.pause()
|
||||||
|
if vu.get("fait"):
|
||||||
|
break
|
||||||
|
vu["fait"] = bool(app._ponts) and app._bridge()
|
||||||
|
await pilote.pause()
|
||||||
|
vu["pont"] = app._bridge()
|
||||||
|
vu["choix_apres"] = [str(o[1]) for o in selecteur._options]
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
return vu
|
||||||
|
|
||||||
|
def test_the_entry_is_offered_when_no_bridge_exists(self):
|
||||||
|
vu = self._ecran(lambda: ("vmbr0", ""))
|
||||||
|
self.assertIn("__creer_pont__", vu["choix_avant"])
|
||||||
|
|
||||||
|
def test_choosing_it_creates_the_bridge_and_selects_it(self):
|
||||||
|
vu = self._ecran(lambda: ("vmbr0", ""))
|
||||||
|
self.assertEqual(vu["pont"], "vmbr0")
|
||||||
|
self.assertIn("vmbr0", vu["choix_apres"])
|
||||||
|
|
||||||
|
def test_a_failure_leaves_no_bridge_selected(self):
|
||||||
|
# Laissé sur « créer », le sélecteur ferait déployer une VM sur
|
||||||
|
# « __creer_pont__ » — un nom que « qm create » refuserait.
|
||||||
|
vu = self._ecran(lambda: ("", "Operation not supported"))
|
||||||
|
self.assertEqual(vu["pont"], "")
|
||||||
|
|
||||||
|
def test_the_entry_stays_offered_when_a_bridge_exists(self):
|
||||||
|
# Un hôte avec un seul pont sur le LAN : on peut vouloir un réseau
|
||||||
|
# interne pour un parc d'essai.
|
||||||
|
vu = self._ecran(lambda: ("vmbr0", ""), ponts=("vmbr9",))
|
||||||
|
self.assertIn("__creer_pont__", vu["choix_avant"])
|
||||||
|
self.assertIn("vmbr9", vu["choix_avant"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestLeSuivi(unittest.TestCase):
|
||||||
|
"""La case « Suivre l'installation » doit commander quelque chose.
|
||||||
|
|
||||||
|
Elle 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. Le suivi
|
||||||
|
vient du DÉPLOIEMENT, pas de l'installation — c'est la règle déjà tirée du
|
||||||
|
côté QEMU/KVM après le même rapport.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _apres_creation(self, install, monitor):
|
||||||
|
"""Rejoue l'épilogue du déploiement et dit quelle voie a été prise."""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.argv = ["todo.py"]
|
||||||
|
from script.todo.todo import TODO
|
||||||
|
|
||||||
|
todo = TODO.__new__(TODO)
|
||||||
|
vus = {}
|
||||||
|
todo._qemu_install_erplibre_monitored = lambda *a, **k: vus.setdefault(
|
||||||
|
"tableau", a
|
||||||
|
)
|
||||||
|
todo._qemu_install_erplibre_vm = lambda *a, **k: vus.setdefault(
|
||||||
|
"serie", a
|
||||||
|
)
|
||||||
|
todo._write_ssh_config_entry = lambda *a, **k: None
|
||||||
|
todo._ssh_private_key = lambda k: None
|
||||||
|
todo._pve_guest_ip = lambda vmid, attente=120: ""
|
||||||
|
spec = {
|
||||||
|
"host": {"target": "pve1"},
|
||||||
|
"vms": [
|
||||||
|
{
|
||||||
|
"name": "vm-a",
|
||||||
|
"vmid": 100,
|
||||||
|
"ipconfig": "ip=10.10.10.150/24,gw=10.10.10.1",
|
||||||
|
"install_cmd": "",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"add_ssh_config": False,
|
||||||
|
"user": "erplibre",
|
||||||
|
"install": install,
|
||||||
|
"monitor": monitor,
|
||||||
|
}
|
||||||
|
import contextlib
|
||||||
|
import io
|
||||||
|
|
||||||
|
with contextlib.redirect_stdout(io.StringIO()):
|
||||||
|
todo._pve_after_create(spec["host"], spec, ["vm-a"], "")
|
||||||
|
return vus
|
||||||
|
|
||||||
|
def test_ticked_without_anything_to_install_still_opens_it(self):
|
||||||
|
# La commande distante regarde alors la VM ARRIVER : c'est justement
|
||||||
|
# ce qu'on veut voir sur une VM déployée nue.
|
||||||
|
vus = self._apres_creation(install=None, monitor=True)
|
||||||
|
self.assertIn("tableau", vus)
|
||||||
|
self.assertNotIn("serie", vus)
|
||||||
|
|
||||||
|
def test_unticked_installs_without_the_dashboard(self):
|
||||||
|
vus = self._apres_creation(
|
||||||
|
install={"branch": "develop", "cmd": "make x", "label": "X"},
|
||||||
|
monitor=False,
|
||||||
|
)
|
||||||
|
self.assertIn("serie", vus)
|
||||||
|
self.assertNotIn("tableau", vus)
|
||||||
|
|
||||||
|
def test_unticked_and_nothing_to_install_does_nothing(self):
|
||||||
|
self.assertEqual(self._apres_creation(install=None, monitor=False), {})
|
||||||
|
|
||||||
|
def test_the_ssh_entry_is_written_when_the_install_needs_it(self):
|
||||||
|
"""La VM est derrière l'hôte : le rebond de ~/.ssh/config est le SEUL
|
||||||
|
chemin. Décoché alors qu'une installation est demandée, le suivi ne
|
||||||
|
pouvait pas entrer dans la VM."""
|
||||||
|
import contextlib
|
||||||
|
import io
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.argv = ["todo.py"]
|
||||||
|
from script.todo.todo import TODO
|
||||||
|
|
||||||
|
def essai(add_ssh_config, install, monitor):
|
||||||
|
todo = TODO.__new__(TODO)
|
||||||
|
ecrites = []
|
||||||
|
todo._write_ssh_config_entry = lambda nom, *a, **k: ecrites.append(
|
||||||
|
nom
|
||||||
|
)
|
||||||
|
todo._ssh_private_key = lambda k: None
|
||||||
|
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
|
||||||
|
spec = {
|
||||||
|
"host": {"target": "pve1"},
|
||||||
|
"vms": [
|
||||||
|
{
|
||||||
|
"name": "vm-a",
|
||||||
|
"vmid": 100,
|
||||||
|
"ipconfig": "ip=10.10.10.150/24,gw=10.10.10.1",
|
||||||
|
"install_cmd": "",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"add_ssh_config": add_ssh_config,
|
||||||
|
"user": "erplibre",
|
||||||
|
"install": install,
|
||||||
|
"monitor": monitor,
|
||||||
|
}
|
||||||
|
with contextlib.redirect_stdout(io.StringIO()):
|
||||||
|
todo._pve_after_create(spec["host"], spec, ["vm-a"], "")
|
||||||
|
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"])
|
||||||
|
# Décoché, suivi demandé : le suivi entre aussi par le rebond.
|
||||||
|
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"])
|
||||||
|
|
||||||
|
def test_ticked_with_an_install_opens_it(self):
|
||||||
|
vus = self._apres_creation(
|
||||||
|
install={"branch": "develop", "cmd": "make x", "label": "X"},
|
||||||
|
monitor=True,
|
||||||
|
)
|
||||||
|
self.assertIn("tableau", vus)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main(verbosity=2)
|
unittest.main(verbosity=2)
|
||||||
|
|
|
||||||
315
test/test_qemu_cleanup.py
Normal file
315
test/test_qemu_cleanup.py
Normal file
|
|
@ -0,0 +1,315 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||||
|
"""Le nettoyage ne doit jamais proposer un fichier EN USAGE.
|
||||||
|
|
||||||
|
Rapporté, et c'est le pire défaut possible ici : après avoir renommé une VM
|
||||||
|
« erplibre-ubuntu-2404 » en « erplibre-ubuntu-2404-MIGRATION », le nettoyage
|
||||||
|
offrait au « rm -f » son disque de 63 Go, son seed et son nvram — tous les
|
||||||
|
trois attachés à une VM EN MARCHE. La cause : l'orphelinat se jugeait sur le
|
||||||
|
NOM du fichier comparé aux noms de domaines.
|
||||||
|
|
||||||
|
Le nom ne dit rien de l'usage. L'autorité, c'est libvirt : ce qu'un domaine
|
||||||
|
référence n'est pas orphelin, quel que soit son nom. Et devant un « rm -f »
|
||||||
|
de 63 Go, un second contrôle indépendant (les fichiers qu'un processus tient
|
||||||
|
ouverts) vaut le coup.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
sys.argv = ["todo.py"]
|
||||||
|
from script.todo.todo import TODO # noqa: E402
|
||||||
|
|
||||||
|
# La forme RÉELLE, relevée sur la machine : le nvram porte un attribut
|
||||||
|
# « template », et c'est ce qui l'avait fait manquer d'un premier filtre.
|
||||||
|
XML_MIGRATION = """<domain type='kvm'>
|
||||||
|
<name>erplibre-ubuntu-2404-MIGRATION</name>
|
||||||
|
<os firmware='efi'>
|
||||||
|
<loader readonly='yes' type='pflash'>/usr/share/OVMF/OVMF_CODE_4M.fd</loader>
|
||||||
|
<nvram template='/usr/share/OVMF/OVMF_VARS_4M.fd'>/var/lib/libvirt/qemu/nvram/erplibre-ubuntu-2404_VARS.fd</nvram>
|
||||||
|
</os>
|
||||||
|
<devices>
|
||||||
|
<disk type='file' device='disk'>
|
||||||
|
<source file='/var/lib/libvirt/images/erplibre-ubuntu-2404.qcow2'/>
|
||||||
|
<target dev='vda' bus='virtio'/>
|
||||||
|
</disk>
|
||||||
|
<disk type='file' device='disk'>
|
||||||
|
<source file='/var/lib/libvirt/images/iso/erplibre-ubuntu-2404-seed.iso'/>
|
||||||
|
<target dev='vdb' bus='virtio'/>
|
||||||
|
</disk>
|
||||||
|
<interface type='network'>
|
||||||
|
<source network='default'/>
|
||||||
|
</interface>
|
||||||
|
</devices>
|
||||||
|
</domain>
|
||||||
|
"""
|
||||||
|
DISQUE = "/var/lib/libvirt/images/erplibre-ubuntu-2404.qcow2"
|
||||||
|
SEED = "/var/lib/libvirt/images/iso/erplibre-ubuntu-2404-seed.iso"
|
||||||
|
NVRAM = "/var/lib/libvirt/qemu/nvram/erplibre-ubuntu-2404_VARS.fd"
|
||||||
|
|
||||||
|
|
||||||
|
def todo_avec(xml_par_domaine, ouverts=()):
|
||||||
|
"""Une instance TODO dont libvirt et /proc sont remplacés par des faits."""
|
||||||
|
todo = TODO.__new__(TODO)
|
||||||
|
todo._qemu_list_domains = lambda: list(xml_par_domaine)
|
||||||
|
todo._qemu_dumpxml = lambda nom, inactive=True: xml_par_domaine.get(
|
||||||
|
nom, ""
|
||||||
|
)
|
||||||
|
todo._qemu_files_in_use = lambda: set(ouverts)
|
||||||
|
return todo
|
||||||
|
|
||||||
|
|
||||||
|
class TestCeQueLesDomainesReferencent(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.todo = todo_avec(
|
||||||
|
{"erplibre-ubuntu-2404-MIGRATION": XML_MIGRATION}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_a_renamed_domain_still_owns_its_files(self):
|
||||||
|
# Le cœur du rapport : le nom du fichier n'a plus rien à voir avec le
|
||||||
|
# nom du domaine, et c'est parfaitement normal après un renommage.
|
||||||
|
refs = self.todo._qemu_referenced_files()
|
||||||
|
for chemin in (DISQUE, SEED, NVRAM):
|
||||||
|
self.assertEqual(
|
||||||
|
refs.get(chemin), "erplibre-ubuntu-2404-MIGRATION", chemin
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_the_nvram_is_read_despite_its_attribute(self):
|
||||||
|
# « <nvram template='…'>chemin</nvram> » : attendre « <nvram> » nu
|
||||||
|
# manquait le fichier le plus facile à perdre.
|
||||||
|
self.assertIn(NVRAM, self.todo._qemu_referenced_files())
|
||||||
|
|
||||||
|
def test_the_network_source_is_not_a_file(self):
|
||||||
|
# « <source network='default'/> » ne doit pas entrer dans la liste.
|
||||||
|
for chemin in self.todo._qemu_referenced_files():
|
||||||
|
self.assertTrue(chemin.startswith("/"), chemin)
|
||||||
|
|
||||||
|
def test_a_domain_without_xml_is_simply_skipped(self):
|
||||||
|
todo = todo_avec({"fantome": ""})
|
||||||
|
self.assertEqual(todo._qemu_referenced_files(), {})
|
||||||
|
|
||||||
|
|
||||||
|
class TestLeTri(unittest.TestCase):
|
||||||
|
"""(orphelins, protégés) : c'est ce tri qui décide de ce qui est effacé."""
|
||||||
|
|
||||||
|
def _tri(self, candidats, xml=None, ouverts=()):
|
||||||
|
todo = todo_avec(
|
||||||
|
xml if xml is not None else {"vm-a": XML_MIGRATION}, ouverts
|
||||||
|
)
|
||||||
|
return todo._qemu_split_orphans(candidats)
|
||||||
|
|
||||||
|
def test_a_referenced_file_is_never_an_orphan(self):
|
||||||
|
orph, prot = self._tri([(63, DISQUE, "disque orphelin")])
|
||||||
|
self.assertEqual(orph, [])
|
||||||
|
self.assertEqual(prot[0][2], "vm-a")
|
||||||
|
|
||||||
|
def test_an_unreferenced_file_stays_an_orphan(self):
|
||||||
|
perdu = "/var/lib/libvirt/images/plus-personne.qcow2"
|
||||||
|
orph, prot = self._tri([(1, perdu, "disque orphelin")])
|
||||||
|
self.assertEqual([o[1] for o in orph], [perdu])
|
||||||
|
self.assertEqual(prot, [])
|
||||||
|
|
||||||
|
def test_a_file_held_open_is_protected_even_without_libvirt(self):
|
||||||
|
# Un qemu lancé à la main, ou une définition que libvirt a perdue :
|
||||||
|
# le fichier est ouvert, donc il n'est pas à jeter.
|
||||||
|
perdu = "/var/lib/libvirt/images/lancee-a-la-main.qcow2"
|
||||||
|
orph, prot = self._tri(
|
||||||
|
[(1, perdu, "disque orphelin")], xml={}, ouverts=(perdu,)
|
||||||
|
)
|
||||||
|
self.assertEqual(orph, [])
|
||||||
|
self.assertIn(
|
||||||
|
prot[0][2], (perdu, "un processus en cours", "a running process")
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_the_three_files_of_the_report_are_all_kept(self):
|
||||||
|
candidats = [
|
||||||
|
(63_000_000_000, DISQUE, "disque orphelin"),
|
||||||
|
(528_000, NVRAM, "nvram orpheline"),
|
||||||
|
(368_000, SEED, "seed orphelin"),
|
||||||
|
]
|
||||||
|
orph, prot = self._tri(candidats)
|
||||||
|
self.assertEqual(orph, [], "un fichier en usage serait effacé")
|
||||||
|
self.assertEqual(len(prot), 3)
|
||||||
|
|
||||||
|
|
||||||
|
class TestEffacerUneVm(unittest.TestCase):
|
||||||
|
"""L'autre bout du même défaut : effacer par le NOM laissait 63 Go.
|
||||||
|
|
||||||
|
« rm /var/lib/libvirt/images/<nom>.qcow2 » ne trouve rien quand la VM a
|
||||||
|
été renommée — la définition partait, le disque restait, et devenait un
|
||||||
|
vrai orphelin de 63 Go que le nettoyage n'osait plus toucher.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_a_renamed_vm_deletes_its_real_files(self):
|
||||||
|
todo = todo_avec({"erplibre-ubuntu-2404-MIGRATION": XML_MIGRATION})
|
||||||
|
self.assertEqual(
|
||||||
|
todo._qemu_vm_own_files("erplibre-ubuntu-2404-MIGRATION"),
|
||||||
|
[DISQUE, SEED],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_the_nvram_is_left_to_virsh_undefine(self):
|
||||||
|
# « virsh undefine --nvram » s'en charge : le lister deux fois ne
|
||||||
|
# sert à rien, et un « rm » sur un nvram encore utilisé serait pire.
|
||||||
|
todo = todo_avec({"vm-a": XML_MIGRATION})
|
||||||
|
self.assertNotIn(NVRAM, todo._qemu_vm_own_files("vm-a"))
|
||||||
|
|
||||||
|
def test_a_file_shared_with_a_neighbour_is_spared(self):
|
||||||
|
# Image de FOND d'une chaîne de qcow2 : l'effacer creverait l'autre.
|
||||||
|
partage = (
|
||||||
|
"<domain><devices><disk><source file='"
|
||||||
|
+ DISQUE
|
||||||
|
+ "'/></disk></devices></domain>"
|
||||||
|
)
|
||||||
|
todo = todo_avec({"vm-a": XML_MIGRATION, "vm-b": partage})
|
||||||
|
propres = todo._qemu_vm_own_files("vm-a")
|
||||||
|
self.assertNotIn(DISQUE, propres)
|
||||||
|
self.assertIn(SEED, propres)
|
||||||
|
|
||||||
|
def test_a_vm_without_files_yields_nothing_rather_than_a_guess(self):
|
||||||
|
todo = todo_avec({"vm-a": "<domain><devices/></domain>"})
|
||||||
|
self.assertEqual(todo._qemu_vm_own_files("vm-a"), [])
|
||||||
|
|
||||||
|
|
||||||
|
CONFIG_SSH = """Host exo
|
||||||
|
HostName 132.207.112.51
|
||||||
|
|
||||||
|
Host erplibre-ubuntu-2404
|
||||||
|
HostName 192.168.123.170
|
||||||
|
User erplibre
|
||||||
|
|
||||||
|
Host erplibre-partie
|
||||||
|
HostName 192.168.123.99
|
||||||
|
User erplibre
|
||||||
|
|
||||||
|
Host erplibre-imbriquee
|
||||||
|
HostName 10.10.10.150
|
||||||
|
User erplibre
|
||||||
|
ProxyJump erplibre-proxmox-9
|
||||||
|
|
||||||
|
Host erplibre-sur-proxmox
|
||||||
|
HostName 10.10.10.151
|
||||||
|
User erplibre
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class TestLesEntreesSsh(unittest.TestCase):
|
||||||
|
"""Le nettoyage des entrées ~/.ssh/config jugeait, lui aussi, sur le NOM.
|
||||||
|
|
||||||
|
Conséquence vécue : après le renommage de la VM, son alias
|
||||||
|
« erplibre-ubuntu-2404 » ne correspondait plus à aucun domaine et a été
|
||||||
|
effacé — alors qu'il menait à une machine EN MARCHE. Trois autres preuves
|
||||||
|
valent mieux qu'un nom.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.todo = TODO.__new__(TODO)
|
||||||
|
self.domaines = {"erplibre-ubuntu-2404-MIGRATION"}
|
||||||
|
self.adresses = {"192.168.123.170": "erplibre-ubuntu-2404-MIGRATION"}
|
||||||
|
|
||||||
|
def _juge(self, nom, distantes=()):
|
||||||
|
return self.todo._ssh_entry_alive(
|
||||||
|
CONFIG_SSH, nom, self.domaines, self.adresses, set(distantes)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_a_renamed_vm_keeps_its_alias(self):
|
||||||
|
# L'adresse mène à un domaine vivant : le nom n'a plus d'importance.
|
||||||
|
self.assertEqual(
|
||||||
|
self._juge("erplibre-ubuntu-2404"),
|
||||||
|
"erplibre-ubuntu-2404-MIGRATION",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_a_name_that_matches_a_domain_is_kept(self):
|
||||||
|
self.assertTrue(self._juge("erplibre-ubuntu-2404-MIGRATION"))
|
||||||
|
|
||||||
|
def test_a_jump_entry_is_kept(self):
|
||||||
|
# Écrite pour une VM imbriquée ou distante : virsh ne la connaîtra
|
||||||
|
# jamais, et son adresse n'est pas routable d'ici.
|
||||||
|
self.assertTrue(self._juge("erplibre-imbriquee"))
|
||||||
|
|
||||||
|
def test_a_vm_of_the_proxmox_host_is_kept(self):
|
||||||
|
self.assertTrue(
|
||||||
|
self._juge("erplibre-sur-proxmox", ["erplibre-sur-proxmox"])
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_an_entry_that_leads_nowhere_is_an_orphan(self):
|
||||||
|
self.assertEqual(self._juge("erplibre-partie"), "")
|
||||||
|
|
||||||
|
|
||||||
|
class TestLAdresseDUneVm(unittest.TestCase):
|
||||||
|
"""« --source arp » remonte les passerelles des ponts : la dernière
|
||||||
|
candidate n'est pas la bonne.
|
||||||
|
|
||||||
|
Vécu sur la VM renommée : son bail porte encore l'ancien nom d'hôte, donc
|
||||||
|
aucune correspondance, et le repli sur « la dernière » annonçait
|
||||||
|
192.168.122.1 — la passerelle — au lieu de 192.168.123.170.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _todo(self, par_source):
|
||||||
|
todo = TODO.__new__(TODO)
|
||||||
|
todo._qemu_candidates_by_source = lambda nom: par_source
|
||||||
|
todo._qemu_lease_ip_for_host = lambda nom, cands: None
|
||||||
|
return todo
|
||||||
|
|
||||||
|
def test_the_lease_wins_over_the_arp_table(self):
|
||||||
|
todo = self._todo(
|
||||||
|
{
|
||||||
|
"lease": ["192.168.123.170"],
|
||||||
|
"agent": ["192.168.123.170", "192.168.122.1"],
|
||||||
|
"arp": ["192.168.123.170", "192.168.122.1"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertEqual(todo._qemu_vm_ip_now("x"), "192.168.123.170")
|
||||||
|
|
||||||
|
def test_without_a_lease_the_agent_speaks(self):
|
||||||
|
todo = self._todo({"agent": ["10.0.0.5"], "arp": ["192.168.122.1"]})
|
||||||
|
self.assertEqual(todo._qemu_vm_ip_now("x"), "10.0.0.5")
|
||||||
|
|
||||||
|
def test_the_freshest_lease_of_the_source_is_taken(self):
|
||||||
|
# Bail précoce (« ubuntu ») puis bail définitif : c'est le dernier du
|
||||||
|
# BAIL qui compte, pas le dernier toutes sources confondues.
|
||||||
|
todo = self._todo(
|
||||||
|
{"lease": ["192.168.123.10", "192.168.123.11"], "arp": ["1.2.3.4"]}
|
||||||
|
)
|
||||||
|
self.assertEqual(todo._qemu_vm_ip_now("x"), "192.168.123.11")
|
||||||
|
|
||||||
|
def test_nothing_known_yields_nothing(self):
|
||||||
|
self.assertIsNone(self._todo({})._qemu_vm_ip_now("x"))
|
||||||
|
|
||||||
|
def test_the_hosts_own_addresses_are_never_candidates(self):
|
||||||
|
siennes = TODO._qemu_host_addresses()
|
||||||
|
self.assertIn("127.0.0.1", siennes)
|
||||||
|
for nom in TODO.__new__(TODO)._qemu_list_domains():
|
||||||
|
for ips in TODO._qemu_candidates_by_source(nom).values():
|
||||||
|
self.assertFalse(set(ips) & siennes, nom)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLesFichiersOuverts(unittest.TestCase):
|
||||||
|
"""Le contrôle indépendant : /proc, sans privilège."""
|
||||||
|
|
||||||
|
def test_it_finds_the_files_of_a_running_vm(self):
|
||||||
|
# Sur cette machine, si une VM tourne, son disque est cité par la
|
||||||
|
# ligne de commande de son qemu. Sinon, le test ne prouve rien et
|
||||||
|
# doit le DIRE plutôt que de passer pour rien.
|
||||||
|
todo = TODO.__new__(TODO)
|
||||||
|
domaines = todo._qemu_list_domains()
|
||||||
|
if not domaines:
|
||||||
|
self.skipTest("aucune VM définie sur cette machine")
|
||||||
|
ouverts = TODO._qemu_files_in_use()
|
||||||
|
if not ouverts:
|
||||||
|
self.skipTest("aucune VM démarrée sur cette machine")
|
||||||
|
self.assertTrue(
|
||||||
|
all(o.startswith("/var/lib/libvirt/") for o in ouverts)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_it_only_keeps_paths_that_exist(self):
|
||||||
|
# La ligne de commande d'un processus quelconque peut contenir
|
||||||
|
# n'importe quoi — ce test-ci en met la preuve dans la sienne :
|
||||||
|
# /var/lib/libvirt/n-existe-pas-du-tout
|
||||||
|
for chemin in TODO._qemu_files_in_use():
|
||||||
|
self.assertTrue(os.path.exists(chemin), chemin)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main(verbosity=2)
|
||||||
|
|
@ -113,8 +113,10 @@ class TestLigneDeTotaux(unittest.TestCase):
|
||||||
ctx["host_cpu"] = 1
|
ctx["host_cpu"] = 1
|
||||||
ligne = self._ligne(2, ctx)["ligne"]
|
ligne = self._ligne(2, ctx)["ligne"]
|
||||||
self.assertEqual(ligne.count("⚠"), 1)
|
self.assertEqual(ligne.count("⚠"), 1)
|
||||||
for morceau in ("RAM", "disque", "cœurs"):
|
from script.todo.deploy_form_lib import t
|
||||||
self.assertIn(morceau, ligne, ligne)
|
|
||||||
|
for cle in ("> host free RAM", "> host free disk", "> host cores"):
|
||||||
|
self.assertIn(t(cle), ligne, ligne)
|
||||||
|
|
||||||
def test_an_unknown_measure_shows_no_room_at_all(self):
|
def test_an_unknown_measure_shows_no_room_at_all(self):
|
||||||
ctx = contexte()
|
ctx = contexte()
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ from unittest import mock
|
||||||
|
|
||||||
sys.argv = ["todo.py"]
|
sys.argv = ["todo.py"]
|
||||||
from script.todo.todo import TODO # noqa: E402
|
from script.todo.todo import TODO # noqa: E402
|
||||||
|
from script.todo.todo_i18n import t # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
def _run_ok(stdout="", returncode=0, stderr=""):
|
def _run_ok(stdout="", returncode=0, stderr=""):
|
||||||
|
|
@ -307,7 +308,10 @@ class TestEmulatorMenu(_MenuCase):
|
||||||
"""
|
"""
|
||||||
out, calls = self._play(["1"], probe="NO_SDK\n")
|
out, calls = self._play(["1"], probe="NO_SDK\n")
|
||||||
self.assertIn("SDK", out)
|
self.assertIn("SDK", out)
|
||||||
self.assertNotIn("[1]", out.split("VM locale")[-1])
|
# Découpé sur la TRADUCTION : ce test échouait dès que l'interface
|
||||||
|
# passait en anglais, pour une raison qui n'a rien à voir avec le
|
||||||
|
# diagnostic qu'il vérifie.
|
||||||
|
self.assertNotIn("[1]", out.split(t("local VM"))[-1])
|
||||||
self.assertEqual(self._started(calls), [])
|
self.assertEqual(self._started(calls), [])
|
||||||
|
|
||||||
def test_a_vm_without_the_avd_is_named_as_such(self):
|
def test_a_vm_without_the_avd_is_named_as_such(self):
|
||||||
|
|
|
||||||
148
test/test_qemu_monitor_pve.py
Normal file
148
test/test_qemu_monitor_pve.py
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||||
|
"""Les colonnes vivantes d'une VM posée sur un hôte Proxmox distant.
|
||||||
|
|
||||||
|
Elles venaient toutes de virsh : état, durée, écrit/s, RAM, disque. Or virsh
|
||||||
|
ne connaît pas les VM d'un hôte Proxmox — elles restaient donc VIDES, et le
|
||||||
|
relevé d'état les déclarait même « effacées », ce qui éteignait le reste.
|
||||||
|
|
||||||
|
L'hôte sait tout cela : « pvesh get /cluster/resources --type vm » rend
|
||||||
|
l'état, la mémoire, le disque et le cumul écrit de TOUTES ses VM en un appel.
|
||||||
|
Le relevé prend la forme exacte de celui de virsh, pour que le calcul du
|
||||||
|
débit, de la RAM et des colonnes ne sache pas d'où vient la mesure.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
sys.argv = ["todo.py"]
|
||||||
|
from script.todo import qemu_install_monitor as mon # noqa: E402
|
||||||
|
|
||||||
|
# Sortie RÉELLE relevée sur l'hôte d'essai (une VM, stockage en fichiers :
|
||||||
|
# Proxmox y rapporte « disk: 0 », d'où le « du » qui suit).
|
||||||
|
REPONSE = (
|
||||||
|
'[{"cpu":0.01,"disk":0,"diskread":32633856,"diskwrite":328233472,'
|
||||||
|
'"id":"qemu/100","maxcpu":1,"maxdisk":4294967296,"maxmem":536870912,'
|
||||||
|
'"mem":385351680,"name":"pve-suivi","netin":190,"netout":0,'
|
||||||
|
'"node":"erplibre-proxmox-9","status":"running","template":0,'
|
||||||
|
'"type":"qemu","uptime":127,"vmid":100}]\n'
|
||||||
|
"---ERPLIBRE-DU---\n"
|
||||||
|
"4294971392\t/var/lib/vz/images/100/\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLaLecture(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.releves = mon.parse_pvestats(REPONSE)
|
||||||
|
|
||||||
|
def test_the_vm_is_keyed_by_its_name(self):
|
||||||
|
# Le suivi raisonne en NOMS : c'est ce que porte le manifeste.
|
||||||
|
self.assertEqual(list(self.releves), ["pve-suivi"])
|
||||||
|
|
||||||
|
def test_the_shape_matches_the_virsh_one(self):
|
||||||
|
# Même forme exprès : `ram_pair`, `WriteWindow` et les colonnes
|
||||||
|
# fonctionnent alors sans distinguer la source.
|
||||||
|
attendus = {
|
||||||
|
"ram_used",
|
||||||
|
"ram_total",
|
||||||
|
"ram_at",
|
||||||
|
"wr_bytes",
|
||||||
|
"disk_used",
|
||||||
|
"disk_total",
|
||||||
|
}
|
||||||
|
self.assertTrue(attendus <= set(self.releves["pve-suivi"]))
|
||||||
|
|
||||||
|
def test_the_measures_are_the_ones_the_host_gave(self):
|
||||||
|
rec = self.releves["pve-suivi"]
|
||||||
|
self.assertEqual(rec["ram_used"], 385351680)
|
||||||
|
self.assertEqual(rec["ram_total"], 536870912)
|
||||||
|
self.assertEqual(rec["wr_bytes"], 328233472)
|
||||||
|
self.assertEqual(rec["state"], "running")
|
||||||
|
self.assertEqual(rec["uptime"], 127)
|
||||||
|
|
||||||
|
def test_a_zero_disk_falls_back_to_the_real_size(self):
|
||||||
|
# Sur un stockage en fichiers, Proxmox NE CALCULE PAS la taille
|
||||||
|
# occupée et rapporte 0 : la colonne aurait affiché « 0/4G ».
|
||||||
|
self.assertEqual(self.releves["pve-suivi"]["disk_used"], 4294971392)
|
||||||
|
self.assertEqual(self.releves["pve-suivi"]["disk_total"], 4294967296)
|
||||||
|
|
||||||
|
def test_the_reading_is_fresh_so_the_ram_is_shown(self):
|
||||||
|
# `ram_pair` refuse un relevé périmé : sans horodatage, la RAM d'une
|
||||||
|
# VM distante ne s'afficherait jamais.
|
||||||
|
rec = self.releves["pve-suivi"]
|
||||||
|
self.assertNotEqual(mon.ram_pair(rec, rec["ram_at"]), "-")
|
||||||
|
|
||||||
|
def test_garbage_yields_nothing_rather_than_raising(self):
|
||||||
|
for brut in ("", "pas du json", "{}", "---ERPLIBRE-DU---"):
|
||||||
|
self.assertEqual(mon.parse_pvestats(brut), {})
|
||||||
|
|
||||||
|
|
||||||
|
class TestLAppel(unittest.TestCase):
|
||||||
|
"""Un appel par HÔTE, mis en cache : chaque tour coûte une poignée de
|
||||||
|
main ssh (1 s mesuré) quand virsh coûte 0,03 s pour tout le parc."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
mon._PVE_CACHE.update({"at": 0.0, "stats": {}})
|
||||||
|
|
||||||
|
def _vms(self, n=2):
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": f"vm-{i}",
|
||||||
|
"pve": {
|
||||||
|
"target": "erplibre@pve1",
|
||||||
|
"sudo": "sudo ",
|
||||||
|
"vmid": 100 + i,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for i in range(n)
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_two_vms_of_the_same_host_cost_one_call(self):
|
||||||
|
with mock.patch(
|
||||||
|
"script.proxmox.proxmox_deploy.run", return_value=(0, REPONSE)
|
||||||
|
) as appel:
|
||||||
|
mon.read_pvestats(self._vms(2), now=1000.0)
|
||||||
|
self.assertEqual(appel.call_count, 1)
|
||||||
|
|
||||||
|
def test_a_local_only_plan_calls_nothing(self):
|
||||||
|
with mock.patch("script.proxmox.proxmox_deploy.run") as appel:
|
||||||
|
self.assertEqual(mon.read_pvestats([{"name": "locale"}]), {})
|
||||||
|
appel.assert_not_called()
|
||||||
|
|
||||||
|
def test_the_cache_spares_the_next_tick(self):
|
||||||
|
with mock.patch(
|
||||||
|
"script.proxmox.proxmox_deploy.run", return_value=(0, REPONSE)
|
||||||
|
) as appel:
|
||||||
|
mon.read_pvestats(self._vms(1), now=1000.0)
|
||||||
|
mon.read_pvestats(self._vms(1), now=1000.0 + 1)
|
||||||
|
self.assertEqual(appel.call_count, 1)
|
||||||
|
# Passé l'intervalle, on redemande.
|
||||||
|
mon.read_pvestats(
|
||||||
|
self._vms(1), now=1000.0 + mon.PVE_STATS_INTERVAL + 0.1
|
||||||
|
)
|
||||||
|
self.assertEqual(appel.call_count, 2)
|
||||||
|
|
||||||
|
def test_a_failing_host_yields_nothing_rather_than_wrong_numbers(self):
|
||||||
|
with mock.patch(
|
||||||
|
"script.proxmox.proxmox_deploy.run", return_value=(255, "timeout")
|
||||||
|
):
|
||||||
|
self.assertEqual(mon.read_pvestats(self._vms(1), now=1.0), {})
|
||||||
|
|
||||||
|
|
||||||
|
class TestLEtat(unittest.TestCase):
|
||||||
|
"""Une VM absente de « virsh list » passait pour EFFACÉE."""
|
||||||
|
|
||||||
|
def test_proxmox_states_map_to_libvirt_ones(self):
|
||||||
|
self.assertEqual(mon.PVE_ETATS.get("running"), "running")
|
||||||
|
self.assertEqual(mon.PVE_ETATS.get("stopped"), "shut off")
|
||||||
|
|
||||||
|
def test_an_unknown_state_is_really_gone(self):
|
||||||
|
# Absente de la réponse de l'hôte : la VM a vraiment disparu.
|
||||||
|
self.assertIsNone(mon.PVE_ETATS.get(None))
|
||||||
|
self.assertIsNone(mon.PVE_ETATS.get("n'importe quoi"))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main(verbosity=2)
|
||||||
197
test/test_restore_config_defaults.py
Normal file
197
test/test_restore_config_defaults.py
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
"""Les réglages qu'Odoo ne recrée qu'à des moments qu'une migration évite.
|
||||||
|
|
||||||
|
Deux enregistrements ont cessé d'être LIVRÉS en données pour devenir le
|
||||||
|
produit d'un geste — créer une société, cocher une case, charger un plan
|
||||||
|
comptable. Une migration n'en fait aucun : le nettoyage des orphelins les
|
||||||
|
emporte et rien ne les remet.
|
||||||
|
|
||||||
|
Mesuré sur une chaîne 12 → 18 réelle : 1 liste de prix en 16, 0 en 17,
|
||||||
|
alors que le groupe compte six membres ; 1 modèle de rapprochement en 12,
|
||||||
|
0 dès la 13, pour trois journaux de trésorerie.
|
||||||
|
|
||||||
|
Ce que l'outil doit surtout savoir faire, c'est SE TAIRE : sans le groupe
|
||||||
|
des listes de prix, sans journal de trésorerie, ou sur une base qui n'a
|
||||||
|
ni vente ni comptabilité, l'absence est normale et le dire serait du
|
||||||
|
bruit qu'on apprendrait à ignorer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
sys.path.append(
|
||||||
|
os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
)
|
||||||
|
|
||||||
|
from script.odoo.migration import restore_config_defaults as conf # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def rapport(**extra):
|
||||||
|
base = {
|
||||||
|
"dry_run": True,
|
||||||
|
"companies": 1,
|
||||||
|
"pricelist_before": 0,
|
||||||
|
"pricelist_after": 0,
|
||||||
|
"pricelist_group": True,
|
||||||
|
"reconcile_before": 0,
|
||||||
|
"reconcile_after": 0,
|
||||||
|
"cash_journals": 3,
|
||||||
|
"charts": ["ca_2023"],
|
||||||
|
}
|
||||||
|
base.update(extra)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
class TestWhenAPricelistIsReallyMissing(unittest.TestCase):
|
||||||
|
def test_none_and_the_feature_is_on(self):
|
||||||
|
self.assertTrue(conf.pricelist_missing(rapport()))
|
||||||
|
|
||||||
|
def test_the_feature_is_off_so_the_absence_is_normal(self):
|
||||||
|
# Sans le groupe, le menu n'existe pas : signaler l'absence
|
||||||
|
# enverrait réparer ce que personne n'utilise.
|
||||||
|
self.assertFalse(
|
||||||
|
conf.pricelist_missing(rapport(pricelist_group=False))
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_there_is_already_one(self):
|
||||||
|
self.assertFalse(conf.pricelist_missing(rapport(pricelist_before=1)))
|
||||||
|
|
||||||
|
def test_the_module_is_not_installed_at_all(self):
|
||||||
|
self.assertFalse(
|
||||||
|
conf.pricelist_missing(rapport(pricelist_absent=True))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestWhenAReconcileModelIsReallyMissing(unittest.TestCase):
|
||||||
|
def test_none_and_there_are_journals_to_match(self):
|
||||||
|
self.assertTrue(conf.reconcile_missing(rapport()))
|
||||||
|
|
||||||
|
def test_no_cash_journal_so_nothing_to_match(self):
|
||||||
|
# Créer des modèles là serait du bruit dans un menu qu'on
|
||||||
|
# n'ouvre pas.
|
||||||
|
self.assertFalse(conf.reconcile_missing(rapport(cash_journals=0)))
|
||||||
|
|
||||||
|
def test_there_are_already_some(self):
|
||||||
|
self.assertFalse(conf.reconcile_missing(rapport(reconcile_before=4)))
|
||||||
|
|
||||||
|
def test_accounting_is_not_installed(self):
|
||||||
|
self.assertFalse(
|
||||||
|
conf.reconcile_missing(rapport(reconcile_absent=True))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestJudgingBeforeAndAfter(unittest.TestCase):
|
||||||
|
"""Le compte d'AVANT reste à zéro pour l'éternité.
|
||||||
|
|
||||||
|
Juger dessus après une réparation réussie conclurait toujours « il en
|
||||||
|
manque encore ». C'est le compte d'APRÈS qui dit si le geste a pris.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_before_the_repair_both_are_missing(self):
|
||||||
|
self.assertEqual(["pricelist", "reconcile"], conf.findings(rapport()))
|
||||||
|
|
||||||
|
def test_after_a_successful_repair_nothing_is_missing(self):
|
||||||
|
plein = rapport(pricelist_after=1, reconcile_after=4)
|
||||||
|
self.assertEqual([], conf.findings(plein, apres=True))
|
||||||
|
# …et le compte d'avant, lui, dit toujours qu'il manquait.
|
||||||
|
self.assertEqual(2, len(conf.findings(plein)))
|
||||||
|
|
||||||
|
def test_a_half_repair_is_reported(self):
|
||||||
|
moitie = rapport(pricelist_after=1, reconcile_after=0)
|
||||||
|
self.assertEqual(["reconcile"], conf.findings(moitie, apres=True))
|
||||||
|
|
||||||
|
|
||||||
|
class TestTheReport(unittest.TestCase):
|
||||||
|
def test_a_healthy_database_says_so(self):
|
||||||
|
sain = rapport(pricelist_before=1, reconcile_before=4)
|
||||||
|
texte = "\n".join(conf.render(sain, True))
|
||||||
|
self.assertIn("✅", texte)
|
||||||
|
|
||||||
|
def test_it_says_what_breaks_for_a_user_not_what_is_missing(self):
|
||||||
|
# « 0 liste de prix » ne dit rien à personne ; « tout devis
|
||||||
|
# s'ouvre sans liste » se comprend sans connaître le modèle.
|
||||||
|
#
|
||||||
|
# On demande la MÊME traduction que le code : comparer à un mot
|
||||||
|
# anglais en dur fait tomber le test dès qu'on traduit, ce qui
|
||||||
|
# m'est arrivé sur celui-ci.
|
||||||
|
texte = "\n".join(conf.render(rapport(), True))
|
||||||
|
self.assertIn(
|
||||||
|
conf.t("Every quotation opens without one: raw price, no rule."),
|
||||||
|
texte,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
conf.t("Every statement line is matched by hand."), texte
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_it_counts_the_journals_it_found(self):
|
||||||
|
texte = "\n".join(conf.render(rapport(cash_journals=3), True))
|
||||||
|
self.assertIn("3", texte)
|
||||||
|
|
||||||
|
def test_the_dry_run_offers_the_flag(self):
|
||||||
|
self.assertIn("--apply", "\n".join(conf.render(rapport(), True)))
|
||||||
|
|
||||||
|
def test_after_applying_it_shows_the_movement(self):
|
||||||
|
# Un rapport qui répète « il en manquait » sans dire ce qui a été
|
||||||
|
# fait laisse rouvrir l'outil pour savoir.
|
||||||
|
fait = rapport(pricelist_after=1, reconcile_after=4)
|
||||||
|
texte = "\n".join(conf.render(fait, False))
|
||||||
|
self.assertIn("0 → 1", texte)
|
||||||
|
self.assertIn("0 → 4", texte)
|
||||||
|
self.assertNotIn("--apply", texte)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTheOrmScript(unittest.TestCase):
|
||||||
|
"""Le script poussé dans le shell doit être du Python valide."""
|
||||||
|
|
||||||
|
def test_both_shapes_compile(self):
|
||||||
|
for sec in (True, False):
|
||||||
|
with self.subTest(dry=sec):
|
||||||
|
compile(conf.build_script(sec), "script", "exec")
|
||||||
|
|
||||||
|
def test_the_dry_run_never_writes(self):
|
||||||
|
sec = conf.build_script(True)
|
||||||
|
self.assertIn("DRY = True", sec)
|
||||||
|
|
||||||
|
def test_applying_commits_each_repair(self):
|
||||||
|
# Le shell d'Odoo annule en sortant : sans commit, la réparation
|
||||||
|
# s'évapore et l'outil annonce une réussite qui n'a rien laissé.
|
||||||
|
#
|
||||||
|
# UN par réparation, et l'on compte : chercher « il y a un
|
||||||
|
# commit » laissait retirer celui du rapprochement sans que rien
|
||||||
|
# ne le dise — l'autre le masquait. Mesuré.
|
||||||
|
vif = conf.build_script(False)
|
||||||
|
self.assertIn("DRY = False", vif)
|
||||||
|
self.assertEqual(2, vif.count("env.cr.commit()"), vif)
|
||||||
|
|
||||||
|
def test_each_branch_has_its_own_commit(self):
|
||||||
|
vif = conf.build_script(False)
|
||||||
|
prix = vif[vif.index("_activate_or_create_pricelists") :]
|
||||||
|
prix = prix[: prix.index("account.reconcile.model")]
|
||||||
|
self.assertIn("env.cr.commit()", prix)
|
||||||
|
reco = vif[vif.index("_get_account_reconcile_model") :]
|
||||||
|
self.assertIn("env.cr.commit()", reco)
|
||||||
|
|
||||||
|
def test_it_calls_odoo_rather_than_inventing_records(self):
|
||||||
|
vif = conf.build_script(False)
|
||||||
|
self.assertIn("_activate_or_create_pricelists", vif)
|
||||||
|
self.assertIn("_get_account_reconcile_model", vif)
|
||||||
|
self.assertIn("_load_data", vif)
|
||||||
|
|
||||||
|
def test_it_lets_odoo_skip_what_already_exists(self):
|
||||||
|
# `ignore_duplicates` est ce qui rend l'outil rejouable ; sans
|
||||||
|
# lui, une seconde passe doublerait les modèles.
|
||||||
|
self.assertIn("ignore_duplicates=True", conf.build_script(False))
|
||||||
|
|
||||||
|
def test_it_uses_the_sentinels_run_shell_imposes(self):
|
||||||
|
# En poser d'autres ferait lire un rapport vide.
|
||||||
|
vif = conf.build_script(False)
|
||||||
|
self.assertIn(conf.DEBUT, vif)
|
||||||
|
self.assertIn(conf.FIN, vif)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
@ -52,16 +52,74 @@ class TestRessourcesLibres(unittest.TestCase):
|
||||||
self.assertEqual(lib.res_value("8", "", 2), 8)
|
self.assertEqual(lib.res_value("8", "", 2), 8)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLesBranches(unittest.TestCase):
|
||||||
|
"""« git ls-remote » rend les branches par ordre alphabétique.
|
||||||
|
|
||||||
|
Rapporté : le formulaire Proxmox proposait « dependabot/pip/aiobotocore-
|
||||||
|
3.1.3 » — la première de la liste. Le bon défaut est la branche du DÉPÔT
|
||||||
|
qu'on a sous les yeux, puis develop, puis master.
|
||||||
|
"""
|
||||||
|
|
||||||
|
BRANCHES = [
|
||||||
|
"dependabot/pip/aiobotocore-3.1.3",
|
||||||
|
"dependabot/pip/boto3-1.42.49",
|
||||||
|
"develop",
|
||||||
|
"dev_mobile",
|
||||||
|
"master",
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_the_checkout_branch_wins(self):
|
||||||
|
self.assertEqual(
|
||||||
|
lib.branch_default(self.BRANCHES, "dev_mobile"), "dev_mobile"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_without_it_develop_then_master(self):
|
||||||
|
self.assertEqual(lib.branch_default(self.BRANCHES, ""), "develop")
|
||||||
|
sans_develop = [b for b in self.BRANCHES if b != "develop"]
|
||||||
|
self.assertEqual(lib.branch_default(sans_develop, ""), "master")
|
||||||
|
|
||||||
|
def test_a_branch_that_no_longer_exists_is_ignored(self):
|
||||||
|
# On déploie une branche qui existe, pas celle qu'on avait localement.
|
||||||
|
self.assertEqual(
|
||||||
|
lib.branch_default(self.BRANCHES, "partie-en-fumée"), "develop"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_the_last_resort_is_the_first_but_never_by_default(self):
|
||||||
|
self.assertEqual(lib.branch_default(["zzz"], ""), "zzz")
|
||||||
|
self.assertEqual(lib.branch_default([], "develop"), "")
|
||||||
|
|
||||||
|
def test_the_list_puts_the_useful_ones_first(self):
|
||||||
|
ordre = lib.branch_order(self.BRANCHES, "dev_mobile")
|
||||||
|
self.assertEqual(ordre[:3], ["dev_mobile", "develop", "master"])
|
||||||
|
|
||||||
|
def test_the_robots_go_last(self):
|
||||||
|
ordre = lib.branch_order(self.BRANCHES, "develop")
|
||||||
|
self.assertTrue(all(b.startswith("dependabot/") for b in ordre[-2:]))
|
||||||
|
|
||||||
|
def test_nothing_is_lost_nor_duplicated(self):
|
||||||
|
# La branche du dépôt EST souvent « develop » : la voir deux fois
|
||||||
|
# ferait douter de la liste.
|
||||||
|
ordre = lib.branch_order(self.BRANCHES, "develop")
|
||||||
|
self.assertEqual(sorted(ordre), sorted(self.BRANCHES))
|
||||||
|
self.assertEqual(len(ordre), len(set(ordre)))
|
||||||
|
|
||||||
|
|
||||||
class TestPlaceDisque(unittest.TestCase):
|
class TestPlaceDisque(unittest.TestCase):
|
||||||
"""La demande du plan ne dit pas si ça rentre : il faut la place à côté."""
|
"""La demande du plan ne dit pas si ça rentre : il faut la place à côté."""
|
||||||
|
|
||||||
def test_it_shows_the_demand_the_room_and_the_capacity(self):
|
def test_it_shows_the_demand_the_room_and_the_capacity(self):
|
||||||
|
# Comparé à la traduction et non au français : la langue de
|
||||||
|
# l'interface se change (EL_LANG), et un test qui la suppose échoue
|
||||||
|
# pour une raison étrangère à ce qu'il vérifie.
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
lib.disk_note(50, 20, 270), "~50 G / 20 G libres sur 270 G"
|
lib.disk_note(50, 20, 270),
|
||||||
|
f"~50 G / 20 G {lib.t('free of')} 270 G",
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_without_a_capacity_it_still_shows_the_room(self):
|
def test_without_a_capacity_it_still_shows_the_room(self):
|
||||||
self.assertEqual(lib.disk_note(50, 20), "~50 G / 20 G libres")
|
self.assertEqual(
|
||||||
|
lib.disk_note(50, 20), f"~50 G / 20 G {lib.t('free')}"
|
||||||
|
)
|
||||||
|
|
||||||
def test_without_a_measure_it_invents_nothing(self):
|
def test_without_a_measure_it_invents_nothing(self):
|
||||||
# Une place inconnue ne doit pas se lire comme « 0 Go libres ».
|
# Une place inconnue ne doit pas se lire comme « 0 Go libres ».
|
||||||
|
|
|
||||||
146
test/test_todo_deploy_progress.py
Normal file
146
test/test_todo_deploy_progress.py
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||||
|
"""La vue de progression d'un déploiement : en direct, et on peut y entrer.
|
||||||
|
|
||||||
|
Rapporté sur un déploiement Proxmox : « les logs ne sont pas live, ils sont
|
||||||
|
apparus à la toute fin » et « il manque les boutons comme s pour se connecter
|
||||||
|
en ssh ». Les deux étaient vrais de cette vue — elle exécutait chaque travail
|
||||||
|
avec `subprocess.run`, qui ne rend sa sortie qu'à la fin, et ses touches se
|
||||||
|
limitaient à copier et quitter.
|
||||||
|
|
||||||
|
Une VM sur Proxmox demande le téléchargement d'une image de 325 Mio puis
|
||||||
|
l'import de son disque : plusieurs minutes d'un bloc vide.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
sys.argv = ["todo.py"]
|
||||||
|
from script.todo.deploy_form_lib import run_deploy_progress # noqa: E402
|
||||||
|
|
||||||
|
try:
|
||||||
|
import textual # noqa: F401
|
||||||
|
|
||||||
|
TEXTUAL = True
|
||||||
|
except Exception: # pragma: no cover - dépend de l'environnement
|
||||||
|
TEXTUAL = False
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(TEXTUAL, "Textual absent")
|
||||||
|
class TestLeFlux(unittest.TestCase):
|
||||||
|
def test_the_lines_appear_while_the_job_runs(self):
|
||||||
|
"""Le cœur du rapport : compté PENDANT, pas après."""
|
||||||
|
jobs = [
|
||||||
|
(
|
||||||
|
"1",
|
||||||
|
"vm-flux",
|
||||||
|
[
|
||||||
|
"bash",
|
||||||
|
"-c",
|
||||||
|
"for i in $(seq 1 6); do echo ligne $i; sleep 1; done",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
]
|
||||||
|
vu = []
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
from textual.widgets import RichLog
|
||||||
|
|
||||||
|
app = run_deploy_progress(jobs, 1, run_app=False)
|
||||||
|
async with app.run_test(size=(120, 30)) as pilote:
|
||||||
|
await pilote.pause()
|
||||||
|
journal = app.query_one(RichLog)
|
||||||
|
for _ in range(3):
|
||||||
|
await asyncio.sleep(1.5)
|
||||||
|
await pilote.pause()
|
||||||
|
vu.append(len(journal.lines))
|
||||||
|
vu.append(len(app._reussies))
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
# Au moins une ligne AVANT la fin, et de plus en plus.
|
||||||
|
self.assertGreater(vu[0], 0, "rien à l'écran pendant le travail")
|
||||||
|
self.assertGreater(vu[2], vu[0], "l'affichage n'avance pas")
|
||||||
|
|
||||||
|
def test_the_output_is_not_written_twice(self):
|
||||||
|
# `_finish` réécrivait tout : la sortie apparaîtrait en double.
|
||||||
|
jobs = [("1", "vm-court", ["bash", "-c", "echo une; echo deux"])]
|
||||||
|
vu = {}
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
from textual.widgets import RichLog
|
||||||
|
|
||||||
|
app = run_deploy_progress(jobs, 1, run_app=False)
|
||||||
|
async with app.run_test(size=(120, 30)) as pilote:
|
||||||
|
await pilote.pause()
|
||||||
|
await asyncio.sleep(1.5)
|
||||||
|
await pilote.pause()
|
||||||
|
vu["lignes"] = len(app.query_one(RichLog).lines)
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
self.assertEqual(vu["lignes"], 2)
|
||||||
|
|
||||||
|
def test_a_command_that_cannot_start_still_says_so(self):
|
||||||
|
# Le processus n'a rien écrit : c'est le seul cas où `_finish` doit
|
||||||
|
# poser la sortie lui-même.
|
||||||
|
jobs = [("1", "vm-absente", ["/n/existe/pas/du/tout"])]
|
||||||
|
vu = {}
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
from textual.widgets import RichLog
|
||||||
|
|
||||||
|
app = run_deploy_progress(jobs, 1, run_app=False)
|
||||||
|
async with app.run_test(size=(120, 30)) as pilote:
|
||||||
|
await pilote.pause()
|
||||||
|
await asyncio.sleep(1.0)
|
||||||
|
await pilote.pause()
|
||||||
|
vu["lignes"] = len(app.query_one(RichLog).lines)
|
||||||
|
vu["resultats"] = list(app._reussies)
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
self.assertGreater(vu["lignes"], 0, "l'échec ne dit rien")
|
||||||
|
self.assertEqual(vu["resultats"], [])
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(TEXTUAL, "Textual absent")
|
||||||
|
class TestLaToucheSsh(unittest.TestCase):
|
||||||
|
def test_the_key_is_offered(self):
|
||||||
|
app = run_deploy_progress([("1", "vm", ["true"])], 1, run_app=False)
|
||||||
|
self.assertIn("s", [b[0] for b in type(app).BINDINGS])
|
||||||
|
|
||||||
|
def test_it_targets_the_vm_that_was_created(self):
|
||||||
|
jobs = [("1", "vm-creee", ["bash", "-c", "echo ok"])]
|
||||||
|
vu = {}
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
app = run_deploy_progress(jobs, 1, run_app=False)
|
||||||
|
async with app.run_test(size=(120, 30)) as pilote:
|
||||||
|
await pilote.pause()
|
||||||
|
await asyncio.sleep(1.2)
|
||||||
|
await pilote.pause()
|
||||||
|
vu["reussies"] = list(app._reussies)
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
# Par son NOM : c'est l'entrée ~/.ssh/config qui sait l'atteindre, et
|
||||||
|
# pour une VM Proxmox elle porte le rebond.
|
||||||
|
self.assertEqual(vu["reussies"], ["vm-creee"])
|
||||||
|
|
||||||
|
def test_a_failed_job_is_not_offered(self):
|
||||||
|
jobs = [("1", "vm-ratee", ["false"])]
|
||||||
|
vu = {}
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
app = run_deploy_progress(jobs, 1, run_app=False)
|
||||||
|
async with app.run_test(size=(120, 30)) as pilote:
|
||||||
|
await pilote.pause()
|
||||||
|
await asyncio.sleep(1.2)
|
||||||
|
await pilote.pause()
|
||||||
|
vu["reussies"] = list(app._reussies)
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
self.assertEqual(vu["reussies"], [])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main(verbosity=2)
|
||||||
|
|
@ -168,7 +168,7 @@ class MenuCoherence:
|
||||||
# ne doit pas dépendre de l'endroit où quelqu'un met un commentaire.
|
# ne doit pas dépendre de l'endroit où quelqu'un met un commentaire.
|
||||||
RE_DISPATCH_CALL = re.compile(
|
RE_DISPATCH_CALL = re.compile(
|
||||||
r'(?:el)?if status == "(\d+)":\s*\n(?:\s*#.*\n)*'
|
r'(?:el)?if status == "(\d+)":\s*\n(?:\s*#.*\n)*'
|
||||||
r'\s*(?:status = )?self\.(\w+)\('
|
r"\s*(?:status = )?self\.(\w+)\("
|
||||||
)
|
)
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
|
@ -230,6 +230,100 @@ class MenuCoherence:
|
||||||
self.assertEqual(set(self.EXPECTED) - keys, set())
|
self.assertEqual(set(self.EXPECTED) - keys, set())
|
||||||
|
|
||||||
|
|
||||||
|
class TestLaParitéProxmox(unittest.TestCase):
|
||||||
|
"""Deux manques signalés par l'audit du découpage, comblés.
|
||||||
|
|
||||||
|
Le menu Proxmox n'offrait pas de changer l'état d'une VM (QEMU/KVM l'a
|
||||||
|
dans « Lister les VM »), et n'acceptait pas les commandes ajoutées par
|
||||||
|
todo.json — deux capacités que son vis-à-vis avait.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.src = (TODO_DIR / "proxmox_menu.py").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
def test_the_list_offers_to_change_the_state(self):
|
||||||
|
self.assertIn("_pve_change_state", self.src)
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.argv = ["todo.py"]
|
||||||
|
from script.todo.todo import TODO as CLASSE
|
||||||
|
|
||||||
|
self.assertTrue(callable(CLASSE._pve_change_state))
|
||||||
|
|
||||||
|
def test_a_clean_shutdown_comes_before_pulling_the_plug(self):
|
||||||
|
# « shutdown » laisse Odoo fermer ses connexions PostgreSQL ; « stop »
|
||||||
|
# coupe le courant. L'ordre des choix est la seule chose qui le dit.
|
||||||
|
self.assertLess(
|
||||||
|
self.src.index("shutdown (clean)"),
|
||||||
|
self.src.index("stop (pulls the plug)"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_the_menu_reads_its_extra_commands_from_todo_json(self):
|
||||||
|
self.assertIn('get_config("proxmox_from_makefile")', self.src)
|
||||||
|
# Et le dispatch sait les lancer, sections non comptées.
|
||||||
|
self.assertIn("execute_from_configuration", self.src)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLArbreDesMenus(unittest.TestCase):
|
||||||
|
"""L'écran de télémétrie lit le CODE, pas la classe assemblée.
|
||||||
|
|
||||||
|
`build_code_tree()` parse un fichier et n'y prend que la première classe.
|
||||||
|
Depuis le découpage, les menus QEMU/KVM et Proxmox vivent dans des mixins :
|
||||||
|
leur colonne avait disparu de cet écran — les commandes s'exécutaient
|
||||||
|
toujours, mais on ne pouvait plus les lancer de là ni les lire. C'est ce
|
||||||
|
que « il manque plein d'informations qu'il y avait avant » désignait.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
from script.todo.todo_telemetry import build_code_tree
|
||||||
|
|
||||||
|
cls.arbre = build_code_tree()
|
||||||
|
|
||||||
|
def _noeud(self, libelle, noeud=None):
|
||||||
|
noeud = noeud if noeud is not None else self.arbre
|
||||||
|
if noeud.get("label") == libelle:
|
||||||
|
return noeud
|
||||||
|
for enfant in noeud.get("children") or []:
|
||||||
|
trouve = self._noeud(libelle, enfant)
|
||||||
|
if trouve:
|
||||||
|
return trouve
|
||||||
|
return None
|
||||||
|
|
||||||
|
def test_the_tree_is_built_at_all(self):
|
||||||
|
self.assertIsNotNone(self.arbre)
|
||||||
|
|
||||||
|
def test_the_mixin_files_come_from_the_imports(self):
|
||||||
|
# Lus dans les imports de todo.py : un mixin ajouté demain apparaît
|
||||||
|
# sans qu'on pense à l'inscrire ici.
|
||||||
|
from script.todo.todo_telemetry import _mixin_files
|
||||||
|
|
||||||
|
noms = {f.name for f in _mixin_files(TODO_DIR / "todo.py")}
|
||||||
|
self.assertIn("qemu_menu.py", noms)
|
||||||
|
self.assertIn("proxmox_menu.py", noms)
|
||||||
|
|
||||||
|
def test_the_qemu_column_carries_its_commands(self):
|
||||||
|
noeud = self._noeud("QEMU/KVM")
|
||||||
|
self.assertIsNotNone(noeud, "colonne QEMU/KVM absente de l'arbre")
|
||||||
|
self.assertGreaterEqual(len(noeud.get("children") or []), 15)
|
||||||
|
|
||||||
|
def test_the_proxmox_column_too(self):
|
||||||
|
noeud = self._noeud("Proxmox VE")
|
||||||
|
self.assertIsNotNone(noeud, "colonne Proxmox VE absente de l'arbre")
|
||||||
|
self.assertGreaterEqual(len(noeud.get("children") or []), 15)
|
||||||
|
|
||||||
|
def test_the_breadcrumb_names_the_proxmox_menu(self):
|
||||||
|
# Sans étiquette, le fil d'Ariane sautait le menu Proxmox : on lisait
|
||||||
|
# « TODO › Execute › Deploy » en étant deux niveaux plus bas.
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.argv = ["todo.py"]
|
||||||
|
from script.todo.todo import TODO as CLASSE
|
||||||
|
|
||||||
|
self.assertIn("prompt_execute_proxmox", CLASSE._MENU_LABELS)
|
||||||
|
|
||||||
|
|
||||||
class TestQemuMenuNumbering(MenuCoherence, unittest.TestCase):
|
class TestQemuMenuNumbering(MenuCoherence, unittest.TestCase):
|
||||||
"""Le menu QEMU/KVM, désormais dans script/todo/qemu_menu.py."""
|
"""Le menu QEMU/KVM, désormais dans script/todo/qemu_menu.py."""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ alors qu'il était « installed » de la 15 à la 18.
|
||||||
import ast
|
import ast
|
||||||
import io
|
import io
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
|
|
@ -349,5 +350,115 @@ class TestDiscardingTheCloneDiscardsItsPreparation(unittest.TestCase):
|
||||||
self.assertIn("write_config", corps)
|
self.assertIn("write_config", corps)
|
||||||
|
|
||||||
|
|
||||||
|
class FauxPiloteParNom(FauxPilote):
|
||||||
|
"""Un faux qui reproduit le VRAI mécanisme du lot.
|
||||||
|
|
||||||
|
« --uninstall » prend une liste virgulée, et Odoo annule la
|
||||||
|
transaction entière au premier échec : soit tout part, soit rien.
|
||||||
|
C'est cela qu'il faut simuler — un faux où chaque nom part
|
||||||
|
indépendamment ne montrerait jamais le défaut.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, fautifs=()):
|
||||||
|
super().__init__()
|
||||||
|
self.fautifs = set(fautifs)
|
||||||
|
self.partis = set()
|
||||||
|
self.tentes = []
|
||||||
|
|
||||||
|
def todo_upgrade_execute(self, cmd, **kwargs):
|
||||||
|
self.commandes.append(cmd)
|
||||||
|
if kwargs.get("get_output"):
|
||||||
|
# `still_installed` : ce qu'on demande et qui n'est pas parti.
|
||||||
|
#
|
||||||
|
# Les noms se lisent dans la clause `IN (…)` SEULE : une
|
||||||
|
# capture large ramassait `'uninstalled'` du `state <>` et
|
||||||
|
# le faux tentait alors de désinstaller un module de ce nom.
|
||||||
|
dedans = re.search(r"name IN \(([^)]*)\)", cmd)
|
||||||
|
demandes = re.findall(
|
||||||
|
r"'([^']+)'", dedans.group(1) if dedans else ""
|
||||||
|
)
|
||||||
|
return 0, cmd, [n for n in demandes if n not in self.partis]
|
||||||
|
if "uninstall_addons.sh" in cmd:
|
||||||
|
noms = cmd.rsplit(" ", 1)[-1].split(",")
|
||||||
|
self.tentes.append(list(noms))
|
||||||
|
if not any(n in self.fautifs for n in noms):
|
||||||
|
self.partis.update(noms)
|
||||||
|
return 0, cmd
|
||||||
|
|
||||||
|
|
||||||
|
class TestOneBadNameNoLongerProtectsTheOthers(unittest.TestCase):
|
||||||
|
"""Un seul module fautif emportait tout le lot.
|
||||||
|
|
||||||
|
« --uninstall » prend une liste et Odoo annule la transaction
|
||||||
|
entière au premier échec. Mesuré sur une chaîne 12 → 18 :
|
||||||
|
`crm_phone` échoue sur une colonne absente de res_users et fait
|
||||||
|
tomber les 22 autres — dont huit modules sans code en 13, qui sont
|
||||||
|
alors montés d'un palier « installed » sans rien pour les charger.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def pilote(self, fautifs, tous):
|
||||||
|
p = FauxPiloteParNom(fautifs)
|
||||||
|
p.dct_module_per_version = {12: list(tous)}
|
||||||
|
return p
|
||||||
|
|
||||||
|
def test_the_healthy_ones_leave_even_when_one_resists(self):
|
||||||
|
tous = ["bon_a", "crm_phone", "bon_b"]
|
||||||
|
p = self.pilote(["crm_phone"], tous)
|
||||||
|
p.uninstall_from_database(tous, "db", 12)
|
||||||
|
# Le lot d'abord, puis chacun seul…
|
||||||
|
self.assertEqual(tous, p.tentes[0])
|
||||||
|
self.assertEqual(
|
||||||
|
sorted(n for lot in p.tentes[1:] for n in lot),
|
||||||
|
["bon_a", "bon_b", "crm_phone"],
|
||||||
|
)
|
||||||
|
# …et la comptabilité ne garde que le fautif.
|
||||||
|
self.assertEqual(["crm_phone"], p.dct_module_per_version[12])
|
||||||
|
|
||||||
|
def test_the_one_that_resists_is_named(self):
|
||||||
|
tous = ["bon_a", "crm_phone"]
|
||||||
|
p = self.pilote(["crm_phone"], tous)
|
||||||
|
p.uninstall_from_database(tous, "db", 12)
|
||||||
|
trace = " ".join(p.commentaires)
|
||||||
|
self.assertIn("crm_phone", trace)
|
||||||
|
self.assertNotIn("bon_a", trace)
|
||||||
|
|
||||||
|
def test_a_lone_module_is_not_tried_twice(self):
|
||||||
|
# La reprise coûte un démarrage d'Odoo par nom : on ne la paie
|
||||||
|
# pas pour rejouer exactement la même commande.
|
||||||
|
p = self.pilote(["seul"], ["seul"])
|
||||||
|
p.uninstall_from_database(["seul"], "db", 12)
|
||||||
|
lancements = [c for c in p.commandes if "uninstall_addons.sh" in c]
|
||||||
|
self.assertEqual(1, len(lancements), lancements)
|
||||||
|
|
||||||
|
def test_a_successful_batch_never_retries(self):
|
||||||
|
tous = ["bon_a", "bon_b"]
|
||||||
|
p = self.pilote([], tous)
|
||||||
|
p.uninstall_from_database(tous, "db", 12)
|
||||||
|
lancements = [c for c in p.commandes if "uninstall_addons.sh" in c]
|
||||||
|
self.assertEqual(1, len(lancements), lancements)
|
||||||
|
|
||||||
|
def test_the_retry_does_not_open_the_error_menu(self):
|
||||||
|
# Un module qui refuse de partir est une trouvaille, pas une
|
||||||
|
# panne du pilote : s'arrêter là ferait échouer la migration
|
||||||
|
# entière pour un module que l'on nomme déjà.
|
||||||
|
import ast
|
||||||
|
|
||||||
|
with io.open(
|
||||||
|
os.path.join(RACINE, "script", "todo", "todo_upgrade.py"),
|
||||||
|
encoding="utf-8",
|
||||||
|
) as handle:
|
||||||
|
arbre = ast.parse(handle.read())
|
||||||
|
for noeud in ast.walk(arbre):
|
||||||
|
if (
|
||||||
|
isinstance(noeud, ast.FunctionDef)
|
||||||
|
and noeud.name == "uninstall_one_by_one"
|
||||||
|
):
|
||||||
|
corps = ast.dump(ast.Module(body=noeud.body, type_ignores=[]))
|
||||||
|
self.assertIn("wait_at_error", corps)
|
||||||
|
self.assertIn("value=False", corps)
|
||||||
|
return
|
||||||
|
self.fail("uninstall_one_by_one introuvable")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue