diff --git a/app/routers/planning_import.py b/app/routers/planning_import.py index 42a5ba9..1a9a8ad 100644 --- a/app/routers/planning_import.py +++ b/app/routers/planning_import.py @@ -948,6 +948,57 @@ async def iexec_yum_update(request: Request, row_id: int, db=Depends(get_db)): return JSONResponse(result) +@router.post("/patching/iexec/reboot/{row_id}") +async def iexec_reboot(request: Request, row_id: int, db=Depends(get_db)): + """Step 3e — lance reboot avec délai +1 minute. + NE JAMAIS appeler en automatique : doit toujours venir d'un clic + utilisateur explicite (double confirmation côté UI).""" + 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 reboot_host + result = reboot_host(hostname) + + try: + db.execute(text(""" + INSERT INTO patch_planning_row_log (row_id, action, details, performed_by) + VALUES (:rid, 'reboot_initiated', :de, :uid) + """), {"rid": row_id, + "de": json.dumps(result, ensure_ascii=False), + "uid": user.get("uid")}) + db.commit() + except Exception as e: + print(f"[iexec_reboot] audit log failed: {e}") + + result["row_id"] = row_id + return JSONResponse(result) + + +@router.get("/patching/iexec/reboot-status/{row_id}") +async def iexec_reboot_status(request: Request, row_id: int, db=Depends(get_db)): + """Step 3e — poll l'état du serveur après reboot (TCP/22 + SSH 'uptime'). + Appelé en boucle côté frontend (toutes les 10s).""" + 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 reboot_status + result = reboot_status(hostname) + 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 index 123a5d6..2a4f3b2 100644 --- a/app/services/patch_run_service.py +++ b/app/services/patch_run_service.py @@ -8,6 +8,8 @@ import base64 import logging import re +import socket +from datetime import datetime from typing import Dict, Any, List from .realtime_audit_service import _resolve, _connect, PARAMIKO_OK @@ -400,3 +402,69 @@ def post_patch_compare(hostname: str) -> Dict[str, Any]: "stdout": r["stdout"][-3000:], "stderr": r["stderr"][:500] if r["stderr"] else "", } + + +# ─── B3.6 — Reboot manuel sur clic + polling reconnexion ────────────────── +# IMPORTANT : reboot_host() n'est JAMAIS lancé automatiquement. +# Toujours déclenché par un clic utilisateur explicite avec double confirmation +# côté frontend. + +def reboot_host(hostname: str) -> Dict[str, Any]: + """Lance reboot avec délai +1 min (laisse le temps à SSH de retourner). + Appelée uniquement après confirmation explicite côté UI.""" + client, target, err = _open_ssh(hostname) + if err: + return {"ok": False, "detail": err, "target": target} + try: + cmd = "sudo -n shutdown -r +1 'PatchCenter post-patch reboot' 2>&1" + r = _exec(client, cmd, timeout=15) + finally: + try: + client.close() + except Exception: + pass + ok = (r["rc"] == 0) + return { + "ok": ok, + "rc": r["rc"], + "cmd": cmd, + "target": target, + "started_at": datetime.now().isoformat(timespec="seconds"), + "stdout": r["stdout"][:500], + "stderr": r["stderr"][:500] if r["stderr"] else "", + } + + +def reboot_status(hostname: str) -> Dict[str, Any]: + """Vérifie si le serveur est revenu : TCP/22 puis SSH 'uptime'. + À appeler en boucle (toutes les 10s côté frontend).""" + target = _resolve(hostname) + if not target: + return {"reachable": False, "tcp22": False, "ssh": False, + "detail": "DNS résolution impossible", "target": None} + # 1. TCP/22 + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(3) + sock.connect((target, 22)) + sock.close() + except Exception as e: + return {"reachable": False, "tcp22": False, "ssh": False, + "target": target, "detail": str(e)[:200]} + # 2. SSH minimal — uptime + if not PARAMIKO_OK: + return {"reachable": True, "tcp22": True, "ssh": False, "target": target, + "detail": "paramiko absent côté serveur PatchCenter"} + client = _connect(target, hostname) + if not client: + return {"reachable": True, "tcp22": True, "ssh": False, "target": target, + "detail": "TCP/22 OK mais SSH KO (probablement encore en boot)"} + try: + r = _exec(client, "uptime 2>&1", timeout=10) + finally: + try: + client.close() + except Exception: + pass + return {"reachable": True, "tcp22": True, "ssh": True, "target": target, + "uptime": (r.get("stdout") or "").strip()[:300]} diff --git a/app/templates/patching_iexec.html b/app/templates/patching_iexec.html index e90532f..be9e928 100644 --- a/app/templates/patching_iexec.html +++ b/app/templates/patching_iexec.html @@ -53,6 +53,7 @@