Compare commits

...

5 commits

Author SHA1 Message Date
8be034f25e [IMP] script todo: réduction — sauvegarde optionnelle + effacement du backup
Trois améliorations autour de la sauvegarde de disque à la réduction :

- La sauvegarde .bak n'est plus systématique : on DEMANDE (défaut OUI)
  « Sauvegarder le disque avant réduction ? ». Si non, avertissement (un
  échec pourrait casser le disque, pas de restauration possible).
- À la FIN, après avoir proposé de démarrer la VM (donc après test manuel
  possible), on propose d'EFFACER la sauvegarde (défaut NON -> on la garde
  par prudence).
- « Nettoyer QEMU » détecte désormais les sauvegardes *.qcow2.bak (libellé
  « sauvegarde de disque (redim.) ») et permet de les effacer.

_qemu_shrink_revert gère l'absence de sauvegarde (avertit de lancer fsck
au lieu de restaurer). Validé de bout en bout sur image jetable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 00:48:42 -04:00
c9ec5b98b9 [FIX] script todo: réduction — détection de partition robuste (FSTYPE vide)
« Partition à réduire introuvable » sur une vraie VM : _qemu_root_part
parsait lsblk en positionnel et EXIGEAIT 4 colonnes. Juste après le connect
nbd, le FSTYPE n'est pas encore en cache -> colonne VIDE -> 3 tokens ->
toutes les partitions étaient ignorées. (Le test initial passait car le FS
avait eu le temps d'être détecté.)

- lsblk -P (paires clé="valeur") : robuste aux colonnes vides ; on repère
  la partition par TYPE="part" et on sonde le FSTYPE via blkid si absent.
- _qemu_nbd_connect attend l'APPARITION des sous-périphériques nbdNpM
  (jusqu'à ~15 s) avant de rendre la main.
- partprobe silencieux (capture) -> plus de spam « Invalid argument during
  seek » pendant la réparation GPT.

Validé sur image jetable au layout cloud (p1 root + p14 bios + p15 ESP),
détection immédiate après connect : 25G -> 15G, GPT « No problems found »,
partitions préservées, fsck propre.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 00:41:54 -04:00
545193d293 [FIX] qemu: disque minimum 20G (10G -> poetry « disk full » sur ubuntu-2004)
erplibre-ubuntu-2004 (déployée à 10G) plantait à l'install : « Poetry
installation error status 1 » car le disque était plein (9.7G/10G). Un
ERPLibre + Odoo occupe ~11G, plus le transitoire (caches pip, sync repo,
sources Odoo). Preuve : la VM à 15G réussissait, celle à 10G non.

Plancher relevé à 20G pour TOUTES les versions (ubuntu 20.04/22.04,
debian, fedora, arch). Le qcow2 est CREUX (sparse) : une taille virtuelle
plus grande ne consomme rien tant qu'elle n'est pas remplie -> aucun
surcoût réel, marge confortable pour l'installation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 00:41:54 -04:00
9fb24e583a [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>
2026-07-30 00:34:45 -04:00
0b02e9fe43 [IMP] script todo: QEMU — rouvrir le suivi d'installation (dernier run / historique)
Si le dashboard se ferme (bug, sortie), on peut désormais le ROUVRIR sur un
run passé pour reprendre l'analyse. Nouvelle entrée [11] du menu QEMU :
« 📈 Rouvrir le suivi d'installation (dernier run / historique) ».

- mon.list_install_runs() : liste les runs (~/.erplibre/qemu-install/*/
  session.json) triés du plus récent au plus ancien.
- _qemu_reopen_monitor : affiche l'historique (VM par run), choix (défaut =
  le dernier), puis run_monitor(session.json) rouvre le dashboard.
- « Lister les images » passe de [11] à [12] ; config -> 13+.

Validé : 20 runs listés, dashboard reconstruit (titre + 5 VM) depuis un
session.json passé.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 00:22:37 -04:00
4 changed files with 509 additions and 95 deletions

View file

@ -73,27 +73,32 @@ from pathlib import Path
# NB : les versions intermédiaires (non-LTS) sont RETIRÉES du miroir # NB : les versions intermédiaires (non-LTS) sont RETIRÉES du miroir
# cloud-images une fois EOL (leur /current/ renvoie 404). On ne garde donc # cloud-images une fois EOL (leur /current/ renvoie 404). On ne garde donc
# que les LTS + les intermédiaires encore publiées. À réviser au fil du temps. # que les LTS + les intermédiaires encore publiées. À réviser au fil du temps.
# Disque MINIMUM = 20G partout : un ERPLibre + Odoo installé occupe ~11G, et
# l'installation (caches pip, sync repo, sources Odoo) en consomme plus en
# transitoire. Un VM à 10G tombait « Poetry installation error » (disque
# plein). Le qcow2 est CREUX (sparse) : une taille virtuelle plus grande ne
# consomme rien tant qu'elle n'est pas remplie -> 20G sans surcoût réel.
UBUNTU_VERSIONS: dict[str, tuple[str, str, int, str]] = { UBUNTU_VERSIONS: dict[str, tuple[str, str, int, str]] = {
"20.04": ("focal", "ubuntu20.04", 2048, "5G"), "20.04": ("focal", "ubuntu20.04", 2048, "20G"),
"22.04": ("jammy", "ubuntu22.04", 2048, "10G"), "22.04": ("jammy", "ubuntu22.04", 2048, "20G"),
"24.04": ("noble", "ubuntu24.04", 3072, "20G"), "24.04": ("noble", "ubuntu24.04", 3072, "20G"),
"25.10": ("questing", "ubuntu25.10", 3072, "20G"), "25.10": ("questing", "ubuntu25.10", 3072, "20G"),
"26.04": ("resolute", "ubuntu26.04", 3072, "20G"), "26.04": ("resolute", "ubuntu26.04", 3072, "20G"),
} }
DEBIAN_VERSIONS: dict[str, tuple[str, str, int, str]] = { DEBIAN_VERSIONS: dict[str, tuple[str, str, int, str]] = {
"11": ("bullseye", "debian11", 1024, "10G"), "11": ("bullseye", "debian11", 1024, "20G"),
"12": ("bookworm", "debian12", 1024, "10G"), "12": ("bookworm", "debian12", 1024, "20G"),
"13": ("trixie", "debian13", 1024, "10G"), "13": ("trixie", "debian13", 1024, "20G"),
} }
FEDORA_VERSIONS: dict[str, tuple[str, str, int, str]] = { FEDORA_VERSIONS: dict[str, tuple[str, str, int, str]] = {
"41": ("41", "fedora41", 2048, "15G"), "41": ("41", "fedora41", 2048, "20G"),
"42": ("42", "fedora42", 2048, "15G"), "42": ("42", "fedora42", 2048, "20G"),
"43": ("43", "fedora43", 2048, "15G"), "43": ("43", "fedora43", 2048, "20G"),
"44": ("44", "fedora44", 2048, "15G"), "44": ("44", "fedora44", 2048, "20G"),
} }
# Arch est en rolling release : une seule « version » (latest). # Arch est en rolling release : une seule « version » (latest).
ARCH_VERSIONS: dict[str, tuple[str, str, int, str]] = { ARCH_VERSIONS: dict[str, tuple[str, str, int, str]] = {
"latest": ("latest", "archlinux", 1024, "10G"), "latest": ("latest", "archlinux", 1024, "20G"),
} }
# distro -> (table des versions, version par défaut). # distro -> (table des versions, version par défaut).

View file

@ -44,6 +44,34 @@ def session_dir() -> Path:
return base return base
def list_install_runs() -> list:
"""Runs d'installation passés (chacun a un session.json), TRIÉS du plus
récent au plus ancien. Chaque entrée : dict {label, manifest, mtime,
vms, branch}. Permet de ROUVRIR le suivi d'un run (le dashboard s'étant
fermé sur un bug, on reprend l'analyse)."""
runs = []
for d in sorted(session_dir().glob("*/"), reverse=True):
manifest = d / "session.json"
if not manifest.is_file():
continue
try:
data = json.loads(manifest.read_text())
mtime = manifest.stat().st_mtime
except (OSError, ValueError):
continue
runs.append(
{
"label": d.name.rstrip("/"),
"manifest": str(manifest),
"mtime": mtime,
"vms": data.get("vms", []),
"branch": data.get("branch", ""),
}
)
runs.sort(key=lambda r: r["mtime"], reverse=True)
return runs
def _launch_one(ip: str, remote_cmd: str, log_path: str) -> None: def _launch_one(ip: str, remote_cmd: str, log_path: str) -> None:
"""Lance une install SSH DÉTACHÉE : attend le sshd, exécute, journalise """Lance une install SSH DÉTACHÉE : attend le sshd, exécute, journalise
la sortie puis écrit le marqueur de fin avec le code de sortie.""" la sortie puis écrit le marqueur de fin avec le code de sortie."""

View file

@ -1087,6 +1087,11 @@ class TODO:
"Test a VM (open Odoo in a CLI browser)" "Test a VM (open Odoo in a CLI browser)"
) )
}, },
{
"prompt_description": t(
"Reopen install monitoring (last run / history)"
)
},
{"section": t("Catalog")}, {"section": t("Catalog")},
{"prompt_description": t("List available images and specs")}, {"prompt_description": t("List available images and specs")},
] ]
@ -1121,6 +1126,8 @@ class TODO:
elif status == "10": elif status == "10":
self._qemu_test_vm() self._qemu_test_vm()
elif status == "11": elif status == "11":
self._qemu_reopen_monitor()
elif status == "12":
self._qemu_list_images() self._qemu_list_images()
else: else:
cmd_no_found = True cmd_no_found = True
@ -1383,6 +1390,46 @@ class TODO:
) )
print(f"⚠ {msg}") print(f"⚠ {msg}")
def _qemu_reopen_monitor(self):
"""Rouvre le suivi d'installation (dashboard) sur un run PASSÉ : le
dernier par défaut, ou un choix dans l'historique. Utile quand le
dashboard s'est fermé et qu'on veut reprendre l'analyse."""
from script.todo import qemu_install_monitor as mon
runs = mon.list_install_runs()
if not runs:
print(t("No install run found in history."))
return
print(f"\n{t('Install runs (most recent first):')}")
for i, r in enumerate(runs, 1):
names = ", ".join(v.get("name", "?") for v in r["vms"])
star = " *" if i == 1 else ""
print(
f" [{i}] {r['label']} — {len(r['vms'])} VM{star}\n"
f" {names}"
)
sel = input(
t("Choice (number, blank = last): ")
).strip()
run = runs[0]
if sel:
try:
idx = int(sel) - 1
if 0 <= idx < len(runs):
run = runs[idx]
else:
print(t("Invalid selection."))
return
except ValueError:
print(t("Invalid selection."))
return
try:
mon.run_monitor(run["manifest"])
except ImportError:
print(t("Install textual for the dashboard (pip)."))
except Exception as exc:
print(f"{t('Command failed: ')}{exc}")
def _qemu_choose_cli_browser(self): def _qemu_choose_cli_browser(self):
"""Offre la LISTE des navigateurs CLI installés, plus une option pour """Offre la LISTE des navigateurs CLI installés, plus une option pour
en INSTALLER un autre, et renvoie celui choisi, sinon None.""" en INSTALLER un autre, et renvoie celui choisi, sinon None."""
@ -1652,12 +1699,8 @@ class TODO:
): ):
print(t("Cancelled.")) print(t("Cancelled."))
return return
# Réduction SÛRE via virt-resize (réduit FS + partition + GPT). # Réduction SÛRE (qemu-nbd : réduit FS + partition + GPT, avec
# On opère sur une COPIE : l'original n'est remplacé qu'en cas de # sauvegarde optionnelle restaurée en cas d'échec).
# SUCCÈS -> aucune corruption possible. (Le brut « qemu-img resize
# --shrink » tronquait le conteneur sans réduire le FS/partition
# -> GPT de secours perdue, racine tronquée -> dracut-initqueue en
# échec, OS non bootable.)
if not self._qemu_safe_shrink(name, disk, new_gb): if not self._qemu_safe_shrink(name, disk, new_gb):
self._qemu_offer_start(name, was_shut_down) self._qemu_offer_start(name, was_shut_down)
return return
@ -1683,9 +1726,24 @@ class TODO:
): ):
self._qemu_grow_guest_fs(name) self._qemu_grow_guest_fs(name)
# 5) La VM a été éteinte pour l'opération : proposer de la redémarrer. # 5) La VM a été éteinte pour l'opération : proposer de la redémarrer
# (l'utilisateur peut ainsi TESTER avant de décider du backup).
self._qemu_offer_start(name, was_shut_down) self._qemu_offer_start(name, was_shut_down)
# 6) Réduction réussie AVEC sauvegarde : proposer de l'effacer une fois
# la VM testée (défaut : NON -> on garde le backup par prudence).
bak = getattr(self, "_shrink_backup", None)
if shrink and bak and os.path.exists(bak):
print(f"\n{t('A disk backup was kept:')} {bak}")
if self._is_yes(
input(t("Delete this backup now? (y/N): "))
):
subprocess.run(["sudo", "rm", "-f", bak], check=False)
print(t("Backup deleted."))
else:
print(t("Backup kept (delete later via Clean up QEMU)."))
self._shrink_backup = None
def _qemu_offer_start(self, name, was_shut_down): def _qemu_offer_start(self, name, was_shut_down):
"""Si la VM a été éteinte pour l'opération, le noter et proposer de la """Si la VM a été éteinte pour l'opération, le noter et proposer de la
redémarrer (sinon ne rien demander).""" redémarrer (sinon ne rien demander)."""
@ -1699,97 +1757,301 @@ class TODO:
print(f"{t('Will execute:')} {cmd}") print(f"{t('Will execute:')} {cmd}")
self.execute.exec_command_live(cmd, source_erplibre=False) 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): def _qemu_safe_shrink(self, name, disk, new_gb):
"""Réduit le disque SANS casser l'OS : virt-resize (libguestfs) réduit """Réduit le disque SANS casser l'OS, via qemu-nbd + resize2fs +
le système de fichiers + la partition + la GPT. On écrit dans une sgdisk (sans libguestfs) : on réduit le FS (ext), puis la partition,
NOUVELLE image ; l'originale n'est remplacée qu'en cas de succès (en cas puis le conteneur qcow2, puis on répare la GPT de secours. Une COPIE
d'échec/refus, le disque d'origine est laissé INTACT). Renvoie True si .bak est faite AVANT ; en cas d'échec on RESTAURE -> jamais de
la réduction a réussi.""" corruption. ext2/3/4 uniquement. Renvoie True si réduit."""
if not ( import math
shutil.which("virt-resize") and shutil.which("virt-filesystems")
): missing = [b for b in self._SHRINK_TOOLS if not shutil.which(b)]
print(f"\n{t('Safe shrink needs libguestfs (virt-resize).')}") if missing:
if not self._is_yes( print(
input(t("Install libguestfs-tools now? (y/N): ")) f"{t('Missing tools for safe shrink:')} {', '.join(missing)}"
) or not self._qemu_install_libguestfs(): )
print(t("Shrink aborted; disk left intact."))
return False return False
part = self._qemu_largest_partition(disk) target = int(round(new_gb * (1 << 30)))
# Sauvegarde OPTIONNELLE (défaut OUI) : permet de restaurer en cas
# d'échec, et de tester la VM avant de la supprimer (proposé à la fin).
self._shrink_backup = None
bak = None
if self._is_yes_default_yes(
input(t("Back up the disk before shrinking? (Y/n): "))
):
bak = f"{disk}.bak"
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
else:
print(f"⚠ {t('No backup: a failure could leave the disk broken.')}")
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: if not part:
print(t("Could not detect the partition to shrink; aborting.")) print(t("Could not detect the partition to shrink; aborting."))
return False return self._qemu_shrink_revert(bak, disk, changed=False)
new_disk = f"{disk}.resized" if not fstype.startswith("ext"):
bak = f"{disk}.bak"
subprocess.run(["sudo", "rm", "-f", new_disk], check=False)
print( print(
f"\n{t('Shrinking guest FS + partition via virt-resize')} " f"{t('Only ext2/3/4 can be shrunk safely; aborting.')}"
f"({part} -> {new_gb:g}G)…" f" ({fstype})"
) )
create = subprocess.run( return self._qemu_shrink_revert(bak, disk, changed=False)
["sudo", "qemu-img", "create", "-f", "qcow2", new_disk, n = self._qemu_part_number(dev, part)
f"{new_gb:g}G"] 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 create.returncode != 0: if subprocess.run(
print(f"❌ {t('Resize failed (see error above).')}") ["sudo", "resize2fs", part, f"{fs_target_mib}M"]
subprocess.run(["sudo", "rm", "-f", new_disk], check=False) ).returncode != 0:
return False print(t("resize2fs failed; reverting."))
# virt-resize copie old -> new en réduisant le FS de `part`. 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( rc = subprocess.run(
["sudo", "virt-resize", "--shrink", part, disk, new_disk] ["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 ).returncode
if rc != 0: if rc != 0:
print(f"❌ {t('virt-resize failed; original disk left intact.')}") print(t("Partition rewrite failed; reverting."))
subprocess.run(["sudo", "rm", "-f", new_disk], check=False) return self._qemu_shrink_revert(bak, disk, changed=True)
return False subprocess.run(
# Succès : on sauvegarde l'original et on met la nouvelle image au ["sudo", "partprobe", dev], check=False, capture_output=True
# MÊME chemin (la définition libvirt reste valide). )
subprocess.run(["sudo", "mv", "-f", disk, bak], check=False) # Détache puis tronque le conteneur qcow2.
subprocess.run(["sudo", "mv", "-f", new_disk, disk], check=False) self._qemu_nbd_disconnect(dev)
print(f"✅ {t('Disk safely shrunk. Backup kept at:')} {bak}") 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, capture_output=True
)
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
self._shrink_backup = bak # proposé à la suppression après le boot
if bak:
print(
f"✅ {t('Disk safely shrunk. Backup kept at:')} {bak}"
)
else:
print(f"✅ {t('Disk safely shrunk.')}")
return True return True
finally:
if dev:
self._qemu_nbd_disconnect(dev)
def _qemu_shrink_revert(self, bak, disk, changed):
"""Restaure le disque depuis la sauvegarde si on l'a modifié (changed)
et qu'une sauvegarde existe ; sinon retire la sauvegarde inutile.
Renvoie False (la réduction a échoué)."""
if changed and bak:
print(t("Restoring the original disk from backup…"))
subprocess.run(["sudo", "mv", "-f", bak, disk], check=False)
elif changed and not bak:
print(
f"⚠ {t('No backup to restore; run fsck on the disk before use.')}"
)
elif bak:
subprocess.run(["sudo", "rm", "-f", bak], check=False)
return False
@staticmethod @staticmethod
def _qemu_largest_partition(disk): def _qemu_nbd_connect(disk):
"""Nom (côté invité, ex. /dev/sda1) de la plus GROSSE partition du """Attache `disk` à un /dev/nbdN libre et renvoie le chemin, ou None.
disque via virt-filesystems — celle qui porte la racine à réduire.""" Attend que les sous-périphériques de partition (nbdNpM) APPARAISSENT
(sinon lsblk/resize2fs ne voient rien juste après le connect)."""
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:
continue
base = f"nbd{i}"
for _ in range(15):
subprocess.run(
["sudo", "partprobe", dev], check=False, capture_output=True
)
time.sleep(1)
if any(
os.path.exists(f"/sys/class/block/{base}p{n}")
for n in range(1, 32)
):
break
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. Format lsblk -P (paires) : robuste aux
colonnes VIDES — juste après le connect, FSTYPE peut être vide, et un
parsing positionnel décalait/ignorait alors toutes les partitions."""
import re
try: try:
res = subprocess.run( res = subprocess.run(
["sudo", "virt-filesystems", "-a", disk, "--partitions", ["lsblk", "-Pbno", "NAME,SIZE,TYPE,FSTYPE", dev],
"--long", "-b"], capture_output=True, text=True, timeout=30,
capture_output=True,
text=True,
timeout=180,
) )
except (OSError, subprocess.SubprocessError): except (OSError, subprocess.SubprocessError):
return None return None, 0, ""
best, best_sz = None, -1 best, best_sz, best_fs = None, -1, ""
for line in res.stdout.splitlines(): for line in res.stdout.splitlines():
parts = line.split() d = dict(re.findall(r'(\w+)="([^"]*)"', line))
if not parts or not parts[0].startswith("/dev/"): if d.get("TYPE") != "part":
continue continue
# Colonnes : Name Type MBR Size Parent -> Size = plus grand entier. try:
size = max( size = int(d.get("SIZE") or 0)
(int(p) for p in parts if p.isdigit()), default=-1 except ValueError:
) size = 0
if size > best_sz: if size > best_sz:
best, best_sz = parts[0], size best, best_sz, best_fs = d.get("NAME"), size, d.get("FSTYPE", "")
return best 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:
# FSTYPE pas encore en cache : sonder directement avec blkid.
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): @staticmethod
"""Installe libguestfs-tools (apt/dnf/pacman). Renvoie True si def _qemu_part_number(dev, part):
virt-resize est ensuite disponible.""" """Numéro de partition (ex. « 1 ») depuis /dev/nbd0p1."""
if shutil.which("apt-get"): return part[len(dev):].lstrip("p")
cmd = "sudo apt-get install -y libguestfs-tools"
elif shutil.which("dnf"): @staticmethod
cmd = "sudo dnf install -y guestfs-tools libguestfs-tools" def _qemu_part_info(dev, n):
elif shutil.which("pacman"): """{type, uuid, name} d'une partition via « sgdisk -i »."""
cmd = "sudo pacman -S --needed --noconfirm libguestfs" info = {"type": "", "uuid": "", "name": ""}
else: res = subprocess.run(
print(t("Unknown package manager; install it manually.")) ["sudo", "sgdisk", "-i", n, dev],
return False capture_output=True, text=True,
print(f"{t('Will execute:')} {cmd}") )
os.system(cmd) for line in res.stdout.splitlines():
return shutil.which("virt-resize") is not None 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 # Commande d'extension du FS racine (partition + FS) réutilisée par SSH
# et par le repli console série. # et par le repli console série.
@ -2128,6 +2390,9 @@ class TODO:
stem = re.sub(r"(_VARS)?\.fd$", "", os.path.basename(path)) stem = re.sub(r"(_VARS)?\.fd$", "", os.path.basename(path))
if stem not in domains: if stem not in domains:
orphans.append((size, path, t("orphan UEFI nvram"))) orphans.append((size, path, t("orphan UEFI nvram")))
# Sauvegardes de disque laissées par un redimensionnement (.qcow2.bak).
for size, path in self._qemu_find_files(disk_dir, "*.qcow2.bak"):
orphans.append((size, path, t("disk backup (resize)")))
if orphans: if orphans:
total = sum(o[0] for o in orphans) total = sum(o[0] for o in orphans)
print(f"\n{t('Orphan files:')}") print(f"\n{t('Orphan files:')}")

View file

@ -1440,6 +1440,102 @@ TRANSLATIONS = {
"fr": "Partition à réduire introuvable ; abandon.", "fr": "Partition à réduire introuvable ; abandon.",
"en": "Could not detect the partition to shrink; aborting.", "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…",
},
"Back up the disk before shrinking? (Y/n): ": {
"fr": "Sauvegarder le disque avant réduction ? (O/n, défaut : oui) : ",
"en": "Back up the disk before shrinking? (Y/n, default: yes): ",
},
"No backup: a failure could leave the disk broken.": {
"fr": "Sans sauvegarde : un échec pourrait rendre le disque inutilisable.",
"en": "No backup: a failure could leave the disk broken.",
},
"Disk safely shrunk.": {
"fr": "Disque réduit en toute sécurité.",
"en": "Disk safely shrunk.",
},
"No backup to restore; run fsck on the disk before use.": {
"fr": "Pas de sauvegarde à restaurer ; lancez fsck avant utilisation.",
"en": "No backup to restore; run fsck on the disk before use.",
},
"A disk backup was kept:": {
"fr": "Une sauvegarde du disque a été conservée :",
"en": "A disk backup was kept:",
},
"Delete this backup now? (y/N): ": {
"fr": "Effacer cette sauvegarde maintenant ? (o/N) : ",
"en": "Delete this backup now? (y/N): ",
},
"Backup deleted.": {
"fr": "Sauvegarde effacée.",
"en": "Backup deleted.",
},
"Backup kept (delete later via Clean up QEMU).": {
"fr": "Sauvegarde conservée (à effacer plus tard via Nettoyer QEMU).",
"en": "Backup kept (delete later via Clean up QEMU).",
},
"disk backup (resize)": {
"fr": "sauvegarde de disque (redim.)",
"en": "disk backup (resize)",
},
"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": { "Shrinking guest FS + partition via virt-resize": {
"fr": "Réduction du FS invité + partition via virt-resize", "fr": "Réduction du FS invité + partition via virt-resize",
"en": "Shrinking guest FS + partition via virt-resize", "en": "Shrinking guest FS + partition via virt-resize",
@ -1488,6 +1584,26 @@ TRANSLATIONS = {
"fr": "🧪 Tester une VM (ouvrir Odoo dans un navigateur CLI)", "fr": "🧪 Tester une VM (ouvrir Odoo dans un navigateur CLI)",
"en": "🧪 Test a VM (open Odoo in a CLI browser)", "en": "🧪 Test a VM (open Odoo in a CLI browser)",
}, },
"Reopen install monitoring (last run / history)": {
"fr": "📈 Rouvrir le suivi d'installation (dernier run / historique)",
"en": "📈 Reopen install monitoring (last run / history)",
},
"No install run found in history.": {
"fr": "Aucun run d'installation dans l'historique.",
"en": "No install run found in history.",
},
"Install runs (most recent first):": {
"fr": "Runs d'installation (du plus récent) :",
"en": "Install runs (most recent first):",
},
"Choice (number, blank = last): ": {
"fr": "Choix (numéro, vide = le dernier) : ",
"en": "Choice (number, blank = last): ",
},
"Invalid selection.": {
"fr": "Sélection invalide.",
"en": "Invalid selection.",
},
"Resolving VM IP...": { "Resolving VM IP...": {
"fr": "Résolution de l'IP de la VM…", "fr": "Résolution de l'IP de la VM…",
"en": "Resolving VM IP...", "en": "Resolving VM IP...",