feat(patching/iexec): detection auto deps problematiques + bouton retry sans paquets KO (multilib, requires, conflicts) - extra_excludes via SSE query param

This commit is contained in:
Pierre & Lumière 2026-05-05 11:32:44 +02:00
parent 8cf78dfef3
commit 19d88f2d53
3 changed files with 123 additions and 35 deletions

View File

@ -859,10 +859,10 @@ async def iexec_post_compare(request: Request, row_id: int, db=Depends(get_db)):
@router.get("/patching/iexec/yum-stream/{row_id}")
async def iexec_yum_stream(request: Request, row_id: int,
mode: str = Query("dryrun"),
extra_excludes: str = Query(""),
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').
"""Streaming SSE du yum dryrun ou update + audit log à la fin.
extra_excludes : liste séparée par espaces ou virgules (retry après dep KO).
"""
from fastapi.responses import StreamingResponse
user = get_current_user(request)
@ -875,7 +875,11 @@ async def iexec_yum_stream(request: Request, row_id: int,
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
excludes_raw = (row.effective_excludes or "")
if extra_excludes:
# Normalise virgules → espaces, fusionne
extra_norm = re.sub(r"[,;]+", " ", extra_excludes)
excludes_raw = (excludes_raw + " " + extra_norm).strip()
uid = user.get("uid")
action_name = "yum_dryrun_stream" if mode == "dryrun" else "yum_update_stream"

View File

@ -64,6 +64,57 @@ def _open_ssh(hostname: str):
return client, target, None
def extract_problem_packages(stdout: str) -> List[str]:
"""Heuristique : extrait les noms de paquets en cause dans les erreurs yum.
Couvre les motifs courants (multilib, requires, cannot install, conflict).
Retourne une liste de noms (string courte type 'glibc', 'httpd')."""
found: set = set()
def add(name: str):
if not name:
return
# Strip arch suffix : .x86_64, .i686, .noarch
name = re.sub(r"\.(x86_64|i[3-6]86|noarch|aarch64)$", "", name)
# Strip version suffix : -1.2.3-rc1.el8 etc. (garde juste le nom)
m = re.match(r"^([A-Za-z0-9._+]+?)(?:-\d.*)?$", name)
if m:
name = m.group(1)
# Validation finale
if EXCLUDE_RE.match(name) and len(name) >= 2:
found.add(name)
# 1. Multilib : "protection ... : glibc-2.17-326.i686 != glibc-2.17-325.x86_64"
for m in re.finditer(r":\s+(\S+)\s*!=\s*(\S+)", stdout):
for v in m.groups():
add(v)
# 2. dnf/yum 4 : "Cannot install the best update candidate for package X"
for m in re.finditer(
r"(?:Cannot install|Impossible d.installer).*?package\s+(\S+)",
stdout, re.IGNORECASE):
add(m.group(1))
# 3. "Problem: package X requires Y" / "nécessite Y"
for m in re.finditer(
r"(?:Problem|Probl.me).*?package\s+(\S+)",
stdout, re.IGNORECASE):
add(m.group(1))
# 4. "Error: Package: X-1.2.3"
for m in re.finditer(r"(?:Error|Erreur):\s*Package:?\s+(\S+)", stdout):
add(m.group(1))
# 5. "conflicts with X provided by Y"
for m in re.finditer(
r"conflicts? with\s+(\S+)|conflit avec\s+(\S+)",
stdout, re.IGNORECASE):
for v in m.groups():
if v:
add(v)
return sorted(found)
def _summary(stdout: str) -> List[str]:
"""Extrait les lignes-clé du plan yum (compte de paquets, 'Nothing to do'…)."""
keys = (
@ -126,13 +177,22 @@ def yum_stream_lines(hostname: str, excludes_raw, mode: str):
cmd = _build_cmd(mode, excludes)
yield {"type": "cmd", "cmd": cmd, "target": target,
"excludes": excludes, "hostname": hostname}
full_lines: List[str] = []
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")}
ln = line.rstrip("\n")
full_lines.append(ln)
yield {"type": "line", "data": ln}
rc = stdout.channel.recv_exit_status()
yield {"type": "end", "rc": rc}
# Détection de paquets problématiques si KO
problems: List[str] = []
if mode == "update" and rc != 0:
problems = extract_problem_packages("\n".join(full_lines))
elif mode == "dryrun" and rc not in (0, 1):
problems = extract_problem_packages("\n".join(full_lines))
yield {"type": "end", "rc": rc, "problems": problems}
except Exception as e:
yield {"type": "error", "msg": f"exec error: {e}"}
finally:

View File

@ -230,12 +230,15 @@
termPane.scrollTop = termPane.scrollHeight;
}
function streamYum(rowId, mode, hostname){
function streamYum(rowId, mode, hostname, extraExcludes){
return new Promise((resolve) => {
openTerm((mode === 'dryrun' ? 'dry-run' : 'PATCH') + ' yum @ ' + hostname);
const url = '/patching/iexec/yum-stream/' + rowId + '?mode=' + mode;
const titre = (mode === 'dryrun' ? 'dry-run' : 'PATCH') + ' yum @ ' + hostname
+ (extraExcludes ? ' [retry +' + extraExcludes + ']' : '');
openTerm(titre);
let url = '/patching/iexec/yum-stream/' + rowId + '?mode=' + mode;
if (extraExcludes) url += '&extra_excludes=' + encodeURIComponent(extraExcludes);
const ev = new EventSource(url);
const result = {ok: false, rc: null, lines: 0, summary: []};
const result = {ok: false, rc: null, lines: 0, summary: [], problems: []};
ev.onmessage = (m) => {
let j;
try { j = JSON.parse(m.data); } catch(e) { return; }
@ -246,7 +249,6 @@
} 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')
@ -255,11 +257,13 @@
}
} else if (j.type === 'end') {
result.rc = j.rc;
// dryrun rc=0 (rien) ou rc=1 (updates dispo) = OK
// update rc=0 = OK
result.problems = j.problems || [];
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');
if (!result.ok && result.problems.length) {
appendTerm('[deps détectées : ' + result.problems.join(', ') + ']\n');
}
ev.close();
resolve(result);
} else if (j.type === 'error') {
@ -277,6 +281,42 @@
});
}
function buildRetryButton(rowId, mode, hostname, packages){
// Bouton qui relance streamYum avec extra_excludes
const btn = document.createElement('button');
btn.className = 'text-cyber-yellow underline hover:text-cyber-accent text-[10px]';
btn.textContent = '🔁 Retry sans ' + packages.join(', ');
btn.title = 'Relance le yum en ajoutant ces paquets aux excludes';
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const extra = packages.join(' ');
const result = await streamYum(rowId, mode, hostname, extra);
// Met à jour la cellule
updateYumCell(rowId, mode, hostname, result);
});
return btn;
}
function updateYumCell(rowId, mode, hostname, result){
const tr = tbody.querySelector('tr[data-row-id="' + rowId + '"]');
if (!tr) return;
const cell = tr.querySelector(mode === 'dryrun' ? '.cell-dry' : '.cell-patch');
if (mode === 'dryrun') tr._dryData = {ok: result.ok, rc: result.rc, summary: result.summary};
else tr._patchData = {ok: result.ok, rc: result.rc, summary: result.summary};
cell.innerHTML = '';
if (result.ok) {
const sumLine = (result.summary || []).slice(-2).join(' / ') || ('OK (' + result.lines + ' lignes)');
cell.innerHTML = '<span class="text-cyber-green"></span><span class="text-[10px] text-gray-300">' + escapeHTML(sumLine.slice(0,60)) + '</span>';
} else {
cell.innerHTML = '<span class="text-cyber-red">✗ KO (rc=' + result.rc + ')</span>';
if ((result.problems || []).length) {
cell.appendChild(document.createElement('br'));
cell.appendChild(buildRetryButton(rowId, mode, hostname, result.problems));
}
}
refreshStepButtons();
}
function refreshStepButtons(){
const trs = Array.from(tbody.querySelectorAll('tr[data-row-id]'));
const ckOk = trs.filter(tr => tr._checkData && tr._checkData.overall === 'ok');
@ -335,20 +375,12 @@
btnDryrun.disabled = true; btnStep3.disabled = true;
let okCount = 0, koCount = 0;
for (const tr of targets) {
const cell = tr.querySelector('.cell-dry');
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>';
tr.querySelector('.cell-dry').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" title="rc=' + result.rc + '">✗ KO (rc=' + result.rc + ')</span>';
}
updateYumCell(tr.dataset.rowId, 'dryrun', host, result);
if (result.ok) okCount++; else koCount++;
}
summary.innerHTML += ' · Dry-run : ✓ ' + okCount + ' / ✗ ' + koCount;
refreshStepButtons();
@ -426,20 +458,12 @@
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');
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>';
tr.querySelector('.cell-patch').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" title="rc=' + result.rc + '">✗ KO (rc=' + result.rc + ')</span>';
}
updateYumCell(tr.dataset.rowId, 'update', host, result);
if (result.ok) okCount++; else koCount++;
}
summary.innerHTML += ' · Patch : ✓ ' + okCount + ' / ✗ ' + koCount;
refreshStepButtons();