erplibre/test/test_todo_deploy_form_parity.py
Mathieu Benoit 0551d807ac [REF] déploiement : la branche, le profil et le type se choisissent par VM
Sur Proxmox on déploie le plus souvent un parc MIXTE — un hyperviseur
imbriqué à côté de VM ERPLibre. C'est exactement le cas où un réglage par
machine sert, et c'est le seul écran qui ne l'offrait pas : ses rangées
n'avaient ni branche, ni profil, ni type.

Les trois choix et leur gestionnaire — quatre-vingts lignes — rejoignent le
socle. Les dupliquer aurait remis en place le mécanisme de dérive qu'on vient
d'enlever. L'écran QEMU/KVM perd encore 130 lignes sans qu'un widget, un
modèle ou une spec ne bouge : ancien et nouveau montés dans le même
processus, mêmes rangées, mêmes valeurs après avoir changé une branche, un
type et un profil.

Le déploiement suit : il lisait la seule valeur commune alors que le plan
portait déjà le choix par rangée. Une seule VM qui s'écarte suffit à rendre
la carte nécessaire — « len(set) > 1 » ne l'aurait pas vu.

Un défaut trouvé par un test, pas à l'usage : l'écho du montage se
reconnaissait à sa commande, or quand la commande imposée par le système
n'est pas dans la liste proposée, la liste retombe au rang 0 — et l'écho de
ce rang 0 effaçait l'imposition. Un Proxmox imbriqué reprenait ERPLibre et
Odoo 18. L'écho se reconnaît maintenant au RANG affiché.

--- EN ---

On Proxmox you usually deploy a MIXED fleet — a nested hypervisor next to
ERPLibre VMs. That is exactly where a per-machine setting earns its keep, and
it was the only screen without one: its rows had no branch, no profile, no
type.

The three choices and their handler — eighty lines — move into the shared
foundation. Duplicating them would have restored the very drift mechanism we
just removed. The QEMU/KVM screen loses another 130 lines with no widget,
model or spec moving: old and new mounted in one process, same rows, same
values after changing a branch, a type and a profile.

The deployment follows: it read the single common value while the plan
already carried the per-row choice. One VM that differs is enough to require
the map — "len(set) > 1" would not have seen it.

One defect found by a test, not by use: the mount echo was recognised by its
command, yet when the command imposed by the guest OS is absent from the
offered list, the list falls back to index 0 — and that index-0 echo erased
the imposition. A nested Proxmox took ERPLibre and Odoo 18 back. The echo is
now recognised by the DISPLAYED index.

Assisted-by: Claude Opus 5
2026-08-25 03:31:12 -04:00

358 lines
13 KiB
Python

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