Compare commits

...

3 commits

Author SHA1 Message Date
b197cc01c5 [IMP] qemu ssh: generate and deploy the key, register virt-manager
Writing ~/.ssh/config only helps if the key is accepted at the other end.
Both SSH options now offer to create an ed25519 key when none exists — the
same choice deploy_qemu.ensure_ssh_key makes, so created and adopted VMs share
one key — and to push it with ssh-copy-id.

Hosts that already accept the key are skipped, tested with
PasswordAuthentication=no: without it ssh would fall back to the password and
every host would look like it already had the key. ssh-copy-id runs on the
real terminal rather than through captured output, otherwise its password
prompt would be invisible.

In the recursive walk the key is deployed BEFORE probing each level, not at
the end. The probe uses BatchMode, so an un-keyed machine answers nothing and
the level below it stays invisible — deploying afterwards would find only the
first level.

virt-manager, when installed, gets the machines that actually run libvirt
added to its connection list, so their nested VMs can be driven from the local
GUI. The URI uses the SSH ALIAS rather than the raw IP: qemu+ssh goes through
the ssh binary, hence ~/.ssh/config, so the alias already carries both the
address and the ProxyJump — a bare IP could not reach a nested VM at all.

Connections live in GSettings, not a file. The list is READ first and written
back merged, so nothing already configured is lost, and a failed read (no
schema, no virt-manager) simply means the whole feature stays silent — no
prompt, no write. virt-manager rewrites its settings when it exits, so a
warning says to restart it.

Verified: key generated with 0600 on the private half and reused on the second
call; ssh-copy-id issued only for hosts that need it; the recursive walk
deploying level by level before each probe; GSettings merge keeping existing
URIs and skipping the write when nothing is missing; « @as [] », a populated
list and a missing schema all parsed correctly.

Caveat: virt-manager is not installed on this machine, so the absent path was
exercised for real and the write path only against a stubbed gsettings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 07:40:00 -04:00
b8bfb0f8e0 [ADD] qemu: an SSH configuration tool, with recursive ProxyJump
~/.ssh/config entries were only ever written while deploying a VM. A fleet
that already exists — or one whose DHCP leases have moved — had no way to
refresh them. Entry [13] of the QEMU menu does it on demand:

  [1] update ~/.ssh/config for the local VMs
  [2] add the nested VMs through ProxyJump, recursively

The second one matters because of the « ERPLibre Deployment (+ QEMU + dev) »
profile: a VM built that way hosts VMs of its own, on its own private network.
Those are not reachable from this host at all — only from their parent. So
each level is written with a ProxyJump to the level above, and OpenSSH chains
the hops on its own. « ssh erplibre-fedora-42 » then works from here even
though the address only means something two machines away.

The recursion probes over « ssh <alias> », i.e. through the block just
written, so the parent's own ProxyJump applies automatically and one probe
works identically at any depth. One SSH connection per MACHINE, not per VM: a
single snippet returns every « name<TAB>ip » pair, falling back to the guest
agent when the dnsmasq lease is missing. Passwordless sudo is a given here —
the cloud-init config grants it (deploy_qemu.py:1175).

A nested VM keeps its short name, which is what one wants to type, and is only
prefixed with its parent on collision — so discovering a machine that already
exists elsewhere never overwrites the other one's entry. Depth defaults to 2
(host, VM, nested VM) and already-seen aliases are skipped, which is what
stops a cycle: a child that reports its own grandparent.

Verified on a simulated two-level fleet including a deliberate cycle: the
ProxyJump chain is correct at each level, the colliding name is prefixed, the
parent block is not overwritten, and only one probe per machine is issued. The
remote snippet itself was run for real on this host — valid POSIX sh, two VMs
with their addresses. A VM without an IP is skipped rather than written with
an empty HostName.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 07:24:15 -04:00
7d96b56fb4 [FIX] migration: tell modules with no code from those to uninstall
Uninstalling before the 13->14 bump asked for 15 modules; 12 of them no longer
exist in the 13.0 addons path. check_addons_exist.py rejects the command on
the first missing name, so the uninstall aborted whole — and the 3 modules
that WERE there stayed installed, blocking the bump for a reason that had
nothing to do with them.

