Compare commits

..

No commits in common. "8be034f25e2ef46f1521b95fb1b5394d7cb4c575" and "4f0c3cd2175827e2faa297caa19b6b32977e27cd" have entirely different histories.

4 changed files with 95 additions and 509 deletions

View file

@ -73,32 +73,27 @@ 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, "20G"), "20.04": ("focal", "ubuntu20.04", 2048, "5G"),
"22.04": ("jammy", "ubuntu22.04", 2048, "20G"), "22.04": ("jammy", "ubuntu22.04", 2048, "10G"),
"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, "20G"), "11": ("bullseye", "debian11", 1024, "10G"),
"12": ("bookworm", "debian12", 1024, "20G"), "12": ("bookworm", "debian12", 1024, "10G"),
"13": ("trixie", "debian13", 1024, "20G"), "13": ("trixie", "debian13", 1024, "10G"),
} }
FEDORA_VERSIONS: dict[str, tuple[str, str, int, str]] = { FEDORA_VERSIONS: dict[str, tuple[str, str, int, str]] = {
"41": ("41", "fedora41", 2048, "20G"), "41": ("41", "fedora41", 2048, "15G"),
"42": ("42", "fedora42", 2048, "20G"), "42": ("42", "fedora42", 2048, "15G"),
"43": ("43", "fedora43", 2048, "20G"), "43": ("43", "fedora43", 2048, "15G"),
"44": ("44", "fedora44", 2048, "20G"), "44": ("44", "fedora44", 2048, "15G"),
} }
# 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, "20G"), "latest": ("latest", "archlinux", 1024, "10G"),
} }
# distro -> (table des versions, version par défaut). # distro -> (table des versions, version par défaut).

View file

