Compare commits

...

4 commits

Author SHA1 Message Date
fba3b8aed1 [FIX] qemu: really prepare the host in the Deployment profile
The « ERPLibre Deployment (+ QEMU + dev) » profile installed packages with a
one-liner ending in « || true » and hiding stderr, so a broken host looked
installed. Reproduced on erplibre-arch-latest: every binary was present, yet
virt-install failed and « virsh list --all » could not reach any hypervisor.

Three distinct causes, all unhandled:

- The user was never added to the libvirt group. Without it a non-root libvirt
  client falls back to qemu:///session, where the « default » network does not
  exist, so « --network network=default » fails while everything looks
  installed. Being in the group grants the RIGHT to reach qemu:///system but
  does NOT change the default URI, so virt-install and virsh now pass
  « --connect qemu:///system » explicitly (new LIBVIRT_URI constant).
- dnsmasq was missing: ensure_tools only installed DAEMON_PACKAGES when the
  daemon was absent, and libvirt was already there. setup_host now forces them.
- On a rolling distro, « make install_os » upgrades the kernel and the package
  manager removes /lib/modules/<running kernel>. modprobe bridge then fails and
  libvirt cannot create virbr0 (« Unable to create bridge virbr0: Package not
  installed »). No package fixes that, only a reboot: it is now diagnosed and
  reported instead of surfacing as an unreadable virsh error.

The profile now calls « deploy_qemu.py --setup-host », which reuses the
existing ensure_* chain, so package names stay defined in one place
(TOOL_PACKAGES / DAEMON_PACKAGES) and keep working for apt, dnf, pacman,
zypper and brew. It fails loudly instead of « || true ».

Verified on the Arch VM that failed: setup-host reports the stale kernel and
exits 1; after a reboot it installs dnsmasq, joins libvirt/kvm, starts the
default network, and reports « Hôte prêt » (exit 0, idempotent on rerun).
The generated command now reads « virt-install --connect qemu:///system ».

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 03:15:46 -04:00
a9ef5a0e8e [IMP] migration: show the real state of each step and replay from any of them
The resume menu exposed internal key names: « Reuse database without state_4 »
tells nobody what will happen, and the most useful option -- keep the prepared
database, redo the version bumps -- was the least readable of the four.

It now prints where the migration actually stands: the zip, the database, the
target, and one line per step with its state. Step 4 reports how many version
bumps are migrated and names them, so « 0/6 · 13 14 15 16 17 18 » replaces a
list index nobody could interpret.

The four numbered options become:
  [c]   continue where it stopped        (was: press enter)
  [0-4] replay from that step            (new: rewind to any step)
  [n]   new migration, erase everything  (was: [1])
  [r]   keep the zip, ask everything     (was: [4])

Rewinding drops « state_* » from the chosen step onward and keeps the rest:
config_* answers, the zip and the target version are decisions, not progress,
so they are no longer re-asked. state_0_search_missing_module is still forced
back to False, as the previous code did: it fills an in-memory dict the later
steps rely on. Old [2] is now « replay from 0 », old [3] « replay from 4 ».

First use of t() in this file; the new strings are translated in todo_i18n.py.

Verified against the real progression of the 12.0 -> 18.0 migration and a
synthetic one: for every step, the kept keys are exactly those of the earlier
steps, config and target survive, and the module search is forced again.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 02:53:17 -04:00
96cc39431c [IMP] qemu: disable automatic upgrades on development VMs only
Measured on erplibre-ubuntu-2404: unattended-upgrades fired in the middle of
an Odoo 12->13 migration and restarted the PostgreSQL cluster three times
(« received fast shutdown request »). OpenUpgrade lost its connection and the
intermediate database was left half migrated.

On a development VM the ERPLibre installer now turns off unattended-upgrades
and the apt-daily timers, and drops an apt.conf.d snippet so they stay off
across reboots. dnf-automatic gets the same treatment on Fedora. It runs right
after the cloud-init wait and before the apt-get calls, so apt-daily can no
longer grab the lock between the two either -- the same contention that made
« apt-get update » fail during deployment.

