Compare commits

..

7 commits

Author SHA1 Message Date
808e37cd59 [IMP] script todo: déploiement — choix dev/prod (/opt + SELinux confiné en prod)
Au déploiement (« Déployer une ou plusieurs VM »), après le choix de la
branche, on demande l'ENVIRONNEMENT cible (dev par défaut / prod).

- DEV : ERPLibre dans ~/git/erplibre ; service systemd unconfined si
  SELinux actif (comportement inchangé).
- PROD : ERPLibre dans /opt/erplibre (sudo git clone + chown à
  l'utilisateur) ; service systemd CONFINÉ par SELinux (pas d'unconfined)
  + restorecon des contextes -> hors user_home_t, un service peut exécuter
  le contenu sans lever le confinement.

Le drapeau `prod` est propagé : _qemu_deploy -> _qemu_install_erplibre_
monitored/_vm -> _qemu_erplibre_remote_cmd -> _qemu_odoo_service_cmd.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 02:09:29 -04:00
43d268e448 [FIX] install: poetry — rejeu -vvv (et non -v) à l'échec pour la vraie cause
« -v » montre l'étape mais pas la sortie des sous-processus. Seul « -vvv »
(debug) affiche git clone/checkout et le build pip -> l'erreur réelle d'une
dépendance VCS/build. Le rejeu de diagnostic passe donc en -vvv.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 01:56:04 -04:00
64c0bbc698 [FIX] install: revue 13 VM — apt lock, wkhtmltopdf trixie, SELinux, poetry
Corrections issues de la revue complète des 13 VM (6 en échec réel) :

- APT lock (debian-12) : au 1er boot, cloud-init/apt-daily tient le verrou
  et « apt-get install » échouait aussitôt. Ajout de
  « -o DPkg::Lock::Timeout=600 » (update + install) -> attend le verrou.

