[ADD] qemu monitor: act on a VM without leaving the dashboard

The monitor could only watch. The "a" key now opens the selected VM's
actions: update, restart Odoo, delete.

The update is chosen by parts, and the order is not free: system packages,
then git repositories, then Python dependencies -- the latter compile
against the former. Nothing is chained with "&&": one failing part must
not take the others down. Deleting demands a second hand, a confirmation
screen naming the disk to be erased.

Two defects the dashboard revealed. Detached installs were stealing the
shell's keystrokes, the child inheriting a terminal it had no business
reading. And the elapsed time restarted from zero every time the dashboard
was reopened, since it was counted from the view rather than from the
run's own first write.

--- FR ---

Le suivi ne savait que regarder. La touche « a » ouvre désormais les
actions de la VM sélectionnée : mettre à jour, redémarrer Odoo, supprimer.

La mise à jour se choisit par parties, et l'ordre n'est pas libre :
paquets système, puis dépôts git, puis dépendances Python — ces dernières
compilent contre les premiers. Rien n'est enchaîné par « && » : une partie
en échec ne doit pas emporter les autres. Supprimer exige une seconde
main, un écran de confirmation nommant le disque à effacer.

Deux défauts que le tableau de bord a révélés. Les installations détachées
volaient les frappes du shell, l'enfant héritant d'un terminal qu'il
n'avait pas à lire. Et la durée repartait de zéro à chaque réouverture,
comptée depuis la vue plutôt que depuis la première écriture du run.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-08-12 03:53:04 -04:00
parent 3fda367151
commit 18f5ddd69f

View file

