[ADD] qemu menu : récupérer des fichiers dans le disque d'une VM
Une VM qui ne démarre plus garde ses fichiers : libguestfs monte son qcow2 sans elle. Toute commande porte « --ro », et c'est ce qui change la manœuvre : ouvrir en écriture le disque d'une machine allumée corrompt son système de fichiers, la lire ne risque rien. Un arrêt reste proposé et non imposé — une lecture vivante voit un état peut-être incohérent, un fichier à moitié écrit, un journal non rejoué. Le nom du paquet suit la distribution, celui de Debian n'existant nulle part ailleurs. Vérifié : 16 tests, rougis par quatre mutations — perdre le « --ro », donner le nom Debian partout, dupliquer un numéro, retirer l'entrée. --- EN --- A VM that no longer boots still holds its files: libguestfs mounts its qcow2 without it. Every command carries « --ro », and that is what changes the operation: opening a running machine's disk for writing corrupts its filesystem, reading it risks nothing. A shutdown stays offered, not imposed — a live read sees a possibly torn state, a half-written file, an unreplayed journal. The package name follows the distribution, Debian's existing nowhere else. Checked: 16 tests, turned red by four mutations — losing « --ro », using Debian's name everywhere, duplicating a number, dropping the entry. Assisted-by: Claude Opus 5
This commit is contained in:
parent
bf114ac182
commit
f016eefce9
5 changed files with 690 additions and 6 deletions
|
|
@ -332,6 +332,11 @@ class QemuMenuMixin:
|
|||
{"prompt_description": t("Resize a VM disk")},
|
||||
{"prompt_description": t("Delete VM(s)")},
|
||||
{"prompt_description": t("Clean up QEMU (orphan files)")},
|
||||
{
|
||||
"prompt_description": t(
|
||||
"Recover files from a VM disk (libguestfs)"
|
||||
)
|
||||
},
|
||||
{
|
||||
"prompt_description": t(
|
||||
"Test a VM (open Odoo in a CLI browser)"
|
||||
|
|
@ -387,16 +392,18 @@ class QemuMenuMixin:
|
|||
elif status == "10":
|
||||
self._qemu_cleanup()
|
||||
elif status == "11":
|
||||
self._qemu_test_vm()
|
||||
self._qemu_recover_files()
|
||||
elif status == "12":
|
||||
self._qemu_stats()
|
||||
self._qemu_test_vm()
|
||||
elif status == "13":
|
||||
self._qemu_ssh_config_menu()
|
||||
self._qemu_stats()
|
||||
elif status == "14":
|
||||
self._qemu_tunnel_menu()
|
||||
self._qemu_ssh_config_menu()
|
||||
elif status == "15":
|
||||
self._qemu_emulator_menu()
|
||||
self._qemu_tunnel_menu()
|
||||
elif status == "16":
|
||||
self._qemu_emulator_menu()
|
||||
elif status == "17":
|
||||
self._qemu_list_images()
|
||||
else:
|
||||
cmd_no_found = True
|
||||
|
|
|
|||
318
script/todo/qemu_recover.py
Normal file
318
script/todo/qemu_recover.py
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
#!/usr/bin/env python3
|
||||
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||
"""Menu QEMU/KVM : récupérer des fichiers dans le disque d'une VM.
|
||||
|
||||
Une VM qui ne démarre plus garde ses fichiers : le qcow2 est là, et libguestfs
|
||||
sait le monter SANS la machine. Le chemin complet : poser les outils selon la
|
||||
distribution de l'hôte, lister les systèmes de fichiers du disque, en choisir
|
||||
un, parcourir un répertoire, puis extraire vers l'hôte.
|
||||
|
||||
Frontière claire : ici on LIT un disque, on n'y écrit jamais. Toute commande
|
||||
porte « --ro » — c'est ce qui rend l'opération sûre même sur une VM allumée,
|
||||
et c'est aussi pourquoi rien de ce fichier ne peut abîmer une machine.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from script.todo.todo_i18n import t
|
||||
|
||||
# Le paquet qui apporte guestfish, par gestionnaire de paquets. Il ne vit pas
|
||||
# dans la table du déploiement : ces outils ne servent QU'À la récupération,
|
||||
# et les poser sur chaque hôte qui déploie une VM serait du poids pour rien.
|
||||
#
|
||||
# Les noms diffèrent d'une distribution à l'autre et le nom Debian ne marche
|
||||
# nulle part ailleurs : « libguestfs-tools » sur apt, « guestfs-tools » sur
|
||||
# dnf et zypper depuis que le paquet a été scindé, « libguestfs » sur pacman.
|
||||
GUESTFS_PACKAGES = {
|
||||
"apt": "libguestfs-tools",
|
||||
"dnf": "guestfs-tools",
|
||||
"pacman": "libguestfs",
|
||||
"zypper": "guestfs-tools",
|
||||
}
|
||||
|
||||
# (gestionnaire, binaire à détecter, commande d'installation)
|
||||
GUESTFS_INSTALL = (
|
||||
("apt", "apt-get", "sudo apt-get install -y"),
|
||||
("dnf", "dnf", "sudo dnf install -y"),
|
||||
("pacman", "pacman", "sudo pacman -S --needed --noconfirm"),
|
||||
("zypper", "zypper", "sudo zypper --non-interactive install"),
|
||||
)
|
||||
|
||||
# guestfish suffit à tout faire ; les deux autres ne font que présenter mieux
|
||||
# ce qu'il montre déjà. Les distinguer évite d'exiger un paquet complet là où
|
||||
# l'essentiel est présent.
|
||||
GUESTFS_BIN_ESSENTIEL = "guestfish"
|
||||
GUESTFS_BIN_CONFORT = ("virt-filesystems", "virt-df")
|
||||
|
||||
|
||||
def guestfs_install_cmd():
|
||||
"""(commande d'installation, paquet) pour cet hôte, ou (None, None).
|
||||
|
||||
Rend None sur un hôte dont le gestionnaire n'est pas connu — y compris
|
||||
macOS, où libguestfs ne tourne pas : proposer une commande qui échouera
|
||||
vaut moins que de dire qu'on ne sait pas.
|
||||
"""
|
||||
for cle, binaire, install in GUESTFS_INSTALL:
|
||||
if shutil.which(binaire):
|
||||
return f"{install} {GUESTFS_PACKAGES[cle]}", GUESTFS_PACKAGES[cle]
|
||||
return None, None
|
||||
|
||||
|
||||
class QemuRecoverMixin:
|
||||
"""Menu QEMU/KVM : récupérer des fichiers dans le disque d'une VM.
|
||||
|
||||
Une VM qui ne démarre plus garde ses fichiers : le qcow2 est là, et
|
||||
libguestfs sait le monter SANS la machine. Le chemin complet : poser les
|
||||
outils selon la distribution de l'hôte, lister les systèmes de fichiers du
|
||||
disque, en choisir un, parcourir un répertoire, puis extraire vers l'hôte.
|
||||
|
||||
Frontière claire : ici on LIT un disque, on n'y écrit jamais.
|
||||
"""
|
||||
|
||||
def _qemu_guestfish_cmd(self, disk, *commandes):
|
||||
"""Commande guestfish en LECTURE SEULE sur ce disque.
|
||||
|
||||
« --ro » n'est pas une précaution parmi d'autres : sans lui, ouvrir le
|
||||
disque d'une VM allumée corrompt son système de fichiers. Avec lui, la
|
||||
lecture est sûre à tout moment — au pire elle voit un instantané
|
||||
incohérent, ce que le menu annonce.
|
||||
"""
|
||||
parties = " : ".join(commandes)
|
||||
return (
|
||||
f"guestfish --ro -a {shlex.quote(str(disk))}"
|
||||
f"{' ' + parties if parties else ''}"
|
||||
)
|
||||
|
||||
def _qemu_guestfish_lines(self, disk, *commandes, timeout=180):
|
||||
"""Lignes rendues par guestfish, ou [] s'il échoue.
|
||||
|
||||
L'appliance libguestfs démarre un noyau : compter en secondes, pas en
|
||||
dixièmes. Un timeout généreux vaut mieux qu'un échec sur une machine
|
||||
chargée.
|
||||
"""
|
||||
cmd = self._qemu_guestfish_cmd(disk, *commandes)
|
||||
try:
|
||||
res = subprocess.run(
|
||||
cmd,
|
||||
shell=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return []
|
||||
if res.returncode != 0:
|
||||
if res.stderr:
|
||||
print(f" ⚠ {res.stderr.strip()[:300]}")
|
||||
return []
|
||||
return [ln for ln in (res.stdout or "").splitlines() if ln.strip()]
|
||||
|
||||
def _qemu_recover_ensure_tools(self):
|
||||
"""guestfish est-il là ? Sinon, proposer de l'installer. Rend un bool.
|
||||
|
||||
L'installation est PROPOSÉE et la commande montrée avant : elle pose
|
||||
un paquet de plusieurs centaines de mégaoctets, avec une image noyau.
|
||||
"""
|
||||
manque_essentiel = shutil.which(GUESTFS_BIN_ESSENTIEL) is None
|
||||
confort = [b for b in GUESTFS_BIN_CONFORT if shutil.which(b) is None]
|
||||
if not manque_essentiel and not confort:
|
||||
return True
|
||||
install, paquet = guestfs_install_cmd()
|
||||
if manque_essentiel:
|
||||
print(f"\n⚠ {t('guestfish is missing: libguestfs is not here.')}")
|
||||
else:
|
||||
print(f"\n⚠ {t('Some libguestfs helpers are missing:')}")
|
||||
print(f" {' '.join(confort)}")
|
||||
if not install:
|
||||
print(f" {t('Unknown package manager: install libguestfs.')}")
|
||||
return not manque_essentiel
|
||||
print(f" {t('Will execute:')} {install}")
|
||||
if not self._is_yes_default_yes(
|
||||
input(t("Install libguestfs now? (Y/n): "))
|
||||
):
|
||||
return not manque_essentiel
|
||||
self.execute.exec_command_live(install, source_erplibre=False)
|
||||
return shutil.which(GUESTFS_BIN_ESSENTIEL) is not None
|
||||
|
||||
def _qemu_recover_ready(self, name):
|
||||
"""La VM est-elle dans un état où la lecture a du sens ? Rend un bool.
|
||||
|
||||
Une VM ALLUMÉE écrit pendant qu'on lit : la copie voit un instantané
|
||||
qui peut être incohérent — un fichier à moitié écrit, un journal non
|
||||
rejoué. Ce n'est pas dangereux en « --ro », mais il faut le dire, et
|
||||
proposer l'arrêt propre qui rend la lecture fidèle.
|
||||
"""
|
||||
etat = self._qemu_domstate(name)
|
||||
if etat != "running":
|
||||
return True
|
||||
print(f"\n⚠ {t('This VM is running.')}")
|
||||
print(f" {t('Reading a live disk sees a possibly torn state:')}")
|
||||
print(f" {t('a half-written file, an unreplayed journal.')}")
|
||||
choix = self._qemu_pick(
|
||||
t("What do you want to do?"),
|
||||
["read", "shutdown", "cancel"],
|
||||
"shutdown",
|
||||
[
|
||||
t("Read anyway (read-only, no risk for the VM)"),
|
||||
t("Shut the VM down cleanly, then read"),
|
||||
t("Cancel"),
|
||||
],
|
||||
)
|
||||
if choix == "cancel":
|
||||
return False
|
||||
if choix == "read":
|
||||
return True
|
||||
return self._qemu_shutdown_wait(name)
|
||||
|
||||
def _qemu_recover_pick_filesystem(self, disk):
|
||||
"""Système de fichiers choisi dans le disque, ou "" si aucun.
|
||||
|
||||
« list-filesystems » les rend tous, y compris ceux qu'on ne peut pas
|
||||
monter — swap, partitions vides. Les montrer quand même : leur absence
|
||||
de la liste inquiéterait plus qu'elle n'aiderait.
|
||||
"""
|
||||
cmd = self._qemu_guestfish_cmd(disk, "run", "list-filesystems")
|
||||
print(f"\n{t('Will execute:')} {cmd}")
|
||||
lignes = self._qemu_guestfish_lines(disk, "run", "list-filesystems")
|
||||
if not lignes:
|
||||
print(f" {t('No filesystem found on this disk.')}")
|
||||
return ""
|
||||
parts, labels = [], []
|
||||
for ligne in lignes:
|
||||
# « /dev/sda3: ext4 » — le nom, puis son type.
|
||||
dev, _, typ = ligne.partition(":")
|
||||
dev, typ = dev.strip(), typ.strip()
|
||||
if not dev:
|
||||
continue
|
||||
parts.append(dev)
|
||||
labels.append(f"{dev} {typ}")
|
||||
if not parts:
|
||||
return ""
|
||||
# Le plus grand système de fichiers non-swap est presque toujours la
|
||||
# racine : le proposer par défaut épargne un choix à qui ne connaît
|
||||
# pas le partitionnement de sa VM.
|
||||
defaut = next(
|
||||
(p for p, lab in zip(parts, labels) if "swap" not in lab),
|
||||
parts[0],
|
||||
)
|
||||
return self._qemu_pick(t("Filesystem to mount"), parts, defaut, labels)
|
||||
|
||||
def _qemu_recover_browse(self, disk, part, chemin):
|
||||
"""Liste un répertoire du système de fichiers monté.
|
||||
|
||||
Rend les entrées trouvées."""
|
||||
cmd = self._qemu_guestfish_cmd(
|
||||
disk, "run", f"mount {part} /", f"ls {shlex.quote(chemin)}"
|
||||
)
|
||||
print(f"\n{t('Will execute:')} {cmd}")
|
||||
entrees = self._qemu_guestfish_lines(
|
||||
disk, "run", f"mount {part} /", f"ls {shlex.quote(chemin)}"
|
||||
)
|
||||
if not entrees:
|
||||
print(f" {t('Empty or unreadable directory.')}")
|
||||
return []
|
||||
for entree in entrees:
|
||||
print(f" {entree}")
|
||||
print(f" {t('Total:')} {len(entrees)}")
|
||||
return entrees
|
||||
|
||||
def _qemu_recover_copy_out(self, disk, part, source, dest):
|
||||
"""Extrait un chemin de la VM vers l'hôte. Rend un bool.
|
||||
|
||||
« copy-out » écrit DANS le répertoire de destination : il faut donc
|
||||
qu'il existe, sinon guestfish s'arrête sur une erreur qui ne dit pas
|
||||
laquelle des deux extrémités manque.
|
||||
"""
|
||||
try:
|
||||
os.makedirs(dest, exist_ok=True)
|
||||
except OSError as exc:
|
||||
print(f" ⚠ {t('Cannot create the destination: ')}{exc}")
|
||||
return False
|
||||
cmd = self._qemu_guestfish_cmd(
|
||||
disk,
|
||||
"run",
|
||||
f"mount {part} /",
|
||||
f"copy-out {shlex.quote(source)} {shlex.quote(dest)}",
|
||||
)
|
||||
print(f"\n{t('Will execute:')} {cmd}")
|
||||
code = self.execute.exec_command_live(cmd, source_erplibre=False)
|
||||
if code:
|
||||
print(f" ⚠ {t('Extraction failed.')}")
|
||||
return False
|
||||
cible = os.path.join(dest, os.path.basename(source.rstrip("/")))
|
||||
print(f"\n✅ {t('Extracted to: ')}{cible}")
|
||||
return True
|
||||
|
||||
def _qemu_recover_diagnostics(self, disk):
|
||||
"""Commandes de diagnostic sur le disque, sans rien monter à la main.
|
||||
|
||||
Chacune répond à une question précise qu'on se pose quand la lecture
|
||||
ne donne pas ce qu'on attend : les partitions existent-elles, restent-
|
||||
elles de la place, le système est-il reconnu, l'appliance
|
||||
démarre-t-elle.
|
||||
"""
|
||||
q = shlex.quote(str(disk))
|
||||
sondes = [
|
||||
(
|
||||
t("Partitions and sizes"),
|
||||
f"virt-filesystems -a {q} --long -h --all",
|
||||
),
|
||||
(t("Free space per filesystem"), f"virt-df -a {q} -h"),
|
||||
(
|
||||
t("Detected operating system"),
|
||||
self._qemu_guestfish_cmd(disk, "run", "inspect-os"),
|
||||
),
|
||||
(
|
||||
t("Does the libguestfs appliance boot?"),
|
||||
"libguestfs-test-tool",
|
||||
),
|
||||
]
|
||||
for titre, cmd in sondes:
|
||||
print(f"\n── {titre} ──")
|
||||
print(f"{t('Will execute:')} {cmd}")
|
||||
self.execute.exec_command_live(cmd, source_erplibre=False)
|
||||
|
||||
def _qemu_recover_files(self):
|
||||
"""Récupère des fichiers dans le disque d'une VM, sans la démarrer."""
|
||||
print(f"\n💾 {t('Recover files from a VM disk (libguestfs)')}")
|
||||
if not self._qemu_recover_ensure_tools():
|
||||
return
|
||||
self._qemu_list_vms()
|
||||
print()
|
||||
name = input(t("VM name or ID: ")).strip()
|
||||
if not name:
|
||||
print(t("VM name is required!"))
|
||||
return
|
||||
name = self._qemu_domname(name)
|
||||
disk = self._qemu_main_disk(name)
|
||||
if not disk:
|
||||
print(f" ⚠ {t('No disk found for this VM.')}")
|
||||
return
|
||||
print(f" {t('Disk:')} {disk}")
|
||||
if not self._qemu_recover_ready(name):
|
||||
print(t("Cancelled."))
|
||||
return
|
||||
if self._is_yes(input(t("Run the diagnostics first? (y/N): "))):
|
||||
self._qemu_recover_diagnostics(disk)
|
||||
part = self._qemu_recover_pick_filesystem(disk)
|
||||
if not part:
|
||||
return
|
||||
chemin = input(t("Directory to list [/]: ")).strip() or "/"
|
||||
while True:
|
||||
self._qemu_recover_browse(disk, part, chemin)
|
||||
suite = input(
|
||||
t("Another directory, or Enter to extract: ")
|
||||
).strip()
|
||||
if not suite:
|
||||
break
|
||||
chemin = suite
|
||||
source = input(f"{t('Path to extract')} [{chemin}] : ").strip()
|
||||
source = source or chemin
|
||||
defaut_dest = f"/tmp/{name}-backup"
|
||||
dest = input(f"{t('Destination on the host')} [{defaut_dest}] : ")
|
||||
dest = dest.strip() or defaut_dest
|
||||
self._qemu_recover_copy_out(disk, part, source, dest)
|
||||
|
|
@ -28,6 +28,7 @@ from script.config import config_file
|
|||
from script.execute import execute
|
||||
from script.todo import todo_install, todo_prefs
|
||||
from script.todo.database_manager import DatabaseManager
|
||||
from script.todo.kdbx_manager import KdbxManager
|
||||
from script.todo.longtest_menu import LongTestMenuMixin
|
||||
from script.todo.proxmox_menu import ProxmoxMenuMixin
|
||||
from script.todo.qemu_access import QemuAccessMixin
|
||||
|
|
@ -35,7 +36,7 @@ from script.todo.qemu_deploy import QemuDeployMixin
|
|||
from script.todo.qemu_install import QemuInstallMixin
|
||||
from script.todo.qemu_manage import QemuManageMixin
|
||||
from script.todo.qemu_menu import QemuMenuMixin
|
||||
from script.todo.kdbx_manager import KdbxManager
|
||||
from script.todo.qemu_recover import QemuRecoverMixin
|
||||
from script.todo.todo_i18n import get_lang, lang_is_configured, set_lang, t
|
||||
from script.todo.version_manager import get_odoo_version
|
||||
|
||||
|
|
@ -95,6 +96,7 @@ class TODO(
|
|||
QemuDeployMixin,
|
||||
QemuInstallMixin,
|
||||
QemuManageMixin,
|
||||
QemuRecoverMixin,
|
||||
QemuAccessMixin,
|
||||
ProxmoxMenuMixin,
|
||||
LongTestMenuMixin,
|
||||
|
|
|
|||
|
|
@ -5004,6 +5004,115 @@ TRANSLATIONS = {
|
|||
" (y/N, default: no): "
|
||||
),
|
||||
},
|
||||
# Récupération de fichiers dans le disque d'une VM (libguestfs)
|
||||
"Recover files from a VM disk (libguestfs)": {
|
||||
"fr": "💾 Récupérer des fichiers dans le disque d'une VM (libguestfs)",
|
||||
"en": "Recover files from a VM disk (libguestfs)",
|
||||
},
|
||||
"guestfish is missing: libguestfs is not here.": {
|
||||
"fr": "guestfish est absent : libguestfs n'est pas installé ici.",
|
||||
"en": "guestfish is missing: libguestfs is not here.",
|
||||
},
|
||||
"Some libguestfs helpers are missing:": {
|
||||
"fr": "Des utilitaires libguestfs manquent :",
|
||||
"en": "Some libguestfs helpers are missing:",
|
||||
},
|
||||
"Unknown package manager: install libguestfs.": {
|
||||
"fr": "Gestionnaire de paquets inconnu : installez libguestfs.",
|
||||
"en": "Unknown package manager: install libguestfs.",
|
||||
},
|
||||
"Install libguestfs now? (Y/n): ": {
|
||||
"fr": "Installer libguestfs maintenant ? (O/n) : ",
|
||||
"en": "Install libguestfs now? (Y/n, default: yes): ",
|
||||
},
|
||||
"This VM is running.": {
|
||||
"fr": "Cette VM est allumée.",
|
||||
"en": "This VM is running.",
|
||||
},
|
||||
"Reading a live disk sees a possibly torn state:": {
|
||||
"fr": "Lire un disque vivant montre un état peut-être incohérent :",
|
||||
"en": "Reading a live disk sees a possibly torn state:",
|
||||
},
|
||||
"a half-written file, an unreplayed journal.": {
|
||||
"fr": "un fichier à moitié écrit, un journal non rejoué.",
|
||||
"en": "a half-written file, an unreplayed journal.",
|
||||
},
|
||||
"Read anyway (read-only, no risk for the VM)": {
|
||||
"fr": "Lire quand même (lecture seule, sans risque pour la VM)",
|
||||
"en": "Read anyway (read-only, no risk for the VM)",
|
||||
},
|
||||
"Shut the VM down cleanly, then read": {
|
||||
"fr": "Éteindre proprement la VM, puis lire",
|
||||
"en": "Shut the VM down cleanly, then read",
|
||||
},
|
||||
"No filesystem found on this disk.": {
|
||||
"fr": "Aucun système de fichiers trouvé sur ce disque.",
|
||||
"en": "No filesystem found on this disk.",
|
||||
},
|
||||
"Filesystem to mount": {
|
||||
"fr": "Système de fichiers à monter",
|
||||
"en": "Filesystem to mount",
|
||||
},
|
||||
"Empty or unreadable directory.": {
|
||||
"fr": "Répertoire vide ou illisible.",
|
||||
"en": "Empty or unreadable directory.",
|
||||
},
|
||||
"Cannot create the destination: ": {
|
||||
"fr": "Impossible de créer la destination : ",
|
||||
"en": "Cannot create the destination: ",
|
||||
},
|
||||
"Extraction failed.": {
|
||||
"fr": "L'extraction a échoué.",
|
||||
"en": "Extraction failed.",
|
||||
},
|
||||
"Extracted to: ": {
|
||||
"fr": "Extrait vers : ",
|
||||
"en": "Extracted to: ",
|
||||
},
|
||||
"Partitions and sizes": {
|
||||
"fr": "Partitions et tailles",
|
||||
"en": "Partitions and sizes",
|
||||
},
|
||||
"Free space per filesystem": {
|
||||
"fr": "Espace libre par système de fichiers",
|
||||
"en": "Free space per filesystem",
|
||||
},
|
||||
"Detected operating system": {
|
||||
"fr": "Système d'exploitation détecté",
|
||||
"en": "Detected operating system",
|
||||
},
|
||||
"Does the libguestfs appliance boot?": {
|
||||
"fr": "L'appliance libguestfs démarre-t-elle ?",
|
||||
"en": "Does the libguestfs appliance boot?",
|
||||
},
|
||||
"No disk found for this VM.": {
|
||||
"fr": "Aucun disque trouvé pour cette VM.",
|
||||
"en": "No disk found for this VM.",
|
||||
},
|
||||
"Disk:": {
|
||||
"fr": "Disque :",
|
||||
"en": "Disk:",
|
||||
},
|
||||
"Run the diagnostics first? (y/N): ": {
|
||||
"fr": "Lancer d'abord les diagnostics ? (o/N) : ",
|
||||
"en": "Run the diagnostics first? (y/N, default: no): ",
|
||||
},
|
||||
"Directory to list [/]: ": {
|
||||
"fr": "Répertoire à lister [/] : ",
|
||||
"en": "Directory to list [/]: ",
|
||||
},
|
||||
"Another directory, or Enter to extract: ": {
|
||||
"fr": "Autre répertoire, ou Entrée pour extraire : ",
|
||||
"en": "Another directory, or Enter to extract: ",
|
||||
},
|
||||
"Path to extract": {
|
||||
"fr": "Chemin à extraire",
|
||||
"en": "Path to extract",
|
||||
},
|
||||
"Destination on the host": {
|
||||
"fr": "Destination sur l'hôte",
|
||||
"en": "Destination on the host",
|
||||
},
|
||||
"Full output:": {
|
||||
"fr": "Sortie complète :",
|
||||
"en": "Full output:",
|
||||
|
|
|
|||
248
test/test_qemu_recover.py
Normal file
248
test/test_qemu_recover.py
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
#!/usr/bin/env python3
|
||||
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||
"""Récupérer des fichiers dans le disque d'une VM, sans la démarrer.
|
||||
|
||||
Une VM qui ne démarre plus garde ses fichiers, et libguestfs sait monter son
|
||||
qcow2 sans elle. Le risque n'est pas la lecture : c'est d'ouvrir en ÉCRITURE
|
||||
le disque d'une machine allumée, ce qui corrompt son système de fichiers.
|
||||
|
||||
Ce que ces tests gardent :
|
||||
|
||||
- « --ro » sur CHAQUE commande, sans exception : c'est lui qui rend l'opération
|
||||
sûre sur une VM en marche ;
|
||||
- le nom du paquet suit la distribution — celui de Debian n'existe nulle part
|
||||
ailleurs ;
|
||||
- les chemins saisis par l'utilisateur sont échappés avant d'atteindre le
|
||||
shell.
|
||||
"""
|
||||
|
||||
import shlex
|
||||
import sys
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
sys.argv = ["todo.py"]
|
||||
from script.todo import qemu_recover as qr # noqa: E402
|
||||
from script.todo.todo import TODO # noqa: E402
|
||||
|
||||
DISQUE = "/var/lib/libvirt/images/vm-a.qcow2"
|
||||
|
||||
|
||||
class LesOutils(unittest.TestCase):
|
||||
def test_each_package_manager_gets_its_own_name(self):
|
||||
"""« libguestfs-tools » est le nom Debian : il n'existe ni sur Arch,
|
||||
ni sur Fedora, ni sur openSUSE."""
|
||||
attendu = {
|
||||
"apt-get": "libguestfs-tools",
|
||||
"dnf": "guestfs-tools",
|
||||
"pacman": "libguestfs",
|
||||
"zypper": "guestfs-tools",
|
||||
}
|
||||
for binaire, paquet in attendu.items():
|
||||
with self.subTest(binaire=binaire):
|
||||
with mock.patch.object(
|
||||
qr.shutil,
|
||||
"which",
|
||||
side_effect=lambda b, cible=binaire: (
|
||||
"/usr/bin/x" if b == cible else None
|
||||
),
|
||||
):
|
||||
cmd, nom = qr.guestfs_install_cmd()
|
||||
self.assertEqual(paquet, nom)
|
||||
self.assertIn(paquet, cmd)
|
||||
|
||||
def test_an_unknown_host_proposes_nothing(self):
|
||||
"""Proposer une commande qui échouera vaut moins que de dire qu'on ne
|
||||
sait pas — sur macOS, libguestfs ne tourne pas."""
|
||||
with mock.patch.object(qr.shutil, "which", return_value=None):
|
||||
self.assertEqual((None, None), qr.guestfs_install_cmd())
|
||||
|
||||
|
||||
class LaLectureEstToujoursSeule(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.todo = TODO.__new__(TODO)
|
||||
|
||||
def test_every_command_carries_read_only(self):
|
||||
"""Sans « --ro », ouvrir le disque d'une VM allumée corrompt son
|
||||
système de fichiers. Aucune commande n'a de raison de s'en passer."""
|
||||
cmds = [
|
||||
self.todo._qemu_guestfish_cmd(DISQUE),
|
||||
self.todo._qemu_guestfish_cmd(DISQUE, "run", "list-filesystems"),
|
||||
self.todo._qemu_guestfish_cmd(
|
||||
DISQUE, "run", "mount /dev/sda3 /", "ls /home"
|
||||
),
|
||||
]
|
||||
for cmd in cmds:
|
||||
with self.subTest(cmd=cmd):
|
||||
self.assertIn("--ro", cmd)
|
||||
self.assertIn(shlex.quote(DISQUE), cmd)
|
||||
|
||||
def test_the_commands_are_chained_the_guestfish_way(self):
|
||||
cmd = self.todo._qemu_guestfish_cmd(
|
||||
DISQUE, "run", "mount /dev/sda3 /", "ls /home"
|
||||
)
|
||||
self.assertIn("run : mount /dev/sda3 / : ls /home", cmd)
|
||||
|
||||
def test_a_hostile_path_cannot_escape_the_shell(self):
|
||||
"""Le chemin vient de l'utilisateur : sans échappement, « ; » ouvre
|
||||
une seconde commande."""
|
||||
cmd = self.todo._qemu_guestfish_cmd("/tmp/x.qcow2; rm -rf /", "run")
|
||||
# Le chemin entier tient dans UNE apostrophe : le « ; » y est du
|
||||
# texte, pas un séparateur de commande.
|
||||
self.assertIn("'/tmp/x.qcow2; rm -rf /'", cmd)
|
||||
self.assertNotIn(
|
||||
"qcow2; rm", cmd.replace("'/tmp/x.qcow2; rm -rf /'", "")
|
||||
)
|
||||
|
||||
|
||||
class LaVmAllumee(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.todo = TODO.__new__(TODO)
|
||||
|
||||
def test_a_stopped_vm_goes_straight_through(self):
|
||||
self.todo._qemu_domstate = lambda n: "shut off"
|
||||
self.assertTrue(self.todo._qemu_recover_ready("vm-a"))
|
||||
|
||||
def test_a_running_vm_offers_three_ways(self):
|
||||
self.todo._qemu_domstate = lambda n: "running"
|
||||
vus = {}
|
||||
|
||||
def faux_pick(titre, valeurs, defaut, labels=None):
|
||||
vus["valeurs"] = valeurs
|
||||
vus["defaut"] = defaut
|
||||
return "read"
|
||||
|
||||
self.todo._qemu_pick = faux_pick
|
||||
with mock.patch("builtins.print"):
|
||||
self.assertTrue(self.todo._qemu_recover_ready("vm-a"))
|
||||
self.assertEqual(["read", "shutdown", "cancel"], vus["valeurs"])
|
||||
# L'arrêt propre est le défaut : c'est la seule voie qui donne une
|
||||
# copie fidèle.
|
||||
self.assertEqual("shutdown", vus["defaut"])
|
||||
|
||||
def test_cancelling_stops_everything(self):
|
||||
self.todo._qemu_domstate = lambda n: "running"
|
||||
self.todo._qemu_pick = lambda *a, **k: "cancel"
|
||||
with mock.patch("builtins.print"):
|
||||
self.assertFalse(self.todo._qemu_recover_ready("vm-a"))
|
||||
|
||||
def test_choosing_shutdown_waits_for_it(self):
|
||||
self.todo._qemu_domstate = lambda n: "running"
|
||||
self.todo._qemu_pick = lambda *a, **k: "shutdown"
|
||||
appels = []
|
||||
self.todo._qemu_shutdown_wait = lambda n: appels.append(n) or True
|
||||
with mock.patch("builtins.print"):
|
||||
self.assertTrue(self.todo._qemu_recover_ready("vm-a"))
|
||||
self.assertEqual(["vm-a"], appels)
|
||||
|
||||
|
||||
class LExtraction(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.todo = TODO.__new__(TODO)
|
||||
self.lances = []
|
||||
self.todo.execute = mock.MagicMock()
|
||||
self.todo.execute.exec_command_live.side_effect = (
|
||||
lambda cmd, **k: self.lances.append(cmd) or 0
|
||||
)
|
||||
|
||||
def test_copy_out_names_both_ends(self):
|
||||
with mock.patch.object(qr.os, "makedirs"), mock.patch(
|
||||
"builtins.print"
|
||||
):
|
||||
ok = self.todo._qemu_recover_copy_out(
|
||||
DISQUE, "/dev/sda3", "/home/erplibre", "/tmp/vm-a-backup"
|
||||
)
|
||||
self.assertTrue(ok)
|
||||
cmd = self.lances[0]
|
||||
self.assertIn("copy-out /home/erplibre /tmp/vm-a-backup", cmd)
|
||||
self.assertIn("mount /dev/sda3 /", cmd)
|
||||
self.assertIn("--ro", cmd)
|
||||
|
||||
def test_an_uncreatable_destination_stops_before_running(self):
|
||||
"""guestfish s'arrêterait sur une erreur qui ne dit pas laquelle des
|
||||
deux extrémités manque."""
|
||||
with mock.patch.object(
|
||||
qr.os, "makedirs", side_effect=OSError("lecture seule")
|
||||
), mock.patch("builtins.print"):
|
||||
ok = self.todo._qemu_recover_copy_out(
|
||||
DISQUE, "/dev/sda3", "/home", "/interdit"
|
||||
)
|
||||
self.assertFalse(ok)
|
||||
self.assertEqual([], self.lances)
|
||||
|
||||
|
||||
class LesDiagnostics(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.todo = TODO.__new__(TODO)
|
||||
self.lances = []
|
||||
self.todo.execute = mock.MagicMock()
|
||||
self.todo.execute.exec_command_live.side_effect = (
|
||||
lambda cmd, **k: self.lances.append(cmd) or 0
|
||||
)
|
||||
|
||||
def test_the_four_probes_answer_four_questions(self):
|
||||
with mock.patch("builtins.print"):
|
||||
self.todo._qemu_recover_diagnostics(DISQUE)
|
||||
joint = " ".join(self.lances)
|
||||
for attendu in (
|
||||
"virt-filesystems",
|
||||
"virt-df",
|
||||
"inspect-os",
|
||||
"libguestfs-test-tool",
|
||||
):
|
||||
self.assertIn(attendu, joint)
|
||||
|
||||
def test_no_diagnostic_writes_to_the_disk(self):
|
||||
with mock.patch("builtins.print"):
|
||||
self.todo._qemu_recover_diagnostics(DISQUE)
|
||||
for cmd in self.lances:
|
||||
if "guestfish" in cmd:
|
||||
self.assertIn("--ro", cmd)
|
||||
|
||||
|
||||
class LeMenu(unittest.TestCase):
|
||||
def test_the_entry_is_wired_in_the_manage_section(self):
|
||||
from pathlib import Path
|
||||
|
||||
source = Path("script/todo/qemu_menu.py").read_text(encoding="utf-8")
|
||||
self.assertIn("Recover files from a VM disk (libguestfs)", source)
|
||||
self.assertIn("self._qemu_recover_files()", source)
|
||||
|
||||
def test_the_config_entry_still_reaches_its_command(self):
|
||||
"""L'entrée du catalogue vient de todo.json : elle n'a pas de branche
|
||||
« elif » et dépend du repli par indice. Insérer une entrée codée en
|
||||
dur la décale, et un décalage manqué lancerait la mauvaise."""
|
||||
from unittest import mock as m
|
||||
|
||||
from script.todo.todo_i18n import set_lang
|
||||
|
||||
set_lang("fr")
|
||||
todo = TODO()
|
||||
todo._menu_header = lambda: "x"
|
||||
lancees = []
|
||||
todo.execute_from_configuration = lambda e: lancees.append(e)
|
||||
with m.patch("script.todo.qemu_menu.click") as click, m.patch.object(
|
||||
todo, "_qemu_ensure_tools", return_value=True
|
||||
), m.patch("builtins.print"):
|
||||
click.prompt.side_effect = ["18", "0"]
|
||||
todo.prompt_execute_qemu()
|
||||
self.assertEqual(1, len(lancees), lancees)
|
||||
self.assertIn("dry-run", lancees[0].get("bash_command", ""))
|
||||
|
||||
def test_the_branches_stay_in_order_and_unique(self):
|
||||
"""Insérer une entrée décale tout ce qui suit : un numéro en double
|
||||
rendrait une commande inatteignable."""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
source = Path("script/todo/qemu_menu.py").read_text(encoding="utf-8")
|
||||
debut = source.index("def prompt_execute_qemu")
|
||||
corps = source[debut : source.index("\n def ", debut + 10)]
|
||||
nums = [int(n) for n in re.findall(r'status == "(\d+)"', corps)]
|
||||
self.assertEqual(sorted(nums), nums, nums)
|
||||
self.assertEqual(len(set(nums)), len(nums), nums)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Reference in a new issue