From 19d88f2d538e95bc20d87cf16781334d65e574f0 Mon Sep 17 00:00:00 2001 From: Admin MPCZ Date: Tue, 5 May 2026 11:32:44 +0200 Subject: [PATCH] feat(patching/iexec): detection auto deps problematiques + bouton retry sans paquets KO (multilib, requires, conflicts) - extra_excludes via SSE query param --- app/routers/planning_import.py | 12 +++-- app/services/patch_run_service.py | 64 +++++++++++++++++++++++- app/templates/patching_iexec.html | 82 ++++++++++++++++++++----------- 3 files changed, 123 insertions(+), 35 deletions(-) diff --git a/app/routers/planning_import.py b/app/routers/planning_import.py index 24ca9ea..42a5ba9 100644 --- a/app/routers/planning_import.py +++ b/app/routers/planning_import.py @@ -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/?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" diff --git a/app/services/patch_run_service.py b/app/services/patch_run_service.py index 2fbf299..123a5d6 100644 --- a/app/services/patch_run_service.py +++ b/app/services/patch_run_service.py @@ -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: diff --git a/app/templates/patching_iexec.html b/app/templates/patching_iexec.html index be4b0f0..e90532f 100644 --- a/app/templates/patching_iexec.html +++ b/app/templates/patching_iexec.html @@ -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 = '' + escapeHTML(sumLine.slice(0,60)) + ''; + } else { + cell.innerHTML = '✗ KO (rc=' + result.rc + ')'; + 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 = '… dry-run (live)'; + tr.querySelector('.cell-dry').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 = '✗ KO (rc=' + result.rc + ')'; - } + 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 = '… patch (live)'; + tr.querySelector('.cell-patch').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 = '✗ KO (rc=' + result.rc + ')'; - } + updateYumCell(tr.dataset.rowId, 'update', host, result); + if (result.ok) okCount++; else koCount++; } summary.innerHTML += ' · Patch : ✓ ' + okCount + ' / ✗ ' + koCount; refreshStepButtons();