Un invité sans magasin de confiance ne peut RIEN recevoir, et sur un hôte Proxmox c'est l'HÔTE qu'il faut excepter : imbriqué, l'invité sort masqué derrière lui et le pont ne voit jamais sa propre adresse. Mesuré depuis l'invité : code 000 et vérification SSL 19, puis 200 et 0. Sans cela le gestionnaire de paquets se rabattait sur 564 dérivations à construire. Le journal par VM ne portait AUCUN message du menu : la cause vivait sur une console qui défile, et le fichier qu'on rouvre après l'échec n'avait que le symptôme. Et « db_drop_all » annonçait détruites des bases qui ne l'étaient pas, son code de retour jeté. --- EN --- A guest with no trust store can receive NOTHING, and on a Proxmox host it is the HOST that must be exempted: nested, the guest leaves masqueraded behind it and the bridge never sees its own address. Measured from the guest: code 000 and SSL verification 19, then 200 and 0. Without it the package manager fell back to building 564 derivations. The per-VM log carried NO message from the menu: the cause lived on a console that scrolls away, and the file reopened after failure held only the symptom. And "db_drop_all" announced databases as dropped that were not, its exit status discarded. Assisted-by: Claude Opus 5
286 lines
11 KiB
Python
286 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)
|
|
"""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: {}
|
|
# Même en ligne, le déploiement relit l'état de la coupure : un hôte
|
|
# simulé sans guet et un verrou introuvable tiennent la machine de
|
|
# test hors du jeu.
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
tmp = tempfile.TemporaryDirectory()
|
|
self.addCleanup(tmp.cleanup)
|
|
todo._qemu_shell = lambda cmd, timeout=60: 1
|
|
todo._qemu_verrou_hors_ligne_chemin = lambda: Path(tmp.name) / "absent"
|
|
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_what_the_host_decided_reaches_the_log(self):
|
|
"""La console qui a dit la décision a défilé ; le journal est ce
|
|
qu'on rouvre quand l'installation a échoué. Sans ces lignes il ne
|
|
porte que le symptôme — des centaines de lignes de construction et
|
|
un certificat refusé, sans un mot sur ce qui l'a voulu."""
|
|
vm = dict(
|
|
self._vm(),
|
|
notes=[
|
|
"⚠ pas de magasin de confiance : nixos",
|
|
"✓ hôte soustrait au cache : pve-local",
|
|
],
|
|
)
|
|
head = mon._log_header(vm, "develop", "2026-01-01 00:00:00")
|
|
self.assertIn(mon.t("Prepared by the host:"), head)
|
|
self.assertIn("pas de magasin de confiance", head)
|
|
self.assertIn("hôte soustrait au cache", head)
|
|
|
|
def test_a_log_without_notes_is_unchanged(self):
|
|
"""Le cas ordinaire ne gagne pas une section vide."""
|
|
head = mon._log_header(self._vm(), "develop", "2026-01-01 00:00:00")
|
|
self.assertNotIn(mon.t("Prepared by the host:"), 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
|
|
# « **kw » et non une liste figée : chaque paramètre ajouté au
|
|
# lanceur (comme « pve ») casserait sinon ce test, qui ne vérifie
|
|
# pourtant que le prologue du journal.
|
|
mon._launch_one = (
|
|
lambda ip, cmd, log, name="", installs=True, **kw: 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)
|