Compare commits

...

3 commits

Author SHA1 Message Date
dcb1af4880 [IMP] qemu: offer to install libvirt, and to create a missing SSH key
Two dead ends the menu let you walk into.

Without libvirt, every entry answered « sudo: virsh: command not found » and
carried on as if nothing had happened — « Aucune VM trouvée » reads like an
empty host, not a missing package. Entering the QEMU menu now checks for virsh
and offers to install it. The packages are not guessed here: deploy_qemu.py
--setup-host already knows them per distribution, so the offer just runs it.
Refusing keeps the menu open — listing images or previewing a deployment needs
no libvirt. If virsh is still absent afterwards, that is said too: on a
rolling-release kernel the setup can need a reboot before the modules load.

Without an SSH key, « Chemin de la clé publique SSH (aucune): » accepted an
empty answer and deployed VMs nobody could log into — cloud-init injects no
key, so there is no install, no check, nothing but a console. The prompt now
says what the consequence is and offers to generate one, reusing the same
_ssh_ensure_key as the SSH configuration tool so the whole fleet shares one
key.

Verified: silence when virsh is present, the sudo --setup-host command issued
when accepted and nothing run when refused; and on an empty ~/.ssh, the key
generated, both halves on disk, and its public path carried into the spec.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-02 05:56:06 -04:00
3e53cbd47d [IMP] deploy: probe the remote port before opening the tunnel
A stopped service on the far side showed up only as a wall of « channel N:
open failed: connect failed: Connection refused », one line per browser
request, saying nothing about WHICH end refused. The tunnel itself was fine;
there was simply nothing to reach. Diagnosing it took three commands.

The port is now probed first, and the answer is plain:

  ⚠ Rien n'écoute sur le port 8069 de test-vm_02+erplibre-ubuntu-2404
    Démarrer le service là-bas, ou continuer quand même.
  Continuer quand même ? (o/N)

The probe opens a real TCP connection to « localhost:<port> » from the remote
host rather than reading its listening table. That is exactly what the tunnel
will do — same host resolution, same IPv4/IPv6 choice — so it cannot say open
where the tunnel would fail. It also needs no ss or netstat, which minimal
images lack.

Three outcomes, three behaviours: listening goes straight through, closed
warns and asks (default no), and an inconclusive probe — unreachable host, no
bash — says so and continues rather than blocking on its own uncertainty.

Verified against the real VM: port 22 open, port 9999 closed, an unknown host
inconclusive, and port 8069 correctly reported closed after the Odoo service
had been stopped — the very case that prompted this. Then at flow level:
refusing aborts without opening anything, forcing opens the tunnel anyway, and
an inconclusive probe still opens it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-02 05:09:55 -04:00
2997dfeb26 [IMP] migration: offer the COW checker at the error prompt
A failed update_addons_all left one option, « [1] to redo the command » —
useless against the failure that actually causes it, since redoing an upgrade
whose COW copy has drifted fails again identically. The prompt now offers to
run reset_stale_cow_views on the database concerned:

  [1] to redo the command
  [2] Check the COW views that drifted (technolibre_…_upgrade_15)

Choosing [2] runs the checker and comes back to the same prompt, so the usual
sequence — look, repair, redo — happens without leaving the migration.

The database is read from the failed command itself, since the executor is not
told which one it is. Three forms are recognised: « -d <db> », « --database
<db> », and the positional argument of the addons scripts. No database found,
no [2] offered — « make format » gets the old prompt unchanged.

The option only ever REPORTS. Resetting a copy can erase a real customisation,
so it stays a separate deliberate command, printed with the exact line to run
and a reminder to read the diff first.

Verified: the six command forms parsed correctly (and two that hold no
database at all), [2] invoking the tool then re-offering the choice, no [2] on
a command without a database, and the whole chain against the real
_upgrade_15, where it prints the two drifted copies followed by the reset
line.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-02 04:36:25 -04:00
3 changed files with 235 additions and 10 deletions

View file