@ -44,34 +44,6 @@ 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,11 +1087,6 @@ 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")},
] ]
@ -1126,8 +1121,6 @@ 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
@ -1390,46 +1383,6 @@ 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."""
@ -1699,8 +1652,12 @@ class TODO:
): ):
print(t("Cancelled.")) print(t("Cancelled."))
return return
# Réduction SÛRE (qemu-nbd : réduit FS + partition + GPT, avec # Réduction SÛRE via virt-resize (réduit FS + partition + GPT).
# sauvegarde optionnelle restaurée en cas d'échec). # On opère sur une COPIE : l'original n'est remplacé qu'en cas de
# 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
@ -1726,24 +1683,9 @@ 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)."""
@ -1757,301 +1699,97 @@ 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, via qemu-nbd + resize2fs + """Réduit le disque SANS casser l'OS : virt-resize (libguestfs) réduit
sgdisk (sans libguestfs) : on réduit le FS (ext), puis la partition, le système de fichiers + la partition + la GPT. On écrit dans une
puis le conteneur qcow2, puis on répare la GPT de secours. Une COPIE NOUVELLE image ; l'originale n'est remplacée qu'en cas de succès (en cas
.bak est faite AVANT ; en cas d'échec on RESTAURE -> jamais de d'échec/refus, le disque d'origine est laissé INTACT). Renvoie True si
corruption. ext2/3/4 uniquement. Renvoie True si réduit.""" la réduction a réussi."""
import math if not (
shutil.which("virt-resize") and shutil.which("virt-filesystems")
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
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('Safe shrink needs libguestfs (virt-resize).')}")
print(f"\n{t('Backing up the disk before shrinking…')}") if not self._is_yes(
if subprocess.run( input(t("Install libguestfs-tools now? (y/N): "))
["sudo", "cp", "--reflink=auto", "--sparse=always", disk, bak] ) or not self._qemu_install_libguestfs():
).returncode != 0: print(t("Shrink aborted; disk left intact."))
print(t("Backup failed; aborting."))
return False return False
else: part = self._qemu_largest_partition(disk)
print(f"⚠ {t('No backup: a failure could leave the disk broken.')}") if not part:
subprocess.run(["sudo", "modprobe", "nbd", "max_part=16"], check=False) print(t("Could not detect the partition to shrink; aborting."))
dev = None return False
try: new_disk = f"{disk}.resized"
dev = self._qemu_nbd_connect(disk) bak = f"{disk}.bak"
if not dev: subprocess.run(["sudo", "rm", "-f", new_disk], check=False)
print(t("Could not attach the disk (nbd); aborting.")) print(
return self._qemu_shrink_revert(bak, disk, changed=False) f"\n{t('Shrinking guest FS + partition via virt-resize')} "
part, start, fstype = self._qemu_root_part(dev) f"({part} -> {new_gb:g}G)…"
if not part: )
print(t("Could not detect the partition to shrink; aborting.")) create = subprocess.run(
return self._qemu_shrink_revert(bak, disk, changed=False) ["sudo", "qemu-img", "create", "-f", "qcow2", new_disk,
if not fstype.startswith("ext"): f"{new_gb:g}G"]
print( )
f"{t('Only ext2/3/4 can be shrunk safely; aborting.')}" if create.returncode != 0:
f" ({fstype})" print(f"❌ {t('Resize failed (see error above).')}")
) subprocess.run(["sudo", "rm", "-f", new_disk], check=False)
return self._qemu_shrink_revert(bak, disk, changed=False) return False
n = self._qemu_part_number(dev, part) # virt-resize copie old -> new en réduisant le FS de `part`.
info = self._qemu_part_info(dev, n) rc = subprocess.run(
# fsck AVANT toute opération. ["sudo", "virt-resize", "--shrink", part, disk, new_disk]
subprocess.run(["sudo", "e2fsck", "-f", "-y", part], check=False) ).returncode
bs = self._qemu_fs_blocksize(part) if rc != 0:
# Cibles (octets), en gardant 2 Mio pour la GPT de secours + marge. print(f"❌ {t('virt-resize failed; original disk left intact.')}")
part_start_b = start * self._SECT subprocess.run(["sudo", "rm", "-f", new_disk], check=False)
max_fs_b = target - part_start_b - 4 * self._MiB return False
if max_fs_b <= 0: # Succès : on sauvegarde l'original et on met la nouvelle image au
print(t("Target size too small for this layout; aborting.")) # MÊME chemin (la définition libvirt reste valide).
return self._qemu_shrink_revert(bak, disk, changed=False) subprocess.run(["sudo", "mv", "-f", disk, bak], check=False)
min_blocks = self._qemu_fs_min_blocks(part) subprocess.run(["sudo", "mv", "-f", new_disk, disk], check=False)
if min_blocks and min_blocks * bs > max_fs_b: print(f"✅ {t('Disk safely shrunk. Backup kept at:')} {bak}")
print(t("Not enough used-space margin to shrink; aborting.")) return True
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, capture_output=True
)
# 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, 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
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_nbd_connect(disk): def _qemu_largest_partition(disk):
"""Attache `disk` à un /dev/nbdN libre et renvoie le chemin, ou None. """Nom (côté invité, ex. /dev/sda1) de la plus GROSSE partition du
Attend que les sous-périphériques de partition (nbdNpM) APPARAISSENT disque via virt-filesystems — celle qui porte la racine à réduire."""
(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(
["lsblk", "-Pbno", "NAME,SIZE,TYPE,FSTYPE", dev], ["sudo", "virt-filesystems", "-a", disk, "--partitions",
capture_output=True, text=True, timeout=30, "--long", "-b"],
capture_output=True,
text=True,
timeout=180,
) )
except (OSError, subprocess.SubprocessError): except (OSError, subprocess.SubprocessError):
return None, 0, "" return None
best, best_sz, best_fs = None, -1, "" best, best_sz = None, -1
for line in res.stdout.splitlines(): for line in res.stdout.splitlines():
d = dict(re.findall(r'(\w+)="([^"]*)"', line)) parts = line.split()
if d.get("TYPE") != "part": if not parts or not parts[0].startswith("/dev/"):
continue continue
try: # Colonnes : Name Type MBR Size Parent -> Size = plus grand entier.
size = int(d.get("SIZE") or 0) size = max(
except ValueError: (int(p) for p in parts if p.isdigit()), default=-1
size = 0
if size > best_sz:
best, best_sz, best_fs = d.get("NAME"), size, d.get("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: if size > best_sz:
start = 0 best, best_sz = parts[0], size
if not best_fs: return best
# 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
@staticmethod def _qemu_install_libguestfs(self):
def _qemu_part_number(dev, part): """Installe libguestfs-tools (apt/dnf/pacman). Renvoie True si
"""Numéro de partition (ex. « 1 ») depuis /dev/nbd0p1.""" virt-resize est ensuite disponible."""
return part[len(dev):].lstrip("p") if shutil.which("apt-get"):
cmd = "sudo apt-get install -y libguestfs-tools"
@staticmethod elif shutil.which("dnf"):
def _qemu_part_info(dev, n): cmd = "sudo dnf install -y guestfs-tools libguestfs-tools"
"""{type, uuid, name} d'une partition via « sgdisk -i ».""" elif shutil.which("pacman"):
info = {"type": "", "uuid": "", "name": ""} cmd = "sudo pacman -S --needed --noconfirm libguestfs"
res = subprocess.run( else:
["sudo", "sgdisk", "-i", n, dev], print(t("Unknown package manager; install it manually."))
capture_output=True, text=True, return False
) print(f"{t('Will execute:')} {cmd}")
for line in res.stdout.splitlines(): os.system(cmd)
low = line.lower() return shutil.which("virt-resize") is not None
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.
@ -2390,9 +2128,6 @@ 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,102 +1440,6 @@ 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",
@ -1584,26 +1488,6 @@ 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...",