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:
commit
9abac6a555
43 changed files with 9370 additions and 91 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
15
doc/RUN.md
15
doc/RUN.md
|
|
@ -17,6 +17,21 @@ Simply
|
|||
./run.sh
|
||||
```
|
||||
|
||||
Without any argument, ERPLibre picks the database for you: with a single
|
||||
one it starts on it, with several it shows a numbered menu. It only asks
|
||||
when a real terminal is there — a service started by systemd behaves
|
||||
exactly as before.
|
||||
|
||||
Two options steer that, and neither reaches Odoo:
|
||||
|
||||
| option | effect |
|
||||
|---|---|
|
||||
| `--auto-erplibre` | pick the database even when other arguments are given |
|
||||
| `--no-cli-erplibre` | never show the menu; a lone database is still taken |
|
||||
|
||||
Naming a database yourself — `-d`, `--database` — turns the whole thing
|
||||
off, and so does a `db_name` set in the configuration file.
|
||||
|
||||
With arguments
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
49
run.sh
|
|
@ -13,10 +13,55 @@ if [ ! -f "${CONFIG_PATH}" ]; then
|
|||
fi
|
||||
fi
|
||||
|
||||
# Deux options qui appartiennent à ERPLibre, retirées avant de passer la
|
||||
# main : Odoo ne les connaît pas et mourrait sur « no such option ».
|
||||
# --auto-erplibre arme le choix de la base à démarrer
|
||||
# --no-cli-erplibre interdit le menu, sans interdire le choix
|
||||
# Sans AUCUN argument, le choix s'arme de lui-même : c'est « make run »,
|
||||
# quelqu'un devant son terminal. Ce défaut-là reste timide — il exige un
|
||||
# terminal des deux côtés — parce que systemd lance lui aussi run.sh sans
|
||||
# argument, avec Restart=always.
|
||||
EL_ARGS=()
|
||||
EL_AUTO=0
|
||||
EL_AUTO_EXPLICITE=0
|
||||
EL_NO_CLI=0
|
||||
[ $# -eq 0 ] && EL_AUTO=1
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--auto-erplibre)
|
||||
EL_AUTO=1
|
||||
EL_AUTO_EXPLICITE=1
|
||||
;;
|
||||
--no-cli-erplibre) EL_NO_CLI=1 ;;
|
||||
# Tout le reste passe tel quel, y compris les arguments vides et ceux
|
||||
# qui portent des espaces : un tableau, jamais une chaîne reconstruite.
|
||||
*) EL_ARGS+=("$1") ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
EL_DB=()
|
||||
EL_LIB="./script/database/lib_db_select.sh"
|
||||
if [ "${EL_AUTO}" = "1" ] && [ -f "${EL_LIB}" ]; then
|
||||
# shellcheck source=script/database/lib_db_select.sh
|
||||
. "${EL_LIB}"
|
||||
EL_DB_NAME="$(el_db_select "${CONFIG_PATH}" "${EL_NO_CLI}" \
|
||||
"${EL_AUTO_EXPLICITE}" "${EL_ARGS[@]}")"
|
||||
retSelect=$?
|
||||
# 130 : on a renoncé au menu. Ne pas démarrer Odoo pour autant, et ne pas
|
||||
# rendre 1 non plus — run.sh:26 réserve déjà 1 à « Odoo a échoué ».
|
||||
if [ ${retSelect} -eq 130 ]; then
|
||||
exit 130
|
||||
fi
|
||||
if [ -n "${EL_DB_NAME}" ]; then
|
||||
EL_DB=(-d "${EL_DB_NAME}")
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$ODOO_MODE_TEST" = "true" ]; then
|
||||
./odoo_bin.sh -c "${CONFIG_PATH}" --limit-time-real 99999 --limit-time-cpu 99999 --limit-memory-hard=0 --log-level=test --test-enable --no-http --stop-after-init "$@"
|
||||
./odoo_bin.sh -c "${CONFIG_PATH}" --limit-time-real 99999 --limit-time-cpu 99999 --limit-memory-hard=0 --log-level=test --test-enable --no-http --stop-after-init "${EL_DB[@]}" "${EL_ARGS[@]}"
|
||||
else
|
||||
./odoo_bin.sh -c "${CONFIG_PATH}" --limit-time-real 99999 --limit-time-cpu 99999 --limit-memory-hard=0 "$@"
|
||||
./odoo_bin.sh -c "${CONFIG_PATH}" --limit-time-real 99999 --limit-time-cpu 99999 --limit-memory-hard=0 "${EL_DB[@]}" "${EL_ARGS[@]}"
|
||||
fi
|
||||
# When need more memory RAM for instance by force
|
||||
#python3 ./odoo/odoo-bin -c ${CONFIG_PATH} --limit-time-real 99999 --limit-time-cpu 99999 --limit-memory-soft=8589934592 --limit-memory-hard=10737418240 $@
|
||||
|
|
|
|||
426
script/analyse/check_module_dependency.py
Executable file
426
script/analyse/check_module_dependency.py
Executable 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())
|
||||
344
script/analyse/check_module_dependency_tui.py
Normal file
344
script/analyse/check_module_dependency_tui.py
Normal 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
|
||||
205
script/database/lib_db_select.sh
Normal file
205
script/database/lib_db_select.sh
Normal 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[@]}"
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
87
script/odoo/migration/fix_migration_odoo120_to_odoo130.sql
Normal file
87
script/odoo/migration/fix_migration_odoo120_to_odoo130.sql
Normal 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 $$;
|
||||
|
|
@ -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é)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
188
script/proxmox/README.base.md
Normal file
188
script/proxmox/README.base.md
Normal 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.
|
||||
92
script/proxmox/README.fr.md
Normal file
92
script/proxmox/README.fr.md
Normal 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
91
script/proxmox/README.md
Normal 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
498
script/proxmox/install_proxmox.sh
Executable 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
|
||||
517
script/proxmox/proxmox_deploy.py
Normal file
517
script/proxmox/proxmox_deploy.py
Normal 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
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
1338
script/todo/todo.py
1338
script/todo/todo.py
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"] = (
|
||||
|
|
|
|||
823
test/test_check_module_dependency.py
Normal file
823
test/test_check_module_dependency.py
Normal 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()
|
||||
225
test/test_fix_migration_120_to_130.py
Normal file
225
test/test_fix_migration_120_to_130.py
Normal 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()
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
201
test/test_old_python_annotations.py
Normal file
201
test/test_old_python_annotations.py
Normal 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
408
test/test_proxmox_deploy.py
Normal 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)
|
||||
249
test/test_qemu_deploy_monitor.py
Normal file
249
test/test_qemu_deploy_monitor.py
Normal 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)
|
||||
372
test/test_qemu_monitor_vmstats.py
Normal file
372
test/test_qemu_monitor_vmstats.py
Normal 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
392
test/test_qemu_proxmox.py
Normal 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
546
test/test_run_sh_auto_db.py
Normal 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
389
test/test_todo_sshfs.py
Normal 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)
|
||||
267
test/test_uninstall_module_list.py
Normal file
267
test/test_uninstall_module_list.py
Normal 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()
|
||||
353
test/test_uninstall_verified.py
Normal file
353
test/test_uninstall_verified.py
Normal 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()
|
||||
Loading…
Reference in a new issue