Compare commits

..

No commits in common. "b197cc01c5bc337f969ad72933eda2c0863e8df0" and "d40e6aa011b58a1ce346e1a15dbe67f48f0e3ba9" have entirely different histories.

3 changed files with 7 additions and 621 deletions

View file

@ -2,7 +2,6 @@
# © 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
@ -880,12 +879,12 @@ class TODO:
{"prompt_description": t("SSH (remote host)...")},
{
"prompt_description": t(
"QEMU/KVM - Deploy an Ubuntu VM (libvirt)"
"Deploy - Install NTFY notification server"
)
},
{
"prompt_description": t(
"Deploy - Install NTFY notification server"
"QEMU/KVM - Deploy an Ubuntu VM (libvirt)"
)
},
]
@ -903,9 +902,9 @@ class TODO:
elif status == "3":
self.prompt_execute_deploy_ssh()
elif status == "4":
self.prompt_execute_qemu()
elif status == "5":
self._deploy_ntfy_server()
elif status == "5":
self.prompt_execute_qemu()
else:
print(t("Command not found !"))
@ -1194,11 +1193,6 @@ 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")},
]
@ -1237,8 +1231,6 @@ 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
@ -1255,388 +1247,6 @@ 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.
@ -2924,12 +2534,8 @@ 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, 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."""
def _write_ssh_config_entry(self, host, user, ip):
"""Écrit/remplace un bloc « Host <host> » dans ~/.ssh/config."""
cfg = os.path.expanduser("~/.ssh/config")
os.makedirs(os.path.dirname(cfg), exist_ok=True)
existing = ""
@ -2953,8 +2559,6 @@ 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,160 +1399,10 @@ 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,86 +2264,18 @@ 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 — only what was REALLY uninstalled, so
# a module left in place stays counted as installed.
# Update list installed module
self.dct_module_per_version[actual_version] = sorted(
list(
set(self.dct_module_per_version[actual_version])