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:
parent
e29ecff949
commit
8cf78dfef3
@ -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=...`."""
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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) {
|
||||
okCount++;
|
||||
const sumLine = (j.summary || []).slice(-2).join(' / ') || 'plan 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) {
|
||||
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 = (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">✗ 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) {
|
||||
okCount++;
|
||||
const sumLine = (j.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) {
|
||||
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 = (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">✗ erreur</span>';
|
||||
cell.innerHTML = '<span class="text-cyber-red" title="rc=' + result.rc + '">✗ KO (rc=' + result.rc + ')</span>';
|
||||
}
|
||||
}
|
||||
summary.innerHTML += ' · Patch : ✓ ' + okCount + ' / ✗ ' + koCount;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user