From eb2e0dc8ba1b48d541cf37677e3b88f5e76b9f43 Mon Sep 17 00:00:00 2001 From: Admin MPCZ Date: Mon, 4 May 2026 15:14:06 +0200 Subject: [PATCH] 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 --- app/routers/planning_import.py | 51 ++++- app/services/prepatch_check_service.py | 282 +++++++++++++++++++++++++ app/templates/patching_iexec.html | 164 ++++++++++++-- 3 files changed, 477 insertions(+), 20 deletions(-) create mode 100644 app/services/prepatch_check_service.py diff --git a/app/routers/planning_import.py b/app/routers/planning_import.py index 2e1b6fe..026803b 100644 --- a/app/routers/planning_import.py +++ b/app/routers/planning_import.py @@ -593,7 +593,8 @@ async def iexec_page(request: Request, db=Depends(get_db), if ids: placeholders = ",".join(str(i) for i in ids) 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, s.hostname, vs.effective_excludes 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) +@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") async def import_delete(request: Request, import_id: int, db=Depends(get_db)): user = get_current_user(request) diff --git a/app/services/prepatch_check_service.py b/app/services/prepatch_check_service.py new file mode 100644 index 0000000..1b95699 --- /dev/null +++ b/app/services/prepatch_check_service.py @@ -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:///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), + } diff --git a/app/templates/patching_iexec.html b/app/templates/patching_iexec.html index 916151e..9db36d8 100644 --- a/app/templates/patching_iexec.html +++ b/app/templates/patching_iexec.html @@ -5,51 +5,177 @@

Pré-patching — workflow iexec

- {{ rows|length }} serveur(s) éligible(s) sélectionné(s) sur {{ row_ids|length }} demandés. + {{ rows|length }} serveur(s) éligible(s). + Note : seuls les Linux sont concernés (Windows non géré).

← Retour -
-

⚠ Étape B — workflow à implémenter

-

- Les 3 steps planifiés : -

-
    -
  1. Step 1 — Pré-patching : vérif résolution DNS · vérif SSH · vérif Satellite (capsule)
  2. -
  3. Step 2 — Snapshot : take snapshot vCenter (avant modif)
  4. -
  5. Step 3 — Patch : yum update -y --exclude=<effective_excludes>
  6. -
+{# ─── Stepper ─── #} +
+ 1. Vérifications + + 2. Snapshot + + 3. Patch yum
-

Serveurs ciblés ({{ rows|length }})

- {% if rows %} +
+

Step 1 — Vérifications pré-patching

+
+ +
+
+

+ Pour chaque serveur Linux : résolution DNS · connexion SSH · + joignabilité Satellite (LAN vpdsiasat2 / DMZ vpdsiasat1) + + subscription-manager identity + yum repolist enabled. + Toutes les commandes utilisent sudo -n. +

+ + - + + + + + - + {% for r in rows %} - + + + + + + + {% else %} + {% endfor %}
Asset Hostname BDD EnvDomaine OSExcludes effectifsExcludesDNSSSHSatelliteVerdict
{{ r.asset_name }} {{ r.hostname or '–' }} {{ r.environnement or '' }}{{ r.domaine if r.domaine is defined else '' }} {{ r.os or '' }} {{ r.effective_excludes or '(aucun)' }}···en attente
Aucune ligne éligible.
- {% else %} -

Aucune ligne éligible parmi les IDs demandés.

- {% endif %}
+ +{# ─── Détails par serveur (panneau pliable) ─── #} + + +
+ + +
+ + {% endblock %}