[IMP] script todo: commande QEMU « Redimensionner le disque d'une VM »

Nouvelle entrée dans le menu QEMU/KVM (section Gérer). Elle :
- affiche le disque principal (qcow2 via domblklist) + « qemu-img info »
  (taille virtuelle + réelle) et l'état de la VM ;
- demande +NG (agrandir), -NG (réduire) ou NG (taille cible) ;
- applique : agrandissement À CHAUD si la VM tourne (virsh blockresize),
  sinon qemu-img resize ; réduction via qemu-img resize --shrink (VM éteinte
  obligatoire + avertissement fort : le FS invité n'est PAS réduit, risque
  de perte de données) ;
- propose ensuite d'étendre le FS invité via SSH (growpart + resize2fs /
  xfs_growfs / btrfs, device et type de FS détectés).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mathieu Benoit 2026-07-29 09:27:20 +00:00
parent 6f70da98d5
commit 1fd98e6ab6
2 changed files with 280 additions and 2 deletions

View file

@ -984,6 +984,7 @@ class TODO:
{"prompt_description": t("List VMs (virsh list --all)")},
{"prompt_description": t("Show a VM IP address")},
{"prompt_description": t("Open the console on a VM")},
{"prompt_description": t("Resize a VM disk")},
{"prompt_description": t("Delete VM(s)")},
{"prompt_description": t("Clean up QEMU (orphan files)")},
{"section": t("Catalog")},
@ -1012,10 +1013,12 @@ class TODO:
elif status == "6":
self._qemu_console()
elif status == "7":
self._qemu_delete_vm()
self._qemu_resize_disk()
elif status == "8":
self._qemu_cleanup()
self._qemu_delete_vm()
elif status == "9":
self._qemu_cleanup()
elif status == "10":
self._qemu_list_images()
else:
cmd_no_found = True
@ -1095,6 +1098,194 @@ class TODO:
print(f"{t('Will execute:')} {cmd}")
self.execute.exec_command_live(cmd, source_erplibre=False)
# ------------------------------------------------------------------ #
# Redimensionnement du disque d'une VM
# ------------------------------------------------------------------ #
@staticmethod
def _qemu_domstate(name):
"""État libvirt de la VM (« running », « shut off », …) ou ''."""
try:
res = subprocess.run(
["sudo", "virsh", "domstate", name],
capture_output=True,
text=True,
timeout=15,
)
except (OSError, subprocess.SubprocessError):
return ""
return res.stdout.strip() if res.returncode == 0 else ""
@staticmethod
def _qemu_main_disk(name):
"""Chemin du disque PRINCIPAL (qcow2) de la VM via domblklist. On
ignore le seed cloud-init (-seed.iso, en lecture seule)."""
try:
res = subprocess.run(
["sudo", "virsh", "domblklist", name, "--details"],
capture_output=True,
text=True,
timeout=15,
)
except (OSError, subprocess.SubprocessError):
return None
disks = []
for line in res.stdout.splitlines():
parts = line.split()
# Colonnes : Type Device Target Source
if len(parts) >= 4 and parts[1] == "disk" and parts[3] != "-":
disks.append(parts[3])
# Le disque de travail est le .qcow2 (le seed est un .iso).
for d in disks:
if d.endswith(".qcow2"):
return d
return disks[0] if disks else None
@staticmethod
def _qemu_disk_virtual_bytes(disk):
"""Taille VIRTUELLE (octets) du disque via « qemu-img info --json »."""
try:
res = subprocess.run(
["sudo", "qemu-img", "info", "--output=json", disk],
capture_output=True,
text=True,
timeout=20,
)
data = json.loads(res.stdout)
return int(data.get("virtual-size", 0))
except (OSError, subprocess.SubprocessError, ValueError):
return 0
def _qemu_resize_disk(self):
"""Redimensionne le disque d'une VM : affiche l'espace actuel, demande
+NG / -NG / taille cible, applique (à chaud si possible pour agrandir),
puis propose d'étendre le système de fichiers invité."""
self._qemu_list_vms()
print()
name = input(t("VM name to resize: ")).strip()
if not name:
print(t("VM name is required!"))
return
if not self._qemu_domain_exists(name):
print(f"{name}: {t('VM not found.')}")
return
disk = self._qemu_main_disk(name)
if not disk:
print(t("Main disk not found for this VM."))
return
# 1) Espace actuel (virtuel + réel) + df invité si joignable.
print(f"\n{t('Current disk:')} {disk}")
self.execute.exec_command_live(
f"sudo qemu-img info {shlex.quote(disk)}", source_erplibre=False
)
cur_bytes = self._qemu_disk_virtual_bytes(disk)
cur_gb = cur_bytes / (1 << 30)
print(f"{t('Current virtual size:')} {cur_gb:.1f} G")
state = self._qemu_domstate(name)
print(f"{t('VM state:')} {state or '?'}")
# 2) Nouvelle taille : +NG (agrandir), -NG (réduire) ou NG (cible).
guide = t(
"Enter +NG to grow, -NG to shrink, or NG for a target size "
"(e.g. +20G, -10G, 60G)."
)
print(f"\n{guide}")
raw = input(t("Resize: ")).strip().upper().replace("G", "")
try:
if raw.startswith("+"):
new_gb = cur_gb + float(raw[1:])
elif raw.startswith("-"):
new_gb = cur_gb - float(raw[1:])
else:
new_gb = float(raw)
except ValueError:
print(t("Invalid size."))
return
if new_gb <= 0:
print(t("Invalid size."))
return
new_gb = round(new_gb, 1)
if abs(new_gb - cur_gb) < 0.05:
print(t("No change."))
return
shrink = new_gb < cur_gb
print(
f"\n{t('New virtual size:')} {cur_gb:.1f} G -> {new_gb:.1f} G"
)
# 3) Application selon agrandir/réduire et l'état de la VM.
if shrink:
# DANGER : qcow2 --shrink ne réduit PAS le FS invité -> perte de
# données si le FS dépasse la cible. VM éteinte obligatoire.
danger = t(
"SHRINKING is DANGEROUS: the guest filesystem is NOT shrunk. "
"Data beyond the new size is LOST. Shrink the guest FS FIRST, "
"and only then shrink here."
)
print(f"{danger}")
if state != "shut off":
print(t("Shut the VM off before shrinking (virsh shutdown)."))
return
if not self._is_yes(
input(t("Type y to confirm you understand the risk (y/N): "))
):
print(t("Cancelled."))
return
cmd = (
f"sudo qemu-img resize --shrink {shlex.quote(disk)} "
f"{new_gb:g}G"
)
elif state == "running":
# Agrandissement À CHAUD : le disque virtuel grossit, le FS invité
# devra être étendu ensuite.
cmd = (
f"sudo virsh blockresize {shlex.quote(name)} "
f"{shlex.quote(disk)} {new_gb:g}G"
)
else:
cmd = f"sudo qemu-img resize {shlex.quote(disk)} {new_gb:g}G"
print(f"{t('Will execute:')} {cmd}")
if self.execute.exec_command_live(cmd, source_erplibre=False) != 0:
print(f"{t('Resize failed (see error above).')}")
return
print(f"{t('Virtual disk resized.')}")
# 4) Étendre le FS invité (agrandissement seulement, VM joignable).
if not shrink and self._is_yes(
input(t("Grow the guest filesystem now (over SSH)? (y/N): "))
):
self._qemu_grow_guest_fs(name)
def _qemu_grow_guest_fs(self, name):
"""Étend la partition racine + le FS invité via SSH (growpart +
resize2fs/xfs_growfs/btrfs). Détecte le device et le type de FS."""
ip = self._qemu_vm_ip(name, timeout=120)
if not ip:
print(t("No IP; grow the guest FS manually once booted."))
return
remote = (
"set -e; "
"root=$(findmnt -no SOURCE /); "
"dev=$(lsblk -no PKNAME \"$root\" | head -1); "
"part=$(echo \"$root\" | grep -oE '[0-9]+$'); "
"sudo growpart /dev/$dev $part || true; "
"fstype=$(findmnt -no FSTYPE /); "
'case "$fstype" in '
"ext*) sudo resize2fs \"$root\";; "
"xfs) sudo xfs_growfs /;; "
"btrfs) sudo btrfs filesystem resize max /;; "
'esac; '
"df -h /"
)
opts = (
"-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "
"-o ConnectTimeout=15"
)
cmd = f"ssh {opts} erplibre@{ip} {shlex.quote(remote)}"
print(f"{t('Will execute:')} {cmd}")
self.execute.exec_command_live(cmd, source_erplibre=False)
def _write_ssh_config_entry(self, host, user, ip):
"""Écrit/remplace un bloc « Host <host> » dans ~/.ssh/config."""
cfg = os.path.expanduser("~/.ssh/config")

