#!/usr/bin/env python3 # © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) """Dashboard Textual : suivi des installations ERPLibre parallèles sur VM. Principe « détachable » : chaque installation tourne dans un processus DÉTACHÉ (setsid) qui écrit un fichier log et, à la fin, un marqueur « __ERPLIBRE_EXIT__ ». Ce module ne fait que VISUALISER ces fichiers : quitter le dashboard n'arrête rien, on peut le rouvrir pour ré-attacher. - launch_installs(...) : lance les process détachés + écrit un manifeste JSON. - run_monitor(manifest_path) : ouvre le dashboard Textual sur un manifeste. """ from __future__ import annotations import json import os import shlex import subprocess import time from pathlib import Path try: from script.todo.todo_i18n import t except Exception: # pragma: no cover - repli si i18n indisponible def t(key: str) -> str: return key EXIT_MARKER = "__ERPLIBRE_EXIT__" SSH_OPTS = ( "-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null " "-o ConnectTimeout=8" ) def session_dir() -> Path: """Répertoire des logs/manifestes d'installation (créé au besoin).""" base = Path(os.path.expanduser("~/.erplibre/qemu-install")) base.mkdir(parents=True, exist_ok=True) return base def _launch_one(ip: str, remote_cmd: str, log_path: str) -> None: """Lance une install SSH DÉTACHÉE : attend le sshd, exécute, journalise la sortie puis écrit le marqueur de fin avec le code de sortie.""" # Sonde de disponibilité : on attend que sshd réponde ET que cloud-init # soit TERMINÉ, via des connexions COURTES successives (jusqu'à ~20 min : # une architecture ÉMULÉE, s390x/arm64 sur hôte x86, boote lentement). # Au 1er boot, cloud-init régénère les clés d'hôte et REDÉMARRE sshd : une # session SSH longue (ex. « cloud-init status --wait ») serait alors tuée # (« Connection closed by remote host », exit 255 — cas Fedora). Chaque # itération étant une connexion neuve, un redémarrage de sshd ne casse que # la tentative en cours. On imprime toujours l'état (|| true) pour matcher # sur le TEXTE, « status: running » n'ayant pas de code de sortie fiable. ci_probe = ( "if command -v cloud-init >/dev/null 2>&1; then " "cloud-init status 2>/dev/null || true; else echo nocloudinit; fi" ) log_q = shlex.quote(log_path) # On écrit un message d'attente + un battement toutes les ~30 s : sinon le # log reste VIDE pendant tout le boot émulé et paraît « bloqué ». msg_wait = t("Waiting for the VM to start (boot + cloud-init)") msg_slow = t("(an emulated architecture can be slow; this is normal)") msg_ready = t("VM ready - starting the ERPLibre install") wrapper = ( f"echo {shlex.quote('== ' + msg_wait + ' ==')} >> {log_q}; " f"echo {shlex.quote(' ' + msg_slow)} >> {log_q}; " f"for i in $(seq 1 240); do " f"st=$(ssh {SSH_OPTS} -o BatchMode=yes erplibre@{ip} " f"{shlex.quote(ci_probe)} 2>/dev/null); " f'case "$st" in ' f"*done*|*disabled*|*error*|*degraded*|*nocloudinit*) break;; " f"esac; " f'if [ $((i % 6)) -eq 0 ]; then echo " ... $((i*5))s" >> {log_q}; fi; ' f"sleep 5; done; " f"echo {shlex.quote('== ' + msg_ready + ' ==')} >> {log_q}; " f"ssh {SSH_OPTS} 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. subprocess.Popen( ["setsid", "-f", "bash", "-c", wrapper], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True, ) def _log_header(vm: dict, branch: str, when: str) -> str: """En-tête du log : date, VM, distribution, version, architecture, branche. Permet d'identifier l'installation d'un coup d'œil (et de ne jamais laisser le log vide pendant l'attente du boot).""" distro = vm.get("distro") or "?" version = vm.get("version") or "" arch = vm.get("arch") or "?" bar = "=" * 64 return ( f"{bar}\n" f" ERPLibre — {t('installation')}\n" f" Date : {when}\n" f" VM : {vm['name']}\n" f" Distribution : {distro} {version}\n" f" Architecture : {arch}\n" f" Branche : {branch}\n" f" IP : {vm['ip']}\n" f"{bar}\n\n" ) def launch_installs(vms: list[dict], branch: str, remote_cmd: str) -> str: """vms : [{name, ip, distro?, version?, arch?}]. Lance chaque install détachée, écrit un manifeste et retourne son chemin. remote_cmd : script exécuté dans chaque VM.""" sdir = session_dir() stamp = time.strftime("%Y%m%d-%H%M%S") when = time.strftime("%Y-%m-%d %H:%M:%S") logdir = sdir / stamp logdir.mkdir(parents=True, exist_ok=True) entries = [] for vm in vms: log_path = str(logdir / f"{vm['name']}.log") # En-tête d'emblée (date/distro/version/arch) : le log n'est jamais # vide, l'utilisateur voit tout de suite QUOI s'installe. Path(log_path).write_text(_log_header(vm, branch, when)) _launch_one(vm["ip"], remote_cmd, log_path) entries.append( { "name": vm["name"], "ip": vm["ip"], "distro": vm.get("distro"), "version": vm.get("version"), "arch": vm.get("arch"), "log": log_path, "ssh": f"ssh erplibre@{vm['ip']}", } ) manifest = { "branch": branch, "started": time.time(), "vms": entries, } manifest_path = str(logdir / "session.json") Path(manifest_path).write_text(json.dumps(manifest, indent=2)) return manifest_path def read_status(log_path: str) -> tuple[str, int | None]: """(état, code) d'un log : pending / running / done / failed.""" try: data = Path(log_path).read_text(errors="replace") except OSError: return "pending", None if not data.strip(): return "pending", None for line in reversed(data.splitlines()): if EXIT_MARKER in line: try: code = int(line.split()[-1]) except ValueError: code = 1 return ("done" if code == 0 else "failed"), code return "running", None # --------------------------------------------------------------------------- # # Dashboard Textual # --------------------------------------------------------------------------- # def run_monitor(manifest_path: str) -> None: """Ouvre le dashboard Textual sur un manifeste d'installation.""" from textual.app import App, ComposeResult from textual.containers import Horizontal from textual.widgets import DataTable, Footer, Header, RichLog, Static manifest = json.loads(Path(manifest_path).read_text()) started = manifest.get("started", time.time()) vms = manifest["vms"] ICON = { "pending": "⏳", "running": "⏳", "done": "✅", "failed": "❌", } class Monitor(App): CSS = """ DataTable { width: 40; border: solid $accent; } RichLog { border: solid $accent; } #sshbar { height: 2; color: $text-muted; } """ BINDINGS = [ ("q", "quit", "Quitter (détaché)"), ("s", "ssh", "SSH"), ("f", "follow", "Suivre"), ("c", "copy_log", "Copier log"), ] def __init__(self): super().__init__() self._offsets = {vm["name"]: 0 for vm in vms} self._selected = vms[0]["name"] if vms else None self._follow = True def compose(self) -> ComposeResult: yield Header(show_clock=True) table = DataTable(id="vms", cursor_type="row") # Clés de colonnes explicites : update_cell() les référence. table.add_column("VM", key="vm") table.add_column("État", key="state") table.add_column("Durée", key="elapsed") for vm in vms: table.add_row(vm["name"], "⏳", "00:00", key=vm["name"]) self._log = RichLog(id="log", highlight=False, markup=False) with Horizontal(): yield table yield self._log yield Static("", id="sshbar") yield Footer() def on_mount(self) -> None: self.title = "ERPLibre — suivi d'installation" self._refresh_ssh() self._load_selected_log(reset=True) self.set_interval(1.0, self._tick) # -- helpers -------------------------------------------------------- # def _vm_by_name(self, name): return next((v for v in vms if v["name"] == name), None) def _refresh_ssh(self): vm = self._vm_by_name(self._selected) bar = self.query_one("#sshbar", Static) if vm: bar.update( f" {vm['ssh']} (s = SSH · c = copier le log · " "Maj+glisser = sélectionner)\n" f" Log : {vm['log']}" ) def _load_selected_log(self, reset=False): vm = self._vm_by_name(self._selected) if not vm: return if reset: self._log.clear() self._offsets[vm["name"]] = 0 try: data = Path(vm["log"]).read_text(errors="replace") except OSError: return off = self._offsets[vm["name"]] new = data[off:] if new: for line in new.splitlines(): if EXIT_MARKER not in line: self._log.write(line) self._offsets[vm["name"]] = len(data) def _tick(self): table = self.query_one("#vms", DataTable) for vm in vms: state, code = read_status(vm["log"]) elapsed = time.time() - started mm, ss = divmod(int(elapsed), 60) label = ICON[state] if state == "failed": label = f"❌ ({code})" elif state == "done": label = "✅" table.update_cell(vm["name"], "state", label) if state in ("running", "pending"): table.update_cell( vm["name"], "elapsed", f"{mm:02d}:{ss:02d}" ) if self._follow: self._load_selected_log() # -- events --------------------------------------------------------- # def on_data_table_row_highlighted(self, event) -> None: name = event.row_key.value if name and name != self._selected: self._selected = name self._refresh_ssh() self._load_selected_log(reset=True) def action_follow(self) -> None: self._follow = not self._follow def action_ssh(self) -> None: vm = self._vm_by_name(self._selected) if not vm: return with self.suspend(): os.system(f"ssh {SSH_OPTS} erplibre@{vm['ip']} || true") def action_copy_log(self) -> None: """Copie le log complet de la VM sélectionnée dans le presse-papiers (OSC 52 ; marche aussi à travers SSH).""" vm = self._vm_by_name(self._selected) if not vm: return try: text = Path(vm["log"]).read_text(errors="replace") except OSError: return self.copy_to_clipboard(text) self.notify( f"Log de {vm['name']} copié ({len(text)} car.)", title="Presse-papiers", ) Monitor().run()