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:
parent
8cf78dfef3
commit
19d88f2d53
@ -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"
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user