Merge branch 'migration_update_and_implement_proxmox_qemu'

[ADD] migration : faire tourner la chaîne 12 → 18, et Proxmox VE

Seize commits antérieurs à l'éclatement de todo.py, et qui vivent donc encore
dedans : les grouper évite de trancher, pour chacun, s'il se relit avant ou
après un déplacement de 9 500 lignes.

Sept font tourner la chaîne 12 → 18, qui mourait à chaque palier autrement :
une ancre française encodée en pourcent qu'OpenUpgrade recolle en sélecteur
CSS, un échec gravé comme fait parce que la progression rend l'objet stocké,
un clone rebâti sans la préparation qui écarte le problème qu'il retrouve, un
autre qui ne rejouait qu'une part de sa liste, xlsxwriter jamais déclaré,
web_responsive que seule la 18 rend incompatible, et « dict | None » évalué au
chargement par le venv de la 12, en Python 3.7.

Les deux analyses en découlent : « qui dépend de qui », parce qu'au palier
17 → 18 la requête a dû être écrite à la main, et les filtres i/u/t sur les
états de passage qu'Odoo ne devrait pas garder.

Le reste ouvre Proxmox VE, localement puis sur hôte distant, relève ce que
chaque VM écrit — figée ou en train de compiler, elles se ressemblaient — rend
le suivi indépendant de l'installation, n'annonce plus un montage sshfs qui a
échoué, et fait choisir sa base à run.sh sans bloquer systemd.

--- EN ---

Sixteen commits that predate the todo.py split and therefore still live inside
it: grouping them avoids ruling, one by one, on whether each reads before or
after a 9 500-line move.

Seven make the 12-to-18 chain run, where every bump died differently: a
percent-encoded French anchor that OpenUpgrade glues into a CSS selector, a
failure engraved as done because the progress map returns the stored object, a
rebuilt clone without the preparation that avoids the problem it then meets,
another that replayed only part of its list, xlsxwriter never declared,
web_responsive that only 18 makes incompatible, and "dict | None" evaluated at
import by the 12's venv, on Python 3.7.

Both analyses follow from that: "who depends on whom", because at the 17-to-18
bump the query had to be written by hand, and the i/u/t filters on the
transitional states Odoo should not keep.

The rest opens Proxmox VE, locally then on a remote host, measures what each VM
writes — frozen or compiling, they looked alike — makes monitoring independent
of the install, stops announcing an sshfs mount that failed, and has run.sh
pick its database without blocking systemd.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-08-25 03:09:37 -04:00
commit 9abac6a555
43 changed files with 9370 additions and 91 deletions

View file

@ -37,6 +37,38 @@ Simplement
./run.sh
```
<!-- [en] -->
Without any argument, ERPLibre picks the database for you: with a single
one it starts on it, with several it shows a numbered menu. It only asks
when a real terminal is there — a service started by systemd behaves
exactly as before.
Two options steer that, and neither reaches Odoo:
| option | effect |
|---|---|
| `--auto-erplibre` | pick the database even when other arguments are given |
| `--no-cli-erplibre` | never show the menu; a lone database is still taken |
Naming a database yourself — `-d`, `--database` — turns the whole thing
off, and so does a `db_name` set in the configuration file.
<!-- [fr] -->
Sans aucun argument, ERPLibre choisit la base pour vous : s'il n'y en a
qu'une il démarre dessus, s'il y en a plusieurs il affiche un menu
numéroté. Il ne pose la question que devant un vrai terminal — un service
lancé par systemd se comporte exactement comme avant.
Deux options le pilotent, et aucune n'arrive jusqu'à Odoo :
| option | effet |
|---|---|
| `--auto-erplibre` | choisir la base même quand d'autres arguments sont donnés |
| `--no-cli-erplibre` | ne jamais afficher le menu ; une base seule est quand même retenue |
Nommer soi-même une base — `-d`, `--database` — désactive tout, de même
qu'un `db_name` posé dans le fichier de configuration.
<!-- [en] -->
With arguments

View file

@ -17,6 +17,21 @@ Simplement
./run.sh
```
Sans aucun argument, ERPLibre choisit la base pour vous : s'il n'y en a
qu'une il démarre dessus, s'il y en a plusieurs il affiche un menu
numéroté. Il ne pose la question que devant un vrai terminal — un service
lancé par systemd se comporte exactement comme avant.
Deux options le pilotent, et aucune n'arrive jusqu'à Odoo :
| option | effet |
|---|---|
| `--auto-erplibre` | choisir la base même quand d'autres arguments sont donnés |
| `--no-cli-erplibre` | ne jamais afficher le menu ; une base seule est quand même retenue |
Nommer soi-même une base — `-d`, `--database` — désactive tout, de même
qu'un `db_name` posé dans le fichier de configuration.
Avec des arguments
```bash

View file

@ -17,6 +17,21 @@ Simply
./run.sh
```
Without any argument, ERPLibre picks the database for you: with a single
one it starts on it, with several it shows a numbered menu. It only asks
when a real terminal is there — a service started by systemd behaves
exactly as before.
Two options steer that, and neither reaches Odoo:
| option | effect |
|---|---|
| `--auto-erplibre` | pick the database even when other arguments are given |
| `--no-cli-erplibre` | never show the menu; a lone database is still taken |
Naming a database yourself — `-d`, `--database` — turns the whole thing
off, and so does a `db_name` set in the configuration file.
With arguments
```bash

View file

@ -6322,6 +6322,17 @@ files = [
{file = "xlrd-1.2.0.tar.gz", hash = "sha256:546eb36cee8db40c3eaa46c351e67ffee6eeb5fa2650b71bc4c758a29a1b29b2"},
]
[[package]]
name = "xlsxwriter"
version = "1.4.5"
description = "A Python module for creating Excel XLSX files."
optional = false
python-versions = "*"
files = [
{file = "XlsxWriter-1.4.5-py2.py3-none-any.whl", hash = "sha256:f9335f1736e2c4fd80e940fe1b6d92d967bf454a1e5d639b0b7a4459ade790cc"},
{file = "XlsxWriter-1.4.5.tar.gz", hash = "sha256:0956747859567ec01907e561a7d8413de18a7aae36860f979f9da52b9d58bc19"},
]
[[package]]
name = "xlwt"
version = "1.3.0"
@ -6620,4 +6631,4 @@ files = [
[metadata]
lock-version = "2.0"
python-versions = ">=3.8.20,<3.9"
content-hash = "660feb736151a54776a49b047a9dcf428145a2add97c89dddca2178be2c9d542"
content-hash = "b627b33459aa69db80c29e21da288e828d4f7f2e002bb14565d050010c1bb12b"

View file

@ -166,6 +166,7 @@ werkzeug = "0.16.1"
wheel = "^0.45.1"
win-unicode-console = "^0.5"
xlrd = "1.2.0"
xlsxwriter = "1.4.5"
xlwt = "1.3.0"
xmlformatter = "^0.2.8"
xmlrunner = "^1.7.7"

49
run.sh
View file

@ -13,10 +13,55 @@ if [ ! -f "${CONFIG_PATH}" ]; then
fi
fi
# Deux options qui appartiennent à ERPLibre, retirées avant de passer la
# main : Odoo ne les connaît pas et mourrait sur « no such option ».
# --auto-erplibre arme le choix de la base à démarrer
# --no-cli-erplibre interdit le menu, sans interdire le choix
# Sans AUCUN argument, le choix s'arme de lui-même : c'est « make run »,
# quelqu'un devant son terminal. Ce défaut-là reste timide — il exige un
# terminal des deux côtés — parce que systemd lance lui aussi run.sh sans
# argument, avec Restart=always.
EL_ARGS=()
EL_AUTO=0
EL_AUTO_EXPLICITE=0
EL_NO_CLI=0
[ $# -eq 0 ] && EL_AUTO=1
while [ $# -gt 0 ]; do
case "$1" in
--auto-erplibre)
EL_AUTO=1
EL_AUTO_EXPLICITE=1
;;
--no-cli-erplibre) EL_NO_CLI=1 ;;
# Tout le reste passe tel quel, y compris les arguments vides et ceux
# qui portent des espaces : un tableau, jamais une chaîne reconstruite.
*) EL_ARGS+=("$1") ;;
esac
shift
done
EL_DB=()
EL_LIB="./script/database/lib_db_select.sh"
if [ "${EL_AUTO}" = "1" ] && [ -f "${EL_LIB}" ]; then
# shellcheck source=script/database/lib_db_select.sh
. "${EL_LIB}"
EL_DB_NAME="$(el_db_select "${CONFIG_PATH}" "${EL_NO_CLI}" \
"${EL_AUTO_EXPLICITE}" "${EL_ARGS[@]}")"
retSelect=$?
# 130 : on a renoncé au menu. Ne pas démarrer Odoo pour autant, et ne pas
# rendre 1 non plus — run.sh:26 réserve déjà 1 à « Odoo a échoué ».
if [ ${retSelect} -eq 130 ]; then
exit 130
fi
if [ -n "${EL_DB_NAME}" ]; then
EL_DB=(-d "${EL_DB_NAME}")
fi
fi
if [ "$ODOO_MODE_TEST" = "true" ]; then
./odoo_bin.sh -c "${CONFIG_PATH}" --limit-time-real 99999 --limit-time-cpu 99999 --limit-memory-hard=0 --log-level=test --test-enable --no-http --stop-after-init "$@"
./odoo_bin.sh -c "${CONFIG_PATH}" --limit-time-real 99999 --limit-time-cpu 99999 --limit-memory-hard=0 --log-level=test --test-enable --no-http --stop-after-init "${EL_DB[@]}" "${EL_ARGS[@]}"
else
./odoo_bin.sh -c "${CONFIG_PATH}" --limit-time-real 99999 --limit-time-cpu 99999 --limit-memory-hard=0 "$@"
./odoo_bin.sh -c "${CONFIG_PATH}" --limit-time-real 99999 --limit-time-cpu 99999 --limit-memory-hard=0 "${EL_DB[@]}" "${EL_ARGS[@]}"
fi
# When need more memory RAM for instance by force
#python3 ./odoo/odoo-bin -c ${CONFIG_PATH} --limit-time-real 99999 --limit-time-cpu 99999 --limit-memory-soft=8589934592 --limit-memory-hard=10737418240 $@

View file

@ -0,0 +1,426 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Les modules d'une base et ce qui les relie.
`check_module_package` répond « quels modules manquent ». La question
d'à côté reste sans réponse : « celui-ci, puis-je le retirer, et
qu'entraîne-t-il avec lui ». On l'a payé cher au palier 17 → 18 —
`web_responsive` devenu incompatible avec `muk_web_theme` — où il a fallu
écrire la requête à la main pour savoir si quelqu'un en dépendait.
Quatre relations, et non une seule liste :
ce dont il dépend ce qu'il faut installer AVANT lui
ce qui en dépend ce qui casse si on le retire
ce qu'il entraîne la fermeture avale, dépendances des dépendances
ce qui tombe avec lui la fermeture amont
La lecture n'écrit jamais : `check_module_package.run_psql` ouvre la
session en lecture seule côté SERVEUR, pas côté politesse.
"""
import json
import os
import sys
sys.path.append(
os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
)
from script.analyse import check_module_package as package # noqa: E402
try:
from script.todo.todo_i18n import t
except Exception: # pragma: no cover - repli si i18n indisponible
def t(key: str) -> str:
return key
# Un graphe de modules Odoo est peu profond — `base` est à la racine et la
# plus longue chaîne réelle tient en une dizaine de sauts. La borne existe
# pour qu'un cycle en base, qui NE DEVRAIT pas exister, produise un
# résultat faux et visible plutôt qu'une boucle sans fin.
PROFONDEUR_MAX = 64
FILTRES = (
"all",
"installed",
"pending",
"application",
"absent",
"broken",
)
# Ce qui compte comme « en place ». « to upgrade » l'est : le module est
# chargé, il sera seulement rejoué. « to install » ne l'est pas encore.
ETATS_PRESENTS = ("installed", "to upgrade")
# Les états qu'Odoo traverse et ne devrait pas garder. Un module qui y
# reste est une opération inachevée : mesuré sur test_neutralize_upgrade_13
# en pleine migration, 22 modules figés en « to remove ». Ils ne sont plus
# tout à fait installés et pas encore partis, donc aucun autre filtre ne
# les montre pour ce qu'ils sont.
ETATS_EN_COURS = ("to install", "to upgrade", "to remove")
def reverse(depend):
"""{module: [ce qui dépend de lui]} à partir de {module: [dépendances]}.
Les modules cités comme dépendance mais absents de la base ont eux
aussi leur entrée : c'est précisément le cas qu'on cherche à voir.
"""
inverse = {}
for module, lst in depend.items():
inverse.setdefault(module, [])
for nom in lst:
inverse.setdefault(nom, []).append(module)
return {nom: sorted(set(lst)) for nom, lst in inverse.items()}
def closure(nom, graphe):
"""Tout ce que `nom` atteint dans `graphe`, lui-même exclu.
En largeur et non en profondeur : la récursion sur un graphe cyclique
déborde la pile avant qu'on ait compris pourquoi.
"""
vus, bord, profondeur = set(), [nom], 0
while bord and profondeur < PROFONDEUR_MAX:
suivant = []
for courant in bord:
for voisin in graphe.get(courant, []):
if voisin not in vus and voisin != nom:
vus.add(voisin)
suivant.append(voisin)
bord = suivant
profondeur += 1
return sorted(vus)
def present(etat):
return etat in ETATS_PRESENTS
def pending(etat):
"""L'état est-il un passage, plutôt qu'une destination ?"""
return etat in ETATS_EN_COURS
def broken(recensement, depend):
"""[(module, dépendance, raison)] — ce qui est installé sur du vide.
Un module installé dont une dépendance ne l'est pas est un état
qu'Odoo ne produit jamais lui-même ; il vient d'une migration, d'une
désinstallation forcée ou d'un addons-path incomplet. Le charger
échoue, ou pire, réussit à moitié.
"""
lst = []
for module, infos in sorted(recensement.items()):
if not present(infos[0]):
continue
for nom in sorted(set(depend.get(module, []))):
if nom not in recensement:
lst.append((module, nom, "unknown"))
elif not present(recensement[nom][0]):
lst.append((module, nom, recensement[nom][0]))
return lst
def survey(database):
"""Tout ce que l'écran et le rapport ont besoin de savoir.
Une seule lecture de la base pour les deux : deux assemblages
finiraient par montrer deux états contradictoires du même système.
"""
recensement = package.census(database)
if recensement is None:
return {"database": database, "unavailable": True}
depend = package.dependencies(database)
# Un module sans dépendance déclarée doit exister dans le graphe :
# sinon `closure` et l'écran le traitent comme inconnu.
depend = {nom: sorted(set(depend.get(nom, []))) for nom in recensement}
return {
"database": database,
"version": package.db_version(database),
"modules": recensement,
"depends": depend,
"dependents": reverse(depend),
"broken": broken(recensement, depend),
}
def verdict(rapport, nom):
infos = rapport["modules"].get(nom)
if infos is None:
return "unknown"
return package.ETAT_VERS_VERDICT.get(infos[0], "uninstallable")
def icon(rapport, nom):
return package.ICONE.get(verdict(rapport, nom), "·")
def counts(rapport, nom):
"""(dépendances, dépendants, dépendants EN PLACE).
Le troisième chiffre est celui qui décide d'un retrait. Sur une base
réelle, `web_responsive` a deux dépendants déclarés et zéro installé :
ne montrer que « 2 » ferait renoncer à un retrait sans danger.
"""
aval = rapport["dependents"].get(nom, [])
return (
len(rapport["depends"].get(nom, [])),
len(aval),
sum(1 for autre in aval if present_of(rapport, autre)),
)
def modules_cassants(rapport):
"""Les modules qui apparaissent à gauche d'une dépendance cassée."""
return {module for module, _dep, _raison in rapport.get("broken", [])}
def rows(rapport, filtre="all"):
"""La liste de gauche : un module par ligne, déjà triée.
Tri par nom. Trier par nombre de dépendants mettrait `base` en tête à
chaque fois — vrai, et sans intérêt : on cherche un module qu'on
nomme, pas le plus populaire.
"""
cassants = modules_cassants(rapport)
lst = []
for nom in sorted(rapport["modules"]):
etat = rapport["modules"][nom][0]
if filtre == "installed" and not present(etat):
continue
# « non installés » exclut aussi les états de passage : un module
# en « to remove » EST encore installé, il s'en va. Le ranger avec
# ce qu'on pourrait installer était trompeur ; il a sa touche.
if filtre == "absent" and (present(etat) or pending(etat)):
continue
if filtre == "pending" and not pending(etat):
continue
# `application` est le drapeau « App » d'Odoo : ces modules-là
# sont les racines, ceux qu'on installe pour de vrai — les autres
# arrivent derrière eux.
if filtre == "application" and not rapport["modules"][nom][2]:
continue
if filtre == "broken" and nom not in cassants:
continue
amont, aval, vivants = counts(rapport, nom)
lst.append(
{
"name": nom,
"label": f"{icon(rapport, nom)} {nom}",
"detail": f"{amont}↓ {vivants}/{aval}↑",
"broken": nom in cassants,
}
)
return lst
DETAILS = ("depends", "dependents", "pulls", "falls")
TITRE_DETAIL = {
"depends": "What it needs, directly",
"dependents": "What needs it, directly",
"pulls": "Everything it pulls in",
"falls": "Everything that falls with it",
}
def listing(rapport, nom, mode):
"""Les noms à montrer pour ce mode, dans l'ordre d'affichage."""
if mode == "depends":
return rapport["depends"].get(nom, [])
if mode == "dependents":
return rapport["dependents"].get(nom, [])
if mode == "pulls":
return closure(nom, rapport["depends"])
if mode == "falls":
return closure(nom, rapport["dependents"])
return []
def paint(texte, genre, colour):
from script.todo import migration_status as status
return status.paint(texte, genre, colour)
def pane_text(rapport, nom, mode=None, colour=False, limit=0):
"""Le panneau de droite pour le module choisi."""
if not nom:
return t("Nothing to show yet.")
infos = rapport["modules"].get(nom)
if infos is None:
return f"❌ {nom} : {t('unknown to this database')}"
etat, resume, application, auteur = infos
lignes = [
paint(f"{icon(rapport, nom)} {nom}", "step", colour),
f" {resume or ''}".rstrip(),
"",
f" {t('state'):<22} {etat}",
f" {t('author'):<22} {auteur or '—'}",
f" {t('application'):<22} {t('yes') if application else t('no')}",
]
amont, aval, vivants = counts(rapport, nom)
lignes += [
f" {t('depends on'):<22} {amont}",
f" {t('needed by'):<22} {aval}"
f" · {vivants} {t('of them installed')}",
]
if present(etat) and aval and not vivants:
# La réponse à « puis-je le retirer », écrite plutôt que déduite
# de deux chiffres qu'il faudrait comparer soi-même.
lignes.append(
" " + paint(t("nothing installed depends on it"), "ok", colour)
)
lignes.append("")
if mode in DETAILS:
lst = listing(rapport, nom, mode)
lignes.append(paint(f" {t(TITRE_DETAIL[mode])}", "step", colour))
if not lst:
lignes.append(f" {t('nothing')}")
for autre in lst[: limit or None]:
marque = "" if present_of(rapport, autre) else f" ← {t('absent')}"
genre = "ok" if present_of(rapport, autre) else "warn"
lignes.append(
f" {icon(rapport, autre)} "
+ paint(f"{autre}{marque}", genre, colour)
)
if limit and len(lst) > limit:
lignes.append(f" … {len(lst) - limit} {t('more')}")
else:
lignes.append(f" {t('press d to walk the dependencies')}")
casses = [
(dep, raison)
for module, dep, raison in rapport.get("broken", [])
if module == nom
]
if casses:
lignes.append("")
lignes.append(
paint(
f" ❌ {t('installed on missing dependencies')}",
"fail",
colour,
)
)
for dep, raison in casses:
lignes.append(f" {dep} ({raison})")
return "\n".join(lignes)
def present_of(rapport, nom):
infos = rapport["modules"].get(nom)
return bool(infos) and present(infos[0])
def head_text(rapport):
installes = sum(
1 for infos in rapport["modules"].values() if present(infos[0])
)
total = len(rapport["modules"])
casses = len(rapport.get("broken", []))
en_cours = sum(
1 for infos in rapport["modules"].values() if pending(infos[0])
)
texte = (
f"📦 {rapport['database']}"
f" ({t('Odoo')} {rapport.get('version') or '?'})"
f" · {installes}/{total} {t('modules installed')}"
)
if en_cours:
# Annoncé dans l'en-tête et non seulement derrière une touche :
# une opération inachevée explique des symptômes qu'on chercherait
# ailleurs pendant une heure.
texte += f" · ⏳ {en_cours} {t('in progress')}"
if casses:
texte += f" · ❌ {casses} {t('broken dependency(ies)')}"
return texte
def render_text(rapport, limit=0, cap=0):
"""Le rapport en clair, pour un terminal qui n'ouvre pas d'écran.
`cap` borne le NOMBRE DE MODULES, `limit` la longueur de chaque liste
de dépendances. Une base porte trois mille modules : sans borne, le
repli déverse six mille lignes dans un menu, ce qui n'est pas un
repli mais une seconde panne.
"""
if rapport.get("unavailable"):
return [f"❌ {t('Cannot read the database: ')}{rapport['database']}"]
lignes = [head_text(rapport), ""]
lst_row = rows(rapport)
for row in lst_row[: cap or None]:
nom = row["name"]
amont = rapport["depends"].get(nom, [])
lignes.append(f"{row['label']:<44} {row['detail']}")
if amont:
lignes.append(f" → {', '.join(amont[: limit or None])}")
if cap and len(lst_row) > cap:
lignes.append(f" … {len(lst_row) - cap} {t('more')}")
lst_en_cours = [
nom
for nom, infos in sorted(rapport["modules"].items())
if pending(infos[0])
]
if lst_en_cours:
lignes.append("")
lignes.append(f"⏳ {t('left in a transient state')}")
for nom in lst_en_cours[: limit or None]:
lignes.append(f" {nom} ({rapport['modules'][nom][0]})")
if limit and len(lst_en_cours) > limit:
lignes.append(f" … {len(lst_en_cours) - limit} {t('more')}")
if rapport.get("broken"):
lignes.append("")
lignes.append(f"❌ {t('installed on missing dependencies')}")
for module, dep, raison in rapport["broken"]:
lignes.append(f" {module} → {dep} ({raison})")
return lignes
def main(argv=None):
import argparse
parser = argparse.ArgumentParser(
description=(
"List every module of a database and how they depend on one"
" another. Read-only."
)
)
parser.add_argument("-d", "--database", required=True)
parser.add_argument(
"--limit", type=int, default=0, help="cap long lists (0 = no cap)"
)
parser.add_argument("--json", action="store_true", help="machine output")
parser.add_argument(
"--no-tui", action="store_true", help="never open the screen"
)
config = parser.parse_args(argv)
rapport = survey(config.database)
if rapport.get("unavailable"):
print(f"❌ {t('Cannot read the database: ')}{config.database}")
return 2
if config.json:
print(
json.dumps(rapport, indent=2, sort_keys=True, ensure_ascii=False)
)
return 1 if rapport["broken"] else 0
if not config.no_tui:
try:
from script.analyse.check_module_dependency_tui import run_tui
except Exception:
run_tui = None
if run_tui and run_tui(rapport):
return 1 if rapport["broken"] else 0
print("\n".join(render_text(rapport, limit=config.limit)))
return 1 if rapport["broken"] else 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,344 @@
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Les modules d'une base et leurs dépendances, en plein écran.
Une base porte trois à six cents modules. Le rapport texte les liste tous
et la question qu'on se pose — « celui-ci, qui en dépend » — se trouve
quelque part au milieu de plusieurs milliers de lignes.
Les données viennent de `check_module_dependency`, comme le rapport
texte : un seul assemblage, donc une seule vérité sur la même base.
"""
import os
import sys
sys.path.append(
os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
)
from script.analyse import check_module_dependency as dependency # noqa: E402
try:
from script.todo.todo_i18n import t
except Exception: # pragma: no cover - repli si i18n indisponible
def t(key: str) -> str:
return key
CSS = """
Screen { layout: vertical; }
#head { height: 3; padding: 0 1; background: $panel; color: $text; }
#body { height: 1fr; }
#left { width: 46; border-right: solid $accent; }
#pane { width: 1fr; padding: 0 1; }
#find { display: none; }
#find.visible { display: block; }
"""
def filter_label(filtre):
return {
"all": t("all modules"),
"installed": t("installed only"),
"pending": t("in progress"),
"application": t("apps only"),
"absent": t("not installed"),
"broken": t("broken dependencies"),
}.get(filtre, filtre)
def mode_label(mode):
"""Le nom du mode courant, pour le sous-titre.
Un panneau qui change sans dire pourquoi se lit comme un écran cassé.
"""
if mode is None:
return t("summary")
return t(dependency.TITRE_DETAIL.get(mode, mode))
def subtitle(mode, filtre, motif):
morceaux = [mode_label(mode), filter_label(filtre)]
if motif:
morceaux.append(f"« {motif} »")
return " · ".join(morceaux)
def matching(lst_row, motif):
"""Les lignes dont le nom contient `motif`, sans égard à la casse."""
if not motif:
return lst_row
bas = motif.lower()
return [row for row in lst_row if bas in row["name"].lower()]
def next_mode(mode):
"""Le mode suivant : résumé → les quatre relations → résumé."""
suite = (None,) + dependency.DETAILS
courant = mode if mode in suite else None
return suite[(suite.index(courant) + 1) % len(suite)]
def toggle_filter(courant, cible):
"""Aller à `cible` — ou en revenir si l'on y est déjà.
Une touche qui ne fait qu'ALLER piège : arrivé sur « en cours », il
faudrait se souvenir de quelle autre touche ramène à la liste
entière. La même touche fait donc l'aller et le retour.
"""
return "all" if courant == cible else cible
def next_filter(filtre):
"""Le filtre suivant, en boucle."""
suite = dependency.FILTRES
index = suite.index(filtre) if filtre in suite else 0
return suite[(index + 1) % len(suite)]
def current_name(lst_row, index):
"""Le module sous le curseur, ou None si la liste est vide.
`index` peut dépasser : la table garde son curseur d'avant quand la
liste raccourcit, et lire hors bornes ferait tomber l'écran sur une
IndexError au moment précis où l'on filtre.
"""
if not lst_row or index is None or not 0 <= index < len(lst_row):
return None
return lst_row[index]["name"]
def cursor_for(lst_row, garde):
"""Où replacer le curseur pour retrouver `garde`. None si parti.
Changer de filtre ramenait le curseur en tête, donc on perdait le
module qu'on lisait — c'est-à-dire la raison même du filtre.
"""
if not garde:
return None
for index, row in enumerate(lst_row):
if row["name"] == garde:
return index
return None
def populate(table, lst_row, garde=None):
"""Remplir la table, puis y remettre le curseur sur `garde`.
Prend la table plutôt que l'App : rien ici ne dépend de Textual sauf
trois appels de méthode, donc un faux objet suffit à l'éprouver.
"""
table.clear()
for row in lst_row:
table.add_row(row["label"][:40], row["detail"])
place = cursor_for(lst_row, garde)
if place is not None:
table.move_cursor(row=place)
return place
def hide_find(champ, table):
"""Refermer la recherche et rendre le clavier à la liste.
Le `focus()` explicite n'est pas une précaution : sans lui, Textual
donne le clavier au premier widget focalisable, et c'est le champ de
recherche — « display: none » ne le retire pas de ce choix-là. On
ouvrait donc l'écran en tapant dans une boîte invisible, et « d » ne
faisait rien. (La TABULATION, elle, saute bien un widget caché :
mesuré, d'où l'absence de `can_focus` ici — une ligne qu'on ne peut
pas faire échouer n'a rien à faire dans le fichier.)
Ne vide PAS le champ : l'appelant décide s'il efface le motif, et
c'est cet effacement qui redéclenche le filtrage.
"""
champ.remove_class("visible")
table.focus()
def build_app(rapport):
"""Textual est importé ICI : le module reste testable sans lui."""
from rich.text import Text
from textual import on
from textual.app import App, ComposeResult
from textual.containers import Horizontal, VerticalScroll
from textual.widgets import DataTable, Footer, Header, Input, Static
class DependencyApp(App):
CSS = globals()["CSS"]
BINDINGS = [
("q", "quit", t("Quit")),
("escape", "leave", t("Quit")),
("d", "cycle_detail", t("Dependencies")),
("f", "cycle_filter", t("Filter")),
("i", "set_filter('installed')", t("Installed")),
("u", "set_filter('pending')", t("In progress")),
("t", "set_filter('application')", t("Apps")),
("slash", "find", t("Search")),
]
def __init__(self, rapport):
super().__init__()
self.rapport = rapport
self.mode = None
self.filtre = "all"
self.motif = ""
self.lst_row = []
def compose(self) -> ComposeResult:
yield Header()
yield Static("", id="head")
with Horizontal(id="body"):
yield DataTable(id="left", cursor_type="row")
with VerticalScroll(id="pane"):
yield Static("", id="content")
yield Input(placeholder=t("module name…"), id="find")
yield Footer()
def on_mount(self):
self.title = t("Modules and their dependencies")
table = self.query_one("#left", DataTable)
table.add_columns(t("module"), "↓↑")
hide_find(self.query_one("#find", Input), table)
self.query_one("#head", Static).update(
dependency.head_text(self.rapport)
)
self._fill()
def _fill(self):
"""Reconstruire la liste, en gardant le module sous le curseur.
Sans cela, changer de filtre ramène le curseur en tête et l'on
perd le module qu'on était en train de lire — c'est-à-dire la
raison pour laquelle on a filtré.
"""
garde = self._current_name()
self.lst_row = matching(
dependency.rows(self.rapport, self.filtre), self.motif
)
populate(self.query_one("#left", DataTable), self.lst_row, garde)
self._show()
def _current_name(self):
table = self.query_one("#left", DataTable)
return current_name(self.lst_row, table.cursor_row)
def _show(self):
self.sub_title = subtitle(self.mode, self.filtre, self.motif)
self.query_one("#content", Static).update(
Text.from_ansi(
dependency.pane_text(
self.rapport,
self._current_name(),
mode=self.mode,
colour=True,
)
)
)
def action_cycle_detail(self):
"""Parcourir les quatre relations, puis revenir au résumé.
« ce dont il dépend » et « ce qui en dépend » répondent à deux
questions opposées, et les fermetures disent l'ampleur réelle :
retirer un module en entraîne parfois trente.
"""
self.mode = next_mode(self.mode)
self._show()
def action_cycle_filter(self):
self.filtre = next_filter(self.filtre)
self._fill()
def action_set_filter(self, cible):
"""Sauter droit à un filtre. `f` reste là pour les parcourir."""
self.filtre = toggle_filter(self.filtre, cible)
self._fill()
def action_find(self):
champ = self.query_one("#find", Input)
champ.add_class("visible")
champ.focus()
def action_leave(self):
"""Échap referme la recherche ; il ne quitte que sinon.
Quitter parce qu'on renonce à une recherche serait une
surprise coûteuse : on a parfois filtré trois mille modules
pour arriver là.
"""
champ = self.query_one("#find", Input)
if not champ.has_class("visible"):
self.exit()
return
# Vider le champ suffit à tout refaire : l'événement
# `Input.Changed` remet le motif à zéro et reconstruit la
# liste. Le refaire ici à la main donnerait deux chemins pour
# le même état, dont un seul serait jamais éprouvé.
champ.value = ""
hide_find(champ, self.query_one("#left", DataTable))
@on(Input.Changed, "#find")
def _find_changed(self, event):
self.motif = event.value.strip()
self._fill()
@on(Input.Submitted, "#find")
def _find_submitted(self, event):
# Le champ reste VISIBLE : il porte le motif en cours, et le
# cacher laisserait une liste filtrée sans dire par quoi.
self.query_one("#left", DataTable).focus()
@on(DataTable.RowHighlighted, "#left")
def _row_changed(self, event):
self._show()
return DependencyApp(rapport)
def in_event_loop():
"""Une boucle asyncio tourne-t-elle déjà dans CE processus ?"""
import asyncio
try:
asyncio.get_running_loop()
except RuntimeError:
return False
return True
def run_tui(rapport, run_app=True):
"""Ouvrir l'écran. False si l'on n'a pas pu — et alors on DIT pourquoi."""
if not rapport or rapport.get("unavailable"):
return False
if not rapport.get("modules"):
return False
if not sys.stdout.isatty():
print(f"ℹ️ {t('Not a terminal: showing the text report instead.')}")
return False
try:
from script.todo import textual_setup
except Exception:
textual_setup = None
if textual_setup and not textual_setup.ensure():
return False
try:
app = build_app(rapport)
except ImportError:
print(
f"ℹ️ {t('Textual is missing from this interpreter:')}"
f" {sys.executable}"
)
return False
if not run_app:
return app
if in_event_loop():
print(
f"ℹ️ {t('Already inside a running screen: open it in its own')}"
f" {t('process instead.')}"
)
return False
app.run()
return True

View file

@ -0,0 +1,205 @@
#!/usr/bin/env bash
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
#
# Bibliothèque SOURÇABLE : quelle base de données démarrer.
#
# « ./run.sh » sans argument démarrait Odoo sans base, et l'on choisissait
# dans le gestionnaire web. Quand il n'y a qu'une base, la question ne se
# pose pas ; quand il y en a quinze, un menu au terminal vaut mieux qu'une
# page web.
#
# TOUT ce qui s'affiche part sur stderr. Sur stdout, UNIQUEMENT le nom
# retenu : l'appelant lit `el_db_select` par substitution de commande, et
# un menu écrit sur stdout deviendrait une partie du nom de la base.
#
# Aucun `set` ici : une bibliothèque sourcée impose ses options à son hôte,
# et run.sh lit `$ODOO_MODE_TEST` sans valeur par défaut — `set -u` le
# tuerait avant tout le reste.
# Les couleurs peuvent déjà venir de l'hôte ; ne pas les écraser.
: "${Red:=\033[0;31m}"
: "${Yellow:=\033[0;33m}"
: "${Color_Off:=\033[0m}"
_el_db_say() {
echo -e "$*" >&2
}
_el_db_tty() {
# Deux descripteurs, et le second est stderr — PAS stdout.
#
# `validate_to_continue.sh` teste stdout parce qu'il y écrit sa question.
# Ici la question part sur stderr, et l'appelant nous lit par
# substitution de commande : dans `$( … )`, stdout est TOUJOURS un tube,
# donc `-t 1` serait faux même devant un vrai terminal et le menu ne
# s'ouvrirait jamais. Ce qu'il faut savoir, c'est « la personne verra-
# t-elle l'invite », et l'invite sort par stderr.
#
# stdin compte tout autant : sans lui, `read` ne rendrait jamais rien.
# Le TUI de todo.py met stdout ET stderr en tube en laissant stdin
# intact — c'est le cas que ce garde doit refuser.
#
# Fonction isolée pour qu'un test puisse la remplacer sans pseudo-terminal.
[ -t 0 ] && [ -t 2 ]
}
el_db_already_chosen() {
# L'appelant a-t-il déjà nommé sa base ? Les quatre formes qu'optparse
# accepte, plus « -d » nu en fin de ligne — ce que produit
# « ./run.sh -d $(bd) » de conf/make.robotlibre.Makefile quand bd est
# vide. Dans tous ces cas on se retire : c'est son intention, pas la
# nôtre, et on ne complète pas un « -d » resté sans valeur.
while [ $# -gt 0 ]; do
case "$1" in
-d | -d?* | --database | --database=*) return 0 ;;
esac
shift
done
return 1
}
el_db_config_has_name() {
# Une configuration de production nomme sa base. L'option de ligne de
# commande l'emporte sur le fichier : injecter « -d » par-dessus
# écraserait ce choix en silence.
local fichier="$1"
[ -f "${fichier}" ] || return 1
grep -Eq '^[[:space:]]*db_name[[:space:]]*=[[:space:]]*[^[:space:]]' \
"${fichier}" || return 1
# « False » et « None » sont la façon d'Odoo d'écrire « aucune ».
if grep -Eq \
'^[[:space:]]*db_name[[:space:]]*=[[:space:]]*(False|false|None)[[:space:]]*$' \
"${fichier}"; then
return 1
fi
return 0
}
el_db_list() {
# Les bases, une par ligne. Sortie non nulle si la SONDE a échoué — et
# alors on ne rend rien plutôt que d'inventer.
#
# stderr jeté, jamais fusionné : le CLI « db » n'appelle pas
# parse_config(), donc aucun journal n'est configuré et tout part nu sur
# stderr. Fusionner ferait de chaque ligne d'une trace un nom de base —
# le défaut que script/todo/database_manager.py a déjà dû corriger.
#
# Le mode couverture est neutralisé pour ce seul appel : sinon la sonde
# se mesure elle-même et sème un .coverage.* dans le rapport.
local brut retenu
brut="$(ODOO_MODE_COVERAGE= ./odoo_bin.sh db --list 2>/dev/null)" || return 1
# « _cache_ » nomme les bases-modèles, pas des bases de travail. Sur une
# machine qui vient de lancer les tests, ce peut être la SEULE : sans ce
# filtre, la règle « une seule base » démarrerait Odoo sur un modèle.
retenu="$(printf '%s\n' "${brut}" \
| tr -d '\r' \
| grep -v '^[[:space:]]*$' \
| grep -v '^_cache_')"
[ -n "${retenu}" ] && printf '%s\n' "${retenu}"
return 0
}
el_db_choose() {
# Le menu, entièrement sur stderr ; le nom retenu sur stdout.
#
# Numérotation à partir de 1 et « [0] » pour annuler : le vocabulaire
# des menus Python du projet, pour qu'on reconnaisse le même produit.
local -a bases=("$@")
local rang nom choix
while true; do
_el_db_say ""
_el_db_say "🗄 ${#bases[@]} bases de données. Laquelle démarrer ?"
rang=1
for nom in "${bases[@]}"; do
_el_db_say " [${rang}] ${nom}"
rang=$((rang + 1))
done
_el_db_say " [0] Annuler"
printf 'Choix : ' >&2
# shellcheck disable=SC2162
if ! read -r choix; then
# Ctrl-D : finir la ligne avant de partir, sinon l'invite reste
# collée au shell qui reprend la main.
_el_db_say ""
return 130
fi
case "${choix}" in
0) return 130 ;;
"" | *[!0-9]*) ;;
*)
if [ "${choix}" -ge 1 ] && [ "${choix}" -le "${#bases[@]}" ]; then
printf '%s\n' "${bases[$((choix - 1))]}"
return 0
fi
;;
esac
_el_db_say "${Red}Choix invalide${Color_Off} :" \
"un nombre entre 0 et ${#bases[@]}."
done
}
el_db_select() {
# Le nom de la base à démarrer, sur stdout — ou rien. 130 si l'on renonce.
#
# Les gardes vont de la moins chère à la plus chère : la sonde coûte
# 0,8 s (l'import d'Odoo, pas la requête) et ne se paie que lorsqu'elle
# peut servir. Sur tous les chemins automatisés, zéro appel.
#
# `explicite` distingue « --auto-erplibre a été demandé » de « run.sh a
# simplement reçu zéro argument ». Le défaut implicite doit rester
# timide : systemd lance « /bin/bash …/run.sh » SANS argument, avec
# Restart=always — un comportement neuf s'y déclencherait à chaque
# démarrage de production, sans que personne l'ait demandé.
local config="$1"
local sans_cli="$2"
local explicite="$3"
shift 3
el_db_already_chosen "$@" && return 0
el_db_config_has_name "${config}" && return 0
# Sans demande explicite, il faut un terminal des deux côtés pour que la
# sélection s'arme du tout. Demandée, elle peut servir sans terminal —
# mais seulement là où aucune question n'est posée.
if [ "${explicite}" != "1" ] && ! _el_db_tty; then
return 0
fi
local liste
if ! liste="$(el_db_list)"; then
_el_db_say "${Yellow}⚠${Color_Off} Impossible de lister les bases ;" \
"Odoo démarre sans base présélectionnée."
return 0
fi
local -a bases=()
while IFS= read -r nom; do
[ -n "${nom}" ] && bases+=("${nom}")
done <<< "${liste}"
if [ "${#bases[@]}" -eq 0 ]; then
_el_db_say "${Yellow}⚠${Color_Off} Aucune base de données ;" \
"Odoo démarre sur son gestionnaire web."
return 0
fi
if [ "${#bases[@]}" -eq 1 ]; then
_el_db_say "🗄 Une seule base : ${bases[0]}"
printf '%s\n' "${bases[0]}"
return 0
fi
if [ "${sans_cli}" = "1" ]; then
_el_db_say "${Yellow}⚠${Color_Off} ${#bases[@]} bases et" \
"--no-cli-erplibre : aucune n'est présélectionnée."
return 0
fi
if ! _el_db_tty; then
_el_db_say "${Yellow}⚠${Color_Off} ${#bases[@]} bases, mais pas de" \
"terminal pour choisir : aucune n'est présélectionnée."
return 0
fi
el_db_choose "${bases[@]}"
}

View file

@ -2,6 +2,12 @@
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
# Annotations différées : la migration charge ce module sous le Python
# d'Odoo 12 — 3.7 — où « dict | None » et « tuple[str, str] » n'existent
# pas encore. Sans ceci, l'annotation est ÉVALUÉE au chargement et la
# migration meurt sur un TypeError avant d'avoir rien fait.
from __future__ import annotations
import datetime
import logging
import os

View file

@ -2,6 +2,12 @@
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
# Annotations différées : la migration charge ce module sous le Python
# d'Odoo 12 — 3.7 — où « dict | None » et « tuple[str, str] » n'existent
# pas encore. Sans ceci, l'annotation est ÉVALUÉE au chargement et la
# migration meurt sur un TypeError avant d'avoir rien fait.
from __future__ import annotations
import os
import pathlib
import webbrowser

View file

@ -2,6 +2,12 @@
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
# Annotations différées : la migration charge ce module sous le Python
# d'Odoo 12 — 3.7 — où « dict | None » et « tuple[str, str] » n'existent
# pas encore. Sans ceci, l'annotation est ÉVALUÉE au chargement et la
# migration meurt sur un TypeError avant d'avoir rien fait.
from __future__ import annotations
import git
from agithub.GitHub import GitHub
from colorama import Fore, Style

View file

@ -2,6 +2,12 @@
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
# Annotations différées : la migration charge ce module sous le Python
# d'Odoo 12 — 3.7 — où « dict | None » et « tuple[str, str] » n'existent
# pas encore. Sans ceci, l'annotation est ÉVALUÉE au chargement et la
# migration meurt sur un TypeError avant d'avoir rien fait.
from __future__ import annotations
import os

View file

@ -0,0 +1,87 @@
-- © 2021-2026 TechnoLibre (http://www.technolibre.ca)
-- License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
--
-- Correctifs à appliquer AVANT qu'OpenUpgrade ne migre vers Odoo 13.
--
-- En SQL et non en Python : ce fichier tourne sur une base encore en 12,
-- que le code de la 13 ne saurait pas charger.
-- Ancres de page encodées en pourcentage.
--
-- Le script d'OpenUpgrade `website/migrations/13.0.1.0/post-migration.py`
-- ramasse les `href` des ancres d'une page et les RECOLLE en sélecteur CSS :
--
-- links = doc.cssselect(r"a[href^=\#]:not([href=\#])")
-- "selector": ", ".join([link.attrib["href"] for link in links])
--
-- Or un `href` n'est pas un sélecteur. Une ancre française encodée —
-- `#principes-mn%C3%A9moniques` — y introduit un `%`, interdit dans un
-- identifiant CSS, et la migration meurt :
--
-- cssselect.parser.SelectorSyntaxError: Expected selector, got <DELIM '%'>
--
-- Éprouvé avec le parseur d'Odoo 13 : `#principes-mnémoniques` passe,
-- `#principes-mn%C3%A9moniques` non. Décoder suffit donc, et un accent
-- reste un identifiant CSS valide.
--
-- On ne touche QUE les vues qu'OpenUpgrade parcourt — celles qui portent
-- une page — et QUE les `href` d'ancre. Un `%` ailleurs dans une URL est
-- légitime et reste intact.
--
-- Rejouable : une fois décodé il n'y a plus de `%XX` à trouver.
-- Le décodeur vit dans `pg_temp` : il disparaît avec la session psql, et
-- ne laisse rien derrière lui dans la base du client.
CREATE FUNCTION pg_temp.el_url_decode(entree text) RETURNS text AS $decode$
DECLARE
octets bytea = '';
morceau text;
BEGIN
-- Deux à deux : « %C3 » devient un octet, tout autre caractère se
-- recopie tel quel. On rassemble en bytea AVANT de convertir, car un
-- caractère accenté tient sur deux octets et les décoder séparément
-- rendrait deux caractères illisibles.
FOR morceau IN
SELECT (regexp_matches(entree, '(%[0-9A-Fa-f]{2}|.)', 'g'))[1]
LOOP
IF length(morceau) = 3 AND left(morceau, 1) = '%' THEN
octets = octets || decode(substring(morceau, 2, 2), 'hex');
ELSE
octets = octets || convert_to(morceau, 'UTF8');
END IF;
END LOOP;
RETURN convert_from(octets, 'UTF8');
END
$decode$ LANGUAGE plpgsql IMMUTABLE STRICT;
DO $$
DECLARE
ancre RECORD;
combien integer := 0;
BEGIN
IF to_regclass('ir_ui_view') IS NULL THEN
RETURN;
END IF;
FOR ancre IN
SELECT DISTINCT trouve[1] AS brut
FROM ir_ui_view v
JOIN website_page p ON p.view_id = v.id,
LATERAL regexp_matches(
v.arch_db, 'href="(#[^"]*%[0-9A-Fa-f]{2}[^"]*)"', 'g'
) AS trouve
LOOP
UPDATE ir_ui_view
SET arch_db = replace(
arch_db,
'href="' || ancre.brut || '"',
'href="' || pg_temp.el_url_decode(ancre.brut) || '"'
)
WHERE position('href="' || ancre.brut || '"' IN arch_db) > 0;
combien := combien + 1;
RAISE NOTICE 'ancre decodee : % -> %',
ancre.brut, pg_temp.el_url_decode(ancre.brut);
END LOOP;
IF combien > 0 THEN
RAISE NOTICE '% ancre(s) de page decodee(s)', combien;
END IF;
END $$;

View file

@ -1 +1,13 @@
muk_web_theme
# Modules à retirer AVANT de monter de 14.0 vers 15.0.
#
# Un module par ligne, une justification après « # ».
# Raison d'origine NON consignée : la ligne arrive avec cbc43fde, sans un
# mot. Mesuré depuis, sur test_neutralize (12 → 18) : la commande part
# bien — « uninstall_addons.sh test_neutralize_upgrade_15 muk_web_theme »
# est dans le journal — et le module est pourtant « installed » de la 15 à
# la 18. Odoo ne cherche que l'état « installed » et le module traînait en
# « to remove » depuis la 13 ; il sort en 0 sans rien retirer.
# Le retrait n'a donc JAMAIS eu lieu, et rien n'a cassé pour autant.
# Avant de s'y fier, retrouver le motif — ou retirer la ligne.
muk_web_theme # motif d'origine inconnu ; retrait jamais effectif (mesuré)

View file

@ -0,0 +1,15 @@
# Modules à retirer AVANT de monter de 17.0 vers 18.0.
#
# Un module par ligne, une justification après « # ».
# muk_web_theme n'excluait que web_enterprise en 16 et en 17 ; la 18 y
# ajoute web_responsive. Les deux cohabitaient donc légalement jusqu'ici,
# et la base arrive en 18 dans un état que la 18 interdit. Le chargement
# meurt dès que `Module.update_list()` installe un module auto_install,
# car Odoo revérifie alors toutes les exclusions :
# UserError: Les modules "MuK Backend Theme" et "Web Responsive"
# sont incompatibles.
# On retire web_responsive et l'on garde le thème MuK, qui porte
# l'apparence du back-office. L'inverse marcherait aussi : les deux
# rendent le même service, il faut simplement en choisir un.
web_responsive # exclu par muk_web_theme a partir de la 18.0

View file

@ -0,0 +1,188 @@
<!---------------------------->
<!-- multilingual suffix: en, fr -->
<!-- no suffix: en -->
<!---------------------------->
<!-- [en] -->
# Deploying VMs on a Proxmox VE host
<!-- [fr] -->
# Déployer des VM sur un hôte Proxmox VE
<!-- [en] -->
Two different things live in this directory:
- `install_proxmox.sh` turns a Debian into a Proxmox hypervisor. See
`script/qemu/README.md`, which documents the `proxmox` distro of the
deployment catalog.
- `proxmox_deploy.py` deploys VMs **on** such a host, from
`TODO › Execute › Deploy › Proxmox VE`, right under `QEMU/KVM`.
<!-- [fr] -->
Deux choses différentes vivent dans ce répertoire :
- `install_proxmox.sh` transforme une Debian en hyperviseur Proxmox. Voir
`script/qemu/README.fr.md`, qui documente la distro `proxmox` du catalogue
de déploiement.
- `proxmox_deploy.py` déploie des VM **sur** un tel hôte, depuis
`TODO › Execute › Deploy › Proxmox VE`, juste sous `QEMU/KVM`.
<!-- [en] -->
## The whole difference: the hypervisor is elsewhere
With QEMU/KVM, the hypervisor is the machine running the script. With Proxmox
it is somewhere else, so the first question is **which host** — and the answer
is remembered for the session. Three ways, all offered by the menu:
1. **From the local QEMU VMs** — a `proxmox` VM deployed here. Its address
comes from the DHCP lease, nothing to retype.
2. **By address** — `user@host`, plus an optional SSH jump.
3. **From `~/.ssh/config`** — the alias already carries user, port and
ProxyJump; nothing else is asked.
<!-- [fr] -->
## Toute la différence : l'hyperviseur est ailleurs
Avec QEMU/KVM, l'hyperviseur est la machine qui exécute le script. Avec
Proxmox il est ailleurs : la première question est donc **quel hôte** — et la
réponse est retenue pour la session. Trois voies, toutes proposées par le menu :
1. **Depuis les VM QEMU locales** — une VM `proxmox` déployée ici. Son adresse
vient du bail DHCP, rien à retaper.
2. **Par adresse** — `utilisateur@hôte`, plus un rebond SSH facultatif.
3. **Depuis `~/.ssh/config`** — l'alias porte déjà l'utilisateur, le port et le
ProxyJump ; on ne demande rien d'autre.
<!-- [en] -->
The chosen host is then **checked**, not assumed: `pveversion` proves it is a
Proxmox, `id -u` and `sudo -n true` decide whether commands need `sudo`, and an
unknown SSH host key is offered for recording (with `ssh-keyscan`, never by
disabling the check — a hypervisor is not a throwaway VM).
<!-- [fr] -->
L'hôte choisi est ensuite **vérifié**, pas supposé : `pveversion` prouve que
c'en est un, `id -u` et `sudo -n true` décident s'il faut `sudo`, et une clé
d'hôte inconnue est proposée à l'enregistrement (par `ssh-keyscan`, jamais en
désactivant la vérification — un hyperviseur n'est pas une VM jetable).
<!-- [common] -->
```bash
# Ce que l'outil envoie, et qu'on peut rejouer à la main :
ssh erplibre@pve1 sudo sh -c 'qm list'
ssh erplibre@pve1 sudo sh -c 'pvesm status --content images'
```
<!-- [en] -->
## Why SSH and `qm`, not the REST API
The API needs a token or a ticket to create and renew. `qm` is the path every
Proxmox administrator knows, the repository already manages SSH access
(`~/.ssh/config`, ProxyJump, keys), and the commands stay readable in the log —
so they can be replayed by hand. That is how every failure of this module was
diagnosed.
`sudo sh -c '<whole command>'` and not `sudo <command>`: these commands are
sequences and redirections. Prefixing with sudo would elevate only the first
word, and the redirection would still be the unprivileged shell's.
<!-- [fr] -->
## Pourquoi SSH et `qm`, pas l'API REST
L'API demande un jeton ou un ticket à créer et à renouveler. `qm` est la voie
que tout administrateur Proxmox connaît, le dépôt sait déjà gérer des accès SSH
(`~/.ssh/config`, ProxyJump, clés), et les commandes restent lisibles dans le
journal — donc rejouables à la main. C'est ainsi que chaque panne de ce module
a été diagnostiquée.
`sudo sh -c '<toute la commande>'` et non `sudo <commande>` : ces commandes sont
des suites et des redirections. Préfixer par sudo n'élèverait que le premier
mot, et la redirection resterait celle du shell non privilégié.
<!-- [en] -->
## Four traps met on a real host
A Proxmox installed **on Debian** has no `vmbr0` — the ISO installer creates
one, that procedure does not. And `qm create` requires a bridge.
The menu offers an **internal** bridge (`vmbr0`, `10.10.10.1/24`, NAT through
the uplink). Never adding the physical NIC to a bridge is deliberate: that
moves the host address and cuts the SSH session in progress — remotely, there
is no way back. A LAN-facing bridge is printed as a stanza to apply from a
console.
On an internal bridge no DHCP answers, so the address is **static**, derived
from the VMID — and therefore known before the VM boots. Looking for it
afterwards was absurd.
The Debian cloud image does not ship `qemu-guest-agent`, so Proxmox cannot tell
the address of a DHCP guest: it does not hand out the leases. The fallback is
the host's own neighbour table (`ip neigh`), which needs nothing from the guest.
<!-- [fr] -->
## Quatre pièges rencontrés sur un hôte réel
Une Proxmox installée **sur Debian** n'a aucun `vmbr0` — l'installateur ISO en
crée un, cette procédure non. Or `qm create` exige un pont.
Le menu propose alors un pont **interne** (`vmbr0`, `10.10.10.1/24`, NAT par la
sortie). Ne jamais ajouter l'interface physique à un pont est un choix : cela
déplace l'adresse de l'hôte et coupe la session SSH en cours — à distance, sans
retour. Pour un pont donnant sur le LAN, la strophe est affichée, à appliquer
depuis une console.
Sur un pont interne, aucun DHCP ne répond : l'adresse est donc **fixe**, dérivée
du VMID — donc connue avant que la VM ne démarre. La chercher ensuite était
absurde.
L'image cloud Debian n'embarque pas `qemu-guest-agent`, et Proxmox ne connaît
pas l'adresse d'un invité en DHCP : il ne distribue pas les baux. Le repli est
le voisinage de l'hôte (`ip neigh`), qui ne demande rien à l'invité.
<!-- [en] -->
## The menu, entry by entry
The seventeen QEMU/KVM entries have their counterpart. Four of them are the
**same code**, because it is the same work: reopening the install monitoring,
the remote desktop tunnel, the Android emulator and the image catalog. They
reach Proxmox guests through the `~/.ssh/config` entries that entry 13 writes,
with the Proxmox host as ProxyJump.
<!-- [fr] -->
## Le menu, entrée par entrée
Les dix-sept entrées de QEMU/KVM ont leur équivalent. Quatre sont le **même
code**, parce que c'est le même travail : rouvrir le suivi d'installation, le
tunnel bureau distant, l'émulateur Android et le catalogue d'images. Elles
atteignent les invités Proxmox par les entrées `~/.ssh/config` que l'entrée 13
écrit, avec l'hôte Proxmox en ProxyJump.
<!-- [common] -->
```text
[1] Déployer une VM [8] Redimensionner un disque [15] Émulateur Android *
[2] Prévisualiser [9] Effacer des VM [16] Catalogue d'images *
[3] Télécharger une image [10] Nettoyer (orphelins) [17] Exemple (dry-run)
[4] Rouvrir le suivi * [11] Tester une VM (Odoo) [18] Changer d'hôte
[5] Lister (qm list) [12] Statistiques
[6] Adresse IP d'une VM [13] Configuration SSH
[7] Console d'une VM [14] Tunnel bureau distant *
* code partagé avec le menu QEMU/KVM
```
<!-- [en] -->
## Verified
Deploying a VM inside a Proxmox that itself runs in a libvirt VM: image
downloaded on the host, internal bridge created, static address, cloud-init
user and key, disk resized, `qm start`. Then `ssh vm-essai` from the outside
reaches it through the jump — three nested levels. Resize `12G → 16G`, delete
with `--purge`, orphan scan: all checked against Proxmox VE 9.2.11.
<!-- [fr] -->
## Vérifié
Déploiement d'une VM dans une Proxmox qui tourne elle-même dans une VM
libvirt : image téléchargée sur l'hôte, pont interne créé, adresse fixe,
utilisateur et clé par cloud-init, disque redimensionné, `qm start`. Puis
`ssh vm-essai` depuis l'extérieur l'atteint par le rebond — trois niveaux
imbriqués. Redimensionnement `12G → 16G`, effacement avec `--purge`, recherche
d'orphelins : tout contrôlé contre Proxmox VE 9.2.11.

View file

@ -0,0 +1,92 @@
# Déployer des VM sur un hôte Proxmox VE
Deux choses différentes vivent dans ce répertoire :
- `install_proxmox.sh` transforme une Debian en hyperviseur Proxmox. Voir
`script/qemu/README.fr.md`, qui documente la distro `proxmox` du catalogue
de déploiement.
- `proxmox_deploy.py` déploie des VM **sur** un tel hôte, depuis
`TODO › Execute › Deploy › Proxmox VE`, juste sous `QEMU/KVM`.
## Toute la différence : l'hyperviseur est ailleurs
Avec QEMU/KVM, l'hyperviseur est la machine qui exécute le script. Avec
Proxmox il est ailleurs : la première question est donc **quel hôte** — et la
réponse est retenue pour la session. Trois voies, toutes proposées par le menu :
1. **Depuis les VM QEMU locales** — une VM `proxmox` déployée ici. Son adresse
vient du bail DHCP, rien à retaper.
2. **Par adresse** — `utilisateur@hôte`, plus un rebond SSH facultatif.
3. **Depuis `~/.ssh/config`** — l'alias porte déjà l'utilisateur, le port et le
ProxyJump ; on ne demande rien d'autre.
L'hôte choisi est ensuite **vérifié**, pas supposé : `pveversion` prouve que
c'en est un, `id -u` et `sudo -n true` décident s'il faut `sudo`, et une clé
d'hôte inconnue est proposée à l'enregistrement (par `ssh-keyscan`, jamais en
désactivant la vérification — un hyperviseur n'est pas une VM jetable).
```bash
# Ce que l'outil envoie, et qu'on peut rejouer à la main :
ssh erplibre@pve1 sudo sh -c 'qm list'
ssh erplibre@pve1 sudo sh -c 'pvesm status --content images'
```
## Pourquoi SSH et `qm`, pas l'API REST
L'API demande un jeton ou un ticket à créer et à renouveler. `qm` est la voie
que tout administrateur Proxmox connaît, le dépôt sait déjà gérer des accès SSH
(`~/.ssh/config`, ProxyJump, clés), et les commandes restent lisibles dans le
journal — donc rejouables à la main. C'est ainsi que chaque panne de ce module
a été diagnostiquée.
`sudo sh -c '<toute la commande>'` et non `sudo <commande>` : ces commandes sont
des suites et des redirections. Préfixer par sudo n'élèverait que le premier
mot, et la redirection resterait celle du shell non privilégié.
## Quatre pièges rencontrés sur un hôte réel
Une Proxmox installée **sur Debian** n'a aucun `vmbr0` — l'installateur ISO en
crée un, cette procédure non. Or `qm create` exige un pont.
Le menu propose alors un pont **interne** (`vmbr0`, `10.10.10.1/24`, NAT par la
sortie). Ne jamais ajouter l'interface physique à un pont est un choix : cela
déplace l'adresse de l'hôte et coupe la session SSH en cours — à distance, sans
retour. Pour un pont donnant sur le LAN, la strophe est affichée, à appliquer
depuis une console.
Sur un pont interne, aucun DHCP ne répond : l'adresse est donc **fixe**, dérivée
du VMID — donc connue avant que la VM ne démarre. La chercher ensuite était
absurde.
L'image cloud Debian n'embarque pas `qemu-guest-agent`, et Proxmox ne connaît
pas l'adresse d'un invité en DHCP : il ne distribue pas les baux. Le repli est
le voisinage de l'hôte (`ip neigh`), qui ne demande rien à l'invité.
## Le menu, entrée par entrée
Les dix-sept entrées de QEMU/KVM ont leur équivalent. Quatre sont le **même
code**, parce que c'est le même travail : rouvrir le suivi d'installation, le
tunnel bureau distant, l'émulateur Android et le catalogue d'images. Elles
atteignent les invités Proxmox par les entrées `~/.ssh/config` que l'entrée 13
écrit, avec l'hôte Proxmox en ProxyJump.
```text
[1] Déployer une VM [8] Redimensionner un disque [15] Émulateur Android *
[2] Prévisualiser [9] Effacer des VM [16] Catalogue d'images *
[3] Télécharger une image [10] Nettoyer (orphelins) [17] Exemple (dry-run)
[4] Rouvrir le suivi * [11] Tester une VM (Odoo) [18] Changer d'hôte
[5] Lister (qm list) [12] Statistiques
[6] Adresse IP d'une VM [13] Configuration SSH
[7] Console d'une VM [14] Tunnel bureau distant *
* code partagé avec le menu QEMU/KVM
```
## Vérifié
Déploiement d'une VM dans une Proxmox qui tourne elle-même dans une VM
libvirt : image téléchargée sur l'hôte, pont interne créé, adresse fixe,
utilisateur et clé par cloud-init, disque redimensionné, `qm start`. Puis
`ssh vm-essai` depuis l'extérieur l'atteint par le rebond — trois niveaux
imbriqués. Redimensionnement `12G → 16G`, effacement avec `--purge`, recherche
d'orphelins : tout contrôlé contre Proxmox VE 9.2.11.

91
script/proxmox/README.md Normal file
View file

@ -0,0 +1,91 @@
# Deploying VMs on a Proxmox VE host
Two different things live in this directory:
- `install_proxmox.sh` turns a Debian into a Proxmox hypervisor. See
`script/qemu/README.md`, which documents the `proxmox` distro of the
deployment catalog.
- `proxmox_deploy.py` deploys VMs **on** such a host, from
`TODO › Execute › Deploy › Proxmox VE`, right under `QEMU/KVM`.
## The whole difference: the hypervisor is elsewhere
With QEMU/KVM, the hypervisor is the machine running the script. With Proxmox
it is somewhere else, so the first question is **which host** — and the answer
is remembered for the session. Three ways, all offered by the menu:
1. **From the local QEMU VMs** — a `proxmox` VM deployed here. Its address
comes from the DHCP lease, nothing to retype.
2. **By address** — `user@host`, plus an optional SSH jump.
3. **From `~/.ssh/config`** — the alias already carries user, port and
ProxyJump; nothing else is asked.
The chosen host is then **checked**, not assumed: `pveversion` proves it is a
Proxmox, `id -u` and `sudo -n true` decide whether commands need `sudo`, and an
unknown SSH host key is offered for recording (with `ssh-keyscan`, never by
disabling the check — a hypervisor is not a throwaway VM).
```bash
# Ce que l'outil envoie, et qu'on peut rejouer à la main :
ssh erplibre@pve1 sudo sh -c 'qm list'
ssh erplibre@pve1 sudo sh -c 'pvesm status --content images'
```
## Why SSH and `qm`, not the REST API
The API needs a token or a ticket to create and renew. `qm` is the path every
Proxmox administrator knows, the repository already manages SSH access
(`~/.ssh/config`, ProxyJump, keys), and the commands stay readable in the log —
so they can be replayed by hand. That is how every failure of this module was
diagnosed.
`sudo sh -c '<whole command>'` and not `sudo <command>`: these commands are
sequences and redirections. Prefixing with sudo would elevate only the first
word, and the redirection would still be the unprivileged shell's.
## Four traps met on a real host
A Proxmox installed **on Debian** has no `vmbr0` — the ISO installer creates
one, that procedure does not. And `qm create` requires a bridge.
The menu offers an **internal** bridge (`vmbr0`, `10.10.10.1/24`, NAT through
the uplink). Never adding the physical NIC to a bridge is deliberate: that
moves the host address and cuts the SSH session in progress — remotely, there
is no way back. A LAN-facing bridge is printed as a stanza to apply from a
console.
On an internal bridge no DHCP answers, so the address is **static**, derived
from the VMID — and therefore known before the VM boots. Looking for it
afterwards was absurd.
The Debian cloud image does not ship `qemu-guest-agent`, so Proxmox cannot tell
the address of a DHCP guest: it does not hand out the leases. The fallback is
the host's own neighbour table (`ip neigh`), which needs nothing from the guest.
## The menu, entry by entry
The seventeen QEMU/KVM entries have their counterpart. Four of them are the
**same code**, because it is the same work: reopening the install monitoring,
the remote desktop tunnel, the Android emulator and the image catalog. They
reach Proxmox guests through the `~/.ssh/config` entries that entry 13 writes,
with the Proxmox host as ProxyJump.
```text
[1] Déployer une VM [8] Redimensionner un disque [15] Émulateur Android *
[2] Prévisualiser [9] Effacer des VM [16] Catalogue d'images *
[3] Télécharger une image [10] Nettoyer (orphelins) [17] Exemple (dry-run)
[4] Rouvrir le suivi * [11] Tester une VM (Odoo) [18] Changer d'hôte
[5] Lister (qm list) [12] Statistiques
[6] Adresse IP d'une VM [13] Configuration SSH
[7] Console d'une VM [14] Tunnel bureau distant *
* code partagé avec le menu QEMU/KVM
```
## Verified
Deploying a VM inside a Proxmox that itself runs in a libvirt VM: image
downloaded on the host, internal bridge created, static address, cloud-init
user and key, disk resized, `qm start`. Then `ssh vm-essai` from the outside
reaches it through the jump — three nested levels. Resize `12G → 16G`, delete
with `--purge`, orphan scan: all checked against Proxmox VE 9.2.11.

498
script/proxmox/install_proxmox.sh Executable file
View file

@ -0,0 +1,498 @@
#!/usr/bin/env bash
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
#
# Installe Proxmox VE — https://www.proxmox.com — SUR UNE DEBIAN existante.
#
# Pourquoi sur Debian : Proxmox ne publie aucune image cloud. Son ISO est un
# installateur qui formate le disque, ce qui ne se pilote pas depuis un
# déploiement cloud-init. La voie que l'amont documente lui-même pour ce cas
# est « Install Proxmox VE on Debian » : on part de l'image cloud Debian, on
# ajoute le dépôt pve, et les paquets font le reste. Le résultat est le même
# hyperviseur, avec le noyau Proxmox et l'interface web sur :8006.
#
# Architectures : amd64 et arm64 (arm64 officiel depuis PVE 9 — le Release de
# trixie annonce « amd64 arm64 »). PAS s390x : l'index « binary-s390x » du
# dépôt répond 404, le port n'existe pas.
#
# Réglages, par variables d'environnement :
# PVE_SUITE suite Debian visée (défaut : trixie, = PVE 9)
# PVE_REBOOT à 1, redémarre à la fin (défaut : ne redémarre PAS —
# lancé par SSH, un reboot couperait la session et ferait
# passer une installation réussie pour un échec)
# PVE_KEEP_DEBIAN_KERNEL à 1, garde le noyau Debian à côté du noyau pve
# PVE_OS_RELEASE fichier os-release à lire (défaut : /etc/os-release) ;
# sert à vérifier le script depuis une autre distribution
set -euo pipefail
Red='\033[0;31m'
Green='\033[0;32m'
Yellow='\033[0;33m'
Color_Off='\033[0m'
SUITE="${PVE_SUITE:-trixie}"
KEYRING=/usr/share/keyrings/proxmox-archive-keyring.gpg
SOURCES=/etc/apt/sources.list.d/pve-install-repo.sources
# Somme relevée sur la page amont « Install Proxmox VE on Debian 13 Trixie »,
# et VÉRIFIÉE contre le fichier servi. Elle vaut ceinture ET bretelle : c'est
# la clé qui authentifiera tout le reste, et la télécharger sans la contrôler
# reviendrait à faire confiance au seul transport.
KEY_URL="https://enterprise.proxmox.com/debian/proxmox-archive-keyring-${SUITE}.gpg"
KEY_SHA256_trixie=136673be77aba35dcce385b28737689ad64fd785a797e57897589aed08db6e45
# Ce qui a changé : décide du redémarrage final. Une installation déjà faite ne
# doit pas redémarrer la machine pour rien.
CHANGED=0
say() { echo -e "$@"; }
die() { say "${Red}✗${Color_Off} $*"; exit 1; }
DRY=0
usage() {
cat <<EOF
Installe Proxmox VE sur une Debian existante (amd64, arm64).
--dry-run dit ce qu'il ferait, sans rien changer
--help cette aide
Variables : PVE_SUITE (défaut trixie), PVE_REBOOT, PVE_KEEP_DEBIAN_KERNEL,
PVE_OS_RELEASE
EOF
}
while [ $# -gt 0 ]; do
case "$1" in
--dry-run) DRY=1 ;;
-h | --help) usage; exit 0 ;;
*) die "option inconnue : $1" ;;
esac
shift
done
# Toute action qui MODIFIE la machine passe par ici. En dry-run elle est
# annoncée, pas exécutée : c'est ce qui rend le script vérifiable de bout en
# bout sans hyperviseur ni dépôt à disposition.
run() {
if [ "${DRY}" = "1" ]; then
say " ${Yellow}[dry-run]${Color_Off} $*"
return 0
fi
"$@"
}
# --- 1. Architecture --------------------------------------------------------
case "$(uname -m)" in
x86_64) ARCH=amd64 ;;
aarch64 | arm64) ARCH=arm64 ;;
*)
die "Proxmox VE n'existe pas pour $(uname -m) :" \
"le dépôt ne publie que amd64 et arm64."
;;
esac
# --- 2. Debian, et la bonne suite ------------------------------------------
OS_RELEASE="${PVE_OS_RELEASE:-/etc/os-release}"
[ -r "${OS_RELEASE}" ] || die "${OS_RELEASE} illisible : Debian attendue."
# shellcheck disable=SC1090
. "${OS_RELEASE}"
if [ "${ID:-}" != "debian" ]; then
die "Proxmox VE s'installe sur Debian, pas sur « ${ID:-inconnu} »." \
"\n Redéployer la VM avec --distro proxmox (base Debian ${SUITE})."
fi
CODENAME="${VERSION_CODENAME:-}"
if [ -n "${CODENAME}" ] && [ "${CODENAME}" != "${SUITE}" ]; then
say "${Yellow}⚠${Color_Off} Debian « ${CODENAME} » alors que le dépôt visé" \
"est « ${SUITE} » : PVE_SUITE=${CODENAME} si c'est voulu."
fi
# --- 3. /etc/hosts : le nom d'hôte doit résoudre vers une IP ROUTABLE -------
# Ce n'est pas un détail de confort. Le système de grappe de Proxmox parcourt
# toutes les adresses du nom d'hôte jusqu'à en trouver une qui ne soit pas de
# bouclage ; l'entrée « 127.0.1.1 <nom> » que pose l'image cloud Debian le
# mène droit dans le mur, et pveproxy comme pvecm s'en trouvent mal.
# « hostname --ip-address » est le test que l'amont donne lui-même.
host_ip() {
local ip=""
for ip in \
"$(ip -4 route get 1 2>/dev/null | awk '{print $7; exit}')" \
"$(hostname -I 2>/dev/null | awk '{print $1}')" \
"$(ip -4 -o addr show scope global 2>/dev/null \
| awk '{split($4, a, "/"); print a[1]; exit}')"
do
case "$ip" in
127.*) continue ;;
[0-9]*.[0-9]*.[0-9]*.[0-9]*) echo "$ip"; return 0 ;;
esac
done
return 1
}
fix_hosts() {
local ip fqdn short
ip="$(host_ip)" || die "aucune adresse IPv4 routable : réseau absent ?"
short="$(hostname -s)"
fqdn="$(hostname -f 2>/dev/null || echo "${short}")"
[ "${fqdn}" = "${short}" ] && fqdn="${short}.local"
if grep -qE "^[[:space:]]*127\.0\.1\.1[[:space:]]" /etc/hosts; then
say " retrait de l'entrée 127.0.1.1 (bouclage) pour ${short}"
run sudo sed -i -E "/^[[:space:]]*127\.0\.1\.1[[:space:]]/d" \
/etc/hosts
CHANGED=1
fi
if ! grep -qE "^[[:space:]]*${ip//./\\.}[[:space:]]+.*\b${short}\b" \
/etc/hosts; then
say " ${short} -> ${ip} dans /etc/hosts"
if [ "${DRY}" = "1" ]; then
say " ${Yellow}[dry-run]${Color_Off} ${ip} ${fqdn} ${short}" \
">> /etc/hosts"
else
printf '%s\t%s %s\n' "${ip}" "${fqdn}" "${short}" \
| sudo tee -a /etc/hosts >/dev/null
fi
CHANGED=1
fi
# Le test de l'amont, mot pour mot : au moins une adresse non-bouclage.
local vu routables
vu="$(hostname --ip-address 2>/dev/null || true)"
routables="$(printf '%s\n' ${vu} | grep -vE '^(127\.|::1$)' || true)"
[ -n "${routables}" ] || die \
"« hostname --ip-address » rend « ${vu:-rien} » : le nom d'hôte ne" \
"résout toujours pas vers une adresse routable."
say " hostname --ip-address : $(printf '%s ' ${routables})"
}
# --- 4. Dépôt et clé --------------------------------------------------------
add_repo() {
local attendu="KEY_SHA256_${SUITE}"
attendu="${!attendu:-}"
if [ ! -s "${KEYRING}" ]; then
say " clé du dépôt -> ${KEYRING}"
run sudo mkdir -p "$(dirname "${KEYRING}")"
run sudo wget -q "${KEY_URL}" -O "${KEYRING}" \
|| die "téléchargement de la clé impossible : ${KEY_URL}"
# Un wget qui rend 0 sans laisser de fichier — proxy captif, disque
# plein — ferait mourir la suite sur un message de sha256sum au lieu
# d'un diagnostic.
if [ "${DRY}" != "1" ]; then
sudo test -s "${KEYRING}" \
|| die "clé absente ou vide après téléchargement : ${KEYRING}"
fi
CHANGED=1
fi
if [ -n "${attendu}" ] && [ "${DRY}" = "1" ]; then
say " clé à vérifier contre sha256 ${attendu:0:16}…"
elif [ -n "${attendu}" ]; then
local vu
vu="$(sudo sha256sum "${KEYRING}" 2>/dev/null | awk '{print $1}')" \
|| true
[ -n "${vu}" ] || die "somme de la clé illisible : ${KEYRING}"
if [ "${vu}" != "${attendu}" ]; then
# On efface la clé douteuse : la laisser en place ferait passer la
# prochaine exécution pour bonne, le fichier étant non vide.
run sudo rm -f "${KEYRING}"
die "somme de la clé inattendue :\n vue ${vu}\n" \
" attendue ${attendu}"
fi
say " clé vérifiée (sha256 ${vu:0:16}…)"
else
say "${Yellow}⚠${Color_Off} aucune somme connue pour « ${SUITE} » :" \
"clé acceptée sans contrôle."
fi
# Format deb822, celui que l'amont recommande sur Debian 13.
local voulu
voulu="$(printf 'Types: deb\nURIs: http://download.proxmox.com/debian/pve\nSuites: %s\nComponents: pve-no-subscription\nSigned-By: %s\n' \
"${SUITE}" "${KEYRING}")"
if [ "$(cat "${SOURCES}" 2>/dev/null || true)" != "${voulu}" ]; then
say " dépôt pve-no-subscription -> ${SOURCES}"
if [ "${DRY}" = "1" ]; then
say " ${Yellow}[dry-run]${Color_Off} ${SOURCES} :" \
"$(printf '%s' "${voulu}" | tr '\n' '|')"
else
printf '%s\n' "${voulu}" | sudo tee "${SOURCES}" >/dev/null
fi
CHANGED=1
fi
}
# --- 6. Dépôt entreprise ----------------------------------------------------
# « proxmox-ve » installe SON dépôt entreprise (pve-enterprise.sources), qui
# exige un abonnement payant. Sans lui, tout « apt update » ultérieur échoue :
# « 401 Unauthorized » puis « The repository is not signed », et la machine ne
# peut plus rien installer — pas même une mise à jour de sécurité. Mesuré sur
# la VM d'essai, au deuxième passage du script.
#
# On le désactive au lieu de l'effacer : « Enabled: false » est la forme deb822
# prévue pour cela, elle survit aux mises à jour du paquet, et il suffit de
# retirer la ligne le jour où un abonnement existe.
disable_enterprise() {
local f
for f in /etc/apt/sources.list.d/pve-enterprise.sources \
/etc/apt/sources.list.d/ceph.sources; do
[ -e "${f}" ] || continue
if sudo grep -qiE '^[[:space:]]*Enabled:[[:space:]]*(false|no)' \
"${f}"; then
continue
fi
say " dépôt entreprise désactivé : $(basename "${f}")"
if [ "${DRY}" = "1" ]; then
say " ${Yellow}[dry-run]${Color_Off} Enabled: false >> ${f}"
else
printf 'Enabled: false\n' | sudo tee -a "${f}" >/dev/null
fi
CHANGED=1
done
# Les anciennes formes « .list », au cas où une mise à jour les remette.
for f in /etc/apt/sources.list.d/pve-enterprise.list; do
[ -e "${f}" ] || continue
sudo grep -qE '^[[:space:]]*#' "${f}" && continue
say " dépôt entreprise commenté : $(basename "${f}")"
run sudo sed -i -E 's/^([[:space:]]*deb)/#\1/' "${f}"
CHANGED=1
done
}
# --- 5. Les paquets ---------------------------------------------------------
# Disque d'amorçage, pour la préréponse de grub-pc.
boot_disk() {
local src
src="$(findmnt -no SOURCE /boot 2>/dev/null \
|| findmnt -no SOURCE / 2>/dev/null)"
[ -n "${src}" ] || return 1
local dq
dq="$(lsblk -no pkname "${src}" 2>/dev/null | head -1)"
[ -n "${dq}" ] || return 1
printf '/dev/%s\n' "${dq}"
}
# Deux paquets posent des questions debconf, et une seule sans réponse suffit à
# faire échouer TOUTE la transaction apt :
#
# - postfix demande son type et son nom de courrier. Sans préréponse,
# l'installation attend une saisie que personne ne verra — un déploiement
# par SSH y reste pendu.
# - grub-pc demande SUR QUEL DISQUE s'installer. C'est le piège propre à
# l'image cloud : elle amorce en EFI (grub-cloud-amd64), les paquets pve
# tirent grub-pc par-dessus, et sa post-installation refuse de deviner —
# « You must correct your GRUB install devices before proceeding », mesuré,
# dpkg s'arrête et le noyau Proxmox reste à moitié configuré.
preseed_debconf() {
command -v debconf-set-selections >/dev/null 2>&1 || return 0
local lignes disque
lignes="$(printf '%s\n' \
"postfix postfix/main_mailer_type select Local only" \
"postfix postfix/mailname string $(hostname -f 2>/dev/null \
|| hostname -s)")"
if disque="$(boot_disk)"; then
lignes="$(printf '%s\n%s\n' "${lignes}" \
"grub-pc grub-pc/install_devices multiselect ${disque}")"
else
say "${Yellow}⚠${Color_Off} disque d'amorçage introuvable :" \
"grub-pc pourrait demander où s'installer."
fi
# Pas de tube vers « run » : en dry-run il ne lirait pas son entrée, le
# printf recevrait SIGPIPE, et « set -o pipefail » emporterait le script —
# mort silencieuse sur un code 141. Vécu.
if [ "${DRY}" = "1" ]; then
say " ${Yellow}[dry-run]${Color_Off} debconf-set-selections :" \
"$(printf '%s' "${lignes}" | tr '\n' '|')"
return 0
fi
printf '%s\n' "${lignes}" | sudo debconf-set-selections
}
# « DPkg::Lock::Timeout » : sur une VM fraîchement déployée, cloud-init tient
# encore le verrou d'apt — mesuré, « E: Could not get lock
# /var/lib/apt/lists/lock. It is held by process 996 (apt-get) », et le script
# mourait 40 secondes après le démarrage. apt sait ATTENDRE son tour depuis la
# 1.9 ; sans cette option il abandonne immédiatement.
apt_get() {
run sudo env DEBIAN_FRONTEND=noninteractive \
apt-get -o Dpkg::Options::=--force-confold \
-o DPkg::Lock::Timeout=600 -y "$@"
}
# Attendre la fin de cloud-init AVANT de toucher à apt : c'est lui qui pose les
# paquets de la première mise en route. Son état final n'est PAS un critère —
# vérifié sur l'image Debian 13, où il rend « error » pour deux modules sans
# rapport (console-setup absent, update-locale) tout en ayant terminé son
# travail. On attend qu'il ait fini, pas qu'il soit content.
wait_cloud_init() {
command -v cloud-init >/dev/null 2>&1 || return 0
say " attente de la fin de cloud-init…"
if [ "${DRY}" = "1" ]; then
say " ${Yellow}[dry-run]${Color_Off} cloud-init status --wait"
return 0
fi
timeout 600 cloud-init status --wait >/dev/null 2>&1 || true
return 0
}
install_pve() {
wait_cloud_init
preseed_debconf
# Réparer une transaction laissée à moitié par une exécution précédente :
# sans réponse à grub-pc, dpkg s'arrête au milieu et tout apt suivant
# refuse de travailler. Inoffensif quand rien n'est cassé.
if ! sudo dpkg -C >/dev/null 2>&1; then
say " paquets à moitié configurés : dpkg --configure -a"
run sudo env DEBIAN_FRONTEND=noninteractive dpkg --configure -a \
|| true
fi
# AVANT le premier apt : sur un second passage, le dépôt entreprise posé
# par proxmox-ve ferait échouer « apt update » (401) et rien n'irait plus
# loin — pas même la désactivation, si elle attendait la fin.
disable_enterprise
say "\n---- apt update ----"
apt_get update
# Le noyau d'abord, comme l'amont le prescrit : c'est lui qui porte les
# modules dont pve a besoin, et l'installer seul laisse une machine qui
# redémarre proprement même si la suite échoue.
if ! dpkg -s proxmox-default-kernel >/dev/null 2>&1; then
say "\n---- noyau Proxmox ----"
apt_get install proxmox-default-kernel
CHANGED=1
else
say " noyau Proxmox déjà posé"
fi
if ! dpkg -s proxmox-ve >/dev/null 2>&1; then
say "\n---- proxmox-ve, postfix, open-iscsi, chrony ----"
apt_get install proxmox-ve postfix open-iscsi chrony
CHANGED=1
# C'est CETTE installation qui vient de poser le dépôt entreprise :
# le désactiver tout de suite, avant que le ménage ne rappelle apt.
disable_enterprise
else
say " proxmox-ve déjà posé"
fi
}
# --- 6. Ménage --------------------------------------------------------------
# os-prober ajoute au menu d'amorçage les systèmes trouvés sur les disques des
# VM invitées : sur un hyperviseur, c'est une liste de faux départs.
# Le noyau Debian, lui, n'a plus de raison d'être une fois celui de pve en
# place — et laissé par défaut, grub peut y revenir.
cleanup() {
if dpkg -s os-prober >/dev/null 2>&1; then
say " retrait d'os-prober"
apt_get remove os-prober
CHANGED=1
fi
[ "${PVE_KEEP_DEBIAN_KERNEL:-0}" = "1" ] && return 0
# Ne JAMAIS retirer le noyau Debian si celui de Proxmox n'est pas posé :
# la machine ne redémarrerait plus. Vérifié, pas supposé — une étape apt
# peut avoir échoué plus haut sans arrêter le reste.
if ! dpkg -s proxmox-default-kernel >/dev/null 2>&1; then
say " noyau Proxmox absent : le noyau Debian reste en place."
return 0
fi
local metas
# Tout « linux-image-* » SAUF ceux de Proxmox. L'amont écrit
# « linux-image-6.12* », la version de trixie ; le motif large couvre les
# versions suivantes, et l'exclusion protège le noyau qu'on vient de
# poser — le retirer laisserait une VM qui n'amorce plus.
# « db:Status-Status » filtre les paquets RÉELLEMENT installés : dpkg
# connaît aussi ceux qu'il a désinstallés (« config-files »,
# « not-installed »), et les passer à apt donnait « is not installed, so
# not removed » — puis un CHANGED=1 qui réclamait un redémarrage inutile.
metas="$(dpkg-query -W -f '${Package} ${db:Status-Status}\n' \
'linux-image-*' 2>/dev/null \
| awk '$2 == "installed" {print $1}' \
| grep -vE 'pve|proxmox' || true)"
if [ -n "${metas}" ]; then
say " retrait du noyau Debian : $(echo "${metas}" | tr '\n' ' ')"
# shellcheck disable=SC2086
apt_get remove ${metas}
run sudo update-grub 2>/dev/null || true
CHANGED=1
fi
return 0
}
# --- 6bis. Amorçage EFI de secours -----------------------------------------
# Une image cloud amorce par le CHEMIN DE SECOURS de l'UEFI —
# \EFI\BOOT\BOOTX64.EFI — parce qu'aucune entrée NVRAM ne la nomme. Les
# paquets grub de Proxmox y recopient bien leurs binaires, mais PAS le petit
# grub.cfg qui indique où trouver la vraie configuration. GRUB s'arrête alors
# sur son invite de secours « grub> » : ni menu, ni noyau.
#
# Vécu, capture d'écran à l'appui : après le premier redémarrage, la VM brûlait
# 100 % d'un cœur SANS lire une seule fois le disque, et l'écran affichait
# « starting Boot0001 UEFI Misc Device » suivi de « grub> ».
#
# On recopie le stub que Debian a généré pour son propre chemin : il porte
# l'UUID de la racine, donc il fonctionne depuis n'importe quel chargeur.
# « grub-install --removable » ferait la même chose en réécrivant le chargeur ;
# copier un fichier est plus sûr — cela ne touche pas au chemin qui marche.
fix_efi_fallback() {
local esp="${PVE_ESP:-/boot/efi}"
local secours="${esp}/EFI/BOOT"
# sudo sur CHAQUE lecture. /boot/efi est une vfat montée « umask=077 » :
# root seul y entre, et un « [ -d ] » non privilégié y répond FAUX. Ce
# correctif ne faisait donc RIEN, en silence, et la VM retombait sur
# « grub> » au redémarrage suivant — vécu deux fois. Le glob du shell est
# aveugle pour la même raison : il faut énumérer avec sudo.
sudo test -d "${secours}" || return 0
sudo test -e "${secours}/grub.cfg" && return 0
local stub=""
# « || true » : quand aucun stub n'existe, grep ne trouve rien et rend 1
# — avec « set -o pipefail », l'affectation échoue et le script s'arrête
# AVANT d'avoir dit ce qui manque. Attrapé par un test, pas sur la machine
# réelle, où un stub existait et masquait le cas.
stub="$(sudo sh -c "ls ${esp}/EFI/*/grub.cfg 2>/dev/null" \
| grep -v '/EFI/BOOT/grub.cfg' | head -1 || true)"
if [ -z "${stub}" ]; then
say "${Yellow}⚠${Color_Off} aucun grub.cfg à recopier sous ${esp} :" \
"vérifier l'amorçage avant de redémarrer."
return 0
fi
say " amorçage de secours : $(dirname "${stub}" | xargs basename)/grub.cfg" \
"-> EFI/BOOT/"
run sudo cp "${stub}" "${secours}/grub.cfg"
CHANGED=1
}
# --- 7. Déroulé -------------------------------------------------------------
say "${Green}==>${Color_Off} Proxmox VE ${SUITE} sur ${ARCH}"
say "\n---- nom d'hôte et /etc/hosts ----"
fix_hosts
say "\n---- dépôt Proxmox ----"
add_repo
install_pve
say "\n---- ménage ----"
cleanup
say "\n---- amorçage ----"
fix_efi_fallback
IP="$(host_ip || echo localhost)"
if [ "${DRY}" = "1" ]; then
# Ne rien annoncer qui n'ait eu lieu : en dry-run, rien n'a été installé.
say "\n${Yellow}✓${Color_Off} dry-run terminé : rien n'a été changé."
say " Ce qui serait joignable ensuite : https://${IP}:8006"
exit 0
fi
say "\n${Green}✓${Color_Off} Proxmox VE installé."
say " Interface web : ${Green}https://${IP}:8006${Color_Off}"
# Proxmox authentifie par PAM : c'est le root du système qui ouvre l'interface.
# Sur une image cloud il est VERROUILLÉ (« passwd -S root » rend « L »), donc
# l'interface est inutilisable tant qu'on ne lui a pas donné un mot de passe.
# Le dire ici plutôt que de laisser chercher devant un formulaire qui refuse.
if [ "$(sudo passwd -S root 2>/dev/null | awk '{print $2}')" = "L" ]; then
say " Compte : root, ${Yellow}sans mot de passe${Color_Off} —" \
"l'interface le refusera. À faire : ${Green}sudo passwd root${Color_Off}"
else
say " Compte : root (mot de passe du système)"
fi
say " Une VM Proxmox est un hyperviseur DANS une VM : ses propres invités"
say " demandent la virtualisation imbriquée à tous les étages."
if [ "${CHANGED}" = "0" ]; then
say "\n Rien n'a changé : pas de redémarrage."
exit 0
fi
if [ "${PVE_REBOOT:-0}" != "1" ]; then
say "\n${Yellow}⚠${Color_Off} Redémarrage NÉCESSAIRE pour amorcer le" \
"noyau Proxmox : sudo reboot"
exit 0
fi
say "\n---- redémarrage pour amorcer le noyau Proxmox ----"
run sudo systemctl reboot

View file

@ -0,0 +1,517 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Déploiement de VM SUR un hôte Proxmox VE, piloté à distance par SSH.
Différence de nature avec `script/qemu/deploy_qemu.py` : là-bas, l'hyperviseur
est la machine qui exécute le script. Ici, il est AILLEURS — « on n'exécute pas
dessus ». Tout ce que ce module produit part donc sur l'hôte choisi, et rien
n'exige de privilège local.
Pourquoi SSH et `qm` plutôt que l'API REST : l'API demande un jeton ou un
ticket à créer et à renouveler, quand `qm` est la voie que tout administrateur
Proxmox connaît, et que le dépôt sait déjà gérer des accès SSH (~/.ssh/config,
ProxyJump, clés). Les commandes restent lisibles dans le journal, donc
rejouables à la main — c'est ce qui a permis de diagnostiquer chaque panne de
ce module.
Découpage voulu : TOUT ce qui construit une commande ou lit une sortie est une
fonction PURE, vérifiable sans hôte Proxmox. Seul `run()` parle au réseau.
"""
from __future__ import annotations
import json
import re
import shlex
import subprocess
# Réglages par défaut d'une VM Proxmox. Chacun a sa raison :
#
# - virtio-scsi-single : le contrôleur que Proxmox recommande depuis PVE 7, et
# le seul qui donne l'iothread par disque.
# - agent enabled=1 : sans l'agent invité, « qm guest cmd » ne rend aucune
# adresse IP et le menu ne peut pas dire où joindre la VM.
# - serial0 socket + vga serial0 : c'est ce qui rend « qm terminal » utilisable.
# Une console graphique seule obligerait à passer par l'interface web.
# - ostype l26 : Linux 2.6+, ce qui règle les horloges et les pilotes.
DEFAULT_BRIDGE = "vmbr0"
DEFAULT_STORAGE = "" # vide = on choisit d'après « pvesm status »
IMAGE_DIR = "/var/lib/vz/template/iso"
VMID_MIN = 100
# Stockages qui savent héberger un disque de VM. « pvesm status » liste aussi
# des stockages de sauvegarde ou d'ISO, où un disque ne peut PAS aller : les
# proposer produirait un « qm set » refusé après le téléchargement de l'image.
DISK_CONTENT = ("images", "rootdir")
def ssh_argv(host: dict, remote: str, tty: bool = False) -> list:
"""Commande ssh complète pour exécuter `remote` sur l'hôte Proxmox.
`host` : {"target": "root@10.0.0.5", "jump": "rebond", "port": "22"} —
« target » suffit quand l'alias vient de ~/.ssh/config, qui porte déjà
l'utilisateur, le port et le ProxyJump.
"""
argv = ["ssh"]
if not tty:
argv += ["-o", "BatchMode=yes"]
argv += ["-o", "ConnectTimeout=10"]
if host.get("port"):
argv += ["-p", str(host["port"])]
if host.get("jump"):
argv += ["-J", host["jump"]]
if tty:
argv.append("-t")
argv += [host["target"], remote]
return argv
def wrap_privilege(remote: str, prefix: str) -> str:
"""Enveloppe la commande pour qu'elle tourne en root, si nécessaire.
« sudo sh -c '<tout>' » et non « sudo <tout> » : les commandes de ce module
sont des SUITES (« mkdir && if … fi », une boucle for, une redirection).
Préfixer par sudo n'élèverait que le premier mot, et la redirection
resterait celle du shell non privilégié — donc « permission denied » sur
/root ou /boot/efi.
"""
if not prefix:
return remote
return "sudo sh -c " + shlex.quote(remote)
def run(host: dict, remote: str, timeout: int = 120) -> tuple:
"""(code, sortie) de `remote` exécuté sur l'hôte. Ne lève jamais.
`host["sudo"]` non vide -> la commande passe par sudo : « qm » exige les
privilèges, et l'accès offert par une VM du parc est celui d'`erplibre`.
"""
remote = wrap_privilege(remote, host.get("sudo") or "")
try:
res = subprocess.run(
ssh_argv(host, remote),
capture_output=True,
text=True,
timeout=timeout,
)
except subprocess.TimeoutExpired:
return 255, "timeout"
except (OSError, subprocess.SubprocessError) as exc:
return 255, str(exc)
return res.returncode, (res.stdout or "") + (res.stderr or "")
# --------------------------------------------------------------------------- #
# Lecture des sorties de l'hôte — fonctions pures
# --------------------------------------------------------------------------- #
def parse_pveversion(text: str) -> str:
"""« pve-manager/9.2.11/f6997e69 (running kernel: 7.0.14-12-pve) » -> 9.2.11.
Sert de PREUVE que l'hôte est bien un Proxmox : une adresse saisie à la
main peut être n'importe quoi, et la première commande `qm` échouerait
alors sur un message qui ne dit pas pourquoi.
"""
m = re.search(r"pve-manager/(\d[\w.]*)", text or "")
return m.group(1) if m else ""
def parse_qm_list(text: str) -> list:
"""Sortie de « qm list » -> [{vmid, name, status, mem, disk}].
L'en-tête et les lignes vides sont écartés. Les colonnes sont séparées par
des espaces, mais un NOM peut en contenir : on découpe donc par la
GAUCHE (vmid) et par la DROITE (status, mem, bootdisk, pid), et ce qui
reste au milieu est le nom.
"""
out = []
for ligne in (text or "").splitlines():
parts = ligne.split()
if len(parts) < 6 or not parts[0].isdigit():
continue
vmid = parts[0]
pid = parts[-1]
bootdisk = parts[-2]
mem = parts[-3]
status = parts[-4]
nom = " ".join(parts[1:-4])
out.append(
{
"vmid": int(vmid),
"name": nom,
"status": status,
"mem": mem,
"disk": bootdisk,
"pid": pid,
}
)
return out
def parse_storages(text: str) -> list:
"""Sortie de « pvesm status --content images » -> [{name, type, avail}]."""
out = []
for ligne in (text or "").splitlines():
parts = ligne.split()
if len(parts) < 6 or parts[0] == "Name":
continue
try:
avail = int(parts[5])
except ValueError:
continue
out.append(
{
"name": parts[0],
"type": parts[1],
"actif": parts[2] == "active",
"avail": avail * 1024, # pvesm compte en Kio
}
)
return out
def parse_bridges(text: str) -> list:
"""Sortie de « ip -o link show type bridge » -> ['vmbr0', …]."""
ponts = []
for ligne in (text or "").splitlines():
parts = ligne.split(":")
if len(parts) > 1:
nom = parts[1].strip().split("@")[0]
if nom:
ponts.append(nom)
return ponts
def parse_guest_ips(text: str) -> list:
"""Adresses IPv4 rendues par « qm guest cmd <id> network-get-interfaces ».
L'agent invité répond du JSON. Les adresses de bouclage sont écartées : la
question posée est « où joindre cette VM », et 127.0.0.1 n'y répond pas.
"""
try:
data = json.loads(text or "")
except (ValueError, TypeError):
return []
ips = []
for iface in data if isinstance(data, list) else []:
for addr in iface.get("ip-addresses") or []:
ip = addr.get("ip-address") or ""
if addr.get("ip-address-type") == "ipv4" and not ip.startswith(
"127."
):
ips.append(ip)
return ips
def mac_from_config(text: str) -> str:
"""MAC de net0 dans « qm config <id> ».
C'est le seul lien entre une VM Proxmox et son adresse IP quand l'agent
invité n'est pas là : l'image cloud Debian ne l'embarque PAS, et Proxmox ne
distribue pas les baux lui-même — il ne peut donc pas répondre.
"""
m = re.search(
r"^net0:.*?([0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){5})",
text or "",
re.M,
)
return m.group(1).lower() if m else ""
def ip_from_neigh(text: str, mac: str) -> str:
"""Adresse vue par le voisinage de l'hôte (« ip neigh »), pour cette MAC.
Marche dès que la VM a émis un paquet — un bail DHCP suffit. C'est le
repli quand l'agent invité manque, et il ne demande rien à l'invité.
"""
if not mac:
return ""
cible = mac.lower()
for ligne in (text or "").splitlines():
if cible in ligne.lower():
parts = ligne.split()
if parts and re.match(r"^\d+\.\d+\.\d+\.\d+$", parts[0]):
return parts[0]
return ""
def next_vmid(existing, mini: int = VMID_MIN) -> int:
"""Premier VMID libre à partir de `mini`.
Proxmox refuse un VMID déjà pris, et le message (« CT/VM 100 already
exists ») arrive APRÈS le téléchargement de l'image : on choisit donc
avant, d'après ce que l'hôte déclare.
"""
pris = {int(v["vmid"]) for v in existing or () if str(v["vmid"]).isdigit()}
vmid = max(mini, VMID_MIN)
while vmid in pris:
vmid += 1
return vmid
def pick_storage(storages, voulu: str = "") -> str:
"""Stockage où poser le disque : celui demandé, sinon le plus libre.
Aucun repli sur un nom devinné (« local-lvm » n'existe pas partout) : sans
stockage utilisable, on rend une chaîne vide et l'appelant le dit.
"""
utiles = [s for s in storages or () if s.get("actif")]
if voulu:
return voulu if any(s["name"] == voulu for s in utiles) else ""
if not utiles:
return ""
return max(utiles, key=lambda s: s.get("avail") or 0)["name"]
def pick_bridge(bridges, voulu: str = "") -> str:
"""Pont réseau : celui demandé, sinon vmbr0, sinon le premier déclaré."""
ponts = list(bridges or ())
if voulu:
return voulu if voulu in ponts else ""
if DEFAULT_BRIDGE in ponts:
return DEFAULT_BRIDGE
return ponts[0] if ponts else ""
# --------------------------------------------------------------------------- #
# Construction des commandes — fonctions pures
# --------------------------------------------------------------------------- #
# Réseau interne proposé quand l'hôte n'a AUCUN pont. Choisi pour être sûr :
# un pont sans port physique ne peut pas couper l'accès SSH à l'hôte, alors
# qu'ajouter « bridge-ports enp1s0 » déplace l'adresse et coupe la session en
# cours — sur une machine distante, c'est un aller sans retour.
INTERNAL_BRIDGE = "vmbr0"
INTERNAL_CIDR = "10.10.10.1/24"
def parse_bridge_config(text: str) -> dict:
"""/etc/network/interfaces -> {pont: {ports, address}}.
Sert à savoir si un pont donne sur le LAN (il a des ports) ou s'il est
interne (« bridge-ports none ») : les VM du premier prennent leur adresse
en DHCP, celles du second n'en auraient aucune et doivent recevoir une
adresse fixe.
"""
ponts = {}
courant = ""
for ligne in (text or "").splitlines():
nu = ligne.strip()
m = re.match(r"^iface\s+(\S+)\s", nu)
if m:
courant = m.group(1)
continue
if not courant:
continue
if nu.startswith("bridge-ports") or nu.startswith("bridge_ports"):
ports = nu.split(None, 1)[1].strip() if " " in nu else ""
ponts.setdefault(courant, {})["ports"] = (
"" if ports in ("none", "") else ports
)
elif nu.startswith("address"):
ponts.setdefault(courant, {})["address"] = nu.split()[1]
return ponts
def bridge_setup_cmds(
nom: str = INTERNAL_BRIDGE,
cidr: str = INTERNAL_CIDR,
uplink: str = "",
) -> list:
"""Crée un pont INTERNE, et le masque derrière l'uplink si demandé.
« bridge-ports none » : aucune interface physique n'est touchée, donc
l'accès à l'hôte survit. Les lignes post-up/post-down de masquerading sont
celles que documente Proxmox pour un hôte à une seule adresse routée : sans
elles les VM se parlent entre elles mais ne sortent pas.
"""
reseau = cidr.rsplit(".", 1)[0] + ".0/" + cidr.split("/")[1]
bloc = [
"",
f"auto {nom}",
f"iface {nom} inet static",
f" address {cidr}",
" bridge-ports none",
" bridge-stp off",
" bridge-fd 0",
]
if uplink:
bloc += [
f" post-up iptables -t nat -A POSTROUTING -s '{reseau}'"
f" -o {uplink} -j MASQUERADE",
f" post-down iptables -t nat -D POSTROUTING -s '{reseau}'"
f" -o {uplink} -j MASQUERADE",
]
texte = "\n".join(bloc) + "\n"
cmds = [
# Idempotent : on n'ajoute la strophe que si le pont n'y est pas déjà.
f"grep -qE '^(auto|iface) {nom}( |$)' /etc/network/interfaces"
f" || printf '%s' {shlex.quote(texte)} >> /etc/network/interfaces",
]
if uplink:
cmds.append(
"printf 'net.ipv4.ip_forward=1\\n' >"
" /etc/sysctl.d/99-erplibre-nat.conf && sysctl -q -p"
" /etc/sysctl.d/99-erplibre-nat.conf"
)
# ifup plutôt qu'« ifreload -a » : recharger TOUTE la configuration d'un
# hôte distant peut emporter l'interface qui porte la session.
cmds.append(f"ifup {nom} 2>/dev/null || ifreload -a")
return cmds
def ipconfig_for(pont_info: dict, vmid: int) -> str:
"""« ip=dhcp » sur un pont qui donne sur le LAN, adresse FIXE sur un pont
interne — où aucun serveur DHCP ne répondrait.
L'adresse est dérivée du VMID : deux VM déployées à la suite ne peuvent pas
se retrouver avec la même, et le lien entre les deux reste lisible.
"""
info = pont_info or {}
adresse = info.get("address") or ""
if info.get("ports") or not adresse:
return "ip=dhcp"
base, _, masque = adresse.partition("/")
tronc = base.rsplit(".", 1)[0]
hote = 50 + (int(vmid) % 200)
return f"ip={tronc}.{hote}/{masque or '24'},gw={base}"
def ip_from_ipconfig(ipconfig: str) -> str:
"""Adresse fixe d'un « ip=10.10.10.150/24,gw=… », ou '' si c'est du DHCP.
Quand c'est NOUS qui avons attribué l'adresse, la chercher ensuite est
absurde : elle est connue avant que la VM ne démarre. La découverte (agent
invité, voisinage de l'hôte) ne sert qu'au DHCP.
"""
m = re.search(r"ip=(\d+\.\d+\.\d+\.\d+)", ipconfig or "")
return m.group(1) if m else ""
def image_fetch_cmd(url: str, nom: str, repertoire: str = IMAGE_DIR) -> str:
"""Télécharge l'image cloud SUR l'hôte Proxmox, une seule fois.
C'est là que le disque de la VM sera écrit : faire descendre l'image chez
soi pour la renvoyer ensuite doublerait le transfert. Le test de présence
évite de retélécharger 325 Mio à chaque VM.
"""
cible = f"{repertoire}/{nom}"
return (
f"mkdir -p {shlex.quote(repertoire)} && "
f"if [ -s {shlex.quote(cible)} ]; then "
f'echo "image déjà présente : {cible}"; else '
f"wget -q --show-progress -O {shlex.quote(cible)} {shlex.quote(url)}; "
f"fi"
)
def create_cmds(vmid: int, spec: dict) -> list:
"""Séquence complète de création d'une VM, dans l'ordre.
Une liste et non une seule commande : chaque étape est lisible dans le
journal, et un échec nomme celle qui a échoué. C'est le contraire d'un
« qm create » géant dont on ne sait pas quel morceau a cédé.
"""
nom = spec["name"]
stockage = spec["storage"]
image = f"{spec.get('image_dir', IMAGE_DIR)}/{spec['image']}"
cmds = [
# 1. La coquille : processeur, mémoire, réseau, contrôleur, agent.
"qm create {id} --name {nom} --memory {mem} --cores {cpu}"
" --cpu host --ostype l26 --scsihw virtio-scsi-single"
" --net0 virtio,bridge={pont} --agent enabled=1"
" --serial0 socket --vga serial0".format(
id=vmid,
nom=shlex.quote(nom),
mem=int(spec["memory"]),
cpu=int(spec["vcpus"]),
pont=spec["bridge"],
),
# 2. Le disque, importé DEPUIS l'image cloud. « import-from » (PVE 8+)
# remplace l'ancien « qm importdisk » en une seule étape et attache
# le disque du même coup.
f"qm set {vmid} --scsi0"
f" {stockage}:0,import-from={shlex.quote(image)},discard=on,ssd=1",
# 3. Le lecteur cloud-init, et l'ordre d'amorçage. Sans « boot order »,
# Proxmox laisse le disque importé hors de la liste et la VM démarre
# sur le réseau.
f"qm set {vmid} --ide2 {stockage}:cloudinit"
f" --boot order=scsi0 --bootdisk scsi0",
]
# 4. cloud-init : utilisateur, clé, réseau. La clé est un FICHIER sur
# l'hôte — « --sshkeys » n'accepte pas la clé en ligne.
ci = (
f"qm set {vmid} --ciuser {shlex.quote(spec.get('user') or 'erplibre')}"
)
if spec.get("sshkey_path"):
ci += f" --sshkeys {shlex.quote(spec['sshkey_path'])}"
if spec.get("password"):
ci += f" --cipassword {shlex.quote(spec['password'])}"
ci += f" --ipconfig0 {spec.get('ipconfig') or 'ip=dhcp'}"
cmds.append(ci)
# 5. La taille. L'image cloud fait 2 Gio : sans agrandissement, il ne reste
# rien pour installer quoi que ce soit.
if spec.get("disk"):
cmds.append(f"qm resize {vmid} scsi0 {spec['disk']}")
if spec.get("start", True):
cmds.append(f"qm start {vmid}")
return cmds
def destroy_cmds(vmid: int, purge: bool = True) -> list:
"""Arrêt puis suppression. « --purge » retire aussi les disques et les
entrées de sauvegarde : sans lui, le stockage garde des volumes orphelins
que rien ne réclame plus."""
return [
f"qm stop {vmid} --skiplock 1 || true",
f"qm destroy {vmid} --purge {1 if purge else 0}"
" --destroy-unreferenced-disks 1",
]
def resize_cmd(vmid: int, taille: str, disque: str = "scsi0") -> str:
"""« +10G » agrandit, « 40G » fixe. Proxmox REFUSE de rétrécir un disque —
le dire ici évite de croire à un bug de l'outil."""
return f"qm resize {vmid} {disque} {taille}"
def status_cmd(vmid: int) -> str:
return f"qm status {vmid} --verbose"
def guest_ip_cmd(vmid: int) -> str:
return f"qm guest cmd {vmid} network-get-interfaces"
def console_cmd(vmid: int) -> str:
"""Console série. `qm terminal` demande serial0, que create_cmds pose."""
return f"qm terminal {vmid}"
def orphan_disks_cmd() -> str:
"""Volumes de disque qui n'appartiennent à aucune VM déclarée.
Proxmox ne les efface pas tout seul : un « qm destroy » sans « --purge »,
ou une création interrompue, en laisse. On les LISTE, on n'efface rien
sans demander.
"""
return (
"for s in $(pvesm status --content images | awk 'NR>1 {print $1}'); "
'do pvesm list "$s" 2>/dev/null; done'
)
def parse_orphans(text: str, vmids) -> list:
"""[(volid, taille)] des volumes dont le VMID n'existe plus."""
connus = {str(v) for v in vmids or ()}
out = []
for ligne in (text or "").splitlines():
parts = ligne.split()
if len(parts) < 5 or parts[0] == "Volid":
continue
volid, vmid = parts[0], parts[-1]
if vmid.isdigit() and vmid not in connus:
try:
taille = int(parts[3])
except ValueError:
taille = 0
out.append((volid, taille))
return out

View file

@ -184,10 +184,24 @@ Catalog, per architecture (`deploy_qemu.py` is the source of truth):
| rocky | `9`, `10` (default) | ✔ | ✔ | ✔ |
| opensuse | `16.0` (default), `tumbleweed` | ✔ | ✔ | ✔ |
| arch | `latest` | ✔ | — | — |
| proxmox | `9` | ✔ | ✔ | — |
Fedora builds s390x only for the current release, and on a separate tree
(`fedora-secondary`) — hence the single version there.
`proxmox` is Proxmox VE, and it deserves a word: it publishes **no cloud
image** — its ISO is an installer that formats the disk. So the deployment
does what upstream itself documents for every other case, *Proxmox VE on
Debian*: it downloads the **Debian trixie** cloud image (the very same file, so
a Debian 13 and a Proxmox deployment share one download) and the `pve`
packages turn it into a hypervisor — Proxmox kernel, web UI on `:8006`.
The version number is Proxmox's, not Debian's: PVE 9 = trixie. arm64 has been
official since PVE 9 (the upstream `trixie` Release announces `amd64 arm64`,
and the arm64 index really serves `proxmox-ve`). s390x is absent and will stay
so by this route: the repository has no `binary-s390x` index at all — the
catalog says it before the deployment rather than failing at the first `apt`.
`opensuse` covers two distinct products, not two versions of one. Leap `16.0`
is numbered and stable (SLE base) and is the default. `tumbleweed` is the
rolling one, kept as a bellwether for breakage to come: its snapshot drift is
@ -218,6 +232,7 @@ Catalogue, par architecture (`deploy_qemu.py` fait autorité) :
| rocky | `9`, `10` (défaut) | ✔ | ✔ | ✔ |
| opensuse | `16.0` (défaut), `tumbleweed` | ✔ | ✔ | ✔ |
| arch | `latest` | ✔ | — | — |
| proxmox | `9` | ✔ | ✔ | — |
Fedora ne construit s390x que pour la version courante, et sur une
arborescence à part (`fedora-secondary`) — d'où la version unique.
@ -232,6 +247,38 @@ Les deux livrent un qpdf au-dessus du seuil de pikepdf : la compilation de
qpdf, une demi-heure, ne s'y déclenche jamais — ce qui compte sous émulation
s390x.
`proxmox`, c'est Proxmox VE, et il mérite un mot : il ne publie **aucune image
cloud** — son ISO est un installateur qui formate le disque. Le déploiement
fait donc ce que l'amont documente lui-même pour tous les autres cas,
*Proxmox VE sur Debian* : il télécharge l'image cloud **Debian trixie** (le
même fichier, si bien qu'un déploiement Debian 13 et un Proxmox se partagent un
seul téléchargement) et les paquets `pve` en font un hyperviseur — noyau
Proxmox, interface web sur `:8006`.
Le numéro de version est celui de Proxmox, pas de Debian : PVE 9 = trixie.
arm64 est officiel depuis PVE 9 (le Release `trixie` de l'amont annonce
`amd64 arm64`, et l'index arm64 sert bien `proxmox-ve`). s390x est absent et le
restera par cette voie : le dépôt n'a aucun index `binary-s390x` — le catalogue
le dit avant le déploiement plutôt que d'échouer au premier `apt`.
Une VM Proxmox est un hyperviseur DANS une VM : ses propres invités demandent
la virtualisation imbriquée à tous les étages. L'installation se fait par le
profil « Hyperviseur Proxmox VE (sans Odoo) » du menu de déploiement, ou à la
main dans la VM :
```bash
sudo ./script/proxmox/install_proxmox.sh --dry-run # dit ce qu'il ferait
sudo ./script/proxmox/install_proxmox.sh # puis : sudo reboot
```
Trois pièges de l'image cloud, tous rencontrés sur une VM réelle et traités par
le script : cloud-init tient encore le verrou d'`apt` au premier démarrage ;
`grub-pc`, tiré par les paquets `pve`, demande sur quel disque s'installer et
bloque toute la transaction sans préréponse ; et le chemin de secours UEFI
(`\EFI\BOOT\`) reçoit les binaires GRUB de Proxmox mais pas le `grub.cfg`
qui dit où trouver la configuration — sans quoi la VM s'arrête sur l'invite
`grub>`, sans menu ni noyau.
Fournissez un chemin d'image en argument positionnel pour surcharger
l'emplacement de téléchargement automatique.

View file

@ -103,6 +103,7 @@ Catalogue, par architecture (`deploy_qemu.py` fait autorité) :
| rocky | `9`, `10` (défaut) | ✔ | ✔ | ✔ |
| opensuse | `16.0` (défaut), `tumbleweed` | ✔ | ✔ | ✔ |
| arch | `latest` | ✔ | — | — |
| proxmox | `9` | ✔ | ✔ | — |
Fedora ne construit s390x que pour la version courante, et sur une
arborescence à part (`fedora-secondary`) — d'où la version unique.
@ -117,6 +118,38 @@ Les deux livrent un qpdf au-dessus du seuil de pikepdf : la compilation de
qpdf, une demi-heure, ne s'y déclenche jamais — ce qui compte sous émulation
s390x.
`proxmox`, c'est Proxmox VE, et il mérite un mot : il ne publie **aucune image
cloud** — son ISO est un installateur qui formate le disque. Le déploiement
fait donc ce que l'amont documente lui-même pour tous les autres cas,
*Proxmox VE sur Debian* : il télécharge l'image cloud **Debian trixie** (le
même fichier, si bien qu'un déploiement Debian 13 et un Proxmox se partagent un
seul téléchargement) et les paquets `pve` en font un hyperviseur — noyau
Proxmox, interface web sur `:8006`.
Le numéro de version est celui de Proxmox, pas de Debian : PVE 9 = trixie.
arm64 est officiel depuis PVE 9 (le Release `trixie` de l'amont annonce
`amd64 arm64`, et l'index arm64 sert bien `proxmox-ve`). s390x est absent et le
restera par cette voie : le dépôt n'a aucun index `binary-s390x` — le catalogue
le dit avant le déploiement plutôt que d'échouer au premier `apt`.
Une VM Proxmox est un hyperviseur DANS une VM : ses propres invités demandent
la virtualisation imbriquée à tous les étages. L'installation se fait par le
profil « Hyperviseur Proxmox VE (sans Odoo) » du menu de déploiement, ou à la
main dans la VM :
```bash
sudo ./script/proxmox/install_proxmox.sh --dry-run # dit ce qu'il ferait
sudo ./script/proxmox/install_proxmox.sh # puis : sudo reboot
```
Trois pièges de l'image cloud, tous rencontrés sur une VM réelle et traités par
le script : cloud-init tient encore le verrou d'`apt` au premier démarrage ;
`grub-pc`, tiré par les paquets `pve`, demande sur quel disque s'installer et
bloque toute la transaction sans préréponse ; et le chemin de secours UEFI
(`\EFI\BOOT\`) reçoit les binaires GRUB de Proxmox mais pas le `grub.cfg`
qui dit où trouver la configuration — sans quoi la VM s'arrête sur l'invite
`grub>`, sans menu ni noyau.
Fournissez un chemin d'image en argument positionnel pour surcharger
l'emplacement de téléchargement automatique.

View file

@ -97,10 +97,24 @@ Catalog, per architecture (`deploy_qemu.py` is the source of truth):
| rocky | `9`, `10` (default) | ✔ | ✔ | ✔ |
| opensuse | `16.0` (default), `tumbleweed` | ✔ | ✔ | ✔ |
| arch | `latest` | ✔ | — | — |
| proxmox | `9` | ✔ | ✔ | — |
Fedora builds s390x only for the current release, and on a separate tree
(`fedora-secondary`) — hence the single version there.
`proxmox` is Proxmox VE, and it deserves a word: it publishes **no cloud
image** — its ISO is an installer that formats the disk. So the deployment
does what upstream itself documents for every other case, *Proxmox VE on
Debian*: it downloads the **Debian trixie** cloud image (the very same file, so
a Debian 13 and a Proxmox deployment share one download) and the `pve`
packages turn it into a hypervisor — Proxmox kernel, web UI on `:8006`.
The version number is Proxmox's, not Debian's: PVE 9 = trixie. arm64 has been
official since PVE 9 (the upstream `trixie` Release announces `amd64 arm64`,
and the arm64 index really serves `proxmox-ve`). s390x is absent and will stay
so by this route: the repository has no `binary-s390x` index at all — the
catalog says it before the deployment rather than failing at the first `apt`.
`opensuse` covers two distinct products, not two versions of one. Leap `16.0`
is numbered and stable (SLE base) and is the default. `tumbleweed` is the
rolling one, kept as a bellwether for breakage to come: its snapshot drift is

View file

@ -140,6 +140,30 @@ ARCH_VERSIONS: dict[str, tuple[str, str, int, str]] = {
"latest": ("latest", "archlinux", 1024, "20G"),
}
# Proxmox VE ne publie AUCUNE image cloud : son ISO est un installateur, et la
# voie que l'amont documente pour tout le reste est « Proxmox VE sur Debian ».
# La base est donc l'image cloud Debian, que les paquets pve transforment en
# hyperviseur. Le numéro est celui de PROXMOX, pas de Debian : PVE 9 = trixie.
#
# arm64 est officiel depuis PVE 9 — vérifié dans le dépôt amont, dont le
# Release de trixie annonce « amd64 arm64 » et dont l'index arm64 sert bien
# proxmox-ve. bookworm (PVE 8), lui, est amd64 seulement : d'où une seule
# version au catalogue, celle qui couvre les deux architectures.
#
# s390x n'y figure pas et n'y figurera pas par cette voie : l'index
# « binary-s390x » du dépôt répond 404. Ce n'est pas une difficulté, c'est une
# absence — et ARCH_DISTRO_SUPPORT la dit à la place d'un échec au montage.
#
# RAM/disque : 4 Gio et 32 Go, pas les 1 Gio/20 Go de Debian. Proxmox demande
# 2 Gio pour lui seul, et l'installation télécharge son propre noyau ; 20 Go ne
# laisseraient pas la place d'une seule VM invitée.
PROXMOX_VERSIONS: dict[str, tuple[str, str, int, str]] = {
"9": ("trixie", "debian13", 4096, "32G"),
}
# Version Debian dont l'image sert de base à chaque version de Proxmox.
PROXMOX_DEBIAN_BASE: dict[str, str] = {"9": "13"}
# distro -> (table des versions, version par défaut).
DISTROS: dict[str, tuple[dict[str, tuple[str, str, int, str]], str]] = {
"ubuntu": (UBUNTU_VERSIONS, "24.04"),
@ -149,6 +173,7 @@ DISTROS: dict[str, tuple[dict[str, tuple[str, str, int, str]], str]] = {
"rocky": (ROCKY_VERSIONS, "10"),
"opensuse": (OPENSUSE_VERSIONS, "16.0"),
"arch": (ARCH_VERSIONS, "latest"),
"proxmox": (PROXMOX_VERSIONS, "9"),
}
# Traduction de l'arch générique (amd64/arm64) vers le nom propre à la distro.
@ -235,6 +260,7 @@ ARM64_DISTROS: tuple[str, ...] = (
"almalinux",
"rocky",
"opensuse",
"proxmox",
)
@ -429,6 +455,13 @@ def image_candidates(
f"{base}/{code}/latest/debian-{version}-genericcloud-{a}.qcow2"
for base in DEBIAN_CLOUD_BASES
]
if distro == "proxmox":
# C'est bien l'image DEBIAN qu'on télécharge : « proxmox-ve », posé
# dessus, en fait l'hyperviseur. Le miroir ne connaît que le numéro de
# Debian, jamais celui de Proxmox.
return image_candidates(
"debian", code, arch, PROXMOX_DEBIAN_BASE[version], dry_run
)
if distro == "fedora":
return [resolve_fedora_url(version, arch, dry_run)]
if distro == "almalinux":
@ -525,6 +558,14 @@ def default_image_name(distro: str, code: str, arch: str, version: str) -> str:
return f"ubuntu-{version}-server-cloudimg-{a}.img"
if distro == "debian":
return f"debian-{version}-genericcloud-{a}.qcow2"
if distro == "proxmox":
# Le fichier téléchargé EST celui de Debian : lui donner le même nom
# de cache fait qu'un déploiement Debian 13 et un Proxmox se
# PARTAGENT le téléchargement (325 Mio) au lieu d'en faire deux. Sans
# cette branche, le repli de fin nommait l'image « fedora-cloud-9 ».
return (
f"debian-{PROXMOX_DEBIAN_BASE[version]}-genericcloud-{a}.qcow2"
)
if distro == "arch":
return f"arch-linux-{a}-cloudimg.qcow2"
if distro == "opensuse":
@ -1615,6 +1656,7 @@ DISTRO_LABELS: dict[str, str] = {
"rocky": "Rocky Linux",
"opensuse": "openSUSE",
"arch": "Arch Linux",
"proxmox": "Proxmox VE",
}
# Gestionnaire de paquets de chaque distribution du catalogue.
@ -1626,6 +1668,8 @@ DISTRO_PKG: dict[str, str] = {
"rocky": "dnf",
"opensuse": "zypper",
"arch": "pacman",
# Debian dessous : c'est apt qui sert, et le guide de connexion le dit.
"proxmox": "apt",
}

View file

@ -42,7 +42,8 @@ FILES=("$@")
if [[ ${#FILES[@]} -eq 0 ]]; then
# Aucun argument : tout ce que le dépôt sait tester sans base de données.
mapfile -t FILES < <(ls test/test_qemu_*.py test/test_mobile_*.py \
test/test_todo_*.py test/test_install_*.py 2>/dev/null)
test/test_todo_*.py test/test_install_*.py test/test_run_*.py \
2>/dev/null)
fi
fail=0

View file

@ -298,6 +298,10 @@ def build_spec(vms, domains, form):
"python_provider": form.get("python_provider", ""),
"app_store": form.get("app_store", "deb"),
"install": form["install"],
# Au NIVEAU DU DÉPLOIEMENT, pas de l'installation : une VM sans
# ERPLibre se suit aussi (cloud-init, puis relevé système). Absent de
# cette assemblée, le choix du formulaire n'atteignait jamais la spec.
"monitor": form.get("monitor", True),
"add_ssh_config": form["add_ssh_config"],
"parallelism": form["parallelism"],
}
@ -1828,6 +1832,10 @@ def run_deploy_form(ctx, run_app: bool = True):
}
key = self.query_one("#f_key", Input).value.strip()
return {
# Le suivi est demandé au NIVEAU DU DÉPLOIEMENT, pas de
# l'installation : décocher ERPLibre emportait la case avec
# elle, et le tableau de bord ne s'ouvrait plus du tout.
"monitor": self.query_one("#f_monitor", Checkbox).value,
"res_label": (
t("custom")
if self.profile == "custom"

View file

@ -80,7 +80,11 @@ def list_install_runs() -> list:
def _launch_one(
ip: str, remote_cmd: str, log_path: str, name: str = ""
ip: str,
remote_cmd: str,
log_path: str,
name: str = "",
installs: bool = True,
) -> None:
"""Lance une install SSH DÉTACHÉE : attend le sshd, exécute, journalise
la sortie puis écrit le marqueur de fin avec le code de sortie."""
@ -102,7 +106,13 @@ def _launch_one(
# log reste VIDE pendant tout le boot émulé et paraît « bloqué ».
msg_wait = t("Waiting for the VM to start (boot + cloud-init)")
msg_slow = t("(an emulated architecture can be slow; this is normal)")
msg_ready = t("VM ready - starting the ERPLibre install")
# « installation ERPLibre en cours » sur un déploiement qui n'installe
# RIEN était un mensonge du journal : la ligne dit maintenant ce qui suit.
msg_ready = (
t("VM ready - starting the ERPLibre install")
if installs
else t("VM ready - taking its measurements")
)
msg_giveup = t(
"cloud-init still running after 20 min - install starts anyway"
" (it waits for cloud-init first)"
@ -230,19 +240,26 @@ def _launch_one(
def _log_header(vm: dict, branch: str, when: str) -> str:
"""En-tête du log : date, VM, distribution, version, architecture, branche.
Permet d'identifier l'installation d'un coup d'œil (et de ne jamais laisser
le log vide pendant l'attente du boot)."""
le log vide pendant l'attente du boot).
Sans branche, il n'y a rien à installer : le titre le dit et la ligne
« Branche » disparaît, au lieu d'annoncer une installation ERPLibre qui
n'aura pas lieu.
"""
distro = vm.get("distro") or "?"
version = vm.get("version") or ""
arch = vm.get("arch") or "?"
bar = "=" * 64
titre = t("installation") if branch else t("VM start-up")
ligne_branche = f" Branche : {branch}\n" if branch else ""
return (
f"{bar}\n"
f" ERPLibre — {t('installation')}\n"
f" ERPLibre — {titre}\n"
f" Date : {when}\n"
f" VM : {vm['name']}\n"
f" Distribution : {distro} {version}\n"
f" Architecture : {arch}\n"
f" Branche : {branch}\n"
f"{ligne_branche}"
f" IP : {vm['ip']}\n"
f"{bar}\n\n"
)
@ -268,7 +285,11 @@ def launch_installs(vms: list[dict], branch: str, remote_cmd: str) -> str:
# plus le même pour toutes. `remote_cmd` reste le défaut, ce qui laisse
# intacts les appelants qui n'en fournissent qu'une.
_launch_one(
vm["ip"], vm.get("remote_cmd") or remote_cmd, log_path, vm["name"]
vm["ip"],
vm.get("remote_cmd") or remote_cmd,
log_path,
vm["name"],
installs=bool(branch),
)
entries.append(
{
@ -411,6 +432,10 @@ def active_run():
# avertissements connus et bénins) : on réutilise la MÊME logique de détection
# que la suite de tests ERPLibre pour analyser les logs d'installation.
_LST_IGNORE_WARNING = (
# ssh annonce l'ajout d'une clé d'hôte à chaque PREMIÈRE connexion à une
# VM neuve. Ce n'est pas un avertissement d'installation : compté, il
# allumait la colonne ⚠ sur TOUTE installation, dès sa première ligne.
"Warning: Permanently added",
"have the same label:",
"odoo.addons.code_generator.extractor_module_file: Ignore next error about"
" ALTER TABLE DROP CONSTRAINT.",
@ -1059,6 +1084,245 @@ def virsh_domstates() -> dict:
return states
# Fenêtre du débit d'écriture. Dix secondes : le disque d'une VM en
# installation travaille par rafales — « apt » décompresse, « poetry » compile,
# puis plus rien pendant trois secondes. Un instantané de deux secondes affiche
# donc tantôt 0, tantôt 200 Mo/s, et ne dit rien. Sur dix secondes, le chiffre
# devient un débit qu'on peut comparer d'une VM à l'autre.
WRITE_WINDOW = 10.0
# Âge maximal d'un relevé du ballon mémoire. Au-delà, la valeur est TAISÉE
# plutôt que montrée : virtio-balloon ne publie ses compteurs que si une
# période de collecte est armée, et une valeur figée depuis une demi-heure
# ferait croire à une VM au repos alors qu'elle compile.
BALLOON_MAX_AGE = 30.0
# Les compteurs du ballon sont en kibioctets ; ceux des blocs, en octets.
_KIB = 1024
def parse_domstats(text: str) -> dict:
"""Sortie de « virsh domstats » -> {nom: relevé}.
Un relevé porte : ram_used / ram_total (octets), ram_at (horodatage du
dernier rapport du ballon), wr_bytes (cumul écrit depuis le démarrage du
processus QEMU), disk_used / disk_total (octets).
Le seed ISO est ÉCARTÉ des disques : monté en lecture seule, il n'est
jamais écrit, et sa capacité (quelques mégaoctets) l'aurait fait passer
pour le disque système sur une VM dont le qcow2 n'est pas encore alloué.
"""
out = {}
nom = None
brut = {}
def clore():
if nom is None:
return
rec = {
"ram_used": 0,
"ram_total": 0,
"ram_at": 0,
"wr_bytes": 0,
"disk_used": 0,
"disk_total": 0,
}
dispo = brut.get("balloon.available")
util = brut.get("balloon.usable")
if dispo:
rec["ram_total"] = dispo * _KIB
if util is not None:
rec["ram_used"] = max(0, (dispo - util)) * _KIB
rec["ram_at"] = brut.get("balloon.last-update") or 0
# Disques : on parcourt les index déclarés par block.count.
meilleur = 0
for i in range(int(brut.get("block.count") or 0)):
chemin = brut.get(f"block.{i}.path.str") or ""
if chemin.lower().endswith(".iso"):
continue
rec["wr_bytes"] += brut.get(f"block.{i}.wr.bytes") or 0
cap = brut.get(f"block.{i}.capacity") or 0
if cap >= meilleur:
meilleur = cap
rec["disk_total"] = cap
rec["disk_used"] = brut.get(f"block.{i}.allocation") or 0
out[nom] = rec
for ligne in (text or "").splitlines():
ligne = ligne.strip()
if ligne.startswith("Domain:"):
clore()
nom = ligne.split("'")[1] if "'" in ligne else None
brut = {}
continue
if nom is None or "=" not in ligne:
continue
cle, _, val = ligne.partition("=")
try:
brut[cle] = int(val)
except ValueError:
# Les valeurs non numériques (chemins, noms de device) sont
# gardées à part : « block.0.path » en est une, et c'est elle qui
# démasque le seed ISO.
brut[f"{cle}.str"] = val
clore()
return out
class WriteWindow:
"""Débit d'écriture moyen par VM, sur une fenêtre glissante."""
def __init__(self, window=WRITE_WINDOW):
self.window = window
self._hist = {}
def add(self, name, wr_bytes, now):
hist = self._hist.setdefault(name, [])
# Un compteur qui RECULE veut dire que le domaine a redémarré : le
# processus QEMU est neuf, ses compteurs repartent de zéro. Sans ce
# garde, le débit affiché serait négatif, puis énorme au relevé
# suivant. Une installation redémarre la VM : le cas est la règle.
if hist and wr_bytes < hist[-1][1]:
hist.clear()
hist.append((now, wr_bytes))
limite = now - self.window
while len(hist) > 2 and hist[1][0] < limite:
hist.pop(0)
def rate(self, name):
"""Octets/s, ou None tant que la fenêtre n'a pas de quoi conclure."""
hist = self._hist.get(name) or []
if len(hist) < 2:
return None
span = hist[-1][0] - hist[0][0]
if span < 1.0:
return None
return max(0.0, (hist[-1][1] - hist[0][1]) / span)
def total(self, name):
"""Écrit depuis le premier relevé de la fenêtre (octets)."""
hist = self._hist.get(name) or []
return hist[-1][1] if hist else None
def fmt_rate(bps) -> str:
"""Octets/s -> « 12.3M/s ». « - » tant qu'on ne sait pas."""
return "-" if bps is None else f"{_fmt_size(int(bps))}/s"
def _fmt_tight(nbytes) -> str:
"""Comme _fmt_size, mais sans décimale au-delà de dix unités.
« 63G » plutôt que « 62.6G » : dans une colonne de tableau, ces deux
caractères décident si « Disque » reste visible ou sort de l'écran, et la
décimale n'apprend rien à côté d'un total de 65 Go.
"""
if nbytes is None:
return "-"
for unit, div in (("T", 1 << 40), ("G", 1 << 30), ("M", 1 << 20)):
if nbytes >= div:
val = nbytes / div
return f"{val:.0f}{unit}" if val >= 10 else f"{val:.1f}{unit}"
return f"{max(0, int(nbytes)) // 1024}K"
def fmt_pair(used, total) -> str:
"""« 1.1G/12G », « 63G/65G ». « - » si le total manque : « ?/12G »
n'informe pas."""
if not total:
return "-"
return f"{_fmt_tight(used)}/{_fmt_tight(total)}"
def fmt_pct(used, total) -> str:
return f" ({int(used / total * 100)}%)" if total else ""
def ram_pair(rec, now, max_age=BALLOON_MAX_AGE) -> str:
"""RAM utilisée/totale de la VM, ou « - » si le relevé est PÉRIMÉ."""
if not rec or not rec.get("ram_total"):
return "-"
at = rec.get("ram_at") or 0
if at and now - at > max_age:
return "-"
return fmt_pair(rec.get("ram_used"), rec["ram_total"])
def vm_stats_line(name, rec, bps, now, ecrit=None) -> str:
"""Section statistiques d'UNE VM, en une ligne dense.
Le tableau porte les mêmes chiffres en colonnes, pour tout le parc d'un
coup d'œil ; cette ligne les détaille pour la VM sélectionnée — celle dont
le journal et la commande SSH sont déjà affichés.
"""
if not rec:
return f" 📊 {name} · {t('no statistics yet')}"
bits = [f"✍ {fmt_rate(bps)} ({t('10s average')})"]
if ecrit:
bits.append(f"{t('total written')} {_fmt_size(ecrit)}")
ram = ram_pair(rec, now)
if ram != "-":
bits.append(
f"🧠 RAM {ram}{fmt_pct(rec['ram_used'], rec['ram_total'])}"
)
if rec.get("disk_total"):
bits.append(
f"💾 {t('disk')} "
f"{fmt_pair(rec['disk_used'], rec['disk_total'])}"
f"{fmt_pct(rec['disk_used'], rec['disk_total'])}"
)
return f" 📊 {name} · " + " · ".join(bits)
def read_domstats() -> str:
"""Sortie brute de « virsh domstats --balloon --block » (tout le parc).
UN appel pour toutes les VM — 0,03 s mesuré sur deux domaines. Le suivi
relève toutes les deux secondes : une commande par VM y coûterait N
processus à chaque tour."""
try:
res = subprocess.run(
["sudo", "virsh", "domstats", "--balloon", "--block"],
capture_output=True,
text=True,
timeout=15,
env={**os.environ, "LC_ALL": "C", "LANG": "C"},
)
except (OSError, subprocess.SubprocessError):
return ""
return res.stdout if res.returncode == 0 else ""
def arm_balloon(names) -> None:
"""Arme la période de collecte du ballon (5 s) sur chaque VM.
Sans elle, « balloon.available » et « balloon.usable » restent FIGÉS sur le
dernier rapport du pilote : mesuré sur une VM fraîche, 388 Mo annoncés
contre 1,1 Go réellement occupés, avec un horodatage vieux d'une
demi-heure. La période se perd quand le domaine redémarre — ce qu'une
installation fait — donc on la réarme à intervalle lent.
"""
for name in names or ():
try:
subprocess.run(
[
"sudo",
"virsh",
"dommemstat",
name,
"--period",
"5",
"--live",
],
capture_output=True,
text=True,
timeout=10,
env={**os.environ, "LC_ALL": "C", "LANG": "C"},
)
except (OSError, subprocess.SubprocessError):
continue
# --------------------------------------------------------------------------- #
# Dashboard Textual
# --------------------------------------------------------------------------- #
@ -1068,13 +1332,26 @@ def virsh_domstates() -> dict:
# que ce qui était affiché au départ.
COL_DEFAULT_WIDTHS = {
"seq": 3,
"vm": 22,
"arch": 7,
# 21 : un caractère de moins que l'ancien 22, et c'est lui qui fait tenir
# la ligne entière sur un terminal de 150 colonnes. « + » l'élargit, et
# c'est déjà la colonne visée par défaut.
"vm": 21,
# 5 : « amd64 », « s390x », « arm64 » font cinq caractères, et l'en-tête
# « Arch » quatre. Les deux de plus ne servaient rien.
"arch": 5,
"err": 4,
"state": 8,
"odoo": 6,
"elapsed": 7,
"disk": 8,
# 4 : une icône (🟢) ou un tiret, sous un en-tête de quatre lettres.
"odoo": 4,
# 6 : « 125:30 » est le pire cas d'une installation de deux heures.
"elapsed": 6,
# Section statistiques de la VM : ce qu'elle écrit, sa RAM, son disque.
# « 12.3M/s » tient en 7 et « 1.1G/12G » en 9 : au-delà, « Disque » sortait
# de l'écran sur un terminal de 150 colonnes, moitié prise par le journal.
"wr": 7,
# 10 et non 9 : sur une VM de 128 Go, « 1001M/128G » fait dix caractères.
"ram": 10,
"disk": 9,
}
@ -1369,7 +1646,7 @@ def run_monitor(manifest_path: str, run_app: bool = True):
que devinable, et « max-width » laisse la table suivre l'élargissement
des colonnes sans manger tout l'écran. */
DataTable {
width: auto; max-width: 60%; height: 1fr;
width: auto; max-width: 66%; height: 1fr;
overflow-x: auto; overflow-y: auto;
scrollbar-size-horizontal: 1; scrollbar-size-vertical: 1;
border: solid $accent;
@ -1444,6 +1721,10 @@ def run_monitor(manifest_path: str, run_app: bool = True):
)
# État libvirt (running/paused/gone), rafraîchi à intervalle LENT.
self._domstate = {}
# Statistiques par VM : dernier relevé libvirt, et la fenêtre
# glissante qui en tire un débit d'écriture.
self._vmstats = {}
self._wrate = WriteWindow()
# Erreurs détectées dans le log à la complétion : {nom: (err, warn)}.
self._errcount = {}
# Sommaire de stats déplié (clic) ou non.
@ -1498,6 +1779,13 @@ def run_monitor(manifest_path: str, run_app: bool = True):
table.add_column(
"Durée", key="elapsed", width=COL_DEFAULT_WIDTHS["elapsed"]
)
# Statistiques de la VM. « Écrit/s » est une MOYENNE sur dix
# secondes : le disque d'une installation travaille par rafales,
# et l'instantané n'y montrait que des 0 et des pics.
table.add_column(
"Écrit/s", key="wr", width=COL_DEFAULT_WIDTHS["wr"]
)
table.add_column("RAM", key="ram", width=COL_DEFAULT_WIDTHS["ram"])
table.add_column(
"Disque", key="disk", width=COL_DEFAULT_WIDTHS["disk"]
)
@ -1511,6 +1799,8 @@ def run_monitor(manifest_path: str, run_app: bool = True):
"—",
"--:--",
"-",
"-",
"-",
key=vm["name"],
)
# max_lines borne la mémoire/rendu (un install verbeux × 30 VM).
@ -1525,6 +1815,11 @@ def run_monitor(manifest_path: str, run_app: bool = True):
# Sommaire de stats en CHIFFRES (cliquable -> détail).
yield Static("", id="stats")
yield Static("", id="statsdetail")
# Section statistiques de la VM SÉLECTIONNÉE : les mêmes chiffres
# que ses colonnes, mais détaillés (pourcentages, cumul écrit).
# Une ligne par VM du parc aurait chassé le pied de page dès cinq
# machines ; la sélection suit déjà le journal et la barre SSH.
yield Static("", id="vmstats")
yield Static("", id="sshbar")
yield Footer()
@ -1536,6 +1831,7 @@ def run_monitor(manifest_path: str, run_app: bool = True):
)
self.sub_title = f"0/{len(vms)} {t('completed')}"
self._refresh_ssh()
self._refresh_vmstats()
self._load_selected_log(reset=True)
# Table toutes les 2 s (30 lectures de fin de log), suivi du log
# sélectionné toutes les 1 s (une seule lecture incrémentale).
@ -1551,6 +1847,25 @@ def run_monitor(manifest_path: str, run_app: bool = True):
def _vm_by_name(self, name):
return next((v for v in vms if v["name"] == name), None)
def _refresh_vmstats(self):
"""Ligne de statistiques de la VM sélectionnée."""
name = self._selected
if not name:
return
try:
bar = self.query_one("#vmstats", Static)
except Exception:
return
bar.update(
vm_stats_line(
name,
self._vmstats.get(name),
self._wrate.rate(name),
time.time(),
self._wrate.total(name),
)
)
def _refresh_ssh(self):
vm = self._vm_by_name(self._selected)
bar = self.query_one("#sshbar", Static)
@ -1612,9 +1927,27 @@ def run_monitor(manifest_path: str, run_app: bool = True):
"""(THREAD) statut + taille disque de chaque VM + télémétrie. AUCUNE
mise à jour d'UI ici : uniquement des I/O bloquantes déportées."""
disks, status, errors, odoo = {}, {}, {}, {}
# UN appel virsh pour tout le parc, dans ce thread : le débit se
# calcule sur les relevés successifs, donc il faut échantillonner
# à chaque tour (2 s) et non au rythme lent des états.
stats = parse_domstats(read_domstats())
now_s = time.time()
for name, rec in stats.items():
self._wrate.add(name, rec["wr_bytes"], now_s)
self._vmstats = stats
wr, ram = {}, {}
for vm in vms:
name = vm["name"]
disks[name] = _fmt_size(disk_actual_size(vm_disk_path(vm)))
rec = stats.get(name)
wr[name] = fmt_rate(self._wrate.rate(name))
ram[name] = ram_pair(rec, now_s)
if rec and rec.get("disk_total"):
disks[name] = fmt_pair(rec["disk_used"], rec["disk_total"])
else:
# Domaine pas encore défini (conversion de l'image, tout
# début de l'installation) : le qcow2 existe déjà, et
# st_blocks dit ce qu'il occupe. Sans total à annoncer.
disks[name] = _fmt_size(disk_actual_size(vm_disk_path(vm)))
if (
name not in self._final
and self._domstate.get(name) != "gone"
@ -1634,16 +1967,22 @@ def run_monitor(manifest_path: str, run_app: bool = True):
):
if _port_open(vm.get("ip"), 8069):
odoo[name] = True
return disks, status, self._collect_tele(), errors, odoo
return disks, status, self._collect_tele(), errors, odoo, wr, ram
async def _tick_table(self):
# I/O (lectures de logs, stat disque, /proc) DÉPORTÉES en thread ->
# la boucle d'événements Textual reste fluide même sous forte
# charge ou disque lent. Les mises à jour d'UI restent sur la boucle.
try:
disks, status, tele, errors, odoo = await asyncio.to_thread(
self._collect_table
)
(
disks,
status,
tele,
errors,
odoo,
wr,
ram,
) = await asyncio.to_thread(self._collect_table)
except Exception:
return
self._errcount.update(errors)
@ -1655,6 +1994,8 @@ def run_monitor(manifest_path: str, run_app: bool = True):
for vm in vms:
name = vm["name"]
self._set_cell(table, name, "disk", disks.get(name, "-"))
self._set_cell(table, name, "wr", wr.get(name, "-"))
self._set_cell(table, name, "ram", ram.get(name, "-"))
# Colonne Odoo : 🟢 dès que :8069 répond, sinon « — ».
self._set_cell(
table,
@ -1743,6 +2084,9 @@ def run_monitor(manifest_path: str, run_app: bool = True):
if tele:
self.query_one("#telemetry", Static).update(tele)
self._update_stats()
# Les chiffres de la VM sélectionnée viennent d'être relevés :
# sa section les redit ici, détaillés.
self._refresh_vmstats()
except Exception:
pass
@ -1849,6 +2193,14 @@ def run_monitor(manifest_path: str, run_app: bool = True):
return
for vm in vms:
self._domstate[vm["name"]] = states.get(vm["name"], "gone")
# Réarmer la période du ballon sur les VM qui tournent : sans elle
# la RAM affichée serait celle du dernier rapport du pilote, et une
# installation redémarre la VM — ce qui remet la période à zéro.
vivantes = [
vm["name"] for vm in vms if states.get(vm["name"]) == "running"
]
if vivantes:
await asyncio.to_thread(arm_balloon, vivantes)
# L'adresse est relue au même rythme. Le processus détaché suivait
# déjà la VM quand son bail changeait, mais les VUES gardaient celle
@ -1889,6 +2241,7 @@ def run_monitor(manifest_path: str, run_app: bool = True):
return
self._selected = name
self._refresh_ssh()
self._refresh_vmstats()
self._load_selected_log(reset=True)
def action_follow(self) -> None:

File diff suppressed because it is too large Load diff

View file

@ -1748,6 +1748,18 @@ TRANSLATIONS = {
"fr": "Rien de désinstallé.",
"en": "Nothing uninstalled.",
},
"The uninstall did not take:": {
"fr": "La désinstallation n'a pas pris :",
"en": "The uninstall did not take:",
},
"Odoo exits 0 even when it removes nothing.": {
"fr": "Odoo sort en 0 même quand il ne retire rien.",
"en": "Odoo exits 0 even when it removes nothing.",
},
"Could not verify the uninstall.": {
"fr": "Impossible de vérifier la désinstallation.",
"en": "Could not verify the uninstall.",
},
"Check the COW views that drifted": {
"fr": "Vérifier les vues COW en retard sur leur vue module",
"en": "Check the COW views that drifted",
@ -3103,6 +3115,441 @@ TRANSLATIONS = {
"fr": "Nœud de rendu",
"en": "Render node",
},
"VM ready - taking its measurements": {
"fr": "VM prête — relevé en cours",
"en": "VM ready - taking its measurements",
},
"VM start-up": {
"fr": "Mise en route de la VM",
"en": "VM start-up",
},
"system:": {
"fr": "système :",
"en": "system:",
},
"kernel:": {
"fr": "noyau :",
"en": "kernel: ",
},
"address:": {
"fr": "adresse :",
"en": "address:",
},
"disk:": {
"fr": "disque :",
"en": "disk: ",
},
"uptime:": {
"fr": "actif :",
"en": "uptime: ",
},
"Watch the VMs start (no install)": {
"fr": "Suivre le démarrage des VM (sans installation)",
"en": "Watch the VMs start (no install)",
},
"Proxmox VE - Deploy a VM on a remote host": {
"fr": "Proxmox VE - Déployer une VM sur un hôte distant",
"en": "Proxmox VE - Deploy a VM on a remote host",
},
"Deploy a virtual machine on Proxmox VE!": {
"fr": "Déployer une machine virtuelle sur Proxmox VE !",
"en": "Deploy a virtual machine on Proxmox VE!",
},
"Which Proxmox host?": {
"fr": "Quel hôte Proxmox ?",
"en": "Which Proxmox host?",
},
"From the local QEMU VMs": {
"fr": "Depuis les VM QEMU locales",
"en": "From the local QEMU VMs",
},
"Type an address": {
"fr": "Saisir une adresse",
"en": "Type an address",
},
"Keep": {
"fr": "Garder",
"en": "Keep",
},
"Address (user@host, default user root): ": {
"fr": "Adresse (utilisateur@hôte, utilisateur root par défaut) : ",
"en": "Address (user@host, default user root): ",
},
"SSH jump host (blank = none): ": {
"fr": "Rebond SSH (vide = aucun) : ",
"en": "SSH jump host (blank = none): ",
},
"Local VMs:": {
"fr": "VM locales :",
"en": "Local VMs:",
},
"No IP for this VM: is it running?": {
"fr": "Pas d'IP pour cette VM : est-elle démarrée ?",
"en": "No IP for this VM: is it running?",
},
"Checking": {
"fr": "Vérification de",
"en": "Checking",
},
"Not a Proxmox host (or unreachable):": {
"fr": "Ce n'est pas un hôte Proxmox (ou il est injoignable) :",
"en": "Not a Proxmox host (or unreachable):",
},
"Check the address, the SSH access and pveversion.": {
"fr": "Vérifier l'adresse, l'accès SSH et pveversion.",
"en": "Check the address, the SSH access and pveversion.",
},
"Proxmox host:": {
"fr": "Hôte Proxmox :",
"en": "Proxmox host:",
},
"through": {
"fr": "par",
"en": "through",
},
"exit code": {
"fr": "code de retour",
"en": "exit code",
},
"No VM on this Proxmox host.": {
"fr": "Aucune VM sur cet hôte Proxmox.",
"en": "No VM on this Proxmox host.",
},
"No running VM on this Proxmox host.": {
"fr": "Aucune VM démarrée sur cet hôte Proxmox.",
"en": "No running VM on this Proxmox host.",
},
"VMs on this host:": {
"fr": "VM de cet hôte :",
"en": "VMs on this host:",
},
"Selection (number): ": {
"fr": "Sélection (numéro) : ",
"en": "Selection (number): ",
},
"Deploy a VM on the Proxmox host": {
"fr": "Déployer une VM sur l'hôte Proxmox",
"en": "Deploy a VM on the Proxmox host",
},
"Preview a deployment (dry-run, nothing sent)": {
"fr": "Prévisualiser un déploiement (dry-run, rien n'est envoyé)",
"en": "Preview a deployment (dry-run, nothing sent)",
},
"Download a cloud image on the host": {
"fr": "Télécharger une image cloud sur l'hôte",
"en": "Download a cloud image on the host",
},
"List VMs (qm list)": {
"fr": "Lister les VM (qm list)",
"en": "List VMs (qm list)",
},
"Clean up (orphan disks)": {
"fr": "Nettoyer (disques orphelins)",
"en": "Clean up (orphan disks)",
},
"Statistics (host and VMs)": {
"fr": "Statistiques (hôte et VM)",
"en": "Statistics (host and VMs)",
},
"Remote desktop tunnel (VNC/RDP over SSH)": {
"fr": "Tunnel bureau distant (VNC/RDP par SSH)",
"en": "Remote desktop tunnel (VNC/RDP over SSH)",
},
"List available images and their specs": {
"fr": "Lister les images disponibles et leurs specs",
"en": "List available images and their specs",
},
"Proxmox - example sequence (dry-run)": {
"fr": "Proxmox - exemple de séquence (dry-run)",
"en": "Proxmox - example sequence (dry-run)",
},
"Host": {
"fr": "Hôte",
"en": "Host",
},
"Change the Proxmox host": {
"fr": "Changer d'hôte Proxmox",
"en": "Change the Proxmox host",
},
"VM name (default: erplibre-<distro>): ": {
"fr": "Nom de la VM (défaut : erplibre-<distro>) : ",
"en": "VM name (default: erplibre-<distro>): ",
},
"RAM in MB, blank = 4096": {
"fr": "RAM en Mo, vide = 4096",
"en": "RAM in MB, blank = 4096",
},
"vCPU, blank = 2": {
"fr": "vCPU, vide = 2",
"en": "vCPU, blank = 2",
},
"Disk size (default 32G): ": {
"fr": "Taille du disque (défaut 32G) : ",
"en": "Disk size (default 32G): ",
},
"Size (+10G to add, 40G for a target): ": {
"fr": "Taille (+10G pour ajouter, 40G pour une cible) : ",
"en": "Size (+10G to add, 40G for a target): ",
},
"No storage able to hold a VM disk.": {
"fr": "Aucun stockage capable d'héberger un disque de VM.",
"en": "No storage able to hold a VM disk.",
},
"No network bridge on this host.": {
"fr": "Aucun pont réseau sur cet hôte.",
"en": "No network bridge on this host.",
},
"offered": {
"fr": "proposés",
"en": "offered",
},
"Would run on": {
"fr": "Serait exécuté sur",
"en": "Would run on",
},
"SSH key ->": {
"fr": "clé SSH ->",
"en": "SSH key ->",
},
"Deploy this VM now? (Y/n): ": {
"fr": "Déployer cette VM maintenant ? (O/n) : ",
"en": "Deploy this VM now? (Y/n): ",
},
"SSH key unreadable:": {
"fr": "Clé SSH illisible :",
"en": "SSH key unreadable:",
},
"SSH key not pushed: password login only.": {
"fr": "Clé SSH non transmise : connexion par mot de passe seulement.",
"en": "SSH key not pushed: password login only.",
},
"Step failed, stopping here.": {
"fr": "Étape en échec, on s'arrête ici.",
"en": "Step failed, stopping here.",
},
"Waiting for the VM address…": {
"fr": "Attente de l'adresse de la VM…",
"en": "Waiting for the VM address…",
},
"No address yet. Try [6] later.": {
"fr": "Pas encore d'adresse. Réessayer avec [6] plus tard.",
"en": "No address yet. Try [6] later.",
},
"Install ERPLibre on it? (Y/n): ": {
"fr": "Y installer ERPLibre ? (O/n) : ",
"en": "Install ERPLibre on it? (Y/n): ",
},
"No address from the guest agent.": {
"fr": "Aucune adresse rendue par l'agent invité.",
"en": "No address from the guest agent.",
},
"Is qemu-guest-agent installed and the VM started?": {
"fr": "qemu-guest-agent est-il installé et la VM démarrée ?",
"en": "Is qemu-guest-agent installed and the VM started?",
},
"Ctrl+O to quit the serial console.": {
"fr": "Ctrl+O pour quitter la console série.",
"en": "Ctrl+O to quit the serial console.",
},
"Proxmox can only GROW a disk, never shrink it.": {
"fr": "Proxmox ne sait qu'AGRANDIR un disque, jamais le rétrécir.",
"en": "Proxmox can only GROW a disk, never shrink it.",
},
"This also destroys their disks and backups.": {
"fr": "Cela détruit aussi leurs disques et leurs sauvegardes.",
"en": "This also destroys their disks and backups.",
},
"Nothing orphaned.": {
"fr": "Rien d'orphelin.",
"en": "Nothing orphaned.",
},
"Orphan disks:": {
"fr": "Disques orphelins :",
"en": "Orphan disks:",
},
"Free them?": {
"fr": "Les libérer ?",
"en": "Free them?",
},
"no address, skipped": {
"fr": "pas d'adresse, ignorée",
"en": "no address, skipped",
},
"unreachable from here.": {
"fr": "injoignable d'ici.",
"en": "unreachable from here.",
},
"Use [13] to add a ProxyJump entry, then a tunnel.": {
"fr": "Utiliser [13] pour l'entrée ProxyJump, puis un tunnel.",
"en": "Use [13] to add a ProxyJump entry, then a tunnel.",
},
"Example: demo-vm, Debian 13, on a Proxmox host": {
"fr": "Exemple : demo-vm, Debian 13, sur un hôte Proxmox",
"en": "Example: demo-vm, Debian 13, on a Proxmox host",
},
"storages": {
"fr": "stockages",
"en": "storages",
},
"memory": {
"fr": "mémoire",
"en": "memory",
},
"No address for this VM.": {
"fr": "Aucune adresse pour cette VM.",
"en": "No address for this VM.",
},
"A static address is visible right after creation.": {
"fr": "Une adresse fixe est connue dès la création.",
"en": "A static address is visible right after creation.",
},
"address given at creation:": {
"fr": "adresse donnée à la création :",
"en": "address given at creation:",
},
"qm create needs one. Two ways:": {
"fr": "« qm create » en exige un. Deux voies :",
"en": "qm create needs one. Two ways:",
},
"create an internal": {"fr": "créer un pont interne", "en": "create an internal"},
"touches no physical NIC": {
"fr": "ne touche à aucune interface physique",
"en": "touches no physical NIC",
},
"do it myself (bridge-ports <nic>, needs console)": {
"fr": "le faire moi-même (bridge-ports <nic>, console requise)",
"en": "do it myself (bridge-ports <nic>, needs console)",
},
"To bridge the LAN, on the host:": {
"fr": "Pour ponter le LAN, sur l'hôte :",
"en": "To bridge the LAN, on the host:",
},
"This moves the host address: do it from a console.": {
"fr": "Cela déplace l'adresse de l'hôte : à faire depuis une console.",
"en": "This moves the host address: do it from a console.",
},
"uplink for NAT": {"fr": "sortie pour le NAT", "en": "uplink for NAT"},
"none": {"fr": "aucune", "en": "none"},
"The bridge did not come up.": {
"fr": "Le pont n'est pas monté.",
"en": "The bridge did not come up.",
},
"SSH does not know this host key yet.": {
"fr": "ssh ne connaît pas encore la clé de cet hôte.",
"en": "SSH does not know this host key yet.",
},
"Would record:": {
"fr": "Enregistrerait :",
"en": "Would record:",
},
"Record it?": {
"fr": "L'enregistrer ?",
"en": "Record it?",
},
"No host key obtained.": {
"fr": "Aucune clé d'hôte obtenue.",
"en": "No host key obtained.",
},
"key(s) recorded in ~/.ssh/known_hosts": {
"fr": "clé(s) enregistrée(s) dans ~/.ssh/known_hosts",
"en": "key(s) recorded in ~/.ssh/known_hosts",
},
"qm needs root: no root, and sudo asks for a password.": {
"fr": "qm exige root : ni root, ni sudo sans mot de passe.",
"en": "qm needs root: no root, and sudo asks for a password.",
},
"Connect as root@, or allow NOPASSWD sudo.": {
"fr": "Se connecter en root@, ou autoriser sudo sans mot de passe.",
"en": "Connect as root@, or allow NOPASSWD sudo.",
},
"Proxmox VE hypervisor (no Odoo)": {
"fr": "Hyperviseur Proxmox VE (sans Odoo)",
"en": "Proxmox VE hypervisor (no Odoo)",
},
"Proxmox will NOT be installed: plain Debian VM.": {
"fr": "Proxmox ne sera PAS installé : la VM restera une Debian nue.",
"en": "Proxmox will NOT be installed: plain Debian VM.",
},
"Later, in the VM:": {
"fr": "Plus tard, dans la VM :",
"en": "Later, in the VM:",
},
"sshfs mount failed.": {
"fr": "Le montage sshfs a échoué.",
"en": "sshfs mount failed.",
},
"Checking SSH access…": {
"fr": "Vérification de l'accès SSH…",
"en": "Checking SSH access…",
},
"SSH reaches this host: ~/.ssh/config is fine.": {
"fr": "SSH joint cet hôte : ~/.ssh/config est bon.",
"en": "SSH reaches this host: ~/.ssh/config is fine.",
},
"sshfs reads the « + » as host chaining.": {
"fr": "sshfs lit le « + » comme un chaînage d'hôtes.",
"en": "sshfs reads the « + » as host chaining.",
},
"Run this instead:": {
"fr": "À lancer plutôt :",
"en": "Run this instead:",
},
"ssh -G resolved nothing: check ~/.ssh/config.": {
"fr": "« ssh -G » n'a rien résolu : vérifier ~/.ssh/config.",
"en": "ssh -G resolved nothing: check ~/.ssh/config.",
},
"Is sshfs (and fuse) installed here?": {
"fr": "sshfs (et fuse) sont-ils installés ici ?",
"en": "Is sshfs (and fuse) installed here?",
},
"SSH fails too:": {
"fr": "SSH échoue aussi :",
"en": "SSH fails too:",
},
"Update ~/.ssh/config, or check the server is up.": {
"fr": "Mettre à jour ~/.ssh/config, ou vérifier que le serveur répond.",
"en": "Update ~/.ssh/config, or check the server is up.",
},
"Check the SSH host and that the server is up.": {
"fr": "Vérifier l'hôte SSH saisi et que le serveur répond.",
"en": "Check the SSH host and that the server is up.",
},
"unknown host name: check HostName": {
"fr": "nom d'hôte inconnu : vérifier HostName",
"en": "unknown host name: check HostName",
},
"no answer: is the server up and reachable?": {
"fr": "aucune réponse : le serveur est-il allumé et joignable ?",
"en": "no answer: is the server up and reachable?",
},
"no route: check the network or the ProxyJump": {
"fr": "pas de route : vérifier le réseau ou le ProxyJump",
"en": "no route: check the network or the ProxyJump",
},
"nothing listening on the SSH port": {
"fr": "rien n'écoute sur le port SSH",
"en": "nothing listening on the SSH port",
},
"authentication refused: check User and key": {
"fr": "authentification refusée : vérifier User et la clé",
"en": "authentication refused: check User and key",
},
"host key changed for this address": {
"fr": "la clé d'hôte a changé pour cette adresse",
"en": "host key changed for this address",
},
"no statistics yet": {
"fr": "pas encore de statistiques",
"en": "no statistics yet",
},
"10s average": {
"fr": "moyenne 10 s",
"en": "10s average",
},
"total written": {
"fr": "cumul écrit",
"en": "total written",
},
"Screens": {
"fr": "Écrans",
"en": "Screens",
@ -6114,6 +6561,150 @@ TRANSLATIONS = {
"fr": "📦 Modules absents du package par défaut",
"en": "📦 Modules missing from the default package",
},
"Dependencies between modules": {
"fr": "🔗 Dépendances entre les modules",
"en": "🔗 Dependencies between modules",
},
"modules installed": {
"fr": "modules installés",
"en": "modules installed",
},
"of them installed": {
"fr": "installé(s)",
"en": "of them installed",
},
"nothing installed depends on it": {
"fr": "rien d'installé n'en dépend",
"en": "nothing installed depends on it",
},
"Reading the modules and their dependencies…": {
"fr": "Lecture des modules et de leurs dépendances…",
"en": "Reading the modules and their dependencies…",
},
"Modules and their dependencies": {
"fr": "Modules et leurs dépendances",
"en": "Modules and their dependencies",
},
"unknown to this database": {
"fr": "inconnu de cette base",
"en": "unknown to this database",
},
"state": {
"fr": "état",
"en": "state",
},
"author": {
"fr": "auteur",
"en": "author",
},
"application": {
"fr": "application",
"en": "application",
},
"yes": {
"fr": "oui",
"en": "yes",
},
"depends on": {
"fr": "dépend de",
"en": "depends on",
},
"needed by": {
"fr": "requis par",
"en": "needed by",
},
"absent": {
"fr": "absent",
"en": "absent",
},
"press d to walk the dependencies": {
"fr": "appuyez sur d pour parcourir les dépendances",
"en": "press d to walk the dependencies",
},
"installed on missing dependencies": {
"fr": "installé sur des dépendances absentes",
"en": "installed on missing dependencies",
},
"broken dependency(ies)": {
"fr": "dépendance(s) cassée(s)",
"en": "broken dependency(ies)",
},
"What it needs, directly": {
"fr": "Ce dont il a besoin, directement",
"en": "What it needs, directly",
},
"What needs it, directly": {
"fr": "Ce qui en a besoin, directement",
"en": "What needs it, directly",
},
"Everything it pulls in": {
"fr": "Tout ce qu'il entraîne",
"en": "Everything it pulls in",
},
"Everything that falls with it": {
"fr": "Tout ce qui tombe avec lui",
"en": "Everything that falls with it",
},
"all modules": {
"fr": "tous les modules",
"en": "all modules",
},
"installed only": {
"fr": "installés seulement",
"en": "installed only",
},
"not installed": {
"fr": "non installés",
"en": "not installed",
},
"broken dependencies": {
"fr": "dépendances cassées",
"en": "broken dependencies",
},
"in progress": {
"fr": "en cours",
"en": "in progress",
},
"apps only": {
"fr": "applications seulement",
"en": "apps only",
},
"left in a transient state": {
"fr": "laissés dans un état de passage",
"en": "left in a transient state",
},
"Installed": {
"fr": "Installés",
"en": "Installed",
},
"In progress": {
"fr": "En cours",
"en": "In progress",
},
"Apps": {
"fr": "Applications",
"en": "Apps",
},
"Dependencies": {
"fr": "Dépendances",
"en": "Dependencies",
},
"Filter": {
"fr": "Filtre",
"en": "Filter",
},
"Search": {
"fr": "Chercher",
"en": "Search",
},
"module": {
"fr": "module",
"en": "module",
},
"module name…": {
"fr": "nom de module…",
"en": "module name…",
},
"Show every entry": {
"fr": "📜 Tout afficher",
"en": "📜 Show every entry",

View file

@ -2020,17 +2020,8 @@ class TodoUpgrade:
# uninstall_module_list_odoo130_to_odoo140.txt is read HERE,
# right before the 13 -> 14 data migration. Without this the
# per-bump files existed in name only and were never read.
lst_file, lst_detail = self.read_uninstall_module_list(
next_version - 1, database_name
)
if lst_detail:
print(
f"✨ {t('Modules to uninstall before Odoo')}"
f"{next_version} :"
)
self.print_uninstall_reason(lst_detail)
lst_module_to_uninstall = list(
dict.fromkeys(list(lst_module_to_uninstall) + lst_file)
lst_module_to_uninstall = self.uninstall_list_for(
next_version, database_name, lst_module_to_uninstall
)
if lst_module_to_uninstall:
@ -2040,9 +2031,6 @@ class TodoUpgrade:
next_version - 1,
)
lst_module_uninstall_module[index] = True
self.dct_progression["state_4_module_migrate_odoo_lst"] = (
lst_module_uninstall_module
)
self.write_config()
self.dct_progression["config_state_4_uninstall_module"] = (
@ -2087,9 +2075,6 @@ class TodoUpgrade:
next_version - 1,
)
lst_module_install_module[index] = True
self.dct_progression["state_4_module_migrate_odoo_lst"] = (
lst_module_install_module
)
self.write_config()
self.dct_progression["config_state_4_install_module"] = (
@ -2309,8 +2294,15 @@ class TodoUpgrade:
# Duplicate database
cmd_clone_database = f"./odoo_bin.sh db --clone --from_database {last_database_name} --database {database_name_upgrade}"
self.todo_upgrade_execute(cmd_clone_database)
# Le clone est NEUF : ce qu'on avait retiré du
# précédent est revenu avec lui. La liste du palier
# se rejoue donc ici, avec les modules choisis.
self.uninstall_from_database(
lst_module_to_delete,
self.uninstall_list_for(
next_version,
database_name,
lst_module_to_delete,
),
database_name_upgrade,
next_version,
)
@ -2621,7 +2613,15 @@ class TodoUpgrade:
cmd_upgrade = f".venv.{erplibre_version}/bin/python ./odoo{next_version}.0/OCA_OpenUpgrade/odoo-bin -c ./config.conf --update all --no-http --stop-after-init -d {database_name_upgrade}"
else:
cmd_upgrade = f"./run.sh --upgrade-path=./odoo{next_version}.0/OCA_OpenUpgrade/openupgrade_scripts/scripts --update all -c config.conf --stop-after-init --no-http --load=base,web,openupgrade_framework -d {database_name_upgrade}"
lst_upgrade_odoo[index] = cmd_upgrade
# NE PAS enregistrer la commande ici. `lst_upgrade_odoo`
# EST la liste de `dct_progression` — `.get` rend l'objet,
# pas une copie — donc la muter maintenant la fait persister
# au premier write_config() venu, y compris celui du chemin
# d'échec juste en dessous. L'étape passait alors pour faite
# et la reprise SAUTAIT OpenUpgrade : mesuré sur
# test_neutralize_upgrade_18, resté en base 17.0.1.3 avec sa
# commande 18 déjà consignée. On l'enregistre après la
# réussite, où le commentaire dit déjà qu'elle appartient.
# Record the website COW views before the data migration. The
# upgrade silently deletes and recreates copies (measured on
@ -2663,6 +2663,24 @@ class TodoUpgrade:
self.dct_progression["state_4_clone_odoo_lst"] = (
lst_clone_odoo
)
# Tout ce qui a été fait À ce clone meurt avec lui : la
# base reconstruite est une copie neuve de la version
# précédente et réclame la même préparation. Laisser ces
# drapeaux debout sautait le SQL de pré-migration sur le
# clone neuf, et OpenUpgrade retombait sur le problème
# même que ce SQL existe pour écarter.
lst_fix_migration_odoo[index] = []
self.dct_progression["state_4_fix_migration_odoo_lst"] = (
lst_fix_migration_odoo
)
lst_module_uninstall_module[index] = False
self.dct_progression["state_4_uninstall_module"] = (
lst_module_uninstall_module
)
lst_module_install_module[index] = False
self.dct_progression["state_4_install_module"] = (
lst_module_install_module
)
self.write_config()
print(
f"\n❌ -> {t('Database migration to Odoo')}"
@ -2688,6 +2706,7 @@ class TodoUpgrade:
f"after_{next_version}",
)
lst_upgrade_odoo[index] = cmd_upgrade
self.dct_progression["state_4_upgrade_odoo_lst"] = (
lst_upgrade_odoo
)
@ -3198,6 +3217,34 @@ class TodoUpgrade:
self.open_step_log(msg)
print(f"🔷 {prefix}{sep}{t(label)}" if sep else f"🔷 {t(msg)}")
def still_installed(self, database_name, lst_module):
"""Parmi ces modules, lesquels la base tient-elle ENCORE ?
Odoo ne signale rien quand il ne retire rien : « --uninstall » ne
cherche que l'état « installed » et laisse filer en silence un module
resté en « to remove » d'une tentative précédente. Le code de sortie
vaut donc 0 pour une désinstallation qui n'a pas eu lieu — c'est ainsi
que muk_web_theme a traversé quatre paliers en étant réputé retiré.
Rendre None, et non la liste vide, quand la base ne répond pas :
« je ne sais pas » et « rien ne reste » appellent des suites
différentes, et les confondre recrée le défaut qu'on corrige.
"""
if not lst_module:
return []
noms = ", ".join(f"'{nom}'" for nom in sorted(set(lst_module)))
status, _cmd, output = self.todo_upgrade_execute(
f'psql -X -w -d {database_name} -tAc "SELECT name FROM'
f" ir_module_module WHERE name IN ({noms})"
" AND state <> 'uninstalled' ORDER BY name;\"",
get_output=True,
wait_at_error=False,
quiet=True,
)
if status:
return None
return [line.strip() for line in (output or []) if line.strip()]
def installed_theme(self, database_name):
"""Thèmes installés, hors theme_default qui EST l'absence de thème."""
status, _cmd, output = self.todo_upgrade_execute(
@ -3747,6 +3794,32 @@ class TodoUpgrade:
return []
return lst_present
def uninstall_list_for(self, next_version, database_name, extra=()):
"""Ce qu'il faut retirer avant `next_version`, liste du palier comprise.
DEUX endroits bâtissent le clone intermédiaire : l'étape
« Uninstall module », et « Choose delete missing module » qui le
jette et le refait depuis la version précédente. Le second ne
rejouait que les modules choisis là, donc la liste du palier était
perdue avec le clone : web_responsive revenait, et la 18 refusait
de charger sur l'exclusion de muk_web_theme — après que la première
désinstallation eut pourtant réussi. Une seule fonction, pour que
les deux ne puissent plus diverger.
`dict.fromkeys` dédoublonne au passage : la liste choisie porte
parfois deux fois le même nom.
"""
lst_file, lst_detail = self.read_uninstall_module_list(
next_version - 1, database_name
)
if lst_detail:
print(
f"✨ {t('Modules to uninstall before Odoo')}"
f"{next_version} :"
)
self.print_uninstall_reason(lst_detail)
return list(dict.fromkeys(list(extra) + lst_file))
def uninstall_from_database(
self, lst_module_to_uninstall, database_name, actual_version
):
@ -3773,12 +3846,26 @@ class TodoUpgrade:
single_source_odoo=True,
)
lst_left = self.still_installed(database_name, lst_module_to_uninstall)
if lst_left is None:
# Base illisible : on ne sait pas. Le dire, plutôt que de trancher.
print(f"⚠️ {t('Could not verify the uninstall.')}")
lst_left = []
elif lst_left:
self.add_comment_progression(
"uninstall - still installed: " + ", ".join(lst_left)
)
print(
f"❌ {t('The uninstall did not take:')} {', '.join(lst_left)}"
)
print(f" {t('Odoo exits 0 even when it removes nothing.')}")
# Update list installed module — only what was REALLY uninstalled, so
# a module left in place stays counted as installed.
self.dct_module_per_version[actual_version] = sorted(
list(
set(self.dct_module_per_version[actual_version])
- set(lst_module_to_uninstall)
- (set(lst_module_to_uninstall) - set(lst_left))
)
)
self.dct_progression["dct_module_per_version"] = (

View file

@ -0,0 +1,823 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Qui dépend de qui, et ce qu'on peut retirer sans casser.
La question posée au palier 17 → 18 — « puis-je retirer web_responsive »
— a demandé une requête écrite à la main. L'outil y répond, et la réponse
qui compte n'est pas « deux modules en dépendent » mais « aucun de ceux
qui en dépendent n'est installé ».
"""
import os
import sys
import unittest
sys.path.append(
os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
)
from script.analyse import check_module_dependency as dep # noqa: E402
from script.analyse import check_module_dependency_tui as tui # noqa: E402
def base(modules, depend=None, apps=()):
"""Un rapport minimal. `modules` : {nom: état}."""
recensement = {
nom: (etat, f"Résumé {nom}", nom in apps, "TechnoLibre")
for nom, etat in modules.items()
}
depend = (
{nom: sorted(depend.get(nom, [])) for nom in recensement}
if depend
else {nom: [] for nom in recensement}
)
return {
"database": "essai",
"version": "18.0",
"modules": recensement,
"depends": depend,
"dependents": dep.reverse(depend),
"broken": dep.broken(recensement, depend),
}
class TestReverse(unittest.TestCase):
def test_it_inverts_the_arrows(self):
self.assertEqual(
dep.reverse({"zebre": ["alpha"], "alpha": []}),
{"alpha": ["zebre"], "zebre": []},
)
def test_a_module_cited_but_absent_still_gets_an_entry(self):
# C'est le cas qu'on cherche : quelqu'un dépend d'un module que la
# base ne connaît pas. L'omettre le rendrait invisible.
self.assertEqual(dep.reverse({"a": ["fantome"]})["fantome"], ["a"])
def test_a_duplicate_dependency_is_counted_once(self):
self.assertEqual(dep.reverse({"a": ["b", "b"]})["b"], ["a"])
class TestClosure(unittest.TestCase):
def test_it_walks_through(self):
graphe = {"zebre": ["milieu"], "milieu": ["alpha"], "alpha": []}
self.assertEqual(dep.closure("zebre", graphe), ["alpha", "milieu"])
def test_it_excludes_itself(self):
self.assertNotIn("a", dep.closure("a", {"a": ["b"], "b": ["a"]}))
def test_a_cycle_terminates(self):
# Un cycle ne devrait pas exister en base ; s'il y en a un, on veut
# un résultat, pas une boucle sans fin.
self.assertEqual(
dep.closure("a", {"a": ["b"], "b": ["c"], "c": ["a"]}),
["b", "c"],
)
def test_a_chain_longer_than_the_bound_is_cut_not_hung(self):
chaine = {
f"n{i}": [f"n{i + 1}"] for i in range(dep.PROFONDEUR_MAX + 8)
}
atteints = dep.closure("n0", chaine)
self.assertEqual(len(atteints), dep.PROFONDEUR_MAX)
def test_an_unknown_module_reaches_nothing(self):
self.assertEqual(dep.closure("absent", {"a": ["b"]}), [])
class TestBroken(unittest.TestCase):
def test_installed_on_an_uninstalled_dependency(self):
rapport = base(
{"chef": "installed", "socle": "uninstalled"},
{"chef": ["socle"]},
)
self.assertEqual(rapport["broken"], [("chef", "socle", "uninstalled")])
def test_installed_on_a_module_the_base_never_heard_of(self):
rapport = base({"chef": "installed"}, {"chef": ["fantome"]})
self.assertEqual(rapport["broken"], [("chef", "fantome", "unknown")])
def test_an_uninstalled_module_may_depend_on_anything(self):
# Il ne tourne pas : ses dépendances ne cassent rien.
rapport = base(
{"chef": "uninstalled", "socle": "uninstalled"},
{"chef": ["socle"]},
)
self.assertEqual(rapport["broken"], [])
def test_to_upgrade_counts_as_in_place(self):
# Le module est chargé, il sera seulement rejoué.
rapport = base(
{"chef": "installed", "socle": "to upgrade"},
{"chef": ["socle"]},
)
self.assertEqual(rapport["broken"], [])
def test_to_install_is_not_in_place_yet(self):
rapport = base(
{"chef": "installed", "socle": "to install"},
{"chef": ["socle"]},
)
self.assertEqual(rapport["broken"], [("chef", "socle", "to install")])
class TestCounts(unittest.TestCase):
def rapport(self):
return base(
{
"web_responsive": "installed",
"erplibre_base": "uninstalled",
"web_responsive_company": "uninstalled",
"mail": "installed",
},
{
"web_responsive": ["mail"],
"erplibre_base": ["web_responsive"],
"web_responsive_company": ["web_responsive"],
},
)
def test_the_third_number_counts_only_what_is_in_place(self):
# Le cas réel : deux dépendants déclarés, aucun installé. Ne
# montrer que « 2 » ferait renoncer à un retrait sans danger.
self.assertEqual(
dep.counts(self.rapport(), "web_responsive"), (1, 2, 0)
)
def test_an_installed_dependent_is_counted(self):
rapport = self.rapport()
rapport["modules"]["erplibre_base"] = ("installed", "", False, "")
self.assertEqual(dep.counts(rapport, "web_responsive")[2], 1)
def test_a_module_nobody_needs(self):
self.assertEqual(
dep.counts(self.rapport(), "erplibre_base"), (1, 0, 0)
)
class TestRows(unittest.TestCase):
def rapport(self):
return base(
{
"zebre": "installed",
"alpha": "uninstalled",
"milieu": "installed",
},
{"zebre": ["alpha"]},
)
def test_they_come_out_sorted_by_name(self):
# Les noms sont posés dans un ordre qui CONTREDIT l'alphabet :
# sans tri, la liste sortirait zebre, alpha, milieu.
self.assertEqual(
[row["name"] for row in dep.rows(self.rapport())],
["alpha", "milieu", "zebre"],
)
def test_the_installed_filter_keeps_only_those(self):
self.assertEqual(
[r["name"] for r in dep.rows(self.rapport(), "installed")],
["milieu", "zebre"],
)
def test_the_absent_filter_is_the_complement(self):
self.assertEqual(
[r["name"] for r in dep.rows(self.rapport(), "absent")], ["alpha"]
)
def test_the_broken_filter_keeps_the_guilty_only(self):
# zebre est installé et dépend d'alpha qui ne l'est pas.
self.assertEqual(
[r["name"] for r in dep.rows(self.rapport(), "broken")], ["zebre"]
)
def test_the_detail_column_shows_both_counts(self):
row = [r for r in dep.rows(self.rapport()) if r["name"] == "alpha"][0]
self.assertEqual(row["detail"], "0↓ 1/1↑")
def test_every_filter_is_understood(self):
# Un filtre inconnu ne doit pas vider la liste en silence.
for filtre in dep.FILTRES:
dep.rows(self.rapport(), filtre)
class TestTheTransientStates(unittest.TestCase):
"""Un module qui reste en chemin est une opération inachevée.
Mesuré sur test_neutralize_upgrade_13, en pleine migration :
22 modules figés en « to remove ». Aucun autre filtre ne les montrait
pour ce qu'ils sont.
"""
def test_the_three_transient_states_are_pending(self):
for etat in ("to install", "to upgrade", "to remove"):
self.assertTrue(dep.pending(etat), etat)
def test_a_settled_state_is_not(self):
for etat in ("installed", "uninstalled", "uninstallable"):
self.assertFalse(dep.pending(etat), etat)
def rapport(self):
return base(
{
"zebre": "installed",
"alpha": "to remove",
"milieu": "to install",
"bravo": "uninstalled",
"charlie": "to upgrade",
}
)
def test_the_pending_filter_shows_exactly_those(self):
self.assertEqual(
[r["name"] for r in dep.rows(self.rapport(), "pending")],
["alpha", "charlie", "milieu"],
)
def test_a_module_on_its_way_out_is_not_listed_as_absent(self):
# « to remove » EST encore installé ; le ranger avec ce qu'on
# pourrait installer envoyait chercher au mauvais endroit.
self.assertEqual(
[r["name"] for r in dep.rows(self.rapport(), "absent")], ["bravo"]
)
def test_to_upgrade_is_both_installed_and_pending(self):
# Il est chargé ET il attend : les deux sont vrais, et les taire
# ferait disparaître un module d'une des deux vues.
noms = [r["name"] for r in dep.rows(self.rapport(), "installed")]
self.assertIn("charlie", noms)
self.assertIn(
"charlie", [r["name"] for r in dep.rows(self.rapport(), "pending")]
)
def test_the_header_announces_them(self):
self.assertIn("3 en cours", dep.head_text(self.rapport()))
def test_the_header_stays_quiet_when_there_are_none(self):
self.assertNotIn("en cours", dep.head_text(base({"a": "installed"})))
def test_the_text_report_lists_them_with_their_state(self):
texte = "\n".join(dep.render_text(self.rapport()))
self.assertIn("alpha (to remove)", texte)
self.assertIn("milieu (to install)", texte)
def bloc_en_cours(self, lignes):
"""Les lignes qui suivent le titre « état de passage »."""
debut = next(i for i, x in enumerate(lignes) if "état de passage" in x)
suite = []
for ligne in lignes[debut + 1 :]:
if not ligne.startswith(" "):
break
suite.append(ligne)
return suite
def test_the_text_report_bounds_that_list_too(self):
# Trois en cours, borne à deux : deux nommés, et le troisième
# ANNONCÉ. Compter les lignes, pas chercher un chiffre au hasard.
suite = self.bloc_en_cours(dep.render_text(self.rapport(), limit=2))
nommes = [x for x in suite if x.strip().endswith(")")]
self.assertEqual(len(nommes), 2)
self.assertEqual(suite[-1].strip(), "… 1 de plus")
def test_without_a_limit_they_are_all_named(self):
suite = self.bloc_en_cours(dep.render_text(self.rapport()))
self.assertEqual(len(suite), 3)
self.assertFalse(any("de plus" in x for x in suite))
class TestTheApplicationFilter(unittest.TestCase):
def rapport(self):
return base(
{
"zebre": "installed",
"alpha": "installed",
"milieu": "uninstalled",
},
apps=("zebre", "milieu"),
)
def test_it_keeps_the_apps_whatever_their_state(self):
# Un module applicatif non installé reste applicatif : filtrer
# sur « App » et sur « installé » sont deux questions.
self.assertEqual(
[r["name"] for r in dep.rows(self.rapport(), "application")],
["milieu", "zebre"],
)
def test_a_plain_module_is_left_out(self):
self.assertNotIn(
"alpha",
[r["name"] for r in dep.rows(self.rapport(), "application")],
)
def test_the_pane_says_whether_it_is_one(self):
self.assertIn("oui", dep.pane_text(self.rapport(), "zebre"))
self.assertIn("non", dep.pane_text(self.rapport(), "alpha"))
class TestToggleFilter(unittest.TestCase):
def test_the_same_key_goes_and_comes_back(self):
# Sans le retour, il faudrait se souvenir de quelle AUTRE touche
# ramène à la liste entière.
self.assertEqual(tui.toggle_filter("all", "pending"), "pending")
self.assertEqual(tui.toggle_filter("pending", "pending"), "all")
def test_another_key_replaces_rather_than_returns(self):
self.assertEqual(tui.toggle_filter("installed", "pending"), "pending")
def test_every_direct_filter_is_a_known_one(self):
for cible in ("installed", "pending", "application"):
self.assertIn(cible, dep.FILTRES)
class TestListing(unittest.TestCase):
def rapport(self):
return base(
{"haut": "installed", "milieu": "installed", "bas": "installed"},
{"haut": ["milieu"], "milieu": ["bas"]},
)
def test_depends_is_direct_only(self):
self.assertEqual(
dep.listing(self.rapport(), "haut", "depends"), ["milieu"]
)
def test_pulls_goes_all_the_way_down(self):
self.assertEqual(
dep.listing(self.rapport(), "haut", "pulls"), ["bas", "milieu"]
)
def test_dependents_is_direct_only(self):
self.assertEqual(
dep.listing(self.rapport(), "bas", "dependents"), ["milieu"]
)
def test_falls_goes_all_the_way_up(self):
self.assertEqual(
dep.listing(self.rapport(), "bas", "falls"), ["haut", "milieu"]
)
def test_every_mode_has_a_title(self):
for mode in dep.DETAILS:
self.assertIn(mode, dep.TITRE_DETAIL)
class TestPaneText(unittest.TestCase):
def rapport(self):
return base(
{"web_responsive": "installed", "compagnon": "uninstalled"},
{"compagnon": ["web_responsive"]},
)
def test_it_answers_the_removal_question_in_words(self):
# Deux chiffres à comparer soi-même, ce n'est pas une réponse.
texte = dep.pane_text(self.rapport(), "web_responsive")
self.assertIn("rien d'installé n'en dépend", texte)
def test_it_stays_silent_when_something_does_depend_on_it(self):
rapport = self.rapport()
rapport["modules"]["compagnon"] = ("installed", "", False, "")
self.assertNotIn(
"rien d'installé n'en dépend",
dep.pane_text(rapport, "web_responsive"),
)
def test_an_absent_dependent_is_marked_as_such(self):
texte = dep.pane_text(
self.rapport(), "web_responsive", mode="dependents"
)
self.assertIn("compagnon", texte)
self.assertIn("absent", texte)
def test_an_unknown_module_says_so(self):
self.assertIn("inconnu", dep.pane_text(self.rapport(), "jamais_vu"))
def test_nothing_selected_is_not_a_crash(self):
self.assertTrue(dep.pane_text(self.rapport(), None))
def test_an_empty_listing_says_nothing_rather_than_showing_blank(self):
texte = dep.pane_text(self.rapport(), "web_responsive", mode="depends")
self.assertIn("rien", texte.lower())
def test_the_limit_announces_what_it_cut(self):
rapport = base(
dict(
{"chef": "installed"},
**{f"d{i}": "installed" for i in range(9)},
),
{"chef": [f"d{i}" for i in range(9)]},
)
texte = dep.pane_text(rapport, "chef", mode="depends", limit=4)
self.assertIn("5", texte)
class TestRenderText(unittest.TestCase):
def rapport(self):
return base({f"m{i:02d}": "installed" for i in range(30)})
def test_without_a_cap_everything_is_there(self):
lignes = dep.render_text(self.rapport())
self.assertEqual(sum(1 for x in lignes if x.startswith("✅")), 30)
def test_the_cap_bounds_the_module_list(self):
# Sur une vraie base c'est 3035 modules et 6046 lignes : sans
# borne, le repli du menu est une seconde panne.
lignes = dep.render_text(self.rapport(), cap=5)
self.assertEqual(sum(1 for x in lignes if x.startswith("✅")), 5)
def test_the_cap_says_how_many_it_hid(self):
self.assertTrue(
any("25" in x for x in dep.render_text(self.rapport(), cap=5))
)
def test_an_unreadable_database_says_so(self):
lignes = dep.render_text({"database": "x", "unavailable": True})
self.assertEqual(len(lignes), 1)
self.assertIn("x", lignes[0])
class TestSurvey(unittest.TestCase):
def setUp(self):
from script.analyse import check_module_package as package
self.package = package
self.vrai = (package.census, package.dependencies, package.db_version)
def tearDown(self):
(
self.package.census,
self.package.dependencies,
self.package.db_version,
) = self.vrai
def poser(self, recensement, depend):
self.package.census = lambda d: recensement
self.package.dependencies = lambda d: depend
self.package.db_version = lambda d: "18.0"
def test_a_module_without_dependency_still_exists_in_the_graph(self):
# Sans cela, `closure` et l'écran le traitent comme inconnu.
self.poser({"seul": ("installed", "", False, "")}, {})
self.assertEqual(dep.survey("x")["depends"], {"seul": []})
def test_an_unreadable_base_is_not_an_empty_one(self):
self.poser(None, {})
self.assertTrue(dep.survey("x").get("unavailable"))
def test_an_empty_base_is_readable(self):
self.poser({}, {})
self.assertFalse(dep.survey("x").get("unavailable"))
def test_it_reads_the_base_once_for_both_views(self):
appels = []
self.poser({"a": ("installed", "", False, "")}, {})
vrai = self.package.census
self.package.census = lambda d: (appels.append(d), vrai(d))[1]
dep.survey("x")
self.assertEqual(appels, ["x"])
class TestTuiPureParts(unittest.TestCase):
def test_the_mode_cycles_through_all_of_them_and_returns(self):
vus, mode = [], None
for _ in range(len(dep.DETAILS) + 1):
mode = tui.next_mode(mode)
vus.append(mode)
self.assertEqual(vus, list(dep.DETAILS) + [None])
def test_an_unknown_mode_restarts_the_cycle(self):
self.assertEqual(tui.next_mode("n'importe quoi"), dep.DETAILS[0])
def test_the_filter_cycles(self):
self.assertEqual(tui.next_filter(dep.FILTRES[-1]), dep.FILTRES[0])
def test_the_cursor_out_of_range_is_not_a_crash(self):
# La table garde son curseur quand la liste raccourcit.
self.assertIsNone(tui.current_name([{"name": "a"}], 7))
self.assertIsNone(tui.current_name([], 0))
self.assertIsNone(tui.current_name([{"name": "a"}], None))
def test_the_cursor_follows_the_module_across_a_filter(self):
lst = [{"name": "alpha"}, {"name": "zebre"}]
self.assertEqual(tui.cursor_for(lst, "zebre"), 1)
def test_a_module_filtered_away_loses_the_cursor(self):
self.assertIsNone(tui.cursor_for([{"name": "alpha"}], "zebre"))
def test_the_search_ignores_the_case(self):
lst = [{"name": "Web_Responsive"}, {"name": "mail"}]
self.assertEqual(len(tui.matching(lst, "web")), 1)
def test_an_empty_search_keeps_everything(self):
lst = [{"name": "a"}, {"name": "b"}]
self.assertIs(tui.matching(lst, ""), lst)
def test_the_subtitle_names_the_three_states(self):
texte = tui.subtitle("depends", "installed", "web")
self.assertIn("directement", texte)
self.assertIn("installés", texte)
self.assertIn("web", texte)
class FausseTable:
"""Le contrat de DataTable, réduit à ce que `populate` en utilise."""
def __init__(self):
self.lignes = []
self.curseur = None
def clear(self):
self.lignes = []
def add_row(self, *cellules):
self.lignes.append(cellules)
def move_cursor(self, row=None):
self.curseur = row
class TestPopulate(unittest.TestCase):
def test_it_fills_and_keeps_the_cursor_on_the_module(self):
table = FausseTable()
lst = [
{"name": "alpha", "label": "✅ alpha", "detail": "0↓ 0/0↑"},
{"name": "zebre", "label": "✅ zebre", "detail": "1↓ 0/0↑"},
]
self.assertEqual(tui.populate(table, lst, "zebre"), 1)
self.assertEqual(len(table.lignes), 2)
self.assertEqual(table.curseur, 1)
def test_it_clears_before_refilling(self):
table = FausseTable()
table.add_row("vieux")
tui.populate(table, [{"name": "a", "label": "a", "detail": ""}])
self.assertEqual(len(table.lignes), 1)
def test_a_vanished_module_leaves_the_cursor_alone(self):
table = FausseTable()
tui.populate(
table, [{"name": "a", "label": "a", "detail": ""}], "parti"
)
self.assertIsNone(table.curseur)
class FauxChamp:
"""Le contrat d'Input, réduit à ce que `hide_find` en utilise."""
def __init__(self):
self.value = "web"
self.classes = {"visible"}
def remove_class(self, nom):
self.classes.discard(nom)
def has_class(self, nom):
return nom in self.classes
class FausseTableFocus(FausseTable):
def __init__(self):
super().__init__()
self.focalisee = False
def focus(self):
self.focalisee = True
class TestHideFind(unittest.TestCase):
def test_it_hands_the_keyboard_back_to_the_list(self):
# Le geste qui compte : sans lui, Textual laisse le clavier au
# champ de recherche et plus aucune touche n'agit.
champ, table = FauxChamp(), FausseTableFocus()
tui.hide_find(champ, table)
self.assertTrue(table.focalisee)
def test_it_hides_the_field(self):
champ, table = FauxChamp(), FausseTableFocus()
tui.hide_find(champ, table)
self.assertFalse(champ.has_class("visible"))
def test_it_leaves_the_text_alone(self):
# Effacer ici ferait deux chemins pour le même état : l'appelant
# vide le champ, et c'est CE geste qui redéclenche le filtrage.
champ, table = FauxChamp(), FausseTableFocus()
tui.hide_find(champ, table)
self.assertEqual(champ.value, "web")
class TestTheScreenRefusesWhenItCannot(unittest.TestCase):
def test_an_unreadable_report_opens_nothing(self):
self.assertFalse(tui.run_tui({"unavailable": True}))
def test_an_empty_base_opens_nothing(self):
self.assertFalse(tui.run_tui({"modules": {}}))
def test_nothing_at_all_opens_nothing(self):
self.assertFalse(tui.run_tui(None))
def textual_present():
try:
import textual # noqa: F401
except Exception:
return False
return True
@unittest.skipUnless(textual_present(), "textual absent")
class TestTheScreenActuallyDrives(unittest.TestCase):
"""Conduire l'écran, touche par touche.
Les fonctions pures étaient toutes vertes et l'écran, lui, ne
répondait pas : le champ de recherche caché prenait le focus au
démarrage — « display: none » ne retire pas un widget du parcours du
clavier — donc « d » tapait dans une boîte invisible. Seul un test
qui PRESSE les touches pouvait le voir.
"""
def rapport(self):
return base(
{
"web_responsive": "installed",
"mail": "installed",
"compagnon": "uninstalled",
"casse": "installed",
"zebre": "installed",
},
{
"web_responsive": ["mail"],
"compagnon": ["web_responsive"],
"casse": ["fantome"],
"zebre": ["web_responsive"],
},
)
def rapport_etats(self):
"""Un jeu qui porte les trois catégories des touches i / u / t."""
return base(
{
"sale": "installed",
"mail": "installed",
"muk_theme": "to remove",
"stock": "to install",
"vieux": "uninstalled",
},
apps=("sale", "stock"),
)
def conduire(self, scenario, rapport=None):
import asyncio
app = tui.build_app(rapport or self.rapport())
async def piloter():
async with app.run_test() as pilote:
await scenario(app, pilote)
asyncio.run(piloter())
return app
def test_the_keyboard_starts_on_the_list_not_in_the_search(self):
vus = []
async def scenario(app, pilote):
vus.append(type(app.focused).__name__)
self.conduire(scenario)
self.assertEqual(vus, ["DataTable"])
def test_pressing_d_walks_every_relation_and_comes_back(self):
vus = []
async def scenario(app, pilote):
for _ in range(len(dep.DETAILS) + 1):
await pilote.press("d")
vus.append(app.mode)
self.conduire(scenario)
self.assertEqual(vus, list(dep.DETAILS) + [None])
def test_pressing_f_narrows_the_list(self):
vus = {}
async def scenario(app, pilote):
table = app.query_one("#left")
for _ in range(len(dep.FILTRES)):
await pilote.press("f")
vus[app.filtre] = table.row_count
self.conduire(scenario)
self.assertEqual(vus["all"], 5)
self.assertEqual(vus["installed"], 4)
self.assertEqual(vus["absent"], 1)
self.assertEqual(vus["broken"], 1)
def test_the_search_filters_then_gives_the_keyboard_back(self):
vus = {}
async def scenario(app, pilote):
await pilote.press("slash")
vus["focus_ouvert"] = type(app.focused).__name__
for lettre in "web":
await pilote.press(lettre)
await pilote.pause()
vus["lignes"] = app.query_one("#left").row_count
await pilote.press("enter")
vus["focus_rendu"] = type(app.focused).__name__
await pilote.press("d")
vus["mode"] = app.mode
self.conduire(scenario)
self.assertEqual(vus["focus_ouvert"], "Input")
self.assertEqual(vus["lignes"], 1)
self.assertEqual(vus["focus_rendu"], "DataTable")
# « d » doit remarcher une fois la recherche finie : sans cela on
# ne peut plus rien faire après avoir cherché.
self.assertEqual(vus["mode"], dep.DETAILS[0])
def test_i_u_and_t_each_jump_straight_to_their_filter(self):
vus = {}
async def scenario(app, pilote):
for touche in ("i", "u", "t"):
await pilote.press(touche)
vus[touche] = (
app.filtre,
sorted(row["name"] for row in app.lst_row),
)
await pilote.press(touche)
vus[touche + "_retour"] = app.filtre
self.conduire(scenario, self.rapport_etats())
self.assertEqual(vus["i"], ("installed", ["mail", "sale"]))
self.assertEqual(vus["u"], ("pending", ["muk_theme", "stock"]))
self.assertEqual(vus["t"], ("application", ["sale", "stock"]))
for touche in ("i", "u", "t"):
self.assertEqual(vus[touche + "_retour"], "all")
def test_a_second_key_replaces_the_filter_rather_than_stacking(self):
vus = {}
async def scenario(app, pilote):
await pilote.press("i")
await pilote.press("u")
vus["filtre"] = app.filtre
vus["lignes"] = app.query_one("#left").row_count
self.conduire(scenario, self.rapport_etats())
self.assertEqual(vus["filtre"], "pending")
self.assertEqual(vus["lignes"], 2)
def test_those_letters_are_typed_not_obeyed_while_searching(self):
# « i », « u » et « t » sont des lettres avant d'être des
# raccourcis : dans le champ de recherche, elles s'écrivent.
vus = {}
async def scenario(app, pilote):
await pilote.press("slash")
for lettre in "it":
await pilote.press(lettre)
await pilote.pause()
vus["motif"] = app.motif
vus["filtre"] = app.filtre
self.conduire(scenario, self.rapport_etats())
self.assertEqual(vus["motif"], "it")
self.assertEqual(vus["filtre"], "all")
def test_escape_closes_the_search_before_it_closes_the_screen(self):
vus = {}
async def scenario(app, pilote):
await pilote.press("slash")
for lettre in "web":
await pilote.press(lettre)
await pilote.pause()
await pilote.press("escape")
await pilote.pause()
vus["motif"] = app.motif
vus["lignes"] = app.query_one("#left").row_count
vus["vivant"] = app.is_running
# La tabulation ne doit pas non plus retomber dans le champ
# refermé : « display: none » ne l'en retire pas, seul
# can_focus le fait.
await pilote.press("tab")
vus["apres_tab"] = type(app.focused).__name__
await pilote.press("escape")
await pilote.pause()
vus["apres_second_echap"] = app.is_running
self.conduire(scenario)
self.assertNotEqual(vus["apres_tab"], "Input")
# …et le second échap, lui, ferme bien l'écran.
self.assertFalse(vus["apres_second_echap"])
# Renoncer à une recherche ne doit pas fermer l'écran : on a
# parfois filtré trois mille modules pour arriver là.
self.assertTrue(vus["vivant"])
self.assertEqual(vus["motif"], "")
self.assertEqual(vus["lignes"], 5)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,225 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Le correctif de palier 12 → 13, exécuté contre un vrai PostgreSQL.
Une assertion sur le TEXTE d'un fichier SQL ne voit pas ce qu'il fait.
Celui-ci décode des séquences UTF-8 en pourcentage — `%C3%A9` tient sur
DEUX octets — et seule l'exécution prouve qu'on rassemble les octets
avant de convertir, plutôt que de produire deux caractères illisibles.
Ce qu'il répare : OpenUpgrade recolle les `href` d'ancres en sélecteur
CSS, où un `%` est interdit. La migration mourait là.
"""
import os
import shutil
import subprocess
import sys
import unittest
sys.path.append(
os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
)
RACINE = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
SQL = os.path.join(
RACINE,
"script",
"odoo",
"migration",
"fix_migration_odoo120_to_odoo130.sql",
)
class Base(unittest.TestCase):
BASE = "tmp_fix_120_130_test"
@classmethod
def setUpClass(cls):
if not shutil.which("psql") or not shutil.which("createdb"):
raise unittest.SkipTest("PostgreSQL absent")
subprocess.run(
["dropdb", "--if-exists", cls.BASE], capture_output=True
)
if subprocess.run(
["createdb", cls.BASE], capture_output=True
).returncode:
raise unittest.SkipTest("createdb impossible")
@classmethod
def tearDownClass(cls):
if shutil.which("dropdb"):
subprocess.run(
["dropdb", "--if-exists", cls.BASE], capture_output=True
)
def sql(self, requete):
done = subprocess.run(
["psql", "-X", "-w", "-q", "-d", self.BASE, "-tAc", requete],
capture_output=True,
text=True,
)
self.assertEqual(done.returncode, 0, done.stderr)
return done.stdout.strip()
def prepare(self, lignes):
"""lignes = [(arch, a_une_page)]"""
self.sql("DROP TABLE IF EXISTS website_page, ir_ui_view")
self.sql(
"CREATE TABLE ir_ui_view (id serial PRIMARY KEY, arch_db text)"
)
self.sql(
"CREATE TABLE website_page (id serial PRIMARY KEY, view_id integer)"
)
for arch, avec_page in lignes:
vid = self.sql(
"INSERT INTO ir_ui_view (arch_db) VALUES ("
+ "'"
+ arch.replace("'", "''")
+ "') RETURNING id"
)
if avec_page:
self.sql(f"INSERT INTO website_page (view_id) VALUES ({vid})")
def applique(self):
done = subprocess.run(
[
"psql",
"-X",
"-w",
"-v",
"ON_ERROR_STOP=1",
"-d",
self.BASE,
"-f",
SQL,
],
capture_output=True,
text=True,
)
self.assertEqual(done.returncode, 0, done.stdout + done.stderr)
return done.stdout + done.stderr
def arch(self, vid=1):
return self.sql(f"SELECT arch_db FROM ir_ui_view WHERE id={vid}")
class TestDecodingTheAnchors(Base):
def test_an_accented_anchor_is_decoded(self):
self.prepare([('<a href="#principes-mn%C3%A9moniques">x</a>', True)])
self.applique()
self.assertEqual(self.arch(), '<a href="#principes-mnémoniques">x</a>')
def test_two_byte_sequences_are_assembled_before_converting(self):
# `%C3%A9` est UN caractère sur DEUX octets. Les décoder
# séparément rendrait deux caractères illisibles.
self.prepare([('<a href="#caf%C3%A9-th%C3%A9">x</a>', True)])
self.applique()
self.assertEqual(self.arch(), '<a href="#café-thé">x</a>')
def test_several_anchors_in_one_view(self):
self.prepare(
[('<a href="#a%C3%A9">1</a><a href="#b%C3%A8">2</a>', True)]
)
self.applique()
self.assertIn("#aé", self.arch())
self.assertIn("#bè", self.arch())
def test_it_is_replayable(self):
self.prepare([('<a href="#r%C3%A9seau">x</a>', True)])
self.applique()
premier = self.arch()
sortie = self.applique()
self.assertEqual(self.arch(), premier)
self.assertNotIn("decodee", sortie)
class TestWhatItMustNotTouch(Base):
def test_a_percent_outside_an_anchor_is_left_alone(self):
# Un `%` dans une vraie URL est légitime : le décoder changerait
# une adresse qui fonctionne.
arch = '<a href="/page?q=a%20b">x</a>'
self.prepare([(arch, True)])
self.applique()
self.assertEqual(self.arch(), arch)
def test_a_view_without_a_page_is_left_alone(self):
# OpenUpgrade ne parcourt que les vues qui portent une page :
# toucher plus large modifierait du contenu sans raison.
arch = '<a href="#r%C3%A9seau">x</a>'
self.prepare([(arch, False)])
self.applique()
self.assertEqual(self.arch(), arch)
def test_a_lone_percent_is_not_an_encoding(self):
# « 100% » n'est pas une séquence : seuls `%XX` hexadécimaux le
# sont, et confondre les deux abîmerait du texte.
arch = '<a href="#remise-100%-ici">x</a>'
self.prepare([(arch, True)])
sortie = self.applique()
self.assertEqual(self.arch(), arch)
# Et il ne doit pas ANNONCER un décodage qui n'a rien changé :
# un rapport qui se félicite à vide fait douter du reste.
self.assertNotIn("decodee", sortie)
def test_an_anchor_without_any_percent_is_untouched(self):
arch = '<a href="#simple">x</a>'
self.prepare([(arch, True)])
self.applique()
self.assertEqual(self.arch(), arch)
class TestItLeavesNothingBehind(Base):
def test_the_decoder_does_not_survive_the_session(self):
# La fonction vit dans `pg_temp` : elle disparaît avec psql et ne
# laisse rien dans la base du client.
self.prepare([('<a href="#a%C3%A9">x</a>', True)])
self.applique()
reste = self.sql(
"SELECT count(*) FROM pg_proc WHERE proname = 'el_url_decode'"
)
self.assertEqual(reste, "0")
def test_a_database_without_the_tables_does_not_crash(self):
self.sql("DROP TABLE IF EXISTS website_page, ir_ui_view")
done = subprocess.run(
[
"psql",
"-X",
"-w",
"-v",
"ON_ERROR_STOP=1",
"-d",
self.BASE,
"-f",
SQL,
],
capture_output=True,
text=True,
)
self.assertEqual(done.returncode, 0, done.stdout + done.stderr)
class TestTheFileIsWiredIn(unittest.TestCase):
def test_the_name_matches_what_the_driver_looks_for(self):
# Le pilote compose « fix_migration_odoo{(v-1)*10}_to_odoo{v*10} ».
self.assertTrue(os.path.isfile(SQL), SQL)
self.assertTrue(SQL.endswith("fix_migration_odoo120_to_odoo130.sql"))
def test_it_runs_before_openupgrade(self):
# Il agit sur une base encore en 12 : après OpenUpgrade, il
# serait trop tard, la migration aurait déjà échoué.
with open(
os.path.join(RACINE, "script", "todo", "todo_upgrade.py"),
encoding="utf-8",
) as handle:
src = handle.read()
self.assertLess(
src.index("- Fix migrate code"), src.index("- Migrate database")
)
if __name__ == "__main__":
unittest.main()

View file

@ -293,11 +293,27 @@ class TestTheRealBundle(unittest.TestCase):
" ./mobile/install_mobile_dev.sh"
)
cls.repos = MOBILE / "dist" / "repos"
if not (cls.repos / "manifest.json").is_file():
manifeste = cls.repos / "manifest.json"
if not manifeste.is_file():
raise unittest.SkipTest(
"dépôt mobile présent mais pas compilé :"
" ./mobile/compile_and_run.sh (ou npm run build)"
)
# Manifeste PRÉSENT mais VIDE : l'application a été compilée sans le
# transfert des dépôts. C'est un choix légitime, pas une régression —
# et le distinguer importe, car ces tests échouaient alors sur
# « aucun dépôt à vérifier », ce qui se lit comme une panne du
# transfert. Vu le 23 août 2026 sur un build de 07:55 : manifeste à
# zéro entrée, aucun pack.
try:
entrees = json.loads(manifeste.read_text())
except (OSError, ValueError) as exc:
raise unittest.SkipTest(f"manifeste illisible : {exc}")
if not entrees:
raise unittest.SkipTest(
"compilé SANS les dépôts (manifeste vide) :"
" relancer ./mobile/compile_and_run.sh pour les inclure"
)
def test_the_transfer_is_coherent(self):
rep = cbt.check(MOBILE, REPO)

View file

@ -0,0 +1,201 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Ce que la migration charge doit se charger sous le plus vieux Python.
Le pilote lance ses outils avec le venv de la version Odoo COURANTE : au
premier palier, c'est celui d'Odoo 12, en Python 3.7. Une annotation
« dict | None » (3.10) ou « tuple[str, str] » (3.9) y est ÉVALUÉE au
chargement du module et lève un TypeError avant que l'outil ait rien
fait — la migration meurt sur la restauration du zip.
`from __future__ import annotations` (disponible depuis 3.7) diffère
l'évaluation : le module se charge partout, et le typage reste lisible.
"""
import ast
import io
import json
import os
import re
import unittest
RACINE = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
PILOTE = os.path.join(RACINE, "script", "todo", "todo_upgrade.py")
VERSIONS = os.path.join(RACINE, "conf", "supported_version_erplibre.json")
DIFFERE = "from __future__ import annotations"
GENERIQUES = ("list", "dict", "tuple", "set", "type", "frozenset")
def lire(chemin):
with io.open(chemin, encoding="utf-8") as handle:
return handle.read()
def annotations_evaluees(arbre):
"""Les annotations que Python évalue au chargement du module.
Celles d'une signature le sont toujours. Une `x: T` de corps de
fonction ne l'est pas — la signaler produirait un faux échec.
"""
for noeud in ast.walk(arbre):
if isinstance(noeud, (ast.FunctionDef, ast.AsyncFunctionDef)):
a = noeud.args
for arg in (
list(getattr(a, "posonlyargs", []))
+ list(a.args)
+ list(a.kwonlyargs)
+ [a.vararg, a.kwarg]
):
if arg is not None and arg.annotation is not None:
yield arg.annotation
if noeud.returns is not None:
yield noeud.returns
elif isinstance(noeud, (ast.Module, ast.ClassDef)):
for petit in noeud.body:
if isinstance(petit, ast.AnnAssign):
yield petit.annotation
def trop_recent(source):
"""Les annotations qu'un Python 3.7 ne saurait pas évaluer."""
if DIFFERE in source:
return []
trouves = []
for annotation in annotations_evaluees(ast.parse(source)):
for noeud in ast.walk(annotation):
if isinstance(noeud, ast.BinOp) and isinstance(
noeud.op, ast.BitOr
):
trouves.append(f"« a | b » ligne {noeud.lineno}")
elif (
isinstance(noeud, ast.Subscript)
and isinstance(noeud.value, ast.Name)
and noeud.value.id in GENERIQUES
):
trouves.append(f"« {noeud.value.id}[…] » ligne {noeud.lineno}")
return trouves
def module_vers_chemin(module):
chemin = os.path.join(RACINE, module.replace(".", os.sep) + ".py")
return chemin if os.path.isfile(chemin) else None
def importes(source):
for noeud in ast.walk(ast.parse(source)):
if isinstance(noeud, ast.ImportFrom):
if noeud.module and noeud.module.startswith("script"):
yield noeud.module
for alias in noeud.names:
yield f"{noeud.module}.{alias.name}"
elif isinstance(noeud, ast.Import):
for alias in noeud.names:
if alias.name.startswith("script"):
yield alias.name
def points_entree():
"""Les scripts que le pilote lance — lus dans le pilote, pas listés.
Une liste écrite à la main vieillit en silence : le script ajouté
demain ne serait pas couvert, et c'est justement celui qui casse.
"""
trouves = set()
for ref in re.findall(r"\./script/[a-z0-9_/]+\.py", lire(PILOTE)):
chemin = os.path.join(RACINE, ref[2:])
if os.path.isfile(chemin):
trouves.add(chemin)
return sorted(trouves)
def fermeture():
vus, pile = set(), list(points_entree())
while pile:
chemin = pile.pop()
if chemin in vus:
continue
vus.add(chemin)
for module in importes(lire(chemin)):
suivant = module_vers_chemin(module)
if suivant:
pile.append(suivant)
return sorted(vus)
class TestTheDetectorDetects(unittest.TestCase):
"""Un détecteur qui ne détecte rien ferait passer le test à vide."""
def test_it_flags_a_union_in_a_signature(self):
self.assertTrue(trop_recent("def f(x: dict | None = None): pass\n"))
def test_it_flags_a_builtin_generic_return(self):
self.assertTrue(trop_recent("def f() -> tuple[int, str]: pass\n"))
def test_a_runtime_union_is_not_an_annotation(self):
# « set(a) | b » est une union d'ENSEMBLES, valide depuis toujours.
self.assertEqual(trop_recent("x = set('ab') | set('cd')\n"), [])
def test_deferring_makes_it_legal(self):
self.assertEqual(
trop_recent(f"{DIFFERE}\ndef f(x: dict | None = None): pass\n"), []
)
def test_a_body_annotation_is_never_evaluated(self):
self.assertEqual(
trop_recent("def f():\n x: dict | None = None\n return x\n"),
[],
)
class TestTheFloorIsWhatTheProjectDeclares(unittest.TestCase):
def test_the_oldest_supported_python_is_still_pre_3_9(self):
# Le jour où la 12 et la 13 disparaissent, ce garde-fou n'a plus
# de raison d'être : qu'il le dise plutôt que de survivre seul.
pythons = []
for valeur in json.loads(lire(VERSIONS)).values():
if isinstance(valeur, dict) and valeur.get("python_version"):
pythons.append(
tuple(
int(x) for x in valeur["python_version"].split(".")[:2]
)
)
self.assertTrue(pythons)
self.assertLess(
min(pythons),
(3, 9),
"plus aucune version sous 3.9 : ce test peut disparaître",
)
class TestWhatTheMigrationLoads(unittest.TestCase):
def test_the_scan_is_not_empty(self):
self.assertGreater(len(points_entree()), 10)
self.assertGreater(len(fermeture()), 15)
def test_execute_is_in_the_closure(self):
# Le module par lequel l'incident est arrivé : s'il sortait de la
# fermeture, le test passerait sans plus rien garder.
self.assertIn(
os.path.join(RACINE, "script", "execute", "execute.py"),
fermeture(),
)
def test_every_loaded_module_survives_python_3_7(self):
coupables = {}
for chemin in fermeture():
trouves = trop_recent(lire(chemin))
if trouves:
coupables[os.path.relpath(chemin, RACINE)] = trouves
self.assertEqual(
coupables,
{},
"annotation évaluée au chargement ; ajouter"
f" « {DIFFERE} » en tête de ces fichiers",
)
if __name__ == "__main__":
unittest.main()

408
test/test_proxmox_deploy.py Normal file
View file

@ -0,0 +1,408 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Déployer sur un hôte Proxmox : l'hyperviseur est AILLEURS.
Toute la différence avec QEMU/KVM tient là. Rien ne s'exécute sur la machine
locale : il faut d'abord savoir OÙ, puis tout envoyer par SSH. Ces tests
gardent ce qui a été appris contre un hôte réel (Proxmox VE 9.2.11 dans une VM
libvirt), une panne après l'autre :
- « qm » exige root. La voie « VM QEMU locale » ne donne que l'accès
d'erplibre : il faut sudo, et l'enrober AUTOUR de toute la commande — les
commandes de ce module sont des suites et des redirections, et « sudo cmd »
n'élèverait que le premier mot.
- Une Proxmox installée SUR Debian n'a AUCUN pont. On en propose un INTERNE :
ajouter l'interface physique à un pont déplace l'adresse de l'hôte et coupe
la session SSH — à distance, c'est sans retour.
- Sur un pont interne, aucun DHCP ne répond : l'adresse doit être fixe, et
elle est alors connue AVANT le démarrage. La chercher ensuite était absurde.
- L'agent invité n'est pas dans l'image cloud Debian : le voisinage de l'hôte
(« ip neigh ») est le seul repli, et il a trouvé l'adresse là où l'agent
répondait « not running ».
"""
import shlex
import subprocess
import sys
import unittest
from unittest import mock
sys.argv = ["todo.py"]
from script.proxmox import proxmox_deploy as pve # noqa: E402
from script.todo.todo import TODO # noqa: E402
# Sorties RÉELLES relevées sur l'hôte d'essai.
PVEVERSION = (
"pve-manager/9.2.11/f6997e698c7933ea (running kernel: 7.0.14-12-pve)"
)
QM_LIST = """ VMID NAME STATUS MEM(MB) BOOTDISK(GB) PID
100 vm-essai running 2048 16.00 2726
101 avec un espace stopped 4096 32.00 0
"""
PVESM = """Name Type Status Total Used Available %
local dir active 32815812 6873084 24559348 20.94%
sauvegarde dir inactive 99999999 0 99999999 0.00%
"""
NEIGH = """192.168.123.1 dev enp1s0 lladdr 52:54:00:cd:73:ef REACHABLE
10.10.10.150 dev vmbr0 lladdr bc:24:11:93:da:22 REACHABLE
"""
QM_CONFIG = """boot: order=scsi0
memory: 2048
net0: virtio=BC:24:11:93:DA:22,bridge=vmbr0
scsi0: local:100/vm-100-disk-0.raw,discard=on,size=16G,ssd=1
"""
INTERFACES = """auto lo
iface lo inet loopback
iface enp1s0 inet manual
auto vmbr0
iface vmbr0 inet static
address 10.10.10.1/24
bridge-ports none
bridge-stp off
auto vmbr1
iface vmbr1 inet manual
bridge-ports enp2s0
"""
class TestLectureDesSorties(unittest.TestCase):
def test_the_version_proves_it_is_a_proxmox(self):
"""Une adresse saisie à la main peut être n'importe quelle machine :
sans cette preuve, la première commande « qm » échouerait sur un
« command not found » qui n'explique rien."""
self.assertEqual("9.2.11", pve.parse_pveversion(PVEVERSION))
self.assertEqual(
"", pve.parse_pveversion("bash: pveversion: not found")
)
def test_a_vm_name_with_spaces_is_read_whole(self):
"""« qm list » sépare par des espaces, et un nom peut en contenir : on
découpe par les deux bouts, le milieu est le nom."""
vms = pve.parse_qm_list(QM_LIST)
self.assertEqual([100, 101], [v["vmid"] for v in vms])
self.assertEqual("avec un espace", vms[1]["name"])
self.assertEqual("running", vms[0]["status"])
def test_the_header_is_not_a_vm(self):
self.assertEqual([], pve.parse_qm_list(" VMID NAME STATUS\n"))
self.assertEqual([], pve.parse_qm_list(""))
def test_only_active_storages_count_and_kib_become_bytes(self):
st = pve.parse_storages(PVESM)
self.assertEqual(["local", "sauvegarde"], [s["name"] for s in st])
self.assertTrue(st[0]["actif"])
self.assertFalse(st[1]["actif"])
self.assertEqual(24559348 * 1024, st[0]["avail"])
def test_bridges_are_read_without_their_at_suffix(self):
texte = "3: vmbr0: <BROADCAST,UP>\n4: vmbr1@if2: <BROADCAST>\n"
self.assertEqual(["vmbr0", "vmbr1"], pve.parse_bridges(texte))
def test_the_guest_agent_answer_drops_loopback_and_ipv6(self):
json_txt = (
'[{"name":"lo","ip-addresses":[{"ip-address-type":"ipv4",'
'"ip-address":"127.0.0.1"}]},{"name":"eth0","ip-addresses":['
'{"ip-address-type":"ipv4","ip-address":"10.10.10.150"},'
'{"ip-address-type":"ipv6","ip-address":"fe80::1"}]}]'
)
self.assertEqual(["10.10.10.150"], pve.parse_guest_ips(json_txt))
def test_a_missing_agent_is_not_a_crash(self):
"""Sa réponse n'est pas du JSON : « QEMU guest agent is not running »."""
self.assertEqual(
[], pve.parse_guest_ips("QEMU guest agent is not running")
)
def test_the_mac_links_a_vm_to_its_address(self):
"""Le seul lien quand l'agent manque, et l'image cloud Debian ne
l'embarque pas."""
mac = pve.mac_from_config(QM_CONFIG)
self.assertEqual("bc:24:11:93:da:22", mac)
self.assertEqual("10.10.10.150", pve.ip_from_neigh(NEIGH, mac))
def test_an_unknown_mac_finds_nothing(self):
self.assertEqual("", pve.ip_from_neigh(NEIGH, "de:ad:be:ef:00:00"))
self.assertEqual("", pve.ip_from_neigh(NEIGH, ""))
def test_a_lan_bridge_and_an_internal_one_are_told_apart(self):
ponts = pve.parse_bridge_config(INTERFACES)
self.assertEqual("", ponts["vmbr0"]["ports"])
self.assertEqual("10.10.10.1/24", ponts["vmbr0"]["address"])
self.assertEqual("enp2s0", ponts["vmbr1"]["ports"])
def test_orphans_are_the_volumes_no_vm_claims(self):
liste = (
"Volid Format Type Size VMID\n"
"local:100/vm-100-disk-0.raw raw images 17179869184 100\n"
"local:999/vm-999-disk-0.raw raw images 8589934592 999\n"
)
orph = pve.parse_orphans(liste, [100])
self.assertEqual(1, len(orph))
self.assertIn("999", orph[0][0])
class TestLesChoix(unittest.TestCase):
def test_the_vmid_skips_the_taken_ones(self):
"""Proxmox refuse un VMID pris, et le dit APRÈS le téléchargement de
l'image : on choisit donc avant, d'après ce que l'hôte déclare."""
self.assertEqual(
102, pve.next_vmid([{"vmid": 100}, {"vmid": 101}, {"vmid": 103}])
)
self.assertEqual(100, pve.next_vmid([]))
def test_the_storage_is_the_freest_active_one(self):
st = pve.parse_storages(PVESM)
self.assertEqual("local", pve.pick_storage(st))
def test_an_unknown_storage_is_refused_not_guessed(self):
"""« local-lvm » n'existe pas partout : un repli deviné ferait échouer
« qm set » après le téléchargement de l'image."""
self.assertEqual(
"", pve.pick_storage(pve.parse_storages(PVESM), "nas")
)
def test_vmbr0_wins_when_it_exists(self):
self.assertEqual("vmbr0", pve.pick_bridge(["vmbr9", "vmbr0"]))
self.assertEqual("br-lan", pve.pick_bridge(["br-lan"]))
self.assertEqual("", pve.pick_bridge([]))
class TestLesCommandes(unittest.TestCase):
def _spec(self, **extra):
base = {
"name": "vm-essai",
"memory": 2048,
"vcpus": 2,
"disk": "12G",
"storage": "local",
"bridge": "vmbr0",
"image": "debian-13-genericcloud-amd64.qcow2",
"sshkey_path": "/root/.ssh/erplibre-deploy.pub",
"ipconfig": "ip=10.10.10.150/24,gw=10.10.10.1",
}
base.update(extra)
return base
def test_the_sequence_is_in_the_order_proxmox_needs(self):
cmds = pve.create_cmds(100, self._spec())
joint = "\n".join(cmds)
self.assertTrue(cmds[0].startswith("qm create 100"))
self.assertIn("import-from=", joint)
self.assertIn(":cloudinit", joint)
self.assertIn("--boot order=scsi0", joint)
self.assertIn("qm resize 100 scsi0 12G", joint)
self.assertTrue(cmds[-1].endswith("qm start 100"))
def test_the_agent_and_the_serial_console_are_asked_for(self):
"""Sans agent, aucune adresse ; sans serial0, « qm terminal » est
inutilisable et il ne reste que l'interface web."""
cmd = pve.create_cmds(100, self._spec())[0]
self.assertIn("--agent enabled=1", cmd)
self.assertIn("--serial0 socket", cmd)
def test_a_name_with_a_space_cannot_break_the_command(self):
"""Le nom vient d'une saisie : découpée par le shell, elle doit rester
UN argument. « rm » ne doit jamais devenir une commande."""
mechant = "vm essai; rm -rf /"
cmds = pve.create_cmds(100, self._spec(name=mechant))
args = shlex.split(cmds[0])
self.assertIn(mechant, args)
self.assertNotIn("rm", args)
def test_destroy_stops_first_and_purges(self):
cmds = pve.destroy_cmds(100)
self.assertIn("qm stop 100", cmds[0])
self.assertIn("--purge 1", cmds[1])
def test_the_image_is_fetched_once_on_the_host(self):
cmd = pve.image_fetch_cmd("https://x/deb.qcow2", "deb.qcow2")
self.assertIn("if [ -s", cmd)
self.assertIn("wget", cmd)
def test_the_internal_bridge_never_touches_a_physical_nic(self):
"""Le point le plus important de ce module : ajouter l'interface au
pont déplace l'adresse de l'hôte et coupe la session SSH — à distance,
sans retour."""
cmds = pve.bridge_setup_cmds(uplink="enp1s0")
joint = "\n".join(cmds)
self.assertIn("bridge-ports none", joint)
self.assertNotIn("bridge-ports enp1s0", joint)
self.assertIn("MASQUERADE", joint)
self.assertIn("ip_forward", joint)
def test_the_bridge_stanza_is_added_only_once(self):
cmds = pve.bridge_setup_cmds()
self.assertIn("grep -qE", cmds[0])
self.assertIn("||", cmds[0])
def test_an_internal_bridge_gets_a_static_address(self):
"""Aucun DHCP n'y répondrait : la VM resterait muette."""
ponts = pve.parse_bridge_config(INTERFACES)
self.assertEqual(
"ip=10.10.10.150/24,gw=10.10.10.1",
pve.ipconfig_for(ponts["vmbr0"], 100),
)
def test_a_lan_bridge_gets_dhcp(self):
ponts = pve.parse_bridge_config(INTERFACES)
self.assertEqual("ip=dhcp", pve.ipconfig_for(ponts["vmbr1"], 100))
def test_two_vms_do_not_share_an_address(self):
ponts = pve.parse_bridge_config(INTERFACES)
a = pve.ipconfig_for(ponts["vmbr0"], 100)
b = pve.ipconfig_for(ponts["vmbr0"], 101)
self.assertNotEqual(a, b)
def test_a_static_address_is_known_before_boot(self):
self.assertEqual(
"10.10.10.150",
pve.ip_from_ipconfig("ip=10.10.10.150/24,gw=10.10.10.1"),
)
self.assertEqual("", pve.ip_from_ipconfig("ip=dhcp"))
class TestLePrivilege(unittest.TestCase):
def test_the_whole_command_is_wrapped_not_just_its_first_word(self):
"""« sudo mkdir && if … fi » n'élèverait que le mkdir, et la
redirection resterait celle du shell non privilégié : « permission
denied » sur /root ou /boot/efi."""
compose = "mkdir -p /root/.ssh && printf x > /root/.ssh/k"
enrobe = pve.wrap_privilege(compose, "sudo ")
self.assertTrue(enrobe.startswith("sudo sh -c "))
self.assertIn("printf x > /root/.ssh/k", enrobe)
def test_without_sudo_the_command_is_untouched(self):
self.assertEqual("qm list", pve.wrap_privilege("qm list", ""))
def test_ssh_never_asks_a_question_it_cannot_show(self):
"""BatchMode : une invite de mot de passe dans un menu bloquerait sans
rien afficher."""
argv = pve.ssh_argv({"target": "root@h"}, "qm list")
self.assertIn("BatchMode=yes", argv)
self.assertIn("ConnectTimeout=10", " ".join(argv))
def test_the_jump_and_the_port_travel(self):
argv = pve.ssh_argv(
{"target": "root@h", "jump": "rebond", "port": "2222"}, "x"
)
self.assertIn("-J", argv)
self.assertIn("rebond", argv)
self.assertIn("-p", argv)
self.assertIn("2222", argv)
def test_a_console_gets_a_tty_and_no_batchmode(self):
argv = pve.ssh_argv({"target": "root@h"}, "qm terminal 100", tty=True)
self.assertIn("-t", argv)
self.assertNotIn("BatchMode=yes", argv)
class TestChoixDeLHote(unittest.TestCase):
"""La question propre à Proxmox : sur QUELLE machine ?"""
def _todo(self):
todo = TODO.__new__(TODO)
todo._pve_remember_host = lambda h: None
return todo
def test_an_unknown_host_key_is_recognised(self):
for texte in (
"Host key verification failed.",
"The authenticity of host '10.0.0.1' can't be established.",
"No ED25519 host key is known for 10.0.0.1",
):
self.assertTrue(TODO._pve_hostkey_missing(texte), texte)
self.assertFalse(TODO._pve_hostkey_missing("Permission denied"))
def _confirm(self, reponses):
"""reponses : [(code, sortie)] pour chaque appel à pve.run."""
it = iter(reponses)
with mock.patch.object(
pve, "run", side_effect=lambda *a, **k: next(it)
):
import contextlib
import io
out = io.StringIO()
with contextlib.redirect_stdout(out):
host = self._todo()._pve_confirm_host(
{"target": "erplibre@10.0.0.5", "jump": ""}
)
return host, out.getvalue()
def test_a_non_proxmox_host_is_refused_with_what_was_seen(self):
host, sortie = self._confirm([(127, "bash: pveversion: not found")])
self.assertIsNone(host)
self.assertIn("pveversion", sortie)
def test_a_non_root_access_gets_sudo(self):
"""C'est le cas de la voie « VM QEMU locale » : cloud-init crée
erplibre, pas root."""
host, _s = self._confirm([(0, PVEVERSION), (0, "1000\n"), (0, "")])
self.assertEqual("sudo ", host["sudo"])
self.assertEqual("9.2.11", host["version"])
def test_root_needs_no_sudo(self):
host, _s = self._confirm([(0, PVEVERSION), (0, "0\n")])
self.assertEqual("", host["sudo"])
def test_without_root_nor_passwordless_sudo_it_stops(self):
"""Un sudo qui réclame un mot de passe bloquerait chaque commande du
menu sur une invite que personne ne voit."""
host, sortie = self._confirm(
[
(0, PVEVERSION),
(0, "1000\n"),
(1, "sudo: a password is required"),
]
)
self.assertIsNone(host)
self.assertIn("root", sortie)
class TestLeMenu(unittest.TestCase):
def test_proxmox_sits_right_under_qemu_in_the_deploy_menu(self):
src = open("script/todo/todo.py", encoding="utf-8").read()
i_qemu = src.index('"QEMU/KVM - Deploy an Ubuntu VM (libvirt)"')
i_pve = src.index('"Proxmox VE - Deploy a VM on a remote host"')
i_ntfy = src.index('"Deploy - Install NTFY notification server"')
self.assertLess(i_qemu, i_pve)
self.assertLess(i_pve, i_ntfy)
def test_the_dispatch_follows_the_list(self):
src = open("script/todo/todo.py", encoding="utf-8").read()
self.assertIn(
'elif status == "6":\n self.prompt_execute_proxmox()',
src,
)
self.assertIn(
'elif status == "7":\n self._deploy_ntfy_server()',
src,
)
def test_every_qemu_entry_has_its_proxmox_counterpart(self):
"""L'équivalent des dix-sept commandes, plus le choix de l'hôte."""
src = open("script/todo/todo.py", encoding="utf-8").read()
debut = src.index(" def prompt_execute_proxmox(self):")
bloc = src[debut : src.index(" def _pve_fetch_image(self):")]
for n in range(1, 19):
self.assertIn(f'elif status == "{n}":', bloc, f"entrée {n}")
def test_the_script_is_valid_python(self):
res = subprocess.run(
[
sys.executable,
"-c",
"import ast;ast.parse(open('script/proxmox/proxmox_deploy.py',encoding='utf-8').read())",
],
capture_output=True,
text=True,
)
self.assertEqual(0, res.returncode, res.stderr)
if __name__ == "__main__":
unittest.main(verbosity=1)

View file

@ -0,0 +1,249 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Suivre un déploiement qui n'installe PAS ERPLibre.
Symptôme rapporté : en décochant l'installation d'ERPLibre, le tableau de bord
ne s'ouvrait plus du tout. Deux causes, l'une derrière l'autre :
- la case « suivi » vivait DANS le groupe de l'installation ERPLibre, et
`build_spec` ne la recopiait même pas dans la spec finale ;
- l'épilogue du déploiement était gardé par « if install or desktop » : sans
rien à installer, il ne se passait rien.
Et si le suivi s'ouvrait quand même, il n'aurait rien montré : la commande
distante valait « true », donc un journal vide et un ✅ instantané. Elle
regarde maintenant la VM ARRIVER — cloud-init, puis un relevé système — ce qui
donne un début, une fin, et de quoi juger qu'elle est prête.
"""
import contextlib
import io
import os
import subprocess
import sys
import unittest
sys.argv = ["todo.py"]
from script.todo import qemu_install_monitor as mon # noqa: E402
from script.todo.qemu_deploy_form import build_spec # noqa: E402
from script.todo.todo import TODO # noqa: E402
class TestLaCommandeDistante(unittest.TestCase):
def setUp(self):
self.todo = TODO.__new__(TODO)
def _sans_rien(self):
return self.todo._qemu_erplibre_remote_cmd(None)
def test_it_is_no_longer_a_bare_true(self):
"""« true » rendait un journal vide et un ✅ instantané : le suivi
s'ouvrait sur rien."""
self.assertNotEqual("true", self._sans_rien().strip())
def test_it_is_valid_shell(self):
res = subprocess.run(
["bash", "-n"],
input=self._sans_rien(),
capture_output=True,
text=True,
)
self.assertEqual(0, res.returncode, res.stderr)
def test_it_waits_for_cloud_init(self):
"""C'est le vrai « suivi » d'une VM neuve : tant que cloud-init tourne,
la machine n'est pas prête."""
self.assertIn("cloud-init status --wait", self._sans_rien())
def test_it_frames_the_step_so_the_monitor_can_bound_it(self):
"""Le tableau lit ces marqueurs pour dire où en est la VM."""
cmd = self._sans_rien()
self.assertIn("===>", cmd)
self.assertIn("<===", cmd)
def test_it_reports_what_says_the_vm_is_up(self):
cmd = self._sans_rien()
for morceau in ("/etc/os-release", "uname -r", "hostname -I", "df -h"):
self.assertIn(morceau, cmd, morceau)
def test_it_runs_and_says_something(self):
"""Exécutée ici, hors VM : l'attente de cloud-init est neutralisée, le
relevé doit sortir et rendre 0."""
cmd = self._sans_rien().replace(
"sudo timeout 900 cloud-init status --wait", "true"
)
res = subprocess.run(
["bash", "-c", cmd], capture_output=True, text=True, timeout=60
)
self.assertEqual(0, res.returncode, res.stderr[-300:])
self.assertIn("===>", res.stdout)
self.assertIn("<===", res.stdout)
# Trois lignes de relevé au moins : sans elles, le journal est creux.
self.assertGreaterEqual(len(res.stdout.strip().splitlines()), 5)
def test_a_desktop_only_vm_still_installs_its_desktop(self):
"""Le chemin qui marchait déjà ne doit pas changer de sens."""
self.todo._qemu_desktop_remote_cmd = lambda d, s: "INSTALLE_BUREAU; "
self.todo._qemu_tools_remote_cmd = lambda *a, **k: ""
self.todo._qemu_no_auto_upgrade = lambda *a, **k: ""
cmd = self.todo._qemu_erplibre_remote_cmd(None, desktop="gnome")
self.assertIn("INSTALLE_BUREAU", cmd)
class TestLaSpecDuFormulaire(unittest.TestCase):
def _form(self, **extra):
base = {
"res_label": "x1",
"ssh_key": "/k.pub",
"install": None,
"add_ssh_config": False,
"parallelism": 1,
}
base.update(extra)
return base
def test_the_choice_reaches_the_spec(self):
"""Il ne la recopiait pas : le choix du formulaire n'atteignait jamais
le déploiement."""
spec = build_spec([], [], self._form(monitor=True))
self.assertTrue(spec["monitor"])
spec = build_spec([], [], self._form(monitor=False))
self.assertFalse(spec["monitor"])
def test_an_old_form_without_the_key_still_monitors(self):
"""Compatibilité : une spec enregistrée avant ce changement ne doit pas
perdre son tableau de bord."""
self.assertTrue(build_spec([], [], self._form())["monitor"])
class TestLaDecisionDuDeploiement(unittest.TestCase):
"""L'épilogue : qui est appelé, et avec quoi."""
def _joue(self, spec):
todo = TODO.__new__(TODO)
appels = []
todo._qemu_install_erplibre_monitored = lambda *a, **k: appels.append(
"suivi"
)
todo._qemu_install_erplibre_vm = lambda *a, **k: appels.append("muet")
todo._qemu_resolve_ips = lambda names, labels=None: {}
base = {
"vms": [],
"existing": ["vm-a"],
"install": None,
"add_ssh_config": False,
"parallelism": 1,
}
base.update(spec)
with contextlib.redirect_stdout(io.StringIO()):
todo._qemu_run_spec(base)
return appels
def test_without_erplibre_the_monitor_still_opens(self):
"""Le cœur du problème rapporté."""
self.assertEqual(["suivi"], self._joue({"monitor": True}))
def test_a_spec_without_the_key_monitors_too(self):
self.assertEqual(["suivi"], self._joue({}))
def test_refusing_the_monitor_does_nothing_at_all(self):
"""Et surtout : ne pas partir installer un profil qui n'existe pas.
L'ancien repli faisait « install['cmd'] » sur un None."""
self.assertEqual([], self._joue({"monitor": False}))
def test_an_install_without_the_monitor_takes_the_quiet_path(self):
appels = self._joue(
{
"monitor": False,
"install": {
"branch": "develop",
"prod": False,
"cmd": "make x",
"monitor": False,
},
}
)
self.assertEqual(["muet"], appels)
def test_the_install_keeps_the_last_word_on_its_own_monitoring(self):
appels = self._joue(
{
"monitor": False,
"install": {
"branch": "develop",
"prod": False,
"cmd": "make x",
"monitor": True,
},
}
)
self.assertEqual(["suivi"], appels)
class TestLeJournal(unittest.TestCase):
"""L'en-tête et le prologue ne doivent pas annoncer ce qui n'a pas lieu."""
def _vm(self):
return {
"name": "vm-a",
"ip": "10.0.0.9",
"distro": "debian",
"version": "13",
"arch": "amd64",
}
def test_an_install_is_titled_an_install(self):
head = mon._log_header(self._vm(), "develop", "2026-01-01 00:00:00")
self.assertIn(mon.t("installation"), head)
self.assertIn("develop", head)
def test_without_a_branch_it_is_not_called_an_install(self):
"""« ERPLibre — installation » puis « Branche : » vide, sur un
déploiement qui n'installe rien : le journal se contredisait."""
head = mon._log_header(self._vm(), "", "2026-01-01 00:00:00")
self.assertIn(mon.t("VM start-up"), head)
self.assertNotIn("Branche", head)
def test_the_prologue_says_what_actually_follows(self):
"""« installation ERPLibre en cours » alors que rien ne s'installe."""
import tempfile
from pathlib import Path
vus = []
vrai_launch, vrai_dir = mon._launch_one, mon.session_dir
mon._launch_one = (
lambda ip, cmd, log, name="", installs=True: vus.append(installs)
)
# session_dir détournée : sans cela le test écrivait de VRAIES sessions
# dans ~/.erplibre/qemu-install, qui polluaient l'historique que
# « Rouvrir le suivi » propose à l'utilisateur.
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
mon.session_dir = lambda: Path(tmp.name)
try:
mon.launch_installs([self._vm()], "", "true")
mon.launch_installs([self._vm()], "develop", "true")
finally:
mon._launch_one, mon.session_dir = vrai_launch, vrai_dir
self.assertEqual([False, True], vus)
def test_the_host_key_notice_is_not_a_warning(self):
"""ssh l'écrit à CHAQUE première connexion : comptée, la colonne ⚠
s'allumait sur toute installation, dès sa première ligne."""
import tempfile
with tempfile.NamedTemporaryFile(
"w", suffix=".log", delete=False, encoding="utf-8"
) as fh:
fh.write(
"Warning: Permanently added '10.0.0.9' (ED25519) to the"
" list of known hosts.\n__ERPLIBRE_EXIT__ 0\n"
)
chemin = fh.name
self.addCleanup(os.unlink, chemin)
self.assertEqual((0, 0), mon.scan_log_errors(chemin))
if __name__ == "__main__":
unittest.main(verbosity=1)

View file

@ -0,0 +1,372 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Statistiques par VM du suivi d'installation : écriture, RAM, disque.
Trois chiffres par VM, tirés d'UN appel « virsh domstats » pour tout le parc :
ce qu'elle écrit (moyenne sur dix secondes), sa RAM occupée/totale, son disque
occupé/total.
Ce que ces tests gardent, appris en les construisant contre des VM réelles :
- Le seed ISO doit être ÉCARTÉ des disques. Monté en lecture seule, il n'est
jamais écrit, et sa capacité minuscule l'aurait fait passer pour le disque
système d'une VM dont le qcow2 n'est pas encore alloué.
- Un compteur d'écriture qui RECULE veut dire que le domaine a redémarré —
ce qu'une installation fait. Sans garde, le débit devient négatif.
- Le ballon mémoire ne publie ses compteurs que si une période de collecte est
armée. Un relevé vieux d'une demi-heure doit être TAISÉ, pas affiché :
mesuré, il annonçait 388 Mo pour une VM qui occupait 1,1 Go.
- La ligne du tableau doit tenir : trois colonnes de plus, et « Disque »
sortait de l'écran sur un terminal de 150 colonnes.
"""
import json
import tempfile
import time
import unittest
from pathlib import Path
from unittest import mock
from script.todo import qemu_install_monitor as mon
from script.todo.qemu_install_monitor import (
BALLOON_MAX_AGE,
COL_DEFAULT_WIDTHS,
WRITE_WINDOW,
WriteWindow,
_fmt_tight,
fmt_pair,
fmt_rate,
parse_domstats,
ram_pair,
vm_stats_line,
)
# Sortie RÉELLE de « virsh domstats --balloon --block » sur deux VM du parc :
# une VM de migration en travail, et son seed ISO en second périphérique.
DOMSTATS = """Domain: 'erplibre-ubuntu-2404-MIGRATION'
balloon.current=12582912
balloon.maximum=12582912
balloon.swap_in=0
balloon.unused=936072
balloon.available=12242352
balloon.usable=11064740
balloon.last-update=1787392761
balloon.rss=12623896
block.count=2
block.0.name=vda
block.0.path=/var/lib/libvirt/images/erplibre-ubuntu-2404.qcow2
block.0.rd.bytes=9664722944
block.0.wr.reqs=2969857
block.0.wr.bytes=165796498432
block.0.allocation=67263266816
block.0.capacity=69793218560
block.0.physical=67265261568
block.1.name=vdb
block.1.path=/var/lib/libvirt/images/iso/erplibre-ubuntu-2404-seed.iso
block.1.wr.bytes=4096
block.1.allocation=380928
block.1.capacity=380928
Domain: 'erplibre-ubuntu-2604'
balloon.current=12582912
balloon.available=12237092
balloon.usable=11054184
balloon.last-update=1787392782
block.count=1
block.0.name=vda
block.0.path=/var/lib/libvirt/images/erplibre-ubuntu-2604.qcow2
block.0.wr.bytes=48117251072
block.0.allocation=43900928000
block.0.capacity=69793218560
"""
MIGRATION = "erplibre-ubuntu-2404-MIGRATION"
class TestLecture(unittest.TestCase):
def test_it_reads_both_domains(self):
st = parse_domstats(DOMSTATS)
self.assertEqual({MIGRATION, "erplibre-ubuntu-2604"}, set(st))
def test_ram_is_available_minus_usable(self):
"""Calibrée en son temps contre le « free » de deux invités : c'est
« available - usable » qui suit ce qu'ils occupent, pas « unused »,
qui compte le cache."""
rec = parse_domstats(DOMSTATS)[MIGRATION]
self.assertEqual(12242352 * 1024, rec["ram_total"])
self.assertEqual((12242352 - 11064740) * 1024, rec["ram_used"])
def test_the_seed_iso_is_left_out_of_the_disk(self):
"""Sa capacité (372 Ko) l'emporterait sur un qcow2 pas encore alloué,
et le tableau annoncerait un disque plein à 100 %."""
rec = parse_domstats(DOMSTATS)[MIGRATION]
self.assertEqual(69793218560, rec["disk_total"])
self.assertEqual(67263266816, rec["disk_used"])
def test_the_seed_iso_writes_are_left_out_too(self):
"""4096 octets écrits sur un ISO en lecture seule : anecdotique ici,
mais compter un périphérique qu'on n'affiche pas rend un débit qu'on
ne peut relier à rien."""
rec = parse_domstats(DOMSTATS)[MIGRATION]
self.assertEqual(165796498432, rec["wr_bytes"])
def test_a_single_disk_vm_is_read_too(self):
rec = parse_domstats(DOMSTATS)["erplibre-ubuntu-2604"]
self.assertEqual(48117251072, rec["wr_bytes"])
self.assertEqual(69793218560, rec["disk_total"])
def test_empty_or_broken_output_gives_nothing(self):
for texte in ("", "erreur: pas de connexion", None):
self.assertEqual({}, parse_domstats(texte))
def test_a_domain_without_balloon_stats_is_not_invented(self):
"""Une VM éteinte, ou dont le pilote n'a rien publié : pas de RAM
plutôt qu'un zéro qui passerait pour une mesure."""
rec = parse_domstats("Domain: 'x'\n block.count=0\n")["x"]
self.assertEqual(0, rec["ram_total"])
self.assertEqual("-", ram_pair(rec, time.time()))
class TestFenetreEcriture(unittest.TestCase):
def test_two_samples_ten_seconds_apart_give_the_rate(self):
w = WriteWindow(10.0)
w.add("vm", 0, 1000.0)
w.add("vm", 10 << 20, 1010.0)
self.assertAlmostEqual(1 << 20, w.rate("vm"), delta=1024)
def test_one_sample_is_not_a_rate(self):
"""Au premier tour, la colonne doit dire « - » et non « 0 » : un débit
nul et un débit inconnu ne se ressemblent pas."""
w = WriteWindow()
w.add("vm", 12345, 1000.0)
self.assertIsNone(w.rate("vm"))
self.assertEqual("-", fmt_rate(w.rate("vm")))
def test_samples_too_close_together_are_not_a_rate(self):
"""Deux relevés à 200 ms d'intervalle donnent un chiffre absurde ;
mieux vaut attendre le tour suivant."""
w = WriteWindow()
w.add("vm", 0, 1000.0)
w.add("vm", 1 << 20, 1000.2)
self.assertIsNone(w.rate("vm"))
def test_the_window_keeps_about_ten_seconds(self):
"""Sinon la moyenne porterait sur toute la durée de l'installation, et
ne montrerait plus ce qui se passe MAINTENANT."""
w = WriteWindow(10.0)
# Une minute de relevés toutes les deux secondes, débit constant.
for i in range(31):
w.add("vm", i * (2 << 20), 1000.0 + 2 * i)
span = w._hist["vm"][-1][0] - w._hist["vm"][0][0]
self.assertLessEqual(span, 12.0)
self.assertGreaterEqual(span, 10.0)
self.assertAlmostEqual(1 << 20, w.rate("vm"), delta=1024)
def test_a_restart_resets_the_window_instead_of_going_negative(self):
"""Le compteur appartient au processus QEMU : un redémarrage du domaine
le remet à zéro, et une installation redémarre la VM."""
w = WriteWindow(10.0)
w.add("vm", 500 << 20, 1000.0)
w.add("vm", 501 << 20, 1002.0)
w.add("vm", 1 << 20, 1004.0) # domaine redémarré
self.assertIsNone(w.rate("vm"))
w.add("vm", 3 << 20, 1014.0)
self.assertIsNotNone(w.rate("vm"))
self.assertGreater(w.rate("vm"), 0)
def test_vms_do_not_mix(self):
w = WriteWindow(10.0)
w.add("a", 0, 1000.0)
w.add("b", 0, 1000.0)
w.add("a", 10 << 20, 1010.0)
w.add("b", 1 << 20, 1010.0)
self.assertAlmostEqual(1 << 20, w.rate("a"), delta=1024)
self.assertAlmostEqual(1 << 20, w.rate("b") * 10, delta=10240)
def test_the_window_is_the_ten_seconds_announced(self):
"""La colonne dit « moyenne 10 s » : la constante doit le tenir."""
self.assertEqual(10.0, WRITE_WINDOW)
class TestFraicheurRam(unittest.TestCase):
def test_a_stale_balloon_report_is_hidden(self):
rec = parse_domstats(DOMSTATS)[MIGRATION]
vieux = rec["ram_at"] + BALLOON_MAX_AGE + 5
self.assertEqual("-", ram_pair(rec, vieux))
def test_a_fresh_report_is_shown(self):
rec = parse_domstats(DOMSTATS)[MIGRATION]
self.assertEqual("1.1G/12G", ram_pair(rec, rec["ram_at"] + 2))
def test_a_report_without_a_date_is_still_shown(self):
"""libvirt joint toujours « last-update » quand le ballon a parlé ;
sans elle on ne sait pas juger, et taire la valeur perdrait la seule
information disponible."""
rec = dict(parse_domstats(DOMSTATS)[MIGRATION], ram_at=0)
self.assertNotEqual("-", ram_pair(rec, time.time()))
class TestFormats(unittest.TestCase):
def test_the_decimal_goes_away_above_ten_units(self):
"""« 63G » plutôt que « 62.6G » : ces deux caractères décident si
« Disque » reste visible."""
self.assertEqual("63G", _fmt_tight(67263266816))
self.assertEqual("1.1G", _fmt_tight(1130479616))
self.assertEqual("12G", _fmt_tight(12536168448))
def test_a_pair_without_a_total_says_nothing(self):
self.assertEqual("-", fmt_pair(1024, 0))
self.assertEqual("-", fmt_pair(None, None))
def test_the_rate_carries_its_unit(self):
self.assertEqual("1.0M/s", fmt_rate(1 << 20))
self.assertEqual("-", fmt_rate(None))
def test_the_pairs_fit_the_columns(self):
"""Une valeur tronquée dans un tableau est un piège : on lit « 1001M/1 »
et on croit une VM à 1 Go."""
for used, total, cle in (
(1001 << 20, 128 << 30, "ram"),
(12 << 30, 128 << 30, "ram"),
(999 << 30, 1 << 40, "disk"),
(67263266816, 69793218560, "disk"),
):
self.assertLessEqual(
len(fmt_pair(used, total)), COL_DEFAULT_WIDTHS[cle]
)
for bps in (0, 1 << 10, 12.3 * (1 << 20), 2 << 30):
self.assertLessEqual(len(fmt_rate(bps)), COL_DEFAULT_WIDTHS["wr"])
class TestSection(unittest.TestCase):
def test_it_names_the_three_measures(self):
rec = parse_domstats(DOMSTATS)[MIGRATION]
ligne = vm_stats_line(
MIGRATION, rec, 1 << 20, rec["ram_at"] + 1, rec["wr_bytes"]
)
self.assertIn(MIGRATION, ligne)
self.assertIn("1.0M/s", ligne)
self.assertIn("1.1G/12G", ligne)
self.assertIn("63G/65G", ligne)
def test_it_shows_percentages(self):
"""« 63G/65G » ne dit pas d'un coup d'œil que le disque est à 96 %."""
rec = parse_domstats(DOMSTATS)[MIGRATION]
ligne = vm_stats_line(MIGRATION, rec, 0, rec["ram_at"] + 1)
self.assertIn("(96%)", ligne)
self.assertIn("(9%)", ligne)
def test_a_vm_without_stats_says_so(self):
ligne = vm_stats_line("vm-x", None, None, time.time())
self.assertIn("vm-x", ligne)
self.assertNotIn("RAM", ligne)
class TestColonnes(unittest.TestCase):
def test_the_three_columns_exist(self):
for cle in ("wr", "ram", "disk"):
self.assertIn(cle, COL_DEFAULT_WIDTHS)
def test_the_row_fits_a_150_column_terminal(self):
"""La table est bornée à 66 % de l'écran, et le journal prend le reste.
Trois colonnes de plus, et « Disque » sortait du cadre : le total est
donc surveillé, padding de DataTable compris (2 par colonne)."""
besoin = sum(COL_DEFAULT_WIDTHS.values()) + 2 * len(COL_DEFAULT_WIDTHS)
self.assertLessEqual(besoin, int(150 * 0.66))
class TestEcranMonte(unittest.IsolatedAsyncioTestCase):
"""Le suivi monté pour de vrai : les colonnes portent-elles les chiffres ?
Aucun appel à libvirt : « read_domstats » rend la sortie enregistrée
ci-dessus. Un test qui lance sudo ne tournerait ni en CI ni sur un poste
sans le parc.
"""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
base = Path(self.tmp.name)
for nom in (MIGRATION, "erplibre-ubuntu-2604"):
(base / f"{nom}.log").write_text("===> install\nligne\n")
manifeste = {
"branch": "develop",
"started": time.time() - 300,
"vms": [
{
"name": nom,
"ip": "127.0.0.9",
"arch": "amd64",
"log": str(base / f"{nom}.log"),
"ssh": f"ssh erplibre@{nom}",
"disk": str(base / f"{nom}.qcow2"),
}
for nom in (MIGRATION, "erplibre-ubuntu-2604")
],
}
self.manifest = base / "session.json"
self.manifest.write_text(json.dumps(manifeste))
# Le relevé du ballon doit paraître FRAIS, sinon la colonne RAM se
# taira — ce qui est le comportement voulu, mais pas ce qu'on teste.
self.stats = DOMSTATS.replace(
"balloon.last-update=1787392761",
f"balloon.last-update={int(time.time())}",
).replace(
"balloon.last-update=1787392782",
f"balloon.last-update={int(time.time())}",
)
async def _monte(self, taille=(150, 24)):
from textual.widgets import DataTable, Static
with mock.patch.object(
mon, "read_domstats", lambda: self.stats
), mock.patch.object(
mon, "arm_balloon", lambda names: None
), mock.patch.object(
mon,
"virsh_domstates",
lambda: {MIGRATION: "running", "erplibre-ubuntu-2604": "running"},
), mock.patch.object(
mon, "_port_open", lambda ip, port=8069, timeout=0.5: False
):
app = mon.run_monitor(str(self.manifest), run_app=False)
async with app.run_test(size=taille) as pilot:
await pilot.pause()
# Le tick du tableau tourne toutes les deux secondes : on
# l'appelle plutôt que d'attendre, et on antidate un premier
# relevé pour que la fenêtre ait de quoi conclure.
app._wrate.add(MIGRATION, 100 << 20, time.time() - 10)
await app._tick_table()
await pilot.pause()
table = app.query_one("#vms", DataTable)
cellules = {
cle: str(table.get_cell(MIGRATION, cle))
for cle in ("wr", "ram", "disk")
}
section = str(app.query_one("#vmstats", Static).content)
besoin = table.virtual_size.width
visible = table.size.width
return cellules, section, besoin, visible
async def test_the_columns_carry_the_numbers(self):
cellules, _section, _b, _v = await self._monte()
self.assertEqual("1.1G/12G", cellules["ram"])
self.assertEqual("63G/65G", cellules["disk"])
self.assertTrue(cellules["wr"].endswith("/s"), cellules["wr"])
async def test_the_selected_vm_has_its_statistics_section(self):
_c, section, _b, _v = await self._monte()
self.assertIn(MIGRATION, section)
self.assertIn("1.1G/12G", section)
self.assertIn("63G/65G", section)
async def test_nothing_scrolls_out_of_a_150_column_terminal(self):
"""Le vrai garde-fou de la largeur : mesuré sur la table montée, pas
calculé à la main."""
_c, _s, besoin, visible = await self._monte((150, 24))
self.assertGreaterEqual(visible, besoin)
if __name__ == "__main__":
unittest.main(verbosity=1)

392
test/test_qemu_proxmox.py Normal file
View file

@ -0,0 +1,392 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Proxmox VE au catalogue de déploiement : ce qui existe, et ce qui n'existe pas.
Proxmox ne publie AUCUNE image cloud — son ISO est un installateur qui formate
le disque. La voie que l'amont documente pour tout le reste est « Proxmox VE
sur Debian » : on part de l'image cloud Debian trixie et les paquets pve en
font l'hyperviseur. Le catalogue le dit ainsi, et l'image téléchargée est
littéralement celle de Debian.
Par architecture, vérifié dans le dépôt amont et non déduit :
- amd64 : 1800 paquets, proxmox-ve présent.
- arm64 : 448 paquets, proxmox-ve présent — officiel depuis PVE 9, dont le
Release de trixie annonce « amd64 arm64 ».
- s390x : l'index « binary-s390x » répond 404. Ce n'est pas une difficulté,
c'est une absence, et le catalogue la dit avant le déploiement.
Ce que ces tests gardent :
- Le nom de cache est celui de Debian : sans lui, le repli de fin nommait
l'image « fedora-cloud-9 » et un même téléchargement se faisait deux fois.
- Les planchers RAM/disque sont ceux d'un hyperviseur, pas ceux de Debian.
- Le retrait du noyau Debian n'emporte JAMAIS celui de Proxmox — sans quoi la
VM ne redémarrerait plus.
"""
import importlib.util
import os
import pathlib
import subprocess
import sys
import tempfile
import unittest
from unittest import mock
RACINE = pathlib.Path(__file__).resolve().parents[1]
SCRIPT = RACINE / "script/proxmox/install_proxmox.sh"
sys.argv = ["todo.py"]
from script.todo.todo import TODO # noqa: E402
from script.todo.todo_i18n import t # noqa: E402
def _deploy():
spec = importlib.util.spec_from_file_location(
"deploy_qemu", RACINE / "script/qemu/deploy_qemu.py"
)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
DQ = _deploy()
# Somme publiée par l'amont sur « Install Proxmox VE on Debian 13 Trixie », et
# recopiée ici EXPRÈS : deux copies indépendantes, c'est ce qui donne son sens
# à un condensat épinglé. Si l'une change sans l'autre, ce test le dit.
KEY_SHA256 = "136673be77aba35dcce385b28737689ad64fd785a797e57897589aed08db6e45"
class TestCatalogue(unittest.TestCase):
def test_it_is_offered_as_a_distro(self):
self.assertIn("proxmox", DQ.DISTROS)
versions, defaut = DQ.DISTROS["proxmox"]
self.assertEqual("9", defaut)
self.assertIn("9", versions)
def test_the_base_is_the_debian_trixie_cloud_image(self):
"""C'est bien l'image DEBIAN qu'on télécharge : Proxmox n'en publie
aucune."""
for arch in ("amd64", "arm64"):
url = DQ.image_url("proxmox", "trixie", arch, "9")
self.assertIn("trixie", url)
self.assertIn(f"debian-13-genericcloud-{arch}.qcow2", url)
def test_the_cache_name_is_debians_so_the_download_is_shared(self):
"""Un déploiement Debian 13 et un Proxmox visent le MÊME fichier : le
repli de fin nommait l'image « fedora-cloud-9 », et 325 Mio se
retéléchargeaient."""
pve = DQ.default_image_name("proxmox", "trixie", "amd64", "9")
deb = DQ.default_image_name("debian", "trixie", "amd64", "13")
self.assertEqual(deb, pve)
self.assertNotIn("fedora", pve)
def test_s390x_does_not_exist_and_is_refused(self):
"""Le dépôt amont n'a pas d'index s390x (404). Le refus vaut mieux
qu'un déploiement qui échouera au premier apt."""
self.assertNotIn("proxmox", DQ.ARCH_DISTRO_SUPPORT["s390x"])
def test_arm64_is_official_since_pve_9(self):
self.assertIn("proxmox", DQ.ARCH_DISTRO_SUPPORT["arm64"])
def test_the_floors_are_a_hypervisors_not_debians(self):
"""Proxmox demande 2 Gio pour lui seul et télécharge son noyau : les
1 Gio/20 Go de Debian donneraient une VM incapable d'héberger une
seule invitée."""
_code, _osinfo, ram, disque = DQ.PROXMOX_VERSIONS["9"]
self.assertGreaterEqual(ram, 4096)
self.assertGreaterEqual(int(disque.rstrip("G")), 32)
deb_ram = DQ.DEBIAN_VERSIONS["13"][2]
self.assertGreater(ram, deb_ram)
def test_it_reuses_debians_osinfo(self):
"""Le système EST une Debian : libosinfo n'a pas d'entrée Proxmox, et
en inventer une ferait échouer virt-install."""
self.assertEqual("debian13", DQ.PROXMOX_VERSIONS["9"][1])
def test_it_is_named_after_proxmox_not_after_the_key(self):
self.assertEqual("Proxmox VE 9", DQ.distro_label("proxmox", "9"))
def test_the_login_guide_knows_it_is_apt(self):
self.assertEqual("apt", DQ.DISTRO_PKG["proxmox"])
class TestLesDeuxCatalogues(unittest.TestCase):
"""todo.py duplique le catalogue de deploy_qemu.py. Qu'ils s'accordent."""
def test_the_menu_offers_every_distro_of_the_catalogue(self):
menu = set(TODO._QEMU_DISTROS)
catalogue = set(DQ.DISTROS)
self.assertEqual(
catalogue,
menu,
"les deux catalogues divergent : "
f"menu seul {menu - catalogue}, deploy seul {catalogue - menu}",
)
def test_the_versions_agree_for_proxmox(self):
versions, defaut = TODO._QEMU_DISTROS["proxmox"]
self.assertEqual(["9"], list(versions))
self.assertEqual("9", defaut)
class TestLeProfil(unittest.TestCase):
def setUp(self):
self.todo = TODO.__new__(TODO)
def test_a_dedicated_profile_calls_the_dedicated_script(self):
profils = dict(self.todo._qemu_install_profiles())
libelle = t("Proxmox VE hypervisor (no Odoo)")
self.assertIn(libelle, profils)
self.assertIn("install_proxmox.sh", profils[libelle])
def test_on_a_proxmox_vm_it_comes_first(self):
"""Laisser « Odoo 18 » en défaut ferait poser un ERP sur un
hyperviseur : c'est le contraire de ce qu'on a demandé."""
vus = []
with mock.patch("builtins.input", lambda *a: ""):
import contextlib
import io
with contextlib.redirect_stdout(io.StringIO()):
libelle, cmd = self.todo._qemu_pick_install_profile("proxmox")
vus.append((libelle, cmd))
self.assertIn("install_proxmox.sh", cmd)
def test_on_any_other_vm_odoo_stays_the_default(self):
import contextlib
import io
with mock.patch("builtins.input", lambda *a: ""):
with contextlib.redirect_stdout(io.StringIO()):
libelle, cmd = self.todo._qemu_pick_install_profile("debian")
self.assertNotIn("install_proxmox.sh", cmd)
self.assertIn("install_odoo_18", cmd)
class TestLeScript(unittest.TestCase):
def test_it_is_executable_and_valid_shell(self):
self.assertTrue(os.access(SCRIPT, os.X_OK), "pas exécutable")
res = subprocess.run(
["bash", "-n", str(SCRIPT)], capture_output=True, text=True
)
self.assertEqual(0, res.returncode, res.stderr)
def test_help_names_the_knobs_and_exits_clean(self):
res = subprocess.run(
["bash", str(SCRIPT), "--help"], capture_output=True, text=True
)
self.assertEqual(0, res.returncode)
for knob in ("--dry-run", "PVE_SUITE", "PVE_REBOOT"):
self.assertIn(knob, res.stdout)
def test_the_pinned_key_hash_is_the_published_one(self):
texte = SCRIPT.read_text(encoding="utf-8")
self.assertIn(KEY_SHA256, texte)
self.assertIn("proxmox-archive-keyring-", texte)
def _lance(self, args=(), stubs=None, env=None):
with tempfile.TemporaryDirectory() as tmp:
bin_dir = pathlib.Path(tmp) / "bin"
bin_dir.mkdir()
for nom, corps in (stubs or {}).items():
(bin_dir / nom).write_text(f"#!/bin/bash\n{corps}\n")
(bin_dir / nom).chmod(0o755)
osrel = pathlib.Path(tmp) / "os-release"
osrel.write_text("ID=debian\nVERSION_CODENAME=trixie\n")
return subprocess.run(
["bash", str(SCRIPT), *args],
capture_output=True,
text=True,
env=dict(
os.environ,
PATH=f"{bin_dir}:/usr/bin:/bin",
PVE_OS_RELEASE=str(osrel),
**(env or {}),
),
timeout=120,
)
def test_an_unpublished_architecture_is_refused_by_name(self):
res = self._lance(stubs={"uname": "echo s390x"})
self.assertNotEqual(0, res.returncode)
self.assertIn("s390x", res.stdout + res.stderr)
def test_another_distribution_is_refused_by_name(self):
"""Le script s'installe SUR Debian : le dire vaut mieux qu'un apt qui
échouera sur un dépôt introuvable."""
with tempfile.NamedTemporaryFile(
"w", suffix=".os", delete=False
) as fh:
fh.write("ID=ubuntu\nVERSION_CODENAME=noble\n")
chemin = fh.name
self.addCleanup(os.unlink, chemin)
res = subprocess.run(
["bash", str(SCRIPT)],
capture_output=True,
text=True,
env=dict(os.environ, PVE_OS_RELEASE=chemin),
timeout=60,
)
self.assertNotEqual(0, res.returncode)
self.assertIn("ubuntu", res.stdout + res.stderr)
def test_the_dry_run_follows_the_upstream_procedure(self):
res = self._lance(["--dry-run"])
self.assertEqual(0, res.returncode, res.stdout + res.stderr)
out = res.stdout
for attendu in (
"proxmox-archive-keyring-trixie.gpg",
"Types: deb",
"pve-no-subscription",
"Signed-By: /usr/share/keyrings/proxmox-archive-keyring.gpg",
"proxmox-default-kernel",
"proxmox-ve postfix open-iscsi chrony",
):
self.assertIn(attendu, out, attendu)
def test_the_dry_run_changes_nothing_and_says_so(self):
"""La leçon du montage sshfs : ne rien annoncer qui n'ait eu lieu."""
res = self._lance(["--dry-run"])
self.assertIn("dry-run", res.stdout)
self.assertNotIn("Proxmox VE installé", res.stdout)
def test_postfix_is_preseeded_or_the_install_hangs(self):
"""postfix pose deux questions debconf : sans préréponse, une
installation lancée par SSH reste pendue sur une saisie invisible."""
res = self._lance(["--dry-run"])
self.assertIn("debconf-set-selections", res.stdout)
self.assertIn("main_mailer_type", res.stdout)
def test_it_never_removes_the_proxmox_kernel(self):
"""Le noyau qu'on vient de poser : l'emporter laisserait une VM qui
n'amorce plus. dpkg-query rend ici les deux, seul celui de Debian doit
partir."""
res = self._lance(
["--dry-run"],
stubs={
"uname": "echo x86_64",
"dpkg": "exit 0",
# Format réel de la requête : nom + état. dpkg connaît
# aussi les paquets DÉSINSTALLÉS (« config-files »), et les
# passer à apt réclamait un redémarrage pour rien.
"dpkg-query": (
"printf 'linux-image-6.12.0-amd64 installed\\n"
"linux-image-amd64 config-files\\n"
"linux-image-6.14.11-1-pve installed\\n'"
),
},
)
self.assertEqual(0, res.returncode, res.stdout + res.stderr)
ligne = [
x for x in res.stdout.splitlines() if "remove linux-image" in x
]
self.assertTrue(ligne, res.stdout)
self.assertIn("linux-image-6.12.0-amd64", ligne[0])
self.assertNotIn("pve", ligne[0])
# « config-files » = déjà désinstallé : ne pas le repasser à apt.
self.assertNotIn("linux-image-amd64", ligne[0])
def test_without_the_proxmox_kernel_nothing_is_removed(self):
"""Une étape apt a pu échouer plus haut : on ne touche pas au noyau
Debian tant que celui de Proxmox n'est pas là."""
res = self._lance(
["--dry-run"],
stubs={"uname": "echo x86_64", "dpkg": "exit 1"},
)
self.assertNotIn("remove linux-image", res.stdout)
self.assertIn("noyau Debian reste en place", res.stdout)
def test_grub_pc_is_preseeded_or_dpkg_stops(self):
"""Le piège propre à l'image cloud : elle amorce en EFI, les paquets
pve tirent grub-pc, et sa post-installation refuse de deviner le
disque — « You must correct your GRUB install devices before
proceeding ». Mesuré : dpkg s'arrête et emporte la transaction."""
res = self._lance(["--dry-run"])
self.assertIn("grub-pc/install_devices", res.stdout)
self.assertRegex(res.stdout, r"install_devices multiselect /dev/\w+")
def test_apt_waits_for_the_lock_instead_of_giving_up(self):
"""Sur une VM fraîche, cloud-init tient encore le verrou : mesuré,
« held by process 996 (apt-get) », et le script mourait 40 secondes
après le démarrage."""
texte = SCRIPT.read_text(encoding="utf-8")
self.assertIn("DPkg::Lock::Timeout", texte)
res = self._lance(["--dry-run"])
self.assertIn("DPkg::Lock::Timeout", res.stdout)
def test_it_waits_for_cloud_init_but_not_for_its_verdict(self):
"""cloud-init rend « error » sur l'image Debian 13 pour deux modules
sans rapport (console-setup absent, update-locale) alors qu'il a bien
fini. On attend qu'il termine, pas qu'il soit content."""
res = self._lance(["--dry-run"])
self.assertIn("cloud-init status --wait", res.stdout)
# L'échec de cloud-init ne doit PAS arrêter le script : « || true »
# sur l'appel réel, pas sur l'écho du dry-run.
texte = SCRIPT.read_text(encoding="utf-8")
reel = [
x
for x in texte.splitlines()
if "cloud-init status --wait" in x and "dry-run" not in x
]
self.assertTrue(reel, texte)
self.assertIn("|| true", reel[0])
def _esp(self, avec_secours):
"""Fausse partition EFI : un stub Debian, et le chemin de secours."""
racine = tempfile.mkdtemp()
self.addCleanup(
lambda: subprocess.run(["rm", "-rf", racine], check=False)
)
deb = pathlib.Path(racine, "EFI", "debian")
boot = pathlib.Path(racine, "EFI", "BOOT")
deb.mkdir(parents=True)
boot.mkdir(parents=True)
(deb / "grub.cfg").write_text(
"search.fs_uuid 81e2a465 root\nset prefix=($root)'/boot/grub'\n"
)
(boot / "BOOTX64.EFI").write_text("binaire")
if avec_secours:
(boot / "grub.cfg").write_text("déjà là")
return racine
def test_the_efi_fallback_stub_is_restored(self):
"""Sans ce fichier, GRUB s'arrête sur « grub> » : ni menu ni noyau.
Vécu après le premier redémarrage — la VM brûlait 100 % d'un cœur sans
lire une seule fois le disque, capture d'écran à l'appui."""
esp = self._esp(avec_secours=False)
res = self._lance(["--dry-run"], env={"PVE_ESP": esp})
self.assertEqual(0, res.returncode, res.stdout + res.stderr)
self.assertIn("amorçage de secours", res.stdout)
self.assertIn("EFI/BOOT/", res.stdout)
def test_a_working_fallback_is_left_alone(self):
"""Ne pas réécrire ce qui marche : le stub en place peut avoir été
ajusté à la main."""
esp = self._esp(avec_secours=True)
res = self._lance(["--dry-run"], env={"PVE_ESP": esp})
self.assertNotIn("amorçage de secours", res.stdout)
def test_an_esp_without_any_stub_is_said_not_guessed(self):
racine = tempfile.mkdtemp()
self.addCleanup(
lambda: subprocess.run(["rm", "-rf", racine], check=False)
)
pathlib.Path(racine, "EFI", "BOOT").mkdir(parents=True)
res = self._lance(["--dry-run"], env={"PVE_ESP": racine})
self.assertIn("aucun grub.cfg à recopier", res.stdout)
def test_it_does_not_reboot_unless_asked(self):
"""Lancé par SSH depuis le déploiement, un reboot couperait la session
et ferait passer une installation réussie pour un échec."""
texte = SCRIPT.read_text(encoding="utf-8")
self.assertIn('PVE_REBOOT:-0}" != "1"', texte)
res = self._lance(["--dry-run"])
self.assertNotIn("systemctl reboot", res.stdout)
if __name__ == "__main__":
unittest.main(verbosity=1)

546
test/test_run_sh_auto_db.py Normal file
View file

@ -0,0 +1,546 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""« ./run.sh » sans argument choisit sa base — sans jamais bloquer.
Le danger n'est pas le menu, c'est l'endroit où il ne doit PAS s'ouvrir.
`script/systemd/install_daemon.sh:34` écrit `ExecStart=/bin/bash …/run.sh`
sans le moindre argument, avec `Restart=always` et `RestartSec=5` : une
question posée là ne serait pas une pause, ce serait une boucle de
redémarrage, chacun repayant 0,8 s d'import Odoo.
D'où la forme des épreuves : on mesure d'abord ce qui NE doit pas arriver
— la sonde jamais lancée, aucun `-d` injecté, la ligne d'arguments intacte
à l'octet — et seulement ensuite le menu, derrière un vrai
pseudo-terminal, seul moyen de rendre `-t 0` et `-t 2` vrais ensemble.
"""
import os
import pathlib
import pty
import select
import shutil
import subprocess
import tempfile
import time
import unittest
RACINE = pathlib.Path(__file__).resolve().parents[1]
SCRIPT = RACINE / "run.sh"
LIB = RACINE / "script" / "database" / "lib_db_select.sh"
DELAI = 30
# Le bouchon joue les DEUX rôles : la sonde (`db --list`) et le lancement
# d'Odoo. `$1` vaut « db » dans un cas et « -c » dans l'autre, le
# discriminant est donc fiable.
#
# `[%s]` par argument, et non `$*` : c'est ce format qui prouve qu'un
# argument vide et un argument à espaces traversent intacts. Une chaîne
# recollée les aurait perdus sans que rien ne le dise.
BOUCHON = """#!/usr/bin/env bash
case "$1" in
db) touch ./SONDE_APPELEE; %s ;;
*) printf 'ARGS:'; printf '[%%s]' "$@"; printf '\\n' ;;
esac
"""
TROIS_BASES = BOUCHON % "printf '%s\\n' _cache_odoo18.0_base demo prod"
UNE_BASE = BOUCHON % "printf '%s\\n' _cache_odoo18.0_base demo"
QUE_DU_CACHE = BOUCHON % "printf '%s\\n' _cache_odoo18.0_base"
AUCUNE_BASE = BOUCHON % ":"
# Le cas qui compte pour la fusion des flux : la sonde RÉUSSIT, et parle
# quand même sur stderr. Le CLI « db » n'appelle jamais parse_config(),
# donc aucun journal n'est configuré et tout enregistrement part nu par là.
SONDE_BAVARDE = BOUCHON % (
"printf 'WARNING odoo.modules.module: module xyz not loadable\\n' >&2;"
" printf '%s\\n' demo"
)
SONDE_CASSEE = BOUCHON % (
"printf 'Traceback (most recent call last):\\n"
' File \\"x.py\\", line 1\\nOperationalError\\n\' >&2; exit 1'
)
class Banc:
"""Un dépôt jetable : run.sh ne fait aucun `cd` et résout tout du cwd."""
def __init__(
self, bouchon=TROIS_BASES, config="[options]\ndb_name = False\n"
):
self.dossier = tempfile.mkdtemp(prefix="el_run_sh_")
chemin = pathlib.Path(self.dossier)
(chemin / "script" / "database").mkdir(parents=True)
shutil.copy(SCRIPT, chemin / "run.sh")
shutil.copy(LIB, chemin / "script" / "database" / "lib_db_select.sh")
(chemin / "config.conf").write_text(config, encoding="utf-8")
stub = chemin / "odoo_bin.sh"
stub.write_text(bouchon, encoding="utf-8")
stub.chmod(0o755)
self.chemin = chemin
def sonde_appelee(self):
return (self.chemin / "SONDE_APPELEE").exists()
def lancer(self, *argv, env=None, entree=None):
milieu = dict(os.environ)
milieu.pop("ODOO_MODE_TEST", None)
milieu.pop("ODOO_MODE_COVERAGE", None)
if env:
milieu.update(env)
return subprocess.run(
["./run.sh", *argv],
cwd=self.dossier,
capture_output=True,
text=True,
timeout=DELAI,
input=entree if entree is not None else "",
env=milieu,
)
def conduire(self, *argv, frappe=""):
"""Derrière un vrai pseudo-terminal — le seul `-t 0` et `-t 2` vrais."""
maitre, esclave = pty.openpty()
proc = subprocess.Popen(
["./run.sh", *argv],
cwd=self.dossier,
stdin=esclave,
stdout=esclave,
stderr=esclave,
close_fds=True,
)
os.close(esclave)
if frappe:
time.sleep(0.4)
os.write(maitre, frappe.encode())
morceaux = []
# ÉCHÉANCE sur la LECTURE, pas seulement sur l'attente du fils.
# `os.read` sur un pseudo-terminal bloque tant que le maître est
# ouvert : si une régression ouvre un menu là où le test ne tape
# rien, la suite entière pend au lieu d'échouer. Mesuré — une
# mutation du filtre « _cache_ » a fait durer 600 s.
limite = time.monotonic() + DELAI
depasse = False
try:
while True:
reste = limite - time.monotonic()
if reste <= 0:
depasse = True
break
pret, _, _ = select.select([maitre], [], [], reste)
if not pret:
depasse = True
break
try:
bloc = os.read(maitre, 4096)
except OSError:
break
if not bloc:
break
morceaux.append(bloc)
finally:
if depasse:
proc.kill()
code = proc.wait(timeout=DELAI)
os.close(maitre)
texte = b"".join(morceaux).decode(errors="replace").replace("\r", "")
if depasse:
raise AssertionError(
f"run.sh n'a pas rendu la main en {DELAI} s — il attend"
f" sans doute une réponse. Vu :\n{texte}"
)
return code, texte
def nettoyer(self):
shutil.rmtree(self.dossier, ignore_errors=True)
class BancTest(unittest.TestCase):
bouchon = TROIS_BASES
config = "[options]\ndb_name = False\n"
def setUp(self):
self.banc = Banc(self.bouchon, self.config)
self.addCleanup(self.banc.nettoyer)
class TestTheScriptsAreValid(unittest.TestCase):
def test_both_files_parse(self):
for chemin in (SCRIPT, LIB):
with self.subTest(chemin=chemin.name):
res = subprocess.run(
["bash", "-n", str(chemin)],
capture_output=True,
text=True,
timeout=DELAI,
)
self.assertEqual(0, res.returncode, res.stderr)
def test_run_sh_is_still_executable(self):
self.assertTrue(os.access(SCRIPT, os.X_OK), SCRIPT)
def test_the_library_announces_itself_sourceable(self):
# Convention du dépôt : lib_pip_provider.sh, lib_python_provider.sh.
self.assertIn(
"Bibliothèque SOURÇABLE", LIB.read_text(encoding="utf-8")
)
def test_the_library_sets_no_shell_option(self):
# Une bibliothèque sourcée imposerait ses options à run.sh, qui lit
# $ODOO_MODE_TEST sans valeur par défaut : `set -u` le tuerait.
for ligne in LIB.read_text(encoding="utf-8").splitlines():
self.assertFalse(
ligne.strip().startswith("set -"), f"« {ligne.strip()} »"
)
class TestNothingHappensWhereNobodyAsked(BancTest):
"""Le contrat de non-régression : systemd, la migration, les scripts."""
def test_zero_argument_without_a_terminal_never_probes(self):
# Le cas systemd. La sonde coûte 0,8 s et se paierait à chaque
# redémarrage d'un service en boucle.
res = self.banc.lancer()
self.assertEqual(0, res.returncode, res.stderr)
self.assertFalse(self.banc.sonde_appelee(), res.stderr)
def test_zero_argument_without_a_terminal_injects_nothing(self):
res = self.banc.lancer()
self.assertIn("ARGS:", res.stdout)
self.assertNotIn("[-d]", res.stdout)
def test_the_forwarded_line_is_byte_for_byte_what_it_always_was(self):
res = self.banc.lancer()
self.assertEqual(
"ARGS:[-c][./config.conf][--limit-time-real][99999]"
"[--limit-time-cpu][99999][--limit-memory-hard=0]",
res.stdout.strip(),
res.stderr,
)
def test_an_empty_argument_and_one_with_spaces_survive(self):
# « ./run.sh -d $(bd) » de make.robotlibre produit un -d vide dès
# que bd n'est pas défini : reconstruire la ligne le perdrait.
res = self.banc.lancer("--log-handler", "a b", "-d", "")
self.assertIn("[--log-handler][a b][-d][]", res.stdout, res.stderr)
def test_the_test_mode_branch_behaves_the_same(self):
res = self.banc.lancer(env={"ODOO_MODE_TEST": "true"})
self.assertIn("[--test-enable]", res.stdout, res.stderr)
self.assertNotIn("[-d]", res.stdout)
self.assertFalse(self.banc.sonde_appelee())
def test_the_test_mode_branch_also_receives_the_chosen_database(self):
# Deux branches lancent Odoo. Oublier l'une des deux ne se voit
# nulle part ailleurs : « make test » passerait sans base et l'on
# chercherait la cause dans Odoo.
banc = Banc(UNE_BASE, self.config)
self.addCleanup(banc.nettoyer)
res = banc.lancer("--auto-erplibre", env={"ODOO_MODE_TEST": "true"})
self.assertIn("[--test-enable]", res.stdout, res.stderr)
self.assertIn("[-d][demo]", res.stdout, res.stderr)
def test_a_database_already_named_disarms_everything(self):
# Les quatre formes qu'optparse accepte, plus « -d » nu — ce que
# produit « ./run.sh -d $(bd) » quand bd n'est pas défini. On ne
# complète jamais le choix de l'appelant, et l'on ne paie pas la
# sonde pour rien.
for forme in (
["-d", "demo"],
["-ddemo"],
["--database", "demo"],
["--database=demo"],
["-d"],
):
with self.subTest(forme=" ".join(forme)):
banc = Banc(self.bouchon, self.config)
self.addCleanup(banc.nettoyer)
res = banc.lancer("--auto-erplibre", *forme)
self.assertFalse(banc.sonde_appelee(), res.stderr)
# Rien d'ajouté : la ligne se termine sur ce que l'appelant
# a écrit, sans « -d » de plus derrière.
attendu = "".join(f"[{arg}]" for arg in forme)
self.assertTrue(
res.stdout.strip().endswith(attendu), res.stdout
)
class TestTheExitContractIsUnchanged(unittest.TestCase):
"""run.sh convertit tout échec d'Odoo en 1, et laisse passer le succès.
L'analyse des options a inséré du code ENTRE le lancement d'Odoo et le
`retVal=$?` de la fin. Des commentaires ne touchent pas `$?` — vérifié
— mais une ligne exécutable ajoutée là volerait le code de sortie sans
que rien ne le dise.
"""
def banc_qui_sort(self, code):
b = Banc(TROIS_BASES)
self.addCleanup(b.nettoyer)
(b.chemin / "odoo_bin.sh").write_text(
f"#!/usr/bin/env bash\nexit {code}\n", encoding="utf-8"
)
(b.chemin / "odoo_bin.sh").chmod(0o755)
return b
def test_a_failing_odoo_becomes_one(self):
res = self.banc_qui_sort(7).lancer("--workers", "0")
self.assertEqual(1, res.returncode, res.stderr)
def test_a_successful_odoo_stays_zero(self):
res = self.banc_qui_sort(0).lancer("--workers", "0")
self.assertEqual(0, res.returncode, res.stderr)
class TestTheTwoOptionsNeverReachOdoo(BancTest):
def test_both_are_stripped(self):
res = self.banc.lancer(
"--auto-erplibre", "--no-cli-erplibre", "--workers", "0"
)
self.assertNotIn("auto-erplibre", res.stdout, res.stdout)
self.assertNotIn("no-cli-erplibre", res.stdout, res.stdout)
self.assertIn("[--workers][0]", res.stdout)
def test_a_neighbouring_option_is_not_eaten(self):
# Une correspondance trop large avalerait des options d'Odoo.
for voisine in (
"--auto-erplibr",
"--auto",
"--no-cli",
"--auto-erplibrex",
):
with self.subTest(voisine=voisine):
banc = Banc(self.bouchon, self.config)
self.addCleanup(banc.nettoyer)
res = banc.lancer(voisine)
self.assertIn(f"[{voisine}]", res.stdout, res.stderr)
def test_odoo_never_sees_them_even_when_they_do_something(self):
banc = Banc(UNE_BASE, self.config)
self.addCleanup(banc.nettoyer)
res = banc.lancer("--auto-erplibre")
self.assertIn("[-d][demo]", res.stdout, res.stderr)
self.assertNotIn("erplibre]", res.stdout)
class TestWhatTheProbeReturns(unittest.TestCase):
def banc(self, bouchon, config="[options]\ndb_name = False\n"):
b = Banc(bouchon, config)
self.addCleanup(b.nettoyer)
return b
def test_a_single_database_is_taken_without_asking(self):
banc = self.banc(UNE_BASE)
res = banc.lancer("--auto-erplibre")
self.assertIn("[-d][demo]", res.stdout, res.stderr)
def test_template_databases_are_not_candidates(self):
# `_cache_…` est une base-modèle. Sur une machine qui vient de
# tester, ce peut être la seule : sans filtre, la règle « une
# seule base » démarrerait Odoo sur un modèle.
banc = self.banc(QUE_DU_CACHE)
res = banc.lancer("--auto-erplibre")
self.assertNotIn("[-d]", res.stdout, res.stderr)
self.assertNotIn("_cache_", res.stdout)
self.assertEqual(0, res.returncode)
def test_no_database_at_all_starts_anyway(self):
banc = self.banc(AUCUNE_BASE)
res = banc.lancer("--auto-erplibre")
self.assertEqual(0, res.returncode, res.stderr)
self.assertNotIn("[-d]", res.stdout)
self.assertIn("ARGS:", res.stdout)
def test_a_warning_on_stderr_never_becomes_a_database_name(self):
# Une sonde qui RÉUSSIT en parlant sur stderr : c'est là que la
# fusion des flux se voit. Avec `2>&1`, l'avertissement devient
# une seconde « base », la règle « une seule » ne s'applique plus
# et l'on ouvre un menu pour un texte de journal. Le défaut exact
# que script/database/db_drop_all.py porte encore.
banc = self.banc(SONDE_BAVARDE)
res = banc.lancer("--auto-erplibre")
self.assertIn("[-d][demo]", res.stdout, res.stdout)
self.assertNotIn("WARNING", res.stdout, res.stdout)
def test_a_failing_probe_never_becomes_a_database_name(self):
# Fusionner stdout et stderr ferait de chaque ligne d'une trace un
# nom de base — le défaut que database_manager.py a déjà corrigé.
banc = self.banc(SONDE_CASSEE)
res = banc.lancer("--auto-erplibre")
self.assertEqual(0, res.returncode, res.stderr)
self.assertNotIn("[-d]", res.stdout)
self.assertNotIn("Traceback", res.stdout)
self.assertNotIn("OperationalError", res.stdout)
# …et le message doit dire « je n'ai pas pu lire », pas « il n'y
# en a pas ». Les deux mènent au même démarrage, mais pas au même
# endroit où chercher : PostgreSQL à terre n'est pas une machine
# neuve. Le CLI d'Odoo confond déjà les deux en rendant 0 sur une
# liste vide ; au moins ne pas ajouter notre propre confusion là
# où le code de sortie, lui, sait.
self.assertIn("lister", res.stderr, res.stderr)
self.assertNotIn("Aucune base", res.stderr, res.stderr)
def test_an_empty_list_says_something_else_than_a_failure(self):
banc = self.banc(AUCUNE_BASE)
res = banc.lancer("--auto-erplibre")
self.assertIn("Aucune base", res.stderr, res.stderr)
self.assertNotIn("lister", res.stderr, res.stderr)
def test_a_configuration_that_names_its_database_is_left_alone(self):
# L'option de ligne de commande l'emporte sur le fichier : injecter
# écraserait en silence le choix d'une production.
banc = self.banc(UNE_BASE, "[options]\ndb_name = prod\n")
res = banc.lancer("--auto-erplibre")
self.assertFalse(banc.sonde_appelee(), res.stderr)
self.assertNotIn("[-d]", res.stdout)
def test_db_name_false_is_not_a_name(self):
for valeur in ("False", "false", "None"):
with self.subTest(valeur=valeur):
banc = self.banc(UNE_BASE, f"[options]\ndb_name = {valeur}\n")
res = banc.lancer("--auto-erplibre")
self.assertIn("[-d][demo]", res.stdout, res.stderr)
class TestWhenSeveralAndNoWayToAsk(unittest.TestCase):
def banc(self, bouchon=TROIS_BASES):
b = Banc(bouchon)
self.addCleanup(b.nettoyer)
return b
def test_no_cli_erplibre_picks_nothing_and_says_so(self):
banc = self.banc()
res = banc.lancer("--auto-erplibre", "--no-cli-erplibre")
self.assertEqual(0, res.returncode, res.stderr)
self.assertNotIn("[-d]", res.stdout)
self.assertIn("no-cli-erplibre", res.stderr, res.stderr)
def test_no_terminal_picks_nothing_and_says_so(self):
banc = self.banc()
res = banc.lancer("--auto-erplibre")
self.assertEqual(0, res.returncode, res.stderr)
self.assertNotIn("[-d]", res.stdout)
self.assertIn("2", res.stderr, res.stderr)
def test_no_cli_erplibre_still_takes_a_lone_database(self):
# L'option interdit le MENU, pas le choix. Sinon elle ferait
# doublon avec « ne pas écrire --auto-erplibre ».
banc = self.banc(UNE_BASE)
res = banc.lancer("--auto-erplibre", "--no-cli-erplibre")
self.assertIn("[-d][demo]", res.stdout, res.stderr)
class TestTheMenuBehindARealTerminal(unittest.TestCase):
"""Un pseudo-terminal : seul moyen de rendre `-t 0` et `-t 2` vrais."""
def banc(self, bouchon=TROIS_BASES):
b = Banc(bouchon)
self.addCleanup(b.nettoyer)
return b
def test_zero_argument_opens_the_menu_and_honours_the_choice(self):
code, texte = self.banc().conduire(frappe="2\n")
self.assertEqual(0, code, texte)
self.assertIn("[1] demo", texte)
self.assertIn("[2] prod", texte)
self.assertIn("[-d][prod]", texte)
def test_the_template_database_is_absent_from_the_menu(self):
code, texte = self.banc().conduire(frappe="1\n")
self.assertNotIn("_cache_", texte, texte)
self.assertIn("[-d][demo]", texte)
def test_an_invalid_answer_asks_again_rather_than_guessing(self):
code, texte = self.banc().conduire(frappe="9\nzzz\n1\n")
self.assertEqual(0, code, texte)
self.assertIn("[-d][demo]", texte)
self.assertGreaterEqual(texte.count("Choix invalide"), 2, texte)
def test_cancelling_starts_nothing_and_returns_130(self):
# 130, pas 1 : run.sh réserve déjà 1 à « Odoo a échoué ».
code, texte = self.banc().conduire(frappe="0\n")
self.assertEqual(130, code, texte)
self.assertNotIn("ARGS:", texte, texte)
def test_end_of_input_is_a_refusal_not_a_hang(self):
code, texte = self.banc().conduire(frappe="\x04")
self.assertEqual(130, code, texte)
self.assertNotIn("ARGS:", texte)
def test_a_lone_database_asks_nothing_at_all(self):
code, texte = self.banc(UNE_BASE).conduire(frappe="")
self.assertEqual(0, code, texte)
self.assertNotIn("Choix", texte, texte)
self.assertIn("[-d][demo]", texte)
def test_no_cli_erplibre_silences_the_menu_even_on_a_terminal(self):
code, texte = self.banc().conduire("--no-cli-erplibre", frappe="")
self.assertEqual(0, code, texte)
self.assertNotIn("Choix", texte, texte)
self.assertNotIn("[-d]", texte)
class TestTheLibraryOnItsOwn(unittest.TestCase):
"""Les fonctions, hors de run.sh, en remplaçant le garde de terminal."""
def bash(self, corps, entree="", cwd=None):
return subprocess.run(
["bash", "-c", f". {LIB}\n{corps}"],
capture_output=True,
text=True,
timeout=DELAI,
input=entree,
cwd=cwd or str(RACINE),
)
def test_it_recognises_every_way_of_naming_a_database(self):
for forme in ("-d x", "-dx", "--database x", "--database=x", "-d"):
with self.subTest(forme=forme):
res = self.bash(f"el_db_already_chosen {forme} && echo OUI")
self.assertIn("OUI", res.stdout, res.stderr)
def test_it_does_not_mistake_a_neighbour_for_a_database(self):
for forme in ("--dev x", "--data-dir /tmp", "--db-filter x", "-u all"):
with self.subTest(forme=forme):
res = self.bash(f"el_db_already_chosen {forme} || echo NON")
self.assertIn("NON", res.stdout, res.stderr)
def test_the_menu_writes_nothing_to_stdout_but_the_name(self):
# Le menu part sur stdout et il devient une partie du nom de base.
res = self.bash("el_db_choose alpha zebre", entree="2\n")
self.assertEqual("zebre", res.stdout.strip(), res.stdout)
self.assertIn("[1] alpha", res.stderr)
def test_the_guard_tests_stdin_and_stderr_not_stdout(self):
# Dans `$( … )`, stdout est TOUJOURS un tube : tester -t 1 fermerait
# le menu même devant un vrai terminal. Mesuré, puis corrigé.
#
# Les commentaires sont retirés : ils PARLENT de « -t 1 », et un
# test qui les lit accuserait la documentation du défaut qu'elle
# explique.
texte = LIB.read_text(encoding="utf-8")
debut = texte.index("_el_db_tty()")
corps = "\n".join(
ligne
for ligne in texte[debut : texte.index("}", debut)].splitlines()
if not ligne.strip().startswith("#")
)
self.assertIn("-t 0", corps)
self.assertIn("-t 2", corps)
self.assertNotIn("-t 1", corps)
def test_the_guard_refuses_a_pipe_on_stderr(self):
# Le cas du TUI de todo.py : stdout et stderr en tube, stdin intact.
res = subprocess.run(
["bash", "-c", f". {LIB}\n_el_db_tty && echo ARME || echo REPOS"],
capture_output=True,
text=True,
timeout=DELAI,
cwd=str(RACINE),
)
self.assertIn("REPOS", res.stdout, res.stdout)
if __name__ == "__main__":
unittest.main()

389
test/test_todo_sshfs.py Normal file
View file

@ -0,0 +1,389 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Montage sshfs : ne rien annoncer qui n'ait eu lieu, et dire pourquoi.
Le symptôme rapporté : « read: Connection reset by peer », « code : 1 », et
juste après « Monté sur /tmp/sshfs_… », la commande pour démonter et celle
pour ouvrir le répertoire dans un explorateur. Le montage n'avait pas eu lieu.
La cause, elle, est plus profonde : sshfs lit « a+b » comme un CHAÎNAGE
d'hôtes — « ssh a, puis ssh b depuis a » — et ne consulte donc jamais
~/.ssh/config pour l'alias entier. Or c'est todo.py qui nomme les VM
découvertes « rebond+domaine », et la seconde moitié de ce nom est un domaine
libvirt, pas un alias SSH du rebond. Ces alias-là, les plus utiles, étaient
les seuls que sshfs ne pouvait pas monter.
Ce que ces tests gardent :
- Aucune ligne de succès après un code non nul.
- Le point de montage inutilisé est retiré, pas laissé dans /tmp.
- Un alias à « + » est résolu par ssh lui-même (« ssh -G ») et rendu à sshfs
sous une forme qu'il ne peut plus mal lire, options comprises — sans
StrictHostKeyChecking, une VM à l'IP recyclée échouerait sur sa clé d'hôte.
- « Host a b » déclare DEUX alias : c'est ce que le générateur du dépôt écrit.
"""
import contextlib
import io
import os
import subprocess
import sys
import tempfile
import unittest
from unittest import mock
sys.argv = ["todo.py"]
from script.todo.todo import TODO # noqa: E402
from script.todo.todo_i18n import t # noqa: E402
# Entrée réelle écrite par todo.py pour une VM derrière un rebond.
CONFIG = """Host *
ServerAliveInterval 60
Host novipro_private
HostName 192.168.100.110
User mathben
Host novipro_private+ERPLibre01
HostName 192.168.122.50
User mathben
StrictHostKeyChecking no
UserKnownHostsFile /dev/null
IdentityFile /home/erplibre/.ssh/id_ed25519
IdentitiesOnly yes
ProxyJump novipro_private
Host erplibre-ubuntu-2604 erplibre-2604-bis
HostName 192.168.123.165
User erplibre
Host web-?
User www
"""
# Sortie de « ssh -G » pour l'alias à « + », réduite à ce qui compte.
SSH_G = """host novipro_private+erplibre01
hostname 192.168.122.50
user mathben
port 22
proxyjump novipro_private
identityfile /home/erplibre/.ssh/id_ed25519
identityfile ~/.ssh/id_rsa
identitiesonly yes
stricthostkeychecking false
userknownhostsfile /dev/null
"""
def _config(texte=CONFIG):
tmp = tempfile.NamedTemporaryFile(
"w", suffix=".config", delete=False, encoding="utf-8"
)
tmp.write(texte)
tmp.close()
return tmp.name
class TestLectureConfig(unittest.TestCase):
def setUp(self):
self.chemin = _config()
self.addCleanup(os.unlink, self.chemin)
def test_it_reads_hosts_in_file_order(self):
hosts = TODO._ssh_config_entries(self.chemin)
noms = [n for n, _i in hosts]
self.assertEqual("novipro_private", noms[0])
self.assertIn("novipro_private+ERPLibre01", noms)
def test_a_host_line_with_two_patterns_gives_two_aliases(self):
"""C'est ce que le générateur du dépôt écrit (« Host {' '.join(names)} »).
Les prendre pour un seul nom donnait l'alias « a b », que sshfs ne peut
pas monter — et qui n'existe pour personne."""
hosts = dict(TODO._ssh_config_entries(self.chemin))
self.assertIn("erplibre-ubuntu-2604", hosts)
self.assertIn("erplibre-2604-bis", hosts)
self.assertEqual(
"192.168.123.165", hosts["erplibre-ubuntu-2604"]["hostname"]
)
self.assertEqual(
hosts["erplibre-ubuntu-2604"], hosts["erplibre-2604-bis"]
)
def test_wildcard_patterns_are_left_out(self):
"""« Host * » et « Host web-? » ne désignent aucune machine : les
proposer dans un menu de montage n'a pas de sens."""
noms = [n for n, _i in TODO._ssh_config_entries(self.chemin)]
self.assertNotIn("*", noms)
self.assertNotIn("web-?", noms)
def test_the_plus_alias_stays_one_name(self):
noms = [n for n, _i in TODO._ssh_config_entries(self.chemin)]
self.assertNotIn("novipro_private", noms[1:2] and [])
self.assertIn("novipro_private+ERPLibre01", noms)
def test_hostname_and_user_are_kept(self):
hosts = dict(TODO._ssh_config_entries(self.chemin))
info = hosts["novipro_private+ERPLibre01"]
self.assertEqual("192.168.122.50", info["hostname"])
self.assertEqual("mathben", info["user"])
def test_a_missing_file_is_not_a_crash(self):
self.assertEqual([], TODO._ssh_config_entries("/nexistepas/config"))
class TestResolution(unittest.TestCase):
def test_it_reads_ssh_dash_g(self):
with mock.patch(
"subprocess.run",
return_value=subprocess.CompletedProcess([], 0, SSH_G, ""),
):
cfg = TODO._ssh_resolve("novipro_private+ERPLibre01")
self.assertEqual("192.168.122.50", cfg["hostname"])
self.assertEqual("novipro_private", cfg["proxyjump"])
def test_the_first_identityfile_wins(self):
"""ssh -G les répète toutes ; la première est celle qu'il essaiera."""
with mock.patch(
"subprocess.run",
return_value=subprocess.CompletedProcess([], 0, SSH_G, ""),
):
cfg = TODO._ssh_resolve("x")
self.assertEqual("/home/erplibre/.ssh/id_ed25519", cfg["identityfile"])
def test_a_failing_ssh_resolves_to_nothing(self):
with mock.patch(
"subprocess.run",
return_value=subprocess.CompletedProcess([], 255, "", "bad"),
):
self.assertEqual({}, TODO._ssh_resolve("x"))
with mock.patch("subprocess.run", side_effect=OSError):
self.assertEqual({}, TODO._ssh_resolve("x"))
class TestCommandeSshfs(unittest.TestCase):
def setUp(self):
# Méthode d'instance : elle interroge self._ssh_resolve quand aucune
# résolution ne lui est fournie.
self.todo = TODO.__new__(TODO)
self.todo._ssh_resolve = lambda alias: {}
def test_an_alias_without_plus_is_handed_over_untouched(self):
"""Sans « + », c'est ssh qui lit la config, et rien ne vaut mieux."""
cmd, contourne = self.todo._sshfs_command("vm-a", "/tmp/mnt")
self.assertEqual("sshfs -o follow_symlinks vm-a:/ /tmp/mnt", cmd)
self.assertFalse(contourne)
def _resolue(self):
return {
"hostname": "192.168.122.50",
"user": "mathben",
"port": "22",
"proxyjump": "novipro_private",
"identityfile": "/home/erplibre/.ssh/id_ed25519",
"identitiesonly": "yes",
"stricthostkeychecking": "false",
"userknownhostsfile": "/dev/null",
}
def test_a_plus_alias_becomes_a_resolved_target(self):
cmd, contourne = self.todo._sshfs_command(
"novipro_private+ERPLibre01", "/tmp/mnt", self._resolue()
)
self.assertTrue(contourne)
self.assertIn("mathben@192.168.122.50:/", cmd)
self.assertNotIn("+", cmd)
def test_the_options_that_matter_travel_with_it(self):
"""Sans ProxyJump, la VM est injoignable ; sans StrictHostKeyChecking,
une IP DHCP recyclée fait échouer le montage sur sa clé d'hôte."""
cmd, _ = self.todo._sshfs_command("a+b", "/tmp/mnt", self._resolue())
for attendu in (
"-o ProxyJump=novipro_private",
"-o Port=22",
"-o IdentityFile=/home/erplibre/.ssh/id_ed25519",
"-o IdentitiesOnly=yes",
"-o StrictHostKeyChecking=false",
"-o UserKnownHostsFile=/dev/null",
):
self.assertIn(attendu, cmd)
def test_options_ssh_reports_as_none_are_not_forwarded(self):
"""« ssh -G » écrit « proxyjump none » quand il n'y en a pas ; le
transmettre ferait échouer ssh sur une valeur qu'il vient d'inventer.
"""
cfg = dict(self._resolue(), proxyjump="none")
cmd, _ = self.todo._sshfs_command("a+b", "/tmp/mnt", cfg)
self.assertNotIn("ProxyJump", cmd)
def test_an_unresolvable_alias_is_left_alone(self):
"""Mieux vaut la commande d'origine, qui échouera en le disant, qu'une
cible inventée qui monterait la mauvaise machine."""
cmd, contourne = self.todo._sshfs_command("a+b", "/tmp/mnt", {})
self.assertEqual("sshfs -o follow_symlinks a+b:/ /tmp/mnt", cmd)
self.assertFalse(contourne)
def test_the_mount_point_stays_last(self):
"""La syntaxe de sshfs : cible puis point de montage. Une option glissée
après monterait ailleurs."""
cmd, _ = self.todo._sshfs_command("a+b", "/tmp/mnt", self._resolue())
self.assertTrue(cmd.endswith(" /tmp/mnt"), cmd)
class TestDiagnostic(unittest.TestCase):
def test_each_kind_of_failure_names_its_culprit(self):
cas = (
(
"ssh: Could not resolve hostname zz: Name or service not known",
"unknown host name: check HostName",
),
(
"ssh: connect to host x port 22: Connection timed out",
"no answer: is the server up and reachable?",
),
(
"ssh: connect to host x port 22: No route to host",
"no route: check the network or the ProxyJump",
),
(
"ssh: connect to host x port 22: Connection refused",
"nothing listening on the SSH port",
),
(
"mathben@x: Permission denied (publickey).",
"authentication refused: check User and key",
),
(
"Host key verification failed.",
"host key changed for this address",
),
)
for stderr, attendu in cas:
self.assertEqual(attendu, TODO._ssh_failure_hint(stderr), stderr)
def test_an_unknown_error_is_not_guessed(self):
"""Rien à dire plutôt qu'un diagnostic inventé : la ligne brute de ssh
sera affichée telle quelle."""
self.assertEqual("", TODO._ssh_failure_hint("sshfs: fuse: bidule"))
self.assertEqual("", TODO._ssh_failure_hint(""))
class TestFlux(unittest.TestCase):
"""Le parcours complet, exécuteur bouchonné."""
def _todo(self, code, probe=(0, "")):
todo = TODO.__new__(TODO)
todo.lances = []
exe = mock.Mock()
def lance(cmd, **kw):
todo.lances.append(cmd)
return code
exe.exec_command_live = lance
todo.execute = exe
todo._ssh_probe = lambda alias, timeout=8: probe
todo._ssh_resolve = lambda alias: {
"hostname": "192.168.122.50",
"user": "mathben",
"proxyjump": "novipro_private",
}
return todo
def _joue(self, todo, config, selection="2"):
chemin = _config(config)
self.addCleanup(os.unlink, chemin)
reponses = iter(["2", selection])
crees = []
vrai_makedirs = os.makedirs
vrai_rmdir = os.rmdir
def makedirs(path, **kw):
crees.append(path)
return vrai_makedirs(path, **kw)
retires = []
def rmdir(path):
retires.append(path)
return vrai_rmdir(path)
out = io.StringIO()
with mock.patch(
"builtins.input", lambda *a: next(reponses, "")
), mock.patch(
"os.path.expanduser",
lambda p: chemin if p.endswith("config") else p,
), mock.patch(
"os.makedirs", makedirs
), mock.patch(
"os.rmdir", rmdir
):
with contextlib.redirect_stdout(out):
todo._configure_sshfs()
return out.getvalue(), crees, retires
def test_a_failed_mount_announces_nothing_mounted(self):
"""Le cœur du problème rapporté : « Monté sur … » après un code 1."""
todo = self._todo(1)
sortie, _crees, _retires = self._joue(todo, CONFIG, "2")
self.assertNotIn(t("Mounted on: "), sortie)
self.assertNotIn("fusermount", sortie)
self.assertNotIn("nautilus", sortie)
self.assertIn(t("sshfs mount failed."), sortie)
def test_a_failed_mount_leaves_no_empty_directory(self):
"""Une tentative par jour pendant un mois laissait trente répertoires
vides dans /tmp."""
todo = self._todo(1)
_s, crees, retires = self._joue(todo, CONFIG, "2")
self.assertEqual(crees, retires)
for chemin in retires:
self.assertFalse(os.path.exists(chemin))
def test_a_successful_mount_says_how_to_unmount(self):
todo = self._todo(0)
sortie, crees, retires = self._joue(todo, CONFIG, "2")
self.assertIn(t("Mounted on: "), sortie)
self.assertIn("fusermount -u", sortie)
self.assertEqual([], retires)
for chemin in crees:
self.addCleanup(lambda p=chemin: os.path.isdir(p) and os.rmdir(p))
def test_ssh_working_points_at_the_plus_not_at_the_network(self):
"""Quand ssh joint l'hôte, ce n'est pas le réseau : c'est sshfs qui a
mal lu l'alias. Le message doit envoyer là, et donner la commande."""
todo = self._todo(1, probe=(0, ""))
# L'alias à « + » est le troisième de la config (après le rebond seul).
sortie, _c, _r = self._joue(todo, CONFIG, "2")
self.assertIn(
t("SSH reaches this host: ~/.ssh/config is fine."), sortie
)
def test_ssh_failing_sends_to_the_config_or_the_server(self):
todo = self._todo(
1,
probe=(
255,
"ssh: connect to host x port 22: Connection timed out",
),
)
sortie, _c, _r = self._joue(todo, CONFIG, "2")
self.assertIn(t("no answer: is the server up and reachable?"), sortie)
self.assertIn(
t("Update ~/.ssh/config, or check the server is up."), sortie
)
def test_the_plus_alias_is_bypassed_before_being_run(self):
"""Le vrai correctif : la commande lancée ne contient plus le « + »."""
todo = self._todo(0)
# [1] novipro_private · [2] novipro_private+ERPLibre01 · [3] la VM
_s, _c, _r = self._joue(todo, CONFIG, "2")
lancee = todo.lances[0]
self.assertIn("mathben@192.168.122.50:/", lancee)
self.assertIn("-o ProxyJump=novipro_private", lancee)
self.assertNotIn("+ERPLibre01", lancee)
if __name__ == "__main__":
unittest.main(verbosity=1)

View file

@ -0,0 +1,267 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Les modules qu'un palier doit retirer AVANT de monter.
Certaines incompatibilités n'existent qu'à partir d'une version donnée.
`muk_web_theme` n'excluait que `web_enterprise` en 16 et en 17 ; la 18 y
ajoute `web_responsive`. Les deux cohabitaient donc légalement, et la
base arrive en 18 dans un état que la 18 interdit — le chargement meurt
dès qu'un module auto_install est installé, car Odoo revérifie alors
toutes les exclusions.
Le retrait doit se faire pendant qu'on est ENCORE sur l'ancienne
version, là où l'état est légal et où l'ORM fonctionne.
"""
import ast
import io
import os
import sys
import unittest
sys.path.append(
os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
)
from script.todo.todo_upgrade import TodoUpgrade # noqa: E402
RACINE = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
DOSSIER = os.path.join(RACINE, "script", "odoo", "migration")
def lecteur():
return TodoUpgrade.__new__(TodoUpgrade)
class TestTheSeventeenToEighteenList(unittest.TestCase):
def test_the_file_exists_where_the_driver_looks(self):
chemin = os.path.join(
DOSSIER, "uninstall_module_list_odoo170_to_odoo180.txt"
)
self.assertTrue(os.path.isfile(chemin), chemin)
def test_it_names_web_responsive(self):
modules, _detail = lecteur().read_uninstall_module_list(17, "peu")
self.assertIn("web_responsive", modules)
def test_the_removal_is_justified(self):
# Sans justification, on retrouve un module retiré des mois plus
# tard sans pouvoir dire pourquoi, ni s'il faut le remettre.
_m, detail = lecteur().read_uninstall_module_list(17, "peu")
raisons = {module: raison for module, raison, _f in detail}
self.assertTrue(raisons.get("web_responsive"), raisons)
def test_muk_web_theme_is_NOT_removed_here(self):
# Les deux rendent le même service : il faut en garder un, et
# c'est le thème qui porte l'apparence du back-office.
modules, _d = lecteur().read_uninstall_module_list(17, "peu")
self.assertNotIn("muk_web_theme", modules)
class TestEveryListIsWellFormed(unittest.TestCase):
def fichiers(self):
import glob
return sorted(
glob.glob(os.path.join(DOSSIER, "uninstall_module_list_*.txt"))
)
def test_there_is_at_least_one(self):
# Sans cette borne, le test suivant passerait en ne vérifiant
# rien le jour où le motif de nom change.
self.assertGreater(len(self.fichiers()), 0)
def test_every_entry_is_justified(self):
for chemin in self.fichiers():
for module, raison in TodoUpgrade.parse_module_list_file(chemin):
self.assertTrue(
raison,
f"{os.path.basename(chemin)} : {module} sans raison",
)
def test_no_entry_looks_like_a_stray_comment(self):
# Le parseur coupe à « # » : une ligne mal écrite produirait un
# nom de module fantôme, retiré en silence de rien du tout.
for chemin in self.fichiers():
for module, _r in TodoUpgrade.parse_module_list_file(chemin):
self.assertRegex(module, r"^[a-z][a-z0-9_]*$", module)
def test_the_name_encodes_the_bump_it_serves(self):
import re
for chemin in self.fichiers():
nom = os.path.basename(chemin)
trouve = re.match(
r"uninstall_module_list_odoo(\d+)_to_odoo(\d+)\.txt$", nom
)
self.assertIsNotNone(trouve, nom)
depart, arrivee = (int(x) for x in trouve.groups())
self.assertEqual(arrivee, depart + 10, nom)
class TestWhenItRuns(unittest.TestCase):
def test_the_uninstall_precedes_the_openupgrade_run(self):
# Retirer un module APRÈS la montée serait trop tard : c'est la
# montée elle-même qui refuse l'état.
with io.open(
os.path.join(RACINE, "script", "todo", "todo_upgrade.py"),
encoding="utf-8",
) as handle:
src = handle.read()
self.assertLess(
src.index("- Uninstall module"), src.index("- Migrate database")
)
def test_a_private_list_wins_over_the_shared_one(self):
# Une base peut avoir ses propres retraits sans qu'on touche à la
# liste partagée de tout le monde.
with io.open(
os.path.join(RACINE, "script", "todo", "todo_upgrade.py"),
encoding="utf-8",
) as handle:
source = handle.read()
debut = source.index("def read_uninstall_module_list")
fin = source.index("def split_present_missing")
bloc = source[debut:fin]
self.assertLess(
bloc.index("PATH_MIGRATION_PRIVATE"),
bloc.index("PATH_MIGRATION_GLOBAL"),
)
class TestTheListFollowsTheClone(unittest.TestCase):
"""Un clone refait doit rejouer la liste de son palier.
Deux endroits bâtissent la base intermédiaire : l'étape « Uninstall
module », et « Choose delete missing module » qui la jette et la
refait depuis la version précédente. Le second ne rejouait que les
modules choisis là. Mesuré sur test_neutralize_upgrade_18 :
web_responsive retiré au rang 218, clone refait au rang 230, et il
était revenu — la 18 a refusé de charger sur l'exclusion de
muk_web_theme, alors que la désinstallation avait réussi.
"""
def source(self):
with io.open(
os.path.join(RACINE, "script", "todo", "todo_upgrade.py"),
encoding="utf-8",
) as handle:
return handle.read()
def fonction(self):
import ast
for noeud in ast.walk(ast.parse(self.source())):
if (
isinstance(noeud, ast.FunctionDef)
and noeud.name == "execute_odoo_upgrade"
):
return noeud
return None
@staticmethod
def _appelle(noeud, methode):
return (
isinstance(noeud, ast.Call)
and isinstance(noeud.func, ast.Attribute)
and noeud.func.attr == methode
)
def appels_uninstall(self):
import ast
return [
n
for n in ast.walk(self.fonction())
if self._appelle(n, "uninstall_from_database")
]
def noms_venant_de_la_liste(self):
"""Les variables affectées depuis `uninstall_list_for(...)`."""
import ast
noms = set()
for n in ast.walk(self.fonction()):
if isinstance(n, ast.Assign) and self._appelle(
n.value, "uninstall_list_for"
):
for cible in n.targets:
if isinstance(cible, ast.Name):
noms.add(cible.id)
return noms
def test_there_is_more_than_one_place_that_uninstalls(self):
# Sans cette borne, le test suivant passerait le jour où un des
# deux chemins disparaît — ou n'existe plus sous cette forme.
self.assertGreaterEqual(len(self.appels_uninstall()), 2)
def test_every_uninstall_goes_through_the_shared_list(self):
import ast
noms = self.noms_venant_de_la_liste()
self.assertTrue(noms, "aucune variable ne vient de uninstall_list_for")
for appel in self.appels_uninstall():
premier = appel.args[0] if appel.args else None
direct = self._appelle(premier, "uninstall_list_for")
indirect = isinstance(premier, ast.Name) and premier.id in noms
self.assertTrue(
direct or indirect,
"un chemin désinstalle sans rejouer la liste du palier :"
f" ligne {appel.lineno}",
)
class TestUninstallListFor(unittest.TestCase):
class Faux(TodoUpgrade):
def __init__(self, fichier=()):
self.fichier = list(fichier)
self.affiche = []
def read_uninstall_module_list(self, depart, database_name):
self.depart = depart
return list(self.fichier), [
(nom, "raison", "f") for nom in self.fichier
]
def print_uninstall_reason(self, detail):
self.affiche.append(detail)
def test_it_reads_the_file_of_the_step_being_left(self):
# Monter vers 18 lit le fichier 17 → 18, pas 18 → 19.
faux = self.Faux(["web_responsive"])
faux.uninstall_list_for(18, "une_base")
self.assertEqual(faux.depart, 17)
def test_the_chosen_modules_come_first(self):
# L'ordre compte : ce que la personne vient de choisir se lit en
# tête de la ligne de commande qui suit.
faux = self.Faux(["duFichier"])
self.assertEqual(
faux.uninstall_list_for(18, "b", ["choisi"]),
["choisi", "duFichier"],
)
def test_a_module_named_twice_is_uninstalled_once(self):
faux = self.Faux(["commun"])
self.assertEqual(
faux.uninstall_list_for(18, "b", ["commun", "commun"]),
["commun"],
)
def test_it_says_why_each_one_goes(self):
faux = self.Faux(["web_responsive"])
faux.uninstall_list_for(18, "b")
self.assertTrue(faux.affiche)
def test_it_stays_quiet_when_the_step_has_no_list(self):
faux = self.Faux([])
self.assertEqual(
faux.uninstall_list_for(18, "b", ["choisi"]), ["choisi"]
)
self.assertEqual(faux.affiche, [])
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,353 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Une désinstallation se MESURE, elle ne se suppose pas.
`odoo-bin --uninstall` ne cherche que l'état « installed ». Un module resté
en « to remove » d'une tentative précédente est ignoré en silence, et Odoo
sort en 0. Le pilote tenait ce 0 pour une réussite : c'est ainsi que
muk_web_theme a traversé quatre paliers de 12 → 18 en étant réputé retiré,
alors qu'il était « installed » de la 15 à la 18.
"""
import ast
import io
import os
import sys
import unittest
sys.path.append(
os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
)
from script.todo.todo_upgrade import TodoUpgrade # noqa: E402
RACINE = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
SOURCE = os.path.join(RACINE, "script", "todo", "todo_upgrade.py")
class FauxPilote(TodoUpgrade):
"""Un pilote qui n'exécute rien, mais respecte le contrat de retour."""
def __init__(self, survivants=(), lisible=True):
self.survivants = list(survivants)
self.lisible = lisible
self.commandes = []
self.commentaires = []
self.dct_progression = {}
self.dct_module_per_version = {}
def todo_upgrade_execute(self, cmd, **kwargs):
self.commandes.append(cmd)
if kwargs.get("get_output"):
# Le vrai rend TROIS valeurs quand on demande la sortie, et un
# statut NON nul veut dire « raté ».
if not self.lisible:
return 1, cmd, None
return 0, cmd, list(self.survivants)
return 0, cmd
def write_config(self):
pass
def add_comment_progression(self, msg):
self.commentaires.append(msg)
def split_present_missing(self, lst):
return list(lst), []
class TestStillInstalled(unittest.TestCase):
def test_it_names_what_survived(self):
pilote = FauxPilote(survivants=["muk_web_theme"])
self.assertEqual(
pilote.still_installed("db", ["muk_web_theme", "web_responsive"]),
["muk_web_theme"],
)
def test_an_unreadable_database_says_UNKNOWN_not_empty(self):
# Rendre [] serait affirmer « tout est parti » sans l'avoir lu :
# exactement le défaut qu'on corrige.
pilote = FauxPilote(lisible=False)
self.assertIsNone(pilote.still_installed("db", ["web_responsive"]))
def test_nothing_to_check_asks_the_database_nothing(self):
pilote = FauxPilote()
self.assertEqual(pilote.still_installed("db", []), [])
self.assertEqual(pilote.commandes, [])
def test_it_counts_to_remove_as_still_there(self):
# « to remove » n'est pas « uninstalled » : c'est justement l'état
# que --uninstall refuse de traiter, donc celui qu'il faut voir.
pilote = FauxPilote()
pilote.still_installed("db", ["web_responsive"])
self.assertIn("state <> 'uninstalled'", pilote.commandes[0])
def test_the_module_names_are_quoted_for_sql(self):
pilote = FauxPilote()
pilote.still_installed("db", ["web_responsive"])
self.assertIn("'web_responsive'", pilote.commandes[0])
class TestTheBookkeepingTellsTheTruth(unittest.TestCase):
def pilote(self, survivants):
pilote = FauxPilote(survivants=survivants)
pilote.dct_module_per_version = {
17: ["web_responsive", "muk_web_theme"]
}
return pilote
def test_a_module_left_in_place_stays_counted_as_installed(self):
pilote = self.pilote(["web_responsive"])
pilote.uninstall_from_database(["web_responsive"], "db", 17)
self.assertIn("web_responsive", pilote.dct_module_per_version[17])
def test_a_module_really_gone_is_dropped(self):
pilote = self.pilote([])
pilote.uninstall_from_database(["web_responsive"], "db", 17)
self.assertNotIn("web_responsive", pilote.dct_module_per_version[17])
# …et sans emporter le voisin au passage.
self.assertIn("muk_web_theme", pilote.dct_module_per_version[17])
def test_the_survivor_is_recorded_where_someone_will_read_it(self):
pilote = self.pilote(["web_responsive"])
pilote.uninstall_from_database(["web_responsive"], "db", 17)
trace = " ".join(pilote.commentaires)
self.assertIn("still installed", trace)
self.assertIn("web_responsive", trace)
def test_an_unreadable_database_does_not_crash_the_migration(self):
# « je ne sais pas » revient en None : le traiter comme une liste
# ferait tomber la migration sur un TypeError, six heures après le
# départ, pour un renseignement qui n'était que confortable.
pilote = FauxPilote(lisible=False)
pilote.dct_module_per_version = {17: ["web_responsive"]}
pilote.uninstall_from_database(["web_responsive"], "db", 17)
self.assertEqual(pilote.dct_module_per_version[17], [])
def test_a_silent_success_leaves_no_alarm(self):
pilote = self.pilote([])
pilote.uninstall_from_database(["web_responsive"], "db", 17)
self.assertEqual(
[c for c in pilote.commentaires if "still installed" in c], []
)
class TestNoStepWritesAnotherStepsFlag(unittest.TestCase):
"""Chaque drapeau `state_4_*` ne doit porter QUE sa propre liste.
Trois étapes rangeaient leurs drapeaux sous
`state_4_module_migrate_odoo_lst`. Sans effet dans la course en cours —
la locale est lue une fois, au début — mais à la REPRISE cette clé est
relue comme « OpenUpgrade est passé », et la migration du palier est
sautée. Un test structurel se justifie ici : conduire une reprise
complète coûterait des heures, et la faute est visible dans l'écriture.
"""
def assignations(self):
with io.open(SOURCE, encoding="utf-8") as handle:
arbre = ast.parse(handle.read())
vues = {}
for noeud in ast.walk(arbre):
if not isinstance(noeud, ast.Assign):
continue
for cible in noeud.targets:
if not (
isinstance(cible, ast.Subscript)
and isinstance(cible.value, ast.Attribute)
and cible.value.attr == "dct_progression"
and isinstance(cible.slice, ast.Constant)
and str(cible.slice.value).startswith("state_4_")
):
continue
if isinstance(noeud.value, ast.Name):
vues.setdefault(cible.slice.value, set()).add(
noeud.value.id
)
return vues
def test_the_scan_actually_finds_something(self):
# Sans cette borne, le test suivant passerait sur un dictionnaire
# vide le jour où la forme de l'écriture change.
self.assertGreater(len(self.assignations()), 2)
def test_each_flag_is_written_from_one_list_only(self):
for cle, noms in sorted(self.assignations().items()):
self.assertEqual(
len(noms), 1, f"{cle} écrit depuis {sorted(noms)}"
)
def test_the_migrate_flag_comes_from_the_migrate_list(self):
self.assertEqual(
self.assignations().get("state_4_module_migrate_odoo_lst"),
{"lst_module_migrate_odoo"},
)
class TestAFailedOpenUpgradeStaysUnrecorded(unittest.TestCase):
"""Un OpenUpgrade raté ne doit pas passer pour fait.
`lst_upgrade_odoo` n'est pas une copie : `dct_progression.get()` rend
l'objet stocké. L'affecter avant l'exécution le faisait persister au
premier `write_config()` venu — celui du chemin d'échec compris, qui
remet pourtant le drapeau de clonage à zéro pour forcer un nouvel
essai. La reprise sautait alors OpenUpgrade et laissait une base 17
tourner sous le code 18. Mesuré sur test_neutralize_upgrade_18 :
base = 17.0.1.3, et sa commande de migration déjà consignée.
Conduire `execute_odoo_upgrade` en vrai demanderait une migration
complète ; la faute est un ORDRE dans le source, et c'est l'ordre
qu'on mesure.
"""
def arbre(self):
with io.open(SOURCE, encoding="utf-8") as handle:
return ast.parse(handle.read())
def lignes_affectation(self):
lignes = []
for noeud in ast.walk(self.arbre()):
if not isinstance(noeud, ast.Assign):
continue
for cible in noeud.targets:
if (
isinstance(cible, ast.Subscript)
and isinstance(cible.value, ast.Name)
and cible.value.id == "lst_upgrade_odoo"
):
lignes.append(noeud.lineno)
return lignes
@staticmethod
def _remet_le_clone_a_zero(noeud):
"""Ce bloc renonce-t-il en redemandant un clonage neuf ?
Le repère est l'affectation `lst_clone_odoo[index] = False` : c'est
elle qui distingue « je renonce, refais le clone » de l'étape de
clonage elle-même, qui écrit `= True` et vit ailleurs. Chercher les
seuls NOMS attrapait les deux, et l'ancre tombait 700 lignes trop
haut — le test passait alors sur n'importe quel ordre.
"""
for petit in ast.walk(noeud):
if not isinstance(petit, ast.Assign):
continue
if not (
isinstance(petit.value, ast.Constant)
and petit.value.value is False
):
continue
for cible in petit.targets:
if (
isinstance(cible, ast.Subscript)
and isinstance(cible.value, ast.Name)
and cible.value.id == "lst_clone_odoo"
):
return True
return False
def ligne_abandon(self):
"""Le `return` qui renonce après un OpenUpgrade raté."""
lignes = [
max(n.lineno for n in ast.walk(noeud) if isinstance(n, ast.Return))
for noeud in ast.walk(self.arbre())
if isinstance(noeud, ast.If)
and self._remet_le_clone_a_zero(noeud)
and any(isinstance(n, ast.Return) for n in ast.walk(noeud))
]
return min(lignes) if lignes else None
def test_both_anchors_are_found(self):
# Sans cette borne, les tests suivants passeraient à vide le jour
# où l'une des deux formes change.
self.assertTrue(self.lignes_affectation())
self.assertIsNotNone(self.ligne_abandon())
def test_the_step_is_recorded_only_after_the_failure_path_gave_up(self):
abandon = self.ligne_abandon()
for ligne in self.lignes_affectation():
self.assertGreater(
ligne,
abandon,
"lst_upgrade_odoo est marqué fait avant que l'échec ait"
" pu renoncer : la reprise sautera OpenUpgrade",
)
def test_it_is_recorded_exactly_once(self):
# Deux affectations, et l'une repasserait devant l'échec.
self.assertEqual(len(self.lignes_affectation()), 1)
class TestDiscardingTheCloneDiscardsItsPreparation(unittest.TestCase):
"""Rebâtir le clone annule tout ce qu'on lui avait fait.
Quand OpenUpgrade échoue, le pilote remet le drapeau de clonage à zéro
pour que la base intermédiaire soit refaite depuis la version
précédente. Mais les drapeaux des étapes qui avaient préparé CE
clone — le SQL de pré-migration, les désinstallations, les
installations — restaient debout. La base neuve repartait donc sans
sa préparation, et OpenUpgrade retombait sur le problème même que le
SQL existe pour écarter.
"""
PAR_CLONE = (
"lst_fix_migration_odoo",
"lst_module_uninstall_module",
"lst_module_install_module",
)
def bloc_abandon(self):
with io.open(SOURCE, encoding="utf-8") as handle:
arbre = ast.parse(handle.read())
for noeud in ast.walk(arbre):
if isinstance(
noeud, ast.If
) and TestAFailedOpenUpgradeStaysUnrecorded._remet_le_clone_a_zero(
noeud
):
return noeud
return None
def remis_a_zero(self):
"""Les listes que ce bloc remet à leur valeur vide."""
noms = set()
for petit in ast.walk(self.bloc_abandon()):
if not isinstance(petit, ast.Assign):
continue
if not (
isinstance(petit.value, ast.Constant)
and petit.value.value is False
) and not (
isinstance(petit.value, ast.List) and not petit.value.elts
):
continue
for cible in petit.targets:
if isinstance(cible, ast.Subscript) and isinstance(
cible.value, ast.Name
):
noms.add(cible.value.id)
return noms
def test_the_failure_block_is_found(self):
self.assertIsNotNone(self.bloc_abandon())
def test_every_per_clone_flag_is_reset(self):
remis = self.remis_a_zero()
for nom in self.PAR_CLONE:
self.assertIn(nom, remis, f"{nom} survit à son clone")
def test_each_reset_is_persisted(self):
# Remettre la liste à zéro sans l'écrire ne survit pas au
# processus : c'est la reprise qui relit le fichier.
corps = ast.dump(self.bloc_abandon())
for cle in (
"state_4_fix_migration_odoo_lst",
"state_4_uninstall_module",
"state_4_install_module",
):
self.assertIn(cle, corps)
self.assertIn("write_config", corps)
if __name__ == "__main__":
unittest.main()