From ff95424e03966cccd9e8736552d39a5f3b796653 Mon Sep 17 00:00:00 2001 From: Admin MPCZ Date: Tue, 5 May 2026 12:06:50 +0200 Subject: [PATCH] feat(patching/iexec B3.6): bouton 3e Reboot manuel (double confirmation, jamais auto) + 3f Wait reconnexion (poll TCP/22 + SSH uptime, timeout 10min) - shutdown -r +1 avec audit log --- app/routers/planning_import.py | 51 ++++++++++++++++ app/services/patch_run_service.py | 68 +++++++++++++++++++++ app/templates/patching_iexec.html | 98 ++++++++++++++++++++++++++++--- 3 files changed, 208 insertions(+), 9 deletions(-) diff --git a/app/routers/planning_import.py b/app/routers/planning_import.py index 42a5ba9..1a9a8ad 100644 --- a/app/routers/planning_import.py +++ b/app/routers/planning_import.py @@ -948,6 +948,57 @@ async def iexec_yum_update(request: Request, row_id: int, db=Depends(get_db)): return JSONResponse(result) +@router.post("/patching/iexec/reboot/{row_id}") +async def iexec_reboot(request: Request, row_id: int, db=Depends(get_db)): + """Step 3e — lance reboot avec délai +1 minute. + NE JAMAIS appeler en automatique : doit toujours venir d'un clic + utilisateur explicite (double confirmation côté UI).""" + user = get_current_user(request) + if not user: + return JSONResponse({"ok": False, "detail": "Non authentifié"}, status_code=401) + perms = get_user_perms(db, user) + row, err = _common_iexec_row_check(row_id, db, user, perms) + if err: + return err + hostname = (row.hostname or row.asset_name).strip() + + from ..services.patch_run_service import reboot_host + result = reboot_host(hostname) + + try: + db.execute(text(""" + INSERT INTO patch_planning_row_log (row_id, action, details, performed_by) + VALUES (:rid, 'reboot_initiated', :de, :uid) + """), {"rid": row_id, + "de": json.dumps(result, ensure_ascii=False), + "uid": user.get("uid")}) + db.commit() + except Exception as e: + print(f"[iexec_reboot] audit log failed: {e}") + + result["row_id"] = row_id + return JSONResponse(result) + + +@router.get("/patching/iexec/reboot-status/{row_id}") +async def iexec_reboot_status(request: Request, row_id: int, db=Depends(get_db)): + """Step 3e — poll l'état du serveur après reboot (TCP/22 + SSH 'uptime'). + Appelé en boucle côté frontend (toutes les 10s).""" + user = get_current_user(request) + if not user: + return JSONResponse({"ok": False, "detail": "Non authentifié"}, status_code=401) + perms = get_user_perms(db, user) + row, err = _common_iexec_row_check(row_id, db, user, perms) + if err: + return err + hostname = (row.hostname or row.asset_name).strip() + + from ..services.patch_run_service import reboot_status + result = reboot_status(hostname) + result["row_id"] = row_id + return JSONResponse(result) + + @router.post("/patching/import/{import_id}/delete") async def import_delete(request: Request, import_id: int, db=Depends(get_db)): user = get_current_user(request) diff --git a/app/services/patch_run_service.py b/app/services/patch_run_service.py index 123a5d6..2a4f3b2 100644 --- a/app/services/patch_run_service.py +++ b/app/services/patch_run_service.py @@ -8,6 +8,8 @@ import base64 import logging import re +import socket +from datetime import datetime from typing import Dict, Any, List from .realtime_audit_service import _resolve, _connect, PARAMIKO_OK @@ -400,3 +402,69 @@ def post_patch_compare(hostname: str) -> Dict[str, Any]: "stdout": r["stdout"][-3000:], "stderr": r["stderr"][:500] if r["stderr"] else "", } + + +# ─── B3.6 — Reboot manuel sur clic + polling reconnexion ────────────────── +# IMPORTANT : reboot_host() n'est JAMAIS lancé automatiquement. +# Toujours déclenché par un clic utilisateur explicite avec double confirmation +# côté frontend. + +def reboot_host(hostname: str) -> Dict[str, Any]: + """Lance reboot avec délai +1 min (laisse le temps à SSH de retourner). + Appelée uniquement après confirmation explicite côté UI.""" + client, target, err = _open_ssh(hostname) + if err: + return {"ok": False, "detail": err, "target": target} + try: + cmd = "sudo -n shutdown -r +1 'PatchCenter post-patch reboot' 2>&1" + r = _exec(client, cmd, timeout=15) + finally: + try: + client.close() + except Exception: + pass + ok = (r["rc"] == 0) + return { + "ok": ok, + "rc": r["rc"], + "cmd": cmd, + "target": target, + "started_at": datetime.now().isoformat(timespec="seconds"), + "stdout": r["stdout"][:500], + "stderr": r["stderr"][:500] if r["stderr"] else "", + } + + +def reboot_status(hostname: str) -> Dict[str, Any]: + """Vérifie si le serveur est revenu : TCP/22 puis SSH 'uptime'. + À appeler en boucle (toutes les 10s côté frontend).""" + target = _resolve(hostname) + if not target: + return {"reachable": False, "tcp22": False, "ssh": False, + "detail": "DNS résolution impossible", "target": None} + # 1. TCP/22 + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(3) + sock.connect((target, 22)) + sock.close() + except Exception as e: + return {"reachable": False, "tcp22": False, "ssh": False, + "target": target, "detail": str(e)[:200]} + # 2. SSH minimal — uptime + if not PARAMIKO_OK: + return {"reachable": True, "tcp22": True, "ssh": False, "target": target, + "detail": "paramiko absent côté serveur PatchCenter"} + client = _connect(target, hostname) + if not client: + return {"reachable": True, "tcp22": True, "ssh": False, "target": target, + "detail": "TCP/22 OK mais SSH KO (probablement encore en boot)"} + try: + r = _exec(client, "uptime 2>&1", timeout=10) + finally: + try: + client.close() + except Exception: + pass + return {"reachable": True, "tcp22": True, "ssh": True, "target": target, + "uptime": (r.get("stdout") or "").strip()[:300]} diff --git a/app/templates/patching_iexec.html b/app/templates/patching_iexec.html index e90532f..be9e928 100644 --- a/app/templates/patching_iexec.html +++ b/app/templates/patching_iexec.html @@ -53,6 +53,7 @@ Dry-run Pre-capt. Patch + Reconnex. Post-cmp. @@ -74,10 +75,11 @@ · · · + · · {% else %} - Aucune ligne éligible. + Aucune ligne éligible. {% endfor %} @@ -116,8 +118,14 @@ - + + @@ -129,6 +137,8 @@ const btnDryrun = document.getElementById('btn-dryrun'); const btnPre = document.getElementById('btn-pre'); const btnStep3 = document.getElementById('btn-step3'); + const btnReboot = document.getElementById('btn-reboot'); + const btnRecon = document.getElementById('btn-recon'); const btnPost = document.getElementById('btn-post'); const tbody = document.getElementById('check-tbody'); const summary = document.getElementById('run-summary'); @@ -319,16 +329,19 @@ function refreshStepButtons(){ const trs = Array.from(tbody.querySelectorAll('tr[data-row-id]')); - const ckOk = trs.filter(tr => tr._checkData && tr._checkData.overall === 'ok'); - const snapOk = trs.filter(tr => tr._snapData && tr._snapData.ok); - const dryOk = trs.filter(tr => tr._dryData && tr._dryData.ok); - const preOk = trs.filter(tr => tr._preData && tr._preData.ok); - const patchOk= trs.filter(tr => tr._patchData && tr._patchData.ok); + const ckOk = trs.filter(tr => tr._checkData && tr._checkData.overall === 'ok'); + const snapOk = trs.filter(tr => tr._snapData && tr._snapData.ok); + const dryOk = trs.filter(tr => tr._dryData && tr._dryData.ok); + const preOk = trs.filter(tr => tr._preData && tr._preData.ok); + const patchOk = trs.filter(tr => tr._patchData && tr._patchData.ok); + const recOk = trs.filter(tr => tr._reconData && tr._reconData.ok); btnStep2.disabled = (ckOk.length === 0); btnDryrun.disabled = (snapOk.length === 0); btnPre.disabled = (dryOk.length === 0); btnStep3.disabled = (preOk.length === 0); - btnPost.disabled = (patchOk.length === 0); + btnReboot.disabled = (patchOk.length === 0); + btnRecon.disabled = (patchOk.length === 0); + btnPost.disabled = (recOk.length === 0 && patchOk.length === 0); } btnStep2.addEventListener('click', async () => { @@ -416,6 +429,73 @@ refreshStepButtons(); }); + btnReboot.addEventListener('click', async () => { + const trs = Array.from(tbody.querySelectorAll('tr[data-row-id]')); + const targets = trs.filter(tr => tr._patchData && tr._patchData.ok); + if (!targets.length) { alert('Aucun serveur avec patch OK'); return; } + if (!confirm('⚠ REBOOT ⚠\n\nDéclencher `shutdown -r +1` sur ' + targets.length + ' serveur(s) ?\n(le reboot effectif a lieu dans 1 minute)')) return; + if (!confirm('Vraiment ? Liste des hôtes :\n' + targets.map(tr => tr.querySelector('td:nth-child(3)').textContent.trim()).join('\n') + '\n\nConfirmer le reboot ?')) return; + btnReboot.disabled = true; + let okCount = 0, koCount = 0; + for (const tr of targets) { + const cell = tr.querySelector('.cell-recon'); + cell.innerHTML = '… reboot demandé'; + try { + const r = await fetch('/patching/iexec/reboot/' + tr.dataset.rowId, {method:'POST'}); + const j = await r.json(); + tr._rebootData = j; + if (j.ok) { + okCount++; + cell.innerHTML = '⏳ reboot dans 1min · ' + escapeHTML(j.started_at||'') + ''; + } else { + koCount++; + cell.innerHTML = '✗ ' + escapeHTML((j.detail||'KO').slice(0,80)) + ''; + } + } catch(e) { + koCount++; + cell.innerHTML = '✗ erreur'; + } + } + summary.innerHTML += ' · Reboot : ✓ ' + okCount + ' / ✗ ' + koCount; + }); + + btnRecon.addEventListener('click', async () => { + const trs = Array.from(tbody.querySelectorAll('tr[data-row-id]')); + const targets = trs.filter(tr => tr._patchData && tr._patchData.ok); + if (!targets.length) { alert('Aucun serveur avec patch OK'); return; } + if (!confirm('Attendre la reconnexion (TCP/22 + SSH) sur ' + targets.length + ' serveur(s) ?\nPoll toutes les 10s, timeout 10 min par serveur.')) return; + btnRecon.disabled = true; + const startTs = Date.now(); + // Pour chaque target, polling indépendant + await Promise.all(targets.map(async (tr) => { + const cell = tr.querySelector('.cell-recon'); + const t0 = Date.now(); + const TIMEOUT_MS = 10 * 60 * 1000; // 10 min + const POLL_MS = 10 * 1000; + cell.innerHTML = '⏳ poll TCP/22…'; + while (Date.now() - t0 < TIMEOUT_MS) { + await new Promise(r => setTimeout(r, POLL_MS)); + try { + const resp = await fetch('/patching/iexec/reboot-status/' + tr.dataset.rowId); + const j = await resp.json(); + if (j.tcp22 && j.ssh) { + const dur = Math.round((Date.now() - t0) / 1000); + tr._reconData = {ok: true, downtime_s: dur, uptime: j.uptime}; + cell.innerHTML = '✓ revenu en ' + dur + 's' + + '
' + escapeHTML((j.uptime||'').slice(0,60)) + ''; + return; + } + cell.innerHTML = '⏳ ' + + (j.tcp22 ? 'TCP/22 OK · SSH KO' : 'pas joignable') + + ' · ' + Math.round((Date.now()-t0)/1000) + 's'; + } catch(e) { /* ignore, retry */ } + } + tr._reconData = {ok: false}; + cell.innerHTML = '✗ timeout 10 min'; + })); + refreshStepButtons(); + }); + btnPost.addEventListener('click', async () => { const trs = Array.from(tbody.querySelectorAll('tr[data-row-id]')); const targets = trs.filter(tr => tr._patchData && tr._patchData.ok);