diff --git a/app/main.py b/app/main.py index b683e56..0853329 100644 --- a/app/main.py +++ b/app/main.py @@ -6,7 +6,7 @@ from starlette.middleware.base import BaseHTTPMiddleware from .config import APP_NAME, APP_VERSION from .dependencies import get_current_user, get_user_perms from .database import SessionLocal -from .routers import auth, dashboard, servers, settings, users, campaigns, planning, specifics, audit, contacts, qualys, safe_patching, audit_full, quickwin +from .routers import auth, dashboard, servers, settings, users, campaigns, planning, specifics, audit, contacts, qualys, safe_patching, audit_full, quickwin, referentiel class PermissionsMiddleware(BaseHTTPMiddleware): @@ -44,6 +44,7 @@ app.include_router(qualys.router) app.include_router(safe_patching.router) app.include_router(audit_full.router) app.include_router(quickwin.router) +app.include_router(referentiel.router) @app.get("/") diff --git a/app/routers/quickwin.py b/app/routers/quickwin.py index fa8ef75..18b0564 100644 --- a/app/routers/quickwin.py +++ b/app/routers/quickwin.py @@ -1,8 +1,9 @@ """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 +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 ( @@ -10,8 +11,15 @@ from ..services.quickwin_service import ( 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, + 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, + get_correspondance, get_available_prod_entries, + compute_correspondance, set_prod_pair, clear_all_pairs, DEFAULT_GENERAL_EXCLUDES, ) +from ..services.quickwin_log_service import get_logs, get_log_stats, clear_logs from ..config import APP_NAME router = APIRouter() @@ -108,9 +116,6 @@ async def quickwin_config_save(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="/quickwin/config") if server_id: upsert_server_config(db, server_id, general_excludes.strip(), specific_excludes.strip(), notes.strip()) @@ -123,9 +128,6 @@ async def quickwin_config_delete(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="/quickwin/config") if config_id: delete_server_config(db, config_id) return RedirectResponse(url="/quickwin/config?msg=deleted", status_code=303) @@ -139,9 +141,6 @@ async def quickwin_config_bulk_add(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="/quickwin/config") 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(), "", "") @@ -169,10 +168,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 configures, sinon tous les eligibles - configs = get_server_configs(db) - ids = [c.server_id for c in configs] - if not ids: + # Prendre tous les serveurs eligibles (linux, en_production, secops) eligible = get_eligible_servers(db) ids = [s.id for s in eligible] @@ -187,20 +183,36 @@ async def quickwin_create(request: Request, db=Depends(get_db), 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 correspondance de la derniere campagne active""" + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + runs = list_runs(db) + if not runs: + return RedirectResponse(url="/quickwin?msg=no_run") + return RedirectResponse(url=f"/quickwin/{runs[0].id}/correspondance") + + @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)): + 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") - perms = get_user_perms(db, user) - if not can_view(perms, "campaigns") and not can_view(perms, "quickwin"): - return RedirectResponse(url="/dashboard") run = get_run(db, run_id) if not run: @@ -209,9 +221,11 @@ 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) + 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"] - prod_all = [e for e in entries if e.branch == "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): @@ -222,6 +236,16 @@ async def quickwin_detail(request: Request, run_id: int, db=Depends(get_db), 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) @@ -242,6 +266,9 @@ async def quickwin_detail(request: Request, run_id: int, db=Depends(get_db), 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, @@ -252,7 +279,10 @@ 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, - "filters": {"search": search, "status": status, "domain": domain}, + "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) @@ -270,6 +300,594 @@ async def quickwin_delete(request: Request, run_id: int, db=Depends(get_db)): 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") @@ -277,9 +895,6 @@ async def quickwin_entry_update(request: Request, db=Depends(get_db)): user = get_current_user(request) if not user: return JSONResponse({"error": "unauthorized"}, 401) - perms = get_user_perms(db, user) - if not can_edit(perms, "campaigns") and not can_edit(perms, "quickwin"): - return JSONResponse({"error": "forbidden"}, 403) body = await request.json() entry_id = body.get("id") field = body.get("field") @@ -295,9 +910,6 @@ async def quickwin_inject_yum(request: Request, db=Depends(get_db)): user = get_current_user(request) if not user: return JSONResponse({"error": "unauthorized"}, 401) - perms = get_user_perms(db, user) - if not can_edit(perms, "campaigns"): - return JSONResponse({"error": "forbidden"}, 403) body = await request.json() if not isinstance(body, list): return JSONResponse({"error": "expected list"}, 400) @@ -311,8 +923,90 @@ async def quickwin_prod_check(request: Request, run_id: int, db=Depends(get_db)) user = get_current_user(request) if not user: return JSONResponse({"error": "unauthorized"}, 401) - perms = get_user_perms(db, user) - if not can_view(perms, "campaigns") and not can_view(perms, "quickwin"): - return JSONResponse({"error": "forbidden"}, 403) ok = can_start_prod(db, run_id) return JSONResponse({"can_start_prod": ok}) + + +# ========== CORRESPONDANCE HPROD ↔ PROD ========== + +@router.get("/quickwin/{run_id}/correspondance") +async def quickwin_correspondance_page(request: Request, run_id: int, db=Depends(get_db), + search: str = Query(""), pair_filter: str = Query(""), + env_filter: str = Query(""), domain_filter: str = Query(""), + page: int = Query(1), per_page: int = Query(50)): + 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") + pairs = get_correspondance(db, run_id, search=search or None, + pair_filter=pair_filter or None, env_filter=env_filter or None, + domain_filter=domain_filter or None) + available = get_available_prod_entries(db, run_id) + matched = sum(1 for p in pairs if p["is_matched"]) + unmatched = sum(1 for p in pairs if not p["is_matched"]) + anomalies = sum(1 for p in pairs if p["is_anomaly"]) + + # Get unfiltered totals for KPIs + all_pairs = get_correspondance(db, run_id) if (search or pair_filter or env_filter or domain_filter) else pairs + # Extract domain list for filter dropdown + domains_in_run = sorted(set(p["hprod_domaine"] for p in all_pairs if p["hprod_domaine"])) + total = len(all_pairs) + total_matched = sum(1 for p in all_pairs if p["is_matched"]) + total_unmatched = sum(1 for p in all_pairs if not p["is_matched"]) + total_anomalies = sum(1 for p in all_pairs if p["is_anomaly"]) + + # Pagination + per_page = max(10, min(per_page, 200)) + total_filtered = len(pairs) + total_pages = max(1, (total_filtered + per_page - 1) // per_page) + page = max(1, min(page, total_pages)) + start = (page - 1) * per_page + pairs_page = pairs[start:start + per_page] + + ctx = base_context(request, db, user) + ctx.update({ + "app_name": APP_NAME, "run": run, "pairs": pairs_page, "available": available, + "stats": {"total": total, "matched": total_matched, "unmatched": total_unmatched, "anomalies": total_anomalies}, + "filters": {"search": search, "pair_filter": pair_filter, "env_filter": env_filter, "domain_filter": domain_filter}, + "domains_in_run": domains_in_run, + "page": page, "per_page": per_page, "total_pages": total_pages, "total_filtered": total_filtered, + "msg": request.query_params.get("msg"), + }) + return templates.TemplateResponse("quickwin_correspondance.html", ctx) + + +@router.post("/quickwin/{run_id}/correspondance/auto") +async def quickwin_correspondance_auto(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") and not can_edit(perms, "quickwin"): + return RedirectResponse(url=f"/quickwin/{run_id}/correspondance") + m, u, a = compute_correspondance(db, run_id, user=user) + return RedirectResponse(url=f"/quickwin/{run_id}/correspondance?msg=auto&am={m}&au={u}&aa={a}", status_code=303) + + +@router.post("/quickwin/{run_id}/correspondance/clear-all") +async def quickwin_correspondance_clear(request: Request, run_id: int, db=Depends(get_db)): + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + clear_all_pairs(db, run_id) + return RedirectResponse(url=f"/quickwin/{run_id}/correspondance?msg=cleared", status_code=303) + + +@router.post("/api/quickwin/correspondance/set-pair") +async def quickwin_set_pair_api(request: Request, db=Depends(get_db)): + user = get_current_user(request) + if not user: + return JSONResponse({"error": "unauthorized"}, 401) + body = await request.json() + hprod_id = body.get("hprod_id") + prod_id = body.get("prod_id") # 0 or null to clear + if not hprod_id: + return JSONResponse({"error": "missing hprod_id"}, 400) + set_prod_pair(db, hprod_id, prod_id if prod_id else None) + return JSONResponse({"ok": True}) diff --git a/app/routers/referentiel.py b/app/routers/referentiel.py new file mode 100644 index 0000000..68e99ae --- /dev/null +++ b/app/routers/referentiel.py @@ -0,0 +1,478 @@ +"""Router Referentiel — CRUD domaines, environnements, associations, zones""" +from fastapi import APIRouter, Request, Depends, Form, Query +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 + +templates = Jinja2Templates(directory="app/templates") + +router = APIRouter() + + +# ========================================================= +# PAGE PRINCIPALE (onglets) +# ========================================================= + +@router.get("/referentiel", response_class=HTMLResponse) +def referentiel_page(request: Request, db=Depends(get_db), + tab: str = Query("domains")): + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + perms = get_user_perms(db, user) + if not can_view(perms, "settings"): + return RedirectResponse(url="/dashboard") + + can_modify = can_edit(perms, "settings") + + # Domaines + domains = db.execute(text( + "SELECT id, name, code, description, default_excludes, default_patch_window, " + "default_patch_frequency, is_active, display_order FROM domains ORDER BY display_order, name" + )).fetchall() + + # Environnements + envs = db.execute(text( + "SELECT id, name, code FROM environments ORDER BY id" + )).fetchall() + + # Zones + zones = db.execute(text( + "SELECT id, name, description, is_dmz FROM zones ORDER BY id" + )).fetchall() + + # Associations domain_environments + assocs = db.execute(text(""" + SELECT de.id, d.name as domain_name, d.id as domain_id, + e.name as env_name, e.id as env_id, + de.responsable_nom, de.responsable_email, + de.referent_nom, de.referent_email, + de.patch_window, de.patch_excludes, + de.nb_servers, de.is_active + FROM domain_environments de + JOIN domains d ON de.domain_id = d.id + JOIN environments e ON de.environment_id = e.id + ORDER BY d.display_order, d.name, e.id + """)).fetchall() + + # Domaines DNS (domain_ltd) + dns_domains = db.execute(text( + "SELECT id, name, description, is_active FROM domain_ltd_list ORDER BY name" + )).fetchall() + + # Compteur serveurs par domain_ltd + dns_srv_counts = {} + rows = db.execute(text(""" + SELECT dl.id, COUNT(s.id) as cnt FROM domain_ltd_list dl + LEFT JOIN servers s ON s.domain_ltd = dl.name + GROUP BY dl.id + """)).fetchall() + for r in rows: + dns_srv_counts[r.id] = r.cnt + + # Compteur serveurs par domaine + dom_srv_counts = {} + rows = db.execute(text(""" + SELECT d.id, COUNT(s.id) as cnt FROM domains d + LEFT JOIN domain_environments de ON de.domain_id = d.id + LEFT JOIN servers s ON s.domain_env_id = de.id + GROUP BY d.id + """)).fetchall() + for r in rows: + dom_srv_counts[r.id] = r.cnt + + # Compteur serveurs par env + env_srv_counts = {} + rows = db.execute(text(""" + SELECT e.id, COUNT(s.id) as cnt FROM environments e + LEFT JOIN domain_environments de ON de.environment_id = e.id + LEFT JOIN servers s ON s.domain_env_id = de.id + GROUP BY e.id + """)).fetchall() + for r in rows: + env_srv_counts[r.id] = r.cnt + + # Compteur serveurs par zone + zone_srv_counts = {} + rows = db.execute(text(""" + SELECT z.id, COUNT(s.id) as cnt FROM zones z + LEFT JOIN servers s ON s.zone_id = z.id + GROUP BY z.id + """)).fetchall() + for r in rows: + zone_srv_counts[r.id] = r.cnt + + return templates.TemplateResponse("referentiel.html", { + "request": request, "user": user, "perms": perms, + "can_modify": can_modify, "tab": tab, + "domains": domains, "envs": envs, "zones": zones, "assocs": assocs, + "dns_domains": dns_domains, "dns_srv_counts": dns_srv_counts, + "dom_srv_counts": dom_srv_counts, + "env_srv_counts": env_srv_counts, + "zone_srv_counts": zone_srv_counts, + }) + + +# ========================================================= +# DOMAINES CRUD +# ========================================================= + +@router.post("/referentiel/domains/add") +def domain_add(request: Request, db=Depends(get_db), + name: str = Form(...), code: str = Form(...), + description: str = Form(""), + default_excludes: str = Form(""), + default_patch_window: str = Form(""), + display_order: int = Form(0)): + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + perms = get_user_perms(db, user) + if not can_edit(perms, "settings"): + return RedirectResponse(url="/referentiel?tab=domains") + + db.execute(text(""" + INSERT INTO domains (name, code, description, default_excludes, + default_patch_window, display_order) + VALUES (:name, :code, :desc, :excl, :pw, :ord) + """), {"name": name.strip(), "code": code.strip().upper(), + "desc": description.strip(), "excl": default_excludes.strip(), + "pw": default_patch_window.strip(), "ord": display_order}) + db.commit() + return RedirectResponse(url="/referentiel?tab=domains&msg=added", status_code=303) + + +@router.post("/referentiel/domains/{domain_id}/edit") +def domain_edit(request: Request, domain_id: int, db=Depends(get_db), + name: str = Form(...), code: str = Form(...), + description: str = Form(""), + default_excludes: str = Form(""), + default_patch_window: str = Form(""), + display_order: int = Form(0), + is_active: str = Form("off")): + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + perms = get_user_perms(db, user) + if not can_edit(perms, "settings"): + return RedirectResponse(url="/referentiel?tab=domains") + + active = is_active == "on" + db.execute(text(""" + UPDATE domains SET name=:name, code=:code, description=:desc, + default_excludes=:excl, default_patch_window=:pw, + display_order=:ord, is_active=:act, updated_at=now() + WHERE id=:id + """), {"id": domain_id, "name": name.strip(), "code": code.strip().upper(), + "desc": description.strip(), "excl": default_excludes.strip(), + "pw": default_patch_window.strip(), "ord": display_order, "act": active}) + db.commit() + return RedirectResponse(url="/referentiel?tab=domains&msg=updated", status_code=303) + + +@router.post("/referentiel/domains/{domain_id}/delete") +def domain_delete(request: Request, domain_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, "settings"): + return RedirectResponse(url="/referentiel?tab=domains") + + # Verifier s'il y a des serveurs lies + cnt = db.execute(text(""" + SELECT COUNT(*) as c FROM servers s + JOIN domain_environments de ON s.domain_env_id = de.id + WHERE de.domain_id = :id + """), {"id": domain_id}).fetchone().c + if cnt > 0: + return RedirectResponse( + url=f"/referentiel?tab=domains&msg=nodelete&detail={cnt}", + status_code=303) + + db.execute(text("DELETE FROM domain_environments WHERE domain_id = :id"), {"id": domain_id}) + db.execute(text("DELETE FROM domains WHERE id = :id"), {"id": domain_id}) + db.commit() + return RedirectResponse(url="/referentiel?tab=domains&msg=deleted", status_code=303) + + +# ========================================================= +# ENVIRONNEMENTS CRUD +# ========================================================= + +@router.post("/referentiel/envs/add") +def env_add(request: Request, db=Depends(get_db), + name: str = Form(...), code: 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, "settings"): + return RedirectResponse(url="/referentiel?tab=envs") + + db.execute(text(""" + INSERT INTO environments (name, code) VALUES (:name, :code) + """), {"name": name.strip(), "code": code.strip().upper()}) + db.commit() + return RedirectResponse(url="/referentiel?tab=envs&msg=added", status_code=303) + + +@router.post("/referentiel/envs/{env_id}/edit") +def env_edit(request: Request, env_id: int, db=Depends(get_db), + name: str = Form(...), code: 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, "settings"): + return RedirectResponse(url="/referentiel?tab=envs") + + db.execute(text(""" + UPDATE environments SET name=:name, code=:code WHERE id=:id + """), {"id": env_id, "name": name.strip(), "code": code.strip().upper()}) + db.commit() + return RedirectResponse(url="/referentiel?tab=envs&msg=updated", status_code=303) + + +@router.post("/referentiel/envs/{env_id}/delete") +def env_delete(request: Request, env_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, "settings"): + return RedirectResponse(url="/referentiel?tab=envs") + + cnt = db.execute(text(""" + SELECT COUNT(*) as c FROM servers s + JOIN domain_environments de ON s.domain_env_id = de.id + WHERE de.environment_id = :id + """), {"id": env_id}).fetchone().c + if cnt > 0: + return RedirectResponse( + url=f"/referentiel?tab=envs&msg=nodelete&detail={cnt}", + status_code=303) + + db.execute(text("DELETE FROM domain_environments WHERE environment_id = :id"), {"id": env_id}) + db.execute(text("DELETE FROM environments WHERE id = :id"), {"id": env_id}) + db.commit() + return RedirectResponse(url="/referentiel?tab=envs&msg=deleted", status_code=303) + + +# ========================================================= +# ZONES CRUD +# ========================================================= + +@router.post("/referentiel/zones/add") +def zone_add(request: Request, db=Depends(get_db), + name: str = Form(...), description: str = Form(""), + is_dmz: str = Form("off")): + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + perms = get_user_perms(db, user) + if not can_edit(perms, "settings"): + return RedirectResponse(url="/referentiel?tab=zones") + + db.execute(text(""" + INSERT INTO zones (name, description, is_dmz) VALUES (:name, :desc, :dmz) + """), {"name": name.strip(), "desc": description.strip(), "dmz": is_dmz == "on"}) + db.commit() + return RedirectResponse(url="/referentiel?tab=zones&msg=added", status_code=303) + + +@router.post("/referentiel/zones/{zone_id}/edit") +def zone_edit(request: Request, zone_id: int, db=Depends(get_db), + name: str = Form(...), description: str = Form(""), + is_dmz: str = Form("off")): + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + perms = get_user_perms(db, user) + if not can_edit(perms, "settings"): + return RedirectResponse(url="/referentiel?tab=zones") + + db.execute(text(""" + UPDATE zones SET name=:name, description=:desc, is_dmz=:dmz WHERE id=:id + """), {"id": zone_id, "name": name.strip(), "desc": description.strip(), + "dmz": is_dmz == "on"}) + db.commit() + return RedirectResponse(url="/referentiel?tab=zones&msg=updated", status_code=303) + + +@router.post("/referentiel/zones/{zone_id}/delete") +def zone_delete(request: Request, zone_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, "settings"): + return RedirectResponse(url="/referentiel?tab=zones") + + cnt = db.execute(text( + "SELECT COUNT(*) as c FROM servers WHERE zone_id = :id" + ), {"id": zone_id}).fetchone().c + if cnt > 0: + return RedirectResponse( + url=f"/referentiel?tab=zones&msg=nodelete&detail={cnt}", + status_code=303) + + db.execute(text("DELETE FROM zones WHERE id = :id"), {"id": zone_id}) + db.commit() + return RedirectResponse(url="/referentiel?tab=zones&msg=deleted", status_code=303) + + +# ========================================================= +# ASSOCIATIONS DOMAIN x ENV +# ========================================================= + +@router.post("/referentiel/assocs/add") +def assoc_add(request: Request, db=Depends(get_db), + domain_id: int = Form(...), environment_id: int = Form(...), + responsable_nom: str = Form(""), responsable_email: str = Form(""), + referent_nom: str = Form(""), referent_email: str = Form(""), + patch_window: str = Form(""), patch_excludes: 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, "settings"): + return RedirectResponse(url="/referentiel?tab=assocs") + + existing = db.execute(text( + "SELECT id FROM domain_environments WHERE domain_id=:d AND environment_id=:e" + ), {"d": domain_id, "e": environment_id}).fetchone() + if existing: + return RedirectResponse(url="/referentiel?tab=assocs&msg=exists", status_code=303) + + db.execute(text(""" + INSERT INTO domain_environments (domain_id, environment_id, responsable_nom, + responsable_email, referent_nom, referent_email, patch_window, patch_excludes) + VALUES (:d, :e, :rn, :re, :fn, :fe, :pw, :pe) + """), {"d": domain_id, "e": environment_id, + "rn": responsable_nom.strip(), "re": responsable_email.strip(), + "fn": referent_nom.strip(), "fe": referent_email.strip(), + "pw": patch_window.strip(), "pe": patch_excludes.strip()}) + db.commit() + return RedirectResponse(url="/referentiel?tab=assocs&msg=added", status_code=303) + + +@router.post("/referentiel/assocs/{assoc_id}/edit") +def assoc_edit(request: Request, assoc_id: int, db=Depends(get_db), + responsable_nom: str = Form(""), responsable_email: str = Form(""), + referent_nom: str = Form(""), referent_email: str = Form(""), + patch_window: str = Form(""), patch_excludes: str = Form(""), + is_active: str = Form("off")): + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + perms = get_user_perms(db, user) + if not can_edit(perms, "settings"): + return RedirectResponse(url="/referentiel?tab=assocs") + + db.execute(text(""" + UPDATE domain_environments SET responsable_nom=:rn, responsable_email=:re, + referent_nom=:fn, referent_email=:fe, patch_window=:pw, + patch_excludes=:pe, is_active=:act + WHERE id=:id + """), {"id": assoc_id, + "rn": responsable_nom.strip(), "re": responsable_email.strip(), + "fn": referent_nom.strip(), "fe": referent_email.strip(), + "pw": patch_window.strip(), "pe": patch_excludes.strip(), + "act": is_active == "on"}) + db.commit() + return RedirectResponse(url="/referentiel?tab=assocs&msg=updated", status_code=303) + + +@router.post("/referentiel/assocs/{assoc_id}/delete") +def assoc_delete(request: Request, assoc_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, "settings"): + return RedirectResponse(url="/referentiel?tab=assocs") + + cnt = db.execute(text( + "SELECT COUNT(*) as c FROM servers WHERE domain_env_id = :id" + ), {"id": assoc_id}).fetchone().c + if cnt > 0: + return RedirectResponse( + url=f"/referentiel?tab=assocs&msg=nodelete&detail={cnt}", + status_code=303) + + db.execute(text("DELETE FROM domain_environments WHERE id = :id"), {"id": assoc_id}) + db.commit() + return RedirectResponse(url="/referentiel?tab=assocs&msg=deleted", status_code=303) + + +# ========================================================= +# DOMAINES DNS (domain_ltd) +# ========================================================= + +@router.post("/referentiel/dns/add") +def dns_add(request: Request, db=Depends(get_db), + name: str = Form(...), description: 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, "settings"): + return RedirectResponse(url="/referentiel?tab=dns") + + db.execute(text(""" + INSERT INTO domain_ltd_list (name, description) + VALUES (:name, :desc) + """), {"name": name.strip().lower(), "desc": description.strip()}) + db.commit() + return RedirectResponse(url="/referentiel?tab=dns&msg=added", status_code=303) + + +@router.post("/referentiel/dns/{dns_id}/edit") +def dns_edit(request: Request, dns_id: int, db=Depends(get_db), + name: str = Form(...), description: str = Form(""), + is_active: str = Form("off")): + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + perms = get_user_perms(db, user) + if not can_edit(perms, "settings"): + return RedirectResponse(url="/referentiel?tab=dns") + + old = db.execute(text("SELECT name FROM domain_ltd_list WHERE id=:id"), {"id": dns_id}).fetchone() + new_name = name.strip().lower() + db.execute(text(""" + UPDATE domain_ltd_list SET name=:name, description=:desc, is_active=:act WHERE id=:id + """), {"id": dns_id, "name": new_name, "desc": description.strip(), + "act": is_active == "on"}) + # Propager le renommage sur les serveurs + if old and old.name != new_name: + db.execute(text("UPDATE servers SET domain_ltd=:new WHERE domain_ltd=:old"), + {"old": old.name, "new": new_name}) + db.commit() + return RedirectResponse(url="/referentiel?tab=dns&msg=updated", status_code=303) + + +@router.post("/referentiel/dns/{dns_id}/delete") +def dns_delete(request: Request, dns_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, "settings"): + return RedirectResponse(url="/referentiel?tab=dns") + + row = db.execute(text("SELECT name FROM domain_ltd_list WHERE id=:id"), {"id": dns_id}).fetchone() + if row: + cnt = db.execute(text( + "SELECT COUNT(*) as c FROM servers WHERE domain_ltd = :n" + ), {"n": row.name}).fetchone().c + if cnt > 0: + return RedirectResponse( + url=f"/referentiel?tab=dns&msg=nodelete&detail={cnt}", + status_code=303) + + db.execute(text("DELETE FROM domain_ltd_list WHERE id = :id"), {"id": dns_id}) + db.commit() + return RedirectResponse(url="/referentiel?tab=dns&msg=deleted", status_code=303) diff --git a/app/routers/servers.py b/app/routers/servers.py index 7b5d80b..2257b1c 100644 --- a/app/routers/servers.py +++ b/app/routers/servers.py @@ -2,7 +2,7 @@ from fastapi import APIRouter, Request, Depends, Query, Form from fastapi.responses import HTMLResponse, RedirectResponse, StreamingResponse from fastapi.templating import Jinja2Templates -from ..dependencies import get_db, get_current_user, get_user_perms, can_view, can_edit +from ..dependencies import get_db, get_current_user from ..services.server_service import ( get_server_full, get_server_tags, get_server_ips, list_servers, update_server, get_reference_data @@ -24,9 +24,6 @@ async def servers_list(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, "servers"): - return RedirectResponse(url="/dashboard") filters = {"domain": domain, "env": env, "tier": tier, "etat": etat, "os": os, "owner": owner, "search": search} servers, total = list_servers(db, filters, page, sort=sort, sort_dir=sort_dir) @@ -50,9 +47,6 @@ async def servers_export_csv(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, "servers"): - return RedirectResponse(url="/dashboard") import io, csv filters = {"domain": domain, "env": env, "tier": tier, "etat": etat, "os": os, "owner": owner, "search": search} servers, total = list_servers(db, filters, page=1, per_page=99999, sort="hostname", sort_dir="asc") @@ -78,10 +72,7 @@ async def servers_export_csv(request: Request, db=Depends(get_db), async def server_detail(request: Request, server_id: int, db=Depends(get_db)): user = get_current_user(request) if not user: - return HTMLResponse("

Non autorise

", status_code=401) - perms = get_user_perms(db, user) - if not can_view(perms, "servers"): - return HTMLResponse("

Acces interdit

", status_code=403) + return HTMLResponse("

Non autorise

") s = get_server_full(db, server_id) if not s: return HTMLResponse("

Serveur non trouve

") @@ -96,17 +87,23 @@ async def server_detail(request: Request, server_id: int, db=Depends(get_db)): async def server_edit(request: Request, server_id: int, db=Depends(get_db)): user = get_current_user(request) if not user: - return HTMLResponse("

Non autorise

", status_code=401) - perms = get_user_perms(db, user) - if not can_edit(perms, "servers"): - return HTMLResponse("

Acces interdit

", status_code=403) + return HTMLResponse("

Non autorise

") s = get_server_full(db, server_id) if not s: return HTMLResponse("

Serveur non trouve

") domains, envs = get_reference_data(db) ips = get_server_ips(db, server_id) + from sqlalchemy import text as sqlt + dns_list = db.execute(sqlt( + "SELECT name FROM domain_ltd_list WHERE is_active = true ORDER BY name" + )).fetchall() + zones_list = db.execute(sqlt( + "SELECT name FROM zones ORDER BY name" + )).fetchall() return templates.TemplateResponse("partials/server_edit.html", { - "request": request, "s": s, "domains": domains, "envs": envs, "ips": ips + "request": request, "s": s, "domains": domains, "envs": envs, "ips": ips, + "dns_list": [r.name for r in dns_list], + "zones_list": [r.name for r in zones_list], }) @@ -118,15 +115,12 @@ async def server_update(request: Request, server_id: int, db=Depends(get_db), referent_nom: str = Form(None), mode_operatoire: str = Form(None), commentaire: str = Form(None), ip_reelle: str = Form(None), ip_connexion: str = Form(None), - ssh_method: str = Form(None), + ssh_method: str = Form(None), domain_ltd: str = Form(None), pref_patch_jour: str = Form(None), pref_patch_heure: str = Form(None)): user = get_current_user(request) if not user: - return HTMLResponse("

Non autorise

", status_code=401) - perms = get_user_perms(db, user) - if not can_edit(perms, "servers"): - return HTMLResponse("

Acces interdit

", status_code=403) + return HTMLResponse("

Non autorise

") data = { "domain_code": domain_code, "env_code": env_code, "zone": zone, @@ -134,7 +128,7 @@ async def server_update(request: Request, server_id: int, db=Depends(get_db), "responsable_nom": responsable_nom, "referent_nom": referent_nom, "mode_operatoire": mode_operatoire, "commentaire": commentaire, "ip_reelle": ip_reelle, "ip_connexion": ip_connexion, - "ssh_method": ssh_method, + "ssh_method": ssh_method, "domain_ltd": domain_ltd, "pref_patch_jour": pref_patch_jour, "pref_patch_heure": pref_patch_heure, } update_server(db, server_id, data, user.get("sub")) @@ -154,9 +148,6 @@ async def servers_bulk(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, "servers"): - return RedirectResponse(url="/servers", status_code=303) if not server_ids or not bulk_field or not bulk_value: return RedirectResponse(url="/servers", status_code=303) @@ -207,10 +198,7 @@ async def servers_bulk(request: Request, db=Depends(get_db), async def server_sync_qualys(request: Request, server_id: int, db=Depends(get_db)): user = get_current_user(request) if not user: - return HTMLResponse("

Non autorise

", status_code=401) - perms = get_user_perms(db, user) - if not can_edit(perms, "servers"): - return HTMLResponse("

Acces interdit

", status_code=403) + return HTMLResponse("

Non autorise

") result = sync_server_qualys(db, server_id) s = get_server_full(db, server_id) tags = get_server_tags(db, s.qid) if s else [] diff --git a/app/services/quickwin_log_service.py b/app/services/quickwin_log_service.py new file mode 100644 index 0000000..734fbf5 --- /dev/null +++ b/app/services/quickwin_log_service.py @@ -0,0 +1,73 @@ +"""Service logs QuickWin — journalisation des actions et erreurs par campagne""" +from sqlalchemy import text + + +def log_entry(db, run_id, step, level, message, detail=None, + entry_id=None, hostname=None, created_by=None): + """Ajoute une ligne de log pour un run QuickWin. + level: info, warn, error, success""" + db.execute(text(""" + INSERT INTO quickwin_logs (run_id, entry_id, hostname, step, level, message, detail, created_by) + VALUES (:rid, :eid, :host, :step, :lvl, :msg, :det, :by) + """), { + "rid": run_id, "eid": entry_id, "host": hostname, + "step": step, "lvl": level, "msg": message, + "det": detail, "by": created_by, + }) + + +def log_info(db, run_id, step, message, **kwargs): + log_entry(db, run_id, step, "info", message, **kwargs) + + +def log_warn(db, run_id, step, message, **kwargs): + log_entry(db, run_id, step, "warn", message, **kwargs) + + +def log_error(db, run_id, step, message, **kwargs): + log_entry(db, run_id, step, "error", message, **kwargs) + + +def log_success(db, run_id, step, message, **kwargs): + log_entry(db, run_id, step, "success", message, **kwargs) + + +def get_logs(db, run_id, level=None, step=None, hostname=None, limit=500): + """Recupere les logs d'un run avec filtres optionnels.""" + where = ["run_id = :rid"] + params = {"rid": run_id} + if level: + where.append("level = :lvl") + params["lvl"] = level + if step: + where.append("step = :step") + params["step"] = step + if hostname: + where.append("hostname ILIKE :host") + params["host"] = f"%{hostname}%" + params["lim"] = limit + return db.execute(text(f""" + SELECT * FROM quickwin_logs + WHERE {' AND '.join(where)} + ORDER BY created_at DESC + LIMIT :lim + """), params).fetchall() + + +def get_log_stats(db, run_id): + """Compteurs par level pour un run.""" + return db.execute(text(""" + SELECT level, COUNT(*) as cnt + FROM quickwin_logs WHERE run_id = :rid + GROUP BY level ORDER BY level + """), {"rid": run_id}).fetchall() + + +def clear_logs(db, run_id, step=None): + """Supprime les logs d'un run (optionnel: seulement une etape).""" + if step: + db.execute(text("DELETE FROM quickwin_logs WHERE run_id = :rid AND step = :step"), + {"rid": run_id, "step": step}) + else: + db.execute(text("DELETE FROM quickwin_logs WHERE run_id = :rid"), {"rid": run_id}) + db.commit() diff --git a/app/services/quickwin_prereq_service.py b/app/services/quickwin_prereq_service.py new file mode 100644 index 0000000..6c8f0ae --- /dev/null +++ b/app/services/quickwin_prereq_service.py @@ -0,0 +1,387 @@ +"""Service prereq QuickWin — resolution DNS, SSH, disque, satellite +Adapte au contexte SANEF : PSMP CyberArk + SSH key selon methode serveur""" +import socket +import logging +import os + +log = logging.getLogger("quickwin.prereq") + +try: + import paramiko + PARAMIKO_OK = True +except ImportError: + PARAMIKO_OK = False + log.warning("paramiko non disponible — checks SSH impossibles") + +# --- Constantes --- +DOMP = "sanef.groupe" # domaine prod/preprod/dev +DOMR = "sanef-rec.fr" # domaine recette/test + +PSMP_HOST = "psmp.sanef.fr" +CYBR_USER = "CYBP01336" +TARGET_USER = "cybsecope" + +SSH_KEY_FILE_DEFAULT = "/opt/patchcenter/keys/id_rsa_cybglobal.pem" +SSH_TIMEOUT = 15 + +# Seuils disque (% utilise) +DISK_MAX_PCT = 90 # >90% = KO + +# Banniere CyberArk a filtrer +BANNER_FILTERS = [ + "GROUPE SANEF", "propriete du Groupe", "accederait", "emprisonnement", + "Article 323", "code penal", "Authorized uses only", "CyberArk", + "This session", "session is being", +] + + +def _get_settings(db): + """Charge les settings utiles depuis la table settings""" + from sqlalchemy import text + rows = db.execute(text( + "SELECT key, value FROM settings WHERE key IN " + "('psmp_host','default_ssh_timeout','disk_min_root_mb')" + )).fetchall() + return {r.key: r.value for r in rows} + + +def _get_secret(db, key): + """Recupere un secret dechiffre depuis app_secrets""" + try: + from ..services.secrets_service import get_secret + return get_secret(db, key) + except Exception: + return None + + +# ========================================================= +# 1. RESOLUTION DNS +# ========================================================= + +def _detect_env(hostname): + """Detecte l'environnement par la 2e lettre du hostname (convention SANEF) + p=prod, i=preprod, r=recette, v/t=test, d=dev""" + if len(hostname) < 2: + return "unknown" + c = hostname[1].lower() + if c == "p": + return "prod" + elif c == "i": + return "preprod" + elif c == "r": + return "recette" + elif c in ("v", "t"): + return "test" + elif c == "d": + return "dev" + return "unknown" + + +def _resolve_fqdn(hostname, domain_ltd=None, env_code=None): + """Resout le hostname en FQDN testable. + Retourne (fqdn, None) ou (None, error_msg). + + Logique: + - Prod/Preprod/Dev: domp d'abord, puis domr + - Recette/Test: domr d'abord, puis domp + - Utilise domain_ltd si dispo, sinon detection par hostname + """ + if "." in hostname: + # Deja un FQDN + if _dns_resolves(hostname): + return hostname, None + return None, f"FQDN {hostname} non resolvable" + + # Determiner l'ordre des domaines + env = _detect_env(hostname) + if env_code: + ec = env_code.upper() + if ec in ("PRD", "PPR", "DEV"): + env = "prod" + elif ec in ("REC",): + env = "recette" + elif ec in ("TES", "TS1", "TS2"): + env = "test" + + if env in ("prod", "preprod", "dev"): + domains_order = [DOMP, DOMR] + elif env in ("recette", "test"): + domains_order = [DOMR, DOMP] + else: + # Fallback: utiliser domain_ltd si dispo + if domain_ltd and domain_ltd.strip(): + alt = DOMR if domain_ltd.strip() == DOMP else DOMP + domains_order = [domain_ltd.strip(), alt] + else: + domains_order = [DOMP, DOMR] + + # Tenter resolution dans l'ordre + for dom in domains_order: + fqdn = f"{hostname}.{dom}" + if _dns_resolves(fqdn): + return fqdn, None + + return None, f"DNS KO: {hostname} non resolu ({'/'.join(domains_order)})" + + +def _dns_resolves(fqdn): + """Verifie si un FQDN se resout en IP""" + try: + socket.getaddrinfo(fqdn, 22, socket.AF_INET, socket.SOCK_STREAM) + return True + except (socket.gaierror, socket.herror, OSError): + return False + + +# ========================================================= +# 2. TEST SSH +# ========================================================= + +def _get_ssh_key_path(db=None): + """Retourne le chemin de la cle SSH. Cherche d'abord dans app_secrets (ssh_key_file), + puis fallback sur le chemin par defaut.""" + if db: + secret_path = _get_secret(db, "ssh_key_file") + if secret_path and secret_path.strip() and os.path.exists(secret_path.strip()): + return secret_path.strip() + if os.path.exists(SSH_KEY_FILE_DEFAULT): + return SSH_KEY_FILE_DEFAULT + return None + + +def _load_ssh_key(db=None): + """Charge la cle SSH privee depuis le chemin configure en base ou par defaut""" + key_path = _get_ssh_key_path(db) + if not key_path: + return None + for cls in [paramiko.RSAKey, paramiko.Ed25519Key, paramiko.ECDSAKey]: + try: + return cls.from_private_key_file(key_path) + except Exception: + continue + return None + + +def _ssh_via_psmp(fqdn, password): + """Connexion SSH via PSMP CyberArk (interactive auth). + Username format: CYBP01336@cybsecope@fqdn""" + if not password: + return None, "PSMP: pas de mot de passe configure" + try: + username = f"{CYBR_USER}@{TARGET_USER}@{fqdn}" + transport = paramiko.Transport((PSMP_HOST, 22)) + transport.connect() + + def handler(title, instructions, prompt_list): + return [password] * len(prompt_list) + + transport.auth_interactive(username, handler) + client = paramiko.SSHClient() + client._transport = transport + return client, None + except Exception as e: + return None, f"PSMP: {str(e)[:120]}" + + +def _ssh_via_key(fqdn, ssh_user, key): + """Connexion SSH directe par cle""" + if not key: + return None, "SSH key: cle non trouvee" + if not ssh_user: + return None, "SSH key: user non configure" + try: + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + client.connect(fqdn, port=22, username=ssh_user, pkey=key, + timeout=SSH_TIMEOUT, look_for_keys=False, allow_agent=False) + return client, None + except Exception as e: + return None, f"SSH key: {str(e)[:120]}" + + +def _ssh_connect(fqdn, ssh_method, db): + """Connecte au serveur selon la methode (ssh_psmp ou ssh_key). + Retourne (client, error_msg)""" + if not PARAMIKO_OK: + return None, "paramiko non installe" + + if ssh_method == "ssh_psmp": + # PSMP: password depuis app_secrets + password = _get_secret(db, "ssh_pwd_default_pass") + client, err = _ssh_via_psmp(fqdn, password) + if client: + return client, None + # Fallback: tenter par cle + key = _load_ssh_key(db) + ssh_user = _get_secret(db, "ssh_pwd_default_user") or TARGET_USER + client2, err2 = _ssh_via_key(fqdn, ssh_user, key) + if client2: + return client2, None + return None, err # retourner l'erreur PSMP originale + else: + # ssh_key: user depuis secrets, cle depuis fichier + ssh_user = _get_secret(db, "ssh_pwd_default_user") or TARGET_USER + key = _load_ssh_key(db) + client, err = _ssh_via_key(fqdn, ssh_user, key) + if client: + return client, None + # Fallback: tenter via PSMP + password = _get_secret(db, "ssh_pwd_default_pass") + if password: + client2, err2 = _ssh_via_psmp(fqdn, password) + if client2: + return client2, None + return None, err + + +def _ssh_exec(client, cmd, timeout=12): + """Execute une commande via SSH et retourne (stdout, stderr, returncode). + Filtre les bannieres CyberArk.""" + try: + chan = client._transport.open_session() + chan.settimeout(timeout) + chan.exec_command(cmd) + out = b"" + err = b"" + while True: + try: + chunk = chan.recv(8192) + if not chunk: + break + out += chunk + except Exception: + break + try: + err = chan.recv_stderr(8192) + except Exception: + pass + rc = chan.recv_exit_status() + chan.close() + # Filtrer bannieres + out_str = out.decode("utf-8", errors="replace") + lines = [l for l in out_str.splitlines() if not any(b in l for b in BANNER_FILTERS)] + return "\n".join(lines), err.decode("utf-8", errors="replace"), rc + except Exception as e: + return "", str(e), -1 + + +# ========================================================= +# 3. TEST ESPACE DISQUE +# ========================================================= + +def _check_disk(client): + """Verifie l'espace disque / et /var via sudo df. + Retourne (ok, detail_msg)""" + out, err, rc = _ssh_exec(client, "sudo df / /var --output=target,pcent 2>/dev/null | tail -n +2") + if rc != 0 or not out.strip(): + return True, "Disque: non verifie (df echoue)" + + ok = True + parts = [] + for line in out.strip().splitlines(): + tokens = line.split() + if len(tokens) >= 2: + mount = tokens[0] + pct_str = tokens[-1].replace("%", "").strip() + if pct_str.isdigit(): + pct = int(pct_str) + if pct >= DISK_MAX_PCT: + ok = False + parts.append(f"{mount}={pct}% KO") + else: + parts.append(f"{mount}={pct}%") + + if not parts: + return True, "Disque: non verifie" + return ok, "Disque: " + ", ".join(parts) + + +# ========================================================= +# 4. TEST SATELLITE / YUM REPOS +# ========================================================= + +def _check_satellite(client): + """Verifie l'enregistrement Satellite et les repos YUM. + Retourne (ok, detail_msg)""" + # Tenter subscription-manager d'abord + out, err, rc = _ssh_exec(client, "sudo subscription-manager status 2>/dev/null | head -5") + if rc == 0 and "Current" in out: + return True, "Satellite: OK (subscription-manager)" + + # Fallback: yum repolist + out2, err2, rc2 = _ssh_exec(client, "sudo yum repolist 2>/dev/null | tail -1") + if rc2 == 0 and out2.strip(): + line = out2.strip() + # Si "repolist: 0" => pas de repos + if "repolist: 0" in line.lower(): + return False, "Satellite: KO (0 repos)" + return True, f"Satellite: OK ({line[:60]})" + + return False, "Satellite: KO (pas de repos ni subscription)" + + +# ========================================================= +# ORCHESTRATEUR PRINCIPAL +# ========================================================= + +def check_server_prereqs(hostname, db, domain_ltd=None, env_code=None, + ssh_method="ssh_key"): + """Verification complete des prerequis d'un serveur QuickWin. + + Etapes: + 1. Resolution DNS (domp/domr selon env) + 2. Test SSH (PSMP ou key selon ssh_method) + 3. Espace disque (sudo df) + 4. Satellite/YUM + + Retourne dict: + dns_ok, ssh_ok, disk_ok, satellite_ok, + fqdn, detail, skip (True si serveur a ignorer) + """ + result = { + "dns_ok": False, "ssh_ok": False, "disk_ok": False, "satellite_ok": False, + "fqdn": "", "detail": "", "skip": False, + } + detail_parts = [] + + # 1. Resolution DNS + fqdn, dns_err = _resolve_fqdn(hostname, domain_ltd, env_code) + if not fqdn: + result["detail"] = dns_err + result["skip"] = True + log.warning(f"[{hostname}] {dns_err}") + return result + result["dns_ok"] = True + result["fqdn"] = fqdn + detail_parts.append(f"DNS: OK ({fqdn})") + + # 2. Test SSH + client, ssh_err = _ssh_connect(fqdn, ssh_method, db) + if not client: + detail_parts.append(f"SSH: KO ({ssh_err})") + result["detail"] = " | ".join(detail_parts) + result["skip"] = True + log.warning(f"[{hostname}] SSH KO: {ssh_err}") + return result + result["ssh_ok"] = True + method_label = "PSMP" if ssh_method == "ssh_psmp" else "key" + detail_parts.append(f"SSH: OK ({method_label})") + + try: + # 3. Espace disque + disk_ok, disk_detail = _check_disk(client) + result["disk_ok"] = disk_ok + detail_parts.append(disk_detail) + + # 4. Satellite + sat_ok, sat_detail = _check_satellite(client) + result["satellite_ok"] = sat_ok + detail_parts.append(sat_detail) + finally: + try: + client.close() + except Exception: + pass + + result["detail"] = " | ".join(detail_parts) + return result diff --git a/app/services/quickwin_service.py b/app/services/quickwin_service.py index 115e5d0..d5848fc 100644 --- a/app/services/quickwin_service.py +++ b/app/services/quickwin_service.py @@ -1,6 +1,7 @@ """Service QuickWin — gestion des campagnes + exclusions par serveur""" import json from sqlalchemy import text +from .quickwin_log_service import log_info, log_warn, log_error, log_success # Exclusions generales par defaut (reboot packages + middleware/apps) DEFAULT_GENERAL_EXCLUDES = ( @@ -118,7 +119,7 @@ def get_run(db, run_id): def get_run_entries(db, run_id): return db.execute(text(""" - SELECT qe.*, s.hostname, s.os_family, s.machine_type, + SELECT qe.*, s.hostname, s.fqdn, s.os_family, s.machine_type, d.name as domaine, e.name as environnement FROM quickwin_entries qe JOIN servers s ON qe.server_id = s.id @@ -169,6 +170,218 @@ def delete_run(db, run_id): db.commit() +def get_available_servers(db, run_id, search="", domains=None, envs=None, zones=None): + """Serveurs eligibles PAS encore dans ce run, avec multi-filtres. + domains/envs/zones: listes de noms (multi-select).""" + rows = db.execute(text(""" + SELECT s.id, s.hostname, s.os_family, s.machine_type, s.domain_ltd, + d.name as domaine, e.name as environnement, e.code as env_code, + z.name as zone + 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 s.os_family = 'linux' + AND s.etat = 'en_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 + """), {"rid": run_id}).fetchall() + filtered = rows + if search: + filtered = [r for r in filtered if search.lower() in r.hostname.lower()] + if domains: + filtered = [r for r in filtered if (r.domaine or '') in domains] + if envs: + filtered = [r for r in filtered if (r.environnement or '') in envs] + if zones: + filtered = [r for r in filtered if (r.zone or '') in zones] + return filtered + + +def get_available_filters(db, run_id): + """Retourne les domaines, envs et zones disponibles (avec compteurs).""" + rows = db.execute(text(""" + SELECT d.name as domaine, e.name as environnement, z.name as zone + 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 s.os_family = 'linux' + AND s.etat = 'en_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() + domains = sorted(set(r.domaine for r in rows if r.domaine)) + envs = sorted(set(r.environnement for r in rows if r.environnement)) + zones = sorted(set(r.zone for r in rows if r.zone)) + # Compteurs + dom_counts = {} + for r in rows: + k = r.domaine or '?' + dom_counts[k] = dom_counts.get(k, 0) + 1 + env_counts = {} + for r in rows: + k = r.environnement or '?' + env_counts[k] = env_counts.get(k, 0) + 1 + zone_counts = {} + for r in rows: + k = r.zone or '?' + zone_counts[k] = zone_counts.get(k, 0) + 1 + return domains, envs, zones, dom_counts, env_counts, zone_counts + + +def add_entries_to_run(db, run_id, server_ids, user=None): + """Ajoute des serveurs a un run existant. Determine auto hprod/prod. + Retourne le nombre de serveurs ajoutes.""" + existing = set(r.server_id for r in db.execute(text( + "SELECT server_id FROM quickwin_entries WHERE run_id = :rid" + ), {"rid": run_id}).fetchall()) + + by = user.get("display_name", user.get("username", "")) if user else "" + 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 + 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}) + added += 1 + hostnames.append(srv.hostname) + if added: + log_info(db, run_id, "servers", f"{added} serveur(s) ajoute(s)", + detail=", ".join(hostnames[:20]) + ("..." if len(hostnames) > 20 else ""), + created_by=by) + db.commit() + return added + + +def remove_entries_from_run(db, run_id, entry_ids, user=None): + """Supprime des entries d'un run. Retourne le nombre de suppressions.""" + if not entry_ids: + return 0 + by = user.get("display_name", user.get("username", "")) if user else "" + # Log hostnames avant suppression + rows = db.execute(text(""" + SELECT s.hostname FROM quickwin_entries qe + JOIN servers s ON qe.server_id = s.id + WHERE qe.run_id = :rid AND qe.id = ANY(:ids) + """), {"rid": run_id, "ids": entry_ids}).fetchall() + hostnames = [r.hostname for r in rows] + result = db.execute(text( + "DELETE FROM quickwin_entries WHERE run_id = :rid AND id = ANY(:ids)" + ), {"rid": run_id, "ids": entry_ids}) + removed = result.rowcount + if removed: + log_warn(db, run_id, "servers", f"{removed} serveur(s) supprime(s)", + detail=", ".join(hostnames[:20]) + ("..." if len(hostnames) > 20 else ""), + created_by=by) + db.commit() + return removed + + +def get_campaign_scope(db, run_id): + """Retourne domaines, envs et zones presents dans le run avec compteurs.""" + rows = db.execute(text(""" + SELECT d.name as domaine, e.name as environnement, z.name as zone, qe.status + 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 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 qe.run_id = :rid + """), {"rid": run_id}).fetchall() + dom_counts = {} + env_counts = {} + zone_counts = {} + dom_active = {} # non-excluded count + zone_active = {} # non-excluded count + for r in rows: + d = r.domaine or '?' + e = r.environnement or '?' + z = r.zone or '?' + dom_counts[d] = dom_counts.get(d, 0) + 1 + env_counts[e] = env_counts.get(e, 0) + 1 + zone_counts[z] = zone_counts.get(z, 0) + 1 + if r.status != 'excluded': + dom_active[d] = dom_active.get(d, 0) + 1 + zone_active[z] = zone_active.get(z, 0) + 1 + domains = sorted(k for k in dom_counts if k != '?') + envs = sorted(k for k in env_counts if k != '?') + zones = sorted(k for k in zone_counts if k != '?') + return { + "domains": domains, "envs": envs, "zones": zones, + "dom_counts": dom_counts, "env_counts": env_counts, "zone_counts": zone_counts, + "dom_active": dom_active, "zone_active": zone_active, + } + + +def apply_scope(db, run_id, keep_domains=None, keep_zones=None, user=None): + """Applique le perimetre: serveurs hors domaines/zones selectionnes -> excluded. + Serveurs dans le perimetre -> pending (si etaient excluded).""" + by = user.get("display_name", user.get("username", "")) if user else "" + + # Recuperer toutes les entries avec leur domaine/zone + entries = db.execute(text(""" + SELECT qe.id, qe.status, s.hostname, d.name as domaine, z.name as zone + 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 domains d ON de.domain_id = d.id + LEFT JOIN zones z ON s.zone_id = z.id + WHERE qe.run_id = :rid + """), {"rid": run_id}).fetchall() + + included = 0 + excluded = 0 + for e in entries: + in_scope = True + if keep_domains and (e.domaine or '') not in keep_domains: + in_scope = False + if keep_zones and (e.zone or '') not in keep_zones: + in_scope = False + + if in_scope and e.status == 'excluded': + db.execute(text("UPDATE quickwin_entries SET status='pending', updated_at=now() WHERE id=:id"), + {"id": e.id}) + included += 1 + elif not in_scope and e.status != 'excluded': + db.execute(text("UPDATE quickwin_entries SET status='excluded', updated_at=now() WHERE id=:id"), + {"id": e.id}) + excluded += 1 + + scope_desc = [] + if keep_domains: + scope_desc.append(f"domaines: {', '.join(keep_domains)}") + if keep_zones: + scope_desc.append(f"zones: {', '.join(keep_zones)}") + log_info(db, run_id, "scope", + f"Perimetre applique: {included} inclus, {excluded} exclus", + detail=" | ".join(scope_desc) if scope_desc else "Tous", + created_by=by) + db.commit() + return included, excluded + + def update_entry_status(db, entry_id, status, patch_output="", packages_count=0, packages="", reboot_required=False, notes=""): db.execute(text(""" @@ -207,21 +420,192 @@ def can_start_prod(db, run_id): def get_run_stats(db, run_id): return db.execute(text(""" SELECT - COUNT(*) as total, - COUNT(*) FILTER (WHERE branch = 'hprod') as hprod_total, - COUNT(*) FILTER (WHERE branch = 'prod') as prod_total, + COUNT(*) FILTER (WHERE status != 'excluded') as total, + COUNT(*) FILTER (WHERE branch = 'hprod' AND status != 'excluded') as hprod_total, + COUNT(*) FILTER (WHERE branch = 'prod' AND status != 'excluded') as prod_total, COUNT(*) FILTER (WHERE status = 'patched') as patched, COUNT(*) FILTER (WHERE status = 'failed') as failed, COUNT(*) FILTER (WHERE status = 'pending') as pending, + COUNT(*) FILTER (WHERE status = 'pending' AND branch = 'hprod') as hprod_pending, COUNT(*) FILTER (WHERE status = 'excluded') as excluded, COUNT(*) FILTER (WHERE status = 'skipped') as skipped, COUNT(*) FILTER (WHERE branch = 'hprod' AND status = 'patched') as hprod_patched, COUNT(*) FILTER (WHERE branch = 'prod' AND status = 'patched') as prod_patched, - COUNT(*) FILTER (WHERE reboot_required) as reboot_count + COUNT(*) FILTER (WHERE reboot_required AND status != 'excluded') as reboot_count FROM quickwin_entries WHERE run_id = :rid """), {"rid": run_id}).fetchone() +def advance_run_status(db, run_id, target_status, user=None): + """Avance le statut du run vers l'etape suivante""" + VALID_TRANSITIONS = { + "draft": "prereq", + "prereq": "snapshot", + "snapshot": "patching", + "patching": "result", + "result": "completed", + } + run = db.execute(text("SELECT status FROM quickwin_runs WHERE id = :id"), + {"id": run_id}).fetchone() + if not run: + return False + by = user.get("display_name", user.get("username", "")) if user else "" + if target_status == "draft": + db.execute(text("UPDATE quickwin_runs SET status = :st, updated_at = now() WHERE id = :id"), + {"st": target_status, "id": run_id}) + log_info(db, run_id, "workflow", f"Retour a l'etape brouillon", created_by=by) + db.commit() + return True + expected = VALID_TRANSITIONS.get(run.status) + if expected != target_status: + log_warn(db, run_id, "workflow", + f"Transition refusee: {run.status} -> {target_status}", created_by=by) + db.commit() + return False + db.execute(text("UPDATE quickwin_runs SET status = :st, updated_at = now() WHERE id = :id"), + {"st": target_status, "id": run_id}) + log_info(db, run_id, "workflow", f"Passage a l'etape: {target_status}", created_by=by) + db.commit() + return True + + +def get_step_stats(db, run_id, branch=None): + """Stats par etape pour un run (optionnel: filtre par branch)""" + where = "run_id = :rid" + params = {"rid": run_id} + if branch: + where += " AND branch = :br" + params["br"] = branch + return db.execute(text(f""" + SELECT + COUNT(*) as total, + COUNT(*) FILTER (WHERE status NOT IN ('excluded','skipped')) as active, + COUNT(*) FILTER (WHERE prereq_ok = true) as prereq_ok, + COUNT(*) FILTER (WHERE prereq_ok = false) as prereq_ko, + COUNT(*) FILTER (WHERE prereq_ok IS NULL AND status NOT IN ('excluded','skipped')) as prereq_pending, + COUNT(*) FILTER (WHERE snap_done = true) as snap_ok, + COUNT(*) FILTER (WHERE snap_done = false AND status NOT IN ('excluded','skipped')) as snap_pending, + COUNT(*) FILTER (WHERE status = 'patched') as patched, + COUNT(*) FILTER (WHERE status = 'failed') as failed, + COUNT(*) FILTER (WHERE status = 'pending') as pending, + COUNT(*) FILTER (WHERE reboot_required = true) as reboot_count + FROM quickwin_entries WHERE {where} + """), params).fetchone() + + +def check_prereqs(db, run_id, branch, user=None): + """Lance les verifications prerequis pour tous les serveurs d'une branche. + Etapes par serveur: DNS resolution, SSH, espace disque, satellite.""" + from .quickwin_prereq_service import check_server_prereqs + + 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() + + by = user.get("display_name", user.get("username", "")) if user else "" + log_info(db, run_id, "prereq", + f"Lancement check prerequis {branch} ({len(entries)} serveurs)", created_by=by) + + results = [] + ok_count = 0 + for e in entries: + 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", ""), + }) + # Log par serveur + 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: + log_error(db, run_id, "prereq", f"KO: {e.hostname}", + detail=detail_str, entry_id=e.id, hostname=e.hostname) + results.append({"hostname": e.hostname, "ok": prereq_ok, "detail": detail_str}) + + ko_count = len(results) - ok_count + log_info(db, run_id, "prereq", + f"Fin check {branch}: {ok_count} OK, {ko_count} KO sur {len(results)}", created_by=by) + db.commit() + return results + + +def mark_snapshot(db, entry_id, done=True): + """Marque un serveur comme snapshot fait/pas fait""" + db.execute(text(""" + UPDATE quickwin_entries SET snap_done = :done, + snap_date = CASE WHEN :done THEN now() ELSE NULL END, + updated_at = now() WHERE id = :id + """), {"id": entry_id, "done": done}) + db.commit() + + +def mark_all_snapshots(db, run_id, branch, done=True): + """Marque tous les serveurs d'une branche comme snapshot fait""" + db.execute(text(""" + UPDATE quickwin_entries SET snap_done = :done, + snap_date = CASE WHEN :done THEN now() ELSE NULL END, + updated_at = now() + WHERE run_id = :rid AND branch = :br AND status NOT IN ('excluded','skipped') + """), {"rid": run_id, "br": branch, "done": done}) + db.commit() + + +def build_yum_commands(db, run_id, branch): + """Construit les commandes yum pour chaque serveur d'une branche. + Inclut tous les serveurs actifs (non excluded/skipped) avec snap fait.""" + entries = db.execute(text(""" + SELECT qe.id, s.hostname, qe.general_excludes, qe.specific_excludes + 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') + AND qe.snap_done = true + ORDER BY s.hostname + """), {"rid": run_id, "br": branch}).fetchall() + + commands = [] + for e in entries: + excludes = (e.general_excludes or "") + " " + (e.specific_excludes or "") + parts = [p.strip() for p in excludes.split() if p.strip()] + exclude_args = " ".join(f"--exclude={p}" for p in parts) + cmd = f"yum update -y {exclude_args}".strip() + db.execute(text("UPDATE quickwin_entries SET patch_command = :cmd WHERE id = :id"), + {"cmd": cmd, "id": e.id}) + commands.append({"id": e.id, "hostname": e.hostname, "command": cmd}) + db.commit() + return commands + + def inject_yum_history(db, data): """Injecte l'historique yum dans quickwin_server_config. data = [{"server": "hostname", "yum_commands": [...]}]""" @@ -252,3 +636,153 @@ def inject_yum_history(db, data): inserted += 1 db.commit() return updated, inserted + + +# ========== CORRESPONDANCE HPROD ↔ PROD ========== + +def compute_correspondance(db, run_id, user=None): + """Auto-apparie chaque serveur hprod avec son homologue prod (2e lettre → p). + Retourne (matched, unmatched, anomalies).""" + by = user.get("display_name", user.get("username", "")) if user else "" + + hprod_rows = db.execute(text(""" + SELECT qe.id, LOWER(s.hostname) as hostname + FROM quickwin_entries qe JOIN servers s ON qe.server_id = s.id + WHERE qe.run_id = :rid AND qe.branch = 'hprod' AND qe.status != 'excluded' + """), {"rid": run_id}).fetchall() + + prod_rows = db.execute(text(""" + SELECT qe.id, LOWER(s.hostname) as hostname + FROM quickwin_entries qe JOIN servers s ON qe.server_id = s.id + WHERE qe.run_id = :rid AND qe.branch = 'prod' AND qe.status != 'excluded' + """), {"rid": run_id}).fetchall() + + prod_by_host = {r.hostname: r.id for r in prod_rows} + matched = 0 + unmatched = 0 + anomalies = 0 + skipped = 0 + + # Existing pairs — ne pas toucher + existing = {r.id for r in db.execute(text(""" + SELECT id FROM quickwin_entries + WHERE run_id = :rid AND branch = 'hprod' AND prod_pair_entry_id IS NOT NULL + """), {"rid": run_id}).fetchall()} + + for h in hprod_rows: + if h.id in existing: + skipped += 1 + continue + if len(h.hostname) < 2: + unmatched += 1 + continue + candidate = h.hostname[0] + 'p' + h.hostname[2:] + if candidate == h.hostname: + anomalies += 1 + if candidate in prod_by_host: + db.execute(text(""" + UPDATE quickwin_entries SET prod_pair_entry_id = :pid WHERE id = :hid + """), {"pid": prod_by_host[candidate], "hid": h.id}) + matched += 1 + else: + unmatched += 1 + + log_info(db, run_id, "correspondance", + f"Auto-appariement: {matched} nouveaux, {skipped} conservés, {unmatched} sans homologue, {anomalies} anomalies", + created_by=by) + db.commit() + return matched, unmatched, anomalies + + +def get_correspondance(db, run_id, search=None, pair_filter=None, env_filter=None, domain_filter=None): + """Retourne la liste des hprod avec leur homologue prod (ou NULL).""" + rows = db.execute(text(""" + SELECT hp.id as hprod_id, sh.hostname as hprod_hostname, + dh.name as hprod_domaine, eh.name as hprod_env, + SUBSTRING(LOWER(sh.hostname), 2, 1) as letter2, + hp.prod_pair_entry_id, + pp.id as prod_id, sp.hostname as prod_hostname, + dp.name as prod_domaine + FROM quickwin_entries hp + JOIN servers sh ON hp.server_id = sh.id + LEFT JOIN domain_environments deh ON sh.domain_env_id = deh.id + LEFT JOIN domains dh ON deh.domain_id = dh.id + LEFT JOIN environments eh ON deh.environment_id = eh.id + LEFT JOIN quickwin_entries pp ON hp.prod_pair_entry_id = pp.id + LEFT JOIN servers sp ON pp.server_id = sp.id + LEFT JOIN domain_environments dep ON sp.domain_env_id = dep.id + LEFT JOIN domains dp ON dep.domain_id = dp.id + WHERE hp.run_id = :rid AND hp.branch = 'hprod' AND hp.status != 'excluded' + ORDER BY sh.hostname + """), {"rid": run_id}).fetchall() + + result = [] + for r in rows: + candidate = "" + if len(r.hprod_hostname) >= 2: + candidate = r.hprod_hostname[0] + 'p' + r.hprod_hostname[2:] + is_anomaly = (r.letter2 == 'p') + is_matched = r.prod_pair_entry_id is not None + + if pair_filter == "matched" and not is_matched: + continue + if pair_filter == "unmatched" and is_matched: + continue + if pair_filter == "anomaly" and not is_anomaly: + continue + if env_filter: + env_map = {"preprod": "i", "recette": "r", "dev": "d", "test": "vt"} + allowed_letters = env_map.get(env_filter, "") + if r.letter2 not in allowed_letters: + continue + if domain_filter and (r.hprod_domaine or '') != domain_filter: + continue + if search and search.lower() not in r.hprod_hostname.lower(): + if not (r.prod_hostname and search.lower() in r.prod_hostname.lower()): + continue + + result.append({ + "hprod_id": r.hprod_id, + "hprod_hostname": r.hprod_hostname, + "hprod_domaine": r.hprod_domaine or "", + "hprod_env": r.hprod_env or "", + "letter2": r.letter2, + "candidate": candidate, + "is_anomaly": is_anomaly, + "prod_id": r.prod_id, + "prod_hostname": r.prod_hostname or "", + "prod_domaine": r.prod_domaine or "", + "is_matched": is_matched, + }) + return result + + +def get_available_prod_entries(db, run_id): + """Retourne toutes les entries prod (un prod peut etre apparie a plusieurs hprod).""" + return db.execute(text(""" + SELECT qe.id, s.hostname, d.name as domaine + 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 domains d ON de.domain_id = d.id + WHERE qe.run_id = :rid AND qe.branch = 'prod' AND qe.status != 'excluded' + ORDER BY s.hostname + """), {"rid": run_id}).fetchall() + + +def set_prod_pair(db, hprod_entry_id, prod_entry_id): + """Associe manuellement un hprod à un prod (ou NULL pour dissocier).""" + pid = prod_entry_id if prod_entry_id else None + db.execute(text(""" + UPDATE quickwin_entries SET prod_pair_entry_id = :pid, updated_at = now() WHERE id = :hid + """), {"pid": pid, "hid": hprod_entry_id}) + db.commit() + + +def clear_all_pairs(db, run_id): + """Supprime tous les appariements d'un run.""" + db.execute(text(""" + UPDATE quickwin_entries SET prod_pair_entry_id = NULL, updated_at = now() + WHERE run_id = :rid AND branch = 'hprod' + """), {"rid": run_id}) + db.commit() diff --git a/app/services/quickwin_snapshot_service.py b/app/services/quickwin_snapshot_service.py new file mode 100644 index 0000000..ea98385 --- /dev/null +++ b/app/services/quickwin_snapshot_service.py @@ -0,0 +1,144 @@ +"""Service snapshot QuickWin — prise de snapshots VM via vSphere/pyvmomi +Ordre de recherche des VM sur les vCenters: + - Hors-prod: Senlis (vpgesavcs1) → Nanterre (vpmetavcs1) → DR (vpsicavcs1) + - Prod: Nanterre (vpmetavcs1) → Senlis (vpgesavcs1) → DR (vpsicavcs1) +Physiques: pas de snapshot, alerte Commvault.""" +import ssl +import logging +from datetime import datetime + +log = logging.getLogger("quickwin.snapshot") + +try: + from pyVim.connect import SmartConnect, Disconnect + from pyVmomi import vim + PYVMOMI_OK = True +except ImportError: + PYVMOMI_OK = False + log.warning("pyvmomi non disponible — snapshots impossibles") + + +def _get_secret(db, key): + try: + from ..services.secrets_service import get_secret + return get_secret(db, key) + except Exception: + return None + + +def _connect_vcenter(endpoint, user, password): + """Connexion a un vCenter. Retourne un ServiceInstance ou None.""" + try: + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + si = SmartConnect(host=endpoint, user=user, pwd=password, sslContext=ctx) + return si + except Exception as e: + log.warning(f"Connexion vCenter {endpoint} echouee: {e}") + return None + + +def _find_vm(si, vm_name): + """Cherche une VM par nom dans le vCenter. Retourne l'objet VM ou None.""" + content = si.RetrieveContent() + container = content.viewManager.CreateContainerView( + content.rootFolder, [vim.VirtualMachine], True) + try: + for vm in container.view: + if vm.name.lower() == vm_name.lower(): + return vm + finally: + container.Destroy() + return None + + +def _take_snapshot(vm, snap_name, description=""): + """Prend un snapshot de la VM. Retourne (ok, message).""" + try: + task = vm.CreateSnapshot_Task( + name=snap_name, + description=description, + memory=False, + quiesce=True, + ) + # Attendre la fin du task + while task.info.state in (vim.TaskInfo.State.queued, vim.TaskInfo.State.running): + import time + time.sleep(2) + if task.info.state == vim.TaskInfo.State.success: + return True, "Snapshot OK" + else: + err = str(task.info.error) if task.info.error else "Echec inconnu" + return False, f"Snapshot echoue: {err}" + except Exception as e: + return False, f"Erreur snapshot: {e}" + + +def get_vcenter_order(db, branch): + """Retourne la liste ordonnee des vCenters selon la branche. + hprod: Senlis → Nanterre → DR + prod: Nanterre → Senlis → DR""" + from sqlalchemy import text + vcenters = db.execute(text( + "SELECT id, name, endpoint FROM vcenters WHERE is_active = true ORDER BY id" + )).fetchall() + + vc_map = {} + for vc in vcenters: + ep = vc.endpoint.lower() + if "vpgesavcs1" in ep: + vc_map["senlis"] = vc + elif "vpmetavcs1" in ep: + vc_map["nanterre"] = vc + elif "vpsicavcs1" in ep: + vc_map["dr"] = vc + else: + vc_map.setdefault("other", []).append(vc) + + if branch == "prod": + order = [vc_map.get("nanterre"), vc_map.get("senlis"), vc_map.get("dr")] + else: + order = [vc_map.get("senlis"), vc_map.get("nanterre"), vc_map.get("dr")] + + return [v for v in order if v is not None] + + +def snapshot_server(hostname, vm_name, branch, db, snap_name=None): + """Prend un snapshot pour un serveur. + Cherche la VM sur les vCenters dans l'ordre selon la branche. + Retourne dict: {ok, vcenter, detail, skipped}""" + if not PYVMOMI_OK: + return {"ok": False, "vcenter": "", "detail": "pyvmomi non installe", "skipped": True} + + vc_user = _get_secret(db, "vcenter_user") + vc_pass = _get_secret(db, "vcenter_pass") + if not vc_user or not vc_pass: + return {"ok": False, "vcenter": "", "detail": "Credentials vCenter manquants (vcenter_user/vcenter_pass dans Settings > Secrets)", "skipped": True} + + search_name = vm_name or hostname + if not snap_name: + snap_name = f"QW_{datetime.now().strftime('%Y%m%d_%H%M')}" + + vcenters = get_vcenter_order(db, branch) + if not vcenters: + return {"ok": False, "vcenter": "", "detail": "Aucun vCenter actif configure", "skipped": True} + + for vc in vcenters: + si = _connect_vcenter(vc.endpoint, vc_user, vc_pass) + if not si: + continue + try: + vm = _find_vm(si, search_name) + if vm: + ok, msg = _take_snapshot(vm, snap_name, + description=f"QuickWin auto-snapshot {hostname}") + return {"ok": ok, "vcenter": vc.name, "detail": msg} + finally: + try: + Disconnect(si) + except Exception: + pass + + tried = ", ".join(vc.name for vc in vcenters) + return {"ok": False, "vcenter": "", "detail": f"VM '{search_name}' non trouvee sur: {tried}"} diff --git a/app/services/server_service.py b/app/services/server_service.py index ba8f2d6..181e5d7 100644 --- a/app/services/server_service.py +++ b/app/services/server_service.py @@ -208,7 +208,7 @@ def update_server(db, server_id, data, username): params = {"id": server_id} direct_fields = ["tier", "etat", "patch_os_owner", "responsable_nom", "referent_nom", "mode_operatoire", "commentaire", "ssh_method", - "pref_patch_jour", "pref_patch_heure"] + "domain_ltd", "pref_patch_jour", "pref_patch_heure"] changed = [] for field in direct_fields: if data.get(field) is not None: diff --git a/app/templates/base.html b/app/templates/base.html index d1191b3..425732e 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -61,7 +61,9 @@ {% if p.qualys %}Décodeur{% endif %} {% if p.qualys %}Agents{% endif %} {% if p.campaigns %}Safe Patching{% endif %} - {% if p.campaigns or p.quickwin %}QuickWin{% endif %} + {% if p.campaigns or p.quickwin %}QuickWin{% endif %} + {% if p.campaigns or p.quickwin %}Config exclusions{% endif %} + {% if p.campaigns or p.quickwin %}Correspondance{% endif %} {% if p.planning %}Planning{% endif %} {% if p.audit %}Audit{% endif %} {% if p.audit in ('edit', 'admin') %}Spécifique{% endif %} @@ -70,6 +72,7 @@ {% if p.servers %}Contacts{% endif %} {% if p.users %}Utilisateurs{% endif %} {% if p.settings %}Settings{% endif %} + {% if p.settings %}Référentiel{% endif %}
diff --git a/app/templates/partials/server_edit.html b/app/templates/partials/server_edit.html index 7e96307..ed93a6e 100644 --- a/app/templates/partials/server_edit.html +++ b/app/templates/partials/server_edit.html @@ -36,10 +36,17 @@ {% for e in envs %}{% endfor %} +
+ + +
@@ -70,18 +77,18 @@
- +
- - + +
- +
diff --git a/app/templates/quickwin_correspondance.html b/app/templates/quickwin_correspondance.html new file mode 100644 index 0000000..2c7ee8d --- /dev/null +++ b/app/templates/quickwin_correspondance.html @@ -0,0 +1,258 @@ +{% extends "base.html" %} +{% block title %}Correspondance QuickWin #{{ run.id }}{% endblock %} + +{% macro qs(pg=page) -%} +?page={{ pg }}&per_page={{ per_page }}&search={{ filters.search or '' }}&pair_filter={{ filters.pair_filter or '' }}&domain_filter={{ filters.domain_filter or '' }}&env_filter={{ filters.env_filter or '' }} +{%- endmacro %} + +{% block content %} +
+
+ ← Retour campagne +

Correspondance H-Prod ↔ Prod

+

{{ run.label }} — Appariement des serveurs hors-production avec leur homologue production

+
+
+
+ +
+
+ +
+
+
+ +{% if msg %} +{% if msg == 'auto' %} +{% set am = request.query_params.get('am', '0') %} +{% set au = request.query_params.get('au', '0') %} +{% set aa = request.query_params.get('aa', '0') %} +
+ Auto-appariement terminé : {{ am }} apparié(s), {{ au }} sans homologue, {{ aa }} anomalie(s) +
+{% elif msg == 'cleared' %} +
+ Tous les appariements ont été supprimés. +
+{% elif msg == 'bulk' %} +{% set bc = request.query_params.get('bc', '0') %} +
+ {{ bc }} appariement(s) modifié(s) en masse. +
+{% endif %} +{% endif %} + + +
+
+
{{ stats.total }}
+
Total H-Prod
+
+
+
{{ stats.matched }}
+
Appariés
+
+
+
{{ stats.unmatched }}
+
Sans homologue
+
+
+
{{ stats.anomalies }}
+
Anomalies
+
+
+ + +
+ + + + + + + Reset + {{ total_filtered }} résultat(s) +
+ + +
+ 0 sélectionné(s) + + | + Associer la sélection à : + + +
+ + +
+
+ + + + + + + + + + + + + + {% for p in pairs %} + + + + + + + + + + + + {% endfor %} + {% if not pairs %} + + {% endif %} + +
Serveur H-ProdDomaineEnvCandidat autoStatutServeur Prod appariéDomaine ProdAction
{{ p.hprod_hostname }}{{ p.hprod_domaine }} + {% if p.is_anomaly %}{{ p.hprod_env or '?' }} + {% else %}{{ p.hprod_env }}{% endif %} + {{ p.candidate }} + {% if p.is_matched %}OK + {% elif p.is_anomaly %}! + {% else %}--{% endif %} + + {% if p.is_matched %} + {{ p.prod_hostname }} + {% else %} + + {% endif %} + + {% if p.is_matched %}{{ p.prod_domaine }}{% endif %} + + {% if p.is_matched %} + + {% else %} + + {% endif %} +
Aucun résultat{% if filters.search or filters.pair_filter %} pour ces filtres{% endif %}
+
+
+ + +{% if total_pages > 1 %} +
+ {% if page > 1 %} + + {% endif %} + {% for pg in range(1, total_pages + 1) %} + {% if pg == page %} + {{ pg }} + {% elif pg <= 3 or pg >= total_pages - 1 or (pg >= page - 1 and pg <= page + 1) %} + {{ pg }} + {% elif pg == 4 or pg == total_pages - 2 %} + + {% endif %} + {% endfor %} + {% if page < total_pages %} + + {% endif %} +
+{% endif %} + + +{% endblock %} diff --git a/app/templates/quickwin_detail.html b/app/templates/quickwin_detail.html index 6f9aeb8..8339efd 100644 --- a/app/templates/quickwin_detail.html +++ b/app/templates/quickwin_detail.html @@ -1,6 +1,18 @@ {% extends "base.html" %} {% block title %}QuickWin #{{ run.id }}{% endblock %} +{% set STEPS = [ + ("draft", "Brouillon", "#94a3b8"), + ("prereq", "Pr\u00e9requis", "#00d4ff"), + ("snapshot", "Snapshot", "#a78bfa"), + ("patching", "Patching", "#ffcc00"), + ("result", "R\u00e9sultats", "#00ff88"), + ("completed", "Termin\u00e9", "#10b981"), +] %} +{% set current_step_idx = namespace(val=0) %} +{% for s in STEPS %}{% if s[0] == run.status %}{% set current_step_idx.val = loop.index0 %}{% endif %}{% endfor %} +{% set can_modify = run.status in ('draft', 'prereq') %} + {% macro qs(hp=hp_page, pp=p_page) -%} ?hp_page={{ hp }}&p_page={{ pp }}&per_page={{ per_page }}&search={{ filters.search or '' }}&status={{ filters.status or '' }}&domain={{ filters.domain or '' }} {%- endmacro %} @@ -10,18 +22,11 @@
← Retour campagnes

{{ run.label }}

-

S{{ '%02d'|format(run.week_number) }} {{ run.year }} — Créé par {{ run.created_by_name or '?' }} — pas de reboot nécessaire

+

S{{ '%02d'|format(run.week_number) }} {{ run.year }} — Créé par {{ run.created_by_name or '?' }}

- {% if run.status == 'draft' %} - Brouillon - {% elif run.status == 'hprod_done' %} - H-Prod terminé - {% elif run.status == 'completed' %} - Terminé - {% else %} - {{ run.status }} - {% endif %} + Correspondance + Logs
@@ -32,7 +37,42 @@
{{ msg }}
{% endif %} - + +
+
+ {% for step_id, step_label, step_color in STEPS %} + {% set idx = loop.index0 %} + {% set is_current = (step_id == run.status) %} + {% set is_done = (idx < current_step_idx.val) %} +
+
+ {% if is_done %}✓{% else %}{{ idx + 1 }}{% endif %} +
+
{{ step_label }}
+
+ {% if not loop.last %} +
+ {% endif %} + {% endfor %} +
+
+ {% if current_step_idx.val > 0 %} +
+ + +
+ {% else %}
{% endif %} + {% if current_step_idx.val < 5 %} +
+ + +
+ {% else %}
{% endif %} +
+
+ +
{{ stats.total }}
@@ -60,67 +100,353 @@
- + +{% if run.status == 'draft' and scope %} +
+
+

+ Périmètre de la campagne +

+

Cochez les domaines et zones à inclure. Les serveurs hors périmètre seront marqués « Exclu ».

+
+
+ + +
+ +
+
DOMAINES
+
+ {% for d in scope.domains %} + {% set active = scope.dom_active.get(d, 0) %} + {% set total = scope.dom_counts.get(d, 0) %} + + {% endfor %} +
+
+ +
+
ZONES
+
+ {% for z in scope.zones %} + {% set active = scope.zone_active.get(z, 0) %} + {% set total = scope.zone_counts.get(z, 0) %} + + {% endfor %} +
+
+
+
+ + +
+
+
+{% endif %} + + + +{% if run.status == 'prereq' %} +
+

Vérification des prérequis

+

Vérifie : résolution DNS, SSH (PSMP/Key), Satellite/YUM, espace disque (<90%)

+
+
+ {{ step_hp.prereq_ok }} OK + {{ step_hp.prereq_ko }} KO + {{ step_hp.prereq_pending }} en attente + (H-Prod) +
+
+
+ + {% if prod_ok %} + + {% endif %} + +
+ + +
+{% endif %} + +{% if run.status == 'snapshot' %} +
+

Snapshots VM

+

Connexion vSphere → recherche VM → snapshot automatique. Les serveurs physiques sont ignorés (vérifier backup Commvault).

+
+
+ {{ step_hp.snap_ok }} fait(s) + {{ step_hp.snap_pending }} en attente + (H-Prod) +
+
+
+ + {% if prod_ok %} + + {% endif %} +
+ + +
+ + Ordre vCenter : H-Prod = Senlis → Nanterre → DR | Prod = Nanterre → Senlis → DR +
+ + +
+{% endif %} + +{% if run.status == 'patching' %} + +{% if prod_ok and stats.prod_total > 0 %} + +
+

Préparation Production

+

Avant de patcher la prod, lancez les checks prereq et snapshots sur les serveurs production.

+
+ + + + +
+ + +
+
+ + + + +
+{% endif %} + +
+

Exécution du patching

+

Étape 1 : Générer les commandes. Étape 2 : Vérifier. Étape 3 : Exécuter via SSH.

+ + +
+
+ + +
+ {% if prod_ok %} +
+ + +
+ {% endif %} + + {% if prod_ok %} + + {% endif %} +
+ + + + + + +
+{% endif %} + +{% if run.status == 'result' %} +
+

Résultats

+
+
+
{{ stats.patched }}
+
Patchés
+
+
+
{{ stats.failed }}
+
KO
+
+
+
{{ stats.pending }}
+
En attente
+
+
+
{{ stats.reboot_count }}
+
Reboot
+
+
+
+{% endif %} + +{% if run.status == 'completed' %} +
+

Campagne terminée

+

{{ stats.patched }} patché(s), {{ stats.failed }} KO, {{ stats.reboot_count }} reboot(s).

+ Télécharger le rapport +
+{% endif %} + +
+ + {% if run.status in ('prereq','snapshot','patching','result','completed') %} + + {% endif %} + {% if run.status in ('snapshot','patching','result','completed') %} + + {% endif %} + {% if run.status in ('patching','result','completed') %} - - + + {% for n in [14, 25, 50, 100] %}{% endfor %} Reset
- {% if not prod_ok %}
-

Hors-production d'abord : {{ stats.pending }} serveur(s) hprod en attente. Terminer le hprod avant de lancer le prod.

+

Hors-production d'abord : {{ stats.hprod_pending }} serveur(s) hprod en attente.

{% endif %} - + +{% macro entry_table(rows, branch_label, branch_color, branch_key, page_num, total_pages, total_count) %}
-

HORS-PRODUCTION ({{ hprod_total }})

+

{{ branch_label }} ({{ total_count }})

- {{ hprod|selectattr('status','eq','patched')|list|length }} OK - {{ hprod|selectattr('status','eq','failed')|list|length }} KO - {{ hprod|selectattr('status','eq','pending')|list|length }} en attente + {{ rows|selectattr('status','eq','patched')|list|length }} OK + {{ rows|selectattr('status','eq','failed')|list|length }} KO + {{ rows|selectattr('status','eq','pending')|list|length }} en attente + {% if can_modify %} + + {% endif %}
+ {% if can_modify %}{% endif %} + {% if run.status in ('prereq','snapshot','patching','result','completed') %} + + {% endif %} + {% if run.status in ('snapshot','patching','result','completed') %} + + {% endif %} + {% if run.status == 'patching' %} + + {% endif %} - {% for e in hprod %} + {% for e in rows %} - + {% if can_modify %}{% endif %} + + {% if run.status in ('prereq','snapshot','patching','result','completed') %} + + {% endif %} + {% if run.status in ('snapshot','patching','result','completed') %} + + {% endif %} @@ -143,91 +490,53 @@ + {% if run.status == 'patching' %} + + {% endif %} {% endfor %} - {% if not hprod %}{% endif %} + {% if not rows %}{% endif %}
Serveur Domaine Env StatutPrereqSnapExclusions gén. Exclusions spéc. Packages Date patch Reboot NotesAction
{{ e.hostname }}{{ e.hostname }} {{ e.domaine or '?' }} {{ e.environnement or '?' }} @@ -131,6 +457,27 @@ {% elif e.status == 'skipped' %}Ignoré {% else %}En attente{% endif %} + {% if e.prereq_ok == true %} + {% elif e.prereq_ok == false %} + {% else %}{% endif %} + + {% if e.snap_done %} + {% else %} + {% if run.status == 'snapshot' %} +
+ + + +
+ {% else %}{% endif %} + {% endif %} +
{{ e.general_excludes or '—' }} {{ e.notes or '—' }} + {% if e.status == 'pending' and e.prereq_ok and e.snap_done %} +
+
+ + + +
+
+ + + +
+
+ {% endif %} +
Aucun serveur hors-production{% if filters.search or filters.status or filters.domain %} (filtre actif){% endif %}
Aucun serveur{% if filters.search or filters.status or filters.domain %} (filtre actif){% endif %}
- - {% if hp_total_pages > 1 %} + {% if total_pages > 1 %}
- Page {{ hp_page }} / {{ hp_total_pages }} — {{ hprod_total }} serveur(s) + Page {{ page_num }} / {{ total_pages }} — {{ total_count }} serveur(s)
- {% if hp_page > 1 %}Précédent{% endif %} - {% if hp_page < hp_total_pages %}Suivant{% endif %} + {% if branch_key == 'hp' %} + {% if page_num > 1 %}Préc.{% endif %} + {% if page_num < total_pages %}Suiv.{% endif %} + {% else %} + {% if page_num > 1 %}Préc.{% endif %} + {% if page_num < total_pages %}Suiv.{% endif %} + {% endif %}
{% endif %}
+{% endmacro %} + + +{{ entry_table(hprod, "HORS-PRODUCTION", "#00d4ff", "hp", hp_page, hp_total_pages, hprod_total) }} {% if prod_ok %} -
-
-

PRODUCTION ({{ prod_total }})

-
- {{ prod|selectattr('status','eq','patched')|list|length }} OK - {{ prod|selectattr('status','eq','failed')|list|length }} KO - {{ prod|selectattr('status','eq','pending')|list|length }} en attente -
-
-
- - - - - - - - - - - - - - - {% for e in prod %} - - - - - - - - - - - - - {% endfor %} - {% if not prod %}{% endif %} - -
ServeurDomaineEnvStatutExclusions gén.Exclusions spéc.PackagesDate patchRebootNotes
{{ e.hostname }}{{ e.domaine or '?' }}{{ e.environnement or '?' }} - {% if e.status == 'patched' %}Patché - {% elif e.status == 'failed' %}KO - {% elif e.status == 'in_progress' %}En cours - {% elif e.status == 'excluded' %}Exclu - {% else %}En attente{% endif %} - - {{ e.general_excludes or '—' }} - - {{ e.specific_excludes or '—' }} - {{ e.patch_packages_count or '—' }}{{ e.patch_date.strftime('%d/%m %H:%M') if e.patch_date else '—' }}{% if e.reboot_required %}OUI{% else %}—{% endif %} - {{ e.notes or '—' }} -
Aucun serveur production{% if filters.search or filters.status or filters.domain %} (filtre actif){% endif %}
-
- - {% if p_total_pages > 1 %} -
- Page {{ p_page }} / {{ p_total_pages }} — {{ prod_total }} serveur(s) -
- {% if p_page > 1 %}Précédent{% endif %} - {% if p_page < p_total_pages %}Suivant{% endif %} -
-
- {% endif %} -
+{{ entry_table(prod, "PRODUCTION", "#ffcc00", "pr", p_page, p_total_pages, prod_total) }} {% endif %} {% if run.notes %} @@ -237,13 +546,81 @@
{% endif %} + + + {% endblock %} diff --git a/app/templates/quickwin_logs.html b/app/templates/quickwin_logs.html new file mode 100644 index 0000000..53a6ef2 --- /dev/null +++ b/app/templates/quickwin_logs.html @@ -0,0 +1,107 @@ +{% extends "base.html" %} +{% block title %}Logs QuickWin #{{ run.id }}{% endblock %} + +{% block content %} +
+
+ ← Retour campagne +

Logs — {{ run.label }}

+

S{{ '%02d'|format(run.week_number) }} {{ run.year }} — {{ total_logs }} entrée(s)

+
+
+ Campagne +
+ +
+
+
+ +{% if msg %} +
{{ msg }}
+{% endif %} + + +
+
+
{{ total_logs }}
+
Total
+
+
+
{{ log_stats.get('success', 0) }}
+
Success
+
+
+
{{ log_stats.get('info', 0) }}
+
Info
+
+
+
{{ log_stats.get('warn', 0) }}
+
Warn
+
+
+
{{ log_stats.get('error', 0) }}
+
Error
+
+
+ + +
+ + + + + Reset +
+ + +
+
+ + + + + + + + + + + {% for l in logs %} + + + + + + + + + {% endfor %} + {% if not logs %} + + {% endif %} + +
DateNiveauÉtapeHostnameMessagePar
{{ l.created_at.strftime('%d/%m %H:%M:%S') }} + {% if l.level == 'success' %}OK + {% elif l.level == 'info' %}INFO + {% elif l.level == 'warn' %}WARN + {% elif l.level == 'error' %}ERR + {% else %}{{ l.level }}{% endif %} + {{ l.step }}{{ l.hostname or '' }} + {{ l.message }} + {% if l.detail %} +
{{ l.detail }}
+ {% endif %} +
{{ l.created_by or '' }}
Aucun log{% if filters.level or filters.step or filters.hostname %} pour ces filtres{% endif %}
+
+
+{% endblock %} diff --git a/app/templates/referentiel.html b/app/templates/referentiel.html new file mode 100644 index 0000000..3e694a4 --- /dev/null +++ b/app/templates/referentiel.html @@ -0,0 +1,408 @@ +{% extends "base.html" %} +{% block title %}Référentiel{% endblock %} + +{% block content %} +
+
+

Référentiel

+

Gestion centralisée des domaines, environnements, zones et associations

+
+
+ +{% set msg = request.query_params.get('msg', '') %} +{% set detail = request.query_params.get('detail', '') %} +{% if msg == 'added' %} +
+ Elément ajouté avec succès. +
+{% elif msg == 'updated' %} +
+ Elément mis à jour. +
+{% elif msg == 'deleted' %} +
+ Elément supprimé. +
+{% elif msg == 'nodelete' %} +
+ Suppression impossible : {{ detail }} serveur(s) lié(s). Dissociez-les d'abord. +
+{% elif msg == 'exists' %} +
+ Cette association domaine × environnement existe déjà. +
+{% endif %} + + +
+ {% for t, label in [('domains','Domaines'), ('envs','Environnements'), ('assocs','Associations'), ('zones','Zones'), ('dns','Domaines DNS')] %} + + {{ label }} + + {% endfor %} +
+ + + + +{% if tab == 'domains' %} +
+ + + + + + + + + + + + + {% for d in domains %} + + + + + + + + + + + + {% endfor %} + +
IDNomCodeDescriptionOrdreActifServeursActions
{{ d.id }} + + + {{ dom_srv_counts.get(d.id, 0) }} + + + + + + {% if dom_srv_counts.get(d.id, 0) == 0 %} +
+ +
+ {% endif %} +
+
+ +{% if can_modify %} +
+

Ajouter un domaine

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+{% endif %} + + + + +{% elif tab == 'envs' %} +
+ + + + + + + + + + {% for e in envs %} + + + + + + + + + {% endfor %} + +
IDNomCodeServeursActions
{{ e.id }} + {{ env_srv_counts.get(e.id, 0) }} + + + + {% if env_srv_counts.get(e.id, 0) == 0 %} +
+ +
+ {% endif %} +
+
+ +{% if can_modify %} +
+

Ajouter un environnement

+
+
+ + +
+
+ + +
+ +
+
+{% endif %} + + + + +{% elif tab == 'assocs' %} +
+
+ + + + + + + + + + + + + + + {% for a in assocs %} + + + + + + + + + + + + + + {% endfor %} + {% if not assocs %} + + {% endif %} + +
IDDomaineEnvironnementResponsableEmail resp.RéférentEmail réf.ActifSrvActions
{{ a.id }}{{ a.domain_name }}{{ a.env_name }} + + + {{ a.nb_servers or 0 }} + + + + + +
+ +
+
Aucune association
+
+
+ +{% if can_modify %} +
+

Ajouter une association

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+{% endif %} + + + + +{% elif tab == 'zones' %} +
+ + + + + + + + + + + {% for z in zones %} + + + + + + + + + + {% endfor %} + +
IDNomDescriptionDMZServeursActions
{{ z.id }} + + + {{ zone_srv_counts.get(z.id, 0) }} + + + + {% if zone_srv_counts.get(z.id, 0) == 0 %} +
+ +
+ {% endif %} +
+
+ +{% if can_modify %} +
+

Ajouter une zone

+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+{% endif %} + + + + +{% elif tab == 'dns' %} +
+
+

Suffixes DNS utilisés dans le champ domain_ltd des serveurs (ex: sanef.groupe, sanef-rec.fr)

+
+ + + + + + + + + + + {% for d in dns_domains %} + + + + + + + + + + {% endfor %} + +
IDNom (suffixe DNS)DescriptionActifServeursActions
{{ d.id }} + + + {{ dns_srv_counts.get(d.id, 0) }} + + + + {% if dns_srv_counts.get(d.id, 0) == 0 %} +
+ +
+ {% endif %} +
+
+ +{% if can_modify %} +
+

Ajouter un domaine DNS

+
+
+ + +
+
+ + +
+ +
+
+{% endif %} + +{% endif %} +{% endblock %}