Compare commits

..

No commits in common. "808e37cd5940a74eb402061d4f33bef96dd4e6cd" and "bea9a82d7524745cb2c8b5209ad93cd5a0c9b536" have entirely different histories.

6 changed files with 146 additions and 270 deletions

View file

@ -36,14 +36,10 @@ elif [[ "${OS}" == "Linuxmint" ]]; then
WKHTMLTOX_X64=https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-3/wkhtmltox_0.12.6.1-3.jammy_amd64.deb WKHTMLTOX_X64=https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-3/wkhtmltox_0.12.6.1-3.jammy_amd64.deb
fi fi
elif [[ "${OS}" == "Debian" ]]; then elif [[ "${OS}" == "Debian" ]]; then
if [ "bullseye" == "${DEBIAN_VERSION}" ]; then if [ "bookworm" == "${DEBIAN_VERSION}" ]; then
WKHTMLTOX_X64=https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-3/wkhtmltox_0.12.6.1-3.bullseye_amd64.deb
else
# bookworm (12), trixie (13) et au-delà : wkhtmltopdf ne publie pas de
# build au-delà de « bookworm » -> on prend bookworm (le plus récent).
# Le build « bullseye » (Debian 11) échouait à s'installer sur trixie
# (gdebi : dépendances incompatibles).
WKHTMLTOX_X64=https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-3/wkhtmltox_0.12.6.1-3.bookworm_amd64.deb WKHTMLTOX_X64=https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-3/wkhtmltox_0.12.6.1-3.bookworm_amd64.deb
else
WKHTMLTOX_X64=https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-3/wkhtmltox_0.12.6.1-3.bullseye_amd64.deb
fi fi
elif [[ "${OS}" == *"Ubuntu"* ]]; then elif [[ "${OS}" == *"Ubuntu"* ]]; then
echo "Your version of Ubuntu is not supported, only support 18.04, 20.04 and 22.04" echo "Your version of Ubuntu is not supported, only support 18.04, 20.04 and 22.04"
@ -222,15 +218,11 @@ elif [ ${EL_INSTALL_WKHTMLTOPDF} = "True" ]; then
sudo gdebi --n $(basename ${_url}) sudo gdebi --n $(basename ${_url})
retVal=$? retVal=$?
if [[ $retVal -ne 0 ]]; then if [[ $retVal -ne 0 ]]; then
# wkhtmltopdf est OPTIONNEL (rapports PDF). NON bloquant : sur Debian echo "gdebi install wkhtmltopdf installation error."
# 13 (trixie) le .deb dépendait de libssl1.1 (absent) et « exit 1 » exit 1
# faisait échouer TOUT install_os -> Odoo jamais installé. On avertit
# et on continue (comme Arch qui poursuit sans wkhtmltopdf).
echo "wkhtmltopdf : installation échouée, ignoré (optionnel — pas de PDF)."
else
sudo ln -fs /usr/local/bin/wkhtmltopdf /usr/bin
sudo ln -fs /usr/local/bin/wkhtmltoimage /usr/bin
fi fi
sudo ln -fs /usr/local/bin/wkhtmltopdf /usr/bin
sudo ln -fs /usr/local/bin/wkhtmltoimage /usr/bin
fi fi
else else
echo -e "\n---- Already installed wkhtml ----" echo -e "\n---- Already installed wkhtml ----"

View file

@ -96,12 +96,6 @@ if [[ "${EL_PHASE}" != "setup" ]]; then
retVal=$? retVal=$?
if [[ $retVal -ne 0 ]]; then if [[ $retVal -ne 0 ]]; then
echo "Poetry installation error with status ${retVal}" echo "Poetry installation error with status ${retVal}"
# « -q » masque la CAUSE. On rejoue en « -vvv » (debug) car c'est
# le SEUL niveau où Poetry affiche la sortie des sous-processus
# (git clone/checkout, build pip) — donc l'erreur réelle d'une
# dépendance VCS/build. Capturé dans le log pour diagnostic.
echo "---- Poetry: rejeu -vvv pour diagnostic ----"
poetry install --no-root -vvv 2>&1 || true
exit 1 exit 1
fi fi
fi fi

View file

@ -161,10 +161,6 @@ DEBIAN_CLOUD_BASES: tuple[str, ...] = (
"https://laotzu.ftp.acc.umu.se/cdimage/cloud", "https://laotzu.ftp.acc.umu.se/cdimage/cloud",
) )
FEDORA_BASE = "https://download.fedoraproject.org/pub/fedora/linux/releases" FEDORA_BASE = "https://download.fedoraproject.org/pub/fedora/linux/releases"
# Serveur MAÎTRE (pas de redirection MirrorManager) : repli fiable quand le
# redirecteur envoie sur un miroir incomplet (fréquent en déploiement
# PARALLÈLE : chaque requête peut tomber sur un miroir différent/désynchronisé).
FEDORA_BASE_MASTER = "https://dl.fedoraproject.org/pub/fedora/linux/releases"
# Répertoire de cache par défaut des images cloud (cohérent avec --disk-dir / # Répertoire de cache par défaut des images cloud (cohérent avec --disk-dir /
# --seed-dir). L'écriture y nécessite root : le déploiement tourne de toute # --seed-dir). L'écriture y nécessite root : le déploiement tourne de toute
@ -218,38 +214,26 @@ def image_candidates(
def resolve_fedora_url(version: str, arch: str, dry_run: bool) -> str: def resolve_fedora_url(version: str, arch: str, dry_run: bool) -> str:
"""Résout l'URL du qcow2 « Fedora Cloud Base Generic » depuis l'index """Résout l'URL du qcow2 « Fedora Cloud Base Generic » depuis l'index
HTML des releases (Fedora ne publie pas de lien « latest »). ROBUSTE : HTML des releases (Fedora ne publie pas de lien « latest »)."""
plusieurs tentatives sur le redirecteur puis repli sur le serveur maître
(dl.fedoraproject.org) — sinon, en déploiement parallèle, une requête
tombée sur un miroir incomplet faisait échouer la VM (« image introuvable »)
alors que l'image existe bel et bien."""
a = distro_arch("fedora", arch) a = distro_arch("fedora", arch)
index = f"{FEDORA_BASE}/{version}/Cloud/{a}/images/"
pattern = re.compile( pattern = re.compile(
rf"Fedora-Cloud-Base-Generic-{version}-[0-9.]+\.{a}\.qcow2" rf"Fedora-Cloud-Base-Generic-{version}-[0-9.]+\.{a}\.qcow2"
) )
if dry_run: if dry_run:
index = f"{FEDORA_BASE}/{version}/Cloud/{a}/images/"
return index + f"Fedora-Cloud-Base-Generic-{version}-<build>.{a}.qcow2" return index + f"Fedora-Cloud-Base-Generic-{version}-<build>.{a}.qcow2"
# On essaie le redirecteur (2 fois : chaque requête peut viser un miroir try:
# différent) puis le serveur maître (toujours complet). with urllib.request.urlopen(index, timeout=30) as resp: # noqa: S310
bases = [FEDORA_BASE, FEDORA_BASE, FEDORA_BASE_MASTER] html = resp.read().decode(errors="replace")
last_err = "" except Exception as exc: # pragma: no cover - dépend du réseau
for base in bases: sys.exit(f"Impossible de lister les images Fedora {version} : {exc}")
index = f"{base}/{version}/Cloud/{a}/images/" names = sorted(set(pattern.findall(html)))
try: if not names:
with urllib.request.urlopen(index, timeout=30) as resp: # noqa: S310 sys.exit(
html = resp.read().decode(errors="replace") "Aucune image « Fedora-Cloud-Base-Generic » trouvée pour "
except Exception as exc: # pragma: no cover - dépend du réseau f"Fedora {version} ({a}) dans {index}"
last_err = str(exc) )
continue return index + names[-1]
names = sorted(set(pattern.findall(html)))
if names:
return index + names[-1]
sys.exit(
"Aucune image « Fedora-Cloud-Base-Generic » trouvée pour "
f"Fedora {version} ({a}) après plusieurs miroirs"
+ (f" (dernière erreur : {last_err})" if last_err else "")
)
def default_image_name(distro: str, code: str, arch: str, version: str) -> str: def default_image_name(distro: str, code: str, arch: str, version: str) -> str:
@ -1257,17 +1241,7 @@ def virt_install(
cmd += ["--virt-type", "qemu"] cmd += ["--virt-type", "qemu"]
if not args.attach_console: if not args.attach_console:
cmd.append("--noautoconsole") cmd.append("--noautoconsole")
# virtinst écrit un journal de debug dans ~/.cache/virt-manager ; sous runner.run(cmd, privileged=True)
# sudo, HOME/cache peut être inaccessible -> l'écriture échoue et Python
# déverse un « Logging error » (le pavé « Fetched capabilities … »). On
# force un cache/HOME ÉCRIVABLE via « env VAR=… » (traverse sudo) pour
# que le journal s'écrive silencieusement.
log_env = [
"env",
"XDG_CACHE_HOME=/var/tmp/erplibre-virtinst",
"HOME=/var/tmp/erplibre-virtinst",
]
runner.run(log_env + cmd, privileged=True)
def _ip_reachable(ip: str, port: int = 22, timeout: float = 3) -> bool: def _ip_reachable(ip: str, port: int = 22, timeout: float = 3) -> bool:

View file

@ -18,7 +18,6 @@ import json
import os import os
import shlex import shlex
import shutil import shutil
import socket
import subprocess import subprocess
import time import time
from pathlib import Path from pathlib import Path
@ -281,17 +280,6 @@ def scan_log_errors(log_path: str) -> tuple[int, int]:
return nerr, nwarn return nerr, nwarn
def _port_open(ip: str, port: int = 8069, timeout: float = 0.5) -> bool:
"""Vrai si un TCP connect réussit (l'UI web Odoo écoute sur :8069)."""
if not ip:
return False
try:
with socket.create_connection((ip, port), timeout=timeout):
return True
except OSError:
return False
def _read_new(path: str, offset: int) -> tuple[str, int]: def _read_new(path: str, offset: int) -> tuple[str, int]:
"""Lit le log à partir de `offset` (lecture incrémentale). Renvoie """Lit le log à partir de `offset` (lecture incrémentale). Renvoie
(nouveau_texte, nouvel_offset). Bloquant -> à appeler dans un thread.""" (nouveau_texte, nouvel_offset). Bloquant -> à appeler dans un thread."""
@ -304,32 +292,6 @@ def _read_new(path: str, offset: int) -> tuple[str, int]:
return "", offset return "", offset
def _read_tail(
path: str, max_bytes: int = 131072, max_lines: int = 1000
) -> tuple[str, int]:
"""Lit uniquement la FIN du log (dernier `max_bytes`, tronqué à
`max_lines` lignes) et renvoie (texte, TAILLE TOTALE du fichier). Utilisé
au CHANGEMENT de VM : lire+réafficher le fichier ENTIER (offset 0) gelait
l'UI sur les gros logs (250 Ko / milliers de lignes). L'offset renvoyé =
taille totale -> le suivi incrémental (_tick_log) continue depuis la fin."""
try:
size = os.path.getsize(path)
with open(path, "rb") as fh:
if size > max_bytes:
fh.seek(-max_bytes, os.SEEK_END)
raw = fh.read()
text = raw.decode(errors="replace")
# Si on a coupé au milieu d'une ligne, jeter la 1re ligne partielle.
if size > max_bytes and "\n" in text:
text = text.split("\n", 1)[1]
lines = text.splitlines()
if len(lines) > max_lines:
lines = lines[-max_lines:]
return "\n".join(lines), size
except OSError:
return "", 0
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Télémétrie, historique de durées (ETA), navigateur CLI # Télémétrie, historique de durées (ETA), navigateur CLI
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@ -614,6 +576,7 @@ def run_monitor(manifest_path: str, run_app: bool = True):
DataTable { width: 74; height: 1fr; overflow-x: auto; border: solid $accent; } DataTable { width: 74; height: 1fr; overflow-x: auto; border: solid $accent; }
RichLog { border: solid $accent; } RichLog { border: solid $accent; }
#telemetry { height: 1; color: $text-muted; } #telemetry { height: 1; color: $text-muted; }
#autobar { height: 1; color: $warning; }
#stats { height: 1; color: $accent; } #stats { height: 1; color: $accent; }
#statsdetail { display: none; height: auto; color: $text-muted; } #statsdetail { display: none; height: auto; color: $text-muted; }
#sshbar { height: 2; color: $text-muted; } #sshbar { height: 2; color: $text-muted; }
@ -632,6 +595,8 @@ def run_monitor(manifest_path: str, run_app: bool = True):
("f", "follow", "Suivre"), ("f", "follow", "Suivre"),
("c", "copy_log", "Copier log"), ("c", "copy_log", "Copier log"),
("d", "details", "Détails erreurs"), ("d", "details", "Détails erreurs"),
("a", "toggle_auto", "Auto-refresh on/off"),
("r", "refresh_now", "Rafraîchir"),
("p", "pause_all", "Pause tout"), ("p", "pause_all", "Pause tout"),
("o", "resume_all", "Reprendre tout"), ("o", "resume_all", "Reprendre tout"),
] ]
@ -657,13 +622,11 @@ def run_monitor(manifest_path: str, run_app: bool = True):
self._errcount = {} self._errcount = {}
# Sommaire de stats déplié (clic) ou non. # Sommaire de stats déplié (clic) ou non.
self._stats_open = False self._stats_open = False
# VM dont l'UI Odoo (:8069) répond déjà : une fois détectée « up », # Rafraîchissement automatique COUPÉ par défaut : sur un gros parc,
# on ne re-teste plus (Odoo ne redescend pas en cours d'install). # les ticks périodiques (logs/table/domstate) causent du lag quand
self._odoo_up = set() # on lit les logs. On l'active à la demande (touche « a ») ; sinon
# Debounce du changement de VM : la sélection défile vite au # « r » rafraîchit une seule fois.
# clavier ; on ne recharge le log qu'une fois le curseur STABILISÉ. self._auto_refresh = False
self._pending_sel = None
self._sel_timer = None
@staticmethod @staticmethod
def _fmt(secs): def _fmt(secs):
@ -688,13 +651,11 @@ def run_monitor(manifest_path: str, run_app: bool = True):
table.add_column("VM", key="vm", width=26) table.add_column("VM", key="vm", width=26)
table.add_column("⚠", key="err", width=4) table.add_column("⚠", key="err", width=4)
table.add_column("État", key="state", width=12) table.add_column("État", key="state", width=12)
# « Odoo » : l'UI web répond-elle sur :8069 ? (🟢 up / — down)
table.add_column("Odoo", key="odoo", width=6)
table.add_column("Durée", key="elapsed", width=7) table.add_column("Durée", key="elapsed", width=7)
table.add_column("Disque", key="disk", width=8) table.add_column("Disque", key="disk", width=8)
for vm in vms: for vm in vms:
table.add_row( table.add_row(
vm["name"], "", "⏳", "—", "--:--", "-", key=vm["name"] vm["name"], "", "⏳", "--:--", "-", key=vm["name"]
) )
# max_lines borne la mémoire/rendu (un install verbeux × 30 VM). # max_lines borne la mémoire/rendu (un install verbeux × 30 VM).
self._log = RichLog( self._log = RichLog(
@ -703,6 +664,8 @@ def run_monitor(manifest_path: str, run_app: bool = True):
with Horizontal(): with Horizontal():
yield table yield table
yield self._log yield self._log
# Indicateur d'auto-refresh (a = on/off · r = rafraîchir).
yield Static("", id="autobar")
# Barre de télémétrie hôte (CPU, disque, ETA parc). # Barre de télémétrie hôte (CPU, disque, ETA parc).
yield Static("", id="telemetry") yield Static("", id="telemetry")
# Sommaire de stats en CHIFFRES (cliquable -> détail). # Sommaire de stats en CHIFFRES (cliquable -> détail).
@ -718,15 +681,17 @@ def run_monitor(manifest_path: str, run_app: bool = True):
self.sub_title = f"0/{len(vms)} {t('completed')}" self.sub_title = f"0/{len(vms)} {t('completed')}"
self._refresh_ssh() self._refresh_ssh()
self._load_selected_log(reset=True) self._load_selected_log(reset=True)
# Table toutes les 2 s (30 lectures de fin de log), suivi du log self._update_autobar()
# sélectionné toutes les 1 s (une seule lecture incrémentale). # Ticks périodiques : ils ne font RIEN tant que l'auto-refresh est
# coupé (wrappers gardés). On garde les intervalles en place pour
# ne pas avoir à les (re)créer au moment du toggle.
self.set_interval(2.0, self._tick_table) self.set_interval(2.0, self._tick_table)
self.set_interval(1.0, self._tick_log) self.set_interval(1.0, self._tick_log)
# État libvirt (pause / effacée) : check LENT (appel virsh) toutes
# les 10 s ; le tableau applique le cache à chaque tick (2 s).
self.set_interval(10.0, self._tick_domstate) self.set_interval(10.0, self._tick_domstate)
# Premier relevé domstate immédiat (async -> via worker). # Un SEUL relevé initial (table + domstate) pour afficher l'état
self.run_worker(self._tick_domstate(), exclusive=False) # courant à l'ouverture, même auto-refresh coupé.
self.run_worker(self._do_domstate(), exclusive=False)
self.run_worker(self._do_table(), exclusive=False)
# -- helpers -------------------------------------------------------- # # -- helpers -------------------------------------------------------- #
def _vm_by_name(self, name): def _vm_by_name(self, name):
@ -739,27 +704,21 @@ def run_monitor(manifest_path: str, run_app: bool = True):
bar.update( bar.update(
f" {vm['ssh']} (s = SSH · w = web :8069 · " f" {vm['ssh']} (s = SSH · w = web :8069 · "
"c = copier le log · d = détails erreurs · " "c = copier le log · d = détails erreurs · "
"a = auto-refresh · r = rafraîchir · "
"Maj+glisser = sélectionner)\n" "Maj+glisser = sélectionner)\n"
f" Log : {vm['log']}" f" Log : {vm['log']}"
) )
def _load_selected_log(self, reset=False): def _load_selected_log(self, reset=False):
# Au reset (changement de VM), on ne lit QUE LA FIN du log # Lecture SYNCHRONE (mount / changement de ligne). Le suivi périodique
# (_read_tail) et on cale l'offset sur la taille totale : le suivi # passe par _tick_log (async, I/O en thread).
# incrémental (_tick_log) continue depuis la fin. Lire+réafficher
# le fichier ENTIER gelait l'UI sur les gros logs.
vm = self._vm_by_name(self._selected) vm = self._vm_by_name(self._selected)
if not vm: if not vm:
return return
name = vm["name"] name = vm["name"]
if reset: if reset:
self._log.clear() self._log.clear()
text, size = _read_tail(vm["log"]) self._offsets[name] = 0
self._offsets[name] = size
for line in text.splitlines():
if EXIT_MARKER not in line:
self._log.write(line)
return
new, off = _read_new(vm["log"], self._offsets.get(name, 0)) new, off = _read_new(vm["log"], self._offsets.get(name, 0))
self._offsets[name] = off self._offsets[name] = off
for line in new.splitlines(): for line in new.splitlines():
@ -786,7 +745,7 @@ def run_monitor(manifest_path: str, run_app: bool = True):
def _collect_table(self): def _collect_table(self):
"""(THREAD) statut + taille disque de chaque VM + télémétrie. AUCUNE """(THREAD) statut + taille disque de chaque VM + télémétrie. AUCUNE
mise à jour d'UI ici : uniquement des I/O bloquantes déportées.""" mise à jour d'UI ici : uniquement des I/O bloquantes déportées."""
disks, status, errors, odoo = {}, {}, {}, {} disks, status, errors = {}, {}, {}
for vm in vms: for vm in vms:
name = vm["name"] name = vm["name"]
disks[name] = _fmt_size(disk_actual_size(vm_disk_path(vm))) disks[name] = _fmt_size(disk_actual_size(vm_disk_path(vm)))
@ -801,27 +760,23 @@ def run_monitor(manifest_path: str, run_app: bool = True):
# peut contenir des erreurs passées inaperçues. # peut contenir des erreurs passées inaperçues.
if st[0] in ("done", "failed") and name not in errors: if st[0] in ("done", "failed") and name not in errors:
errors[name] = scan_log_errors(vm["log"]) errors[name] = scan_log_errors(vm["log"])
# Odoo up ? On ne teste que celles pas encore confirmées up return disks, status, self._collect_tele(), errors
# et non effacées (test TCP court sur :8069).
if name not in self._odoo_up and self._domstate.get(
name
) != "gone":
if _port_open(vm.get("ip"), 8069):
odoo[name] = True
return disks, status, self._collect_tele(), errors, odoo
async def _tick_table(self): async def _tick_table(self):
if self._auto_refresh:
await self._do_table()
async def _do_table(self):
# I/O (lectures de logs, stat disque, /proc) DÉPORTÉES en thread -> # I/O (lectures de logs, stat disque, /proc) DÉPORTÉES en thread ->
# la boucle d'événements Textual reste fluide même sous forte # la boucle d'événements Textual reste fluide même sous forte
# charge ou disque lent. Les mises à jour d'UI restent sur la boucle. # charge ou disque lent. Les mises à jour d'UI restent sur la boucle.
try: try:
disks, status, tele, errors, odoo = await asyncio.to_thread( disks, status, tele, errors = await asyncio.to_thread(
self._collect_table self._collect_table
) )
except Exception: except Exception:
return return
self._errcount.update(errors) self._errcount.update(errors)
self._odoo_up.update(odoo)
try: try:
table = self.query_one("#vms", DataTable) table = self.query_one("#vms", DataTable)
now = time.time() now = time.time()
@ -829,11 +784,6 @@ def run_monitor(manifest_path: str, run_app: bool = True):
for vm in vms: for vm in vms:
name = vm["name"] name = vm["name"]
self._set_cell(table, name, "disk", disks.get(name, "-")) self._set_cell(table, name, "disk", disks.get(name, "-"))
# Colonne Odoo : 🟢 dès que :8069 répond, sinon « — ».
self._set_cell(
table, name, "odoo",
"🟢" if name in self._odoo_up else "—",
)
if name in self._final: if name in self._final:
continue continue
ds = self._domstate.get(name) ds = self._domstate.get(name)
@ -964,6 +914,10 @@ def run_monitor(manifest_path: str, run_app: bool = True):
return "\n".join(lines) return "\n".join(lines)
async def _tick_log(self): async def _tick_log(self):
if self._auto_refresh:
await self._do_log()
async def _do_log(self):
if not self._follow: if not self._follow:
return return
vm = self._vm_by_name(self._selected) vm = self._vm_by_name(self._selected)
@ -982,6 +936,10 @@ def run_monitor(manifest_path: str, run_app: bool = True):
self._log.write(line) self._log.write(line)
async def _tick_domstate(self): async def _tick_domstate(self):
if self._auto_refresh:
await self._do_domstate()
async def _do_domstate(self):
# Relevé LENT (subprocess virsh) DÉPORTÉ en thread : ne bloque plus # Relevé LENT (subprocess virsh) DÉPORTÉ en thread : ne bloque plus
# la boucle. Une VM absente de « virsh list » est EFFACÉE (« gone »). # la boucle. Une VM absente de « virsh list » est EFFACÉE (« gone »).
try: try:
@ -991,28 +949,40 @@ def run_monitor(manifest_path: str, run_app: bool = True):
for vm in vms: for vm in vms:
self._domstate[vm["name"]] = states.get(vm["name"], "gone") self._domstate[vm["name"]] = states.get(vm["name"], "gone")
# -- auto-refresh (a = on/off · r = rafraîchir une fois) ------------- #
def _update_autobar(self):
if self._auto_refresh:
msg = f" ▶ {t('Auto-refresh: ON')} (a = {t('turn off')})"
else:
msg = (
f" ⏸ {t('Auto-refresh: OFF')} "
f"(a = {t('turn on')} · r = {t('refresh once')})"
)
self.query_one("#autobar", Static).update(msg)
def action_toggle_auto(self) -> None:
self._auto_refresh = not self._auto_refresh
self._update_autobar()
if self._auto_refresh:
# Rafraîchit tout de suite en activant.
self.run_worker(self._do_domstate(), exclusive=False)
self.run_worker(self._do_table(), exclusive=False)
self.run_worker(self._do_log(), exclusive=False)
def action_refresh_now(self) -> None:
# Une seule actualisation, même auto-refresh coupé.
self.run_worker(self._do_domstate(), exclusive=False)
self.run_worker(self._do_table(), exclusive=False)
self.run_worker(self._do_log(), exclusive=False)
self.notify(t("Refreshed."))
# -- events --------------------------------------------------------- # # -- events --------------------------------------------------------- #
def on_data_table_row_highlighted(self, event) -> None: def on_data_table_row_highlighted(self, event) -> None:
# DEBOUNCE : RowHighlighted se déclenche à CHAQUE mouvement du
# curseur. Recharger le log à chaque pas (surtout en maintenant la
# flèche) enchaînait les rechargements -> gros lag. On diffère de
# 0,25 s et on ne charge que la DERNIÈRE VM sélectionnée.
name = event.row_key.value name = event.row_key.value
if not name or name == self._selected: if name and name != self._selected:
return self._selected = name
self._pending_sel = name self._refresh_ssh()
if self._sel_timer is not None: self._load_selected_log(reset=True)
self._sel_timer.stop()
self._sel_timer = self.set_timer(0.25, self._apply_pending_sel)
def _apply_pending_sel(self) -> None:
name = self._pending_sel
self._sel_timer = None
if not name or name == self._selected:
return
self._selected = name
self._refresh_ssh()
self._load_selected_log(reset=True)
def action_follow(self) -> None: def action_follow(self) -> None:
self._follow = not self._follow self._follow = not self._follow
@ -1197,8 +1167,10 @@ def run_monitor(manifest_path: str, run_app: bool = True):
await asyncio.to_thread(self._virsh_bulk, action, targets) await asyncio.to_thread(self._virsh_bulk, action, targets)
verb = t("paused") if action == "suspend" else t("resumed") verb = t("paused") if action == "suspend" else t("resumed")
self.notify(f"{len(targets)} VM {verb}.") self.notify(f"{len(targets)} VM {verb}.")
# Rafraîchit tout de suite l'état libvirt (pause/reprise visible). # Rafraîchit tout de suite l'état libvirt (pause/reprise visible),
self.run_worker(self._tick_domstate(), exclusive=False) # même si l'auto-refresh est coupé -> _do_domstate (forcé).
self.run_worker(self._do_domstate(), exclusive=False)
self.run_worker(self._do_table(), exclusive=False)
app = Monitor() app = Monitor()
if run_app: if run_app:

View file

@ -3017,16 +3017,6 @@ class TODO:
"libvirt virt-install libguestfs 2>/dev/null || true)" "libvirt virt-install libguestfs 2>/dev/null || true)"
) )
def _qemu_ask_prod(self):
"""Environnement cible : dev (défaut) ou prod. En PROD : ERPLibre est
installé dans /opt/erplibre (au lieu de ~/git/erplibre) et le service
systemd reste CONFINÉ par SELinux (pas d'unconfined)."""
print(f"\n{t('Target environment?')}")
print(f" [1] {t('Development (~/git/erplibre, SELinux relaxed)')} *")
print(f" [2] {t('Production (/opt/erplibre, SELinux enforced)')}")
sel = input(t("Choice (1-2, default 1): ")).strip()
return sel == "2"
def _qemu_pick_install_profile(self): def _qemu_pick_install_profile(self):
"""Choix de CE QU'ON installe sur la VM. Renvoie (label, commande """Choix de CE QU'ON installe sur la VM. Renvoie (label, commande
finale exécutée dans ~/git/erplibre).""" finale exécutée dans ~/git/erplibre)."""
@ -3069,43 +3059,14 @@ class TODO:
return profiles[0] # défaut : ERPLibre + Odoo 18 return profiles[0] # défaut : ERPLibre + Odoo 18
@staticmethod @staticmethod
@staticmethod def _qemu_odoo_service_cmd():
def _qemu_install_dir(prod):
"""Répertoire d'installation ERPLibre dans la VM : /opt/erplibre en
PROD (hors /home -> service SELinux confiné possible), sinon
~/git/erplibre (dev)."""
return "/opt/erplibre" if prod else "$HOME/git/erplibre"
def _qemu_odoo_service_cmd(self, prod=False):
"""Snippet shell (exécuté dans la VM) qui installe ERPLibre/Odoo comme """Snippet shell (exécuté dans la VM) qui installe ERPLibre/Odoo comme
service systemd puis l'active. N'est ajouté QUE pour les profils Odoo. service systemd (inspiré de script/systemd/install_daemon.sh) puis
l'active + démarre. N'est ajouté QUE pour les profils avec Odoo."""
DEV : ERPLibre sous ~/home ; si SELinux est actif (Fedora), on lève le
confinement du service (unconfined) — un service système ne peut pas
exécuter du user_home_t. Acceptable pour une VM de dev.
PROD : ERPLibre sous /opt/erplibre (hors user_home_t) -> le service
reste CONFINÉ par SELinux ; on restaure les contextes (restorecon)."""
svc_dir = self._qemu_install_dir(prod)
if prod:
pre = (
"command -v restorecon >/dev/null 2>&1 && "
"sudo restorecon -R /opt/erplibre >/dev/null 2>&1 || true; "
)
selinux_shell = 'SELINUX_LINE=""; ' # confiné : pas d'unconfined
else:
pre = ""
selinux_shell = (
'SELINUX_LINE=""; '
"if command -v getenforce >/dev/null 2>&1 && "
'[ "$(getenforce)" != "Disabled" ]; then '
'SELINUX_LINE="SELinuxContext=unconfined_u:unconfined_r:'
'unconfined_t:s0"; fi; '
)
return ( return (
f'SVC_USER=$(whoami); SVC_GROUP=$(id -gn); SVC_DIR="{svc_dir}"; ' 'SVC_USER=$(whoami); SVC_GROUP=$(id -gn); '
+ pre 'SVC_DIR="$HOME/git/erplibre"; '
+ selinux_shell "sudo tee /etc/systemd/system/erplibre.service >/dev/null <<UNIT\n"
+ "sudo tee /etc/systemd/system/erplibre.service >/dev/null <<UNIT\n"
"[Unit]\n" "[Unit]\n"
"Description=ERPLibre\n" "Description=ERPLibre\n"
"Requires=postgresql.service\n" "Requires=postgresql.service\n"
@ -3120,7 +3081,6 @@ class TODO:
"ExecStart=$SVC_DIR/run.sh\n" "ExecStart=$SVC_DIR/run.sh\n"
"WorkingDirectory=$SVC_DIR\n" "WorkingDirectory=$SVC_DIR\n"
"StandardOutput=journal+console\n" "StandardOutput=journal+console\n"
"$SELINUX_LINE\n"
"\n" "\n"
"[Install]\n" "[Install]\n"
"WantedBy=multi-user.target\n" "WantedBy=multi-user.target\n"
@ -3129,18 +3089,17 @@ class TODO:
"sudo systemctl enable --now erplibre.service" "sudo systemctl enable --now erplibre.service"
) )
def _qemu_erplibre_remote_cmd(self, branch, final_cmd=None, prod=False): def _qemu_erplibre_remote_cmd(self, branch, final_cmd=None):
"""Script d'installation ERPLibre exécuté DANS la VM (curl/git/make """Script d'installation ERPLibre exécuté DANS la VM (curl/git/make
puis clone + `final_cmd`). `final_cmd` par défaut : install_os + puis clone + `final_cmd` dans ~/git/erplibre). `final_cmd` par défaut :
install_odoo_18. `prod` : installe dans /opt/erplibre (au lieu de install_os + install_odoo_18."""
~/git/erplibre) + service SELinux confiné."""
if not final_cmd: if not final_cmd:
final_cmd = f"make install_os && make {self.ERPLIBRE_ODOO_TARGET}" final_cmd = f"make install_os && make {self.ERPLIBRE_ODOO_TARGET}"
# Profils AVEC Odoo (install_odoo*) uniquement : après l'install, on # Profils AVEC Odoo (install_odoo*) uniquement : après l'install, on
# enregistre Odoo comme service systemd (enable + start). Pas pour # enregistre Odoo comme service systemd (enable + start). Pas pour
# « ERPLibre seul », « mobile » ni « Déploiement ». # « ERPLibre seul », « mobile » ni « Déploiement ».
if "install_odoo" in final_cmd: if "install_odoo" in final_cmd:
final_cmd = f"{final_cmd} && {self._qemu_odoo_service_cmd(prod)}" final_cmd = f"{final_cmd} && {self._qemu_odoo_service_cmd()}"
return ( return (
"set -e; " "set -e; "
# Attendre la FIN de cloud-init : pendant sa phase « paquets » il # Attendre la FIN de cloud-init : pendant sa phase « paquets » il
@ -3157,15 +3116,11 @@ class TODO:
# (Fedora) et pacman (Arch). # (Fedora) et pacman (Arch).
"PKGS='curl git make'; " "PKGS='curl git make'; "
"if command -v apt-get >/dev/null 2>&1; then " "if command -v apt-get >/dev/null 2>&1; then "
# DPkg::Lock::Timeout=600 : au 1er boot, cloud-init (install de
# qemu-guest-agent) et/ou apt-daily.service tiennent le verrou apt.
# Sans attente, « apt-get install » échouait aussitôt (« Could not
# get lock … held by process »). On ATTEND le verrou jusqu'à 10 min.
# update best-effort (|| true) puis install OBLIGATOIRE : sans le # update best-effort (|| true) puis install OBLIGATOIRE : sans le
# « || true », un « apt-get update » en échec (réseau) sautait # « || true », un « apt-get update » en échec (réseau) sautait
# l'install sans erreur (liste &&) -> git/make absents ensuite. # l'install sans erreur (liste &&) -> git/make absents ensuite.
"sudo apt-get -o DPkg::Lock::Timeout=600 update -qq || true; " "sudo apt-get update -qq || true; "
"sudo apt-get -o DPkg::Lock::Timeout=600 install -y $PKGS; " "sudo apt-get install -y $PKGS; "
"elif command -v dnf >/dev/null 2>&1; then " "elif command -v dnf >/dev/null 2>&1; then "
# makecache (dnf5 choisit les miroirs les plus rapides) puis # makecache (dnf5 choisit les miroirs les plus rapides) puis
# install --refresh ; retry avec « clean all » car les images # install --refresh ; retry avec « clean all » car les images
@ -3196,42 +3151,27 @@ class TODO:
"for t in curl git make; do command -v $t >/dev/null 2>&1 || " "for t in curl git make; do command -v $t >/dev/null 2>&1 || "
'{ echo "Outil manquant apres installation: $t ' '{ echo "Outil manquant apres installation: $t '
'(reseau de la VM ?)"; exit 1; }; done; ' '(reseau de la VM ?)"; exit 1; }; done; '
# Clone : /opt/erplibre en PROD (racine, puis chown à l'utilisateur "mkdir -p ~/git; "
# pour que make/venv s'exécutent sans sudo), ~/git/erplibre en dev. "if [ ! -d ~/git/erplibre/.git ]; then "
+ ( f"git clone --branch {shlex.quote(branch)} "
( f"{self.ERPLIBRE_GIT_URL} ~/git/erplibre; fi; "
"sudo mkdir -p /opt; " # Installation : commande finale selon le profil choisi.
"if [ ! -d /opt/erplibre/.git ]; then " f"cd ~/git/erplibre && {final_cmd}"
f"sudo git clone --branch {shlex.quote(branch)} "
f"{self.ERPLIBRE_GIT_URL} /opt/erplibre; "
"sudo chown -R $(id -un):$(id -gn) /opt/erplibre; fi; "
f"cd /opt/erplibre && {final_cmd}"
)
if prod
else (
"mkdir -p ~/git; "
"if [ ! -d ~/git/erplibre/.git ]; then "
f"git clone --branch {shlex.quote(branch)} "
f"{self.ERPLIBRE_GIT_URL} ~/git/erplibre; fi; "
f"cd ~/git/erplibre && {final_cmd}"
)
)
) )
def _qemu_install_erplibre_monitored( def _qemu_install_erplibre_monitored(
self, names, branch, ip_map=None, final_cmd=None, prod=False self, names, branch, ip_map=None, final_cmd=None
): ):
"""Lance l'install ERPLibre en parallèle DÉTACHÉE sur les VM et ouvre """Lance l'install ERPLibre en parallèle DÉTACHÉE sur les VM et ouvre
le dashboard Textual. Quitter le dashboard n'arrête pas les installs. le dashboard Textual. Quitter le dashboard n'arrête pas les installs.
`ip_map` : IP déjà résolues (sinon on résout ici, EN PARALLÈLE). `ip_map` : IP déjà résolues (sinon on résout ici, EN PARALLÈLE).
`final_cmd` : commande d'install selon le profil choisi. `final_cmd` : commande d'install selon le profil choisi."""
`prod` : install /opt/erplibre + service SELinux confiné."""
from script.todo.qemu_install_monitor import ( from script.todo.qemu_install_monitor import (
launch_installs, launch_installs,
run_monitor, run_monitor,
) )
remote = self._qemu_erplibre_remote_cmd(branch, final_cmd, prod) remote = self._qemu_erplibre_remote_cmd(branch, final_cmd)
try: try:
mod = self._qemu_import_module() mod = self._qemu_import_module()
except Exception: except Exception:
@ -3284,11 +3224,11 @@ class TODO:
print(f" {t('Read the logs:')} tail -n +1 {logdir}/*.log") print(f" {t('Read the logs:')} tail -n +1 {logdir}/*.log")
def _qemu_install_erplibre_vm( def _qemu_install_erplibre_vm(
self, name, ssh_key, branch, ip=None, final_cmd=None, prod=False self, name, ssh_key, branch, ip=None, final_cmd=None
): ):
"""Clone ERPLibre (branche donnée) dans la VM puis exécute la commande """Clone ERPLibre (branche donnée) dans ~/git/erplibre de la VM puis
d'install du profil choisi (streamé). `ip` : IP déjà résolue ; exécute la commande d'install du profil choisi (streamé). `ip` : IP
`final_cmd` : commande d'install ; `prod` : /opt + SELinux confiné.""" déjà résolue ; `final_cmd` : commande d'install."""
if ip is None: if ip is None:
ip = self._qemu_vm_ip(name) ip = self._qemu_vm_ip(name)
if not ip: if not ip:
@ -3305,7 +3245,7 @@ class TODO:
f"{t('SSH not reachable, ERPLibre install skipped.')}" f"{t('SSH not reachable, ERPLibre install skipped.')}"
) )
return return
remote = self._qemu_erplibre_remote_cmd(branch, final_cmd, prod) remote = self._qemu_erplibre_remote_cmd(branch, final_cmd)
ssh_opts = ( ssh_opts = (
"-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null " "-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "
"-o ConnectTimeout=15" "-o ConnectTimeout=15"
@ -3619,14 +3559,12 @@ class TODO:
# 4) Option : installer ERPLibre dans ~/git/erplibre de chaque VM. # 4) Option : installer ERPLibre dans ~/git/erplibre de chaque VM.
install_branch = None install_branch = None
install_monitor = False install_monitor = False
install_prod = False # dev (~/git, SELinux dev) vs prod (/opt, confiné)
install_cmd = None # commande finale selon le profil choisi install_cmd = None # commande finale selon le profil choisi
ans = input( ans = input(
t("Install ERPLibre into ~/git/erplibre on each VM? (y/N): ") t("Install ERPLibre into ~/git/erplibre on each VM? (y/N): ")
) )
if self._is_yes(ans): if self._is_yes(ans):
install_branch = self._qemu_pick_branch() install_branch = self._qemu_pick_branch()
install_prod = self._qemu_ask_prod()
_label, install_cmd = self._qemu_pick_install_profile() _label, install_cmd = self._qemu_pick_install_profile()
install_monitor = self._is_yes_default_yes( install_monitor = self._is_yes_default_yes(
input(t("Interactive monitoring dashboard? (y/N): ")) input(t("Interactive monitoring dashboard? (y/N): "))
@ -3745,8 +3683,7 @@ class TODO:
if install_monitor: if install_monitor:
# Installs détachées en parallèle + dashboard Textual. # Installs détachées en parallèle + dashboard Textual.
self._qemu_install_erplibre_monitored( self._qemu_install_erplibre_monitored(
deployed, install_branch, ip_map, install_cmd, deployed, install_branch, ip_map, install_cmd
install_prod,
) )
else: else:
print( print(
@ -3760,7 +3697,6 @@ class TODO:
install_branch, install_branch,
ip_map.get(name), ip_map.get(name),
install_cmd, install_cmd,
install_prod,
) )
# Sommaire TOTAL (déploiement + résolution IP + ssh_config + install # Sommaire TOTAL (déploiement + résolution IP + ssh_config + install

View file

@ -1575,6 +1575,30 @@ TRANSLATIONS = {
"fr": "Aucune erreur détectée.", "fr": "Aucune erreur détectée.",
"en": "No error detected.", "en": "No error detected.",
}, },
"Auto-refresh: ON": {
"fr": "Rafraîchissement auto : ACTIVÉ",
"en": "Auto-refresh: ON",
},
"Auto-refresh: OFF": {
"fr": "Rafraîchissement auto : COUPÉ",
"en": "Auto-refresh: OFF",
},
"turn off": {
"fr": "couper",
"en": "turn off",
},
"turn on": {
"fr": "activer",
"en": "turn on",
},
"refresh once": {
"fr": "rafraîchir",
"en": "refresh once",
},
"Refreshed.": {
"fr": "Rafraîchi.",
"en": "Refreshed.",
},
"Test a VM (open Odoo in a CLI browser)": { "Test a VM (open Odoo in a CLI browser)": {
"fr": "🧪 Tester une VM (ouvrir Odoo dans un navigateur CLI)", "fr": "🧪 Tester une VM (ouvrir Odoo dans un navigateur CLI)",
"en": "🧪 Test a VM (open Odoo in a CLI browser)", "en": "🧪 Test a VM (open Odoo in a CLI browser)",
@ -1794,22 +1818,6 @@ TRANSLATIONS = {
"fr": "État cible :", "fr": "État cible :",
"en": "Target state:", "en": "Target state:",
}, },
"Target environment?": {
"fr": "Environnement cible ?",
"en": "Target environment?",
},
"Development (~/git/erplibre, SELinux relaxed)": {
"fr": "Développement (~/git/erplibre, SELinux relâché)",
"en": "Development (~/git/erplibre, SELinux relaxed)",
},
"Production (/opt/erplibre, SELinux enforced)": {
"fr": "Production (/opt/erplibre, SELinux confiné)",
"en": "Production (/opt/erplibre, SELinux enforced)",
},
"Choice (1-2, default 1): ": {
"fr": "Choix (1-2, défaut 1) : ",
"en": "Choice (1-2, default 1): ",
},
"Open (start)": { "Open (start)": {
"fr": "Ouvrir (démarrer)", "fr": "Ouvrir (démarrer)",
"en": "Open (start)", "en": "Open (start)",