erplibre/test/test_qemu_cleanup.py
Mathieu Benoit 9dd4b3e37a [FIX] qemu : un alias ssh et une adresse ne se jugent pas sur le nom
Suite du nettoyage. Le tri des entrées ~/.ssh/config comparait lui aussi le
NOM aux noms de domaines : après le renommage de la VM, son alias
« erplibre-ubuntu-2404 » ne correspondait plus à rien et a été effacé, alors
qu'il menait à une machine en marche. Une entrée est conservée si son adresse
est celle d'un domaine vivant, si elle porte un rebond — donc écrite pour une
VM que virsh ne verra jamais — ou si son nom est une VM de l'hôte Proxmox. Et
l'écran nomme ce qu'il garde, comme pour les fichiers.

Même racine pour l'adresse affichée : « virsh domifaddr --source arp »
remonte les passerelles des ponts, et le repli « la dernière candidate » y
tombait dès que le bail portait l'ancien nom d'hôte. La VM renommée était
annoncée en 192.168.122.1 au lieu de 192.168.123.170. On préfère désormais le
bail, puis l'agent, puis l'ARP, et les adresses de l'hôte sont écartées.

--- EN ---

More of the same cleanup. Sorting ~/.ssh/config entries also compared the
NAME against domain names: after the VM was renamed, its alias
"erplibre-ubuntu-2404" matched nothing and was deleted, though it led to a
running machine. An entry is kept if its address belongs to a live domain, if
it carries a jump — hence written for a VM virsh will never see — or if its
name is a VM of the Proxmox host. And the screen names what it keeps, as it
does for files.

Same root for the displayed address: "virsh domifaddr --source arp" returns
the bridges' gateways, and falling back to "the last candidate" landed there
as soon as the lease carried the old hostname. The renamed VM was announced
as 192.168.122.1 instead of 192.168.123.170. The lease now wins over the
agent, which wins over ARP, and the host's own addresses are excluded.

Assisted-by: Claude Opus 5
2026-08-25 03:19:18 -04:00

315 lines
12 KiB
Python

