298 lines
11 KiB
Python
298 lines
11 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
||
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||
|
|
"""Ce qu'un système IMPOSE d'installer sur la VM.
|
||
|
|
|
||
|
|
Choisir « Proxmox VE » comme système, c'est demander qu'il soit installé : ni
|
||
|
|
ERPLibre ni Odoo n'ont leur place sur un hyperviseur. L'invite en ligne le
|
||
|
|
savait déjà — elle remontait le profil hyperviseur en tête — mais le
|
||
|
|
formulaire, lui, posait « ERPLibre + Odoo 18 » par défaut, et lui ajoutait
|
||
|
|
même les cinq gigaoctets réservés au dépôt ERPLibre.
|
||
|
|
|
||
|
|
La règle vit maintenant en un seul endroit et les deux chemins la lisent. Ces
|
||
|
|
tests gardent les deux, plus le fait qu'un choix explicite l'emporte toujours
|
||
|
|
sur elle.
|
||
|
|
"""
|
||
|
|
|
||
|
|
import asyncio
|
||
|
|
import builtins
|
||
|
|
import contextlib
|
||
|
|
import io
|
||
|
|
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
|
||
|
|
|
||
|
|
PVE_CMD = "./script/proxmox/install_proxmox.sh"
|
||
|
|
|
||
|
|
|
||
|
|
class TestLaRegle(unittest.TestCase):
|
||
|
|
def setUp(self):
|
||
|
|
self.todo = TODO.__new__(TODO)
|
||
|
|
|
||
|
|
def test_proxmox_imposes_the_hypervisor_profile(self):
|
||
|
|
label, cmd = self.todo._qemu_distro_profile("proxmox")
|
||
|
|
self.assertEqual(cmd, PVE_CMD)
|
||
|
|
self.assertIn("Proxmox", label)
|
||
|
|
|
||
|
|
def test_an_ordinary_system_imposes_nothing(self):
|
||
|
|
for distro in ("ubuntu", "debian", "fedora", "arch"):
|
||
|
|
self.assertIsNone(self.todo._qemu_distro_profile(distro))
|
||
|
|
|
||
|
|
def test_every_mapped_label_still_exists_in_the_list(self):
|
||
|
|
# La table désigne un profil PAR SON LIBELLÉ : renommer le profil sans
|
||
|
|
# toucher la table ferait disparaître la règle en silence.
|
||
|
|
for distro in self.todo._QEMU_DISTRO_PROFILE:
|
||
|
|
self.assertIsNotNone(
|
||
|
|
self.todo._qemu_distro_profile(distro), distro
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_the_prompt_offers_it_first(self):
|
||
|
|
# Réponse vide = premier de la liste : c'est ce qui rend le défaut.
|
||
|
|
with contextlib.redirect_stdout(io.StringIO()):
|
||
|
|
vrai_input, builtins.input = builtins.input, lambda _p="": ""
|
||
|
|
try:
|
||
|
|
label, cmd = self.todo._qemu_pick_install_profile("proxmox")
|
||
|
|
ordinaire = self.todo._qemu_pick_install_profile("ubuntu")
|
||
|
|
finally:
|
||
|
|
builtins.input = vrai_input
|
||
|
|
self.assertEqual(cmd, PVE_CMD)
|
||
|
|
self.assertIn("odoo_18", ordinaire[1])
|
||
|
|
|
||
|
|
|
||
|
|
class TestUneSeuleVM(unittest.TestCase):
|
||
|
|
"""Le piège du parc d'UNE machine.
|
||
|
|
|
||
|
|
L'exécution ne passait à une commande par VM que si DEUX VM différaient
|
||
|
|
entre elles. Déployée seule, une VM Proxmox donnait un ensemble d'un seul
|
||
|
|
élément : tout retombait sur le choix commun, et l'hyperviseur recevait
|
||
|
|
ERPLibre et Odoo 18 — le défaut corrigé dans le formulaire, réintroduit à
|
||
|
|
l'exécution."""
|
||
|
|
|
||
|
|
def test_a_lone_vm_with_its_own_command_needs_the_map(self):
|
||
|
|
self.assertTrue(
|
||
|
|
TODO._qemu_per_vm({"a": PVE_CMD}, "make install_odoo_18")
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_a_lone_vm_on_the_common_choice_does_not(self):
|
||
|
|
self.assertFalse(
|
||
|
|
TODO._qemu_per_vm(
|
||
|
|
{"a": "make install_odoo_18"}, "make install_odoo_18"
|
||
|
|
)
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_two_identical_vms_do_not(self):
|
||
|
|
self.assertFalse(TODO._qemu_per_vm({"a": "x", "b": "x"}, "x"))
|
||
|
|
|
||
|
|
def test_two_different_vms_do(self):
|
||
|
|
self.assertTrue(TODO._qemu_per_vm({"a": "x", "b": "y"}, "x"))
|
||
|
|
|
||
|
|
def test_an_empty_plan_never_asks_for_a_map(self):
|
||
|
|
# Une carte vide passée à l'installateur y serait prise pour une
|
||
|
|
# commande : le repli sur le choix commun est le seul sûr.
|
||
|
|
self.assertFalse(TODO._qemu_per_vm({}, "x"))
|
||
|
|
|
||
|
|
def test_the_same_trap_held_for_the_branch(self):
|
||
|
|
self.assertTrue(TODO._qemu_per_vm({"a": "master"}, "develop"))
|
||
|
|
|
||
|
|
|
||
|
|
class TestLaCommandeConstruite(unittest.TestCase):
|
||
|
|
"""La commande deploy_qemu.py doit dire la même chose que le plan."""
|
||
|
|
|
||
|
|
def setUp(self):
|
||
|
|
self.todo = TODO.__new__(TODO)
|
||
|
|
|
||
|
|
def _parts(self, distro, disque, cmd_vm="", installe=True):
|
||
|
|
vm = {
|
||
|
|
"name": "essai",
|
||
|
|
"distro": distro,
|
||
|
|
"version": "24.04" if distro == "ubuntu" else "9",
|
||
|
|
"arch": "amd64",
|
||
|
|
"vcpus": 2,
|
||
|
|
"ram": 2048,
|
||
|
|
"disk": disque,
|
||
|
|
"desktop": "",
|
||
|
|
"branch": "",
|
||
|
|
"install_cmd": cmd_vm,
|
||
|
|
"install_label": "",
|
||
|
|
}
|
||
|
|
spec = {
|
||
|
|
"vms": [vm],
|
||
|
|
"existing": [],
|
||
|
|
"ssh_key": "",
|
||
|
|
"timezone": "",
|
||
|
|
"desktop": "",
|
||
|
|
"vm_tools": (),
|
||
|
|
"python_provider": "",
|
||
|
|
"app_store": "deb",
|
||
|
|
"install": (
|
||
|
|
{
|
||
|
|
"branch": "develop",
|
||
|
|
"prod": False,
|
||
|
|
"label": "x",
|
||
|
|
"monitor": True,
|
||
|
|
"cmd": "make install_os && make install_odoo_18",
|
||
|
|
}
|
||
|
|
if installe
|
||
|
|
else None
|
||
|
|
),
|
||
|
|
"monitor": True,
|
||
|
|
"add_ssh_config": True,
|
||
|
|
"parallelism": 1,
|
||
|
|
"res_label": "x1",
|
||
|
|
}
|
||
|
|
return self.todo._qemu_deploy_parts_for(vm, spec, dry_run=True)
|
||
|
|
|
||
|
|
def _taille(self, parts):
|
||
|
|
return parts[parts.index("--disk-size") + 1]
|
||
|
|
|
||
|
|
def test_a_proxmox_vm_gets_no_erplibre_margin(self):
|
||
|
|
parts = self._parts("proxmox", "32G", PVE_CMD)
|
||
|
|
self.assertEqual(self._taille(parts), "32G")
|
||
|
|
|
||
|
|
def test_an_ordinary_vm_keeps_its_margin(self):
|
||
|
|
parts = self._parts("ubuntu", "20G")
|
||
|
|
self.assertEqual(self._taille(parts), "25G")
|
||
|
|
|
||
|
|
def test_a_proxmox_vm_advertises_no_erplibre_in_its_guide(self):
|
||
|
|
# Le guide affiché à la connexion SSH ne doit pas annoncer un dépôt
|
||
|
|
# qui n'existera pas sur cette VM.
|
||
|
|
parts = self._parts("proxmox", "32G", PVE_CMD)
|
||
|
|
self.assertNotIn("--erplibre-make", parts)
|
||
|
|
self.assertNotIn("--erplibre-dir", parts)
|
||
|
|
self.assertIn("--erplibre-dir", self._parts("ubuntu", "20G"))
|
||
|
|
|
||
|
|
def test_a_chosen_size_is_never_dropped(self):
|
||
|
|
# Sans le drapeau, deploy_qemu.py reprend la taille du catalogue : une
|
||
|
|
# VM réglée à 60 G mais sans rien à installer repartait à 20 G.
|
||
|
|
parts = self._parts("ubuntu", "60G", installe=False)
|
||
|
|
self.assertEqual(self._taille(parts), "60G")
|
||
|
|
|
||
|
|
|
||
|
|
def contexte():
|
||
|
|
todo = TODO.__new__(TODO)
|
||
|
|
mod = todo._qemu_import_module()
|
||
|
|
todo._qemu_list_domains = lambda: []
|
||
|
|
todo._qemu_branch_list = lambda: ["develop", "master"]
|
||
|
|
return todo._qemu_form_context(mod)
|
||
|
|
|
||
|
|
|
||
|
|
@unittest.skipUnless(TEXTUAL, "Textual absent")
|
||
|
|
class TestLeFormulaire(unittest.TestCase):
|
||
|
|
"""Le formulaire doit appliquer la règle AU MODÈLE : le déploiement lit
|
||
|
|
« install_cmd » VM par VM, pas ce que la liste affiche."""
|
||
|
|
|
||
|
|
@classmethod
|
||
|
|
def setUpClass(cls):
|
||
|
|
cls.ctx = contexte()
|
||
|
|
cls.rangs = {}
|
||
|
|
for i, e in enumerate(cls.ctx["catalog"]["amd64"]):
|
||
|
|
cls.rangs.setdefault(e["distro"], i)
|
||
|
|
|
||
|
|
def _plan(self, distros, gestes=None):
|
||
|
|
from script.todo.qemu_deploy_form import run_deploy_form
|
||
|
|
|
||
|
|
vu = {}
|
||
|
|
|
||
|
|
async def scenario():
|
||
|
|
from textual.widgets import SelectionList
|
||
|
|
|
||
|
|
app = run_deploy_form(self.ctx, run_app=False)
|
||
|
|
async with app.run_test(size=(200, 50)) as pilote:
|
||
|
|
await pilote.pause()
|
||
|
|
liste = app.query_one(SelectionList)
|
||
|
|
for d in distros:
|
||
|
|
liste.select(
|
||
|
|
liste.get_option_at_index(self.rangs[d]).value
|
||
|
|
)
|
||
|
|
await pilote.pause()
|
||
|
|
await pilote.pause()
|
||
|
|
if gestes:
|
||
|
|
await gestes(app, pilote)
|
||
|
|
vu["app"] = app
|
||
|
|
vu["par_distro"] = {r["vm"]["distro"]: r for r in app.rows}
|
||
|
|
|
||
|
|
asyncio.run(scenario())
|
||
|
|
return vu
|
||
|
|
|
||
|
|
def test_a_proxmox_vm_installs_the_hypervisor_not_erplibre(self):
|
||
|
|
r = self._plan(["proxmox"])["par_distro"]["proxmox"]
|
||
|
|
self.assertEqual(r["vm"]["install_cmd"], PVE_CMD)
|
||
|
|
self.assertIn("Proxmox", r["vm"]["install_label"])
|
||
|
|
|
||
|
|
def test_an_ordinary_vm_still_follows_the_common_choice(self):
|
||
|
|
# « » sur une VM veut dire « le choix du formulaire » : le déploiement
|
||
|
|
# y met alors la commande commune.
|
||
|
|
r = self._plan(["ubuntu"])["par_distro"]["ubuntu"]
|
||
|
|
self.assertEqual(r["vm"]["install_cmd"], "")
|
||
|
|
|
||
|
|
def test_a_mixed_plan_gives_each_vm_its_own(self):
|
||
|
|
par = self._plan(["proxmox", "ubuntu"])["par_distro"]
|
||
|
|
self.assertEqual(par["proxmox"]["vm"]["install_cmd"], PVE_CMD)
|
||
|
|
self.assertEqual(par["ubuntu"]["vm"]["install_cmd"], "")
|
||
|
|
|
||
|
|
def test_the_erplibre_disk_supplement_skips_it(self):
|
||
|
|
# Cinq gigaoctets pour un dépôt qu'une VM Proxmox ne clonera pas.
|
||
|
|
par = self._plan(["proxmox", "ubuntu"])["par_distro"]
|
||
|
|
pve, ubu = par["proxmox"], par["ubuntu"]
|
||
|
|
self.assertEqual(pve["disk_gb"], int(pve["vm"]["disk"].rstrip("G")))
|
||
|
|
self.assertGreater(ubu["disk_gb"], int(ubu["vm"]["disk"].rstrip("G")))
|
||
|
|
|
||
|
|
def test_mounting_marks_nothing_as_customised(self):
|
||
|
|
# Le disque de Proxmox (32 G) n'est pas dans les préréglages : sa
|
||
|
|
# saisie libre émettait un Changed au montage, écrit comme une
|
||
|
|
# surcharge, et la rangée s'affichait ✎ sans qu'on l'ait touchée.
|
||
|
|
app = self._plan(["proxmox"])["app"]
|
||
|
|
self.assertEqual(app.overrides, {})
|
||
|
|
self.assertNotIn("✎", app._row_head(0, app.rows[0]))
|
||
|
|
|
||
|
|
def test_an_explicit_choice_beats_the_rule(self):
|
||
|
|
async def gestes(app, pilote):
|
||
|
|
from textual.widgets import Select
|
||
|
|
|
||
|
|
app.query_one("#v0_prof", Select).value = 0 # ERPLibre + Odoo 18
|
||
|
|
await pilote.pause()
|
||
|
|
await pilote.pause()
|
||
|
|
|
||
|
|
r = self._plan(["proxmox"], gestes)["par_distro"]["proxmox"]
|
||
|
|
self.assertIn("odoo_18", r["vm"]["install_cmd"])
|
||
|
|
|
||
|
|
def test_resetting_the_row_returns_to_the_rule(self):
|
||
|
|
async def gestes(app, pilote):
|
||
|
|
from textual.widgets import Select
|
||
|
|
|
||
|
|
app.query_one("#v0_prof", Select).value = 0
|
||
|
|
await pilote.pause()
|
||
|
|
app.query_one("#v0_prof", Select).focus()
|
||
|
|
await pilote.pause()
|
||
|
|
app.action_clear_vm()
|
||
|
|
await pilote.pause()
|
||
|
|
await pilote.pause()
|
||
|
|
|
||
|
|
r = self._plan(["proxmox"], gestes)["par_distro"]["proxmox"]
|
||
|
|
self.assertEqual(r["vm"]["install_cmd"], PVE_CMD)
|
||
|
|
|
||
|
|
def test_a_real_keystroke_still_lands(self):
|
||
|
|
# La règle de l'écho ne doit pas avaler une VRAIE saisie.
|
||
|
|
async def gestes(app, pilote):
|
||
|
|
from textual.widgets import Input
|
||
|
|
|
||
|
|
app.query_one("#c0_disk", Input).value = "60G"
|
||
|
|
await pilote.pause()
|
||
|
|
await pilote.pause()
|
||
|
|
|
||
|
|
r = self._plan(["proxmox"], gestes)["par_distro"]["proxmox"]
|
||
|
|
self.assertEqual(r["vm"]["disk"], "60G")
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
unittest.main(verbosity=2)
|