- wkhtmltopdf Debian 13 « trixie » (debian-13) : on prenait le build
  « bullseye » (dépend de libssl1.1, absent de trixie) -> gdebi échouait.
  Désormais bookworm pour bookworm/trixie/+. ET l'échec gdebi est NON
  bloquant (avertissement au lieu d'exit 1 : wkhtmltopdf est optionnel,
  sinon tout install_os avortait et Odoo n'était jamais installé).

- SELinux 203/EXEC (fedora-41/43/44) : un service système ne peut pas
  exécuter run.sh/venv sous /home (contexte user_home_t). Ajout
  conditionnel de « SELinuxContext=unconfined_u:unconfined_r:unconfined_t:s0 »
  au service (uniquement si getenforce != Disabled).

- poetry status 1 (ubuntu-2604) : « -q » masquait la cause. À l'échec, on
  rejoue « poetry install -v » pour capturer l'erreur dans le log.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 01:52:55 -04:00
7904c72d71 [IMP] script todo: suivi install — colonne « Odoo » (état :8069)
Nouvelle colonne « Odoo » dans le dashboard : teste par TCP si l'UI web
répond sur le port 8069 de la VM (🟢 up / — sinon). Une fois « up »
détecté, on ne re-teste plus (Odoo ne redescend pas en cours d'install).
Le test (socket, timeout 0,5 s) est fait dans le thread collecteur ->
n'impacte pas la fluidité de l'UI.

Validé headless : port ouvert -> 🟢, fermé -> —.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 01:52:55 -04:00
1ebf2b353f [FIX] qemu: Fedora image robuste (miroirs) + silence du bruit virtinst
Déploiement PARALLÈLE de 13 VM -> 2 problèmes observés :

1. « Aucune image Fedora-Cloud-Base-Generic pour Fedora 42 » alors que
   l'image EXISTE. download.fedoraproject.org est un redirecteur
   (MirrorManager) : sous requêtes parallèles, une VM tombait sur un miroir
   incomplet -> index HTML sans correspondance -> sys.exit.
   resolve_fedora_url réessaie (2× le redirecteur) puis se replie sur le
   serveur MAÎTRE dl.fedoraproject.org (toujours complet). Validé : F41/F42
   résolus.

2. Pavé « Fetched capabilities … » (énorme XML) dans la sortie : erreur de
   LOGGING de virtinst — sous sudo, HOME/cache inaccessible -> échec
   d'écriture du journal debug -> Python déverse l'enregistrement raté.
   On préfixe virt-install de « env XDG_CACHE_HOME=… HOME=… » (traverse
   sudo) vers un cache écrivable -> journal silencieux.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 01:41:17 -04:00
b99db0ea66 [FIX] script todo: suivi install — navigation fluide (tail + debounce)
Cause des GROS ralentissements en changeant de VM / lisant les logs :
- on_data_table_row_highlighted rechargeait le log à CHAQUE mouvement du
  curseur (donc en rafale quand on tient la flèche) ;
- _load_selected_log(reset) lisait le fichier ENTIER (offset 0) et écrivait
  TOUTES les lignes dans le RichLog, SYNCHRONE sur la boucle d'événements
  -> un log de 250 Ko / plusieurs milliers de lignes gelait l'UI, × chaque
  pas de navigation.

Corrections :
- _read_tail : au changement de VM on ne lit que la FIN du log (128 Ko /
  1000 lignes max) et on cale l'offset sur la taille totale -> le suivi
  incrémental continue depuis la fin. Affichage quasi instantané.
- Debounce (0,25 s) : RowHighlighted mémorise seulement la cible ; on ne
  charge qu'une fois le curseur stabilisé, et uniquement la DERNIÈRE VM.

Validé headless : 2 flèches rapides -> 1 seul chargement (VM finale),
1000 lignes affichées au lieu de 8000.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 01:31:00 -04:00
fd3226a053 [REV] script todo: suivi install — revert auto-refresh coupé par défaut (4f0c3cd)
L'auto-refresh coupé par défaut était une mauvaise idée : (1) les lags
persistaient malgré tout ; (2) une fois coupé, on ne voyait plus
l'avancement (et l'activation ne suffisait pas). On revient au
comportement précédent : rafraîchissement automatique TOUJOURS actif
(logs 1s / table 2s / domstate 10s) — au moins on voit la progression.

Revert de 4f0c3cd. L'analyse du VRAI ralentissement (navigation / logs)
est traitée séparément.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 01:28:06 -04:00
6 changed files with 270 additions and 146 deletions

View file

@ -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 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 [ "bookworm" == "${DEBIAN_VERSION}" ]; then 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.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 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 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"
@ -218,11 +222,15 @@ 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
echo "gdebi install wkhtmltopdf installation error." # wkhtmltopdf est OPTIONNEL (rapports PDF). NON bloquant : sur Debian
exit 1 # 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 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,6 +96,12 @@ 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,6 +161,10 @@ 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
@ -214,26 +218,38 @@ 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 »).""" 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) 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"
try: # On essaie le redirecteur (2 fois : chaque requête peut viser un miroir
with urllib.request.urlopen(index, timeout=30) as resp: # noqa: S310 # différent) puis le serveur maître (toujours complet).
html = resp.read().decode(errors="replace") bases = [FEDORA_BASE, FEDORA_BASE, FEDORA_BASE_MASTER]
except Exception as exc: # pragma: no cover - dépend du réseau last_err = ""
sys.exit(f"Impossible de lister les images Fedora {version} : {exc}") for base in bases:
names = sorted(set(pattern.findall(html))) index = f"{base}/{version}/Cloud/{a}/images/"
if not names: try:
sys.exit( with urllib.request.urlopen(index, timeout=30) as resp: # noqa: S310
"Aucune image « Fedora-Cloud-Base-Generic » trouvée pour " html = resp.read().decode(errors="replace")
f"Fedora {version} ({a}) dans {index}" except Exception as exc: # pragma: no cover - dépend du réseau
) last_err = str(exc)
return index + names[-1] 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: def default_image_name(distro: str, code: str, arch: str, version: str) -> str:
@ -1241,7 +1257,17 @@ 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")
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: def _ip_reachable(ip: str, port: int = 22, timeout: float = 3) -> bool:

View file

@ -18,6 +18,7 @@ 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
@ -280,6 +281,17 @@ 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."""
@ -292,6 +304,32 @@ 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
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@ -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; } 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; }
@ -595,8 +632,6 @@ 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"),
] ]
@ -622,11 +657,13 @@ 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
# Rafraîchissement automatique COUPÉ par défaut : sur un gros parc, # VM dont l'UI Odoo (:8069) répond déjà : une fois détectée « up »,
# les ticks périodiques (logs/table/domstate) causent du lag quand # on ne re-teste plus (Odoo ne redescend pas en cours d'install).
# on lit les logs. On l'active à la demande (touche « a ») ; sinon self._odoo_up = set()
# « r » rafraîchit une seule fois. # Debounce du changement de VM : la sélection défile vite au
self._auto_refresh = False # clavier ; on ne recharge le log qu'une fois le curseur STABILISÉ.
self._pending_sel = None
self._sel_timer = None
@staticmethod @staticmethod
def _fmt(secs): 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("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(
@ -664,8 +703,6 @@ 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).
@ -681,17 +718,15 @@ 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)
self._update_autobar() # Table toutes les 2 s (30 lectures de fin de log), suivi du log
# Ticks périodiques : ils ne font RIEN tant que l'auto-refresh est # sélectionné toutes les 1 s (une seule lecture incrémentale).
# 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)
# Un SEUL relevé initial (table + domstate) pour afficher l'état # Premier relevé domstate immédiat (async -> via worker).
# courant à l'ouverture, même auto-refresh coupé. self.run_worker(self._tick_domstate(), exclusive=False)
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):
@ -704,21 +739,27 @@ 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):
# Lecture SYNCHRONE (mount / changement de ligne). Le suivi périodique # Au reset (changement de VM), on ne lit QUE LA FIN du log
# passe par _tick_log (async, I/O en thread). # (_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) 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()
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)) 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():
@ -745,7 +786,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 = {}, {}, {} disks, status, errors, odoo = {}, {}, {}, {}
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)))
@ -760,23 +801,27 @@ 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"])
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): 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 = await asyncio.to_thread( disks, status, tele, errors, odoo = 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()
@ -784,6 +829,11 @@ 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)
@ -914,10 +964,6 @@ 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)
@ -936,10 +982,6 @@ 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:
@ -949,40 +991,28 @@ 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 name and name != self._selected: if not name or name == self._selected:
self._selected = name return
self._refresh_ssh() self._pending_sel = name
self._load_selected_log(reset=True) 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: def action_follow(self) -> None:
self._follow = not self._follow 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) 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).
# même si l'auto-refresh est coupé -> _do_domstate (forcé). self.run_worker(self._tick_domstate(), exclusive=False)
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,6 +3017,16 @@ 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)."""
@ -3059,14 +3069,43 @@ class TODO:
return profiles[0] # défaut : ERPLibre + Odoo 18 return profiles[0] # défaut : ERPLibre + Odoo 18
@staticmethod @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 """Snippet shell (exécuté dans la VM) qui installe ERPLibre/Odoo comme
service systemd (inspiré de script/systemd/install_daemon.sh) puis service systemd puis l'active. N'est ajouté QUE pour les profils Odoo.
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 (
'SVC_USER=$(whoami); SVC_GROUP=$(id -gn); ' f'SVC_USER=$(whoami); SVC_GROUP=$(id -gn); SVC_DIR="{svc_dir}"; '
'SVC_DIR="$HOME/git/erplibre"; ' + pre
"sudo tee /etc/systemd/system/erplibre.service >/dev/null <<UNIT\n" + selinux_shell
+ "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"
@ -3081,6 +3120,7 @@ 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"
@ -3089,17 +3129,18 @@ class TODO:
"sudo systemctl enable --now erplibre.service" "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 """Script d'installation ERPLibre exécuté DANS la VM (curl/git/make
puis clone + `final_cmd` dans ~/git/erplibre). `final_cmd` par défaut : puis clone + `final_cmd`). `final_cmd` par défaut : install_os +
install_os + install_odoo_18.""" install_odoo_18. `prod` : installe dans /opt/erplibre (au lieu de
~/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()}" final_cmd = f"{final_cmd} && {self._qemu_odoo_service_cmd(prod)}"
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
@ -3116,11 +3157,15 @@ 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 update -qq || true; " "sudo apt-get -o DPkg::Lock::Timeout=600 update -qq || true; "
"sudo apt-get install -y $PKGS; " "sudo apt-get -o DPkg::Lock::Timeout=600 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
@ -3151,27 +3196,42 @@ 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; '
"mkdir -p ~/git; " # Clone : /opt/erplibre en PROD (racine, puis chown à l'utilisateur
"if [ ! -d ~/git/erplibre/.git ]; then " # pour que make/venv s'exécutent sans sudo), ~/git/erplibre en dev.
f"git clone --branch {shlex.quote(branch)} " + (
f"{self.ERPLIBRE_GIT_URL} ~/git/erplibre; fi; " (
# Installation : commande finale selon le profil choisi. "sudo mkdir -p /opt; "
f"cd ~/git/erplibre && {final_cmd}" "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( 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 """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) remote = self._qemu_erplibre_remote_cmd(branch, final_cmd, prod)
try: try:
mod = self._qemu_import_module() mod = self._qemu_import_module()
except Exception: except Exception:
@ -3224,11 +3284,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 self, name, ssh_key, branch, ip=None, final_cmd=None, prod=False
): ):
"""Clone ERPLibre (branche donnée) dans ~/git/erplibre de la VM puis """Clone ERPLibre (branche donnée) dans la VM puis exécute la commande
exécute la commande d'install du profil choisi (streamé). `ip` : IP d'install du profil choisi (streamé). `ip` : IP déjà résolue ;
déjà résolue ; `final_cmd` : commande d'install.""" `final_cmd` : commande d'install ; `prod` : /opt + SELinux confiné."""
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:
@ -3245,7 +3305,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) remote = self._qemu_erplibre_remote_cmd(branch, final_cmd, prod)
ssh_opts = ( ssh_opts = (
"-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null " "-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "
"-o ConnectTimeout=15" "-o ConnectTimeout=15"
@ -3559,12 +3619,14 @@ 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): "))
@ -3683,7 +3745,8 @@ 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(
@ -3697,6 +3760,7 @@ 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,30 +1575,6 @@ 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)",
@ -1818,6 +1794,22 @@ 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)",