Production VMs are left untouched: automatic security updates must stay on
there. The switch is the existing dev/prod answer, already threaded down to
_qemu_erplibre_remote_cmd.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 02:42:45 -04:00
79a07ac577 [FIX] migration: rebuild the clone when the data migration fails
When OpenUpgrade fails, the intermediate <db>_upgrade_<N> database is left
half migrated. The clone step had already been recorded as done, so a rerun
skipped it and restarted OpenUpgrade on top of that broken clone.

Clear the clone flag on failure so the replay rebuilds the intermediate
database from the pristine source. Found by a real 12.0 -> 13.0 run: Ubuntu
unattended-upgrades restarted the PostgreSQL cluster mid-migration, Odoo lost
its connection, and the replay would have resumed on the damaged clone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 02:38:54 -04:00
4 changed files with 692 additions and 214 deletions

View file

@ -47,6 +47,7 @@ from __future__ import annotations
import argparse
import getpass
import grp
import hashlib
import os
import re
@ -148,6 +149,7 @@ def host_arch() -> str:
"s390x": "s390x",
}.get(machine, "amd64")
ARCH_CLOUD_BASE = "https://geo.mirror.pkgbuild.com/images/latest"
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:
index = f"{base}/{version}/Cloud/{a}/images/"
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")
except Exception as exc: # pragma: no cover - dépend du réseau
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
# 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")
# Pour construire le seed cloud-init il faut AU MOINS un de ces outils.
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
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()
need_daemon = daemon_missing()
need_daemon = daemon_missing() or force_daemon
# Tout est là et l'hyperviseur répond : rien à faire.
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]:
"""(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:
res = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
except (OSError, subprocess.SubprocessError):
@ -1156,9 +1305,15 @@ def ensure_network(name: str | None, runner: Runner) -> None:
return
if runner.dry_run:
print(f" [dry-run] réseau libvirt '{name}' activé si nécessaire")
runner.run(["virsh", "net-start", name], privileged=True, check=False)
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
active, autostart = network_state(name, runner.use_sudo)
@ -1167,10 +1322,16 @@ def ensure_network(name: str | None, runner: Runner) -> None:
return
print(f" Configuration du réseau libvirt '{name}'…")
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:
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"
cmd = [
"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",
args.name,
"--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
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
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 []) + [
"virsh",
"domifaddr",
@ -1544,6 +1711,12 @@ def build_parser() -> argparse.ArgumentParser:
action="store_true",
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(
"--list-images",
action="store_true",
@ -1590,6 +1763,19 @@ def main() -> None:
list_images()
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]
if args.version is None:
args.version = default_version

View file

@ -614,9 +614,7 @@ class TODO:
except Exception as exc:
print(f"{t('Command failed: ')}{exc}")
# Revenir (curseur restauré) ou quitter ?
ans = input(
f"\n{t('Back to telemetry (r) or quit (Enter)? ')}"
)
ans = input(f"\n{t('Back to telemetry (r) or quit (Enter)? ')}")
if ans.strip().lower() not in ("r", "revenir", "o", "oui", "y"):
return
@ -1212,14 +1210,10 @@ class TODO:
return
# DOUBLE validation avant d'appliquer.
summary = f"{verb} -> {', '.join(resolved)}"
if not self._is_yes(
input(f"{t('Apply:')} {summary} ? (o/N) : ")
):
if not self._is_yes(input(f"{t('Apply:')} {summary} ? (o/N) : ")):
print(t("Cancelled."))
return
if not self._is_yes(
input(t("Confirm for real? (y/N): "))
):
if not self._is_yes(input(t("Confirm for real? (y/N): "))):
print(t("Cancelled."))
return
for real in resolved:
@ -1404,9 +1398,7 @@ class TODO:
f" [{i}] {r['label']} — {len(r['vms'])} VM{star}\n"
f" {names}"
)
sel = input(
t("Choice (number, blank = last): ")
).strip()
sel = input(t("Choice (number, blank = last): ")).strip()
run = runs[0]
if sel:
try:
@ -1544,8 +1536,10 @@ class TODO:
cmd = f"sudo virsh shutdown {shlex.quote(name)} --mode acpi,agent"
print(f"{t('Will execute:')} {cmd}")
self.execute.exec_command_live(cmd, source_erplibre=False)
print(f"{t('Waiting for the VM to shut down...')} "
f"({t('timeout')}: {timeout} s)")
print(
f"{t('Waiting for the VM to shut down...')} "
f"({t('timeout')}: {timeout} s)"
)
deadline = time.time() + timeout
while time.time() < deadline:
if self._qemu_domstate(name) == "shut off":
@ -1553,15 +1547,22 @@ class TODO:
print(f"\r{' ' * 40}\r✅ {name}: {t('VM is off.')}")
return True
remaining = int(deadline - time.time())
print(f"\r ⏳ {t('shutting down')}… "
f"{remaining:>3d} s {t('remaining')}",
end="", flush=True)
print(
f"\r ⏳ {t('shutting down')}… "
f"{remaining:>3d} s {t('remaining')}",
end="",
flush=True,
)
time.sleep(2)
print() # newline après le compte à rebours
# Arrêt gracieux trop long : proposer un arrêt forcé.
if self._is_yes(
input(t("Graceful shutdown timed out. Force off (destroy)? "
"(y/N): "))
input(
t(
"Graceful shutdown timed out. Force off (destroy)? "
"(y/N): "
)
)
):
cmd = f"sudo virsh destroy {shlex.quote(name)}"
print(f"{t('Will execute:')} {cmd}")
@ -1695,25 +1696,28 @@ class TODO:
print(t("No change."))
return
shrink = new_gb < cur_gb
print(
f"\n{t('New virtual size:')} {cur_gb:.1f} G -> {new_gb:.1f} G"
)
print(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
# peut soutenir -> surallocation, l'hôte se remplira si la VM utilise
# tout l'espace.
if not shrink and max_safe_gb and new_gb > max_safe_gb:
over = new_gb - max_safe_gb
msg1 = t("Beyond host capacity by ~%.1f G — overcommit.") % over
msg2 = t(
"The qcow2 is thin: fine until the VM fills it, then the "
"host disk runs out. Max sustainable: ~%.1f G."
) % max_safe_gb
msg2 = (
t(
"The qcow2 is thin: fine until the VM fills it, then the "
"host disk runs out. Max sustainable: ~%.1f G."
)
% max_safe_gb
)
print(f"⚠ {msg1}")
print(f" {msg2}")
# 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 ?
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:
# DANGER : qcow2 --shrink ne réduit PAS le FS invité -> perte de
# données si le FS dépasse la cible. VM éteinte obligatoire.
@ -1725,8 +1729,12 @@ class TODO:
print(f"⚠ {danger}")
if state != "shut off":
if not self._is_yes(
input(t("The VM must be off. Shut it down and retry? "
"(y/N): "))
input(
t(
"The VM must be off. Shut it down and retry? "
"(y/N): "
)
)
):
print(t("Cancelled."))
return
@ -1776,9 +1784,7 @@ class TODO:
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): "))
):
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:
@ -1802,8 +1808,14 @@ class TODO:
# 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",
"qemu-nbd",
"e2fsck",
"resize2fs",
"sgdisk",
"partprobe",
"lsblk",
"dumpe2fs",
"blockdev",
)
_SECT = 512
_MiB = 1024 * 1024
@ -1832,13 +1844,25 @@ class TODO:
):
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:
if (
subprocess.run(
[
"sudo",
"cp",
"--reflink=auto",
"--sparse=always",
disk,
bak,
]
).returncode
!= 0
):
print(t("Backup failed; aborting."))
return False
else:
print(f"⚠ {t('No backup: a failure could leave the disk broken.')}")
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:
@ -1876,14 +1900,19 @@ class TODO:
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:
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 = 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."))
@ -1892,9 +1921,19 @@ class TODO:
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]
[
"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."))
@ -1906,10 +1945,19 @@ class TODO:
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:
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.
@ -1917,8 +1965,10 @@ class TODO:
if dev:
subprocess.run(["sudo", "sgdisk", "-e", dev], check=False)
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]
if p2:
subprocess.run(
@ -1928,9 +1978,7 @@ class TODO:
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}"
)
print(f"✅ {t('Disk safely shrunk. Backup kept at:')} {bak}")
else:
print(f"✅ {t('Disk safely shrunk.')}")
return True
@ -1965,15 +2013,18 @@ class TODO:
continue
rc = subprocess.run(
["sudo", "qemu-nbd", "-c", dev, disk],
capture_output=True, text=True,
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
)
["sudo", "partprobe", dev],
check=False,
capture_output=True,
)
time.sleep(1)
if any(
os.path.exists(f"/sys/class/block/{base}p{n}")
@ -1999,7 +2050,9 @@ class TODO:
try:
res = subprocess.run(
["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):
return None, 0, ""
@ -2013,28 +2066,31 @@ class TODO:
except ValueError:
size = 0
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:
return None, 0, ""
part = f"/dev/{best}"
try:
start = int(
open(f"/sys/class/block/{best}/start").read().strip()
)
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,
capture_output=True,
text=True,
).stdout.strip()
return part, start, best_fs
@staticmethod
def _qemu_part_number(dev, part):
"""Numéro de partition (ex. « 1 ») depuis /dev/nbd0p1."""
return part[len(dev):].lstrip("p")
return part[len(dev) :].lstrip("p")
@staticmethod
def _qemu_part_info(dev, n):
@ -2042,7 +2098,9 @@ class TODO:
info = {"type": "", "uuid": "", "name": ""}
res = subprocess.run(
["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():
low = line.lower()
@ -2058,7 +2116,9 @@ class TODO:
def _qemu_fs_blocksize(part):
res = subprocess.run(
["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():
if line.startswith("Block size:"):
@ -2072,7 +2132,9 @@ class TODO:
def _qemu_fs_blocks(part):
res = subprocess.run(
["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():
if line.startswith("Block count:"):
@ -2087,7 +2149,9 @@ class TODO:
"""Taille minimale (blocs) du FS via « resize2fs -P »."""
res = subprocess.run(
["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():
if tok.isdigit():
@ -2099,15 +2163,15 @@ class TODO:
_GROW_FS_REMOTE = (
"set -e; "
"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]+$'); "
"sudo growpart /dev/$dev $part || true; "
"fstype=$(findmnt -no FSTYPE /); "
'case "$fstype" in '
"ext*) sudo resize2fs \"$root\";; "
'ext*) sudo resize2fs "$root";; '
"xfs) sudo xfs_growfs /;; "
"btrfs) sudo btrfs filesystem resize max /;; "
'esac; '
"esac; "
"df -h /"
)
@ -2142,9 +2206,13 @@ class TODO:
if rc == 0:
print(f"✅ {t('Guest filesystem grown via guest agent.')}")
return
print(f"⚠ {t('Guest agent grow failed; falling back to console.')}")
print(
f"⚠ {t('Guest agent grow failed; falling back to console.')}"
)
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).
self._qemu_grow_via_console(real, remote)
@ -2158,8 +2226,11 @@ class TODO:
try:
res = subprocess.run(
[
"sudo", "virsh", "qemu-agent-command",
name, json.dumps(payload),
"sudo",
"virsh",
"qemu-agent-command",
name,
json.dumps(payload),
],
capture_output=True,
text=True,
@ -2219,9 +2290,7 @@ class TODO:
print(
f"👤 {t('Default login (if set at deploy): erplibre / erplibre')}"
)
if not self._is_yes(
input(t("Open the serial console now? (y/N): "))
):
if not self._is_yes(input(t("Open the serial console now? (y/N): "))):
return
cmd = f"sudo virsh console {shlex.quote(name)}"
print(f"{t('Will execute:')} {cmd}")
@ -2827,7 +2896,9 @@ class TODO:
from concurrent.futures import TimeoutError as _FTimeout
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 = {}
t0 = time.time()
starts = {}
@ -2999,18 +3070,15 @@ class TODO:
# sshd répondait au moins (mieux qu'un abandon silencieux).
return ssh_up
# Paquets QEMU/libvirt pour le profil « Déploiement » (nos 3 gestionnaires).
# Paquets SYSTÈME du profil « ERPLibre Déploiement » : QEMU/libvirt/
# virtinst + libguestfs (virt-resize/virt-filesystems, requis pour la
# RÉDUCTION SÛRE de disque). Ce sont des binaires système (OCaml) : ils ne
# peuvent PAS vivre dans .venv.erplibre (venv Python) — d'où leur place ici.
_QEMU_QEMU_PKGS = (
"(sudo apt-get install -y qemu-kvm libvirt-daemon-system virtinst "
"cloud-image-utils libguestfs-tools 2>/dev/null || sudo dnf install "
"-y qemu-kvm libvirt virt-install cloud-utils guestfs-tools "
"2>/dev/null || sudo pacman -S --needed --noconfirm qemu-desktop "
"libvirt virt-install libguestfs 2>/dev/null || true)"
)
# Préparation hôte QEMU/libvirt du profil « ERPLibre Déploiement ».
# Délègue à deploy_qemu.py --setup-host : les noms de paquets y sont déjà
# définis pour apt/dnf/pacman/zypper/brew (TOOL_PACKAGES, DAEMON_PACKAGES),
# et il fait ce que l'ancien one-liner ne faisait PAS — démarrer le démon,
# ajouter l'utilisateur au groupe libvirt et activer le réseau « default ».
# Sans le groupe, virt-install retombe sur qemu:///session où « default »
# n'existe pas : la VM échoue alors que tous les paquets sont installés.
# L'ancien one-liner finissait par « || true » et masquait ses erreurs.
_QEMU_QEMU_PKGS = "./script/qemu/deploy_qemu.py --setup-host --assume-yes"
def _qemu_ask_prod(self):
"""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
reste CONFINÉ par SELinux ; on restaure les contextes (restorecon)."""
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:
pre = (
"command -v restorecon >/dev/null 2>&1 && "
@ -3138,6 +3208,31 @@ class TODO:
# « ERPLibre seul », « mobile » ni « Déploiement ».
if "install_odoo" in final_cmd:
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 (
"set -e; "
# 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 && "
"sudo timeout 900 cloud-init status --wait >/dev/null 2>&1 "
"|| 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,
# 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
@ -3312,8 +3410,7 @@ class TODO:
)
cmd = f"ssh {ssh_opts} erplibre@{ip} {shlex.quote(remote)}"
print(
f"\n 📦 {name} ({ip}): {t('installing ERPLibre')} "
f"({branch})"
f"\n 📦 {name} ({ip}): {t('installing ERPLibre')} " f"({branch})"
)
print(f" {t('Will execute:')} {cmd}")
self.execute.exec_command_live(cmd, source_erplibre=False)
@ -3395,10 +3492,15 @@ class TODO:
).strip()
if new:
names[i] = new
dk = input(
f" {t('New disk size in G, blank = keep')} "
f"({sel[i][3]}): "
).strip().upper().rstrip("G")
dk = (
input(
f" {t('New disk size in G, blank = keep')} "
f"({sel[i][3]}): "
)
.strip()
.upper()
.rstrip("G")
)
if dk:
try:
sel[i][3] = f"{int(float(dk))}G"
@ -3424,9 +3526,18 @@ class TODO:
parts = [] if dry_run else ["sudo"]
parts += [
self._qemu_script_path(),
"--distro", d, "--version", v, "--name", name,
"--memory", str(eram), "--vcpus", str(evcpus),
"--password", "erplibre",
"--distro",
d,
"--version",
v,
"--name",
name,
"--memory",
str(eram),
"--vcpus",
str(evcpus),
"--password",
"erplibre",
]
if not dry_run:
# --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):')}")
for i, (d, v, ram, disk, a) in enumerate(selected):
parts = self._qemu_build_deploy_parts(
d, v, a, names[i], ram, evcpus, disk,
default_key, None, dry_run=True,
d,
v,
a,
names[i],
ram,
evcpus,
disk,
default_key,
None,
dry_run=True,
)
print(" " + " ".join(shlex.quote(p) for p in parts))
return
@ -3651,7 +3770,9 @@ class TODO:
# 4) Option : installer ERPLibre dans ~/git/erplibre de chaque VM.
install_branch = None
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
ans = input(
t("Install ERPLibre into ~/git/erplibre on each VM? (y/N): ")
@ -3705,8 +3826,16 @@ class TODO:
jobs = [] # (id, name, parts)
for k, (name, d, v, ram, disk, a) in enumerate(pending, 1):
parts = self._qemu_build_deploy_parts(
d, v, a, name, ram, evcpus, disk,
ssh_key, install_branch, dry_run=False,
d,
v,
a,
name,
ram,
evcpus,
disk,
ssh_key,
install_branch,
dry_run=False,
)
jobs.append((f"{k}/{n_jobs}", name, parts))
@ -3760,8 +3889,7 @@ class TODO:
ip_map = {}
if deployed and (add_ssh_config or install_branch):
labels = {
nm: f"{k}/{len(deployed)}"
for k, nm in enumerate(deployed, 1)
nm: f"{k}/{len(deployed)}" for k, nm in enumerate(deployed, 1)
}
ip_map = self._qemu_resolve_ips(deployed, labels)
@ -3776,7 +3904,10 @@ class TODO:
if install_monitor:
# Installs détachées en parallèle + dashboard Textual.
self._qemu_install_erplibre_monitored(
deployed, install_branch, ip_map, install_cmd,
deployed,
install_branch,
ip_map,
install_cmd,
install_prod,
)
else:
@ -3798,9 +3929,13 @@ class TODO:
# synchrone ; l'install monitorée est détachée, non comptée ici).
print(f"\n{'═' * 60}")
print(f" {t('TOTAL summary')}")
print(f" {t('VMs deployed:')} {n_ok}/{len(jobs) if jobs else 0}"
f" ({t('total incl. existing:')} {len(deployed)})")
print(f" {t('Total time:')} {self._fmt_dur(time.time() - deploy_start)}")
print(
f" {t('VMs deployed:')} {n_ok}/{len(jobs) if jobs else 0}"
f" ({t('total incl. existing:')} {len(deployed)})"
)
print(
f" {t('Total time:')} {self._fmt_dur(time.time() - deploy_start)}"
)
print(f"{'═' * 60}")
print(f"\n✅ {t('ERPLibre infra deployment done.')}")
print(f" {t('Default login:')} erplibre / erplibre")

View file

@ -2457,6 +2457,76 @@ TRANSLATIONS = {
"fr": "QEMU - Exemple 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",
},
}

View file

@ -16,6 +16,14 @@ import todo_file_browser
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(
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.
PATH_MIGRATION_GLOBAL = os.path.join("script", "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:
@ -75,6 +94,149 @@ class TodoUpgrade:
with open(UPGRADE_DATABASE_CONFIG_LOG, "w") as f:
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):
self.file_path = file_path
todo_file_browser.exit_program()
@ -407,97 +569,13 @@ class TodoUpgrade:
default_database_name = "test"
if os.path.exists(UPGRADE_DATABASE_CONFIG_LOG):
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"),
}
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."
)
old_dct_progression = self.read_progression()
if old_dct_progression:
self.dct_progression, changed = self.prompt_resume(
old_dct_progression
)
if changed:
self.write_config()
if "migration_file" in self.dct_progression:
self.file_path = self.dct_progression["migration_file"]
@ -1651,12 +1729,21 @@ class TodoUpgrade:
# half-migrated database. Stop here instead: the state stays
# unset, so a rerun replays this version.
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(
f"❌ -> Database migration to Odoo{next_version} FAILED"
f" (status {status}). Stopping before version"
f" {next_version + 1} to avoid migrating a broken"
" database. Fix the cause, then relaunch: this version"
" will be replayed."
f" replays from a fresh clone of '{database_name}'."
)
return