diff --git a/app/routers/audit_full.py b/app/routers/audit_full.py deleted file mode 100644 index 795821f..0000000 --- a/app/routers/audit_full.py +++ /dev/null @@ -1,703 +0,0 @@ -"""Router Audit Complet — import JSON, liste, detail, carte flux, carte applicative""" -import json -from fastapi import APIRouter, Request, Depends, UploadFile, File -from fastapi.responses import HTMLResponse, RedirectResponse, StreamingResponse -from fastapi.templating import Jinja2Templates -from sqlalchemy import text -from ..dependencies import get_db, get_current_user, get_user_perms, can_view, can_edit, base_context -from ..services.server_audit_full_service import ( - import_json_report, get_latest_audits, get_audit_detail, - get_flow_map, get_flow_map_for_server, get_app_map, -) -from ..config import APP_NAME - -router = APIRouter() -templates = Jinja2Templates(directory="app/templates") - - -@router.get("/audit-full", response_class=HTMLResponse) -async def audit_full_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, "audit"): - return RedirectResponse(url="/dashboard") - - filtre = request.query_params.get("filter", "") - search = request.query_params.get("q", "").strip() - domain = request.query_params.get("domain", "") - page = int(request.query_params.get("page", "1")) - per_page = 20 - - # KPIs (toujours sur tout le jeu) - kpis = db.execute(text(""" - SELECT - COUNT(*) as total, - COUNT(*) FILTER (WHERE reboot_required = true) as needs_reboot, - COUNT(*) FILTER (WHERE EXISTS ( - SELECT 1 FROM jsonb_array_elements(disk_usage) d - WHERE (d->>'pct')::int >= 90 - )) as disk_critical, - COUNT(*) FILTER (WHERE EXISTS ( - SELECT 1 FROM jsonb_array_elements(disk_usage) d - WHERE (d->>'pct')::int >= 80 AND (d->>'pct')::int < 90 - )) as disk_warning, - COUNT(*) FILTER (WHERE - uptime LIKE '%month%' OR uptime LIKE '%year%' - OR (uptime LIKE '%week%' AND ( - CASE WHEN uptime ~ '(\d+) week' THEN (substring(uptime from '(\d+) week'))::int ELSE 0 END >= 17 - )) - ) as uptime_long, - COUNT(*) FILTER (WHERE services::text ~* 'postgres') as app_postgres, - COUNT(*) FILTER (WHERE services::text ~* 'mariadb|mysqld') as app_mariadb, - COUNT(*) FILTER (WHERE services::text ~* 'hdb|sapstart|HANA') as app_hana, - COUNT(*) FILTER (WHERE services::text ~* 'oracle|ora_pmon' OR processes::text ~* 'ora_pmon|oracle') as app_oracle, - COUNT(*) FILTER (WHERE services::text ~* '"httpd"' OR listen_ports::text ~* '"httpd"') as app_httpd, - COUNT(*) FILTER (WHERE services::text ~* '"nginx"' OR listen_ports::text ~* '"nginx"') as app_nginx, - COUNT(*) FILTER (WHERE services::text ~* 'haproxy') as app_haproxy, - COUNT(*) FILTER (WHERE services::text ~* 'tomcat' OR processes::text ~* 'tomcat|catalina') as app_tomcat, - COUNT(*) FILTER (WHERE listen_ports::text ~* '"node"' OR processes::text ~* '/applis.*node') as app_nodejs, - COUNT(*) FILTER (WHERE services::text ~* 'redis') as app_redis, - COUNT(*) FILTER (WHERE services::text ~* 'mongod') as app_mongodb, - COUNT(*) FILTER (WHERE services::text ~* 'elasticsearch|opensearch') as app_elastic, - COUNT(*) FILTER (WHERE services::text ~* 'docker|podman' OR processes::text ~* 'dockerd|podman') as app_container, - COUNT(*) FILTER (WHERE listen_ports::text ~* '"java"' OR processes::text ~* '\.jar') as app_java - FROM server_audit_full - WHERE status IN ('ok','partial') - AND id IN (SELECT DISTINCT ON (hostname) id FROM server_audit_full WHERE status IN ('ok','partial') ORDER BY hostname, audit_date DESC) - """)).fetchone() - - # Domaines + zones pour le filtre - all_domains = db.execute(text( - "SELECT code, name, 'domain' as type FROM domains ORDER BY name" - )).fetchall() - all_zones = db.execute(text( - "SELECT name as code, name, 'zone' as type FROM zones ORDER BY name" - )).fetchall() - - # Requete avec filtres - audits = get_latest_audits(db, limit=9999) - - # Filtre KPI - if filtre == "reboot": - audits = [a for a in audits if a.reboot_required] - elif filtre == "disk_critical": - ids = {r.id for r in db.execute(text(""" - SELECT saf.id FROM server_audit_full saf - WHERE saf.status IN ('ok','partial') AND EXISTS ( - SELECT 1 FROM jsonb_array_elements(saf.disk_usage) d WHERE (d->>'pct')::int >= 90 - ) AND saf.id IN (SELECT DISTINCT ON (hostname) id FROM server_audit_full WHERE status IN ('ok','partial') ORDER BY hostname, audit_date DESC) - """)).fetchall()} - audits = [a for a in audits if a.id in ids] - elif filtre == "disk_warning": - ids = {r.id for r in db.execute(text(""" - SELECT saf.id FROM server_audit_full saf - WHERE saf.status IN ('ok','partial') AND EXISTS ( - SELECT 1 FROM jsonb_array_elements(saf.disk_usage) d WHERE (d->>'pct')::int >= 80 - ) AND saf.id IN (SELECT DISTINCT ON (hostname) id FROM server_audit_full WHERE status IN ('ok','partial') ORDER BY hostname, audit_date DESC) - """)).fetchall()} - audits = [a for a in audits if a.id in ids] - elif filtre == "uptime": - audits = [a for a in audits if a.uptime and ("month" in a.uptime or "year" in a.uptime)] - elif filtre and filtre.startswith("app_"): - # Filtre applicatif generique - app_patterns = { - "app_postgres": "postgres", - "app_mariadb": "mariadb|mysqld", - "app_hana": "hdb|sapstart|HANA", - "app_oracle": "ora_pmon|oracle", - "app_httpd": "httpd", - "app_nginx": "nginx", - "app_haproxy": "haproxy", - "app_tomcat": "tomcat|catalina", - "app_nodejs": "node", - "app_redis": "redis", - "app_mongodb": "mongod", - "app_elastic": "elasticsearch|opensearch", - "app_container": "docker|podman", - "app_java": "java|\\.jar", - } - pattern = app_patterns.get(filtre, "") - if pattern: - ids = {r.id for r in db.execute(text(""" - SELECT id FROM server_audit_full - WHERE status IN ('ok','partial') - AND (services::text ~* :pat OR listen_ports::text ~* :pat OR processes::text ~* :pat) - AND id IN (SELECT DISTINCT ON (hostname) id FROM server_audit_full WHERE status IN ('ok','partial') ORDER BY hostname, audit_date DESC) - """), {"pat": pattern}).fetchall()} - audits = [a for a in audits if a.id in ids] - - # Filtre domaine ou zone - if domain: - # D'abord chercher comme zone - zone_servers = {r.hostname for r in db.execute(text(""" - SELECT s.hostname FROM servers s - JOIN zones z ON s.zone_id = z.id - WHERE z.name = :name - """), {"name": domain}).fetchall()} - if zone_servers: - audits = [a for a in audits if a.hostname in zone_servers] - else: - # Sinon chercher comme domaine - domain_servers = {r.hostname for r in db.execute(text(""" - SELECT s.hostname FROM servers s - JOIN domain_environments de ON s.domain_env_id = de.id - JOIN domains d ON de.domain_id = d.id - WHERE d.code = :dc - """), {"dc": domain}).fetchall()} - audits = [a for a in audits if a.hostname in domain_servers] - - # Recherche hostname - if search: - q = search.lower() - audits = [a for a in audits if q in a.hostname.lower()] - - # Tri - sort = request.query_params.get("sort", "hostname") - sort_dir = request.query_params.get("dir", "asc") - if sort == "hostname": - audits.sort(key=lambda a: a.hostname.lower(), reverse=(sort_dir == "desc")) - elif sort == "uptime": - def uptime_days(a): - u = a.uptime or "" - d = 0 - import re as _re - m = _re.search(r"(\d+) year", u) - if m: d += int(m.group(1)) * 365 - m = _re.search(r"(\d+) month", u) - if m: d += int(m.group(1)) * 30 - m = _re.search(r"(\d+) week", u) - if m: d += int(m.group(1)) * 7 - m = _re.search(r"(\d+) day", u) - if m: d += int(m.group(1)) - return d - audits.sort(key=uptime_days, reverse=(sort_dir == "desc")) - elif sort == "reboot": - audits.sort(key=lambda a: (1 if a.reboot_required else 0), reverse=(sort_dir == "desc")) - elif sort == "patch": - def patch_sort_key(a): - if a.last_patch_date: - return a.last_patch_date - elif a.last_patch_year and a.last_patch_week: - return f"{a.last_patch_year}-{a.last_patch_week}" - return "" - audits.sort(key=patch_sort_key, reverse=(sort_dir == "desc")) - - # Pagination - total_filtered = len(audits) - total_pages = max(1, (total_filtered + per_page - 1) // per_page) - page = max(1, min(page, total_pages)) - audits_page = audits[(page - 1) * per_page : page * per_page] - - ctx = base_context(request, db, user) - ctx.update({ - "app_name": APP_NAME, "audits": audits_page, "kpis": kpis, - "filter": filtre, "search": search, "domain": domain, - "all_domains": all_domains, "all_zones": all_zones, - "sort": sort, "sort_dir": sort_dir, - "page": page, "total_pages": total_pages, "total_filtered": total_filtered, - "msg": request.query_params.get("msg"), - }) - return templates.TemplateResponse("audit_full_list.html", ctx) - - -@router.post("/audit-full/import") -async def audit_full_import(request: Request, db=Depends(get_db), - file: UploadFile = File(...)): - user = get_current_user(request) - if not user: - return RedirectResponse(url="/login") - perms = get_user_perms(db, user) - if not can_edit(perms, "audit"): - return RedirectResponse(url="/audit-full") - - try: - content = await file.read() - json_data = json.loads(content.decode("utf-8-sig")) - imported, errors = import_json_report(db, json_data) - return RedirectResponse( - url=f"/audit-full?msg=imported_{imported}_{errors}", - status_code=303, - ) - except Exception as e: - return RedirectResponse( - url=f"/audit-full?msg=error_{str(e)[:50]}", - status_code=303, - ) - - -@router.get("/audit-full/patching", response_class=HTMLResponse) -async def audit_full_patching(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, "audit"): - return RedirectResponse(url="/dashboard") - - year = int(request.query_params.get("year", "2026")) - search = request.query_params.get("q", "").strip() - domain = request.query_params.get("domain", "") - scope = request.query_params.get("scope", "") # secops, other, ou vide=tout - page = int(request.query_params.get("page", "1")) - sort = request.query_params.get("sort", "hostname") - sort_dir = request.query_params.get("dir", "asc") - per_page = 30 - - yr_count = "patch_count_2026" if year == 2026 else "patch_count_2025" - yr_weeks = "patch_weeks_2026" if year == 2026 else "patch_weeks_2025" - - # KPIs globaux + secops/autre - _latest = "id IN (SELECT DISTINCT ON (hostname) id FROM server_audit_full WHERE status IN ('ok','partial') ORDER BY hostname, audit_date DESC)" - kpis = db.execute(text( - f"SELECT COUNT(*) as total," - f" COUNT(*) FILTER (WHERE {yr_count} >= 1) as patched," - f" COUNT(*) FILTER (WHERE {yr_count} = 1) as once," - f" COUNT(*) FILTER (WHERE {yr_count} >= 2) as twice," - f" COUNT(*) FILTER (WHERE {yr_count} >= 3) as thrice," - f" COUNT(*) FILTER (WHERE {yr_count} = 0 OR {yr_count} IS NULL) as never" - f" FROM server_audit_full WHERE status IN ('ok','partial') AND {_latest}" - )).fetchone() - - kpis_secops = db.execute(text( - f"SELECT COUNT(*) as total," - f" COUNT(*) FILTER (WHERE saf.{yr_count} >= 1) as patched," - f" COUNT(*) FILTER (WHERE saf.{yr_count} = 0 OR saf.{yr_count} IS NULL) as never" - f" FROM server_audit_full saf JOIN servers s ON saf.server_id = s.id" - f" WHERE saf.status IN ('ok','partial') AND s.patch_os_owner = 'secops'" - f" AND saf.{_latest}" - )).fetchone() - - kpis_other = db.execute(text( - f"SELECT COUNT(*) as total," - f" COUNT(*) FILTER (WHERE saf.{yr_count} >= 1) as patched," - f" COUNT(*) FILTER (WHERE saf.{yr_count} = 0 OR saf.{yr_count} IS NULL) as never" - f" FROM server_audit_full saf JOIN servers s ON saf.server_id = s.id" - f" WHERE saf.status IN ('ok','partial') AND (s.patch_os_owner != 'secops' OR s.patch_os_owner IS NULL)" - f" AND saf.{_latest}" - )).fetchone() - - # Comparaison Y-1 a meme semaine - compare = None - from datetime import datetime as _dt - current_week = _dt.now().isocalendar()[1] - if year == 2026: - # Cumulatif 2025 a la meme semaine (pre-calcule) - import json as _json, os as _os - cumul_2025_path = _os.path.join(_os.path.dirname(_os.path.abspath(__file__)), "..", "data", "cumul_2025_by_week.json") - prev_at_same_week = 0 - prev_total = 1045 - prev_data_ok = False - try: - with open(cumul_2025_path) as f: - cumul_2025 = _json.load(f) - prev_at_same_week = cumul_2025.get(str(current_week - 1), cumul_2025.get(str(current_week), 0)) - prev_data_ok = True - except Exception: - pass - - compare = db.execute(text( - f"SELECT" - f" COUNT(*) FILTER (WHERE patch_count_2026 >= 1) as current_patched," - f" COUNT(*) FILTER (WHERE patch_count_2025 >= 1) as prev_year_total," - f" COUNT(*) as total" - f" FROM server_audit_full WHERE status IN ('ok','partial') AND {_latest}" - )).fetchone() - compare = { - "current_patched": compare.current_patched, - "current_total": compare.total, - "prev_year_total": compare.prev_year_total, - "prev_at_same_week": prev_at_same_week, - "prev_total": prev_total, - "prev_data_ok": prev_data_ok, - "compare_week": current_week - 1, - } - - patch_by_domain = db.execute(text( - f"SELECT d.name as domain, d.code," - f" COUNT(DISTINCT saf.hostname) as total," - f" COUNT(DISTINCT saf.hostname) FILTER (WHERE saf.{yr_count} >= 1) as patched," - f" COUNT(DISTINCT saf.hostname) FILTER (WHERE saf.{yr_count} >= 2) as twice," - f" COUNT(DISTINCT saf.hostname) FILTER (WHERE saf.{yr_count} = 0 OR saf.{yr_count} IS NULL) as never" - f" FROM server_audit_full saf JOIN servers s ON saf.server_id = s.id" - f" JOIN domain_environments de ON s.domain_env_id = de.id JOIN domains d ON de.domain_id = d.id" - f" WHERE saf.status IN ('ok','partial')" - f" AND saf.id IN (SELECT DISTINCT ON (hostname) id FROM server_audit_full WHERE status IN ('ok','partial') ORDER BY hostname, audit_date DESC)" - f" GROUP BY d.name, d.code, d.display_order ORDER BY d.display_order" - )).fetchall() - - patch_weekly = [] - if year == 2026: - patch_weekly = db.execute(text(""" - SELECT week, SUM(patched)::int as patched, SUM(cancelled)::int as cancelled FROM ( - SELECT unnest(string_to_array(patch_weeks_2026, ',')) as week, 1 as patched, 0 as cancelled - FROM server_audit_full - WHERE status IN ('ok','partial') AND patch_weeks_2026 IS NOT NULL AND patch_weeks_2026 != '' - AND id IN (SELECT DISTINCT ON (hostname) id FROM server_audit_full WHERE status IN ('ok','partial') ORDER BY hostname, audit_date DESC) - UNION ALL - SELECT unnest(string_to_array(cancelled_weeks_2026, ',')) as week, 0 as patched, 1 as cancelled - FROM server_audit_full - WHERE status IN ('ok','partial') AND cancelled_weeks_2026 IS NOT NULL AND cancelled_weeks_2026 != '' - AND id IN (SELECT DISTINCT ON (hostname) id FROM server_audit_full WHERE status IN ('ok','partial') ORDER BY hostname, audit_date DESC) - ) combined WHERE week != '' GROUP BY week ORDER BY week - """)).fetchall() - - all_domains = db.execute(text("SELECT code, name, 'domain' as type FROM domains ORDER BY name")).fetchall() - all_zones = db.execute(text("SELECT name as code, name, 'zone' as type FROM zones ORDER BY name")).fetchall() - - servers = db.execute(text( - f"SELECT DISTINCT ON (saf.hostname) saf.id, saf.hostname, saf.os_release," - f" saf.last_patch_date, saf.last_patch_week, saf.last_patch_year," - f" saf.{yr_count} as patch_count, saf.{yr_weeks} as patch_weeks," - f" d.name as domain, e.name as env, z.name as zone" - f" FROM server_audit_full saf" - f" LEFT JOIN servers s ON saf.server_id = s.id" - f" LEFT JOIN domain_environments de ON s.domain_env_id = de.id" - f" LEFT JOIN domains d ON de.domain_id = d.id" - f" LEFT JOIN environments e ON de.environment_id = e.id" - f" LEFT JOIN zones z ON s.zone_id = z.id" - f" WHERE saf.status IN ('ok','partial')" - f" ORDER BY saf.hostname, saf.audit_date DESC" - )).fetchall() - - if domain: - zone_hosts = {r.hostname for r in db.execute(text( - "SELECT s.hostname FROM servers s JOIN zones z ON s.zone_id = z.id WHERE z.name = :n" - ), {"n": domain}).fetchall()} - if zone_hosts: - servers = [s for s in servers if s.hostname in zone_hosts] - else: - dom_hosts = {r.hostname for r in db.execute(text( - "SELECT s.hostname FROM servers s JOIN domain_environments de ON s.domain_env_id = de.id" - " JOIN domains d ON de.domain_id = d.id WHERE d.code = :dc" - ), {"dc": domain}).fetchall()} - servers = [s for s in servers if s.hostname in dom_hosts] - if search: - servers = [s for s in servers if search.lower() in s.hostname.lower()] - - # Filtre scope secops / autre - if scope == "secops": - secops_hosts = {r.hostname for r in db.execute(text( - "SELECT hostname FROM servers WHERE patch_os_owner = 'secops'" - )).fetchall()} - servers = [s for s in servers if s.hostname in secops_hosts] - elif scope == "other": - secops_hosts = {r.hostname for r in db.execute(text( - "SELECT hostname FROM servers WHERE patch_os_owner = 'secops'" - )).fetchall()} - servers = [s for s in servers if s.hostname not in secops_hosts] - - if sort == "hostname": - servers.sort(key=lambda s: s.hostname.lower(), reverse=(sort_dir == "desc")) - elif sort == "count": - servers.sort(key=lambda s: s.patch_count or 0, reverse=(sort_dir == "desc")) - elif sort == "last": - servers.sort(key=lambda s: s.last_patch_week or "", reverse=(sort_dir == "desc")) - - total_filtered = len(servers) - total_pages = max(1, (total_filtered + per_page - 1) // per_page) - page = max(1, min(page, total_pages)) - servers_page = servers[(page - 1) * per_page : page * per_page] - - ctx = base_context(request, db, user) - ctx.update({ - "app_name": APP_NAME, "year": year, "kpis": kpis, - "kpis_secops": kpis_secops, "kpis_other": kpis_other, - "compare": compare, - "patch_by_domain": patch_by_domain, "patch_weekly": patch_weekly, - "servers": servers_page, "all_domains": all_domains, "all_zones": all_zones, - "search": search, "domain": domain, "scope": scope, - "sort": sort, "sort_dir": sort_dir, - "page": page, "total_pages": total_pages, "total_filtered": total_filtered, - }) - return templates.TemplateResponse("audit_full_patching.html", ctx) - - -@router.get("/audit-full/patching/export-csv") -async def patching_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, "audit"): - return RedirectResponse(url="/audit-full") - - import io, csv - year = int(request.query_params.get("year", "2026")) - search = request.query_params.get("q", "").strip() - domain = request.query_params.get("domain", "") - scope = request.query_params.get("scope", "") - - yr_count = "patch_count_2026" if year == 2026 else "patch_count_2025" - yr_weeks = "patch_weeks_2026" if year == 2026 else "patch_weeks_2025" - - servers = db.execute(text( - f"SELECT DISTINCT ON (saf.hostname) saf.hostname," - f" saf.{yr_count} as patch_count, saf.{yr_weeks} as patch_weeks," - f" saf.last_patch_date, saf.last_patch_week, saf.last_patch_year," - f" saf.patch_status_2026," - f" d.name as domain, e.name as env, z.name as zone, s.os_family, s.etat" - f" FROM server_audit_full saf" - f" LEFT JOIN servers s ON saf.server_id = s.id" - f" LEFT JOIN domain_environments de ON s.domain_env_id = de.id" - f" LEFT JOIN domains d ON de.domain_id = d.id" - f" LEFT JOIN environments e ON de.environment_id = e.id" - f" LEFT JOIN zones z ON s.zone_id = z.id" - f" WHERE saf.status IN ('ok','partial')" - f" ORDER BY saf.hostname, saf.audit_date DESC" - )).fetchall() - - if scope == "secops": - secops = {r.hostname for r in db.execute(text("SELECT hostname FROM servers WHERE patch_os_owner = 'secops'")).fetchall()} - servers = [s for s in servers if s.hostname in secops] - elif scope == "other": - secops = {r.hostname for r in db.execute(text("SELECT hostname FROM servers WHERE patch_os_owner = 'secops'")).fetchall()} - servers = [s for s in servers if s.hostname not in secops] - if domain: - zone_hosts = {r.hostname for r in db.execute(text( - "SELECT s.hostname FROM servers s JOIN zones z ON s.zone_id = z.id WHERE z.name = :n" - ), {"n": domain}).fetchall()} - if zone_hosts: - servers = [s for s in servers if s.hostname in zone_hosts] - else: - dom_hosts = {r.hostname for r in db.execute(text( - "SELECT s.hostname FROM servers s JOIN domain_environments de ON s.domain_env_id = de.id" - " JOIN domains d ON de.domain_id = d.id WHERE d.code = :dc" - ), {"dc": domain}).fetchall()} - servers = [s for s in servers if s.hostname in dom_hosts] - if search: - servers = [s for s in servers if search.lower() in s.hostname.lower()] - - output = io.StringIO() - w = csv.writer(output, delimiter=";") - w.writerow(["Hostname", "OS", "Domaine", "Environnement", "Zone", "Etat", - "Nb patches", "Semaines", "Dernier patch", "Statut"]) - for s in servers: - w.writerow([ - s.hostname, s.os_family or "", s.domain or "", s.env or "", - s.zone or "", s.etat or "", s.patch_count or 0, - s.patch_weeks or "", s.last_patch_date or s.last_patch_week or "", - s.patch_status_2026 or "", - ]) - - output.seek(0) - return StreamingResponse( - iter(["\ufeff" + output.getvalue()]), - media_type="text/csv", - headers={"Content-Disposition": f"attachment; filename=patching_{year}.csv"}) - - -@router.get("/audit-full/export-csv") -async def audit_full_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, "audit"): - return RedirectResponse(url="/audit-full") - - import io, csv - filtre = request.query_params.get("filter", "") - search = request.query_params.get("q", "").strip() - domain = request.query_params.get("domain", "") - - audits = get_latest_audits(db, limit=9999) - - # Memes filtres que la page liste - if filtre == "reboot": - audits = [a for a in audits if a.reboot_required] - elif filtre == "uptime": - audits = [a for a in audits if a.uptime and ("month" in a.uptime or "year" in a.uptime)] - elif filtre and filtre.startswith("app_"): - app_patterns = { - "app_postgres": "postgres", "app_mariadb": "mariadb|mysqld", - "app_hana": "hdb|sapstart|HANA", "app_oracle": "ora_pmon|oracle", - "app_httpd": "httpd", "app_nginx": "nginx", "app_haproxy": "haproxy", - "app_tomcat": "tomcat|catalina", "app_nodejs": "node", - "app_redis": "redis", "app_mongodb": "mongod", - "app_elastic": "elasticsearch|opensearch", "app_container": "docker|podman", - "app_java": "java|\\.jar", - } - pattern = app_patterns.get(filtre, "") - if pattern: - ids = {r.id for r in db.execute(text(""" - SELECT id FROM server_audit_full - WHERE status IN ('ok','partial') - AND (services::text ~* :pat OR listen_ports::text ~* :pat OR processes::text ~* :pat) - AND id IN (SELECT DISTINCT ON (hostname) id FROM server_audit_full WHERE status IN ('ok','partial') ORDER BY hostname, audit_date DESC) - """), {"pat": pattern}).fetchall()} - audits = [a for a in audits if a.id in ids] - if domain: - zone_servers = {r.hostname for r in db.execute(text( - "SELECT s.hostname FROM servers s JOIN zones z ON s.zone_id = z.id WHERE z.name = :name" - ), {"name": domain}).fetchall()} - if zone_servers: - audits = [a for a in audits if a.hostname in zone_servers] - else: - domain_servers = {r.hostname for r in db.execute(text(""" - SELECT s.hostname FROM servers s JOIN domain_environments de ON s.domain_env_id = de.id - JOIN domains d ON de.domain_id = d.id WHERE d.code = :dc - """), {"dc": domain}).fetchall()} - audits = [a for a in audits if a.hostname in domain_servers] - if search: - q = search.lower() - audits = [a for a in audits if q in a.hostname.lower()] - - # Generer CSV - output = io.StringIO() - writer = csv.writer(output, delimiter=";") - writer.writerow(["Hostname", "OS", "Kernel", "Uptime", "Services", "Processus", - "Ports", "Connexions", "Reboot requis", "Date audit"]) - for a in audits: - writer.writerow([ - a.hostname, a.os_release or "", a.kernel or "", a.uptime or "", - a.svc_count, a.proc_count, a.port_count, a.conn_count, - "Oui" if a.reboot_required else "Non", - a.audit_date.strftime("%Y-%m-%d %H:%M") if a.audit_date else "", - ]) - - output.seek(0) - return StreamingResponse( - iter(["\ufeff" + output.getvalue()]), - media_type="text/csv", - headers={"Content-Disposition": "attachment; filename=audit_serveurs.csv"}, - ) - - -@router.get("/audit-full/flow-map", response_class=HTMLResponse) -async def audit_full_flow_map(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, "audit"): - return RedirectResponse(url="/audit-full") - - domain_filter = request.query_params.get("domain", "") - server_filter = request.query_params.get("server", "").strip() - - # Domaines + zones pour le dropdown - all_domains = db.execute(text( - "SELECT code, name, 'domain' as type FROM domains ORDER BY name" - )).fetchall() - all_zones = db.execute(text( - "SELECT name as code, name, 'zone' as type FROM zones ORDER BY name" - )).fetchall() - - # Serveurs audites pour l'autocompletion - audited_servers = db.execute(text(""" - SELECT DISTINCT hostname FROM server_audit_full WHERE status IN ('ok','partial') ORDER BY hostname - """)).fetchall() - - if server_filter: - # Flux pour un serveur specifique (IN + OUT) - flows = db.execute(text(""" - SELECT source_hostname, source_ip, dest_ip, dest_port, - dest_hostname, process_name, direction, state, - COUNT(*) as cnt - FROM network_flow_map nfm - JOIN server_audit_full saf ON nfm.audit_id = saf.id - WHERE saf.id IN ( - SELECT DISTINCT ON (hostname) id FROM server_audit_full - WHERE status IN ('ok','partial') ORDER BY hostname, audit_date DESC - ) - AND (nfm.source_hostname = :srv OR nfm.dest_hostname = :srv) - AND nfm.source_hostname != nfm.dest_hostname - AND nfm.dest_hostname IS NOT NULL - GROUP BY source_hostname, source_ip, dest_ip, dest_port, - dest_hostname, process_name, direction, state - ORDER BY source_hostname - """), {"srv": server_filter}).fetchall() - elif domain_filter: - # Flux pour un domaine ou une zone - # D'abord chercher comme zone - hostnames = [r.hostname for r in db.execute(text(""" - SELECT s.hostname FROM servers s - JOIN zones z ON s.zone_id = z.id WHERE z.name = :name - """), {"name": domain_filter}).fetchall()] - if not hostnames: - # Sinon comme domaine - hostnames = [r.hostname for r in db.execute(text(""" - SELECT s.hostname FROM servers s - JOIN domain_environments de ON s.domain_env_id = de.id - JOIN domains d ON de.domain_id = d.id WHERE d.code = :dc - """), {"dc": domain_filter}).fetchall()] - if hostnames: - flows = db.execute(text(""" - SELECT source_hostname, source_ip, dest_ip, dest_port, - dest_hostname, process_name, direction, state, - COUNT(*) as cnt - FROM network_flow_map nfm - JOIN server_audit_full saf ON nfm.audit_id = saf.id - WHERE saf.id IN ( - SELECT DISTINCT ON (hostname) id FROM server_audit_full - WHERE status IN ('ok','partial') ORDER BY hostname, audit_date DESC - ) - AND (nfm.source_hostname = ANY(:hosts) OR nfm.dest_hostname = ANY(:hosts)) - AND nfm.source_hostname != COALESCE(nfm.dest_hostname, '') - AND nfm.dest_hostname IS NOT NULL - GROUP BY source_hostname, source_ip, dest_ip, dest_port, - dest_hostname, process_name, direction, state - ORDER BY source_hostname - """), {"hosts": hostnames}).fetchall() - else: - flows = [] - else: - flows = get_flow_map(db) - - app_map = get_app_map(db) - - ctx = base_context(request, db, user) - ctx.update({ - "app_name": APP_NAME, "flows": flows, "app_map": app_map, - "all_domains": all_domains, "all_zones": all_zones, - "audited_servers": audited_servers, - "domain_filter": domain_filter, "server_filter": server_filter, - }) - return templates.TemplateResponse("audit_full_flowmap.html", ctx) - - -@router.get("/audit-full/{audit_id}", response_class=HTMLResponse) -async def audit_full_detail(request: Request, audit_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_view(perms, "audit"): - return RedirectResponse(url="/audit-full") - - audit = get_audit_detail(db, audit_id) - if not audit: - return RedirectResponse(url="/audit-full") - - def _j(val, default): - if val is None: return default - if isinstance(val, (list, dict)): return val - try: return json.loads(val) - except: return default - - # Serveur partial (pas encore audite via SSH) - is_partial = (audit.status == "partial") - - flows = [] if is_partial else get_flow_map_for_server(db, audit.hostname) - - ctx = base_context(request, db, user) - ctx.update({ - "app_name": APP_NAME, "a": audit, "flows": flows, - "is_partial": is_partial, - "services": _j(audit.services, []), - "processes": _j(audit.processes, []), - "listen_ports": _j(audit.listen_ports, []), - "connections": _j(audit.connections, []), - "flux_in": _j(audit.flux_in, []), - "flux_out": _j(audit.flux_out, []), - "disk_usage": _j(audit.disk_usage, []), - "interfaces": _j(audit.interfaces, []), - "correlation": _j(audit.correlation_matrix, []), - "outbound": _j(audit.outbound_only, []), - "firewall": _j(audit.firewall, {}), - "conn_wait": _j(audit.conn_wait, []), - "traffic": _j(audit.traffic, []), - }) - return templates.TemplateResponse("audit_full_detail.html", ctx) diff --git a/app/routers/safe_patching.py b/app/routers/safe_patching.py deleted file mode 100644 index 89f1a41..0000000 --- a/app/routers/safe_patching.py +++ /dev/null @@ -1,262 +0,0 @@ -"""Router Safe Patching — Quick Win campagnes + SSE terminal live""" -import asyncio, json -from datetime import datetime -from fastapi import APIRouter, Request, Depends, Query, Form -from fastapi.responses import HTMLResponse, RedirectResponse, StreamingResponse -from fastapi.templating import Jinja2Templates -from sqlalchemy import text -from ..dependencies import get_db, get_current_user, get_user_perms, can_view, can_edit, base_context -from ..services.safe_patching_service import ( - create_quickwin_campaign, get_quickwin_stats, build_yum_command, build_safe_excludes, -) -from ..services.campaign_service import get_campaign, get_campaign_sessions, get_campaign_stats -from ..services.patching_executor import get_stream, start_execution, emit -from ..config import APP_NAME, DATABASE_URL - -router = APIRouter() -templates = Jinja2Templates(directory="app/templates") - - -@router.get("/safe-patching", response_class=HTMLResponse) -async def safe_patching_page(request: Request, db=Depends(get_db)): - user = get_current_user(request) - if not user: - return RedirectResponse(url="/login") - perms = get_user_perms(db, user) - if not can_view(perms, "campaigns"): - return RedirectResponse(url="/dashboard") - - # Campagnes quickwin existantes - campaigns = db.execute(text(""" - SELECT c.*, u.display_name as created_by_name, - (SELECT COUNT(*) FROM patch_sessions ps WHERE ps.campaign_id = c.id) as session_count, - (SELECT COUNT(*) FROM patch_sessions ps WHERE ps.campaign_id = c.id AND ps.status = 'patched') as patched_count - FROM campaigns c LEFT JOIN users u ON c.created_by = u.id - WHERE c.campaign_type = 'quickwin' - ORDER BY c.year DESC, c.week_code DESC - """)).fetchall() - - # Intervenants pour le formulaire - operators = db.execute(text( - "SELECT id, display_name FROM users WHERE is_active = true AND role = 'operator' ORDER BY display_name" - )).fetchall() - - now = datetime.now() - current_week = now.isocalendar()[1] - current_year = now.isocalendar()[0] - - ctx = base_context(request, db, user) - ctx.update({ - "app_name": APP_NAME, "campaigns": campaigns, - "operators": operators, - "current_week": current_week, "current_year": current_year, - "can_create": can_edit(perms, "campaigns"), - "msg": request.query_params.get("msg"), - }) - return templates.TemplateResponse("safe_patching.html", ctx) - - -@router.post("/safe-patching/create") -async def safe_patching_create(request: Request, db=Depends(get_db), - label: str = Form(""), week_number: str = Form("0"), - year: str = Form("0"), lead_id: str = Form("0"), - assistant_id: str = Form("")): - user = get_current_user(request) - if not user: - return RedirectResponse(url="/login") - perms = get_user_perms(db, user) - if not can_edit(perms, "campaigns"): - return RedirectResponse(url="/safe-patching") - - wn = int(week_number) if week_number else 0 - yr = int(year) if year else 0 - lid = int(lead_id) if lead_id else 0 - aid = int(assistant_id) if assistant_id.strip() else None - - if not label: - label = f"Quick Win S{wn:02d} {yr}" - - try: - cid = create_quickwin_campaign(db, yr, wn, label, lid, aid) - return RedirectResponse(url=f"/safe-patching/{cid}", status_code=303) - except Exception: - db.rollback() - return RedirectResponse(url="/safe-patching?msg=error", status_code=303) - - -@router.get("/safe-patching/{campaign_id}", response_class=HTMLResponse) -async def safe_patching_detail(request: Request, campaign_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_view(perms, "campaigns"): - return RedirectResponse(url="/dashboard") - - campaign = get_campaign(db, campaign_id) - if not campaign: - return RedirectResponse(url="/safe-patching") - - sessions = get_campaign_sessions(db, campaign_id) - stats = get_campaign_stats(db, campaign_id) - qw_stats = get_quickwin_stats(db, campaign_id) - - # Séparer hprod et prod - hprod = [s for s in sessions if s.environnement != 'Production' and s.status != 'excluded'] - prod = [s for s in sessions if s.environnement == 'Production' and s.status != 'excluded'] - excluded = [s for s in sessions if s.status == 'excluded'] - - # Commande safe patching - safe_cmd = build_yum_command() - safe_excludes = build_safe_excludes() - - # Déterminer le step courant - if qw_stats.hprod_patched > 0 or qw_stats.prod_patched > 0: - current_step = "postcheck" - elif any(s.prereq_validated for s in sessions if s.status == 'pending'): - current_step = "execute" - else: - current_step = "prereqs" - - ctx = base_context(request, db, user) - ctx.update({ - "app_name": APP_NAME, "c": campaign, - "sessions": sessions, "stats": stats, "qw_stats": qw_stats, - "hprod": hprod, "prod": prod, "excluded": excluded, - "safe_cmd": safe_cmd, "safe_excludes": safe_excludes, - "current_step": request.query_params.get("step", current_step), - "msg": request.query_params.get("msg"), - }) - return templates.TemplateResponse("safe_patching_detail.html", ctx) - - -@router.post("/safe-patching/{campaign_id}/delete") -async def safe_patching_delete(request: Request, campaign_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 perms.get("campaigns") != "admin": - return RedirectResponse(url="/safe-patching", status_code=303) - db.execute(text("DELETE FROM campaign_operator_limits WHERE campaign_id = :cid"), {"cid": campaign_id}) - db.execute(text("DELETE FROM patch_sessions WHERE campaign_id = :cid"), {"cid": campaign_id}) - db.execute(text("DELETE FROM campaigns WHERE id = :cid"), {"cid": campaign_id}) - db.commit() - return RedirectResponse(url="/safe-patching?msg=deleted", status_code=303) - - -@router.post("/safe-patching/{campaign_id}/check-prereqs") -async def safe_patching_check_prereqs(request: Request, campaign_id: int, db=Depends(get_db), - branch: str = Form("hprod")): - user = get_current_user(request) - if not user: - return RedirectResponse(url="/login") - perms = get_user_perms(db, user) - if not can_edit(perms, "campaigns"): - return RedirectResponse(url=f"/safe-patching/{campaign_id}") - from ..services.prereq_service import check_prereqs_campaign - checked, auto_excluded = check_prereqs_campaign(db, campaign_id) - return RedirectResponse(url=f"/safe-patching/{campaign_id}?step=prereqs&msg=prereqs_done", status_code=303) - - -@router.post("/safe-patching/{campaign_id}/bulk-exclude") -async def safe_patching_bulk_exclude(request: Request, campaign_id: int, db=Depends(get_db), - session_ids: str = Form("")): - user = get_current_user(request) - if not user: - return RedirectResponse(url="/login") - perms = get_user_perms(db, user) - if not can_edit(perms, "campaigns"): - return RedirectResponse(url=f"/safe-patching/{campaign_id}") - from ..services.campaign_service import exclude_session - ids = [int(x) for x in session_ids.split(",") if x.strip().isdigit()] - for sid in ids: - exclude_session(db, sid, "autre", "Exclu du Quick Win", user.get("sub")) - return RedirectResponse(url=f"/safe-patching/{campaign_id}?msg=excluded_{len(ids)}", status_code=303) - - -@router.post("/safe-patching/{campaign_id}/execute") -async def safe_patching_execute(request: Request, campaign_id: int, db=Depends(get_db), - branch: str = Form("hprod")): - """Lance l'exécution du safe patching pour une branche""" - user = get_current_user(request) - if not user: - return RedirectResponse(url="/login") - perms = get_user_perms(db, user) - if not can_edit(perms, "campaigns"): - return RedirectResponse(url=f"/safe-patching/{campaign_id}") - - # Récupérer les sessions pending de la branche - if branch == "hprod": - sessions = db.execute(text(""" - SELECT ps.id FROM patch_sessions ps - JOIN servers s ON ps.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 ps.campaign_id = :cid AND ps.status = 'pending' AND e.name != 'Production' - ORDER BY s.hostname - """), {"cid": campaign_id}).fetchall() - else: - sessions = db.execute(text(""" - SELECT ps.id FROM patch_sessions ps - JOIN servers s ON ps.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 ps.campaign_id = :cid AND ps.status = 'pending' AND e.name = 'Production' - ORDER BY s.hostname - """), {"cid": campaign_id}).fetchall() - - session_ids = [s.id for s in sessions] - if not session_ids: - return RedirectResponse(url=f"/safe-patching/{campaign_id}?msg=no_pending", status_code=303) - - # Passer la campagne en in_progress - db.execute(text("UPDATE campaigns SET status = 'in_progress' WHERE id = :cid"), {"cid": campaign_id}) - db.commit() - - # Lancer en background - start_execution(DATABASE_URL, campaign_id, session_ids, branch) - - return RedirectResponse(url=f"/safe-patching/{campaign_id}/terminal?branch={branch}", status_code=303) - - -@router.get("/safe-patching/{campaign_id}/terminal", response_class=HTMLResponse) -async def safe_patching_terminal(request: Request, campaign_id: int, db=Depends(get_db), - branch: str = Query("hprod")): - """Page terminal live""" - user = get_current_user(request) - if not user: - return RedirectResponse(url="/login") - perms = get_user_perms(db, user) - if not can_view(perms, "campaigns"): - return RedirectResponse(url="/safe-patching") - campaign = get_campaign(db, campaign_id) - ctx = base_context(request, db, user) - ctx.update({"app_name": APP_NAME, "c": campaign, "branch": branch}) - return templates.TemplateResponse("safe_patching_terminal.html", ctx) - - -@router.get("/safe-patching/{campaign_id}/stream") -async def safe_patching_stream(request: Request, campaign_id: int, db=Depends(get_db)): - """SSE endpoint — stream les logs en temps réel""" - user = get_current_user(request) - if not user: - return StreamingResponse(iter([]), media_type="text/event-stream") - async def event_generator(): - q = get_stream(campaign_id) - while True: - try: - msg = q.get(timeout=0.5) - data = json.dumps(msg) - yield f"data: {data}\n\n" - if msg.get("level") == "done": - break - except Exception: - yield f": keepalive\n\n" - await asyncio.sleep(0.3) - - return StreamingResponse( - event_generator(), - media_type="text/event-stream", - headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"} - ) diff --git a/app/services/patching_executor.py b/app/services/patching_executor.py deleted file mode 100644 index c5a8c9e..0000000 --- a/app/services/patching_executor.py +++ /dev/null @@ -1,146 +0,0 @@ -"""Exécuteur de patching — exécute les commandes SSH et stream les résultats""" -import threading -import queue -import time -from datetime import datetime -from sqlalchemy import text - -# File de messages par campagne (thread-safe) -_streams = {} # campaign_id -> queue.Queue - - -def get_stream(campaign_id): - """Récupère ou crée la file de messages pour une campagne""" - if campaign_id not in _streams: - _streams[campaign_id] = queue.Queue(maxsize=1000) - return _streams[campaign_id] - - -def emit(campaign_id, msg, level="info"): - """Émet un message dans le stream""" - ts = datetime.now().strftime("%H:%M:%S") - q = get_stream(campaign_id) - try: - q.put_nowait({"ts": ts, "msg": msg, "level": level}) - except queue.Full: - pass # Drop si full - - -def clear_stream(campaign_id): - """Vide le stream""" - if campaign_id in _streams: - while not _streams[campaign_id].empty(): - try: - _streams[campaign_id].get_nowait() - except queue.Empty: - break - - -def execute_safe_patching(db_url, campaign_id, session_ids, branch="hprod"): - """Exécute le safe patching en background (thread)""" - from sqlalchemy import create_engine, text - engine = create_engine(db_url) - - emit(campaign_id, f"=== Safe Patching — Branche {'Hors-prod' if branch == 'hprod' else 'Production'} ===", "header") - emit(campaign_id, f"{len(session_ids)} serveur(s) à traiter", "info") - emit(campaign_id, "") - - with engine.connect() as conn: - for i, sid in enumerate(session_ids, 1): - row = conn.execute(text(""" - SELECT ps.id, s.hostname, s.fqdn, s.satellite_host, s.machine_type - FROM patch_sessions ps JOIN servers s ON ps.server_id = s.id - WHERE ps.id = :sid - """), {"sid": sid}).fetchone() - - if not row: - continue - - hn = row.hostname - emit(campaign_id, f"[{i}/{len(session_ids)}] {hn}", "server") - - # Step 1: Check SSH - emit(campaign_id, f" Connexion SSH...", "step") - ssh_ok = _check_ssh(hn) - if ssh_ok: - emit(campaign_id, f" SSH : OK", "ok") - else: - emit(campaign_id, f" SSH : ÉCHEC — serveur ignoré", "error") - conn.execute(text("UPDATE patch_sessions SET status = 'failed' WHERE id = :id"), {"id": sid}) - conn.commit() - continue - - # Step 2: Check disk - emit(campaign_id, f" Espace disque...", "step") - emit(campaign_id, f" Disque : OK (mode démo)", "ok") - - # Step 3: Check satellite - emit(campaign_id, f" Satellite...", "step") - emit(campaign_id, f" Satellite : OK (mode démo)", "ok") - - # Step 4: Snapshot - if row.machine_type == 'vm': - emit(campaign_id, f" Snapshot vSphere...", "step") - emit(campaign_id, f" Snapshot : OK (mode démo)", "ok") - - # Step 5: Save state - emit(campaign_id, f" Sauvegarde services/ports...", "step") - emit(campaign_id, f" État sauvegardé", "ok") - - # Step 6: Dry run - emit(campaign_id, f" Dry run yum check-update...", "step") - time.sleep(0.3) # Simule - emit(campaign_id, f" X packages disponibles (mode démo)", "info") - - # Step 7: Patching - emit(campaign_id, f" Exécution safe patching...", "step") - time.sleep(0.5) # Simule - emit(campaign_id, f" Patching : OK (mode démo)", "ok") - - # Step 8: Post-check - emit(campaign_id, f" Vérification post-patch...", "step") - emit(campaign_id, f" needs-restarting : pas de reboot ✓", "ok") - emit(campaign_id, f" Services : identiques ✓", "ok") - - # Update status - conn.execute(text(""" - UPDATE patch_sessions SET status = 'patched', date_realise = now() WHERE id = :id - """), {"id": sid}) - conn.commit() - - emit(campaign_id, f" → {hn} PATCHÉ ✓", "success") - emit(campaign_id, "") - - # Fin - emit(campaign_id, f"=== Terminé — {len(session_ids)} serveur(s) traité(s) ===", "header") - emit(campaign_id, "__DONE__", "done") - - -def _check_ssh(hostname): - """Check SSH TCP (mode démo = toujours OK)""" - import socket - suffixes = ["", ".sanef.groupe", ".sanef-rec.fr"] - for suffix in suffixes: - try: - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(3) - r = sock.connect_ex((hostname + suffix, 22)) - sock.close() - if r == 0: - return True - except Exception: - continue - # Mode démo : retourner True même si pas joignable - return True - - -def start_execution(db_url, campaign_id, session_ids, branch="hprod"): - """Lance l'exécution dans un thread séparé""" - clear_stream(campaign_id) - t = threading.Thread( - target=execute_safe_patching, - args=(db_url, campaign_id, session_ids, branch), - daemon=True - ) - t.start() - return t diff --git a/app/services/safe_patching_service.py b/app/services/safe_patching_service.py deleted file mode 100644 index 9d679de..0000000 --- a/app/services/safe_patching_service.py +++ /dev/null @@ -1,110 +0,0 @@ -"""Service Safe Patching — Quick Win : patching sans interruption de service""" -from datetime import datetime -from sqlalchemy import text - -# Packages qui TOUJOURS nécessitent un reboot -REBOOT_PACKAGES = [ - "kernel", "kernel-core", "kernel-modules", "kernel-tools", - "glibc", "glibc-common", "glibc-devel", - "systemd", "systemd-libs", "systemd-udev", - "dbus", "dbus-libs", "dbus-daemon", - "linux-firmware", "microcode_ctl", - "polkit", "polkit-libs", - "tuned", -] - -# Standard excludes (middleware/apps — jamais en safe) -STD_EXCLUDES = [ - "mongodb*", "mysql*", "postgres*", "mariadb*", "oracle*", "pgdg*", - "php*", "java*", "redis*", "elasticsearch*", "nginx*", "mod_ssl*", - "haproxy*", "certbot*", "python-certbot*", "docker*", "podman*", - "centreon*", "qwserver*", "ansible*", "node*", "tina*", "memcached*", - "nextcloud*", "pgbouncer*", "pgpool*", "pgbadger*", "psycopg2*", - "barman*", "kibana*", "splunk*", -] - - -def build_safe_excludes(): - """Construit la liste d'exclusions pour le safe patching""" - excludes = list(REBOOT_PACKAGES) + [e.replace("*", "") for e in STD_EXCLUDES] - return excludes - - -def build_yum_command(extra_excludes=None): - """Génère la commande yum update safe""" - all_excludes = REBOOT_PACKAGES + STD_EXCLUDES - if extra_excludes: - all_excludes += extra_excludes - exclude_str = " ".join([f"--exclude={e}*" if not e.endswith("*") else f"--exclude={e}" for e in all_excludes]) - return f"yum update {exclude_str} -y" - - -def create_quickwin_campaign(db, year, week_number, label, user_id, assistant_id=None): - """Crée une campagne Quick Win avec les deux branches (hprod + prod)""" - from .campaign_service import _week_dates - wc = f"S{week_number:02d}" - lun, mar, mer, jeu = _week_dates(year, week_number) - - row = db.execute(text(""" - INSERT INTO campaigns (week_code, year, label, status, date_start, date_end, - created_by, campaign_type) - VALUES (:wc, :y, :label, 'draft', :ds, :de, :uid, 'quickwin') - RETURNING id - """), {"wc": wc, "y": year, "label": label, "ds": lun, "de": jeu, "uid": user_id}).fetchone() - cid = row.id - - # Tous les serveurs Linux en prod secops - servers = db.execute(text(""" - SELECT s.id, s.hostname, e.name as env_name - FROM servers s - LEFT JOIN domain_environments de ON s.domain_env_id = de.id - LEFT JOIN environments e ON de.environment_id = e.id - WHERE s.etat = 'en_production' AND s.patch_os_owner = 'secops' - AND s.licence_support IN ('active', 'els') AND s.os_family = 'linux' - ORDER BY e.name, s.hostname - """)).fetchall() - - for s in servers: - is_prod = (s.env_name == 'Production') - date_prevue = mer if is_prod else lun # hprod lundi, prod mercredi - db.execute(text(""" - INSERT INTO patch_sessions (campaign_id, server_id, status, date_prevue, - intervenant_id, forced_assignment, assigned_at) - VALUES (:cid, :sid, 'pending', :dp, :uid, true, now()) - ON CONFLICT (campaign_id, server_id) DO NOTHING - """), {"cid": cid, "sid": s.id, "dp": date_prevue, "uid": user_id}) - - # Assigner l'assistant si défini - if assistant_id: - db.execute(text(""" - INSERT INTO campaign_operator_limits (campaign_id, user_id, max_servers, note) - VALUES (:cid, :aid, 0, 'Assistant Quick Win') - """), {"cid": cid, "aid": assistant_id}) - - count = db.execute(text( - "SELECT COUNT(*) FROM patch_sessions WHERE campaign_id = :cid" - ), {"cid": cid}).scalar() - db.execute(text("UPDATE campaigns SET total_servers = :c WHERE id = :cid"), - {"c": count, "cid": cid}) - - db.commit() - return cid - - -def get_quickwin_stats(db, campaign_id): - """Stats Quick Win par branche""" - return db.execute(text(""" - SELECT - COUNT(*) FILTER (WHERE e.name != 'Production') as hprod_total, - COUNT(*) FILTER (WHERE e.name != 'Production' AND ps.status = 'patched') as hprod_patched, - COUNT(*) FILTER (WHERE e.name != 'Production' AND ps.status = 'failed') as hprod_failed, - COUNT(*) FILTER (WHERE e.name = 'Production') as prod_total, - COUNT(*) FILTER (WHERE e.name = 'Production' AND ps.status = 'patched') as prod_patched, - COUNT(*) FILTER (WHERE e.name = 'Production' AND ps.status = 'failed') as prod_failed, - COUNT(*) FILTER (WHERE ps.status = 'excluded') as excluded - FROM patch_sessions ps - JOIN servers s ON ps.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 ps.campaign_id = :cid - """), {"cid": campaign_id}).fetchone() diff --git a/app/services/server_audit_full_service.py b/app/services/server_audit_full_service.py deleted file mode 100644 index 0c636d7..0000000 --- a/app/services/server_audit_full_service.py +++ /dev/null @@ -1,504 +0,0 @@ -"""Service audit complet serveur — applicatif + reseau + correlation + carte flux -Adapte du standalone SANEF corrige pour PatchCenter (FastAPI/PostgreSQL) -""" -import json -import re -import os -import socket -import logging -from datetime import datetime -from sqlalchemy import text - -logging.getLogger("paramiko").setLevel(logging.CRITICAL) -logging.getLogger("paramiko.transport").setLevel(logging.CRITICAL) - -try: - import paramiko - PARAMIKO_OK = True -except ImportError: - PARAMIKO_OK = False - -SSH_KEY_FILE = "/opt/patchcenter/keys/id_rsa_cybglobal.pem" -PSMP_HOST = "psmp.sanef.fr" -CYBR_USER = "CYBP01336" -TARGET_USER = "cybsecope" -SSH_TIMEOUT = 20 - -ENV_DOMAINS = { - "prod": ".sanef.groupe", - "preprod": ".sanef.groupe", - "recette": ".sanef-rec.fr", - "test": ".sanef-rec.fr", - "dev": ".sanef-rec.fr", -} - -BANNER_FILTERS = [ - "GROUPE SANEF", "propriete du Groupe", "accederait", "emprisonnement", - "Article 323", "code penal", "Authorized uses only", "CyberArk", - "This session", "session is being", -] - -SCRIPT_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "scripts", "server_audit.sh") - - -def _load_script(): - with open(SCRIPT_PATH, "r", encoding="utf-8") as f: - return f.read() - - -def _get_psmp_password(db=None): - if not db: - return None - try: - from .secrets_service import get_secret - return get_secret(db, "ssh_pwd_default_pass") - except Exception: - return None - - -# ── DETECTION ENV + SSH (pattern SANEF corrige) ── - -def detect_env(hostname): - h = hostname.lower() - c = h[1] if len(h) > 1 else "" - if c == "p": return "prod" - elif c == "i": return "preprod" - elif c == "r": return "recette" - elif c == "v": return "test" - elif c == "d": return "dev" - return "recette" - - -def _load_key(): - if not os.path.exists(SSH_KEY_FILE): - return None - for cls in [paramiko.RSAKey, paramiko.Ed25519Key, paramiko.ECDSAKey]: - try: - return cls.from_private_key_file(SSH_KEY_FILE) - except Exception: - continue - return None - - -def _build_fqdn_candidates(hostname): - if "." in hostname: - return [hostname] - c = hostname[1] if len(hostname) > 1 else "" - if c in ("p", "i"): - return [f"{hostname}.sanef.groupe", f"{hostname}.sanef-rec.fr", hostname] - else: - return [f"{hostname}.sanef-rec.fr", f"{hostname}.sanef.groupe", hostname] - - -def _try_psmp(fqdn, password): - if not password: - return None - 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 - except Exception: - return None - - -def _try_key(fqdn, key): - if not key: - return None - try: - client = paramiko.SSHClient() - client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) - client.connect(fqdn, port=22, username=TARGET_USER, pkey=key, - timeout=SSH_TIMEOUT, look_for_keys=False, allow_agent=False) - return client - except Exception: - return None - - -def ssh_connect(hostname, password=None): - fqdn_candidates = _build_fqdn_candidates(hostname) - key = _load_key() - for fqdn in fqdn_candidates: - if password: - client = _try_psmp(fqdn, password) - if client: - return client, None - if key: - client = _try_key(fqdn, key) - if client: - return client, None - return None, f"Connexion impossible sur {fqdn_candidates}" - - -def ssh_run_script(client, script_content, timeout=300): - try: - chan = client._transport.open_session() - chan.settimeout(timeout) - chan.exec_command("bash -s") - chan.sendall(script_content.encode("utf-8")) - chan.shutdown_write() - out = b"" - while True: - try: - chunk = chan.recv(8192) - if not chunk: - break - out += chunk - except Exception: - break - chan.close() - out_str = out.decode("utf-8", errors="replace") - if not out_str.strip(): - return "", "Sortie vide" - lines = [l for l in out_str.splitlines() if not any(b in l for b in BANNER_FILTERS)] - return "\n".join(lines), None - except Exception as e: - return "", str(e) - - -# ── PARSING ── - -def parse_audit_output(raw): - result = { - "hostname": "", "os_release": "", "kernel": "", "uptime": "", - "services": [], "processes": [], "services_failed": "", - "needs_restarting": "", "reboot_required": False, "disk_usage": [], - "interfaces": [], "routes": [], "listen_ports": [], - "connections": [], "flux_in": [], "flux_out": [], - "conn_wait": [], "net_stats": {}, "traffic": [], - "firewall": {"policy": {}, "input": [], "output": [], "firewalld": []}, - "correlation_matrix": [], "outbound_only": [], - } - section = None - firewall_sub = None - - for line in raw.splitlines(): - ls = line.strip() - m = re.match(r"^# AUDIT COMPLET .+ (.+)$", ls) - if m: result["hostname"] = m.group(1); continue - m = re.match(r"^# OS: (.+)$", ls) - if m: result["os_release"] = m.group(1); continue - m = re.match(r"^# Kernel: (.+)$", ls) - if m: result["kernel"] = m.group(1); continue - m = re.match(r"^# Uptime: (.+)$", ls) - if m: result["uptime"] = m.group(1); continue - if "1.1 SERVICES APPLICATIFS" in ls: section = "services"; continue - elif "1.2 PROCESSUS APPLICATIFS" in ls: section = "processes"; continue - elif "1.3 SERVICES EN ECHEC" in ls: section = "services_failed"; continue - elif "1.4 NEEDS-RESTARTING" in ls: section = "needs_restarting"; continue - elif "1.5 ESPACE DISQUE" in ls: section = "disk"; continue - elif "2.1 INTERFACES" in ls: section = "interfaces"; continue - elif "2.2 TABLE DE ROUTAGE" in ls: section = "routes"; continue - elif "2.3 PORTS EN ECOUTE" in ls: section = "listen_ports"; continue - elif "2.4 CONNEXIONS ETABLIES" in ls: section = "connections"; continue - elif "2.5 RESUME FLUX ENTRANTS" in ls: section = "flux_in"; continue - elif "2.6 RESUME FLUX SORTANTS" in ls: section = "flux_out"; continue - elif "2.7 CONNEXIONS EN ATTENTE" in ls: section = "conn_wait"; continue - elif "2.8 STATISTIQUES" in ls: section = "net_stats"; continue - elif "2.9 TRAFIC" in ls: section = "traffic"; continue - elif "2.10 FIREWALL" in ls: section = "firewall"; firewall_sub = None; continue - elif "3.1 MATRICE" in ls: section = "correlation"; continue - elif "3.2 PROCESS SORTANTS" in ls: section = "outbound"; continue - elif ls.startswith("===") or ls.startswith("###"): section = None; continue - if not ls: continue - headers = ["SERVICE|","PID|PPID","PROTO|","DIRECTION|","PORT|","DEST_IP|", - "METRIC|","INTERFACE|","DESTINATION|","NUM|","PROCESS|USER", - "ZONE|","Mont","STATE|COUNT"] - if any(ls.startswith(h) for h in headers): continue - parts = ls.split("|") - if section == "services" and len(parts) >= 2: - result["services"].append({"name":parts[0],"enabled":parts[1],"pid":parts[2] if len(parts)>2 else "","user":parts[3] if len(parts)>3 else "","exec":parts[4] if len(parts)>4 else ""}) - elif section == "processes" and len(parts) >= 6: - result["processes"].append({"pid":parts[0],"ppid":parts[1],"user":parts[2],"exe":parts[3],"cwd":parts[4],"cmdline":parts[5],"restart_hint":parts[6] if len(parts)>6 else ""}) - elif section == "services_failed": - if ls != "Aucun service en echec": result["services_failed"] += ls + "\n" - elif section == "needs_restarting": - result["needs_restarting"] += ls + "\n" - if "EXIT_CODE=1" in ls: result["reboot_required"] = True - elif section == "disk": - p = ls.split() - if len(p) >= 5 and "%" in p[-1]: - try: result["disk_usage"].append({"mount":p[0],"size":p[1],"used":p[2],"avail":p[3],"pct":int(p[4].replace("%",""))}) - except: pass - elif section == "interfaces" and len(parts) >= 3: - result["interfaces"].append({"iface":parts[0],"ip":parts[1],"mask":parts[2],"state":parts[3] if len(parts)>3 else "","mac":parts[4] if len(parts)>4 else ""}) - elif section == "routes" and len(parts) >= 3: - result["routes"].append({"dest":parts[0],"gw":parts[1],"iface":parts[2],"metric":parts[3] if len(parts)>3 else ""}) - elif section == "listen_ports" and len(parts) >= 3: - result["listen_ports"].append({"proto":parts[0],"addr_port":parts[1],"pid":parts[2],"process":parts[3] if len(parts)>3 else "","user":parts[4] if len(parts)>4 else "","service":parts[5] if len(parts)>5 else ""}) - elif section == "connections" and len(parts) >= 5: - result["connections"].append({"direction":parts[0],"proto":parts[1],"local":parts[2],"remote":parts[3],"pid":parts[4],"process":parts[5] if len(parts)>5 else "","user":parts[6] if len(parts)>6 else "","state":parts[7] if len(parts)>7 else ""}) - elif section == "flux_in" and len(parts) >= 3: - result["flux_in"].append({"port":parts[0],"service":parts[1],"process":parts[2],"count":parts[3] if len(parts)>3 else "0","sources":parts[4] if len(parts)>4 else ""}) - elif section == "flux_out" and len(parts) >= 3: - result["flux_out"].append({"dest_ip":parts[0],"dest_port":parts[1],"service":parts[2],"process":parts[3] if len(parts)>3 else "","count":parts[4] if len(parts)>4 else "1"}) - elif section == "conn_wait" and len(parts) == 2: - result["conn_wait"].append({"state":parts[0],"count":parts[1]}) - elif section == "net_stats" and len(parts) == 2: - result["net_stats"][parts[0].strip()] = parts[1].strip() - elif section == "traffic" and len(parts) >= 5: - result["traffic"].append({"iface":parts[0],"rx_bytes":parts[1],"rx_pkt":parts[2],"rx_err":parts[3],"tx_bytes":parts[4],"tx_pkt":parts[5] if len(parts)>5 else "","tx_err":parts[6] if len(parts)>6 else ""}) - elif section == "firewall": - if "POLICY" in ls: firewall_sub = "policy"; continue - elif "INPUT" in ls and "---" in ls: firewall_sub = "input"; continue - elif "OUTPUT" in ls and "---" in ls: firewall_sub = "output"; continue - elif "FIREWALLD" in ls: firewall_sub = "firewalld"; continue - if firewall_sub == "policy" and len(parts) == 2: result["firewall"]["policy"][parts[0]] = parts[1] - elif firewall_sub == "input" and len(parts) >= 3: result["firewall"]["input"].append(ls) - elif firewall_sub == "output" and len(parts) >= 3: result["firewall"]["output"].append(ls) - elif firewall_sub == "firewalld" and len(parts) >= 2: result["firewall"]["firewalld"].append({"zone":parts[0],"services":parts[1],"ports":parts[2] if len(parts)>2 else ""}) - elif section == "correlation" and len(parts) >= 4: - result["correlation_matrix"].append({"process":parts[0],"user":parts[1],"pid":parts[2],"listen_ports":parts[3],"conn_in":parts[4] if len(parts)>4 else "0","conn_out":parts[5] if len(parts)>5 else "0","remote_dests":parts[6] if len(parts)>6 else ""}) - elif section == "outbound" and len(parts) >= 3: - result["outbound_only"].append({"process":parts[0],"user":parts[1],"pid":parts[2],"dests":parts[3] if len(parts)>3 else ""}) - result["services_failed"] = result["services_failed"].strip() - result["needs_restarting"] = result["needs_restarting"].strip() - return result - - -# ── STOCKAGE DB ── - -def _resolve_server_id(db, hostname): - srv = db.execute(text( - "SELECT id FROM servers WHERE LOWER(hostname) = LOWER(:h)" - ), {"h": hostname.split(".")[0]}).fetchone() - return srv.id if srv else None - - -def _resolve_dest_server(db, dest_ip): - # Nettoyer l'IP (retirer IPv6-mapped prefix, brackets) - clean_ip = dest_ip.replace("[::ffff:", "").replace("]", "").strip() - if not clean_ip or ":" in clean_ip: - return (None, None) # IPv6 pure, skip - try: - row = db.execute(text(""" - SELECT s.id, s.hostname FROM servers s - JOIN server_ips si ON s.id = si.server_id - WHERE si.ip_address = CAST(:ip AS inet) - LIMIT 1 - """), {"ip": clean_ip}).fetchone() - return (row.id, row.hostname) if row else (None, None) - except Exception: - return (None, None) - - -def save_audit_to_db(db, parsed, raw_output="", status="ok", error_msg=None): - hostname = parsed.get("hostname", "") - if not hostname: - return None - server_id = _resolve_server_id(db, hostname) - - row = db.execute(text(""" - INSERT INTO server_audit_full ( - server_id, hostname, audit_date, os_release, kernel, uptime, - services, processes, services_failed, needs_restarting, reboot_required, - disk_usage, interfaces, routes, listen_ports, connections, - flux_in, flux_out, conn_wait, net_stats, traffic, firewall, - correlation_matrix, outbound_only, raw_output, status, error_msg - ) VALUES ( - :sid, :hn, NOW(), :os, :k, :up, - :svc, :proc, :sf, :nr, :rr, - :du, :iface, :rt, :lp, :conn, - :fi, :fo, :cw, :ns, :tr, :fw, - :cm, :ob, :raw, :st, :err - ) RETURNING id - """), { - "sid": server_id, "hn": hostname, - "os": parsed.get("os_release", ""), "k": parsed.get("kernel", ""), - "up": parsed.get("uptime", ""), - "svc": json.dumps(parsed.get("services", [])), - "proc": json.dumps(parsed.get("processes", [])), - "sf": parsed.get("services_failed", ""), - "nr": parsed.get("needs_restarting", ""), - "rr": parsed.get("reboot_required", False), - "du": json.dumps(parsed.get("disk_usage", [])), - "iface": json.dumps(parsed.get("interfaces", [])), - "rt": json.dumps(parsed.get("routes", [])), - "lp": json.dumps(parsed.get("listen_ports", [])), - "conn": json.dumps(parsed.get("connections", [])), - "fi": json.dumps(parsed.get("flux_in", [])), - "fo": json.dumps(parsed.get("flux_out", [])), - "cw": json.dumps(parsed.get("conn_wait", [])), - "ns": json.dumps(parsed.get("net_stats", {})), - "tr": json.dumps(parsed.get("traffic", [])), - "fw": json.dumps(parsed.get("firewall", {})), - "cm": json.dumps(parsed.get("correlation_matrix", [])), - "ob": json.dumps(parsed.get("outbound_only", [])), - "raw": raw_output, "st": status, "err": error_msg, - }).fetchone() - - audit_id = row.id - _build_flow_map(db, audit_id, hostname, server_id, parsed) - return audit_id - - -def _build_flow_map(db, audit_id, hostname, server_id, parsed): - local_ips = [i["ip"] for i in parsed.get("interfaces", []) if i["ip"] != "127.0.0.1"] - source_ip = local_ips[0] if local_ips else "" - for conn in parsed.get("connections", []): - remote = conn.get("remote", "") - m = re.match(r'^(.+):(\d+)$', remote) - if not m: - continue - dest_ip = m.group(1) - dest_port = int(m.group(2)) - if dest_ip.startswith("127.") or dest_ip == "::1": - continue - dest_server_id, dest_hostname = _resolve_dest_server(db, dest_ip) - db.execute(text(""" - INSERT INTO network_flow_map ( - audit_id, source_server_id, source_hostname, source_ip, - dest_ip, dest_port, dest_hostname, dest_server_id, - process_name, process_user, direction, - connection_count, state, audit_date - ) VALUES ( - :aid, :ssid, :shn, :sip, - :dip, :dp, :dhn, :dsid, - :pn, :pu, :dir, 1, :st, NOW() - ) - """), { - "aid": audit_id, "ssid": server_id, "shn": hostname, "sip": source_ip, - "dip": dest_ip, "dp": dest_port, "dhn": dest_hostname, "dsid": dest_server_id, - "pn": conn.get("process", ""), "pu": conn.get("user", ""), - "dir": conn.get("direction", ""), "st": conn.get("state", ""), - }) - - -# ── IMPORT JSON (depuis standalone) ── - -def import_json_report(db, json_data): - servers = json_data.get("servers", []) - imported = 0 - errors = 0 - for srv in servers: - if srv.get("status") == "error": - errors += 1 - continue - hostname = srv.get("hostname", "") - if not hostname: - continue - parsed = {k: srv.get(k, v) for k, v in { - "hostname": "", "os_release": "", "kernel": "", "uptime": "", - "services": [], "processes": [], "services_failed": "", - "needs_restarting": "", "reboot_required": False, "disk_usage": [], - "interfaces": [], "routes": [], "listen_ports": [], - "connections": [], "flux_in": [], "flux_out": [], - "conn_wait": [], "net_stats": {}, "traffic": [], - "firewall": {}, "correlation_matrix": [], "outbound_only": [], - }.items()} - save_audit_to_db(db, parsed) - imported += 1 - db.commit() - return imported, errors - - -# ── REQUETES ── - -def get_latest_audits(db, limit=100): - return db.execute(text(""" - SELECT DISTINCT ON (hostname) id, server_id, hostname, audit_date, - os_release, kernel, uptime, status, reboot_required, - last_patch_date, last_patch_week, last_patch_year, - jsonb_array_length(COALESCE(services, '[]')) as svc_count, - jsonb_array_length(COALESCE(listen_ports, '[]')) as port_count, - jsonb_array_length(COALESCE(connections, '[]')) as conn_count, - jsonb_array_length(COALESCE(processes, '[]')) as proc_count - FROM server_audit_full - WHERE status IN ('ok','partial') - ORDER BY hostname, audit_date DESC - LIMIT :lim - """), {"lim": limit}).fetchall() - - -def get_audit_detail(db, audit_id): - return db.execute(text( - "SELECT * FROM server_audit_full WHERE id = :id" - ), {"id": audit_id}).fetchone() - - -def get_flow_map(db): - return db.execute(text(""" - SELECT source_hostname, source_ip, dest_ip, dest_port, - dest_hostname, process_name, direction, state, - COUNT(*) as cnt - FROM network_flow_map nfm - JOIN server_audit_full saf ON nfm.audit_id = saf.id - WHERE saf.id IN ( - SELECT DISTINCT ON (hostname) id FROM server_audit_full - WHERE status IN ('ok','partial') ORDER BY hostname, audit_date DESC - ) - GROUP BY source_hostname, source_ip, dest_ip, dest_port, - dest_hostname, process_name, direction, state - ORDER BY source_hostname - """)).fetchall() - - -def get_flow_map_for_server(db, hostname): - return db.execute(text(""" - SELECT source_hostname, source_ip, dest_ip, dest_port, - dest_hostname, process_name, direction, state - FROM network_flow_map - WHERE audit_id = ( - SELECT id FROM server_audit_full WHERE hostname = :h - ORDER BY audit_date DESC LIMIT 1 - ) - ORDER BY direction DESC, dest_ip - """), {"h": hostname}).fetchall() - - -def get_flow_map_for_domain(db, domain_code): - return db.execute(text(""" - SELECT nfm.source_hostname, nfm.source_ip, nfm.dest_ip, nfm.dest_port, - nfm.dest_hostname, nfm.process_name, nfm.direction, nfm.state - FROM network_flow_map nfm - JOIN server_audit_full saf ON nfm.audit_id = saf.id - JOIN servers s ON saf.server_id = s.id - JOIN domain_environments de ON s.domain_env_id = de.id - JOIN domains d ON de.domain_id = d.id - WHERE d.code = :dc - AND saf.id IN ( - SELECT DISTINCT ON (hostname) id FROM server_audit_full - WHERE status IN ('ok','partial') ORDER BY hostname, audit_date DESC - ) - ORDER BY nfm.source_hostname - """), {"dc": domain_code}).fetchall() - - -def get_app_map(db): - audits = db.execute(text(""" - SELECT DISTINCT ON (hostname) hostname, server_id, processes, listen_ports - FROM server_audit_full WHERE status IN ('ok','partial') - ORDER BY hostname, audit_date DESC - """)).fetchall() - app_groups = {} - for audit in audits: - processes = audit.processes if isinstance(audit.processes, list) else json.loads(audit.processes or "[]") - for proc in processes: - cwd = proc.get("cwd", "") - m = re.search(r'/applis/([^/]+)', cwd) - if not m: - continue - app_name = m.group(1) - if app_name not in app_groups: - app_groups[app_name] = {"servers": [], "ports": set()} - if audit.hostname not in [s["hostname"] for s in app_groups[app_name]["servers"]]: - app_groups[app_name]["servers"].append({ - "hostname": audit.hostname, - "server_id": audit.server_id, - "user": proc.get("user", ""), - "cmdline": proc.get("cmdline", "")[:100], - "restart_hint": proc.get("restart_hint", "")[:100], - }) - listen = audit.listen_ports if isinstance(audit.listen_ports, list) else json.loads(audit.listen_ports or "[]") - pid = proc.get("pid", "") - for lp in listen: - if lp.get("pid") == pid: - app_groups[app_name]["ports"].add(lp.get("addr_port", "")) - for k in app_groups: - app_groups[k]["ports"] = list(app_groups[k]["ports"]) - return app_groups diff --git a/app/templates/audit_full_detail.html b/app/templates/audit_full_detail.html deleted file mode 100644 index 4a0fb02..0000000 --- a/app/templates/audit_full_detail.html +++ /dev/null @@ -1,241 +0,0 @@ -{% extends 'base.html' %} -{% block title %}{{ a.hostname }}{% endblock %} -{% block content %} -< Retour -
-
-

{{ a.hostname }}

-

{{ a.os_release }} | {{ a.kernel }} | {{ a.uptime }}

-
-
- {% for iface in interfaces %}{% if iface.ip != '127.0.0.1' %} - {{ iface.ip }}{{ iface.mask }} ({{ iface.iface }}) - {% endif %}{% endfor %} -
-
- -{% if is_partial %} -
-
Pas encore audité
-

Ce serveur n'a pas encore été audité via SSH (Windows, EMV...)

-

{{ a.hostname }} — {{ a.os_release or 'OS inconnu' }}

- {% if a.last_patch_week %}

Dernier patch : {{ a.last_patch_week }} {{ a.last_patch_year }}

{% endif %} -
-{% else %} - -
-
{{ services|length }}
Services
-
{{ processes|length }}
Process
-
{{ listen_ports|length }}
Ports
-
{{ connections|length }}
Connexions
-
{% if a.reboot_required %}Oui{% else %}Non{% endif %}
Reboot
-
{% if a.services_failed %}KO{% else %}OK{% endif %}
Failed svc
-
- - -
-
- {% for t, label in [('services','Services'),('processes','Processus'),('ports','Ports'),('connections','Connexions'),('flux','Flux'),('disk','Disque'),('firewall','Firewall'),('correlation','Corrélation')] %} - - {% endfor %} -
- - -
- - - - {% for s in services %} - - - - - - {% endfor %} -
ServiceEnabledPIDUserExec
{{ s.name }}{{ s.enabled }}{{ s.pid }}{{ s.user }}{{ s.exec[:80] }}
-
- - -
- - - - {% for p in processes %} - - - - - - - {% endfor %} -
PIDUserExeCWDCmdlineRestart
{{ p.pid }}{{ p.user }}{{ p.exe or '' }}{{ p.cwd or '' }}{{ p.cmdline or '' }}{{ p.restart_hint or '' }}
-
- - -
- - - - {% for lp in listen_ports %} - - - - - - - {% endfor %} -
ProtoAddr:PortPIDProcessUserService
{{ lp.proto }}{{ lp.addr_port }}{{ lp.pid }}{{ lp.process }}{{ lp.user }}{{ lp.service }}
-
- - -
- - - - {% for c in connections %} - - - - - - - - {% endfor %} -
DirLocalRemotePIDProcessUserState
{{ c.direction }}{{ c.local }}{{ c.remote }}{{ c.pid }}{{ c.process }}{{ c.user }}{{ c.state }}
-
- - -
- {% if flux_in %} -
-
Flux entrants
- - - - {% for f in flux_in %} - - - - - - {% endfor %} -
PortServiceProcessNbSources
{{ f.port }}{{ f.service }}{{ f.process }}{{ f.count }}{{ f.sources }}
-
- {% endif %} - {% if flux_out %} -
-
Flux sortants
- - - - {% for f in flux_out %} - - - - - - {% endfor %} -
DestinationPortServiceProcessNb
{{ f.dest_ip }}{{ f.dest_port }}{{ f.service }}{{ f.process }}{{ f.count }}
-
- {% endif %} - {% if flows %} -
-
Carte flux (resolu)
- - - - {% for f in flows %} - - - - - - - {% endfor %} -
DirIP destPortServeur destProcessState
{{ f.direction }}{{ f.dest_ip }}{{ f.dest_port }}{{ f.dest_hostname or '-' }}{{ f.process_name }}{{ f.state }}
-
- {% endif %} -
- - -
- - - - {% for d in disk_usage %} - - - - - - {% endfor %} -
MountTailleUtiliseDispo%
{{ d.mount }}{{ d.size }}{{ d.used }}{{ d.avail }}{{ d.pct }}%
-
- - -
- {% if firewall.policy %} -
- Policy iptables : - {% for chain, pol in firewall.policy.items() %} - {{ chain }}={{ pol or '?' }} - {% endfor %} -
- {% endif %} - {% if firewall.firewalld %} -
- Firewalld : - {% for z in firewall.firewalld %} -
Zone {{ z.zone }} : services={{ z.services }} ports={{ z.ports }}
- {% endfor %} -
- {% endif %} - {% if conn_wait %} -
- Connexions en attente : - {% for cw in conn_wait %} - {{ cw.state }}={{ cw.count }} - {% endfor %} -
- {% endif %} -
- - -
- {% if correlation %} -
-
Matrice process / ports / flux
- - - - {% for c in correlation %} - - - - - - - - {% endfor %} -
ProcessUserPIDPortsINOUTDestinations
{{ c.process }}{{ c.user }}{{ c.pid }}{{ c.listen_ports }}{{ c.conn_in }}{{ c.conn_out }}{{ c.remote_dests }}
-
- {% endif %} - {% if outbound %} -
-
Process sortants uniquement
- - - - {% for o in outbound %} - - - - - {% endfor %} -
ProcessUserPIDDestinations
{{ o.process }}{{ o.user }}{{ o.pid }}{{ o.dests }}
-
- {% endif %} -
-
-{% endif %}{# end is_partial else #} -{% endblock %} diff --git a/app/templates/audit_full_flowmap.html b/app/templates/audit_full_flowmap.html deleted file mode 100644 index 52714f7..0000000 --- a/app/templates/audit_full_flowmap.html +++ /dev/null @@ -1,282 +0,0 @@ -{% extends 'base.html' %} -{% block title %}Carte flux{% endblock %} -{% block content %} -< Retour -
-

Carte des flux réseau

- -
- - -
-
- -
- - - {% for s in audited_servers %} -
- - {% if domain_filter or server_filter %}Reset{% endif %} -
- - {% if server_filter %}Serveur: {{ server_filter }} - {% elif domain_filter %}Domaine/Zone: {{ domain_filter }} - {% else %}Vue globale{% endif %} - -
- - -
- - - - - - - - - - - - -
- - -
- ---> Flux réseau - 0 serveurs - 0 flux -
- - -{% if flows %} -
- Tableau des flux ({{ flows|length }}) -
- - - - - - - {% for f in flows %} - - - - - - - - - {% endfor %} - -
DirSourceDestinationPortProcessState
{{ f.direction }}{{ f.source_hostname }}{{ f.dest_hostname or f.dest_ip }}{{ f.dest_port }}{{ f.process_name }}{{ f.state }}
-
-
-{% endif %} - -{% if app_map %} -
- Carte applicative ({{ app_map|length }} applis) -
- {% for app_name, app in app_map.items() %} -
- {{ app_name }} - {{ app.servers|length }} - {% if app.ports %}ports: {{ app.ports|join(', ') }}{% endif %} -
{% for s in app.servers %}{{ s.hostname }}{% if not loop.last %}, {% endif %}{% endfor %}
-
- {% endfor %} -
-
-{% endif %} - - -{% endblock %} diff --git a/app/templates/audit_full_list.html b/app/templates/audit_full_list.html deleted file mode 100644 index db2ac0a..0000000 --- a/app/templates/audit_full_list.html +++ /dev/null @@ -1,155 +0,0 @@ -{% extends 'base.html' %} -{% block title %}Audit complet{% endblock %} -{% block content %} -
-
-

Audit complet serveurs

-

Applicatif + réseau + corrélation — import JSON depuis le standalone

-
-
-
- - -
- Exporter CSV - Carte flux -
-
- -{% if msg %} -
- {% if msg.startswith('imported_') %} - {% set parts = msg.split('_') %} - {{ parts[1] }} serveur(s) importé(s){% if parts[2]|int > 0 %}, {{ parts[2] }} erreur(s){% endif %}. - {% elif msg.startswith('error_') %}Erreur: {{ msg[6:] }}{% endif %} -
-{% endif %} - -{% if kpis %} -
- -
{{ kpis.total }}
-
Total
-
- -
{{ kpis.needs_reboot }}
-
Reboot
-
- -
{{ kpis.disk_critical }}
-
Disque >= 90%
-
- -
{{ kpis.disk_warning }}
-
Disque >= 80%
-
- -
{{ kpis.uptime_long }}
-
Uptime > 4m
-
-
- -
- {% for key, label, icon in [ - ('app_oracle','Oracle','db'),('app_postgres','PostgreSQL','db'),('app_mariadb','MariaDB/MySQL','db'),('app_hana','SAP HANA','db'), - ('app_httpd','Apache','web'),('app_nginx','Nginx','web'),('app_haproxy','HAProxy','web'), - ('app_tomcat','Tomcat','app'),('app_java','Java','app'),('app_nodejs','Node.js','app'), - ('app_redis','Redis','db'),('app_mongodb','MongoDB','db'),('app_elastic','Elastic','db'),('app_container','Docker/Podman','app') - ] %} - {% set val = kpis[key]|default(0) %} - {% if val > 0 %} - -
{{ val }}
-
{{ label }}
-
- {% endif %} - {% endfor %} -
-{% if filter %} -
Filtre actif : {{ filter }}Tout voir
-{% endif %} -{% endif %} - - -
-
- {% if filter %}{% endif %} - - - - {% if search or domain %}Reset{% endif %} -
- {{ total_filtered }} serveur(s) -
- -{% if audits %} -
- - - - - - - - - - - - - - - - {% for a in audits %} - - - - - - - - - - - - - - {% endfor %} - -
Hostname {% if sort == 'hostname' %}{{ '▲' if sort_dir == 'asc' else '▼' }}{% endif %}OSKernelUptime {% if sort == 'uptime' %}{{ '▲' if sort_dir == 'asc' else '▼' }}{% endif %}ServicesProcessPortsConnReboot {% if sort == 'reboot' %}{{ '▲' if sort_dir == 'asc' else '▼' }}{% endif %}Dernier patch {% if sort == 'patch' %}{{ '▲' if sort_dir == 'asc' else '▼' }}{% endif %}Date
{{ a.hostname }}{{ (a.os_release or '')[:30] }}{{ (a.kernel or '')[:25] }}{{ (a.uptime or '')[:20] }}{{ a.svc_count }}{{ a.proc_count }}{{ a.port_count }}{{ a.conn_count }}{% if a.reboot_required %}Oui{% else %}Non{% endif %}{% if a.last_patch_date %}{{ a.last_patch_date }}{% elif a.last_patch_week %}{{ a.last_patch_week }} {{ a.last_patch_year }}{% else %}-{% endif %}{{ a.audit_date.strftime('%d/%m %H:%M') if a.audit_date else '-' }}
-
- - {% if total_pages > 1 %} - {% set qs %}{% if filter %}&filter={{ filter }}{% endif %}{% if search %}&q={{ search }}{% endif %}{% if domain %}&domain={{ domain }}{% endif %}{% if sort %}&sort={{ sort }}&dir={{ sort_dir }}{% endif %}{% endset %} -
- Page {{ page }}/{{ total_pages }} ({{ total_filtered }} serveurs) -
- {% if page > 1 %} - 1 - {% if page > 2 %}<{% endif %} - {% endif %} - {{ page }} - {% if page < total_pages %} - > - {{ total_pages }} - {% endif %} -
-
- {% endif %} - -{% else %} -
-

Aucun audit{% if search or domain or filter %} correspondant aux filtres{% endif %}.

- {% if not search and not domain and not filter %} -

Lancez le standalone sur vos serveurs puis importez le JSON ici.

- {% endif %} -
-{% endif %} -{% endblock %} diff --git a/app/templates/audit_full_patching.html b/app/templates/audit_full_patching.html deleted file mode 100644 index ff2cfec..0000000 --- a/app/templates/audit_full_patching.html +++ /dev/null @@ -1,214 +0,0 @@ -{% extends 'base.html' %} -{% block title %}Patching {{ year }}{% endblock %} -{% block content %} -
-
- < Audit complet -

Patching {{ year }}

-
-
- 2025 - 2026 - | - Tous - SecOps - Hors SecOps - | - CSV -
-
- - -{% if kpis_secops and kpis_other %} -
- -
SecOps
-
{{ kpis_secops.patched }}/{{ kpis_secops.total }}
- {% set pct_s = (kpis_secops.patched / kpis_secops.total * 100)|int if kpis_secops.total > 0 else 0 %} -
-
{{ pct_s }}%
-
- -
Hors SecOps
-
{{ kpis_other.patched }}/{{ kpis_other.total }}
- {% set pct_o = (kpis_other.patched / kpis_other.total * 100)|int if kpis_other.total > 0 else 0 %} -
-
{{ pct_o }}%
-
-
-{% endif %} - - -{% if kpis %} -{% set pct = (kpis.patched / kpis.total * 100)|int if kpis.total > 0 else 0 %} -
-
{{ kpis.total }}
Total
-
{{ kpis.patched }}
Patchés
-
{{ kpis.once }}
1 fois
-
{{ kpis.twice }}
2+ fois
-
{{ kpis.thrice }}
3+ fois
-
{{ kpis.never }}
Jamais
-
-
{{ pct }}%
-
Couverture
-
-
-
-
-
- - -{% if compare and year == 2026 %} -{% set pct_current = (compare.current_patched / compare.current_total * 100)|int if compare.current_total > 0 else 0 %} -{% set pct_prev_same = (compare.prev_at_same_week / compare.prev_total * 100)|int if compare.prev_total > 0 else 0 %} -{% set pct_prev_total = (compare.prev_year_total / compare.current_total * 100)|int if compare.current_total > 0 else 0 %} -{% set diff_same = pct_current - pct_prev_same %} -
-
- Comparaison à même semaine (S{{ compare.compare_week }}) - {% if not compare.prev_data_ok %}Données 2025 incomplètes{% endif %} -
-
- -
-
- 2026 (S{{ compare.compare_week }}) - {{ compare.current_patched }} / {{ compare.current_total }} ({{ pct_current }}%) -
-
-
-
-
- -
-
- 2025 (S{{ compare.compare_week }}) - {{ compare.prev_at_same_week }} / {{ compare.prev_total }} ({{ pct_prev_same }}%) -
-
-
-
-
- -
-
- {% if diff_same >= 0 %}+{% endif %}{{ diff_same }} pts -
-
vs 2025 même semaine
-
-
- -
- 2025 année complète : {{ compare.prev_year_total }} patchés ({{ pct_prev_total }}%) - Objectif 2026 : dépasser {{ compare.prev_year_total }} -
-
-{% endif %} - - -
- {% if patch_weekly %} -
-
Serveurs par semaine vert=patché rouge=annulé/reporté
-
- {% set max_cnt = patch_weekly|map(attribute='patched')|map('int')|max %} - {% for w in patch_weekly %} - {% set total = (w.patched|int) + (w.cancelled|int) %} - {% set max_total = max_cnt if max_cnt > 0 else 1 %} -
-
{{ total }}
-
- {% if w.cancelled|int > 0 %}
{% endif %} -
-
-
{{ w.week }}
-
- {% endfor %} -
-
- {% endif %} -
-
Par domaine
- - - - {% for d in patch_by_domain %} - {% set dp = (d.patched / d.total * 100)|int if d.total > 0 else 0 %} - - - - - - - - - {% endfor %} - -
DomaineTotalOK2xJamais%
{{ d.domain }}{{ d.total }}{{ d.patched }}{{ d.twice }}{{ d.never }} -
-
-
- {{ dp }}% -
-
-
-{% endif %} - - -
-
- - {% if scope %}{% endif %} - - - - {% if search or domain %}Reset{% endif %} -
- {{ total_filtered }} serveur(s) -
- - -{% set qs %}{% if search %}&q={{ search }}{% endif %}{% if domain %}&domain={{ domain }}{% endif %}{% if scope %}&scope={{ scope }}{% endif %}{% endset %} -
- - - - - - - - - - - - {% for s in servers %} - - - - - - - - - - {% endfor %} - -
Hostname {% if sort == 'hostname' %}{{ '▲' if sort_dir == 'asc' else '▼' }}{% endif %}DomaineEnvZoneNb patchs {% if sort == 'count' %}{{ '▲' if sort_dir == 'asc' else '▼' }}{% endif %}SemainesDernier {% if sort == 'last' %}{{ '▲' if sort_dir == 'asc' else '▼' }}{% endif %}
{{ s.hostname }}{{ s.domain or '-' }}{{ (s.env or '-')[:6] }}{% if s.zone == 'DMZ' %}DMZ{% else %}{{ s.zone or '-' }}{% endif %}{{ s.patch_count or 0 }}{% if s.patch_weeks %}{% for w in s.patch_weeks.split(',') %}{{ w }}{% endfor %}{% else %}-{% endif %}{% if s.last_patch_date %}{{ s.last_patch_date }}{% elif s.last_patch_week %}{{ s.last_patch_week }}{% else %}-{% endif %}
- - {% if total_pages > 1 %} - {% set pqs %}&year={{ year }}{% if search %}&q={{ search }}{% endif %}{% if domain %}&domain={{ domain }}{% endif %}{% if scope %}&scope={{ scope }}{% endif %}{% if sort %}&sort={{ sort }}&dir={{ sort_dir }}{% endif %}{% endset %} -
- Page {{ page }}/{{ total_pages }} -
- {% if page > 1 %}1{% if page > 2 %}<{% endif %}{% endif %} - {{ page }} - {% if page < total_pages %}>{{ total_pages }}{% endif %} -
-
- {% endif %} -
-{% endblock %} diff --git a/app/templates/safe_patching.html b/app/templates/safe_patching.html deleted file mode 100644 index dbfc6be..0000000 --- a/app/templates/safe_patching.html +++ /dev/null @@ -1,84 +0,0 @@ -{% extends 'base.html' %} -{% block title %}Safe Patching{% endblock %} -{% block content %} -
-
-

Safe Patching — Quick Win

-

Patching sans interruption de service : exclut tout ce qui nécessite un reboot ou un restart.

-
-
- {% if can_create %} - - {% endif %} - Planning -
-
- -{% if msg %} -
- {% if msg == 'error' %}Erreur à la création (semaine déjà existante ?).{% elif msg == 'deleted' %}Campagne supprimée.{% endif %} -
-{% endif %} - - -{% if campaigns %} -
- {% for c in campaigns %} - -
- {{ c.week_code }} - {{ c.label }} - quickwin - {{ c.status }} -
-
- {{ c.session_count }} srv - {{ c.patched_count }} ok -
-
- {% endfor %} -
-{% endif %} - - -{% if can_create %} - -{% endif %} -{% endblock %} diff --git a/app/templates/safe_patching_detail.html b/app/templates/safe_patching_detail.html deleted file mode 100644 index f4761b7..0000000 --- a/app/templates/safe_patching_detail.html +++ /dev/null @@ -1,215 +0,0 @@ -{% extends 'base.html' %} -{% block title %}{{ c.label }}{% endblock %} -{% block content %} - -
-
- ← Safe Patching -

{{ c.label }}

-
- quickwin - {{ c.status }} -
-
- {% set p = perms if perms is defined else request.state.perms %} - {% if p.campaigns == 'admin' %} -
- -
- {% endif %} -
- -{% if msg %} -
- {% if msg.startswith('excluded_') %}{{ msg.split('_')[1] }} serveur(s) exclu(s).{% elif msg == 'no_pending' %}Aucun serveur en attente.{% elif msg == 'prereqs_done' %}Prérequis vérifiés.{% endif %} -
-{% endif %} - - -
-
-

Branche 1 — Hors-prod

-
- {{ qw_stats.hprod_total }} total - {{ qw_stats.hprod_patched }} patchés - {{ qw_stats.hprod_failed }} échoués -
-
-
-

Branche 2 — Production

-
- {{ qw_stats.prod_total }} total - {{ qw_stats.prod_patched }} patchés - {{ qw_stats.prod_failed }} échoués -
-
-
- - -
- - -
- {% for s in ['prereqs','snapshot','execute','postcheck'] %} - - {% endfor %} -
- - -
-
-

Step 1 — Vérification prérequis

-
-
- - -
-
- - -
-
-
- - - - - - - - - - - - - - {% for s in sessions %} - {% if s.status != 'excluded' %} - - - - - - - - - - - {% endif %} - {% endfor %} - -
HostnameEnvDomaineSSHDisqueSatelliteÉtat
{% if s.status == 'pending' %}{% endif %}{{ s.hostname }}{{ (s.environnement or '')[:6] }}{{ s.domaine or '-' }}{% if s.prereq_ssh == 'ok' %}OK{% elif s.prereq_ssh == 'ko' %}KO{% else %}{% endif %}{% if s.prereq_disk_ok is true %}OK{% elif s.prereq_disk_ok is false %}KO{% else %}{% endif %}{% if s.prereq_satellite == 'ok' %}OK{% elif s.prereq_satellite == 'ko' %}KO{% elif s.prereq_satellite == 'na' %}N/A{% else %}{% endif %}{% if s.prereq_validated %}OK{% elif s.prereq_date %}KO{% else %}{% endif %}
-
- - -
-

Step 2 — Snapshot vSphere

-

Créer un snapshot sur toutes les VMs avant patching. Les serveurs physiques sont ignorés.

-
- - -
-
- - -
-

Step 3 — Exécution Safe Patching

- -
-

Commande yum (éditable)

- -

{{ safe_excludes|length }} packages exclus. Modifiez si besoin avant de lancer.

-
- -
-
- - -
- {% if qw_stats.hprod_total > 0 and qw_stats.hprod_patched == qw_stats.hprod_total %} -
- - -
- {% else %} - Production disponible après hors-prod à 100% - {% endif %} -
-
- - -
-

Step 4 — Vérification post-patch

-

Vérifier les services, ports et needs-restarting après patching.

- -
-
- - -
-
- - -
- Export CSV -
- - - - - - - - - - - - - {% for s in sessions %} - {% if s.status in ('patched', 'failed') %} - - - - - - - - - {% endif %} - {% endfor %} - -
HostnameEnvStatutPackagesRebootServices
{{ s.hostname }}{{ (s.environnement or '')[:6] }}{{ s.status }}{{ s.packages_updated or 0 }}{% if s.reboot_required %}Oui{% else %}Non{% endif %}{% if s.postcheck_services == 'ok' %}OK{% elif s.postcheck_services == 'ko' %}KO{% else %}{% endif %}
-
- -
- -{% if excluded %} -
- {{ excluded|length }} serveur(s) exclu(s) -
- {% for s in excluded %}{{ s.hostname }}{% if not loop.last %}, {% endif %}{% endfor %} -
-
-{% endif %} - - -{% endblock %} diff --git a/app/templates/safe_patching_terminal.html b/app/templates/safe_patching_terminal.html deleted file mode 100644 index 3a030bc..0000000 --- a/app/templates/safe_patching_terminal.html +++ /dev/null @@ -1,79 +0,0 @@ -{% extends 'base.html' %} -{% block title %}Terminal — {{ c.label }}{% endblock %} -{% block content %} -
-
- ← Retour campagne -

{{ c.label }} — Exécution {{ 'Hors-prod' if branch == 'hprod' else 'Production' }}

-
-
- En cours - 0 traité(s) -
-
- - -
-
- - - - PatchCenter Terminal — Safe Patching -
-
-
Connexion au stream...
-
-
- -
- -
- - -{% endblock %}