diff --git a/app/routers/patching.py b/app/routers/patching.py new file mode 100644 index 0000000..51a1b92 --- /dev/null +++ b/app/routers/patching.py @@ -0,0 +1,679 @@ +"""Router Patching — exclusions, correspondance prod↔hors-prod, validations.""" +from fastapi import APIRouter, Request, Depends, Query, Form +from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse +from fastapi.templating import Jinja2Templates +from sqlalchemy import text +from ..dependencies import get_db, get_current_user, get_user_perms, can_view, can_edit, base_context +from ..services import correspondance_service as corr +from ..config import APP_NAME + +router = APIRouter() +templates = Jinja2Templates(directory="app/templates") + + +def _can_edit_excludes(perms): + """Peut éditer les exclusions : admin, coordinator, operator (pas viewer).""" + return can_edit(perms, "servers") or can_edit(perms, "campaigns") or can_edit(perms, "quickwin") + + +@router.get("/patching/config-exclusions", response_class=HTMLResponse) +async def config_exclusions_page(request: Request, db=Depends(get_db), + search: str = Query(""), + domain: str = Query(""), + env: str = Query(""), + zone: str = Query(""), + tier: str = Query(""), + os: str = Query(""), + application: str = Query(""), + has_excludes: str = Query(""), + page: int = Query(1), + per_page: int = Query(30)): + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + perms = get_user_perms(db, user) + if not _can_edit_excludes(perms): + return RedirectResponse(url="/dashboard") + + # Requête principale + where = ["1=1"] + params = {} + if search: + where.append("s.hostname ILIKE :s"); params["s"] = f"%{search}%" + if domain: + where.append("d.code = :d"); params["d"] = domain + if env: + where.append("e.code = :e"); params["e"] = env + if zone: + where.append("z.name = :z"); params["z"] = zone + if tier: + where.append("s.tier = :t"); params["t"] = tier + if os: + where.append("s.os_family = :o"); params["o"] = os + if application: + where.append("s.application_name = :app"); params["app"] = application + if has_excludes == "yes": + where.append("s.patch_excludes IS NOT NULL AND s.patch_excludes != ''") + elif has_excludes == "no": + where.append("(s.patch_excludes IS NULL OR s.patch_excludes = '')") + + wc = " AND ".join(where) + + # Count + total = db.execute(text(f""" + SELECT COUNT(*) FROM servers s + LEFT JOIN domain_environments de ON s.domain_env_id = de.id + LEFT JOIN domains d ON de.domain_id = d.id + LEFT JOIN environments e ON de.environment_id = e.id + LEFT JOIN zones z ON s.zone_id = z.id + WHERE {wc} + """), params).scalar() or 0 + + per_page = max(10, min(per_page, 200)) + total_pages = max(1, (total + per_page - 1) // per_page) + page = max(1, min(page, total_pages)) + offset = (page - 1) * per_page + + rows = db.execute(text(f""" + SELECT s.id, s.hostname, s.os_family, s.os_version, s.tier, s.etat, + s.patch_excludes, s.application_name, + d.name as domain_name, d.code as domain_code, + e.name as env_name, e.code as env_code, + z.name as zone_name + FROM servers s + LEFT JOIN domain_environments de ON s.domain_env_id = de.id + LEFT JOIN domains d ON de.domain_id = d.id + LEFT JOIN environments e ON de.environment_id = e.id + LEFT JOIN zones z ON s.zone_id = z.id + WHERE {wc} + ORDER BY s.hostname + LIMIT :limit OFFSET :offset + """), {**params, "limit": per_page, "offset": offset}).fetchall() + + # Listes pour filtres + domains = db.execute(text("SELECT code, name FROM domains ORDER BY name")).fetchall() + envs = db.execute(text("SELECT code, name FROM environments ORDER BY name")).fetchall() + zones = db.execute(text("SELECT DISTINCT name FROM zones ORDER BY name")).fetchall() + applications = db.execute(text("""SELECT application_name, COUNT(*) as c FROM servers + WHERE application_name IS NOT NULL AND application_name != '' + GROUP BY application_name ORDER BY application_name""")).fetchall() + all_apps = db.execute(text("""SELECT id, nom_court FROM applications + WHERE itop_id IS NOT NULL ORDER BY nom_court""")).fetchall() + + # Stats globales + stats = { + "total_servers": db.execute(text("SELECT COUNT(*) FROM servers")).scalar(), + "with_excludes": db.execute(text("SELECT COUNT(*) FROM servers WHERE patch_excludes IS NOT NULL AND patch_excludes != ''")).scalar(), + } + + ctx = base_context(request, db, user) + ctx.update({ + "app_name": APP_NAME, + "servers": rows, "total": total, + "page": page, "per_page": per_page, "total_pages": total_pages, + "filters": {"search": search, "domain": domain, "env": env, + "zone": zone, "tier": tier, "os": os, + "application": application, "has_excludes": has_excludes}, + "domains": domains, "envs": envs, "zones": [z.name for z in zones], + "applications": applications, "all_apps": all_apps, + "stats": stats, + "msg": request.query_params.get("msg"), + }) + return templates.TemplateResponse("patching_config_exclusions.html", ctx) + + +@router.post("/patching/config-exclusions/{server_id}/save") +async def save_server_excludes(request: Request, server_id: int, db=Depends(get_db), + patch_excludes: str = Form("")): + """Enregistre les exclusions d'un serveur + push iTop.""" + from fastapi.responses import JSONResponse + 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_edit_excludes(perms): + return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403) + + # Normalise : split sur espaces/newlines, dédoublonne, rejoint avec un espace + parts = [p.strip() for p in patch_excludes.replace("\n", " ").replace("\t", " ").split() if p.strip()] + seen = set() + cleaned = [] + for p in parts: + if p not in seen: + seen.add(p) + cleaned.append(p) + new_val = " ".join(cleaned) + srv = db.execute(text("SELECT id, hostname FROM servers WHERE id=:id"), {"id": server_id}).fetchone() + if not srv: + return JSONResponse({"ok": False, "msg": "Serveur introuvable"}, status_code=404) + + # 1. Maj base locale + db.execute(text("UPDATE servers SET patch_excludes=:pe, updated_at=NOW() WHERE id=:id"), + {"pe": new_val, "id": server_id}) + db.commit() + + # 2. Push iTop immédiat (best effort) + itop_result = {"pushed": False, "msg": ""} + try: + from ..services.itop_service import ITopClient + from ..services.secrets_service import get_secret + url = get_secret(db, "itop_url") + u = get_secret(db, "itop_user") + p = get_secret(db, "itop_pass") + if url and u and p: + client = ITopClient(url, u, p) + r = client._call("core/get", **{"class": "VirtualMachine", + "key": f'SELECT VirtualMachine WHERE name = "{srv.hostname}"', "output_fields": "name"}) + if r.get("objects"): + vm_id = list(r["objects"].values())[0]["key"] + upd = client.update("VirtualMachine", vm_id, {"patch_excludes": new_val}) + if upd.get("code") == 0: + itop_result = {"pushed": True, "msg": "Poussé vers iTop"} + else: + itop_result = {"pushed": False, "msg": f"iTop: {upd.get('message','')[:80]}"} + except Exception as e: + itop_result = {"pushed": False, "msg": f"iTop error: {str(e)[:80]}"} + + return JSONResponse({"ok": True, "patch_excludes": new_val, "itop": itop_result}) + + +@router.post("/patching/config-exclusions/bulk") +async def bulk_update_excludes(request: Request, db=Depends(get_db)): + """Bulk add/remove pattern sur plusieurs serveurs.""" + from fastapi.responses import JSONResponse + 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_edit_excludes(perms): + return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403) + + body = await request.json() + server_ids = body.get("server_ids", []) + pattern = (body.get("pattern") or "").strip() + action = body.get("action", "add") # "add" | "remove" | "replace" + + if not server_ids or not pattern and action != "replace": + return JSONResponse({"ok": False, "msg": "Paramètres manquants"}) + + ids = [int(x) for x in server_ids if str(x).isdigit()] + if not ids: + return JSONResponse({"ok": False, "msg": "Aucun serveur valide"}) + + placeholders = ",".join(str(i) for i in ids) + rows = db.execute(text(f"SELECT id, hostname, patch_excludes FROM servers WHERE id IN ({placeholders})")).fetchall() + + updated = 0 + for r in rows: + current = (r.patch_excludes or "").strip() + parts = current.split() if current else [] + if action == "add": + if pattern not in parts: + parts.append(pattern) + elif action == "remove": + parts = [p for p in parts if p != pattern] + elif action == "replace": + parts = pattern.split() + new_val = " ".join(parts) + if new_val != current: + db.execute(text("UPDATE servers SET patch_excludes=:pe, updated_at=NOW() WHERE id=:id"), + {"pe": new_val, "id": r.id}) + updated += 1 + db.commit() + + # Push iTop en batch (best effort, async conceptually) + itop_pushed = 0 + itop_errors = 0 + try: + from ..services.itop_service import ITopClient + from ..services.secrets_service import get_secret + url = get_secret(db, "itop_url") + u = get_secret(db, "itop_user") + p = get_secret(db, "itop_pass") + if url and u and p: + client = ITopClient(url, u, p) + # Refresh après update + rows2 = db.execute(text(f"SELECT hostname, patch_excludes FROM servers WHERE id IN ({placeholders})")).fetchall() + for r in rows2: + resp = client._call("core/get", **{"class": "VirtualMachine", + "key": f'SELECT VirtualMachine WHERE name = "{r.hostname}"', "output_fields": "name"}) + if resp.get("objects"): + vm_id = list(resp["objects"].values())[0]["key"] + up = client.update("VirtualMachine", vm_id, {"patch_excludes": r.patch_excludes or ""}) + if up.get("code") == 0: + itop_pushed += 1 + else: + itop_errors += 1 + except Exception: + pass + + return JSONResponse({"ok": True, "updated": updated, "itop_pushed": itop_pushed, "itop_errors": itop_errors}) + + +@router.post("/patching/config-exclusions/bulk-application") +async def bulk_update_application(request: Request, db=Depends(get_db)): + """Bulk changement de solution applicative sur plusieurs serveurs.""" + from fastapi.responses import JSONResponse + 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_edit_excludes(perms): + return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403) + + body = await request.json() + server_ids = body.get("server_ids", []) + application_id = body.get("application_id") # int ou None/"" pour désassocier + + ids = [int(x) for x in server_ids if str(x).isdigit()] + if not ids: + return JSONResponse({"ok": False, "msg": "Aucun serveur"}) + + app_id_val = None + app_itop_id = None + app_name = None + if application_id and str(application_id).strip().isdigit(): + app_id_val = int(application_id) + row = db.execute(text("SELECT itop_id, nom_court FROM applications WHERE id=:id"), + {"id": app_id_val}).fetchone() + if row: + app_itop_id = row.itop_id + app_name = row.nom_court + else: + return JSONResponse({"ok": False, "msg": "Application introuvable"}) + + placeholders = ",".join(str(i) for i in ids) + db.execute(text(f"""UPDATE servers SET application_id=:aid, application_name=:an, updated_at=NOW() + WHERE id IN ({placeholders})"""), {"aid": app_id_val, "an": app_name}) + db.commit() + updated = len(ids) + + # Push iTop + itop_pushed = 0 + itop_errors = 0 + try: + from ..services.itop_service import ITopClient + from ..services.secrets_service import get_secret + url = get_secret(db, "itop_url") + u = get_secret(db, "itop_user") + p = get_secret(db, "itop_pass") + if url and u and p: + client = ITopClient(url, u, p) + new_list = [{"applicationsolution_id": int(app_itop_id)}] if app_itop_id else [] + hosts = db.execute(text(f"SELECT hostname FROM servers WHERE id IN ({placeholders})")).fetchall() + for h in hosts: + try: + rr = client._call("core/get", **{"class": "VirtualMachine", + "key": f'SELECT VirtualMachine WHERE name = "{h.hostname}"', "output_fields": "name"}) + if rr.get("objects"): + vm_id = list(rr["objects"].values())[0]["key"] + up = client.update("VirtualMachine", vm_id, {"applicationsolution_list": new_list}) + if up.get("code") == 0: + itop_pushed += 1 + else: + itop_errors += 1 + except Exception: + itop_errors += 1 + except Exception: + pass + + return JSONResponse({"ok": True, "updated": updated, "itop_pushed": itop_pushed, "itop_errors": itop_errors, + "app_name": app_name or "(aucune)"}) + + +# ═══════════════════════════════════════════════════════ +# Correspondance prod ↔ hors-prod +# ═══════════════════════════════════════════════════════ + +@router.get("/patching/correspondance", response_class=HTMLResponse) +async def correspondance_page(request: Request, db=Depends(get_db), + search: str = Query(""), application: str = Query(""), + domain: str = Query(""), env: str = Query("")): + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + perms = get_user_perms(db, user) + if not _can_edit_excludes(perms) and not can_view(perms, "campaigns"): + return RedirectResponse(url="/dashboard") + + servers = corr.get_servers_for_builder(db, search=search, app=application, + domain=domain, env=env) + + applications = db.execute(text("""SELECT DISTINCT application_name FROM servers + WHERE application_name IS NOT NULL AND application_name != '' + ORDER BY application_name""")).fetchall() + envs = db.execute(text("SELECT DISTINCT name FROM environments ORDER BY name")).fetchall() + domains = db.execute(text("SELECT DISTINCT name FROM domains ORDER BY name")).fetchall() + all_apps = db.execute(text("""SELECT id, nom_court FROM applications + WHERE itop_id IS NOT NULL ORDER BY nom_court""")).fetchall() + + # Stats globales + stats = { + "total_links": db.execute(text("SELECT COUNT(*) FROM server_correspondance")).scalar() or 0, + "filtered": len(servers), + } + + ctx = base_context(request, db, user) + ctx.update({"app_name": APP_NAME, "servers": servers, "stats": stats, + "applications": applications, + "envs": [e.name for e in envs], + "domains": [d.name for d in domains], + "all_apps": all_apps, + "search": search, "application": application, + "domain": domain, "env": env, + "can_edit": _can_edit_excludes(perms), + "msg": request.query_params.get("msg", "")}) + return templates.TemplateResponse("patching_correspondance.html", ctx) + + +@router.post("/patching/correspondance/bulk-env") +async def correspondance_bulk_env(request: Request, db=Depends(get_db)): + """Change l'environnement réel de N serveurs (PatchCenter + push iTop).""" + user = get_current_user(request) + if not user: + return JSONResponse({"ok": False}, status_code=401) + perms = get_user_perms(db, user) + if not _can_edit_excludes(perms): + return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403) + body = await request.json() + server_ids = [int(x) for x in body.get("server_ids", []) if str(x).isdigit()] + env_name = (body.get("env_name") or "").strip() + if not server_ids or not env_name: + return JSONResponse({"ok": False, "msg": "Paramètres manquants"}) + + # Trouver env_id + env_row = db.execute(text("SELECT id FROM environments WHERE name=:n"), {"n": env_name}).fetchone() + if not env_row: + return JSONResponse({"ok": False, "msg": f"Env '{env_name}' introuvable"}) + env_id = env_row.id + + placeholders = ",".join(str(i) for i in server_ids) + # Pour chaque serveur : trouver/créer le domain_env correspondant et l'affecter + updated = 0 + srvs = db.execute(text(f"""SELECT s.id, s.hostname, s.domain_env_id, de.domain_id + FROM servers s LEFT JOIN domain_environments de ON s.domain_env_id = de.id + WHERE s.id IN ({placeholders})""")).fetchall() + + for s in srvs: + if not s.domain_id: + continue # pas de domaine actuel, skip + # Trouver ou créer domain_environments(domain_id, env_id) + de = db.execute(text("""SELECT id FROM domain_environments + WHERE domain_id=:d AND environment_id=:e"""), + {"d": s.domain_id, "e": env_id}).fetchone() + if not de: + db.execute(text("""INSERT INTO domain_environments (domain_id, environment_id) + VALUES (:d, :e)"""), {"d": s.domain_id, "e": env_id}) + db.commit() + de = db.execute(text("""SELECT id FROM domain_environments + WHERE domain_id=:d AND environment_id=:e"""), + {"d": s.domain_id, "e": env_id}).fetchone() + db.execute(text("UPDATE servers SET domain_env_id=:de_id, updated_at=NOW() WHERE id=:sid"), + {"de_id": de.id, "sid": s.id}) + updated += 1 + db.commit() + + # Push iTop + itop_pushed = 0 + itop_errors = 0 + try: + from ..services.itop_service import ITopClient + from ..services.secrets_service import get_secret + url = get_secret(db, "itop_url") + u = get_secret(db, "itop_user") + p = get_secret(db, "itop_pass") + if url and u and p: + client = ITopClient(url, u, p) + for s in srvs: + try: + rr = client._call("core/get", **{"class": "VirtualMachine", + "key": f'SELECT VirtualMachine WHERE name = "{s.hostname}"', "output_fields": "name"}) + if rr.get("objects"): + vm_id = list(rr["objects"].values())[0]["key"] + upd = client.update("VirtualMachine", vm_id, { + "environnement_id": f"SELECT Environnement WHERE name = '{env_name}'" + }) + if upd.get("code") == 0: + itop_pushed += 1 + else: + itop_errors += 1 + except Exception: + itop_errors += 1 + except Exception: + pass + + return JSONResponse({"ok": True, "updated": updated, + "itop_pushed": itop_pushed, "itop_errors": itop_errors, + "env_name": env_name}) + + +@router.post("/patching/correspondance/bulk-application") +async def correspondance_bulk_app(request: Request, db=Depends(get_db)): + """Change la solution applicative de N serveurs (PatchCenter + push iTop).""" + user = get_current_user(request) + if not user: + return JSONResponse({"ok": False}, status_code=401) + perms = get_user_perms(db, user) + if not _can_edit_excludes(perms): + return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403) + body = await request.json() + server_ids = [int(x) for x in body.get("server_ids", []) if str(x).isdigit()] + application_id = body.get("application_id") + if not server_ids: + return JSONResponse({"ok": False, "msg": "Aucun serveur"}) + + app_id_val = None + app_itop_id = None + app_name = None + if application_id and str(application_id).strip().isdigit(): + app_id_val = int(application_id) + row = db.execute(text("SELECT itop_id, nom_court FROM applications WHERE id=:id"), + {"id": app_id_val}).fetchone() + if row: + app_itop_id = row.itop_id + app_name = row.nom_court + else: + return JSONResponse({"ok": False, "msg": "Application introuvable"}) + + placeholders = ",".join(str(i) for i in server_ids) + db.execute(text(f"""UPDATE servers SET application_id=:aid, application_name=:an, updated_at=NOW() + WHERE id IN ({placeholders})"""), {"aid": app_id_val, "an": app_name}) + db.commit() + + itop_pushed = 0 + itop_errors = 0 + try: + from ..services.itop_service import ITopClient + from ..services.secrets_service import get_secret + url = get_secret(db, "itop_url") + u = get_secret(db, "itop_user") + p = get_secret(db, "itop_pass") + if url and u and p: + client = ITopClient(url, u, p) + new_list = [{"applicationsolution_id": int(app_itop_id)}] if app_itop_id else [] + hosts = db.execute(text(f"SELECT hostname FROM servers WHERE id IN ({placeholders})")).fetchall() + for h in hosts: + try: + rr = client._call("core/get", **{"class": "VirtualMachine", + "key": f'SELECT VirtualMachine WHERE name = "{h.hostname}"', "output_fields": "name"}) + if rr.get("objects"): + vm_id = list(rr["objects"].values())[0]["key"] + up = client.update("VirtualMachine", vm_id, {"applicationsolution_list": new_list}) + if up.get("code") == 0: + itop_pushed += 1 + else: + itop_errors += 1 + except Exception: + itop_errors += 1 + except Exception: + pass + + return JSONResponse({"ok": True, "updated": len(server_ids), + "itop_pushed": itop_pushed, "itop_errors": itop_errors, + "app_name": app_name or "(aucune)"}) + + +@router.post("/patching/correspondance/bulk-create") +async def correspondance_bulk_create(request: Request, db=Depends(get_db)): + user = get_current_user(request) + if not user: + return JSONResponse({"ok": False}, status_code=401) + perms = get_user_perms(db, user) + if not _can_edit_excludes(perms): + return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403) + body = await request.json() + prod_ids = [int(x) for x in body.get("prod_ids", []) if str(x).isdigit()] + nonprod_ids = [int(x) for x in body.get("nonprod_ids", []) if str(x).isdigit()] + env_labels = body.get("env_labels", {}) + if not prod_ids or not nonprod_ids: + return JSONResponse({"ok": False, "msg": "Au moins 1 prod et 1 non-prod requis"}) + r = corr.bulk_create_correspondance(db, prod_ids, nonprod_ids, env_labels, user.get("uid")) + return JSONResponse({"ok": True, **r}) + + +@router.post("/patching/correspondance/auto-detect") +async def correspondance_auto_detect(request: Request, db=Depends(get_db)): + user = get_current_user(request) + if not user: + return JSONResponse({"ok": False}, status_code=401) + perms = get_user_perms(db, user) + if not _can_edit_excludes(perms): + return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403) + stats = corr.detect_correspondances(db) + return JSONResponse({"ok": True, **{k: v for k, v in stats.items() if k != "plan"}}) + + +@router.post("/patching/correspondance/link") +async def correspondance_link(request: Request, db=Depends(get_db), + prod_id: int = Form(...), nonprod_id: int = Form(...), + env_code: str = Form(""), note: str = Form("")): + user = get_current_user(request) + if not user: + return JSONResponse({"ok": False}, status_code=401) + perms = get_user_perms(db, user) + if not _can_edit_excludes(perms): + return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403) + corr.create_manual_link(db, prod_id, nonprod_id, env_code, note, user.get("uid")) + return JSONResponse({"ok": True}) + + +@router.post("/patching/correspondance/link-by-host") +async def correspondance_link_by_host(request: Request, db=Depends(get_db), + prod_id: int = Form(...), + nonprod_hostname: str = Form(...), + env_code: str = Form(""), note: str = Form("")): + user = get_current_user(request) + if not user: + return JSONResponse({"ok": False}, status_code=401) + perms = get_user_perms(db, user) + if not _can_edit_excludes(perms): + return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403) + row = db.execute(text("SELECT id FROM servers WHERE LOWER(hostname)=LOWER(:h)"), + {"h": nonprod_hostname.strip()}).fetchone() + if not row: + return JSONResponse({"ok": False, "msg": f"Serveur '{nonprod_hostname}' introuvable"}) + corr.create_manual_link(db, prod_id, row.id, env_code, note, user.get("uid")) + return JSONResponse({"ok": True}) + + +@router.post("/patching/correspondance/{corr_id}/delete") +async def correspondance_delete(request: Request, corr_id: int, db=Depends(get_db)): + user = get_current_user(request) + if not user: + return JSONResponse({"ok": False}, status_code=401) + perms = get_user_perms(db, user) + if not _can_edit_excludes(perms): + return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403) + corr.delete_link(db, corr_id) + return JSONResponse({"ok": True}) + + +# ═══════════════════════════════════════════════════════ +# Validations post-patching +# ═══════════════════════════════════════════════════════ + +@router.get("/patching/validations", response_class=HTMLResponse) +async def validations_page(request: Request, db=Depends(get_db), + status: str = Query("en_attente"), + campaign_id: int = Query(None), + env: str = Query("")): + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + perms = get_user_perms(db, user) + if not _can_edit_excludes(perms): + return RedirectResponse(url="/dashboard") + + validations = corr.get_pending_validations(db, env=env, campaign_id=campaign_id, status=status) + + # Contacts validateurs (responsables + référents) + contacts = db.execute(text("""SELECT id, name, email, role, team + FROM contacts WHERE is_active=true + AND role IN ('responsable_applicatif','responsable_domaine','referent_technique','ra_prod','ra_test') + ORDER BY name""")).fetchall() + + stats = { + "en_attente": db.execute(text("SELECT COUNT(*) FROM patch_validation WHERE status='en_attente'")).scalar() or 0, + "validated_ok": db.execute(text("SELECT COUNT(*) FROM patch_validation WHERE status='validated_ok'")).scalar() or 0, + "validated_ko": db.execute(text("SELECT COUNT(*) FROM patch_validation WHERE status='validated_ko'")).scalar() or 0, + "forced": db.execute(text("SELECT COUNT(*) FROM patch_validation WHERE status='forced'")).scalar() or 0, + } + + envs = db.execute(text("SELECT DISTINCT name FROM environments ORDER BY name")).fetchall() + + ctx = base_context(request, db, user) + ctx.update({"app_name": APP_NAME, "validations": validations, "contacts": contacts, + "stats": stats, "status": status, "campaign_id": campaign_id, "env": env, + "envs": [e.name for e in envs], + "can_force": can_edit(perms, "campaigns") or user.get("role") == "admin", + "msg": request.query_params.get("msg", "")}) + return templates.TemplateResponse("patching_validations.html", ctx) + + +@router.post("/patching/validations/mark") +async def validations_mark(request: Request, db=Depends(get_db)): + user = get_current_user(request) + if not user: + return JSONResponse({"ok": False}, status_code=401) + perms = get_user_perms(db, user) + if not _can_edit_excludes(perms): + return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403) + + body = await request.json() + ids = body.get("validation_ids", []) + status = body.get("status", "validated_ok") + contact_id = body.get("contact_id") + forced_reason = body.get("forced_reason", "") + notes = body.get("notes", "") + + if status not in ("validated_ok", "validated_ko", "forced"): + return JSONResponse({"ok": False, "msg": "Status invalide"}) + if status == "forced" and not forced_reason.strip(): + return JSONResponse({"ok": False, "msg": "Raison obligatoire pour forcer"}) + if status in ("validated_ok", "validated_ko") and not contact_id: + return JSONResponse({"ok": False, "msg": "Validateur obligatoire"}) + + validator_name = None + if contact_id: + row = db.execute(text("SELECT name FROM contacts WHERE id=:id"), + {"id": int(contact_id)}).fetchone() + if row: + validator_name = row.name + + n = corr.mark_validation(db, ids, status, contact_id, validator_name, + forced_reason, notes, user.get("uid")) + return JSONResponse({"ok": True, "updated": n}) + + +@router.get("/patching/validations/history/{server_id}", response_class=HTMLResponse) +async def validations_history(request: Request, server_id: int, db=Depends(get_db)): + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + srv = db.execute(text("SELECT id, hostname FROM servers WHERE id=:id"), {"id": server_id}).fetchone() + if not srv: + return HTMLResponse("Serveur introuvable", status_code=404) + history = corr.get_validation_history(db, server_id) + ctx = base_context(request, db, user) + ctx.update({"app_name": APP_NAME, "server": srv, "history": history}) + return templates.TemplateResponse("patching_validations_history.html", ctx) diff --git a/app/routers/quickwin.py b/app/routers/quickwin.py index 18b0564..1f0f49c 100644 --- a/app/routers/quickwin.py +++ b/app/routers/quickwin.py @@ -10,7 +10,7 @@ from ..services.quickwin_service import ( get_server_configs, upsert_server_config, delete_server_config, get_eligible_servers, list_runs, get_run, get_run_entries, create_run, delete_run, update_entry_field, - can_start_prod, get_run_stats, inject_yum_history, + can_start_prod, check_prod_validations, get_run_stats, inject_yum_history, advance_run_status, get_step_stats, mark_snapshot, mark_all_snapshots, build_yum_commands, get_available_servers, get_available_filters, add_entries_to_run, remove_entries_from_run, @@ -55,53 +55,32 @@ async def quickwin_page(request: Request, db=Depends(get_db)): # -- Config exclusions par serveur -- +DEFAULT_REBOOT_PACKAGES = ( + "kernel* glibc* systemd* dbus* polkit* linux-firmware* microcode_ctl* " + "tuned* dracut* grub2* kexec-tools* libselinux* selinux-policy* shim* " + "mokutil* net-snmp* NetworkManager* network-scripts* nss* openssl-libs*" +) + + @router.get("/quickwin/config", response_class=HTMLResponse) -async def quickwin_config_page(request: Request, db=Depends(get_db), - page: int = Query(1), - per_page: int = Query(14), - search: str = Query(""), - env: str = Query(""), - domain: str = Query(""), - zone: str = Query("")): +async def quickwin_config_page(request: Request, db=Depends(get_db)): + """Page d'édition de la liste globale des packages qui nécessitent un reboot. + Cette liste est utilisée par QuickWin (en plus des exclusions iTop par serveur).""" user = get_current_user(request) if not user: return RedirectResponse(url="/login") perms = get_user_perms(db, user) - if not can_view(perms, "campaigns") and not can_view(perms, "quickwin"): + if not can_edit(perms, "campaigns") and not can_edit(perms, "quickwin"): return RedirectResponse(url="/dashboard") - configs = get_server_configs(db) - - # Filtres - filtered = configs - if search: - filtered = [s for s in filtered if search.lower() in s.hostname.lower()] - if env: - filtered = [s for s in filtered if s.environnement == env] - if domain: - filtered = [s for s in filtered if s.domaine == domain] - if zone: - filtered = [s for s in filtered if (s.zone or '') == zone] - - # Pagination - per_page = max(5, min(per_page, 100)) - total = len(filtered) - total_pages = max(1, (total + per_page - 1) // per_page) - page = max(1, min(page, total_pages)) - start = (page - 1) * per_page - page_servers = filtered[start:start + per_page] + from ..services.secrets_service import get_secret + current = get_secret(db, "patching_reboot_packages") or DEFAULT_REBOOT_PACKAGES ctx = base_context(request, db, user) ctx.update({ "app_name": APP_NAME, - "all_servers": page_servers, - "all_configs": configs, - "default_excludes": DEFAULT_GENERAL_EXCLUDES, - "total_count": total, - "page": page, - "per_page": per_page, - "total_pages": total_pages, - "filters": {"search": search, "env": env, "domain": domain, "zone": zone}, + "reboot_packages": current, + "default_packages": DEFAULT_REBOOT_PACKAGES, "msg": request.query_params.get("msg"), }) return templates.TemplateResponse("quickwin_config.html", ctx) @@ -109,44 +88,20 @@ async def quickwin_config_page(request: Request, db=Depends(get_db), @router.post("/quickwin/config/save") async def quickwin_config_save(request: Request, db=Depends(get_db), - server_id: int = Form(0), - general_excludes: str = Form(""), - specific_excludes: str = Form(""), - notes: str = Form("")): + reboot_packages: str = Form("")): + """Sauvegarde la liste globale des packages nécessitant un reboot.""" user = get_current_user(request) if not user: return RedirectResponse(url="/login") - if server_id: - upsert_server_config(db, server_id, general_excludes.strip(), - specific_excludes.strip(), notes.strip()) + perms = get_user_perms(db, user) + if not can_edit(perms, "campaigns") and not can_edit(perms, "quickwin"): + return RedirectResponse(url="/dashboard") + from ..services.secrets_service import set_secret + set_secret(db, "patching_reboot_packages", reboot_packages.strip(), + "Packages nécessitant un reboot (QuickWin)") return RedirectResponse(url="/quickwin/config?msg=saved", status_code=303) -@router.post("/quickwin/config/delete") -async def quickwin_config_delete(request: Request, db=Depends(get_db), - config_id: int = Form(0)): - user = get_current_user(request) - if not user: - return RedirectResponse(url="/login") - if config_id: - delete_server_config(db, config_id) - return RedirectResponse(url="/quickwin/config?msg=deleted", status_code=303) - - -@router.post("/quickwin/config/bulk-add") -async def quickwin_config_bulk_add(request: Request, db=Depends(get_db), - server_ids: str = Form(""), - general_excludes: str = Form("")): - """Ajouter plusieurs serveurs d'un coup avec les memes exclusions generales""" - user = get_current_user(request) - if not user: - return RedirectResponse(url="/login") - ids = [int(x) for x in server_ids.split(",") if x.strip().isdigit()] - for sid in ids: - upsert_server_config(db, sid, general_excludes.strip(), "", "") - return RedirectResponse(url=f"/quickwin/config?msg=added_{len(ids)}", status_code=303) - - # -- Runs QuickWin -- @router.post("/quickwin/create") @@ -168,7 +123,7 @@ async def quickwin_create(request: Request, db=Depends(get_db), ids = [int(x) for x in server_ids.split(",") if x.strip().isdigit()] if not ids: - # Prendre tous les serveurs eligibles (linux, en_production, secops) + # Prendre tous les serveurs eligibles (linux, production, secops) eligible = get_eligible_servers(db) ids = [s.id for s in eligible] @@ -189,6 +144,9 @@ async def quickwin_correspondance_redirect(request: Request, db=Depends(get_db)) user = get_current_user(request) if not user: return RedirectResponse(url="/login") + perms = get_user_perms(db, user) + if not can_edit(perms, "campaigns") and not can_edit(perms, "quickwin"): + return RedirectResponse(url="/dashboard") runs = list_runs(db) if not runs: return RedirectResponse(url="/quickwin?msg=no_run") @@ -221,6 +179,7 @@ async def quickwin_detail(request: Request, run_id: int, db=Depends(get_db), entries = get_run_entries(db, run_id) stats = get_run_stats(db, run_id) prod_ok = can_start_prod(db, run_id) + validations_ok, validations_blockers = check_prod_validations(db, run_id) step_stats_hp = get_step_stats(db, run_id, "hprod") step_stats_pr = get_step_stats(db, run_id, "prod") @@ -279,6 +238,7 @@ async def quickwin_detail(request: Request, run_id: int, db=Depends(get_db), "p_page": p_page, "p_total_pages": p_total_pages, "per_page": per_page, "prod_ok": prod_ok, + "validations_ok": validations_ok, "validations_blockers": validations_blockers, "step_hp": step_stats_hp, "step_pr": step_stats_pr, "scope": scope, "filters": {"search": search, "status": status, "domain": domain, diff --git a/app/services/correspondance_service.py b/app/services/correspondance_service.py new file mode 100644 index 0000000..68eebe3 --- /dev/null +++ b/app/services/correspondance_service.py @@ -0,0 +1,442 @@ +"""Service de correspondance prod ↔ hors-prod + validations post-patching. + +Détection automatique par signature de hostname : + - 2ème caractère = environnement SANEF (p=prod, r=recette, t=test, i=preprod, v=validation, d=dev, o=preprod, s=prod) + - Signature = hostname avec le 2ème char remplacé par "_" + - Tous les hostnames avec la même signature sont candidats correspondants. + +Exceptions (ls-*, sp-*, etc.) : ne sont pas traitées automatiquement. +""" +import logging +from sqlalchemy import text +from collections import defaultdict + +log = logging.getLogger(__name__) + +# Lettres prod (un prod pour une signature) +PROD_CHARS = {"p", "s"} # p=Production, s=Production secours (à valider) + +# Lettres hors-prod avec label +NONPROD_CHARS = { + "r": "Recette", "t": "Test", "i": "Pre-production", + "v": "Validation", "d": "Developpement", "o": "Pre-production", +} + +# Préfixes qui ne suivent PAS la nomenclature +EXCEPTION_PREFIXES = ("ls-", "sp") + + +def _signature(hostname): + """Retourne (signature, env_char) ou (None, None) si non analysable.""" + hn = (hostname or "").lower().strip() + if not hn or len(hn) < 3: + return None, None + # Préfixes d'exception + for pref in EXCEPTION_PREFIXES: + if hn.startswith(pref): + return None, None + # Format standard : X{env_char}YYYYYY + env_char = hn[1] + if env_char not in PROD_CHARS and env_char not in NONPROD_CHARS: + return None, None + signature = hn[0] + "_" + hn[2:] + return signature, env_char + + +def detect_correspondances(db, dry_run=False): + """Parcourt tous les serveurs, groupe par signature, crée les liens auto. + Ne touche pas aux liens 'manual' ou 'exception' existants. + Retourne un dict de stats.""" + stats = {"signatures": 0, "prod_found": 0, "nonprod_found": 0, + "links_created": 0, "links_kept_manual": 0, "orphan_nonprod": 0, + "ambiguous": 0, "exceptions": 0} + + # Tous les serveurs actifs (exclut stock/obsolete) + rows = db.execute(text("""SELECT id, hostname FROM servers + WHERE etat NOT IN ('stock','obsolete') ORDER BY hostname""")).fetchall() + + by_signature = defaultdict(list) # signature -> [(server_id, env_char, hostname)] + for r in rows: + sig, env = _signature(r.hostname) + if sig is None: + stats["exceptions"] += 1 + continue + by_signature[sig].append((r.id, env, r.hostname)) + + stats["signatures"] = len(by_signature) + + if dry_run: + # Préparer plan + plan = [] + for sig, members in by_signature.items(): + prods = [m for m in members if m[1] in PROD_CHARS] + nps = [m for m in members if m[1] in NONPROD_CHARS] + if len(prods) == 1 and nps: + plan.append({"signature": sig, "prod": prods[0][2], + "nonprods": [(n[2], NONPROD_CHARS[n[1]]) for n in nps]}) + elif len(prods) > 1 and nps: + stats["ambiguous"] += 1 + elif not prods and nps: + stats["orphan_nonprod"] += len(nps) + stats["plan"] = plan[:50] + return stats + + for sig, members in by_signature.items(): + prods = [m for m in members if m[1] in PROD_CHARS] + nonprods = [m for m in members if m[1] in NONPROD_CHARS] + stats["prod_found"] += len(prods) + stats["nonprod_found"] += len(nonprods) + + if not prods and nonprods: + stats["orphan_nonprod"] += len(nonprods) + continue + + if len(prods) > 1: + stats["ambiguous"] += 1 + # On n'auto-détecte pas quand plusieurs prods (ambigu) + continue + + if len(prods) == 1 and nonprods: + prod_id = prods[0][0] + for np_id, np_env, np_host in nonprods: + env_label = NONPROD_CHARS.get(np_env, "Autre") + # Insert si pas déjà présent + pas 'manual' ou 'exception' + existing = db.execute(text("""SELECT id, source FROM server_correspondance + WHERE prod_server_id=:p AND nonprod_server_id=:n"""), + {"p": prod_id, "n": np_id}).fetchone() + if existing: + if existing.source in ("manual", "exception"): + stats["links_kept_manual"] += 1 + # sinon déjà auto, on skip + continue + try: + db.execute(text("""INSERT INTO server_correspondance + (prod_server_id, nonprod_server_id, environment_code, source) + VALUES (:p, :n, :env, 'auto')"""), + {"p": prod_id, "n": np_id, "env": env_label}) + stats["links_created"] += 1 + except Exception as e: + db.rollback() + + db.commit() + return stats + + +def get_servers_for_builder(db, search="", app="", domain="", env=""): + """Retourne tous les serveurs matchant les filtres, avec leurs correspondances existantes. + Exclut les serveurs en stock / obsolete (décommissionnés, EOL).""" + where = ["s.etat NOT IN ('stock','obsolete')"] + params = {} + if search: + where.append("s.hostname ILIKE :s"); params["s"] = f"%{search}%" + if app: + where.append("s.application_name = :app"); params["app"] = app + if domain: + where.append("d.name = :dom"); params["dom"] = domain + if env: + where.append("e.name = :env"); params["env"] = env + wc = " AND ".join(where) + + return db.execute(text(f""" + SELECT s.id, s.hostname, s.application_name, + e.name as env_name, d.name as domain_name, z.name as zone_name, + (SELECT COUNT(*) FROM server_correspondance sc WHERE sc.prod_server_id = s.id) as n_as_prod, + (SELECT COUNT(*) FROM server_correspondance sc WHERE sc.nonprod_server_id = s.id) as n_as_nonprod + FROM servers s + LEFT JOIN domain_environments de ON s.domain_env_id = de.id + LEFT JOIN environments e ON de.environment_id = e.id + LEFT JOIN domains d ON de.domain_id = d.id + LEFT JOIN zones z ON s.zone_id = z.id + WHERE {wc} + ORDER BY e.name, s.hostname + LIMIT 500 + """), params).fetchall() + + +def bulk_create_correspondance(db, prod_ids, nonprod_ids, env_labels, user_id): + """Crée toutes les correspondances prod × non-prod. + env_labels est un dict {nonprod_id: env_label} optionnel.""" + created = 0 + skipped = 0 + for pid in prod_ids: + for npid in nonprod_ids: + if pid == npid: + continue + existing = db.execute(text("""SELECT id FROM server_correspondance + WHERE prod_server_id=:p AND nonprod_server_id=:n"""), + {"p": pid, "n": npid}).fetchone() + if existing: + skipped += 1 + continue + env = (env_labels or {}).get(str(npid)) or (env_labels or {}).get(npid) or "" + db.execute(text("""INSERT INTO server_correspondance + (prod_server_id, nonprod_server_id, environment_code, source, created_by) + VALUES (:p, :n, :env, 'manual', :uid)"""), + {"p": pid, "n": npid, "env": env, "uid": user_id}) + created += 1 + db.commit() + return {"created": created, "skipped": skipped} + + +def get_correspondance_view(db, search="", app="", env=""): + """Vue hiérarchique des correspondances groupées par application. + Exclut les serveurs en stock/obsolete.""" + where = ["s.etat NOT IN ('stock','obsolete')"] + params = {} + if search: + where.append("s.hostname ILIKE :s"); params["s"] = f"%{search}%" + if app: + where.append("s.application_name = :app"); params["app"] = app + if env: + where.append("e.name = :env"); params["env"] = env + else: + # Par défaut : tout ce qui ressemble à prod (Production ou code prod) + where.append("(e.name ILIKE '%production%' OR e.code ILIKE '%prod%')") + + wc = " AND ".join(where) + + prods = db.execute(text(f""" + SELECT s.id, s.hostname, s.application_name, e.name as env_name, + d.name as domain_name + FROM servers s + LEFT JOIN domain_environments de ON s.domain_env_id = de.id + LEFT JOIN environments e ON de.environment_id = e.id + LEFT JOIN domains d ON de.domain_id = d.id + WHERE {wc} + ORDER BY s.application_name, s.hostname + """), params).fetchall() + + results = [] + for p in prods: + corrs = db.execute(text("""SELECT sc.id as corr_id, sc.environment_code, sc.source, sc.note, + ns.id as np_id, ns.hostname as np_hostname, + (SELECT pv.status FROM patch_validation pv WHERE pv.server_id = ns.id + ORDER BY pv.patch_date DESC LIMIT 1) as last_validation_status, + (SELECT pv.validated_at FROM patch_validation pv WHERE pv.server_id = ns.id + ORDER BY pv.patch_date DESC LIMIT 1) as last_validated_at, + (SELECT pv.patch_date FROM patch_validation pv WHERE pv.server_id = ns.id + ORDER BY pv.patch_date DESC LIMIT 1) as last_patch_date + FROM server_correspondance sc + JOIN servers ns ON sc.nonprod_server_id = ns.id + WHERE sc.prod_server_id = :pid + ORDER BY sc.environment_code, ns.hostname"""), {"pid": p.id}).fetchall() + + # Validation status agrégé du prod + # Compter statuts des hors-prod liés + n_total = len(corrs) + n_ok = sum(1 for c in corrs if c.last_validation_status in ("validated_ok", "forced")) + n_pending = sum(1 for c in corrs if c.last_validation_status == "en_attente") + n_ko = sum(1 for c in corrs if c.last_validation_status == "validated_ko") + + if n_total == 0: + global_status = "no_nonprod" # gris + elif n_ko > 0: + global_status = "ko" + elif n_pending > 0: + global_status = "pending" + elif n_ok == n_total: + global_status = "all_ok" + else: + global_status = "partial" + + results.append({ + "prod_id": p.id, "prod_hostname": p.hostname, + "application": p.application_name, "domain": p.domain_name, + "env": p.env_name, + "correspondants": [dict(c._mapping) for c in corrs], + "n_total": n_total, "n_ok": n_ok, "n_pending": n_pending, "n_ko": n_ko, + "global_status": global_status, + }) + + return results + + +def get_server_links(db, server_id): + """Pour un serveur donné, retourne ses liens : + - as_prod : liste des hors-prod qui lui sont liés (si ce serveur est prod) + - as_nonprod : liste des prod auxquels il est lié (si ce serveur est non-prod) + Chaque item : {hostname, env_name, environment_code, source, corr_id} + """ + as_prod = db.execute(text("""SELECT sc.id as corr_id, sc.environment_code, sc.source, + ns.id, ns.hostname, e.name as env_name + FROM server_correspondance sc + JOIN servers ns ON sc.nonprod_server_id = ns.id + LEFT JOIN domain_environments de ON ns.domain_env_id = de.id + LEFT JOIN environments e ON de.environment_id = e.id + WHERE sc.prod_server_id = :id ORDER BY e.name, ns.hostname"""), + {"id": server_id}).fetchall() + + as_nonprod = db.execute(text("""SELECT sc.id as corr_id, sc.environment_code, sc.source, + ps.id, ps.hostname, e.name as env_name + FROM server_correspondance sc + JOIN servers ps ON sc.prod_server_id = ps.id + LEFT JOIN domain_environments de ON ps.domain_env_id = de.id + LEFT JOIN environments e ON de.environment_id = e.id + WHERE sc.nonprod_server_id = :id ORDER BY ps.hostname"""), + {"id": server_id}).fetchall() + + return { + "as_prod": [dict(r._mapping) for r in as_prod], + "as_nonprod": [dict(r._mapping) for r in as_nonprod], + } + + +def get_links_bulk(db, server_ids): + """Pour une liste d'IDs, retourne un dict {server_id: {as_prod: [...], as_nonprod: [...]}}. + Optimisé pour affichage en liste (/servers).""" + if not server_ids: + return {} + placeholders = ",".join(str(i) for i in server_ids if str(i).isdigit()) + if not placeholders: + return {} + result = {sid: {"as_prod": [], "as_nonprod": []} for sid in server_ids} + + # Prod → non-prods + rows = db.execute(text(f"""SELECT sc.prod_server_id as sid, sc.environment_code, + ns.hostname, e.name as env_name + FROM server_correspondance sc + JOIN servers ns ON sc.nonprod_server_id = ns.id + LEFT JOIN domain_environments de ON ns.domain_env_id = de.id + LEFT JOIN environments e ON de.environment_id = e.id + WHERE sc.prod_server_id IN ({placeholders}) + ORDER BY ns.hostname""")).fetchall() + for r in rows: + if r.sid in result: + result[r.sid]["as_prod"].append({"hostname": r.hostname, + "env_name": r.env_name, "environment_code": r.environment_code}) + + # Non-prod → prods + rows = db.execute(text(f"""SELECT sc.nonprod_server_id as sid, + ps.hostname, e.name as env_name + FROM server_correspondance sc + JOIN servers ps ON sc.prod_server_id = ps.id + LEFT JOIN domain_environments de ON ps.domain_env_id = de.id + LEFT JOIN environments e ON de.environment_id = e.id + WHERE sc.nonprod_server_id IN ({placeholders}) + ORDER BY ps.hostname""")).fetchall() + for r in rows: + if r.sid in result: + result[r.sid]["as_nonprod"].append({"hostname": r.hostname, "env_name": r.env_name}) + return result + + +def get_orphan_nonprod(db): + """Retourne les hors-prod sans prod associée (exclut stock/obsolete).""" + rows = db.execute(text(""" + SELECT s.id, s.hostname, s.application_name, e.name as env_name, + d.name as domain_name + FROM servers s + LEFT JOIN domain_environments de ON s.domain_env_id = de.id + LEFT JOIN environments e ON de.environment_id = e.id + LEFT JOIN domains d ON de.domain_id = d.id + WHERE e.name IS NOT NULL AND e.name NOT ILIKE '%production%' + AND s.etat NOT IN ('stock','obsolete') + AND NOT EXISTS (SELECT 1 FROM server_correspondance sc WHERE sc.nonprod_server_id = s.id) + ORDER BY s.application_name, s.hostname + LIMIT 500 + """)).fetchall() + return rows + + +def create_manual_link(db, prod_id, nonprod_id, env_code, note, user_id): + """Crée un lien manuel.""" + existing = db.execute(text("""SELECT id FROM server_correspondance + WHERE prod_server_id=:p AND nonprod_server_id=:n"""), + {"p": prod_id, "n": nonprod_id}).fetchone() + if existing: + db.execute(text("""UPDATE server_correspondance SET source='manual', + environment_code=:env, note=:note, updated_at=NOW() WHERE id=:id"""), + {"env": env_code, "note": note, "id": existing.id}) + else: + db.execute(text("""INSERT INTO server_correspondance (prod_server_id, + nonprod_server_id, environment_code, source, note, created_by) + VALUES (:p, :n, :env, 'manual', :note, :uid)"""), + {"p": prod_id, "n": nonprod_id, "env": env_code, "note": note, "uid": user_id}) + db.commit() + + +def delete_link(db, corr_id): + db.execute(text("DELETE FROM server_correspondance WHERE id=:id"), {"id": corr_id}) + db.commit() + + +# ─── Patch validation ─── + +def create_validation_entry(db, server_id, campaign_id=None, campaign_type="manual"): + """Crée une entrée 'en_attente' après patching.""" + db.execute(text("""INSERT INTO patch_validation (server_id, campaign_id, campaign_type, + patch_date, status) VALUES (:sid, :cid, :ct, NOW(), 'en_attente')"""), + {"sid": server_id, "cid": campaign_id, "ct": campaign_type}) + db.commit() + + +def mark_validation(db, validation_ids, status, validator_contact_id, validator_name, + forced_reason, notes, user_id): + """Marque N validations. status dans (validated_ok, validated_ko, forced).""" + placeholders = ",".join(str(i) for i in validation_ids if str(i).isdigit()) + if not placeholders: + return 0 + db.execute(text(f"""UPDATE patch_validation SET + status=:s, validated_by_contact_id=:cid, validated_by_name=:n, + validated_at=NOW(), marked_by_user_id=:uid, + forced_reason=:fr, notes=:nt, updated_at=NOW() + WHERE id IN ({placeholders})"""), + {"s": status, "cid": validator_contact_id, "n": validator_name, + "uid": user_id, "fr": forced_reason, "nt": notes}) + db.commit() + return len(placeholders.split(",")) + + +def get_pending_validations(db, env="", campaign_id=None, status="en_attente", limit=500): + """Liste les validations filtrées.""" + where = ["1=1"] + params = {} + if status: + where.append("pv.status = :st"); params["st"] = status + if campaign_id: + where.append("pv.campaign_id = :cid"); params["cid"] = campaign_id + if env: + where.append("e.name = :env"); params["env"] = env + wc = " AND ".join(where) + return db.execute(text(f""" + SELECT pv.id, pv.server_id, s.hostname, s.application_name, + e.name as env_name, d.name as domain_name, + pv.campaign_id, pv.campaign_type, pv.patch_date, pv.status, + pv.validated_by_name, pv.validated_at, + pv.forced_reason, pv.notes, + EXTRACT(day FROM NOW() - pv.patch_date) as days_pending + FROM patch_validation pv + JOIN servers s ON pv.server_id = s.id + LEFT JOIN domain_environments de ON s.domain_env_id = de.id + LEFT JOIN environments e ON de.environment_id = e.id + LEFT JOIN domains d ON de.domain_id = d.id + WHERE {wc} + ORDER BY pv.patch_date DESC + LIMIT {int(limit)} + """), params).fetchall() + + +def get_validation_history(db, server_id): + return db.execute(text(""" + SELECT pv.id, pv.campaign_id, pv.campaign_type, pv.patch_date, pv.status, + pv.validated_by_name, pv.validated_at, pv.forced_reason, pv.notes, + u.display_name as marked_by + FROM patch_validation pv + LEFT JOIN users u ON pv.marked_by_user_id = u.id + WHERE pv.server_id = :sid + ORDER BY pv.patch_date DESC + """), {"sid": server_id}).fetchall() + + +def can_patch_prod(db, prod_server_id): + """Retourne (bool, list_of_pending_hostnames) : peut-on patcher le prod ? + OK si tous les hors-prod liés ont validated_ok ou forced sur leur dernier patching.""" + corrs = db.execute(text("""SELECT ns.id, ns.hostname, + (SELECT pv.status FROM patch_validation pv WHERE pv.server_id = ns.id + ORDER BY pv.patch_date DESC LIMIT 1) as last_status + FROM server_correspondance sc JOIN servers ns ON sc.nonprod_server_id = ns.id + WHERE sc.prod_server_id = :pid"""), {"pid": prod_server_id}).fetchall() + if not corrs: + return True, [] # pas de hors-prod = OK (ou selon règle, à ajuster) + blockers = [c.hostname for c in corrs if c.last_status not in ("validated_ok", "forced")] + return (len(blockers) == 0), blockers diff --git a/app/services/quickwin_service.py b/app/services/quickwin_service.py index d5848fc..886d336 100644 --- a/app/services/quickwin_service.py +++ b/app/services/quickwin_service.py @@ -72,7 +72,7 @@ def delete_server_config(db, config_id): def get_eligible_servers(db): - """Serveurs Linux en_production, patch_os_owner=secops""" + """Serveurs Linux production, patch_os_owner=secops""" return db.execute(text(""" SELECT s.id, s.hostname, s.os_family, s.os_version, s.machine_type, s.tier, s.etat, s.patch_excludes, s.is_flux_libre, s.is_podman, @@ -86,7 +86,7 @@ def get_eligible_servers(db): LEFT JOIN environments e ON de.environment_id = e.id LEFT JOIN quickwin_server_config qc ON qc.server_id = s.id WHERE s.os_family = 'linux' - AND s.etat = 'en_production' + AND s.etat = 'production' AND s.patch_os_owner = 'secops' ORDER BY e.display_order, d.display_order, s.hostname """)).fetchall() @@ -140,25 +140,26 @@ def create_run(db, year, week_number, label, user_id, server_ids, notes=""): """), {"y": year, "w": week_number, "l": label, "uid": user_id, "n": notes}).fetchone() run_id = row.id + # Lire les reboot packages globaux (source: app_secrets) + from .secrets_service import get_secret + reboot_pkgs = get_secret(db, "patching_reboot_packages") or DEFAULT_GENERAL_EXCLUDES + for sid in server_ids: srv = db.execute(text(""" - SELECT s.id, e.name as env_name, - COALESCE(qc.general_excludes, '') as ge, - COALESCE(qc.specific_excludes, '') as se + SELECT s.id, e.name as env_name, COALESCE(s.patch_excludes, '') as pe FROM servers s LEFT JOIN domain_environments de ON s.domain_env_id = de.id LEFT JOIN environments e ON de.environment_id = e.id - LEFT JOIN quickwin_server_config qc ON qc.server_id = s.id WHERE s.id = :sid """), {"sid": sid}).fetchone() if not srv: continue branch = "prod" if srv.env_name and "production" in srv.env_name.lower() else "hprod" - ge = srv.ge if srv.ge else DEFAULT_GENERAL_EXCLUDES + # QuickWin : reboot globaux + exclusions iTop du serveur db.execute(text(""" INSERT INTO quickwin_entries (run_id, server_id, branch, general_excludes, specific_excludes) VALUES (:rid, :sid, :br, :ge, :se) - """), {"rid": run_id, "sid": sid, "br": branch, "ge": ge, "se": srv.se}) + """), {"rid": run_id, "sid": sid, "br": branch, "ge": reboot_pkgs, "se": srv.pe}) db.commit() return run_id @@ -183,7 +184,7 @@ def get_available_servers(db, run_id, search="", domains=None, envs=None, zones= LEFT JOIN environments e ON de.environment_id = e.id LEFT JOIN zones z ON s.zone_id = z.id WHERE s.os_family = 'linux' - AND s.etat = 'en_production' + AND s.etat = 'production' AND s.patch_os_owner = 'secops' AND s.id NOT IN (SELECT server_id FROM quickwin_entries WHERE run_id = :rid) ORDER BY d.name, e.name, s.hostname @@ -210,7 +211,7 @@ def get_available_filters(db, run_id): LEFT JOIN environments e ON de.environment_id = e.id LEFT JOIN zones z ON s.zone_id = z.id WHERE s.os_family = 'linux' - AND s.etat = 'en_production' + AND s.etat = 'production' AND s.patch_os_owner = 'secops' AND s.id NOT IN (SELECT server_id FROM quickwin_entries WHERE run_id = :rid) """), {"rid": run_id}).fetchall() @@ -241,29 +242,28 @@ def add_entries_to_run(db, run_id, server_ids, user=None): ), {"rid": run_id}).fetchall()) by = user.get("display_name", user.get("username", "")) if user else "" + from .secrets_service import get_secret + reboot_pkgs = get_secret(db, "patching_reboot_packages") or DEFAULT_GENERAL_EXCLUDES + added = 0 hostnames = [] for sid in server_ids: if sid in existing: continue srv = db.execute(text(""" - SELECT s.id, s.hostname, e.name as env_name, - COALESCE(qc.general_excludes, '') as ge, - COALESCE(qc.specific_excludes, '') as se + SELECT s.id, s.hostname, e.name as env_name, COALESCE(s.patch_excludes, '') as pe FROM servers s LEFT JOIN domain_environments de ON s.domain_env_id = de.id LEFT JOIN environments e ON de.environment_id = e.id - LEFT JOIN quickwin_server_config qc ON qc.server_id = s.id WHERE s.id = :sid """), {"sid": sid}).fetchone() if not srv: continue branch = "prod" if srv.env_name and "production" in srv.env_name.lower() else "hprod" - ge = srv.ge if srv.ge else DEFAULT_GENERAL_EXCLUDES db.execute(text(""" INSERT INTO quickwin_entries (run_id, server_id, branch, general_excludes, specific_excludes) VALUES (:rid, :sid, :br, :ge, :se) - """), {"rid": run_id, "sid": sid, "br": branch, "ge": ge, "se": srv.se}) + """), {"rid": run_id, "sid": sid, "br": branch, "ge": reboot_pkgs, "se": srv.pe}) added += 1 hostnames.append(srv.hostname) if added: @@ -395,6 +395,23 @@ def update_entry_status(db, entry_id, status, patch_output="", packages_count=0, "pp": packages, "rb": reboot_required, "n": notes}) db.commit() + # Création auto d'une entrée patch_validation (en_attente) pour les serveurs patchés + if status == "patched": + row = db.execute(text("SELECT server_id, run_id FROM quickwin_entries WHERE id=:id"), + {"id": entry_id}).fetchone() + if row: + # Éviter les doublons (même run + même server dans la dernière heure) + existing = db.execute(text("""SELECT id FROM patch_validation + WHERE server_id=:sid AND campaign_id=:cid AND campaign_type='quickwin' + AND patch_date >= NOW() - INTERVAL '1 hour'"""), + {"sid": row.server_id, "cid": row.run_id}).fetchone() + if not existing: + db.execute(text("""INSERT INTO patch_validation (server_id, campaign_id, + campaign_type, patch_date, status) + VALUES (:sid, :cid, 'quickwin', NOW(), 'en_attente')"""), + {"sid": row.server_id, "cid": row.run_id}) + db.commit() + def update_entry_field(db, entry_id, field, value): """Mise a jour d'un champ unique (pour inline edit)""" @@ -417,6 +434,31 @@ def can_start_prod(db, run_id): return pending.cnt == 0 +def check_prod_validations(db, run_id): + """Vérifie que chaque prod du run a ses non-prod liés validés (via server_correspondance + patch_validation). + Retourne (ok, blockers) où blockers = liste [{prod_hostname, nonprod_hostname, status}]. + Ignore les prods sans non-prod lié (OK par défaut).""" + rows = db.execute(text(""" + SELECT qe.id as entry_id, ps.id as prod_id, ps.hostname as prod_host + FROM quickwin_entries qe JOIN servers ps ON qe.server_id = ps.id + WHERE qe.run_id = :rid AND qe.branch = 'prod' AND qe.status NOT IN ('excluded','skipped','patched') + """), {"rid": run_id}).fetchall() + + blockers = [] + for r in rows: + corrs = db.execute(text("""SELECT ns.hostname, + (SELECT pv.status FROM patch_validation pv WHERE pv.server_id = ns.id + ORDER BY pv.patch_date DESC LIMIT 1) as last_status + FROM server_correspondance sc JOIN servers ns ON sc.nonprod_server_id = ns.id + WHERE sc.prod_server_id = :pid"""), {"pid": r.prod_id}).fetchall() + for c in corrs: + if c.last_status not in ("validated_ok", "forced"): + blockers.append({"prod_hostname": r.prod_host, + "nonprod_hostname": c.hostname, + "status": c.last_status or "aucun_patching"}) + return (len(blockers) == 0), blockers + + def get_run_stats(db, run_id): return db.execute(text(""" SELECT diff --git a/app/templates/patching_config_exclusions.html b/app/templates/patching_config_exclusions.html new file mode 100644 index 0000000..e91877d --- /dev/null +++ b/app/templates/patching_config_exclusions.html @@ -0,0 +1,225 @@ +{% extends 'base.html' %} +{% block title %}Patching — Config exclusions{% endblock %} +{% block content %} +
Exclusions de packages lors du yum update. Stockées dans iTop (champ patch_excludes) et poussées en temps réel.
| + | Hostname | +Solution applicative | +Domaine | +Env | +Zone | +Tier | +OS | +Exclusions | +Action | +
|---|---|---|---|---|---|---|---|---|---|
| + | {{ s.hostname }} | +{{ (s.application_name or '-')[:40] }} | +{{ s.domain_name or '-' }} | +{{ s.env_name or '-' }} | +{{ s.zone_name or '-' }} | +{{ s.tier or '-' }} | +{{ s.os_family or '-' }} | ++ + | ++ + + | +
Filtrer les serveurs, les désigner comme Prod ou Non-Prod, puis générer les liens en masse.
++ 0 prod × + 0 non-prod = + 0 liens +
+| {% endif %} + | Hostname | +Env | +Application | +Domaine | +Zone | +Liens existants | + {% if can_edit %}Rôle | {% endif %} +
|---|---|---|---|---|---|---|---|
| {% endif %} + | {{ s.hostname }} | ++ {% if s.env_name == 'Production' %}{{ s.env_name }} + {% elif s.env_name %}{{ s.env_name }} + {% else %}-{% endif %} + | +{{ (s.application_name or '-')[:35] }} | +{{ s.domain_name or '-' }} | +{{ s.zone_name or '-' }} | ++ {% if s.n_as_prod %}{{ s.n_as_prod }}P{% endif %} + {% if s.n_as_nonprod %}{{ s.n_as_nonprod }}N{% endif %} + {% if not s.n_as_prod and not s.n_as_nonprod %}-{% endif %} + | + {% if can_edit %} ++ — + | + {% endif %} +
| Aucun serveur pour ces filtres | |||||||
Serveurs patchés en attente de validation par les responsables applicatifs.
+| + | Hostname | +Application | +Env | +Domaine | +Patched | +Jours | +Statut | +Validé par | +Action | +
|---|---|---|---|---|---|---|---|---|---|
| + | {{ v.hostname }} | +{{ (v.application_name or '-')[:30] }} | +{{ v.env_name or '-' }} | +{{ v.domain_name or '-' }} | +{% if v.patch_date %}{{ v.patch_date.strftime('%Y-%m-%d %H:%M') }}{% endif %} | +{{ v.days_pending|int if v.days_pending else '-' }} | ++ {% if v.status == 'en_attente' %}En attente + {% elif v.status == 'validated_ok' %}✓ OK + {% elif v.status == 'validated_ko' %}✗ KO + {% elif v.status == 'forced' %}Forcé + {% endif %} + | +
+ {% if v.validated_by_name %}{{ v.validated_by_name }}{% if v.validated_at %} {{ v.validated_at.strftime('%Y-%m-%d') }} {% endif %}
+ {% else %}—{% endif %}
+ |
+ + Historique + | +
| Aucune validation dans ce filtre | |||||||||
| Patched | +Campagne | +Statut | +Validé par | +Le | +Raison forcée | +Notes | +Marqué par | +
|---|---|---|---|---|---|---|---|
| {{ h.patch_date.strftime('%Y-%m-%d %H:%M') if h.patch_date }} | +{{ h.campaign_type or '-' }} #{{ h.campaign_id or '' }} | ++ {% if h.status == 'en_attente' %}En attente + {% elif h.status == 'validated_ok' %}OK + {% elif h.status == 'validated_ko' %}KO + {% elif h.status == 'forced' %}Forcé + {% endif %} + | +{{ h.validated_by_name or '—' }} | +{{ h.validated_at.strftime('%Y-%m-%d %H:%M') if h.validated_at else '—' }} | +{{ h.forced_reason or '' }} | +{{ h.notes or '' }} | +{{ h.marked_by or '—' }} | +
| Aucun historique | |||||||
Tous les serveurs Linux en_production / secops — exclusions générales par défaut pré-remplies — pas de reboot nécessaire
-Liste globale utilisée par QuickWin pour exclure ces packages du yum update.
kernel* glibc* systemd*).