erplibre/test/test_todo_deploy_form_parity.py
Mathieu Benoit 6b985e57ab [REF] déploiement : un seul socle pour ce qui décrit le 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 valaient donc déjà sur Proxmox — l'écran n'en
offrait que trois. Une VM créée là-bas naissait serveur nu, sans outils et en
UTC, et rien ne le disait.

La duplication était le mécanisme de la dérive : chaque correctif se posait
sur un seul des deux écrans. Les six vivent maintenant dans un socle commun,
widgets ET logique, et le contexte qui les nourrit est écrit une fois. L'écran
QEMU/KVM perd 330 lignes sans qu'un widget ni une valeur de spec ne bouge —
vérifié en montant l'ancien et le nouveau dans le même processus.

Trois conséquences se sont propagées seules : le disque annoncé (bureau et
outils compris) atteint enfin « qm resize », le parallélisme suit les cœurs de
l'hôte au lieu d'un plafond de quatre, et le nom prend le suffixe du bureau —
sans lui, une VM graphique et sa jumelle serveur se disputaient le même.

Deux défauts nommés au passage. Le fuseau : « qm set » n'en pose pas, il part
maintenant par ssh avant l'installation. Et l'architecture d'une VM Proxmox
venait de « virsh », qui ne connaît que les domaines d'ici — une VM ARM prise
pour x86_64 recevait Android Studio, que Google ne publie pas pour elle.

Le test porte sur la PARITÉ, pas sur six comportements : ajouter un réglage à
un seul écran le fait échouer. Il a d'abord échoué à se lancer — hors des
préfixes du lanceur, douze tests n'ont jamais tourné et le total n'avait pas
bougé. La règle de nommage est maintenant dans son en-tête.

--- EN ---

VM type, production, app store, development tools, timezone, Python
interpreter: six settings that describe the guest, not the machine hosting it.
They already applied to Proxmox — the screen offered three. A VM created there
was born a bare server, no tools, in UTC, and nothing said so.

Duplication was the mechanism of the drift: each fix landed on one screen
only. The six now live in a shared foundation, widgets AND logic, and the
context feeding them is written once. The QEMU/KVM screen loses 330 lines with
no widget and no spec value moving — verified by mounting the old and the new
in one process.

Three consequences followed on their own: the announced disk (desktop and
tools included) finally reaches "qm resize", parallelism follows the host's
cores instead of a cap of four, and the name takes the desktop suffix —
without it a graphical VM and its server twin fought over the same one.

Two defects named along the way. The timezone: "qm set" sets none, it now goes
over ssh before the install. And a Proxmox VM's architecture came from
"virsh", which only knows local domains — an ARM VM taken for x86_64 got
Android Studio, which Google does not publish for it.

The test covers PARITY, not six behaviours: adding a setting to one screen
alone fails it. It first failed to run at all — outside the runner's prefixes,
twelve tests never ran and the total had not moved. The naming rule is now in
its header.

Assisted-by: Claude Opus 5
2026-08-25 03:28:39 -04:00

327 lines
12 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["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_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)