[FIX] script todo: réduction disque via qemu-nbd (libguestfs cassé sur l'hôte)

La réduction « sûre » via virt-resize ne marchait pas : (1) j'utilisais
« virt-filesystems -b » (option INEXISTANTE -> aucune partition détectée ->
« abandon »), et (2) libguestfs est inutilisable ici (aucun vmlinuz dans
/boot -> l'appliance supermin échoue). La VM restait donc à 25G.

Réécriture SANS libguestfs, avec des outils de base présents et éprouvés
(qemu-nbd, e2fsck, resize2fs, sgdisk, parted/partprobe) :
1. copie .bak AVANT toute modification ;
2. nbd + détection de la racine (plus grosse partition, ext seulement) ;
3. e2fsck -> resize2fs (FS) -> sgdisk réécrit la partition en PRÉSERVANT
   type/UUID/nom (PARTUUID intact) -> qemu-img --shrink (conteneur) ->
   sgdisk -e (GPT de secours) -> fsck final ;
4. en cas d'échec à N'IMPORTE quelle étape : restauration depuis .bak
   -> corruption impossible.

Validé de bout en bout sur une image jetable (GPT + ext4, racine à offset
élevé, 800M de données) : 25G -> 15G, GPT « No problems found », fsck
propre, UUID préservé, données intactes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mathieu Benoit 2026-07-30 00:34:45 -04:00
parent 0b02e9fe43
commit 9fb24e583a
2 changed files with 302 additions and 77 deletions

View file

@ -1746,97 +1746,262 @@ class TODO:
print(f"{t('Will execute:')} {cmd}")
self.execute.exec_command_live(cmd, source_erplibre=False)
# Outils requis pour la réduction sûre (tous « base » : e2fsprogs, gdisk,
# util-linux, qemu-utils) — PAS libguestfs (souvent cassé : appliance
# supermin sans noyau dans /boot).
_SHRINK_TOOLS = (
"qemu-nbd", "e2fsck", "resize2fs", "sgdisk", "partprobe",
"lsblk", "dumpe2fs", "blockdev",
)
_SECT = 512
_MiB = 1024 * 1024
def _qemu_safe_shrink(self, name, disk, new_gb):
"""Réduit le disque SANS casser l'OS : virt-resize (libguestfs) réduit
le système de fichiers + la partition + la GPT. On écrit dans une
NOUVELLE image ; l'originale n'est remplacée qu'en cas de succès (en cas
d'échec/refus, le disque d'origine est laissé INTACT). Renvoie True si
la réduction a réussi."""
if not (
shutil.which("virt-resize") and shutil.which("virt-filesystems")
):
print(f"\n{t('Safe shrink needs libguestfs (virt-resize).')}")
if not self._is_yes(
input(t("Install libguestfs-tools now? (y/N): "))
) or not self._qemu_install_libguestfs():
print(t("Shrink aborted; disk left intact."))
return False
part = self._qemu_largest_partition(disk)
if not part:
print(t("Could not detect the partition to shrink; aborting."))
"""Réduit le disque SANS casser l'OS, via qemu-nbd + resize2fs +
sgdisk (sans libguestfs) : on réduit le FS (ext), puis la partition,
puis le conteneur qcow2, puis on répare la GPT de secours. Une COPIE
.bak est faite AVANT ; en cas d'échec on RESTAURE -> jamais de
corruption. ext2/3/4 uniquement. Renvoie True si réduit."""
import math
missing = [b for b in self._SHRINK_TOOLS if not shutil.which(b)]
if missing:
print(
f"{t('Missing tools for safe shrink:')} {', '.join(missing)}"
)
return False
new_disk = f"{disk}.resized"
target = int(round(new_gb * (1 << 30)))
bak = f"{disk}.bak"
subprocess.run(["sudo", "rm", "-f", new_disk], check=False)
print(
f"\n{t('Shrinking guest FS + partition via virt-resize')} "
f"({part} -> {new_gb:g}G)…"
)
create = subprocess.run(
["sudo", "qemu-img", "create", "-f", "qcow2", new_disk,
f"{new_gb:g}G"]
)
if create.returncode != 0:
print(f"{t('Resize failed (see error above).')}")
subprocess.run(["sudo", "rm", "-f", new_disk], check=False)
print(f"\n{t('Backing up the disk before shrinking…')}")
if subprocess.run(
["sudo", "cp", "--reflink=auto", "--sparse=always", disk, bak]
).returncode != 0:
print(t("Backup failed; aborting."))
return False
# virt-resize copie old -> new en réduisant le FS de `part`.
rc = subprocess.run(
["sudo", "virt-resize", "--shrink", part, disk, new_disk]
).returncode
if rc != 0:
print(f"{t('virt-resize failed; original disk left intact.')}")
subprocess.run(["sudo", "rm", "-f", new_disk], check=False)
return False
# Succès : on sauvegarde l'original et on met la nouvelle image au
# MÊME chemin (la définition libvirt reste valide).
subprocess.run(["sudo", "mv", "-f", disk, bak], check=False)
subprocess.run(["sudo", "mv", "-f", new_disk, disk], check=False)
print(f"{t('Disk safely shrunk. Backup kept at:')} {bak}")
return True
subprocess.run(["sudo", "modprobe", "nbd", "max_part=16"], check=False)
dev = None
try:
dev = self._qemu_nbd_connect(disk)
if not dev:
print(t("Could not attach the disk (nbd); aborting."))
return self._qemu_shrink_revert(bak, disk, changed=False)
part, start, fstype = self._qemu_root_part(dev)
if not part:
print(t("Could not detect the partition to shrink; aborting."))
return self._qemu_shrink_revert(bak, disk, changed=False)
if not fstype.startswith("ext"):
print(
f"{t('Only ext2/3/4 can be shrunk safely; aborting.')}"
f" ({fstype})"
)
return self._qemu_shrink_revert(bak, disk, changed=False)
n = self._qemu_part_number(dev, part)
info = self._qemu_part_info(dev, n)
# fsck AVANT toute opération.
subprocess.run(["sudo", "e2fsck", "-f", "-y", part], check=False)
bs = self._qemu_fs_blocksize(part)
# Cibles (octets), en gardant 2 Mio pour la GPT de secours + marge.
part_start_b = start * self._SECT
max_fs_b = target - part_start_b - 4 * self._MiB
if max_fs_b <= 0:
print(t("Target size too small for this layout; aborting."))
return self._qemu_shrink_revert(bak, disk, changed=False)
min_blocks = self._qemu_fs_min_blocks(part)
if min_blocks and min_blocks * bs > max_fs_b:
print(t("Not enough used-space margin to shrink; aborting."))
return self._qemu_shrink_revert(bak, disk, changed=False)
fs_target_mib = max_fs_b // self._MiB
print(
f"\n{t('Shrinking guest ext filesystem')} {part} "
f"-> {fs_target_mib} MiB…"
)
if subprocess.run(
["sudo", "resize2fs", part, f"{fs_target_mib}M"]
).returncode != 0:
print(t("resize2fs failed; reverting."))
return self._qemu_shrink_revert(bak, disk, changed=True)
# Fin de partition = début + taille RÉELLE du FS + 1 Mio, alignée.
fs_bytes = self._qemu_fs_blocks(part) * bs
new_end = start + int(math.ceil((fs_bytes + self._MiB) / self._SECT))
new_end = ((new_end + 2047) // 2048) * 2048 - 1 # align 2048
if (new_end + 34) * self._SECT > target:
print(t("Internal size check failed; reverting."))
return self._qemu_shrink_revert(bak, disk, changed=True)
# Réécrit la partition (mêmes type/UUID/nom -> PARTUUID préservé).
print(f"{t('Shrinking the partition…')} ({part})")
subprocess.run(["sudo", "sgdisk", "-d", n, dev], check=False)
rc = subprocess.run(
["sudo", "sgdisk", "-n", f"{n}:{start}:{new_end}",
"-t", f"{n}:{info['type']}", "-u", f"{n}:{info['uuid']}",
"-c", f"{n}:{info['name']}", dev]
).returncode
if rc != 0:
print(t("Partition rewrite failed; reverting."))
return self._qemu_shrink_revert(bak, disk, changed=True)
subprocess.run(["sudo", "partprobe", dev], check=False)
# Détache puis tronque le conteneur qcow2.
self._qemu_nbd_disconnect(dev)
dev = None
print(f"{t('Shrinking the qcow2 container…')} {new_gb:g}G")
if subprocess.run(
["sudo", "qemu-img", "resize", "--shrink", disk,
f"{new_gb:g}G"]
).returncode != 0:
print(t("Container shrink failed; reverting."))
return self._qemu_shrink_revert(bak, disk, changed=True)
# Répare la GPT de secours (fin du disque) + fsck final.
dev = self._qemu_nbd_connect(disk)
if dev:
subprocess.run(["sudo", "sgdisk", "-e", dev], check=False)
subprocess.run(["sudo", "partprobe", dev], check=False)
p2 = self._qemu_root_part(dev)[0]
if p2:
subprocess.run(
["sudo", "e2fsck", "-f", "-y", p2], check=False
)
self._qemu_nbd_disconnect(dev)
dev = None
print(f"{t('Disk safely shrunk. Backup kept at:')} {bak}")
return True
finally:
if dev:
self._qemu_nbd_disconnect(dev)
def _qemu_shrink_revert(self, bak, disk, changed):
"""Restaure le disque depuis la sauvegarde .bak si on l'a modifié
(changed) ; sinon retire juste la sauvegarde inutile. Renvoie False
(la réduction a échoué)."""
if changed:
print(t("Restoring the original disk from backup…"))
subprocess.run(["sudo", "mv", "-f", bak, disk], check=False)
else:
subprocess.run(["sudo", "rm", "-f", bak], check=False)
return False
@staticmethod
def _qemu_largest_partition(disk):
"""Nom (côté invité, ex. /dev/sda1) de la plus GROSSE partition du
disque via virt-filesystems celle qui porte la racine à réduire."""
def _qemu_nbd_connect(disk):
"""Attache `disk` à un /dev/nbdN libre et renvoie le chemin, ou None."""
for i in range(16):
dev = f"/dev/nbd{i}"
# /sys/block/nbdN/pid absent => device libre.
if os.path.exists(f"/sys/block/nbd{i}/pid"):
continue
rc = subprocess.run(
["sudo", "qemu-nbd", "-c", dev, disk],
capture_output=True, text=True,
).returncode
if rc == 0:
time.sleep(1)
subprocess.run(["sudo", "partprobe", dev], check=False)
time.sleep(1)
return dev
return None
@staticmethod
def _qemu_nbd_disconnect(dev):
subprocess.run(["sudo", "qemu-nbd", "-d", dev], check=False)
time.sleep(1)
@staticmethod
def _qemu_root_part(dev):
"""(partition la plus grosse, secteur de début, type FS) du disque nbd.
(None, 0, '') si introuvable."""
try:
res = subprocess.run(
["sudo", "virt-filesystems", "-a", disk, "--partitions",
"--long", "-b"],
capture_output=True,
text=True,
timeout=180,
["lsblk", "-bnro", "NAME,SIZE,FSTYPE,TYPE", dev],
capture_output=True, text=True, timeout=30,
)
except (OSError, subprocess.SubprocessError):
return None
best, best_sz = None, -1
return None, 0, ""
best, best_sz, best_fs = None, -1, ""
for line in res.stdout.splitlines():
parts = line.split()
if not parts or not parts[0].startswith("/dev/"):
cols = line.split()
if len(cols) < 4 or cols[-1] != "part":
continue
# Colonnes : Name Type MBR Size Parent -> Size = plus grand entier.
size = max(
(int(p) for p in parts if p.isdigit()), default=-1
)
name, size = cols[0], int(cols[1])
fstype = cols[2] if len(cols) >= 4 and cols[2] != "part" else ""
if len(cols) == 3: # pas de FSTYPE -> décalage
fstype = ""
if size > best_sz:
best, best_sz = parts[0], size
return best
best, best_sz, best_fs = name, size, fstype
if not best:
return None, 0, ""
part = f"/dev/{best}"
try:
start = int(
open(f"/sys/class/block/{best}/start").read().strip()
)
except OSError:
start = 0
if not best_fs:
best_fs = subprocess.run(
["sudo", "blkid", "-o", "value", "-s", "TYPE", part],
capture_output=True, text=True,
).stdout.strip()
return part, start, best_fs
def _qemu_install_libguestfs(self):
"""Installe libguestfs-tools (apt/dnf/pacman). Renvoie True si
virt-resize est ensuite disponible."""
if shutil.which("apt-get"):
cmd = "sudo apt-get install -y libguestfs-tools"
elif shutil.which("dnf"):
cmd = "sudo dnf install -y guestfs-tools libguestfs-tools"
elif shutil.which("pacman"):
cmd = "sudo pacman -S --needed --noconfirm libguestfs"
else:
print(t("Unknown package manager; install it manually."))
return False
print(f"{t('Will execute:')} {cmd}")
os.system(cmd)
return shutil.which("virt-resize") is not None
@staticmethod
def _qemu_part_number(dev, part):
"""Numéro de partition (ex. « 1 ») depuis /dev/nbd0p1."""
return part[len(dev):].lstrip("p")
@staticmethod
def _qemu_part_info(dev, n):
"""{type, uuid, name} d'une partition via « sgdisk -i »."""
info = {"type": "", "uuid": "", "name": ""}
res = subprocess.run(
["sudo", "sgdisk", "-i", n, dev],
capture_output=True, text=True,
)
for line in res.stdout.splitlines():
low = line.lower()
if low.startswith("partition guid code"):
info["type"] = line.split(":", 1)[1].split()[0]
elif low.startswith("partition unique guid"):
info["uuid"] = line.split(":", 1)[1].strip()
elif low.startswith("partition name"):
info["name"] = line.split(":", 1)[1].strip().strip("'")
return info
@staticmethod
def _qemu_fs_blocksize(part):
res = subprocess.run(
["sudo", "dumpe2fs", "-h", part],
capture_output=True, text=True,
)
for line in res.stdout.splitlines():
if line.startswith("Block size:"):
try:
return int(line.split(":", 1)[1].strip())
except ValueError:
pass
return 4096
@staticmethod
def _qemu_fs_blocks(part):
res = subprocess.run(
["sudo", "dumpe2fs", "-h", part],
capture_output=True, text=True,
)
for line in res.stdout.splitlines():
if line.startswith("Block count:"):
try:
return int(line.split(":", 1)[1].strip())
except ValueError:
pass
return 0
@staticmethod
def _qemu_fs_min_blocks(part):
"""Taille minimale (blocs) du FS via « resize2fs -P »."""
res = subprocess.run(
["sudo", "resize2fs", "-P", part],
capture_output=True, text=True,
)
for tok in res.stdout.replace(":", " ").split():
if tok.isdigit():
return int(tok)
return 0
# Commande d'extension du FS racine (partition + FS) réutilisée par SSH
# et par le repli console série.

View file

@ -1440,6 +1440,66 @@ TRANSLATIONS = {
"fr": "Partition à réduire introuvable ; abandon.",
"en": "Could not detect the partition to shrink; aborting.",
},
"Missing tools for safe shrink:": {
"fr": "Outils manquants pour la réduction sûre :",
"en": "Missing tools for safe shrink:",
},
"Backing up the disk before shrinking…": {
"fr": "Sauvegarde du disque avant réduction…",
"en": "Backing up the disk before shrinking…",
},
"Backup failed; aborting.": {
"fr": "Échec de la sauvegarde ; abandon.",
"en": "Backup failed; aborting.",
},
"Could not attach the disk (nbd); aborting.": {
"fr": "Impossible d'attacher le disque (nbd) ; abandon.",
"en": "Could not attach the disk (nbd); aborting.",
},
"Only ext2/3/4 can be shrunk safely; aborting.": {
"fr": "Seul ext2/3/4 peut être réduit en sécurité ; abandon.",
"en": "Only ext2/3/4 can be shrunk safely; aborting.",
},
"Target size too small for this layout; aborting.": {
"fr": "Taille cible trop petite pour cette disposition ; abandon.",
"en": "Target size too small for this layout; aborting.",
},
"Not enough used-space margin to shrink; aborting.": {
"fr": "Espace utilisé trop proche de la cible ; abandon.",
"en": "Not enough used-space margin to shrink; aborting.",
},
"Shrinking guest ext filesystem": {
"fr": "Réduction du système de fichiers ext invité",
"en": "Shrinking guest ext filesystem",
},
"resize2fs failed; reverting.": {
"fr": "resize2fs a échoué ; restauration.",
"en": "resize2fs failed; reverting.",
},
"Internal size check failed; reverting.": {
"fr": "Contrôle de taille interne échoué ; restauration.",
"en": "Internal size check failed; reverting.",
},
"Shrinking the partition…": {
"fr": "Réduction de la partition…",
"en": "Shrinking the partition…",
},
"Partition rewrite failed; reverting.": {
"fr": "Réécriture de la partition échouée ; restauration.",
"en": "Partition rewrite failed; reverting.",
},
"Shrinking the qcow2 container…": {
"fr": "Réduction du conteneur qcow2…",
"en": "Shrinking the qcow2 container…",
},
"Container shrink failed; reverting.": {
"fr": "Réduction du conteneur échouée ; restauration.",
"en": "Container shrink failed; reverting.",
},
"Restoring the original disk from backup…": {
"fr": "Restauration du disque d'origine depuis la sauvegarde…",
"en": "Restoring the original disk from backup…",
},
"Shrinking guest FS + partition via virt-resize": {
"fr": "Réduction du FS invité + partition via virt-resize",
"en": "Shrinking guest FS + partition via virt-resize",