Compare commits
5 commits
4f0c3cd217
...
8be034f25e
| Author | SHA1 | Date | |
|---|---|---|---|
| 8be034f25e | |||
| c9ec5b98b9 | |||
| 545193d293 | |||
| 9fb24e583a | |||
| 0b02e9fe43 |
4 changed files with 509 additions and 95 deletions
|
|
@ -73,27 +73,32 @@ from pathlib import Path
|
|||
# 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
|
||||
# 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]] = {
|
||||
"20.04": ("focal", "ubuntu20.04", 2048, "5G"),
|
||||
"22.04": ("jammy", "ubuntu22.04", 2048, "10G"),
|
||||
"20.04": ("focal", "ubuntu20.04", 2048, "20G"),
|
||||
"22.04": ("jammy", "ubuntu22.04", 2048, "20G"),
|
||||
"24.04": ("noble", "ubuntu24.04", 3072, "20G"),
|
||||
"25.10": ("questing", "ubuntu25.10", 3072, "20G"),
|
||||
"26.04": ("resolute", "ubuntu26.04", 3072, "20G"),
|
||||
}
|
||||
DEBIAN_VERSIONS: dict[str, tuple[str, str, int, str]] = {
|
||||
"11": ("bullseye", "debian11", 1024, "10G"),
|
||||
"12": ("bookworm", "debian12", 1024, "10G"),
|
||||
"13": ("trixie", "debian13", 1024, "10G"),
|
||||
"11": ("bullseye", "debian11", 1024, "20G"),
|
||||
"12": ("bookworm", "debian12", 1024, "20G"),
|
||||
"13": ("trixie", "debian13", 1024, "20G"),
|
||||
}
|
||||
FEDORA_VERSIONS: dict[str, tuple[str, str, int, str]] = {
|
||||
"41": ("41", "fedora41", 2048, "15G"),
|
||||
"42": ("42", "fedora42", 2048, "15G"),
|
||||
"43": ("43", "fedora43", 2048, "15G"),
|
||||
"44": ("44", "fedora44", 2048, "15G"),
|
||||
"41": ("41", "fedora41", 2048, "20G"),
|
||||
"42": ("42", "fedora42", 2048, "20G"),
|
||||
"43": ("43", "fedora43", 2048, "20G"),
|
||||
"44": ("44", "fedora44", 2048, "20G"),
|
||||
}
|
||||
# Arch est en rolling release : une seule « version » (latest).
|
||||
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).
|
||||
|
|
|
|||
|
|
@ -44,6 +44,34 @@ def session_dir() -> Path:
|
|||
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:
|
||||
"""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."""
|
||||
|
|
|
|||
|
|
@ -1087,6 +1087,11 @@ class TODO:
|
|||
"Test a VM (open Odoo in a CLI browser)"
|
||||
)
|
||||
},
|
||||
{
|
||||
"prompt_description": t(
|
||||
"Reopen install monitoring (last run / history)"
|
||||
)
|
||||
},
|
||||
{"section": t("Catalog")},
|
||||
{"prompt_description": t("List available images and specs")},
|
||||
]
|
||||
|
|
@ -1121,6 +1126,8 @@ class TODO:
|
|||
elif status == "10":
|
||||
self._qemu_test_vm()
|
||||
elif status == "11":
|
||||
self._qemu_reopen_monitor()
|
||||
elif status == "12":
|
||||
self._qemu_list_images()
|
||||
else:
|
||||
cmd_no_found = True
|
||||
|
|
@ -1383,6 +1390,46 @@ class TODO:
|
|||
)
|
||||
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):
|
||||
"""Offre la LISTE des navigateurs CLI installés, plus une option pour
|
||||
en INSTALLER un autre, et renvoie celui choisi, sinon None."""
|
||||
|
|
@ -1652,12 +1699,8 @@ class TODO:
|
|||
):
|
||||
print(t("Cancelled."))
|
||||
return
|
||||
# Réduction SÛRE via virt-resize (réduit FS + partition + GPT).
|
||||
# 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.)
|
||||
# Réduction SÛRE (qemu-nbd : réduit FS + partition + GPT, avec
|
||||
# sauvegarde optionnelle restaurée en cas d'échec).
|
||||
if not self._qemu_safe_shrink(name, disk, new_gb):
|
||||
self._qemu_offer_start(name, was_shut_down)
|
||||
return
|
||||
|
|
@ -1683,9 +1726,24 @@ class TODO:
|
|||
):
|
||||
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)
|
||||
|
||||
# 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):
|
||||
"""Si la VM a été éteinte pour l'opération, le noter et proposer de la
|
||||
redémarrer (sinon ne rien demander)."""
|
||||
|
|
@ -1699,97 +1757,301 @@ 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")
|
||||
"""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
|
||||
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): "))
|
||||
):
|
||||
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."))
|
||||
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
|
||||
part = self._qemu_largest_partition(disk)
|
||||
if not part:
|
||||
print(t("Could not detect the partition to shrink; aborting."))
|
||||
return False
|
||||
new_disk = f"{disk}.resized"
|
||||
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)
|
||||
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
|
||||
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:
|
||||
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, 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
|
||||
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.
|
||||
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:
|
||||
res = subprocess.run(
|
||||
["sudo", "virt-filesystems", "-a", disk, "--partitions",
|
||||
"--long", "-b"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=180,
|
||||
["lsblk", "-Pbno", "NAME,SIZE,TYPE,FSTYPE", 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/"):
|
||||
d = dict(re.findall(r'(\w+)="([^"]*)"', line))
|
||||
if d.get("TYPE") != "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
|
||||
)
|
||||
try:
|
||||
size = int(d.get("SIZE") or 0)
|
||||
except ValueError:
|
||||
size = 0
|
||||
if size > best_sz:
|
||||
best, best_sz = parts[0], size
|
||||
return best
|
||||
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:
|
||||
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):
|
||||
"""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.
|
||||
|
|
@ -2128,6 +2390,9 @@ class TODO:
|
|||
stem = re.sub(r"(_VARS)?\.fd$", "", os.path.basename(path))
|
||||
if stem not in domains:
|
||||
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:
|
||||
total = sum(o[0] for o in orphans)
|
||||
print(f"\n{t('Orphan files:')}")
|
||||
|
|
|
|||
|
|
@ -1440,6 +1440,102 @@ 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…",
|
||||
},
|
||||
"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": {
|
||||
"fr": "Réduction du FS invité + 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)",
|
||||
"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...": {
|
||||
"fr": "Résolution de l'IP de la VM…",
|
||||
"en": "Resolving VM IP...",
|
||||
|
|
|
|||
Loading…
Reference in a new issue