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}")
|
@router.get("/patching/iexec/yum-stream/{row_id}")
|
||||||
async def iexec_yum_stream(request: Request, row_id: int,
|
async def iexec_yum_stream(request: Request, row_id: int,
|
||||||
mode: str = Query("dryrun"),
|
mode: str = Query("dryrun"),
|
||||||
|
extra_excludes: str = Query(""),
|
||||||
db=Depends(get_db)):
|
db=Depends(get_db)):
|
||||||
"""Streaming SSE (Server-Sent Events) du yum dryrun ou update.
|
"""Streaming SSE du yum dryrun ou update + audit log à la fin.
|
||||||
Yield chaque ligne stdout en live + log audit en fin de stream.
|
extra_excludes : liste séparée par espaces ou virgules (retry après dep KO).
|
||||||
Frontend : new EventSource('/patching/iexec/yum-stream/<id>?mode=dryrun').
|
|
||||||
"""
|
"""
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
user = get_current_user(request)
|
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"):
|
if mode not in ("dryrun", "update"):
|
||||||
return JSONResponse({"ok": False, "detail": "mode invalide"}, status_code=400)
|
return JSONResponse({"ok": False, "detail": "mode invalide"}, status_code=400)
|
||||||
hostname = (row.hostname or row.asset_name).strip()
|
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")
|
uid = user.get("uid")
|
||||||
action_name = "yum_dryrun_stream" if mode == "dryrun" else "yum_update_stream"
|
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
|
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]:
|
def _summary(stdout: str) -> List[str]:
|
||||||
"""Extrait les lignes-clé du plan yum (compte de paquets, 'Nothing to do'…)."""
|
"""Extrait les lignes-clé du plan yum (compte de paquets, 'Nothing to do'…)."""
|
||||||
keys = (
|
keys = (
|
||||||
@ -126,13 +177,22 @@ def yum_stream_lines(hostname: str, excludes_raw, mode: str):
|
|||||||
cmd = _build_cmd(mode, excludes)
|
cmd = _build_cmd(mode, excludes)
|
||||||
yield {"type": "cmd", "cmd": cmd, "target": target,
|
yield {"type": "cmd", "cmd": cmd, "target": target,
|
||||||
"excludes": excludes, "hostname": hostname}
|
"excludes": excludes, "hostname": hostname}
|
||||||
|
full_lines: List[str] = []
|
||||||
try:
|
try:
|
||||||
stdin, stdout, stderr = client.exec_command(cmd, get_pty=False)
|
stdin, stdout, stderr = client.exec_command(cmd, get_pty=False)
|
||||||
# Lecture ligne par ligne ; yum bufferise peu son stdout sur opérations longues
|
# Lecture ligne par ligne ; yum bufferise peu son stdout sur opérations longues
|
||||||
for line in iter(stdout.readline, ""):
|
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()
|
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:
|
except Exception as e:
|
||||||
yield {"type": "error", "msg": f"exec error: {e}"}
|
yield {"type": "error", "msg": f"exec error: {e}"}
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@ -230,12 +230,15 @@
|
|||||||
termPane.scrollTop = termPane.scrollHeight;
|
termPane.scrollTop = termPane.scrollHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
function streamYum(rowId, mode, hostname){
|
function streamYum(rowId, mode, hostname, extraExcludes){
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
openTerm((mode === 'dryrun' ? 'dry-run' : 'PATCH') + ' yum @ ' + hostname);
|
const titre = (mode === 'dryrun' ? 'dry-run' : 'PATCH') + ' yum @ ' + hostname
|
||||||
const url = '/patching/iexec/yum-stream/' + rowId + '?mode=' + mode;
|
+ (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 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) => {
|
ev.onmessage = (m) => {
|
||||||
let j;
|
let j;
|
||||||
try { j = JSON.parse(m.data); } catch(e) { return; }
|
try { j = JSON.parse(m.data); } catch(e) { return; }
|
||||||
@ -246,7 +249,6 @@
|
|||||||
} else if (j.type === 'line') {
|
} else if (j.type === 'line') {
|
||||||
appendTerm(j.data + '\n');
|
appendTerm(j.data + '\n');
|
||||||
result.lines++;
|
result.lines++;
|
||||||
// Capture quelques lignes-clé pour le badge cellule
|
|
||||||
const ll = j.data.toLowerCase();
|
const ll = j.data.toLowerCase();
|
||||||
if (ll.includes('package') || ll.includes('paquet')
|
if (ll.includes('package') || ll.includes('paquet')
|
||||||
|| ll.includes('nothing to do') || ll.includes('rien à faire')
|
|| ll.includes('nothing to do') || ll.includes('rien à faire')
|
||||||
@ -255,11 +257,13 @@
|
|||||||
}
|
}
|
||||||
} else if (j.type === 'end') {
|
} else if (j.type === 'end') {
|
||||||
result.rc = j.rc;
|
result.rc = j.rc;
|
||||||
// dryrun rc=0 (rien) ou rc=1 (updates dispo) = OK
|
result.problems = j.problems || [];
|
||||||
// update rc=0 = OK
|
|
||||||
if (mode === 'dryrun') result.ok = (j.rc === 0 || j.rc === 1);
|
if (mode === 'dryrun') result.ok = (j.rc === 0 || j.rc === 1);
|
||||||
else result.ok = (j.rc === 0);
|
else result.ok = (j.rc === 0);
|
||||||
appendTerm('\n[exit code: ' + j.rc + ' — ' + (result.ok ? 'OK' : 'KO') + ']\n');
|
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();
|
ev.close();
|
||||||
resolve(result);
|
resolve(result);
|
||||||
} else if (j.type === 'error') {
|
} 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(){
|
function refreshStepButtons(){
|
||||||
const trs = Array.from(tbody.querySelectorAll('tr[data-row-id]'));
|
const trs = Array.from(tbody.querySelectorAll('tr[data-row-id]'));
|
||||||
const ckOk = trs.filter(tr => tr._checkData && tr._checkData.overall === 'ok');
|
const ckOk = trs.filter(tr => tr._checkData && tr._checkData.overall === 'ok');
|
||||||
@ -335,20 +375,12 @@
|
|||||||
btnDryrun.disabled = true; btnStep3.disabled = true;
|
btnDryrun.disabled = true; btnStep3.disabled = true;
|
||||||
let okCount = 0, koCount = 0;
|
let okCount = 0, koCount = 0;
|
||||||
for (const tr of targets) {
|
for (const tr of targets) {
|
||||||
const cell = tr.querySelector('.cell-dry');
|
|
||||||
const host = tr.querySelector('td:nth-child(2)').textContent.trim()
|
const host = tr.querySelector('td:nth-child(2)').textContent.trim()
|
||||||
|| tr.querySelector('td:nth-child(3)').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);
|
const result = await streamYum(tr.dataset.rowId, 'dryrun', host);
|
||||||
tr._dryData = {ok: result.ok, rc: result.rc, summary: result.summary};
|
updateYumCell(tr.dataset.rowId, 'dryrun', host, result);
|
||||||
if (result.ok) {
|
if (result.ok) okCount++; else koCount++;
|
||||||
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>';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
summary.innerHTML += ' · Dry-run : ✓ ' + okCount + ' / ✗ ' + koCount;
|
summary.innerHTML += ' · Dry-run : ✓ ' + okCount + ' / ✗ ' + koCount;
|
||||||
refreshStepButtons();
|
refreshStepButtons();
|
||||||
@ -426,20 +458,12 @@
|
|||||||
btnStep3.disabled = true; btnDryrun.disabled = true; btnStep2.disabled = true;
|
btnStep3.disabled = true; btnDryrun.disabled = true; btnStep2.disabled = true;
|
||||||
let okCount = 0, koCount = 0;
|
let okCount = 0, koCount = 0;
|
||||||
for (const tr of targets) {
|
for (const tr of targets) {
|
||||||
const cell = tr.querySelector('.cell-patch');
|
|
||||||
const host = tr.querySelector('td:nth-child(2)').textContent.trim()
|
const host = tr.querySelector('td:nth-child(2)').textContent.trim()
|
||||||
|| tr.querySelector('td:nth-child(3)').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);
|
const result = await streamYum(tr.dataset.rowId, 'update', host);
|
||||||
tr._patchData = {ok: result.ok, rc: result.rc, summary: result.summary};
|
updateYumCell(tr.dataset.rowId, 'update', host, result);
|
||||||
if (result.ok) {
|
if (result.ok) okCount++; else koCount++;
|
||||||
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>';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
summary.innerHTML += ' · Patch : ✓ ' + okCount + ' / ✗ ' + koCount;
|
summary.innerHTML += ' · Patch : ✓ ' + okCount + ' / ✗ ' + koCount;
|
||||||
refreshStepButtons();
|
refreshStepButtons();
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user