[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>
This commit is contained in:
Mathieu Benoit 2026-08-01 02:42:45 -04:00
parent 79a07ac577
commit 96cc39431c

View file

@ -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 = {}
@ -3082,7 +3153,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 +3211,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 +3245,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 +3413,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 +3495,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 +3529,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 +3747,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 +3773,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 +3829,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 +3892,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 +3907,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 +3932,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")