#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Le nettoyage ne doit jamais proposer un fichier EN USAGE.
Rapporté, et c'est le pire défaut possible ici : après avoir renommé une VM
« erplibre-ubuntu-2404 » en « erplibre-ubuntu-2404-MIGRATION », le nettoyage
offrait au « rm -f » son disque de 63 Go, son seed et son nvram — tous les
trois attachés à une VM EN MARCHE. La cause : l'orphelinat se jugeait sur le
NOM du fichier comparé aux noms de domaines.
Le nom ne dit rien de l'usage. L'autorité, c'est libvirt : ce qu'un domaine
référence n'est pas orphelin, quel que soit son nom. Et devant un « rm -f »
de 63 Go, un second contrôle indépendant (les fichiers qu'un processus tient
ouverts) vaut le coup.
"""
import os
import sys
import unittest
sys.argv = ["todo.py"]
from script.todo.todo import TODO # noqa: E402
# La forme RÉELLE, relevée sur la machine : le nvram porte un attribut
# « template », et c'est ce qui l'avait fait manquer d'un premier filtre.
XML_MIGRATION = """<domain type='kvm'>
<name>erplibre-ubuntu-2404-MIGRATION</name>
<os firmware='efi'>
<loader readonly='yes' type='pflash'>/usr/share/OVMF/OVMF_CODE_4M.fd</loader>
<nvram template='/usr/share/OVMF/OVMF_VARS_4M.fd'>/var/lib/libvirt/qemu/nvram/erplibre-ubuntu-2404_VARS.fd</nvram>
</os>
<devices>
<disk type='file' device='disk'>
<source file='/var/lib/libvirt/images/erplibre-ubuntu-2404.qcow2'/>
<target dev='vda' bus='virtio'/>
</disk>
<disk type='file' device='disk'>
<source file='/var/lib/libvirt/images/iso/erplibre-ubuntu-2404-seed.iso'/>
<target dev='vdb' bus='virtio'/>
</disk>
<interface type='network'>
<source network='default'/>
</interface>
</devices>
</domain>
"""
DISQUE = "/var/lib/libvirt/images/erplibre-ubuntu-2404.qcow2"
SEED = "/var/lib/libvirt/images/iso/erplibre-ubuntu-2404-seed.iso"
NVRAM = "/var/lib/libvirt/qemu/nvram/erplibre-ubuntu-2404_VARS.fd"
def todo_avec(xml_par_domaine, ouverts=()):
"""Une instance TODO dont libvirt et /proc sont remplacés par des faits."""
todo = TODO.__new__(TODO)
todo._qemu_list_domains = lambda: list(xml_par_domaine)
todo._qemu_dumpxml = lambda nom, inactive=True: xml_par_domaine.get(
nom, ""
)
todo._qemu_files_in_use = lambda: set(ouverts)
return todo
class TestCeQueLesDomainesReferencent(unittest.TestCase):
def setUp(self):
self.todo = todo_avec(
{"erplibre-ubuntu-2404-MIGRATION": XML_MIGRATION}
)
def test_a_renamed_domain_still_owns_its_files(self):
# Le cœur du rapport : le nom du fichier n'a plus rien à voir avec le
# nom du domaine, et c'est parfaitement normal après un renommage.
refs = self.todo._qemu_referenced_files()
for chemin in (DISQUE, SEED, NVRAM):
self.assertEqual(
refs.get(chemin), "erplibre-ubuntu-2404-MIGRATION", chemin
)
def test_the_nvram_is_read_despite_its_attribute(self):
# « <nvram template='…'>chemin</nvram> » : attendre « <nvram> » nu
# manquait le fichier le plus facile à perdre.
self.assertIn(NVRAM, self.todo._qemu_referenced_files())
def test_the_network_source_is_not_a_file(self):
# « <source network='default'/> » ne doit pas entrer dans la liste.
for chemin in self.todo._qemu_referenced_files():
self.assertTrue(chemin.startswith("/"), chemin)
def test_a_domain_without_xml_is_simply_skipped(self):
todo = todo_avec({"fantome": ""})
self.assertEqual(todo._qemu_referenced_files(), {})
class TestLeTri(unittest.TestCase):
"""(orphelins, protégés) : c'est ce tri qui décide de ce qui est effacé."""
def _tri(self, candidats, xml=None, ouverts=()):
todo = todo_avec(
xml if xml is not None else {"vm-a": XML_MIGRATION}, ouverts
)
return todo._qemu_split_orphans(candidats)
def test_a_referenced_file_is_never_an_orphan(self):
orph, prot = self._tri([(63, DISQUE, "disque orphelin")])
self.assertEqual(orph, [])
self.assertEqual(prot[0][2], "vm-a")
def test_an_unreferenced_file_stays_an_orphan(self):
perdu = "/var/lib/libvirt/images/plus-personne.qcow2"
orph, prot = self._tri([(1, perdu, "disque orphelin")])
self.assertEqual([o[1] for o in orph], [perdu])
self.assertEqual(prot, [])
def test_a_file_held_open_is_protected_even_without_libvirt(self):
# Un qemu lancé à la main, ou une définition que libvirt a perdue :
# le fichier est ouvert, donc il n'est pas à jeter.
perdu = "/var/lib/libvirt/images/lancee-a-la-main.qcow2"
orph, prot = self._tri(
[(1, perdu, "disque orphelin")], xml={}, ouverts=(perdu,)
)
self.assertEqual(orph, [])
self.assertIn(
prot[0][2], (perdu, "un processus en cours", "a running process")
)
def test_the_three_files_of_the_report_are_all_kept(self):
candidats = [
(63_000_000_000, DISQUE, "disque orphelin"),
(528_000, NVRAM, "nvram orpheline"),
(368_000, SEED, "seed orphelin"),
]
orph, prot = self._tri(candidats)
self.assertEqual(orph, [], "un fichier en usage serait effacé")
self.assertEqual(len(prot), 3)
class TestEffacerUneVm(unittest.TestCase):
"""L'autre bout du même défaut : effacer par le NOM laissait 63 Go.
« rm /var/lib/libvirt/images/<nom>.qcow2 » ne trouve rien quand la VM a
été renommée — la définition partait, le disque restait, et devenait un
vrai orphelin de 63 Go que le nettoyage n'osait plus toucher.
"""
def test_a_renamed_vm_deletes_its_real_files(self):
todo = todo_avec({"erplibre-ubuntu-2404-MIGRATION": XML_MIGRATION})
self.assertEqual(
todo._qemu_vm_own_files("erplibre-ubuntu-2404-MIGRATION"),
[DISQUE, SEED],
)
def test_the_nvram_is_left_to_virsh_undefine(self):
# « virsh undefine --nvram » s'en charge : le lister deux fois ne
# sert à rien, et un « rm » sur un nvram encore utilisé serait pire.
todo = todo_avec({"vm-a": XML_MIGRATION})
self.assertNotIn(NVRAM, todo._qemu_vm_own_files("vm-a"))
def test_a_file_shared_with_a_neighbour_is_spared(self):
# Image de FOND d'une chaîne de qcow2 : l'effacer creverait l'autre.
partage = (
"<domain><devices><disk><source file='"
+ DISQUE
+ "'/></disk></devices></domain>"
)
todo = todo_avec({"vm-a": XML_MIGRATION, "vm-b": partage})
propres = todo._qemu_vm_own_files("vm-a")
self.assertNotIn(DISQUE, propres)
self.assertIn(SEED, propres)
def test_a_vm_without_files_yields_nothing_rather_than_a_guess(self):
todo = todo_avec({"vm-a": "<domain><devices/></domain>"})
self.assertEqual(todo._qemu_vm_own_files("vm-a"), [])
CONFIG_SSH = """Host exo
HostName 132.207.112.51
Host erplibre-ubuntu-2404
HostName 192.168.123.170
User erplibre
Host erplibre-partie
HostName 192.168.123.99
User erplibre
Host erplibre-imbriquee
HostName 10.10.10.150
User erplibre
ProxyJump erplibre-proxmox-9
Host erplibre-sur-proxmox
HostName 10.10.10.151
User erplibre
"""
class TestLesEntreesSsh(unittest.TestCase):
"""Le nettoyage des entrées ~/.ssh/config jugeait, lui aussi, sur le NOM.
Conséquence vécue : après le renommage de la VM, son alias
« erplibre-ubuntu-2404 » ne correspondait plus à aucun domaine et a été
effacé — alors qu'il menait à une machine EN MARCHE. Trois autres preuves
valent mieux qu'un nom.
"""
def setUp(self):
self.todo = TODO.__new__(TODO)
self.domaines = {"erplibre-ubuntu-2404-MIGRATION"}
self.adresses = {"192.168.123.170": "erplibre-ubuntu-2404-MIGRATION"}
def _juge(self, nom, distantes=()):
return self.todo._ssh_entry_alive(
CONFIG_SSH, nom, self.domaines, self.adresses, set(distantes)
)
def test_a_renamed_vm_keeps_its_alias(self):
# L'adresse mène à un domaine vivant : le nom n'a plus d'importance.
self.assertEqual(
self._juge("erplibre-ubuntu-2404"),
"erplibre-ubuntu-2404-MIGRATION",
)
def test_a_name_that_matches_a_domain_is_kept(self):
self.assertTrue(self._juge("erplibre-ubuntu-2404-MIGRATION"))
def test_a_jump_entry_is_kept(self):
# Écrite pour une VM imbriquée ou distante : virsh ne la connaîtra
# jamais, et son adresse n'est pas routable d'ici.
self.assertTrue(self._juge("erplibre-imbriquee"))
def test_a_vm_of_the_proxmox_host_is_kept(self):
self.assertTrue(
self._juge("erplibre-sur-proxmox", ["erplibre-sur-proxmox"])
)
def test_an_entry_that_leads_nowhere_is_an_orphan(self):
self.assertEqual(self._juge("erplibre-partie"), "")
class TestLAdresseDUneVm(unittest.TestCase):
"""« --source arp » remonte les passerelles des ponts : la dernière
candidate n'est pas la bonne.
Vécu sur la VM renommée : son bail porte encore l'ancien nom d'hôte, donc
aucune correspondance, et le repli sur « la dernière » annonçait
192.168.122.1 — la passerelle — au lieu de 192.168.123.170.
"""
def _todo(self, par_source):
todo = TODO.__new__(TODO)
todo._qemu_candidates_by_source = lambda nom: par_source
todo._qemu_lease_ip_for_host = lambda nom, cands: None
return todo
def test_the_lease_wins_over_the_arp_table(self):
todo = self._todo(
{
"lease": ["192.168.123.170"],
"agent": ["192.168.123.170", "192.168.122.1"],
"arp": ["192.168.123.170", "192.168.122.1"],
}
)
self.assertEqual(todo._qemu_vm_ip_now("x"), "192.168.123.170")
def test_without_a_lease_the_agent_speaks(self):
todo = self._todo({"agent": ["10.0.0.5"], "arp": ["192.168.122.1"]})
self.assertEqual(todo._qemu_vm_ip_now("x"), "10.0.0.5")
def test_the_freshest_lease_of_the_source_is_taken(self):
# Bail précoce (« ubuntu ») puis bail définitif : c'est le dernier du
# BAIL qui compte, pas le dernier toutes sources confondues.
todo = self._todo(
{"lease": ["192.168.123.10", "192.168.123.11"], "arp": ["1.2.3.4"]}
)
self.assertEqual(todo._qemu_vm_ip_now("x"), "192.168.123.11")
def test_nothing_known_yields_nothing(self):
self.assertIsNone(self._todo({})._qemu_vm_ip_now("x"))
def test_the_hosts_own_addresses_are_never_candidates(self):
siennes = TODO._qemu_host_addresses()
self.assertIn("127.0.0.1", siennes)
for nom in TODO.__new__(TODO)._qemu_list_domains():
for ips in TODO._qemu_candidates_by_source(nom).values():
self.assertFalse(set(ips) & siennes, nom)
class TestLesFichiersOuverts(unittest.TestCase):
"""Le contrôle indépendant : /proc, sans privilège."""
def test_it_finds_the_files_of_a_running_vm(self):
# Sur cette machine, si une VM tourne, son disque est cité par la
# ligne de commande de son qemu. Sinon, le test ne prouve rien et
# doit le DIRE plutôt que de passer pour rien.
todo = TODO.__new__(TODO)
domaines = todo._qemu_list_domains()
if not domaines:
self.skipTest("aucune VM définie sur cette machine")
ouverts = TODO._qemu_files_in_use()
if not ouverts:
self.skipTest("aucune VM démarrée sur cette machine")
self.assertTrue(
all(o.startswith("/var/lib/libvirt/") for o in ouverts)
)
def test_it_only_keeps_paths_that_exist(self):
# La ligne de commande d'un processus quelconque peut contenir
# n'importe quoi — ce test-ci en met la preuve dans la sienne :
# /var/lib/libvirt/n-existe-pas-du-tout
for chemin in TODO._qemu_files_in_use():
self.assertTrue(os.path.exists(chemin), chemin)
if __name__ == "__main__":
unittest.main(verbosity=2)