Compare commits
4 commits
8ce4b93292
...
fba3b8aed1
| Author | SHA1 | Date | |
|---|---|---|---|
| fba3b8aed1 | |||
| a9ef5a0e8e | |||
| 96cc39431c | |||
| 79a07ac577 |
4 changed files with 692 additions and 214 deletions
|
|
@ -47,6 +47,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import getpass
|
import getpass
|
||||||
|
import grp
|
||||||
import hashlib
|
import hashlib
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
|
@ -148,6 +149,7 @@ def host_arch() -> str:
|
||||||
"s390x": "s390x",
|
"s390x": "s390x",
|
||||||
}.get(machine, "amd64")
|
}.get(machine, "amd64")
|
||||||
|
|
||||||
|
|
||||||
ARCH_CLOUD_BASE = "https://geo.mirror.pkgbuild.com/images/latest"
|
ARCH_CLOUD_BASE = "https://geo.mirror.pkgbuild.com/images/latest"
|
||||||
|
|
||||||
CLOUD_IMG_BASE = "https://cloud-images.ubuntu.com"
|
CLOUD_IMG_BASE = "https://cloud-images.ubuntu.com"
|
||||||
|
|
@ -237,7 +239,9 @@ def resolve_fedora_url(version: str, arch: str, dry_run: bool) -> str:
|
||||||
for base in bases:
|
for base in bases:
|
||||||
index = f"{base}/{version}/Cloud/{a}/images/"
|
index = f"{base}/{version}/Cloud/{a}/images/"
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(index, timeout=30) as resp: # noqa: S310
|
with urllib.request.urlopen(
|
||||||
|
index, timeout=30
|
||||||
|
) as resp: # noqa: S310
|
||||||
html = resp.read().decode(errors="replace")
|
html = resp.read().decode(errors="replace")
|
||||||
except Exception as exc: # pragma: no cover - dépend du réseau
|
except Exception as exc: # pragma: no cover - dépend du réseau
|
||||||
last_err = str(exc)
|
last_err = str(exc)
|
||||||
|
|
@ -377,6 +381,14 @@ def need_tool(name: str) -> None:
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# Outils indispensables au déploiement complet (le mode --download-only n'en
|
# Outils indispensables au déploiement complet (le mode --download-only n'en
|
||||||
# requiert aucun).
|
# requiert aucun).
|
||||||
|
# URI libvirt visée par TOUS les clients (virsh, virt-install). À ne jamais
|
||||||
|
# laisser implicite : pour un utilisateur non root, libvirt choisit
|
||||||
|
# « qemu:///session », où le réseau « default » N'EXISTE PAS — virt-install
|
||||||
|
# échoue alors sur « --network network=default » de façon incompréhensible.
|
||||||
|
# Appartenir au groupe libvirt donne le DROIT d'accéder à qemu:///system mais
|
||||||
|
# ne change PAS l'URI par défaut : il faut l'imposer.
|
||||||
|
LIBVIRT_URI = "qemu:///system"
|
||||||
|
|
||||||
REQUIRED_TOOLS: tuple[str, ...] = ("qemu-img", "virt-install", "virsh")
|
REQUIRED_TOOLS: tuple[str, ...] = ("qemu-img", "virt-install", "virsh")
|
||||||
# Pour construire le seed cloud-init il faut AU MOINS un de ces outils.
|
# Pour construire le seed cloud-init il faut AU MOINS un de ces outils.
|
||||||
SEED_TOOLS: tuple[str, ...] = ("cloud-localds", "genisoimage")
|
SEED_TOOLS: tuple[str, ...] = ("cloud-localds", "genisoimage")
|
||||||
|
|
@ -613,11 +625,142 @@ def ensure_libvirt_service(runner: Runner) -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def ensure_tools(runner: Runner, assume_yes: bool, no_install: bool) -> None:
|
def invoking_user() -> str:
|
||||||
|
"""Utilisateur réel derrière l'appel, même sous sudo."""
|
||||||
|
return os.environ.get("SUDO_USER") or getpass.getuser()
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_libvirt_group(runner: Runner) -> bool:
|
||||||
|
"""Ajoute l'utilisateur au groupe libvirt. Renvoie True s'il l'était déjà.
|
||||||
|
|
||||||
|
SANS ce groupe, les clients libvirt d'un utilisateur non root retombent sur
|
||||||
|
« qemu:///session », où le réseau « default » N'EXISTE PAS : virt-install
|
||||||
|
échoue alors sur « --network network=default » alors que tous les paquets
|
||||||
|
sont pourtant installés. C'est le trou qui manquait au profil
|
||||||
|
« ERPLibre Déploiement » : il installait QEMU sans jamais rendre
|
||||||
|
qemu:///system accessible.
|
||||||
|
"""
|
||||||
|
user = invoking_user()
|
||||||
|
if user == "root":
|
||||||
|
return True
|
||||||
|
lst_group = []
|
||||||
|
for group in ("libvirt", "kvm"):
|
||||||
|
try:
|
||||||
|
grp.getgrnam(group)
|
||||||
|
except KeyError:
|
||||||
|
continue # groupe absent sur cette distro
|
||||||
|
try:
|
||||||
|
if user in grp.getgrnam(group).gr_mem:
|
||||||
|
continue
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
lst_group.append(group)
|
||||||
|
if not lst_group:
|
||||||
|
print(f" Utilisateur « {user} » déjà dans les groupes libvirt/kvm.")
|
||||||
|
return True
|
||||||
|
for group in lst_group:
|
||||||
|
print(f" Ajout de « {user} » au groupe « {group} »…")
|
||||||
|
runner.run(
|
||||||
|
["usermod", "-aG", group, user], privileged=True, check=False
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def kernel_modules_stale() -> str:
|
||||||
|
"""Renvoie un message si les modules du noyau EN COURS ont disparu.
|
||||||
|
|
||||||
|
Sur une distro roulante, « make install_os » met le noyau à jour et le
|
||||||
|
gestionnaire de paquets SUPPRIME /lib/modules/<noyau en cours>. Tant qu'on
|
||||||
|
n'a pas redémarré, « modprobe bridge » échoue et libvirt ne peut pas créer
|
||||||
|
virbr0 : « Unable to create bridge virbr0: Package not installed ». Aucun
|
||||||
|
paquet ne corrige ça, seul un redémarrage le fait — d'où ce diagnostic.
|
||||||
|
"""
|
||||||
|
if not shutil.which("uname"):
|
||||||
|
return ""
|
||||||
|
running = os.uname().release
|
||||||
|
path_module = f"/lib/modules/{running}"
|
||||||
|
if os.path.isdir(path_module):
|
||||||
|
return ""
|
||||||
|
return (
|
||||||
|
f"Le noyau en cours ({running}) n'a plus ses modules "
|
||||||
|
f"({path_module} absent) : il a été mis à jour depuis le démarrage.\n"
|
||||||
|
" libvirt ne pourra pas créer le pont virbr0 tant que la machine\n"
|
||||||
|
" n'aura pas REDÉMARRÉ. Redémarrez, puis relancez --setup-host."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def setup_host(runner: Runner, assume_yes: bool, no_install: bool) -> None:
|
||||||
|
"""Prépare l'hôte à faire tourner des VM : paquets, démon, groupe, réseau.
|
||||||
|
|
||||||
|
Point d'entrée unique du profil d'installation « ERPLibre Déploiement ».
|
||||||
|
Il réutilise les mêmes fonctions que le déploiement, donc les noms de
|
||||||
|
paquets restent définis à UN SEUL endroit (TOOL_PACKAGES /
|
||||||
|
DAEMON_PACKAGES) et restent valides pour apt, dnf, pacman, zypper et brew.
|
||||||
|
"""
|
||||||
|
print("\n== Préparation de l'hôte pour QEMU/libvirt ==")
|
||||||
|
# ensure_tools installe les clients ET, si le démon manque, les paquets de
|
||||||
|
# DAEMON_PACKAGES (qemu système + libvirt + firmware UEFI), puis démarre le
|
||||||
|
# service. ensure_emulator ne sert QU'À l'émulation croisée (arm64 sur x86)
|
||||||
|
# et n'a pas de clé pour l'architecture hôte.
|
||||||
|
ensure_tools(runner, assume_yes, no_install, force_daemon=True)
|
||||||
|
ensure_libvirt_service(runner)
|
||||||
|
already_in_group = ensure_libvirt_group(runner)
|
||||||
|
# Diagnostiqué AVANT le réseau : sans les modules du noyau en cours, le
|
||||||
|
# pont virbr0 est impossible et l'erreur de virsh est indéchiffrable.
|
||||||
|
stale = kernel_modules_stale()
|
||||||
|
if stale:
|
||||||
|
print(f"\n⚠ {stale}")
|
||||||
|
ensure_network("default", runner)
|
||||||
|
|
||||||
|
if runner.dry_run:
|
||||||
|
print("\n[dry-run] Rien n'a été modifié, vérification ignorée.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("\n== Vérification ==")
|
||||||
|
ok = libvirt_ready(runner.use_sudo)
|
||||||
|
print(f" hyperviseur qemu:///system : {'OK' if ok else 'INJOIGNABLE'}")
|
||||||
|
active, autostart = network_state("default", runner.use_sudo)
|
||||||
|
print(
|
||||||
|
f" réseau libvirt « default » : "
|
||||||
|
f"{'actif' if active else 'INACTIF'}"
|
||||||
|
f" / {'autostart' if autostart else 'PAS autostart'}"
|
||||||
|
)
|
||||||
|
if not (ok and active):
|
||||||
|
sys.exit(
|
||||||
|
"Erreur : l'hôte n'est pas prêt.\n"
|
||||||
|
+ (f" {stale}\n" if stale else "")
|
||||||
|
+ " Sinon vérifiez :\n"
|
||||||
|
" sudo systemctl enable --now libvirtd\n"
|
||||||
|
" sudo virsh -c qemu:///system net-start default"
|
||||||
|
)
|
||||||
|
if not already_in_group:
|
||||||
|
print(
|
||||||
|
f"\n⚠ « {invoking_user()} » vient d'être ajouté au groupe libvirt."
|
||||||
|
" Les groupes ne s'appliquent qu'aux NOUVELLES sessions :"
|
||||||
|
" reconnectez-vous (ou « newgrp libvirt »), sinon virt-install"
|
||||||
|
" continuera d'utiliser qemu:///session et le réseau « default »"
|
||||||
|
" restera introuvable."
|
||||||
|
)
|
||||||
|
print("\nHôte prêt.")
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_tools(
|
||||||
|
runner: Runner,
|
||||||
|
assume_yes: bool,
|
||||||
|
no_install: bool,
|
||||||
|
force_daemon: bool = False,
|
||||||
|
) -> None:
|
||||||
"""Vérifie outils, démon libvirt et émulateur ; installe/démarre ce qui
|
"""Vérifie outils, démon libvirt et émulateur ; installe/démarre ce qui
|
||||||
manque, puis vérifie la connexion à l'hyperviseur."""
|
manque, puis vérifie la connexion à l'hyperviseur.
|
||||||
|
|
||||||
|
`force_daemon` réinstalle DAEMON_PACKAGES même quand le démon répond déjà.
|
||||||
|
Vécu sur Arch : libvirt était présent (posé par un ancien one-liner qui ne
|
||||||
|
listait pas dnsmasq), donc daemon_missing() renvoyait False et dnsmasq
|
||||||
|
n'était jamais installé -> « Failed to start network default ». Les
|
||||||
|
gestionnaires de paquets ignorent ce qui est déjà là : c'est bon marché.
|
||||||
|
"""
|
||||||
missing = missing_tools()
|
missing = missing_tools()
|
||||||
need_daemon = daemon_missing()
|
need_daemon = daemon_missing() or force_daemon
|
||||||
|
|
||||||
# Tout est là et l'hyperviseur répond : rien à faire.
|
# Tout est là et l'hyperviseur répond : rien à faire.
|
||||||
if not missing and not need_daemon and libvirt_ready(runner.use_sudo):
|
if not missing and not need_daemon and libvirt_ready(runner.use_sudo):
|
||||||
|
|
@ -1138,7 +1281,13 @@ def network_name(network_arg: str) -> str | None:
|
||||||
|
|
||||||
def network_state(name: str, use_sudo: bool) -> tuple[bool, bool]:
|
def network_state(name: str, use_sudo: bool) -> tuple[bool, bool]:
|
||||||
"""(actif, autostart) d'un réseau libvirt, via « virsh net-info »."""
|
"""(actif, autostart) d'un réseau libvirt, via « virsh net-info »."""
|
||||||
cmd = (["sudo"] if use_sudo else []) + ["virsh", "net-info", name]
|
cmd = (["sudo"] if use_sudo else []) + [
|
||||||
|
"virsh",
|
||||||
|
"-c",
|
||||||
|
LIBVIRT_URI,
|
||||||
|
"net-info",
|
||||||
|
name,
|
||||||
|
]
|
||||||
try:
|
try:
|
||||||
res = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
|
res = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
|
||||||
except (OSError, subprocess.SubprocessError):
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
|
@ -1156,9 +1305,15 @@ def ensure_network(name: str | None, runner: Runner) -> None:
|
||||||
return
|
return
|
||||||
if runner.dry_run:
|
if runner.dry_run:
|
||||||
print(f" [dry-run] réseau libvirt '{name}' activé si nécessaire")
|
print(f" [dry-run] réseau libvirt '{name}' activé si nécessaire")
|
||||||
runner.run(["virsh", "net-start", name], privileged=True, check=False)
|
|
||||||
runner.run(
|
runner.run(
|
||||||
["virsh", "net-autostart", name], privileged=True, check=False
|
["virsh", "-c", LIBVIRT_URI, "net-start", name],
|
||||||
|
privileged=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
runner.run(
|
||||||
|
["virsh", "-c", LIBVIRT_URI, "net-autostart", name],
|
||||||
|
privileged=True,
|
||||||
|
check=False,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
active, autostart = network_state(name, runner.use_sudo)
|
active, autostart = network_state(name, runner.use_sudo)
|
||||||
|
|
@ -1167,10 +1322,16 @@ def ensure_network(name: str | None, runner: Runner) -> None:
|
||||||
return
|
return
|
||||||
print(f" Configuration du réseau libvirt '{name}'…")
|
print(f" Configuration du réseau libvirt '{name}'…")
|
||||||
if not active:
|
if not active:
|
||||||
runner.run(["virsh", "net-start", name], privileged=True, check=False)
|
runner.run(
|
||||||
|
["virsh", "-c", LIBVIRT_URI, "net-start", name],
|
||||||
|
privileged=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
if not autostart:
|
if not autostart:
|
||||||
runner.run(
|
runner.run(
|
||||||
["virsh", "net-autostart", name], privileged=True, check=False
|
["virsh", "-c", LIBVIRT_URI, "net-autostart", name],
|
||||||
|
privileged=True,
|
||||||
|
check=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1188,6 +1349,11 @@ def virt_install(
|
||||||
console_target = "sclp" if args.arch == "s390x" else "serial"
|
console_target = "sclp" if args.arch == "s390x" else "serial"
|
||||||
cmd = [
|
cmd = [
|
||||||
"virt-install",
|
"virt-install",
|
||||||
|
# Sans --connect, un utilisateur non root vise qemu:///session : le
|
||||||
|
# réseau « default » n'y existe pas et la création échoue même quand
|
||||||
|
# tous les paquets sont installés et le réseau actif côté système.
|
||||||
|
"--connect",
|
||||||
|
LIBVIRT_URI,
|
||||||
"--name",
|
"--name",
|
||||||
args.name,
|
args.name,
|
||||||
"--memory",
|
"--memory",
|
||||||
|
|
@ -1284,7 +1450,8 @@ def wait_for_ip(name: str, use_sudo: bool, timeout: int) -> str | None:
|
||||||
peut avoir PLUSIEURS baux (l'image demande d'abord une IP avec le hostname
|
peut avoir PLUSIEURS baux (l'image demande d'abord une IP avec le hostname
|
||||||
par défaut « ubuntu », puis cloud-init fixe le vrai hostname -> 2e bail) :
|
par défaut « ubuntu », puis cloud-init fixe le vrai hostname -> 2e bail) :
|
||||||
on renvoie une IP JOIGNABLE (sshd up), sinon la plus récente, jamais
|
on renvoie une IP JOIGNABLE (sshd up), sinon la plus récente, jamais
|
||||||
aveuglément la 1re (souvent le bail précoce périmé, « No route to host »)."""
|
aveuglément la 1re (souvent le bail précoce périmé, « No route to host »).
|
||||||
|
"""
|
||||||
base = (["sudo"] if use_sudo else []) + [
|
base = (["sudo"] if use_sudo else []) + [
|
||||||
"virsh",
|
"virsh",
|
||||||
"domifaddr",
|
"domifaddr",
|
||||||
|
|
@ -1544,6 +1711,12 @@ def build_parser() -> argparse.ArgumentParser:
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="N'installe jamais les dépendances manquantes (échoue si absentes).",
|
help="N'installe jamais les dépendances manquantes (échoue si absentes).",
|
||||||
)
|
)
|
||||||
|
g_run.add_argument(
|
||||||
|
"--setup-host",
|
||||||
|
action="store_true",
|
||||||
|
help="Prépare l'hôte (paquets QEMU/libvirt, démon, groupe libvirt, "
|
||||||
|
"réseau default) puis quitte. Ne déploie aucune VM.",
|
||||||
|
)
|
||||||
g_run.add_argument(
|
g_run.add_argument(
|
||||||
"--list-images",
|
"--list-images",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
|
|
@ -1590,6 +1763,19 @@ def main() -> None:
|
||||||
list_images()
|
list_images()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Préparation de l'hôte : aucune image, aucune distro cible, aucun --name.
|
||||||
|
# Traité AVANT la résolution de version/image pour rester utilisable seul.
|
||||||
|
if args.setup_host:
|
||||||
|
setup_host(
|
||||||
|
Runner(
|
||||||
|
use_sudo=not args.no_sudo and os.geteuid() != 0,
|
||||||
|
dry_run=args.dry_run,
|
||||||
|
),
|
||||||
|
args.assume_yes,
|
||||||
|
args.no_install_deps,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
versions, default_version = DISTROS[args.distro]
|
versions, default_version = DISTROS[args.distro]
|
||||||
if args.version is None:
|
if args.version is None:
|
||||||
args.version = default_version
|
args.version = default_version
|
||||||
|
|
|
||||||
|
|
@ -614,9 +614,7 @@ class TODO:
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f"{t('Command failed: ')}{exc}")
|
print(f"{t('Command failed: ')}{exc}")
|
||||||
# Revenir (curseur restauré) ou quitter ?
|
# Revenir (curseur restauré) ou quitter ?
|
||||||
ans = input(
|
ans = input(f"\n{t('Back to telemetry (r) or quit (Enter)? ')}")
|
||||||
f"\n{t('Back to telemetry (r) or quit (Enter)? ')}"
|
|
||||||
)
|
|
||||||
if ans.strip().lower() not in ("r", "revenir", "o", "oui", "y"):
|
if ans.strip().lower() not in ("r", "revenir", "o", "oui", "y"):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -1212,14 +1210,10 @@ class TODO:
|
||||||
return
|
return
|
||||||
# DOUBLE validation avant d'appliquer.
|
# DOUBLE validation avant d'appliquer.
|
||||||
summary = f"{verb} -> {', '.join(resolved)}"
|
summary = f"{verb} -> {', '.join(resolved)}"
|
||||||
if not self._is_yes(
|
if not self._is_yes(input(f"{t('Apply:')} {summary} ? (o/N) : ")):
|
||||||
input(f"{t('Apply:')} {summary} ? (o/N) : ")
|
|
||||||
):
|
|
||||||
print(t("Cancelled."))
|
print(t("Cancelled."))
|
||||||
return
|
return
|
||||||
if not self._is_yes(
|
if not self._is_yes(input(t("Confirm for real? (y/N): "))):
|
||||||
input(t("Confirm for real? (y/N): "))
|
|
||||||
):
|
|
||||||
print(t("Cancelled."))
|
print(t("Cancelled."))
|
||||||
return
|
return
|
||||||
for real in resolved:
|
for real in resolved:
|
||||||
|
|
@ -1404,9 +1398,7 @@ class TODO:
|
||||||
f" [{i}] {r['label']} — {len(r['vms'])} VM{star}\n"
|
f" [{i}] {r['label']} — {len(r['vms'])} VM{star}\n"
|
||||||
f" {names}"
|
f" {names}"
|
||||||
)
|
)
|
||||||
sel = input(
|
sel = input(t("Choice (number, blank = last): ")).strip()
|
||||||
t("Choice (number, blank = last): ")
|
|
||||||
).strip()
|
|
||||||
run = runs[0]
|
run = runs[0]
|
||||||
if sel:
|
if sel:
|
||||||
try:
|
try:
|
||||||
|
|
@ -1544,8 +1536,10 @@ class TODO:
|
||||||
cmd = f"sudo virsh shutdown {shlex.quote(name)} --mode acpi,agent"
|
cmd = f"sudo virsh shutdown {shlex.quote(name)} --mode acpi,agent"
|
||||||
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)
|
||||||
print(f"{t('Waiting for the VM to shut down...')} "
|
print(
|
||||||
f"({t('timeout')}: {timeout} s)")
|
f"{t('Waiting for the VM to shut down...')} "
|
||||||
|
f"({t('timeout')}: {timeout} s)"
|
||||||
|
)
|
||||||
deadline = time.time() + timeout
|
deadline = time.time() + timeout
|
||||||
while time.time() < deadline:
|
while time.time() < deadline:
|
||||||
if self._qemu_domstate(name) == "shut off":
|
if self._qemu_domstate(name) == "shut off":
|
||||||
|
|
@ -1553,15 +1547,22 @@ class TODO:
|
||||||
print(f"\r{' ' * 40}\r✅ {name}: {t('VM is off.')}")
|
print(f"\r{' ' * 40}\r✅ {name}: {t('VM is off.')}")
|
||||||
return True
|
return True
|
||||||
remaining = int(deadline - time.time())
|
remaining = int(deadline - time.time())
|
||||||
print(f"\r ⏳ {t('shutting down')}… "
|
print(
|
||||||
f"{remaining:>3d} s {t('remaining')}",
|
f"\r ⏳ {t('shutting down')}… "
|
||||||
end="", flush=True)
|
f"{remaining:>3d} s {t('remaining')}",
|
||||||
|
end="",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
time.sleep(2)
|
time.sleep(2)
|
||||||
print() # newline après le compte à rebours
|
print() # newline après le compte à rebours
|
||||||
# Arrêt gracieux trop long : proposer un arrêt forcé.
|
# Arrêt gracieux trop long : proposer un arrêt forcé.
|
||||||
if self._is_yes(
|
if self._is_yes(
|
||||||
input(t("Graceful shutdown timed out. Force off (destroy)? "
|
input(
|
||||||
"(y/N): "))
|
t(
|
||||||
|
"Graceful shutdown timed out. Force off (destroy)? "
|
||||||
|
"(y/N): "
|
||||||
|
)
|
||||||
|
)
|
||||||
):
|
):
|
||||||
cmd = f"sudo virsh destroy {shlex.quote(name)}"
|
cmd = f"sudo virsh destroy {shlex.quote(name)}"
|
||||||
print(f"{t('Will execute:')} {cmd}")
|
print(f"{t('Will execute:')} {cmd}")
|
||||||
|
|
@ -1695,25 +1696,28 @@ class TODO:
|
||||||
print(t("No change."))
|
print(t("No change."))
|
||||||
return
|
return
|
||||||
shrink = new_gb < cur_gb
|
shrink = new_gb < cur_gb
|
||||||
print(
|
print(f"\n{t('New virtual size:')} {cur_gb:.1f} G -> {new_gb:.1f} G")
|
||||||
f"\n{t('New virtual size:')} {cur_gb:.1f} G -> {new_gb:.1f} G"
|
|
||||||
)
|
|
||||||
# Avertissement (NON bloquant) : agrandir au-delà de ce que l'hôte
|
# Avertissement (NON bloquant) : agrandir au-delà de ce que l'hôte
|
||||||
# peut soutenir -> surallocation, l'hôte se remplira si la VM utilise
|
# peut soutenir -> surallocation, l'hôte se remplira si la VM utilise
|
||||||
# tout l'espace.
|
# tout l'espace.
|
||||||
if not shrink and max_safe_gb and new_gb > max_safe_gb:
|
if not shrink and max_safe_gb and new_gb > max_safe_gb:
|
||||||
over = new_gb - max_safe_gb
|
over = new_gb - max_safe_gb
|
||||||
msg1 = t("Beyond host capacity by ~%.1f G — overcommit.") % over
|
msg1 = t("Beyond host capacity by ~%.1f G — overcommit.") % over
|
||||||
msg2 = t(
|
msg2 = (
|
||||||
"The qcow2 is thin: fine until the VM fills it, then the "
|
t(
|
||||||
"host disk runs out. Max sustainable: ~%.1f G."
|
"The qcow2 is thin: fine until the VM fills it, then the "
|
||||||
) % max_safe_gb
|
"host disk runs out. Max sustainable: ~%.1f G."
|
||||||
|
)
|
||||||
|
% max_safe_gb
|
||||||
|
)
|
||||||
print(f"⚠ {msg1}")
|
print(f"⚠ {msg1}")
|
||||||
print(f" {msg2}")
|
print(f" {msg2}")
|
||||||
|
|
||||||
# 3) Application selon agrandir/réduire et l'état de la VM.
|
# 3) Application selon agrandir/réduire et l'état de la VM.
|
||||||
was_shut_down = False # la VM a-t-elle été éteinte pour l'occasion ?
|
was_shut_down = False # la VM a-t-elle été éteinte pour l'occasion ?
|
||||||
cmd = None # commande d'AGRANDISSEMENT (la réduction a son propre flux)
|
cmd = (
|
||||||
|
None # commande d'AGRANDISSEMENT (la réduction a son propre flux)
|
||||||
|
)
|
||||||
if shrink:
|
if shrink:
|
||||||
# DANGER : qcow2 --shrink ne réduit PAS le FS invité -> perte de
|
# DANGER : qcow2 --shrink ne réduit PAS le FS invité -> perte de
|
||||||
# données si le FS dépasse la cible. VM éteinte obligatoire.
|
# données si le FS dépasse la cible. VM éteinte obligatoire.
|
||||||
|
|
@ -1725,8 +1729,12 @@ class TODO:
|
||||||
print(f"⚠ {danger}")
|
print(f"⚠ {danger}")
|
||||||
if state != "shut off":
|
if state != "shut off":
|
||||||
if not self._is_yes(
|
if not self._is_yes(
|
||||||
input(t("The VM must be off. Shut it down and retry? "
|
input(
|
||||||
"(y/N): "))
|
t(
|
||||||
|
"The VM must be off. Shut it down and retry? "
|
||||||
|
"(y/N): "
|
||||||
|
)
|
||||||
|
)
|
||||||
):
|
):
|
||||||
print(t("Cancelled."))
|
print(t("Cancelled."))
|
||||||
return
|
return
|
||||||
|
|
@ -1776,9 +1784,7 @@ class TODO:
|
||||||
bak = getattr(self, "_shrink_backup", None)
|
bak = getattr(self, "_shrink_backup", None)
|
||||||
if shrink and bak and os.path.exists(bak):
|
if shrink and bak and os.path.exists(bak):
|
||||||
print(f"\n{t('A disk backup was kept:')} {bak}")
|
print(f"\n{t('A disk backup was kept:')} {bak}")
|
||||||
if self._is_yes(
|
if self._is_yes(input(t("Delete this backup now? (y/N): "))):
|
||||||
input(t("Delete this backup now? (y/N): "))
|
|
||||||
):
|
|
||||||
subprocess.run(["sudo", "rm", "-f", bak], check=False)
|
subprocess.run(["sudo", "rm", "-f", bak], check=False)
|
||||||
print(t("Backup deleted."))
|
print(t("Backup deleted."))
|
||||||
else:
|
else:
|
||||||
|
|
@ -1802,8 +1808,14 @@ class TODO:
|
||||||
# util-linux, qemu-utils) — PAS libguestfs (souvent cassé : appliance
|
# util-linux, qemu-utils) — PAS libguestfs (souvent cassé : appliance
|
||||||
# supermin sans noyau dans /boot).
|
# supermin sans noyau dans /boot).
|
||||||
_SHRINK_TOOLS = (
|
_SHRINK_TOOLS = (
|
||||||
"qemu-nbd", "e2fsck", "resize2fs", "sgdisk", "partprobe",
|
"qemu-nbd",
|
||||||
"lsblk", "dumpe2fs", "blockdev",
|
"e2fsck",
|
||||||
|
"resize2fs",
|
||||||
|
"sgdisk",
|
||||||
|
"partprobe",
|
||||||
|
"lsblk",
|
||||||
|
"dumpe2fs",
|
||||||
|
"blockdev",
|
||||||
)
|
)
|
||||||
_SECT = 512
|
_SECT = 512
|
||||||
_MiB = 1024 * 1024
|
_MiB = 1024 * 1024
|
||||||
|
|
@ -1832,13 +1844,25 @@ class TODO:
|
||||||
):
|
):
|
||||||
bak = f"{disk}.bak"
|
bak = f"{disk}.bak"
|
||||||
print(f"\n{t('Backing up the disk before shrinking…')}")
|
print(f"\n{t('Backing up the disk before shrinking…')}")
|
||||||
if subprocess.run(
|
if (
|
||||||
["sudo", "cp", "--reflink=auto", "--sparse=always", disk, bak]
|
subprocess.run(
|
||||||
).returncode != 0:
|
[
|
||||||
|
"sudo",
|
||||||
|
"cp",
|
||||||
|
"--reflink=auto",
|
||||||
|
"--sparse=always",
|
||||||
|
disk,
|
||||||
|
bak,
|
||||||
|
]
|
||||||
|
).returncode
|
||||||
|
!= 0
|
||||||
|
):
|
||||||
print(t("Backup failed; aborting."))
|
print(t("Backup failed; aborting."))
|
||||||
return False
|
return False
|
||||||
else:
|
else:
|
||||||
print(f"⚠ {t('No backup: a failure could leave the disk broken.')}")
|
print(
|
||||||
|
f"⚠ {t('No backup: a failure could leave the disk broken.')}"
|
||||||
|
)
|
||||||
subprocess.run(["sudo", "modprobe", "nbd", "max_part=16"], check=False)
|
subprocess.run(["sudo", "modprobe", "nbd", "max_part=16"], check=False)
|
||||||
dev = None
|
dev = None
|
||||||
try:
|
try:
|
||||||
|
|
@ -1876,14 +1900,19 @@ class TODO:
|
||||||
f"\n{t('Shrinking guest ext filesystem')} {part} "
|
f"\n{t('Shrinking guest ext filesystem')} {part} "
|
||||||
f"-> {fs_target_mib} MiB…"
|
f"-> {fs_target_mib} MiB…"
|
||||||
)
|
)
|
||||||
if subprocess.run(
|
if (
|
||||||
["sudo", "resize2fs", part, f"{fs_target_mib}M"]
|
subprocess.run(
|
||||||
).returncode != 0:
|
["sudo", "resize2fs", part, f"{fs_target_mib}M"]
|
||||||
|
).returncode
|
||||||
|
!= 0
|
||||||
|
):
|
||||||
print(t("resize2fs failed; reverting."))
|
print(t("resize2fs failed; reverting."))
|
||||||
return self._qemu_shrink_revert(bak, disk, changed=True)
|
return self._qemu_shrink_revert(bak, disk, changed=True)
|
||||||
# Fin de partition = début + taille RÉELLE du FS + 1 Mio, alignée.
|
# Fin de partition = début + taille RÉELLE du FS + 1 Mio, alignée.
|
||||||
fs_bytes = self._qemu_fs_blocks(part) * bs
|
fs_bytes = self._qemu_fs_blocks(part) * bs
|
||||||
new_end = start + int(math.ceil((fs_bytes + self._MiB) / self._SECT))
|
new_end = start + int(
|
||||||
|
math.ceil((fs_bytes + self._MiB) / self._SECT)
|
||||||
|
)
|
||||||
new_end = ((new_end + 2047) // 2048) * 2048 - 1 # align 2048
|
new_end = ((new_end + 2047) // 2048) * 2048 - 1 # align 2048
|
||||||
if (new_end + 34) * self._SECT > target:
|
if (new_end + 34) * self._SECT > target:
|
||||||
print(t("Internal size check failed; reverting."))
|
print(t("Internal size check failed; reverting."))
|
||||||
|
|
@ -1892,9 +1921,19 @@ class TODO:
|
||||||
print(f"{t('Shrinking the partition…')} ({part})")
|
print(f"{t('Shrinking the partition…')} ({part})")
|
||||||
subprocess.run(["sudo", "sgdisk", "-d", n, dev], check=False)
|
subprocess.run(["sudo", "sgdisk", "-d", n, dev], check=False)
|
||||||
rc = subprocess.run(
|
rc = subprocess.run(
|
||||||
["sudo", "sgdisk", "-n", f"{n}:{start}:{new_end}",
|
[
|
||||||
"-t", f"{n}:{info['type']}", "-u", f"{n}:{info['uuid']}",
|
"sudo",
|
||||||
"-c", f"{n}:{info['name']}", dev]
|
"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(t("Partition rewrite failed; reverting."))
|
print(t("Partition rewrite failed; reverting."))
|
||||||
|
|
@ -1906,10 +1945,19 @@ class TODO:
|
||||||
self._qemu_nbd_disconnect(dev)
|
self._qemu_nbd_disconnect(dev)
|
||||||
dev = None
|
dev = None
|
||||||
print(f"{t('Shrinking the qcow2 container…')} {new_gb:g}G")
|
print(f"{t('Shrinking the qcow2 container…')} {new_gb:g}G")
|
||||||
if subprocess.run(
|
if (
|
||||||
["sudo", "qemu-img", "resize", "--shrink", disk,
|
subprocess.run(
|
||||||
f"{new_gb:g}G"]
|
[
|
||||||
).returncode != 0:
|
"sudo",
|
||||||
|
"qemu-img",
|
||||||
|
"resize",
|
||||||
|
"--shrink",
|
||||||
|
disk,
|
||||||
|
f"{new_gb:g}G",
|
||||||
|
]
|
||||||
|
).returncode
|
||||||
|
!= 0
|
||||||
|
):
|
||||||
print(t("Container shrink failed; reverting."))
|
print(t("Container shrink failed; reverting."))
|
||||||
return self._qemu_shrink_revert(bak, disk, changed=True)
|
return self._qemu_shrink_revert(bak, disk, changed=True)
|
||||||
# Répare la GPT de secours (fin du disque) + fsck final.
|
# Répare la GPT de secours (fin du disque) + fsck final.
|
||||||
|
|
@ -1917,8 +1965,10 @@ class TODO:
|
||||||
if dev:
|
if dev:
|
||||||
subprocess.run(["sudo", "sgdisk", "-e", dev], check=False)
|
subprocess.run(["sudo", "sgdisk", "-e", dev], check=False)
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["sudo", "partprobe", dev], check=False, capture_output=True
|
["sudo", "partprobe", dev],
|
||||||
)
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
p2 = self._qemu_root_part(dev)[0]
|
p2 = self._qemu_root_part(dev)[0]
|
||||||
if p2:
|
if p2:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
|
|
@ -1928,9 +1978,7 @@ class TODO:
|
||||||
dev = None
|
dev = None
|
||||||
self._shrink_backup = bak # proposé à la suppression après le boot
|
self._shrink_backup = bak # proposé à la suppression après le boot
|
||||||
if bak:
|
if bak:
|
||||||
print(
|
print(f"✅ {t('Disk safely shrunk. Backup kept at:')} {bak}")
|
||||||
f"✅ {t('Disk safely shrunk. Backup kept at:')} {bak}"
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
print(f"✅ {t('Disk safely shrunk.')}")
|
print(f"✅ {t('Disk safely shrunk.')}")
|
||||||
return True
|
return True
|
||||||
|
|
@ -1965,15 +2013,18 @@ class TODO:
|
||||||
continue
|
continue
|
||||||
rc = subprocess.run(
|
rc = subprocess.run(
|
||||||
["sudo", "qemu-nbd", "-c", dev, disk],
|
["sudo", "qemu-nbd", "-c", dev, disk],
|
||||||
capture_output=True, text=True,
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
).returncode
|
).returncode
|
||||||
if rc != 0:
|
if rc != 0:
|
||||||
continue
|
continue
|
||||||
base = f"nbd{i}"
|
base = f"nbd{i}"
|
||||||
for _ in range(15):
|
for _ in range(15):
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["sudo", "partprobe", dev], check=False, capture_output=True
|
["sudo", "partprobe", dev],
|
||||||
)
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
if any(
|
if any(
|
||||||
os.path.exists(f"/sys/class/block/{base}p{n}")
|
os.path.exists(f"/sys/class/block/{base}p{n}")
|
||||||
|
|
@ -1999,7 +2050,9 @@ class TODO:
|
||||||
try:
|
try:
|
||||||
res = subprocess.run(
|
res = subprocess.run(
|
||||||
["lsblk", "-Pbno", "NAME,SIZE,TYPE,FSTYPE", dev],
|
["lsblk", "-Pbno", "NAME,SIZE,TYPE,FSTYPE", dev],
|
||||||
capture_output=True, text=True, timeout=30,
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30,
|
||||||
)
|
)
|
||||||
except (OSError, subprocess.SubprocessError):
|
except (OSError, subprocess.SubprocessError):
|
||||||
return None, 0, ""
|
return None, 0, ""
|
||||||
|
|
@ -2013,28 +2066,31 @@ class TODO:
|
||||||
except ValueError:
|
except ValueError:
|
||||||
size = 0
|
size = 0
|
||||||
if size > best_sz:
|
if size > best_sz:
|
||||||
best, best_sz, best_fs = d.get("NAME"), size, d.get("FSTYPE", "")
|
best, best_sz, best_fs = (
|
||||||
|
d.get("NAME"),
|
||||||
|
size,
|
||||||
|
d.get("FSTYPE", ""),
|
||||||
|
)
|
||||||
if not best:
|
if not best:
|
||||||
return None, 0, ""
|
return None, 0, ""
|
||||||
part = f"/dev/{best}"
|
part = f"/dev/{best}"
|
||||||
try:
|
try:
|
||||||
start = int(
|
start = int(open(f"/sys/class/block/{best}/start").read().strip())
|
||||||
open(f"/sys/class/block/{best}/start").read().strip()
|
|
||||||
)
|
|
||||||
except OSError:
|
except OSError:
|
||||||
start = 0
|
start = 0
|
||||||
if not best_fs:
|
if not best_fs:
|
||||||
# FSTYPE pas encore en cache : sonder directement avec blkid.
|
# FSTYPE pas encore en cache : sonder directement avec blkid.
|
||||||
best_fs = subprocess.run(
|
best_fs = subprocess.run(
|
||||||
["sudo", "blkid", "-o", "value", "-s", "TYPE", part],
|
["sudo", "blkid", "-o", "value", "-s", "TYPE", part],
|
||||||
capture_output=True, text=True,
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
).stdout.strip()
|
).stdout.strip()
|
||||||
return part, start, best_fs
|
return part, start, best_fs
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _qemu_part_number(dev, part):
|
def _qemu_part_number(dev, part):
|
||||||
"""Numéro de partition (ex. « 1 ») depuis /dev/nbd0p1."""
|
"""Numéro de partition (ex. « 1 ») depuis /dev/nbd0p1."""
|
||||||
return part[len(dev):].lstrip("p")
|
return part[len(dev) :].lstrip("p")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _qemu_part_info(dev, n):
|
def _qemu_part_info(dev, n):
|
||||||
|
|
@ -2042,7 +2098,9 @@ class TODO:
|
||||||
info = {"type": "", "uuid": "", "name": ""}
|
info = {"type": "", "uuid": "", "name": ""}
|
||||||
res = subprocess.run(
|
res = subprocess.run(
|
||||||
["sudo", "sgdisk", "-i", n, dev],
|
["sudo", "sgdisk", "-i", n, dev],
|
||||||
capture_output=True, text=True, env=TODO._qemu_c_env(),
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
env=TODO._qemu_c_env(),
|
||||||
)
|
)
|
||||||
for line in res.stdout.splitlines():
|
for line in res.stdout.splitlines():
|
||||||
low = line.lower()
|
low = line.lower()
|
||||||
|
|
@ -2058,7 +2116,9 @@ class TODO:
|
||||||
def _qemu_fs_blocksize(part):
|
def _qemu_fs_blocksize(part):
|
||||||
res = subprocess.run(
|
res = subprocess.run(
|
||||||
["sudo", "dumpe2fs", "-h", part],
|
["sudo", "dumpe2fs", "-h", part],
|
||||||
capture_output=True, text=True, env=TODO._qemu_c_env(),
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
env=TODO._qemu_c_env(),
|
||||||
)
|
)
|
||||||
for line in res.stdout.splitlines():
|
for line in res.stdout.splitlines():
|
||||||
if line.startswith("Block size:"):
|
if line.startswith("Block size:"):
|
||||||
|
|
@ -2072,7 +2132,9 @@ class TODO:
|
||||||
def _qemu_fs_blocks(part):
|
def _qemu_fs_blocks(part):
|
||||||
res = subprocess.run(
|
res = subprocess.run(
|
||||||
["sudo", "dumpe2fs", "-h", part],
|
["sudo", "dumpe2fs", "-h", part],
|
||||||
capture_output=True, text=True, env=TODO._qemu_c_env(),
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
env=TODO._qemu_c_env(),
|
||||||
)
|
)
|
||||||
for line in res.stdout.splitlines():
|
for line in res.stdout.splitlines():
|
||||||
if line.startswith("Block count:"):
|
if line.startswith("Block count:"):
|
||||||
|
|
@ -2087,7 +2149,9 @@ class TODO:
|
||||||
"""Taille minimale (blocs) du FS via « resize2fs -P »."""
|
"""Taille minimale (blocs) du FS via « resize2fs -P »."""
|
||||||
res = subprocess.run(
|
res = subprocess.run(
|
||||||
["sudo", "resize2fs", "-P", part],
|
["sudo", "resize2fs", "-P", part],
|
||||||
capture_output=True, text=True, env=TODO._qemu_c_env(),
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
env=TODO._qemu_c_env(),
|
||||||
)
|
)
|
||||||
for tok in res.stdout.replace(":", " ").split():
|
for tok in res.stdout.replace(":", " ").split():
|
||||||
if tok.isdigit():
|
if tok.isdigit():
|
||||||
|
|
@ -2099,15 +2163,15 @@ class TODO:
|
||||||
_GROW_FS_REMOTE = (
|
_GROW_FS_REMOTE = (
|
||||||
"set -e; "
|
"set -e; "
|
||||||
"root=$(findmnt -no SOURCE /); "
|
"root=$(findmnt -no SOURCE /); "
|
||||||
"dev=$(lsblk -no PKNAME \"$root\" | head -1); "
|
'dev=$(lsblk -no PKNAME "$root" | head -1); '
|
||||||
"part=$(echo \"$root\" | grep -oE '[0-9]+$'); "
|
"part=$(echo \"$root\" | grep -oE '[0-9]+$'); "
|
||||||
"sudo growpart /dev/$dev $part || true; "
|
"sudo growpart /dev/$dev $part || true; "
|
||||||
"fstype=$(findmnt -no FSTYPE /); "
|
"fstype=$(findmnt -no FSTYPE /); "
|
||||||
'case "$fstype" in '
|
'case "$fstype" in '
|
||||||
"ext*) sudo resize2fs \"$root\";; "
|
'ext*) sudo resize2fs "$root";; '
|
||||||
"xfs) sudo xfs_growfs /;; "
|
"xfs) sudo xfs_growfs /;; "
|
||||||
"btrfs) sudo btrfs filesystem resize max /;; "
|
"btrfs) sudo btrfs filesystem resize max /;; "
|
||||||
'esac; '
|
"esac; "
|
||||||
"df -h /"
|
"df -h /"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -2142,9 +2206,13 @@ class TODO:
|
||||||
if rc == 0:
|
if rc == 0:
|
||||||
print(f"✅ {t('Guest filesystem grown via guest agent.')}")
|
print(f"✅ {t('Guest filesystem grown via guest agent.')}")
|
||||||
return
|
return
|
||||||
print(f"⚠ {t('Guest agent grow failed; falling back to console.')}")
|
print(
|
||||||
|
f"⚠ {t('Guest agent grow failed; falling back to console.')}"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
print(t("Guest agent unavailable; falling back to serial console."))
|
print(
|
||||||
|
t("Guest agent unavailable; falling back to serial console.")
|
||||||
|
)
|
||||||
# 3) Console série (commande prête à coller, login interactif).
|
# 3) Console série (commande prête à coller, login interactif).
|
||||||
self._qemu_grow_via_console(real, remote)
|
self._qemu_grow_via_console(real, remote)
|
||||||
|
|
||||||
|
|
@ -2158,8 +2226,11 @@ class TODO:
|
||||||
try:
|
try:
|
||||||
res = subprocess.run(
|
res = subprocess.run(
|
||||||
[
|
[
|
||||||
"sudo", "virsh", "qemu-agent-command",
|
"sudo",
|
||||||
name, json.dumps(payload),
|
"virsh",
|
||||||
|
"qemu-agent-command",
|
||||||
|
name,
|
||||||
|
json.dumps(payload),
|
||||||
],
|
],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
|
|
@ -2219,9 +2290,7 @@ class TODO:
|
||||||
print(
|
print(
|
||||||
f"👤 {t('Default login (if set at deploy): erplibre / erplibre')}"
|
f"👤 {t('Default login (if set at deploy): erplibre / erplibre')}"
|
||||||
)
|
)
|
||||||
if not self._is_yes(
|
if not self._is_yes(input(t("Open the serial console now? (y/N): "))):
|
||||||
input(t("Open the serial console now? (y/N): "))
|
|
||||||
):
|
|
||||||
return
|
return
|
||||||
cmd = f"sudo virsh console {shlex.quote(name)}"
|
cmd = f"sudo virsh console {shlex.quote(name)}"
|
||||||
print(f"{t('Will execute:')} {cmd}")
|
print(f"{t('Will execute:')} {cmd}")
|
||||||
|
|
@ -2827,7 +2896,9 @@ class TODO:
|
||||||
from concurrent.futures import TimeoutError as _FTimeout
|
from concurrent.futures import TimeoutError as _FTimeout
|
||||||
|
|
||||||
labels = labels or {}
|
labels = labels or {}
|
||||||
print(f"\n{t('Resolving VM IPs (parallel, emulated boot is slow)...')}")
|
print(
|
||||||
|
f"\n{t('Resolving VM IPs (parallel, emulated boot is slow)...')}"
|
||||||
|
)
|
||||||
result = {}
|
result = {}
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
starts = {}
|
starts = {}
|
||||||
|
|
@ -2999,18 +3070,15 @@ class TODO:
|
||||||
# sshd répondait au moins (mieux qu'un abandon silencieux).
|
# sshd répondait au moins (mieux qu'un abandon silencieux).
|
||||||
return ssh_up
|
return ssh_up
|
||||||
|
|
||||||
# Paquets QEMU/libvirt pour le profil « Déploiement » (nos 3 gestionnaires).
|
# Préparation hôte QEMU/libvirt du profil « ERPLibre Déploiement ».
|
||||||
# Paquets SYSTÈME du profil « ERPLibre Déploiement » : QEMU/libvirt/
|
# Délègue à deploy_qemu.py --setup-host : les noms de paquets y sont déjà
|
||||||
# virtinst + libguestfs (virt-resize/virt-filesystems, requis pour la
|
# définis pour apt/dnf/pacman/zypper/brew (TOOL_PACKAGES, DAEMON_PACKAGES),
|
||||||
# RÉDUCTION SÛRE de disque). Ce sont des binaires système (OCaml) : ils ne
|
# et il fait ce que l'ancien one-liner ne faisait PAS — démarrer le démon,
|
||||||
# peuvent PAS vivre dans .venv.erplibre (venv Python) — d'où leur place ici.
|
# ajouter l'utilisateur au groupe libvirt et activer le réseau « default ».
|
||||||
_QEMU_QEMU_PKGS = (
|
# Sans le groupe, virt-install retombe sur qemu:///session où « default »
|
||||||
"(sudo apt-get install -y qemu-kvm libvirt-daemon-system virtinst "
|
# n'existe pas : la VM échoue alors que tous les paquets sont installés.
|
||||||
"cloud-image-utils libguestfs-tools 2>/dev/null || sudo dnf install "
|
# L'ancien one-liner finissait par « || true » et masquait ses erreurs.
|
||||||
"-y qemu-kvm libvirt virt-install cloud-utils guestfs-tools "
|
_QEMU_QEMU_PKGS = "./script/qemu/deploy_qemu.py --setup-host --assume-yes"
|
||||||
"2>/dev/null || sudo pacman -S --needed --noconfirm qemu-desktop "
|
|
||||||
"libvirt virt-install libguestfs 2>/dev/null || true)"
|
|
||||||
)
|
|
||||||
|
|
||||||
def _qemu_ask_prod(self):
|
def _qemu_ask_prod(self):
|
||||||
"""Environnement cible : dev (défaut) ou prod. En PROD : ERPLibre est
|
"""Environnement cible : dev (défaut) ou prod. En PROD : ERPLibre est
|
||||||
|
|
@ -3082,7 +3150,9 @@ class TODO:
|
||||||
PROD : ERPLibre sous /opt/erplibre (hors user_home_t) -> le service
|
PROD : ERPLibre sous /opt/erplibre (hors user_home_t) -> le service
|
||||||
reste CONFINÉ par SELinux ; on restaure les contextes (restorecon)."""
|
reste CONFINÉ par SELinux ; on restaure les contextes (restorecon)."""
|
||||||
svc_dir = self._qemu_install_dir(prod)
|
svc_dir = self._qemu_install_dir(prod)
|
||||||
selinux_shell = 'SELINUX_LINE=""; ' # pas de SELinuxContext (inefficace)
|
selinux_shell = (
|
||||||
|
'SELINUX_LINE=""; ' # pas de SELinuxContext (inefficace)
|
||||||
|
)
|
||||||
if prod:
|
if prod:
|
||||||
pre = (
|
pre = (
|
||||||
"command -v restorecon >/dev/null 2>&1 && "
|
"command -v restorecon >/dev/null 2>&1 && "
|
||||||
|
|
@ -3138,6 +3208,31 @@ class TODO:
|
||||||
# « ERPLibre seul », « mobile » ni « Déploiement ».
|
# « ERPLibre seul », « mobile » ni « Déploiement ».
|
||||||
if "install_odoo" in final_cmd:
|
if "install_odoo" in final_cmd:
|
||||||
final_cmd = f"{final_cmd} && {self._qemu_odoo_service_cmd(prod)}"
|
final_cmd = f"{final_cmd} && {self._qemu_odoo_service_cmd(prod)}"
|
||||||
|
# VM de DÉVELOPPEMENT uniquement : couper les mises à jour automatiques.
|
||||||
|
# Vécu sur erplibre-ubuntu-2404 : unattended-upgrades s'est déclenché en
|
||||||
|
# pleine migration Odoo 12->13 et a redémarré le cluster PostgreSQL
|
||||||
|
# (« received fast shutdown request » x3) -> OpenUpgrade a perdu sa
|
||||||
|
# connexion et la base intermédiaire est restée à moitié migrée. Effet
|
||||||
|
# secondaire bienvenu : les timers apt-daily ne tiennent plus le verrou
|
||||||
|
# apt pendant l'installation. En PROD on ne touche à rien : les
|
||||||
|
# correctifs de sécurité automatiques doivent rester actifs.
|
||||||
|
no_auto_upgrade = ""
|
||||||
|
if not prod:
|
||||||
|
no_auto_upgrade = (
|
||||||
|
"if command -v apt-get >/dev/null 2>&1; then "
|
||||||
|
"sudo systemctl disable --now unattended-upgrades.service "
|
||||||
|
"apt-daily.timer apt-daily-upgrade.timer "
|
||||||
|
">/dev/null 2>&1 || true; "
|
||||||
|
'printf \'APT::Periodic::Update-Package-Lists "0";\\n'
|
||||||
|
'APT::Periodic::Unattended-Upgrade "0";\\n\' '
|
||||||
|
"| sudo tee /etc/apt/apt.conf.d/99-erplibre-no-auto-upgrade "
|
||||||
|
">/dev/null; "
|
||||||
|
"fi; "
|
||||||
|
"if command -v dnf >/dev/null 2>&1; then "
|
||||||
|
"sudo systemctl disable --now dnf-automatic.timer "
|
||||||
|
"dnf-automatic-install.timer >/dev/null 2>&1 || true; "
|
||||||
|
"fi; "
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
"set -e; "
|
"set -e; "
|
||||||
# Attendre la FIN de cloud-init : pendant sa phase « paquets » il
|
# Attendre la FIN de cloud-init : pendant sa phase « paquets » il
|
||||||
|
|
@ -3147,6 +3242,9 @@ class TODO:
|
||||||
"command -v cloud-init >/dev/null 2>&1 && "
|
"command -v cloud-init >/dev/null 2>&1 && "
|
||||||
"sudo timeout 900 cloud-init status --wait >/dev/null 2>&1 "
|
"sudo timeout 900 cloud-init status --wait >/dev/null 2>&1 "
|
||||||
"|| true; "
|
"|| true; "
|
||||||
|
# Coupé AVANT les apt-get ci-dessous : sinon apt-daily peut reprendre
|
||||||
|
# le verrou entre l'attente cloud-init et l'installation.
|
||||||
|
+ no_auto_upgrade +
|
||||||
# Outils d'amorçage (absents des images cloud minimales) : curl,
|
# Outils d'amorçage (absents des images cloud minimales) : curl,
|
||||||
# git, make. Chaque branche RAFRAÎCHIT d'abord les dépôts pour que
|
# git, make. Chaque branche RAFRAÎCHIT d'abord les dépôts pour que
|
||||||
# la VM soit la plus rapide possible (miroirs à jour / les plus
|
# la VM soit la plus rapide possible (miroirs à jour / les plus
|
||||||
|
|
@ -3312,8 +3410,7 @@ class TODO:
|
||||||
)
|
)
|
||||||
cmd = f"ssh {ssh_opts} erplibre@{ip} {shlex.quote(remote)}"
|
cmd = f"ssh {ssh_opts} erplibre@{ip} {shlex.quote(remote)}"
|
||||||
print(
|
print(
|
||||||
f"\n 📦 {name} ({ip}): {t('installing ERPLibre')} "
|
f"\n 📦 {name} ({ip}): {t('installing ERPLibre')} " f"({branch})"
|
||||||
f"({branch})"
|
|
||||||
)
|
)
|
||||||
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)
|
||||||
|
|
@ -3395,10 +3492,15 @@ class TODO:
|
||||||
).strip()
|
).strip()
|
||||||
if new:
|
if new:
|
||||||
names[i] = new
|
names[i] = new
|
||||||
dk = input(
|
dk = (
|
||||||
f" {t('New disk size in G, blank = keep')} "
|
input(
|
||||||
f"({sel[i][3]}): "
|
f" {t('New disk size in G, blank = keep')} "
|
||||||
).strip().upper().rstrip("G")
|
f"({sel[i][3]}): "
|
||||||
|
)
|
||||||
|
.strip()
|
||||||
|
.upper()
|
||||||
|
.rstrip("G")
|
||||||
|
)
|
||||||
if dk:
|
if dk:
|
||||||
try:
|
try:
|
||||||
sel[i][3] = f"{int(float(dk))}G"
|
sel[i][3] = f"{int(float(dk))}G"
|
||||||
|
|
@ -3424,9 +3526,18 @@ class TODO:
|
||||||
parts = [] if dry_run else ["sudo"]
|
parts = [] if dry_run else ["sudo"]
|
||||||
parts += [
|
parts += [
|
||||||
self._qemu_script_path(),
|
self._qemu_script_path(),
|
||||||
"--distro", d, "--version", v, "--name", name,
|
"--distro",
|
||||||
"--memory", str(eram), "--vcpus", str(evcpus),
|
d,
|
||||||
"--password", "erplibre",
|
"--version",
|
||||||
|
v,
|
||||||
|
"--name",
|
||||||
|
name,
|
||||||
|
"--memory",
|
||||||
|
str(eram),
|
||||||
|
"--vcpus",
|
||||||
|
str(evcpus),
|
||||||
|
"--password",
|
||||||
|
"erplibre",
|
||||||
]
|
]
|
||||||
if not dry_run:
|
if not dry_run:
|
||||||
# --no-wait-ip : ne bloque pas 90s/VM, l'IP est collectée après.
|
# --no-wait-ip : ne bloque pas 90s/VM, l'IP est collectée après.
|
||||||
|
|
@ -3633,8 +3744,16 @@ class TODO:
|
||||||
print(f"\n{t('Preview (dry-run):')}")
|
print(f"\n{t('Preview (dry-run):')}")
|
||||||
for i, (d, v, ram, disk, a) in enumerate(selected):
|
for i, (d, v, ram, disk, a) in enumerate(selected):
|
||||||
parts = self._qemu_build_deploy_parts(
|
parts = self._qemu_build_deploy_parts(
|
||||||
d, v, a, names[i], ram, evcpus, disk,
|
d,
|
||||||
default_key, None, dry_run=True,
|
v,
|
||||||
|
a,
|
||||||
|
names[i],
|
||||||
|
ram,
|
||||||
|
evcpus,
|
||||||
|
disk,
|
||||||
|
default_key,
|
||||||
|
None,
|
||||||
|
dry_run=True,
|
||||||
)
|
)
|
||||||
print(" " + " ".join(shlex.quote(p) for p in parts))
|
print(" " + " ".join(shlex.quote(p) for p in parts))
|
||||||
return
|
return
|
||||||
|
|
@ -3651,7 +3770,9 @@ class TODO:
|
||||||
# 4) Option : installer ERPLibre dans ~/git/erplibre de chaque VM.
|
# 4) Option : installer ERPLibre dans ~/git/erplibre de chaque VM.
|
||||||
install_branch = None
|
install_branch = None
|
||||||
install_monitor = False
|
install_monitor = False
|
||||||
install_prod = False # dev (~/git, SELinux dev) vs prod (/opt, confiné)
|
install_prod = (
|
||||||
|
False # dev (~/git, SELinux dev) vs prod (/opt, confiné)
|
||||||
|
)
|
||||||
install_cmd = None # commande finale selon le profil choisi
|
install_cmd = None # commande finale selon le profil choisi
|
||||||
ans = input(
|
ans = input(
|
||||||
t("Install ERPLibre into ~/git/erplibre on each VM? (y/N): ")
|
t("Install ERPLibre into ~/git/erplibre on each VM? (y/N): ")
|
||||||
|
|
@ -3705,8 +3826,16 @@ class TODO:
|
||||||
jobs = [] # (id, name, parts)
|
jobs = [] # (id, name, parts)
|
||||||
for k, (name, d, v, ram, disk, a) in enumerate(pending, 1):
|
for k, (name, d, v, ram, disk, a) in enumerate(pending, 1):
|
||||||
parts = self._qemu_build_deploy_parts(
|
parts = self._qemu_build_deploy_parts(
|
||||||
d, v, a, name, ram, evcpus, disk,
|
d,
|
||||||
ssh_key, install_branch, dry_run=False,
|
v,
|
||||||
|
a,
|
||||||
|
name,
|
||||||
|
ram,
|
||||||
|
evcpus,
|
||||||
|
disk,
|
||||||
|
ssh_key,
|
||||||
|
install_branch,
|
||||||
|
dry_run=False,
|
||||||
)
|
)
|
||||||
jobs.append((f"{k}/{n_jobs}", name, parts))
|
jobs.append((f"{k}/{n_jobs}", name, parts))
|
||||||
|
|
||||||
|
|
@ -3760,8 +3889,7 @@ class TODO:
|
||||||
ip_map = {}
|
ip_map = {}
|
||||||
if deployed and (add_ssh_config or install_branch):
|
if deployed and (add_ssh_config or install_branch):
|
||||||
labels = {
|
labels = {
|
||||||
nm: f"{k}/{len(deployed)}"
|
nm: f"{k}/{len(deployed)}" for k, nm in enumerate(deployed, 1)
|
||||||
for k, nm in enumerate(deployed, 1)
|
|
||||||
}
|
}
|
||||||
ip_map = self._qemu_resolve_ips(deployed, labels)
|
ip_map = self._qemu_resolve_ips(deployed, labels)
|
||||||
|
|
||||||
|
|
@ -3776,7 +3904,10 @@ class TODO:
|
||||||
if install_monitor:
|
if install_monitor:
|
||||||
# Installs détachées en parallèle + dashboard Textual.
|
# Installs détachées en parallèle + dashboard Textual.
|
||||||
self._qemu_install_erplibre_monitored(
|
self._qemu_install_erplibre_monitored(
|
||||||
deployed, install_branch, ip_map, install_cmd,
|
deployed,
|
||||||
|
install_branch,
|
||||||
|
ip_map,
|
||||||
|
install_cmd,
|
||||||
install_prod,
|
install_prod,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
|
@ -3798,9 +3929,13 @@ class TODO:
|
||||||
# synchrone ; l'install monitorée est détachée, non comptée ici).
|
# synchrone ; l'install monitorée est détachée, non comptée ici).
|
||||||
print(f"\n{'═' * 60}")
|
print(f"\n{'═' * 60}")
|
||||||
print(f" {t('TOTAL summary')}")
|
print(f" {t('TOTAL summary')}")
|
||||||
print(f" {t('VMs deployed:')} {n_ok}/{len(jobs) if jobs else 0}"
|
print(
|
||||||
f" ({t('total incl. existing:')} {len(deployed)})")
|
f" {t('VMs deployed:')} {n_ok}/{len(jobs) if jobs else 0}"
|
||||||
print(f" {t('Total time:')} {self._fmt_dur(time.time() - deploy_start)}")
|
f" ({t('total incl. existing:')} {len(deployed)})"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f" {t('Total time:')} {self._fmt_dur(time.time() - deploy_start)}"
|
||||||
|
)
|
||||||
print(f"{'═' * 60}")
|
print(f"{'═' * 60}")
|
||||||
print(f"\n✅ {t('ERPLibre infra deployment done.')}")
|
print(f"\n✅ {t('ERPLibre infra deployment done.')}")
|
||||||
print(f" {t('Default login:')} erplibre / erplibre")
|
print(f" {t('Default login:')} erplibre / erplibre")
|
||||||
|
|
|
||||||
|
|
@ -2457,6 +2457,76 @@ TRANSLATIONS = {
|
||||||
"fr": "QEMU - Exemple dry-run (demo-vm, Ubuntu 24.04)",
|
"fr": "QEMU - Exemple dry-run (demo-vm, Ubuntu 24.04)",
|
||||||
"en": "QEMU - Sample dry-run (demo-vm, Ubuntu 24.04)",
|
"en": "QEMU - Sample dry-run (demo-vm, Ubuntu 24.04)",
|
||||||
},
|
},
|
||||||
|
# Database migration - resume menu
|
||||||
|
"Migration in progress": {
|
||||||
|
"fr": "Migration en cours",
|
||||||
|
"en": "Migration in progress",
|
||||||
|
},
|
||||||
|
"File": {"fr": "Fichier", "en": "File"},
|
||||||
|
"Database": {"fr": "Base", "en": "Database"},
|
||||||
|
"Target": {"fr": "Cible", "en": "Target"},
|
||||||
|
"Started": {"fr": "Démarrée", "en": "Started"},
|
||||||
|
"Steps": {"fr": "Étapes", "en": "Steps"},
|
||||||
|
"Prepare the environment": {
|
||||||
|
"fr": "Préparer l'environnement",
|
||||||
|
"en": "Prepare the environment",
|
||||||
|
},
|
||||||
|
"Restore and neutralize the database": {
|
||||||
|
"fr": "Restaurer et neutraliser la base",
|
||||||
|
"en": "Restore and neutralize the database",
|
||||||
|
},
|
||||||
|
"Update all addons": {
|
||||||
|
"fr": "Mettre à jour tous les modules",
|
||||||
|
"en": "Update all addons",
|
||||||
|
},
|
||||||
|
"Clean up before data migration": {
|
||||||
|
"fr": "Nettoyer avant la migration des données",
|
||||||
|
"en": "Clean up before data migration",
|
||||||
|
},
|
||||||
|
"Upgrade version by version (OpenUpgrade)": {
|
||||||
|
"fr": "Monter de version en version (OpenUpgrade)",
|
||||||
|
"en": "Upgrade version by version (OpenUpgrade)",
|
||||||
|
},
|
||||||
|
"not started": {"fr": "non démarrée", "en": "not started"},
|
||||||
|
"done": {"fr": "terminée", "en": "done"},
|
||||||
|
"partially done": {"fr": "partielle", "en": "partially done"},
|
||||||
|
"version bumps migrated": {
|
||||||
|
"fr": "montées de version faites",
|
||||||
|
"en": "version bumps migrated",
|
||||||
|
},
|
||||||
|
"Continue where it stopped": {
|
||||||
|
"fr": "Continuer là où ça s'est arrêté",
|
||||||
|
"en": "Continue where it stopped",
|
||||||
|
},
|
||||||
|
"Replay from that step": {
|
||||||
|
"fr": "Reprendre à partir de cette étape",
|
||||||
|
"en": "Replay from that step",
|
||||||
|
},
|
||||||
|
"erases the progression of that step and the next ones": {
|
||||||
|
"fr": "efface la progression de cette étape et des suivantes",
|
||||||
|
"en": "erases the progression of that step and the next ones",
|
||||||
|
},
|
||||||
|
"New migration, erase everything": {
|
||||||
|
"fr": "Nouvelle migration, tout effacer",
|
||||||
|
"en": "New migration, erase everything",
|
||||||
|
},
|
||||||
|
"Keep the zip only, ask every question again": {
|
||||||
|
"fr": "Garder seulement le zip, reposer toutes les questions",
|
||||||
|
"en": "Keep the zip only, ask every question again",
|
||||||
|
},
|
||||||
|
"Your choice": {"fr": "Votre choix", "en": "Your choice"},
|
||||||
|
"Unknown choice, continuing where it stopped": {
|
||||||
|
"fr": "Choix inconnu, on continue là où ça s'est arrêté",
|
||||||
|
"en": "Unknown choice, continuing where it stopped",
|
||||||
|
},
|
||||||
|
"Replaying from step": {
|
||||||
|
"fr": "Reprise à partir de l'étape",
|
||||||
|
"en": "Replaying from step",
|
||||||
|
},
|
||||||
|
"The progression file is invalid, ignoring it": {
|
||||||
|
"fr": "Le fichier de progression est invalide, on l'ignore",
|
||||||
|
"en": "The progression file is invalid, ignoring it",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,14 @@ import todo_file_browser
|
||||||
|
|
||||||
from script.todo.version_manager import get_odoo_version
|
from script.todo.version_manager import get_odoo_version
|
||||||
|
|
||||||
|
try:
|
||||||
|
from script.todo.todo_i18n import t
|
||||||
|
except Exception: # pragma: no cover - fallback when i18n is unavailable
|
||||||
|
|
||||||
|
def t(key: str) -> str:
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
new_path = os.path.normpath(
|
new_path = os.path.normpath(
|
||||||
os.path.join(os.path.dirname(__file__), "..", "..")
|
os.path.join(os.path.dirname(__file__), "..", "..")
|
||||||
)
|
)
|
||||||
|
|
@ -50,6 +58,17 @@ LOCAL_MANIFEST = os.path.join(
|
||||||
# be dropped depends on the database, so that choice is never versioned.
|
# be dropped depends on the database, so that choice is never versioned.
|
||||||
PATH_MIGRATION_GLOBAL = os.path.join("script", "odoo", "migration")
|
PATH_MIGRATION_GLOBAL = os.path.join("script", "odoo", "migration")
|
||||||
PATH_MIGRATION_PRIVATE = os.path.join("private", "odoo", "migration")
|
PATH_MIGRATION_PRIVATE = os.path.join("private", "odoo", "migration")
|
||||||
|
# Steps of the migration, in order. Each one owns the progression keys prefixed
|
||||||
|
# with « state_<index> ». Rewinding to a step drops its keys and every later
|
||||||
|
# one, so the run replays from there. Labels go through t(): the key IS the
|
||||||
|
# English string, as everywhere else in this project.
|
||||||
|
MIGRATION_STEP = [
|
||||||
|
(0, "Prepare the environment"),
|
||||||
|
(1, "Restore and neutralize the database"),
|
||||||
|
(2, "Update all addons"),
|
||||||
|
(3, "Clean up before data migration"),
|
||||||
|
(4, "Upgrade version by version (OpenUpgrade)"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class TodoUpgrade:
|
class TodoUpgrade:
|
||||||
|
|
@ -75,6 +94,149 @@ class TodoUpgrade:
|
||||||
with open(UPGRADE_DATABASE_CONFIG_LOG, "w") as f:
|
with open(UPGRADE_DATABASE_CONFIG_LOG, "w") as f:
|
||||||
json.dump(self.dct_progression, f, indent=4)
|
json.dump(self.dct_progression, f, indent=4)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def read_progression():
|
||||||
|
"""Return the saved progression, or an empty dict if unreadable."""
|
||||||
|
try:
|
||||||
|
with open(UPGRADE_DATABASE_CONFIG_LOG, "r") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except (json.decoder.JSONDecodeError, OSError):
|
||||||
|
print(
|
||||||
|
f"⚠️ {t('The progression file is invalid, ignoring it')}:"
|
||||||
|
f" {UPGRADE_DATABASE_CONFIG_LOG}"
|
||||||
|
)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def step_status(dct_progression, step):
|
||||||
|
"""Return (icon, detail) telling how far a migration step went.
|
||||||
|
|
||||||
|
Steps 0 to 3 are plain booleans. Step 4 keeps one list per version, so
|
||||||
|
its detail reports which version bumps are already migrated.
|
||||||
|
"""
|
||||||
|
prefix = f"state_{step}_"
|
||||||
|
dct_flag = {
|
||||||
|
key: value
|
||||||
|
for key, value in dct_progression.items()
|
||||||
|
if key.startswith(prefix)
|
||||||
|
}
|
||||||
|
if not dct_flag:
|
||||||
|
return "⬜", t("not started")
|
||||||
|
|
||||||
|
if step == 4:
|
||||||
|
lst_done = dct_progression.get("state_4_upgrade_odoo_lst") or []
|
||||||
|
# The data migration list only appears once a bump succeeds, so the
|
||||||
|
# number of bumps comes from any per-version list (« *_odoo_lst »).
|
||||||
|
total = max(
|
||||||
|
[
|
||||||
|
len(value)
|
||||||
|
for key, value in dct_progression.items()
|
||||||
|
if key.startswith("state_4_")
|
||||||
|
and key.endswith("_odoo_lst")
|
||||||
|
and isinstance(value, list)
|
||||||
|
]
|
||||||
|
or [0]
|
||||||
|
)
|
||||||
|
done = sum(1 for item in lst_done if item)
|
||||||
|
detail = f"{done}/{total} " + t("version bumps migrated")
|
||||||
|
# Name the versions when the target is known: the list ends on the
|
||||||
|
# target, so the first bump is target - total + 1.
|
||||||
|
try:
|
||||||
|
last = int(float(dct_progression["target_odoo_version"]))
|
||||||
|
detail += " · " + " ".join(
|
||||||
|
f"{last - total + 1 + i}"
|
||||||
|
f"{'✓' if i < len(lst_done) and lst_done[i] else ''}"
|
||||||
|
for i in range(total)
|
||||||
|
)
|
||||||
|
except (KeyError, TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
return ("✅" if total and done == total else "⏳"), detail
|
||||||
|
|
||||||
|
if all(dct_flag.values()):
|
||||||
|
return "✅", t("done")
|
||||||
|
return "⏳", t("partially done")
|
||||||
|
|
||||||
|
def prompt_resume(self, old_dct_progression):
|
||||||
|
"""Show where the migration stands and ask what to do next.
|
||||||
|
|
||||||
|
Returns (progression, changed). The previous menu exposed internal key
|
||||||
|
names (« Reuse database without state_4 »), which said nothing about
|
||||||
|
what would actually happen. This shows the real state of every step and
|
||||||
|
lets the user replay from any of them.
|
||||||
|
"""
|
||||||
|
migration_file = old_dct_progression.get("migration_file") or "?"
|
||||||
|
print()
|
||||||
|
print(f"📍 {t('Migration in progress')}")
|
||||||
|
# Pad in code, not in the translations: the labels differ in length
|
||||||
|
# between languages and a hardcoded padding misaligns the colons.
|
||||||
|
print(f" {t('File'):<9}: {os.path.basename(migration_file)}")
|
||||||
|
print(
|
||||||
|
f" {t('Database'):<9}: "
|
||||||
|
f"{old_dct_progression.get('config_database_name') or '?'}"
|
||||||
|
f" · {t('Target')} :"
|
||||||
|
f" {old_dct_progression.get('target_odoo_version') or '?'}"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f" {t('Started'):<9}: "
|
||||||
|
f"{old_dct_progression.get('date_create')}"
|
||||||
|
)
|
||||||
|
print()
|
||||||
|
print(f" {t('Steps')} :")
|
||||||
|
for step, label in MIGRATION_STEP:
|
||||||
|
icon, detail = self.step_status(old_dct_progression, step)
|
||||||
|
print(f" [{step}] {icon} {t(label):<44} {detail}")
|
||||||
|
print()
|
||||||
|
print(f" [c] {t('Continue where it stopped')}")
|
||||||
|
print(
|
||||||
|
f" [0-4] {t('Replay from that step')}"
|
||||||
|
f" ({t('erases the progression of that step and the next ones')})"
|
||||||
|
)
|
||||||
|
print(f" [n] {t('New migration, erase everything')}")
|
||||||
|
print(f" [r] {t('Keep the zip only, ask every question again')}")
|
||||||
|
answer = input(f"💬 {t('Your choice')} : ").strip().lower()
|
||||||
|
|
||||||
|
if answer in ("", "c"):
|
||||||
|
return old_dct_progression, False
|
||||||
|
if answer == "n":
|
||||||
|
return {}, True
|
||||||
|
if answer == "r":
|
||||||
|
return {
|
||||||
|
"migration_file": old_dct_progression.get("migration_file"),
|
||||||
|
"date_create": old_dct_progression.get("date_create"),
|
||||||
|
}, True
|
||||||
|
if answer.isdigit() and 0 <= int(answer) <= MIGRATION_STEP[-1][0]:
|
||||||
|
return (
|
||||||
|
self.rewind_progression(old_dct_progression, int(answer)),
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"⚠️ {t('Unknown choice, continuing where it stopped')}.")
|
||||||
|
return old_dct_progression, False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def rewind_progression(old_dct_progression, step):
|
||||||
|
"""Drop the progression of `step` and of every later step.
|
||||||
|
|
||||||
|
Configuration answers (config_*), the zip and the target version are
|
||||||
|
kept: they are decisions, not progress. Only « state_* » is rewound.
|
||||||
|
"""
|
||||||
|
dct_kept = {}
|
||||||
|
for key, value in old_dct_progression.items():
|
||||||
|
if not key.startswith("state_"):
|
||||||
|
dct_kept[key] = value
|
||||||
|
continue
|
||||||
|
index = key[len("state_") :].split("_", 1)[0]
|
||||||
|
if index.isdigit() and int(index) < step:
|
||||||
|
dct_kept[key] = value
|
||||||
|
# The module search fills an in-memory dict the later steps rely on;
|
||||||
|
# it must run again even when step 0 itself is kept.
|
||||||
|
dct_kept["state_0_search_missing_module"] = False
|
||||||
|
print(
|
||||||
|
f"⏪ {t('Replaying from step')} {step} —"
|
||||||
|
f" {t(dict(MIGRATION_STEP)[step])}"
|
||||||
|
)
|
||||||
|
return dct_kept
|
||||||
|
|
||||||
def on_file_selected(self, file_path):
|
def on_file_selected(self, file_path):
|
||||||
self.file_path = file_path
|
self.file_path = file_path
|
||||||
todo_file_browser.exit_program()
|
todo_file_browser.exit_program()
|
||||||
|
|
@ -407,97 +569,13 @@ class TodoUpgrade:
|
||||||
default_database_name = "test"
|
default_database_name = "test"
|
||||||
|
|
||||||
if os.path.exists(UPGRADE_DATABASE_CONFIG_LOG):
|
if os.path.exists(UPGRADE_DATABASE_CONFIG_LOG):
|
||||||
with open(UPGRADE_DATABASE_CONFIG_LOG, "r") as f:
|
old_dct_progression = self.read_progression()
|
||||||
try:
|
if old_dct_progression:
|
||||||
old_dct_progression = json.load(f)
|
self.dct_progression, changed = self.prompt_resume(
|
||||||
self.dct_progression = {
|
old_dct_progression
|
||||||
"migration_file": old_dct_progression.get(
|
)
|
||||||
"migration_file"
|
if changed:
|
||||||
),
|
self.write_config()
|
||||||
# More useful to ask this question each time
|
|
||||||
"target_odoo_version": old_dct_progression.get(
|
|
||||||
"target_odoo_version"
|
|
||||||
),
|
|
||||||
"date_create": old_dct_progression.get("date_create"),
|
|
||||||
}
|
|
||||||
except json.decoder.JSONDecodeError:
|
|
||||||
print(
|
|
||||||
f'⚠️ The config file "{UPGRADE_DATABASE_CONFIG_LOG}" is invalid, ignore it.'
|
|
||||||
)
|
|
||||||
|
|
||||||
print(
|
|
||||||
f"✨ Detected migration \"{self.dct_progression.get('migration_file')}\" "
|
|
||||||
f"to version \"{self.dct_progression.get('target_odoo_version')}\", "
|
|
||||||
f"please select an option."
|
|
||||||
)
|
|
||||||
|
|
||||||
print("[1] Erase progression for a new migration")
|
|
||||||
print("[2] Reuse database with new process")
|
|
||||||
print(
|
|
||||||
"[3] Reuse database without state_4, before looping on next version"
|
|
||||||
)
|
|
||||||
print("[4] Reuse database without configuration")
|
|
||||||
erase_progression_input = (
|
|
||||||
input("💬 Select an option or press to continue : ")
|
|
||||||
.strip()
|
|
||||||
.lower()
|
|
||||||
)
|
|
||||||
self.dct_progression = {}
|
|
||||||
if erase_progression_input in ["2", "3", "4"]:
|
|
||||||
with open(UPGRADE_DATABASE_CONFIG_LOG, "r") as f:
|
|
||||||
try:
|
|
||||||
old_dct_progression = json.load(f)
|
|
||||||
self.dct_progression = {
|
|
||||||
"migration_file": old_dct_progression.get(
|
|
||||||
"migration_file"
|
|
||||||
),
|
|
||||||
# More useful to ask this question each time
|
|
||||||
# "target_odoo_version": old_dct_progression.get(
|
|
||||||
# "target_odoo_version"
|
|
||||||
# ),
|
|
||||||
"date_create": old_dct_progression.get(
|
|
||||||
"date_create"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
for key, value in old_dct_progression.items():
|
|
||||||
if erase_progression_input == "3":
|
|
||||||
if (
|
|
||||||
key.startswith("state_0")
|
|
||||||
or key.startswith("state_1")
|
|
||||||
or key.startswith("state_2")
|
|
||||||
or key.startswith("state_3")
|
|
||||||
):
|
|
||||||
self.dct_progression[key] = value
|
|
||||||
if key.startswith(f"config_state") and not (
|
|
||||||
key.startswith(f"config_state_0")
|
|
||||||
or key.startswith(f"config_state_1")
|
|
||||||
or key.startswith(f"config_state_2")
|
|
||||||
or key.startswith(f"config_state_3")
|
|
||||||
):
|
|
||||||
continue
|
|
||||||
if (
|
|
||||||
key.startswith("config_")
|
|
||||||
and erase_progression_input != "4"
|
|
||||||
):
|
|
||||||
self.dct_progression[key] = value
|
|
||||||
# Force to search missing module to fill dict
|
|
||||||
self.dct_progression[
|
|
||||||
"state_0_search_missing_module"
|
|
||||||
] = False
|
|
||||||
except json.decoder.JSONDecodeError:
|
|
||||||
print(
|
|
||||||
f"⚠️ The config file '{UPGRADE_DATABASE_CONFIG_LOG}' is invalid, ignore it."
|
|
||||||
)
|
|
||||||
|
|
||||||
self.write_config()
|
|
||||||
elif erase_progression_input not in ["1"]:
|
|
||||||
with open(UPGRADE_DATABASE_CONFIG_LOG, "r") as f:
|
|
||||||
try:
|
|
||||||
self.dct_progression = json.load(f)
|
|
||||||
except json.decoder.JSONDecodeError:
|
|
||||||
print(
|
|
||||||
f"⚠️ The config file '{UPGRADE_DATABASE_CONFIG_LOG}' is invalid, ignore it."
|
|
||||||
)
|
|
||||||
|
|
||||||
if "migration_file" in self.dct_progression:
|
if "migration_file" in self.dct_progression:
|
||||||
self.file_path = self.dct_progression["migration_file"]
|
self.file_path = self.dct_progression["migration_file"]
|
||||||
|
|
@ -1651,12 +1729,21 @@ class TodoUpgrade:
|
||||||
# half-migrated database. Stop here instead: the state stays
|
# half-migrated database. Stop here instead: the state stays
|
||||||
# unset, so a rerun replays this version.
|
# unset, so a rerun replays this version.
|
||||||
if status:
|
if status:
|
||||||
|
# The intermediate database is now half migrated and must
|
||||||
|
# not be reused: drop the clone flag so the rerun rebuilds
|
||||||
|
# it from the pristine source. Without this the replay
|
||||||
|
# would restart OpenUpgrade on top of the broken clone.
|
||||||
|
lst_clone_odoo[index] = False
|
||||||
|
self.dct_progression["state_4_clone_odoo_lst"] = (
|
||||||
|
lst_clone_odoo
|
||||||
|
)
|
||||||
|
self.write_config()
|
||||||
print(
|
print(
|
||||||
f"❌ -> Database migration to Odoo{next_version} FAILED"
|
f"❌ -> Database migration to Odoo{next_version} FAILED"
|
||||||
f" (status {status}). Stopping before version"
|
f" (status {status}). Stopping before version"
|
||||||
f" {next_version + 1} to avoid migrating a broken"
|
f" {next_version + 1} to avoid migrating a broken"
|
||||||
" database. Fix the cause, then relaunch: this version"
|
" database. Fix the cause, then relaunch: this version"
|
||||||
" will be replayed."
|
f" replays from a fresh clone of '{database_name}'."
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue