Compare commits
7 commits
bea9a82d75
...
808e37cd59
| Author | SHA1 | Date | |
|---|---|---|---|
| 808e37cd59 | |||
| 43d268e448 | |||
| 64c0bbc698 | |||
| 7904c72d71 | |||
| 1ebf2b353f | |||
| b99db0ea66 | |||
| fd3226a053 |
6 changed files with 270 additions and 146 deletions
|
|
@ -36,10 +36,14 @@ 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
|
||||
fi
|
||||
elif [[ "${OS}" == "Debian" ]]; 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.bookworm_amd64.deb
|
||||
else
|
||||
if [ "bullseye" == "${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
|
||||
fi
|
||||
elif [[ "${OS}" == *"Ubuntu"* ]]; then
|
||||
echo "Your version of Ubuntu is not supported, only support 18.04, 20.04 and 22.04"
|
||||
|
|
@ -218,11 +222,15 @@ elif [ ${EL_INSTALL_WKHTMLTOPDF} = "True" ]; then
|
|||
sudo gdebi --n $(basename ${_url})
|
||||
retVal=$?
|
||||
if [[ $retVal -ne 0 ]]; then
|
||||
echo "gdebi install wkhtmltopdf installation error."
|
||||
exit 1
|
||||
# wkhtmltopdf est OPTIONNEL (rapports PDF). NON bloquant : sur Debian
|
||||
# 13 (trixie) le .deb dépendait de libssl1.1 (absent) et « 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
|
||||
sudo ln -fs /usr/local/bin/wkhtmltopdf /usr/bin
|
||||
sudo ln -fs /usr/local/bin/wkhtmltoimage /usr/bin
|
||||
fi
|
||||
else
|
||||
echo -e "\n---- Already installed wkhtml ----"
|
||||
|
|
|
|||
|
|
@ -96,6 +96,12 @@ if [[ "${EL_PHASE}" != "setup" ]]; then
|
|||
retVal=$?
|
||||
if [[ $retVal -ne 0 ]]; then
|
||||
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
|
||||
fi
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -161,6 +161,10 @@ DEBIAN_CLOUD_BASES: tuple[str, ...] = (
|
|||
"https://laotzu.ftp.acc.umu.se/cdimage/cloud",
|
||||
)
|
||||
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 /
|
||||
# --seed-dir). L'écriture y nécessite root : le déploiement tourne de toute
|
||||
|
|
@ -214,26 +218,38 @@ def image_candidates(
|
|||
|
||||
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
|
||||
HTML des releases (Fedora ne publie pas de lien « latest »)."""
|
||||
HTML des releases (Fedora ne publie pas de lien « latest »). ROBUSTE :
|
||||
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)
|
||||
index = f"{FEDORA_BASE}/{version}/Cloud/{a}/images/"
|
||||
pattern = re.compile(
|
||||
rf"Fedora-Cloud-Base-Generic-{version}-[0-9.]+\.{a}\.qcow2"
|
||||
)
|
||||
if dry_run:
|
||||
index = f"{FEDORA_BASE}/{version}/Cloud/{a}/images/"
|
||||
return index + f"Fedora-Cloud-Base-Generic-{version}-<build>.{a}.qcow2"
|
||||
try:
|
||||
with urllib.request.urlopen(index, timeout=30) as resp: # noqa: S310
|
||||
html = resp.read().decode(errors="replace")
|
||||
except Exception as exc: # pragma: no cover - dépend du réseau
|
||||
sys.exit(f"Impossible de lister les images Fedora {version} : {exc}")
|
||||
names = sorted(set(pattern.findall(html)))
|
||||
if not names:
|
||||
sys.exit(
|
||||
"Aucune image « Fedora-Cloud-Base-Generic » trouvée pour "
|
||||
f"Fedora {version} ({a}) dans {index}"
|
||||
)
|
||||
return index + names[-1]
|
||||
# On essaie le redirecteur (2 fois : chaque requête peut viser un miroir
|
||||
# différent) puis le serveur maître (toujours complet).
|
||||
bases = [FEDORA_BASE, FEDORA_BASE, FEDORA_BASE_MASTER]
|
||||
last_err = ""
|
||||
for base in bases:
|
||||
index = f"{base}/{version}/Cloud/{a}/images/"
|
||||
try:
|
||||
with urllib.request.urlopen(index, timeout=30) as resp: # noqa: S310
|
||||
html = resp.read().decode(errors="replace")
|
||||
except Exception as exc: # pragma: no cover - dépend du réseau
|
||||
last_err = str(exc)
|
||||
continue
|
||||
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:
|
||||
|
|
@ -1241,7 +1257,17 @@ def virt_install(
|
|||
cmd += ["--virt-type", "qemu"]
|
||||
if not args.attach_console:
|
||||
cmd.append("--noautoconsole")
|
||||
runner.run(cmd, privileged=True)
|
||||
# virtinst écrit un journal de debug dans ~/.cache/virt-manager ; sous
|
||||
# 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:
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import json
|
|||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
|
@ -280,6 +281,17 @@ def scan_log_errors(log_path: str) -> tuple[int, int]:
|
|||
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]:
|
||||
"""Lit le log à partir de `offset` (lecture incrémentale). Renvoie
|
||||
(nouveau_texte, nouvel_offset). Bloquant -> à appeler dans un thread."""
|
||||
|
|
@ -292,6 +304,32 @@ def _read_new(path: str, offset: int) -> tuple[str, int]:
|
|||
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
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
|
@ -576,7 +614,6 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
|||
DataTable { width: 74; height: 1fr; overflow-x: auto; border: solid $accent; }
|
||||
RichLog { border: solid $accent; }
|
||||
#telemetry { height: 1; color: $text-muted; }
|
||||
#autobar { height: 1; color: $warning; }
|
||||
#stats { height: 1; color: $accent; }
|
||||
#statsdetail { display: none; height: auto; color: $text-muted; }
|
||||
#sshbar { height: 2; color: $text-muted; }
|
||||
|
|
@ -595,8 +632,6 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
|||
("f", "follow", "Suivre"),
|
||||
("c", "copy_log", "Copier log"),
|
||||
("d", "details", "Détails erreurs"),
|
||||
("a", "toggle_auto", "Auto-refresh on/off"),
|
||||
("r", "refresh_now", "Rafraîchir"),
|
||||
("p", "pause_all", "Pause tout"),
|
||||
("o", "resume_all", "Reprendre tout"),
|
||||
]
|
||||
|
|
@ -622,11 +657,13 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
|||
self._errcount = {}
|
||||
# Sommaire de stats déplié (clic) ou non.
|
||||
self._stats_open = False
|
||||
# Rafraîchissement automatique COUPÉ par défaut : sur un gros parc,
|
||||
# les ticks périodiques (logs/table/domstate) causent du lag quand
|
||||
# on lit les logs. On l'active à la demande (touche « a ») ; sinon
|
||||
# « r » rafraîchit une seule fois.
|
||||
self._auto_refresh = False
|
||||
# VM dont l'UI Odoo (:8069) répond déjà : une fois détectée « up »,
|
||||
# on ne re-teste plus (Odoo ne redescend pas en cours d'install).
|
||||
self._odoo_up = set()
|
||||
# Debounce du changement de VM : la sélection défile vite au
|
||||
# clavier ; on ne recharge le log qu'une fois le curseur STABILISÉ.
|
||||
self._pending_sel = None
|
||||
self._sel_timer = None
|
||||
|
||||
@staticmethod
|
||||
def _fmt(secs):
|
||||
|
|
@ -651,11 +688,13 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
|||
table.add_column("VM", key="vm", width=26)
|
||||
table.add_column("⚠", key="err", width=4)
|
||||
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("Disque", key="disk", width=8)
|
||||
for vm in vms:
|
||||
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).
|
||||
self._log = RichLog(
|
||||
|
|
@ -664,8 +703,6 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
|||
with Horizontal():
|
||||
yield table
|
||||
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).
|
||||
yield Static("", id="telemetry")
|
||||
# Sommaire de stats en CHIFFRES (cliquable -> détail).
|
||||
|
|
@ -681,17 +718,15 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
|||
self.sub_title = f"0/{len(vms)} {t('completed')}"
|
||||
self._refresh_ssh()
|
||||
self._load_selected_log(reset=True)
|
||||
self._update_autobar()
|
||||
# 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.
|
||||
# Table toutes les 2 s (30 lectures de fin de log), suivi du log
|
||||
# sélectionné toutes les 1 s (une seule lecture incrémentale).
|
||||
self.set_interval(2.0, self._tick_table)
|
||||
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)
|
||||
# Un SEUL relevé initial (table + domstate) pour afficher l'état
|
||||
# courant à l'ouverture, même auto-refresh coupé.
|
||||
self.run_worker(self._do_domstate(), exclusive=False)
|
||||
self.run_worker(self._do_table(), exclusive=False)
|
||||
# Premier relevé domstate immédiat (async -> via worker).
|
||||
self.run_worker(self._tick_domstate(), exclusive=False)
|
||||
|
||||
# -- helpers -------------------------------------------------------- #
|
||||
def _vm_by_name(self, name):
|
||||
|
|
@ -704,21 +739,27 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
|||
bar.update(
|
||||
f" {vm['ssh']} (s = SSH · w = web :8069 · "
|
||||
"c = copier le log · d = détails erreurs · "
|
||||
"a = auto-refresh · r = rafraîchir · "
|
||||
"Maj+glisser = sélectionner)\n"
|
||||
f" Log : {vm['log']}"
|
||||
)
|
||||
|
||||
def _load_selected_log(self, reset=False):
|
||||
# Lecture SYNCHRONE (mount / changement de ligne). Le suivi périodique
|
||||
# passe par _tick_log (async, I/O en thread).
|
||||
# Au reset (changement de VM), on ne lit QUE LA FIN du log
|
||||
# (_read_tail) et on cale l'offset sur la taille totale : le suivi
|
||||
# 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)
|
||||
if not vm:
|
||||
return
|
||||
name = vm["name"]
|
||||
if reset:
|
||||
self._log.clear()
|
||||
self._offsets[name] = 0
|
||||
text, size = _read_tail(vm["log"])
|
||||
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))
|
||||
self._offsets[name] = off
|
||||
for line in new.splitlines():
|
||||
|
|
@ -745,7 +786,7 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
|||
def _collect_table(self):
|
||||
"""(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."""
|
||||
disks, status, errors = {}, {}, {}
|
||||
disks, status, errors, odoo = {}, {}, {}, {}
|
||||
for vm in vms:
|
||||
name = vm["name"]
|
||||
disks[name] = _fmt_size(disk_actual_size(vm_disk_path(vm)))
|
||||
|
|
@ -760,23 +801,27 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
|||
# peut contenir des erreurs passées inaperçues.
|
||||
if st[0] in ("done", "failed") and name not in errors:
|
||||
errors[name] = scan_log_errors(vm["log"])
|
||||
return disks, status, self._collect_tele(), errors
|
||||
# Odoo up ? On ne teste que celles pas encore confirmées up
|
||||
# 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):
|
||||
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 ->
|
||||
# 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.
|
||||
try:
|
||||
disks, status, tele, errors = await asyncio.to_thread(
|
||||
disks, status, tele, errors, odoo = await asyncio.to_thread(
|
||||
self._collect_table
|
||||
)
|
||||
except Exception:
|
||||
return
|
||||
self._errcount.update(errors)
|
||||
self._odoo_up.update(odoo)
|
||||
try:
|
||||
table = self.query_one("#vms", DataTable)
|
||||
now = time.time()
|
||||
|
|
@ -784,6 +829,11 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
|||
for vm in vms:
|
||||
name = vm["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:
|
||||
continue
|
||||
ds = self._domstate.get(name)
|
||||
|
|
@ -914,10 +964,6 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
|||
return "\n".join(lines)
|
||||
|
||||
async def _tick_log(self):
|
||||
if self._auto_refresh:
|
||||
await self._do_log()
|
||||
|
||||
async def _do_log(self):
|
||||
if not self._follow:
|
||||
return
|
||||
vm = self._vm_by_name(self._selected)
|
||||
|
|
@ -936,10 +982,6 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
|||
self._log.write(line)
|
||||
|
||||
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
|
||||
# la boucle. Une VM absente de « virsh list » est EFFACÉE (« gone »).
|
||||
try:
|
||||
|
|
@ -949,40 +991,28 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
|||
for vm in vms:
|
||||
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 --------------------------------------------------------- #
|
||||
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
|
||||
if name and name != self._selected:
|
||||
self._selected = name
|
||||
self._refresh_ssh()
|
||||
self._load_selected_log(reset=True)
|
||||
if not name or name == self._selected:
|
||||
return
|
||||
self._pending_sel = name
|
||||
if self._sel_timer is not None:
|
||||
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:
|
||||
self._follow = not self._follow
|
||||
|
|
@ -1167,10 +1197,8 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
|||
await asyncio.to_thread(self._virsh_bulk, action, targets)
|
||||
verb = t("paused") if action == "suspend" else t("resumed")
|
||||
self.notify(f"{len(targets)} VM {verb}.")
|
||||
# Rafraîchit tout de suite l'état libvirt (pause/reprise visible),
|
||||
# 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)
|
||||
# Rafraîchit tout de suite l'état libvirt (pause/reprise visible).
|
||||
self.run_worker(self._tick_domstate(), exclusive=False)
|
||||
|
||||
app = Monitor()
|
||||
if run_app:
|
||||
|
|
|
|||
|
|
@ -3017,6 +3017,16 @@ class TODO:
|
|||
"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):
|
||||
"""Choix de CE QU'ON installe sur la VM. Renvoie (label, commande
|
||||
finale exécutée dans ~/git/erplibre)."""
|
||||
|
|
@ -3059,14 +3069,43 @@ class TODO:
|
|||
return profiles[0] # défaut : ERPLibre + Odoo 18
|
||||
|
||||
@staticmethod
|
||||
def _qemu_odoo_service_cmd():
|
||||
@staticmethod
|
||||
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
|
||||
service systemd (inspiré de script/systemd/install_daemon.sh) puis
|
||||
l'active + démarre. N'est ajouté QUE pour les profils avec Odoo."""
|
||||
service systemd puis l'active. N'est ajouté QUE pour les profils 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 (
|
||||
'SVC_USER=$(whoami); SVC_GROUP=$(id -gn); '
|
||||
'SVC_DIR="$HOME/git/erplibre"; '
|
||||
"sudo tee /etc/systemd/system/erplibre.service >/dev/null <<UNIT\n"
|
||||
f'SVC_USER=$(whoami); SVC_GROUP=$(id -gn); SVC_DIR="{svc_dir}"; '
|
||||
+ pre
|
||||
+ selinux_shell
|
||||
+ "sudo tee /etc/systemd/system/erplibre.service >/dev/null <<UNIT\n"
|
||||
"[Unit]\n"
|
||||
"Description=ERPLibre\n"
|
||||
"Requires=postgresql.service\n"
|
||||
|
|
@ -3081,6 +3120,7 @@ class TODO:
|
|||
"ExecStart=$SVC_DIR/run.sh\n"
|
||||
"WorkingDirectory=$SVC_DIR\n"
|
||||
"StandardOutput=journal+console\n"
|
||||
"$SELINUX_LINE\n"
|
||||
"\n"
|
||||
"[Install]\n"
|
||||
"WantedBy=multi-user.target\n"
|
||||
|
|
@ -3089,17 +3129,18 @@ class TODO:
|
|||
"sudo systemctl enable --now erplibre.service"
|
||||
)
|
||||
|
||||
def _qemu_erplibre_remote_cmd(self, branch, final_cmd=None):
|
||||
def _qemu_erplibre_remote_cmd(self, branch, final_cmd=None, prod=False):
|
||||
"""Script d'installation ERPLibre exécuté DANS la VM (curl/git/make
|
||||
puis clone + `final_cmd` dans ~/git/erplibre). `final_cmd` par défaut :
|
||||
install_os + install_odoo_18."""
|
||||
puis clone + `final_cmd`). `final_cmd` par défaut : install_os +
|
||||
install_odoo_18. `prod` : installe dans /opt/erplibre (au lieu de
|
||||
~/git/erplibre) + service SELinux confiné."""
|
||||
if not final_cmd:
|
||||
final_cmd = f"make install_os && make {self.ERPLIBRE_ODOO_TARGET}"
|
||||
# Profils AVEC Odoo (install_odoo*) uniquement : après l'install, on
|
||||
# enregistre Odoo comme service systemd (enable + start). Pas pour
|
||||
# « ERPLibre seul », « mobile » ni « Déploiement ».
|
||||
if "install_odoo" in final_cmd:
|
||||
final_cmd = f"{final_cmd} && {self._qemu_odoo_service_cmd()}"
|
||||
final_cmd = f"{final_cmd} && {self._qemu_odoo_service_cmd(prod)}"
|
||||
return (
|
||||
"set -e; "
|
||||
# Attendre la FIN de cloud-init : pendant sa phase « paquets » il
|
||||
|
|
@ -3116,11 +3157,15 @@ class TODO:
|
|||
# (Fedora) et pacman (Arch).
|
||||
"PKGS='curl git make'; "
|
||||
"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
|
||||
# « || true », un « apt-get update » en échec (réseau) sautait
|
||||
# l'install sans erreur (liste &&) -> git/make absents ensuite.
|
||||
"sudo apt-get update -qq || true; "
|
||||
"sudo apt-get install -y $PKGS; "
|
||||
"sudo apt-get -o DPkg::Lock::Timeout=600 update -qq || true; "
|
||||
"sudo apt-get -o DPkg::Lock::Timeout=600 install -y $PKGS; "
|
||||
"elif command -v dnf >/dev/null 2>&1; then "
|
||||
# makecache (dnf5 choisit les miroirs les plus rapides) puis
|
||||
# install --refresh ; retry avec « clean all » car les images
|
||||
|
|
@ -3151,27 +3196,42 @@ class TODO:
|
|||
"for t in curl git make; do command -v $t >/dev/null 2>&1 || "
|
||||
'{ echo "Outil manquant apres installation: $t '
|
||||
'(reseau de la VM ?)"; exit 1; }; done; '
|
||||
"mkdir -p ~/git; "
|
||||
"if [ ! -d ~/git/erplibre/.git ]; then "
|
||||
f"git clone --branch {shlex.quote(branch)} "
|
||||
f"{self.ERPLIBRE_GIT_URL} ~/git/erplibre; fi; "
|
||||
# Installation : commande finale selon le profil choisi.
|
||||
f"cd ~/git/erplibre && {final_cmd}"
|
||||
# Clone : /opt/erplibre en PROD (racine, puis chown à l'utilisateur
|
||||
# pour que make/venv s'exécutent sans sudo), ~/git/erplibre en dev.
|
||||
+ (
|
||||
(
|
||||
"sudo mkdir -p /opt; "
|
||||
"if [ ! -d /opt/erplibre/.git ]; then "
|
||||
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(
|
||||
self, names, branch, ip_map=None, final_cmd=None
|
||||
self, names, branch, ip_map=None, final_cmd=None, prod=False
|
||||
):
|
||||
"""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.
|
||||
`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 (
|
||||
launch_installs,
|
||||
run_monitor,
|
||||
)
|
||||
|
||||
remote = self._qemu_erplibre_remote_cmd(branch, final_cmd)
|
||||
remote = self._qemu_erplibre_remote_cmd(branch, final_cmd, prod)
|
||||
try:
|
||||
mod = self._qemu_import_module()
|
||||
except Exception:
|
||||
|
|
@ -3224,11 +3284,11 @@ class TODO:
|
|||
print(f" {t('Read the logs:')} tail -n +1 {logdir}/*.log")
|
||||
|
||||
def _qemu_install_erplibre_vm(
|
||||
self, name, ssh_key, branch, ip=None, final_cmd=None
|
||||
self, name, ssh_key, branch, ip=None, final_cmd=None, prod=False
|
||||
):
|
||||
"""Clone ERPLibre (branche donnée) dans ~/git/erplibre de la VM puis
|
||||
exécute la commande d'install du profil choisi (streamé). `ip` : IP
|
||||
déjà résolue ; `final_cmd` : commande d'install."""
|
||||
"""Clone ERPLibre (branche donnée) dans la VM puis exécute la commande
|
||||
d'install du profil choisi (streamé). `ip` : IP déjà résolue ;
|
||||
`final_cmd` : commande d'install ; `prod` : /opt + SELinux confiné."""
|
||||
if ip is None:
|
||||
ip = self._qemu_vm_ip(name)
|
||||
if not ip:
|
||||
|
|
@ -3245,7 +3305,7 @@ class TODO:
|
|||
f"{t('SSH not reachable, ERPLibre install skipped.')}"
|
||||
)
|
||||
return
|
||||
remote = self._qemu_erplibre_remote_cmd(branch, final_cmd)
|
||||
remote = self._qemu_erplibre_remote_cmd(branch, final_cmd, prod)
|
||||
ssh_opts = (
|
||||
"-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "
|
||||
"-o ConnectTimeout=15"
|
||||
|
|
@ -3559,12 +3619,14 @@ class TODO:
|
|||
# 4) Option : installer ERPLibre dans ~/git/erplibre de chaque VM.
|
||||
install_branch = None
|
||||
install_monitor = False
|
||||
install_prod = False # dev (~/git, SELinux dev) vs prod (/opt, confiné)
|
||||
install_cmd = None # commande finale selon le profil choisi
|
||||
ans = input(
|
||||
t("Install ERPLibre into ~/git/erplibre on each VM? (y/N): ")
|
||||
)
|
||||
if self._is_yes(ans):
|
||||
install_branch = self._qemu_pick_branch()
|
||||
install_prod = self._qemu_ask_prod()
|
||||
_label, install_cmd = self._qemu_pick_install_profile()
|
||||
install_monitor = self._is_yes_default_yes(
|
||||
input(t("Interactive monitoring dashboard? (y/N): "))
|
||||
|
|
@ -3683,7 +3745,8 @@ class TODO:
|
|||
if install_monitor:
|
||||
# Installs détachées en parallèle + dashboard Textual.
|
||||
self._qemu_install_erplibre_monitored(
|
||||
deployed, install_branch, ip_map, install_cmd
|
||||
deployed, install_branch, ip_map, install_cmd,
|
||||
install_prod,
|
||||
)
|
||||
else:
|
||||
print(
|
||||
|
|
@ -3697,6 +3760,7 @@ class TODO:
|
|||
install_branch,
|
||||
ip_map.get(name),
|
||||
install_cmd,
|
||||
install_prod,
|
||||
)
|
||||
|
||||
# Sommaire TOTAL (déploiement + résolution IP + ssh_config + install
|
||||
|
|
|
|||
|
|
@ -1575,30 +1575,6 @@ TRANSLATIONS = {
|
|||
"fr": "Aucune erreur détectée.",
|
||||
"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)": {
|
||||
"fr": "🧪 Tester une VM (ouvrir Odoo dans un navigateur CLI)",
|
||||
"en": "🧪 Test a VM (open Odoo in a CLI browser)",
|
||||
|
|
@ -1818,6 +1794,22 @@ TRANSLATIONS = {
|
|||
"fr": "État cible :",
|
||||
"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)": {
|
||||
"fr": "Ouvrir (démarrer)",
|
||||
"en": "Open (start)",
|
||||
|
|
|
|||
Loading…
Reference in a new issue