diff --git a/app/routers/planning_import.py b/app/routers/planning_import.py index a25f0aa..584106c 100644 --- a/app/routers/planning_import.py +++ b/app/routers/planning_import.py @@ -738,6 +738,93 @@ async def iexec_snapshot(request: Request, row_id: int, db=Depends(get_db)): return JSONResponse(result) +def _common_iexec_row_check(row_id: int, db, user, perms): + """Charge la row et applique les vérifs communes (éligible + Linux + hostname). + Retourne (row_obj, error_response). error_response is None si OK.""" + if not _can_import(perms): + return None, JSONResponse({"ok": False, "detail": "Permission refusée"}, status_code=403) + row = db.execute(text(""" + SELECT r.id, r.asset_name, r.os, r.is_eligible, + s.hostname, vs.effective_excludes + FROM patch_planning_import_rows r + LEFT JOIN servers s ON s.id = r.server_id + LEFT JOIN v_servers vs ON vs.id = r.server_id + WHERE r.id = :id + """), {"id": row_id}).fetchone() + if not row: + return None, JSONResponse({"ok": False, "detail": "Ligne introuvable"}, status_code=404) + if not row.is_eligible: + return None, JSONResponse({"ok": False, "detail": "Ligne non éligible"}, status_code=400) + os_str = str(row.os or "").lower() + if "windows" in os_str: + return None, JSONResponse({"ok": False, "detail": "OS Windows non géré"}, status_code=400) + if not (row.hostname or row.asset_name): + return None, JSONResponse({"ok": False, "detail": "Pas de hostname"}, status_code=400) + return row, None + + +@router.post("/patching/iexec/yum-dryrun/{row_id}") +async def iexec_yum_dryrun(request: Request, row_id: int, db=Depends(get_db)): + """Step 3 — pré-vol : `sudo -n yum update --assumeno --exclude=...`.""" + user = get_current_user(request) + if not user: + return JSONResponse({"ok": False, "detail": "Non authentifié"}, status_code=401) + perms = get_user_perms(db, user) + row, err = _common_iexec_row_check(row_id, db, user, perms) + if err: + return err + hostname = (row.hostname or row.asset_name).strip() + + from ..services.patch_run_service import yum_dryrun + result = yum_dryrun(hostname, row.effective_excludes) + + try: + db.execute(text(""" + INSERT INTO patch_planning_row_log (row_id, action, details, performed_by) + VALUES (:rid, 'yum_dryrun', :de, :uid) + """), {"rid": row_id, + "de": json.dumps({k: v for k, v in result.items() if k != "stdout_tail"}, + ensure_ascii=False), + "uid": user.get("uid")}) + db.commit() + except Exception as e: + print(f"[iexec_yum_dryrun] audit log failed: {e}") + + result["row_id"] = row_id + return JSONResponse(result) + + +@router.post("/patching/iexec/yum-update/{row_id}") +async def iexec_yum_update(request: Request, row_id: int, db=Depends(get_db)): + """Step 3 — vrai patch : `sudo -n yum update -y --exclude=...`.""" + user = get_current_user(request) + if not user: + return JSONResponse({"ok": False, "detail": "Non authentifié"}, status_code=401) + perms = get_user_perms(db, user) + row, err = _common_iexec_row_check(row_id, db, user, perms) + if err: + return err + hostname = (row.hostname or row.asset_name).strip() + + from ..services.patch_run_service import yum_update + result = yum_update(hostname, row.effective_excludes) + + try: + db.execute(text(""" + INSERT INTO patch_planning_row_log (row_id, action, details, performed_by) + VALUES (:rid, 'yum_update', :de, :uid) + """), {"rid": row_id, + "de": json.dumps({k: v for k, v in result.items() if k != "stdout_tail"}, + ensure_ascii=False), + "uid": user.get("uid")}) + db.commit() + except Exception as e: + print(f"[iexec_yum_update] audit log failed: {e}") + + result["row_id"] = row_id + return JSONResponse(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/patch_run_service.py b/app/services/patch_run_service.py new file mode 100644 index 0000000..5e8efee --- /dev/null +++ b/app/services/patch_run_service.py @@ -0,0 +1,138 @@ +"""Service exécution patch yum : + - yum_dryrun() : `sudo -n yum update --assumeno --exclude=...` + - yum_update() : `sudo -n yum update -y --exclude=...` + Gère la résolution DNS, ouverture SSH (réutilise _resolve/_connect du + realtime_audit_service), validation des excludes (anti-injection shell), + capture de la sortie + heuristique de résumé du plan yum. +""" +import logging +import re +from typing import Dict, Any, List + +from .realtime_audit_service import _resolve, _connect, PARAMIKO_OK + +log = logging.getLogger("patchcenter.patch_run") + +# Timeouts SSH (secondes) +TIMEOUT_DRYRUN = 60 +TIMEOUT_UPDATE = 1800 # 30 min — yum update peut être long + +# Whitelist caractères autorisés dans un nom de paquet (anti-injection shell) +EXCLUDE_RE = re.compile(r"^[A-Za-z0-9._*\-/+]+$") + + +def _safe_excludes(raw) -> List[str]: + """Normalise et filtre la string `effective_excludes` (séparée par espaces). + Rejette les patterns suspects pour éviter toute injection.""" + if not raw: + return [] + parts = str(raw).split() + return [p for p in parts if EXCLUDE_RE.match(p)] + + +def _build_cmd(mode: str, excludes: List[str]) -> str: + """mode = 'dryrun' (--assumeno) ou 'update' (-y).""" + flag = "--assumeno" if mode == "dryrun" else "-y" + parts = ["sudo", "-n", "yum", "update", flag] + for e in excludes: + parts.append(f"--exclude={e}") + parts.append("2>&1") + return " ".join(parts) + + +def _exec(client, cmd: str, timeout: int) -> Dict[str, Any]: + try: + stdin, stdout, stderr = client.exec_command(cmd, timeout=timeout) + out = stdout.read().decode("utf-8", "replace") + err = stderr.read().decode("utf-8", "replace") + 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}"} + + +def _open_ssh(hostname: str): + target = _resolve(hostname) + if not target: + return None, None, "DNS résolution impossible" + if not PARAMIKO_OK: + return None, target, "paramiko non disponible côté serveur PatchCenter" + client = _connect(target, hostname) + if not client: + return None, target, "Connexion SSH échouée" + return client, target, None + + +def _summary(stdout: str) -> List[str]: + """Extrait les lignes-clé du plan yum (compte de paquets, 'Nothing to do'…).""" + keys = ( + "package(s)", "paquet(s)", + "nothing to do", "rien à faire", + "transaction summary", "résumé de la transaction", + "install ", "installation ", "upgrade ", "mise à niveau", + "complete!", "terminé !", + "no packages marked for update", "no package", + ) + summary = [] + for ln in stdout.splitlines(): + ll = ln.lower().strip() + if any(k in ll for k in keys): + summary.append(ln.strip()) + if len(summary) >= 25: + break + return summary + + +def yum_dryrun(hostname: str, excludes_raw) -> Dict[str, Any]: + """Lance un dry-run (--assumeno). rc=0 → rien à faire, rc=1 → updates dispo (normal).""" + excludes = _safe_excludes(excludes_raw) + client, target, err = _open_ssh(hostname) + if err: + return {"ok": False, "detail": err, "target": target, "excludes": excludes} + try: + cmd = _build_cmd("dryrun", excludes) + r = _exec(client, cmd, TIMEOUT_DRYRUN) + finally: + try: + client.close() + except Exception: + pass + # Avec --assumeno : rc=0 si "Nothing to do", rc=1 si plan d'updates dispo + ok = r["rc"] in (0, 1) + return { + "ok": ok, + "rc": r["rc"], + "cmd": cmd, + "target": target, + "excludes": excludes, + "summary": _summary(r["stdout"]), + "stdout_tail": r["stdout"][-3000:], + "stderr": r["stderr"][:500] if r["stderr"] else "", + } + + +def yum_update(hostname: str, excludes_raw) -> Dict[str, Any]: + """Lance le vrai patch (-y).""" + excludes = _safe_excludes(excludes_raw) + client, target, err = _open_ssh(hostname) + if err: + return {"ok": False, "detail": err, "target": target, "excludes": excludes} + try: + cmd = _build_cmd("update", excludes) + r = _exec(client, cmd, TIMEOUT_UPDATE) + finally: + try: + client.close() + except Exception: + pass + ok = (r["rc"] == 0) + return { + "ok": ok, + "rc": r["rc"], + "cmd": cmd, + "target": target, + "excludes": excludes, + "summary": _summary(r["stdout"]), + "stdout_tail": r["stdout"][-5000:], + "stderr": r["stderr"][:500] if r["stderr"] else "", + } diff --git a/app/templates/patching_iexec.html b/app/templates/patching_iexec.html index d6b0790..efa3d77 100644 --- a/app/templates/patching_iexec.html +++ b/app/templates/patching_iexec.html @@ -18,7 +18,7 @@ 2. Snapshot - 3. Patch yum + 3. Patch yum
@@ -50,6 +50,8 @@ Satellite Verdict Snapshot + Dry-run + Patch @@ -67,9 +69,11 @@ · en attente · + · + · {% else %} - Aucune ligne éligible. + Aucune ligne éligible. {% endfor %} @@ -81,17 +85,27 @@

 
-
+
- +
+ + + +