The list is now split before the script is called, using check_addons_exist
which was already there and simply never consulted at this point. « Missing »
means the addons path has no code for it, not that the database lacks it —
the distinction is the whole point, since Odoo cannot uninstall a module whose
code is gone.

When some are missing, a choice is offered rather than a failure:

  [1] uninstall the present ones, skip the missing   (default)
  [2] try the whole list anyway (it will fail)
  [3] uninstall nothing, continue

Only what is really uninstalled is subtracted from the per-version module
list, so a module left in place stays counted as installed — which it is. The
missing names are written to the progression comments, so what was skipped is
still on record afterwards.

Verified on the exact list from the failure: 3 present and 12 missing,
option 1 issuing a command with only the 3 and leaving the 12 counted as
installed, option 2 sending all 15, option 3 running nothing.

Also swapped entries 4 and 5 of the Deploy menu — QEMU/KVM now sits at [4],
NTFY at [5].

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 07:16:26 -04:00
3 changed files with 621 additions and 7 deletions

View file

@ -2,6 +2,7 @@
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
import ast
import configparser
import datetime
import getpass
@ -879,12 +880,12 @@ class TODO:
{"prompt_description": t("SSH (remote host)...")},
{
"prompt_description": t(
"Deploy - Install NTFY notification server"
"QEMU/KVM - Deploy an Ubuntu VM (libvirt)"
)
},
{
"prompt_description": t(
"QEMU/KVM - Deploy an Ubuntu VM (libvirt)"
"Deploy - Install NTFY notification server"
)
},
]
@ -902,9 +903,9 @@ class TODO:
elif status == "3":
self.prompt_execute_deploy_ssh()
elif status == "4":
self._deploy_ntfy_server()
elif status == "5":
self.prompt_execute_qemu()
elif status == "5":
self._deploy_ntfy_server()
else:
print(t("Command not found !"))
@ -1193,6 +1194,11 @@ class TODO:
)
},
{"prompt_description": t("Statistics (installs, durations, VMs)")},
{
"prompt_description": t(
"SSH configuration (~/.ssh/config, ProxyJump)"
)
},
{"section": t("Catalog")},
{"prompt_description": t("List available images and specs")},
]
@ -1231,6 +1237,8 @@ class TODO:
elif status == "12":
self._qemu_stats()
elif status == "13":
self._qemu_ssh_config_menu()
elif status == "14":
self._qemu_list_images()
else:
cmd_no_found = True
@ -1247,6 +1255,388 @@ class TODO:
if cmd_no_found:
print(t("Command not found !"))
# Profondeur d'exploration par défaut : hôte -> VM -> VM imbriquée. Le
# profil « ERPLibre Déploiement (+ QEMU + dev) » installe QEMU DANS la VM,
# donc un parc à deux niveaux est le cas courant.
_QEMU_SSH_DEPTH = 2
# Sonde exécutée SUR une machine : un couple « nom<TAB>ip » par VM
# libvirt. Une seule connexion SSH par niveau plutôt qu'une par VM. Le
# bail dnsmasq peut manquer, d'où le repli sur l'agent invité.
_QEMU_SSH_PROBE = (
"for n in $(sudo virsh list --all --name 2>/dev/null); do "
'ip=$(sudo virsh domifaddr "$n" --source lease 2>/dev/null '
"| grep -oE '([0-9]{1,3}\\.){3}[0-9]{1,3}' | head -1); "
'if [ -z "$ip" ]; then '
'ip=$(sudo virsh domifaddr "$n" --source agent 2>/dev/null '
"| grep -oE '([0-9]{1,3}\\.){3}[0-9]{1,3}' "
"| grep -v '^127\\.' | head -1); fi; "
'printf "%s\\t%s\\n" "$n" "$ip"; done'
)
def _qemu_ssh_config_menu(self):
"""Écrit les entrées ~/.ssh/config du parc QEMU."""
print(f"🔑 {t('SSH configuration for QEMU VMs')}")
choices = [
{"prompt_description": t("Update ~/.ssh/config for local VMs")},
{
"prompt_description": t(
"Add nested VMs through ProxyJump (recursive)"
)
},
]
help_info = self.fill_help_info(choices)
while True:
status = click.prompt(help_info)
print()
if status == "0":
return False
elif status == "1":
self._qemu_ssh_config_local()
elif status == "2":
self._qemu_ssh_config_nested()
else:
print(t("Command not found !"))
def _qemu_pick_domains(self):
"""Fait choisir des VM parmi celles définies. Vide = toutes."""
names = self._qemu_list_domains()
if not names:
print(t("No VM found."))
return []
for i, name in enumerate(names, 1):
print(f" [{i}] {name}")
raw = input(
t("Which VMs? (numbers, comma-separated; blank = all): ")
).strip()
if not raw:
return names
chosen = self._parse_index_selection(raw, names)
return chosen or names
def _qemu_ssh_config_local(self):
"""Une entrée ~/.ssh/config par VM locale, comme le fait déjà le
déploiement — mais sur un parc déjà en place."""
names = self._qemu_pick_domains()
if not names:
return
deploy_key = self._is_yes_default_yes(
input(t("Create the SSH key if missing and deploy it? (Y/n): "))
)
ip_map = self._qemu_resolve_ips(names, timeout=60)
written = []
for name in names:
ip = ip_map.get(name)
if not ip:
print(f" ⏭ {name}: {t('no IP')}")
continue
self._write_ssh_config_entry(name, "erplibre", ip)
written.append(name)
print(
f"\n✅ {len(written)} {self._plural(t('entry'), len(written))}"
f" {t('written')} ({len(names) - len(written)} {t('skipped')})"
)
if deploy_key:
self._ssh_deploy_keys(written)
def _ssh_ensure_key(self):
"""Chemin de la clé PUBLIQUE, générée si aucune n'existe.
Sans clé, ssh-copy-id n'a rien à déployer. On en crée une ed25519 sans
passphrase — le même choix que `deploy_qemu.ensure_ssh_key`, pour que
les VM créées et celles adoptées ici partagent la même clé."""
existing = self._qemu_default_ssh_key()
if existing:
return existing
path = os.path.expanduser("~/.ssh/id_ed25519")
os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True)
print(f"🔑 {t('Generating an ed25519 SSH key')}: {path}")
try:
res = subprocess.run(
["ssh-keygen", "-t", "ed25519", "-N", "", "-f", path],
capture_output=True,
text=True,
timeout=60,
)
except (OSError, subprocess.SubprocessError) as exc:
print(f" ⚠ {t('Cannot generate the key')}: {exc}")
return ""
if res.returncode != 0:
print(f" ⚠ {t('Cannot generate the key')}: {res.stderr.strip()}")
return ""
return f"{path}.pub"
@staticmethod
def _ssh_key_accepted(alias):
"""Vrai si la connexion par CLÉ passe déjà (aucun mot de passe).
`PasswordAuthentication=no` est le point clé : sans lui, ssh
basculerait sur le mot de passe et on croirait la clé installée."""
try:
res = subprocess.run(
[
"ssh",
"-o",
"BatchMode=yes",
"-o",
"PasswordAuthentication=no",
"-o",
"ConnectTimeout=10",
alias,
"true",
],
capture_output=True,
timeout=45,
)
return res.returncode == 0
except (OSError, subprocess.SubprocessError):
return False
def _ssh_deploy_keys(self, aliases):
"""Déploie la clé publique sur les hôtes qui ne l'ont pas encore.
ssh-copy-id passe par ssh, donc par ~/.ssh/config : le ProxyJump d'une
VM imbriquée s'applique tout seul. Le mot de passe est demandé
directement dans le terminal (pas de capture de la sortie), sinon
l'invite serait invisible."""
if not aliases:
return
pub = self._ssh_ensure_key()
if not pub:
return
print(
f"\n🔑 {t('Deploying the key on')} {len(aliases)} "
f"{self._plural(t('host'), len(aliases))} ({pub})"
)
n_ok = n_skip = n_fail = 0
for alias in aliases:
if self._ssh_key_accepted(alias):
print(f" · {alias}: {t('key already accepted')}")
n_skip += 1
continue
print(f" ⤴ ssh-copy-id {alias}")
try:
res = subprocess.run(
["ssh-copy-id", "-i", pub, alias], timeout=180
)
ok = res.returncode == 0
except (OSError, subprocess.SubprocessError) as exc:
print(f" ⚠ {exc}")
ok = False
if ok:
n_ok += 1
else:
n_fail += 1
print(
f" {n_ok} {t('deployed')} · {n_skip} {t('already there')} · "
f"{n_fail} {t('failed')}"
)
# Connexions de virt-manager : stockées dans GSettings, pas dans un
# fichier. Le schéma est le même depuis des années (virt-manager 5.1
# inclus) ; on LIT d'abord, et on n'écrit que si la lecture a marché.
_VIRT_MANAGER_SCHEMA = "org.virt-manager.virt-manager.connections"
def _virt_manager_uris(self):
"""URI déjà connues de virt-manager, ou None s'il n'est pas là."""
if not shutil.which("virt-manager") or not shutil.which("gsettings"):
return None
try:
res = subprocess.run(
["gsettings", "get", self._VIRT_MANAGER_SCHEMA, "uris"],
capture_output=True,
text=True,
timeout=15,
)
except (OSError, subprocess.SubprocessError):
return None
if res.returncode != 0:
return None
# GSettings rend du littéral Python : ['a', 'b'] ou @as [].
raw = res.stdout.strip()
if raw.startswith("@as "):
raw = raw[4:].strip()
try:
value = ast.literal_eval(raw)
except (ValueError, SyntaxError):
return None
return [str(item) for item in value] if isinstance(value, list) else []
def _virt_manager_add(self, uris):
"""Ajoute les URI manquantes à virt-manager. Renvoie le nb ajouté."""
current = self._virt_manager_uris()
if current is None:
return 0
missing = [uri for uri in uris if uri not in current]
if not missing:
print(f" · {t('virt-manager: every connection already there')}")
return 0
merged = current + missing
literal = "[" + ", ".join(f"'{uri}'" for uri in merged) + "]"
try:
res = subprocess.run(
[
"gsettings",
"set",
self._VIRT_MANAGER_SCHEMA,
"uris",
literal,
],
capture_output=True,
text=True,
timeout=15,
)
except (OSError, subprocess.SubprocessError) as exc:
print(f" ⚠ virt-manager: {exc}")
return 0
if res.returncode != 0:
print(f" ⚠ virt-manager: {res.stderr.strip()}")
return 0
for uri in missing:
print(f" ✅ virt-manager: {uri}")
return len(missing)
def _virt_manager_offer(self, hosts):
"""Propose d'ajouter à virt-manager les machines qui font tourner
libvirt, pour piloter leurs VM depuis l'interface graphique locale.
On passe par l'ALIAS SSH et non par l'IP : le transport qemu+ssh
utilise le binaire ssh, donc ~/.ssh/config — l'alias porte déjà
l'adresse ET le ProxyJump, ce qu'une IP brute ne saurait pas faire
pour une VM imbriquée."""
if self._virt_manager_uris() is None:
return
uris = ["qemu:///system"] + [
f"qemu+ssh://erplibre@{alias}/system" for alias in hosts
]
print(f"\n🖥 {t('virt-manager detected')}")
for uri in uris:
print(f" {uri}")
if not self._is_yes_default_yes(
input(t("Add the missing connections to virt-manager? (Y/n): "))
):
return
added = self._virt_manager_add(uris)
if added:
# virt-manager réécrit ses réglages en quittant : s'il tourne, il
# écraserait ce qu'on vient d'ajouter.
print(f" ⚠ {t('Restart virt-manager if it is open.')}")
def _qemu_ssh_probe_remote(self, alias):
"""VM libvirt vues DEPUIS `alias` : [(nom, ip)].
Passe par « ssh <alias> », donc par le bloc ~/.ssh/config qu'on vient
d'écrire : le ProxyJump du parent s'applique tout seul et la même
sonde marche à n'importe quelle profondeur."""
cmd = [
"ssh",
"-o",
"BatchMode=yes",
"-o",
"ConnectTimeout=10",
alias,
self._QEMU_SSH_PROBE,
]
try:
res = subprocess.run(
cmd, capture_output=True, text=True, timeout=90
)
except (OSError, subprocess.SubprocessError):
return None
if res.returncode != 0:
return None
found = []
for line in res.stdout.splitlines():
parts = line.strip().split("\t")
if len(parts) == 2 and parts[0]:
found.append((parts[0], parts[1].strip()))
return found
def _qemu_ssh_config_nested(self):
"""Descend le parc en profondeur et écrit un ProxyJump par niveau.
Une VM du profil « Déploiement » héberge elle-même des VM : celles-ci
n'ont pas d'IP joignable depuis l'hôte, seulement depuis leur parent.
ProxyJump enchaîne les sauts, et la chaîne se construit d'elle-même
puisque le parent est déjà dans ~/.ssh/config quand on écrit l'enfant.
"""
roots = self._qemu_pick_domains()
if not roots:
return
raw = input(
f"{t('Depth (default:')} {self._QEMU_SSH_DEPTH}): "
).strip()
try:
max_depth = max(1, int(raw)) if raw else self._QEMU_SSH_DEPTH
except ValueError:
max_depth = self._QEMU_SSH_DEPTH
deploy_key = self._is_yes_default_yes(
input(t("Create the SSH key if missing and deploy it? (Y/n): "))
)
# Niveau 0 : les VM locales, jointes directement.
ip_map = self._qemu_resolve_ips(roots, timeout=60)
aliases = {} # alias -> (nom, ip, parent|None)
hosts_libvirt = [] # machines qui font tourner libvirt
frontier = []
for name in roots:
ip = ip_map.get(name)
if not ip:
print(f" ⏭ {name}: {t('no IP')}")
continue
self._write_ssh_config_entry(name, "erplibre", ip)
aliases[name] = (name, ip, None)
frontier.append(name)
for depth in range(1, max_depth + 1):
if not frontier:
break
# La clé est déployée AVANT de sonder : la sonde utilise
# BatchMode, donc sans clé acceptée elle échouerait et le niveau
# suivant resterait invisible.
if deploy_key:
self._ssh_deploy_keys(frontier)
print(
f"\n🔎 {t('Level')} {depth} — "
f"{len(frontier)} {t('machines to probe')}"
)
next_frontier = []
for parent in frontier:
found = self._qemu_ssh_probe_remote(parent)
if found is None:
print(f" ⏭ {parent}: {t('unreachable or no libvirt')}")
continue
hosts_libvirt.append(parent)
if not found:
print(f" · {parent}: {t('no nested VM')}")
continue
for child, ip in found:
if not ip:
print(f" ⏭ {parent} › {child}: {t('no IP')}")
continue
# Le nom court est plus agréable à taper ; on ne le
# préfixe du parent qu'en cas de collision, pour ne
# jamais écraser l'entrée d'une autre machine.
alias = child
if alias in aliases:
alias = f"{parent}+{child}"
if alias in aliases:
continue # déjà vu (cycle)
self._write_ssh_config_entry(
alias, "erplibre", ip, proxy_jump=parent
)
aliases[alias] = (child, ip, parent)
next_frontier.append(alias)
frontier = next_frontier
print(f"\n── {t('SSH hosts written')} ──")
for alias, (name, ip, parent) in aliases.items():
via = f" ({t('via')} {parent})" if parent else ""
print(f" ssh {alias:<34} {ip}{via}")
# Les machines qui hébergent des VM sont celles qui valent d'être
# ajoutées à virt-manager : c'est de là qu'on pilote leurs invitées.
self._virt_manager_offer(hosts_libvirt)
def _qemu_stats(self):
"""Statistiques d'utilisation de QEMU, et remise à zéro.
@ -2534,8 +2924,12 @@ class TODO:
print(f"{t('Will execute:')} {cmd}")
self.execute.exec_command_live(cmd, source_erplibre=False)
def _write_ssh_config_entry(self, host, user, ip):
"""Écrit/remplace un bloc « Host <host> » dans ~/.ssh/config."""
def _write_ssh_config_entry(self, host, user, ip, proxy_jump=None):
"""Écrit/remplace un bloc « Host <host> » dans ~/.ssh/config.
`proxy_jump` : alias du rebond pour une VM imbriquée, dont l'IP n'est
joignable que depuis son hôte. OpenSSH enchaîne les ProxyJump tout
seul dès que le parent a lui-même le sien."""
cfg = os.path.expanduser("~/.ssh/config")
os.makedirs(os.path.dirname(cfg), exist_ok=True)
existing = ""
@ -2559,6 +2953,8 @@ class TODO:
f" StrictHostKeyChecking no\n"
f" UserKnownHostsFile /dev/null\n"
)
if proxy_jump:
block += f" ProxyJump {proxy_jump}\n"
content = (existing + "\n\n" + block) if existing else block
with open(cfg, "w", encoding="utf-8") as fh:
fh.write(content)

View file

@ -1399,10 +1399,160 @@ TRANSLATIONS = {
"fr": "Choix (numéro, vide = garder) :",
"en": "Choice (number, blank = keep):",
},
"SSH configuration (~/.ssh/config, ProxyJump)": {
"fr": "🔑 Configuration SSH (~/.ssh/config, ProxyJump)",
"en": "🔑 SSH configuration (~/.ssh/config, ProxyJump)",
},
"SSH configuration for QEMU VMs": {
"fr": "Configuration SSH des VM QEMU",
"en": "SSH configuration for QEMU VMs",
},
"Update ~/.ssh/config for local VMs": {
"fr": "📝 Mettre à jour ~/.ssh/config pour les VM locales",
"en": "📝 Update ~/.ssh/config for local VMs",
},
"Add nested VMs through ProxyJump (recursive)": {
"fr": "🪜 Ajouter les VM imbriquées via ProxyJump (récursif)",
"en": "🪜 Add nested VMs through ProxyJump (recursive)",
},
"Which VMs? (numbers, comma-separated; blank = all): ": {
"fr": "Quelles VM ? (numéros séparés par des virgules ; "
"vide = toutes) : ",
"en": "Which VMs? (numbers, comma-separated; blank = all): ",
},
"Depth (default:": {
"fr": "Profondeur (défaut :",
"en": "Depth (default:",
},
"entry": {
"fr": "entrée",
"en": "entry",
},
"written": {
"fr": "écrite(s)",
"en": "written",
},
"skipped": {
"fr": "ignorée(s)",
"en": "skipped",
},
"Level": {
"fr": "Niveau",
"en": "Level",
},
"machines to probe": {
"fr": "machines à sonder",
"en": "machines to probe",
},
"unreachable or no libvirt": {
"fr": "injoignable ou sans libvirt",
"en": "unreachable or no libvirt",
},
"no nested VM": {
"fr": "aucune VM imbriquée",
"en": "no nested VM",
},
"Create the SSH key if missing and deploy it? (Y/n): ": {
"fr": "Créer la clé SSH si absente et la déployer ? "
"(O/n, défaut : oui) : ",
"en": "Create the SSH key if missing and deploy it? "
"(Y/n, default: yes): ",
},
"Generating an ed25519 SSH key": {
"fr": "Génération d'une clé SSH ed25519",
"en": "Generating an ed25519 SSH key",
},
"Cannot generate the key": {
"fr": "Impossible de générer la clé",
"en": "Cannot generate the key",
},
"Deploying the key on": {
"fr": "Déploiement de la clé sur",
"en": "Deploying the key on",
},
"host": {
"fr": "hôte",
"en": "host",
},
"key already accepted": {
"fr": "clé déjà acceptée",
"en": "key already accepted",
},
"deployed": {
"fr": "déployée(s)",
"en": "deployed",
},
"already there": {
"fr": "déjà en place",
"en": "already there",
},
"virt-manager detected": {
"fr": "virt-manager détecté",
"en": "virt-manager detected",
},
"virt-manager: every connection already there": {
"fr": "virt-manager : toutes les connexions sont déjà là",
"en": "virt-manager: every connection already there",
},
"Add the missing connections to virt-manager? (Y/n): ": {
"fr": "Ajouter les connexions manquantes à virt-manager ? "
"(O/n, défaut : oui) : ",
"en": "Add the missing connections to virt-manager? "
"(Y/n, default: yes): ",
},
"Restart virt-manager if it is open.": {
"fr": "Redémarrer virt-manager s'il est ouvert.",
"en": "Restart virt-manager if it is open.",
},
"SSH hosts written": {
"fr": "Hôtes SSH écrits",
"en": "SSH hosts written",
},
"via": {
"fr": "via",
"en": "via",
},
"Odoo migration interface": {
"fr": "🚚 Interface de la migration Odoo",
"en": "🚚 Odoo migration interface",
},
"modules of the list have no code in the active Odoo:": {
"fr": "modules de la liste n'ont plus de code dans l'Odoo actif :",
"en": "modules of the list have no code in the active Odoo:",
},
"Odoo cannot uninstall a module whose code is gone;": {
"fr": "Odoo ne peut pas désinstaller un module dont le code a"
" disparu ;",
"en": "Odoo cannot uninstall a module whose code is gone;",
},
"one of them fails the whole uninstall command.": {
"fr": "un seul d'entre eux fait échouer toute la désinstallation.",
"en": "one of them fails the whole uninstall command.",
},
"are present and can be uninstalled:": {
"fr": "sont présents et peuvent être désinstallés :",
"en": "are present and can be uninstalled:",
},
"No module of the list is present.": {
"fr": "Aucun module de la liste n'est présent.",
"en": "No module of the list is present.",
},
"Uninstall the present ones, skip the missing": {
"fr": "Désinstaller les présents, ignorer les manquants",
"en": "Uninstall the present ones, skip the missing",
},
"Try the whole list anyway (it will fail)": {
"fr": "Tout tenter quand même (ça échouera)",
"en": "Try the whole list anyway (it will fail)",
},
"Uninstall nothing, continue": {
"fr": "Ne rien désinstaller, continuer",
"en": "Uninstall nothing, continue",
},
"Nothing uninstalled.": {
"fr": "Rien de désinstallé.",
"en": "Nothing uninstalled.",
},
"No migration in progress to resume.": {
"fr": "Aucune migration en cours à reprendre.",
"en": "No migration in progress to resume.",

View file

@ -2264,18 +2264,86 @@ class TodoUpgrade:
lst_detail.append((module_name, reason, file_path))
return lst_module, lst_detail
def split_present_missing(self, lst_module):
"""Split a module list into (present, missing) against the ACTIVE code.
« Missing » means the addons path no longer holds the module — not
that it is absent from the database. The distinction matters because
check_addons_exist.py refuses the WHOLE uninstall for a single missing
name, so the modules that ARE there never get uninstalled either.
"""
lst_missing, _lst_duplicate = self.check_addons_exist(lst_module)
set_missing = set(lst_missing or [])
return (
[name for name in lst_module if name not in set_missing],
[name for name in lst_module if name in set_missing],
)
def prompt_uninstall_missing(self, lst_present, lst_missing):
"""Ask what to do when part of the list has no code left.
Returns the list to actually uninstall (possibly empty)."""
print()
print(
f"⚠️ {len(lst_missing)} "
f"{t('modules of the list have no code in the active Odoo:')}"
)
for name in lst_missing:
print(f" {name}")
print(f" {t('Odoo cannot uninstall a module whose code is gone;')}")
print(f" {t('one of them fails the whole uninstall command.')}")
print()
if lst_present:
print(
f" {len(lst_present)} "
f"{t('are present and can be uninstalled:')}"
)
for name in lst_present:
print(f" {name}")
else:
print(f" {t('No module of the list is present.')}")
print()
if lst_present:
print(
f" [1] {t('Uninstall the present ones, skip the missing')}"
" *"
)
print(f" [2] {t('Try the whole list anyway (it will fail)')}")
print(f" [3] {t('Uninstall nothing, continue')}")
answer = input(f"💬 {t('Your choice')} : ").strip()
if answer == "2":
return lst_present + lst_missing
if answer == "3" or not lst_present:
return []
return lst_present
def uninstall_from_database(
self, lst_module_to_uninstall, database_name, actual_version
):
if not lst_module_to_uninstall:
return
# Sort out what the active code still holds BEFORE calling the script:
# it aborts on the first missing name and takes the rest down with it.
lst_present, lst_missing = self.split_present_missing(
lst_module_to_uninstall
)
if lst_missing:
self.add_comment_progression(
"uninstall - no code for: " + ", ".join(lst_missing)
)
lst_module_to_uninstall = self.prompt_uninstall_missing(
lst_present, lst_missing
)
if not lst_module_to_uninstall:
print(f"⏭ {t('Nothing uninstalled.')}")
return
uninstall_module = ",".join(lst_module_to_uninstall)
self.todo_upgrade_execute(
f"./script/addons/uninstall_addons.sh {database_name} {uninstall_module}",
single_source_odoo=True,
)
# Update list installed module
# Update list installed module — only what was REALLY uninstalled, so
# a module left in place stays counted as installed.
self.dct_module_per_version[actual_version] = sorted(
list(
set(self.dct_module_per_version[actual_version])