feat(patching/iexec B1): page wizard step 1 - checks DNS+SSH+Satellite (LAN vpdsiasat2 / DMZ vpdsiasat1 selon domaine), Linux uniquement (Windows skip), sudo -n partout

This commit is contained in:
Pierre & Lumière 2026-05-04 15:14:06 +02:00
parent a5f3a25198
commit eb2e0dc8ba
3 changed files with 477 additions and 20 deletions

View File

@ -593,7 +593,8 @@ async def iexec_page(request: Request, db=Depends(get_db),
if ids: if ids:
placeholders = ",".join(str(i) for i in ids) placeholders = ",".join(str(i) for i in ids)
rows = db.execute(text(f""" rows = db.execute(text(f"""
SELECT r.id, r.asset_name, r.environnement, r.os, r.os_version, SELECT r.id, r.asset_name, r.environnement, r.domaine, r.os, r.os_version,
r.intervenant,
r.is_eligible, r.server_id, r.is_eligible, r.server_id,
s.hostname, vs.effective_excludes s.hostname, vs.effective_excludes
FROM patch_planning_import_rows r FROM patch_planning_import_rows r
@ -611,6 +612,54 @@ async def iexec_page(request: Request, db=Depends(get_db),
return templates.TemplateResponse("patching_iexec.html", ctx) return templates.TemplateResponse("patching_iexec.html", ctx)
@router.post("/patching/iexec/check/{row_id}")
async def iexec_check(request: Request, row_id: int, db=Depends(get_db)):
"""Lance les 3 checks pré-patching (DNS, SSH, Satellite) sur 1 row éligible.
Retourne JSON avec le résultat détaillé."""
user = get_current_user(request)
if not user:
return JSONResponse({"ok": False, "msg": "Non authentifié"}, status_code=401)
perms = get_user_perms(db, user)
if not (can_view(perms, "planning") or can_view(perms, "campaigns")):
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
row = db.execute(text("""
SELECT r.id, r.asset_name, r.intervenant, r.environnement, r.domaine,
r.is_eligible, r.server_id, s.hostname
FROM patch_planning_import_rows r
LEFT JOIN servers s ON s.id = r.server_id
WHERE r.id = :id
"""), {"id": row_id}).fetchone()
if not row:
return JSONResponse({"ok": False, "msg": "Ligne introuvable"}, status_code=404)
if not row.is_eligible:
return JSONResponse({"ok": False, "msg": "Ligne non éligible"}, status_code=400)
# Workflow yum/Satellite = Linux uniquement
os_str = str(row.os or "").lower()
if "windows" in os_str or os_str.strip() == "win":
return JSONResponse({
"ok": True, "row_id": row_id,
"hostname": row.asset_name, "target": None,
"overall": "unsupported",
"checks": [],
"skipped_reason": f"OS '{row.os}' non concerné — workflow Linux uniquement",
})
hostname = (row.hostname or row.asset_name or "").strip()
if not hostname:
return JSONResponse({"ok": False, "msg": "Pas de hostname"}, status_code=400)
from ..services.prepatch_check_service import run_all_checks
result = run_all_checks(hostname, row={
"asset_name": row.asset_name,
"intervenant": row.intervenant,
"environnement": row.environnement,
"domaine": row.domaine,
})
return JSONResponse({"ok": True, "row_id": row_id, **result})
@router.post("/patching/import/{import_id}/delete") @router.post("/patching/import/{import_id}/delete")
async def import_delete(request: Request, import_id: int, db=Depends(get_db)): async def import_delete(request: Request, import_id: int, db=Depends(get_db)):
user = get_current_user(request) user = get_current_user(request)

View File

@ -0,0 +1,282 @@
"""Service pré-patching : vérifications avant lancement du patch.
Architecture extensible : un dict `CHECKS` mappe `name -> callable`.
Chaque check prend `(ctx)` et renvoie un dict :
{"name": str, "label": str, "status": "ok"|"warn"|"ko", "message": str, "details": str}
`ctx` est un dict :
{
"hostname": str, # nom court ex 'vpdsiawik1'
"target": str|None, # FQDN résolu (None si DNS KO)
"client": SSHClient|None, # paramiko ouvert ou None
"row": dict, # ligne du planning Excel (pour ctxe additionnel)
}
Les checks sont indépendants : un check peut tourner même si un autre a échoué.
La fonction `run_all_checks(hostname, row)` orchestre l'enchaînement et
calcule un verdict global.
"""
from __future__ import annotations
import logging
import socket
import time
from typing import Callable, Dict, List, Any
from .realtime_audit_service import _resolve, _connect, PARAMIKO_OK
log = logging.getLogger("patchcenter.prepatch")
# Timeout par commande SSH
EXEC_TIMEOUT = 15
# Satellites SANEF par zone réseau
SATELLITE_LAN = "vpdsiasat2.sanef.groupe"
SATELLITE_DMZ = "vpdsiasat1.sanef.groupe"
def _pick_satellite(row: Dict[str, Any]) -> str:
"""Renvoie le hostname du Satellite cible selon le domaine.
Si la colonne Domaine contient 'DMZ' vpdsiasat1, sinon vpdsiasat2 (LAN)."""
domaine = str(row.get("domaine") or "").upper()
if "DMZ" in domaine:
return SATELLITE_DMZ
return SATELLITE_LAN
def _exec(client, cmd: str) -> Dict[str, Any]:
"""Exécute une commande SSH et renvoie {rc, stdout, stderr}."""
try:
stdin, stdout, stderr = client.exec_command(cmd, timeout=EXEC_TIMEOUT)
out = stdout.read().decode("utf-8", "replace").strip()
err = stderr.read().decode("utf-8", "replace").strip()
rc = stdout.channel.recv_exit_status()
return {"rc": rc, "stdout": out, "stderr": err}
except Exception as e:
return {"rc": -1, "stdout": "", "stderr": f"exec error: {e}"}
# ────────────────────────────────────────────────────────────────────────
# Checks individuels
# ────────────────────────────────────────────────────────────────────────
def check_dns(ctx: Dict[str, Any]) -> Dict[str, Any]:
"""Résolution DNS du hostname (nom court → FQDN connu via base ou suffixes)."""
hostname = ctx["hostname"]
target = ctx.get("target")
if target:
return {
"name": "dns",
"label": "Résolution DNS",
"status": "ok",
"message": f"{hostname}{target}",
"details": "",
}
# Si _resolve a échoué, on retente directement gethostbyname pour récupérer une IP
try:
ip = socket.gethostbyname(hostname)
return {
"name": "dns",
"label": "Résolution DNS",
"status": "warn",
"message": f"{hostname}{ip} (FQDN non confirmé)",
"details": "Aucun FQDN en base et aucun suffixe SANEF ne répond sur :22.",
}
except Exception as e:
return {
"name": "dns",
"label": "Résolution DNS",
"status": "ko",
"message": "Impossible de résoudre le hostname",
"details": str(e),
}
def check_ssh(ctx: Dict[str, Any]) -> Dict[str, Any]:
"""Vérifie qu'on a une session SSH ouverte (déjà tentée dans run_all_checks)."""
if ctx.get("client") is not None:
return {
"name": "ssh",
"label": "Connexion SSH",
"status": "ok",
"message": f"Connecté à {ctx.get('target')}",
"details": "",
}
if not PARAMIKO_OK:
return {
"name": "ssh",
"label": "Connexion SSH",
"status": "ko",
"message": "paramiko non disponible côté serveur PatchCenter",
"details": "",
}
if not ctx.get("target"):
return {
"name": "ssh",
"label": "Connexion SSH",
"status": "ko",
"message": "Pas de cible (DNS KO en amont)",
"details": "",
}
return {
"name": "ssh",
"label": "Connexion SSH",
"status": "ko",
"message": "Échec connexion SSH",
"details": "Vérifier ssh_method/clé/PSMP/mot de passe dans Settings.",
}
def check_satellite(ctx: Dict[str, Any]) -> Dict[str, Any]:
"""Vérifie :
1. la joignabilité du Satellite cible (LAN ou DMZ selon Domaine)
2. l'inscription du serveur (subscription-manager identity)
3. l'accès aux repos (yum repolist enabled --quiet)
Toutes les commandes utilisent sudo -n (non-interactif).
"""
client = ctx.get("client")
sat = _pick_satellite(ctx.get("row") or {})
label = f"Satellite ({sat})"
if client is None:
return {
"name": "satellite",
"label": label,
"status": "ko",
"message": "SSH KO en amont",
"details": "",
}
# 1) Joignabilité réseau du Satellite (HEAD https://<sat>/pub/)
r0 = _exec(client,
f"sudo -n curl -k -s -o /dev/null -w '%{{http_code}}' "
f"--max-time 5 https://{sat}/pub/ 2>&1")
http_code = (r0["stdout"] or "").strip()
sat_reachable = http_code in ("200", "301", "302", "403")
# 2) subscription-manager identity
r1 = _exec(client, "sudo -n subscription-manager identity 2>&1")
sub_ok = (r1["rc"] == 0 and "system identity" in r1["stdout"].lower())
# 3) yum repolist enabled --quiet
r2 = _exec(client, "sudo -n yum repolist enabled --quiet 2>&1 | head -50")
repolist_ok = (r2["rc"] == 0 and r2["stdout"].strip() != "")
details = (
f"$ curl https://{sat}/pub/ → http_code={http_code or 'N/A'}\n"
f"$ sudo subscription-manager identity →\n{r1['stdout']}\n{r1['stderr']}\n"
f"---\n"
f"$ sudo yum repolist enabled --quiet (head -50) →\n{r2['stdout']}\n{r2['stderr']}"
)[:2500]
if sat_reachable and sub_ok and repolist_ok:
nb = sum(1 for ln in r2["stdout"].splitlines()
if ln and not ln.lower().startswith(("repo id", "loaded plugins",
"updating subscription",
"this system")))
return {
"name": "satellite",
"label": label,
"status": "ok",
"message": f"{sat} joignable · système enregistré · ~{nb} repo(s) actifs",
"details": details,
}
# Construit message synthétique des KO
issues = []
if not sat_reachable:
issues.append(f"Satellite {sat} injoignable (http={http_code or 'N/A'})")
if not sub_ok:
issues.append("subscription-manager identity KO")
if not repolist_ok:
issues.append("yum repolist vide / KO")
status = "ko" if (not sat_reachable or not repolist_ok) else "warn"
return {
"name": "satellite",
"label": label,
"status": status,
"message": " · ".join(issues),
"details": details,
}
# ────────────────────────────────────────────────────────────────────────
# Registre extensible
# ────────────────────────────────────────────────────────────────────────
CHECKS: Dict[str, Callable[[Dict[str, Any]], Dict[str, Any]]] = {
"dns": check_dns,
"ssh": check_ssh,
"satellite": check_satellite,
}
def register_check(name: str, fn: Callable):
"""Enregistre un check supplémentaire (pour extension future)."""
CHECKS[name] = fn
# ────────────────────────────────────────────────────────────────────────
# Orchestration
# ────────────────────────────────────────────────────────────────────────
def run_all_checks(hostname: str, row: Dict[str, Any] | None = None,
only: List[str] | None = None) -> Dict[str, Any]:
"""Exécute la séquence de checks pour 1 serveur.
Args:
hostname: nom court ex 'vpdsiawik1'
row: dict optionnel d'éléments du planning (pour ctxe additionnel)
only: liste de noms de checks à lancer (par défaut tous)
Returns:
{
"hostname": str,
"target": str|None,
"checks": [check_result, ...],
"overall": "ok" | "warn" | "ko"
}
"""
t0 = time.time()
only_set = set(only) if only else None
target = _resolve(hostname)
client = None
if target and PARAMIKO_OK:
try:
client = _connect(target, hostname)
except Exception as e:
log.warning(f"_connect raised on {hostname}: {e}")
client = None
ctx = {"hostname": hostname, "target": target, "client": client, "row": row or {}}
results = []
for name, fn in CHECKS.items():
if only_set is not None and name not in only_set:
continue
try:
r = fn(ctx)
except Exception as e:
r = {"name": name, "label": name, "status": "ko",
"message": f"Exception: {e}", "details": ""}
results.append(r)
if client is not None:
try:
client.close()
except Exception:
pass
# Verdict global : ok si tous OK ; warn si au moins un warn et aucun ko ; ko sinon
statuses = [r["status"] for r in results]
if all(s == "ok" for s in statuses):
overall = "ok"
elif any(s == "ko" for s in statuses):
overall = "ko"
else:
overall = "warn"
return {
"hostname": hostname,
"target": target,
"checks": results,
"overall": overall,
"duration_ms": int((time.time() - t0) * 1000),
}

View File

@ -5,51 +5,177 @@
<div> <div>
<h2 class="text-xl font-bold text-cyber-accent">Pré-patching — workflow iexec</h2> <h2 class="text-xl font-bold text-cyber-accent">Pré-patching — workflow iexec</h2>
<p class="text-xs text-gray-500 mt-1"> <p class="text-xs text-gray-500 mt-1">
{{ rows|length }} serveur(s) éligible(s) sélectionné(s) sur {{ row_ids|length }} demandés. {{ rows|length }} serveur(s) éligible(s).
<span class="text-cyber-yellow">Note : seuls les Linux sont concernés (Windows non géré).</span>
</p> </p>
</div> </div>
<a href="javascript:history.back()" class="btn-sm bg-cyber-border text-cyber-accent px-4 py-2">← Retour</a> <a href="javascript:history.back()" class="btn-sm bg-cyber-border text-cyber-accent px-4 py-2">← Retour</a>
</div> </div>
<div class="card p-4 mb-4 border border-cyber-yellow/40"> {# ─── Stepper ─── #}
<h3 class="text-sm font-bold text-cyber-yellow mb-2">⚠ Étape B — workflow à implémenter</h3> <div class="flex items-center mb-4 gap-2 text-xs">
<p class="text-xs text-gray-400"> <span class="px-3 py-1 rounded bg-cyber-yellow/20 text-cyber-yellow font-bold">1. Vérifications</span>
Les 3 steps planifiés : <span class="text-gray-500"></span>
</p> <span class="px-3 py-1 rounded bg-cyber-border text-gray-500">2. Snapshot</span>
<ol class="text-xs text-gray-300 list-decimal pl-5 mt-2 space-y-1"> <span class="text-gray-500"></span>
<li><strong>Step 1 — Pré-patching</strong> : vérif résolution DNS · vérif SSH · vérif Satellite (capsule)</li> <span class="px-3 py-1 rounded bg-cyber-border text-gray-500">3. Patch yum</span>
<li><strong>Step 2 — Snapshot</strong> : take snapshot vCenter (avant modif)</li>
<li><strong>Step 3 — Patch</strong> : <code>yum update -y --exclude=&lt;effective_excludes&gt;</code></li>
</ol>
</div> </div>
<div class="card p-3 mb-4"> <div class="card p-3 mb-4">
<h3 class="text-sm font-bold text-cyber-accent mb-2">Serveurs ciblés ({{ rows|length }})</h3> <div class="flex items-center justify-between mb-2">
{% if rows %} <h3 class="text-sm font-bold text-cyber-accent">Step 1 — Vérifications pré-patching</h3>
<div class="flex gap-2">
<button id="btn-run-all" class="btn-primary px-3 py-1 text-xs">Lancer les checks</button>
</div>
</div>
<p class="text-xs text-gray-500 mb-3">
Pour chaque serveur Linux : résolution DNS · connexion SSH ·
joignabilité Satellite (LAN <code>vpdsiasat2</code> / DMZ <code>vpdsiasat1</code>) +
<code>subscription-manager identity</code> + <code>yum repolist enabled</code>.
Toutes les commandes utilisent <code>sudo -n</code>.
</p>
<table class="w-full text-xs"> <table class="w-full text-xs">
<thead class="text-cyber-accent border-b border-cyber-border"> <thead class="text-cyber-accent border-b border-cyber-border">
<tr> <tr>
<th class="text-left p-1">Asset</th> <th class="text-left p-1">Asset</th>
<th class="text-left p-1">Hostname BDD</th> <th class="text-left p-1">Hostname BDD</th>
<th class="text-left p-1">Env</th> <th class="text-left p-1">Env</th>
<th class="text-left p-1">Domaine</th>
<th class="text-left p-1">OS</th> <th class="text-left p-1">OS</th>
<th class="text-left p-1">Excludes effectifs</th> <th class="text-left p-1">Excludes</th>
<th class="text-left p-1">DNS</th>
<th class="text-left p-1">SSH</th>
<th class="text-left p-1">Satellite</th>
<th class="text-left p-1">Verdict</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody id="check-tbody">
{% for r in rows %} {% for r in rows %}
<tr class="border-b border-cyber-border/30"> <tr class="border-b border-cyber-border/30" data-row-id="{{ r.id }}" data-os="{{ r.os or '' }}">
<td class="p-1 font-mono">{{ r.asset_name }}</td> <td class="p-1 font-mono">{{ r.asset_name }}</td>
<td class="p-1 font-mono">{{ r.hostname or '' }}</td> <td class="p-1 font-mono">{{ r.hostname or '' }}</td>
<td class="p-1">{{ r.environnement or '' }}</td> <td class="p-1">{{ r.environnement or '' }}</td>
<td class="p-1">{{ r.domaine if r.domaine is defined else '' }}</td>
<td class="p-1">{{ r.os or '' }}</td> <td class="p-1">{{ r.os or '' }}</td>
<td class="p-1 text-cyber-yellow">{{ r.effective_excludes or '(aucun)' }}</td> <td class="p-1 text-cyber-yellow">{{ r.effective_excludes or '(aucun)' }}</td>
<td class="p-1 cell-dns text-gray-500">·</td>
<td class="p-1 cell-ssh text-gray-500">·</td>
<td class="p-1 cell-sat text-gray-500">·</td>
<td class="p-1 cell-overall text-gray-500">en attente</td>
</tr> </tr>
{% else %}
<tr><td colspan="10" class="p-2 text-gray-500">Aucune ligne éligible.</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% else %}
<p class="text-xs text-gray-500">Aucune ligne éligible parmi les IDs demandés.</p>
{% endif %}
</div> </div>
{# ─── Détails par serveur (panneau pliable) ─── #}
<div class="card p-3 mb-4" id="details-card" style="display:none;">
<h3 class="text-sm font-bold text-cyber-accent mb-2">Détails du dernier check</h3>
<pre id="details-pane" class="bg-cyber-bg p-2 text-[11px] whitespace-pre-wrap overflow-x-auto" style="max-height:400px;"></pre>
</div>
<div class="flex justify-between items-center mt-4">
<span id="run-summary" class="text-xs text-gray-400"></span>
<button id="btn-step2" class="btn-sm bg-cyber-green/20 text-cyber-green px-4 py-2 text-xs" disabled>
→ Step 2 (snapshot) — à brancher
</button>
</div>
<script>
(function(){
const btnRun = document.getElementById('btn-run-all');
const btnStep2 = document.getElementById('btn-step2');
const tbody = document.getElementById('check-tbody');
const summary = document.getElementById('run-summary');
const detailsCard = document.getElementById('details-card');
const detailsPane = document.getElementById('details-pane');
function statusBadge(st){
if (st === 'ok') return '<span class="text-cyber-green">✓ OK</span>';
if (st === 'warn')return '<span class="text-cyber-yellow">⚠ WARN</span>';
if (st === 'ko') return '<span class="text-cyber-red">✗ KO</span>';
if (st === 'unsupported') return '<span class="text-gray-400">⊘ N/A</span>';
return '<span class="text-gray-500">' + st + '</span>';
}
function isLinux(osStr){
const s = (osStr || '').toLowerCase();
return s && !s.includes('windows') && s !== 'win';
}
async function checkOne(tr){
const rowId = tr.dataset.rowId;
const osStr = tr.dataset.os || '';
if (!isLinux(osStr)) {
tr.querySelector('.cell-dns').innerHTML = statusBadge('unsupported');
tr.querySelector('.cell-ssh').innerHTML = statusBadge('unsupported');
tr.querySelector('.cell-sat').innerHTML = statusBadge('unsupported');
tr.querySelector('.cell-overall').innerHTML = '<span class="text-gray-400" title="Workflow Linux uniquement">⊘ Windows</span>';
return {overall: 'unsupported'};
}
tr.querySelector('.cell-overall').innerHTML = '<span class="text-cyber-yellow"></span>';
try {
const r = await fetch('/patching/iexec/check/' + rowId, {method:'POST'});
const j = await r.json();
if (!j.ok) {
tr.querySelector('.cell-overall').innerHTML = '<span class="text-cyber-red">err</span>';
return {overall: 'ko'};
}
if (j.overall === 'unsupported') {
tr.querySelector('.cell-dns').innerHTML = statusBadge('unsupported');
tr.querySelector('.cell-ssh').innerHTML = statusBadge('unsupported');
tr.querySelector('.cell-sat').innerHTML = statusBadge('unsupported');
tr.querySelector('.cell-overall').innerHTML = '<span class="text-gray-400" title="' + (j.skipped_reason||'') + '">⊘ N/A</span>';
return j;
}
const byName = {};
(j.checks || []).forEach(c => byName[c.name] = c);
tr.querySelector('.cell-dns').innerHTML = byName.dns ? statusBadge(byName.dns.status) : '';
tr.querySelector('.cell-ssh').innerHTML = byName.ssh ? statusBadge(byName.ssh.status) : '';
tr.querySelector('.cell-sat').innerHTML = byName.satellite ? statusBadge(byName.satellite.status) : '';
tr.querySelector('.cell-overall').innerHTML = statusBadge(j.overall) + ' <span class="text-[10px] text-gray-500">(' + (j.duration_ms||0) + 'ms)</span>';
tr._checkData = j;
return j;
} catch(e) {
tr.querySelector('.cell-overall').innerHTML = '<span class="text-cyber-red">err</span>';
return {overall: 'ko'};
}
}
btnRun.addEventListener('click', async () => {
btnRun.disabled = true; btnStep2.disabled = true;
summary.textContent = 'Lancement…';
const trs = Array.from(tbody.querySelectorAll('tr[data-row-id]'));
let okCount = 0, warnCount = 0, koCount = 0, naCount = 0;
for (const tr of trs) {
const r = await checkOne(tr);
if (r.overall === 'ok') okCount++;
else if (r.overall === 'warn') warnCount++;
else if (r.overall === 'unsupported') naCount++;
else koCount++;
}
summary.innerHTML = '✓ ' + okCount + ' OK · ⚠ ' + warnCount + ' warn · ✗ ' + koCount + ' KO · ⊘ ' + naCount + ' N/A';
btnRun.disabled = false;
btnStep2.disabled = (okCount === 0);
});
// Click sur une ligne → afficher les détails
tbody.addEventListener('click', (ev) => {
const tr = ev.target.closest('tr[data-row-id]');
if (!tr || !tr._checkData) return;
detailsCard.style.display = '';
const j = tr._checkData;
let txt = `Hostname: ${j.hostname || ''}\nTarget: ${j.target || ''}\nVerdict: ${j.overall}\n\n`;
(j.checks || []).forEach(c => {
txt += `[${c.status.toUpperCase()}] ${c.label} : ${c.message}\n`;
if (c.details) txt += c.details + '\n';
txt += '---\n';
});
detailsPane.textContent = txt;
});
})();
</script>
{% endblock %} {% endblock %}