diff --git a/app/routers/planning_import.py b/app/routers/planning_import.py index cd25034..24ca9ea 100644 --- a/app/routers/planning_import.py +++ b/app/routers/planning_import.py @@ -856,6 +856,63 @@ async def iexec_post_compare(request: Request, row_id: int, db=Depends(get_db)): return JSONResponse(result) +@router.get("/patching/iexec/yum-stream/{row_id}") +async def iexec_yum_stream(request: Request, row_id: int, + mode: str = Query("dryrun"), + db=Depends(get_db)): + """Streaming SSE (Server-Sent Events) du yum dryrun ou update. + Yield chaque ligne stdout en live + log audit en fin de stream. + Frontend : new EventSource('/patching/iexec/yum-stream/?mode=dryrun'). + """ + from fastapi.responses import StreamingResponse + 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 + if mode not in ("dryrun", "update"): + return JSONResponse({"ok": False, "detail": "mode invalide"}, status_code=400) + hostname = (row.hostname or row.asset_name).strip() + excludes_raw = row.effective_excludes + uid = user.get("uid") + action_name = "yum_dryrun_stream" if mode == "dryrun" else "yum_update_stream" + + from ..services.patch_run_service import yum_stream_lines + + def event_stream(): + full_lines = [] + cmd_used = "" + rc_final = -1 + for ev in yum_stream_lines(hostname, excludes_raw, mode): + if ev.get("type") == "cmd": + cmd_used = ev.get("cmd", "") + elif ev.get("type") == "line": + full_lines.append(ev["data"]) + elif ev.get("type") == "end": + rc_final = ev.get("rc", -1) + yield f"data: {json.dumps(ev, ensure_ascii=False)}\n\n" + # Audit log final (best-effort, ne casse pas le stream) + try: + full = "\n".join(full_lines) + db.execute(text(""" + INSERT INTO patch_planning_row_log (row_id, action, details, performed_by) + VALUES (:rid, :ac, :de, :uid) + """), {"rid": row_id, "ac": action_name, + "de": json.dumps({"cmd": cmd_used, "rc": rc_final, + "stdout_tail": full[-5000:]}, + ensure_ascii=False), + "uid": uid}) + db.commit() + except Exception as e: + print(f"[iexec_yum_stream] audit log failed: {e}") + + return StreamingResponse(event_stream(), media_type="text/event-stream", + headers={"X-Accel-Buffering": "no", + "Cache-Control": "no-cache"}) + + @router.post("/patching/iexec/yum-update/{row_id}") async def iexec_yum_update(request: Request, row_id: int, db=Depends(get_db)): """Step 3 — vrai patch : `sudo -n yum update -y --exclude=...`.""" diff --git a/app/services/patch_run_service.py b/app/services/patch_run_service.py index 3f81786..2fbf299 100644 --- a/app/services/patch_run_service.py +++ b/app/services/patch_run_service.py @@ -112,6 +112,36 @@ def yum_dryrun(hostname: str, excludes_raw) -> Dict[str, Any]: } +def yum_stream_lines(hostname: str, excludes_raw, mode: str): + """Generator SSE-friendly : yield des dicts {type, ...} en live. + mode = 'dryrun' (--assumeno) ou 'update' (-y).""" + excludes = _safe_excludes(excludes_raw) + if mode not in ("dryrun", "update"): + yield {"type": "error", "msg": f"mode invalide: {mode}"} + return + client, target, err = _open_ssh(hostname) + if err: + yield {"type": "error", "msg": err} + return + cmd = _build_cmd(mode, excludes) + yield {"type": "cmd", "cmd": cmd, "target": target, + "excludes": excludes, "hostname": hostname} + try: + stdin, stdout, stderr = client.exec_command(cmd, get_pty=False) + # Lecture ligne par ligne ; yum bufferise peu son stdout sur opérations longues + for line in iter(stdout.readline, ""): + yield {"type": "line", "data": line.rstrip("\n")} + rc = stdout.channel.recv_exit_status() + yield {"type": "end", "rc": rc} + except Exception as e: + yield {"type": "error", "msg": f"exec error: {e}"} + finally: + try: + client.close() + except Exception: + pass + + def yum_update(hostname: str, excludes_raw) -> Dict[str, Any]: """Lance le vrai patch (-y).""" excludes = _safe_excludes(excludes_raw) diff --git a/app/templates/patching_iexec.html b/app/templates/patching_iexec.html index 8a625f5..be4b0f0 100644 --- a/app/templates/patching_iexec.html +++ b/app/templates/patching_iexec.html @@ -89,6 +89,18 @@

 
 
