#!/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 = """
erplibre-ubuntu-2404-MIGRATION
/usr/share/OVMF/OVMF_CODE_4M.fd
/var/lib/libvirt/qemu/nvram/erplibre-ubuntu-2404_VARS.fd
"""
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):
# « chemin » : attendre « » 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):
# « » 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/.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 = (
""
)
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": ""})
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_is_not_a_direct_proof(self):
# Un ProxyJump valait preuve À LUI SEUL. Il n'en est plus une ICI :
# la réponse dépend du rebond, donc des AUTRES entrées, et elle se
# décide dans ssh_orphans. Sinon une chaîne de rebonds morts se
# soutiendrait toute seule.
self.assertEqual(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 TestLeRebondQuiNExistePlus(unittest.TestCase):
"""Le nettoyage a effacé la VM Proxmox locale, retiré son entrée — c'était
juste — et GARDÉ les trois entrées qui rebondissaient par elle.
Trois culs-de-sac, présentés comme « mènent encore quelque part ». Deux
fautes : un ProxyJump valait preuve de vie sans qu'on regarde jamais si le
rebond existait, et chaque entrée était jugée ISOLÉMENT — retirer le
parent ne faisait pas réexaminer les enfants."""
CONFIG = """
Host erplibre-vivante
HostName 192.168.123.170
Host erplibre-proxmox-9
HostName 192.168.123.208
Host erplibre-proxmox-9+enfant
HostName 10.10.10.150
ProxyJump erplibre-proxmox-9
Host erplibre-petit-enfant
HostName 10.10.20.1
ProxyJump erplibre-proxmox-9+enfant
Host erplibre-par-hote-personnel
HostName 10.10.30.1
ProxyJump mon-serveur-perso
Host mon-serveur-perso
HostName 203.0.113.9
"""
def _passe(self, vivants):
from script.todo.qemu_manage import parse_ssh_blocks, ssh_orphans
return ssh_orphans(
parse_ssh_blocks(self.CONFIG),
lambda nom: nom if nom in vivants else "",
)
def test_the_children_go_with_their_jump(self):
_gardes, orphelines = self._passe({"erplibre-vivante"})
noms = [n for n, _r in orphelines]
self.assertIn("erplibre-proxmox-9", noms)
self.assertIn("erplibre-proxmox-9+enfant", noms)
def test_and_so_do_the_grandchildren(self):
# Le point fixe : retirer un parent orpheline ses enfants, qui
# orphelinent les leurs. Une seule passe n'aurait vu que le premier
# étage.
_gardes, orphelines = self._passe({"erplibre-vivante"})
self.assertIn("erplibre-petit-enfant", [n for n, _r in orphelines])
def test_the_reason_names_the_missing_jump(self):
# Seule chose qui permet de répondre non en connaissance de cause.
_gardes, orphelines = self._passe({"erplibre-vivante"})
raison = dict(orphelines)["erplibre-proxmox-9+enfant"]
self.assertIn("erplibre-proxmox-9", raison)
def test_a_living_jump_keeps_its_children(self):
gardes, _orphelines = self._passe(
{"erplibre-vivante", "erplibre-proxmox-9"}
)
noms = [n for n, _r in gardes]
self.assertIn("erplibre-proxmox-9+enfant", noms)
self.assertIn("erplibre-petit-enfant", noms)
def test_a_jump_whose_entry_is_already_gone(self):
"""L'état laissé par le nettoyage précédent : le parent RETIRÉ, les
enfants gardés.
Le rebond ne désigne alors plus rien du tout — ni entrée, ni domaine.
Le prendre pour « un rebond qu'on ne gère pas » laissait les trois
culs-de-sac en place une seconde fois."""
from script.todo.qemu_manage import parse_ssh_blocks, ssh_orphans
sans_parent = """
Host erplibre-vivante
HostName 192.168.123.170
Host erplibre-proxmox-9+enfant
HostName 10.10.10.150
ProxyJump erplibre-proxmox-9
"""
_gardes, orphelines = ssh_orphans(
parse_ssh_blocks(sans_parent),
lambda nom: nom if nom == "erplibre-vivante" else "",
)
self.assertEqual(
[n for n, _r in orphelines], ["erplibre-proxmox-9+enfant"]
)
def test_a_jump_that_is_a_living_domain_without_an_entry(self):
# Le nom est du nôtre et n'a pas d'entrée, mais le domaine TOURNE :
# on ne coupe pas.
from script.todo.qemu_manage import parse_ssh_blocks, ssh_orphans
cfg = """
Host erplibre-enfant
HostName 10.10.10.150
ProxyJump erplibre-hote
"""
gardes, _o = ssh_orphans(
parse_ssh_blocks(cfg),
lambda nom: nom if nom == "erplibre-hote" else "",
)
self.assertEqual([n for n, _r in gardes], ["erplibre-enfant"])
def test_a_jump_we_do_not_manage_is_never_our_call(self):
# Hôte personnel, adresse, nom DNS : on le suppose vivant plutôt que
# d'effacer sur une supposition.
gardes, _orphelines = self._passe({"erplibre-vivante"})
self.assertIn("erplibre-par-hote-personnel", [n for n, _r in gardes])
def test_entries_outside_the_prefix_are_never_judged(self):
gardes, orphelines = self._passe({"erplibre-vivante"})
tous = [n for n, _r in gardes] + [n for n, _r in orphelines]
self.assertNotIn("mon-serveur-perso", tous)
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)