L'audit de npm interroge un service qu'aucun cache ne rejoue : amont coupé, il échoue à chaque installation sans rien vérifier. Une VM déployée hors ligne reçoit donc NPM_CONFIG_AUDIT=false, écrit dans /etc/environment et relu après cloud-init ; une VM en ligne garde son audit. Le retrait d'une exception du détournement parle désormais la langue du menu, son message allant à l'erreur standard tandis que la sortie standard garde les règles que nft lit, qui ne se traduisent jamais. --- EN --- The npm audit queries a service no cache can replay: upstream cut, it fails every install without checking anything. A VM deployed offline therefore gets NPM_CONFIG_AUDIT=false, written in /etc/environment and read back after cloud-init; an online VM keeps its audit. Removing a redirection exception now speaks the menu's language, its message going to standard error while standard output keeps the rules nft reads, which are never translated. Assisted-by: Claude Opus 5
2556 lines
96 KiB
Python
2556 lines
96 KiB
Python
#!/usr/bin/env python3
|
|
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
"""L'écran de déploiement Proxmox VE.
|
|
|
|
Ce que Proxmox a en plus de QEMU/KVM, et qui doit donc être éprouvé ici : le
|
|
VMID choisi AVANT le téléchargement de l'image (l'hôte ne dit « déjà pris »
|
|
qu'après), l'adresse qui s'en déduit sur un pont interne, le stockage et le
|
|
pont lus sur l'hôte, et une VM qui existe déjà et qu'on ne doit surtout pas
|
|
écraser.
|
|
|
|
Le rendu est vérifié sans terminal (`run_test`), sur un contexte synthétique :
|
|
aucun hôte Proxmox n'est joint.
|
|
"""
|
|
|
|
import asyncio
|
|
import sys
|
|
import unittest
|
|
from unittest import mock
|
|
|
|
sys.argv = ["todo.py"]
|
|
from script.todo.proxmox_deploy_form import ( # noqa: E402
|
|
assign_vmids,
|
|
build_spec,
|
|
res_label,
|
|
run_proxmox_form,
|
|
)
|
|
|
|
try:
|
|
import textual # noqa: F401
|
|
|
|
TEXTUAL = True
|
|
except Exception: # pragma: no cover - dépend de l'environnement
|
|
TEXTUAL = False
|
|
|
|
|
|
def rangee(nom, etat="new"):
|
|
return {
|
|
"vm": {
|
|
"name": nom,
|
|
"distro": "debian",
|
|
"version": "13",
|
|
"arch": "amd64",
|
|
"vcpus": 2,
|
|
"ram": 2048,
|
|
"disk": "32G",
|
|
},
|
|
"state": etat,
|
|
"note": "",
|
|
"disk_gb": 32,
|
|
}
|
|
|
|
|
|
class TestVmid(unittest.TestCase):
|
|
"""Proxmox refuse un VMID déjà pris, et il le dit APRÈS avoir téléchargé
|
|
l'image : le choix se fait donc avant, d'après ce que l'hôte déclare."""
|
|
|
|
def test_taken_ids_are_skipped(self):
|
|
rows = [rangee("a"), rangee("b")]
|
|
assign_vmids(rows, [100, 101, 103], 100, lambda v: "ip=dhcp")
|
|
self.assertEqual([r["vm"]["vmid"] for r in rows], [102, 104])
|
|
|
|
def test_an_existing_vm_keeps_its_own(self):
|
|
rows = [rangee("a", "exists"), rangee("b")]
|
|
assign_vmids(rows, [], 100, lambda v: "ip=dhcp")
|
|
self.assertNotIn("vmid", rows[0]["vm"])
|
|
self.assertEqual(rows[1]["vm"]["vmid"], 100)
|
|
|
|
def test_the_first_vmid_is_honoured(self):
|
|
rows = [rangee("a")]
|
|
assign_vmids(rows, [], 250, lambda v: "ip=dhcp")
|
|
self.assertEqual(rows[0]["vm"]["vmid"], 250)
|
|
|
|
def test_a_vmid_never_goes_below_100(self):
|
|
# Proxmox réserve les VMID sous 100.
|
|
rows = [rangee("a")]
|
|
assign_vmids(rows, [], 7, lambda v: "ip=dhcp")
|
|
self.assertEqual(rows[0]["vm"]["vmid"], 100)
|
|
|
|
def test_the_address_is_derived_from_the_vmid(self):
|
|
rows = [rangee("a"), rangee("b")]
|
|
assign_vmids(rows, [], 100, lambda v: f"ip=10.10.10.{50 + v % 200}/24")
|
|
self.assertEqual(rows[0]["vm"]["ipconfig"], "ip=10.10.10.150/24")
|
|
self.assertEqual(rows[1]["vm"]["ipconfig"], "ip=10.10.10.151/24")
|
|
|
|
def test_without_a_bridge_rule_it_falls_back_to_dhcp(self):
|
|
rows = [rangee("a")]
|
|
assign_vmids(rows, [], 100, None)
|
|
self.assertEqual(rows[0]["vm"]["ipconfig"], "ip=dhcp")
|
|
|
|
|
|
class TestSpec(unittest.TestCase):
|
|
def _form(self, **extra):
|
|
base = {
|
|
"host": {"target": "erplibre@10.0.0.5"},
|
|
"storage": "local-lvm",
|
|
"bridge": "vmbr0",
|
|
"res_label": "x1",
|
|
"ssh_key": "/home/x/.ssh/id_ed25519.pub",
|
|
"start": True,
|
|
"add_ssh_config": True,
|
|
"install": {
|
|
"branch": "develop",
|
|
"label": "Odoo 18",
|
|
"cmd": "make",
|
|
},
|
|
"monitor": True,
|
|
"parallelism": 2,
|
|
}
|
|
base.update(extra)
|
|
return base
|
|
|
|
def test_an_existing_vm_is_never_recreated(self):
|
|
vms = [{"name": "a"}, {"name": "b"}]
|
|
spec = build_spec(vms, ["b"], self._form())
|
|
self.assertEqual([v["name"] for v in spec["vms"]], ["a"])
|
|
self.assertEqual(spec["existing"], ["b"])
|
|
|
|
def test_the_user_defaults_to_erplibre(self):
|
|
spec = build_spec([], [], self._form())
|
|
self.assertEqual(spec["user"], "erplibre")
|
|
|
|
def test_the_monitor_choice_reaches_the_spec(self):
|
|
# Le suivi est demandé au NIVEAU DU DÉPLOIEMENT : une VM sans
|
|
# ERPLibre se suit aussi.
|
|
spec = build_spec([], [], self._form(install=None, monitor=True))
|
|
self.assertIsNone(spec["install"])
|
|
self.assertTrue(spec["monitor"])
|
|
|
|
def test_the_resource_label_names_the_common_setting(self):
|
|
self.assertEqual(res_label("3"), "x3")
|
|
self.assertNotEqual(res_label("custom"), "xcustom")
|
|
|
|
|
|
def setUpModule():
|
|
"""L'attente ssh ne part JAMAIS pour de vrai depuis les tests.
|
|
|
|
Le déploiement attend qu'une VM fraîche réponde en ssh avant de lui poser
|
|
le guide, le fuseau, le miroir et l'autorité — une adresse n'est pas une
|
|
machine prête. Les harnais d'ici remplacent chaque geste distant mais
|
|
appellent le vrai `_pve_ssh` : sans ce remplacement, chacun tenterait une
|
|
connexion vers un alias inventé et la suite unitaire y perdrait des
|
|
minutes. Les tests de l'attente elle-même la rappellent sur place.
|
|
"""
|
|
from script.todo.todo import TODO
|
|
|
|
patch = mock.patch.object(TODO, "_pve_attendre_ssh", lambda *a, **k: True)
|
|
patch.start()
|
|
unittest.addModuleCleanup(patch.stop)
|
|
|
|
|
|
def contexte():
|
|
def entree(distro, version, arch="amd64"):
|
|
return {
|
|
"name": f"erplibre-{distro}-{version}",
|
|
"distro": distro,
|
|
"version": version,
|
|
"arch": arch,
|
|
"ram": 2048,
|
|
"disk": "32G",
|
|
}
|
|
|
|
return {
|
|
"host": {
|
|
"target": "erplibre@10.0.0.5",
|
|
"sudo": "sudo ",
|
|
"label": "pve",
|
|
},
|
|
"node": "pve1",
|
|
"catalog": {
|
|
"amd64": [
|
|
entree("ubuntu", "26.04"),
|
|
entree("debian", "13"),
|
|
entree("fedora", "44"),
|
|
entree("proxmox", "9"),
|
|
],
|
|
"arm64": [entree("debian", "13", "arm64")],
|
|
},
|
|
"arches": ["amd64", "arm64"],
|
|
"native": "amd64",
|
|
"names": ["erplibre-debian-13"],
|
|
"vmids": [100, 101],
|
|
"next_vmid": 102,
|
|
"storages": ["local-lvm", "local"],
|
|
"storage": "local-lvm",
|
|
"storage_avail": {
|
|
"local-lvm": 90 * (1 << 30),
|
|
"local": 12 * (1 << 30),
|
|
},
|
|
"bridges": ["vmbr0"],
|
|
"bridge": "vmbr0",
|
|
# L'hôte d'essai est une VM de notre pont : la case « Sans connexion
|
|
# internet » est donc offerte, comme sur un Proxmox imbriqué.
|
|
"cache_offert": True,
|
|
# L'hôte d'essai a un nœud de rendu et le VIRGL : la case 3D est
|
|
# donc offerte, comme sur un hôte Proxmox capable.
|
|
"gpu_offert": True,
|
|
"ipconfig": lambda pont, vmid: f"ip=10.10.10.{50 + vmid % 200}/24",
|
|
"build_command": lambda vm, spec: [f"qm create {vm['vmid']}"],
|
|
"branches": ["develop", "master"],
|
|
"install_profiles": [("ERPLibre + Odoo 18", "make install_odoo_18")],
|
|
"distro_profiles": {
|
|
"proxmox": (
|
|
"Hyperviseur Proxmox VE (sans Odoo)",
|
|
"./script/proxmox/install_proxmox.sh",
|
|
)
|
|
},
|
|
"ssh_key": "/home/x/.ssh/id_ed25519.pub",
|
|
"cpu_presets": [2, 4, 8],
|
|
"ram_presets": [2048, 4096, 8192],
|
|
"disk_presets": ["32G", "64G"],
|
|
"base_vcpus": 2,
|
|
"host_cpu": 8,
|
|
"free_ram": 12000,
|
|
"extra_disk_gb": 10,
|
|
}
|
|
|
|
|
|
@unittest.skipUnless(TEXTUAL, "Textual absent")
|
|
class TestEcran(unittest.TestCase):
|
|
"""Le formulaire, monté sans terminal."""
|
|
|
|
def _rendu(self, gestes):
|
|
ctx = contexte()
|
|
resultat = {}
|
|
|
|
async def scenario():
|
|
from textual.widgets import SelectionList
|
|
|
|
app = run_proxmox_form(ctx, run_app=False)
|
|
async with app.run_test(size=(200, 50)) as pilote:
|
|
await pilote.pause()
|
|
liste = app.query_one(SelectionList)
|
|
for i in range(3):
|
|
liste.select(liste.get_option_at_index(i).value)
|
|
await pilote.pause()
|
|
await pilote.pause()
|
|
await gestes(app, pilote)
|
|
# Relevé AVANT la sortie du contexte : `run_test` démonte
|
|
# l'écran, et « #totals » n'existe plus après.
|
|
from textual.widgets import Static
|
|
|
|
widget = app.query_one("#totals", Static)
|
|
app.ligne_totaux = str(
|
|
getattr(widget, "_content", "") or widget.render()
|
|
)
|
|
resultat["app"] = app
|
|
|
|
asyncio.run(scenario())
|
|
return resultat["app"]
|
|
|
|
def test_the_plan_shows_a_row_per_selected_system(self):
|
|
async def rien(app, pilote):
|
|
pass
|
|
|
|
app = self._rendu(rien)
|
|
self.assertEqual(len(app.rows), 3)
|
|
|
|
def test_the_head_line_carries_the_vmid_and_the_address(self):
|
|
async def rien(app, pilote):
|
|
pass
|
|
|
|
app = self._rendu(rien)
|
|
tete = app._row_head(0, app.rows[0])
|
|
self.assertIn("VMID", tete)
|
|
self.assertIn("10.10.10.", tete)
|
|
|
|
def test_an_existing_vm_is_marked_and_gets_no_vmid(self):
|
|
async def rien(app, pilote):
|
|
pass
|
|
|
|
app = self._rendu(rien)
|
|
deja = [r for r in app.rows if r["state"] == "exists"]
|
|
self.assertEqual(len(deja), 1)
|
|
self.assertNotIn("VMID", app._row_head(1, deja[0]))
|
|
|
|
def test_mounting_does_not_mark_every_row_as_custom(self):
|
|
# Poser « value= » sur un Select fait émettre un Changed : pris pour
|
|
# une saisie, il surchargeait les trois champs de CHAQUE VM et toutes
|
|
# les rangées portaient la marque ✎ avant qu'on ne touche à rien.
|
|
async def rien(app, pilote):
|
|
pass
|
|
|
|
app = self._rendu(rien)
|
|
self.assertEqual(app.overrides, {})
|
|
self.assertNotIn("✎", app._row_head(0, app.rows[0]))
|
|
|
|
def test_a_lock_survives_a_common_setting(self):
|
|
async def gestes(app, pilote):
|
|
app._set_lock(0, True)
|
|
await pilote.pause()
|
|
app.custom["ram"] = 8192
|
|
app.profile = "custom"
|
|
app._clear_overrides(("ram",))
|
|
app._recompute()
|
|
await pilote.pause()
|
|
|
|
app = self._rendu(gestes)
|
|
self.assertEqual(app.rows[0]["vm"]["ram"], 2048)
|
|
|
|
def test_a_copy_adds_a_vm_with_its_own_vmid(self):
|
|
async def gestes(app, pilote):
|
|
app._add_copy(0, 1)
|
|
await pilote.pause()
|
|
|
|
app = self._rendu(gestes)
|
|
self.assertEqual(len(app.rows), 4)
|
|
vmids = [r["vm"]["vmid"] for r in app.rows if r["state"] != "exists"]
|
|
self.assertEqual(len(vmids), len(set(vmids)))
|
|
|
|
def test_deploying_yields_a_spec_the_engine_can_run(self):
|
|
async def gestes(app, pilote):
|
|
app.action_deploy()
|
|
|
|
app = self._rendu(gestes)
|
|
spec = app.result
|
|
self.assertEqual(len(spec["vms"]), 2)
|
|
self.assertEqual(spec["existing"], ["erplibre-debian-13"])
|
|
self.assertEqual(spec["storage"], "local-lvm")
|
|
self.assertEqual(spec["bridge"], "vmbr0")
|
|
for vm in spec["vms"]:
|
|
self.assertIn("vmid", vm)
|
|
self.assertIn("ip=", vm["ipconfig"])
|
|
self.assertEqual(spec["install"]["branch"], "develop")
|
|
|
|
def _totaux(self, app):
|
|
return app.ligne_totaux
|
|
|
|
def test_the_totals_line_shows_the_room_left_on_the_storage(self):
|
|
async def rien(app, pilote):
|
|
pass
|
|
|
|
ligne = self._totaux(self._rendu(rien))
|
|
# « pvesm status » donne déjà la place : la demande du plan s'affiche
|
|
# donc à côté d'elle, sans un aller-retour de plus vers l'hôte.
|
|
self.assertIn("/ 90 G", ligne)
|
|
self.assertIn("local-lvm", ligne)
|
|
|
|
def test_changing_the_storage_changes_the_room(self):
|
|
# La marque de génération ne vaut que pour les widgets de RANGÉE :
|
|
# l'exiger des widgets globaux faisait taire tous les réglages
|
|
# communs, stockage compris.
|
|
async def gestes(app, pilote):
|
|
from textual.widgets import Select
|
|
|
|
app.query_one("#f_storage", Select).value = "local"
|
|
await pilote.pause()
|
|
await pilote.pause()
|
|
|
|
ligne = self._totaux(self._rendu(gestes))
|
|
self.assertIn("/ 12 G", ligne)
|
|
self.assertIn("local", ligne)
|
|
|
|
def test_a_plan_bigger_than_the_storage_is_flagged(self):
|
|
async def gestes(app, pilote):
|
|
from textual.widgets import Select
|
|
|
|
app.query_one("#f_storage", Select).value = "local"
|
|
await pilote.pause()
|
|
await pilote.pause()
|
|
|
|
self.assertIn("⚠", self._totaux(self._rendu(gestes)))
|
|
|
|
def test_a_common_setting_reaches_every_vm(self):
|
|
async def gestes(app, pilote):
|
|
# « value = True » sur le bouton : action_next_button() ne
|
|
# déplace que la surbrillance et n'émet aucun message.
|
|
list(app.query("#f_profile RadioButton"))[2].value = True
|
|
await pilote.pause()
|
|
await pilote.pause()
|
|
|
|
app = self._rendu(gestes)
|
|
self.assertEqual(app.profile, "3")
|
|
self.assertTrue(all(r["vm"]["ram"] == 6144 for r in app.rows))
|
|
|
|
def test_the_resource_label_survives_the_markup(self):
|
|
# « [x1] » se faisait manger : Static lit le balisage Rich, et une
|
|
# balise inconnue disparaît avec son contenu.
|
|
async def rien(app, pilote):
|
|
pass
|
|
|
|
self.assertIn("x1", self._totaux(self._rendu(rien)))
|
|
|
|
def test_a_nested_proxmox_guest_installs_its_hypervisor(self):
|
|
# Même défaut que sur l'écran QEMU/KVM avant correction : un Proxmox
|
|
# imbriqué recevait ERPLibre et Odoo 18.
|
|
async def gestes(app, pilote):
|
|
from textual.widgets import SelectionList
|
|
|
|
liste = app.query_one(SelectionList)
|
|
liste.select(liste.get_option_at_index(3).value)
|
|
await pilote.pause()
|
|
await pilote.pause()
|
|
|
|
app = self._rendu(gestes)
|
|
par = {r["vm"]["distro"]: r for r in app.rows}
|
|
self.assertEqual(
|
|
par["proxmox"]["vm"]["install_cmd"],
|
|
"./script/proxmox/install_proxmox.sh",
|
|
)
|
|
# Et ses voisines gardent le choix commun.
|
|
self.assertEqual(par["ubuntu"]["vm"]["install_cmd"], "")
|
|
# Cinq gigaoctets pour un dépôt qu'elle ne clonera pas.
|
|
self.assertEqual(par["proxmox"]["disk_gb"], 32)
|
|
self.assertEqual(par["ubuntu"]["disk_gb"], 42)
|
|
|
|
def test_text_prompts_are_not_a_cancellation(self):
|
|
# {} n'est pas None : l'appelant distingue « annulé » de
|
|
# « pose-moi les questions à l'ancienne ».
|
|
async def gestes(app, pilote):
|
|
from textual.widgets import Button
|
|
|
|
app.on_button_pressed(
|
|
type("E", (), {"button": Button("x", id="prompts")})()
|
|
)
|
|
|
|
app = self._rendu(gestes)
|
|
self.assertEqual(app.result, {})
|
|
|
|
def test_cancelling_yields_nothing(self):
|
|
async def gestes(app, pilote):
|
|
app.action_cancel()
|
|
|
|
self.assertIsNone(self._rendu(gestes).result)
|
|
|
|
|
|
@unittest.skipUnless(TEXTUAL, "Textual absent")
|
|
class TestCreerUnPont(unittest.TestCase):
|
|
"""Sans pont, « qm create » est impossible — et l'écran refusait de
|
|
déployer sans offrir le moindre moyen d'en avoir un. Rapporté.
|
|
|
|
Le pont INTERNE se crée depuis l'écran parce qu'il ne touche à aucune
|
|
interface physique : il n'y a rien à faire arbitrer. Un pont sur le LAN
|
|
déplace l'adresse de l'hôte et coupe la session : il reste manuel.
|
|
"""
|
|
|
|
def _ecran(self, fabrique, ponts=()):
|
|
from script.todo.proxmox_deploy_form import (
|
|
CREER_PONT,
|
|
run_proxmox_form,
|
|
)
|
|
|
|
ctx = contexte()
|
|
ctx["bridges"] = list(ponts)
|
|
ctx["bridge"] = ponts[0] if ponts else ""
|
|
ctx["make_bridge"] = fabrique
|
|
ctx["internal_bridge"] = ("vmbr0", "10.10.10.1/24")
|
|
vu = {}
|
|
|
|
async def scenario():
|
|
from textual.widgets import Select
|
|
|
|
app = run_proxmox_form(ctx, run_app=False)
|
|
async with app.run_test(size=(200, 55)) as pilote:
|
|
await pilote.pause()
|
|
selecteur = app.query_one("#f_bridge", Select)
|
|
vu["choix_avant"] = [str(o[1]) for o in selecteur._options]
|
|
selecteur.value = CREER_PONT
|
|
for _ in range(30):
|
|
await pilote.pause()
|
|
if vu.get("fait"):
|
|
break
|
|
vu["fait"] = bool(app._ponts) and app._bridge()
|
|
await pilote.pause()
|
|
vu["pont"] = app._bridge()
|
|
vu["choix_apres"] = [str(o[1]) for o in selecteur._options]
|
|
|
|
asyncio.run(scenario())
|
|
return vu
|
|
|
|
def test_the_entry_is_offered_when_no_bridge_exists(self):
|
|
vu = self._ecran(lambda: ("vmbr0", ""))
|
|
self.assertIn("__creer_pont__", vu["choix_avant"])
|
|
|
|
def test_choosing_it_creates_the_bridge_and_selects_it(self):
|
|
vu = self._ecran(lambda: ("vmbr0", ""))
|
|
self.assertEqual(vu["pont"], "vmbr0")
|
|
self.assertIn("vmbr0", vu["choix_apres"])
|
|
|
|
def test_a_failure_leaves_no_bridge_selected(self):
|
|
# Laissé sur « créer », le sélecteur ferait déployer une VM sur
|
|
# « __creer_pont__ » — un nom que « qm create » refuserait.
|
|
vu = self._ecran(lambda: ("", "Operation not supported"))
|
|
self.assertEqual(vu["pont"], "")
|
|
|
|
def test_the_entry_stays_offered_when_a_bridge_exists(self):
|
|
# Un hôte avec un seul pont sur le LAN : on peut vouloir un réseau
|
|
# interne pour un parc d'essai.
|
|
vu = self._ecran(lambda: ("vmbr0", ""), ponts=("vmbr9",))
|
|
self.assertIn("__creer_pont__", vu["choix_avant"])
|
|
self.assertIn("vmbr9", vu["choix_avant"])
|
|
|
|
|
|
@unittest.skipUnless(TEXTUAL, "Textual absent")
|
|
class TestLInterpretePython(unittest.TestCase):
|
|
"""L'écran Proxmox n'offrait pas le choix, donc envoyait toujours
|
|
« automatique » — et comme mise n'est jamais installé d'office, c'était
|
|
pyenv, qui COMPILE Python. Rapporté sur une VM Arch : « il utilise le
|
|
tar.xz pour le compiler »."""
|
|
|
|
def _ecran(self, gestes=None, mise_arches=("amd64", "arm64")):
|
|
from script.todo.proxmox_deploy_form import run_proxmox_form
|
|
|
|
ctx = contexte()
|
|
ctx["mise_arches"] = mise_arches
|
|
vu = {}
|
|
|
|
async def scenario():
|
|
from textual.widgets import SelectionList
|
|
|
|
app = run_proxmox_form(ctx, run_app=False)
|
|
async with app.run_test(size=(200, 60)) 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["choix"] = app._python_provider()
|
|
app.action_deploy()
|
|
vu["spec"] = app.result or {}
|
|
|
|
asyncio.run(scenario())
|
|
return vu
|
|
|
|
def test_mise_is_offered_by_default(self):
|
|
# Un CPython précompilé plutôt qu'une compilation de trois minutes.
|
|
self.assertEqual(self._ecran()["choix"], "mise")
|
|
|
|
def test_the_choice_reaches_the_spec(self):
|
|
async def gestes(app, pilote):
|
|
list(app.query("#f_python RadioButton"))[1].value = True
|
|
await pilote.pause()
|
|
|
|
vu = self._ecran(gestes)
|
|
self.assertEqual(vu["choix"], "pyenv")
|
|
self.assertEqual(vu["spec"].get("python_provider"), "pyenv")
|
|
|
|
def test_an_arch_mise_does_not_serve_yields_nothing(self):
|
|
# « mise indisponible » ne veut pas dire « l'utilisateur exige
|
|
# pyenv » : un choix explicite écarterait le Python de la distro.
|
|
self.assertEqual(self._ecran(mise_arches=("s390x",))["choix"], "")
|
|
|
|
|
|
class TestLeDisquePromis(unittest.TestCase):
|
|
"""Le plan annonçait « 25G » et « qm resize » recevait 20 G.
|
|
|
|
La voie libvirt ajoute la marge d'ERPLibre à la taille créée ; celle de
|
|
Proxmox la perdait entre l'écran et la commande. La VM naissait cinq
|
|
gigaoctets trop petite pour ce qu'on venait de lui promettre."""
|
|
|
|
def _taille(self, install, cmd_vm=""):
|
|
import sys
|
|
|
|
sys.argv = ["todo.py"]
|
|
from script.todo.todo import TODO
|
|
|
|
todo = TODO.__new__(TODO)
|
|
vm = {"disk": "20G", "install_cmd": cmd_vm}
|
|
return todo._pve_disk_with_margin(vm, {"install": install})
|
|
|
|
def test_the_margin_reaches_the_created_disk(self):
|
|
self.assertEqual(
|
|
self._taille(
|
|
{
|
|
"branch": "develop",
|
|
"cmd": "make install_os && make install_odoo_18",
|
|
}
|
|
),
|
|
"25G",
|
|
)
|
|
|
|
def test_nothing_to_install_means_no_margin(self):
|
|
self.assertEqual(self._taille(None), "20G")
|
|
|
|
def test_a_hypervisor_profile_gets_no_margin(self):
|
|
# Elle est réservée au dépôt ERPLibre, qu'un Proxmox ne clonera pas.
|
|
self.assertEqual(
|
|
self._taille(
|
|
{
|
|
"branch": "develop",
|
|
"cmd": "./script/proxmox/install_proxmox.sh",
|
|
}
|
|
),
|
|
"20G",
|
|
)
|
|
|
|
|
|
class TestDeuxVmDuMemeNom(unittest.TestCase):
|
|
"""Sur Proxmox, seul le VMID est unique : deux VM du même hôte peuvent
|
|
porter le même nom. « Changer l'état » les choisissait par NOM — cocher
|
|
l'une éteignait les deux."""
|
|
|
|
def test_selecting_one_twin_takes_only_that_one(self):
|
|
import sys
|
|
|
|
sys.argv = ["todo.py"]
|
|
from script.todo.todo import TODO
|
|
|
|
todo = TODO.__new__(TODO)
|
|
vms = [
|
|
{"vmid": 100, "name": "jumeau", "status": "running"},
|
|
{"vmid": 101, "name": "jumeau", "status": "running"},
|
|
]
|
|
rangs = [str(i) for i in range(1, len(vms) + 1)]
|
|
for choix, attendu in (
|
|
("1", [100]),
|
|
("2", [101]),
|
|
("1,2", [100, 101]),
|
|
):
|
|
voulus = {
|
|
int(r)
|
|
for r in todo._parse_index_selection(choix, rangs)
|
|
if str(r).isdigit()
|
|
}
|
|
self.assertEqual(
|
|
[vm["vmid"] for i, vm in enumerate(vms, 1) if i in voulus],
|
|
attendu,
|
|
choix,
|
|
)
|
|
|
|
|
|
@unittest.skipUnless(TEXTUAL, "Textual absent")
|
|
class TestLePreVolDuCacheSurProxmox(unittest.TestCase):
|
|
"""Ce que le cache ne détient pas, aucune VM coupée ne le lira.
|
|
|
|
Le formulaire libvirt le dit depuis toujours ; celui de Proxmox partait
|
|
sans rien vérifier, et l'échec tombait une heure plus tard, à la pose du
|
|
bureau — un message qui accuse le dépôt, jamais le cache. F5 à nouveau
|
|
vaut passage outre : le journal peut avoir tourné, ou le magasin avoir
|
|
été rempli autrement.
|
|
"""
|
|
|
|
def _deployer(self, absentes=(), hors_ligne=True):
|
|
"""Deux F5 d'affilée. Rend ce que la spec valait après chacun."""
|
|
import asyncio
|
|
from unittest import mock
|
|
|
|
from script.qemu import cache_offline
|
|
|
|
ctx = contexte()
|
|
vu = {}
|
|
|
|
async def scenario():
|
|
from textual.widgets import Checkbox, SelectionList
|
|
|
|
app = run_proxmox_form(ctx, run_app=False)
|
|
async with app.run_test(size=(200, 50)) as pilote:
|
|
await pilote.pause()
|
|
liste = app.query_one(SelectionList)
|
|
liste.select(liste.get_option_at_index(0).value)
|
|
await pilote.pause()
|
|
if hors_ligne:
|
|
app.query_one("#f_offline", Checkbox).value = True
|
|
await pilote.pause()
|
|
app.action_deploy()
|
|
vu["premier"] = getattr(app, "result", None)
|
|
app.action_deploy()
|
|
vu["second"] = getattr(app, "result", None)
|
|
|
|
with mock.patch.object(
|
|
cache_offline, "suites_absentes", return_value=list(absentes)
|
|
), mock.patch.object(
|
|
cache_offline, "composants_absents", return_value=[]
|
|
), mock.patch.object(
|
|
cache_offline, "manques_hors_ligne", return_value=[]
|
|
), mock.patch.object(
|
|
cache_offline, "paquets_absents", return_value=[]
|
|
), mock.patch.object(
|
|
cache_offline, "miroirs_absents", return_value=[]
|
|
):
|
|
asyncio.run(scenario())
|
|
return vu
|
|
|
|
def test_le_premier_f5_avertit_au_lieu_de_partir(self):
|
|
vu = self._deployer(absentes=[("ubuntu", "26.04")])
|
|
self.assertFalse(vu["premier"], "parti sans rien dire du manque")
|
|
self.assertTrue(vu["second"], "le second F5 ne passe pas outre")
|
|
|
|
def test_un_magasin_complet_ne_retarde_personne(self):
|
|
"""Un avertissement qui tombe quand rien ne manque s'apprend par
|
|
cœur, et c'est ainsi qu'on cesse de le lire."""
|
|
vu = self._deployer(absentes=[])
|
|
self.assertTrue(vu["premier"], "avertissement sans manque")
|
|
|
|
def test_en_ligne_le_pre_vol_ne_se_pose_pas(self):
|
|
"""Le cache n'est qu'un raccourci tant que l'amont répond : ce qui
|
|
lui manque se télécharge, et rien n'échoue."""
|
|
vu = self._deployer(absentes=[("ubuntu", "26.04")], hors_ligne=False)
|
|
self.assertTrue(vu["premier"], "le pré-vol s'est posé hors coupure")
|
|
|
|
|
|
class TestLeHorsLigneSurProxmox(unittest.TestCase):
|
|
"""La coupure d'amont, offerte sur Proxmox VE comme sur QEMU/KVM.
|
|
|
|
Elle est posée ICI, sur le pont local, et jamais sur l'hôte distant : un
|
|
hôte Proxmox qui reçoit l'autorité du cache est une VM de ce pont, et ses
|
|
invités sortent derrière son adresse. Un hôte qui ne vit pas ici ne doit
|
|
donc PAS se voir offrir la case — rien ici ne sait couper sa sortie, et
|
|
une VM qui s'y bâtirait réussirait en ligne sous une promesse de
|
|
hors-ligne.
|
|
"""
|
|
|
|
def _todo(self):
|
|
import sys
|
|
|
|
sys.argv = ["todo.py"]
|
|
from script.todo.todo import TODO
|
|
|
|
todo = TODO.__new__(TODO)
|
|
todo._write_ssh_config_entry = lambda *a, **k: None
|
|
todo._ssh_private_key = lambda k: None
|
|
todo._ssh_config_block = lambda nom: {}
|
|
todo._pve_guest_ip = lambda vmid, attente=120: ""
|
|
todo._pve_write_guide = lambda *a, **k: True
|
|
todo._pve_set_timezone = lambda *a, **k: True
|
|
todo._qemu_import_module = lambda: None
|
|
return todo
|
|
|
|
def test_la_case_ne_sort_que_pour_un_hote_qui_vit_ici(self):
|
|
"""C'est le même verdict que celui de l'autorité du cache : là où
|
|
elle est posée, le trafic traverse notre pont."""
|
|
todo = self._todo()
|
|
todo._qemu_cache_ca_path = lambda: "/var/lib/cache/ca.crt"
|
|
todo._qemu_list_domains = lambda: ["pve-imbrique"]
|
|
self.assertTrue(
|
|
todo._pve_cache_ca({"target": "erplibre@pve-imbrique"})
|
|
)
|
|
self.assertFalse(todo._pve_cache_ca({"target": "erplibre@ailleurs"}))
|
|
|
|
def test_le_contexte_offre_la_case_selon_ce_verdict(self):
|
|
from pathlib import Path
|
|
|
|
racine = Path(__file__).resolve().parent.parent
|
|
src = (racine / "script" / "todo" / "proxmox_menu.py").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
self.assertIn('"cache_offert": bool(self._pve_cache_ca(host))', src)
|
|
|
|
def _capture(self, **kw):
|
|
"""Ce que l'installateur reçoit, la coupure tenue ou non."""
|
|
import contextlib
|
|
import io
|
|
|
|
todo = self._todo()
|
|
vu = {}
|
|
todo._qemu_install_erplibre_monitored = lambda *a, **k: vu.update(k)
|
|
spec = {
|
|
"host": {"target": "pve1"},
|
|
"vms": [
|
|
{
|
|
"name": "vm-a",
|
|
"vmid": 100,
|
|
"ipconfig": "ip=10.10.10.150/24,gw=10.10.10.1",
|
|
"install_cmd": "",
|
|
}
|
|
],
|
|
"user": "erplibre",
|
|
"add_ssh_config": True,
|
|
"install": {"branch": "develop", "cmd": "make", "label": "X"},
|
|
"monitor": True,
|
|
}
|
|
todo._qemu_list_domains = lambda: []
|
|
with contextlib.redirect_stdout(io.StringIO()):
|
|
todo._pve_after_create(spec["host"], spec, ["vm-a"], "", **kw)
|
|
return vu
|
|
|
|
def test_la_coupure_tenue_atteint_linstallateur(self):
|
|
"""Le guet reçoit la levée, et le manifeste porte la coupure : sans
|
|
cela, la coupure tomberait avec ce processus — avant la fin de ce qui
|
|
télécharge — et le bilan hors ligne ne saurait pas quoi relire."""
|
|
vu = self._capture(coupee=True, debut=1789000000.0)
|
|
self.assertTrue(vu["guet_hors_ligne"])
|
|
self.assertTrue(vu["hors_ligne"])
|
|
self.assertEqual(vu["deploy_started"], 1789000000.0)
|
|
|
|
def test_sans_coupure_rien_nest_promis(self):
|
|
vu = self._capture()
|
|
self.assertFalse(vu["guet_hors_ligne"])
|
|
self.assertFalse(vu["hors_ligne"])
|
|
|
|
def test_le_deploiement_passe_par_lenveloppe(self):
|
|
"""La spec ENTIÈRE tient dans le bloc coupé, création comprise : une
|
|
coupure levée avant l'installation ne prouverait rien."""
|
|
import contextlib
|
|
|
|
todo = self._todo()
|
|
vu = {}
|
|
|
|
@contextlib.contextmanager
|
|
def fausse_coupure(actif):
|
|
vu["demandee"] = actif
|
|
yield actif
|
|
|
|
todo._qemu_sans_internet = fausse_coupure
|
|
todo._pve_deploy_spec = lambda *a, **k: vu.setdefault(
|
|
"coupee", k.get("coupee")
|
|
)
|
|
todo._pve_run_spec({"target": "pve1"}, {"offline": True}, None)
|
|
self.assertTrue(vu["demandee"], "la coupure n'a pas été demandée")
|
|
self.assertTrue(vu["coupee"], "le déploiement ignore la coupure")
|
|
|
|
def test_une_coupure_impossible_ne_deploie_rien(self):
|
|
"""Le refus vient de la coupure elle-même — amont debout, verrou pris,
|
|
dnsmasq absent. Déployer quand même bâtirait une VM en ligne sous une
|
|
promesse de hors-ligne."""
|
|
import contextlib
|
|
|
|
from script.todo.qemu_deploy import _SansInternetImpossible
|
|
|
|
todo = self._todo()
|
|
vu = {}
|
|
|
|
@contextlib.contextmanager
|
|
def refus(actif):
|
|
raise _SansInternetImpossible()
|
|
yield # pragma: no cover - jamais atteint
|
|
|
|
todo._qemu_sans_internet = refus
|
|
todo._pve_deploy_spec = lambda *a, **k: vu.setdefault("parti", True)
|
|
self.assertIsNone(
|
|
todo._pve_run_spec({"target": "pve1"}, {"offline": True}, None)
|
|
)
|
|
self.assertNotIn("parti", vu)
|
|
|
|
def test_la_case_atteint_la_spec(self):
|
|
form = {
|
|
"host": {"target": "pve1"},
|
|
"storage": "local-lvm",
|
|
"bridge": "vmbr0",
|
|
"res_label": "x1",
|
|
"ssh_key": "",
|
|
"start": True,
|
|
"add_ssh_config": True,
|
|
"install": None,
|
|
"monitor": True,
|
|
"parallelism": 1,
|
|
"offline": True,
|
|
}
|
|
self.assertTrue(build_spec([{"name": "a"}], [], form)["offline"])
|
|
form["offline"] = False
|
|
self.assertFalse(build_spec([{"name": "a"}], [], form)["offline"])
|
|
|
|
def _ecran(self, cache_offert, cocher=False):
|
|
"""Monte l'écran avec — ou sans — le cache offert, et relève l'état
|
|
DANS le contexte : `run_test` démonte les widgets en sortant."""
|
|
from textual.widgets import Checkbox
|
|
|
|
ctx = contexte()
|
|
ctx["cache_offert"] = cache_offert
|
|
vu = {}
|
|
|
|
async def scenario():
|
|
app = run_proxmox_form(ctx, run_app=False)
|
|
async with app.run_test(size=(200, 50)) as pilote:
|
|
await pilote.pause()
|
|
cases = app.query("#f_offline")
|
|
vu["offerte"] = bool(cases)
|
|
if cocher and cases:
|
|
cases.first(Checkbox).value = True
|
|
await pilote.pause()
|
|
suivi = app.query_one("#f_monitor", Checkbox)
|
|
vu["suivi"] = suivi.value
|
|
vu["suivi_fige"] = suivi.disabled
|
|
vu["avertissement"] = [
|
|
w.display for w in app.query("#t_offline_w1")
|
|
]
|
|
|
|
asyncio.run(scenario())
|
|
return vu
|
|
|
|
def test_sans_cache_aucune_case(self):
|
|
"""Une case sans effet est pire que pas de case : elle promet."""
|
|
self.assertFalse(self._ecran(False)["offerte"])
|
|
|
|
def test_avec_le_cache_la_case_est_la_et_muette_tant_quon_ny_touche_pas(
|
|
self,
|
|
):
|
|
vu = self._ecran(True)
|
|
self.assertTrue(vu["offerte"])
|
|
self.assertEqual(vu["avertissement"], [False])
|
|
self.assertFalse(vu["suivi_fige"])
|
|
|
|
def test_cocher_decouvre_lavertissement_et_fige_le_suivi(self):
|
|
"""Seule la voie suivie confie la levée au guet : sans suivi, la
|
|
coupure tomberait avec le tableau de bord."""
|
|
vu = self._ecran(True, cocher=True)
|
|
self.assertEqual(vu["avertissement"], [True])
|
|
self.assertTrue(vu["suivi"])
|
|
self.assertTrue(vu["suivi_fige"])
|
|
|
|
|
|
class TestLaTroisDSurProxmox(unittest.TestCase):
|
|
"""L'accélération 3D, offerte sur Proxmox VE comme sur QEMU/KVM.
|
|
|
|
Deux moitiés, et l'une sans l'autre ne donne rien. L'ÉCRAN se pose à la
|
|
création (« --vga virtio-gl ») ; l'ACCÈS au nœud de rendu est une affaire
|
|
de groupes DANS l'invité, que « qm set » ne sait pas écrire. Sans les
|
|
groupes, toute application GL retombe en rendu logiciel alors que la
|
|
négociation VIRGL a réussi — et rien ne le signale.
|
|
"""
|
|
|
|
def _todo(self):
|
|
import sys
|
|
|
|
sys.argv = ["todo.py"]
|
|
from script.todo.todo import TODO
|
|
|
|
return TODO.__new__(TODO)
|
|
|
|
def test_les_groupes_sont_crees_avant_detre_donnes(self):
|
|
"""« usermod -aG » sur un groupe inconnu échoue, et « render » manque
|
|
des images les plus anciennes."""
|
|
todo = self._todo()
|
|
vu = {}
|
|
|
|
def faux_ssh(cible, cmd, timeout=120):
|
|
vu["cible"], vu["cmd"] = cible, cmd
|
|
return 0, ""
|
|
|
|
todo._pve_ssh = faux_ssh
|
|
import contextlib
|
|
import io
|
|
|
|
with contextlib.redirect_stdout(io.StringIO()):
|
|
self.assertTrue(todo._pve_set_gpu_groups("pve+vm-a", "erplibre"))
|
|
self.assertEqual(vu["cible"], "pve+vm-a")
|
|
self.assertLess(
|
|
vu["cmd"].index("groupadd -f render"),
|
|
vu["cmd"].index("usermod -aG"),
|
|
)
|
|
self.assertIn("groupadd -f video", vu["cmd"])
|
|
self.assertIn("usermod -aG render,video erplibre", vu["cmd"])
|
|
|
|
def test_un_echec_est_dit_et_non_tu(self):
|
|
todo = self._todo()
|
|
todo._pve_ssh = lambda *a, **k: (255, "")
|
|
import contextlib
|
|
import io
|
|
|
|
sortie = io.StringIO()
|
|
with contextlib.redirect_stdout(sortie):
|
|
self.assertFalse(todo._pve_set_gpu_groups("pve+vm-a", "erplibre"))
|
|
self.assertIn("255", sortie.getvalue())
|
|
|
|
def test_la_sonde_exige_le_noeud_ET_les_trois_bibliotheques(self):
|
|
"""Un hôte sans GPU n'expose aucun nœud de rendu ; sans VIRGL, GL ou
|
|
EGL, Proxmox refuse de démarrer la machine — « missing libraries for
|
|
'virtio-gl' detected! Please install 'libgl1' and 'libegl1' » —, et
|
|
il le refuse APRÈS avoir écrit le disque. Un hôte peut porter GL sans
|
|
EGL : les exiger ensemble est le seul contrôle qui vaille."""
|
|
todo = self._todo()
|
|
vus = []
|
|
|
|
def faux_show(remote, timeout=120, quiet=False):
|
|
vus.append(remote)
|
|
# Rien de manquant : la sonde ne dit que son jeton final.
|
|
return 0, "FIN\n"
|
|
|
|
todo._pve_show = faux_show
|
|
# Tout est là : la sonde ne dit que « FIN ».
|
|
self.assertEqual(todo._pve_gpu_dispo(), (True, ""))
|
|
for attendu in (
|
|
"/dev/dri/renderD*",
|
|
"libvirglrenderer.so.*",
|
|
"libGL.so.1",
|
|
"libEGL.so.1",
|
|
):
|
|
self.assertIn(attendu, vus[0])
|
|
|
|
def test_la_sonde_nomme_ce_qui_manque(self):
|
|
"""Le cas vécu : un hôte porte GL mais pas EGL, et Proxmox refuse de
|
|
démarrer la machine APRÈS avoir écrit son disque. La case ne doit pas
|
|
disparaître en silence — ce qui manque se nomme."""
|
|
todo = self._todo()
|
|
todo._pve_show = lambda *a, **k: (0, "libegl1\nFIN\n")
|
|
self.assertEqual(todo._pve_gpu_dispo(), (False, "libegl1"))
|
|
todo._pve_show = lambda *a, **k: (0, "noeud\nlibgl1\nlibegl1\nFIN\n")
|
|
self.assertEqual(
|
|
todo._pve_gpu_dispo(), (False, "noeud libgl1 libegl1")
|
|
)
|
|
|
|
def test_une_sonde_qui_naboutit_pas_naccuse_rien(self):
|
|
"""Sans le jeton final, une sortie vide voudrait dire « tout est
|
|
là » : c'est ssh qui a échoué, et on ne promet rien."""
|
|
todo = self._todo()
|
|
todo._pve_show = lambda *a, **k: (0, "")
|
|
self.assertEqual(todo._pve_gpu_dispo(), (False, ""))
|
|
todo._pve_show = lambda *a, **k: (255, "FIN")
|
|
self.assertEqual(todo._pve_gpu_dispo(), (False, ""))
|
|
|
|
def test_le_choix_atteint_la_commande_de_creation(self):
|
|
from pathlib import Path
|
|
|
|
racine = Path(__file__).resolve().parent.parent
|
|
src = (racine / "script" / "todo" / "proxmox_menu.py").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
self.assertIn('"gpu3d": bool(spec.get("gpu3d"))', src)
|
|
# UNE sonde, deux clés : l'écran ne doit pas pouvoir offrir la case
|
|
# et nommer en même temps ce qui l'empêche.
|
|
self.assertIn("gpu_possible, gpu_manque = self._pve_gpu_dispo()", src)
|
|
self.assertIn('"gpu_offert": gpu_possible', src)
|
|
self.assertIn('"gpu_manque": gpu_manque', src)
|
|
|
|
def test_la_case_atteint_la_spec(self):
|
|
form = {
|
|
"host": {"target": "pve1"},
|
|
"storage": "local-lvm",
|
|
"bridge": "vmbr0",
|
|
"res_label": "x1",
|
|
"ssh_key": "",
|
|
"start": True,
|
|
"add_ssh_config": True,
|
|
"install": None,
|
|
"monitor": True,
|
|
"parallelism": 1,
|
|
"gpu3d": True,
|
|
}
|
|
self.assertTrue(build_spec([{"name": "a"}], [], form)["gpu3d"])
|
|
form["gpu3d"] = False
|
|
self.assertFalse(build_spec([{"name": "a"}], [], form)["gpu3d"])
|
|
|
|
def _ecran(self, gpu_offert, cocher=False, manque=""):
|
|
"""Monte l'écran avec — ou sans — la 3D possible sur l'hôte."""
|
|
from textual.widgets import Checkbox, Static
|
|
|
|
ctx = contexte()
|
|
ctx["gpu_offert"] = gpu_offert
|
|
ctx["gpu_manque"] = manque
|
|
vu = {}
|
|
|
|
async def scenario():
|
|
app = run_proxmox_form(ctx, run_app=False)
|
|
async with app.run_test(size=(200, 50)) as pilote:
|
|
await pilote.pause()
|
|
cases = app.query("#f_gpu3d")
|
|
vu["offerte"] = bool(cases)
|
|
if cocher and cases:
|
|
cases.first(Checkbox).value = True
|
|
await pilote.pause()
|
|
vu["valeur"] = app._form_values()["gpu3d"]
|
|
# Relevé DANS le contexte : « run_test » démonte les widgets
|
|
# en sortant, et le texte n'existerait plus après.
|
|
vu["explication"] = " ".join(
|
|
str(getattr(w, "_content", "") or w.render())
|
|
for w in app.query("#t_gpu_manque")
|
|
)
|
|
vu["geste"] = " ".join(
|
|
str(getattr(w, "_content", "") or w.render())
|
|
for w in app.query("#t_gpu_geste")
|
|
)
|
|
assert Static # l'import sert au typage de la requête
|
|
|
|
asyncio.run(scenario())
|
|
return vu
|
|
|
|
def test_sans_gpu_sur_lhote_aucune_case(self):
|
|
"""Une case qui promettrait une accélération que l'hôte ne peut pas
|
|
rendre vaut moins que pas de case."""
|
|
vu = self._ecran(False)
|
|
self.assertFalse(vu["offerte"])
|
|
self.assertFalse(vu["valeur"])
|
|
|
|
def test_ce_qui_manque_est_nomme_avec_son_paquet(self):
|
|
"""Le cas vécu : la case avait disparu après correction de la sonde,
|
|
sans que rien ne dise pourquoi. Elle nomme désormais la pièce ET la
|
|
commande qui la pose — sur l'HÔTE, pas dans la VM."""
|
|
vu = self._ecran(False, manque="libegl1")
|
|
self.assertFalse(vu["offerte"])
|
|
self.assertIn("libegl1", vu["explication"])
|
|
self.assertIn("apt install libegl1", vu["geste"])
|
|
|
|
def test_un_noeud_de_rendu_absent_ne_propose_aucun_paquet(self):
|
|
"""Le nœud vient du matériel ou d'un GPU transmis : « apt install
|
|
noeud » enverrait l'opérateur dans le mur."""
|
|
vu = self._ecran(False, manque="noeud")
|
|
self.assertIn("noeud", vu["explication"])
|
|
self.assertEqual(vu["geste"], "")
|
|
|
|
def test_rien_nest_dit_quand_la_sonde_na_pas_abouti(self):
|
|
"""Sonde muette : on ne promet rien, et on n'accuse rien non plus."""
|
|
vu = self._ecran(False)
|
|
self.assertEqual(vu["explication"], "")
|
|
self.assertEqual(vu["geste"], "")
|
|
|
|
def test_avec_un_gpu_la_case_est_la_et_decochee(self):
|
|
vu = self._ecran(True)
|
|
self.assertTrue(vu["offerte"])
|
|
self.assertFalse(vu["valeur"])
|
|
|
|
def test_cocher_porte_le_choix_jusqua_la_spec(self):
|
|
self.assertTrue(self._ecran(True, cocher=True)["valeur"])
|
|
|
|
def _bouton(self, manque="libegl1", apres=(True, ""), moyen=True):
|
|
"""Monte l'écran, presse « Installer sur l'hôte », relève la suite.
|
|
|
|
« suspend() » est remplacé : un écran monté sans terminal ne peut pas
|
|
le rendre, et ce n'est pas lui qu'on éprouve. Ce qu'on éprouve, c'est
|
|
que le paquet parte, que l'hôte soit RELU, et que l'écran suive.
|
|
"""
|
|
import contextlib
|
|
|
|
from textual.widgets import Button
|
|
|
|
ctx = contexte()
|
|
ctx["gpu_offert"] = False
|
|
ctx["gpu_manque"] = manque
|
|
vu = {"recu": None, "notes": []}
|
|
if moyen:
|
|
ctx["installer_gpu"] = (
|
|
lambda paquets: vu.update(recu=paquets) or True
|
|
)
|
|
ctx["sonder_gpu"] = lambda: apres
|
|
|
|
async def scenario():
|
|
app = run_proxmox_form(ctx, run_app=False)
|
|
async with app.run_test(size=(200, 60)) as pilote:
|
|
await pilote.pause()
|
|
app.suspend = lambda: contextlib.nullcontext()
|
|
app.notify = lambda m, **k: vu["notes"].append(str(m))
|
|
boutons = app.query("#f_gpu_poser")
|
|
vu["bouton"] = bool(boutons)
|
|
if boutons:
|
|
boutons.first(Button).press()
|
|
await pilote.pause()
|
|
await pilote.pause()
|
|
vu["case"] = bool(app.query("#f_gpu3d"))
|
|
vu["lignes"] = bool(app.query("#t_gpu_manque"))
|
|
|
|
asyncio.run(scenario())
|
|
return vu
|
|
|
|
def test_le_bouton_pose_le_paquet_et_la_case_apparait(self):
|
|
"""Ce que l'opérateur demande : ne pas quitter l'écran pour une
|
|
commande que l'écran vient de lui montrer."""
|
|
vu = self._bouton()
|
|
self.assertTrue(vu["bouton"])
|
|
self.assertEqual(vu["recu"], "libegl1")
|
|
self.assertTrue(vu["case"], "la case n'est pas apparue")
|
|
self.assertFalse(vu["lignes"], "le message est resté sous la case")
|
|
|
|
def test_seuls_les_paquets_partent_a_linstallation(self):
|
|
"""Le nœud de rendu ne s'installe pas : l'envoyer à apt ferait
|
|
échouer la pose des paquets qui, eux, existent."""
|
|
self.assertEqual(
|
|
self._bouton(manque="noeud libegl1")["recu"], "libegl1"
|
|
)
|
|
|
|
def test_un_noeud_seul_ne_donne_aucun_bouton(self):
|
|
vu = self._bouton(manque="noeud")
|
|
self.assertFalse(vu["bouton"])
|
|
self.assertFalse(vu["case"])
|
|
|
|
def test_sans_moyen_de_poser_aucun_bouton(self):
|
|
"""Un bouton sans effet vaut moins qu'une commande à recopier."""
|
|
self.assertFalse(self._bouton(moyen=False)["bouton"])
|
|
|
|
def test_lhote_est_relu_et_la_case_ne_vient_pas_sur_parole(self):
|
|
"""Croire apt sur parole offrirait une case que Proxmox refuserait
|
|
ensuite — après avoir écrit le disque de la VM."""
|
|
vu = self._bouton(apres=(False, "libgl1"))
|
|
self.assertFalse(vu["case"])
|
|
self.assertTrue(vu["lignes"], "le message a disparu pour rien")
|
|
self.assertTrue(any("libgl1" in n for n in vu["notes"]), vu["notes"])
|
|
|
|
|
|
class TestLeMiroirAptDesVmProxmox(unittest.TestCase):
|
|
"""Une VM Proxmox tire du miroir que le cache a rempli.
|
|
|
|
Le magasin range ses index sous l'HÔTE demandé. Une VM qui réclame
|
|
« archive.ubuntu.com » ne retrouve donc rien de ce qu'une autre a gardé
|
|
depuis un miroir : hors ligne, chacun de ces index manque, et
|
|
l'installation échoue plus bas sur des dépendances introuvables — un
|
|
message qui accuse le dépôt, jamais le miroir.
|
|
"""
|
|
|
|
def _todo(self):
|
|
import sys
|
|
|
|
sys.argv = ["todo.py"]
|
|
from script.todo.todo import TODO
|
|
|
|
return TODO.__new__(TODO)
|
|
|
|
class _Mod:
|
|
APT_MIRRORS_MAIN = [
|
|
"http://miroir.invalid/ubuntu",
|
|
"http://second.invalid/ubuntu",
|
|
]
|
|
APT_MIRRORS_PORTS = ["http://miroir.invalid/ubuntu-ports"]
|
|
PORTS_ARCHES = ("arm64", "s390x")
|
|
|
|
def _poser(self, vm, code=0):
|
|
import contextlib
|
|
import io
|
|
|
|
todo = self._todo()
|
|
vu = {}
|
|
|
|
def faux_ssh(cible, cmd, timeout=120):
|
|
vu["cible"], vu["cmd"] = cible, cmd
|
|
return code, ""
|
|
|
|
todo._pve_ssh = faux_ssh
|
|
with contextlib.redirect_stdout(io.StringIO()) as sortie:
|
|
vu["rendu"] = todo._pve_set_apt_mirror("pve+vm-a", vm, self._Mod)
|
|
vu["ecrit"] = sortie.getvalue()
|
|
return vu
|
|
|
|
def test_le_premier_miroir_remplace_les_depots_officiels(self):
|
|
vu = self._poser({"distro": "ubuntu", "arch": "amd64"})
|
|
self.assertTrue(vu["rendu"])
|
|
self.assertIn("miroir.invalid/ubuntu", vu["cmd"])
|
|
self.assertIn("archive|security", vu["cmd"])
|
|
self.assertNotIn("second.invalid", vu["cmd"])
|
|
|
|
def test_les_deux_formats_de_sources_sont_couverts(self):
|
|
"""Le « .sources » deb822 des images récentes, et le
|
|
« sources.list » des anciennes : n'en réécrire qu'un laisse l'autre
|
|
pointer ailleurs."""
|
|
cmd = self._poser({"distro": "ubuntu", "arch": "amd64"})["cmd"]
|
|
self.assertIn("/etc/apt/sources.list ", cmd)
|
|
self.assertIn("sources.list.d/*.sources", cmd)
|
|
self.assertIn("sources.list.d/*.list", cmd)
|
|
|
|
def test_une_arche_ports_prend_son_propre_miroir(self):
|
|
"""Les arches « ports » ne sont pas sur archive.ubuntu.com, et amd64
|
|
n'est pas sur ports.ubuntu.com."""
|
|
cmd = self._poser({"distro": "ubuntu", "arch": "arm64"})["cmd"]
|
|
# Le motif est une EXPRESSION : ses points sont échappés, sans quoi
|
|
# ils vaudraient « n'importe quel caractère ».
|
|
self.assertIn(r"ports\.ubuntu\.com/ubuntu-ports", cmd)
|
|
self.assertIn("miroir.invalid/ubuntu-ports", cmd)
|
|
|
|
def test_les_autres_distributions_sont_laissees_tranquilles(self):
|
|
"""Debian, Fedora et Arch ont leurs propres dépôts : y réécrire une
|
|
URI ubuntu ne viserait rien."""
|
|
todo = self._todo()
|
|
todo._pve_ssh = lambda *a, **k: self.fail("ssh lancé pour rien")
|
|
self.assertFalse(
|
|
todo._pve_set_apt_mirror(
|
|
"pve+vm-a", {"distro": "debian", "arch": "amd64"}, self._Mod
|
|
)
|
|
)
|
|
|
|
def test_un_echec_est_dit(self):
|
|
vu = self._poser({"distro": "ubuntu", "arch": "amd64"}, code=255)
|
|
self.assertFalse(vu["rendu"])
|
|
self.assertIn("255", vu["ecrit"])
|
|
|
|
def test_le_miroir_est_pose_avant_lautorite_du_cache(self):
|
|
"""L'ordre est le sujet : l'autorité sert aux téléchargements, et le
|
|
miroir décide OÙ ils vont. Posé après, il ne vaudrait que pour ce qui
|
|
reste à venir."""
|
|
from pathlib import Path
|
|
|
|
racine = Path(__file__).resolve().parent.parent
|
|
src = (racine / "script" / "todo" / "proxmox_menu.py").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
self.assertLess(
|
|
src.index('self._pve_set_apt_mirror(vm["alias"]'),
|
|
src.index("self._pve_set_cache_ca("),
|
|
)
|
|
|
|
|
|
class TestLAttenteAvantLesGestesDansLInvite(unittest.TestCase):
|
|
"""Une adresse n'est pas une machine prête.
|
|
|
|
Vécu : une VM Proxmox est née sans guide, en UTC, sans l'autorité du
|
|
cache et sur le miroir de son image. Les quatre gestes passent tous par
|
|
ssh et partaient dès l'adresse connue, pendant que cloud-init posait
|
|
encore les comptes et les clés. Ils échouaient donc ENSEMBLE, et la panne
|
|
ressemblait à quatre pannes sans lien.
|
|
"""
|
|
|
|
def _todo(self):
|
|
import sys
|
|
|
|
sys.argv = ["todo.py"]
|
|
from script.todo.todo import TODO
|
|
|
|
return TODO.__new__(TODO)
|
|
|
|
@property
|
|
def attendre(self):
|
|
"""La VRAIE attente : le module de test la remplace partout
|
|
ailleurs, et c'est elle qu'on éprouve ici."""
|
|
from script.todo.proxmox_menu import ProxmoxMenuMixin
|
|
|
|
return ProxmoxMenuMixin._pve_attendre_ssh
|
|
|
|
def test_elle_rend_vrai_des_que_le_ssh_repond(self):
|
|
todo = self._todo()
|
|
essais = []
|
|
todo._pve_ssh = lambda c, cmd, timeout=120: (
|
|
essais.append(cmd),
|
|
(0, ""),
|
|
)[1]
|
|
self.assertTrue(self.attendre(todo, "pve+vm-a"))
|
|
self.assertEqual(essais, ["true"], "une seule sonde suffit")
|
|
|
|
def test_elle_rend_faux_au_bout_du_delai(self):
|
|
"""Bornée par le TEMPS : un essai coûte le délai de connexion de ssh,
|
|
que rien ici ne borne à l'avance."""
|
|
import contextlib
|
|
import io
|
|
|
|
from script.todo import proxmox_menu
|
|
|
|
todo = self._todo()
|
|
todo._pve_ssh = lambda c, cmd, timeout=120: (255, "")
|
|
horloge = iter([0, 0, 5, 10, 15, 20, 25, 30, 35, 40])
|
|
with mock.patch.object(proxmox_menu.time, "sleep", lambda _s: None):
|
|
with mock.patch.object(
|
|
proxmox_menu.time, "time", lambda: next(horloge)
|
|
):
|
|
with contextlib.redirect_stdout(io.StringIO()) as sortie:
|
|
rendu = self.attendre(todo, "pve+vm-a", delai=20)
|
|
self.assertFalse(rendu)
|
|
self.assertIn("ssh", sortie.getvalue())
|
|
|
|
def test_sans_reponse_les_gestes_sont_sautes_et_dits(self):
|
|
"""Quatre échecs silencieux valent moins qu'un refus qui se nomme."""
|
|
import contextlib
|
|
import io
|
|
|
|
todo = self._todo()
|
|
faits = []
|
|
todo._write_ssh_config_entry = lambda *a, **k: None
|
|
todo._ssh_private_key = lambda k: None
|
|
todo._qemu_list_domains = lambda: []
|
|
todo._pve_guest_ip = lambda vmid, attente=120: ""
|
|
todo._qemu_import_module = lambda: None
|
|
todo._pve_attendre_ssh = lambda *a, **k: False
|
|
for nom in (
|
|
"_pve_write_guide",
|
|
"_pve_set_timezone",
|
|
"_pve_set_apt_mirror",
|
|
"_pve_set_cache_ca",
|
|
):
|
|
setattr(
|
|
todo, nom, (lambda n: lambda *a, **k: faits.append(n))(nom)
|
|
)
|
|
todo._qemu_install_erplibre_monitored = lambda *a, **k: None
|
|
spec = {
|
|
"host": {"target": "pve1"},
|
|
"vms": [
|
|
{
|
|
"name": "vm-a",
|
|
"vmid": 100,
|
|
"ipconfig": "ip=10.0.0.2/24,gw=10.0.0.1",
|
|
"distro": "ubuntu",
|
|
"arch": "amd64",
|
|
}
|
|
],
|
|
"user": "erplibre",
|
|
"add_ssh_config": True,
|
|
"install": None,
|
|
"monitor": False,
|
|
}
|
|
with contextlib.redirect_stdout(io.StringIO()) as sortie:
|
|
todo._pve_after_create(spec["host"], spec, ["vm-a"], "")
|
|
self.assertEqual(faits, [], f"des gestes sont partis : {faits}")
|
|
self.assertIn("ssh", sortie.getvalue())
|
|
|
|
|
|
class TestUnParcMixte(unittest.TestCase):
|
|
"""Le plan porte branche, profil et type PAR RANGÉE — le déploiement
|
|
lisait encore la seule valeur commune.
|
|
|
|
C'est le cas qu'on déploie le plus souvent sur un Proxmox : un
|
|
hyperviseur imbriqué à côté de VM ERPLibre. Une seule VM qui porte sa
|
|
propre valeur suffit à rendre la carte nécessaire — « len(set) > 1 » ne
|
|
l'aurait pas vu, et tout le parc serait retombé sur le commun."""
|
|
|
|
def _capture(self, vms):
|
|
import contextlib
|
|
import io
|
|
import sys
|
|
|
|
sys.argv = ["todo.py"]
|
|
from script.todo.todo import TODO
|
|
|
|
vu = {}
|
|
todo = TODO.__new__(TODO)
|
|
todo._write_ssh_config_entry = lambda *a, **k: None
|
|
todo._ssh_private_key = lambda k: None
|
|
todo._ssh_config_block = lambda nom: {}
|
|
todo._qemu_list_domains = lambda: []
|
|
todo._pve_guest_ip = lambda vmid, attente=120: ""
|
|
todo._pve_write_guide = lambda *a, **k: True
|
|
todo._pve_set_timezone = lambda *a, **k: True
|
|
todo._qemu_import_module = lambda: None
|
|
|
|
def prise(noms, branche, alias, finale, **kw):
|
|
vu.update(branche=branche, finale=finale, kw=kw)
|
|
|
|
todo._qemu_install_erplibre_monitored = prise
|
|
spec = {
|
|
"host": {"target": "pve1"},
|
|
"vms": vms,
|
|
"user": "erplibre",
|
|
"add_ssh_config": True,
|
|
"install": {
|
|
"branch": "develop",
|
|
"cmd": "make install_odoo_18",
|
|
"label": "X",
|
|
},
|
|
"monitor": True,
|
|
"desktop": "",
|
|
}
|
|
with contextlib.redirect_stdout(io.StringIO()):
|
|
todo._pve_after_create(
|
|
spec["host"], spec, [v["name"] for v in vms], ""
|
|
)
|
|
return vu
|
|
|
|
def _vm(self, nom, **extra):
|
|
base = {
|
|
"name": nom,
|
|
"vmid": 100,
|
|
"ipconfig": "ip=10.10.10.150/24,gw=10.10.10.1",
|
|
"install_cmd": "",
|
|
}
|
|
base.update(extra)
|
|
return base
|
|
|
|
def test_a_single_vm_with_its_own_branch_forces_the_map(self):
|
|
vu = self._capture(
|
|
[
|
|
self._vm("vm-a", branch="master"),
|
|
self._vm("vm-b", vmid=101),
|
|
]
|
|
)
|
|
self.assertEqual(vu["branche"], {"vm-a": "master", "vm-b": "develop"})
|
|
|
|
def test_a_uniform_fleet_keeps_the_common_value(self):
|
|
vu = self._capture([self._vm("vm-a"), self._vm("vm-b", vmid=101)])
|
|
self.assertEqual(vu["branche"], "develop")
|
|
|
|
def test_a_per_vm_desktop_reaches_the_install(self):
|
|
vu = self._capture(
|
|
[
|
|
self._vm("vm-a", desktop="gnome"),
|
|
self._vm("vm-b", vmid=101),
|
|
]
|
|
)
|
|
self.assertEqual(vu["kw"]["desktop"], {"vm-a": "gnome", "vm-b": ""})
|
|
|
|
def test_a_uniform_fleet_keeps_the_common_desktop(self):
|
|
vu = self._capture([self._vm("vm-a"), self._vm("vm-b", vmid=101)])
|
|
self.assertEqual(vu["kw"]["desktop"], "")
|
|
|
|
|
|
class TestLaVmCloneLeDepotDistant(unittest.TestCase):
|
|
"""« Le problème est revenu » — alors qu'il était corrigé.
|
|
|
|
La VM ne reçoit pas le checkout d'ici : elle CLONE la branche depuis le
|
|
dépôt DISTANT. Tout ce qui tourne dedans — install_proxmox.sh, les
|
|
scripts d'installation, le Makefile — vient donc de là. Un correctif
|
|
commité ici et non poussé lui est invisible.
|
|
|
|
Vécu deux fois de suite : la correction de /etc/hosts était dans le
|
|
checkout depuis la veille, absente du distant, et chaque VM déployée
|
|
ensuite recevait l'ancien script. Il a fallu comparer les deux versions à
|
|
la main pour le voir. Rien ne le disait."""
|
|
|
|
def _todo(self, sortie, code=0):
|
|
import sys
|
|
|
|
sys.argv = ["todo.py"]
|
|
from script.todo.todo import TODO
|
|
|
|
todo = TODO.__new__(TODO)
|
|
faux = mock.Mock(returncode=code, stdout=sortie)
|
|
return todo, faux
|
|
|
|
def test_the_gap_is_counted_and_named(self):
|
|
todo, faux = self._todo(
|
|
"abc1234 [FIX] un correctif\ndef5678 [ADD] autre chose\n"
|
|
)
|
|
with mock.patch("subprocess.run", return_value=faux):
|
|
nombre, sujets = todo._qemu_branch_gap("develop")
|
|
self.assertEqual(nombre, 2)
|
|
self.assertIn("[FIX] un correctif", sujets[0])
|
|
|
|
def test_nothing_to_say_when_the_remote_is_up_to_date(self):
|
|
todo, faux = self._todo("")
|
|
with mock.patch("subprocess.run", return_value=faux):
|
|
self.assertEqual(todo._qemu_branch_gap("develop"), (0, []))
|
|
self.assertEqual(todo._qemu_branch_gap_lines("develop"), [])
|
|
|
|
def test_an_unknown_remote_branch_is_not_a_gap(self):
|
|
# « origin/xyz » inconnu fait échouer git : ce n'est pas un écart à
|
|
# signaler, c'est une question qui ne se pose pas. Le dire quand même
|
|
# serait un avertissement à chaque déploiement d'une branche neuve.
|
|
todo, faux = self._todo("", code=128)
|
|
with mock.patch("subprocess.run", return_value=faux):
|
|
self.assertEqual(todo._qemu_branch_gap("nouvelle"), (0, []))
|
|
|
|
def test_no_branch_asks_nothing(self):
|
|
todo, _faux = self._todo("")
|
|
self.assertEqual(todo._qemu_branch_gap(""), (0, []))
|
|
|
|
def test_the_long_list_is_trimmed_but_counted(self):
|
|
todo, faux = self._todo(
|
|
"\n".join(f"c{i} sujet {i}" for i in range(10))
|
|
)
|
|
with mock.patch("subprocess.run", return_value=faux):
|
|
lignes = todo._qemu_branch_gap_lines("develop", limite=2)
|
|
texte = " ".join(lignes)
|
|
self.assertIn("10", texte, "le nombre TOTAL doit rester lisible")
|
|
self.assertIn("8", texte, "et ce qui n'est pas montré, dit")
|
|
self.assertIn("git push", texte)
|
|
|
|
def test_both_screens_say_it_before_deploying(self):
|
|
# L'avertissement ne vaut que là où on peut encore renoncer.
|
|
import inspect
|
|
|
|
from script.todo.proxmox_menu import ProxmoxMenuMixin
|
|
from script.todo.qemu_deploy import QemuDeployMixin
|
|
|
|
for fn in (
|
|
ProxmoxMenuMixin._pve_confirm_spec,
|
|
QemuDeployMixin._qemu_print_recap,
|
|
):
|
|
with self.subTest(fonction=fn.__name__):
|
|
self.assertIn("_qemu_branch_gap_lines", inspect.getsource(fn))
|
|
|
|
|
|
class TestLePontQuiNeMeneraitNullePart(unittest.TestCase):
|
|
"""Le pont NAT était écrit AVANT qu'on sache si le NAT existe.
|
|
|
|
Résultat rapporté : la strophe posée dans /etc/network/interfaces, le
|
|
pont absent, et six lignes d'iptables qui ne parlent pas de redémarrage.
|
|
L'avertissement sur le noyau existait — mais à la CONFIRMATION de l'hôte,
|
|
et l'hôte est ensuite mémorisé : on revient des jours plus tard créer un
|
|
pont, et plus personne ne rappelle rien."""
|
|
|
|
def _todo(self, sortie):
|
|
import sys
|
|
|
|
sys.argv = ["todo.py"]
|
|
from script.todo.todo import TODO
|
|
|
|
todo = TODO.__new__(TODO)
|
|
vu = []
|
|
todo._pve_host = lambda ask=True: {"target": "pve9", "sudo": ""}
|
|
todo._pve_uplink = lambda: "eth0"
|
|
|
|
def faux_run(host, cmd, timeout=120):
|
|
vu.append(cmd)
|
|
from script.proxmox import proxmox_deploy as pve
|
|
|
|
if cmd == pve.NAT_CHECK_CMD:
|
|
return 0, sortie
|
|
return 0, ""
|
|
|
|
return todo, vu, faux_run
|
|
|
|
def _sortie(self, nat, pve_kernel="7.0.14-14-pve"):
|
|
return (
|
|
f"{'7.0.14-14-pve' if nat else '6.12.101+deb13-cloud-amd64'}\n"
|
|
f"---ERPLIBRE-NAT---\n{'NAT-OK' if nat else 'NAT-KO'}\n"
|
|
f"---ERPLIBRE-PVE-KERNEL---\n{pve_kernel}\n"
|
|
)
|
|
|
|
def test_nothing_is_written_when_there_is_no_nat(self):
|
|
todo, vu, faux = self._todo(self._sortie(nat=False))
|
|
with mock.patch("script.proxmox.proxmox_deploy.run", faux):
|
|
nom, raison = todo._pve_make_internal_bridge()
|
|
self.assertEqual(nom, "")
|
|
self.assertTrue(raison)
|
|
# Une seule commande : la sonde. Rien n'a touché au fichier.
|
|
self.assertEqual(len(vu), 1, vu)
|
|
self.assertNotIn(
|
|
"interfaces", " ".join(vu), "la strophe ne doit pas être écrite"
|
|
)
|
|
|
|
def test_the_reason_names_the_kernel_to_boot(self):
|
|
todo, _vu, faux = self._todo(self._sortie(nat=False))
|
|
with mock.patch("script.proxmox.proxmox_deploy.run", faux):
|
|
ok, lignes = todo._pve_nat_ready({"target": "pve9", "sudo": ""})
|
|
self.assertFalse(ok)
|
|
texte = " ".join(lignes)
|
|
self.assertIn("6.12.101+deb13-cloud-amd64", texte)
|
|
self.assertIn("7.0.14-14-pve", texte)
|
|
self.assertIn("reboot", texte)
|
|
|
|
def test_an_unfinished_install_says_so_instead(self):
|
|
todo, _vu, faux = self._todo(self._sortie(nat=False, pve_kernel=""))
|
|
with mock.patch("script.proxmox.proxmox_deploy.run", faux):
|
|
_ok, lignes = todo._pve_nat_ready({"target": "pve9", "sudo": ""})
|
|
texte = " ".join(lignes)
|
|
self.assertNotIn("reboot", texte, "rien à redémarrer, rien de posé")
|
|
|
|
def test_a_working_host_goes_through(self):
|
|
todo, vu, faux = self._todo(self._sortie(nat=True))
|
|
with mock.patch("script.proxmox.proxmox_deploy.run", faux):
|
|
todo._pve_make_internal_bridge()
|
|
self.assertGreater(len(vu), 1, "la création doit suivre la sonde")
|
|
|
|
|
|
class TestUneProxmoxImbriqueeDoitRedemarrer(unittest.TestCase):
|
|
"""Le sommaire ne disait pas qu'une VM qui vient de recevoir Proxmox
|
|
tourne encore le noyau de son image cloud.
|
|
|
|
On le redécouvrait des jours plus tard, en créant un pont, devant six
|
|
lignes d'iptables."""
|
|
|
|
def _juge(self, vm, commun=""):
|
|
import sys
|
|
|
|
sys.argv = ["todo.py"]
|
|
from script.todo.todo import TODO
|
|
|
|
return TODO._pve_installs_proxmox(
|
|
vm, {"install": {"cmd": commun}} if commun else {}
|
|
)
|
|
|
|
def test_a_vm_that_gets_the_hypervisor(self):
|
|
self.assertTrue(
|
|
self._juge({"install_cmd": "./script/proxmox/install_proxmox.sh"})
|
|
)
|
|
|
|
def test_through_the_common_choice_too(self):
|
|
self.assertTrue(self._juge({}, "./script/proxmox/install_proxmox.sh"))
|
|
|
|
def test_an_erplibre_vm_is_left_alone(self):
|
|
self.assertFalse(
|
|
self._juge({}, "make install_os && make install_odoo_18")
|
|
)
|
|
|
|
def test_the_note_only_shows_when_nothing_reboots_it(self):
|
|
"""Avec suivi, l'enveloppe redémarre elle-même : réclamer un
|
|
redémarrage déjà fait est une consigne fausse. Sans suivi, la voie en
|
|
série s'arrête à la fin du script, et la note est la seule chose qui
|
|
dit que l'hyperviseur n'est pas encore utilisable."""
|
|
import contextlib
|
|
import io
|
|
import sys
|
|
|
|
sys.argv = ["todo.py"]
|
|
from script.todo.todo import TODO
|
|
|
|
def sommaire(monitor):
|
|
todo = TODO.__new__(TODO)
|
|
spec = {
|
|
"vms": [{"name": "pve-imbrique"}],
|
|
"install": {
|
|
"cmd": "./script/proxmox/install_proxmox.sh",
|
|
"label": "Proxmox VE",
|
|
"branch": "develop",
|
|
},
|
|
"monitor": monitor,
|
|
"storage": "local",
|
|
"bridge": "vmbr0",
|
|
}
|
|
vm = dict(spec["vms"][0], alias="pve9+pve-imbrique", vmid=102)
|
|
tampon = io.StringIO()
|
|
with contextlib.redirect_stdout(tampon):
|
|
todo._pve_print_summary(spec, [vm], "")
|
|
return tampon.getvalue()
|
|
|
|
self.assertIn("reboot", sommaire(monitor=False))
|
|
self.assertNotIn("reboot", sommaire(monitor=True))
|
|
|
|
def test_a_vm_of_its_own_overrides_the_common_choice(self):
|
|
# Parc mixte : la commande de la VM l'emporte sur celle du parc.
|
|
self.assertFalse(
|
|
self._juge(
|
|
{"install_cmd": "make install_odoo_18"},
|
|
"./script/proxmox/install_proxmox.sh",
|
|
)
|
|
)
|
|
|
|
|
|
class TestLEcranDUneVmProxmox(unittest.TestCase):
|
|
"""« Console de l'hyperviseur » conseillait des commandes virsh sur une
|
|
machine qui n'a pas libvirt.
|
|
|
|
Le tunnel lit le port VNC par « virsh vncdisplay » sur l'hyperviseur. Un
|
|
Proxmox VE n'a pas de libvirt : la commande échoue, et l'absence de port
|
|
était lue « écran fermé ». On imprimait alors « sudo virsh edit » — sur un
|
|
hôte où le binaire n'existe pas. Ce n'est pas un écran fermé, c'est la
|
|
mauvaise question : Proxmox sert son écran par un ticket, sur son
|
|
interface web."""
|
|
|
|
def _sortie(self, qm_present, port=0):
|
|
import contextlib
|
|
import io
|
|
import sys
|
|
|
|
sys.argv = ["todo.py"]
|
|
from script.todo.todo import TODO
|
|
|
|
todo = TODO.__new__(TODO)
|
|
todo._ssh_proxyjump = lambda nom: "pve9"
|
|
todo._qemu_vnc_port = staticmethod(lambda d, j="": port)
|
|
todo._hypervisor_is_proxmox = lambda jump: qm_present
|
|
tampon = io.StringIO()
|
|
with contextlib.redirect_stdout(tampon):
|
|
todo._qemu_console_tunnel("pve9+vm-a", "ssh_config")
|
|
return tampon.getvalue()
|
|
|
|
def test_a_proxmox_host_is_never_told_to_run_virsh(self):
|
|
sortie = self._sortie(qm_present=True)
|
|
self.assertNotIn("virsh", sortie)
|
|
self.assertIn("qm terminal", sortie)
|
|
self.assertIn("8006", sortie, "l'interface web est le second chemin")
|
|
|
|
def test_a_libvirt_host_keeps_its_repair_commands(self):
|
|
# La voie libvirt ne régresse pas : sans port, ses commandes de
|
|
# réparation restent la bonne réponse.
|
|
sortie = self._sortie(qm_present=False)
|
|
self.assertIn("virsh edit", sortie)
|
|
|
|
def test_a_working_vnc_port_still_wins(self):
|
|
# La sonde ne doit pas s'exécuter quand il y a un port : ce serait un
|
|
# aller-retour ssh pour rien.
|
|
sortie = self._sortie(qm_present=True, port=5901)
|
|
self.assertIn("-L 5901:127.0.0.1:5901", sortie)
|
|
self.assertNotIn("qm terminal", sortie)
|
|
|
|
|
|
class TestUnSeulNomDansSshConfig(unittest.TestCase):
|
|
"""L'entrée portait DEUX noms sur sa ligne « Host », puis le mauvais.
|
|
|
|
D'abord le doublon : « Host erplibre-proxmox-9+erplibre-arch-latest
|
|
erplibre-arch-latest ». ssh n'a besoin que d'un nom, et le second
|
|
n'ajoutait qu'une façon de plus d'écrire la même adresse.
|
|
|
|
Puis le choix. Prendre le nom COURT quand il se trouvait libre donnait un
|
|
parc incohérent : sur un même déploiement de trois VM, deux recevaient
|
|
« hôte+vm » — leurs noms étaient pris par des domaines locaux — et la
|
|
troisième son nom court. Une convention qui dépend de ce qui traîne dans
|
|
le fichier n'est pas une convention. Le chaîné est systématique."""
|
|
|
|
def _choisit(self, nom, locaux=()):
|
|
import sys
|
|
|
|
sys.argv = ["todo.py"]
|
|
from script.todo.todo import TODO
|
|
|
|
todo = TODO.__new__(TODO)
|
|
return todo._pve_alias_names(nom, f"pve9+{nom}", set(locaux), "pve9")
|
|
|
|
def test_one_name_and_it_is_the_chained_one(self):
|
|
noms, vole = self._choisit("erplibre-arch-latest")
|
|
self.assertEqual(noms, ["pve9+erplibre-arch-latest"])
|
|
self.assertFalse(vole)
|
|
|
|
def test_a_fleet_gets_one_single_convention(self):
|
|
# Le défaut rapporté : trois VM du même déploiement, deux nommées
|
|
# d'une façon et la troisième d'une autre.
|
|
noms = [
|
|
self._choisit(n, locaux=("erplibre-ubuntu-2604",))[0][0]
|
|
for n in (
|
|
"erplibre-ubuntu-2604",
|
|
"erplibre-arch-latest",
|
|
"erplibre-proxmox-9",
|
|
)
|
|
]
|
|
self.assertTrue(
|
|
all(n.startswith("pve9+") for n in noms),
|
|
f"un parc, une convention : {noms}",
|
|
)
|
|
|
|
def test_a_local_namesake_is_still_named(self):
|
|
# Le nom chaîné ne lui vole rien, mais on le DIT : c'est ce qui
|
|
# explique pourquoi « ssh <nom court> » va ailleurs.
|
|
_noms, vole = self._choisit(
|
|
"erplibre-arch-latest", locaux=("erplibre-arch-latest",)
|
|
)
|
|
self.assertTrue(vole)
|
|
|
|
def test_no_deploy_path_writes_two_names_anymore(self):
|
|
import re
|
|
from pathlib import Path as P
|
|
|
|
src = P("script/todo/proxmox_menu.py").read_text(encoding="utf-8")
|
|
self.assertIsNone(
|
|
re.search(r"noms_alias\.append|noms\.append\(vm\[.name.\]\)", src),
|
|
"le second nom ne doit plus être ajouté",
|
|
)
|
|
|
|
|
|
class TestNeRienPerdreDansSshConfig(unittest.TestCase):
|
|
"""~/.ssh/config contient les entrées PERSONNELLES de l'utilisateur.
|
|
|
|
Ce fichier est réécrit en entier à chaque déploiement de VM. Deux pertes
|
|
de données y ont été constatées, l'une capable de désactiver la
|
|
vérification de clé d'hôte sur un serveur de production."""
|
|
|
|
def setUp(self):
|
|
import sys
|
|
|
|
sys.argv = ["todo.py"]
|
|
from script.todo.todo import TODO
|
|
|
|
self.retirer = TODO._ssh_config_drop_hosts
|
|
|
|
def test_a_block_with_an_unindented_body_goes_entirely(self):
|
|
"""L'indentation est COSMÉTIQUE dans ce format, et un fichier écrit à
|
|
la main s'en passe souvent. La règle d'avant la prenait pour de la
|
|
syntaxe : seule la ligne « Host » partait."""
|
|
avant = (
|
|
"Host prod\n"
|
|
" HostName prod.example.com\n"
|
|
" User root\n"
|
|
"\n"
|
|
"Host deep-1\n"
|
|
"HostName 10.0.0.1\n"
|
|
"User erplibre\n"
|
|
"StrictHostKeyChecking no\n"
|
|
"UserKnownHostsFile /dev/null\n"
|
|
"IdentityFile ~/.ssh/id_deep\n"
|
|
)
|
|
apres = self.retirer(avant, ["deep-1"])
|
|
# Rien du bloc retiré ne subsiste : sans Host au-dessus, ssh
|
|
# rattacherait ces lignes à « prod » et la production perdrait sa
|
|
# vérification de clé d'hôte.
|
|
for orphelin in (
|
|
"10.0.0.1",
|
|
"StrictHostKeyChecking",
|
|
"UserKnownHostsFile",
|
|
"id_deep",
|
|
):
|
|
self.assertNotIn(orphelin, apres, orphelin)
|
|
# Et le bloc de l'utilisateur est intact.
|
|
self.assertIn("HostName prod.example.com", apres)
|
|
self.assertIn("User root", apres)
|
|
|
|
def test_a_shared_host_line_keeps_the_names_not_dropped(self):
|
|
"""« Host prod-db vm-a » perdait le prod-db de l'utilisateur : le bloc
|
|
partait en entier dès qu'UN de ses noms était repris."""
|
|
avant = (
|
|
"Host prod-db vm-a\n"
|
|
" HostName db.interne\n"
|
|
" ProxyJump pve9\n"
|
|
)
|
|
apres = self.retirer(avant, ["vm-a"])
|
|
self.assertIn("Host prod-db\n", apres)
|
|
self.assertNotIn("vm-a", apres)
|
|
# Le corps suit le nom qui reste : sinon prod-db perd son rebond.
|
|
self.assertIn("HostName db.interne", apres)
|
|
self.assertIn("ProxyJump pve9", apres)
|
|
|
|
def test_a_nickname_added_by_hand_survives_a_redeploy(self):
|
|
avant = "Host pve9+vm-a webtest\n HostName 10.10.10.5\n"
|
|
apres = self.retirer(avant, ["pve9+vm-a"])
|
|
self.assertIn("Host webtest\n", apres)
|
|
self.assertIn("HostName 10.10.10.5", apres)
|
|
|
|
def test_all_names_dropped_removes_the_block(self):
|
|
avant = "Host a b\n HostName 1.2.3.4\n\nHost garde\n User x\n"
|
|
apres = self.retirer(avant, ["a", "b"])
|
|
self.assertNotIn("1.2.3.4", apres)
|
|
self.assertIn("Host garde", apres)
|
|
|
|
def test_a_match_section_is_never_swallowed(self):
|
|
avant = (
|
|
"Host part\n"
|
|
" HostName 10.0.0.9\n"
|
|
"\n"
|
|
"Match host *.interne\n"
|
|
" User admin\n"
|
|
)
|
|
apres = self.retirer(avant, ["part"])
|
|
self.assertIn("Match host *.interne", apres)
|
|
self.assertIn("User admin", apres)
|
|
self.assertNotIn("10.0.0.9", apres)
|
|
|
|
def test_comments_before_the_next_block_are_not_swallowed(self):
|
|
avant = (
|
|
"Host part\n"
|
|
" HostName 10.0.0.9\n"
|
|
"\n"
|
|
"# la machine du client, ne pas toucher\n"
|
|
"Host client\n"
|
|
" HostName 10.0.0.10\n"
|
|
)
|
|
apres = self.retirer(avant, ["part"])
|
|
self.assertIn("# la machine du client, ne pas toucher", apres)
|
|
self.assertIn("Host client", apres)
|
|
|
|
def test_global_directives_above_the_first_host_stay(self):
|
|
avant = "ServerAliveInterval 60\n\nHost part\n HostName 10.0.0.9\n"
|
|
apres = self.retirer(avant, ["part"])
|
|
self.assertIn("ServerAliveInterval 60", apres)
|
|
self.assertNotIn("10.0.0.9", apres)
|
|
|
|
def test_the_keyword_is_read_case_insensitively(self):
|
|
# ssh lit ses mots-clés sans égard à la casse ; nous aussi, sinon un
|
|
# « host » minuscule échappe au retrait et le nom vit deux fois.
|
|
avant = "host part\n HostName 10.0.0.9\n"
|
|
self.assertNotIn("10.0.0.9", self.retirer(avant, ["part"]))
|
|
|
|
def test_hostname_is_not_mistaken_for_a_host_line(self):
|
|
avant = "Host garde\n HostName part\n"
|
|
apres = self.retirer(avant, ["part"])
|
|
self.assertIn("Host garde", apres)
|
|
self.assertIn("HostName part", apres)
|
|
|
|
|
|
class TestLAncienNomSEnVa(unittest.TestCase):
|
|
"""La convention a changé : les entrées écrites AVANT portent le nom
|
|
court, et rien ne les retirerait — elles ne portent pas le nom qu'on
|
|
écrit maintenant. Deux blocs mèneraient à la même machine, ce qu'on
|
|
venait justement d'enlever."""
|
|
|
|
def setUp(self):
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
|
|
sys.argv = ["todo.py"]
|
|
from script.todo.todo import TODO
|
|
|
|
self.maison = tempfile.mkdtemp()
|
|
os.makedirs(os.path.join(self.maison, ".ssh"))
|
|
self._vrai = os.environ.get("HOME")
|
|
os.environ["HOME"] = self.maison
|
|
self.todo = TODO.__new__(TODO)
|
|
|
|
def tearDown(self):
|
|
import os
|
|
import shutil
|
|
|
|
if self._vrai is not None:
|
|
os.environ["HOME"] = self._vrai
|
|
shutil.rmtree(self.maison, ignore_errors=True)
|
|
|
|
def _hosts(self):
|
|
import os
|
|
|
|
with open(
|
|
os.path.join(self.maison, ".ssh/config"), encoding="utf-8"
|
|
) as fh:
|
|
return [
|
|
ligne.rstrip() for ligne in fh if ligne.startswith("Host ")
|
|
]
|
|
|
|
def test_dropping_the_last_entry_writes_no_nameless_block(self):
|
|
"""Retirer sans réécrire est un appel légitime : les machines
|
|
n'existent plus.
|
|
|
|
Constaté dans le vrai ~/.ssh/config de l'utilisateur : l'appel écrivait
|
|
« Host » NU, suivi d'un « HostName » vide, puis mourait sur un
|
|
IndexError en annonçant l'ajout. Le bloc sans nom s'applique à rien et
|
|
brouille la lecture du fichier."""
|
|
import os
|
|
|
|
self.todo._write_ssh_config_entry(
|
|
["deep-1"], "erplibre", "10.10.10.150"
|
|
)
|
|
self.todo._write_ssh_config_entry(
|
|
["deep-2"], "erplibre", "10.10.10.151", proxy_jump="deep-1"
|
|
)
|
|
self.todo._write_ssh_config_entry(
|
|
[], "erplibre", "", also_drop=("deep-1", "deep-2")
|
|
)
|
|
self.assertEqual(self._hosts(), [])
|
|
with open(
|
|
os.path.join(self.maison, ".ssh/config"), encoding="utf-8"
|
|
) as fh:
|
|
reste = fh.read()
|
|
self.assertNotIn("Host", reste)
|
|
self.assertNotIn("HostName", reste)
|
|
# Et le fichier garde ses droits : ssh refuse un config trop ouvert.
|
|
self.assertEqual(
|
|
oct(os.stat(os.path.join(self.maison, ".ssh/config")).st_mode)[
|
|
-3:
|
|
],
|
|
"600",
|
|
)
|
|
|
|
def test_dropping_one_entry_leaves_the_others_untouched(self):
|
|
for nom, ip in (("garde-a", "10.0.0.1"), ("part", "10.0.0.2")):
|
|
self.todo._write_ssh_config_entry([nom], "erplibre", ip)
|
|
self.todo._write_ssh_config_entry(
|
|
[], "erplibre", "", also_drop=("part",)
|
|
)
|
|
self.assertEqual(self._hosts(), ["Host garde-a"])
|
|
|
|
def test_the_old_short_entry_is_retired(self):
|
|
# L'état d'avant : une entrée écrite sous l'ancienne convention.
|
|
self.todo._write_ssh_config_entry(
|
|
["vm-a"], "erplibre", "10.10.10.151", proxy_jump="pve9"
|
|
)
|
|
perime = self.todo._pve_alias_perime("vm-a", "pve9")
|
|
self.assertEqual(perime, ["vm-a"])
|
|
self.todo._write_ssh_config_entry(
|
|
["pve9+vm-a"],
|
|
"erplibre",
|
|
"10.10.10.151",
|
|
proxy_jump="pve9",
|
|
also_drop=perime,
|
|
)
|
|
self.assertEqual(self._hosts(), ["Host pve9+vm-a"])
|
|
|
|
def test_a_local_vm_of_the_same_name_is_left_alone(self):
|
|
# Sans ProxyJump vers cet hôte, le bloc n'est pas le nôtre : on n'y
|
|
# touche pas, même s'il porte exactement ce nom.
|
|
self.todo._write_ssh_config_entry(["vm-a"], "erplibre", "192.168.1.9")
|
|
self.assertEqual(self.todo._pve_alias_perime("vm-a", "pve9"), [])
|
|
self.todo._write_ssh_config_entry(
|
|
["pve9+vm-a"],
|
|
"erplibre",
|
|
"10.10.10.151",
|
|
proxy_jump="pve9",
|
|
also_drop=self.todo._pve_alias_perime("vm-a", "pve9"),
|
|
)
|
|
self.assertEqual(self._hosts(), ["Host vm-a", "Host pve9+vm-a"])
|
|
|
|
def test_another_hosts_vm_is_left_alone(self):
|
|
self.todo._write_ssh_config_entry(
|
|
["vm-a"], "erplibre", "10.0.0.9", proxy_jump="pve7"
|
|
)
|
|
self.assertEqual(self.todo._pve_alias_perime("vm-a", "pve9"), [])
|
|
|
|
|
|
class TestLeGuideDeConnexion(unittest.TestCase):
|
|
"""Une VM Proxmox n'avait AUCUN guide, quelle que soit sa distribution.
|
|
|
|
Rapporté sur Arch : « pas l'écran de connexion, avec le guide qui dit de
|
|
prendre pacman, comme sur ubuntu ». La voie libvirt livre /etc/motd par le
|
|
« write_files » de cloud-init ; « qm set » n'offre pas cela. Le contenu
|
|
vient de la MÊME source (`guide_files`) et part par ssh.
|
|
"""
|
|
|
|
def _ecrit(self, vm=None, install=None, distro="arch"):
|
|
import sys
|
|
|
|
sys.argv = ["todo.py"]
|
|
from script.todo.todo import TODO
|
|
|
|
todo = TODO.__new__(TODO)
|
|
vus = {}
|
|
todo._pve_ssh = lambda cible, remote, timeout=60: (
|
|
vus.update(cible=cible, remote=remote) or (0, "")
|
|
)
|
|
mod = todo._qemu_import_module()
|
|
vm = vm or {
|
|
"name": "vm-a",
|
|
"distro": distro,
|
|
"version": "latest",
|
|
"arch": "amd64",
|
|
"desktop": "",
|
|
"install_cmd": "",
|
|
}
|
|
spec = {"user": "erplibre", "install": install}
|
|
import contextlib
|
|
import io
|
|
|
|
with contextlib.redirect_stdout(io.StringIO()):
|
|
ok = todo._pve_write_guide("hote+vm-a", vm, spec, mod)
|
|
vus["ok"] = ok
|
|
return vus
|
|
|
|
def test_the_guide_goes_to_etc_motd_through_the_alias(self):
|
|
vus = self._ecrit()
|
|
self.assertTrue(vus["ok"])
|
|
# Par l'ALIAS : lui seul porte le rebond vers le réseau interne.
|
|
self.assertEqual(vus["cible"], "hote+vm-a")
|
|
self.assertIn("/etc/motd", vus["remote"])
|
|
self.assertIn("sudo tee", vus["remote"])
|
|
|
|
def test_an_arch_vm_is_told_about_pacman(self):
|
|
self.assertIn("pacman", self._ecrit(distro="arch")["remote"])
|
|
|
|
def test_a_debian_vm_is_told_about_apt(self):
|
|
self.assertIn("apt", self._ecrit(distro="debian")["remote"])
|
|
|
|
def test_without_erplibre_the_guide_does_not_promise_a_repository(self):
|
|
# Un guide qui annonce un dépôt absent est un guide qui mente.
|
|
sans = self._ecrit(install=None)["remote"]
|
|
self.assertNotIn("git/erplibre", sans)
|
|
|
|
def test_with_erplibre_it_says_where_it_lives(self):
|
|
avec = self._ecrit(
|
|
install={
|
|
"branch": "develop",
|
|
"cmd": "make install_os && make install_odoo_18",
|
|
}
|
|
)["remote"]
|
|
self.assertIn("git/erplibre", avec)
|
|
|
|
def test_a_failure_is_said_not_swallowed(self):
|
|
import contextlib
|
|
import io
|
|
import sys
|
|
|
|
sys.argv = ["todo.py"]
|
|
from script.todo.todo import TODO
|
|
|
|
todo = TODO.__new__(TODO)
|
|
todo._pve_ssh = lambda *a, **k: (255, "no route")
|
|
mod = todo._qemu_import_module()
|
|
vm = {
|
|
"name": "vm-a",
|
|
"distro": "arch",
|
|
"version": "latest",
|
|
"arch": "amd64",
|
|
"desktop": "",
|
|
"install_cmd": "",
|
|
}
|
|
with contextlib.redirect_stdout(io.StringIO()) as sortie:
|
|
ok = todo._pve_write_guide("x", vm, {"user": "erplibre"}, mod)
|
|
self.assertFalse(ok)
|
|
self.assertIn("⚠", sortie.getvalue())
|
|
|
|
|
|
class TestLeSuivi(unittest.TestCase):
|
|
"""La case « Suivre l'installation » doit commander quelque chose.
|
|
|
|
Elle ne commandait rien : décochée, le tableau de bord s'ouvrait quand
|
|
même ; cochée sans rien à installer, il ne s'ouvrait jamais. Le suivi
|
|
vient du DÉPLOIEMENT, pas de l'installation — c'est la règle déjà tirée du
|
|
côté QEMU/KVM après le même rapport.
|
|
"""
|
|
|
|
def _apres_creation(self, install, monitor):
|
|
"""Rejoue l'épilogue du déploiement et dit quelle voie a été prise."""
|
|
import sys
|
|
|
|
sys.argv = ["todo.py"]
|
|
from script.todo.todo import TODO
|
|
|
|
todo = TODO.__new__(TODO)
|
|
vus = {}
|
|
todo._qemu_install_erplibre_monitored = lambda *a, **k: vus.setdefault(
|
|
"tableau", a
|
|
)
|
|
todo._qemu_install_erplibre_vm = lambda *a, **k: vus.setdefault(
|
|
"serie", a
|
|
)
|
|
todo._write_ssh_config_entry = lambda *a, **k: None
|
|
todo._ssh_private_key = lambda k: None
|
|
todo._pve_guest_ip = lambda vmid, attente=120: ""
|
|
spec = {
|
|
"host": {"target": "pve1"},
|
|
"vms": [
|
|
{
|
|
"name": "vm-a",
|
|
"vmid": 100,
|
|
"ipconfig": "ip=10.10.10.150/24,gw=10.10.10.1",
|
|
"install_cmd": "",
|
|
}
|
|
],
|
|
"add_ssh_config": False,
|
|
"user": "erplibre",
|
|
"install": install,
|
|
"monitor": monitor,
|
|
}
|
|
import contextlib
|
|
import io
|
|
|
|
with contextlib.redirect_stdout(io.StringIO()):
|
|
todo._pve_after_create(spec["host"], spec, ["vm-a"], "")
|
|
return vus
|
|
|
|
def test_ticked_without_anything_to_install_still_opens_it(self):
|
|
# La commande distante regarde alors la VM ARRIVER : c'est justement
|
|
# ce qu'on veut voir sur une VM déployée nue.
|
|
vus = self._apres_creation(install=None, monitor=True)
|
|
self.assertIn("tableau", vus)
|
|
self.assertNotIn("serie", vus)
|
|
|
|
def test_unticked_installs_without_the_dashboard(self):
|
|
vus = self._apres_creation(
|
|
install={"branch": "develop", "cmd": "make x", "label": "X"},
|
|
monitor=False,
|
|
)
|
|
self.assertIn("serie", vus)
|
|
self.assertNotIn("tableau", vus)
|
|
|
|
def test_unticked_and_nothing_to_install_does_nothing(self):
|
|
self.assertEqual(self._apres_creation(install=None, monitor=False), {})
|
|
|
|
def test_the_ssh_entry_is_written_when_the_install_needs_it(self):
|
|
"""La VM est derrière l'hôte : le rebond de ~/.ssh/config est le SEUL
|
|
chemin. Décoché alors qu'une installation est demandée, le suivi ne
|
|
pouvait pas entrer dans la VM."""
|
|
import contextlib
|
|
import io
|
|
import sys
|
|
|
|
sys.argv = ["todo.py"]
|
|
from script.todo.todo import TODO
|
|
|
|
def essai(add_ssh_config, install, monitor):
|
|
todo = TODO.__new__(TODO)
|
|
ecrites = []
|
|
todo._write_ssh_config_entry = lambda nom, *a, **k: ecrites.append(
|
|
nom
|
|
)
|
|
todo._ssh_private_key = lambda k: None
|
|
# Hermétique : le choix du nom lit la liste des domaines
|
|
# locaux. Sans ce bouchon, le test dépendrait de la machine qui
|
|
# le lance.
|
|
todo._qemu_list_domains = lambda: []
|
|
todo._pve_guest_ip = lambda vmid, attente=120: ""
|
|
todo._qemu_install_erplibre_monitored = lambda *a, **k: None
|
|
todo._qemu_install_erplibre_vm = lambda *a, **k: None
|
|
spec = {
|
|
"host": {"target": "pve1"},
|
|
"vms": [
|
|
{
|
|
"name": "vm-a",
|
|
"vmid": 100,
|
|
"ipconfig": "ip=10.10.10.150/24,gw=10.10.10.1",
|
|
"install_cmd": "",
|
|
}
|
|
],
|
|
"add_ssh_config": add_ssh_config,
|
|
"user": "erplibre",
|
|
"install": install,
|
|
"monitor": monitor,
|
|
}
|
|
with contextlib.redirect_stdout(io.StringIO()):
|
|
todo._pve_after_create(spec["host"], spec, ["vm-a"], "")
|
|
return ecrites
|
|
|
|
cmd = {"branch": "develop", "cmd": "make x", "label": "X"}
|
|
# UN nom, et le chaîné : « hôte+vm » dit où la machine vit et ne
|
|
# dépend pas de ce qui traîne dans ~/.ssh/config (voir
|
|
# TestUnSeulNomDansSshConfig).
|
|
self.assertEqual(essai(False, cmd, False), [["pve1+vm-a"]])
|
|
# Décoché, suivi demandé : le suivi entre aussi par le rebond.
|
|
self.assertEqual(essai(False, None, True), [["pve1+vm-a"]])
|
|
# Décoché et rien à faire dans la VM : le choix est respecté.
|
|
self.assertEqual(essai(False, None, False), [])
|
|
# Coché : écrite, évidemment.
|
|
self.assertEqual(essai(True, None, False), [["pve1+vm-a"]])
|
|
|
|
def test_a_local_vm_of_the_same_name_keeps_its_alias(self):
|
|
"""Le piège qui a fait installer ERPLibre sur la MAUVAISE machine.
|
|
|
|
Une VM déployée sur Proxmox sous un nom déjà porté par un domaine
|
|
LOCAL volait son alias ~/.ssh/config, et le suivi — qui ré-résolvait
|
|
l'adresse par virsh — partait installer sur la locale."""
|
|
import contextlib
|
|
import io
|
|
import sys
|
|
|
|
sys.argv = ["todo.py"]
|
|
from script.todo.todo import TODO
|
|
|
|
todo = TODO.__new__(TODO)
|
|
ecrites = []
|
|
todo._qemu_list_domains = lambda: ["vm-a"]
|
|
todo._write_ssh_config_entry = lambda noms, *a, **k: ecrites.append(
|
|
noms
|
|
)
|
|
todo._ssh_private_key = lambda k: None
|
|
todo._pve_guest_ip = lambda vmid, attente=120: ""
|
|
vus = {}
|
|
todo._qemu_install_erplibre_monitored = (
|
|
lambda noms, br, ipmap, cmd, **k: vus.update(ipmap=ipmap)
|
|
)
|
|
spec = {
|
|
"host": {"target": "erplibre@pve1"},
|
|
"vms": [
|
|
{
|
|
"name": "vm-a",
|
|
"vmid": 100,
|
|
"ipconfig": "ip=10.10.10.150/24,gw=10.10.10.1",
|
|
"install_cmd": "",
|
|
}
|
|
],
|
|
"add_ssh_config": True,
|
|
"user": "erplibre",
|
|
"install": None,
|
|
"monitor": True,
|
|
}
|
|
with contextlib.redirect_stdout(io.StringIO()) as sortie:
|
|
todo._pve_after_create(spec["host"], spec, ["vm-a"], "")
|
|
# SEUL le nom chaîné est écrit : l'alias court reste à la VM locale.
|
|
self.assertEqual(ecrites, [["pve1+vm-a"]])
|
|
# Et le suivi passe par ce nom-là, jamais par « vm-a ».
|
|
self.assertEqual(vus["ipmap"], {"vm-a": "pve1+vm-a"})
|
|
self.assertIn("pve1+vm-a", sortie.getvalue())
|
|
|
|
def test_ticked_with_an_install_opens_it(self):
|
|
vus = self._apres_creation(
|
|
install={"branch": "develop", "cmd": "make x", "label": "X"},
|
|
monitor=True,
|
|
)
|
|
self.assertIn("tableau", vus)
|
|
|
|
|
|
class TestLAutoriteDuCacheDansUneVmImbriquee(unittest.TestCase):
|
|
"""Une VM née sur un Proxmox imbriqué est interceptée sans le savoir.
|
|
|
|
Le cache détourne tout ce qui sort de son pont. Un hôte Proxmox qui est
|
|
lui-même une VM d'ici y est branché, et les machines qu'il porte sortent
|
|
derrière son adresse : elles traversent donc le cache, alors que rien à
|
|
l'intérieur ne leur a donné son autorité. Le mode de défaillance est
|
|
trompeur — un dépôt apt en clair passe, si bien que l'installation
|
|
démarre, et seuls les téléchargements HTTPS échouent, sur « self-signed
|
|
certificate in certificate chain ».
|
|
|
|
« qm set » ne sait écrire aucun fichier : l'autorité part par ssh, depuis
|
|
la MÊME source que la voie libvirt (`cache_files`, `cache_commands`).
|
|
"""
|
|
|
|
def _todo(self, domaines=("pve-local",), ca="/tmp/ca.crt"):
|
|
import sys
|
|
|
|
sys.argv = ["todo.py"]
|
|
from script.todo.todo import TODO
|
|
|
|
todo = TODO.__new__(TODO)
|
|
todo._qemu_list_domains = lambda: list(domaines)
|
|
todo._qemu_cache_ca_path = classmethod(lambda cls: ca).__get__(
|
|
todo, type(todo)
|
|
)
|
|
return todo
|
|
|
|
# -- Qui est concerné ----------------------------------------------
|
|
|
|
def test_a_nested_proxmox_host_gets_the_authority(self):
|
|
todo = self._todo()
|
|
self.assertEqual(
|
|
todo._pve_cache_ca({"target": "root@pve-local"}),
|
|
"/tmp/ca.crt",
|
|
)
|
|
|
|
def test_a_proxmox_host_that_lives_elsewhere_gets_nothing(self):
|
|
"""Son trafic ne traverse pas ce pont : l'autorité n'y servirait à
|
|
rien, et le cache doit s'installer sur cet hôte-là."""
|
|
todo = self._todo()
|
|
self.assertEqual(todo._pve_cache_ca({"target": "root@10.0.0.5"}), "")
|
|
|
|
def test_no_cache_installed_here_means_no_authority(self):
|
|
todo = self._todo(ca="")
|
|
self.assertEqual(todo._pve_cache_ca({"target": "pve-local"}), "")
|
|
|
|
# -- Ce qui est réellement posé ------------------------------------
|
|
|
|
def _pose(self, distro="ubuntu", cache_files=None):
|
|
import contextlib
|
|
import io
|
|
import tempfile
|
|
|
|
todo = self._todo()
|
|
vus = {}
|
|
todo._pve_ssh = lambda cible, remote, timeout=60: (
|
|
vus.update(cible=cible, remote=remote, timeout=timeout) or (0, "")
|
|
)
|
|
mod = todo._qemu_import_module()
|
|
with tempfile.NamedTemporaryFile(
|
|
"w", suffix=".crt", delete=False
|
|
) as fh:
|
|
fh.write("-----BEGIN CERTIFICATE-----\nZm F1eA==\n")
|
|
fh.write("-----END CERTIFICATE-----\n")
|
|
ca = fh.name
|
|
patch = (
|
|
mock.patch.object(mod, "cache_files", cache_files)
|
|
if cache_files
|
|
else contextlib.nullcontext()
|
|
)
|
|
with contextlib.redirect_stdout(io.StringIO()), patch:
|
|
vus["ok"] = todo._pve_set_cache_ca(
|
|
"hote+vm-a", {"name": "vm-a", "distro": distro}, ca
|
|
)
|
|
return vus
|
|
|
|
def test_the_authority_goes_where_the_family_reads_it(self):
|
|
vus = self._pose()
|
|
self.assertTrue(vus["ok"])
|
|
# Par l'ALIAS : lui seul porte le rebond vers le réseau interne.
|
|
self.assertEqual(vus["cible"], "hote+vm-a")
|
|
self.assertIn(
|
|
"/usr/local/share/ca-certificates/erplibre-cache.crt",
|
|
vus["remote"],
|
|
)
|
|
|
|
def test_arch_does_not_get_the_debian_path(self):
|
|
vus = self._pose(distro="arch")
|
|
self.assertIn(
|
|
"/etc/ca-certificates/trust-source/anchors", vus["remote"]
|
|
)
|
|
self.assertNotIn("/usr/local/share/ca-certificates", vus["remote"])
|
|
|
|
def test_the_store_is_reread_then_the_variables_are_written(self):
|
|
"""Dans cet ordre : les variables visent le faisceau que la commande
|
|
de confiance vient de régénérer."""
|
|
vus = self._pose()
|
|
confiance = vus["remote"].index("update-ca-certificates")
|
|
for var in ("PIP_CERT", "REQUESTS_CA_BUNDLE", "NODE_EXTRA_CA_CERTS"):
|
|
self.assertLess(confiance, vus["remote"].index(var), var)
|
|
|
|
def test_the_content_comes_from_cache_files_and_is_not_rebuilt_here(self):
|
|
"""Une seule source pour les deux voies de livraison : ce que
|
|
cloud-init écrirait est ce que ssh pose."""
|
|
vus = self._pose(
|
|
cache_files=lambda args: [
|
|
("/etc/anchors/temoin.crt", "0644", "PEM-TEMOIN", "")
|
|
]
|
|
)
|
|
self.assertIn("/etc/anchors/temoin.crt", vus["remote"])
|
|
self.assertIn("PEM-TEMOIN", vus["remote"])
|
|
|
|
def test_a_distro_out_of_the_table_poses_nothing(self):
|
|
"""Le fichier au mauvais endroit ne servirait à rien sans rien
|
|
dire ; la VM télécharge en direct, ce qui marche."""
|
|
vus = self._pose(distro="plan9")
|
|
self.assertFalse(vus["ok"])
|
|
self.assertNotIn("remote", vus)
|
|
|
|
# -- Le câblage ----------------------------------------------------
|
|
|
|
def test_the_authority_is_posed_before_the_install(self):
|
|
"""Le contrôle porte sur l'ORDRE : c'est l'installation qui
|
|
télécharge, et un magasin relu ensuite ne rattrape rien."""
|
|
import contextlib
|
|
import io
|
|
import sys
|
|
|
|
sys.argv = ["todo.py"]
|
|
from script.todo.todo import TODO
|
|
|
|
ordre = []
|
|
todo = TODO.__new__(TODO)
|
|
todo._write_ssh_config_entry = lambda *a, **k: None
|
|
todo._ssh_private_key = lambda k: None
|
|
todo._pve_alias_perime = lambda *a, **k: []
|
|
todo._qemu_list_domains = lambda: ["pve-local"]
|
|
todo._qemu_cache_ca_path = classmethod(
|
|
lambda cls: "/tmp/ca.crt"
|
|
).__get__(todo, type(todo))
|
|
todo._pve_guest_ip = lambda vmid, attente=120: ""
|
|
todo._pve_write_guide = lambda *a, **k: True
|
|
todo._pve_set_timezone = lambda *a, **k: True
|
|
todo._qemu_import_module = lambda: None
|
|
todo._pve_set_cache_ca = lambda cible, vm, ca, **k: ordre.append(
|
|
("autorité", cible, ca)
|
|
)
|
|
todo._qemu_install_erplibre_monitored = lambda *a, **k: ordre.append(
|
|
("installation",)
|
|
)
|
|
spec = {
|
|
"host": {"target": "root@pve-local"},
|
|
"vms": [
|
|
{
|
|
"name": "vm-a",
|
|
"vmid": 100,
|
|
"distro": "ubuntu",
|
|
"ipconfig": "ip=10.10.10.150/24,gw=10.10.10.1",
|
|
"install_cmd": "",
|
|
}
|
|
],
|
|
"user": "erplibre",
|
|
"add_ssh_config": True,
|
|
"install": {"branch": "develop", "cmd": "make x", "label": "X"},
|
|
"monitor": True,
|
|
"desktop": "",
|
|
}
|
|
with contextlib.redirect_stdout(io.StringIO()):
|
|
todo._pve_after_create(spec["host"], spec, ["vm-a"], "")
|
|
self.assertEqual([e[0] for e in ordre], ["autorité", "installation"])
|
|
self.assertEqual(ordre[0][2], "/tmp/ca.crt")
|
|
|
|
def test_a_remote_host_does_not_get_the_step_at_all(self):
|
|
import contextlib
|
|
import io
|
|
import sys
|
|
|
|
sys.argv = ["todo.py"]
|
|
from script.todo.todo import TODO
|
|
|
|
appels = []
|
|
todo = TODO.__new__(TODO)
|
|
todo._write_ssh_config_entry = lambda *a, **k: None
|
|
todo._ssh_private_key = lambda k: None
|
|
todo._pve_alias_perime = lambda *a, **k: []
|
|
todo._qemu_list_domains = lambda: []
|
|
todo._qemu_cache_ca_path = classmethod(
|
|
lambda cls: "/tmp/ca.crt"
|
|
).__get__(todo, type(todo))
|
|
todo._pve_guest_ip = lambda vmid, attente=120: ""
|
|
todo._pve_write_guide = lambda *a, **k: True
|
|
todo._pve_set_timezone = lambda *a, **k: True
|
|
todo._qemu_import_module = lambda: None
|
|
todo._pve_set_cache_ca = lambda *a: appels.append(a)
|
|
todo._qemu_install_erplibre_monitored = lambda *a, **k: None
|
|
spec = {
|
|
"host": {"target": "root@10.0.0.5"},
|
|
"vms": [
|
|
{
|
|
"name": "vm-a",
|
|
"vmid": 100,
|
|
"distro": "ubuntu",
|
|
"ipconfig": "ip=10.10.10.150/24,gw=10.10.10.1",
|
|
"install_cmd": "",
|
|
}
|
|
],
|
|
"user": "erplibre",
|
|
"add_ssh_config": True,
|
|
"install": None,
|
|
"monitor": False,
|
|
"desktop": "",
|
|
}
|
|
with contextlib.redirect_stdout(io.StringIO()):
|
|
todo._pve_after_create(spec["host"], spec, ["vm-a"], "")
|
|
self.assertEqual(appels, [])
|
|
|
|
|
|
class TestLAutoriteDUneVmProxmoxHorsLigne(unittest.TestCase):
|
|
"""Une VM Proxmox déployée hors ligne reçoit, comme la voie libvirt, de quoi
|
|
couper l'audit de npm : aucun cache ne rejoue ce service."""
|
|
|
|
def test_seule_la_vm_hors_ligne_coupe_l_audit(self):
|
|
import contextlib
|
|
import io
|
|
import sys
|
|
import tempfile
|
|
|
|
sys.argv = ["todo.py"]
|
|
from script.todo.todo import TODO
|
|
|
|
todo = TODO.__new__(TODO)
|
|
vu = {}
|
|
|
|
def faux_ssh(cible, cmd, timeout=120):
|
|
vu["cmd"] = cmd
|
|
return 0, ""
|
|
|
|
todo._pve_ssh = faux_ssh
|
|
with tempfile.NamedTemporaryFile(
|
|
"w", suffix=".crt", delete=False
|
|
) as fh:
|
|
fh.write("-----BEGIN CERTIFICATE-----\nZm F1eA==\n")
|
|
fh.write("-----END CERTIFICATE-----\n")
|
|
ca = fh.name
|
|
rendus = {}
|
|
with contextlib.redirect_stdout(io.StringIO()):
|
|
for hors_ligne in (True, False):
|
|
todo._pve_set_cache_ca(
|
|
"hote+vm-a",
|
|
{"name": "vm-a", "distro": "ubuntu"},
|
|
ca,
|
|
hors_ligne=hors_ligne,
|
|
)
|
|
rendus[hors_ligne] = vu.get("cmd", "")
|
|
self.assertIn("NPM_CONFIG_AUDIT", rendus[True])
|
|
self.assertNotIn("NPM_CONFIG_AUDIT", rendus[False])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main(verbosity=2)
|