@ -1168,12 +1168,38 @@ class TODO:
print(f"{t('Will execute:')} {cmd}")
self.execute.exec_command_live(cmd, source_erplibre=False)
def _qemu_ensure_tools(self):
"""virsh absent : proposer l'installation plutôt que de laisser
chaque commande échouer sur « sudo: virsh: command not found ».
deploy_qemu.py --setup-host connaît les paquets de chaque
distribution ; on ne devine donc rien ici, on le délègue."""
if shutil.which("virsh"):
return True
print(f"\n⚠ {t('virsh is missing: libvirt is not installed here.')}")
print(f" {t('Every VM command will fail until it is.')}")
if not self._is_yes_default_yes(
input(t("Install the QEMU/libvirt tools now? (Y/n): "))
):
return False
cmd = f"sudo {self._QEMU_QEMU_PKGS}"
print(f"{t('Will execute:')} {cmd}")
self.execute.exec_command_live(cmd, source_erplibre=False)
if shutil.which("virsh"):
print(f"✅ {t('libvirt is available.')}")
return True
# Sur une distribution à noyau roulant, --setup-host peut demander un
# redémarrage avant que les modules soient chargeables.
print(f"⚠ {t('virsh still missing; a reboot may be required.')}")
return False
def prompt_execute_qemu(self):
print(f"🤖 {t('Deploy a QEMU/KVM virtual machine (libvirt)!')}")
script_path = self._qemu_script_path()
if not os.path.isfile(script_path):
print(f"{t('QEMU deploy script not found: ')}{script_path}")
return False
self._qemu_ensure_tools()
choices = [
{"section": t("Deployment")},
{"prompt_description": t("Deploy VM(s) (one or many)")},
@ -5035,8 +5061,16 @@ class TODO:
"""Invites en ligne : clé SSH, installation ERPLibre, ~/.ssh/config,
parallélisme, puis récapitulatif et confirmation.
Renvoie la spec complète, ou None si l'utilisateur renonce."""
# Clé SSH (partagée par tout le parc).
# Clé SSH (partagée par tout le parc). Sans clé, cloud-init n'en
# injecte aucune : la VM démarre sans accès SSH, donc sans
# installation ni vérification possibles. On propose donc d'en créer
# une plutôt que de laisser passer un déploiement inutilisable.
default_key = self._qemu_default_ssh_key()
if not default_key:
print(f"\n⚠ {t('No SSH public key found in ~/.ssh.')}")
print(f" {t('Without one the VMs start with no SSH access.')}")
if self._is_yes_default_yes(input(t("Generate one now? (Y/n): "))):
default_key = self._ssh_ensure_key()
key_hint = default_key or t("none")
ssh_key = input(f"{t('SSH public key path')} ({key_hint}): ").strip()
if not ssh_key:
@ -5372,6 +5406,43 @@ class TODO:
except OSError:
return False
@staticmethod
def _remote_port_open(host, port):
"""Quelqu'un écoute-t-il sur ce port DEPUIS l'hôte distant ?
True / False / None quand on n'a pas pu conclure (hôte injoignable,
pas de bash). On teste une vraie connexion TCP vers « localhost » et
non la table d'écoute : c'est exactement ce que fera le tunnel, y
compris le choix IPv4/IPv6 de la résolution.
"""
probe = (
f"exec 3<>/dev/tcp/localhost/{int(port)} && echo OPEN"
" || echo CLOSED"
)
try:
res = subprocess.run(
[
"ssh",
"-o",
"BatchMode=yes",
"-o",
"ConnectTimeout=10",
host,
f"bash -c {shlex.quote(probe)} 2>/dev/null",
],
capture_output=True,
text=True,
timeout=45,
)
except (OSError, subprocess.SubprocessError):
return None
out = res.stdout.strip()
if "OPEN" in out:
return True
if "CLOSED" in out:
return False
return None
def _deploy_port_forward(self):
"""Ouvre un tunnel SSH pour joindre un service distant depuis le
navigateur local.
@ -5397,6 +5468,22 @@ class TODO:
raw = input(f"{t('Local port (default:')} {remote}): ").strip()
local = raw if raw.isdigit() else remote
# Sonde AVANT d'ouvrir : sans elle, un service arrêté à l'autre bout
# ne se manifeste que par un mur de « channel N: open failed » à
# chaque requête du navigateur, qui ne dit pas d'où vient le refus.
print(f" {t('Checking the remote port...')}")
listening = self._remote_port_open(host, remote)
if listening is False:
print(
f" ⚠ {t('Nothing is listening on port')} {remote}"
f" {t('of')} {host}"
)
print(f" {t('Start the service there, or continue anyway.')}")
if not self._is_yes(input(t("Continue anyway? (y/N): "))):
return
elif listening is None:
print(f" ℹ️ {t('Could not probe the remote port; going on.')}")
if not self._port_is_free(local):
print(f" ⚠ {t('Local port already in use:')} {local}")
if not self._is_yes(input(t("Try anyway? (y/N): "))):

View file

@ -1419,6 +1419,63 @@ TRANSLATIONS = {
"fr": "Port local (défaut :",
"en": "Local port (default:",
},
"virsh is missing: libvirt is not installed here.": {
"fr": "virsh est absent : libvirt n'est pas installé ici.",
"en": "virsh is missing: libvirt is not installed here.",
},
"Every VM command will fail until it is.": {
"fr": "Toutes les commandes VM échoueront tant que ce sera le cas.",
"en": "Every VM command will fail until it is.",
},
"Install the QEMU/libvirt tools now? (Y/n): ": {
"fr": "Installer les outils QEMU/libvirt maintenant ? "
"(O/n, défaut : oui) : ",
"en": "Install the QEMU/libvirt tools now? (Y/n, default: yes): ",
},
"libvirt is available.": {
"fr": "libvirt est disponible.",
"en": "libvirt is available.",
},
"virsh still missing; a reboot may be required.": {
"fr": "virsh toujours absent ; un redémarrage est peut-être requis.",
"en": "virsh still missing; a reboot may be required.",
},
"No SSH public key found in ~/.ssh.": {
"fr": "Aucune clé publique SSH trouvée dans ~/.ssh.",
"en": "No SSH public key found in ~/.ssh.",
},
"Without one the VMs start with no SSH access.": {
"fr": "Sans elle, les VM démarrent sans accès SSH.",
"en": "Without one the VMs start with no SSH access.",
},
"Generate one now? (Y/n): ": {
"fr": "En générer une maintenant ? (O/n, défaut : oui) : ",
"en": "Generate one now? (Y/n, default: yes): ",
},
"Checking the remote port...": {
"fr": "Vérification du port distant...",
"en": "Checking the remote port...",
},
"Nothing is listening on port": {
"fr": "Rien n'écoute sur le port",
"en": "Nothing is listening on port",
},
"of": {
"fr": "de",
"en": "of",
},
"Start the service there, or continue anyway.": {
"fr": "Démarrer le service là-bas, ou continuer quand même.",
"en": "Start the service there, or continue anyway.",
},
"Continue anyway? (y/N): ": {
"fr": "Continuer quand même ? (o/N, défaut : non) : ",
"en": "Continue anyway? (y/N, default: no): ",
},
"Could not probe the remote port; going on.": {
"fr": "Impossible de sonder le port distant ; on continue.",
"en": "Could not probe the remote port; going on.",
},
"Local port already in use:": {
"fr": "Port local déjà occupé :",
"en": "Local port already in use:",
@ -1609,6 +1666,23 @@ TRANSLATIONS = {
"fr": "Rien de désinstallé.",
"en": "Nothing uninstalled.",
},
"Check the COW views that drifted": {
"fr": "Vérifier les vues COW en retard sur leur vue module",
"en": "Check the COW views that drifted",
},
"Tool not found": {
"fr": "Outil introuvable",
"en": "Tool not found",
},
"To reset one of them onto its module view:": {
"fr": "Pour réinitialiser l'une d'elles sur sa vue module :",
"en": "To reset one of them onto its module view:",
},
"Read the diff first: a copy can hold a customisation.": {
"fr": "Lire le diff d'abord : une copie peut porter une"
" personnalisation.",
"en": "Read the diff first: a copy can hold a customisation.",
},
"Migration statistics (read-only)": {
"fr": "📊 Statistiques de la migration (lecture seule)",
"en": "📊 Migration statistics (read-only)",

View file

@ -6,6 +6,7 @@ import datetime
import json
import logging
import os
import re
import shutil
import sys
import zipfile
@ -312,6 +313,52 @@ class TodoUpgrade:
answer = input(t("Choice (0-3, default 1): ")).strip()
return {"0": None, "2": "cli", "3": "stats"}.get(answer, "tui")
@staticmethod
def database_from_command(cmd):
"""Nom de base visé par une commande, ou "" si indécelable.
Sert à proposer le bon outil au bon moment quand une commande échoue.
On reconnaît les trois formes du dépôt : « -d <base> », « --database
<base> », et l'argument positionnel des scripts addons
(`update_addons_all.sh <base>`, `install_addons*.sh <base> <modules>`).
"""
if not cmd:
return ""
match = re.search(r"(?:^|\s)(?:-d|--database)[=\s]+([\w.-]+)", cmd)
if match:
return match.group(1)
match = re.search(
r"\./script/addons/\w+\.sh\s+([\w.-]+)",
cmd,
)
return match.group(1) if match else ""
def check_stale_cow_views(self, database_name):
"""Lance le détecteur de copies COW en retard sur leur vue module.
Purement consultatif : il n'écrit rien sans « --reset … --apply », que
l'on propose seulement après avoir montré le diff — réinitialiser une
copie peut effacer une personnalisation réelle."""
script_path = os.path.join(
PATH_MIGRATION_GLOBAL, "reset_stale_cow_views.py"
)
if not os.path.exists(script_path):
print(f"⚠️ {t('Tool not found')}: {script_path}")
return
status, _cmd = self.todo_upgrade_execute(
f"{PYTHON_BIN} ./{script_path} -d {database_name}",
wait_at_error=False,
)
if status:
# Sortie 1 = des écarts ont été trouvés (le script les a listés).
warn = t("Read the diff first: a copy can hold a customisation.")
print(f"\n💡 {t('To reset one of them onto its module view:')}")
print(
f" {PYTHON_BIN} ./{script_path} -d {database_name}"
f" --reset <key> --apply"
)
print(f" {warn}")
def show_stats(self):
"""Écran de statistiques, en lecture seule : rien n'est écrit, ni
dans la base ni dans le journal de migration."""
@ -2650,17 +2697,34 @@ class TodoUpgrade:
# failure, never as a success (defence in depth: exec_command_live now
# always sets one, but a silent None must not skip this prompt).
if (status is None or status) and wait_at_error:
print("[1] to redo the command")
wait_status = (
input(
"💬 Error detected, press to continue or ctrl+c to stop : "
database_name = self.database_from_command(cmd)
while True:
print("[1] to redo the command")
if database_name:
print(
f"[2] {t('Check the COW views that drifted')}"
f" ({database_name})"
)
wait_status = (
input(
"💬 Error detected, press to continue or ctrl+c to"
" stop : "
)
.strip()
.lower()
)
.strip()
.lower()
)
# psycopg2.errors.UndefinedTable: relation "discuss_channel" does not exist
# LIGNE 1 : SELECT "discuss_channel"."id" FROM "discuss_channel" WHERE (...
# psycopg2.errors.UndefinedTable: relation "discuss_channel" does not exist
# LIGNE 1 : SELECT "discuss_channel"."id" FROM "discuss_channel" WHERE (...
if wait_status == "2" and database_name:
# Le motif d'échec le plus fréquent ici est « Element
# <xpath …> cannot be located in parent view » : une copie
# COW en retard sur sa vue module. On propose l'outil sur
# place, puis on repose le choix pour rejouer.
self.check_stale_cow_views(database_name)
continue
break
if wait_status == "1":
return self.todo_upgrade_execute(