View file

@ -1282,6 +1282,93 @@ TRANSLATIONS = {
"fr": "suivi d'installation",
"en": "install monitoring",
},
"Resize a VM disk": {
"fr": "Redimensionner le disque d'une VM",
"en": "Resize a VM disk",
},
"VM name to resize: ": {
"fr": "Nom de la VM à redimensionner : ",
"en": "VM name to resize: ",
},
"VM not found.": {
"fr": "VM introuvable.",
"en": "VM not found.",
},
"Main disk not found for this VM.": {
"fr": "Disque principal introuvable pour cette VM.",
"en": "Main disk not found for this VM.",
},
"Current disk:": {
"fr": "Disque actuel :",
"en": "Current disk:",
},
"Current virtual size:": {
"fr": "Taille virtuelle actuelle :",
"en": "Current virtual size:",
},
"VM state:": {
"fr": "État de la VM :",
"en": "VM state:",
},
"Enter +NG to grow, -NG to shrink, or NG for a target size "
"(e.g. +20G, -10G, 60G).": {
"fr": "Entrez +NG pour agrandir, -NG pour réduire, ou NG pour une "
"taille cible (ex. +20G, -10G, 60G).",
"en": "Enter +NG to grow, -NG to shrink, or NG for a target size "
"(e.g. +20G, -10G, 60G).",
},
"Resize: ": {
"fr": "Redimensionner : ",
"en": "Resize: ",
},
"Invalid size.": {
"fr": "Taille invalide.",
"en": "Invalid size.",
},
"No change.": {
"fr": "Aucun changement.",
"en": "No change.",
},
"New virtual size:": {
"fr": "Nouvelle taille virtuelle :",
"en": "New virtual size:",
},
"SHRINKING is DANGEROUS: the guest filesystem is NOT shrunk. "
"Data beyond the new size is LOST. Shrink the guest FS FIRST, "
"and only then shrink here.": {
"fr": "RÉDUIRE est DANGEREUX : le système de fichiers invité n'est "
"PAS réduit. Les données au-delà de la nouvelle taille sont PERDUES. "
"Réduisez d'ABORD le FS invité, puis seulement ici.",
"en": "SHRINKING is DANGEROUS: the guest filesystem is NOT shrunk. "
"Data beyond the new size is LOST. Shrink the guest FS FIRST, "
"and only then shrink here.",
},
"Shut the VM off before shrinking (virsh shutdown).": {
"fr": "Éteignez la VM avant de réduire (virsh shutdown).",
"en": "Shut the VM off before shrinking (virsh shutdown).",
},
"Type y to confirm you understand the risk (y/N): ": {
"fr": "Tapez o pour confirmer que vous comprenez le risque "
"(o/N, défaut : non) : ",
"en": "Type y to confirm you understand the risk (y/N): ",
},
"Resize failed (see error above).": {
"fr": "Échec du redimensionnement (voir l'erreur ci-dessus).",
"en": "Resize failed (see error above).",
},
"Virtual disk resized.": {
"fr": "Disque virtuel redimensionné.",
"en": "Virtual disk resized.",
},
"Grow the guest filesystem now (over SSH)? (y/N): ": {
"fr": "Étendre le système de fichiers invité maintenant (via SSH) ? "
"(o/N, défaut : non) : ",
"en": "Grow the guest filesystem now (over SSH)? (y/N): ",
},
"No IP; grow the guest FS manually once booted.": {
"fr": "Pas d'IP ; étendez le FS invité manuellement après le boot.",
"en": "No IP; grow the guest FS manually once booted.",
},
"completed": {
"fr": "terminées",
"en": "completed",