[IMP] qemu: guest-agent au déploiement + suivi (erreurs, stats, pause parc)
Provisioning (deploy_qemu.py) : - qemu-guest-agent installé + activé dans le runcmd cloud-init (APRÈS sshd, « || true » : ne bloque pas le boot si le réseau est lent). - Canal virtio org.qemu.guest_agent.0 ajouté à virt-install : virsh peut piloter la VM SANS réseau. - Extension du FS invité (todo.py) : nouveau tier AGENT INVITÉ (_qemu_guest_exec via qemu-agent-command guest-exec) entre SSH et la console série -> étend le FS même sans IP. Suivi d'installation (qemu_install_monitor.py) : - Détection d'erreurs dans le log à la complétion (succès OU échec) en réutilisant la logique de script/test/run_parallel_test.py (sous-chaîne error/warning + listes d'ignore). Nouvelle colonne « ⚠ » À GAUCHE d'État (⚠N erreurs / ⚡N avert. / ✓ propre). - Sommaire de stats EN CHIFFRES (📊 total · ✅ · ❌ · ⏳ · ⏸ · 🗑 · ⚠ · ⚡) ; CLIC pour déplier le détail (VM en erreur + durées). - Boutons « p » Pause tout (virsh suspend des VM running) et « o » Reprendre tout (virsh resume) — les logs continuent (offsets conservés). Validé headless : succès-avec-erreur -> ⚠1, stats/détail, pause du parc. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3f20497912
commit
ab8bfec113
4 changed files with 342 additions and 8 deletions
|
|
@ -970,6 +970,16 @@ def build_cloud_config(
|
||||||
"runcmd:",
|
"runcmd:",
|
||||||
" - systemctl enable --now ssh 2>/dev/null"
|
" - systemctl enable --now ssh 2>/dev/null"
|
||||||
" || systemctl enable --now sshd 2>/dev/null || true",
|
" || systemctl enable --now sshd 2>/dev/null || true",
|
||||||
|
# qemu-guest-agent : installé + activé APRÈS sshd (donc SSH reste
|
||||||
|
# disponible tout de suite, sans attendre le réseau). Tout est
|
||||||
|
# « || true » : si l'installation échoue (réseau lent/absent), le boot
|
||||||
|
# n'est pas bloqué. La plupart des images cloud l'incluent déjà.
|
||||||
|
" - (command -v apt-get >/dev/null && apt-get install -y"
|
||||||
|
" qemu-guest-agent) || (command -v dnf >/dev/null && dnf install -y"
|
||||||
|
" qemu-guest-agent) || (command -v pacman >/dev/null && pacman -S"
|
||||||
|
" --noconfirm qemu-guest-agent) || true",
|
||||||
|
" - systemctl enable --now qemu-guest-agent 2>/dev/null"
|
||||||
|
" || systemctl enable --now qemu-ga 2>/dev/null || true",
|
||||||
]
|
]
|
||||||
return "\n".join(lines) + "\n"
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
@ -1159,6 +1169,11 @@ def virt_install(
|
||||||
args.graphics,
|
args.graphics,
|
||||||
"--console",
|
"--console",
|
||||||
f"pty,target_type={console_target}",
|
f"pty,target_type={console_target}",
|
||||||
|
# Canal virtio de l'agent invité (org.qemu.guest_agent.0) : permet à
|
||||||
|
# virsh de piloter la VM SANS réseau (ex. étendre le FS invité après
|
||||||
|
# un redimensionnement de disque). Inoffensif si l'agent est absent.
|
||||||
|
"--channel",
|
||||||
|
"unix,target.type=virtio,target.name=org.qemu.guest_agent.0",
|
||||||
]
|
]
|
||||||
if args.arch == "s390x":
|
if args.arch == "s390x":
|
||||||
# s390x (IBM Z) : machine s390-ccw-virtio, amorçage IPL/zipl depuis le
|
# s390x (IBM Z) : machine s390-ccw-virtio, amorçage IPL/zipl depuis le
|
||||||
|
|
|
||||||
|
|
@ -176,6 +176,54 @@ def read_status(log_path: str) -> tuple[str, int | None]:
|
||||||
return "running", None
|
return "running", None
|
||||||
|
|
||||||
|
|
||||||
|
# Listes d'ignore reprises de script/test/run_parallel_test.py (erreurs/
|
||||||
|
# avertissements connus et bénins) : on réutilise la MÊME logique de détection
|
||||||
|
# que la suite de tests ERPLibre pour analyser les logs d'installation.
|
||||||
|
_LST_IGNORE_WARNING = (
|
||||||
|
"have the same label:",
|
||||||
|
"odoo.addons.code_generator.extractor_module_file: Ignore next error about"
|
||||||
|
" ALTER TABLE DROP CONSTRAINT.",
|
||||||
|
)
|
||||||
|
_LST_IGNORE_ERROR = (
|
||||||
|
"fetchmail_notify_error_to_sender",
|
||||||
|
'odoo.sql_db: bad query: ALTER TABLE "db_backup" DROP CONSTRAINT'
|
||||||
|
' "db_backup_db_backup_name_unique"',
|
||||||
|
'ERROR: constraint "db_backup_db_backup_name_unique" of relation'
|
||||||
|
' "db_backup" does not exist',
|
||||||
|
'odoo.sql_db: bad query: ALTER TABLE "db_backup" DROP CONSTRAINT'
|
||||||
|
' "db_backup_db_backup_days_to_keep_positive"',
|
||||||
|
'ERROR: constraint "db_backup_db_backup_days_to_keep_positive" of relation'
|
||||||
|
' "db_backup" does not exist',
|
||||||
|
"odoo.addons.code_generator.extractor_module_file: Ignore next error about"
|
||||||
|
" ALTER TABLE DROP CONSTRAINT.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def scan_log_errors(log_path: str) -> tuple[int, int]:
|
||||||
|
"""(nb_erreurs, nb_avertissements) dans un log d'installation, en
|
||||||
|
réutilisant la détection de la suite de tests ERPLibre : sous-chaîne
|
||||||
|
« error »/« warning » (insensible à la casse) moins les listes d'ignore.
|
||||||
|
Lit le fichier COMPLET (appelé une seule fois, à la complétion d'une VM)."""
|
||||||
|
try:
|
||||||
|
text = Path(log_path).read_text(errors="replace")
|
||||||
|
except OSError:
|
||||||
|
return 0, 0
|
||||||
|
nerr = nwarn = 0
|
||||||
|
for line in text.splitlines():
|
||||||
|
low = line.lower()
|
||||||
|
if EXIT_MARKER in line:
|
||||||
|
continue
|
||||||
|
if "error" in low and not any(
|
||||||
|
ig in line for ig in _LST_IGNORE_ERROR
|
||||||
|
):
|
||||||
|
nerr += 1
|
||||||
|
if "warning" in low and not any(
|
||||||
|
ig in line for ig in _LST_IGNORE_WARNING
|
||||||
|
):
|
||||||
|
nwarn += 1
|
||||||
|
return nerr, nwarn
|
||||||
|
|
||||||
|
|
||||||
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."""
|
||||||
|
|
@ -425,6 +473,8 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
||||||
DataTable { width: 58; border: solid $accent; }
|
DataTable { width: 58; border: solid $accent; }
|
||||||
RichLog { border: solid $accent; }
|
RichLog { border: solid $accent; }
|
||||||
#telemetry { height: 1; color: $text-muted; }
|
#telemetry { height: 1; color: $text-muted; }
|
||||||
|
#stats { height: 1; color: $accent; }
|
||||||
|
#statsdetail { display: none; height: auto; color: $text-muted; }
|
||||||
#sshbar { height: 2; color: $text-muted; }
|
#sshbar { height: 2; color: $text-muted; }
|
||||||
"""
|
"""
|
||||||
BINDINGS = [
|
BINDINGS = [
|
||||||
|
|
@ -433,6 +483,8 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
||||||
("w", "web", "Web (navigateur CLI)"),
|
("w", "web", "Web (navigateur CLI)"),
|
||||||
("f", "follow", "Suivre"),
|
("f", "follow", "Suivre"),
|
||||||
("c", "copy_log", "Copier log"),
|
("c", "copy_log", "Copier log"),
|
||||||
|
("p", "pause_all", "Pause tout"),
|
||||||
|
("o", "resume_all", "Reprendre tout"),
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
|
@ -452,6 +504,10 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
||||||
self._disk_dir = os.path.dirname(vm_disk_path(vms[0])) if vms else "/"
|
self._disk_dir = os.path.dirname(vm_disk_path(vms[0])) if vms else "/"
|
||||||
# État libvirt (running/paused/gone), rafraîchi à intervalle LENT.
|
# État libvirt (running/paused/gone), rafraîchi à intervalle LENT.
|
||||||
self._domstate = {}
|
self._domstate = {}
|
||||||
|
# Erreurs détectées dans le log à la complétion : {nom: (err, warn)}.
|
||||||
|
self._errcount = {}
|
||||||
|
# Sommaire de stats déplié (clic) ou non.
|
||||||
|
self._stats_open = False
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _fmt(secs):
|
def _fmt(secs):
|
||||||
|
|
@ -469,12 +525,16 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
||||||
yield Header(show_clock=True)
|
yield Header(show_clock=True)
|
||||||
table = DataTable(id="vms", cursor_type="row")
|
table = DataTable(id="vms", cursor_type="row")
|
||||||
# Clés de colonnes explicites : update_cell() les référence.
|
# Clés de colonnes explicites : update_cell() les référence.
|
||||||
|
# La colonne « ⚠ » (erreurs détectées) est à GAUCHE d'« État ».
|
||||||
table.add_column("VM", key="vm")
|
table.add_column("VM", key="vm")
|
||||||
|
table.add_column("⚠", key="err")
|
||||||
table.add_column("État", key="state")
|
table.add_column("État", key="state")
|
||||||
table.add_column("Durée", key="elapsed")
|
table.add_column("Durée", key="elapsed")
|
||||||
table.add_column("Disque", key="disk")
|
table.add_column("Disque", key="disk")
|
||||||
for vm in vms:
|
for vm in vms:
|
||||||
table.add_row(vm["name"], "⏳", "--:--", "-", key=vm["name"])
|
table.add_row(
|
||||||
|
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(
|
||||||
id="log", highlight=False, markup=False, max_lines=5000
|
id="log", highlight=False, markup=False, max_lines=5000
|
||||||
|
|
@ -484,6 +544,9 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
||||||
yield self._log
|
yield self._log
|
||||||
# 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).
|
||||||
|
yield Static("", id="stats")
|
||||||
|
yield Static("", id="statsdetail")
|
||||||
yield Static("", id="sshbar")
|
yield Static("", id="sshbar")
|
||||||
yield Footer()
|
yield Footer()
|
||||||
|
|
||||||
|
|
@ -554,7 +617,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 = {}, {}
|
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)))
|
||||||
|
|
@ -562,19 +625,26 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
||||||
name not in self._final
|
name not in self._final
|
||||||
and self._domstate.get(name) != "gone"
|
and self._domstate.get(name) != "gone"
|
||||||
):
|
):
|
||||||
status[name] = read_status(vm["log"])
|
st = read_status(vm["log"])
|
||||||
return disks, status, self._collect_tele()
|
status[name] = st
|
||||||
|
# À la complétion (succès OU échec), on ANALYSE le log
|
||||||
|
# complet pour signaler les erreurs — même un « succès »
|
||||||
|
# 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
|
||||||
|
|
||||||
async def _tick_table(self):
|
async def _tick_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 = 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)
|
||||||
try:
|
try:
|
||||||
table = self.query_one("#vms", DataTable)
|
table = self.query_one("#vms", DataTable)
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|
@ -611,6 +681,12 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
||||||
self._set_cell(
|
self._set_cell(
|
||||||
table, name, "elapsed", self._fmt(elapsed)
|
table, name, "elapsed", self._fmt(elapsed)
|
||||||
)
|
)
|
||||||
|
# Colonne ⚠ (à gauche d'État) : erreurs détectées dans
|
||||||
|
# le log, y compris pour un « succès ».
|
||||||
|
self._set_cell(
|
||||||
|
table, name, "err",
|
||||||
|
self._err_label(self._errcount.get(name)),
|
||||||
|
)
|
||||||
elif ds == "paused":
|
elif ds == "paused":
|
||||||
self._set_cell(
|
self._set_cell(
|
||||||
table, name, "state", f"⏸ {t('paused')}"
|
table, name, "state", f"⏸ {t('paused')}"
|
||||||
|
|
@ -630,9 +706,81 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
||||||
)
|
)
|
||||||
if tele:
|
if tele:
|
||||||
self.query_one("#telemetry", Static).update(tele)
|
self.query_one("#telemetry", Static).update(tele)
|
||||||
|
self._update_stats()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _err_label(counts):
|
||||||
|
"""Libellé de la colonne ⚠ : « ⚠N » si erreurs, « ⚡N » si seulement
|
||||||
|
des avertissements, « ✓ » si log propre."""
|
||||||
|
if not counts:
|
||||||
|
return ""
|
||||||
|
nerr, nwarn = counts
|
||||||
|
if nerr:
|
||||||
|
return f"⚠{nerr}"
|
||||||
|
if nwarn:
|
||||||
|
return f"⚡{nwarn}"
|
||||||
|
return "✓"
|
||||||
|
|
||||||
|
def _stats_counts(self):
|
||||||
|
"""Compte par catégorie (terminées, échecs, erreurs, etc.)."""
|
||||||
|
done = fail = deleted = err_vms = warn_vms = 0
|
||||||
|
for name, (state, _code, _el) in self._final.items():
|
||||||
|
if state == "done":
|
||||||
|
done += 1
|
||||||
|
elif state == "failed":
|
||||||
|
fail += 1
|
||||||
|
elif state == "deleted":
|
||||||
|
deleted += 1
|
||||||
|
counts = self._errcount.get(name)
|
||||||
|
if counts:
|
||||||
|
if counts[0]:
|
||||||
|
err_vms += 1
|
||||||
|
elif counts[1]:
|
||||||
|
warn_vms += 1
|
||||||
|
running = paused = 0
|
||||||
|
for vm in vms:
|
||||||
|
if vm["name"] in self._final:
|
||||||
|
continue
|
||||||
|
if self._domstate.get(vm["name"]) == "paused":
|
||||||
|
paused += 1
|
||||||
|
else:
|
||||||
|
running += 1
|
||||||
|
return {
|
||||||
|
"total": len(vms), "done": done, "fail": fail,
|
||||||
|
"deleted": deleted, "running": running, "paused": paused,
|
||||||
|
"err_vms": err_vms, "warn_vms": warn_vms,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _update_stats(self):
|
||||||
|
c = self._stats_counts()
|
||||||
|
line = (
|
||||||
|
f" 📊 {c['total']} VM · ✅ {c['done']} · ❌ {c['fail']} · "
|
||||||
|
f"⏳ {c['running']} · ⏸ {c['paused']} · 🗑 {c['deleted']} · "
|
||||||
|
f"⚠ {c['err_vms']} · ⚡ {c['warn_vms']} "
|
||||||
|
f"({t('click to expand')})"
|
||||||
|
)
|
||||||
|
self.query_one("#stats", Static).update(line)
|
||||||
|
if self._stats_open:
|
||||||
|
self.query_one("#statsdetail", Static).update(
|
||||||
|
self._render_stats_detail()
|
||||||
|
)
|
||||||
|
|
||||||
|
def _render_stats_detail(self):
|
||||||
|
"""Détail déplié : VM avec erreurs/avertissements + moyennes hist."""
|
||||||
|
lines = []
|
||||||
|
for name, (state, code, el) in self._final.items():
|
||||||
|
counts = self._errcount.get(name)
|
||||||
|
if counts and (counts[0] or counts[1]):
|
||||||
|
lines.append(
|
||||||
|
f" • {name}: ⚠{counts[0]} erreurs, "
|
||||||
|
f"⚡{counts[1]} avert. ({self._fmt(el)})"
|
||||||
|
)
|
||||||
|
if not lines:
|
||||||
|
lines.append(f" {t('No error detected.')}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
async def _tick_log(self):
|
async def _tick_log(self):
|
||||||
if not self._follow:
|
if not self._follow:
|
||||||
return
|
return
|
||||||
|
|
@ -767,6 +915,58 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
||||||
title="Presse-papiers",
|
title="Presse-papiers",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def on_click(self, event) -> None:
|
||||||
|
# Clic sur le sommaire de stats -> déplie / replie le détail.
|
||||||
|
w = getattr(event, "widget", None)
|
||||||
|
if w is not None and getattr(w, "id", None) == "stats":
|
||||||
|
self._stats_open = not self._stats_open
|
||||||
|
self.query_one("#statsdetail").display = self._stats_open
|
||||||
|
self._update_stats()
|
||||||
|
|
||||||
|
# -- pause / reprise de tout le parc -------------------------------- #
|
||||||
|
@staticmethod
|
||||||
|
def _virsh_bulk(action, names):
|
||||||
|
for n in names:
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["sudo", "virsh", action, n],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def action_pause_all(self) -> None:
|
||||||
|
"""Met en PAUSE (virsh suspend) toutes les VM en cours d'exécution.
|
||||||
|
L'install reprend là où elle en était après « Reprendre »."""
|
||||||
|
self.run_worker(self._bulk_worker("suspend"), exclusive=False)
|
||||||
|
|
||||||
|
def action_resume_all(self) -> None:
|
||||||
|
"""REPREND (virsh resume) toutes les VM en pause ; le suivi des
|
||||||
|
logs continue automatiquement (offsets conservés)."""
|
||||||
|
self.run_worker(self._bulk_worker("resume"), exclusive=False)
|
||||||
|
|
||||||
|
async def _bulk_worker(self, action):
|
||||||
|
want = "running" if action == "suspend" else "paused"
|
||||||
|
targets = [
|
||||||
|
vm["name"]
|
||||||
|
for vm in vms
|
||||||
|
if self._domstate.get(vm["name"]) == want
|
||||||
|
]
|
||||||
|
if not targets:
|
||||||
|
self.notify(
|
||||||
|
t("No running VM to pause.")
|
||||||
|
if action == "suspend"
|
||||||
|
else t("No paused VM to resume.")
|
||||||
|
)
|
||||||
|
return
|
||||||
|
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).
|
||||||
|
self.run_worker(self._tick_domstate(), exclusive=False)
|
||||||
|
|
||||||
app = Monitor()
|
app = Monitor()
|
||||||
if run_app:
|
if run_app:
|
||||||
app.run()
|
app.run()
|
||||||
|
|
|
||||||
|
|
@ -1611,7 +1611,7 @@ class TODO:
|
||||||
d'échec SSH, propose le repli par CONSOLE SÉRIE (commande à coller)."""
|
d'échec SSH, propose le repli par CONSOLE SÉRIE (commande à coller)."""
|
||||||
remote = self._GROW_FS_REMOTE
|
remote = self._GROW_FS_REMOTE
|
||||||
real = self._qemu_domname(name)
|
real = self._qemu_domname(name)
|
||||||
# Résolution d'IP robuste (parallèle + battement toutes les 30 s)
|
# 1) SSH : IP résolue avec BATTEMENT (parallèle, boot émulé lent)
|
||||||
# plutôt qu'un simple timeout court qui abandonnait trop tôt.
|
# plutôt qu'un simple timeout court qui abandonnait trop tôt.
|
||||||
ip = self._qemu_resolve_ips([real], timeout=300).get(real)
|
ip = self._qemu_resolve_ips([real], timeout=300).get(real)
|
||||||
if ip:
|
if ip:
|
||||||
|
|
@ -1623,11 +1623,86 @@ class TODO:
|
||||||
print(f"{t('Will execute:')} {cmd}")
|
print(f"{t('Will execute:')} {cmd}")
|
||||||
if self.execute.exec_command_live(cmd, source_erplibre=False) == 0:
|
if self.execute.exec_command_live(cmd, source_erplibre=False) == 0:
|
||||||
return
|
return
|
||||||
print(f"⚠ {t('SSH grow failed; falling back to serial console.')}")
|
print(f"⚠ {t('SSH grow failed; trying the guest agent.')}")
|
||||||
else:
|
else:
|
||||||
print(t("No IP; falling back to serial console."))
|
print(t("No IP; trying the guest agent (no network)."))
|
||||||
|
# 2) Agent invité (virtio, SANS réseau) — nécessite qemu-guest-agent
|
||||||
|
# dans la VM (installé au déploiement) + guest-exec autorisé.
|
||||||
|
res = self._qemu_guest_exec(real, remote)
|
||||||
|
if res is not None:
|
||||||
|
rc, out = res
|
||||||
|
if out.strip():
|
||||||
|
print(out.rstrip())
|
||||||
|
if rc == 0:
|
||||||
|
print(f"✅ {t('Guest filesystem grown via guest agent.')}")
|
||||||
|
return
|
||||||
|
print(f"⚠ {t('Guest agent grow failed; falling back to console.')}")
|
||||||
|
else:
|
||||||
|
print(t("Guest agent unavailable; falling back to serial console."))
|
||||||
|
# 3) Console série (commande prête à coller, login interactif).
|
||||||
self._qemu_grow_via_console(real, remote)
|
self._qemu_grow_via_console(real, remote)
|
||||||
|
|
||||||
|
def _qemu_guest_exec(self, name, script, wait=180):
|
||||||
|
"""Exécute `script` (sh -c) DANS la VM via l'AGENT INVITÉ (canal
|
||||||
|
virtio, sans réseau). Renvoie (code_sortie, sortie) ou None si l'agent
|
||||||
|
est indisponible / guest-exec refusé."""
|
||||||
|
import base64
|
||||||
|
|
||||||
|
def agent(payload):
|
||||||
|
try:
|
||||||
|
res = subprocess.run(
|
||||||
|
[
|
||||||
|
"sudo", "virsh", "qemu-agent-command",
|
||||||
|
name, json.dumps(payload),
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
return None
|
||||||
|
if res.returncode != 0:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(res.stdout).get("return")
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if agent({"execute": "guest-ping"}) is None:
|
||||||
|
return None
|
||||||
|
start = agent(
|
||||||
|
{
|
||||||
|
"execute": "guest-exec",
|
||||||
|
"arguments": {
|
||||||
|
"path": "/bin/sh",
|
||||||
|
"arg": ["-c", script],
|
||||||
|
"capture-output": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if not start or "pid" not in start:
|
||||||
|
return None
|
||||||
|
pid = start["pid"]
|
||||||
|
deadline = time.time() + wait
|
||||||
|
print(t("Running via guest agent (no network)…"))
|
||||||
|
while time.time() < deadline:
|
||||||
|
st = agent(
|
||||||
|
{"execute": "guest-exec-status", "arguments": {"pid": pid}}
|
||||||
|
)
|
||||||
|
if st and st.get("exited"):
|
||||||
|
out = ""
|
||||||
|
for k in ("out-data", "err-data"):
|
||||||
|
if st.get(k):
|
||||||
|
try:
|
||||||
|
out += base64.b64decode(st[k]).decode(
|
||||||
|
errors="replace"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return st.get("exitcode", 0), out
|
||||||
|
time.sleep(2)
|
||||||
|
return None
|
||||||
|
|
||||||
def _qemu_grow_via_console(self, name, remote):
|
def _qemu_grow_via_console(self, name, remote):
|
||||||
"""Repli console série : affiche la commande prête à coller puis ouvre
|
"""Repli console série : affiche la commande prête à coller puis ouvre
|
||||||
la console (login interactif erplibre/erplibre — pas d'automatisation
|
la console (login interactif erplibre/erplibre — pas d'automatisation
|
||||||
|
|
|
||||||
|
|
@ -1424,6 +1424,50 @@ TRANSLATIONS = {
|
||||||
"fr": "Impossible de lire la taille actuelle du disque ; abandon.",
|
"fr": "Impossible de lire la taille actuelle du disque ; abandon.",
|
||||||
"en": "Could not read current disk size; aborting.",
|
"en": "Could not read current disk size; aborting.",
|
||||||
},
|
},
|
||||||
|
"click to expand": {
|
||||||
|
"fr": "cliquer pour déplier",
|
||||||
|
"en": "click to expand",
|
||||||
|
},
|
||||||
|
"No error detected.": {
|
||||||
|
"fr": "Aucune erreur détectée.",
|
||||||
|
"en": "No error detected.",
|
||||||
|
},
|
||||||
|
"No running VM to pause.": {
|
||||||
|
"fr": "Aucune VM en cours à mettre en pause.",
|
||||||
|
"en": "No running VM to pause.",
|
||||||
|
},
|
||||||
|
"No paused VM to resume.": {
|
||||||
|
"fr": "Aucune VM en pause à reprendre.",
|
||||||
|
"en": "No paused VM to resume.",
|
||||||
|
},
|
||||||
|
"resumed": {
|
||||||
|
"fr": "reprise(s)",
|
||||||
|
"en": "resumed",
|
||||||
|
},
|
||||||
|
"SSH grow failed; trying the guest agent.": {
|
||||||
|
"fr": "Échec SSH ; tentative via l'agent invité.",
|
||||||
|
"en": "SSH grow failed; trying the guest agent.",
|
||||||
|
},
|
||||||
|
"No IP; trying the guest agent (no network).": {
|
||||||
|
"fr": "Pas d'IP ; tentative via l'agent invité (sans réseau).",
|
||||||
|
"en": "No IP; trying the guest agent (no network).",
|
||||||
|
},
|
||||||
|
"Guest filesystem grown via guest agent.": {
|
||||||
|
"fr": "FS invité étendu via l'agent invité.",
|
||||||
|
"en": "Guest filesystem grown via guest agent.",
|
||||||
|
},
|
||||||
|
"Guest agent grow failed; falling back to console.": {
|
||||||
|
"fr": "Échec de l'agent invité ; repli sur la console.",
|
||||||
|
"en": "Guest agent grow failed; falling back to console.",
|
||||||
|
},
|
||||||
|
"Guest agent unavailable; falling back to serial console.": {
|
||||||
|
"fr": "Agent invité indisponible ; repli sur la console série.",
|
||||||
|
"en": "Guest agent unavailable; falling back to serial console.",
|
||||||
|
},
|
||||||
|
"Running via guest agent (no network)…": {
|
||||||
|
"fr": "Exécution via l'agent invité (sans réseau)…",
|
||||||
|
"en": "Running via guest agent (no network)…",
|
||||||
|
},
|
||||||
"SSH grow failed; falling back to serial console.": {
|
"SSH grow failed; falling back to serial console.": {
|
||||||
"fr": "Échec de l'extension via SSH ; repli sur la console série.",
|
"fr": "Échec de l'extension via SSH ; repli sur la console série.",
|
||||||
"en": "SSH grow failed; falling back to serial console.",
|
"en": "SSH grow failed; falling back to serial console.",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue