"""Router QuickWin — Campagnes patching rapide avec exclusions par serveur""" import json from datetime import datetime from sqlalchemy import text from fastapi import APIRouter, Request, Depends, Query, Form from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse, StreamingResponse from fastapi.templating import Jinja2Templates from ..dependencies import get_db, get_current_user, get_user_perms, can_view, can_edit, base_context 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, 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, get_campaign_scope, apply_scope, DEFAULT_GENERAL_EXCLUDES, ) from ..services.quickwin_log_service import get_logs, get_log_stats, clear_logs from ..config import APP_NAME router = APIRouter() templates = Jinja2Templates(directory="app/templates") @router.get("/quickwin", response_class=HTMLResponse) async def quickwin_page(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_view(perms, "campaigns") and not can_view(perms, "quickwin"): return RedirectResponse(url="/dashboard") runs = list_runs(db) configs = get_server_configs(db) now = datetime.now() ctx = base_context(request, db, user) ctx.update({ "app_name": APP_NAME, "runs": runs, "configs": configs, "config_count": len(configs), "current_week": now.isocalendar()[1], "current_year": now.isocalendar()[0], "can_create": can_edit(perms, "campaigns"), "msg": request.query_params.get("msg"), }) return templates.TemplateResponse("quickwin.html", ctx) # -- 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 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_edit(perms, "campaigns") and not can_edit(perms, "quickwin"): return RedirectResponse(url="/dashboard") 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, "reboot_packages": current, "default_packages": DEFAULT_REBOOT_PACKAGES, "msg": request.query_params.get("msg"), }) return templates.TemplateResponse("quickwin_config.html", ctx) @router.post("/quickwin/config/save") async def quickwin_config_save(request: Request, db=Depends(get_db), 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") 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) # -- Runs QuickWin -- @router.post("/quickwin/create") async def quickwin_create(request: Request, db=Depends(get_db), label: str = Form(""), week_number: int = Form(0), year: int = Form(0), server_ids: str = Form(""), notes: str = Form("")): user = get_current_user(request) if not user: return RedirectResponse(url="/login") perms = get_user_perms(db, user) if not can_edit(perms, "campaigns"): return RedirectResponse(url="/quickwin") if not label: label = f"Quick Win S{week_number:02d} {year}" ids = [int(x) for x in server_ids.split(",") if x.strip().isdigit()] if not ids: # Prendre tous les serveurs eligibles (linux, production, secops) eligible = get_eligible_servers(db) ids = [s.id for s in eligible] if not ids: return RedirectResponse(url="/quickwin?msg=no_servers", status_code=303) try: run_id = create_run(db, year, week_number, label, user.get("uid"), ids, notes) return RedirectResponse(url=f"/quickwin/{run_id}", status_code=303) except Exception as e: db.rollback() return RedirectResponse(url=f"/quickwin?msg=error", status_code=303) @router.get("/quickwin/correspondance", response_class=HTMLResponse) async def quickwin_correspondance_redirect(request: Request, db=Depends(get_db)): """Redirige vers la nouvelle correspondance globale.""" return RedirectResponse(url="/patching/correspondance", status_code=303) @router.get("/quickwin/{run_id}", response_class=HTMLResponse) async def quickwin_detail(request: Request, run_id: int, db=Depends(get_db), search: str = Query(""), status: str = Query(""), domain: str = Query(""), prereq_filter: str = Query(""), snap_filter: str = Query(""), hp_page: int = Query(1), p_page: int = Query(1), per_page: int = Query(14), add_search: str = Query(""), add_domains: str = Query(""), add_envs: str = Query(""), add_zones: str = Query(""), show_add: int = Query(0)): user = get_current_user(request) if not user: return RedirectResponse(url="/login") run = get_run(db, run_id) if not run: return RedirectResponse(url="/quickwin") 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") hprod_all = [e for e in entries if e.branch == "hprod" and e.status != "excluded"] prod_all = [e for e in entries if e.branch == "prod" and e.status != "excluded"] # Filtres def apply_filters(lst): filtered = lst if search: filtered = [e for e in filtered if search.lower() in e.hostname.lower()] if status: filtered = [e for e in filtered if e.status == status] if domain: filtered = [e for e in filtered if e.domaine == domain] if prereq_filter == "ok": filtered = [e for e in filtered if e.prereq_ok is True] elif prereq_filter == "ko": filtered = [e for e in filtered if e.prereq_ok is False] elif prereq_filter == "pending": filtered = [e for e in filtered if e.prereq_ok is None] if snap_filter == "ok": filtered = [e for e in filtered if e.snap_done is True] elif snap_filter == "pending": filtered = [e for e in filtered if not e.snap_done] return filtered hprod = apply_filters(hprod_all) prod = apply_filters(prod_all) # Pagination per_page = max(5, min(per_page, 100)) hp_total = len(hprod) hp_total_pages = max(1, (hp_total + per_page - 1) // per_page) hp_page = max(1, min(hp_page, hp_total_pages)) hp_start = (hp_page - 1) * per_page hprod_page = hprod[hp_start:hp_start + per_page] p_total = len(prod) p_total_pages = max(1, (p_total + per_page - 1) // per_page) p_page = max(1, min(p_page, p_total_pages)) p_start = (p_page - 1) * per_page prod_page = prod[p_start:p_start + per_page] # Campaign scope (domains/zones in this run) scope = get_campaign_scope(db, run_id) if run.status in ("draft", "prereq") else {} ctx = base_context(request, db, user) ctx.update({ "app_name": APP_NAME, "run": run, "entries": entries, "stats": stats, "hprod": hprod_page, "prod": prod_page, "hprod_total": hp_total, "prod_total": p_total, "hp_page": hp_page, "hp_total_pages": hp_total_pages, "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, "prereq": prereq_filter, "snap": snap_filter}, "msg": request.query_params.get("msg"), }) return templates.TemplateResponse("quickwin_detail.html", ctx) @router.post("/quickwin/{run_id}/delete") async def quickwin_delete(request: Request, run_id: int, 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"): return RedirectResponse(url="/quickwin") delete_run(db, run_id) return RedirectResponse(url="/quickwin?msg=deleted", status_code=303) @router.post("/quickwin/{run_id}/apply-scope") async def quickwin_apply_scope(request: Request, run_id: int, db=Depends(get_db), scope_domains: str = Form(""), scope_zones: str = Form("")): """Applique le perimetre: domaines + zones selectionnes = inclus, reste = excluded""" 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=f"/quickwin/{run_id}") domains = [d for d in scope_domains.split(",") if d.strip()] or None zones = [z for z in scope_zones.split(",") if z.strip()] or None included, excluded = apply_scope(db, run_id, keep_domains=domains, keep_zones=zones, user=user) return RedirectResponse(url=f"/quickwin/{run_id}?msg=scope_{included}in_{excluded}ex", status_code=303) @router.post("/quickwin/{run_id}/add-servers") async def quickwin_add_servers(request: Request, run_id: int, db=Depends(get_db), server_ids: str = Form("")): """Ajoute des serveurs au run (depuis le panneau d'ajout)""" 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=f"/quickwin/{run_id}") ids = [int(x) for x in server_ids.split(",") if x.strip().isdigit()] added = add_entries_to_run(db, run_id, ids, user=user) if ids else 0 return RedirectResponse(url=f"/quickwin/{run_id}?msg=added_{added}", status_code=303) @router.post("/quickwin/{run_id}/add-filtered") async def quickwin_add_filtered(request: Request, run_id: int, db=Depends(get_db), add_domains: str = Form(""), add_envs: str = Form(""), add_zones: str = Form(""), add_search: str = Form("")): """Ajoute tous les serveurs matchant les filtres multi-select""" 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=f"/quickwin/{run_id}") domains = [d for d in add_domains.split(",") if d.strip()] or None envs = [e for e in add_envs.split(",") if e.strip()] or None zones = [z for z in add_zones.split(",") if z.strip()] or None servers = get_available_servers(db, run_id, search=add_search, domains=domains, envs=envs, zones=zones) ids = [s.id for s in servers] added = add_entries_to_run(db, run_id, ids, user=user) if ids else 0 return RedirectResponse(url=f"/quickwin/{run_id}?msg=added_{added}", status_code=303) @router.post("/quickwin/{run_id}/remove-entries") async def quickwin_remove_entries(request: Request, run_id: int, db=Depends(get_db), entry_ids: str = Form("")): """Supprime des entries du run""" 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=f"/quickwin/{run_id}") ids = [int(x) for x in entry_ids.split(",") if x.strip().isdigit()] removed = remove_entries_from_run(db, run_id, ids, user=user) if ids else 0 return RedirectResponse(url=f"/quickwin/{run_id}?msg=removed_{removed}", status_code=303) # -- Workflow steps -- STEPS = ["draft", "prereq", "snapshot", "patching", "result", "completed"] @router.post("/quickwin/{run_id}/advance") async def quickwin_advance(request: Request, run_id: int, db=Depends(get_db), target: str = Form("")): """Avance le run vers l'etape suivante""" 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=f"/quickwin/{run_id}") if target in STEPS: advance_run_status(db, run_id, target, user=user) return RedirectResponse(url=f"/quickwin/{run_id}?msg=step_{target}", status_code=303) @router.get("/quickwin/{run_id}/prereq-stream") async def quickwin_prereq_stream(request: Request, run_id: int, db=Depends(get_db), branch: str = Query("hprod")): """SSE: streame les resultats prereq serveur par serveur""" user = get_current_user(request) if not user: return StreamingResponse(iter([]), media_type="text/event-stream") def event_generator(): from ..services.quickwin_prereq_service import check_server_prereqs from ..services.quickwin_log_service import log_info, log_success, log_error by = user.get("display_name", user.get("username", "")) if user else "" entries = db.execute(text(""" SELECT qe.id, s.hostname, s.domain_ltd, s.ssh_method, e.code as env_code FROM quickwin_entries qe JOIN servers s ON qe.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 WHERE qe.run_id = :rid AND qe.branch = :br AND qe.status NOT IN ('excluded','skipped') ORDER BY s.hostname """), {"rid": run_id, "br": branch}).fetchall() total = len(entries) log_info(db, run_id, "prereq", f"Lancement check prerequis {branch} ({total} serveurs)", created_by=by) db.commit() yield f"data: {json.dumps({'type':'start','total':total,'branch':branch})}\n\n" ok_count = 0 ko_count = 0 for idx, e in enumerate(entries): yield f"data: {json.dumps({'type':'progress','idx':idx+1,'total':total,'hostname':e.hostname,'status':'checking'})}\n\n" try: r = check_server_prereqs( hostname=e.hostname, db=db, domain_ltd=e.domain_ltd, env_code=e.env_code, ssh_method=e.ssh_method or "ssh_key", ) except Exception as ex: r = {"dns_ok": False, "ssh_ok": False, "satellite_ok": False, "disk_ok": False, "detail": str(ex), "skip": True} prereq_ok = (r.get("dns_ok", False) and r.get("ssh_ok", False) and r.get("satellite_ok", False) and r.get("disk_ok", True)) db.execute(text(""" UPDATE quickwin_entries SET prereq_ok = :ok, prereq_ssh = :ssh, prereq_satellite = :sat, prereq_disk = :disk, prereq_detail = :detail, prereq_date = now(), updated_at = now() WHERE id = :id """), { "id": e.id, "ok": prereq_ok, "ssh": r.get("ssh_ok", False), "sat": r.get("satellite_ok", False), "disk": r.get("disk_ok", True), "detail": r.get("detail", ""), }) detail_str = r.get("detail", "") if prereq_ok: ok_count += 1 log_success(db, run_id, "prereq", f"OK: {e.hostname}", detail=detail_str, entry_id=e.id, hostname=e.hostname) else: ko_count += 1 log_error(db, run_id, "prereq", f"KO: {e.hostname}", detail=detail_str, entry_id=e.id, hostname=e.hostname) db.commit() result_data = { 'type': 'result', 'idx': idx+1, 'total': total, 'hostname': e.hostname, 'ok': prereq_ok, 'dns': r.get('dns_ok', False), 'ssh': r.get('ssh_ok', False), 'sat': r.get('satellite_ok', False), 'disk': r.get('disk_ok', True), 'fqdn': r.get('fqdn', ''), 'detail': detail_str[:200], } yield f"data: {json.dumps(result_data)}\n\n" log_info(db, run_id, "prereq", f"Fin check {branch}: {ok_count} OK, {ko_count} KO sur {total}", created_by=by) db.commit() yield f"data: {json.dumps({'type':'done','ok':ok_count,'ko':ko_count,'total':total})}\n\n" return StreamingResponse(event_generator(), media_type="text/event-stream") @router.post("/quickwin/{run_id}/snapshot/mark") async def quickwin_snapshot_mark(request: Request, run_id: int, db=Depends(get_db), entry_id: int = Form(0), done: str = Form("true")): """Marque un serveur comme snapshot fait/pas fait""" user = get_current_user(request) if not user: return RedirectResponse(url="/login") if entry_id: mark_snapshot(db, entry_id, done == "true") return RedirectResponse(url=f"/quickwin/{run_id}?msg=snap_marked", status_code=303) @router.post("/quickwin/{run_id}/snapshot/mark-all") async def quickwin_snapshot_mark_all(request: Request, run_id: int, db=Depends(get_db), branch: str = Form("hprod")): """Marque tous les serveurs d'une branche comme snapshot fait""" 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=f"/quickwin/{run_id}") mark_all_snapshots(db, run_id, branch, True) return RedirectResponse(url=f"/quickwin/{run_id}?msg=snap_all", status_code=303) @router.get("/quickwin/{run_id}/snapshot-stream") async def quickwin_snapshot_stream(request: Request, run_id: int, db=Depends(get_db), branch: str = Query("hprod")): """SSE: prend les snapshots VM serveur par serveur""" user = get_current_user(request) if not user: return StreamingResponse(iter([]), media_type="text/event-stream") def event_generator(): from ..services.quickwin_snapshot_service import snapshot_server from ..services.quickwin_log_service import log_info, log_success, log_error, log_warn by = user.get("display_name", user.get("username", "")) if user else "" entries = db.execute(text(""" SELECT qe.id, s.hostname, s.machine_type, s.vcenter_vm_name, qe.branch FROM quickwin_entries qe JOIN servers s ON qe.server_id = s.id WHERE qe.run_id = :rid AND qe.branch = :br AND qe.status NOT IN ('excluded','skipped') ORDER BY s.hostname """), {"rid": run_id, "br": branch}).fetchall() total = len(entries) physical_entries = [e for e in entries if e.machine_type == 'physical'] vm_entries = [e for e in entries if e.machine_type != 'physical'] physical_count = len(physical_entries) log_info(db, run_id, "snapshot", f"Lancement snapshots {branch} ({len(vm_entries)} VMs, {physical_count} physiques ignores)", created_by=by) db.commit() yield f"data: {json.dumps({'type':'start','total':total,'vms':len(vm_entries),'physical':physical_count,'branch':branch})}\n\n" # Marquer les physiques comme snap_done (pas de snap necessaire) for e in physical_entries: db.execute(text(""" UPDATE quickwin_entries SET snap_done = true, snap_detail = 'Serveur physique - pas de snapshot VM', updated_at = now() WHERE id = :id """), {"id": e.id}) log_warn(db, run_id, "snapshot", f"Physique ignore: {e.hostname} (verifier backup Commvault)", entry_id=e.id, hostname=e.hostname, created_by=by) if physical_entries: db.commit() ok_count = 0 ko_count = 0 snap_name = f"QW_{run_id}_{branch}_{__import__('datetime').datetime.now().strftime('%Y%m%d_%H%M')}" for idx, e in enumerate(vm_entries): yield f"data: {json.dumps({'type':'progress','idx':idx+1,'total':len(vm_entries),'hostname':e.hostname,'status':'connecting'})}\n\n" r = snapshot_server( hostname=e.hostname, vm_name=e.vcenter_vm_name, branch=e.branch, db=db, snap_name=snap_name, ) snap_ok = r.get("ok", False) detail = r.get("detail", "") vcenter = r.get("vcenter", "") db.execute(text(""" UPDATE quickwin_entries SET snap_done = :done, snap_detail = :detail, updated_at = now() WHERE id = :id """), {"id": e.id, "done": snap_ok, "detail": f"[{vcenter}] {detail}" if vcenter else detail}) if snap_ok: ok_count += 1 log_success(db, run_id, "snapshot", f"OK: {e.hostname} ({vcenter})", detail=detail, entry_id=e.id, hostname=e.hostname, created_by=by) else: ko_count += 1 log_error(db, run_id, "snapshot", f"KO: {e.hostname}", detail=detail, entry_id=e.id, hostname=e.hostname, created_by=by) db.commit() result_data = { 'type': 'result', 'idx': idx+1, 'total': len(vm_entries), 'hostname': e.hostname, 'ok': snap_ok, 'vcenter': vcenter, 'detail': detail[:200], } yield f"data: {json.dumps(result_data)}\n\n" log_info(db, run_id, "snapshot", f"Fin snapshots {branch}: {ok_count} OK, {ko_count} KO sur {len(vm_entries)} VMs", created_by=by) db.commit() yield f"data: {json.dumps({'type':'done','ok':ok_count,'ko':ko_count,'total':len(vm_entries),'physical':physical_count})}\n\n" return StreamingResponse(event_generator(), media_type="text/event-stream") @router.post("/quickwin/{run_id}/build-commands") async def quickwin_build_commands(request: Request, run_id: int, db=Depends(get_db), branch: str = Form("hprod")): """Construit les commandes yum pour une branche""" user = get_current_user(request) if not user: return RedirectResponse(url="/login") commands = build_yum_commands(db, run_id, branch) return RedirectResponse(url=f"/quickwin/{run_id}?msg=commands_{len(commands)}&show_cmds={branch}", status_code=303) @router.get("/api/quickwin/{run_id}/commands/{branch}") async def quickwin_get_commands(request: Request, run_id: int, branch: str, db=Depends(get_db)): """Retourne les commandes generees pour une branche""" user = get_current_user(request) if not user: return JSONResponse({"error": "unauthorized"}, 401) entries = db.execute(text(""" SELECT qe.id, s.hostname, qe.patch_command, s.ssh_method, s.domain_ltd, e.code as env_code FROM quickwin_entries qe JOIN servers s ON qe.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 WHERE qe.run_id = :rid AND qe.branch = :br AND qe.status NOT IN ('excluded','skipped') AND qe.patch_command IS NOT NULL AND qe.patch_command != '' ORDER BY s.hostname """), {"rid": run_id, "br": branch}).fetchall() return JSONResponse([ {"id": e.id, "hostname": e.hostname, "command": e.patch_command} for e in entries ]) @router.get("/quickwin/{run_id}/patch-stream") async def quickwin_patch_stream(request: Request, run_id: int, db=Depends(get_db), branch: str = Query("hprod")): """SSE: execute les commandes yum serveur par serveur via SSH""" user = get_current_user(request) if not user: return StreamingResponse(iter([]), media_type="text/event-stream") def event_generator(): from ..services.quickwin_prereq_service import _get_secret, _resolve_fqdn, _get_ssh_key_path, SSH_TIMEOUT, PSMP_HOST, CYBR_USER, TARGET_USER from ..services.quickwin_log_service import log_info, log_success, log_error import paramiko by = user.get("display_name", user.get("username", "")) if user else "" entries = db.execute(text(""" SELECT qe.id, s.hostname, qe.patch_command, s.ssh_method, s.domain_ltd, e.code as env_code FROM quickwin_entries qe JOIN servers s ON qe.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 WHERE qe.run_id = :rid AND qe.branch = :br AND qe.status NOT IN ('excluded','skipped') AND qe.patch_command IS NOT NULL AND qe.patch_command != '' ORDER BY s.hostname """), {"rid": run_id, "br": branch}).fetchall() total = len(entries) log_info(db, run_id, "patching", f"Lancement patching {branch} ({total} serveurs)", created_by=by) db.commit() yield f"data: {json.dumps({'type':'start','total':total,'branch':branch})}\n\n" ok_count = 0 ko_count = 0 for idx, e in enumerate(entries): yield f"data: {json.dumps({'type':'progress','idx':idx+1,'total':total,'hostname':e.hostname,'command':e.patch_command,'status':'connecting'})}\n\n" # Resoudre FQDN fqdn, _dns_err = _resolve_fqdn(e.hostname, e.domain_ltd, e.env_code) if not fqdn: fqdn = e.hostname # Connexion SSH client = None ssh_err = None try: ssh_method = e.ssh_method or "ssh_key" if ssh_method == "ssh_key": client = paramiko.SSHClient() client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) key_path = _get_ssh_key_path(db) if not key_path: raise Exception("Cle SSH non configuree (ssh_key_file dans Secrets)") client.connect(fqdn, username="root", key_filename=key_path, timeout=SSH_TIMEOUT, allow_agent=False, look_for_keys=False) else: password = _get_secret(db, "ssh_pwd_default_pass") transport = paramiko.Transport((PSMP_HOST, 22)) psmp_user = f"{CYBR_USER}@{TARGET_USER}@{fqdn}" transport.connect(username=psmp_user, password=password or "") transport.set_keepalive(30) client = paramiko.SSHClient() client._transport = transport except Exception as ex: ssh_err = str(ex) if ssh_err or not client: ko_count += 1 detail = f"SSH echoue: {ssh_err or 'connexion impossible'}" db.execute(text(""" UPDATE quickwin_entries SET status='failed', patch_output=:out, patch_date=now(), updated_at=now() WHERE id=:id """), {"id": e.id, "out": detail}) log_error(db, run_id, "patching", f"KO SSH: {e.hostname}", detail=detail, entry_id=e.id, hostname=e.hostname, created_by=by) db.commit() yield f"data: {json.dumps({'type':'result','idx':idx+1,'total':total,'hostname':e.hostname,'ok':False,'detail':detail[:300]})}\n\n" continue # Executer la commande try: yield f"data: {json.dumps({'type':'progress','idx':idx+1,'total':total,'hostname':e.hostname,'status':'executing','command':e.patch_command})}\n\n" stdin, stdout, stderr = client.exec_command(e.patch_command, timeout=600) output = stdout.read().decode('utf-8', errors='replace') err_output = stderr.read().decode('utf-8', errors='replace') exit_code = stdout.channel.recv_exit_status() full_output = output if err_output: full_output += "\n--- STDERR ---\n" + err_output # Parser la sortie yum/dnf pkg_count = 0 packages = "" summary_lines = [] upgrade_keywords = ("Updating", "Installing", "Upgrading", "Mise à niveau", "Installation de", "Mise à jour") result_keywords = ("Updated:", "Installed:", "Upgraded:", "Mis à jour", "Installé", "Mis à niveau") skip_keywords = ("vérification", "métadonnées", "Dernière", "===", "---", "Dépendances résolues", "Paquet ", "Architecture", "Version", "Dépôt", "Taille") for line in output.splitlines(): stripped = line.strip() if not stripped: continue if any(stripped.startswith(k) for k in result_keywords): packages += stripped + "\n" if any(stripped.startswith(k) for k in upgrade_keywords): pkg_count += 1 # Construire un résumé pour le terminal SSE for line in output.splitlines(): stripped = line.strip() if not stripped: continue if any(k in stripped for k in skip_keywords): continue summary_lines.append(stripped) # Garder les lignes utiles (fin = résumé transaction) if len(summary_lines) > 15: summary_lines = summary_lines[-15:] summary = "\n".join(summary_lines) # Rien à faire ? nothing = any(k in output for k in ("Rien à faire", "Nothing to do", "No packages marked")) if nothing and pkg_count == 0: summary = "Rien à faire — tous les paquets sont à jour." reboot = "reboot" in output.lower() or "kernel" in output.lower() if exit_code == 0: ok_count += 1 status = "patched" log_success(db, run_id, "patching", f"OK: {e.hostname} ({pkg_count} paquets)", detail=full_output[:500], entry_id=e.id, hostname=e.hostname, created_by=by) else: ko_count += 1 status = "failed" log_error(db, run_id, "patching", f"KO: {e.hostname} (exit {exit_code})", detail=full_output[:500], entry_id=e.id, hostname=e.hostname, created_by=by) db.execute(text(""" UPDATE quickwin_entries SET status=:st, patch_output=:out, patch_packages_count=:pc, patch_packages=:pp, reboot_required=:rb, patch_date=now(), updated_at=now() WHERE id=:id """), {"id": e.id, "st": status, "out": full_output[:5000], "pc": pkg_count, "pp": packages[:2000], "rb": reboot}) db.commit() yield f"data: {json.dumps({'type':'result','idx':idx+1,'total':total,'hostname':e.hostname,'ok':exit_code==0,'exit_code':exit_code,'packages':pkg_count,'reboot':reboot,'detail':summary[:1000]})}\n\n" except Exception as ex: ko_count += 1 detail = f"Erreur execution: {ex}" db.execute(text(""" UPDATE quickwin_entries SET status='failed', patch_output=:out, patch_date=now(), updated_at=now() WHERE id=:id """), {"id": e.id, "out": detail}) log_error(db, run_id, "patching", f"KO: {e.hostname}", detail=detail, entry_id=e.id, hostname=e.hostname, created_by=by) db.commit() yield f"data: {json.dumps({'type':'result','idx':idx+1,'total':total,'hostname':e.hostname,'ok':False,'detail':detail[:300]})}\n\n" finally: try: client.close() except Exception: pass log_info(db, run_id, "patching", f"Fin patching {branch}: {ok_count} OK, {ko_count} KO sur {total}", created_by=by) db.commit() yield f"data: {json.dumps({'type':'done','ok':ok_count,'ko':ko_count,'total':total})}\n\n" return StreamingResponse(event_generator(), media_type="text/event-stream") @router.post("/quickwin/{run_id}/mark-patched") async def quickwin_mark_patched(request: Request, run_id: int, db=Depends(get_db), entry_id: int = Form(0), patch_status: str = Form("patched"), packages_count: int = Form(0), reboot_required: str = Form("false"), patch_output: str = Form("")): """Marque un serveur comme patche ou echoue""" user = get_current_user(request) if not user: return RedirectResponse(url="/login") if entry_id: from sqlalchemy import text reboot = reboot_required == "true" db.execute(text(""" UPDATE quickwin_entries SET status = :st, patch_packages_count = :pc, reboot_required = :rb, patch_output = :po, patch_date = now(), updated_at = now() WHERE id = :id """), {"id": entry_id, "st": patch_status, "pc": packages_count, "rb": reboot, "po": patch_output}) db.commit() return RedirectResponse(url=f"/quickwin/{run_id}?msg=marked", status_code=303) # -- Logs -- @router.get("/quickwin/{run_id}/logs", response_class=HTMLResponse) async def quickwin_logs_page(request: Request, run_id: int, db=Depends(get_db), level: str = Query(""), step: str = Query(""), hostname: str = Query("")): user = get_current_user(request) if not user: return RedirectResponse(url="/login") run = get_run(db, run_id) if not run: return RedirectResponse(url="/quickwin") logs = get_logs(db, run_id, level=level or None, step=step or None, hostname=hostname or None) log_stats = get_log_stats(db, run_id) stats_dict = {r.level: r.cnt for r in log_stats} ctx = base_context(request, db, user) ctx.update({ "app_name": APP_NAME, "run": run, "logs": logs, "log_stats": stats_dict, "total_logs": sum(stats_dict.values()), "filters": {"level": level, "step": step, "hostname": hostname}, "msg": request.query_params.get("msg"), }) return templates.TemplateResponse("quickwin_logs.html", ctx) @router.post("/quickwin/{run_id}/logs/clear") async def quickwin_logs_clear(request: Request, run_id: int, db=Depends(get_db), clear_step: str = Form("")): 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=f"/quickwin/{run_id}/logs") clear_logs(db, run_id, step=clear_step or None) return RedirectResponse(url=f"/quickwin/{run_id}/logs?msg=cleared", status_code=303) # -- API JSON -- @router.post("/api/quickwin/entry/update") async def quickwin_entry_update(request: Request, db=Depends(get_db)): user = get_current_user(request) if not user: return JSONResponse({"error": "unauthorized"}, 401) body = await request.json() entry_id = body.get("id") field = body.get("field") value = body.get("value") if not entry_id or not field: return JSONResponse({"error": "id and field required"}, 400) ok = update_entry_field(db, entry_id, field, value) return JSONResponse({"ok": ok}) @router.post("/api/quickwin/inject-yum-history") async def quickwin_inject_yum(request: Request, db=Depends(get_db)): user = get_current_user(request) if not user: return JSONResponse({"error": "unauthorized"}, 401) body = await request.json() if not isinstance(body, list): return JSONResponse({"error": "expected list"}, 400) updated, inserted = inject_yum_history(db, body) return JSONResponse({"ok": True, "updated": updated, "inserted": inserted}) @router.get("/api/quickwin/prod-check/{run_id}") async def quickwin_prod_check(request: Request, run_id: int, db=Depends(get_db)): """Verifie si le prod peut demarrer (tous hprod termines)""" user = get_current_user(request) if not user: return JSONResponse({"error": "unauthorized"}, 401) ok = can_start_prod(db, run_id) return JSONResponse({"can_start_prod": ok}) # Correspondance par-run supprimée — utiliser /patching/correspondance (global) @router.get("/quickwin/{run_id}/correspondance") async def quickwin_correspondance_deprecated(request: Request, run_id: int, db=Depends(get_db)): return RedirectResponse(url="/patching/correspondance", status_code=303)