+{# ─── Terminal style log dry-run / patch ─── #}
+
+
 
@@ -200,6 +212,71 @@ btnStep2.disabled = (okCount === 0); }); + // ─── Terminal ─── + const termCard = document.getElementById('term-card'); + const termTitle = document.getElementById('term-title'); + const termPane = document.getElementById('term-pane'); + document.getElementById('term-close').addEventListener('click', () => { + termCard.style.display = 'none'; + }); + function openTerm(title){ + termCard.style.display = ''; + termTitle.textContent = '$ ' + title; + termPane.textContent = ''; + termCard.scrollIntoView({behavior:'smooth', block:'nearest'}); + } + function appendTerm(s){ + termPane.textContent += s; + termPane.scrollTop = termPane.scrollHeight; + } + + function streamYum(rowId, mode, hostname){ + return new Promise((resolve) => { + openTerm((mode === 'dryrun' ? 'dry-run' : 'PATCH') + ' yum @ ' + hostname); + const url = '/patching/iexec/yum-stream/' + rowId + '?mode=' + mode; + const ev = new EventSource(url); + const result = {ok: false, rc: null, lines: 0, summary: []}; + ev.onmessage = (m) => { + let j; + try { j = JSON.parse(m.data); } catch(e) { return; } + if (j.type === 'cmd') { + appendTerm('# host : ' + (j.hostname||'') + ' (' + (j.target||'') + ')\n'); + appendTerm('# cmd : ' + (j.cmd||'') + '\n'); + appendTerm('# excludes (' + (j.excludes||[]).length + ')\n\n'); + } else if (j.type === 'line') { + appendTerm(j.data + '\n'); + result.lines++; + // Capture quelques lignes-clé pour le badge cellule + const ll = j.data.toLowerCase(); + if (ll.includes('package') || ll.includes('paquet') + || ll.includes('nothing to do') || ll.includes('rien à faire') + || ll.includes('complete!') || ll.includes('terminé !')) { + result.summary.push(j.data); + } + } else if (j.type === 'end') { + result.rc = j.rc; + // dryrun rc=0 (rien) ou rc=1 (updates dispo) = OK + // update rc=0 = OK + if (mode === 'dryrun') result.ok = (j.rc === 0 || j.rc === 1); + else result.ok = (j.rc === 0); + appendTerm('\n[exit code: ' + j.rc + ' — ' + (result.ok ? 'OK' : 'KO') + ']\n'); + ev.close(); + resolve(result); + } else if (j.type === 'error') { + appendTerm('\n[ERROR] ' + (j.msg||'') + '\n'); + result.error = j.msg; + ev.close(); + resolve(result); + } + }; + ev.onerror = () => { + appendTerm('\n[connection lost]\n'); + ev.close(); + resolve(result); + }; + }); + } + function refreshStepButtons(){ const trs = Array.from(tbody.querySelectorAll('tr[data-row-id]')); const ckOk = trs.filter(tr => tr._checkData && tr._checkData.overall === 'ok'); @@ -254,27 +331,23 @@ const trs = Array.from(tbody.querySelectorAll('tr[data-row-id]')); const targets = trs.filter(tr => tr._snapData && tr._snapData.ok); if (!targets.length) { alert('Aucun serveur avec snapshot OK'); return; } - if (!confirm('Lancer dry-run yum (simulation) sur ' + targets.length + ' serveur(s) ?')) return; + if (!confirm('Lancer dry-run yum (simulation) sur ' + targets.length + ' serveur(s) ?\nLog en temps réel dans le terminal.')) return; btnDryrun.disabled = true; btnStep3.disabled = true; let okCount = 0, koCount = 0; for (const tr of targets) { const cell = tr.querySelector('.cell-dry'); - cell.innerHTML = '… dry-run'; - try { - const r = await fetch('/patching/iexec/yum-dryrun/' + tr.dataset.rowId, {method:'POST'}); - const j = await r.json(); - tr._dryData = j; - if (j.ok) { - okCount++; - const sumLine = (j.summary || []).slice(-2).join(' / ') || 'plan OK'; - cell.innerHTML = '' + escapeHTML(sumLine.slice(0,80)) + ''; - } else { - koCount++; - cell.innerHTML = '✗ ' + escapeHTML((j.detail||'KO').slice(0,80)) + ''; - } - } catch(e) { + const host = tr.querySelector('td:nth-child(2)').textContent.trim() + || tr.querySelector('td:nth-child(3)').textContent.trim(); + cell.innerHTML = '… dry-run (live)'; + const result = await streamYum(tr.dataset.rowId, 'dryrun', host); + tr._dryData = {ok: result.ok, rc: result.rc, summary: result.summary}; + if (result.ok) { + okCount++; + const sumLine = (result.summary || []).slice(-2).join(' / ') || ('plan OK (' + result.lines + ' lignes)'); + cell.innerHTML = '' + escapeHTML(sumLine.slice(0,80)) + ''; + } else { koCount++; - cell.innerHTML = '✗ erreur'; + cell.innerHTML = '✗ KO (rc=' + result.rc + ')'; } } summary.innerHTML += ' · Dry-run : ✓ ' + okCount + ' / ✗ ' + koCount; @@ -348,28 +421,24 @@ const trs = Array.from(tbody.querySelectorAll('tr[data-row-id]')); const targets = trs.filter(tr => tr._preData && tr._preData.ok); if (!targets.length) { alert('Aucun serveur avec pre-capture OK'); return; } - if (!confirm('⚠ ATTENTION ⚠\n\nLancer yum update -y (PATCH RÉEL) sur ' + targets.length + ' serveur(s) ?\nSnapshot + pre-capture déjà faits.')) return; + if (!confirm('⚠ ATTENTION ⚠\n\nLancer yum update -y (PATCH RÉEL) sur ' + targets.length + ' serveur(s) ?\nSnapshot + pre-capture déjà faits.\nLog en temps réel dans le terminal.')) return; if (!confirm('Confirmer une 2e fois : patcher RÉELLEMENT ' + targets.length + ' serveur(s) ?')) return; btnStep3.disabled = true; btnDryrun.disabled = true; btnStep2.disabled = true; let okCount = 0, koCount = 0; for (const tr of targets) { const cell = tr.querySelector('.cell-patch'); - cell.innerHTML = '… patch en cours'; - try { - const r = await fetch('/patching/iexec/yum-update/' + tr.dataset.rowId, {method:'POST'}); - const j = await r.json(); - tr._patchData = j; - if (j.ok) { - okCount++; - const sumLine = (j.summary || []).slice(-2).join(' / ') || 'patch OK'; - cell.innerHTML = '' + escapeHTML(sumLine.slice(0,80)) + ''; - } else { - koCount++; - cell.innerHTML = '✗ ' + escapeHTML((j.detail||'KO').slice(0,80)) + ''; - } - } catch(e) { + const host = tr.querySelector('td:nth-child(2)').textContent.trim() + || tr.querySelector('td:nth-child(3)').textContent.trim(); + cell.innerHTML = '… patch (live)'; + const result = await streamYum(tr.dataset.rowId, 'update', host); + tr._patchData = {ok: result.ok, rc: result.rc, summary: result.summary}; + if (result.ok) { + okCount++; + const sumLine = (result.summary || []).slice(-2).join(' / ') || 'patch OK'; + cell.innerHTML = '' + escapeHTML(sumLine.slice(0,80)) + ''; + } else { koCount++; - cell.innerHTML = '✗ erreur'; + cell.innerHTML = '✗ KO (rc=' + result.rc + ')'; } } summary.innerHTML += ' · Patch : ✓ ' + okCount + ' / ✗ ' + koCount;