feat(patching/iexec): terminal live SSE pour dry-run et patch reel - generator yum_stream_lines + endpoint /yum-stream + EventSource cote client + log audit en fin de stream

This commit is contained in:
Pierre & Lumière 2026-05-04 17:02:28 +02:00
parent e29ecff949
commit 8cf78dfef3
3 changed files with 188 additions and 32 deletions

View File

@ -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/<id>?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=...`."""

View File

@ -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)

View File

@ -89,6 +89,18 @@
<pre id="details-pane" class="bg-cyber-bg p-2 text-[11px] whitespace-pre-wrap overflow-x-auto" style="max-height:400px;"></pre>
</div>
{# ─── Terminal style log dry-run / patch ─── #}
<div class="card p-2 mb-4" id="term-card" style="display:none;">
<div class="flex justify-between items-center mb-1">
<h3 id="term-title" class="text-xs text-cyber-accent font-bold font-mono">$ terminal</h3>
<button id="term-close" class="text-xs text-gray-500 hover:text-cyber-accent">✕ Fermer</button>
</div>
<pre id="term-pane" class="p-3 text-[11px] whitespace-pre-wrap overflow-auto"
style="max-height:500px; background:#0a0e14; color:#a6e22e;
font-family:'Cascadia Code','Consolas','Courier New',monospace;
border:1px solid #1f2937; line-height:1.4;"></pre>
</div>
<div class="flex justify-between items-center mt-4 flex-wrap gap-2">
<span id="run-summary" class="text-xs text-gray-400"></span>
<div class="flex gap-2 flex-wrap">
@ -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 = '<span class="text-cyber-yellow">… dry-run</span>';
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) {
const host = tr.querySelector('td:nth-child(2)').textContent.trim()
|| tr.querySelector('td:nth-child(3)').textContent.trim();
cell.innerHTML = '<span class="text-cyber-yellow">… dry-run (live)</span>';
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 = (j.summary || []).slice(-2).join(' / ') || 'plan OK';
const sumLine = (result.summary || []).slice(-2).join(' / ') || ('plan OK (' + result.lines + ' lignes)');
cell.innerHTML = '<span class="text-cyber-green"></span><span class="text-[10px] text-gray-300" title="' + escapeHTML(sumLine) + '">' + escapeHTML(sumLine.slice(0,80)) + '</span>';
} else {
koCount++;
cell.innerHTML = '<span class="text-cyber-red" title="' + escapeHTML(j.detail || j.stderr || '') + '">✗ ' + escapeHTML((j.detail||'KO').slice(0,80)) + '</span>';
}
} catch(e) {
koCount++;
cell.innerHTML = '<span class="text-cyber-red">✗ erreur</span>';
cell.innerHTML = '<span class="text-cyber-red" title="rc=' + result.rc + '">✗ KO (rc=' + result.rc + ')</span>';
}
}
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 = '<span class="text-cyber-yellow">… patch en cours</span>';
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) {
const host = tr.querySelector('td:nth-child(2)').textContent.trim()
|| tr.querySelector('td:nth-child(3)').textContent.trim();
cell.innerHTML = '<span class="text-cyber-yellow">… patch (live)</span>';
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 = (j.summary || []).slice(-2).join(' / ') || 'patch OK';
const sumLine = (result.summary || []).slice(-2).join(' / ') || 'patch OK';
cell.innerHTML = '<span class="text-cyber-green"></span><span class="text-[10px] text-gray-300" title="' + escapeHTML(sumLine) + '">' + escapeHTML(sumLine.slice(0,80)) + '</span>';
} else {
koCount++;
cell.innerHTML = '<span class="text-cyber-red" title="' + escapeHTML(j.detail || j.stderr || '') + '">✗ ' + escapeHTML((j.detail||'KO').slice(0,80)) + '</span>';
}
} catch(e) {
koCount++;
cell.innerHTML = '<span class="text-cyber-red">✗ erreur</span>';
cell.innerHTML = '<span class="text-cyber-red" title="rc=' + result.rc + '">✗ KO (rc=' + result.rc + ')</span>';
}
}
summary.innerHTML += ' · Patch : ✓ ' + okCount + ' / ✗ ' + koCount;