@ -37,6 +37,11 @@ SSH_OPTS = (
"-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "
"-o ConnectTimeout=8"
)
# Pour les ssh NON INTERACTIFS : « -n » branche leur entrée sur /dev/null.
# Ceinture et bretelles avec le stdin du processus détaché — un ssh qui lit le
# terminal vole les frappes du shell, et le diagnostic est très difficile.
# Jamais pour un ssh interactif (touche « s »), qui doit garder le clavier.
SSH_OPTS_BATCH = f"{SSH_OPTS} -n"
def session_dir() -> Path:
@ -184,7 +189,7 @@ def _launch_one(
f"seen=0; "
f"for i in $(seq 1 240); do "
f"{refresh}"
f'st=$(ssh {SSH_OPTS} -o BatchMode=yes "erplibre@$ip" '
f'st=$(ssh {SSH_OPTS_BATCH} -o BatchMode=yes "erplibre@$ip" '
f"{shlex.quote(ci_probe)} 2>/dev/null); "
f'case "$st" in '
f"*done*|*disabled*|*error*|*degraded*|*nocloudinit*) seen=1; break;; "
@ -200,13 +205,22 @@ def _launch_one(
f"echo {shlex.quote('== ' + msg_ready + ' ==')} >> {log_q}; "
f"else echo {shlex.quote('== ' + msg_giveup + ' ==')} >> {log_q}; fi; "
f'echo " → $ip" >> {log_q}; '
f'ssh {SSH_OPTS} "erplibre@$ip" {shlex.quote(remote_cmd)} '
f'ssh {SSH_OPTS_BATCH} "erplibre@$ip" {shlex.quote(remote_cmd)} '
f">> {log_q} 2>&1; "
f'echo "{EXIT_MARKER} $?" >> {log_q}'
)
# setsid -f : le process survit à la fermeture du menu / du dashboard.
# stdin sur /dev/null : SANS lui, le descripteur 0 du processus détaché
# reste le TERMINAL. « setsid » lui retire le terminal de contrôle, mais ne
# ferme aucun descripteur — et ssh, lui, LIT son entrée pour la transmettre
# à la commande distante. Deux lecteurs se partagent alors le clavier : une
# frappe sur deux part vers l'installation au lieu du shell, et il faut
# appuyer plusieurs fois pour qu'une lettre arrive. Le symptôme survit à
# todo.py, puisque l'installation continue une demi-heure après sa
# fermeture — d'où un terminal qui « bogue » sans cause visible.
subprocess.Popen(
["setsid", "-f", "bash", "-c", wrapper],
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
@ -277,6 +291,20 @@ def launch_installs(vms: list[dict], branch: str, remote_cmd: str) -> str:
return manifest_path
def finished_at(log_path: str, fallback: float) -> float:
"""Instant où l'installation s'est RÉELLEMENT arrêtée.
La dernière écriture dans le log, c'est-à-dire le marqueur de sortie. On
ne peut pas prendre « maintenant » : le suivi est détachable, et il
observe souvent l'état final longtemps après. Rouvrir le tableau de bord
une heure plus tard ajoutait cette heure à la durée affichée, comme si
l'installation avait tourné pendant tout ce temps."""
try:
return os.path.getmtime(log_path)
except OSError:
return fallback
def read_status(log_path: str) -> tuple[str, int | None]:
"""(état, code) d'un log : pending / running / done / failed. Ne lit que
la FIN du fichier (le marqueur de sortie est sur la dernière ligne) : lire
@ -814,13 +842,118 @@ def virsh_domstates() -> dict:
# --------------------------------------------------------------------------- #
# Dashboard Textual
# --------------------------------------------------------------------------- #
# Parties d'une remise à jour, dans l'ordre où elles doivent tourner : les
# paquets système d'abord (compilateurs et en-têtes), le code ensuite, les
# dépendances Python en dernier — elles se compilent contre les deux premiers.
UPDATE_PARTS = (
("system", "Paquets systeme (apt/dnf/pacman/zypper)"),
("git", "Depots git (ERPLibre + addons)"),
("python", "Dependances Python (poetry)"),
)
EL_DIR = "~/git/erplibre"
def update_remote_cmd(parts) -> str:
"""Commande exécutée DANS la VM pour remettre à jour ce qui est coché.
Rien n'est enchaîné par « && » : une partie qui échoue ne doit pas
empêcher les suivantes, et son code de retour se lit dans la sortie.
Chaque bloc s'annonce, sinon un log de mise à jour est illisible."""
chosen = [p for p in parts if p in dict(UPDATE_PARTS)]
if not chosen:
return ""
out = ["set -u"]
if "system" in chosen:
out.append(
'echo "== Paquets systeme =="; '
"if command -v apt-get >/dev/null 2>&1; then "
"sudo apt-get -o DPkg::Lock::Timeout=600 update -qq "
"&& sudo DEBIAN_FRONTEND=noninteractive "
"apt-get -o DPkg::Lock::Timeout=600 -y upgrade; "
"elif command -v dnf >/dev/null 2>&1; then "
"sudo dnf -y upgrade --refresh; "
"elif command -v pacman >/dev/null 2>&1; then "
# Arch ne supporte pas la mise à jour partielle : -Syu, jamais -S.
"pgrep -x pacman >/dev/null 2>&1 "
"|| sudo rm -f /var/lib/pacman/db.lck; "
"sudo pacman -Syu --noconfirm; "
"elif command -v zypper >/dev/null 2>&1; then "
". /etc/os-release; "
'case "$ID" in *tumbleweed*) '
"sudo zypper --non-interactive dup "
"--auto-agree-with-licenses --allow-vendor-change;; "
"*) sudo zypper --non-interactive up "
"--auto-agree-with-licenses;; esac; "
'else echo "Gestionnaire de paquets inconnu"; fi; '
)
if "git" in chosen:
out.append(
'echo "== Depots git =="; '
f"cd {EL_DIR} || exit 1; "
# --ff-only : une VM ne doit jamais fusionner toute seule. Un
# historique divergent s'arrête ici, visiblement.
"git pull --ff-only; "
# Les addons viennent de Google Repo, pas de git : c'est le script
# de l'installation qui sait les synchroniser.
"./script/install/install_git_repo.sh; "
)
if "python" in chosen:
out.append(
'echo "== Dependances Python =="; '
f"cd {EL_DIR} || exit 1; "
# La phase « poetry » seule : ni venvs ni repo, juste les paquets.
"EL_PHASE=poetry ./script/install/install_locally.sh; "
)
out.append('echo "== Mise a jour terminee =="')
return "".join(out[:1]) + "; " + "".join(out[1:])
def restart_odoo_cmd() -> str:
"""Redémarre le service ERPLibre, ou le lance à la main s'il n'existe pas.
Une VM installée sans profil de production n'a pas forcément l'unité
systemd : le repli dit quoi faire plutôt que d'échouer sans un mot."""
return (
"if systemctl list-unit-files 2>/dev/null | grep -q '^erplibre'; then "
"sudo systemctl restart erplibre.service "
"&& sudo systemctl --no-pager --lines=15 status erplibre.service; "
"else echo 'Pas de service erplibre : lancez ./run.sh dans "
f"{EL_DIR}'; fi"
)
def delete_vm_cmd(name: str, with_disks: bool) -> str:
"""Efface la VM sur l'HÔTE. Même séquence que « TODO._qemu_delete_vm » :
arrêt, retrait de la définition (nvram si UEFI, repli sinon), puis les
disques à la demande."""
q = shlex.quote(name)
cmd = (
f"sudo virsh destroy {q} 2>/dev/null; "
f"sudo virsh undefine {q} --nvram 2>/dev/null "
f"|| sudo virsh undefine {q}"
)
if with_disks:
disk = shlex.quote(f"/var/lib/libvirt/images/{name}.qcow2")
seed = shlex.quote(f"/var/lib/libvirt/images/iso/{name}-seed.iso")
cmd += f"; sudo rm -f {disk} {seed}"
return cmd
def run_monitor(manifest_path: str, run_app: bool = True):
"""Ouvre le dashboard Textual sur un manifeste d'installation. `run_app`
à False renvoie l'instance sans la lancer (tests headless)."""
from textual.app import App, ComposeResult
from textual.containers import Horizontal, Vertical
from textual.screen import ModalScreen
from textual.widgets import DataTable, Footer, Header, RichLog, Static
from textual.widgets import (
Button,
Checkbox,
DataTable,
Footer,
Header,
RichLog,
Static,
)
manifest = json.loads(Path(manifest_path).read_text())
started = manifest.get("started", time.time())
@ -869,6 +1002,84 @@ def run_monitor(manifest_path: str, run_app: bool = True):
def action_dismiss(self) -> None:
self.dismiss()
class ConfirmScreen(ModalScreen):
"""Confirmation d'une action irréversible. Le bouton dangereux n'est
PAS le défaut : il faut le viser, pas juste appuyer sur Entrée."""
BINDINGS = [("escape", "cancel", "Annuler")]
def __init__(self, title, lines, danger_label):
super().__init__()
self._title = title
self._lines = lines
self._danger = danger_label
def compose(self) -> ComposeResult:
with Vertical(id="confbox"):
yield Static(self._title, id="conftitle")
for line in self._lines:
yield Static(line)
with Horizontal(id="confbtns"):
yield Button("Annuler", variant="primary", id="c_no")
yield Button(self._danger, variant="error", id="c_yes")
def on_button_pressed(self, event) -> None:
self.dismiss(event.button.id == "c_yes")
def action_cancel(self) -> None:
self.dismiss(False)
class VmActionsScreen(ModalScreen):
"""Les opérations d'une VM, rassemblées en un seul endroit.
Plutôt que trois touches de plus dans un pied de page qui en compte
déjà neuf : on voit ce qu'on va faire, et sur quelle machine."""
BINDINGS = [("escape", "cancel", "Fermer")]
def __init__(self, vm):
super().__init__()
self._vm = vm
def compose(self) -> ComposeResult:
with Vertical(id="actbox"):
yield Static(
f"Actions — {self._vm['name']} ({self._vm.get('ip') or '?'})",
id="acttitle",
)
yield Static("Remettre à jour", classes="actgroup")
for key, label in UPDATE_PARTS:
yield Checkbox(label, value=True, id=f"u_{key}")
yield Button(
"Lancer la mise à jour",
variant="primary",
id="a_update",
)
yield Static("Service", classes="actgroup")
yield Button("Redémarrer Odoo", id="a_restart")
yield Static("Irréversible", classes="actdanger")
yield Button(
"Supprimer la VM et ses disques",
variant="error",
id="a_delete",
)
def on_button_pressed(self, event) -> None:
if event.button.id == "a_update":
parts = [
k
for k, _lbl in UPDATE_PARTS
if self.query_one(f"#u_{k}", Checkbox).value
]
self.dismiss(("update", parts))
elif event.button.id == "a_restart":
self.dismiss(("restart", []))
elif event.button.id == "a_delete":
self.dismiss(("delete", []))
def action_cancel(self) -> None:
self.dismiss(None)
ICON = {
"pending": "⏳",
"running": "⏳",
@ -891,6 +1102,21 @@ def run_monitor(manifest_path: str, run_app: bool = True):
}
#errtitle { height: 1; color: $accent; text-style: bold; }
#errlog { height: 1fr; border: solid $accent; }
VmActionsScreen { align: center middle; }
#actbox {
width: 62; height: auto; padding: 1 2;
border: thick $accent; background: $surface;
}
#acttitle { color: $accent; text-style: bold; padding-bottom: 1; }
.actgroup { color: $accent; text-style: bold; padding: 1 0 0 0; }
.actdanger { color: $error; text-style: bold; padding: 1 0 0 0; }
ConfirmScreen { align: center middle; }
#confbox {
width: 66; height: auto; padding: 1 2;
border: thick $error; background: $surface;
}
#conftitle { color: $error; text-style: bold; padding-bottom: 1; }
#confbtns { height: auto; padding-top: 1; }
"""
BINDINGS = [
("q", "quit", "Quitter (détaché)"),
@ -900,6 +1126,7 @@ def run_monitor(manifest_path: str, run_app: bool = True):
("f", "follow", "Suivre"),
("c", "copy_log", "Copier log"),
("d", "details", "Détails erreurs"),
("a", "vm_actions", "Actions VM"),
("p", "pause_all", "Pause tout"),
("o", "resume_all", "Reprendre tout"),
]
@ -1124,7 +1351,14 @@ def run_monitor(manifest_path: str, run_app: bool = True):
continue
ds = self._domstate.get(name)
if ds == "gone":
self._final[name] = ("deleted", None, now - started)
# Même bornage que les autres états terminaux : une
# VM effacée ne « tourne » plus depuis la dernière
# ligne de son log.
self._final[name] = (
"deleted",
None,
max(0.0, finished_at(vm["log"], now) - started),
)
# Icône seule (VM effacée) : évite « ❌ effacée » (10
# cellules) qui forçait une colonne État large.
self._set_cell(table, name, "state", "🗑")
@ -1134,7 +1368,11 @@ def run_monitor(manifest_path: str, run_app: bool = True):
continue
state, code = st
if state in ("done", "failed"):
elapsed = now - started
# Bornée à la dernière écriture du log, pas à l'instant
# présent : le suivi peut être rouvert bien plus tard.
elapsed = max(
0.0, finished_at(vm["log"], now) - started
)
self._final[name] = (state, code, elapsed)
# Les échecs sont enregistrés eux aussi (ok=False) :
# sans eux, aucun taux de réussite n'est calculable.
@ -1179,7 +1417,8 @@ def run_monitor(manifest_path: str, run_app: bool = True):
maxd = f" · max {self._fmt(max_dur)}" if max_dur else ""
self.sub_title = (
f"{done}/{len(vms)} {t('completed')} · "
f"{self._fmt(now - started)}{eta}{maxd}"
f"{self._fmt(self._total_elapsed(now, started))}"
f"{eta}{maxd}"
)
if tele:
self.query_one("#telemetry", Static).update(tele)
@ -1516,6 +1755,157 @@ def run_monitor(manifest_path: str, run_app: bool = True):
except (OSError, subprocess.SubprocessError):
pass
# -- largeur des colonnes ---------------------------------------- #
COL_STEP = 2
COL_MIN = 3
COL_MAX = 60
def _cursor_column(self):
"""Colonne sous le curseur, ou None. DataTable expose ses colonnes
dans un dictionnaire ORDONNÉ : le rang du curseur y donne la clé.
"""
table = self.query_one("#vms", DataTable)
keys = list(table.columns)
index = table.cursor_column
if not (0 <= index < len(keys)):
return None, None
return table, keys[index]
def _apply_column_widths(self, table) -> None:
"""Fait PRENDRE les largeurs à l'écran.
« refresh(layout=True) » ne suffit pas, et c'est le piège :
mesuré sur Textual 8.2.8, la largeur de la colonne passe bien de
22 à 34, mais la taille virtuelle du tableau reste à 81 — donc
rien ne bouge. « clear_cached_dimensions » et « refresh_column »
n'y changent rien non plus ; seul le recalcul des dimensions la
porte à 93.
C'est une API privée, d'où le repli : une version future de
Textual dégradera l'ajustement au lieu de casser le suivi."""
try:
table._update_dimensions(list(table.rows))
except Exception:
table.refresh(layout=True)
def _resize_column(self, delta) -> None:
table, key = self._cursor_column()
if key is None:
return
col = table.columns[key]
width = max(
self.COL_MIN, min(self.COL_MAX, (col.width or 0) + delta)
)
if width == col.width:
# Butée atteinte : le SEUL cas où il faut le dire, puisque
# rien ne bougera à l'écran pour l'expliquer.
self.notify(
f"{col.label} : {width} (butee {self.COL_MIN}-{self.COL_MAX})",
severity="warning",
)
return
col.width = width
# auto_width écraserait la largeur au prochain rendu : on la coupe,
# sinon le réglage ne survit pas à la première mise à jour.
col.auto_width = False
# Pas de notification quand ça marche : le changement se VOIT, et
# une bulle par frappe rendait l'ajustement pénible.
self._apply_column_widths(table)
def action_col_grow(self) -> None:
self._resize_column(self.COL_STEP)
def action_col_shrink(self) -> None:
self._resize_column(-self.COL_STEP)
def action_col_reset(self) -> None:
"""Rend aux colonnes les largeurs déclarées à la construction."""
table = self.query_one("#vms", DataTable)
for key, width in COL_DEFAULT_WIDTHS.items():
if key in table.columns:
table.columns[key].width = width
table.columns[key].auto_width = False
self._apply_column_widths(table)
def _total_elapsed(self, now, started):
"""Durée globale. Elle se FIGE quand plus rien ne tourne : sinon
le total continuait de courir sur un parc entièrement terminé,
et repartait de plus belle à chaque réouverture."""
if len(self._final) < len(vms) or not self._final:
return now - started
return max(e for _s, _c, e in self._final.values())
def action_vm_actions(self) -> None:
"""Ouvre les actions de la VM sélectionnée."""
vm = self._vm_by_name(self._selected)
if not vm:
return
def chosen(result):
if not result:
return
kind, parts = result
if kind == "update":
self._run_update(vm, parts)
elif kind == "restart":
self._run_in_vm(
vm, restart_odoo_cmd(), "Redemarrage d'Odoo"
)
elif kind == "delete":
self._ask_delete(vm)
self.push_screen(VmActionsScreen(vm), chosen)
def _run_in_vm(self, vm, cmd, title) -> None:
"""Exécute une commande DANS la VM, terminal rendu.
« suspend() » comme pour SSH et la console : sudo peut demander son
mot de passe et la sortie est longue ; la garder derrière Textual
la rendrait illisible. La pause finale évite que l'écran reparte
avant qu'on ait lu le résultat."""
if not vm.get("ip"):
self.notify("Pas d'IP pour cette VM.", severity="error")
return
with self.suspend():
print(f"\n=== {title} — {vm['name']} ===")
os.system(
f"ssh {SSH_OPTS} erplibre@{vm['ip']} "
f"{shlex.quote(cmd)} || true"
)
input("\nEntrée pour revenir au suivi… ")
def _run_update(self, vm, parts) -> None:
cmd = update_remote_cmd(parts)
if not cmd:
self.notify("Rien de coché.", severity="warning")
return
self._run_in_vm(vm, cmd, "Mise a jour")
def _ask_delete(self, vm) -> None:
"""Suppression : jamais sans une seconde main."""
def confirmed(yes):
if not yes:
return
with self.suspend():
print(f"\n=== Suppression — {vm['name']} ===")
os.system(delete_vm_cmd(vm["name"], True) + " || true")
input("\nEntrée pour revenir au suivi… ")
self.push_screen(
ConfirmScreen(
f"Supprimer {vm['name']} ?",
[
"La VM est arrêtée, sa définition retirée,",
"et son disque qcow2 EFFACÉ. Rien n'est récupérable.",
"",
f" /var/lib/libvirt/images/{vm['name']}.qcow2",
],
"Supprimer définitivement",
),
confirmed,
)
def action_pause_all(self) -> None:
"""Met en PAUSE (virsh suspend) toutes les VM en cours d'exécution.
L'install reprend là où elle en était après « Reprendre »."""