Compare commits
3 commits
d40e6aa011
...
b197cc01c5
| Author | SHA1 | Date | |
|---|---|---|---|
| b197cc01c5 | |||
| b8bfb0f8e0 | |||
| 7d96b56fb4 |
3 changed files with 621 additions and 7 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
Loading…
Reference in a new issue